From e0af6ddd93aca6c461ab0bbad33c8783fff22044 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 10 Aug 2026 12:38:00 -0700 Subject: [PATCH] FN-8952: derive Quick Add Save smoke fixtures from locales Keep 300px Quick Add Save browser smoke coverage synchronized with shipped translations. - Derive board and list Save fixtures from supported locale catalogs. - Escape fixture labels and reject missing Save translations. - Add fast fixture parity, derivation, and escaping coverage. Files changed: docs/testing.md | 3 + .../__tests__/browser-layout-smoke-fixture.test.ts | 75 +++++++++++++- .../dashboard/scripts/browser-layout-smoke.mjs | 110 ++++++++++++++------- 3 files changed, 147 insertions(+), 41 deletions(-) Fusion-Task-Id: FN-8952 Fusion-Task-Lineage: 31daec25-5d9a-43bb-9ccf-667702006e76 Co-authored-by: Fusion (runfusion.ai) --- docs/testing.md | 3 + .../browser-layout-smoke-fixture.test.ts | 75 +++++++++++- .../scripts/browser-layout-smoke.mjs | 110 ++++++++++++------ 3 files changed, 147 insertions(+), 41 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 2fa684f7ba..03467aa0fc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -204,6 +204,9 @@ Command Center responsive chart fixes need evidence beyond jsdom. Keep the jsdom The same required-browser lane also measures the Agents Overview fixture at 390×844 mobile and 1280×700 short-desktop viewports. It verifies the real Active Agents scroll owner overflows, reaches the final card after scrolling, preserves sibling Agents content, avoids horizontal page overflow, and leaves the metrics-only empty state unclipped. + +The Quick Add Save fixtures at the supported 300px minimum derive both their locale set and expected count from `SUPPORTED_LOCALES` plus each shipped `tasks.save` catalog entry; adding a locale needs no smoke-script edit. The fixture HTML escapes catalog labels to preserve React-equivalent text rendering, and `browser-layout-smoke-fixture.test.ts` uses an injection seam to guard locale derivation drift, missing translations, and escaping in the fast jsdom lane. + The shared mobile/tablet overflow-containment net lives at `packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx`. It covers board/kanban columns, task-detail modal shell, workflow/simple workflow editors, and Activity Log modal at mobile, tablet, and landscape-phone breakpoints. Run it directly when touching dashboard viewport containment or shared modal/workflow CSS: ```bash diff --git a/packages/dashboard/app/__tests__/browser-layout-smoke-fixture.test.ts b/packages/dashboard/app/__tests__/browser-layout-smoke-fixture.test.ts index 7132df008d..ceeb6b9805 100644 --- a/packages/dashboard/app/__tests__/browser-layout-smoke-fixture.test.ts +++ b/packages/dashboard/app/__tests__/browser-layout-smoke-fixture.test.ts @@ -1,5 +1,33 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { SUPPORTED_LOCALES } from "@fusion/core"; import { describe, expect, it, vi } from "vitest"; -import { createSmokeHtml, prepareBrowserSmoke } from "../../scripts/browser-layout-smoke.mjs"; +import { + buildQuickAddSaveFixtures, + createSmokeHtml, + prepareBrowserSmoke, + QUICK_ADD_SAVE_FIXTURE_COUNT, +} from "../../scripts/browser-layout-smoke.mjs"; + +const i18nLocalesRoot = path.resolve(import.meta.dirname, "../../../i18n/locales"); +const shippedQuickAddSaveLabels = SUPPORTED_LOCALES.map((locale) => { + const catalog = JSON.parse(readFileSync(path.join(i18nLocalesRoot, locale, "app.json"), "utf8")); + return [locale, catalog.tasks.save] as const; +}); + +function quickAddFixtureIds(html: string) { + return [...html.matchAll(/data-smoke="quick-add-save-(?:board|list)-(?:minimum|wide)-([^"]+)"/g)]; +} + +function hasQuickAddFixtureParity(html: string) { + const ids = quickAddFixtureIds(html); + const locales = ids.map((match) => match[1]); + return ids.length === SUPPORTED_LOCALES.length * 4 + && new Set(locales).size === SUPPORTED_LOCALES.length + && [...SUPPORTED_LOCALES].every((locale) => locales.filter((candidate) => candidate === locale).length === 4) + && new Set(locales).size === new Set(SUPPORTED_LOCALES).size + && [...new Set(locales)].every((locale) => SUPPORTED_LOCALES.includes(locale as typeof SUPPORTED_LOCALES[number])); +} describe("browser layout smoke fixture", () => { /* @@ -173,8 +201,17 @@ describe("browser layout smoke fixture", () => { expect(html).toContain(' { + /* + FNXC:DashboardBrowserSmoke 2026-08-10-19:14: + FN-8952 keeps locale-derivation and escaping failures in this jsdom lane so they fail in seconds + instead of aborting Chromium after its expensive client build. Negative cases use the fixture + injection seam because a real shipped locale addition must correctly keep the derived guard green. + */ + it("derives localized Quick Add Save fixtures from every shipped locale", () => { const html = createSmokeHtml(); + const fixtureIds = quickAddFixtureIds(html); + const fixtureLocales = fixtureIds.map((match) => match[1]); + expect(html).toContain('data-smoke="quick-add-save-fixtures"'); expect(html).toContain('data-smoke="quick-add-save-board-minimum-fr"'); expect(html).toContain('data-smoke="quick-add-save-list-minimum-fr"'); @@ -182,12 +219,42 @@ describe("browser layout smoke fixture", () => { expect(html).toContain('data-smoke="quick-add-save-row"'); expect(html).toContain('data-smoke="quick-add-save-button"'); expect(html).toContain('data-testid="quick-entry-session-advisor-toggle"'); - expect(html.match(/data-testid="quick-entry-(?:attach|github-toggle|session-advisor-toggle|priority-button|fast-toggle)"/g)).toHaveLength(140); - for (const label of ["Save", "Guardar", "Enregistrer", "저장", "保存", "儲存"]) { + expect(fixtureIds).toHaveLength(SUPPORTED_LOCALES.length * 4); + expect(QUICK_ADD_SAVE_FIXTURE_COUNT).toBe(SUPPORTED_LOCALES.length * 4); + expect(new Set(fixtureLocales)).toEqual(new Set(SUPPORTED_LOCALES)); + for (const locale of SUPPORTED_LOCALES) { + expect(fixtureLocales.filter((candidate) => candidate === locale)).toHaveLength(4); + } + expect(new Set(fixtureIds.map((match) => match[0]))).toHaveLength(fixtureIds.length); + expect(html.match(/data-testid="quick-entry-(?:attach|github-toggle|session-advisor-toggle|priority-button|fast-toggle)"/g)) + .toHaveLength(QUICK_ADD_SAVE_FIXTURE_COUNT * 5); + for (const [, label] of shippedQuickAddSaveLabels) { expect(html).toContain(label); } }); + it("detects injected Quick Add locale derivation drift", () => { + expect(hasQuickAddFixtureParity(buildQuickAddSaveFixtures(shippedQuickAddSaveLabels.slice(0, -1)))).toBe(false); + expect(hasQuickAddFixtureParity(buildQuickAddSaveFixtures([...shippedQuickAddSaveLabels, ["synthetic", "Synthetic"]]))).toBe(false); + }); + + it("escapes injected Quick Add labels as React-equivalent text", () => { + const label = 'Save & > "quoted"'; + const fixtures = buildQuickAddSaveFixtures([["synthetic", label]]); + const buttonMarkup = fixtures.match(/]*data-locale="synthetic"[^>]*>.*?<\/button>/)?.[0]; + const container = document.createElement("div"); + container.innerHTML = fixtures; + + expect(buttonMarkup).toContain("Save & <measure> > "quoted""); + expect(buttonMarkup).not.toContain(label); + expect(container.querySelector('[data-locale="synthetic"]')?.textContent).toBe(label); + }); + + it("fails loudly when an injected Quick Add translation is missing", () => { + expect(() => buildQuickAddSaveFixtures([["en", ""]])).toThrow(/non-empty tasks\.save translation/); + expect(() => buildQuickAddSaveFixtures([["en", undefined] as unknown as [string, string]])).toThrow(/non-empty tasks\.save translation/); + }); + /* FNXC:ListView 2026-08-03-07:00: The mobile List smoke fixture must carry the production list-view--single-pane marker without coupling the regression to HTML attribute order or spacing. diff --git a/packages/dashboard/scripts/browser-layout-smoke.mjs b/packages/dashboard/scripts/browser-layout-smoke.mjs index 4e6fd11ab7..d0cb6e3fa2 100644 --- a/packages/dashboard/scripts/browser-layout-smoke.mjs +++ b/packages/dashboard/scripts/browser-layout-smoke.mjs @@ -4,9 +4,9 @@ import { Buffer } from "node:buffer"; import { spawn } from "node:child_process"; import { createServer } from "node:http"; -import { superviseSpawn } from "@fusion/core"; +import { superviseSpawn, SUPPORTED_LOCALES } from "@fusion/core"; import { readFile, readdir, rm, stat, mkdtemp, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url"; const dashboardRoot = path.resolve(import.meta.dirname, ".."); const appRoot = path.join(dashboardRoot, "app"); const clientDistRoot = path.join(dashboardRoot, "dist", "client"); +const i18nLocalesRoot = path.resolve(dashboardRoot, "..", "i18n", "locales"); const requireBrowser = process.argv.includes("--require-browser") || process.env.FUSION_BROWSER_SMOKE_REQUIRE === "1"; const screenshotPath = process.env.FUSION_BROWSER_SMOKE_SCREENSHOT; const agentHeartbeatMobileScreenshotPath = process.env.FUSION_AGENT_HEARTBEAT_MOBILE_SCREENSHOT; @@ -38,6 +39,62 @@ function fail(message) { throw new Error(message); } +function escapeHtml(value) { + return value.replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function validateQuickAddSaveLabels(labels) { + for (const [locale, label] of labels) { + if (typeof locale !== "string" || locale.length === 0 || typeof label !== "string" || label.trim().length === 0) { + fail(`Quick Add Save fixture requires a non-empty tasks.save translation for locale ${String(locale)}.`); + } + } + return labels; +} + +function loadShippedQuickAddSaveLabels() { + return validateQuickAddSaveLabels(SUPPORTED_LOCALES + .map((locale) => { + const catalog = JSON.parse(readFileSync(path.join(i18nLocalesRoot, locale, "app.json"), "utf8")); + return [locale, catalog?.tasks?.save]; + }) + // Preserve the emitted fixture section's existing deterministic order while deriving its members. + .sort(([left], [right]) => left.localeCompare(right))); +} + +const QUICK_ADD_COMPOSER_VARIANTS = [ + ["board", "", "minimum", "300px", "disabled"], + ["board", "", "wide", "600px", "disabled"], + ["list", "quick-entry--single-line", "minimum", "300px", "enabled"], + ["list", "quick-entry--single-line", "wide", "600px", "enabled"], +]; + +const shippedQuickAddSaveLabels = loadShippedQuickAddSaveLabels(); +export const QUICK_ADD_SAVE_FIXTURE_COUNT = QUICK_ADD_COMPOSER_VARIANTS.length * shippedQuickAddSaveLabels.length; + +export function buildQuickAddSaveFixtures(labels = shippedQuickAddSaveLabels) { + return QUICK_ADD_COMPOSER_VARIANTS.flatMap(([surface, modifier, width, maxWidth, state]) => validateQuickAddSaveLabels(labels).map(([locale, label]) => ` +
+
+
+
+ + + + + + +
+
+
+ + `)).join(""); +} + /* FNXC:GitHubImport 2026-07-23-13:05: The mobile GitHub action-bar regression measures emitted CSS, so every smoke invocation must @@ -98,7 +155,7 @@ function runCommand(command, commandArgs, cwd) { }); } -export function createSmokeHtml() { +export function createSmokeHtml(options = {}) { const columns = [ ["triage", "Triage", "1"], ["todo", "Todo", "2"], @@ -155,37 +212,14 @@ export function createSmokeHtml() { pt-BR joined the supported translations (the scaffold was previously empty and now carries machine-drafted translations) — add it here so the smoke keeps measuring the widest emitted-font label across every shipped locale. + + FNXC:QuickAddActionRow 2026-08-10-19:11: + FN-8952 replaces the stale 24-fixture expectation desynchronized by pt-BR with fixtures and + counts derived from SUPPORTED_LOCALES plus each shipped tasks.save catalog value. Catalog text + is HTML-escaped before interpolation because QuickEntryBox renders a React text child: arbitrary + metacharacters must remain literal measured glyphs, never become fixture markup. */ - const localizedSaveLabels = [ - ["en", "Save"], - ["es", "Guardar"], - ["fr", "Enregistrer"], - ["ko", "저장"], - ["pt-BR", "Salvar"], - ["zh-CN", "保存"], - ["zh-TW", "儲存"], - ]; - const quickAddComposerFixtures = [ - ["board", "", "minimum", "300px", "disabled"], - ["board", "", "wide", "600px", "disabled"], - ["list", "quick-entry--single-line", "minimum", "300px", "enabled"], - ["list", "quick-entry--single-line", "wide", "600px", "enabled"], - ].flatMap(([surface, modifier, width, maxWidth, state]) => localizedSaveLabels.map(([locale, label]) => ` -
-
-
-
- - - - - - -
-
-
-
- `)).join(""); + const quickAddComposerFixtures = buildQuickAddSaveFixtures(options.quickAddSaveLabels); /* FNXC:TaskDetailModalResponsive 2026-07-19-12:00: @@ -1959,8 +1993,9 @@ async function runSmokeChecks(page, pageUrl) { .map((layout) => layout.saveWidth)); assertSmokeResult( "Quick Add localized Save labels fit at the 300px supported minimum on mobile", - frenchMobileWidth === widestMobileWidth - && mobileQuickAddSaveLayout.length === 24 + Number.isFinite(frenchMobileWidth) + && frenchMobileWidth === widestMobileWidth + && mobileQuickAddSaveLayout.length === QUICK_ADD_SAVE_FIXTURE_COUNT && mobileQuickAddSaveLayout.every((layout) => layout.saveOverflow <= 1 && layout.rowOverflow <= 1 && layout.composerOverflow <= 1 @@ -2022,8 +2057,9 @@ async function runSmokeChecks(page, pageUrl) { .map((layout) => layout.saveWidth)); assertSmokeResult( "Quick Add localized Save labels fit at the 300px supported minimum on desktop", - frenchDesktopWidth === widestDesktopWidth - && desktopQuickAddSaveLayout.length === 24 + Number.isFinite(frenchDesktopWidth) + && frenchDesktopWidth === widestDesktopWidth + && desktopQuickAddSaveLayout.length === QUICK_ADD_SAVE_FIXTURE_COUNT && desktopQuickAddSaveLayout.every((layout) => layout.saveOverflow <= 1 && layout.rowOverflow <= 1 && layout.composerOverflow <= 1