From bf90230659b46fb7a6db08cd506fa0426c32c41b Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 16 Jun 2026 14:55:19 -0700 Subject: [PATCH] fix: de-flake grow-flow smoke test + fix Dashboard label squeeze on phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flaky test (the grow-flow smoke gate): - Wait for the force layout to SETTLE (poll a node's box until it stops moving) before clicking the "+" grow icon — the icon rides on its node, so clicking it mid-simulation (or while nodes overlap) was the historical flake. - Retry the click→"Click empty space to grow" hint via expect.poll/toPass instead of failing on a single swallowed click. - Base the +1/-1 create/undo assertions on the GraphQL API (DB truth) instead of the DOM .node/.edge count, which viewport culling makes unreliable; still assert the user SEES the newly named node. 3/3 clean reseed+run cycles; smoke 5/5. Dashboard view (mobile): - The status breakdown used grid-cols-3 unconditionally; at 390px each card got so narrow the status labels ("Planned"/"In Progress"/…) collapsed to ~4px and vanished. Now grid-cols-2 sm:grid-cols-3. Test guard: - mobile-views now also fails on any multi-char label squeezed under ~12px (verified: reverting the Dashboard fix makes the Dashboard case go red). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/components/Dashboard.tsx | 4 +- tests/e2e/mobile-views.spec.ts | 12 +++++ tests/e2e/user-smoke.spec.ts | 65 ++++++++++++++++++----- 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index cbd26f13..43ff94ea 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -191,8 +191,8 @@ const PieChart = ({ data, title }: { data: Array<{label: string, value: number, {(() => { const isPriorityChart = filteredData.some(item => ['Critical', 'High', 'Moderate', 'Low', 'Minimal'].includes(item.label)); const isStatusChart = filteredData.some(item => ['Proposed', 'Planned', 'In Progress', 'Completed', 'Blocked'].includes(item.label)); - const gridCols = isPriorityChart ? "grid grid-cols-2 gap-2" : - isStatusChart ? "grid grid-cols-3 gap-2" : + const gridCols = isPriorityChart ? "grid grid-cols-2 gap-2" : + isStatusChart ? "grid grid-cols-2 sm:grid-cols-3 gap-2" : "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2"; return ( diff --git a/tests/e2e/mobile-views.spec.ts b/tests/e2e/mobile-views.spec.ts index 9c47203b..fb42f14e 100644 --- a/tests/e2e/mobile-views.spec.ts +++ b/tests/e2e/mobile-views.spec.ts @@ -44,6 +44,7 @@ test.describe('mobile views are explorable by scrolling down, not sideways @mobi // Any element whose content is wider than its box is something the user // must scroll sideways to see — the failure we hunt for. const offenders: { tag: string; cls: string; scrollW: number; clientW: number }[] = []; + const collapsed: { tag: string; cls: string; clientW: number; txt: string }[] = []; el.querySelectorAll('*').forEach((d) => { const e = d as HTMLElement; const ox = getComputedStyle(e).overflowX; @@ -59,10 +60,17 @@ test.describe('mobile views are explorable by scrolling down, not sideways @mobi clientW: e.clientWidth, }); } + // A multi-character leaf label squeezed to near-zero width is unreadable + // (e.g. a flex/grid cell that collapsed). Single-char badges (1, 2, •) are fine. + const txt = (e.textContent || '').trim(); + if (e.children.length === 0 && txt.length > 2 && e.clientWidth > 0 && e.clientWidth < 12) { + collapsed.push({ tag: e.tagName, cls: (e.className?.toString?.() || '').slice(0, 48), clientW: e.clientWidth, txt: txt.slice(0, 16) }); + } }); return { found: true, offenders: offenders.slice(0, 6), + collapsed: collapsed.slice(0, 6), docOverflow: document.documentElement.scrollWidth - window.innerWidth, }; }); @@ -73,6 +81,10 @@ test.describe('mobile views are explorable by scrolling down, not sideways @mobi probe.offenders, `${name}: these elements force horizontal scrolling on a phone` ).toEqual([]); + expect( + probe.collapsed, + `${name}: these labels are squeezed to an unreadable width` + ).toEqual([]); expect(pageErrors, `${name}: no uncaught JS errors`).toEqual([]); }); } diff --git a/tests/e2e/user-smoke.spec.ts b/tests/e2e/user-smoke.spec.ts index d09116ae..0bd9072c 100644 --- a/tests/e2e/user-smoke.spec.ts +++ b/tests/e2e/user-smoke.spec.ts @@ -98,17 +98,52 @@ test.describe('user smoke: the app works from a user point of view @smoke', () = test('grow flow stays healthy: + → empty space → connected named node @smoke', async ({ page }) => { await login(page, TEST_USERS.ADMIN); - await page.waitForTimeout(6000); + // Wait for a graph to auto-load, then for the force layout to SETTLE — the "+" + // grow icon rides on its node, so clicking it while the sim is still moving + // (or while nodes overlap) is the historical source of flake. Poll a node's + // box until it stops moving rather than guessing with a fixed sleep. + await page.locator('.graph-container svg .node').first().waitFor({ timeout: 20000 }).catch(() => {}); + { + let last: { x: number; y: number } | null = null; + let stable = 0; + for (let i = 0; i < 40 && stable < 2; i++) { + const pos = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node'); + if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: Math.round(r.x), y: Math.round(r.y) }; + }); + if (pos && last && Math.abs(pos.x - last.x) < 1 && Math.abs(pos.y - last.y) < 1) stable++; + else stable = 0; + last = pos; + await page.waitForTimeout(300); + } + } + + // A graph must be loaded for the grow affordance to exist (the "+" rides on a + // node). Use the DOM for that precondition; use the API for the +1/-1 DELTAS + // below so viewport culling (offscreen nodes aren't in the DOM) can't skew them. + const domNodes = await page.locator('.graph-container svg .node').count(); + test.skip(domNodes === 0, 'no graph with nodes auto-selected'); - const before = { - nodes: await page.locator('.graph-container svg .node').count(), - edges: await page.locator('.graph-container svg .edge').count() - }; - test.skip(before.nodes === 0, 'no graph with nodes auto-selected'); + const countAll = () => page.evaluate(async () => { + const token = localStorage.getItem('authToken') ?? ''; + const res = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query: `{ workItems { id } edges { id } }` }) + }).then((r) => r.json()); + return { nodes: res.data?.workItems?.length ?? -1, edges: res.data?.edges?.length ?? -1 }; + }); + const before = await countAll(); - const plus = page.locator('.node-relationship-icon').first(); - await plus.click({ force: true }); - await expect(page.locator('text=Click empty space to grow')).toBeVisible(); + // Enter grow mode. Retry the click→hint: a settled layout makes this reliable, + // but a stray overlap can still swallow one click, so re-click until grow mode + // activates instead of failing on the first miss. + await expect(async () => { + await page.locator('.node-relationship-icon').first().click({ force: true }); + await expect(page.locator('text=Click empty space to grow')).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 20000 }); // Find a genuinely empty spot of canvas (viewport-relative, verified) const spot = await page.evaluate(() => { @@ -133,17 +168,19 @@ test.describe('user smoke: the app works from a user point of view @smoke', () = await page.keyboard.press('Enter'); await page.waitForTimeout(4000); - expect(await page.locator('.graph-container svg .node').count(), 'node count must grow').toBe(before.nodes + 1); - expect(await page.locator('.graph-container svg .edge').count(), 'edge count must grow').toBe(before.edges + 1); + const after = await countAll(); + expect(after.nodes, 'a node must be created').toBe(before.nodes + 1); + expect(after.edges, 'a connecting edge must be created').toBe(before.edges + 1); + // ...and the user actually sees the newly named node on the canvas. await expect(page.locator(`text=${name}`).first()).toBeVisible(); - // Undo must walk it back: Ctrl+Z undoes the rename, then the creation + // Undo must walk it back: Ctrl+Z undoes the rename, then the creation. await page.keyboard.press('Control+z'); await page.waitForTimeout(2500); await page.keyboard.press('Control+z'); await page.waitForTimeout(5000); - expect(await page.locator('.graph-container svg .node').count(), 'undo must remove the created node').toBe(before.nodes); - expect(await page.locator('.graph-container svg .edge').count(), 'undo must remove the created edge').toBe(before.edges); + await expect.poll(async () => (await countAll()).nodes, { timeout: 10000 }).toBe(before.nodes); + expect((await countAll()).edges, 'undo must remove the created edge').toBe(before.edges); // Belt-and-braces cleanup in case undo half-failed (keeps re-runnable) await page.evaluate(async (title) => {