fix: add psql binary guard + delete expired quarantine tests (ratchet) (#2090)

## Summary

Follow-up to PR #2086 addressing two Greptile review findings.

## P2 — Missing `psql` binary guard (Greptile P2)

`hasPg` in `_helpers.ts` previously checked only TCP connectivity to
PostgreSQL. But `adminExecAsync()` shells out to the `psql` CLI for DDL
(`CREATE/DROP DATABASE`). On a runner where Postgres is reachable but
`psql` isn't installed, tests would fail with `spawn psql ENOENT`
instead of skipping cleanly.

**Fix**: Added `hasPsql = spawnSync("psql", ["--version"]).status === 0`
to the `hasPg` guard, so tests skip when either Postgres is unreachable
OR `psql` is missing.

## P1 — Expired quarantine entries (Greptile P1)

The 16 dashboard test files quarantined on 2026-06-25 were past the
14-day deletion ratchet (AGENTS.md: "DELETED after 14 days unless
rescued"). Per the ratchet, the test files were deleted and all
references removed:

- **Deleted 16 test files** (CSS drift, mock drift, mobile-render
regressions)
- **Removed 16 entries** from `scripts/lib/test-quarantine.json` (only
the CLI entry remains)
- **Emptied `quarantinedDashboardTests` array** in
`packages/dashboard/vitest.config.ts`

## Verification

| Check | Result |
|---|---|
| Merge gate (`pnpm test:gate`) | ✅ 294 + 99 + 63 = 456 passed |
| Dashboard curated-gate | ✅ passes (891 files, 892 executed, 1
skip-listed, 1 quarantined) |
| Typecheck (engine) | ✅ clean |
| Lint | ✅ exit 0 |

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
* Removed multiple outdated dashboard UI, CSS/token, theme contrast, and
API/route test suites.
* Updated dashboard test configuration to stop excluding quarantined
tests and to prune the quality shard to the current set.
* Updated the Vitest split/config guard to match the new test fixture
set.
* Improved PostgreSQL test detection by requiring the `psql` CLI before
running database checks.
* Adjusted quarantine tracking by adding a new CLI extension
distribution ledger entry and removing obsolete dashboard quarantine
entries.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-14 08:18:10 -07:00
committed by GitHub
parent d8f0b1a268
commit d7e072a03c
20 changed files with 19 additions and 5543 deletions

View File

@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
function extractMobileMediaBlocks(content: string): string {
const blocks: string[] = [];
const regex = /@media[^{]*\(max-width: 768px\)[^{]*\{/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const startIdx = match.index + match[0].length;
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < content.length) {
if (content[endIdx] === "{") braceCount += 1;
if (content[endIdx] === "}") braceCount -= 1;
endIdx += 1;
}
if (braceCount === 0) {
blocks.push(content.slice(startIdx, endIdx - 1));
}
}
return blocks.join("\n");
}
describe("chat tool-call mobile layout css", () => {
const css = loadAllAppCss();
const mobileCss = extractMobileMediaBlocks(css);
it("keeps full-chat grouped and single tool-call summaries on one row in mobile media blocks", () => {
const groupedSummaryRule = mobileCss.match(/\.chat-tool-calls-group-summary,\s*\n\s*\.chat-tool-call summary\s*\{[^}]*\}/m)?.[0] ?? "";
expect(groupedSummaryRule).toMatch(/flex-wrap:\s*nowrap/);
expect(groupedSummaryRule).toMatch(/flex-direction:\s*row/);
expect(groupedSummaryRule).toMatch(/align-items:\s*center/);
const nowrapRule = mobileCss.match(/\.chat-tool-calls-names,\s*\n\s*\.chat-tool-call-name,\s*\n\s*\.chat-tool-call-status-text,\s*\n\s*\.chat-tool-calls-group-status,\s*\n\s*\.chat-tool-calls-count\s*\{[^}]*\}/m)?.[0] ?? "";
expect(nowrapRule).toMatch(/white-space:\s*nowrap/);
// FNXC:ChatToolCalls 2026-06-25-13:15: Match any mobile rule whose selector list
// includes .chat-tool-calls-group-summary (grouped or standalone). The previously
// standalone quick-chat rule was removed when quick chat became the modal chat, so
// the invariant (no mobile group-summary rule may revert to flex-direction: column)
// must now be asserted against the grouped full-chat rule that actually exists.
const allMobileSummaryRules = [...mobileCss.matchAll(/\.chat-tool-calls-group-summary[^{}]*\{[^}]*\}/g)].map((m) => m[0]);
expect(allMobileSummaryRules.length).toBeGreaterThan(0);
expect(allMobileSummaryRules.every((rule) => !/flex-direction:\s*column/.test(rule))).toBe(true);
});
});

View File

@@ -1,106 +0,0 @@
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve, sep } from "node:path";
import { describe, expect, it } from "vitest";
const componentsDir = resolve(__dirname, "..", "components");
function stripVarFallbackRgba(content: string): string {
return content.replace(/var\([^()]*,\s*rgba?\([^)]*\)\s*\)/g, "");
}
function findComponentCssFiles(dir = componentsDir): string[] {
const entries = readdirSync(dir, { withFileTypes: true });
const files = entries.flatMap((entry) => {
const entryPath = join(dir, entry.name);
if (entry.isDirectory()) {
return findComponentCssFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".css") ? [entryPath] : [];
});
return files.sort((left, right) =>
relative(componentsDir, left).localeCompare(relative(componentsDir, right))
);
}
function formatComponentCssPath(filePath: string): string {
return relative(componentsDir, filePath).split(sep).join("/");
}
function findRawRgbViolations(source: string, fileName: string): string[] {
const withoutFallbacks = stripVarFallbackRgba(source);
const lines = withoutFallbacks.split(/\r?\n/);
return lines.flatMap((line, index) =>
/rgba?\(/.test(line) ? [`${fileName}:${index + 1}:${line.trim()}`] : []
);
}
function findRawRgbViolationsIncludingFallbacks(source: string, fileName: string): string[] {
const lines = source.split(/\r?\n/);
return lines.flatMap((line, index) =>
/rgba?\(/.test(line) ? [`${fileName}:${index + 1}:${line.trim()}`] : []
);
}
function buildRawRgbFailureMessage(violations: string[]): string {
return [
"Raw rgb/rgba() found in component CSS.",
"Use design tokens or color-mix(in srgb, var(--color-X) N%, transparent) instead:",
...violations,
].join("\n");
}
describe("component CSS color token hygiene", () => {
it("detects raw rgb/rgba calls but permits var() fallback rgb/rgba", () => {
const source = [
".clean { color: var(--color-text); }",
".fallback { color: var(--custom-color, rgba(1, 2, 3, 0.5)); }",
".violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }",
].join("\n");
const violations = findRawRgbViolations(source, "fixture.css");
expect(violations).toEqual([
"fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }",
]);
expect(buildRawRgbFailureMessage(violations)).toContain(
"fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }"
);
expect(buildRawRgbFailureMessage(violations)).toContain(
"color-mix(in srgb, var(--color-X) N%, transparent)"
);
});
it("contains no raw rgb/rgba calls outside var() fallbacks", () => {
const cssFiles = findComponentCssFiles();
const violations = cssFiles.flatMap((filePath) =>
findRawRgbViolations(readFileSync(filePath, "utf8"), formatComponentCssPath(filePath))
);
expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]);
});
it("contains no raw rgb/rgba calls anywhere in command-center component CSS", () => {
/*
FNXC:CommandCenterStyling 2026-06-18-00:00:
Command Center has a stricter invariant than the global guard: raw rgb/rgba is forbidden even inside var() fallbacks because undefined surface/border tokens must keep concrete-hex color-mix fallbacks. Use the recursive component CSS scan because loadAllAppCss() only includes top-level components/*.css and does not load command-center subdirectories.
*/
const cssFiles = findComponentCssFiles().filter((filePath) =>
formatComponentCssPath(filePath).startsWith("command-center/")
);
const violations = cssFiles.flatMap((filePath) =>
findRawRgbViolationsIncludingFallbacks(
readFileSync(filePath, "utf8"),
formatComponentCssPath(filePath)
)
);
expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]);
});
});

View File

@@ -1,165 +0,0 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const APP_ROOT = path.resolve(__dirname, "..");
const COMPONENTS_ROOT = path.join(APP_ROOT, "components");
const JS_SET_PROPERTY_ALLOWLIST = new Set([
"--cc-radial-value",
"--mobile-wf-depth",
"--icb-bottom-offset",
"--icb-right-offset",
"--quick-chat-fab-lift",
"--quick-chat-fab-shadow",
"--quick-chat-fab-shadow-hover",
"--selection-comment-panel-width",
"--task-chat-composer-max-height",
"--layout-content-max-width",
"--opacity-disabled",
"--provider-icon-color",
]);
/**
* FNXC:DashboardStyling 2026-06-19-00:00:
* FN-6690/FN-6693 proved jsdom style assertions miss undefined CSS custom-property references because jsdom does not resolve `var()` at computed-value time.
* Scan raw dashboard CSS instead: a bare `var(--missing-token)` can silently invalidate a declaration or depend on a stale fallback, so every referenced custom property must be defined by CSS, assigned by React inline style, or documented as a runtime-local allowlist entry.
*/
function stripCssComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "");
}
function collectFiles(dir: string, predicate: (fileName: string) => boolean): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist" || entry === "public" || entry.startsWith(".")) continue;
const fullPath = path.join(dir, entry);
const info = statSync(fullPath);
if (info.isDirectory()) {
out.push(...collectFiles(fullPath, predicate));
continue;
}
if (info.isFile() && predicate(entry)) out.push(fullPath);
}
return out.sort((left, right) => formatAppPath(left).localeCompare(formatAppPath(right)));
}
function collectCssFilesToScan(): string[] {
const componentCss = collectFiles(COMPONENTS_ROOT, (fileName) => fileName.endsWith(".css"));
const appLevelCss = readdirSync(APP_ROOT)
.filter((entry) => entry.endsWith(".css") && entry !== "styles.css")
.map((entry) => path.join(APP_ROOT, entry));
return [...componentCss, ...appLevelCss].sort((left, right) => formatAppPath(left).localeCompare(formatAppPath(right)));
}
function collectAllCssFiles(): string[] {
return collectFiles(APP_ROOT, (fileName) => fileName.endsWith(".css"));
}
function collectSourceFiles(): string[] {
return collectFiles(APP_ROOT, (fileName) => /\.(tsx?|jsx?)$/.test(fileName));
}
function collectDefinedProperties(cssFiles: string[]): Set<string> {
const properties = new Set<string>();
for (const filePath of cssFiles) {
const source = stripCssComments(readFileSync(filePath, "utf8"));
for (const match of source.matchAll(/(^|[\s{;])(--[A-Za-z0-9_-]+)\s*:/g)) {
properties.add(match[2]);
}
}
return properties;
}
function collectInlineSetProperties(sourceFiles: string[]): Set<string> {
const properties = new Set<string>();
for (const filePath of sourceFiles) {
const source = readFileSync(filePath, "utf8");
for (const match of source.matchAll(/\[\s*["'`](--[A-Za-z0-9_-]+)["'`]\s*(?:as\s+string)?\s*\]\s*:/g)) {
properties.add(match[1]);
}
for (const match of source.matchAll(/["'`](--[A-Za-z0-9_-]+)["'`]\s*:/g)) {
properties.add(match[1]);
}
}
return properties;
}
function collectReferencedProperties(source: string): Set<string> {
const references = new Set<string>();
const uncommented = stripCssComments(source);
for (const match of uncommented.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) {
references.add(match[1]);
}
return references;
}
function formatAppPath(filePath: string): string {
return path.relative(APP_ROOT, filePath).split(path.sep).join("/");
}
function findUndefinedReferences(args: {
cssFilesToScan: string[];
definedProperties: Set<string>;
inlineSetProperties: Set<string>;
allowlist?: Set<string>;
sourceByFile?: Map<string, string>;
}): string[] {
const {
cssFilesToScan,
definedProperties,
inlineSetProperties,
allowlist = JS_SET_PROPERTY_ALLOWLIST,
sourceByFile = new Map(),
} = args;
const violations: string[] = [];
for (const filePath of cssFilesToScan) {
const source = sourceByFile.get(filePath) ?? readFileSync(filePath, "utf8");
for (const property of collectReferencedProperties(source)) {
if (definedProperties.has(property) || inlineSetProperties.has(property) || allowlist.has(property)) continue;
violations.push(`${formatAppPath(filePath)} references ${property}`);
}
}
return violations.sort();
}
describe("dashboard CSS token validity", () => {
it("flags a synthetic undefined custom-property reference", () => {
const fixturePath = path.join(APP_ROOT, "fixture.css");
const fixtureSource = "/* var(--commented-out) */ .x { color: var(--does-not-exist); }";
const violations = findUndefinedReferences({
cssFilesToScan: [fixturePath],
definedProperties: new Set(["--defined-token"]),
inlineSetProperties: new Set(),
allowlist: new Set(),
sourceByFile: new Map([[fixturePath, fixtureSource]]),
});
expect(collectReferencedProperties(fixtureSource)).toEqual(new Set(["--does-not-exist"]));
expect(violations).toEqual(["fixture.css references --does-not-exist"]);
});
it("keeps component and app-level CSS references backed by defined or runtime-set properties", () => {
const violations = findUndefinedReferences({
cssFilesToScan: collectCssFilesToScan(),
definedProperties: collectDefinedProperties(collectAllCssFiles()),
inlineSetProperties: collectInlineSetProperties(collectSourceFiles()),
});
expect(violations, [`Undefined CSS custom-property references found:`, ...violations].join("\n")).toEqual([]);
});
});

View File

@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
const css = loadAllAppCss();
function getRule(selector: string, options: { last?: boolean } = {}): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matches = [...css.matchAll(new RegExp(`${escapedSelector}\\s*\\{[^}]*\\}`, "g"))];
expect(matches.length, `Expected CSS rule for ${selector}`).toBeGreaterThan(0);
if (options.last) {
return matches[matches.length - 1]?.[0] ?? "";
}
return matches[0]?.[0] ?? "";
}
describe("Dev server view CSS layout regressions", () => {
it("allows vertical page scrolling to keep all controls reachable on short viewports", () => {
const rootRule = getRule(".dev-server-view");
expect(rootRule).toContain("overflow-y: auto");
expect(rootRule).toContain("overflow-x: hidden");
});
it("keeps config and candidate lists scrollable with bounded height", () => {
const configRule = getRule(".dev-server-config");
expect(configRule).toContain("max-height: min(52vh, calc(var(--space-2xl) * 16))");
expect(configRule).toContain("overflow-y: auto");
const candidatesRule = getRule(".dev-server-candidates");
expect(candidatesRule).toContain("max-height: min(36vh, calc(var(--space-2xl) * 9))");
expect(candidatesRule).toContain("overflow-y: auto");
});
it("renders fullscreen log viewer above fixed chrome layers", () => {
const fullscreenRule = getRule(".devserver-log-viewer--fullscreen");
const zIndexMatch = fullscreenRule.match(/z-index:\s*(\d+)/);
expect(zIndexMatch).toBeTruthy();
const zIndex = Number.parseInt(zIndexMatch?.[1] ?? "0", 10);
expect(zIndex).toBeGreaterThan(50);
});
it("separates warning fallback styling from external-only mode", () => {
const fallbackRule = getRule(".dev-server-preview-fallback", { last: true });
const externalOnlyRule = getRule(".dev-server-preview-external-only", { last: true });
expect(fallbackRule).toContain("var(--color-warning)");
expect(externalOnlyRule).toContain("border: 1px solid var(--border)");
expect(externalOnlyRule).toContain("background: var(--surface)");
expect(externalOnlyRule).not.toContain("--color-warning");
});
it("keeps the preview header responsive on narrow screens", () => {
const badgeRule = getRule(".devserver-preview-url-badge");
expect(badgeRule).toContain("flex: 1 1 auto");
expect(badgeRule).toContain("min-width: 0");
const mobileHeaderRule = getRule(".devserver-preview-header", { last: true });
expect(mobileHeaderRule).toContain("flex-wrap: wrap");
const mobileActionsRule = getRule(".devserver-preview-actions", { last: true });
expect(mobileActionsRule).toContain("width: 100%");
expect(mobileActionsRule).toContain("justify-content: flex-end");
});
});

View File

@@ -1,68 +0,0 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss, loadAllAppCssBaseOnly, loadThemeDataCss } from "../test/cssFixture";
const ALLOWED_EXCEPTIONS: string[] = [];
function stripVarFallbackRgba(content: string): string {
return content.replace(/var\([^()]*,\s*rgba?\([^)]*\)\s*\)/g, "");
}
function findRawRgbViolations(source: string, fileName: string): string[] {
const withoutFallbacks = stripVarFallbackRgba(source);
const lines = withoutFallbacks.split(/\r?\n/);
return lines
.flatMap((line, index) =>
/rgba?\(/.test(line) ? [`${fileName}:${index + 1}:${line.trim()}`] : []
)
.filter((violation) => !ALLOWED_EXCEPTIONS.includes(violation));
}
function buildRawRgbFailureMessage(violations: string[]): string {
return [
"Raw rgb/rgba() found in global dashboard CSS.",
"Use design tokens or color-mix(in srgb, var(--color-X) N%, transparent) instead.",
"Allowed exceptions must be documented in ALLOWED_EXCEPTIONS:",
...violations,
].join("\n");
}
describe("global and theme CSS color token hygiene", () => {
it("detects raw rgb/rgba calls but permits var() fallback rgb/rgba", () => {
const source = [
".clean { color: var(--color-text); }",
".fallback { color: var(--custom-color, rgba(1, 2, 3, 0.5)); }",
".violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }",
].join("\n");
const violations = findRawRgbViolations(source, "fixture.css");
expect(violations).toEqual([
"fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }",
]);
expect(buildRawRgbFailureMessage(violations)).toContain(
"fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }"
);
expect(buildRawRgbFailureMessage(violations)).toContain(
"color-mix(in srgb, var(--color-X) N%, transparent)"
);
});
it("keeps base global CSS free of raw rgb/rgba calls outside var() fallbacks", () => {
const violations = findRawRgbViolations(loadAllAppCssBaseOnly(), "loadAllAppCssBaseOnly()");
expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]);
});
it("keeps all app CSS free of raw rgb/rgba calls outside var() fallbacks", () => {
const violations = findRawRgbViolations(loadAllAppCss(), "loadAllAppCss()");
expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]);
});
it("keeps theme-data CSS free of raw rgb/rgba calls outside var() fallbacks", () => {
const violations = findRawRgbViolations(loadThemeDataCss(), "public/theme-data.css");
expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]);
});
});

View File

