chore: record event gql mutation (#27416)

* feat: record event gql mutation

* Allow projectSlug to be optional

* Do not start source in run mode

* Fix unit test

* ts linting fix

* Adding test

* Adding test

* refactor

---------

Co-authored-by: Lachlan Miller <lachlan.miller.1990@outlook.com>
This commit is contained in:
Stokes Player
2023-08-06 19:45:37 -04:00
committed by GitHub
parent 24c733c3d5
commit eca37aec74
17 changed files with 398 additions and 27 deletions

View File

@@ -115,6 +115,10 @@ export class DataContext {
return this._config.mode === 'run'
}
get isOpenMode () {
return !this.isRunMode
}
@cached
get graphql () {
return new GraphQLDataSource()
@@ -236,6 +240,9 @@ export class DataContext {
getUser: () => this.user,
logout: () => this.actions.auth.logout().catch(this.logTraceError),
invalidateClientUrqlCache: () => this.graphql.invalidateClientUrqlCache(this),
headers: {
getMachineId: this.coreData.machineId,
},
})
}

View File

@@ -294,4 +294,12 @@ export class DataEmitterActions extends DataEmitterEvents {
return iterator
}
subscribeToRawEvent (evt: keyof DataEmitterEvents, listener: Parameters<EventEmitter['on']>[1]) {
this.pub.on(evt, listener)
}
unsubscribeToRawEvent (evt: keyof DataEmitterEvents, listener: Parameters<EventEmitter['on']>[1]) {
this.pub.off(evt, listener)
}
}

View File

