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:
@@ -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
|
||||
|
||||
@@ -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 & <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.
|
||||
|
||||
@@ -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, """)
|
||||
.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]) => `
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user