test(dashboard): the mobile board needs RESOLVED lanes — U12/R9 deleted the legacy board (22 → 0) (#2740)

## Root cause

`Board.tsx:874` renders `BoardWorkflowSkeleton` whenever `boardWorkflows
=== null || boardWorkflows.workflows.length === 0`.

All three files depended on the **legacy board**, which U12/R9 deleted —
along with the `flagEnabled` conjunct in that condition. So
`flagEnabled: false` now selects nothing, and the board sits at
`"Loading workflow lanes"` with no lanes, no task cards, and no
Auto-merge toggle.

Every failure surfaced as `Unable to find an accessible element with the
role "checkbox" and name "Auto-merge"` — which points at the query, not
at the board state. The `aria-label` in the error's role dump is what
gave it away.

## Three causes, in sequence — each only visible after fixing the one
before

| # | File | Cause |
|---|---|---|
| 1 | `auto-merge-toggle-blank.mobile` | mocked `workflows: []` →
empty-lane skeleton |
| 2 | `auto-merge-toggle-blank.mobile-integration` | builds its mock
with `createDashboardApiMock`, which **spreads the real module** — so
`fetchBoardWorkflows` was the real network call, never resolving under
jsdom |
| 3 | both | even with a payload, lanes arrive on a **promise** while
these synchronous tests assert immediately after `render()` |

(3) is the one worth remembering: the legacy board rendered
**synchronously**, so no await was ever needed. `renderBoardWithLanes`
flushes a microtask inside `act` — a microtask and not a timer advance,
because this suite runs on fake timers.

## Also re-pinned: a source-scan guard pinning deleted code

`board-mobile` asserted `ref={setBoardRef}` appears **3** times across
"legacy, selected, and aggregate" renders. The legacy `<main
className="board" id="board" ref={setBoardRef}>` render has **0**
occurrences now, so both the count *and* its `toContain` were pinning
removed markup — the second would have failed as soon as the first was
fixed.

Now **2**, with the legacy markup asserted **absent** so it stays gone,
and the test renamed to the two renders that exist. The guard's real
point — that both share one scroll-snap hook — still holds.

## Measured

| Check | Result |
|---|---|
| `components-a` group | 22 failed → **0** (51 files, **1195 passed**) |
| lane payload emptied again | **all 8** cases in the first file fail —
load-bearing |
| `pnpm lint`, dashboard app `tsc` | clean |

## One wrong turn, recorded

My first attempt declared the payload as a module `const` and referenced
it from the `vi.mock` factory. Factories are **hoisted above const
declarations**, and the file reported `"no tests"` rather than a
hoisting error — a failure mode that reads as a collection problem, not
a reference problem. Inlined instead.

## Dashboard status

With #2735 (50 → 1) and this (22 → 0), dashboard goes from 88 failures
across 9 files to **17 across 6**. The remainder are unrelated: CSS
`toHaveClass` / computed-colour assertions, a missing
`wf-add-step-modal` testid, and the one GitHub-tracking affordance
question flagged in #2735.

Per #2732 these lanes are currently **never executed in CI** — the shard
aborts on the first failing package — so this is cleared ahead of them
starting to run.
This commit is contained in:
gsxdsm
2026-07-30 06:00:11 -07:00
committed by GitHub
parent e9d7945f71
commit a09263ae8c
3 changed files with 106 additions and 19 deletions

View File

@@ -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({

View File

@@ -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(<BoardHarness tasks={[createTask("FN-5936", "in-review")]} />);
await renderBoardWithLanes(<BoardHarness tasks={[createTask("FN-5936", "in-review")]} />);
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(<BoardHarness tasks={[]} />);
await renderBoardWithLanes(<BoardHarness tasks={[]} />);
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(
<BoardHarness
showWorktreeGrouping
tasks={[
@@ -337,7 +389,7 @@ describe("auto-merge toggle mobile blank regression", () => {
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(<BoardHarness tasks={[createTask("FN-IOS", "in-review")]} />);
await renderBoardWithLanes(<BoardHarness tasks={[createTask("FN-IOS", "in-review")]} />);
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(<BoardHarness tasks={[createTask("FN-TABLET", "in-review")]} />);
await renderBoardWithLanes(<BoardHarness tasks={[createTask("FN-TABLET", "in-review")]} />);
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(<BoardHarness tasks={[createTask("FN-DESKTOP", "in-review")]} />);
await renderBoardWithLanes(<BoardHarness tasks={[createTask("FN-DESKTOP", "in-review")]} />);
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
@@ -406,7 +458,7 @@ describe("auto-merge toggle mobile blank regression", () => {
});
installAnimationFrame();
render(<RollbackBoardHarness tasks={[createTask("FN-ROLLBACK", "in-review")]} />);
await renderBoardWithLanes(<RollbackBoardHarness tasks={[createTask("FN-ROLLBACK", "in-review")]} />);
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(<BoardHarness tasks={[createTask("FN-ERROR", "in-review")]} />);
await renderBoardWithLanes(<BoardHarness tasks={[createTask("FN-ERROR", "in-review")]} />);
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));

View File

@@ -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 —
`<main className="board" id="board" ref={setBoardRef}>` 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('<main className="board" id="board" ref={setBoardRef}>');
// The legacy `<main className="board">` render is gone; assert it stays gone.
expect(boardSource).not.toContain('<main className="board" id="board" ref={setBoardRef}>');
});
});