@@ -1,5 +1,7 @@
import { gql } from '@urql/core'
import type { DataContext } from '..'
import Debug from 'debug'
import type { LocalTestCountsInput } from '@packages/graphql/src/gen/nxs.gen'
const pkg = require('@packages/root')
@@ -14,6 +16,10 @@ interface CollectibleEvent {
machineId?: string
}
type EventInputs = {
localTestCounts?: LocalTestCountsInput
}
/**
* Defaults to staging when doing development. To override to production for development,
* explicitly set process.env.CYPRESS_INTERNAL_ENV to 'production`
@@ -56,4 +62,21 @@ export class EventCollectorActions {
return false
}
}
recordEventGQL (eventInputs: EventInputs) {
const RECORD_EVENT_GQL = gql`
mutation EventCollectorActions_RecordEvent($localTestCounts: LocalTestCountsInput) {
cloudRecordEvent(localTestCounts: $localTestCounts)
}
`
debug('recordEventGQL final variables %o', eventInputs)
return this.ctx.cloud.executeRemoteGraphQL({
operationType: 'mutation',
fieldName: 'cloudRecordEvent',
operationDoc: RECORD_EVENT_GQL,
operationVariables: eventInputs,
})
}
}

View File

@@ -181,3 +181,16 @@ export async function hasNonExampleSpec (testTemplateDir: string, specs: string[
return specs.some((spec) => !specInTemplates(spec))
}
export async function getExampleSpecPaths (testTemplateDir: string): Promise<string[]> {
debug(`getExampleSpecPaths - calling with template directory "${testTemplateDir}"`)
const dirExists = await fileExists(testTemplateDir)
if (!dirExists) {
throw new Error(`Template directory does not exist: ${testTemplateDir}`)
}
const templateFiles = await allFilesInDir(testTemplateDir)
return templateFiles.map((templateFile) => templateFile.substring(testTemplateDir.length + 1))
}

View File

@@ -16,7 +16,7 @@ import type { DataContext } from '..'
import assert from 'assert'
import type { AllModeOptions, FoundBrowser, FullConfig, TestingType } from '@packages/types'
import { autoBindDebug } from '../util/autoBindDebug'
import { GitDataSource, LegacyCypressConfigJson } from '../sources'
import { EventCollectorSource, GitDataSource, LegacyCypressConfigJson } from '../sources'
import { OnFinalConfigLoadedOptions, ProjectConfigManager } from './ProjectConfigManager'
import pDefer from 'p-defer'
import { EventRegistrar } from './EventRegistrar'
@@ -433,6 +433,11 @@ export class ProjectLifecycleManager {
},
})
s.eventCollectorSource?.destroy()
if (this.ctx.isOpenMode) {
s.eventCollectorSource = new EventCollectorSource(this.ctx)
}
s.diagnostics = { error: null, warnings: [] }
s.packageManager = packageManagerUsed
})
@@ -574,6 +579,7 @@ export class ProjectLifecycleManager {
await this.ctx.coreData.currentProjectGitInfo?.destroy()
await this.ctx.project.destroy()
await this.ctx.coreData.eventCollectorSource?.destroy()
this._currentTestingType = null
this._cachedInitialConfig = undefined
this._cachedFullConfig = undefined

View File

@@ -6,7 +6,7 @@ import type { ChildProcess } from 'child_process'
import type { SocketIONamespace, SocketIOServer } from '@packages/socket'
import type { Server } from 'http'
import type { ErrorWrapperSource } from '@packages/errors'
import type { GitDataSource, LegacyCypressConfigJson } from '../sources'
import type { EventCollectorSource, GitDataSource, LegacyCypressConfigJson } from '../sources'
import { machineId as getMachineId } from 'node-machine-id'
export type Maybe<T> = T | null | undefined
@@ -163,6 +163,7 @@ export interface CoreDataShape {
npmMetadata: Promise<Record<string, string>>
} | null
cloudProject: CloudDataShape
eventCollectorSource: EventCollectorSource | null
}
/**
@@ -240,6 +241,7 @@ export function makeCoreData (modeOptions: Partial<AllModeOptions> = {}): CoreDa
cloudProject: {
testsForRunResults: {},
},
eventCollectorSource: null,
}
async function machineId (): Promise<string | null> {

View File

@@ -73,6 +73,9 @@ export interface CloudDataSourceParams {
* and we need to clear both the server & client side cache
*/
invalidateClientUrqlCache(): void
headers?: {
getMachineId: Promise<string | null>
}
}
/**
@@ -99,10 +102,11 @@ export class CloudDataSource {
return this.params.getUser()
}
get #additionalHeaders () {
async #additionalHeaders () {
return {
'Authorization': this.#user ? `bearer ${this.#user.authToken}` : '',
'x-cypress-version': pkg.version,
'x-machine-id': await this.params.headers?.getMachineId || '',
}
}
@@ -156,7 +160,7 @@ export class CloudDataSource {
...init,
headers: {
...init?.headers,
...this.#additionalHeaders,
...await this.#additionalHeaders(),
},
})
},

View File

@@ -0,0 +1,78 @@
import Debug from 'debug'
import dayjs from 'dayjs'
import type { DataContext } from '..'
import type { CloudTestingTypeEnum, LocalTestCountsInput } from '@packages/graphql/src/gen/nxs.gen'
import { getTestCounts } from '../util/testCounts'
import { debounce } from 'lodash'
const debug = Debug('cypress:data-context:sources:EventCollectorSource')
export class EventCollectorSource {
constructor (private ctx: DataContext) {
debug('Starting')
ctx.emitter.subscribeToRawEvent('authChange', this.#localTestCountsListener)
ctx.emitter.subscribeToRawEvent('configChange', this.#localTestCountsListener)
}
#localTestCountsListener = debounce(() => {
this.sendLocalTestCounts()
.catch((error) => {
debug('error caught from sending counts', error)
})
}, 250)
destroy () {
this.ctx.emitter.unsubscribeToRawEvent('authChange', this.#localTestCountsListener)
this.ctx.emitter.unsubscribeToRawEvent('configChange', this.#localTestCountsListener)
}
async sendLocalTestCounts () {
debug('Checking to send local test counts')
if (!this.ctx.coreData.currentTestingType) {
debug('will not send - no testing type')
return
}
const user = this.ctx.coreData.user
const isAuthenticated = !!user && !!user.name
if (!isAuthenticated) {
debug('will not send - not authenticated')
return
}
const currentLocalPreferences = this.ctx.project.getCurrentProjectSavedState()
const lastTestCountsEvent = currentLocalPreferences?.lastTestCountsEvent
const thirtyDaysAgo = dayjs().subtract(30, 'days')
const hasBeenSentLast30Days = !!lastTestCountsEvent && thirtyDaysAgo.isBefore(dayjs(lastTestCountsEvent))
if (hasBeenSentLast30Days) {
debug('will not send', { isAuthenticated, hasBeenSentLast30Days })
return
}
const testingType: CloudTestingTypeEnum = this.ctx.coreData.currentTestingType === 'e2e' ? 'E2E' : 'COMPONENT'
const testCounts = await getTestCounts(this.ctx.project.specs)
const projectSlug = await this.ctx.project.projectId()
const localTestCounts: LocalTestCountsInput = {
projectSlug,
testingType,
...testCounts,
branch: this.ctx.git?.currentBranch,
}
debug('sending recordEvent for local test counts', localTestCounts)
const result = await this.ctx.actions.eventCollector.recordEventGQL({ localTestCounts })
if (result.data?.cloudRecordEvent === true) {
await this.ctx.actions.localSettings.setPreferences(JSON.stringify({ lastTestCountsEvent: Date.now() }), 'project')
}
debug('result', result)
}
}

View File

@@ -5,6 +5,7 @@ export * from './BrowserDataSource'
export * from './CloudDataSource'
export * from './EnvDataSource'
export * from './ErrorDataSource'
export * from './EventCollectorSource'
export * from './FileDataSource'
export * from './GitDataSource'
export * from './GraphQLDataSource'

View File

@@ -8,5 +8,6 @@ export * from './config-file-updater'
export * from './file'
export * from './hasTypescript'
export * from './pluginHandlers'
export * from './testCounts'
export * from './urqlCacheKeys'
export * from './weightedChoice'

View File

@@ -0,0 +1,66 @@
import fs from 'fs'
import readline from 'readline'
import Debug from 'debug'
import type { SpecWithRelativeRoot } from '@packages/types'
import { getExampleSpecPaths } from '../codegen'
import templates from '../codegen/templates'
const debug = Debug('cypress:data-context:util:testCounts')
export async function getTestCounts (specs: SpecWithRelativeRoot[]) {
const templateSpecPaths = await getExampleSpecPaths(templates.e2eExamples)
const startTime = performance.now()
const specCountPromises = specs.map((spec) => {
return new Promise<{path: string, isExample: boolean, testCounts: number}>((resolve, reject) => {
let testCounts = 0
readline.createInterface({
input: fs.createReadStream(spec.absolute),
})
.on('line', (line) => {
// test for "it(" appearing at beginning of line or with space before
const isTest = /(^| )it\(/.test(line)
if (isTest) {
testCounts++
}
})
.on('close', () => {
resolve({
path: spec.absolute,
isExample: templateSpecPaths.some((templateSpec) => templateSpec === spec.relativeToCommonRoot),
testCounts,
})
})
})
})
const countResults = await Promise.all(specCountPromises)
interface CountSummary {
totalTests: number
exampleSpecs: number
exampleTests: number
}
const countSummary = countResults.reduce<CountSummary>((summary, curr) => {
summary.totalTests += curr.testCounts
if (curr.isExample) {
summary.exampleSpecs++
summary.exampleTests += curr.testCounts
}
return summary
}, { totalTests: 0, exampleSpecs: 0, exampleTests: 0 })
const totalTime = performance.now() - startTime
debug(`took ${totalTime} ms to count ${specs.length} specs`)
return {
totalSpecs: specs.length,
...countSummary,
}
}

View File

@@ -5,7 +5,12 @@ import fs from 'fs-extra'
import path from 'path'
import { DataContext } from '../../../src'
import {
Action, codeGenerator, CodeGenResult, CodeGenResults, hasNonExampleSpec,
Action,
codeGenerator,
CodeGenResult,
CodeGenResults,
hasNonExampleSpec,
getExampleSpecPaths,
} from '../../../src/codegen/code-generator'
import { SpecOptions } from '../../../src/codegen/spec-options'
import templates from '../../../src/codegen/templates'
@@ -415,4 +420,22 @@ describe('code-generator', () => {
expect(async () => await hasNonExampleSpec('', singleSpec)).to.throw
})
})
context('hasNonExampleSpec', async () => {
it('should error if template dir does not exist', () => {
expect(async () => await getExampleSpecPaths('')).to.throw
})
it('should return relative paths to example specs', async () => {
const results = await getExampleSpecPaths(templates.e2eExamples)
expect(results.length).to.be.greaterThan(0)
results.forEach((specPath) => {
const fullPathToSpec = path.join(templates.e2eExamples, specPath)
expect(fs.pathExistsSync(fullPathToSpec), `expected to find file at ${fullPathToSpec}`).to.be.true
})
})
})
})

View File

@@ -1,5 +1,5 @@
import sinon from 'sinon'
import { execute, print } from 'graphql'
import { execute } from 'graphql'
import chaiAsPromised from 'chai-as-promised'
import { Response } from 'cross-fetch'
@@ -56,7 +56,6 @@ describe('CloudDataSource', () => {
getUserStub.returns(null)
const result = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
@@ -69,7 +68,6 @@ describe('CloudDataSource', () => {
it('issues a fetch request for the data when the user is defined', async () => {
const result = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
@@ -83,14 +81,12 @@ describe('CloudDataSource', () => {
it('only issues a single fetch if the operation is called twice', async () => {
const result1 = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
})
const result2 = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
@@ -107,7 +103,6 @@ describe('CloudDataSource', () => {
it('resolves eagerly with the cached data if the data has already been resolved', async () => {
const result = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
@@ -117,7 +112,6 @@ describe('CloudDataSource', () => {
const immediateResult = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
@@ -130,7 +124,6 @@ describe('CloudDataSource', () => {
it('when there is a nullable field missing, resolves with the eager result & fetches for the rest', async () => {
const result = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
@@ -142,7 +135,6 @@ describe('CloudDataSource', () => {
const immediateResult = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_WITH_OPTIONAL_MISSING),
operationDoc: FAKE_USER_WITH_OPTIONAL_MISSING,
operationVariables: {},
operationType: 'query',
@@ -161,7 +153,6 @@ describe('CloudDataSource', () => {
it('when there is a non-nullable field missing, issues the remote query immediately', async () => {
const result = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
@@ -173,7 +164,6 @@ describe('CloudDataSource', () => {
const requiredResult = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_WITH_REQUIRED_MISSING),
operationDoc: FAKE_USER_WITH_REQUIRED_MISSING,
operationVariables: {},
operationType: 'query',
@@ -191,7 +181,6 @@ describe('CloudDataSource', () => {
const result = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
@@ -209,7 +198,6 @@ describe('CloudDataSource', () => {
const result = cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
@@ -228,7 +216,6 @@ describe('CloudDataSource', () => {
describe('isResolving', () => {
it('returns false if we are not currently resolving the request', () => {
const result = cloudDataSource.isResolving({
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
})
@@ -239,14 +226,12 @@ describe('CloudDataSource', () => {
it('returns true if we are currently resolving the request', () => {
cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
})
const result = cloudDataSource.isResolving({
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
})
@@ -258,7 +243,6 @@ describe('CloudDataSource', () => {
describe('hasResolved', () => {
it('returns false if we have not resolved the data yet', () => {
const result = cloudDataSource.hasResolved({
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
})
@@ -269,14 +253,12 @@ describe('CloudDataSource', () => {
it('returns true if we have resolved the data for the query', async () => {
await cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
})
const result = cloudDataSource.hasResolved({
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
})
@@ -289,14 +271,12 @@ describe('CloudDataSource', () => {
it('allows us to issue a cache.invalidate on individual fields in the cloud schema', async () => {
await cloudDataSource.executeRemoteGraphQL({
fieldName: 'cloudViewer',
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
operationType: 'query',
})
expect(cloudDataSource.hasResolved({
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
})).to.eq(true)
@@ -304,7 +284,6 @@ describe('CloudDataSource', () => {
await cloudDataSource.invalidate('Query', 'cloudViewer')
expect(cloudDataSource.hasResolved({
operation: print(FAKE_USER_QUERY),
operationDoc: FAKE_USER_QUERY,
operationVariables: {},
})).to.eq(false)

View File

@@ -0,0 +1,54 @@
import { SpecWithRelativeRoot } from '@packages/types'
import { expect } from 'chai'
import fs from 'fs-extra'
import { scaffoldMigrationProject } from '../helper'
import { getTestCounts } from '../../../src/util/testCounts'
import path from 'path'
describe('getTestCounts', () => {
it('should return zeros for no input', async () => {
const specs = []
const counts = await getTestCounts(specs)
expect(counts).to.deep.equal({
totalSpecs: 0,
totalTests: 0,
exampleSpecs: 0,
exampleTests: 0,
})
})
context('with e2e project', () => {
let specs: SpecWithRelativeRoot[]
beforeEach(async () => {
const cwd = await scaffoldMigrationProject('e2e')
const e2eSpecs = await fs.readdir(path.join(cwd, 'cypress/e2e'))
specs = e2eSpecs.map((spec) => {
const absolute = path.join(cwd, 'cypress/e2e', spec)
return {
absolute,
relativeToCommonRoot: path.join('cypress/e2e', spec),
} as SpecWithRelativeRoot
})
.filter((spec) => {
return !fs.lstatSync(spec.absolute).isDirectory()
})
})
it('should return counts for tests e2e migration project', async () => {
const counts = await getTestCounts(specs)
expect(counts.totalSpecs).to.equal(specs.length)
// don't test for exact number since tests in sample project might change
expect(counts.totalTests).to.be.greaterThan(0)
expect(counts.exampleSpecs).to.eq(0)
expect(counts.exampleTests).to.eq(0)
})
})
})

View File

@@ -810,6 +810,11 @@ type CloudRunInstance implements Node {
"""
groupId: String!
"""
Used to determine if the instance has test replay
"""
hasReplay: Boolean
"""
Used to determine if the instance has screenshots
"""
@@ -826,6 +831,11 @@ type CloudRunInstance implements Node {
hasVideo: Boolean!
id: ID!
"""
Link to Cypress Cloud to view test replay, if available
"""
replayUrl: String
"""
Link to Cypress Cloud to view stdout, if available
"""
@@ -1094,6 +1104,14 @@ enum CloudTestResultStateEnum {
UNKNOWN
}
"""
Type of tests
"""
enum CloudTestingTypeEnum {
COMPONENT
E2E
}
"""
A CloudUser represents an User stored in the Cypress Cloud
"""
@@ -1175,6 +1193,46 @@ A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `dat
"""
scalar DateTime
"""
Counts for specs and tests from a local project at a point in time
"""
input LocalTestCountsInput {
"""
Current Git branch name for local project
"""
branch: String
"""
Total number of example specs found in project
"""
exampleSpecs: Int!
"""
Total number of tests found in example specs. This can be an estimate
"""
exampleTests: Int!
"""
Project slug for project
"""
projectSlug: String
"""
Testing type
"""
testingType: CloudTestingTypeEnum!
"""
Total number of specs found in project
"""
totalSpecs: Int!
"""
Total number of tests found in all specs in project. This can be an estimate
"""
totalTests: Int!
}
type Mutation {
"""
Create a project in the dashboard and return its object
@@ -1195,6 +1253,11 @@ type Mutation {
"""
cloudProjectRequestAccess(projectSlug: String!): CloudProjectResult
"""
Record event
"""
cloudRecordEvent(localTestCounts: LocalTestCountsInput): Boolean
"""
Adding as a test
"""

View File

@@ -646,6 +646,9 @@ type CloudRunInstance implements Node {
"""The ID of the group that this instance belongs to"""
groupId: String!
"""Used to determine if the instance has test replay"""
hasReplay: Boolean
"""Used to determine if the instance has screenshots"""
hasScreenshots: Boolean!
@@ -656,6 +659,9 @@ type CloudRunInstance implements Node {
hasVideo: Boolean!
id: ID!
"""Link to Cypress Cloud to view test replay, if available"""
replayUrl: String
"""Link to Cypress Cloud to view stdout, if available"""
screenshotsUrl: String
@@ -842,6 +848,12 @@ enum CloudTestResultStateEnum {
UNKNOWN
}
"""Type of tests"""
enum CloudTestingTypeEnum {
COMPONENT
E2E
}
"""A CloudUser represents an User stored in the Cypress Cloud"""
type CloudUser implements Node {
"""Url to manage cloud organizations for this user"""
@@ -1426,6 +1438,32 @@ type LocalSettingsPreferences {
wasBrowserSetInCLI: Boolean
}
"""Counts for specs and tests from a local project at a point in time"""
input LocalTestCountsInput {
"""Current Git branch name for local project"""
branch: String
"""Total number of example specs found in project"""
exampleSpecs: Int!
"""Total number of tests found in example specs. This can be an estimate"""
exampleTests: Int!
"""Project slug for project"""
projectSlug: String
"""Testing type"""
testingType: CloudTestingTypeEnum!
"""Total number of specs found in project"""
totalSpecs: Int!
"""
Total number of tests found in all specs in project. This can be an estimate
"""
totalTests: Int!
}
type ManualMigration implements Node {
"""is the manual migration completed (all files are moved)"""
completed: Boolean!
@@ -1598,6 +1636,9 @@ type Mutation {
"""Request access to an organization from a projectId"""
cloudProjectRequestAccess(projectSlug: String!): CloudProjectResult
"""Record event"""
cloudRecordEvent(localTestCounts: LocalTestCountsInput): Boolean
completeSetup: Query
"""add the passed text to the local clipboard"""

View File

@@ -37,6 +37,7 @@ export const allowedKeys: Readonly<Array<keyof AllowedState>> = [
'firstOpened',
'lastOpened',
'lastProjectId',
'lastTestCountsEvent',
'promptsShown',
'specFilter',
'preferredEditorBinary',
@@ -76,6 +77,7 @@ export type AllowedState = Partial<{
lastProjectId: Maybe<string>
firstOpened: Maybe<number>
lastOpened: Maybe<number>
lastTestCountsEvent: Maybe<number>
promptsShown: Maybe<object>
specFilter: Maybe<string>
preferredEditorBinary: Maybe<string>