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:
@@ -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({
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
|
||||
@@ -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}>');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user