@@ -1,163 +0,0 @@
import React from "react";
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
import { basename, extname, join, relative, resolve } from "path";
import { render, screen } from "@testing-library/react";
import { Loader2, RefreshCw } from "lucide-react";
const SHARED_SPINNER_KEYFRAME = "fusion-spinner-spin";
const CSS_ROOT = resolve(__dirname, "..");
const COMPONENTS_ROOT = resolve(CSS_ROOT, "components");
function extractBlock(content: string, pattern: RegExp): string {
const match = content.match(pattern);
expect(match?.index).toBeDefined();
const start = match!.index! + match![0].length;
let index = start;
let depth = 1;
while (index < content.length && depth > 0) {
if (content[index] === "{") depth += 1;
if (content[index] === "}") depth -= 1;
index += 1;
}
expect(depth).toBe(0);
return content.slice(match!.index!, index);
}
function collectCssFiles(root: string): string[] {
return readdirSync(root).flatMap((entry) => {
const absolutePath = join(root, entry);
const stats = statSync(absolutePath);
if (stats.isDirectory()) {
return collectCssFiles(absolutePath);
}
return extname(entry) === ".css" ? [absolutePath] : [];
});
}
function assertBlockUsesSharedAnimation(block: string, selector: string): void {
expect(block, selector).toContain(SHARED_SPINNER_KEYFRAME);
expect(block, selector).toContain("transform-origin: center;");
expect(block, selector).not.toContain("animation: spin");
expect(block, selector).not.toContain("animation-name: spin");
}
function assertSharedSpinnerCssContract(css: string): void {
const sharedKeyframeBlock = extractBlock(css, new RegExp(`@keyframes\\s+${SHARED_SPINNER_KEYFRAME}\\s*\\{`));
const animateSpinBlock = extractBlock(css, /\.animate-spin\s*\{/);
const spinBlock = extractBlock(css, /\.spin\s*\{/);
const spinnerBlock = extractBlock(css, /\.spinner,\s*\n\.spinning\s*\{/);
const svgSpinnerBlock = extractBlock(css, /svg\.animate-spin,\s*\nsvg\.spin,\s*\nsvg\.spinner,\s*\nsvg\.spinning\s*\{/);
expect(sharedKeyframeBlock).toContain("transform: rotate(360deg);");
expect(css).not.toMatch(/@keyframes\s+spin\s*\{/);
assertBlockUsesSharedAnimation(animateSpinBlock, ".animate-spin");
assertBlockUsesSharedAnimation(spinBlock, ".spin");
assertBlockUsesSharedAnimation(spinnerBlock, ".spinner/.spinning");
expect(svgSpinnerBlock).toContain("transform-box: fill-box;");
expect(svgSpinnerBlock).toContain("svg.animate-spin");
expect(svgSpinnerBlock).toContain("svg.spin");
expect(svgSpinnerBlock).toContain("svg.spinner");
expect(svgSpinnerBlock).toContain("svg.spinning");
}
function assertRenderedSpinnerClass(className: string): void {
const testId = `spinner-${className}`;
render(React.createElement(Loader2, { className, "data-testid": testId }));
const spinner = screen.getByTestId(testId);
expect(spinner.tagName.toLowerCase()).toBe("svg");
expect(spinner).toHaveAttribute("class", expect.stringContaining(className));
expect(spinner).toHaveAttribute("fill", "none");
expect(spinner).toHaveAttribute("viewBox", "0 0 24 24");
}
describe("global spinner animation utility", () => {
const css = readFileSync(resolve(CSS_ROOT, "styles.css"), "utf8");
it("keeps every shared utility on the collision-proof spinner keyframe", () => {
assertSharedSpinnerCssContract(css);
});
it("keeps representative lucide svg spinner classes wired for the shared contract", () => {
["spin", "animate-spin", "spinner", "spinning"].forEach(assertRenderedSpinnerClass);
render(React.createElement(RefreshCw, { className: "spinning", "data-testid": "refresh-spinner" }));
expect(screen.getByTestId("refresh-spinner")).toHaveAttribute("class", expect.stringContaining("spinning"));
});
it("protects the FN-6916 first-paint svg transform box contract", () => {
expect(() => assertSharedSpinnerCssContract(css.replace("transform-box: fill-box;", "transform-box: view-box;"))).toThrow();
});
it("fails the contract if shared utilities regress back to a generic spin keyframe", () => {
const regressedCss = css
.replaceAll(SHARED_SPINNER_KEYFRAME, "spin")
.replace("@keyframes spin", "@keyframes spin");
expect(() => assertSharedSpinnerCssContract(regressedCss)).toThrow();
});
it("keeps component css chunks from defining globally colliding spin keyframes or utility overrides", () => {
const offenders = collectCssFiles(COMPONENTS_ROOT)
.map((file) => ({ file, content: readFileSync(file, "utf8") }))
.filter(({ content }) => /@keyframes\s+spin\s*\{|^\.(?:spin|animate-spin|spinner|spinning)\s*\{/m.test(content))
.map(({ file }) => relative(CSS_ROOT, file));
expect(offenders).toEqual([]);
});
it("keeps css-only spinner surfaces on isolated keyframes", () => {
const expectedCssOnlySurfaces = [
["components/TerminalModal.css", ".terminal-spinner", "terminal-spin"],
["components/TaskCard.css", ".card-edit-loading-spinner", "task-card-edit-spinner-spin"],
["components/FileMentionPopup.css", ".file-mention-popup-loading .spinner", "file-mention-popup-spinner-spin"],
["components/IssueMentionPopup.css", ".issue-mention-popup-loading .spinner", "issue-mention-popup-spinner-spin"],
["components/WorkflowResultsTab.css", ".workflow-results-spinner", "workflow-results-spinner-spin"],
] as const;
for (const [relativeFile, selector, keyframeName] of expectedCssOnlySurfaces) {
const filePath = resolve(CSS_ROOT, relativeFile);
expect(existsSync(filePath), relativeFile).toBe(true);
const fileCss = readFileSync(filePath, "utf8");
expect(fileCss, `${relativeFile} defines ${keyframeName}`).toMatch(new RegExp(`@keyframes\\s+${keyframeName}\\s*\\{`));
expect(fileCss, `${relativeFile} animates ${selector}`).toContain(`animation: ${keyframeName}`);
}
});
it("keeps renamed local spinner classes connected to rendered loading affordances", () => {
const customProvidersSource = readFileSync(resolve(COMPONENTS_ROOT, "CustomProvidersSection.tsx"), "utf8");
const taskCardSource = readFileSync(resolve(COMPONENTS_ROOT, "TaskCard.tsx"), "utf8");
const terminalModalSource = readFileSync(resolve(COMPONENTS_ROOT, "TerminalModal.tsx"), "utf8");
expect(customProvidersSource).toContain('className="custom-provider-spin"');
expect(taskCardSource).toContain('className="card-edit-loading-spinner"');
expect(terminalModalSource).toContain('className="terminal-spinner"');
});
it("records the searched dashboard spinner surface inventory", () => {
const surfaceFiles = collectCssFiles(COMPONENTS_ROOT)
.filter((file) => /spinner|spin|keyframes/.test(readFileSync(file, "utf8")))
.map((file) => basename(file))
.sort();
expect(surfaceFiles).toEqual(expect.arrayContaining([
"FileMentionPopup.css",
"GitHubImportModal.css",
"Header.css",
"IssueMentionPopup.css",
"NodesView.css",
"ScriptsModal.css",
"TaskCard.css",
"TaskDetailModal.css",
"TerminalModal.css",
"TodoView.css",
"WorkflowResultsTab.css",
]));
});
});

View File

@@ -1,635 +0,0 @@
import { describe, it, expect } from "vitest";
import { loadAllAppCss, loadStylesCss } from "../test/cssFixture";
import fs from "fs";
import path from "path";
const themeDataPath = path.resolve(__dirname, "../public/theme-data.css");
/**
* Theme-safety regression tests for status color tokens across
* TaskCard, GitHubBadge, and PrPanel components.
*
* These tests verify that hardcoded rgba/hex colors have been replaced
* with theme-aware CSS custom properties using color-mix(), ensuring
* correct rendering across all 31 color themes and both light/dark modes.
*/
describe("Status color CSS custom properties", () => {
let css: string;
let stylesCss: string;
beforeAll(() => {
css = loadAllAppCss();
stylesCss = loadStylesCss();
});
it("defines --status-triage-bg custom property in :root using color-mix()", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--status-triage-bg");
expect(rootBlock).toContain("color-mix(in srgb, var(--triage) 15%, transparent)");
});
it("defines --status-todo-bg custom property in :root using color-mix()", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--status-todo-bg");
expect(rootBlock).toContain("color-mix(in srgb, var(--todo) 15%, transparent)");
});
it("defines --status-in-progress-bg custom property in :root using color-mix()", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--status-in-progress-bg");
expect(rootBlock).toContain("color-mix(in srgb, var(--in-progress) 15%, transparent)");
});
it("defines --status-in-review-bg custom property in :root using color-mix()", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--status-in-review-bg");
expect(rootBlock).toContain("color-mix(in srgb, var(--in-review) 15%, transparent)");
});
it("defines --status-done-bg custom property in :root using color-mix()", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--status-done-bg");
expect(rootBlock).toContain("color-mix(in srgb, var(--done) 15%, transparent)");
});
it("defines --status-error-bg custom property in :root using color-mix()", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--status-error-bg");
expect(rootBlock).toContain("color-mix(in srgb, var(--color-error-dark");
});
it("defines --status-archived-bg custom property in :root using color-mix()", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--status-archived-bg");
expect(rootBlock).toContain("color-mix(in srgb, var(--text-muted");
});
it("defines --surface-hover in :root using semantic color-mix()", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--surface-hover");
expect(rootBlock).toContain(
"--surface-hover: color-mix(in srgb, var(--surface) 90%, var(--text) 10%)"
);
});
it("defines light theme override for --surface-hover", () => {
const lightBlock = extractLightThemeBlock(stylesCss);
expect(lightBlock).toContain("--surface-hover");
expect(lightBlock).toContain(
"--surface-hover: color-mix(in srgb, var(--surface) 92%, var(--text) 8%)"
);
});
it("defines semantic neutral surface tiers in :root", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--surface-subtle");
expect(rootBlock).toContain("--surface-muted");
expect(rootBlock).toContain("--surface-emphasis");
expect(rootBlock).toContain("--surface-hover-strong");
});
it("defines semantic neutral surface tier overrides in light theme", () => {
const lightBlock = extractLightThemeBlock(stylesCss);
expect(lightBlock).toContain("--surface-subtle");
expect(lightBlock).toContain("--surface-muted");
expect(lightBlock).toContain("--surface-emphasis");
expect(lightBlock).toContain("--surface-hover-strong");
});
it("defines semantic neutral tiers with tokenized color-mix expressions", () => {
expect(stylesCss).toMatch(/--surface-subtle:\s*color-mix\(in\s+srgb,\s*var\(--surface\)\s+96%,\s*var\(--text\)\s+4%\)/);
expect(stylesCss).toMatch(/--surface-muted:\s*color-mix\(in\s+srgb,\s*var\(--surface\)\s+94%,\s*var\(--text\)\s+6%\)/);
expect(stylesCss).toMatch(/--surface-emphasis:\s*color-mix\(in\s+srgb,\s*var\(--surface\)\s+92%,\s*var\(--text\)\s+8%\)/);
expect(stylesCss).toMatch(/--surface-hover-strong:\s*color-mix\(in\s+srgb,\s*var\(--surface\)\s+88%,\s*var\(--text\)\s+12%\)/);
expect(stylesCss).not.toMatch(/--surface-(subtle|muted|emphasis|hover-strong):\s*rgba\(/);
});
it("uses --surface-hover token references with tokenized fallback (no raw rgba)", () => {
// FNXC:DashboardThemeTokens 2026-06-25-13:15: The invariant is that every
// --surface-hover fallback stays tokenized (color-mix), never a raw rgba().
// The original example string (QuickChatFAB's `var(--surface) 55%, transparent`)
// was removed when quick chat was replaced by the modal chat refactor, so we
// assert against a still-present tokenized fallback form instead.
expect(css).toContain("var(--surface-hover)");
expect(css).toContain("var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent))");
expect(css).not.toMatch(/var\(--surface-hover,\s*rgba\(/);
});
it("defines --surface-hover with tokenized color-mix (no raw rgba/hex)", () => {
expect(stylesCss).toMatch(/--surface-hover:\s*color-mix\(in\s+srgb,\s*var\(--surface\)\s+90%,\s*var\(--text\)\s+10%\)/);
expect(stylesCss).toMatch(/:root\[data-theme="light"\]\s*\{[^}]*--surface-hover:\s*color-mix\(in\s+srgb,\s*var\(--surface\)\s+92%,\s*var\(--text\)\s+8%\)/s);
expect(stylesCss).not.toMatch(/--surface-hover:\s*rgba\(/);
expect(stylesCss).not.toMatch(/--surface-hover:\s*#[0-9a-fA-F]{3,8}/);
});
it("defines light theme override for --status-error-bg", () => {
const lightBlock = extractLightThemeBlock(stylesCss);
expect(lightBlock).toContain("--status-error-bg");
expect(lightBlock).toContain("--status-error-bg-deep");
});
it("keeps :root[data-theme=\"light\"] owned by styles.css", () => {
const stylesLightMatches = stylesCss.match(/:root\[data-theme="light"\]\s*\{/g) ?? [];
const allCssLightMatches = css.match(/:root\[data-theme="light"\]\s*\{/g) ?? [];
expect(stylesLightMatches).toHaveLength(1);
expect(allCssLightMatches).toHaveLength(stylesLightMatches.length);
});
it("does not scope global design tokens under runtime-card cobrand selector", () => {
const cobrandMarkBlock = extractSelectorBlock(stylesCss, ".runtime-card__cobrand-mark");
expect(cobrandMarkBlock).not.toContain("--cta-bg");
expect(cobrandMarkBlock).not.toContain("--state-idle-bg");
expect(cobrandMarkBlock).not.toContain("--event-error-text");
expect(cobrandMarkBlock).not.toContain("--star-idle");
});
});
describe("TaskCard theme safety", () => {
const componentPath = path.resolve(__dirname, "../components/TaskCard.tsx");
let source: string;
beforeAll(() => {
source = fs.readFileSync(componentPath, "utf-8");
});
it("does not contain COLUMN_COLOR_MAP with hardcoded rgba colors", () => {
expect(source).not.toContain("COLUMN_COLOR_MAP");
expect(source).not.toContain("rgba(210,153,34");
expect(source).not.toContain("rgba(88,166,255");
expect(source).not.toContain("rgba(188,140,255");
expect(source).not.toContain("rgba(63,185,80");
expect(source).not.toContain("rgba(139,148,158");
expect(source).not.toContain("rgba(120,120,120");
});
it("does not contain hardcoded paused badge color rgba(139,148,158,0.2)", () => {
expect(source).not.toContain("rgba(139,148,158");
});
it("does not contain hardcoded failed badge colors", () => {
expect(source).not.toContain("rgba(218,54,51");
expect(source).not.toContain("#da3633");
});
it("does not contain hardcoded awaiting-approval badge color", () => {
expect(source).not.toContain("rgba(210,153,34,0.2)");
});
it("uses CSS classes for status badges instead of inline styles", () => {
// Should use card-status-badge--${column} pattern
expect(source).toContain("card-status-badge--");
// Should use paused class
expect(source).toContain('"card-status-badge paused"');
});
it("does not contain hardcoded hex colors in inline styles", () => {
// No raw #rrggbb or #rgb hex values in the component source
const hexPattern = /"[^"]*#[0-9a-fA-F]{3,8}[^"]*"/g;
const matches = source.match(hexPattern);
expect(
matches,
`Found hardcoded hex colors in TaskCard.tsx: ${matches}`
).toBeNull();
});
});
describe("GitHubBadge theme safety", () => {
const componentPath = path.resolve(__dirname, "../components/GitHubBadge.tsx");
let source: string;
beforeAll(() => {
source = fs.readFileSync(componentPath, "utf-8");
});
it("does not contain COLORS object with hardcoded rgba values", () => {
expect(source).not.toContain("COLORS");
expect(source).not.toContain("rgba(63,185,80");
expect(source).not.toContain("rgba(218,54,51");
expect(source).not.toContain("rgba(188,140,255");
expect(source).not.toContain("rgba(248,81,73");
expect(source).not.toContain("rgba(139,148,158");
});
it("does not contain hardcoded hex colors", () => {
expect(source).not.toContain("#3fb950");
expect(source).not.toContain("#da3633");
expect(source).not.toContain("#bc8cff");
expect(source).not.toContain("#f85149");
expect(source).not.toContain("#8b949e");
});
it("does not use getPrColors or getIssueColors helpers", () => {
expect(source).not.toContain("getPrColors");
expect(source).not.toContain("getIssueColors");
});
it("does not use inline style props for badge coloring", () => {
// Should not have style={{ background: or style={{ color: in badge spans
expect(source).not.toMatch(/style=\{\{[^}]*background:/);
expect(source).not.toMatch(/style=\{\{[^}]*color:/);
});
});
describe("PrPanel theme safety", () => {
const componentPath = path.resolve(__dirname, "../components/PrPanel.tsx");
let source: string;
beforeAll(() => {
source = fs.readFileSync(componentPath, "utf-8");
});
it("does not contain STATUS_COLORS with hardcoded rgba values", () => {
expect(source).not.toContain("STATUS_COLORS");
expect(source).not.toContain("rgba(63,185,80");
expect(source).not.toContain("rgba(218,54,51");
expect(source).not.toContain("rgba(188,140,255");
});
it("does not contain hardcoded hex colors", () => {
expect(source).not.toContain("#3fb950");
expect(source).not.toContain("#da3633");
expect(source).not.toContain("#bc8cff");
});
it("uses CSS modifier classes for PR status badges", () => {
expect(source).toContain("pr-status-badge--");
expect(source).toContain("pr-card--status-");
});
});
describe("CSS modifier classes for status colors", () => {
let css: string;
beforeAll(() => {
css = loadAllAppCss();
});
it("defines card-status-badge modifier classes for all columns", () => {
expect(css).toContain(".card-status-badge--triage");
expect(css).toContain(".card-status-badge--todo");
expect(css).toContain(".card-status-badge--in-progress");
expect(css).toContain(".card-status-badge--in-review");
expect(css).toContain(".card-status-badge--done");
expect(css).toContain(".card-status-badge--archived");
});
it("defines card-status-badge modifier classes for paused and awaiting-approval", () => {
expect(css).toContain(".card-status-badge.paused");
expect(css).toContain(".card-status-badge.awaiting-approval");
});
it("defines GitHub badge modifier classes", () => {
expect(css).toContain(".card-github-badge--open");
expect(css).toContain(".card-github-badge--closed");
expect(css).toContain(".card-github-badge--merged");
expect(css).toContain(".card-github-badge--completed");
expect(css).toContain(".card-github-badge--not-planned");
});
it("defines PR status badge modifier classes", () => {
expect(css).toContain(".pr-status-badge--open");
expect(css).toContain(".pr-status-badge--closed");
expect(css).toContain(".pr-status-badge--merged");
});
it("defines PR card status modifier classes", () => {
expect(css).toContain(".pr-card--status-open");
expect(css).toContain(".pr-card--status-closed");
expect(css).toContain(".pr-card--status-merged");
});
it("uses var() tokens in all status modifier classes", () => {
const modifierBlocks = [
".card-status-badge--triage",
".card-status-badge--todo",
".card-status-badge--in-progress",
".card-status-badge--in-review",
".card-status-badge--done",
];
for (const selector of modifierBlocks) {
const blockStart = css.indexOf(selector);
expect(blockStart, `Missing selector: ${selector}`).toBeGreaterThan(-1);
const block = css.slice(blockStart, blockStart + 300);
// Split at } to get just this block
const blockEnd = block.indexOf("}");
const blockContent = block.slice(0, blockEnd);
expect(
blockContent.includes("var(--"),
`${selector} should use var() tokens but got: ${blockContent}`
).toBe(true);
}
});
it("card-status-badge.failed uses --status-error-bg token", () => {
const failedIdx = css.indexOf(".card-status-badge.failed");
expect(failedIdx).toBeGreaterThan(-1);
const block = css.slice(failedIdx, failedIdx + 200);
expect(block).toContain("var(--status-error-bg)");
});
it("card-status-badge.stuck uses --status-triage-bg-deep token", () => {
const stuckIdx = css.indexOf(".card-status-badge.stuck");
expect(stuckIdx).toBeGreaterThan(-1);
const block = css.slice(stuckIdx, stuckIdx + 200);
expect(block).toContain("var(--status-triage-bg-deep)");
});
});
describe("Accent color per color theme", () => {
// Theme blocks are in theme-data.css and intentionally outside loadAllAppCss().
let css: string;
let stylesCss: string;
let themeData: string;
beforeAll(() => {
css = loadAllAppCss();
stylesCss = loadStylesCss();
themeData = fs.readFileSync(themeDataPath, "utf-8");
});
/**
* Extract every dark color-theme block [data-color-theme="<name>"] { … }
* (excludes light variants and compound selectors with spaces/dots).
*/
function getDarkColorThemeBlocks(): Map<string, string> {
const blocks = new Map<string, string>();
const regex = /^\[data-color-theme="([^"]+)"\]\s*\{/gm;
let match: RegExpExecArray | null;
while ((match = regex.exec(themeData)) !== null) {
const themeName = match[1];
// Skip if this is actually a light variant or compound selector
const fullLine = themeData.slice(match.index, themeData.indexOf("\n", match.index));
if (fullLine.includes("[data-theme=") || fullLine.includes(".") || fullLine.includes(",")) {
continue;
}
const openBraceIdx = match.index + match[0].length - 1;
let depth = 1;
let end = openBraceIdx;
for (let i = openBraceIdx + 1; i < themeData.length; i++) {
if (themeData[i] === "{") depth++;
if (themeData[i] === "}") depth--;
if (depth === 0) {
end = i;
break;
}
}
blocks.set(themeName, themeData.slice(match.index, end + 1));
}
return blocks;
}
/**
* Extract every light color-theme block [data-color-theme="<name>"][data-theme="light"] { … }
*/
function getLightColorThemeBlocks(): Map<string, string> {
const blocks = new Map<string, string>();
const regex = /^\[data-color-theme="([^"]+)"\]\[data-theme="light"\]\s*\{/gm;
let match: RegExpExecArray | null;
while ((match = regex.exec(themeData)) !== null) {
const themeName = match[1];
const openBraceIdx = match.index + match[0].length - 1;
let depth = 1;
let end = openBraceIdx;
for (let i = openBraceIdx + 1; i < themeData.length; i++) {
if (themeData[i] === "{") depth++;
if (themeData[i] === "}") depth--;
if (depth === 0) {
end = i;
break;
}
}
blocks.set(themeName, themeData.slice(match.index, end + 1));
}
return blocks;
}
it("every dark color theme block defines --accent", () => {
const blocks = getDarkColorThemeBlocks();
expect(blocks.size).toBeGreaterThanOrEqual(34);
const missing: string[] = [];
for (const [theme, block] of blocks) {
if (!block.includes("--accent:")) {
missing.push(theme);
}
}
expect(missing, `Dark themes missing --accent: ${missing.join(", ")}`).toEqual([]);
});
it("every light color theme block defines --accent", () => {
const blocks = getLightColorThemeBlocks();
expect(blocks.size).toBeGreaterThanOrEqual(34);
const missing: string[] = [];
for (const [theme, block] of blocks) {
if (!block.includes("--accent:")) {
missing.push(theme);
}
}
expect(missing, `Light themes missing --accent: ${missing.join(", ")}`).toEqual([]);
});
it("dark and light accent counts match color theme counts", () => {
const darkBlocks = getDarkColorThemeBlocks();
const lightBlocks = getLightColorThemeBlocks();
const darkAccentCount = Array.from(darkBlocks.values()).filter(b => b.includes("--accent:")).length;
const lightAccentCount = Array.from(lightBlocks.values()).filter(b => b.includes("--accent:")).length;
expect(darkAccentCount).toBe(darkBlocks.size);
expect(lightAccentCount).toBe(lightBlocks.size);
});
it("every dark color theme block defines --accent-text", () => {
const blocks = getDarkColorThemeBlocks();
expect(blocks.size).toBeGreaterThanOrEqual(34);
const missing: string[] = [];
for (const [theme, block] of blocks) {
if (!block.includes("--accent-text:")) {
missing.push(theme);
}
}
expect(missing, `Dark themes missing --accent-text: ${missing.join(", ")}`).toEqual([]);
});
it("every light color theme block defines --accent-text", () => {
const blocks = getLightColorThemeBlocks();
expect(blocks.size).toBeGreaterThanOrEqual(34);
const missing: string[] = [];
for (const [theme, block] of blocks) {
if (!block.includes("--accent-text:")) {
missing.push(theme);
}
}
expect(missing, `Light themes missing --accent-text: ${missing.join(", ")}`).toEqual([]);
});
it("dark and light accent-text counts match color theme counts", () => {
const darkBlocks = getDarkColorThemeBlocks();
const lightBlocks = getLightColorThemeBlocks();
const darkAccentTextCount = Array.from(darkBlocks.values()).filter(b => b.includes("--accent-text:")).length;
const lightAccentTextCount = Array.from(lightBlocks.values()).filter(b => b.includes("--accent-text:")).length;
expect(darkAccentTextCount).toBe(darkBlocks.size);
expect(lightAccentTextCount).toBe(lightBlocks.size);
});
it(":root in styles.css defines --accent-text", () => {
const rootBlock = extractRootBlock(stylesCss);
expect(rootBlock).toContain("--accent-text:");
});
it("every dark color theme block defines --surface-hover using tokenized color-mix", () => {
const blocks = getDarkColorThemeBlocks();
expect(blocks.size).toBeGreaterThanOrEqual(34);
const missing: string[] = [];
const invalid: string[] = [];
for (const [theme, block] of blocks) {
if (!block.includes("--surface-hover:")) {
missing.push(theme);
continue;
}
if (
!block.includes("--surface-hover: color-mix(in srgb, var(--surface) 90%, var(--text) 10%)") ||
/--surface-hover:\s*(rgba\(|#[0-9a-fA-F]{3,8})/.test(block)
) {
invalid.push(theme);
}
}
expect(missing, `Dark themes missing --surface-hover: ${missing.join(", ")}`).toEqual([]);
expect(invalid, `Dark themes with non-tokenized --surface-hover: ${invalid.join(", ")}`).toEqual([]);
});
it("every light color theme block defines --surface-hover using tokenized color-mix", () => {
const blocks = getLightColorThemeBlocks();
expect(blocks.size).toBeGreaterThanOrEqual(34);
const missing: string[] = [];
const invalid: string[] = [];
for (const [theme, block] of blocks) {
if (!block.includes("--surface-hover:")) {
missing.push(theme);
continue;
}
if (
!block.includes("--surface-hover: color-mix(in srgb, var(--surface) 92%, var(--text) 8%)") ||
/--surface-hover:\s*(rgba\(|#[0-9a-fA-F]{3,8})/.test(block)
) {
invalid.push(theme);
}
}
expect(missing, `Light themes missing --surface-hover: ${missing.join(", ")}`).toEqual([]);
expect(invalid, `Light themes with non-tokenized --surface-hover: ${invalid.join(", ")}`).toEqual([]);
});
});
// ── Helpers ────────────────────────────────────────────────────────────────
function extractRootBlock(css: string): string {
// Find the second :root block (the one with status tokens)
const rootRegex = /:root\s*\{/g;
let match;
let secondRootIdx = -1;
let count = 0;
while ((match = rootRegex.exec(css)) !== null) {
count++;
if (count === 2) {
secondRootIdx = match.index;
break;
}
}
if (secondRootIdx === -1) {
throw new Error("Could not find second :root block");
}
// Find the opening brace position
const openBraceIdx = secondRootIdx + css.slice(secondRootIdx).indexOf("{");
// Start depth at 1 since we're already inside the block
let depth = 1;
let end = openBraceIdx;
for (let i = openBraceIdx + 1; i < css.length; i++) {
if (css[i] === "{") depth++;
if (css[i] === "}") depth--;
if (depth === 0) {
end = i;
break;
}
}
return css.slice(secondRootIdx, end + 1);
}
function extractLightThemeBlock(css: string): string {
// Intentionally parses the default light token block from styles.css
// (`:root[data-theme="light"]`). Color-theme light variants are validated
// from theme-data.css.
const startMatch = css.match(/:root\[data-theme="light"\]\s*\{/);
if (!startMatch) {
throw new Error("Could not find :root[data-theme=\"light\"] block");
}
const startIdx = startMatch.index!;
const openBraceIdx = startIdx + css.slice(startIdx).indexOf("{");
let depth = 1;
let end = openBraceIdx;
for (let i = openBraceIdx + 1; i < css.length; i++) {
if (css[i] === "{") depth++;
if (css[i] === "}") depth--;
if (depth === 0) {
end = i;
break;
}
}
return css.slice(startIdx, end + 1);
}
function extractSelectorBlock(css: string, selector: string): string {
const startIdx = css.indexOf(selector);
if (startIdx === -1) {
throw new Error(`Could not find selector block: ${selector}`);
}
const openBraceIdx = css.indexOf("{", startIdx);
if (openBraceIdx === -1) {
throw new Error(`Could not find opening brace for selector: ${selector}`);
}
let depth = 1;
let end = openBraceIdx;
for (let i = openBraceIdx + 1; i < css.length; i++) {
if (css[i] === "{") depth++;
if (css[i] === "}") depth--;
if (depth === 0) {
end = i;
break;
}
}
return css.slice(startIdx, end + 1);
}

View File

@@ -1,65 +0,0 @@
// Regression guard for FN-4286/FN-6688: prevent reintroducing undefined primary/secondary text aliases.
import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadStylesCss } from "../test/cssFixture";
const APP_ROOT = path.resolve(__dirname, "..");
const ALLOWLIST = new Set([
"__tests__/text-token-canonicalization.test.ts",
"__tests__/agent-css-classes.test.ts",
]);
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist") continue;
const fullPath = path.join(dir, entry);
const relPath = path.relative(APP_ROOT, fullPath).split(path.sep).join("/");
const stats = statSync(fullPath);
if (stats.isDirectory()) {
out.push(...collectSourceFiles(fullPath));
continue;
}
if (/\.(css|tsx?|ts)$/.test(entry)) out.push(relPath);
}
return out;
}
describe("text token canonicalization", () => {
it("keeps --text-secondary out of dashboard source files", () => {
const offenders: string[] = [];
for (const relPath of collectSourceFiles(APP_ROOT)) {
if (ALLOWLIST.has(relPath)) continue;
const content = readFileSync(path.join(APP_ROOT, relPath), "utf8");
if (content.includes("--text-secondary")) offenders.push(relPath);
}
expect(offenders, `Unexpected --text-secondary references in: ${offenders.join(", ")}`).toEqual([]);
});
it("keeps --text-primary out of dashboard source files outside command-center", () => {
const offenders: string[] = [];
for (const relPath of collectSourceFiles(APP_ROOT)) {
if (relPath.startsWith("components/command-center/")) continue;
if (ALLOWLIST.has(relPath)) continue;
const content = readFileSync(path.join(APP_ROOT, relPath), "utf8");
if (content.includes("--text-primary")) offenders.push(relPath);
}
expect(offenders, `Unexpected --text-primary references in: ${offenders.join(", ")}`).toEqual([]);
});
it("defines canonical text tokens and does not define legacy text aliases at :root", () => {
const stylesCss = loadStylesCss();
const rootBlocks = [...stylesCss.matchAll(/:root\s*\{([\s\S]*?)\}/g)].map((match) => match[1]);
expect(rootBlocks.length).toBeGreaterThan(0);
const allRootContent = rootBlocks.join("\n");
expect(allRootContent).not.toMatch(/^\s*--text-primary\s*:/m);
expect(allRootContent).not.toMatch(/^\s*--text-secondary\s*:/m);
expect(allRootContent).toMatch(/^\s*--text\s*:/m);
expect(allRootContent).toMatch(/^\s*--text-muted\s*:/m);
expect(allRootContent).toMatch(/^\s*--text-dim\s*:/m);
});
});

View File

@@ -1,320 +0,0 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss, loadStylesCss, loadThemeDataCss } from "../test/cssFixture";
const WCAG_AA_NORMAL_TEXT_CONTRAST = 4.5;
/*
FNXC:ToastTheming 2026-06-21-00:00:
Toast contrast is a cross-theme invariant. These tests resolve the CSS cascade instead of checking one selector string so Shadcn success, error, and info toasts stay readable in dark and light modes, including long messages that wrap under the mobile .toast rule.
*/
describe("toast theme contrast", () => {
const stylesCss = loadStylesCss();
const themeDataCss = loadThemeDataCss();
const allAppCss = loadAllAppCss();
const shadcnThemes = getShadcnThemeNames(themeDataCss);
it("uses the CTA text token for success toasts instead of inherited white text", () => {
const successBlock = extractSelectorBlock(stylesCss, ".toast-success");
const baseToastBlock = extractSelectorBlock(stylesCss, ".toast");
const lightSuccessBlock = extractSelectorBlock(
stylesCss,
'[data-theme="light"] .toast-success'
);
expect(baseToastBlock).not.toContain("color: #fff");
expect(successBlock).toContain("background: var(--cta-bg)");
expect(successBlock).toContain("color: var(--cta-text)");
expect(lightSuccessBlock).toContain("color: var(--cta-text)");
});
it("keeps success, error, and info toasts legible for every Shadcn variant in dark and light modes", () => {
expect(shadcnThemes).toEqual(
expect.arrayContaining([
"shadcn",
"shadcn-mono-red",
"shadcn-black",
"shadcn-gray",
])
);
const failures: string[] = [];
for (const theme of shadcnThemes) {
for (const mode of ["dark", "light"] as const) {
const tokens = resolveThemeTokens(stylesCss, themeDataCss, theme, mode);
for (const toastType of ["success", "error", "info"] as const) {
const background = resolveCssValue(
resolveToastDeclaration(stylesCss, theme, mode, toastType, "background"),
tokens
);
const color = resolveCssValue(
resolveToastDeclaration(stylesCss, theme, mode, toastType, "color"),
tokens
);
const contrast = contrastRatio(color, background);
if (contrast < WCAG_AA_NORMAL_TEXT_CONTRAST) {
failures.push(
`${theme}/${mode}/${toastType}: ${color} on ${background} = ${contrast.toFixed(2)}`
);
}
}
}
}
expect(failures).toEqual([]);
});
it("resolves representative Shadcn dark success to readable non-white text", () => {
for (const theme of ["shadcn", "shadcn-mono-red", "shadcn-black", "shadcn-gray"]) {
const tokens = resolveThemeTokens(stylesCss, themeDataCss, theme, "dark");
const successColor = resolveCssValue(
resolveToastDeclaration(stylesCss, theme, "dark", "success", "color"),
tokens
);
const successBackground = resolveCssValue(
resolveToastDeclaration(stylesCss, theme, "dark", "success", "background"),
tokens
);
expect(normalizeHex(successColor)).not.toBe("#ffffff");
expect(contrastRatio(successColor, successBackground)).toBeGreaterThanOrEqual(
WCAG_AA_NORMAL_TEXT_CONTRAST
);
}
});
it("does not reset toast color at the mobile breakpoint", () => {
const mobileToastBlock = extractNestedSelectorBlock(
allAppCss,
"@media (max-width: 768px)",
".toast"
);
expect(mobileToastBlock).not.toMatch(/\bcolor\s*:/);
});
});
type ThemeMode = "dark" | "light";
type ToastType = "success" | "error" | "info";
type CssRule = {
selectors: string[];
declarations: Map<string, string>;
};
function getShadcnThemeNames(css: string): string[] {
const matches = css.matchAll(/\[data-color-theme="(shadcn[^"]*)"\]\s*\{/g);
return [...new Set([...matches].map((match) => match[1]))].sort();
}
function resolveThemeTokens(
stylesCss: string,
themeDataCss: string,
theme: string,
mode: ThemeMode
): Map<string, string> {
const tokens = new Map<string, string>();
for (const block of extractAllSelectorBlocks(stylesCss, ":root")) {
mergeDeclarations(tokens, block);
}
const lightRootBlock = maybeExtractSelectorBlock(stylesCss, ':root[data-theme="light"]');
if (mode === "light" && lightRootBlock) {
mergeDeclarations(tokens, lightRootBlock);
}
mergeDeclarations(tokens, extractSelectorBlock(themeDataCss, `[data-color-theme="${theme}"]`));
if (mode === "light") {
mergeDeclarations(
tokens,
extractSelectorBlock(themeDataCss, `[data-color-theme="${theme}"][data-theme="light"]`)
);
}
return tokens;
}
function resolveToastDeclaration(
stylesCss: string,
theme: string,
mode: ThemeMode,
toastType: ToastType,
property: "background" | "color"
): string {
let value: string | undefined;
for (const rule of parseTopLevelRules(stylesCss)) {
if (!rule.declarations.has(property)) continue;
if (rule.selectors.some((selector) => selectorMatchesToast(selector, theme, mode, toastType))) {
value = rule.declarations.get(property);
}
}
if (!value) {
throw new Error(`No ${property} declaration resolved for ${theme}/${mode}/${toastType}`);
}
return value;
}
function selectorMatchesToast(
selector: string,
theme: string,
mode: ThemeMode,
toastType: ToastType
): boolean {
if (!selector.includes(`.toast-${toastType}`) && selector !== ".toast") return false;
const exactThemeMatches = [...selector.matchAll(/\[data-color-theme="([^"]+)"\]/g)].map(
(match) => match[1]
);
if (exactThemeMatches.length > 0 && !exactThemeMatches.includes(theme)) return false;
const prefixThemeMatch = selector.match(/\[data-color-theme\^="([^"]+)"\]/);
if (prefixThemeMatch && !theme.startsWith(prefixThemeMatch[1])) return false;
const excludedThemeModeMatches = [...selector.matchAll(/:not\(\[data-theme="([^"]+)"\]\)/g)].map(
(match) => match[1]
);
if (excludedThemeModeMatches.includes(mode)) return false;
const selectorWithoutNegations = selector.replace(/:not\(\[data-theme="[^"]+"\]\)/g, "");
const themeModeMatch = selectorWithoutNegations.match(/\[data-theme="([^"]+)"\]/);
if (themeModeMatch && themeModeMatch[1] !== mode) return false;
return true;
}
function parseTopLevelRules(css: string): CssRule[] {
const rules: CssRule[] = [];
let index = 0;
while (index < css.length) {
const openBrace = css.indexOf("{", index);
if (openBrace === -1) break;
const selector = css.slice(index, openBrace).trim();
const closeBrace = findMatchingBrace(css, openBrace);
if (!selector.startsWith("@")) {
rules.push({
selectors: selector.split(",").map((part) => part.trim()),
declarations: parseDeclarations(css.slice(openBrace + 1, closeBrace)),
});
}
index = closeBrace + 1;
}
return rules;
}
function parseDeclarations(block: string): Map<string, string> {
const declarations = new Map<string, string>();
for (const match of block.matchAll(/(--[\w-]+|[\w-]+)\s*:\s*([^;]+);/g)) {
declarations.set(match[1], match[2].trim());
}
return declarations;
}
function mergeDeclarations(tokens: Map<string, string>, block: string): void {
for (const [name, value] of parseDeclarations(block)) {
if (name.startsWith("--")) tokens.set(name, value);
}
}
function resolveCssValue(value: string, tokens: Map<string, string>, seen = new Set<string>()): string {
const varMatch = value.match(/^var\((--[\w-]+)(?:,[^)]+)?\)$/);
if (!varMatch) return normalizeHex(value);
const tokenName = varMatch[1];
if (seen.has(tokenName)) throw new Error(`Circular CSS token reference: ${tokenName}`);
const tokenValue = tokens.get(tokenName);
if (!tokenValue) throw new Error(`Missing CSS token: ${tokenName}`);
seen.add(tokenName);
return resolveCssValue(tokenValue, tokens, seen);
}
function contrastRatio(foreground: string, background: string): number {
const foregroundLuminance = relativeLuminance(foreground);
const backgroundLuminance = relativeLuminance(background);
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
function relativeLuminance(hex: string): number {
const [red, green, blue] = hexToRgb(hex).map((channel) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4);
});
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
function hexToRgb(hex: string): [number, number, number] {
const normalized = normalizeHex(hex).replace("#", "");
return [0, 2, 4].map((offset) => parseInt(normalized.slice(offset, offset + 2), 16)) as [
number,
number,
number,
];
}
function normalizeHex(value: string): string {
const hex = value.trim().toLowerCase();
if (hex === "#fff") return "#ffffff";
if (hex === "#000") return "#000000";
if (/^#[0-9a-f]{6}$/.test(hex)) return hex;
throw new Error(`Expected a hex color, received: ${value}`);
}
function extractAllSelectorBlocks(css: string, selector: string): string[] {
const blocks: string[] = [];
let searchFrom = 0;
while (searchFrom < css.length) {
const startIdx = css.indexOf(`${selector} {`, searchFrom);
if (startIdx === -1) break;
const openBraceIdx = css.indexOf("{", startIdx);
const closeBraceIdx = findMatchingBrace(css, openBraceIdx);
blocks.push(css.slice(startIdx, closeBraceIdx + 1));
searchFrom = closeBraceIdx + 1;
}
return blocks;
}
function maybeExtractSelectorBlock(css: string, selector: string): string | null {
const startIdx = css.indexOf(`${selector} {`);
if (startIdx === -1) return null;
const openBraceIdx = css.indexOf("{", startIdx);
const closeBraceIdx = findMatchingBrace(css, openBraceIdx);
return css.slice(startIdx, closeBraceIdx + 1);
}
function extractSelectorBlock(css: string, selector: string): string {
const block = maybeExtractSelectorBlock(css, selector);
if (!block) throw new Error(`Could not find selector block: ${selector}`);
return block;
}
function extractNestedSelectorBlock(css: string, parentRule: string, selector: string): string {
let searchFrom = 0;
while (searchFrom < css.length) {
const parentStart = css.indexOf(parentRule, searchFrom);
if (parentStart === -1) break;
const parentOpen = css.indexOf("{", parentStart);
const parentClose = findMatchingBrace(css, parentOpen);
const block = maybeExtractSelectorBlock(css.slice(parentOpen + 1, parentClose), selector);
if (block) return block;
searchFrom = parentClose + 1;
}
throw new Error(`Could not find nested selector block: ${parentRule} ${selector}`);
}
function findMatchingBrace(css: string, openBraceIdx: number): number {
let depth = 1;
for (let index = openBraceIdx + 1; index < css.length; index++) {
if (css[index] === "{") depth++;
if (css[index] === "}") depth--;
if (depth === 0) return index;
}
throw new Error("Unclosed CSS block");
}

View File

@@ -1,172 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ResearchLifecycleError } from "@fusion/core";
import { EventEmitter } from "node:events";
import { createServer } from "../../../src/server.js";
import { request } from "../../../src/test-request.js";
const researchStore = {
listRuns: vi.fn(),
createRun: vi.fn(),
getRun: vi.fn(),
updateRun: vi.fn(),
deleteRun: vi.fn(),
appendEvent: vi.fn(),
addSource: vi.fn(),
updateSource: vi.fn(),
setResults: vi.fn(),
updateStatus: vi.fn(),
requestCancellation: vi.fn(),
createRetryRun: vi.fn(),
createExport: vi.fn(),
getExports: vi.fn(),
getExport: vi.fn(),
getStats: vi.fn(),
searchRuns: vi.fn(),
};
class MockStore extends EventEmitter {
getRootDir() { return "/tmp/fn-2991"; }
getFusionDir() { return "/tmp/fn-2991/.fusion"; }
getDatabase() { return { exec: vi.fn(), prepare: vi.fn(() => ({ run: vi.fn().mockReturnValue({ changes: 0 }), all: vi.fn().mockReturnValue([]), get: vi.fn() })) }; }
getResearchStore() { return researchStore; }
}
describe("research routes", () => {
const app = createServer(new MockStore() as any);
beforeEach(() => {
vi.clearAllMocks();
researchStore.listRuns.mockReturnValue([]);
researchStore.getRun.mockReturnValue(undefined);
researchStore.createRun.mockReturnValue({ id: "RR-1", query: "q", status: "queued", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
researchStore.updateRun.mockReturnValue({ id: "RR-1", query: "q", status: "running", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "y" });
researchStore.requestCancellation.mockReturnValue({ id: "RR-1", query: "q", status: "cancelling", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "y" });
researchStore.createRetryRun.mockReturnValue({ id: "RR-2", query: "q", status: "retry_waiting", sources: [], events: [], tags: [], lifecycle: { retryOfRunId: "RR-1", rootRunId: "RR-1" }, createdAt: "x", updatedAt: "y" });
researchStore.deleteRun.mockReturnValue(true);
researchStore.appendEvent.mockReturnValue({ id: "E1", timestamp: "x", type: "info", message: "ok" });
researchStore.addSource.mockReturnValue({ id: "S1", type: "web", reference: "https://e.com", status: "pending" });
researchStore.getExports.mockReturnValue([]);
researchStore.getStats.mockReturnValue({ total: 0, byStatus: { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 } });
researchStore.searchRuns.mockReturnValue([]);
});
it("supports run CRUD", async () => {
const list = await request(app, "GET", "/api/research/runs");
expect(list.status).toBe(200);
const created = await request(app, "POST", "/api/research/runs", JSON.stringify({ query: "topic" }), { "Content-Type": "application/json" });
expect(created.status).toBe(201);
researchStore.getRun.mockReturnValue(researchStore.createRun.mock.results[0]?.value ?? { id: "RR-1" });
const get = await request(app, "GET", "/api/research/runs/RR-1");
expect(get.status).toBe(200);
const patch = await request(app, "PATCH", "/api/research/runs/RR-1", JSON.stringify({ topic: "x" }), { "Content-Type": "application/json" });
expect(patch.status).toBe(200);
const del = await request(app, "DELETE", "/api/research/runs/RR-1");
expect(del.status).toBe(204);
});
it("supports events, sources, results and exports", async () => {
const evt = await request(app, "POST", "/api/research/runs/RR-1/events", JSON.stringify({ type: "info", message: "hello" }), { "Content-Type": "application/json" });
expect(evt.status).toBe(201);
const src = await request(app, "POST", "/api/research/runs/RR-1/sources", JSON.stringify({ type: "web", reference: "https://x.com", status: "pending" }), { "Content-Type": "application/json" });
expect(src.status).toBe(201);
const srcPatch = await request(app, "PATCH", "/api/research/runs/RR-1/sources/S1", JSON.stringify({ status: "completed" }), { "Content-Type": "application/json" });
expect(srcPatch.status).toBe(204);
const results = await request(app, "PUT", "/api/research/runs/RR-1/results", JSON.stringify({ findings: [] }), { "Content-Type": "application/json" });
expect(results.status).toBe(204);
researchStore.createExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
const createEx = await request(app, "POST", "/api/research/runs/RR-1/exports", JSON.stringify({ format: "json", content: "{}" }), { "Content-Type": "application/json" });
expect(createEx.status).toBe(201);
researchStore.getExports.mockReturnValue([{ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" }]);
const listEx = await request(app, "GET", "/api/research/runs/RR-1/exports");
expect(listEx.status).toBe(200);
expect((listEx.body as { exports: unknown[] }).exports).toHaveLength(1);
researchStore.getExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
const getEx = await request(app, "GET", "/api/research/exports/EX1");
expect(getEx.status).toBe(200);
});
it("supports cancel and retry with structured success and 409 responses", async () => {
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "running", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
const cancel = await request(app, "POST", "/api/research/runs/RR-1/cancel");
expect(cancel.status).toBe(200);
expect((cancel.body as any).run.status).toBe("cancelling");
const retry = await request(app, "POST", "/api/research/runs/RR-1/retry");
expect(retry.status).toBe(200);
expect((retry.body as any).run.status).toBe("retry_waiting");
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "completed", sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
const cancelConflict = await request(app, "POST", "/api/research/runs/RR-1/cancel");
expect(cancelConflict.status).toBe(409);
expect((cancelConflict.body as any).error).toContain("cannot be cancelled");
expect((cancelConflict.body as any).code).toBe("INVALID_TRANSITION");
researchStore.getRun.mockReturnValue({ id: "RR-1", query: "q", status: "retry_exhausted", lifecycle: { errorCode: "RETRY_EXHAUSTED" }, sources: [], events: [], tags: [], createdAt: "x", updatedAt: "x" });
researchStore.createRetryRun.mockImplementationOnce(() => {
throw new ResearchLifecycleError("Run RR-1 exhausted retries", "not_retryable");
});
const retryConflict = await request(app, "POST", "/api/research/runs/RR-1/retry");
expect(retryConflict.status).toBe(409);
expect((retryConflict.body as any).error).toContain("exhausted");
});
it("supports stats, search and validation errors", async () => {
const stats = await request(app, "GET", "/api/research/stats");
expect(stats.status).toBe(200);
const search = await request(app, "GET", "/api/research/search?q=test");
expect(search.status).toBe(200);
const invalidStatus = await request(app, "PATCH", "/api/research/runs/RR-1/status", JSON.stringify({ status: "bogus" }), { "Content-Type": "application/json" });
expect(invalidStatus.status).toBe(400);
expect((invalidStatus.body as any).error).toContain("Invalid status");
if ((invalidStatus.body as any).code !== undefined) {
expect(typeof (invalidStatus.body as any).code).toBe("string");
}
const invalidEvent = await request(app, "POST", "/api/research/runs/RR-1/events", JSON.stringify({ type: "bad", message: "x" }), { "Content-Type": "application/json" });
expect(invalidEvent.status).toBe(400);
const invalidSourceType = await request(app, "POST", "/api/research/runs/RR-1/sources", JSON.stringify({ type: "bad", reference: "x", status: "pending" }), { "Content-Type": "application/json" });
expect(invalidSourceType.status).toBe(400);
const invalidSourceStatus = await request(app, "POST", "/api/research/runs/RR-1/sources", JSON.stringify({ type: "web", reference: "x", status: "bad" }), { "Content-Type": "application/json" });
expect(invalidSourceStatus.status).toBe(400);
researchStore.getExport.mockReturnValue(undefined);
const missingExport = await request(app, "GET", "/api/research/exports/EX-404");
expect(missingExport.status).toBe(404);
expect((missingExport.body as any).error).toContain("Export not found");
if ((missingExport.body as any).code !== undefined) {
expect(typeof (missingExport.body as any).code).toBe("string");
}
researchStore.getRun.mockReturnValue(undefined);
const missing = await request(app, "GET", "/api/research/runs/RR-404");
expect(missing.status).toBe(404);
expect((missing.body as any).error).toContain("Run not found");
if ((missing.body as any).code !== undefined) {
expect(typeof (missing.body as any).code).toBe("string");
}
});
it("returns export payload with json content type", async () => {
researchStore.getExport.mockReturnValue({ id: "EX1", runId: "RR-1", format: "json", content: "{}", createdAt: "x" });
const response = await request(app, "GET", "/api/research/exports/EX1");
expect(response.status).toBe(200);
expect((response.headers["content-type"] as string)).toContain("application/json");
expect(response.body).toMatchObject({ id: "EX1", runId: "RR-1", format: "json", content: "{}" });
});
});

View File

@@ -1,155 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
import { DevServerView } from "../DevServerView";
const mockUseDevServer = vi.fn();
const mockUseDevServerLogs = vi.fn();
const mockUsePreviewEmbed = vi.fn();
vi.mock("../../hooks/useDevServer", () => ({
useDevServer: (...args: unknown[]) => mockUseDevServer(...args),
}));
vi.mock("../../hooks/useDevServerLogs", () => ({
useDevServerLogs: (...args: unknown[]) => mockUseDevServerLogs(...args),
}));
vi.mock("../../hooks/usePreviewEmbed", () => ({
usePreviewEmbed: (...args: unknown[]) => mockUsePreviewEmbed(...args),
}));
vi.mock("../DevServerLogViewer", () => ({
DevServerLogViewer: () => <div data-testid="mock-devserver-log-viewer" />,
}));
function extractAtRuleBlocks(css: string, marker: string): string[] {
const blocks: string[] = [];
let searchFrom = 0;
while (searchFrom < css.length) {
const start = css.indexOf(marker, searchFrom);
if (start === -1) break;
const open = css.indexOf("{", start);
if (open === -1) break;
let depth = 1;
let cursor = open + 1;
while (cursor < css.length && depth > 0) {
if (css[cursor] === "{") depth++;
else if (css[cursor] === "}") depth--;
cursor++;
}
blocks.push(css.slice(open + 1, cursor - 1));
searchFrom = cursor;
}
return blocks;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function selectorRule(css: string, selector: string): string | null {
const escapedSelector = escapeRegExp(selector);
const match = css.match(new RegExp(`(?:^|\\n)\\s*${escapedSelector}\\s*\\{[\\s\\S]*?\\n\\s*\\}`, "m"));
return match?.[0] ?? null;
}
function countSelectorRules(css: string, selector: string): number {
const escapedSelector = escapeRegExp(selector);
return (css.match(new RegExp(`(?:^|\\n)\\s*${escapedSelector}\\s*\\{`, "g")) ?? []).length;
}
function createDevServerHookState() {
return {
session: {
config: { id: "default", name: "Dev Server", command: "pnpm dev", cwd: "." },
status: "running",
previewUrl: "http://localhost:3000",
logHistory: [],
},
sessions: [],
detectedCommands: [],
previewUrl: "http://localhost:3000",
isLoading: false,
error: null,
startServer: vi.fn().mockResolvedValue(undefined),
stopServer: vi.fn().mockResolvedValue(undefined),
restartServer: vi.fn().mockResolvedValue(undefined),
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
detectCommands: vi.fn().mockResolvedValue(undefined),
refresh: vi.fn().mockResolvedValue(undefined),
};
}
describe("DevServerView mobile CSS/structure", () => {
it("defines one mobile rule-set for preview header/actions and wraps badge correctly", () => {
const css = loadAllAppCss();
/*
FNXC:DashboardTests 2026-06-26-13:05:
DevServerView intentionally keeps preview-header and modal-launcher copy mobile wrapping in separate rules so each surface can be asserted directly. Extract the balanced viewport at-rule from the loaded app CSS instead of counting a stale grouped selector that drifted after the CSS was split.
*/
const mobileCss = extractAtRuleBlocks(css, "@media (max-width: 768px)")
.find((block) => block.includes(".devserver-preview-header") && block.includes(".devserver-preview-modal-launcher__copy"));
expect(mobileCss).toBeTruthy();
expect(countSelectorRules(mobileCss ?? "", ".devserver-preview-header")).toBe(1);
expect(selectorRule(mobileCss ?? "", ".devserver-preview-header")).toMatch(/flex-wrap:\s*wrap/);
expect(countSelectorRules(mobileCss ?? "", ".devserver-preview-modal-launcher__copy")).toBe(1);
expect(selectorRule(mobileCss ?? "", ".devserver-preview-modal-launcher__copy")).toMatch(/flex-wrap:\s*wrap/);
expect(selectorRule(mobileCss ?? "", ".devserver-preview-url-badge")).toMatch(/max-width:\s*100%/);
expect(selectorRule(mobileCss ?? "", ".dev-server-header-title")).toMatch(/flex-wrap:\s*wrap/);
});
it("defines narrow right-dock launcher and modal rules without duplicating mobile media rules", () => {
const css = loadAllAppCss();
const containerCss = extractAtRuleBlocks(css, "@container right-dock-body (max-width: 768px)")[0];
expect(containerCss).toBeTruthy();
expect(containerCss ?? "").toMatch(/\.devserver-preview-panel,\s*\.devserver-preview-modal-launcher\s*\{[\s\S]*grid-column:\s*auto/);
expect(containerCss ?? "").toMatch(/\.devserver-preview-modal\s*\{[\s\S]*width:\s*min\(calc\(var\(--space-2xl\) \* 20\), calc\(100vw - var\(--space-md\) \* 2\)\)/);
expect(containerCss ?? "").toMatch(/\.devserver-preview-panel \.devserver-preview-container/);
expect(containerCss ?? "").not.toMatch(/\.dev-server-logs,\s*\.devserver-preview-container,\s*\.devserver-preview-iframe/);
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)/);
expect(css).toMatch(/@container right-dock-body \(max-width: 768px\)/);
});
it("renders preview header elements and keeps URL badge outside preview actions", () => {
mockUseDevServer.mockReturnValue(createDevServerHookState());
mockUseDevServerLogs.mockReturnValue({
entries: [],
loading: false,
loadingMore: false,
hasMore: false,
total: 0,
loadMore: vi.fn(),
});
mockUsePreviewEmbed.mockReturnValue({
embedStatus: "embedded",
setEmbedStatus: vi.fn(),
resetEmbedStatus: vi.fn(),
iframeRef: { current: null },
isEmbedded: true,
isBlocked: false,
blockReason: null,
retry: vi.fn(),
});
render(<DevServerView addToast={vi.fn()} projectId="project-a" />);
const previewPanel = screen.getByTestId("devserver-preview-panel");
const badge = screen.getByTestId("devserver-preview-url-badge");
const actions = previewPanel.querySelector(".devserver-preview-actions");
const statusBadge = screen.getByTestId("dev-server-status-badge");
expect(previewPanel.querySelector(".devserver-preview-header")).toBeInTheDocument();
expect(statusBadge).toBeInTheDocument();
expect(actions).toBeTruthy();
expect(actions?.contains(badge)).toBe(false);
expect(badge.parentElement).toBe(previewPanel.querySelector(".devserver-preview-header"));
});
});

View File

@@ -1,786 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import fs from "fs";
import path from "path";
import { ProjectOverview } from "../ProjectOverview";
import type { ProjectInfo, ProjectHealth } from "@fusion/core";
import { useProjectHealth } from "../../hooks/useProjectHealth";
// Extended type with source node info for cross-node tests
interface ProjectInfoWithSource extends ProjectInfo {
_sourceNodeName?: string;
nodeMappings?: Array<{ nodeId: string; path: string; available: boolean; nodeName?: string }>;
}
// Default mock implementation
function createDefaultHealthMap(projectIds: string[]): Record<string, ProjectHealth> {
return projectIds.reduce((acc, id) => {
acc[id] = {
projectId: id,
status: "active" as const,
activeTaskCount: 5,
inFlightAgentCount: 2,
totalTasksCompleted: 100,
totalTasksFailed: 3,
updatedAt: new Date().toISOString(),
};
return acc;
}, {} as Record<string, ProjectHealth>);
}
// Mock the hooks
vi.mock("../../hooks/useProjectHealth", () => ({
useProjectHealth: vi.fn((projectIds: string[]) => ({
healthMap: createDefaultHealthMap(projectIds),
loading: false,
error: null,
refresh: vi.fn(),
refreshProject: vi.fn(),
})),
}));
// Mock lucide-react
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
Plus: () => <span data-testid="plus-icon">+</span>,
LayoutGrid: () => <span data-testid="grid-icon">⊞</span>,
Filter: () => <span data-testid="filter-icon">⚙</span>,
ArrowUpDown: () => <span data-testid="sort-icon">⇅</span>,
Activity: () => <span data-testid="activity-icon">⚡</span>,
CheckCircle: () => <span data-testid="check-icon">✓</span>,
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
Folder: () => <span data-testid="folder-icon">📁</span>,
Inbox: () => <span data-testid="inbox-icon">📥</span>,
Server: () => <span data-testid="server-icon">🖥</span>,
};
});
// Mock ProjectCard
vi.mock("../ProjectCard", () => ({
ProjectCard: ({
project,
onSelect,
availabilityMappings,
}: {
project: ProjectInfo;
onSelect: (p: ProjectInfo) => void;
availabilityMappings?: Array<{ displayName: string; path: string }>;
}) => (
<div
data-testid={`project-card-${project.id}`}
data-node={(availabilityMappings ?? []).map((mapping) => mapping.displayName).join(",") || "none"}
onClick={() => onSelect(project)}
>
{project.name}
{(availabilityMappings ?? []).map((mapping) => (
<span key={`${mapping.displayName}-${mapping.path}`} className="node-badge">{mapping.displayName}</span>
))}
</div>
),
}));
// Mock ProjectGridSkeleton
vi.mock("../ProjectGridSkeleton", () => ({
ProjectGridSkeleton: () => <div data-testid="project-grid-skeleton">Loading...</div>,
}));
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
const project: ProjectInfoWithSource = {
id: "proj_abc123",
name: "Test Project",
path: "/home/user/projects/test",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lastActivityAt: new Date().toISOString(),
...overrides,
};
if (!project.nodeMappings && project.nodeId) {
project.nodeMappings = [{ nodeId: project.nodeId, path: project.path, available: true }];
}
return project;
}
const noop = () => {};
const projectOverviewCss = fs.readFileSync(path.resolve(__dirname, "../ProjectOverview.css"), "utf8");
describe("ProjectOverview", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock to default state
vi.mocked(useProjectHealth).mockImplementation((projectIds: string[]) => ({
healthMap: createDefaultHealthMap(projectIds),
loading: false,
error: null,
refresh: vi.fn(),
refreshProject: vi.fn(),
}));
});
it("renders without crashing with projects", () => {
render(
<ProjectOverview
projects={[makeProject()]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
expect(screen.getByRole("heading", { name: /Dashboard/ })).toBeDefined();
});
it("keeps Dashboard header full-width while only the overview body is constrained", () => {
expect(projectOverviewCss).toContain(".project-overview > :where(.view-header)");
expect(projectOverviewCss).toContain("width: 100%;");
expect(projectOverviewCss).toContain("flex: 0 0 auto;");
expect(projectOverviewCss).toContain("background: var(--surface);");
// FNXC:Dashboard 2026-06-25-12:30: The Dashboard header intentionally dropped its
// bottom divider so the shared ViewHeader chrome matches Missions and Chat (see
// ProjectOverview.css FNXC note). Assert the divider is absent rather than present.
expect(projectOverviewCss).not.toContain("border-bottom-color: var(--border);");
expect(projectOverviewCss).toContain("max-width: 1400px;");
});
it("displays project cards when projects provided", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
expect(screen.getByTestId("project-card-proj_2")).toBeDefined();
});
it("shows empty state when no projects", () => {
render(
<ProjectOverview
projects={[]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
expect(screen.getByText("No Projects Found")).toBeDefined();
expect(screen.getByText("Add Your First Project")).toBeDefined();
});
it("triggers onAddProject when empty state CTA clicked", () => {
const onAddProject = vi.fn();
render(
<ProjectOverview
projects={[]}
onSelectProject={noop}
onAddProject={onAddProject}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
fireEvent.click(screen.getByText("Add Your First Project"));
expect(onAddProject).toHaveBeenCalled();
});
it("triggers onAddProject when header button clicked", () => {
const onAddProject = vi.fn();
render(
<ProjectOverview
projects={[makeProject()]}
onSelectProject={noop}
onAddProject={onAddProject}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
fireEvent.click(screen.getByText("Add Project"));
expect(onAddProject).toHaveBeenCalled();
});
it("displays correct stats in header", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", status: "active" }),
makeProject({ id: "proj_2", status: "active" }),
makeProject({ id: "proj_3", status: "paused" }),
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Total projects = 3 - look specifically in stats section
const statsSection = screen.getByText("Total").closest(".project-stat__content");
expect(statsSection?.querySelector(".project-stat__value")?.textContent).toBe("3");
});
it("filters projects when clicking filter tabs", async () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", name: "Active Project", status: "active" }),
makeProject({ id: "proj_2", name: "Paused Project", status: "paused" }),
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Initially shows all projects
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
expect(screen.getByTestId("project-card-proj_2")).toBeDefined();
// Click on "Active" filter
fireEvent.click(screen.getByText("Active"));
// Should only show active project
await waitFor(() => {
expect(screen.queryByTestId("project-card-proj_1")).toBeDefined();
});
});
it("shows filter counts on tabs", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", status: "active" }),
makeProject({ id: "proj_2", status: "active" }),
makeProject({ id: "proj_3", status: "paused" }),
makeProject({ id: "proj_4", status: "errored" }),
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Find the "All" tab and check its count
const allTab = screen.getByText("All").closest("button");
expect(allTab?.textContent).toContain("4");
// Active tab should show 2
const activeTab = screen.getByText("Active").closest("button");
expect(activeTab?.textContent).toContain("2");
// Paused tab should show 1
const pausedTab = screen.getByText("Paused").closest("button");
expect(pausedTab?.textContent).toContain("1");
});
it("shows no results message when filter returns empty", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", status: "active" }),
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Click on "Errored" filter - no projects match
fireEvent.click(screen.getByText("Errored"));
expect(screen.getByText("No projects match the current filter")).toBeDefined();
expect(screen.getByText("Show All Projects")).toBeDefined();
});
it("clears filter when clicking Show All Projects button", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", status: "active" }),
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// First filter to errored (empty results)
fireEvent.click(screen.getByText("Errored"));
expect(screen.getByText("No projects match the current filter")).toBeDefined();
// Click Show All Projects
fireEvent.click(screen.getByText("Show All Projects"));
// Should be back to showing all
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
});
it("does not crash when sorting by status with unknown project statuses", () => {
expect(() => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_active", name: "Active Project", status: "active" }),
makeProject({ id: "proj_unknown", name: "Unknown Project", status: "removing" as ProjectStatus }),
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
}).not.toThrow();
fireEvent.change(screen.getByLabelText("Sort projects"), {
target: { value: "status-asc" },
});
expect(screen.getByTestId("project-card-proj_active")).toBeDefined();
expect(screen.getByTestId("project-card-proj_unknown")).toBeDefined();
});
it("shows loading skeleton when loading prop is true", () => {
render(
<ProjectOverview
projects={[]}
loading={true}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
expect(screen.getByTestId("project-grid-skeleton")).toBeDefined();
});
it("calls onSelectProject when a project card is clicked", () => {
const onSelectProject = vi.fn();
const project = makeProject({ id: "proj_1", name: "Test Project" });
render(
<ProjectOverview
projects={[project]}
onSelectProject={onSelectProject}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
fireEvent.click(screen.getByTestId("project-card-proj_1"));
expect(onSelectProject).toHaveBeenCalledWith(project);
});
it("errored tab has special styling when errored projects exist", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", status: "errored" }),
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Find the errored filter tab specifically (not the stat label)
const erroredTab = screen.getAllByText("Errored").find(el => el.tagName === "BUTTON");
expect(erroredTab?.className).toContain("has-errors");
});
describe("FN-1850: Node filter and badges", () => {
it("shows node badge for project with node association", () => {
const localNode = { id: "node_local", name: "Local", type: "local" as const, status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "" };
render(
<ProjectOverview
projects={[makeProject({ id: "proj_1", nodeId: "node_local" })]}
nodes={[localNode]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
const card = screen.getByTestId("project-card-proj_1");
expect(card.getAttribute("data-node")).toBe("Local");
});
it("renders node name fallback for remote projects without local node object", () => {
// Remote project with _sourceNodeName but no matching local node
render(
<ProjectOverview
projects={[
makeProject({
id: "proj_remote",
nodeId: "node_remote_abc",
_sourceNodeName: "Remote Alpha",
}),
]}
nodes={[]} // No matching local node
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
const card = screen.getByTestId("project-card-proj_remote");
expect(card.getAttribute("data-node")).toBe("Remote Alpha");
});
it("shows node filter dropdown when projects have multiple nodes", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", nodeId: "node_1" }),
makeProject({ id: "proj_2", nodeId: "node_2" }),
]}
nodes={[
{ id: "node_1", name: "Node One", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
{ id: "node_2", name: "Node Two", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Node filter dropdown should be present
expect(screen.getByLabelText("Filter by node")).toBeDefined();
});
it("node filter dropdown filters projects by node", async () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", name: "Project One", nodeId: "node_alpha" }),
makeProject({ id: "proj_2", name: "Project Two", nodeId: "node_beta" }),
]}
nodes={[
{ id: "node_alpha", name: "Alpha", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
{ id: "node_beta", name: "Beta", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Initially shows both projects
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
expect(screen.getByTestId("project-card-proj_2")).toBeDefined();
// Select "Alpha" from the node filter
const nodeFilter = screen.getByLabelText("Filter by node");
fireEvent.change(nodeFilter, { target: { value: "node_alpha" } });
// Should only show Alpha project
await waitFor(() => {
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
});
expect(screen.queryByTestId("project-card-proj_2")).toBeNull();
});
it("shows nodes stat in header when projects span multiple nodes", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", nodeId: "node_1" }),
makeProject({ id: "proj_2", nodeId: "node_2" }),
]}
nodes={[
{ id: "node_1", name: "Node One", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
{ id: "node_2", name: "Node Two", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Nodes stat should be visible
const nodesStats = screen.getByText("Nodes").closest(".project-stat__content");
expect(nodesStats?.querySelector(".project-stat__value")?.textContent).toBe("2");
});
it("does not show node filter when all projects are local", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1" }), // No nodeId - local project
makeProject({ id: "proj_2" }),
]}
nodes={[]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Node filter should not be present
expect(screen.queryByLabelText("Filter by node")).toBeNull();
});
it("does not show nodes stat when only one node (or no node ID)", () => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1" }), // No nodeId
]}
nodes={[]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Nodes stat should not be present
expect(screen.queryByText("Nodes")).toBeNull();
});
});
describe("mobile responsive structure", () => {
it("renders overview with correct class structure for mobile CSS targets", () => {
const { container } = render(
<ProjectOverview
projects={[makeProject()]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Verify container class exists for mobile override targeting
expect(container.querySelector(".project-overview")).not.toBeNull();
// Verify header section
expect(container.querySelector(".project-overview__header")).not.toBeNull();
// Verify stats section
expect(container.querySelector(".project-overview__stats")).not.toBeNull();
// Verify filters section
expect(container.querySelector(".project-overview__filters")).not.toBeNull();
// Verify grid container
expect(container.querySelector(".project-grid")).not.toBeNull();
});
it("renders stat elements with value/label structure for mobile sizing", () => {
const { container } = render(
<ProjectOverview
projects={[makeProject()]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Stats should have the nested structure mobile CSS targets
const stats = container.querySelectorAll(".project-stat");
expect(stats.length).toBeGreaterThanOrEqual(3); // Total, Active Tasks, Completed
stats.forEach((stat) => {
expect(stat.querySelector(".project-stat__value")).not.toBeNull();
expect(stat.querySelector(".project-stat__label")).not.toBeNull();
});
});
it("renders filter tabs with correct classes for mobile stacking", () => {
const { container } = render(
<ProjectOverview
projects={[makeProject()]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
expect(container.querySelector(".project-filter-tabs")).not.toBeNull();
const tabs = container.querySelectorAll(".project-filter-tab");
expect(tabs.length).toBe(4); // All, Active, Paused, Errored
tabs.forEach((tab) => {
expect(tab.querySelector(".project-filter-count")).not.toBeNull();
});
});
it("renders sort select with aria-label for mobile full-width targeting", () => {
const { container } = render(
<ProjectOverview
projects={[makeProject()]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
expect(container.querySelector(".project-sort")).not.toBeNull();
expect(container.querySelector(".project-sort-select")).not.toBeNull();
expect(screen.getByLabelText("Sort projects")).toBeDefined();
});
it("renders node filter select with correct classes for mobile", () => {
const { container } = render(
<ProjectOverview
projects={[
makeProject({ id: "proj_1", nodeId: "node_1" }),
makeProject({ id: "proj_2", nodeId: "node_2" }),
]}
nodes={[
{ id: "node_1", name: "Node One", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
{ id: "node_2", name: "Node Two", type: "remote" as const, status: "online" as const, maxConcurrent: 4, createdAt: "", updatedAt: "" },
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
expect(container.querySelector(".project-node-filter")).not.toBeNull();
expect(container.querySelector(".project-node-filter-select")).not.toBeNull();
expect(screen.getByLabelText("Filter by node")).toBeDefined();
});
});
describe("FN-1734: Health polling scroll position regression", () => {
it("shows project cards when health hook is in loading state but healthMap has existing data", () => {
// This is the key regression test for FN-1734:
// When background polling refreshes health, loading becomes true but we
// should NOT show skeleton if we already have health data
vi.mocked(useProjectHealth).mockReturnValue({
healthMap: {
proj_1: {
projectId: "proj_1",
status: "active",
activeTaskCount: 5,
inFlightAgentCount: 2,
totalTasksCompleted: 100,
totalTasksFailed: 3,
updatedAt: new Date().toISOString(),
},
},
loading: false,
error: null,
refresh: vi.fn(),
refreshProject: vi.fn(),
});
const { container } = render(
<ProjectOverview
projects={[makeProject({ id: "proj_1", name: "Test Project" })]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Project card should be visible
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
// Skeleton should NOT be shown (we have existing health data)
expect(screen.queryByTestId("project-grid-skeleton")).toBeNull();
// Grid should be rendered
expect(container.querySelector(".project-grid")).not.toBeNull();
});
it("shows skeleton only when projects exist but no health data has been fetched", () => {
// When loading prop is true AND we have no health data yet,
// skeleton should be shown
render(
<ProjectOverview
projects={[makeProject({ id: "proj_1", name: "Test Project" })]}
loading={true} // Projects are loading
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Skeleton should be shown (projects loading)
expect(screen.getByTestId("project-grid-skeleton")).toBeDefined();
});
it("shows skeleton when health hook is loading with no existing health data", () => {
// When health hook returns loading=true AND we have no health data yet,
// skeleton should be shown even if loading prop is false
vi.mocked(useProjectHealth).mockReturnValue({
healthMap: {}, // Empty - no health data yet
loading: true, // Health is still loading
error: null,
refresh: vi.fn(),
refreshProject: vi.fn(),
});
render(
<ProjectOverview
projects={[makeProject({ id: "proj_1", name: "Test Project" })]}
loading={false}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
// Skeleton should be shown (health loading with no data)
expect(screen.getByTestId("project-grid-skeleton")).toBeDefined();
});
});
});

View File

@@ -1,489 +0,0 @@
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
const COMPONENTS_DIR = resolve(__dirname, "..");
function readComponentCss(fileName: string): string {
return readFileSync(join(COMPONENTS_DIR, fileName), "utf-8");
}
function extractMediaBlocks(css: string, query: string): string[] {
const blocks: string[] = [];
let cursor = 0;
while (cursor < css.length) {
const start = css.indexOf(`@media ${query}`, cursor);
if (start < 0) break;
const open = css.indexOf("{", start);
let depth = 1;
let i = open + 1;
while (i < css.length && depth > 0) {
if (css[i] === "{") depth += 1;
else if (css[i] === "}") depth -= 1;
i += 1;
}
blocks.push(css.slice(open + 1, i - 1));
cursor = i;
}
return blocks;
}
function findRule(blocks: string[], selector: RegExp): string {
const globalSelector = new RegExp(selector.source, selector.flags.includes("g") ? selector.flags : `${selector.flags}g`);
const matches = blocks.flatMap((block) => [...block.matchAll(globalSelector)].map((match) => match[0]));
const rule = matches.at(-1) ?? "";
expect(rule).toBeTruthy();
return rule;
}
function expectNoHardcodedWhiteBackground(rule: string): void {
expect(rule).not.toMatch(/background(?:-color)?\s*:[^;]*(?:#fff|#ffffff|\bwhite\b)/i);
}
describe("WorkflowNodeEditor themed React Flow CSS contract", () => {
it("matches the shared Insights/ViewHeader chrome", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const headerRule = findRule([editorCss], /\.wf-editor-header\s*\{[^}]*\}/);
const titleRule = findRule([editorCss], /\.wf-editor-header h2\s*\{[^}]*\}/);
const iconRule = findRule([editorCss], /\.wf-editor-header h2 svg\s*\{[^}]*\}/);
expect(headerRule).toMatch(/min-height\s*:\s*var\(--view-header-min-height\)\s*;/);
expect(headerRule).toMatch(/padding\s*:\s*var\(--space-lg\) var\(--space-xl\)\s*;/);
expect(headerRule).toMatch(/background\s*:\s*var\(--surface\)\s*;/);
// FNXC:WorkflowEditorFloatingModal 2026-06-25-19:06: FloatingWindow owns the modal frame; the embedded editor header keeps shared ViewHeader sizing but no inner divider so the floating shell does not render double chrome.
expect(headerRule).toMatch(/border-bottom\s*:\s*none\s*;/);
expect(titleRule).toMatch(/font-size\s*:\s*1\.125rem\s*;/);
expect(titleRule).toMatch(/font-weight\s*:\s*600\s*;/);
expect(iconRule).toMatch(/color\s*:\s*var\(--todo\)\s*;/);
});
it("FN-6701 themes zoom controls, mini-map, and sidebar checkboxes with tokens", () => {
const baseCss = loadAllAppCssBaseOnly();
// Surface Enumeration: WorkflowNodeEditor.tsx is the only React Flow <Controls /> / <MiniMap pannable zoomable /> mount; WorkflowResultsTab and MobileWorkflowGraphView do not mount those affordances. Left-sidebar checkbox surfaces are WorkflowSettingsPanel, WorkflowFieldsPanel, and WorkflowColumnPanel trait toggles; inspector .wf-field--checkbox stays covered by WorkflowNodeEditor.css.
const controlsRule = findRule([baseCss], /\.wf-editor-canvas \.react-flow__controls\s*\{[^}]*\}/);
expect(controlsRule).toMatch(/background\s*:\s*var\(--surface\)\s*;/);
expect(controlsRule).toMatch(/border\s*:\s*var\(--btn-border-width\) solid var\(--border\)\s*;/);
expect(controlsRule).toMatch(/color\s*:\s*var\(--text\)\s*;/);
expectNoHardcodedWhiteBackground(controlsRule);
const controlsButtonRule = findRule([baseCss], /\.wf-editor-canvas \.react-flow__controls-button\s*\{[^}]*\}/);
expect(controlsButtonRule).toMatch(/background\s*:\s*var\(--surface\)\s*;/);
expect(controlsButtonRule).toMatch(/border-bottom\s*:\s*var\(--btn-border-width\) solid var\(--border\)\s*;/);
expect(controlsButtonRule).toMatch(/color\s*:\s*var\(--text\)\s*;/);
expect(controlsButtonRule).toMatch(/fill\s*:\s*currentColor\s*;/);
expectNoHardcodedWhiteBackground(controlsButtonRule);
const controlsButtonHoverRule = findRule(
[baseCss],
/\.wf-editor-canvas \.react-flow__controls-button:hover\s*\{[^}]*\}/,
);
expect(controlsButtonHoverRule).toMatch(/background\s*:\s*var\(--surface-hover\)\s*;/);
expect(controlsButtonHoverRule).toMatch(/color\s*:\s*var\(--text\)\s*;/);
expectNoHardcodedWhiteBackground(controlsButtonHoverRule);
const controlsSvgRule = findRule([baseCss], /\.wf-editor-canvas \.react-flow__controls-button svg\s*\{[^}]*\}/);
expect(controlsSvgRule).toMatch(/fill\s*:\s*currentColor\s*;/);
expect(controlsSvgRule).toMatch(/stroke\s*:\s*currentColor\s*;/);
const minimapRule = findRule([baseCss], /\.wf-editor-canvas \.react-flow__minimap\s*\{[^}]*\}/);
expect(minimapRule).toMatch(/background\s*:\s*var\(--surface\)\s*;/);
expect(minimapRule).toMatch(/border\s*:\s*var\(--btn-border-width\) solid var\(--border\)\s*;/);
expectNoHardcodedWhiteBackground(minimapRule);
const minimapNodeRule = findRule([baseCss], /\.wf-editor-canvas \.react-flow__minimap-node\s*\{[^}]*\}/);
expect(minimapNodeRule).not.toMatch(/\bfill\s*:/);
expect(minimapNodeRule).toMatch(/stroke\s*:\s*var\(--border-strong, var\(--border\)\)\s*;/);
const minimapMaskRule = findRule([baseCss], /\.wf-editor-canvas \.react-flow__minimap-mask\s*\{[^}]*\}/);
expect(minimapMaskRule).toMatch(/fill\s*:\s*color-mix\(in srgb, var\(--surface\) 70%, transparent\)\s*;/);
const minimapToggleRule = findRule([baseCss], /\.wf-minimap-toggle\s*\{[^}]*\}/);
expect(minimapToggleRule).toMatch(/position\s*:\s*absolute\s*;/);
expect(minimapToggleRule).toMatch(/background\s*:\s*var\(--surface\)\s*;/);
expect(minimapToggleRule).toMatch(/border\s*:\s*var\(--btn-border-width\) solid var\(--border\)\s*;/);
expectNoHardcodedWhiteBackground(minimapToggleRule);
for (const selector of [
/\.wf-setting--checkbox input\[type="checkbox"\]\s*\{[^}]*\}/,
/\.wf-field--checkbox input\[type="checkbox"\]\s*\{[^}]*\}/,
/\.wf-column-trait input\[type="checkbox"\]\s*\{[^}]*\}/,
/\.wf-column-agent-mode-option input\[type="radio"\]\s*\{[^}]*\}/,
]) {
const checkboxRule = findRule([baseCss], selector);
expect(checkboxRule).toMatch(/accent-color\s*:\s*var\(--todo\)\s*;/);
expectNoHardcodedWhiteBackground(checkboxRule);
}
const lightThemeOverrides = [...baseCss.matchAll(/\[data-theme="light"\][^{]*\{[^}]*\}/g)].map((match) => match[0]);
for (const overrideRule of lightThemeOverrides.filter((rule) => /react-flow__|wf-(?:setting|field|column-trait)/.test(rule))) {
expect(overrideRule).toMatch(/var\(--/);
expectNoHardcodedWhiteBackground(overrideRule);
}
});
});
describe("WorkflowNodeEditor edge visibility CSS contract", () => {
it("keeps swimlane bands translucent so built-in workflow edges remain visible", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const columnBandRule = findRule([editorCss], /\.wf-column-band\s*\{[^}]*\}/);
expect(columnBandRule).toMatch(/background\s*:\s*color-mix\(in srgb, var\(--bg-secondary\) 65%, transparent\)\s*;/);
expect(columnBandRule).toMatch(/pointer-events\s*:\s*none\s*;/);
const reworkRule = findRule([editorCss], /\.wf-edge-rework \.react-flow__edge-path\s*\{[^}]*\}/);
expect(reworkRule).toMatch(/stroke\s*:\s*var\(--accent, var\(--ws-info\)\)\s*;/);
expect(reworkRule).toMatch(/stroke-dasharray\s*:\s*5 4\s*;/);
const failureRule = findRule([editorCss], /\.react-flow__edge\.wf-edge-failure \.react-flow__edge-path\s*\{[^}]*\}/);
expect(failureRule).toMatch(/stroke\s*:\s*var\(--ws-error\)\s*;/);
expect(failureRule).toMatch(/stroke-dasharray\s*:\s*2 4\s*;/);
});
});
describe("WorkflowNodeEditor sidebar overflow CSS contract", () => {
it("FN-6379 clamps horizontal overflow on desktop and list-stage sidebars", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
const desktopSidebarRule = findRule([editorCss], /\.wf-editor-sidebar\s*\{(?=[^}]*width\s*:\s*300px)[^}]*\}/);
expect(desktopSidebarRule).toMatch(/width\s*:\s*300px\s*;/);
expect(desktopSidebarRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(desktopSidebarRule).toMatch(/overflow-x\s*:\s*hidden\s*;/);
expect(desktopSidebarRule).toMatch(/overflow-y\s*:\s*auto\s*;/);
const listStageSidebarRule = findRule(mobileBlocks, /\.wf-editor-body--list-stage \.wf-editor-sidebar\s*\{[^}]*\}/);
expect(listStageSidebarRule).toMatch(/width\s*:\s*100%\s*;/);
expect(listStageSidebarRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(listStageSidebarRule).toMatch(/overflow-x\s*:\s*hidden\s*;/);
expect(listStageSidebarRule).toMatch(/overflow-y\s*:\s*auto\s*;/);
const collapsedSidebarRule = findRule([editorCss], /\.wf-editor-body--sidebar-collapsed \.wf-editor-sidebar\s*\{[^}]*\}/);
expect(collapsedSidebarRule).toMatch(/display\s*:\s*none\s*;/);
const restoreRule = findRule([editorCss], /\.wf-sidebar-shell-restore\s*\{[^}]*\}/);
expect(restoreRule).not.toMatch(/position\s*:\s*absolute\s*;/);
expect(restoreRule).toMatch(/flex\s*:\s*0 0 auto\s*;/);
expect(restoreRule).toMatch(/width\s*:\s*30px\s*;/);
expect(restoreRule).toMatch(/padding-inline\s*:\s*0\s*;/);
expect(restoreRule).toMatch(/white-space\s*:\s*nowrap\s*;/);
});
it("FN-6379 keeps sidebar children from forcing horizontal scroll", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const listRule = findRule([editorCss], /\.wf-editor-list\s*\{[^}]*\}/);
expect(listRule).toMatch(/min-width\s*:\s*0\s*;/);
const listItemRule = findRule([editorCss], /\.wf-editor-list-item\s*\{[^}]*\}/);
expect(listItemRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(listItemRule).toMatch(/overflow\s*:\s*hidden\s*;/);
expect(listItemRule).toMatch(/text-overflow\s*:\s*ellipsis\s*;/);
expect(listItemRule).toMatch(/white-space\s*:\s*nowrap\s*;/);
const paletteRule = findRule([editorCss], /\.wf-editor-palette\s*\{[^}]*\}/);
expect(paletteRule).toMatch(/min-width\s*:\s*0\s*;/);
const paletteButtonRule = findRule(
[editorCss],
/\.wf-palette-btn,\s*\.wf-editor-action,\s*\.wf-editor-delete,\s*\.wf-editor-save\s*\{[^}]*\}/,
);
expect(paletteButtonRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(paletteButtonRule).toMatch(/overflow-wrap\s*:\s*anywhere\s*;/);
const actionNoWrapRule = findRule(
[editorCss],
/\.wf-editor-toolbar \.wf-editor-action,\s*\.wf-editor-toolbar \.wf-editor-delete,\s*\.wf-editor-toolbar \.wf-editor-save,\s*\.wf-editor-readonly-banner \.wf-editor-action,\s*\.wf-editor-readonly-banner \.wf-editor-save\s*\{[^}]*\}/,
);
expect(actionNoWrapRule).toMatch(/white-space\s*:\s*nowrap\s*;/);
expect(actionNoWrapRule).toMatch(/overflow-wrap\s*:\s*normal\s*;/);
const sidebarCodeRule = findRule([editorCss], /\.wf-editor-sidebar \.wf-code-source\s*\{[^}]*\}/);
expect(sidebarCodeRule).toMatch(/overflow-x\s*:\s*hidden\s*;/);
expect(sidebarCodeRule).toMatch(/overflow-wrap\s*:\s*anywhere\s*;/);
expect(sidebarCodeRule).toMatch(/white-space\s*:\s*pre-wrap\s*;/);
});
});
describe("WorkflowNodeEditor mobile CSS contract", () => {
it("FN-7827 keeps workflow editor shell unclamped across floating, embedded, and mobile hosts", () => {
const baseCss = loadAllAppCssBaseOnly();
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
// FNXC:WorkflowEditorFloatingModal 2026-07-11-00:00: Surface Enumeration for FN-7827 covers the desktop FloatingWindow base shell, embedded main-content pane, and mobile full-screen sheet so the fix encodes the invariant instead of only the reported 80vh repro.
const desktopModalRule = findRule([baseCss], /\.wf-editor-modal\s*\{[^}]*\}/);
expect(desktopModalRule).toMatch(/max-height\s*:\s*none\s*;/);
expect(desktopModalRule).toMatch(/max-width\s*:\s*none\s*;/);
expect(desktopModalRule).not.toMatch(/max-height\s*:\s*80vh\s*;/);
const embeddedModalRule = findRule([baseCss], /\.wf-editor-modal--embedded\s*\{[^}]*\}/);
expect(embeddedModalRule).toMatch(/max-height\s*:\s*none\s*;/);
const mobileModalRule = findRule(
mobileBlocks,
/\.wf-editor-modal:not\(\.wf-editor-modal--embedded\),\s*\.wf-create-modal\s*\{[^}]*\}/,
);
expect(mobileModalRule).toMatch(/height\s*:\s*100dvh\s*;/);
expect(mobileModalRule).toMatch(/max-height\s*:\s*100dvh\s*;/);
});
it("FN-5992 lets FloatingWindow own desktop minimum while keeping mobile overrides", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
const desktopModalRule = findRule([editorCss], /\.wf-editor-modal\s*\{[^}]*\}/);
// FNXC:WorkflowEditorFloatingModal 2026-06-25-19:06: Desktop minimum size moved from the inner .wf-editor-modal CSS to the FloatingWindow minSize contract so persisted resizes clamp at the shared shell instead of fighting nested min-width rules.
expect(desktopModalRule).toMatch(/min-width\s*:\s*0\s*;/);
// FN-6: the mobile viewport-takeover rule is scoped to the dialog presentation
// via :not(.wf-editor-modal--embedded) so the embedded main-view variant keeps its
// 100%-of-pane sizing. Match the scoped selector; .wf-create-modal has no embedded
// variant and stays unscoped.
const editorModalRule = findRule(
mobileBlocks,
/\.wf-editor-modal:not\(\.wf-editor-modal--embedded\),\s*\.wf-create-modal\s*\{[^}]*\}/,
);
expect(editorModalRule).toMatch(/width\s*:\s*100vw\s*;/);
expect(editorModalRule).toMatch(/height\s*:\s*100dvh\s*;/);
expect(editorModalRule).toMatch(/border-radius\s*:\s*0\s*;/);
expect(editorModalRule).toMatch(/resize\s*:\s*none\s*;/);
const sidebarRule = findRule(mobileBlocks, /\.wf-editor-body--list-stage \.wf-editor-sidebar\s*\{[^}]*\}/);
expect(sidebarRule).toMatch(/width\s*:\s*100%\s*;/);
const inspectorRule = findRule(mobileBlocks, /\.wf-editor-inspector\s*\{[^}]*\}/);
expect(inspectorRule).toMatch(/width\s*:\s*100%\s*;/);
const settingsRule = findRule(mobileBlocks, /\.wf-editor-body \.wf-settings-panel\s*\{[^}]*\}/);
expect(settingsRule).toMatch(/width\s*:\s*100%\s*;/);
expect(settingsRule).toMatch(/min-width\s*:\s*0\s*;/);
const canvasWrapRule = findRule(mobileBlocks, /\.wf-editor-canvas-wrap\s*\{[^}]*\}/);
expect(canvasWrapRule).toMatch(/min-height\s*:\s*0\s*;/);
});
it("FN-6058 keeps mobile workflow editor controls from crowding the canvas", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
const toolbarRule = findRule(mobileBlocks, /\.wf-editor-toolbar\s*\{[^}]*\}/);
expect(toolbarRule).toMatch(/flex-wrap\s*:\s*nowrap\s*;/);
expect(toolbarRule).toMatch(/overflow-x\s*:\s*auto\s*;/);
const editorStageCanvasRule = findRule(mobileBlocks, /\.wf-editor-body--editor-stage \.wf-editor-canvas\s*\{[^}]*\}/);
expect(editorStageCanvasRule).toMatch(/flex\s*:\s*1 1 auto\s*;/);
expect(editorStageCanvasRule).toMatch(/min-height\s*:\s*0\s*;/);
const inspectorRule = findRule(mobileBlocks, /\.wf-editor-body--editor-stage \.wf-editor-inspector\s*\{[^}]*\}/);
expect(inspectorRule).toMatch(/width\s*:\s*100%\s*;/);
expect(inspectorRule).toMatch(/max-height\s*:\s*none\s*;/);
expect(inspectorRule).toMatch(/flex\s*:\s*1 1 auto\s*;/);
expect(inspectorRule).toMatch(/min-height\s*:\s*0\s*;/);
const mobileDetailCanvasRule = findRule(mobileBlocks, /\.wf-editor-body--mobile-node-detail \.wf-editor-canvas-wrap\s*\{[^}]*\}/);
expect(mobileDetailCanvasRule).toMatch(/display\s*:\s*none\s*;/);
const mobileDetailInspectorRule = findRule(mobileBlocks, /\.wf-editor-body--mobile-node-detail \.wf-editor-inspector\s*\{[^}]*\}/);
expect(mobileDetailInspectorRule).toMatch(/display\s*:\s*flex\s*;/);
expect(mobileDetailInspectorRule).toMatch(/flex\s*:\s*1 1 auto\s*;/);
expect(mobileDetailInspectorRule).toMatch(/min-height\s*:\s*0\s*;/);
expect(mobileDetailInspectorRule).toMatch(/max-height\s*:\s*none\s*;/);
const mobileEdgeDetailCanvasRule = findRule(mobileBlocks, /\.wf-editor-body--mobile-edge-detail \.wf-editor-canvas-wrap\s*\{[^}]*\}/);
expect(mobileEdgeDetailCanvasRule).toMatch(/display\s*:\s*none\s*;/);
const mobileEdgeDetailInspectorRule = findRule(mobileBlocks, /\.wf-editor-body--mobile-edge-detail \.wf-editor-inspector\s*\{[^}]*\}/);
expect(mobileEdgeDetailInspectorRule).toMatch(/display\s*:\s*flex\s*;/);
expect(mobileEdgeDetailInspectorRule).toMatch(/flex\s*:\s*1 1 auto\s*;/);
expect(mobileEdgeDetailInspectorRule).toMatch(/max-height\s*:\s*none\s*;/);
const mobileTabsRule = findRule([editorCss], /\.wf-mobile-tabs\s*\{[^}]*\}/);
expect(mobileTabsRule).toMatch(/flex\s*:\s*0 0 auto\s*;/);
const collapsedToggleRule = findRule([editorCss], /\.wf-inspector-toggle--collapsed\s*\{[^}]*\}/);
expect(collapsedToggleRule).toMatch(/position\s*:\s*absolute\s*;/);
expect(collapsedToggleRule).toMatch(/bottom\s*:\s*var\(--space-sm\)\s*;/);
});
it("FN-7236 keeps simple-editor tabs as the touch-pannable horizontal scroller", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
// Surface Enumeration: the same .wf-mobile-tabs/.wf-mobile-tab rules render for mobile automatic simple layout and desktop/tablet compact simple layout because they intentionally live outside the mobile media query.
const tabsRule = findRule([editorCss], /\.wf-mobile-tabs\s*\{[^}]*\}/);
expect(tabsRule).toMatch(/box-sizing\s*:\s*border-box\s*;/);
expect(tabsRule).toMatch(/display\s*:\s*flex\s*;/);
expect(tabsRule).toMatch(/flex\s*:\s*0 0 auto\s*;/);
expect(tabsRule).toMatch(/width\s*:\s*100%\s*;/);
expect(tabsRule).toMatch(/inline-size\s*:\s*100%\s*;/);
expect(tabsRule).toMatch(/max-width\s*:\s*100%\s*;/);
expect(tabsRule).toMatch(/max-inline-size\s*:\s*100%\s*;/);
expect(tabsRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(tabsRule).toMatch(/min-inline-size\s*:\s*0\s*;/);
expect(tabsRule).toMatch(/overflow-x\s*:\s*auto\s*;/);
expect(tabsRule).toMatch(/overflow-y\s*:\s*hidden\s*;/);
expect(tabsRule).toMatch(/overscroll-behavior-inline\s*:\s*contain\s*;/);
expect(tabsRule).toMatch(/touch-action\s*:\s*pan-x pan-y\s*;/);
expect(tabsRule).toMatch(/-webkit-overflow-scrolling\s*:\s*touch\s*;/);
const tabRule = findRule([editorCss], /\.wf-mobile-tab\s*\{[^}]*\}/);
expect(tabRule).toMatch(/box-sizing\s*:\s*border-box\s*;/);
expect(tabRule).toMatch(/flex\s*:\s*0 0 auto\s*;/);
expect(tabRule).toMatch(/min-width\s*:\s*max-content\s*;/);
expect(tabRule).toMatch(/white-space\s*:\s*nowrap\s*;/);
expect(tabRule).toMatch(/touch-action\s*:\s*pan-x pan-y\s*;/);
expect(tabRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
});
it("FN-6034 keeps the desktop graph canvas shrinkable while FloatingWindow owns the modal minimum", () => {
const baseCss = loadAllAppCssBaseOnly();
const desktopModalRule = findRule([baseCss], /\.wf-editor-modal\s*\{[^}]*\}/);
// FNXC:WorkflowEditorFloatingModal 2026-06-25-19:06: The inner editor remains shrinkable (min-width: 0); the non-embedded floating shell enforces the 640px minimum through WorkflowNodeEditor.tsx minSize.
expect(desktopModalRule).toMatch(/min-width\s*:\s*0\s*;/);
const bodyRule = findRule([baseCss], /\.wf-editor-body\s*\{[^}]*\}/);
expect(bodyRule).toMatch(/min-width\s*:\s*0\s*;/);
const canvasWrapRule = findRule([baseCss], /\.wf-editor-canvas-wrap\s*\{[^}]*\}/);
expect(canvasWrapRule).toMatch(/min-width\s*:\s*0\s*;/);
const canvasRule = findRule([baseCss], /\.wf-editor-canvas\s*\{[^}]*\}/);
expect(canvasRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(canvasRule).toMatch(/width\s*:\s*100%\s*;/);
expect(canvasRule).toMatch(/overflow\s*:\s*hidden\s*;/);
});
it("FN-6034 makes the mobile React Flow surface fill the editor stage without horizontal overflow", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
const editorBodyRule = findRule(mobileBlocks, /\.wf-editor-body\s*\{[^}]*\}/);
expect(editorBodyRule).toMatch(/width\s*:\s*100%\s*;/);
expect(editorBodyRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(editorBodyRule).toMatch(/overflow-x\s*:\s*hidden\s*;/);
const editorStageWrapRule = findRule(mobileBlocks, /\.wf-editor-body--editor-stage \.wf-editor-canvas-wrap\s*\{[^}]*\}/);
expect(editorStageWrapRule).toMatch(/flex\s*:\s*0 1 auto\s*;/);
expect(editorStageWrapRule).toMatch(/width\s*:\s*100%\s*;/);
expect(editorStageWrapRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(editorStageWrapRule).toMatch(/overflow\s*:\s*hidden\s*;/);
const canvasRule = findRule(mobileBlocks, /\.wf-editor-canvas\s*\{[^}]*\}/);
expect(canvasRule).toMatch(/width\s*:\s*100%\s*;/);
expect(canvasRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(canvasRule).toMatch(/max-width\s*:\s*100%\s*;/);
expect(canvasRule).toMatch(/overflow\s*:\s*hidden\s*;/);
const reactFlowSurfaceRule = findRule(
mobileBlocks,
/\.wf-editor-canvas \.react-flow,\s*\.wf-editor-canvas \.react-flow__renderer,\s*\.wf-editor-canvas \.react-flow__pane,\s*\.wf-editor-canvas \.react-flow__viewport\s*\{[^}]*\}/,
);
expect(reactFlowSurfaceRule).toMatch(/width\s*:\s*100%\s*;/);
expect(reactFlowSurfaceRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(reactFlowSurfaceRule).toMatch(/max-width\s*:\s*100%\s*;/);
expect(reactFlowSurfaceRule).toMatch(/height\s*:\s*100%\s*;/);
});
it("FN-6034 preserves mobile staged editor visibility and inspector stacking", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
const listStageHiddenRule = findRule(
mobileBlocks,
/\.wf-editor-body--list-stage \.wf-editor-canvas-wrap,\s*\.wf-editor-body--list-stage \.wf-editor-inspector\s*\{[^}]*\}/,
);
expect(listStageHiddenRule).toMatch(/display\s*:\s*none\s*;/);
const editorStageSidebarRule = findRule(mobileBlocks, /\.wf-editor-body--editor-stage \.wf-editor-sidebar\s*\{[^}]*\}/);
expect(editorStageSidebarRule).toMatch(/display\s*:\s*none\s*;/);
const inspectorRule = findRule(mobileBlocks, /\.wf-editor-body--editor-stage \.wf-editor-inspector\s*\{[^}]*\}/);
expect(inspectorRule).toMatch(/width\s*:\s*100%\s*;/);
expect(inspectorRule).toMatch(/min-width\s*:\s*0\s*;/);
expect(inspectorRule).toMatch(/border-top\s*:\s*1px solid var\(--border\)\s*;/);
});
it("FN-6033 keeps workflow editor touch target increases mobile-scoped", () => {
const baseCss = loadAllAppCssBaseOnly();
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
const modalRule = findRule([baseCss], /\.wf-editor-modal\s*\{[^}]*\}/);
expect(modalRule).toMatch(/--wf-editor-touch-target\s*:\s*calc\(var\(--space-xl\) \+ var\(--space-lg\) \+ var\(--space-xs\)\)\s*;/);
const listAndActionRule = findRule(
mobileBlocks,
/\.wf-editor-list-item,\s*\.wf-editor-new,\s*\.wf-editor-import,[^}]*\.wf-settings-panel button\s*\{[^}]*\}/,
);
expect(listAndActionRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
const editorButtonsRule = findRule(
mobileBlocks,
/\.wf-editor-list-item,\s*\.wf-editor-new,\s*\.wf-editor-import,[^}]*\.wf-ai-toggle\s*\{[^}]*\}/,
);
expect(editorButtonsRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-md\)\s*;/);
const inlineControlsRule = findRule(
mobileBlocks,
/\.wf-field input,\s*\.wf-field textarea,\s*\.wf-field select,[^}]*\.wf-ai-prompt\s*\{[^}]*\}/,
);
expect(inlineControlsRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
expect(inlineControlsRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-md\)\s*;/);
const mobileBackRule = findRule(mobileBlocks, /\.wf-editor-mobile-back\s*\{[^}]*\}/);
expect(mobileBackRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
expect(mobileBackRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-md\)\s*;/);
const desktopListRule = findRule([baseCss], /\.wf-editor-list-item\s*\{[^}]*\}/);
expect(desktopListRule).not.toMatch(/min-height\s*:/);
expect(editorCss).not.toMatch(/@media \(max-width: 768px\)\s*\{[^}]*\.btn\s*\{/s);
});
it("FN-5992 covers create dialog and AI panel mobile overlays", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
// FNXC:WorkflowEditorFloatingModal 2026-06-25-19:02: The workflow editor modal now uses FloatingWindow, so the mobile overlay stretch contract only applies to the create dialog overlay. The editor takeover is covered by floating-window mobile rules and must not reintroduce a .wf-editor-modal overlay selector.
const overlayRule = findRule(
mobileBlocks,
/\.modal-overlay:has\(\.wf-create-modal\)\s*\{[^}]*\}/,
);
expect(overlayRule).toMatch(/padding-top\s*:\s*0\s*;/);
expect(overlayRule).toMatch(/align-items\s*:\s*stretch\s*;/);
const templateListRule = findRule(mobileBlocks, /\.wf-template-list\s*\{[^}]*\}/);
expect(templateListRule).toMatch(/max-height\s*:\s*40vh\s*;/);
const aiPanelRule = findRule(mobileBlocks, /\.wf-ai-panel\s*\{[^}]*\}/);
expect(aiPanelRule).toMatch(/position\s*:\s*fixed\s*;/);
expect(aiPanelRule).toMatch(/inset\s*:\s*var\(--space-sm\)\s*;/);
expect(aiPanelRule).toMatch(/z-index\s*:\s*30\s*;/);
});
it("FN-5992 adds standalone mobile workflow panel overrides", () => {
const settingsCss = readComponentCss("WorkflowSettingsPanel.css");
const fieldsCss = readComponentCss("WorkflowFieldsPanel.css");
const selectorCss = readComponentCss("WorkflowSelector.css");
const settingsMobile = extractMediaBlocks(settingsCss, "(max-width: 768px)");
const settingsRule = findRule(settingsMobile, /\.wf-settings-panel\s*\{[^}]*\}/);
expect(settingsRule).toMatch(/width\s*:\s*100%\s*;/);
expect(settingsRule).toMatch(/min-width\s*:\s*0\s*;/);
const fieldsMobile = extractMediaBlocks(fieldsCss, "(max-width: 768px)");
const fieldsRule = findRule(fieldsMobile, /\.wf-fields-panel\s*\{[^}]*\}/);
expect(fieldsRule).toMatch(/width\s*:\s*100%\s*;/);
expect(fieldsRule).toMatch(/min-width\s*:\s*0\s*;/);
const selectorMobile = extractMediaBlocks(selectorCss, "(max-width: 768px)");
const selectorRule = findRule(selectorMobile, /\.workflow-selector\s*\{[^}]*\}/);
expect(selectorRule).toMatch(/flex-direction\s*:\s*column\s*;/);
const manageRule = findRule(
selectorMobile,
/\.workflow-selector select,\s*\.workflow-selector-manage\s*\{[^}]*\}/,
);
expect(manageRule).toMatch(/width\s*:\s*100%\s*;/);
});
});

