From e70a703540441e366c46fa14626902d492a52a60 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 10:37:12 +0100 Subject: [PATCH 1/2] ci: diagnose CI-only slash-menu test flakes This is a temporary diagnostic change to be reverted once the underlying race in the slash-menu suite is identified and fixed (see #1004). Two changes, both narrowly targeted: 1. Upload vitest browser-mode failure screenshots (and any other __screenshots__ contents) as a Browser Tests job artifact when the job fails. Retention is 1 day; the workflow only emits the artifact on failure. This gives us the actual rendered DOM state at the moment the test failed, which is invisible from CI logs today. 2. Wrap the 5 historically-failing slash-menu vi.waitFor blocks in a diagWaitFor helper that, on timeout, dumps menu DOM state to console.log before re-throwing -- menu presence, items.length, data-index per button, full class list per button, document.activeElement, and the first 2000 chars of menu.outerHTML. On success it is a straight passthrough to vi.waitFor, so passing runs are unchanged. Locally verified by forcing a failure that the dump fires and contains the expected fields; all 23 tests pass with no diag output on a clean run. The failure mode is CI-only (Linux Chromium); we cannot reproduce on Mac even at 40x CPU throttle via CDP setCPUThrottlingRate. Revert both changes once #1004 follow-up lands. --- .github/workflows/ci.yml | 12 ++++ .../admin/tests/editor/slash-menu.test.tsx | 66 +++++++++++++++++-- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a401f5f2..1692786cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,6 +204,18 @@ jobs: - run: pnpm exec playwright install --with-deps chromium if: steps.playwright-cache.outputs.cache-hit != 'true' - run: pnpm run --filter @emdash-cms/admin test + # Upload vitest browser-mode failure screenshots so we can diagnose + # CI-only flakes (e.g. the recurring slash-menu race). Only runs on + # failure, only uploads the screenshot directories. + - name: Upload failure screenshots + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: browser-test-screenshots + path: | + packages/admin/tests/**/__screenshots__/** + if-no-files-found: ignore + retention-days: 1 test-e2e-rollup: name: E2E Tests diff --git a/packages/admin/tests/editor/slash-menu.test.tsx b/packages/admin/tests/editor/slash-menu.test.tsx index c589218cd..2aeef3d9c 100644 --- a/packages/admin/tests/editor/slash-menu.test.tsx +++ b/packages/admin/tests/editor/slash-menu.test.tsx @@ -189,6 +189,59 @@ function isItemSelected(el: HTMLElement): boolean { return el.className.split(WHITESPACE_SPLIT_REGEX).includes("bg-kumo-tint"); } +/** + * DIAGNOSTIC (temporary, see #1004 follow-up): dump everything useful about + * the slash menu state to console.log. Called from a catch block when + * vi.waitFor times out, so the failing CI log contains the actual menu + * contents and DOM state at the moment of failure. Remove once the + * underlying race is understood and fixed. + */ +function dumpMenuState(label: string): void { + const menu = getSlashMenu(); + const lines: string[] = [ + `[slash-menu diag] === ${label} ===`, + `[slash-menu diag] menu present: ${menu !== null}`, + `[slash-menu diag] activeElement: ${document.activeElement?.tagName} (class=${document.activeElement?.className?.slice(0, 80)})`, + `[slash-menu diag] body > div count: ${document.querySelectorAll("body > div").length}`, + ]; + if (menu) { + const items = getSlashMenuItems(menu); + lines.push(`[slash-menu diag] items rendered: ${items.length}`); + lines.push(`[slash-menu diag] menu textContent length: ${menu.textContent?.length ?? 0}`); + lines.push(`[slash-menu diag] menu first 200 chars: ${menu.textContent?.slice(0, 200)}`); + items.forEach((el, i) => { + const dataIndex = el.getAttribute("data-index"); + const selected = isItemSelected(el); + const classes = el.className; + lines.push( + `[slash-menu diag] item ${i} (data-index=${dataIndex}) selected=${selected} classes=${classes.slice(0, 200)}`, + ); + }); + // menu.outerHTML truncated to 2000 chars + lines.push(`[slash-menu diag] menu outerHTML: ${menu.outerHTML.slice(0, 2000)}`); + } + for (const line of lines) { + console.log(line); + } +} + +/** + * DIAGNOSTIC wrapper around vi.waitFor that dumps menu state on timeout. + * Same semantics as vi.waitFor; only adds logging on failure. + */ +async function diagWaitFor( + label: string, + predicate: () => T | Promise, + options?: { timeout?: number; interval?: number }, +): Promise { + try { + return await vi.waitFor(predicate, options); + } catch (err) { + dumpMenuState(label); + throw err; + } +} + // ============================================================================= // Slash Command Menu // ============================================================================= @@ -287,7 +340,8 @@ describe("Slash Command Menu", () => { await waitForSlashMenu(); - await vi.waitFor( + await diagWaitFor( + "highlights the first item by default", () => { const menu = getSlashMenu()!; const items = getSlashMenuItems(menu); @@ -305,7 +359,7 @@ describe("Slash Command Menu", () => { await userEvent.keyboard("{ArrowDown}"); - await vi.waitFor(() => { + await diagWaitFor("moves selection down with ArrowDown", () => { const menu = getSlashMenu()!; const items = getSlashMenuItems(menu); expect(isItemSelected(items[1]!)).toBe(true); @@ -321,13 +375,13 @@ describe("Slash Command Menu", () => { // Move down, then back up await userEvent.keyboard("{ArrowDown}"); - await vi.waitFor(() => { + await diagWaitFor("ArrowUp from second item: first ArrowDown", () => { const items = getSlashMenuItems(getSlashMenu()!); expect(isItemSelected(items[1]!)).toBe(true); }); await userEvent.keyboard("{ArrowUp}"); - await vi.waitFor(() => { + await diagWaitFor("ArrowUp from second item: back to first", () => { const items = getSlashMenuItems(getSlashMenu()!); expect(isItemSelected(items[0]!)).toBe(true); }); @@ -341,7 +395,7 @@ describe("Slash Command Menu", () => { await userEvent.keyboard("{ArrowUp}"); - await vi.waitFor(() => { + await diagWaitFor("wraps selection around (ArrowUp from first)", () => { const menu = getSlashMenu()!; const items = getSlashMenuItems(menu); const lastItem = items.at(-1)!; @@ -360,7 +414,7 @@ describe("Slash Command Menu", () => { await waitForSlashMenuClosed(); - await vi.waitFor(() => { + await diagWaitFor("Enter converts to heading: h1 should exist", () => { expect(pm.querySelector("h1")).toBeTruthy(); }); }); From 0951414b4640de899aa8a0edec076159f829c160 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 10:44:31 +0100 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/admin/tests/editor/slash-menu.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/admin/tests/editor/slash-menu.test.tsx b/packages/admin/tests/editor/slash-menu.test.tsx index 2aeef3d9c..94b068e89 100644 --- a/packages/admin/tests/editor/slash-menu.test.tsx +++ b/packages/admin/tests/editor/slash-menu.test.tsx @@ -220,9 +220,7 @@ function dumpMenuState(label: string): void { // menu.outerHTML truncated to 2000 chars lines.push(`[slash-menu diag] menu outerHTML: ${menu.outerHTML.slice(0, 2000)}`); } - for (const line of lines) { - console.log(line); - } + console.log(lines.join("\n")); } /**