diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index a421757654..a5aaa5d40b 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -114,6 +114,7 @@ Other rows gain workspace fields in a linked project: - Every event carries `client: "altimate-code"`, which names the product that sent it. `source` still says how the CLI was launched. - Rows of a turn in a linked project carry that project's `workspace_id`: `session_start`, `session_end`, `first_prompt_sent`, `first_job_completed`, `skill_used`, `skill_published`, `memory_injection`, `agent_outcome`, `task_classified`, `task_outcome_signal`, `tool_chain_outcome`, `core_failure`, `error_fingerprint`, `plan_revision`, `sql_quality` and `tool_call`. +- With `ALTIMATE_DISABLE_WORKSPACE` set, no `first_prompt_sent` or `first_job_completed` is sent for the project's saved link, and an onboarding session's own rows name no workspace. The turn's other rows still name that link, read from the local cache with no request; a pin from the IDE extension is not read. Once the switch is off, the workspace's first rows are sent as usual. - `session_end` counts the workspace skills, memory blocks and integration calls the session used. `memory_injection` counts the workspace's blocks. `task_outcome_signal` says whether workspace context was used. - A call to a workspace integration through the engine is reported as a `tool_call` with `tool_type: "mcp"` and `via_workspace: true`. As for other tools, there are no arguments or outputs, and an error message is masked and capped at 500 characters. - An `endpoint` value is a route label with every id and path segment replaced by `:id`, for example `POST /datamate-project-bindings/bind`. It never contains an id, a file path or a query string. diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index 549e482dbb..2699a60279 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -264,14 +264,20 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise() + +/** Notes `scope` for `callID` until the returned handle is disposed. */ +export function noteCallScope(callID: string | undefined, scope: Scope | undefined): Disposable { + if (callID === undefined || !scope) return { [Symbol.dispose]() {} } + callScopes.set(callID, scope) + return { + [Symbol.dispose]() { + if (callScopes.get(callID) === scope) callScopes.delete(callID) + }, + } +} + +/** The scope noted for a tool call, while its plugins run. */ +export function callScope(callID: string): Scope | undefined { + return callScopes.get(callID) +} + +/** The workspace a first event may name: none while the workspace kill switch is set. Nothing is + * marked then either, so the workspace's rows are still sent once the switch is off. */ +export function firstEventWorkspace(workspaceId: number | undefined): number | undefined { + return Flag.ALTIMATE_DISABLE_WORKSPACE ? undefined : workspaceId +} + /** A typed prompt: the onboarding row and the workspace row are one row when both apply. */ -export async function promptSent(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise { +export async function promptSent(sessionID: string, turnWorkspaceId: number | undefined, scope?: Scope): Promise { + const workspaceId = firstEventWorkspace(turnWorkspaceId) const onboarding = OnboardingTelemetry.isOnboardingSession(sessionID) && OnboardingTelemetry.claimFirstPrompt(sessionID) const inWorkspace = workspaceId !== undefined && (await claim("first_prompt_sent", workspaceId, scope)) if (onboarding || inWorkspace) { @@ -93,7 +127,12 @@ export async function promptSent(sessionID: string, workspaceId: number | undefi } /** A turn in a workspace that ended `completed` with at least one tool call that didn't fail. */ -export async function jobCompleted(sessionID: string, workspaceId: number | undefined, scope?: Scope): Promise { +export async function jobCompleted( + sessionID: string, + turnWorkspaceId: number | undefined, + scope?: Scope, +): Promise { + const workspaceId = firstEventWorkspace(turnWorkspaceId) if (workspaceId === undefined) return if (await claim("first_job_completed", workspaceId, scope)) { await OnboardingTelemetry.emit( @@ -106,5 +145,6 @@ export async function jobCompleted(sessionID: string, workspaceId: number | unde /** Test seam: drops this process's memory of the marks, as a new process would start. */ export function resetForTest() { sent.clear() + callScopes.clear() warnedUnwritable = false } diff --git a/packages/opencode/src/altimate/workspace/state.ts b/packages/opencode/src/altimate/workspace/state.ts index 24f62d57a6..959d859d44 100644 --- a/packages/opencode/src/altimate/workspace/state.ts +++ b/packages/opencode/src/altimate/workspace/state.ts @@ -793,7 +793,8 @@ export async function resolveTurnBinding( waitMs = TURN_PIN_WAIT_MS, ): Promise { // With the workspace kill switch on, nothing else in the session reads the pin, so the turn sends - // no validation request and waits for none. Its rows read the link, as they do on `main`. + // no validation request and waits for none. Its rows read the link, as they did before the pin + // was honoured. if (!workspaceEnabled()) return linkedTurnBinding(directory) let timer: ReturnType | undefined const deadline = new Promise((done) => { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 03558326bf..c45e18f964 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -125,7 +125,13 @@ import { Telemetry } from "@/telemetry" // altimate_change — session telemetry import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_change — error_fingerprint payload // altimate_change start — the session's bound workspace rides on every telemetry row; workspace rules canonicalize bound // directories like the bindings cache does -import { canonicalDirectory, endTurnBinding, turnBinding, unvalidatedTurnBinding } from "@/altimate/workspace/state" +import { + canonicalDirectory, + endTurnBinding, + turnBinding, + unvalidatedTurnBinding, + type AccountKey, +} from "@/altimate/workspace/state" /** The session's workspace use, as session_end fields. Read only: the row is tracked first, then the * turn ends its telemetry entries (`Telemetry.endSession`), so the row itself still carries the @@ -1825,6 +1831,7 @@ export namespace SessionPrompt { // altimate_change start — file rules append only to this tool result lessons, workspaceId: boundWorkspace?.datamateId, + workspaceScope: boundWorkspace?.scope, turn: telemetryTurn, // altimate_change end }), @@ -2845,6 +2852,8 @@ export namespace SessionPrompt { /** The workspace the calling turn resolved, for the rows its MCP calls emit, and the turn itself, * so a call landing after the turn was replaced does not count toward the replacement. */ workspaceId?: number + /** The credential that turn's workspace was resolved under, noted for each tool call's plugins. */ + workspaceScope?: AccountKey turn?: number // altimate_change end }) { @@ -2970,6 +2979,8 @@ export namespace SessionPrompt { // altimate_change start — stamp authoritative tool source so clients render the right // badge. Shared with SessionTools.resolve (session/tools.ts) so the resolvers can't drift. const stamped = stampRegistryToolSource(output, item) + // The turn's credential, for this call's plugins: a first-event mark names its tenant. + using _callScope = WorkspaceFirstEvents.noteCallScope(ctx.callID, input.workspaceScope) // altimate_change end await Plugin.trigger( "tool.execute.after", @@ -3195,6 +3206,14 @@ export namespace SessionPrompt { ? await unvalidatedTurnBinding(Instance.directory) : await turnBinding(input.sessionID, Instance.directory) )?.datamateId + // Every part settles before a failed one is rethrown. A part still running after another failed could + // otherwise read the turn's workspace once `prompt` has cleared it, and leave a verdict for the next + // loop started without a prompt (summarize, resume) to take. + function settled(results: PromiseSettledResult[]): T[] { + const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected") + if (failed) throw failed.reason + return results.map((result) => (result as PromiseFulfilledResult).value) + } // altimate_change end const agentName = input.agent ?? (await Agent.defaultAgent()) const agent = await Agent.get(agentName) @@ -3250,7 +3269,9 @@ export namespace SessionPrompt { id: part.id ? PartID.make(part.id) : PartID.ascending(), }) - const parts = await Promise.all( + // altimate_change start — every part settles before a failure is rethrown (see `settled`) + const parts = await Promise.allSettled( + // altimate_change end input.parts.map(async (part): Promise[]> => { if (part.type === "file") { // before checking the protocol we check if this is an mcp resource because it needs special handling @@ -3580,7 +3601,9 @@ export namespace SessionPrompt { }, ] }), - ).then((x) => x.flat().map(assign)) + // altimate_change start — every part settles before a failure is rethrown (see `settled`) + ).then((x) => settled(x).flat().map(assign)) + // altimate_change end await Plugin.trigger( "chat.message", diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index 2e60295682..25460f2784 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -123,6 +123,14 @@ async function runTool(hooks: any, input: any, output: any) { describe("activation events", () => { const SESSION = "ses_test" + // The suite's preload sets the workspace kill switch. A test that needs workspaces on, as a user's + // CLI has them, clears it, and it is put back after each test. + const KILL_SWITCH = process.env.ALTIMATE_DISABLE_WORKSPACE + const workspacesOn = () => delete process.env.ALTIMATE_DISABLE_WORKSPACE + afterEach(() => { + if (KILL_SWITCH === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = KILL_SWITCH + }) async function plugin() { return OnboardingTelemetryPlugin({} as any) @@ -221,6 +229,7 @@ describe("activation events", () => { }) test("the first job's row names the workspace the hook input carries", async () => { + workspacesOn() const events = captureEvents() const hooks = await plugin() await startOnboarding(hooks, "skip") @@ -240,6 +249,7 @@ describe("activation events", () => { }) test("the first job's row marks its workspace, so the workspace's own row isn't sent after it", async () => { + workspacesOn() await withTestStateHome(async () => { spyOn(Telemetry, "isEnabled").mockImplementation(() => true) spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) @@ -264,6 +274,70 @@ describe("activation events", () => { }) }) + test("the first job's mark is the tool's own turn's tenant, when the credential switched before a late return", async () => { + workspacesOn() + await withTestStateHome(async () => { + spyOn(Telemetry, "isEnabled").mockImplementation(() => true) + spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) + // By the time the tool returns, the user has signed in to another tenant. + spyOn(AltimateApi, "getCredentials").mockImplementation( + async () => ({ altimateInstanceName: "other", altimateUrl: "https://api.test", altimateApiKey: "k2" }) as never, + ) + WorkspaceFirstEvents.resetForTest() + captureEvents() + const hooks = await plugin() + await startOnboarding(hooks, "skip") + const acme = { tenant: "acme", apiUrl: "https://api.test" } + const other = { tenant: "other", apiUrl: "https://api.test" } + + // The cancelled turn's call, and its replacement's, both still running: each trigger site notes + // its own turn's credential. + using _late = WorkspaceFirstEvents.noteCallScope("c3late", acme) + using _replacement = WorkspaceFirstEvents.noteCallScope("c9new", other) + await runTool( + hooks, + { tool: "sample_setup", sessionID: SESSION, callID: "c3late", args: {}, workspaceId: 7 }, + { title: "", output: "", metadata: { success: true } }, + ) + await settle() + + expect(await WorkspaceFirstEvents.claim("first_job_completed", 7, acme)).toBe(false) + // The other tenant's workspace 7 is a different workspace: its own first row is still sent. + expect(await WorkspaceFirstEvents.claim("first_job_completed", 7, other)).toBe(true) + WorkspaceFirstEvents.resetForTest() + }) + }) + + test("with the workspace kill switch set, the first job's row names no workspace and marks none", async () => { + await withTestStateHome(async () => { + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + spyOn(Telemetry, "isEnabled").mockImplementation(() => true) + spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) + spyOn(AltimateApi, "getCredentials").mockImplementation( + async () => ({ altimateInstanceName: "acme", altimateUrl: "https://api.test", altimateApiKey: "k" }) as never, + ) + WorkspaceFirstEvents.resetForTest() + const events = captureEvents() + const hooks = await plugin() + await startOnboarding(hooks, "skip") + + await runTool( + hooks, + { tool: "sample_setup", sessionID: SESSION, callID: "c3k", args: {}, workspaceId: 7 }, + { title: "", output: "", metadata: { success: true } }, + ) + await settle() + + const completed = events.find((e) => e.type === "first_job_completed") as any + expect(completed.origin).toBe("onboarding") + expect(completed.workspace_id).toBeUndefined() + // Nothing was marked: with the switch off, the workspace's own first job is still sent. + workspacesOn() + expect(await WorkspaceFirstEvents.claim("first_job_completed", 7)).toBe(true) + WorkspaceFirstEvents.resetForTest() + }) + }) + test("a failed sample setup counts as selected but not completed", async () => { const events = captureEvents() const hooks = await plugin() diff --git a/packages/opencode/test/session/pinned-workspace-loop.test.ts b/packages/opencode/test/session/pinned-workspace-loop.test.ts index 4ae7254553..cc5a709f3e 100644 --- a/packages/opencode/test/session/pinned-workspace-loop.test.ts +++ b/packages/opencode/test/session/pinned-workspace-loop.test.ts @@ -27,9 +27,10 @@ import { readLocalBinding, recordApprovedBinding, TURN_PIN_WAIT_MS, + turnBinding, } from "../../src/altimate/workspace/state" import { stubEmptySkillList } from "../altimate/workspace/skill-list-fixture" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" import { TestLLMServer } from "../lib/llm-server" import { provideTmpdirServerLegacy } from "./legacy-instance" import { LSP } from "@/lsp/lsp" @@ -64,6 +65,9 @@ const mcp = Layer.succeed( }), ) +/** The ranged-file attachment's symbol lookup. Instant unless a test slows it down. */ +let symbolLookup: () => Promise = async () => [] + const lsp = Layer.succeed( LSP.Service, LSP.Service.of({ @@ -76,7 +80,7 @@ const lsp = Layer.succeed( definition: () => Effect.succeed([]), references: () => Effect.succeed([]), implementation: () => Effect.succeed([]), - documentSymbol: () => Effect.succeed([]), + documentSymbol: () => Effect.promise(() => symbolLookup()), workspaceSymbol: () => Effect.succeed([]), prepareCallHierarchy: () => Effect.succeed([]), incomingCalls: () => Effect.succeed([]), @@ -154,6 +158,7 @@ const originalKillSwitch = process.env.ALTIMATE_DISABLE_WORKSPACE beforeEach(() => { rows = [] + symbolLookup = async () => [] delete process.env.ALTIMATE_DISABLE_WORKSPACE // Cleared, so a pin in the runner's own environment can't decide the unpinned case; restored after. inheritedPin = Object.fromEntries(PIN_VARS.map((name) => [name, process.env[name]])) @@ -358,6 +363,62 @@ it.live("a directory attachment's read names the turn's workspace, as a file's d { timeout: 60_000 }, ) +it.live("a prompt that fails leaves no verdict, even from an attachment read that ends after the failure", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir }) { + accountSees([{ id: LINKED, name: "project-link" }]) + yield* linkProject(dir) + // The ranged file's symbol lookup outlasts the missing directory, whose failed read fails the prompt. + symbolLookup = () => new Promise((done) => setTimeout(() => done([]), 300)) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "a session", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const exit = yield* Effect.exit( + prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [ + { type: "text", text: "read these" }, + { + type: "file", + url: pathToFileURL(path.join(dir, "missing-dir")).href, + filename: "missing-dir", + mime: "application/x-directory", + }, + { + type: "file", + url: `${pathToFileURL(path.join(dir, "missing.txt")).href}?start=3&end=3`, + filename: "missing.txt", + mime: "text/plain", + }, + ], + }), + ) + expect(exit._tag).toBe("Failure") + // Both attachments' reads have run: the ranged file's row is the last thing its part does. + yield* pollWithTimeout( + Effect.sync(() => (reads().length === 2 ? true : undefined)), + "the ranged file's read never ran", + ) + yield* Effect.promise(() => + recordApprovedBinding( + dir, + { datamateId: RELINKED, datamateName: "relinked", linkedAt: Date.now(), repoRemote: null, projectPath: dir } as never, + { awaitBackfill: true }, + ), + ) + + // A loop started now without a prompt (summarize, resume) resolves its own verdict. + expect((yield* Effect.promise(() => turnBinding(session.id, dir)))?.datamateId).toBe(RELINKED) + }), + { git: true, config: providerCfg }, + ), + { timeout: 60_000 }, +) + it.live("a noReply prompt's attachments in a pinned session name no workspace and send no validation", () => provideTmpdirServerLegacy( Effect.fnUntraced(function* ({ dir }) { diff --git a/packages/opencode/test/session/workspace-first-events-loop.test.ts b/packages/opencode/test/session/workspace-first-events-loop.test.ts index 6a7a8294c9..602c296718 100644 --- a/packages/opencode/test/session/workspace-first-events-loop.test.ts +++ b/packages/opencode/test/session/workspace-first-events-loop.test.ts @@ -17,7 +17,13 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { Telemetry } from "../../src/altimate/telemetry" import * as OnboardingTelemetry from "../../src/altimate/telemetry/onboarding" import { AltimateApi } from "../../src/altimate/api/client" -import { readLocalBinding, recordApprovedBinding } from "../../src/altimate/workspace/state" +import { + __resetPinValidation, + currentScope, + readLocalBinding, + recordApprovedBinding, +} from "../../src/altimate/workspace/state" +import { Plugin } from "../../src/plugin" import * as WorkspaceFirstEvents from "../../src/altimate/workspace/first-events" import { stubEmptySkillList } from "../altimate/workspace/skill-list-fixture" import { pollWithTimeout, testEffect } from "../lib/effect" @@ -137,11 +143,27 @@ type Creds = Awaited> let rows: Row[] = [] let restore: (() => void)[] = [] +/** The tenant the stubbed credential names. A test can switch it while a turn runs. */ +let tenant = "acme" let stateHome = "" const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME +// The suite's preload turns the workspace feature off, and with it off a turn never reads the pin. These +// tests turn it on, as a user's CLI has it, and clear any pin in the runner's own environment. +const PIN_VARS = [ + "ALTIMATE_CODE_SERVE", + "ALTIMATE_PINNED_WORKSPACE_ID", + "ALTIMATE_PINNED_WORKSPACE_NAME", + "ALTIMATE_PINNED_WORKSPACE_ROOT", +] +const inherited = Object.fromEntries( + ["ALTIMATE_DISABLE_WORKSPACE", ...PIN_VARS].map((name) => [name, process.env[name]]), +) beforeEach(() => { rows = [] + tenant = "acme" + for (const name of Object.keys(inherited)) delete process.env[name] + __resetPinValidation() stateHome = mkdtempSync(path.join(os.tmpdir(), "first-events-loop-state-")) process.env.OPENCODE_TEST_STATE_HOME = stateHome WorkspaceFirstEvents.resetForTest() @@ -149,7 +171,7 @@ beforeEach(() => { const track = spyOn(Telemetry, "track").mockImplementation((event) => void rows.push(event as unknown as Row)) const enabled = spyOn(Telemetry, "isEnabled").mockImplementation(() => true) const creds = spyOn(AltimateApi, "getCredentials").mockImplementation( - async () => ({ altimateInstanceName: "acme", altimateUrl: "https://api.test", altimateApiKey: "k" }) as Creds, + async () => ({ altimateInstanceName: tenant, altimateUrl: "https://api.test", altimateApiKey: "k" }) as Creds, ) const configured = spyOn(AltimateApi, "isConfigured").mockImplementation(async () => true) restore = [ @@ -164,6 +186,10 @@ beforeEach(() => { afterEach(() => { for (const undo of restore) undo() OnboardingTelemetry.resetForTest() + for (const [name, value] of Object.entries(inherited)) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome rmSync(stateHome, { recursive: true, force: true }) @@ -441,6 +467,71 @@ it.live("a subagent's working turn is not the workspace's job, and the parent's { timeout: 30_000 }, ) +it.live("a tool call's plugins get the credential its turn started under, not one switched to mid-turn", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + yield* linkProject(dir) + const seen: { turn?: string; now?: string }[] = [] + const trigger = Plugin.trigger + const spy = spyOn(Plugin, "trigger").mockImplementation((async (name: string, input: any, output: any) => { + // The user signs in to another tenant while the tool runs. + if (name === "tool.execute.before") tenant = "other" + if (name === "tool.execute.after") { + seen.push({ + turn: WorkspaceFirstEvents.callScope(input.callID)?.tenant, + now: (await currentScope())?.tenant, + }) + } + return (trigger as any)(name, input, output) + }) as never) + restore.push(() => spy.mockRestore()) + yield* turn(dir, llm, { tool: true }) + + expect(seen).toEqual([{ turn: "acme", now: "other" }]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + +it.live("with the workspace kill switch set, a linked project sends neither row, and both come once it is off", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + yield* linkProject(dir) + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + yield* turn(dir, llm, { tool: true }) + + expect(firstPrompts()).toEqual([]) + expect(firstJobs()).toEqual([]) + // The turn's other rows still name the saved link. + expect(named("task_outcome_signal").map((row) => row.workspace_id)).toEqual([LINKED]) + + delete process.env.ALTIMATE_DISABLE_WORKSPACE + yield* turn(dir, llm, { tool: true }) + + expect(firstPrompts()).toEqual([LINKED]) + expect(firstJobs()).toEqual([[LINKED, "something_else"]]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + +it.live("with the workspace kill switch set, an onboarding session's row names no workspace", () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + yield* linkProject(dir) + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + yield* turn(dir, llm, { before: (id) => OnboardingTelemetry.markOnboardingSession(id) }) + + expect(firstPrompts()).toEqual([undefined]) + expect(origins("first_prompt_sent")).toEqual(["onboarding"]) + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +) + it.live("an unbound session sends neither row", () => provideTmpdirServerLegacy( Effect.fnUntraced(function* ({ dir, llm }) { diff --git a/packages/opencode/test/telemetry/telemetry.test.ts b/packages/opencode/test/telemetry/telemetry.test.ts index 593759d34a..d1e07f6ddb 100644 --- a/packages/opencode/test/telemetry/telemetry.test.ts +++ b/packages/opencode/test/telemetry/telemetry.test.ts @@ -258,11 +258,13 @@ const ALL_EVENT_TYPES: Telemetry.Event["type"][] = [ "rules_delivered", "rules_learning_changed", "rules_command", + "first_prompt_sent", + "first_job_completed", ] describe("telemetry.event-types", () => { test("all event types are valid", () => { - expect(ALL_EVENT_TYPES.length).toBe(55) + expect(ALL_EVENT_TYPES.length).toBe(57) }) })