View File

@@ -1,534 +0,0 @@
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, waitFor } from "@testing-library/react";
import { Board } from "../Board";
import { loadAllAppCss } from "../../test/cssFixture";
const apiMocks = vi.hoisted(() => ({
fetchBoardWorkflows: vi.fn(),
fetchWorkflowSteps: vi.fn(),
}));
vi.mock("../../api", () => ({
fetchBoardWorkflows: apiMocks.fetchBoardWorkflows,
fetchWorkflowSteps: apiMocks.fetchWorkflowSteps,
promoteTask: vi.fn().mockResolvedValue({}),
}));
vi.mock("../../hooks/useBlockerFanout", () => ({
useBlockerFanout: () => new Map(),
}));
vi.mock("../Column", () => ({
Column: React.memo(({ column, tasks }: { column: string; tasks?: unknown[] }) => (
<div className="column" data-task-count={tasks?.length ?? 0} data-testid={`column-${column}`} />
)),
}));
function ensureMatchMedia() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
}
}
function mockViewport(width: number) {
ensureMatchMedia();
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)" ? width <= 768 : false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
function extractMediaBlocks(content: string, queryPattern: RegExp): string {
const blocks: string[] = [];
const regex = new RegExp(`@media[^{}]*${queryPattern.source}[^{}]*\\{`, "g");
let match;
while ((match = regex.exec(content)) !== null) {
const startIdx = match.index + match[0].length;
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < content.length) {
if (content[endIdx] === "{") braceCount++;
if (content[endIdx] === "}") braceCount--;
endIdx++;
}
if (braceCount === 0) blocks.push(content.slice(startIdx, endIdx - 1));
}
return blocks.join("\n");
}
function extractRule(content: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return content.match(new RegExp(`${escapedSelector}\\s*\\{[^}]*\\}`))?.[0] ?? "";
}
function expectLogicalOrPhysicalMinSize(rule: string, axis: "block" | "inline"): void {
const logicalProp = axis === "block" ? "min-block-size" : "min-inline-size";
const physicalProp = axis === "block" ? "min-height" : "min-width";
expect(rule).toSatisfy((value: string) => value.includes(`${logicalProp}: 0`) || value.includes(`${physicalProp}: 0`));
}
const workflowPayload = {
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [
{ id: "triage", name: "Triage", flags: { intake: true } },
{ id: "todo", name: "Todo", flags: {} },
{ id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } },
{ id: "in-review", name: "In Review", flags: { humanReview: true } },
{ id: "done", name: "Done", flags: { complete: true } },
{ id: "archived", name: "Archived", flags: { archived: true } },
],
},
],
taskWorkflowIds: {},
};
const boardProps = {
tasks: [],
maxConcurrent: 2,
onMoveTask: vi.fn(async () => ({} as any)),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
onQuickCreate: vi.fn(async () => ({} as any)),
onNewTask: vi.fn(),
autoMerge: true,
onToggleAutoMerge: vi.fn(),
globalPaused: false,
};
describe("Board mobile initial render stabilization (FN-4574)", () => {
beforeEach(() => {
vi.clearAllMocks();
apiMocks.fetchBoardWorkflows.mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} });
apiMocks.fetchWorkflowSteps.mockResolvedValue([]);
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("preserves board column scroll during initial mobile stabilization while keeping snap style in CSS, not inline", () => {
const viewportSpy = mockViewport(375);
const raf = vi.fn<(cb: FrameRequestCallback) => number>((cb) => {
setTimeout(() => cb(0), 0);
return 1;
});
vi.stubGlobal("requestAnimationFrame", raf);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
render(<Board {...boardProps} />);
const board = document.querySelector("main.board") as HTMLElement;
expect(board).not.toBeNull();
board.scrollLeft = 500;
act(() => {
vi.runOnlyPendingTimers();
});
/*
FNXC:BoardMobile 2026-07-07-08:30:
FN-7342 (preserve board scroll during refresh stabilization) removed the `boardEl.scrollLeft = 0` reset from mobile stabilization — #board is the user's horizontal scroller, so stabilization now only normalizes document-level horizontal drift and must not force the board back to triage. The board's column scroll position is therefore preserved (500) instead of reset to 0; rAF scheduling and the CSS-not-inline snap invariant still hold.
*/
expect(board.scrollLeft).toBe(500);
expect(raf).toHaveBeenCalled();
expect(board.style.scrollSnapType).toBe("");
viewportSpy.mockRestore();
});
it("preserves board column scroll on pageshow persisted restore for mobile", () => {
const viewportSpy = mockViewport(375);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
setTimeout(() => cb(0), 0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
render(<Board {...boardProps} />);
const board = document.querySelector("main.board") as HTMLElement;
act(() => {
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(0);
board.scrollLeft = 500;
const pageShow = new Event("pageshow") as PageTransitionEvent;
Object.defineProperty(pageShow, "persisted", { configurable: true, value: true });
window.dispatchEvent(pageShow);
act(() => {
vi.runOnlyPendingTimers();
});
/*
FNXC:BoardMobile 2026-07-07-08:32:
FN-7342 keeps pageshow/bfcache stabilization scoped to document-level drift, so the board column scroll set before the restore (500) is preserved rather than re-anchored to 0.
*/
expect(board.scrollLeft).toBe(500);
viewportSpy.mockRestore();
});
it("does not throw when visualViewport resize listener lacks removeEventListener (Android seam)", () => {
const viewportSpy = mockViewport(375);
const visualViewportResizeListeners: Array<() => void> = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
setTimeout(() => cb(0), 0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: {
scale: 1,
addEventListener: (_event: string, listener: () => void) => {
visualViewportResizeListeners.push(listener);
},
},
});
const { unmount } = render(<Board {...boardProps} />);
/*
* FNXC:MobileBoard 2026-06-25-11:24:
* Board owns more than one legitimate visualViewport resize subscriber (responsive mode plus mobile re-anchor). The Android seam is the missing `removeEventListener`, so assert every registered listener is safe instead of pinning an incidental listener count.
*/
expect(visualViewportResizeListeners.length).toBeGreaterThan(0);
expect(() => {
act(() => {
for (const listener of visualViewportResizeListeners) {
listener();
}
});
}).not.toThrow();
// Exercises the Android seam: cleanup must not throw without removeEventListener.
expect(() => unmount()).not.toThrow();
viewportSpy.mockRestore();
});
it("is a desktop no-op and does not force pageshow re-anchor", () => {
const viewportSpy = mockViewport(1280);
const addEventListenerSpy = vi.spyOn(window, "addEventListener");
render(<Board {...boardProps} />);
const board = document.querySelector("main.board") as HTMLElement;
board.scrollLeft = 500;
const pageShow = new Event("pageshow") as PageTransitionEvent;
Object.defineProperty(pageShow, "persisted", { configurable: true, value: true });
window.dispatchEvent(pageShow);
act(() => {
vi.runOnlyPendingTimers();
});
expect(board.scrollLeft).toBe(500);
expect(addEventListenerSpy).toHaveBeenCalledWith("pageshow", expect.any(Function));
viewportSpy.mockRestore();
});
it("preserves FN-001 mobile board invariants and avoids button-rule mutations in .board mobile block", () => {
const cssContent = loadAllAppCss();
const mobileCss = extractMediaBlocks(cssContent, /\(max-width: 768px\)/);
const boardBlock = extractRule(mobileCss, ".board");
expect(boardBlock).toContain("scroll-snap-type: x proximity");
expect(boardBlock).toContain("overflow-anchor: none");
expect(boardBlock).not.toContain("scroll-snap-type: x mandatory");
const forbiddenBoardSelectors = [
/\.board[^\{]*\.btn\s*\{/,
/\.board[^\{]*\.btn-icon\s*\{/,
/\.board[^\{]*\.modal-close\s*\{/,
/\.board[^\{]*\.card-[^\s\{]*\s*\{/,
/\.board[^\{]*\.btn[^\{]*min-height\s*:/,
/\.board[^\{]*\.btn-icon[^\{]*min-height\s*:/,
/\.board[^\{]*\.modal-close[^\{]*min-height\s*:/,
/\.board[^\{]*\.card-[^\{]*min-height\s*:/,
];
for (const forbiddenSelector of forbiddenBoardSelectors) {
expect(mobileCss).not.toMatch(forbiddenSelector);
}
});
it("keeps the board fill-height invariant across workflow, base, tablet, and mobile CSS tiers", () => {
const cssContent = loadAllAppCss();
const baseBoardRule = extractRule(cssContent, ".board");
const workflowViewRule = extractRule(cssContent, ".board-workflow-view");
const workflowColumnsRule = extractRule(cssContent, ".board.board-workflow-columns");
const workflowColumnRule = extractRule(cssContent, ".board.board-workflow-columns > .column");
const sharedColumnRule = extractRule(cssContent, ".column");
const workflowTabletCss = extractMediaBlocks(cssContent, /\(max-width: 1024px\)/);
const workflowTabletColumnsRule = extractRule(workflowTabletCss, ".board.board-workflow-columns");
const tabletCss = extractMediaBlocks(cssContent, /\(min-width: 769px\) and \(max-width: 1024px\)/);
const mobileCss = extractMediaBlocks(cssContent, /\(max-width: 768px\)/);
const tabletBoardRule = extractRule(tabletCss, ".board");
const mobileBoardRule = extractRule(mobileCss, ".board");
const mobileColumnRule = extractRule(mobileCss, ".board > .column");
const mobileProjectContentRule = extractRule(mobileCss, ".project-content");
const mobileWorkflowViewRule = extractRule(mobileCss, ".board-workflow-view");
const mobileWorkflowColumnsRule = extractRule(mobileCss, ".board.board-workflow-columns");
const mobileWorkflowColumnRule = extractRule(mobileCss, ".board.board-workflow-columns > .column");
const projectContentRule = extractRule(cssContent, ".project-content");
expect(projectContentRule).toContain("display: flex");
/*
* FNXC:BoardMobileCss 2026-06-19-03:16:
* The fill-height invariant accepts logical min-size properties because styles.css canonicalizes .project-content to writing-mode-safe min-block-size/min-inline-size declarations.
*/
expectLogicalOrPhysicalMinSize(projectContentRule, "block");
expectLogicalOrPhysicalMinSize(projectContentRule, "inline");
expect(baseBoardRule).toContain("box-sizing: border-box");
expect(baseBoardRule).toContain("flex: 1 1 auto");
expect(baseBoardRule).toContain("height: 100%");
expect(baseBoardRule).toContain("min-height: 0");
expect(baseBoardRule).toContain("min-width: 0");
expect(workflowViewRule).toContain("display: flex");
expect(workflowViewRule).toContain("flex-direction: column");
expect(workflowViewRule).toContain("flex: 1 1 auto");
expect(workflowViewRule).toContain("height: 100%");
expect(workflowViewRule).toContain("max-height: 100%");
expect(workflowViewRule).toContain("min-height: 0");
expect(workflowColumnsRule).toContain("flex: 1 1 auto");
expect(workflowColumnsRule).toContain("display: flex");
expect(workflowColumnsRule).toContain("align-items: stretch");
expect(workflowColumnsRule).toContain("height: 100%");
expect(workflowColumnsRule).toContain("max-height: 100%");
expect(workflowColumnsRule).toContain("min-height: 0");
expect(workflowColumnsRule).toContain("scroll-snap-type: x proximity");
expect(workflowColumnsRule).not.toContain("scroll-snap-type: x mandatory");
expect(workflowTabletColumnsRule).toContain("flex: 1 1 auto");
expect(workflowTabletColumnsRule).toContain("align-items: stretch");
expect(workflowTabletColumnsRule).toContain("height: 100%");
expect(workflowTabletColumnsRule).toContain("max-height: 100%");
expect(workflowTabletColumnsRule).toContain("min-height: 0");
expect(workflowTabletColumnsRule).toContain("scroll-snap-type: x proximity");
expect(workflowTabletColumnsRule).not.toContain("scroll-snap-type: x mandatory");
expect(workflowColumnRule).toContain("flex: 1 0 300px");
expect(workflowColumnRule).toContain("min-width: 300px");
expect(workflowColumnRule).toContain("height: 100%");
expect(workflowColumnRule).toContain("min-height: 0");
expect(sharedColumnRule).toContain("min-height: 0");
expect(tabletBoardRule).toContain("grid-template-columns: repeat(6, minmax(260px, 1fr))");
expect(tabletBoardRule).toContain("overflow-x: auto");
expect(mobileBoardRule).toContain("display: flex");
expect(mobileBoardRule).toContain("scroll-snap-type: x proximity");
expect(mobileBoardRule).toContain("width: 100%");
expect(mobileColumnRule).toContain("width: 300px");
expect(mobileColumnRule).toContain("min-width: 300px");
expect(mobileColumnRule).toContain("flex-shrink: 0");
expect(mobileProjectContentRule).toContain("display: flex");
expect(mobileProjectContentRule).toContain("align-items: stretch");
expect(mobileProjectContentRule).toContain("width: 100%");
expectLogicalOrPhysicalMinSize(mobileProjectContentRule, "block");
expect(mobileProjectContentRule).toContain("overflow: hidden");
expect(mobileWorkflowViewRule).toContain("display: flex");
expect(mobileWorkflowViewRule).toContain("flex-direction: column");
expect(mobileWorkflowViewRule).toContain("flex: 1 1 auto");
expect(mobileWorkflowViewRule).toContain("width: 100%");
expect(mobileWorkflowViewRule).toContain("height: 100%");
expect(mobileWorkflowViewRule).toContain("min-height: 0");
expect(mobileWorkflowViewRule).toContain("overflow: hidden");
expect(mobileWorkflowColumnsRule).toContain("display: flex");
expect(mobileWorkflowColumnsRule).toContain("flex: 1 1 auto");
expect(mobileWorkflowColumnsRule).toContain("align-items: stretch");
expect(mobileWorkflowColumnsRule).toContain("width: 100%");
expect(mobileWorkflowColumnsRule).toContain("height: 100%");
expect(mobileWorkflowColumnsRule).toContain("min-height: 0");
expect(mobileWorkflowColumnsRule).toContain("overflow-x: auto");
expect(mobileWorkflowColumnsRule).toContain("overscroll-behavior-x: contain");
expect(mobileWorkflowColumnsRule).toContain("touch-action: pan-x pan-y");
expect(mobileWorkflowColumnsRule).toContain("scroll-snap-type: x proximity");
expect(mobileWorkflowColumnsRule).not.toContain("scroll-snap-type: x mandatory");
expect(mobileWorkflowColumnRule).toContain("flex: 1 0 300px");
expect(mobileWorkflowColumnRule).toContain("min-width: 300px");
expect(mobileWorkflowColumnRule).toContain("height: 100%");
expect(mobileWorkflowColumnRule).toContain("min-height: 0");
});
it("renders the board main element and all column children for empty and populated states", () => {
const viewportSpy = mockViewport(1280);
const { rerender } = render(<Board {...boardProps} />);
let board = document.querySelector("main.board");
expect(board).not.toBeNull();
let columns = document.querySelectorAll("[data-testid^='column-']");
expect(columns).toHaveLength(6);
for (const column of columns) {
expect(column).toHaveAttribute("data-task-count", "0");
}
rerender(
<Board
{...boardProps}
tasks={[
{ id: "FN-1", title: "Planning task", column: "triage" },
{ id: "FN-2", title: "Todo task", column: "todo" },
] as any}
/>,
);
board = document.querySelector("main.board");
expect(board).not.toBeNull();
columns = document.querySelectorAll("[data-testid^='column-']");
expect(columns).toHaveLength(6);
expect(document.querySelector("[data-testid='column-triage']")).toHaveAttribute("data-task-count", "1");
expect(document.querySelector("[data-testid='column-todo']")).toHaveAttribute("data-task-count", "1");
viewportSpy.mockRestore();
});
it("renders workflow-mode columns for empty and populated states at mobile width with and without the toolbar", async () => {
vi.useRealTimers();
const viewportSpy = mockViewport(390);
apiMocks.fetchBoardWorkflows.mockResolvedValue(workflowPayload);
const { rerender } = render(
<Board
{...boardProps}
onCreateWorkflow={vi.fn()}
onOpenWorkflowEditor={vi.fn()}
/>,
);
await waitFor(() => {
expect(document.querySelector(".board-workflow-view")).not.toBeNull();
});
expect(document.querySelector(".board-workflow-toolbar")).not.toBeNull();
let board = document.querySelector("main.board.board-workflow-columns");
expect(board).not.toBeNull();
let columns = document.querySelectorAll(".board-workflow-columns [data-testid^='column-']");
expect(columns).toHaveLength(6);
for (const column of columns) {
expect(column).toHaveClass("column");
expect(column).toHaveAttribute("data-task-count", "0");
}
rerender(
<Board
{...boardProps}
onCreateWorkflow={vi.fn()}
onOpenWorkflowEditor={vi.fn()}
tasks={[
{ id: "FN-1", title: "Workflow planning task", column: "triage" },
{ id: "FN-2", title: "Workflow todo task", column: "todo" },
] as any}
/>,
);
await waitFor(() => {
expect(document.querySelector("main.board.board-workflow-columns")).not.toBeNull();
});
board = document.querySelector("main.board.board-workflow-columns");
expect(board).not.toBeNull();
columns = document.querySelectorAll(".board-workflow-columns [data-testid^='column-']");
expect(columns).toHaveLength(6);
expect(document.querySelector(".board-workflow-columns [data-testid='column-triage']")).toHaveAttribute("data-task-count", "1");
expect(document.querySelector(".board-workflow-columns [data-testid='column-todo']")).toHaveAttribute("data-task-count", "1");
cleanup();
render(<Board {...boardProps} />);
await waitFor(() => {
expect(document.querySelector("main.board.board-workflow-columns")).not.toBeNull();
});
/*
FNXC:BoardMobile 2026-07-07-08:35:
FN-6825 (combine workflow actions in switcher) moved edit/create into the WorkflowSwitcher, so shouldRenderWorkflowControls is now `workflowOptions.length > 0` rather than gated on onCreateWorkflow/onOpenWorkflowEditor. A Board rendered without those callbacks still shows the switcher toolbar whenever workflow options exist, so the no-callbacks toolbar shell no longer disappears.
*/
expect(document.querySelector(".board-workflow-toolbar")).not.toBeNull();
expect(document.querySelectorAll(".board-workflow-columns [data-testid^='column-']")).toHaveLength(6);
viewportSpy.mockRestore();
});
it("renders workflow-mode columns for empty and populated states at tablet width", async () => {
vi.useRealTimers();
const viewportSpy = mockViewport(900);
apiMocks.fetchBoardWorkflows.mockResolvedValue(workflowPayload);
const { rerender } = render(<Board {...boardProps} />);
await waitFor(() => {
expect(document.querySelector(".board-workflow-view")).not.toBeNull();
});
let board = document.querySelector("main.board.board-workflow-columns");
expect(board).not.toBeNull();
let columns = document.querySelectorAll(".board-workflow-columns [data-testid^='column-']");
expect(columns).toHaveLength(6);
for (const column of columns) {
expect(column).toHaveClass("column");
expect(column).toHaveAttribute("data-task-count", "0");
}
rerender(
<Board
{...boardProps}
tasks={[
{ id: "FN-1", title: "Workflow planning task", column: "triage" },
{ id: "FN-2", title: "Workflow todo task", column: "todo" },
] as any}
/>,
);
await waitFor(() => {
expect(document.querySelector("main.board.board-workflow-columns")).not.toBeNull();
});
board = document.querySelector("main.board.board-workflow-columns");
expect(board).not.toBeNull();
columns = document.querySelectorAll(".board-workflow-columns [data-testid^='column-']");
expect(columns).toHaveLength(6);
expect(document.querySelector(".board-workflow-columns [data-testid='column-triage']")).toHaveAttribute("data-task-count", "1");
expect(document.querySelector(".board-workflow-columns [data-testid='column-todo']")).toHaveAttribute("data-task-count", "1");
viewportSpy.mockRestore();
});
});

View File

@@ -1,547 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { CommandCenterControls } from "../CommandCenterControls";
import { COLOR_THEMES } from "../../themeOptions";
import { ConfirmDialogProvider } from "../../../hooks/useConfirm";
const commandCenterControlsCss = readFileSync(
join(process.cwd(), "app/components/command-center/CommandCenterControls.css"),
"utf8",
);
const mocks = vi.hoisted(() => ({
fetchSettings: vi.fn(),
fetchConfig: vi.fn(),
fetchGlobalConcurrency: vi.fn(),
updateSettings: vi.fn(),
updateGlobalConcurrency: vi.fn(),
toggleGlobalPause: vi.fn(),
toggleEnginePause: vi.fn(),
refresh: vi.fn(),
appSettings: {
globalPaused: false,
enginePaused: false,
},
}));
vi.mock("../../../api/legacy", () => ({
fetchSettings: mocks.fetchSettings,
fetchConfig: mocks.fetchConfig,
fetchGlobalConcurrency: mocks.fetchGlobalConcurrency,
updateSettings: mocks.updateSettings,
updateGlobalConcurrency: mocks.updateGlobalConcurrency,
}));
vi.mock("../../../hooks/useAppSettings", () => ({
useAppSettings: () => ({
globalPaused: mocks.appSettings.globalPaused,
enginePaused: mocks.appSettings.enginePaused,
toggleGlobalPause: mocks.toggleGlobalPause,
toggleEnginePause: mocks.toggleEnginePause,
refresh: mocks.refresh,
}),
}));
function renderControls(projectId?: string) {
return render(
<ConfirmDialogProvider>
<CommandCenterControls
projectId={projectId}
colorTheme="default"
themeMode="dark"
onColorThemeChange={vi.fn()}
onThemeModeChange={vi.fn()}
/>
</ConfirmDialogProvider>,
);
}
async function flushPromises() {
await act(async () => {
await Promise.resolve();
});
}
async function advanceConfirmDebounce() {
await act(async () => {
vi.advanceTimersByTime(500);
await Promise.resolve();
});
}
function getConfirmDialog() {
return screen.getByRole("dialog", { name: /confirm concurrency change/i });
}
async function clickConfirmSave() {
fireEvent.click(within(getConfirmDialog()).getByRole("button", { name: /save change/i }));
await flushPromises();
}
async function clickConfirmCancel() {
fireEvent.click(within(getConfirmDialog()).getByRole("button", { name: /cancel/i }));
await flushPromises();
}
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mocks.appSettings.globalPaused = false;
mocks.appSettings.enginePaused = false;
mocks.fetchSettings.mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 2, maxWorktrees: 4 });
mocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/repo" });
mocks.fetchGlobalConcurrency.mockResolvedValue({
globalMaxConcurrent: 8,
currentlyActive: 3,
queuedCount: 0,
projectsActive: { "project-a": 2 },
});
mocks.updateSettings.mockResolvedValue({});
mocks.updateGlobalConcurrency.mockResolvedValue({});
mocks.refresh.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
describe("CommandCenterControls", () => {
it("renders only overview controls after team affordances move", async () => {
renderControls(undefined);
await flushPromises();
expect(screen.getByTestId("command-center-controls")).toBeDefined();
expect(screen.queryByTestId("cc-controls-org-chart")).toBeNull();
expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull();
expect(screen.getByTestId("cc-controls-engine")).toBeDefined();
expect(screen.getByTestId("cc-controls-concurrency")).toBeDefined();
expect(screen.getByTestId("cc-controls-theme")).toBeDefined();
});
it("engine controls call the existing settings toggle", async () => {
renderControls("project-a");
await flushPromises();
fireEvent.click(screen.getByRole("button", { name: /stop ai engine/i }));
expect(mocks.toggleGlobalPause).toHaveBeenCalledTimes(1);
expect(mocks.toggleEnginePause).not.toHaveBeenCalled();
});
it("shows loaded global and current-project running counts and use markers", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
expect(within(section).getByTestId("cc-global-running")).toHaveTextContent("3 running (all projects)");
expect(within(section).getByTestId("cc-project-running")).toHaveTextContent("2 running (this project)");
expect(within(section).getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).toBe(`${(3 / 32) * 100}%`);
expect(within(section).getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).toBe(`${(2 / 50) * 100}%`);
expect(within(section).queryAllByTestId(/cc-.*-use-marker/)).toHaveLength(2);
});
it("positions one active agent above zero on both use markers", async () => {
mocks.fetchGlobalConcurrency.mockResolvedValueOnce({
globalMaxConcurrent: 8,
currentlyActive: 1,
queuedCount: 0,
projectsActive: { "project-a": 1 },
});
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
expect(within(section).getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).toBe(`${(1 / 32) * 100}%`);
expect(within(section).getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).toBe(`${(1 / 50) * 100}%`);
expect(within(section).getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).not.toBe("0%");
expect(within(section).getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).not.toBe("0%");
});
it("shows truthful zero or missing project running counts only after utilization loads", async () => {
mocks.fetchGlobalConcurrency.mockResolvedValueOnce({
globalMaxConcurrent: 8,
currentlyActive: 0,
queuedCount: 0,
projectsActive: {},
});
renderControls(undefined);
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
expect(within(section).getByTestId("cc-global-running")).toHaveTextContent("0 running (all projects)");
expect(within(section).getByTestId("cc-project-running")).toHaveTextContent("0 running (this project)");
expect(within(section).getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).toBe("0%");
expect(within(section).getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).toBe("0%");
});
it("clamps over-subscribed current-use markers while keeping truthful counts", async () => {
mocks.fetchSettings.mockResolvedValueOnce({ maxConcurrent: 4, maxTriageConcurrent: 2, maxWorktrees: 4 });
mocks.fetchGlobalConcurrency.mockResolvedValueOnce({
globalMaxConcurrent: 8,
currentlyActive: 60,
queuedCount: 0,
projectsActive: { "project-a": 60 },
});
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
expect(within(section).getByTestId("cc-global-running")).toHaveTextContent("60 running (all projects)");
expect(within(section).getByTestId("cc-project-running")).toHaveTextContent("60 running (this project)");
expect(within(section).getByTestId("cc-global-use-marker").style.getPropertyValue("--use-pct")).toBe("100%");
expect(within(section).getByTestId("cc-project-use-marker").style.getPropertyValue("--use-pct")).toBe("100%");
});
it("suppresses running counts before global utilization finishes loading", async () => {
let resolveGlobalConcurrency!: (value: { globalMaxConcurrent: number; currentlyActive: number; queuedCount: number; projectsActive: Record<string, number> }) => void;
mocks.fetchGlobalConcurrency.mockReturnValueOnce(new Promise((resolve) => {
resolveGlobalConcurrency = resolve;
}));
renderControls("project-a");
const section = screen.getByTestId("cc-controls-concurrency");
expect(within(section).queryByTestId("cc-global-running")).toBeNull();
expect(within(section).queryByTestId("cc-project-running")).toBeNull();
expect(within(section).queryByTestId("cc-global-use-marker")).toBeNull();
expect(within(section).queryByTestId("cc-project-use-marker")).toBeNull();
resolveGlobalConcurrency({ globalMaxConcurrent: 8, currentlyActive: 1, queuedCount: 0, projectsActive: {} });
await flushPromises();
});
it("suppresses running counts while global utilization is unavailable", async () => {
mocks.fetchGlobalConcurrency.mockRejectedValueOnce(new Error("global unavailable"));
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
expect(within(section).queryByTestId("cc-global-running")).toBeNull();
expect(within(section).queryByTestId("cc-project-running")).toBeNull();
expect(within(section).queryByTestId("cc-global-use-marker")).toBeNull();
expect(within(section).queryByTestId("cc-project-use-marker")).toBeNull();
});
it("gates the global cap slider behind confirmation before persisting", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/global max concurrent/i) as HTMLInputElement;
fireEvent.change(slider, { target: { value: "10" } });
await advanceConfirmDebounce();
expect(getConfirmDialog()).toHaveTextContent("Change Global Max Concurrent from 8 to 10?");
expect(mocks.updateGlobalConcurrency).not.toHaveBeenCalled();
expect(mocks.updateSettings).not.toHaveBeenCalled();
await clickConfirmSave();
await advanceConfirmDebounce();
expect(mocks.updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 10 });
expect(mocks.updateGlobalConcurrency).toHaveBeenCalledTimes(1);
expect(mocks.updateSettings).not.toHaveBeenCalled();
});
it.each([
{
name: "Max concurrent tasks",
label: /max concurrent tasks/i,
value: "7",
expected: { maxConcurrent: 7, maxTriageConcurrent: 2, maxWorktrees: 4 },
},
{
name: "Max triage concurrent",
label: /max triage concurrent/i,
value: "6",
expected: { maxConcurrent: 2, maxTriageConcurrent: 6, maxWorktrees: 4 },
},
{
name: "Max worktrees",
label: /max worktrees/i,
value: "12",
expected: { maxConcurrent: 2, maxTriageConcurrent: 2, maxWorktrees: 12 },
},
])("gates the $name slider behind confirmation before persisting", async ({ name, label, value, expected }) => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(label) as HTMLInputElement;
fireEvent.change(slider, { target: { value } });
await advanceConfirmDebounce();
expect(getConfirmDialog()).toHaveTextContent(`Change ${name} from ${name === "Max worktrees" ? 4 : 2} to ${value}?`);
expect(mocks.updateSettings).not.toHaveBeenCalled();
await clickConfirmSave();
expect(mocks.updateSettings).toHaveBeenCalledWith(expected, "project-a");
expect(mocks.refresh).toHaveBeenCalledTimes(1);
});
it("confirms all per-project slider changes made inside one debounce before persisting", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
fireEvent.change(within(section).getByLabelText(/max concurrent tasks/i), { target: { value: "7" } });
fireEvent.change(within(section).getByLabelText(/max worktrees/i), { target: { value: "12" } });
await advanceConfirmDebounce();
const dialog = getConfirmDialog();
expect(dialog).toHaveTextContent("Change these concurrency settings");
expect(dialog).toHaveTextContent("Max concurrent tasks from 2 to 7");
expect(dialog).toHaveTextContent("Max worktrees from 4 to 12");
expect(mocks.updateSettings).not.toHaveBeenCalled();
await clickConfirmSave();
expect(mocks.updateSettings).toHaveBeenCalledWith(
{ maxConcurrent: 7, maxTriageConcurrent: 2, maxWorktrees: 12 },
"project-a",
);
});
it("persists concurrency slider changes at the default maximum of 50 after confirmation", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/max concurrent tasks/i);
fireEvent.change(slider, { target: { value: "50" } });
await advanceConfirmDebounce();
await clickConfirmSave();
expect(mocks.updateSettings).toHaveBeenCalledWith(
{ maxConcurrent: 50, maxTriageConcurrent: 2, maxWorktrees: 4 },
"project-a",
);
});
it("persists concurrency slider changes without a project id after confirmation", async () => {
renderControls(undefined);
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/max worktrees/i);
fireEvent.change(slider, { target: { value: "12" } });
await advanceConfirmDebounce();
await clickConfirmSave();
expect(mocks.updateSettings).toHaveBeenCalledWith(
{ maxConcurrent: 2, maxTriageConcurrent: 2, maxWorktrees: 12 },
undefined,
);
});
it("cancels per-project concurrency changes and reverts the slider without saving", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/max concurrent tasks/i) as HTMLInputElement;
fireEvent.change(slider, { target: { value: "9" } });
expect(slider.value).toBe("9");
await advanceConfirmDebounce();
await clickConfirmCancel();
expect(slider.value).toBe("2");
expect(mocks.updateSettings).not.toHaveBeenCalled();
});
it("dismisses per-project concurrency changes with Escape and reverts without saving", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/max triage concurrent/i) as HTMLInputElement;
fireEvent.change(slider, { target: { value: "5" } });
await advanceConfirmDebounce();
fireEvent.keyDown(document, { key: "Escape" });
await flushPromises();
expect(slider.value).toBe("2");
expect(mocks.updateSettings).not.toHaveBeenCalled();
});
it("dismisses global concurrency changes via backdrop and reverts without saving", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/global max concurrent/i) as HTMLInputElement;
fireEvent.change(slider, { target: { value: "11" } });
expect(slider.value).toBe("11");
await advanceConfirmDebounce();
fireEvent.click(getConfirmDialog().parentElement!);
await flushPromises();
expect(slider.value).toBe("8");
await advanceConfirmDebounce();
expect(mocks.updateGlobalConcurrency).not.toHaveBeenCalled();
});
it("does not open a confirmation or save for no-op slider settles", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
fireEvent.change(within(section).getByLabelText(/global max concurrent/i), { target: { value: "8" } });
fireEvent.change(within(section).getByLabelText(/max worktrees/i), { target: { value: "4" } });
await advanceConfirmDebounce();
expect(screen.queryByRole("dialog", { name: /confirm concurrency change/i })).toBeNull();
expect(mocks.updateGlobalConcurrency).not.toHaveBeenCalled();
expect(mocks.updateSettings).not.toHaveBeenCalled();
});
it("renders persisted concurrency settings without stale default drift", async () => {
mocks.fetchSettings.mockResolvedValueOnce({ maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 9 });
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const maxConcurrent = within(section).getByLabelText(/max concurrent tasks/i) as HTMLInputElement;
const maxTriageConcurrent = within(section).getByLabelText(/max triage concurrent/i) as HTMLInputElement;
const maxWorktrees = within(section).getByLabelText(/max worktrees/i) as HTMLInputElement;
expect(maxConcurrent.value).toBe("6");
expect(maxConcurrent.closest("label")).toHaveTextContent("Max concurrent tasks6");
expect(maxTriageConcurrent.value).toBe("3");
expect(maxTriageConcurrent.closest("label")).toHaveTextContent("Max triage concurrent3");
expect(maxWorktrees.value).toBe("9");
expect(maxWorktrees.closest("label")).toHaveTextContent("Max worktrees9");
});
it("sets all concurrency slider maximums to 50 for default and in-range settings", async () => {
const defaultRender = renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const sliders = [
within(section).getByLabelText(/max concurrent tasks/i),
within(section).getByLabelText(/max triage concurrent/i),
within(section).getByLabelText(/max worktrees/i),
] as HTMLInputElement[];
for (const slider of sliders) {
expect(slider.max).toBe("50");
}
defaultRender.unmount();
mocks.fetchSettings.mockResolvedValueOnce({ maxConcurrent: 50, maxTriageConcurrent: 49, maxWorktrees: 48 });
renderControls("project-b");
await flushPromises();
const inRangeSection = screen.getByTestId("cc-controls-concurrency");
const inRangeSliders = [
within(inRangeSection).getByLabelText(/max concurrent tasks/i),
within(inRangeSection).getByLabelText(/max triage concurrent/i),
within(inRangeSection).getByLabelText(/max worktrees/i),
] as HTMLInputElement[];
for (const slider of inRangeSliders) {
expect(slider.max).toBe("50");
}
});
it("keeps out-of-range persisted concurrency values visible instead of silently clamping", async () => {
mocks.fetchSettings.mockResolvedValueOnce({ maxConcurrent: 60, maxTriageConcurrent: 70, maxWorktrees: 80 });
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const maxConcurrent = within(section).getByLabelText(/max concurrent tasks/i) as HTMLInputElement;
const maxTriageConcurrent = within(section).getByLabelText(/max triage concurrent/i) as HTMLInputElement;
const maxWorktrees = within(section).getByLabelText(/max worktrees/i) as HTMLInputElement;
expect(maxConcurrent.value).toBe("60");
expect(maxConcurrent.max).toBe("60");
expect(maxConcurrent.closest("label")).toHaveTextContent("Max concurrent tasks60");
expect(maxTriageConcurrent.value).toBe("70");
expect(maxTriageConcurrent.max).toBe("70");
expect(maxTriageConcurrent.closest("label")).toHaveTextContent("Max triage concurrent70");
expect(maxWorktrees.value).toBe("80");
expect(maxWorktrees.max).toBe("80");
expect(maxWorktrees.closest("label")).toHaveTextContent("Max worktrees80");
});
it("marks concurrency sliders with the mobile touch-drag affordance contract", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const sliders = [
within(section).getByLabelText(/global max concurrent/i),
within(section).getByLabelText(/max concurrent tasks/i),
within(section).getByLabelText(/max triage concurrent/i),
within(section).getByLabelText(/max worktrees/i),
];
for (const slider of sliders) {
expect(slider).toHaveClass("cc-controls-touch-slider");
}
// jsdom cannot simulate whether a touch drag is captured by page scrolling, so this verifies the CSS contract that enables horizontal thumb drags on mobile.
expect(commandCenterControlsCss).toContain("touch-action: pan-y");
expect(commandCenterControlsCss).toContain("pointer-events: none");
expect(commandCenterControlsCss).toContain("inset-inline-start: var(--use-offset, var(--use-pct))");
expect(commandCenterControlsCss).toContain("@media (max-width: 768px)");
expect(commandCenterControlsCss).toContain("min-block-size: var(--space-2xl)");
expect(commandCenterControlsCss).toContain("--cc-controls-range-thumb-size: var(--space-xl)");
});
it("shows save error indicator when concurrency update fails", async () => {
mocks.updateSettings.mockRejectedValueOnce(new Error("network error"));
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/max concurrent tasks/i);
fireEvent.change(slider, { target: { value: "8" } });
await advanceConfirmDebounce();
await clickConfirmSave();
expect(within(section).getByText(/save failed/i)).toBeDefined();
});
it("selects a theme from the embedded dropdown", async () => {
const onColorThemeChange = vi.fn();
render(
<CommandCenterControls
colorTheme="default"
themeMode="dark"
onColorThemeChange={onColorThemeChange}
onThemeModeChange={vi.fn()}
/>,
);
await flushPromises();
/*
FNXC:Theme 2026-06-25-16:55:
Command Center embeds the shared theme dropdown, whose trigger label follows the current theme copy; look up the default label from theme metadata instead of assuming user-facing text contains "Default".
*/
const defaultTheme = COLOR_THEMES.find((theme) => theme.value === "default")!;
fireEvent.click(screen.getByRole("button", { name: defaultTheme.label }));
fireEvent.click(screen.getAllByRole("option").find((element) => element.textContent?.trim() === "Forest")!);
expect(onColorThemeChange).toHaveBeenCalledWith("forest");
});
});

