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 in5d35b64bd("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 reflect4c31e885b("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:
11
.changeset/test-isolation-engine-active-detect.md
Normal file
11
.changeset/test-isolation-engine-active-detect.md
Normal 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.
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user