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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 12:38:00 -07:00
parent 878db6dca7
commit e0af6ddd93
3 changed files with 147 additions and 41 deletions

View File

@@ -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.
<!-- FNXC:DashboardBrowserSmoke 2026-08-10-19:25: FN-8952 derives Quick Add Save smoke fixtures and their assertion count from shipped locales and tasks.save catalogs, so adding a locale cannot desynchronize the Chromium layout contract. -->
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

View File

@@ -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('<span class="provider-icon"><svg width="16" height="16"');
});
it("includes localized Quick Add Save fixtures for Board and List composers", () => {
/*
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 & <measure> > "quoted"';
const fixtures = buildQuickAddSaveFixtures([["synthetic", label]]);
const buttonMarkup = fixtures.match(/<button[^>]*data-locale="synthetic"[^>]*>.*?<\/button>/)?.[0];
const container = document.createElement("div");
container.innerHTML = fixtures;
expect(buttonMarkup).toContain("Save &amp; &lt;measure&gt; &gt; &quot;quoted&quot;");
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.

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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]) => `
<section class="quick-entry-smoke-fixture" data-smoke="quick-add-save-${surface}-${width}-${locale}" style="width: min(${maxWidth}, calc(100vw - 24px)); margin: 0 auto 12px;">
<div class="quick-entry-box quick-entry-box--expanded ${modifier}" data-smoke="quick-add-${surface}-composer">
<div class="quick-entry-actions" data-smoke="quick-add-save-row">
<div class="quick-entry-primary-group">
<button class="btn btn-icon btn-sm" data-testid="quick-entry-attach" type="button" aria-label="Attach"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 7h8"/></svg></button>
<button class="btn btn-icon btn-sm" data-testid="quick-entry-github-toggle" type="button" aria-label="GitHub"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 7h8"/></svg></button>
<button class="btn btn-icon btn-sm" data-testid="quick-entry-session-advisor-toggle" type="button" aria-label="Session advisor"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7s2-3 5-3 5 3 5 3-2 3-5 3-5-3-5-3Z"/></svg></button>
<button class="btn btn-icon btn-sm" data-testid="quick-entry-priority-button" type="button" aria-label="Priority"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 7h8"/></svg></button>
<button class="btn btn-icon btn-sm" data-testid="quick-entry-fast-toggle" type="button" aria-label="Fast"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 7h8"/></svg></button>
<button class="btn btn-task-create btn-sm" data-testid="quick-entry-save" data-smoke="quick-add-save-button" data-locale="${escapeHtml(locale)}" type="button" ${state === "disabled" ? "disabled" : ""}><svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><path d="M2 6h8"/></svg>${escapeHtml(label)}</button>
</div>
</div>
</div>
</section>
`)).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]) => `
<section class="quick-entry-smoke-fixture" data-smoke="quick-add-save-${surface}-${width}-${locale}" style="width: min(${maxWidth}, calc(100vw - 24px)); margin: 0 auto 12px;">
<div class="quick-entry-box quick-entry-box--expanded ${modifier}" data-smoke="quick-add-${surface}-composer">
<div class="quick-entry-actions" data-smoke="quick-add-save-row">
<div class="quick-entry-primary-group">
<button class="btn btn-icon btn-sm" data-testid="quick-entry-attach" type="button" aria-label="Attach"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 7h8"/></svg></button>
<button class="btn btn-icon btn-sm" data-testid="quick-entry-github-toggle" type="button" aria-label="GitHub"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 7h8"/></svg></button>
<button class="btn btn-icon btn-sm" data-testid="quick-entry-session-advisor-toggle" type="button" aria-label="Session advisor"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7s2-3 5-3 5 3 5 3-2 3-5 3-5-3-5-3Z"/></svg></button>
<button class="btn btn-icon btn-sm" data-testid="quick-entry-priority-button" type="button" aria-label="Priority"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 7h8"/></svg></button>
<button class="btn btn-icon btn-sm" data-testid="quick-entry-fast-toggle" type="button" aria-label="Fast"><svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M3 7h8"/></svg></button>
<button class="btn btn-task-create btn-sm" data-testid="quick-entry-save" data-smoke="quick-add-save-button" data-locale="${locale}" type="button" ${state === "disabled" ? "disabled" : ""}><svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><path d="M2 6h8"/></svg>${label}</button>
</div>
</div>
</div>
</section>
`)).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