From 1ef02b5afe6a00b1409a40926997a03d27ce6174 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 10 Oct 2026 19:49:13 -0300 Subject: [PATCH 1/4] test: configure opt-in frontend coverage (#2962) --- package-lock.json | 180 ++++++++++++++++++++++++++++++++++++++++++---- package.json | 1 + vitest.config.ts | 17 +++++ 3 files changed, 183 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdacbe814..248b62597 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "devDependencies": { "@tauri-apps/cli": "^2", "@types/markdown-it": "^14.1.2", + "@vitest/coverage-v8": "4.1.5", "cross-env": "^10.1.0", "dependency-cruiser": "18.0.0", "husky": "^9.1.7", @@ -213,9 +214,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -223,9 +224,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -257,13 +258,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -323,19 +324,29 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -1496,6 +1507,38 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", @@ -1729,6 +1772,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3026,6 +3088,13 @@ "dev": true, "license": "MIT" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3204,6 +3273,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3759,6 +3867,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -4745,6 +4894,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", diff --git a/package.json b/package.json index 4be97085a..2e5dfa801 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "devDependencies": { "@tauri-apps/cli": "^2", "@types/markdown-it": "^14.1.2", + "@vitest/coverage-v8": "4.1.5", "cross-env": "^10.1.0", "dependency-cruiser": "18.0.0", "husky": "^9.1.7", diff --git a/vitest.config.ts b/vitest.config.ts index 5bb12d15b..f3aae9f4a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,23 @@ export default defineConfig({ __BUILD_PROFILE__: JSON.stringify("test"), }, test: { + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: [ + '**/*.{test,spec}.{ts,tsx}', '**/*.d.ts', + 'src/shared/testing/**', 'src/sidebar/testing/**', + 'src/sidebar/styles/css-test-helpers.ts', + 'src/sidebar/stores/project-archive-test-helpers.ts', + 'src/sidebar/components/ac-discovery-fixture.ts', + 'src/sidebar/components/loop-summary-fixture.ts', + 'src/sidebar/components/restart-toast-harness.tsx', + ], + reporter: ['html', 'json', 'json-summary', 'text', 'text-summary'], + reportsDirectory: process.env.COVERAGE_OUTPUT || 'artifacts/coverage/frontend', + excludeAfterRemap: true, + clean: false, + }, includeTaskLocation: true, environment: 'node', include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], From d1c16a67d14f3dc74c01c0ee405ab9066c5f85ff Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Thu, 15 Oct 2026 13:30:43 -0300 Subject: [PATCH 2/4] fix(frontend): guard stale task snapshots and await loop actions --- .../ProjectPanel.loop-automation.test.tsx | 134 ++++++++++++++++-- src/sidebar/stores/project.test.ts | 74 +++++++++- src/sidebar/stores/project.ts | 5 +- 3 files changed, 197 insertions(+), 16 deletions(-) diff --git a/src/sidebar/components/ProjectPanel.loop-automation.test.tsx b/src/sidebar/components/ProjectPanel.loop-automation.test.tsx index e23ed39a2..82e41207b 100644 --- a/src/sidebar/components/ProjectPanel.loop-automation.test.tsx +++ b/src/sidebar/components/ProjectPanel.loop-automation.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ProjectPanel from "./ProjectPanel"; -import type { AcLoopSummary } from "../../shared/types"; +import type { AcLoopSummary, TaskSnapshot } from "../../shared/types"; import { FakeTransport } from "../../shared/testing/fake-transport"; import { click, @@ -112,6 +112,44 @@ function setupProject(fake: FakeTransport): void { fake.resolve("discover_project", discoveryWithLoop(disabledLoop())); } +function taskFixture(fake: FakeTransport) { + let latest: TaskSnapshot | undefined; + fake.onInvoke("task_get_snapshot_at", (args) => { + expect(args).toEqual({ workgroupRoot: workgroupPath }); + latest = { + workgroupRoot: workgroupPath, task: null, taskTitle: "Loop automation", + description: "", status: null, revision: "legacy:0", statusRecord: null, + tailIncomplete: false, + }; + return latest; + }); + return { + current: () => projectStore.taskSnapshot(workgroupPath), + applied: async (previous?: TaskSnapshot) => { + await waitFor(() => { + expect(latest).toBeDefined(); + expect(latest).not.toBe(previous); + expect(projectStore.taskSnapshot(workgroupPath)).toBe(latest); + }); + }, + }; +} + +async function actionFinished(root: HTMLElement, loopId: string) { + const suffix = `${automationIdPart(projectPath)}.${automationIdPart(loopId)}`; + const row = root.querySelector(`[data-ac-testid="loop.row.${suffix}"]`); + if (!(row instanceof HTMLElement)) throw new Error("Retained loop row not found"); + contextMenu(row); + await waitFor(() => { + const action = document.body.querySelector(`[data-ac-testid="loop.action.delete.${suffix}"]`); + expect(action instanceof HTMLButtonElement && !action.disabled).toBe(true); + }); + await waitFor(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(document.body.querySelector(`[data-ac-testid="loop.action.delete.${suffix}"]`)).toBeNull(); + }); +} + describe("ProjectPanel loop automation hooks", () => { let cleanupDom: (() => void) | null = null; @@ -129,11 +167,13 @@ describe("ProjectPanel loop automation hooks", () => { it("keeps disabled loop rows context-menuable for automation", async () => { const fake = new FakeTransport(); + const snapshots = taskFixture(fake); setupProject(fake); const rendered = renderWithFakeTransport(() => , fake); try { await projectStore.createAndLoad(projectPath); + await snapshots.applied(); const projectId = automationIdPart(projectPath); const loopId = automationIdPart("weekday-standup"); @@ -165,17 +205,23 @@ describe("ProjectPanel loop automation hooks", () => { expect(deleteAction?.textContent?.trim()).toBe("Delete Loop"); }); } finally { - rendered.cleanup(); + try { + projectStore.invalidateTaskSnapshots(); + } finally { + rendered.cleanup(); + } } }); it("opens selector-addressable delete confirmation and keeps the loop on cancel", async () => { const fake = new FakeTransport(); + const snapshots = taskFixture(fake); setupProject(fake); const rendered = renderWithFakeTransport(() => , fake); try { await projectStore.createAndLoad(projectPath); + await snapshots.applied(); const projectId = automationIdPart(projectPath); const loopId = automationIdPart("weekday-standup"); @@ -208,12 +254,17 @@ describe("ProjectPanel loop automation hooks", () => { expect(rendered.root.querySelector(rowSelector)).toBeTruthy(); expect(document.body.querySelector(cancelSelector)).toBeNull(); } finally { - rendered.cleanup(); + try { + projectStore.invalidateTaskSnapshots(); + } finally { + rendered.cleanup(); + } } }); it("confirms loop delete through selectors and removes the visible loop", async () => { const fake = new FakeTransport(); + const snapshots = taskFixture(fake); let deleted = false; fake.resolve("new_project", { @@ -235,6 +286,7 @@ describe("ProjectPanel loop automation hooks", () => { const rendered = renderWithFakeTransport(() => , fake); try { await projectStore.createAndLoad(projectPath); + await snapshots.applied(); const projectId = automationIdPart(projectPath); const loopId = automationIdPart("weekday-standup"); @@ -261,18 +313,25 @@ describe("ProjectPanel loop automation hooks", () => { if (!(confirmAction instanceof HTMLButtonElement)) { throw new Error("Loop delete confirm action not found"); } + const initialSnapshot = snapshots.current(); click(confirmAction); await waitFor(() => expect(fake.callsFor("delete_loop")).toHaveLength(1)); await waitFor(() => expect(rendered.root.querySelector(rowSelector)).toBeNull()); expect(document.body.querySelector(confirmSelector)).toBeNull(); + await snapshots.applied(initialSnapshot); } finally { - rendered.cleanup(); + try { + projectStore.invalidateTaskSnapshots(); + } finally { + rendered.cleanup(); + } } }); it("blocks opening loop delete while an unrelated loop action is in progress", async () => { const fake = new FakeTransport(); + const snapshots = taskFixture(fake); const runNow = deferred(); const primaryLoop = disabledLoop(); const unrelatedLoop = dailyReportLoop(); @@ -298,6 +357,7 @@ describe("ProjectPanel loop automation hooks", () => { const rendered = renderWithFakeTransport(() => , fake); try { await projectStore.createAndLoad(projectPath); + await snapshots.applied(); const projectId = automationIdPart(projectPath); const primaryLoopId = automationIdPart(primaryLoop.id); @@ -339,16 +399,23 @@ describe("ProjectPanel loop automation hooks", () => { click(deleteAction); expect(document.body.querySelector(deleteDialogSelector)).toBeNull(); + const beforeRun = snapshots.current(); runNow.resolve(); - await waitFor(() => expect(fake.callsFor("discover_project").length).toBeGreaterThanOrEqual(2)); + await snapshots.applied(beforeRun); + await actionFinished(rendered.root, primaryLoop.id); } finally { runNow.resolve(); - rendered.cleanup(); + try { + projectStore.invalidateTaskSnapshots(); + } finally { + rendered.cleanup(); + } } }); it("keeps loop delete cancel and Escape available during an unrelated loop action", async () => { const fake = new FakeTransport(); + const snapshots = taskFixture(fake); const pendingRuns = [deferred(), deferred()]; const primaryLoop = disabledLoop(); const unrelatedLoop = dailyReportLoop(); @@ -421,6 +488,7 @@ describe("ProjectPanel loop automation hooks", () => { try { await projectStore.createAndLoad(projectPath); + await snapshots.applied(); await waitFor(() => expect(rendered.root.querySelector(primaryRowSelector)).toBeTruthy()); await waitFor(() => expect(rendered.root.querySelector(unrelatedRowSelector)).toBeTruthy()); @@ -438,8 +506,10 @@ describe("ProjectPanel loop automation hooks", () => { await waitFor(() => expect(document.body.querySelector(dialogSelector)).toBeNull()); expect(fake.callsFor("delete_loop")).toHaveLength(0); + const beforeFirstRun = snapshots.current(); pendingRuns[0].resolve(); - await waitFor(() => expect(fake.callsFor("discover_project").length).toBeGreaterThanOrEqual(2)); + await snapshots.applied(beforeFirstRun); + await actionFinished(rendered.root, unrelatedLoop.id); await openDeleteDialog(); await startUnrelatedRun(2); @@ -447,17 +517,24 @@ describe("ProjectPanel loop automation hooks", () => { await waitFor(() => expect(document.body.querySelector(dialogSelector)).toBeNull()); expect(fake.callsFor("delete_loop")).toHaveLength(0); + const beforeSecondRun = snapshots.current(); pendingRuns[1].resolve(); - await waitFor(() => expect(fake.callsFor("discover_project").length).toBeGreaterThanOrEqual(3)); + await snapshots.applied(beforeSecondRun); + await actionFinished(rendered.root, unrelatedLoop.id); } finally { pendingRuns[0].resolve(); pendingRuns[1].resolve(); - rendered.cleanup(); + try { + projectStore.invalidateTaskSnapshots(); + } finally { + rendered.cleanup(); + } } }); it("keeps loop delete cancel and Escape guarded while the current delete is in progress", async () => { const fake = new FakeTransport(); + const snapshots = taskFixture(fake); const deletePending = deferred(); const primaryLoop = disabledLoop(); const unrelatedLoop = dailyReportLoop(); @@ -483,6 +560,7 @@ describe("ProjectPanel loop automation hooks", () => { const rendered = renderWithFakeTransport(() => , fake); try { await projectStore.createAndLoad(projectPath); + await snapshots.applied(); const projectId = automationIdPart(projectPath); const primaryLoopId = automationIdPart(primaryLoop.id); @@ -524,16 +602,24 @@ describe("ProjectPanel loop automation hooks", () => { click(cancelAction); expect(document.body.querySelector(dialogSelector)).toBeTruthy(); + const beforeDelete = snapshots.current(); deletePending.resolve(); await waitFor(() => expect(document.body.querySelector(dialogSelector)).toBeNull()); + await waitFor(() => expect(rendered.root.querySelector(rowSelector)).toBeNull()); + await snapshots.applied(beforeDelete); } finally { deletePending.resolve(); - rendered.cleanup(); + try { + projectStore.invalidateTaskSnapshots(); + } finally { + rendered.cleanup(); + } } }); it("does not regress enabled loop row while a queued reload follows a stale response", async () => { const fake = new FakeTransport(); + const snapshots = taskFixture(fake); let discoverCalls = 0; const staleReload = deferred(); const queuedReload = deferred(); @@ -571,6 +657,7 @@ describe("ProjectPanel loop automation hooks", () => { const rendered = renderWithFakeTransport(() => , fake); try { await projectStore.createAndLoad(projectPath); + await snapshots.applied(); const projectId = automationIdPart(projectPath); const loopId = automationIdPart("weekday-standup"); @@ -609,6 +696,7 @@ describe("ProjectPanel loop automation hooks", () => { ); }); + const beforeQueuedReload = snapshots.current(); staleReload.resolve(); await waitFor(() => expect(discoverCalls).toBe(3)); @@ -618,6 +706,8 @@ describe("ProjectPanel loop automation hooks", () => { queuedReload.resolve(); await staleReloadPromise; + await snapshots.applied(beforeQueuedReload); + await actionFinished(rendered.root, "weekday-standup"); await waitFor(() => { expect(discoverCalls).toBeGreaterThanOrEqual(3); @@ -636,12 +726,19 @@ describe("ProjectPanel loop automation hooks", () => { expect(document.body.querySelector(toggleSelector)?.textContent?.trim()).toBe("Disable"); }); } finally { - rendered.cleanup(); + staleReload.resolve(); + queuedReload.resolve(); + try { + projectStore.invalidateTaskSnapshots(); + } finally { + rendered.cleanup(); + } } }); it("keeps the enabled loop summary visible when the queued follow-up reload fails", async () => { const fake = new FakeTransport(); + const snapshots = taskFixture(fake); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); let discoverCalls = 0; const staleReload = deferred(); @@ -673,6 +770,7 @@ describe("ProjectPanel loop automation hooks", () => { const rendered = renderWithFakeTransport(() => , fake); try { await projectStore.createAndLoad(projectPath); + await snapshots.applied(); const projectId = automationIdPart(projectPath); const loopId = automationIdPart("weekday-standup"); @@ -711,6 +809,9 @@ describe("ProjectPanel loop automation hooks", () => { ); }); + const beforeQueuedReload = snapshots.current(); + expect(beforeQueuedReload).toBeDefined(); + const snapshotCallsBeforeQueuedReload = fake.callsFor("task_get_snapshot_at").length; staleReload.resolve(); await waitFor(() => expect(discoverCalls).toBe(3)); expect(rendered.root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( @@ -719,13 +820,22 @@ describe("ProjectPanel loop automation hooks", () => { queuedReload.resolve(); await staleReloadPromise; + await actionFinished(rendered.root, "weekday-standup"); + expect(projectStore.taskSnapshot(workgroupPath)).toBe(beforeQueuedReload); + expect(fake.callsFor("task_get_snapshot_at")).toHaveLength(snapshotCallsBeforeQueuedReload); expect(rendered.root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( "enabled", ); } finally { + staleReload.resolve(); + queuedReload.resolve(); consoleError.mockRestore(); - rendered.cleanup(); + try { + projectStore.invalidateTaskSnapshots(); + } finally { + rendered.cleanup(); + } } }); }); diff --git a/src/sidebar/stores/project.test.ts b/src/sidebar/stores/project.test.ts index beb4ac9fd..5483a34ec 100644 --- a/src/sidebar/stores/project.test.ts +++ b/src/sidebar/stores/project.test.ts @@ -11,6 +11,7 @@ import { toastStore } from "../../shared/stores/toasts"; const m = vi.hoisted(() => ({ connection: { state: "connected" as "connected" | "disconnected", generation: 0 }, + getConnection: vi.fn(), getSnapshot: vi.fn(), open: vi.fn(), newProject: vi.fn(), @@ -21,7 +22,7 @@ const m = vi.hoisted(() => ({ })); vi.mock("../../shared/ipc", () => ({ - getTransportConnectionState: () => m.connection, + getTransportConnectionState: m.getConnection, TaskAPI: { getSnapshotAt: m.getSnapshot }, ProjectAPI: { open: m.open, @@ -96,6 +97,7 @@ describe("projectStore", () => { vi.clearAllMocks(); projectStore.clear(); m.connection = { state: "connected", generation: 0 }; + m.getConnection.mockImplementation(() => m.connection); m.open.mockResolvedValue({ path: "C:\\Users\\Maria\\Project" }); m.discover.mockResolvedValue({ workgroups: [], agents: [], teams: [], loops: [] }); }); @@ -105,6 +107,71 @@ describe("projectStore", () => { }); + it.each((["clear", "invalidate", "remove", "archive", "prune", "replace"] as const) + .flatMap(action => (["resolve", "reject"] as const).map(outcome => ({ action, outcome }))))( + "does not read transport for stale $outcome after $action", async ({ action, outcome }) => { + projectStore.clear(); + const wg = makeWorkgroup("room-1", []); + const initial = taskSnapshot(wg.path); + m.getSnapshot.mockResolvedValue(initial); + await loadProjectWith([wg]); + await vi.waitFor(() => expect(projectStore.taskSnapshot(wg.path)).toBe(initial)); + const old = deferred(); + m.getSnapshot.mockReturnValueOnce(old.promise); + const pending = projectStore.refreshTaskSnapshot(wg.path); + const error = vi.spyOn(toastStore, "error"); + try { + if (action === "clear") projectStore.clear(); + else if (action === "invalidate") { + projectStore.invalidateTaskSnapshots(); + m.getSnapshot.mockResolvedValueOnce(taskSnapshot(wg.path, { status: "new generation" })); + await projectStore.refreshTaskSnapshot(wg.path); + } + else if (action === "remove") await projectStore.removeProject(PROJECT_PATH); + else if (action === "archive") await projectStore.archiveProject(PROJECT_PATH); + else if (action === "prune") { + m.discover.mockResolvedValueOnce({ workgroups: [], agents: [], teams: [], loops: [] }); + await projectStore.reloadProject(PROJECT_PATH); + } else { + m.getSnapshot.mockResolvedValueOnce(taskSnapshot(wg.path, { status: "replacement" })); + await projectStore.refreshTaskSnapshot(wg.path); + } + const reads = m.getConnection.mock.calls.length; + const snapshot = projectStore.taskSnapshot(wg.path); + const toasts = error.mock.calls.length; + if (outcome === "resolve") old.resolve(taskSnapshot(wg.path, { status: "stale" })); + else old.reject(new Error("stale failure")); + await pending; + expect(m.getConnection).toHaveBeenCalledTimes(reads); + expect(projectStore.taskSnapshot(wg.path)).toBe(snapshot); + expect(error).toHaveBeenCalledTimes(toasts); + } finally { + old.resolve(initial); + await pending; + error.mockRestore(); + } + }, + ); + + it.each(["connected", "disconnected", "generation"] as const)("checks transport for a current request after %s", async (change) => { + const wg = makeWorkgroup("room-1", []); + const initial = taskSnapshot(wg.path); + m.getSnapshot.mockResolvedValue(initial); + await loadProjectWith([wg]); + await vi.waitFor(() => expect(projectStore.taskSnapshot(wg.path)).toBe(initial)); + const old = deferred(); + m.getSnapshot.mockReturnValueOnce(old.promise); + const pending = projectStore.refreshTaskSnapshot(wg.path); + m.connection = change === "disconnected" + ? { state: "disconnected", generation: 0 } + : { state: "connected", generation: change === "generation" ? 1 : 0 }; + const reads = m.getConnection.mock.calls.length; + old.resolve(initial); + await pending; + expect(m.getConnection).toHaveBeenCalledTimes(reads + 1); + expect(projectStore.taskSnapshot(wg.path)).toBe(change === "connected" ? initial : undefined); + }); + it("hydrates a no-session room and keeps status separate from its descriptor", async () => { const wg = makeWorkgroup("room-1", []); m.getSnapshot.mockResolvedValue(taskSnapshot(wg.path, { status: "line one\nline two" })); @@ -182,6 +249,7 @@ describe("projectStore", () => { const wg = makeWorkgroup("room-1", []); await loadProjectWith([wg]); const error = vi.spyOn(toastStore, "error"); + try { m.getSnapshot.mockRejectedValue(new Error("unreadable")); await projectStore.refreshTaskSnapshot(wg.path); await projectStore.refreshTaskSnapshot(wg.path); @@ -193,7 +261,9 @@ describe("projectStore", () => { await projectStore.refreshTaskSnapshot(wg.path); expect(projectStore.taskSnapshot(wg.path)).toBeUndefined(); expect(error).toHaveBeenCalledTimes(2); - error.mockRestore(); + } finally { + error.mockRestore(); + } }); it("shares concurrent loadProject calls for equivalent paths", async () => { diff --git a/src/sidebar/stores/project.ts b/src/sidebar/stores/project.ts index 2c6aaba49..598660e1d 100644 --- a/src/sidebar/stores/project.ts +++ b/src/sidebar/stores/project.ts @@ -80,9 +80,10 @@ async function refreshTaskSnapshot(path: string): Promise { taskRequests.set(key, request); setTaskSnapshots(prev => { const next = new Map(prev); next.delete(key); return next; }); const current = () => { + if (generation !== taskGeneration || taskRequests.get(key) !== request || + !loadedTaskRooms().has(key)) return false; const now = getTransportConnectionState(); - return generation === taskGeneration && taskRequests.get(key) === request && - loadedTaskRooms().has(key) && now.state === "connected" && now.generation === connection.generation; + return now.state === "connected" && now.generation === connection.generation; }; try { const snapshot = await TaskAPI.getSnapshotAt(root); From bd7384fa8e2b4532d45696be08e72e2d3f9d45ba Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Thu, 15 Oct 2026 14:15:00 -0300 Subject: [PATCH 3/4] fix(test): preserve runtime coverage when scanning source boundaries --- .../watchdog/context-menu-import-boundary.test.ts | 10 +++++----- src/sidebar/watchdog/no-alarm-import.test.ts | 10 +++++----- src/sidebar/watchdog/no-presentation-import.test.ts | 10 +++++----- vitest.config.ts | 1 + 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/sidebar/watchdog/context-menu-import-boundary.test.ts b/src/sidebar/watchdog/context-menu-import-boundary.test.ts index c2f3e45fe..d467e508e 100644 --- a/src/sidebar/watchdog/context-menu-import-boundary.test.ts +++ b/src/sidebar/watchdog/context-menu-import-boundary.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; // #1871 section 10.4 - the catalogue imports no store, no ipc, no presentation // module and no other component: two EXACT ALLOWLISTS over raw source, on the // model of no-presentation-import.test.ts. Raw source is what makes this immune @@ -21,11 +22,10 @@ import ts from "typescript"; import { describe, expect, it } from "vitest"; const SELF = "sidebar/watchdog/context-menu-import-boundary.test.ts"; -const SOURCES = import.meta.glob("../../**/*.{ts,tsx}", { - query: "?raw", - import: "default", - eager: true, -}); +const SOURCES = Object.fromEntries( + Object.keys(import.meta.glob("../../**/*.{ts,tsx}")) + .map(key => [key, readFileSync(new URL(key, import.meta.url), "utf8")]), +); const CONTEXT_MENU_DIR = "sidebar/components/context-menu/"; diff --git a/src/sidebar/watchdog/no-alarm-import.test.ts b/src/sidebar/watchdog/no-alarm-import.test.ts index 9bf884e43..cb205d429 100644 --- a/src/sidebar/watchdog/no-alarm-import.test.ts +++ b/src/sidebar/watchdog/no-alarm-import.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; // #2180 — the non-stop alarm must have exactly one importer. @@ -22,11 +23,10 @@ import { describe, expect, it } from "vitest"; const IMPORT_RE = /^\s*(?:import|export)\b[^;]*?\bfrom\s*["']([^"']+)["']/gm; const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*["']([^"']+)["']/g; const SELF = "sidebar/watchdog/no-alarm-import.test.ts"; -const SOURCES = import.meta.glob("../../**/*.{ts,tsx}", { - query: "?raw", - import: "default", - eager: true, -}); +const SOURCES = Object.fromEntries( + Object.keys(import.meta.glob("../../**/*.{ts,tsx}")) + .map(key => [key, readFileSync(new URL(key, import.meta.url), "utf8")]), +); const ALARM_IDENTIFIERS = [ "playNonStopAlarm", diff --git a/src/sidebar/watchdog/no-presentation-import.test.ts b/src/sidebar/watchdog/no-presentation-import.test.ts index 48c33f832..5c80cb7b3 100644 --- a/src/sidebar/watchdog/no-presentation-import.test.ts +++ b/src/sidebar/watchdog/no-presentation-import.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; type PresentationModule = "session-status" | "replica-dot"; @@ -5,11 +6,10 @@ type PresentationModule = "session-status" | "replica-dot"; const IMPORT_RE = /^\s*(?:import|export)\b[^;]*?\bfrom\s*["']([^"']+)["']/gm; const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*["']([^"']+)["']/g; const SELF = "sidebar/watchdog/no-presentation-import.test.ts"; -const SOURCES = import.meta.glob("../../**/*.{ts,tsx}", { - query: "?raw", - import: "default", - eager: true, -}); +const SOURCES = Object.fromEntries( + Object.keys(import.meta.glob("../../**/*.{ts,tsx}")) + .map(key => [key, readFileSync(new URL(key, import.meta.url), "utf8")]), +); const ALLOWED: Record = { "session-status": [ diff --git a/vitest.config.ts b/vitest.config.ts index f3aae9f4a..201b150b2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ 'src/sidebar/components/ac-discovery-fixture.ts', 'src/sidebar/components/loop-summary-fixture.ts', 'src/sidebar/components/restart-toast-harness.tsx', + 'src/sidebar/components/settings/agentReorderDnd.testkit.ts', ], reporter: ['html', 'json', 'json-summary', 'text', 'text-summary'], reportsDirectory: process.env.COVERAGE_OUTPUT || 'artifacts/coverage/frontend', From 9f819d3f1e90ff19cf924ca1b35ab83e9a5e78a5 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Thu, 15 Oct 2026 17:49:04 -0300 Subject: [PATCH 4/4] test: reuse loop automation interactions (#2962) --- .../ProjectPanel.loop-automation.test.tsx | 290 ++++++++---------- 1 file changed, 129 insertions(+), 161 deletions(-) diff --git a/src/sidebar/components/ProjectPanel.loop-automation.test.tsx b/src/sidebar/components/ProjectPanel.loop-automation.test.tsx index 82e41207b..e1548450a 100644 --- a/src/sidebar/components/ProjectPanel.loop-automation.test.tsx +++ b/src/sidebar/components/ProjectPanel.loop-automation.test.tsx @@ -150,6 +150,105 @@ async function actionFinished(root: HTMLElement, loopId: string) { }); } +async function openLoopDeleteDialog({ root, rowSelector, deleteActionSelector }: { + root: HTMLElement; + rowSelector: string; + deleteActionSelector: string; +}) { + const row = root.querySelector(rowSelector); + if (!(row instanceof HTMLElement)) { + throw new Error("Loop row not found"); + } + + contextMenu(row); + await waitFor(() => expect(document.body.querySelector(deleteActionSelector)).toBeTruthy()); + const deleteAction = document.body.querySelector(deleteActionSelector); + if (!(deleteAction instanceof HTMLButtonElement)) { + throw new Error("Loop delete action not found"); + } + click(deleteAction); +} + +async function openLoopDeleteConfirmation({ root, rowSelector, deleteActionSelector, confirmSelector }: { + root: HTMLElement; + rowSelector: string; + deleteActionSelector: string; + confirmSelector: string; +}) { + await openLoopDeleteDialog({ root, rowSelector, deleteActionSelector }); + + await waitFor(() => expect(document.body.querySelector(confirmSelector)).toBeTruthy()); + const confirmAction = document.body.querySelector(confirmSelector); + if (!(confirmAction instanceof HTMLButtonElement)) { + throw new Error("Loop delete confirm action not found"); + } + return confirmAction; +} + +function installDiscoverySequence({ fake, responses }: { + fake: FakeTransport; + responses: AsyncGenerator, void, unknown>; +}) { + fake.resolve("new_project", { + path: projectPath, + registered: true, + created: false, + }); + const counter = { calls: 0 }; + fake.onInvoke("discover_project", async () => { + counter.calls += 1; + const response = await responses.next(); + return response.value ?? discoveryWithLoop(enabledLoop()); + }); + return counter; +} + +async function enableLoopDuringStaleReload({ root, fake, loopId, discoveryCounter }: { + root: HTMLElement; + fake: FakeTransport; + loopId: string; + discoveryCounter: { calls: number }; +}) { + const projectId = automationIdPart(projectPath); + const encodedLoopId = automationIdPart(loopId); + const rowSelector = `[data-ac-testid="loop.row.${projectId}.${encodedLoopId}"]`; + const toggleSelector = `[data-ac-testid="loop.action.toggle.${projectId}.${encodedLoopId}"]`; + + await waitFor(() => { + expect(root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( + "loop-disabled", + ); + }); + + const staleReloadPromise = projectStore.reloadProject(projectPath); + await waitFor(() => expect(discoveryCounter.calls).toBe(2)); + + const row = root.querySelector(rowSelector); + if (!(row instanceof HTMLElement)) { + throw new Error("Loop row not found"); + } + contextMenu(row); + + await waitFor(() => { + expect(document.body.querySelector(toggleSelector)?.textContent?.trim()).toBe("Enable"); + }); + + const enableAction = document.body.querySelector(toggleSelector); + if (!(enableAction instanceof HTMLButtonElement)) { + throw new Error("Loop toggle action not found"); + } + click(enableAction); + + await waitFor(() => expect(fake.callsFor("toggle_loop")).toHaveLength(1)); + await waitFor(() => { + expect(root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( + "enabled", + ); + }); + + return { rowSelector, toggleSelector, staleReloadPromise }; +} + describe("ProjectPanel loop automation hooks", () => { let cleanupDom: (() => void) | null = null; @@ -230,18 +329,7 @@ describe("ProjectPanel loop automation hooks", () => { const cancelSelector = `[data-ac-testid="loop.delete.cancel.${projectId}.${loopId}"]`; await waitFor(() => expect(rendered.root.querySelector(rowSelector)).toBeTruthy()); - const row = rendered.root.querySelector(rowSelector); - if (!(row instanceof HTMLElement)) { - throw new Error("Loop row not found"); - } - - contextMenu(row); - await waitFor(() => expect(document.body.querySelector(deleteActionSelector)).toBeTruthy()); - const deleteAction = document.body.querySelector(deleteActionSelector); - if (!(deleteAction instanceof HTMLButtonElement)) { - throw new Error("Loop delete action not found"); - } - click(deleteAction); + await openLoopDeleteDialog({ root: rendered.root, rowSelector, deleteActionSelector }); await waitFor(() => expect(document.body.querySelector(cancelSelector)).toBeTruthy()); const cancelAction = document.body.querySelector(cancelSelector); @@ -295,24 +383,9 @@ describe("ProjectPanel loop automation hooks", () => { const confirmSelector = `[data-ac-testid="loop.delete.confirm.${projectId}.${loopId}"]`; await waitFor(() => expect(rendered.root.querySelector(rowSelector)).toBeTruthy()); - const row = rendered.root.querySelector(rowSelector); - if (!(row instanceof HTMLElement)) { - throw new Error("Loop row not found"); - } - - contextMenu(row); - await waitFor(() => expect(document.body.querySelector(deleteActionSelector)).toBeTruthy()); - const deleteAction = document.body.querySelector(deleteActionSelector); - if (!(deleteAction instanceof HTMLButtonElement)) { - throw new Error("Loop delete action not found"); - } - click(deleteAction); - - await waitFor(() => expect(document.body.querySelector(confirmSelector)).toBeTruthy()); - const confirmAction = document.body.querySelector(confirmSelector); - if (!(confirmAction instanceof HTMLButtonElement)) { - throw new Error("Loop delete confirm action not found"); - } + const confirmAction = await openLoopDeleteConfirmation({ + root: rendered.root, rowSelector, deleteActionSelector, confirmSelector, + }); const initialSnapshot = snapshots.current(); click(confirmAction); @@ -571,24 +644,9 @@ describe("ProjectPanel loop automation hooks", () => { const confirmSelector = `[data-ac-testid="loop.delete.confirm.${projectId}.${primaryLoopId}"]`; await waitFor(() => expect(rendered.root.querySelector(rowSelector)).toBeTruthy()); - const row = rendered.root.querySelector(rowSelector); - if (!(row instanceof HTMLElement)) { - throw new Error("Loop row not found"); - } - - contextMenu(row); - await waitFor(() => expect(document.body.querySelector(deleteActionSelector)).toBeTruthy()); - const deleteAction = document.body.querySelector(deleteActionSelector); - if (!(deleteAction instanceof HTMLButtonElement)) { - throw new Error("Loop delete action not found"); - } - click(deleteAction); - - await waitFor(() => expect(document.body.querySelector(confirmSelector)).toBeTruthy()); - const confirmAction = document.body.querySelector(confirmSelector); - if (!(confirmAction instanceof HTMLButtonElement)) { - throw new Error("Loop delete confirm action not found"); - } + const confirmAction = await openLoopDeleteConfirmation({ + root: rendered.root, rowSelector, deleteActionSelector, confirmSelector, + }); click(confirmAction); await waitFor(() => expect(fake.callsFor("delete_loop")).toHaveLength(1)); @@ -620,28 +678,17 @@ describe("ProjectPanel loop automation hooks", () => { it("does not regress enabled loop row while a queued reload follows a stale response", async () => { const fake = new FakeTransport(); const snapshots = taskFixture(fake); - let discoverCalls = 0; const staleReload = deferred(); const queuedReload = deferred(); - fake.resolve("new_project", { - path: projectPath, - registered: true, - created: false, - }); - fake.onInvoke("discover_project", async () => { - discoverCalls += 1; - if (discoverCalls === 1) return discoveryWithLoop(disabledLoop()); - if (discoverCalls === 2) { - await staleReload.promise; - return discoveryWithLoop(disabledLoop()); - } - if (discoverCalls === 3) { - await queuedReload.promise; - return discoveryWithLoop(enabledLoop()); - } - return discoveryWithLoop(enabledLoop()); - }); + async function* responses() { + yield discoveryWithLoop(disabledLoop()); + await staleReload.promise; + yield discoveryWithLoop(disabledLoop()); + await queuedReload.promise; + yield discoveryWithLoop(enabledLoop()); + } + const discoveryCounter = installDiscoverySequence({ fake, responses: responses() }); fake.onInvoke("toggle_loop", (args) => { expect(args).toEqual({ projectPath, @@ -659,46 +706,12 @@ describe("ProjectPanel loop automation hooks", () => { await projectStore.createAndLoad(projectPath); await snapshots.applied(); - const projectId = automationIdPart(projectPath); - const loopId = automationIdPart("weekday-standup"); - const rowSelector = `[data-ac-testid="loop.row.${projectId}.${loopId}"]`; - const toggleSelector = `[data-ac-testid="loop.action.toggle.${projectId}.${loopId}"]`; - - await waitFor(() => { - expect(rendered.root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( - "loop-disabled", - ); - }); - - const staleReloadPromise = projectStore.reloadProject(projectPath); - await waitFor(() => expect(discoverCalls).toBe(2)); - - const row = rendered.root.querySelector(rowSelector); - if (!(row instanceof HTMLElement)) { - throw new Error("Loop row not found"); - } - contextMenu(row); - - await waitFor(() => { - expect(document.body.querySelector(toggleSelector)?.textContent?.trim()).toBe("Enable"); - }); - - const enableAction = document.body.querySelector(toggleSelector); - if (!(enableAction instanceof HTMLButtonElement)) { - throw new Error("Loop toggle action not found"); - } - click(enableAction); - - await waitFor(() => expect(fake.callsFor("toggle_loop")).toHaveLength(1)); - await waitFor(() => { - expect(rendered.root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( - "enabled", - ); + const { rowSelector, toggleSelector, staleReloadPromise } = await enableLoopDuringStaleReload({ + root: rendered.root, fake, loopId: "weekday-standup", discoveryCounter, }); - const beforeQueuedReload = snapshots.current(); staleReload.resolve(); - await waitFor(() => expect(discoverCalls).toBe(3)); + await waitFor(() => expect(discoveryCounter.calls).toBe(3)); expect(rendered.root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( "enabled", @@ -710,7 +723,7 @@ describe("ProjectPanel loop automation hooks", () => { await actionFinished(rendered.root, "weekday-standup"); await waitFor(() => { - expect(discoverCalls).toBeGreaterThanOrEqual(3); + expect(discoveryCounter.calls).toBeGreaterThanOrEqual(3); expect(rendered.root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( "enabled", ); @@ -740,28 +753,17 @@ describe("ProjectPanel loop automation hooks", () => { const fake = new FakeTransport(); const snapshots = taskFixture(fake); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - let discoverCalls = 0; const staleReload = deferred(); const queuedReload = deferred(); - fake.resolve("new_project", { - path: projectPath, - registered: true, - created: false, - }); - fake.onInvoke("discover_project", async () => { - discoverCalls += 1; - if (discoverCalls === 1) return discoveryWithLoop(disabledLoop()); - if (discoverCalls === 2) { - await staleReload.promise; - return discoveryWithLoop(disabledLoop()); - } - if (discoverCalls === 3) { - await queuedReload.promise; - throw new Error("queued reload failed"); - } - return discoveryWithLoop(enabledLoop()); - }); + async function* responses() { + yield discoveryWithLoop(disabledLoop()); + await staleReload.promise; + yield discoveryWithLoop(disabledLoop()); + await queuedReload.promise; + throw new Error("queued reload failed"); + } + const discoveryCounter = installDiscoverySequence({ fake, responses: responses() }); fake.onInvoke("toggle_loop", () => ({ summary: enabledLoop(), promptBody: "Short preview", @@ -772,48 +774,14 @@ describe("ProjectPanel loop automation hooks", () => { await projectStore.createAndLoad(projectPath); await snapshots.applied(); - const projectId = automationIdPart(projectPath); - const loopId = automationIdPart("weekday-standup"); - const rowSelector = `[data-ac-testid="loop.row.${projectId}.${loopId}"]`; - const toggleSelector = `[data-ac-testid="loop.action.toggle.${projectId}.${loopId}"]`; - - await waitFor(() => { - expect(rendered.root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( - "loop-disabled", - ); + const { rowSelector, toggleSelector, staleReloadPromise } = await enableLoopDuringStaleReload({ + root: rendered.root, fake, loopId: "weekday-standup", discoveryCounter, }); - - const staleReloadPromise = projectStore.reloadProject(projectPath); - await waitFor(() => expect(discoverCalls).toBe(2)); - - const row = rendered.root.querySelector(rowSelector); - if (!(row instanceof HTMLElement)) { - throw new Error("Loop row not found"); - } - contextMenu(row); - - await waitFor(() => { - expect(document.body.querySelector(toggleSelector)?.textContent?.trim()).toBe("Enable"); - }); - - const enableAction = document.body.querySelector(toggleSelector); - if (!(enableAction instanceof HTMLButtonElement)) { - throw new Error("Loop toggle action not found"); - } - click(enableAction); - - await waitFor(() => expect(fake.callsFor("toggle_loop")).toHaveLength(1)); - await waitFor(() => { - expect(rendered.root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( - "enabled", - ); - }); - const beforeQueuedReload = snapshots.current(); expect(beforeQueuedReload).toBeDefined(); const snapshotCallsBeforeQueuedReload = fake.callsFor("task_get_snapshot_at").length; staleReload.resolve(); - await waitFor(() => expect(discoverCalls).toBe(3)); + await waitFor(() => expect(discoveryCounter.calls).toBe(3)); expect(rendered.root.querySelector(rowSelector)?.getAttribute("data-ac-state")).toBe( "enabled", );