chore(test-isolation): detect live engine lock + prune stale tests

Three coupled fixes to make `pnpm test:full` exit cleanly when the local
`fn` dashboard is running:

1. scripts/check-test-isolation.mjs — replace timing-based "is the
   engine writing?" heuristic with a deterministic check: if
   `.fusion/engine.lock.lock/` exists (proper-lockfile's held-lock
   marker), the dir is engine-active and auto-skipped from violation
   reporting. The 2-second mutability probe is retained as a backstop
   for dirs with another external writer but no live lock. Also adds
   `engine.lock` / `engine.lock.lock/` to RUNTIME_IGNORE_PATTERNS so
   a mid-test engine start/stop doesn't trip the signature compare.

2. packages/dashboard/.../__tests__/GitManagerModal.test.tsx — prune
   the Status-panel Sync button + Recent-advances-events describe
   blocks. Their UI was removed in 5d35b64bd ("remove duplicate
   integration-advances UI") but the tests stayed and were timing
   out at 1s each. The Remotes-panel Sync describe is kept because
   the `remotes-sync-integration-tip-btn` still exists.

3. packages/engine/.../merge-reuse-task-worktree.slow.test.ts —
   update the happy-path assertion to reflect 4c31e885b
   ("merger auto-syncs project-root checkout after ref advance").
   Before that change, the merger's `update-ref` advance left the
   project root's working tree stale, so `git status --porcelain`
   would differ after the merge. With auto-sync, the new file is
   tracked + clean at HEAD, so status doesn't change. Verify the
   file actually landed via `git ls-files` instead.

After this, `pnpm test:full` exits 0 with the local dashboard running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-23 20:18:08 -07:00
parent 0c0839eeb6
commit b8919b7bb4
4 changed files with 76 additions and 361 deletions

View File

@@ -0,0 +1,11 @@
---
"fusion-workspace": patch
---
chore(test-isolation): detect live engine via lock-held marker instead of timing probe
`scripts/check-test-isolation.mjs` previously relied on a 2-second post-test mutability probe to distinguish "tests wrote `.fusion/`" from "the local dashboard is running and writing `.fusion/`". When the engine's heartbeat happened to land outside the probe window, the script flagged the user's live engine as a test pollution violation and failed `pnpm test:full` with exit 1 despite every test suite passing.
Now the script first checks for `engine.lock.lock/` (the proper-lockfile directory the engine creates while holding the singleton lock). When present, the `.fusion` dir is auto-marked externally-active and skipped from violation reporting — race-free, no timing window. The mutability probe is retained as a backstop for dirs without a live lock but with another external writer.
Also added `engine.lock` and `engine.lock.lock/` to `RUNTIME_IGNORE_PATTERNS` so a mid-test engine start/stop doesn't trip the signature comparison on its own.

View File

@@ -2991,180 +2991,6 @@ describe("GitManagerModal", () => {
});
});
// ── Sync Integration Tip button ────────────────────────────────
describe("Sync local tip button (Status panel)", () => {
it("renders the Sync local tip button when integrationBranch is set", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
isOnIntegrationBranch: true,
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByTestId("sync-integration-tip-btn")).toBeInTheDocument();
});
});
it("the Sync local tip button is disabled when not on integration branch", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "feature/foo",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
isOnIntegrationBranch: false,
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
const btn = screen.getByTestId("sync-integration-tip-btn");
expect(btn).toBeDisabled();
});
});
it("the Sync local tip button is disabled when no integrationBranch", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
// no integrationBranch
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Repository Status")).toBeInTheDocument();
});
// Button should not render when there's no integrationBranch
expect(screen.queryByTestId("sync-integration-tip-btn")).not.toBeInTheDocument();
});
it("calls POST /api/git/pull with correct body when Sync local tip is clicked", async () => {
const user = userEvent.setup();
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
isOnIntegrationBranch: true,
});
(fetchConfig as any).mockResolvedValue({ maxConcurrent: 4, rootDir: "/my/project" });
(api as any).mockResolvedValue({ kind: "pull-clean", toSha: "deadbeef" });
render(
<GitManagerModal
isOpen={true}
onClose={vi.fn()}
tasks={mockTasks}
addToast={mockAddToast}
projectId="proj-123"
/>
);
await waitFor(() => {
expect(screen.getByTestId("sync-integration-tip-btn")).toBeInTheDocument();
});
await user.click(screen.getByTestId("sync-integration-tip-btn"));
await waitFor(() => {
expect(api).toHaveBeenCalledWith(
expect.stringContaining("/git/pull"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
worktreePath: "/my/project",
integrationBranch: "main",
taskId: undefined,
}),
})
);
});
});
it("shows a success toast and refreshes status after sync succeeds", async () => {
const user = userEvent.setup();
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
isOnIntegrationBranch: true,
});
(fetchConfig as any).mockResolvedValue({ maxConcurrent: 4, rootDir: "/my/project" });
(api as any).mockResolvedValue({ kind: "pull-clean", toSha: "deadbeef" });
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByTestId("sync-integration-tip-btn")).toBeInTheDocument();
});
await user.click(screen.getByTestId("sync-integration-tip-btn"));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Synced worktree to integration tip", "success");
expect(fetchGitStatus).toHaveBeenCalledTimes(2); // initial load + post-sync refresh
});
});
it("shows error toast when sync fails", async () => {
const user = userEvent.setup();
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
isOnIntegrationBranch: true,
});
(fetchConfig as any).mockResolvedValue({ maxConcurrent: 4, rootDir: "/my/project" });
(api as any).mockRejectedValue(new Error("merge conflict"));
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByTestId("sync-integration-tip-btn")).toBeInTheDocument();
});
await user.click(screen.getByTestId("sync-integration-tip-btn"));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("merge conflict", "error");
});
});
});
describe("Sync local tip button (Remotes panel)", () => {
it("renders the Sync local tip button in the remotes panel when integrationBranch is set", async () => {
(fetchGitStatus as any).mockResolvedValue({
@@ -3212,170 +3038,6 @@ describe("GitManagerModal", () => {
});
});
// ── Recent integration advance events panel ─────────────────────
describe("Recent integration advance events panel", () => {
it("renders the events panel when there are succeeded events", async () => {
(api as any).mockResolvedValue({
events: [
{
taskId: "FN-101",
integrationBranch: "main",
toSha: "aabbccdd1234",
fromSha: "00112233",
advanceMode: "fast-forward",
succeeded: true,
advancedAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(), // 2 minutes ago
},
{
taskId: "FN-102",
integrationBranch: "main",
toSha: "eeff00112233",
fromSha: "aabbccdd1234",
advanceMode: "update-ref",
succeeded: true,
advancedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 minutes ago
},
],
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByTestId("recent-advance-events")).toBeInTheDocument();
});
expect(screen.getByText("FN-101")).toBeInTheDocument();
expect(screen.getByText("FN-102")).toBeInTheDocument();
// Short SHAs (first 8 chars)
expect(screen.getByText("aabbccdd")).toBeInTheDocument();
expect(screen.getByText("eeff0011")).toBeInTheDocument();
});
it("shows events in the order returned by the API (newest first)", async () => {
(api as any).mockResolvedValue({
events: [
{
taskId: "FN-200",
integrationBranch: "main",
toSha: "111111112222",
fromSha: null,
advanceMode: "fast-forward",
succeeded: true,
advancedAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
},
{
taskId: "FN-199",
integrationBranch: "main",
toSha: "222222223333",
fromSha: null,
advanceMode: "fast-forward",
succeeded: true,
advancedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
],
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByTestId("recent-advance-events")).toBeInTheDocument();
});
const items = screen.getAllByRole("listitem");
// FN-200 should appear before FN-199
const firstText = items[0].textContent ?? "";
const secondText = items[1].textContent ?? "";
expect(firstText).toContain("FN-200");
expect(secondText).toContain("FN-199");
});
it("hides the events panel when there are no events", async () => {
(api as any).mockResolvedValue({ events: [] });
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Repository Status")).toBeInTheDocument();
});
expect(screen.queryByTestId("recent-advance-events")).not.toBeInTheDocument();
});
it("hides the events panel when only failed events are returned", async () => {
(api as any).mockResolvedValue({
events: [
{
taskId: "FN-300",
integrationBranch: "main",
toSha: "deadbeef1234",
fromSha: null,
advanceMode: "fast-forward",
succeeded: false, // failed — should be filtered out
advancedAt: new Date().toISOString(),
},
],
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Repository Status")).toBeInTheDocument();
});
expect(screen.queryByTestId("recent-advance-events")).not.toBeInTheDocument();
});
it("refreshes events after a successful sync", async () => {
const user = userEvent.setup();
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
commit: "abc1234",
isDirty: false,
ahead: 0,
behind: 0,
integrationBranch: "main",
isOnIntegrationBranch: true,
});
(fetchConfig as any).mockResolvedValue({ maxConcurrent: 4, rootDir: "/project" });
let apiCallCount = 0;
(api as any).mockImplementation((path: string) => {
if (path.includes("/tasks/merge-advance-events")) {
apiCallCount++;
return Promise.resolve({ events: [] });
}
return Promise.resolve({ kind: "pull-clean", toSha: "abc" });
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByTestId("sync-integration-tip-btn")).toBeInTheDocument();
});
const callsBefore = apiCallCount;
await user.click(screen.getByTestId("sync-integration-tip-btn"));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Synced worktree to integration tip", "success");
});
// fetchMergeAdvanceEvents should have been called again after sync
expect(apiCallCount).toBeGreaterThan(callsBefore);
});
});
describe("CSS regression coverage", () => {
it("includes remotes layout selectors and mobile rules", () => {

View File

@@ -122,7 +122,6 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
try {
const rootHeadBefore = git(rootDir, "git rev-parse HEAD");
const rootTrackedStatusBefore = git(rootDir, "git status --porcelain --untracked-files=no");
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
@@ -141,9 +140,12 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
const advanced = audits.find((event) => event.mutationType === "merge:integration-ref-advance");
expect(advanced?.metadata).toMatchObject({ advanceMode: "update-ref", succeeded: true });
expect(git(rootDir, "git rev-parse HEAD")).not.toBe(rootHeadBefore);
const rootTrackedStatusAfter = git(rootDir, "git status --porcelain --untracked-files=no");
expect(rootTrackedStatusAfter).not.toBe(rootTrackedStatusBefore);
expect(rootTrackedStatusAfter).toContain("fn-5279-ri-happy.ts");
// 4c31e885b (engine auto-sync) keeps the project root's working tree
// in step with the advanced ref, so the new file is a tracked, clean
// path at HEAD rather than appearing as a dirty/untracked entry. Verify
// landing via `git ls-files` (commit-reachable) instead of `git status`.
const rootLsFilesAfter = git(rootDir, "git ls-files");
expect(rootLsFilesAfter).toContain("packages/engine/src/fn-5279-ri-happy.ts");
} finally {
await fixture.cleanup();
}

View File

@@ -99,12 +99,34 @@ const RUNTIME_IGNORE_PATTERNS = [
/^scripts\.json$/,
/^update-check\.json$/,
/^disabled-auto-extension-discovery$/,
// Engine singleton lock (proper-lockfile creates `engine.lock.lock/` while
// held; `engine.lock` is the sentinel file). Their entries appear/disappear
// as the local dashboard starts/stops, which is not test pollution.
/^engine\.lock$/,
/^engine\.lock\.lock(?:[/\\]|$)/,
];
function isRuntimePath(relPath) {
return RUNTIME_IGNORE_PATTERNS.some((re) => re.test(relPath));
}
// A .fusion dir is "engine-active" when an engine process currently holds the
// singleton lock. proper-lockfile materializes this as `engine.lock.lock/`
// being present alongside the `engine.lock` sentinel. This is a deterministic
// signal — no timing/probe required — so we use it to auto-skip live-engine
// dirs instead of relying on the post-run mutability burst landing inside
// our 2-second probe window.
function isFusionEngineActive(fusionDir) {
if (!existsSync(fusionDir)) return false;
try {
const lockHeldDir = join(fusionDir, "engine.lock.lock");
const stat = statSync(lockHeldDir);
return stat.isDirectory();
} catch {
return false;
}
}
function collectFusionSignature(rootDir, out = []) {
if (!existsSync(rootDir)) return out;
let stat;
@@ -160,6 +182,13 @@ function recordBaseline() {
const unstableProtectedDirs = [];
const firstProtected = samples[0];
for (const first of firstProtected) {
// Live engine lock present → auto-mark unstable. Same outcome as the
// mutability probe below would (eventually) reach, but deterministic and
// immune to write-cadence gaps.
if (isFusionEngineActive(first.dir)) {
unstableProtectedDirs.push(first.dir);
continue;
}
let unstable = false;
for (let i = 1; i < samples.length; i++) {
const current = samples[i].find((entry) => entry.dir === first.dir);
@@ -239,28 +268,39 @@ function checkAgainstBaseline() {
const protectedViolations = [];
if (candidateViolations.length > 0) {
// A live local app can write in bursts (e.g. heartbeat every few seconds),
// so do a short mutability probe before blaming tests.
const postSamples = [currentProtected];
for (let i = 0; i < 4; i++) {
sleepMs(500);
postSamples.push(snapshotProtectedFusion());
}
// First: skip any candidate dir where an engine is currently holding the
// singleton lock. This is the dominant local-dev case (`fn` dashboard
// running while tests are invoked) and the engine-lock signal is
// race-free, unlike the post-test mutability probe.
const remainingCandidates = candidateViolations.filter((dir) => !isFusionEngineActive(dir));
for (const dir of candidateViolations) {
let externallyActive = false;
for (let i = 1; i < postSamples.length; i++) {
const prev = postSamples[i - 1].find((entry) => entry.dir === dir);
const next = postSamples[i].find((entry) => entry.dir === dir);
if (!prev || !next) continue;
if (JSON.stringify(prev.entries) !== JSON.stringify(next.entries)) {
externallyActive = true;
break;
}
if (remainingCandidates.length > 0) {
// A live local app can write in bursts (e.g. heartbeat every few seconds),
// so do a short mutability probe before blaming tests. Retained as a
// backstop for dirs that don't have an engine lock but do have an
// external writer (e.g. an `fn` process that crashed mid-run and left
// the lock stale).
const postSamples = [currentProtected];
for (let i = 0; i < 4; i++) {
sleepMs(500);
postSamples.push(snapshotProtectedFusion());
}
if (!externallyActive) {
protectedViolations.push(dir);
for (const dir of remainingCandidates) {
let externallyActive = false;
for (let i = 1; i < postSamples.length; i++) {
const prev = postSamples[i - 1].find((entry) => entry.dir === dir);
const next = postSamples[i].find((entry) => entry.dir === dir);
if (!prev || !next) continue;
if (JSON.stringify(prev.entries) !== JSON.stringify(next.entries)) {
externallyActive = true;
break;
}
}
if (!externallyActive) {
protectedViolations.push(dir);
}
}
}
}