View File

@@ -156,7 +156,6 @@ describe("dashboard test config guard", () => {
expect(vitestConfig).toContain(`name: \"${projectName}\"`);
}
expect(vitestConfig).toContain('"app/__tests__/spinner-animation.css.test.ts"');
expect(vitestConfig).toContain('"scripts/__tests__/{run-quality-tests,run-vitest-with-heap}.test.ts"');
});

View File

@@ -48,14 +48,10 @@ const qualityAppFoundationUiTests = [
"app/__tests__/board-mobile-corner-rendering.test.ts",
"app/__tests__/board-tablet-overflow.test.ts",
"app/__tests__/browser-layout-smoke-fixture.test.ts",
"app/__tests__/chat-tool-calls-mobile-layout.test.ts",
"app/__tests__/column-fixed-width.test.ts",
"app/__tests__/component-css-no-raw-rgba.test.ts",
"app/__tests__/dashboard-component-color-tokenization.test.ts",
"app/__tests__/dashboard-css-token-validity.css.test.ts",
"app/__tests__/dashboard-footer-mobile-layout.test.ts",
"app/__tests__/detail-body-mobile-overflow.test.ts",
"app/__tests__/dev-server-layout-css.test.ts",
"app/__tests__/executor-status-bar-theme.test.ts",
"app/__tests__/footer-safe-layout.test.ts",
"app/__tests__/git-manager-theme-styling.test.ts",
@@ -83,16 +79,13 @@ const qualityAppFoundationUiTests = [
"app/__tests__/setup-wizard-modal-layout.test.ts",
"app/__tests__/shell-host.test.ts",
"app/__tests__/shell-native.test.ts",
"app/__tests__/spinner-animation.css.test.ts",
"app/__tests__/sse-bus.test.ts",
"app/__tests__/status-colors-theme.test.ts",
"app/__tests__/swUpdate.test.ts",
"app/__tests__/tablet-header-controls.test.tsx",
"app/__tests__/task-detail-modal-tablet-width.test.ts",
"app/__tests__/terminal-input.test.ts",
"app/__tests__/terminal-mobile-header-row.test.ts",
"app/__tests__/terminal-mobile-keyboard-layout.test.ts",
"app/__tests__/text-token-canonicalization.test.ts",
"app/__tests__/versionCheck.test.ts",
"app/__tests__/viewport-compensation-keyboard.test.ts",
];
@@ -328,59 +321,14 @@ FN-6937 verified that FN-6860's claimed session-cross-tab ledger removal had not
FNXC:DashboardTestQuarantine 2026-06-25-11:15:
The SQLite-to-PostgreSQL cutover (feature quarantine-sqlite-internals-tests) quarantines dashboard test files that exercise SQLite-only behavior. knowledge-index.test.ts asserts the knowledge_pages schema via PRAGMA table_info and sqlite_master on the SQLite store, with no PostgreSQL equivalent at this layer. Mirrored in scripts/lib/test-quarantine.json; will be DELETED when the SQLite code is removed.
*/
const quarantinedDashboardTests: string[] = [
// SQLite-internals quarantine (cutover): see scripts/lib/test-quarantine.json.
/*
FNXC:DashboardTestQuarantine 2026-06-25-09:50:
Quarantine DevServerView.mobile.test.tsx: CI full-suite shard 4/4 fails with 'expected +0 to be 1' on the mobile CSS structure assertion. Under the deletion ratchet — see scripts/lib/test-quarantine.json.
*/
"app/components/__tests__/DevServerView.mobile.test.tsx",
// Pre-existing CSS token/lint regressions (cutover batch): see scripts/lib/test-quarantine.json.
// These fail on clean baseline (CSS drift, not flakes); quarantined on sight
// per AGENTS.md so verify:workspace goes green during the SQLite-to-PostgreSQL cutover.
"app/__tests__/chat-tool-calls-mobile-layout.test.ts",
"app/__tests__/component-css-no-raw-rgba.test.ts",
"app/__tests__/dashboard-css-token-validity.css.test.ts",
"app/__tests__/dev-server-layout-css.test.ts",
"app/__tests__/spinner-animation.css.test.ts",
"app/__tests__/status-colors-theme.test.ts",
"app/__tests__/text-token-canonicalization.test.ts",
// Pre-existing mock drift (getAsyncLayer not on mock store); quarantined on sight per AGENTS.md.
"app/api/__tests__/research-api.test.ts",
// Pre-existing mock drift (detectWorkspace / theme dropdown): quarantined on sight per AGENTS.md.
"app/components/__tests__/SetupWizardModal.test.tsx",
"app/components/command-center/__tests__/CommandCenterControls.test.tsx",
// Pre-existing CSS / mobile-render regressions (backfill shards, cutover batch):
// see scripts/lib/test-quarantine.json. All fail on clean baseline.
"app/__tests__/global-theme-css-no-raw-rgba.test.ts",
"app/components/__tests__/WorkflowNodeEditor.css.test.ts",
"app/components/__tests__/board-mobile-initial-render.test.tsx",
"app/__tests__/toast-theme-contrast.test.ts",
"app/components/__tests__/ProjectOverview.test.tsx",
/*
FNXC:DashboardTestQuarantine 2026-06-25-13:30:
The SQLite-to-PostgreSQL cutover (feature quarantine-sqlite-internals-tests, retry session)
applied the undefined→null coercion fix in sqlite-adapter.ts prepare() which resolved the
ERR_INVALID_ARG_TYPE binding failures. However, 92 dashboard API test files still fail from
a DIFFERENT pre-existing root cause: the async-satellite dual-path work (server.ts:924 +
routes/*) now calls store.getAsyncLayer() / store.isBackendMode() but these test files' mock
TaskStore implementations do not expose those methods (TypeError: store.getAsyncLayer is not
a function). Confirmed pre-existing on clean baseline (stash + rerun). Quarantined on sight
per AGENTS.md so verify:workspace goes green. Rescue requires updating each file's mock
TaskStore to expose getAsyncLayer() (return null) and isBackendMode() (return false).
Mirrored in scripts/lib/test-quarantine.json; will be DELETED when the SQLite code is removed.
*/
/*
FNXC:DashboardTestQuarantine 2026-07-05-17:10:
RESCUED: the five 2026-06-28 SQLite-cutover entries (project-store-resolver,
routes-planning, routes-auth, routes-automation, routes-tasks) were root-cause
fixed and un-quarantined: mock stores expose getAsyncLayer(), AgentStore
seeding was replaced with prototype spies (legacy runtime removed under
VAL-REMOVAL-005), the resolver test mocks the createTaskStoreForBackend seam,
and the manual-backup parity tests stub runBackupCommand (post-cutover it
pg_dumps a live cluster). Ledger entries removed in the same commit.
*/
];
/*
FNXC:DashboardTestQuarantine 2026-07-14-07:15:
The 16 dashboard test files quarantined on 2026-06-25 (cutover batch) were
deleted per the AGENTS.md deletion ratchet (14 days expired, not rescued).
Ledger entries removed from scripts/lib/test-quarantine.json in the same commit.
The array stays empty; add new entries here only with a matching ledger row.
*/
const quarantinedDashboardTests: string[] = [];
const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation,

View File

@@ -92,7 +92,17 @@ function probeTcpReachable(host: string, port: number, timeoutMs = 1500): boolea
return view[0] === 1;
}
export const hasPg = process.env.FUSION_PG_TEST_SKIP !== "1" && (() => {
/*
FNXC:PgTestGuard 2026-07-14-07:10:
hasPg must verify BOTH that the PostgreSQL server is TCP-reachable AND that the
psql CLI binary is installed. adminExecAsync() shells out to psql for DDL
(CREATE/DROP DATABASE). Without this check, a runner with Postgres reachable
but psql missing would pass the gate and fail inside fixture creation with
spawn ENOENT instead of skipping cleanly.
*/
const hasPsql = spawnSync("psql", ["--version"], { stdio: "pipe" }).status === 0;
export const hasPg = process.env.FUSION_PG_TEST_SKIP !== "1" && hasPsql && (() => {
if (!PG_TEST_URL_BASE) return false;
const { host, port } = parseProbeTarget(PG_TEST_URL_BASE);
return probeTcpReachable(host, port);

View File

@@ -5,86 +5,6 @@
"file": "packages/cli/src/__tests__/extension-dist-barrel.test.ts",
"reason": "FN-7530 RESCUE-by-split of the prior extension.test.ts entry: the dist-barrel fn_task_list test timed out at 5000ms in the full-suite shard 4/4 (run https://github.com/Runfusion/Fusion/actions/runs/28697507894) while passing locally in ~1.2s and in 3 of the 4 surrounding CI runs. Root-cause invariant: the test does in-test module recompilation (vi.resetModules + vi.importActual of the full @fusion/core dist barrel + a fresh dynamic import of extension.js) inside the default 5s test timeout; that work is CPU-bound and degrades non-linearly under 4-shard CI contention (same loaded-lane signature as FN-6483/FN-6705/FN-6795/FN-6839). Widening the timeout is forbidden by the flaky-test rule and removing the recompilation removes the test's only point, so the file is quarantined pending a split into smaller compilation units.",
"quarantinedAt": "2026-07-04"
},
{
"file": "packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx",
"reason": "FN-6860 CI full-suite shard 4/4 fails with 'expected +0 to be 1' on the mobile CSS structure assertion. Deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/__tests__/chat-tool-calls-mobile-layout.test.ts",
"reason": "Pre-existing CSS token/lint drift — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md so verify:workspace goes green. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts",
"reason": "Pre-existing CSS token/lint drift — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md so verify:workspace goes green. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/__tests__/dashboard-css-token-validity.css.test.ts",
"reason": "Pre-existing CSS token/lint drift — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md so verify:workspace goes green. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/__tests__/dev-server-layout-css.test.ts",
"reason": "Pre-existing CSS token/lint drift — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md so verify:workspace goes green. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/__tests__/spinner-animation.css.test.ts",
"reason": "Pre-existing CSS token/lint drift — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md so verify:workspace goes green. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/__tests__/status-colors-theme.test.ts",
"reason": "Pre-existing CSS token/lint drift — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md so verify:workspace goes green. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/__tests__/text-token-canonicalization.test.ts",
"reason": "Pre-existing CSS token/lint drift — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md so verify:workspace goes green. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/api/__tests__/research-api.test.ts",
"reason": "Pre-existing mock drift (getAsyncLayer not on mock store) — deterministic clean-baseline failure. Quarantined on sight per AGENTS.md. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx",
"reason": "Pre-existing mock drift (detectWorkspace / theme dropdown) — deterministic clean-baseline failure. Quarantined on sight per AGENTS.md. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx",
"reason": "Pre-existing mock drift (detectWorkspace / theme dropdown) — deterministic clean-baseline failure. Quarantined on sight per AGENTS.md. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/__tests__/global-theme-css-no-raw-rgba.test.ts",
"reason": "Pre-existing CSS / mobile-render regression — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts",
"reason": "Pre-existing CSS / mobile-render regression — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx",
"reason": "Pre-existing CSS / mobile-render regression — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/__tests__/toast-theme-contrast.test.ts",
"reason": "Pre-existing CSS / mobile-render regression — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
},
{
"file": "packages/dashboard/app/components/__tests__/ProjectOverview.test.tsx",
"reason": "Pre-existing CSS / mobile-render regression — deterministic clean-baseline failure during SQLite-to-PostgreSQL cutover. Quarantined on sight per AGENTS.md. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29305968000. Mirrored in packages/dashboard/vitest.config.ts quarantinedDashboardTests.",
"quarantinedAt": "2026-06-25"
}
]
}