From a09263ae8cfb9a86198f423fa146fd830b9760a8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 06:00:11 -0700 Subject: [PATCH] =?UTF-8?q?test(dashboard):=20the=20mobile=20board=20needs?= =?UTF-8?q?=20RESOLVED=20lanes=20=E2=80=94=20U12/R9=20deleted=20the=20lega?= =?UTF-8?q?cy=20board=20(22=20=E2=86=92=200)=20(#2740)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `
` 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. --- ...e-toggle-blank.mobile-integration.test.tsx | 26 ++++++ .../auto-merge-toggle-blank.mobile.test.tsx | 84 +++++++++++++++---- .../__tests__/board-mobile.test.tsx | 15 +++- 3 files changed, 106 insertions(+), 19 deletions(-) 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('
'); }); });