From baa41d66adf8fa5a14108ccc3b274b61f2c2e5ee Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 4 Jul 2026 12:37:28 +0300 Subject: [PATCH] test(infra): coverage-gate + acceptInvitation atomicity int-spec + turn-end unit (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tail of #244. Three items: 1. Coverage-gate (main). develop had no coverage tooling at all. Added @vitest/coverage-v8@4.1.6 (pinned to the vitest already in use) to the three vitest packages — git-sync, editor-ext (which also gains its missing direct `vitest` devDep), apps/client — and enabled v8 coverage with per-package thresholds (no root vitest config exists, so per-package is the only meaningful scope). v8 provider is chosen deliberately: istanbul broke on the ESM `@docmost/editor-ext` barrel; v8 collects native runtime coverage and never re-parses ESM. `enabled: true` wires the gate into the plain `test` script, so `pnpm -r test` (the CI entrypoint) enforces it without a manual `--coverage`. Thresholds set ~4-5 pts below measured current coverage so the gate PASSES today and FAILS on regression (verified: forcing lines=95 on editor-ext exits 1). `all: false` — coverage counts test-touched files; documented in the configs (with `all: true` the many untested type/barrel files would sink the % and make the gate meaningless). Measured→threshold (S/B/F/L): git-sync 91.78/79.16/76.76/92.46 → 88/75/72/88; editor-ext 58.58/48.1/64.96/58.91 → 54/44/60/54; client 59.93/58/48.47/59.39 → 55/53/44/55. All exit 0. 2. acceptInvitation atomicity int-spec. New apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts (+ createDefaultGroup/createInvitation seeders in test/integration/db.ts per its convention). Wires the real WorkspaceInvitationService with real User/Group/GroupUser repos against the test Kysely, stubbing only the post-commit collaborators. Asserts the invariant protected by users_email_workspace_id_unique: (a) two CONCURRENT accepts → exactly one fulfilled, one BadRequestException('Invitation already accepted'), membership count == 1, invitation consumed; (b) repeated sequential accept → still one membership; (c) the survivor is in the workspace default group (whole-tx, no torn state). Ran against real Postgres+Redis: 3/3 pass. 3. turn-end decision unit test. `decideTurnEnd` does not exist as a symbol; the turn-end logic lives in chat-thread.tsx's onFinish handler. Added a focused block to the existing chat-thread.test.tsx (matching its hoisted-mock style): clean finish → flush queued (continue); abort/disconnect/error → queue preserved (end) with the correct notice; parent notified on every terminal outcome. 8 passed (3 existing + 5 new). Verified: git-sync 712, editor-ext 247, client 888 (all with the gate, exit 0); int-spec 3/3 (real Postgres); tsc --noEmit clean for client + server; pnpm install --frozen-lockfile consistent (lockfile additive). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/client/package.json | 1 + .../ai-chat/components/chat-thread.test.tsx | 90 +++++++- apps/client/vitest.config.ts | 17 ++ apps/server/test/integration/db.ts | 56 +++++ ...ce-accept-invitation-atomicity.int-spec.ts | 218 ++++++++++++++++++ packages/editor-ext/package.json | 4 + packages/editor-ext/vitest.config.ts | 16 ++ packages/git-sync/package.json | 1 + packages/git-sync/vitest.config.ts | 19 ++ pnpm-lock.yaml | 173 +++++++++++++- 10 files changed, 589 insertions(+), 6 deletions(-) create mode 100644 apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts diff --git a/apps/client/package.json b/apps/client/package.json index 010cb5e4..ca1eb5cc 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -81,6 +81,7 @@ "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "6.0.1", + "@vitest/coverage-v8": "4.1.6", "eslint": "9.28.0", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.0.1", diff --git a/apps/client/src/features/ai-chat/components/chat-thread.test.tsx b/apps/client/src/features/ai-chat/components/chat-thread.test.tsx index 94499d0f..359abbd7 100644 --- a/apps/client/src/features/ai-chat/components/chat-thread.test.tsx +++ b/apps/client/src/features/ai-chat/components/chat-thread.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { render, screen, fireEvent, act } from "@testing-library/react"; +import { render, screen, fireEvent, act, cleanup } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; // Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted @@ -140,3 +140,91 @@ describe("ChatThread — send now (#198)", () => { expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false); }); }); + +// The turn-end decision lives in the `onFinish` handler: given the terminal +// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean), +// it decides whether to CONTINUE (flush the next queued message) or END (leave +// the queue intact for the user), and which stop notice — if any — to show. +// `sendNow` is exercised above; these tests pin down the plain outcomes. +describe("ChatThread — turn-end decision (onFinish)", () => { + beforeEach(() => { + h.state.status = "streaming"; + h.state.onFinish = null; + h.state.sendMessage.mockClear(); + h.state.stop.mockClear(); + h.state.transport = null; + }); + + // Drive a fresh onFinish with the given terminal flags after queueing a + // message, and report both what the parent was told and whether the queue was + // flushed (a resend to the sendMessage spy). + function finishWith(flags: { + isAbort?: boolean; + isDisconnect?: boolean; + isError?: boolean; + }) { + // Tear down any prior render so the loop-driven "every outcome" case does + // not leave duplicate queue buttons in the DOM. + cleanup(); + h.state.sendMessage.mockClear(); + const { onTurnFinished } = renderThread(); + // Populate the queue while the turn is streaming. + fireEvent.click(screen.getByTestId("queue-btn")); + act(() => { + h.state.onFinish?.({ + message: { id: "a", role: "assistant", parts: [] }, + isAbort: false, + isDisconnect: false, + isError: false, + ...flags, + }); + }); + return { onTurnFinished }; + } + + it("CONTINUES — flushes the next queued message on a clean finish", () => { + finishWith({}); + // Clean finish (no terminal flag): the queued message is auto-sent. + expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" }); + // A clean finish shows no stop notice. + expect(screen.queryByText("Response stopped.")).toBeNull(); + }); + + it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => { + finishWith({ isAbort: true }); + // A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the + // queue is preserved for the user to decide. + expect(h.state.sendMessage).not.toHaveBeenCalled(); + expect(screen.getByText("Response stopped.")).toBeTruthy(); + }); + + it("ENDS — keeps the queue intact on a disconnect and shows the connection-lost notice", () => { + finishWith({ isDisconnect: true }); + expect(h.state.sendMessage).not.toHaveBeenCalled(); + expect( + screen.getByText("Connection lost — the answer was interrupted."), + ).toBeTruthy(); + }); + + it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => { + finishWith({ isError: true }); + // Blindly retrying after a failure would be wrong; the queue is left alone. + expect(h.state.sendMessage).not.toHaveBeenCalled(); + // isError clears the neutral notice (the error banner covers this case). + expect(screen.queryByText("Response stopped.")).toBeNull(); + }); + + it("notifies the parent on EVERY terminal outcome", () => { + // The chat-list refresh / new-chat id adoption must run on success and on + // every failure path alike. + for (const flags of [ + {}, + { isAbort: true }, + { isDisconnect: true }, + { isError: true }, + ]) { + const { onTurnFinished } = finishWith(flags); + expect(onTurnFinished).toHaveBeenCalled(); + } + }); +}); diff --git a/apps/client/vitest.config.ts b/apps/client/vitest.config.ts index 334f6226..c40bb93e 100644 --- a/apps/client/vitest.config.ts +++ b/apps/client/vitest.config.ts @@ -13,5 +13,22 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], + // Coverage gate (issue #324). v8 provider (not istanbul) so ESM barrels + // like `@docmost/editor-ext` are not re-parsed/instrumented. Thresholds are + // set a few points below the level measured on develop, scoped to the files + // the suite exercises (`all: false`) rather than the whole app, so the gate + // passes today but fails on a genuine coverage regression. + coverage: { + enabled: true, + provider: 'v8', + reporter: ['text-summary', 'text'], + all: false, + thresholds: { + statements: 55, + branches: 53, + functions: 44, + lines: 55, + }, + }, }, }); diff --git a/apps/server/test/integration/db.ts b/apps/server/test/integration/db.ts index ede53494..db795a2f 100644 --- a/apps/server/test/integration/db.ts +++ b/apps/server/test/integration/db.ts @@ -132,6 +132,62 @@ export async function createUser( return { id: row.id as string }; } +// The default group every workspace has; `groupUserRepo.addUserToDefaultGroup` +// (invoked by acceptInvitation) looks it up by `isDefault = true`, so a +// workspace under test must have exactly one for the accept path to complete. +export async function createDefaultGroup( + db: Kysely, + workspaceId: string, + overrides: { name?: string } = {}, +): Promise<{ id: string }> { + const id = randomUUID(); + const suffix = shortId(id); + const row = await db + .insertInto('groups') + .values({ + id, + // name is unique per workspace + NOT NULL. + name: overrides.name ?? `group-${suffix}`, + isDefault: true, + workspaceId, + }) + .returning(['id']) + .executeTakeFirstOrThrow(); + return { id: row.id as string }; +} + +// A pending workspace invitation. `role`/`token` are NOT NULL; `groupIds` is a +// nullable uuid[] and `invitedById` a nullable FK to users. Returns the fields a +// spec needs to drive acceptInvitation (id + token + the invited email). +export async function createInvitation( + db: Kysely, + args: { + workspaceId: string; + email: string; + invitedById?: string | null; + role?: string; + token?: string; + groupIds?: string[] | null; + }, +): Promise<{ id: string; token: string; email: string }> { + const id = randomUUID(); + const token = args.token ?? `tok-${shortId(id)}`; + const row = await db + .insertInto('workspaceInvitations') + .values({ + id, + email: args.email, + role: args.role ?? 'member', + token, + groupIds: (args.groupIds ?? null) as any, + invitedById: args.invitedById ?? null, + workspaceId: args.workspaceId, + }) + .returning(['id']) + .executeTakeFirstOrThrow(); + return { id: row.id as string, token, email: args.email }; +} + export async function createSpace( db: Kysely, workspaceId: string, diff --git a/apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts b/apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts new file mode 100644 index 00000000..7be776a9 --- /dev/null +++ b/apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts @@ -0,0 +1,218 @@ +import { BadRequestException } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { Workspace } from '@docmost/db/types/entity.types'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { GroupRepo } from '@docmost/db/repos/group/group.repo'; +import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; +import { WorkspaceInvitationService } from 'src/core/workspace/services/workspace-invitation.service'; +import { + getTestDb, + destroyTestDb, + createWorkspace, + createUser, + createDefaultGroup, + createInvitation, +} from './db'; + +/** + * acceptInvitation atomicity (issue #324, tail of #244). + * + * acceptInvitation() reads the invitation OUTSIDE the transaction, then inside a + * single tx: inserts the invited user, adds them to the default group, and + * deletes the invitation. Two accepts of the SAME invitation therefore race to + * insert a user with the same (email, workspaceId) — which the + * `users_email_workspace_id_unique` constraint forbids. The service catches that + * violation and reports "Invitation already accepted". + * + * These specs pin the INVARIANT that path protects: no matter how many times the + * invitation is accepted (concurrently or repeatedly), the workspace ends up + * with exactly ONE membership for the invited email and the invitation is + * consumed exactly once — never a duplicate user and never a half-applied state. + * + * The service is wired with the REAL repos (UserRepo / GroupRepo / GroupUserRepo) + * against the test Kysely; only the peripheral collaborators that acceptInvitation + * touches AFTER the transaction (mail, session token, billing, audit, env) are + * stubbed, so the exercised DB write path is the production one. + */ +describe('WorkspaceInvitationService.acceptInvitation atomicity [integration]', () => { + let db: Kysely; + let service: WorkspaceInvitationService; + + // Count the memberships (user rows) for an email within a workspace — the + // quantity the atomicity guarantee is about. + async function membershipCount( + workspaceId: string, + email: string, + ): Promise { + const rows = await db + .selectFrom('users') + .select('id') + .where('workspaceId', '=', workspaceId) + .where('email', '=', email.toLowerCase()) + .execute(); + return rows.length; + } + + async function invitationExists(invitationId: string): Promise { + const row = await db + .selectFrom('workspaceInvitations') + .select('id') + .where('id', '=', invitationId) + .executeTakeFirst(); + return !!row; + } + + beforeAll(() => { + db = getTestDb(); + + const userRepo = new UserRepo(db as any); + const groupRepo = new GroupRepo(db as any); + const groupUserRepo = new GroupUserRepo(db as any, groupRepo, userRepo); + + // Collaborators used only on the post-commit success tail; safe to stub. + const mailService = { sendToQueue: jest.fn().mockResolvedValue(undefined) }; + const domainService = {} as any; + const tokenService = {} as any; + const sessionService = { + createSessionAndToken: jest.fn().mockResolvedValue('test-auth-token'), + }; + const billingQueue = { add: jest.fn().mockResolvedValue(undefined) }; + const environmentService = { isCloud: () => false }; + const auditService = { log: jest.fn() }; + + service = new WorkspaceInvitationService( + userRepo, + groupUserRepo, + mailService as any, + domainService, + tokenService, + sessionService as any, + db as any, + billingQueue as any, + environmentService as any, + auditService as any, + ); + }); + + afterAll(async () => { + await destroyTestDb(); + }); + + // A workspace with its default group, an inviter, and a pending invitation. + async function seedInvite(): Promise<{ + workspace: Workspace; + invitationId: string; + token: string; + email: string; + }> { + const { id: workspaceId } = await createWorkspace(db); + await createDefaultGroup(db, workspaceId); + const inviter = await createUser(db, workspaceId); + // Distinct address per invite so specs never collide across the suite. + const email = `invitee-${workspaceId.slice(0, 8)}@example.test`; + const invite = await createInvitation(db, { + workspaceId, + email, + invitedById: inviter.id, + }); + + // acceptInvitation only reads id/hostname/enforceSso/emailDomains/enforceMfa + // off the workspace; a minimal plain object is sufficient. + const workspace = { + id: workspaceId, + hostname: `host-${workspaceId.slice(0, 8)}`, + enforceSso: false, + enforceMfa: false, + emailDomains: [] as string[], + } as unknown as Workspace; + + return { workspace, invitationId: invite.id, token: invite.token, email }; + } + + it('concurrent accepts create a single membership and consume the invitation once', async () => { + const { workspace, invitationId, token, email } = await seedInvite(); + + const dto = { invitationId, token, name: 'Invited User', password: 'password123' }; + + // Fire two accepts of the SAME invitation at once. They race to insert the + // same (email, workspaceId); the unique constraint lets exactly one win. + const results = await Promise.allSettled([ + service.acceptInvitation({ ...dto }, workspace), + service.acceptInvitation({ ...dto }, workspace), + ]); + + const fulfilled = results.filter((r) => r.status === 'fulfilled'); + const rejected = results.filter( + (r): r is PromiseRejectedResult => r.status === 'rejected', + ); + + // Exactly one accept succeeds; the other is rejected. + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + + // The loser fails via the caught unique-constraint path with the specific + // "already accepted" message — not a half-state / generic failure. + expect(rejected[0].reason).toBeInstanceOf(BadRequestException); + expect(rejected[0].reason.message).toBe('Invitation already accepted'); + + // Invariant: exactly one membership, and the invitation is gone. + expect(await membershipCount(workspace.id, email)).toBe(1); + expect(await invitationExists(invitationId)).toBe(false); + }); + + it('a repeated (sequential) accept does not create a duplicate membership', async () => { + const { workspace, invitationId, token, email } = await seedInvite(); + const dto = { invitationId, token, name: 'Invited User', password: 'password123' }; + + // First accept succeeds and returns an auth token. + const first = await service.acceptInvitation({ ...dto }, workspace); + expect(first?.authToken).toBe('test-auth-token'); + expect(await membershipCount(workspace.id, email)).toBe(1); + expect(await invitationExists(invitationId)).toBe(false); + + // Re-accepting the (now consumed) invitation must be rejected and must NOT + // add a second membership. The invitation row is gone, so this hits the + // "Invitation not found" guard rather than the unique-constraint path. + await expect( + service.acceptInvitation({ ...dto }, workspace), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(await membershipCount(workspace.id, email)).toBe(1); + }); + + it('the single created membership is added to the default group (no partial state)', async () => { + const { workspace, invitationId, token, email } = await seedInvite(); + const dto = { invitationId, token, name: 'Invited User', password: 'password123' }; + + await Promise.allSettled([ + service.acceptInvitation({ ...dto }, workspace), + service.acceptInvitation({ ...dto }, workspace), + ]); + + // Resolve the one surviving user and assert the whole tx applied: they exist + // AND are in the workspace default group (the mid-transaction step), proving + // the winning accept committed as a whole rather than leaving a torn state. + const user = await db + .selectFrom('users') + .select(['id']) + .where('workspaceId', '=', workspace.id) + .where('email', '=', email.toLowerCase()) + .executeTakeFirstOrThrow(); + + const defaultGroup = await db + .selectFrom('groups') + .select(['id']) + .where('workspaceId', '=', workspace.id) + .where('isDefault', '=', true) + .executeTakeFirstOrThrow(); + + const membership = await db + .selectFrom('groupUsers') + .select(['userId']) + .where('groupId', '=', defaultGroup.id) + .where('userId', '=', user.id) + .execute(); + + expect(membership).toHaveLength(1); + }); +}); diff --git a/packages/editor-ext/package.json b/packages/editor-ext/package.json index 0e9b8305..1f2b5ff8 100644 --- a/packages/editor-ext/package.json +++ b/packages/editor-ext/package.json @@ -13,5 +13,9 @@ "types": "dist/index.d.ts", "dependencies": { "marked": "17.0.5" + }, + "devDependencies": { + "@vitest/coverage-v8": "4.1.6", + "vitest": "4.1.6" } } diff --git a/packages/editor-ext/vitest.config.ts b/packages/editor-ext/vitest.config.ts index 617c62d3..cb5a542b 100644 --- a/packages/editor-ext/vitest.config.ts +++ b/packages/editor-ext/vitest.config.ts @@ -5,5 +5,21 @@ export default defineConfig({ environment: "jsdom", globals: true, include: ["src/**/*.{test,spec}.ts"], + // Coverage gate (issue #324). v8 provider avoids the istanbul AST-rewrite + // that broke on this package's ESM barrel. Thresholds sit a few points + // below the level measured on develop, over the files the suite exercises + // (`all: false`), so the gate passes today and catches a real regression. + coverage: { + enabled: true, + provider: "v8", + reporter: ["text-summary", "text"], + all: false, + thresholds: { + statements: 54, + branches: 44, + functions: 60, + lines: 54, + }, + }, }, }); diff --git a/packages/git-sync/package.json b/packages/git-sync/package.json index cce08975..96765ffa 100644 --- a/packages/git-sync/package.json +++ b/packages/git-sync/package.json @@ -38,6 +38,7 @@ "@docmost/editor-ext": "workspace:*", "@types/jsdom": "^21.1.7", "@types/node": "^20.0.0", + "@vitest/coverage-v8": "4.1.6", "fast-check": "^4.8.0", "typescript": "^5.0.0", "vitest": "4.1.6" diff --git a/packages/git-sync/vitest.config.ts b/packages/git-sync/vitest.config.ts index 1c63f4e3..676c4111 100644 --- a/packages/git-sync/vitest.config.ts +++ b/packages/git-sync/vitest.config.ts @@ -18,6 +18,25 @@ export default defineConfig({ }, test: { environment: 'node', + // Coverage gate (issue #324). The v8 provider is used deliberately: the + // istanbul provider instruments sources by rewriting their AST, which broke + // on the ESM `@docmost/editor-ext` barrel import; v8 collects native + // coverage from the runtime and never re-parses ESM, so it sidesteps that. + // Thresholds are calibrated a few points BELOW the level measured on + // develop so the gate passes today but fails on a real regression. Numbers + // reflect the files actually exercised by the suite (`all: false`). + coverage: { + enabled: true, + provider: 'v8', + reporter: ['text-summary', 'text'], + all: false, + thresholds: { + statements: 88, + branches: 75, + functions: 72, + lines: 88, + }, + }, // Runtime suites. The `.test.ts` glob deliberately EXCLUDES the type-only // contract file (`*.test-d.ts`), which is enforced by the typecheck pass // below instead — so the 35 runtime suites are never typechecked. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7540bafe..66aeb468 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -453,6 +453,9 @@ importers: '@vitejs/plugin-react': specifier: 6.0.1 version: 6.0.1(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/coverage-v8': + specifier: 4.1.6 + version: 4.1.6(vitest@4.1.6) eslint: specifier: 9.28.0 version: 9.28.0(jiti@2.4.2) @@ -497,7 +500,7 @@ importers: version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) apps/server: dependencies: @@ -886,6 +889,13 @@ importers: marked: specifier: 17.0.5 version: 17.0.5 + devDependencies: + '@vitest/coverage-v8': + specifier: 4.1.6 + version: 4.1.6(vitest@4.1.6) + vitest: + specifier: 4.1.6 + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@27.4.0(@noble/hashes@2.0.1))(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) packages/git-sync: dependencies: @@ -938,6 +948,9 @@ importers: '@types/node': specifier: ^20.0.0 version: 20.19.43 + '@vitest/coverage-v8': + specifier: 4.1.6 + version: 4.1.6(vitest@4.1.6) fast-check: specifier: ^4.8.0 version: 4.8.0 @@ -946,7 +959,7 @@ importers: version: 5.9.3 vitest: specifier: 4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) packages/mcp: dependencies: @@ -1500,10 +1513,18 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -1526,6 +1547,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3': resolution: {integrity: sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==} engines: {node: '>=6.9.0'} @@ -2015,9 +2041,17 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} @@ -5453,6 +5487,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.6': + resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} + peerDependencies: + '@vitest/browser': 4.1.6 + vitest: 4.1.6 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.6': resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} @@ -5738,6 +5781,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} @@ -7665,6 +7711,10 @@ packages: resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} engines: {node: '>=8'} + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterare@1.2.1: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} @@ -7890,6 +7940,9 @@ packages: js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8335,6 +8388,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} @@ -11643,8 +11699,12 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helper-wrap-function@7.22.20': @@ -11666,6 +11726,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -12271,8 +12335,15 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@0.2.3': {} + '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.1': {} '@braintree/sanitize-url@6.0.2': {} @@ -13256,7 +13327,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping@0.3.9': dependencies: @@ -15931,6 +16002,20 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.6 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 @@ -15956,6 +16041,14 @@ snapshots: optionalDependencies: vite: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/mocker@4.1.6(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/pretty-format@4.1.6': dependencies: tinyrainbow: 3.1.0 @@ -16268,6 +16361,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-lock@1.4.1: {} async-mutex@0.5.0: @@ -18487,6 +18586,11 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterare@1.2.1: {} iterator.prototype@1.1.5: @@ -18897,6 +19001,8 @@ snapshots: dependencies: base64-js: 1.5.1 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -19333,6 +19439,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + make-dir@2.1.0: dependencies: pify: 4.0.1 @@ -21690,7 +21802,25 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): + vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0-rc.12 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.4.2 + less: 4.2.0 + sugarss: 5.0.1(postcss@8.5.14) + terser: 5.39.0 + tsx: 4.21.0 + yaml: 2.8.3 + + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -21715,12 +21845,13 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 20.19.43 + '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) happy-dom: 20.8.9 jsdom: 25.0.0 transitivePeerDependencies: - msw - vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -21745,11 +21876,43 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 22.19.1 + '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) happy-dom: 20.8.9 jsdom: 25.0.0 transitivePeerDependencies: - msw + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@27.4.0(@noble/hashes@2.0.1))(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.5.0 + '@vitest/coverage-v8': 4.1.6(vitest@4.1.6) + happy-dom: 20.8.9 + jsdom: 27.4.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} -- 2.52.0