diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx
index c34663ed1d..f6807d6a07 100644
--- a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx
+++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx
@@ -39,6 +39,32 @@ vi.mock("../../api", async (importOriginal) => {
return Promise.resolve({ ...mockSettings });
}),
fetchWorkflowSteps: vi.fn(() => Promise.resolve([])),
+ /*
+ FNXC:WorkflowColumns 2026-07-30-07:45:
+ The board needs a RESOLVED lane payload. This file builds its mock with
+ `createDashboardApiMock`, which SPREADS the real module, so `fetchBoardWorkflows` was the real
+ network call — it never resolves under jsdom, `boardWorkflows` stays null, and Board.tsx:874 holds
+ `BoardWorkflowSkeleton` ("Loading workflow lanes") with no lanes, task cards or Auto-merge toggle.
+ Overriding it with the default lifecycle lanes is what a real board sends.
+ */
+ fetchBoardWorkflows: vi.fn(() => Promise.resolve({
+ flagEnabled: true,
+ defaultWorkflowId: "builtin:coding",
+ workflows: [
+ {
+ id: "builtin:coding",
+ name: "Coding",
+ columns: [
+ { id: "todo", name: "Todo", flags: { intake: true, hold: true } },
+ { id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } },
+ { id: "in-review", name: "In Review", flags: { mergeBlocker: true, humanReview: true } },
+ { id: "done", name: "Done", flags: { complete: true } },
+ { id: "archived", name: "Archived", flags: { archived: true } },
+ ],
+ },
+ ],
+ taskWorkflowIds: {},
+ })),
fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskReview: vi.fn(() =>
Promise.resolve({
diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx
index 3160f12f0c..f29baf92b4 100644
--- a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx
+++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx
@@ -7,7 +7,40 @@ import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
import type { Task } from "@fusion/core";
vi.mock("../../api", () => ({
- fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
+ /*
+ FNXC:WorkflowColumns 2026-07-30-07:10:
+ The board needs a RESOLVED lane payload or it renders the skeleton forever. Board.tsx:874 shows
+ `BoardWorkflowSkeleton` whenever `boardWorkflows === null || boardWorkflows.workflows.length === 0`,
+ so an empty `workflows: []` leaves the board at `data-testid="board-workflows-skeleton"` with no
+ lanes, no task cards and no Auto-merge toggle — which is why every query here failed with
+ "Unable to find an accessible element".
+
+ This fixture passed `flagEnabled: false` to get the LEGACY board. U12/R9 deleted that path and
+ dropped the `flagEnabled` conjunct from the skeleton condition, so the flag now selects nothing and
+ the empty lane list is all that remains. These are the default lifecycle lanes a real board sends.
+
+ Inlined rather than referencing a module const: `vi.mock` factories are HOISTED above const
+ declarations, so a named payload above this block fails with "Cannot access before initialization"
+ and the file reports "no tests".
+ */
+ fetchBoardWorkflows: vi.fn().mockResolvedValue({
+ flagEnabled: true,
+ defaultWorkflowId: "builtin:coding",
+ workflows: [
+ {
+ id: "builtin:coding",
+ name: "Coding",
+ columns: [
+ { id: "todo", name: "Todo", flags: { intake: true, hold: true } },
+ { id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } },
+ { id: "in-review", name: "In Review", flags: { mergeBlocker: true, humanReview: true } },
+ { id: "done", name: "Done", flags: { complete: true } },
+ { id: "archived", name: "Archived", flags: { archived: true } },
+ ],
+ },
+ ],
+ taskWorkflowIds: {},
+ }),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
}));
@@ -202,6 +235,25 @@ function installAnimationFrame() {
vi.stubGlobal("cancelAnimationFrame", vi.fn());
}
+/*
+FNXC:WorkflowColumns 2026-07-30-07:30:
+Render, then FLUSH the board-workflows promise before asserting. `Board` resolves its lane payload
+asynchronously and shows `BoardWorkflowSkeleton` until it lands (Board.tsx:874), so a synchronous
+assertion right after `render()` always saw "Loading workflow lanes" and no lanes. These fixtures
+used to pass `flagEnabled: false` and get the LEGACY board, which rendered synchronously — U12/R9
+deleted that path, so the await is now required rather than optional.
+
+Wrapped in `act` so the resulting state update is applied before the queries run, and kept to a
+microtask flush (not a timer advance) because this suite runs on fake timers.
+*/
+async function renderBoardWithLanes(ui: React.ReactElement) {
+ const result = render(ui);
+ await act(async () => {
+ await Promise.resolve();
+ });
+ return result;
+}
+
function expectBoardVisible() {
expect(document.querySelector("main.board")).not.toBeNull();
expect(screen.getByText("In Review")).toBeInTheDocument();
@@ -219,7 +271,7 @@ describe("auto-merge toggle mobile blank regression", () => {
vi.unstubAllGlobals();
});
- it("keeps the mobile board visible after an Android viewport resize triggered by toggling auto-merge", () => {
+ it("keeps the mobile board visible after an Android viewport resize triggered by toggling auto-merge", async () => {
const viewportSpy = mockViewport(375);
const visualViewport = createVisualViewport(1);
Object.defineProperty(window, "visualViewport", {
@@ -228,7 +280,7 @@ describe("auto-merge toggle mobile blank regression", () => {
});
installAnimationFrame();
- render();
+ await renderBoardWithLanes();
const board = document.querySelector("main.board") as HTMLElement;
expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true");
@@ -261,7 +313,7 @@ describe("auto-merge toggle mobile blank regression", () => {
viewportSpy.mockRestore();
});
- it("round-trips auto-merge on mobile Android with an empty in-review column without blanking", () => {
+ it("round-trips auto-merge on mobile Android with an empty in-review column without blanking", async () => {
const viewportSpy = mockViewport(375);
const visualViewport = createVisualViewport(1);
Object.defineProperty(window, "visualViewport", {
@@ -270,7 +322,7 @@ describe("auto-merge toggle mobile blank regression", () => {
});
installAnimationFrame();
- render();
+ await renderBoardWithLanes();
const board = document.querySelector("main.board") as HTMLElement;
act(() => {
@@ -303,7 +355,7 @@ describe("auto-merge toggle mobile blank regression", () => {
viewportSpy.mockRestore();
});
- it("keeps populated task-card and worktree surfaces visible when auto-merge toggles on mobile", () => {
+ it("keeps populated task-card and worktree surfaces visible when auto-merge toggles on mobile", async () => {
const viewportSpy = mockViewport(375);
const visualViewport = createVisualViewport(1);
Object.defineProperty(window, "visualViewport", {
@@ -312,7 +364,7 @@ describe("auto-merge toggle mobile blank regression", () => {
});
installAnimationFrame();
- render(
+ await renderBoardWithLanes(
{
viewportSpy.mockRestore();
});
- it("re-anchors on the mobile iOS pageshow path after toggling auto-merge", () => {
+ it("re-anchors on the mobile iOS pageshow path after toggling auto-merge", async () => {
const viewportSpy = mockViewport(375);
Object.defineProperty(window, "visualViewport", {
configurable: true,
@@ -345,7 +397,7 @@ describe("auto-merge toggle mobile blank regression", () => {
});
installAnimationFrame();
- render();
+ await renderBoardWithLanes();
const board = document.querySelector("main.board") as HTMLElement;
act(() => {
@@ -367,11 +419,11 @@ describe("auto-merge toggle mobile blank regression", () => {
viewportSpy.mockRestore();
});
- it("keeps the board visible on tablet where the mobile stabilization effect is disabled", () => {
+ it("keeps the board visible on tablet where the mobile stabilization effect is disabled", async () => {
const viewportSpy = mockViewport(900);
installAnimationFrame();
- render();
+ await renderBoardWithLanes();
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
expect(toggle).toBeChecked();
@@ -384,11 +436,11 @@ describe("auto-merge toggle mobile blank regression", () => {
viewportSpy.mockRestore();
});
- it("keeps the board visible on desktop after toggling auto-merge", () => {
+ it("keeps the board visible on desktop after toggling auto-merge", async () => {
const viewportSpy = mockViewport(1280);
installAnimationFrame();
- render();
+ await renderBoardWithLanes();
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
@@ -406,7 +458,7 @@ describe("auto-merge toggle mobile blank regression", () => {
});
installAnimationFrame();
- render();
+ await renderBoardWithLanes();
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
expect(toggle).toBeChecked();
@@ -423,7 +475,7 @@ describe("auto-merge toggle mobile blank regression", () => {
viewportSpy.mockRestore();
});
- it("shows a visible page error boundary fallback instead of a blank board when a board child throws", () => {
+ it("shows a visible page error boundary fallback instead of a blank board when a board child throws", async () => {
const viewportSpy = mockViewport(375);
const visualViewport = createVisualViewport(1);
Object.defineProperty(window, "visualViewport", {
@@ -433,7 +485,7 @@ describe("auto-merge toggle mobile blank regression", () => {
installAnimationFrame();
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
- render();
+ await renderBoardWithLanes();
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
diff --git a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx
index a798bfe8b8..abba8ce35c 100644
--- a/packages/dashboard/app/components/__tests__/board-mobile.test.tsx
+++ b/packages/dashboard/app/components/__tests__/board-mobile.test.tsx
@@ -90,14 +90,23 @@ function getMainMobileSection(css: string): string {
}
describe("mobile board magnetic column snap wiring (FN-8235)", () => {
- it("shares the mobile scroll-end hook across legacy, selected, and aggregate live board renders", () => {
+ /*
+ FNXC:WorkflowColumns 2026-07-30-08:00:
+ Re-pinned to TWO board renders, not three. The third was the LEGACY board, deleted by U12/R9 —
+ `` no longer appears in Board.tsx at all, so
+ both the count of 3 and the `toContain` for that markup were pinning removed code. The two that
+ remain are the selected and aggregate workflow-column renders, and the point of this guard is that
+ BOTH share the one scroll-snap hook, which still holds.
+ */
+ it("shares the mobile scroll-end hook across the selected and aggregate live board renders", () => {
const boardSource = readAppFile("components/Board.tsx");
expect(boardSource).toContain('import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap";');
expect(boardSource).toContain("useColumnScrollSnap(boardElement, { mobileOnly: true });");
- expect(boardSource.match(/ref=\{setBoardRef\}/g)).toHaveLength(3);
+ expect(boardSource.match(/ref=\{setBoardRef\}/g)).toHaveLength(2);
expect(boardSource.match(/className="board board-workflow-columns"/g)).toHaveLength(2);
- expect(boardSource).toContain('');
+ // The legacy `` render is gone; assert it stays gone.
+ expect(boardSource).not.toContain('');
});
});