From d7e072a03cbf2223cb2bbc5c745d29e48651f73b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Jul 2026 08:18:10 -0700 Subject: [PATCH] fix: add psql binary guard + delete expired quarantine tests (ratchet) (#2090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 | ## 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. --- .../chat-tool-calls-mobile-layout.test.ts | 48 - .../component-css-no-raw-rgba.test.ts | 106 -- .../dashboard-css-token-validity.css.test.ts | 165 --- .../__tests__/dev-server-layout-css.test.ts | 66 - .../global-theme-css-no-raw-rgba.test.ts | 68 -- .../__tests__/spinner-animation.css.test.ts | 163 --- .../app/__tests__/status-colors-theme.test.ts | 635 ---------- .../text-token-canonicalization.test.ts | 65 - .../__tests__/toast-theme-contrast.test.ts | 320 ----- .../app/api/__tests__/research-api.test.ts | 172 --- .../__tests__/DevServerView.mobile.test.tsx | 155 --- .../__tests__/ProjectOverview.test.tsx | 786 ------------ .../__tests__/SetupWizardModal.test.tsx | 1082 ----------------- .../__tests__/WorkflowNodeEditor.css.test.ts | 489 -------- .../board-mobile-initial-render.test.tsx | 534 -------- .../__tests__/CommandCenterControls.test.tsx | 547 --------- .../dashboard-test-config-guard.test.ts | 1 - packages/dashboard/vitest.config.ts | 68 +- .../reliability-interactions/_helpers.ts | 12 +- scripts/lib/test-quarantine.json | 80 -- 20 files changed, 19 insertions(+), 5543 deletions(-) delete mode 100644 packages/dashboard/app/__tests__/chat-tool-calls-mobile-layout.test.ts delete mode 100644 packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts delete mode 100644 packages/dashboard/app/__tests__/dashboard-css-token-validity.css.test.ts delete mode 100644 packages/dashboard/app/__tests__/dev-server-layout-css.test.ts delete mode 100644 packages/dashboard/app/__tests__/global-theme-css-no-raw-rgba.test.ts delete mode 100644 packages/dashboard/app/__tests__/spinner-animation.css.test.ts delete mode 100644 packages/dashboard/app/__tests__/status-colors-theme.test.ts delete mode 100644 packages/dashboard/app/__tests__/text-token-canonicalization.test.ts delete mode 100644 packages/dashboard/app/__tests__/toast-theme-contrast.test.ts delete mode 100644 packages/dashboard/app/api/__tests__/research-api.test.ts delete mode 100644 packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx delete mode 100644 packages/dashboard/app/components/__tests__/ProjectOverview.test.tsx delete mode 100644 packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx delete mode 100644 packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts delete mode 100644 packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx delete mode 100644 packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx diff --git a/packages/dashboard/app/__tests__/chat-tool-calls-mobile-layout.test.ts b/packages/dashboard/app/__tests__/chat-tool-calls-mobile-layout.test.ts deleted file mode 100644 index 33569c053a..0000000000 --- a/packages/dashboard/app/__tests__/chat-tool-calls-mobile-layout.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts b/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts deleted file mode 100644 index 09995fa4e0..0000000000 --- a/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts +++ /dev/null @@ -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([]); - }); -}); diff --git a/packages/dashboard/app/__tests__/dashboard-css-token-validity.css.test.ts b/packages/dashboard/app/__tests__/dashboard-css-token-validity.css.test.ts deleted file mode 100644 index ddfe96641e..0000000000 --- a/packages/dashboard/app/__tests__/dashboard-css-token-validity.css.test.ts +++ /dev/null @@ -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 { - const properties = new Set(); - - 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 { - const properties = new Set(); - - 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 { - const references = new Set(); - 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; - inlineSetProperties: Set; - allowlist?: Set; - sourceByFile?: Map; -}): 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([]); - }); -}); diff --git a/packages/dashboard/app/__tests__/dev-server-layout-css.test.ts b/packages/dashboard/app/__tests__/dev-server-layout-css.test.ts deleted file mode 100644 index ee7b905dbe..0000000000 --- a/packages/dashboard/app/__tests__/dev-server-layout-css.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/dashboard/app/__tests__/global-theme-css-no-raw-rgba.test.ts b/packages/dashboard/app/__tests__/global-theme-css-no-raw-rgba.test.ts deleted file mode 100644 index e6f7c69220..0000000000 --- a/packages/dashboard/app/__tests__/global-theme-css-no-raw-rgba.test.ts +++ /dev/null @@ -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([]); - }); -}); diff --git a/packages/dashboard/app/__tests__/spinner-animation.css.test.ts b/packages/dashboard/app/__tests__/spinner-animation.css.test.ts deleted file mode 100644 index 6dfbbc3093..0000000000 --- a/packages/dashboard/app/__tests__/spinner-animation.css.test.ts +++ /dev/null @@ -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", - ])); - }); -}); diff --git a/packages/dashboard/app/__tests__/status-colors-theme.test.ts b/packages/dashboard/app/__tests__/status-colors-theme.test.ts deleted file mode 100644 index fdd14d83b7..0000000000 --- a/packages/dashboard/app/__tests__/status-colors-theme.test.ts +++ /dev/null @@ -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=""] { … } - * (excludes light variants and compound selectors with spaces/dots). - */ - function getDarkColorThemeBlocks(): Map { - const blocks = new Map(); - 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=""][data-theme="light"] { … } - */ - function getLightColorThemeBlocks(): Map { - const blocks = new Map(); - 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); -} diff --git a/packages/dashboard/app/__tests__/text-token-canonicalization.test.ts b/packages/dashboard/app/__tests__/text-token-canonicalization.test.ts deleted file mode 100644 index e136e559d8..0000000000 --- a/packages/dashboard/app/__tests__/text-token-canonicalization.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/dashboard/app/__tests__/toast-theme-contrast.test.ts b/packages/dashboard/app/__tests__/toast-theme-contrast.test.ts deleted file mode 100644 index 451e0f2d7d..0000000000 --- a/packages/dashboard/app/__tests__/toast-theme-contrast.test.ts +++ /dev/null @@ -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; -}; - -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 { - const tokens = new Map(); - - 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 { - const declarations = new Map(); - for (const match of block.matchAll(/(--[\w-]+|[\w-]+)\s*:\s*([^;]+);/g)) { - declarations.set(match[1], match[2].trim()); - } - return declarations; -} - -function mergeDeclarations(tokens: Map, block: string): void { - for (const [name, value] of parseDeclarations(block)) { - if (name.startsWith("--")) tokens.set(name, value); - } -} - -function resolveCssValue(value: string, tokens: Map, seen = new Set()): 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"); -} diff --git a/packages/dashboard/app/api/__tests__/research-api.test.ts b/packages/dashboard/app/api/__tests__/research-api.test.ts deleted file mode 100644 index 5c1b06d521..0000000000 --- a/packages/dashboard/app/api/__tests__/research-api.test.ts +++ /dev/null @@ -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: "{}" }); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx deleted file mode 100644 index bd4603160a..0000000000 --- a/packages/dashboard/app/components/__tests__/DevServerView.mobile.test.tsx +++ /dev/null @@ -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: () =>
, -})); - -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(); - - 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")); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/ProjectOverview.test.tsx b/packages/dashboard/app/components/__tests__/ProjectOverview.test.tsx deleted file mode 100644 index 18f6ae706b..0000000000 --- a/packages/dashboard/app/components/__tests__/ProjectOverview.test.tsx +++ /dev/null @@ -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 { - 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); -} - -// 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: () => +, - LayoutGrid: () => ⊞, - Filter: () => ⚙, - ArrowUpDown: () => ⇅, - Activity: () => ⚡, - CheckCircle: () => ✓, - AlertCircle: () => ⚠, - Folder: () => 📁, - Inbox: () => 📥, - Server: () => 🖥, - }; -}); - -// Mock ProjectCard -vi.mock("../ProjectCard", () => ({ - ProjectCard: ({ - project, - onSelect, - availabilityMappings, - }: { - project: ProjectInfo; - onSelect: (p: ProjectInfo) => void; - availabilityMappings?: Array<{ displayName: string; path: string }>; - }) => ( -
mapping.displayName).join(",") || "none"} - onClick={() => onSelect(project)} - > - {project.name} - {(availabilityMappings ?? []).map((mapping) => ( - {mapping.displayName} - ))} -
- ), -})); - -// Mock ProjectGridSkeleton -vi.mock("../ProjectGridSkeleton", () => ({ - ProjectGridSkeleton: () =>
Loading...
, -})); - -function makeProject(overrides: Partial = {}): 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( - - ); - - 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( - - ); - - expect(screen.getByTestId("project-card-proj_1")).toBeDefined(); - expect(screen.getByTestId("project-card-proj_2")).toBeDefined(); - }); - - it("shows empty state when no projects", () => { - render( - - ); - - 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( - - ); - - fireEvent.click(screen.getByText("Add Your First Project")); - expect(onAddProject).toHaveBeenCalled(); - }); - - it("triggers onAddProject when header button clicked", () => { - const onAddProject = vi.fn(); - render( - - ); - - fireEvent.click(screen.getByText("Add Project")); - expect(onAddProject).toHaveBeenCalled(); - }); - - it("displays correct stats in header", () => { - render( - - ); - - // 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( - - ); - - // 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( - - ); - - // 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( - - ); - - // 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( - - ); - - // 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( - - ); - }).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( - - ); - - 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( - - ); - - fireEvent.click(screen.getByTestId("project-card-proj_1")); - expect(onSelectProject).toHaveBeenCalledWith(project); - }); - - it("errored tab has special styling when errored projects exist", () => { - render( - - ); - - // 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( - - ); - - 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( - - ); - - 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( - - ); - - // Node filter dropdown should be present - expect(screen.getByLabelText("Filter by node")).toBeDefined(); - }); - - it("node filter dropdown filters projects by node", async () => { - render( - - ); - - // 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( - - ); - - // 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( - - ); - - // 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( - - ); - - // 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( - - ); - - // 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( - - ); - - // 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( - - ); - - 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( - - ); - - 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( - - ); - - 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( - - ); - - // 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( - - ); - - // 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( - - ); - - // Skeleton should be shown (health loading with no data) - expect(screen.getByTestId("project-grid-skeleton")).toBeDefined(); - }); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx b/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx deleted file mode 100644 index 20584f76f0..0000000000 --- a/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx +++ /dev/null @@ -1,1082 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { SetupWizardModal } from "../SetupWizardModal"; -import { AGENT_PRESETS } from "../agent-presets"; -import { buildAgentCreatePayload, mapPresetToAgentDraft } from "../agent-presets/agentCreatePayload"; - -// Mock lucide-react -vi.mock("lucide-react", async () => { - const actual = await vi.importActual("lucide-react"); - return { - ...actual, - X: ({ size, ...props }: any) => ×, - Loader2: ({ size, ...props }: any) => ⟳, - Sparkles: ({ size, ...props }: any) => ✨, - CheckCircle: ({ size, ...props }: any) => ✓, - Folder: ({ size, ...props }: any) => 📁, - FolderOpen: ({ size, ...props }: any) => 📂, - ChevronRight: ({ size, ...props }: any) => →, - ChevronUp: ({ size, ...props }: any) => ↑, - Eye: ({ size, ...props }: any) => 👁, - EyeOff: ({ size, ...props }: any) => 🙈, - AlertCircle: ({ size, ...props }: any) => ⚠, - }; -}); - -// Mock useNodes hook -vi.mock("../../hooks/useNodes", () => ({ - useNodes: vi.fn(() => ({ - nodes: [], - loading: false, - error: null, - refresh: vi.fn(), - register: vi.fn(), - update: vi.fn(), - unregister: vi.fn(), - healthCheck: vi.fn(), - })), -})); - -// Mock api module -// FNXC:Onboarding 2026-06-25-13:20: -// SetupWizardModal now probes `detectWorkspace` on existing-directory path entry -// (FNXC:Workspace 2026-06-24-21:00). The mock must export it or the component's -// path-change handler throws an unhandled rejection. Default to a non-workspace -// single-repo result so the legacy submit-payload assertions stay deterministic. -vi.mock("../../api", () => ({ - registerProject: vi.fn(), - createAgent: vi.fn(), - detectWorkspace: vi.fn().mockResolvedValue({ repos: [], isWorkspace: false }), - browseDirectory: vi.fn().mockResolvedValue({ - currentPath: "/home/user", - parentPath: "/home", - entries: [], - }), - createDirectory: vi.fn().mockResolvedValue({ success: true, path: "/home/user/fresh-project" }), -})); - -vi.mock("../ExperimentalAgentOnboardingModal", () => ({ - ExperimentalAgentOnboardingModal: ({ isOpen, onClose, onUseDraft }: { isOpen: boolean; onClose: () => void; onUseDraft: (draft: any) => void }) => ( - isOpen ? ( -
- AI Interview Modal - -
- ) : null - ), -})); - -import { createAgent, registerProject, detectWorkspace, browseDirectory, createDirectory } from "../../api"; -import { useNodes } from "../../hooks/useNodes"; - -const mockRegisterProject = vi.mocked(registerProject); -const mockCreateAgent = vi.mocked(createAgent); -const mockDetectWorkspace = vi.mocked(detectWorkspace); -const mockBrowseDirectory = vi.mocked(browseDirectory); -const mockCreateDirectory = vi.mocked(createDirectory); -const mockUseNodes = vi.mocked(useNodes); - -function buildMockProject(overrides = {}) { - return { - id: "proj_123", - name: "test-project", - path: "/home/user/project", - status: "active" as const, - isolationMode: "in-process" as const, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...overrides, - }; -} - -function buildMockAgent(overrides = {}) { - return { - id: "agent_123", - projectId: "proj_123", - name: "Agent", - role: "custom", - status: "active", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...overrides, - }; -} - -async function registerProjectFromWizard() { - fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { - target: { value: "/home/user/project" }, - }); - fireEvent.change(screen.getByPlaceholderText("my-project"), { - target: { value: "test-project" }, - }); - fireEvent.click(screen.getByText("Register Project")); - return screen.findByText("Create your first agent"); -} - -describe("SetupWizardModal", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRegisterProject.mockReset(); - mockCreateAgent.mockReset(); - mockDetectWorkspace.mockResolvedValue({ repos: [], isWorkspace: false }); - mockBrowseDirectory.mockResolvedValue({ - currentPath: "/home/user", - parentPath: "/home", - entries: [], - }); - mockCreateDirectory.mockResolvedValue({ success: true, path: "/home/user/fresh-project" }); - }); - - it("starts on the project form without an auth token step", () => { - render( - - ); - - expect(screen.getByText("Welcome to Fusion")).toBeDefined(); - expect(screen.getByText("Project Name")).toBeDefined(); - expect(screen.getByLabelText("Fusion logo")).toBeDefined(); - expect(screen.getByText("Repository setup")).toBeDefined(); - expect(screen.getByLabelText("Use Existing Directory")).toBeDefined(); - expect(screen.getByLabelText("Initialize New Repository")).toBeDefined(); - expect(screen.getByLabelText("Clone Git Repository")).toBeDefined(); - expect(screen.getByText("Advanced settings")).toBeDefined(); - expect(screen.queryByText("Set Auth Token")).toBeNull(); - expect(screen.queryByText("Set Token & Continue")).toBeNull(); - expect(screen.getByRole("link", { name: "Need help?" })).toHaveAttribute( - "href", - "https://discord.gg/ksrfuy7WYR" - ); - }); - - it("keeps onboarding step context when opened as the onboarding Project sub-flow", () => { - // FNXC:SetupWizard 2026-07-10-11:05: opened from the 5-step onboarding wizard - // (includeAgentStep=false), the header must keep the "Step 3 of 5" context instead - // of the standalone "Welcome to Fusion" chrome, so users don't feel ejected from setup. - render( - - ); - - expect(screen.getByText("Step 3 of 5 — Project · Fusion setup")).toBeDefined(); - expect(screen.getByText("Set Up Your Project")).toBeDefined(); - expect(screen.queryByText("Welcome to Fusion")).toBeNull(); - }); - - it("has DirectoryPicker for path selection", () => { - render( - - ); - - // DirectoryPicker renders an input and a Browse button - expect(screen.getByPlaceholderText("/path/to/your/project")).toBeDefined(); - expect(screen.getByText("Browse")).toBeDefined(); - }); - - it("auto-populates project name from selected directory path", () => { - render( - - ); - - // Type a path in the DirectoryPicker input - const pathInput = screen.getByPlaceholderText("/path/to/your/project"); - fireEvent.change(pathInput, { target: { value: "/home/user/my-awesome-project" } }); - - const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement; - expect(nameInput.value).toBe("my-awesome-project"); - }); - - it("selects a newly created project directory and derives the project name", async () => { - mockBrowseDirectory.mockResolvedValue({ - currentPath: "/home/user", - parentPath: "/home", - entries: [], - }); - mockCreateDirectory.mockResolvedValue({ success: true, path: "/home/user/fresh-project" }); - - render( - - ); - - fireEvent.click(screen.getByText("Browse")); - - await waitFor(() => { - expect(screen.getByText("No subdirectories")).toBeDefined(); - }); - - fireEvent.click(screen.getByRole("button", { name: "Create new folder" })); - fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "fresh-project" } }); - fireEvent.click(screen.getByRole("button", { name: "Create" })); - - await waitFor(() => { - expect((screen.getByPlaceholderText("/path/to/your/project") as HTMLInputElement).value).toBe("/home/user/fresh-project"); - }); - expect((screen.getByPlaceholderText("my-project") as HTMLInputElement).value).toBe("fresh-project"); - expect(mockCreateDirectory).toHaveBeenCalledWith("/home/user/fresh-project"); - }); - - it("register button is disabled when required fields are empty", () => { - render( - - ); - - const registerBtn = screen.getByText("Register Project").closest("button")!; - expect(registerBtn.disabled).toBe(true); - }); - - it("register button is enabled when path and name are provided", () => { - render( - - ); - - fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { - target: { value: "/home/user/project" }, - }); - // Name auto-populates; ensure it's not empty - const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement; - expect(nameInput.value).toBe("project"); - - const registerBtn = screen.getByText("Register Project").closest("button")!; - expect(registerBtn.disabled).toBe(false); - }); - - it("existing-directory submit payload is unchanged", async () => { - mockRegisterProject.mockResolvedValueOnce({ - id: "proj_existing", - name: "existing-project", - path: "/existing/project", - status: "active", - isolationMode: "in-process", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - render( - - ); - - fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { - target: { value: "/existing/project" }, - }); - - fireEvent.click(screen.getByText("Register Project")); - - await waitFor(() => { - /* - FNXC:SetupWizard 2026-06-25-16:55: - Project registration now always sends the operator-visible workspace and task prefix fields, even when the existing-directory path is a single repo. - Keep this stale-expectation test pinned to the current create-project contract instead of asserting the pre-workspace payload shape. - */ - expect(mockRegisterProject).toHaveBeenCalledWith({ - name: "project", - path: "/existing/project", - isolationMode: "in-process", - nodeId: undefined, - gitSetupMode: "existing", - cloneUrl: undefined, - workspaceMode: false, - taskPrefix: "PROJ", - }); - }); - }); - - it("renders first-class repository setup modes without opening advanced settings", () => { - render( - - ); - - expect(screen.getByText("Repository setup")).toBeDefined(); - expect(screen.getByText(/Register a folder that is already a git repository/)).toBeDefined(); - expect(screen.getByText(/let Fusion run git init/)).toBeDefined(); - expect(screen.getByText(/Clone a remote repository/)).toBeDefined(); - expect(screen.queryByLabelText("Repository URL")).toBeNull(); - - fireEvent.click(screen.getByLabelText("Clone Git Repository")); - - expect(screen.getByLabelText("Repository URL")).toBeDefined(); - expect(screen.getByPlaceholderText("/path/for/new-clone")).toBeDefined(); - expect(screen.getByText(/destination must be empty or absent/)).toBeDefined(); - expect(screen.getByText(/Fusion will run git clone into the destination directory/)).toBeDefined(); - }); - - it("clone mode submit sends cloneUrl payload", async () => { - const onProjectRegistered = vi.fn(); - mockRegisterProject.mockResolvedValueOnce({ - id: "proj_clone", - name: "fusion", - path: "/tmp/fusion", - status: "active", - isolationMode: "in-process", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - render( - - ); - - fireEvent.click(screen.getByLabelText("Clone Git Repository")); - fireEvent.change(screen.getByLabelText("Repository URL"), { - target: { value: "https://github.com/runfusion/fusion.git" }, - }); - fireEvent.change(screen.getByPlaceholderText("/path/for/new-clone"), { - target: { value: "/tmp/fusion" }, - }); - - fireEvent.click(screen.getByText("Clone and Register Project")); - - await waitFor(() => { - /* - FNXC:SetupWizard 2026-06-25-16:55: - Clone registration uses the same explicit workspace/task-prefix contract as existing-directory registration so downstream project setup receives one complete shape. - */ - expect(mockRegisterProject).toHaveBeenCalledWith({ - name: "fusion", - path: "/tmp/fusion", - isolationMode: "in-process", - nodeId: undefined, - gitSetupMode: "clone", - cloneUrl: "https://github.com/runfusion/fusion.git", - workspaceMode: false, - taskPrefix: "FUSI", - }); - }); - expect(await screen.findByText("Create your first agent")).toBeDefined(); - expect(screen.getByRole("radio", { name: "CEO selected" })).toHaveAttribute("aria-checked", "true"); - expect(onProjectRegistered).not.toHaveBeenCalled(); - }); - - it("initialize mode submits an explicit init setup payload without clone or workspace state", async () => { - mockRegisterProject.mockResolvedValueOnce({ - id: "proj_init", - name: "new-project", - path: "/tmp/new-project", - status: "active", - isolationMode: "in-process", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - render( - - ); - - fireEvent.click(screen.getByLabelText("Initialize New Repository")); - expect(screen.getByText(/Fusion will run git init during registration/)).toBeDefined(); - fireEvent.change(screen.getByPlaceholderText("/path/to/new-project"), { - target: { value: "/tmp/new-project" }, - }); - - fireEvent.click(screen.getByText("Initialize and Register Project")); - - await waitFor(() => { - expect(mockRegisterProject).toHaveBeenCalledWith({ - name: "new-project", - path: "/tmp/new-project", - isolationMode: "in-process", - nodeId: undefined, - gitSetupMode: "init", - cloneUrl: undefined, - workspaceMode: false, - taskPrefix: "NEWP", - }); - }); - }); - - it("switching setup modes clears stale clone and workspace state", async () => { - mockDetectWorkspace.mockResolvedValueOnce({ repos: ["api"], isWorkspace: true }); - mockRegisterProject.mockResolvedValueOnce({ - id: "proj_existing", - name: "workspace", - path: "/tmp/workspace", - status: "active", - isolationMode: "in-process", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - render( - - ); - - fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { - target: { value: "/tmp/workspace" }, - }); - expect(await screen.findByText(/Found 1 repositories/)).toBeDefined(); - - fireEvent.click(screen.getByLabelText("Clone Git Repository")); - fireEvent.change(screen.getByLabelText("Repository URL"), { - target: { value: "https://github.com/runfusion/fusion.git" }, - }); - fireEvent.click(screen.getByLabelText("Initialize New Repository")); - expect(screen.queryByLabelText("Repository URL")).toBeNull(); - - fireEvent.click(screen.getByText("Initialize and Register Project")); - - await waitFor(() => { - expect(mockRegisterProject).toHaveBeenCalledWith(expect.objectContaining({ - gitSetupMode: "init", - cloneUrl: undefined, - workspaceMode: false, - })); - }); - }); - - it("register button disabled/enabled logic is mode-aware", () => { - render( - - ); - - const registerBtn = screen.getByText("Register Project").closest("button")!; - expect(registerBtn.disabled).toBe(true); - - fireEvent.click(screen.getByLabelText("Clone Git Repository")); - fireEvent.change(screen.getByPlaceholderText("/path/for/new-clone"), { - target: { value: "/tmp/repo" }, - }); - expect(registerBtn.disabled).toBe(true); - - fireEvent.change(screen.getByLabelText("Repository URL"), { - target: { value: "https://github.com/runfusion/fusion.git" }, - }); - expect(registerBtn.disabled).toBe(false); - - fireEvent.click(screen.getByLabelText("Use Existing Directory")); - expect(registerBtn.disabled).toBe(false); - }); - - it("auto-suggested name updates until manually edited", () => { - render( - - ); - - fireEvent.click(screen.getByLabelText("Clone Git Repository")); - - const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement; - const destinationInput = screen.getByPlaceholderText("/path/for/new-clone"); - - fireEvent.change(destinationInput, { target: { value: "/tmp/fusion" } }); - expect(nameInput.value).toBe("fusion"); - - fireEvent.change(destinationInput, { target: { value: "/tmp/fusion-next" } }); - expect(nameInput.value).toBe("fusion-next"); - - fireEvent.change(nameInput, { target: { value: "custom-name" } }); - fireEvent.change(destinationInput, { target: { value: "/tmp/fusion-final" } }); - expect(nameInput.value).toBe("custom-name"); - }); - - it("shows error state on registration failure", async () => { - mockRegisterProject.mockRejectedValueOnce(new Error("Path does not exist")); - - render( - - ); - - fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { - target: { value: "/bad/path" }, - }); - fireEvent.change(screen.getByPlaceholderText("my-project"), { - target: { value: "test-project" }, - }); - - fireEvent.click(screen.getByText("Register Project")); - - await waitFor(() => { - expect(mockRegisterProject).toHaveBeenCalled(); - }); - expect(await screen.findByText("Path does not exist")).toBeDefined(); - }); - - it("shows optional first-agent step after successful registration", async () => { - const mockProject = buildMockProject(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - - const onProjectRegistered = vi.fn(); - render( - - ); - - await registerProjectFromWizard(); - - await waitFor(() => { - expect(mockRegisterProject).toHaveBeenCalled(); - }); - expect(screen.getByText(/Agents are optional/)).toBeDefined(); - expect(screen.getByRole("radio", { name: "CEO selected" })).toHaveAttribute("aria-checked", "true"); - expect(screen.getByText("Create Agent")).toBeDefined(); - expect(screen.getByText("Skip for now")).toBeDefined(); - expect(screen.queryByText("AI Interview")).toBeNull(); - - expect(onProjectRegistered).not.toHaveBeenCalled(); - }); - - it("can register project only when embedded in brand-new onboarding", async () => { - const mockProject = buildMockProject(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - - const onProjectRegistered = vi.fn(); - render( - - ); - - fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { - target: { value: "/home/user/project" }, - }); - fireEvent.change(screen.getByPlaceholderText("my-project"), { - target: { value: "test-project" }, - }); - fireEvent.click(screen.getByText("Register Project")); - - await waitFor(() => { - expect(mockRegisterProject).toHaveBeenCalled(); - expect(onProjectRegistered).toHaveBeenCalledWith(mockProject); - }); - expect(screen.queryByText("Create your first agent")).toBeNull(); - }); - - it("can skip first-agent creation and finish setup", async () => { - const mockProject = buildMockProject(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - - const onProjectRegistered = vi.fn(); - render( - - ); - - expect(await registerProjectFromWizard()).toBeDefined(); - - fireEvent.click(screen.getByText("Skip for now")); - expect(await screen.findByText("All Set!")).toBeDefined(); - expect(screen.getByText("You can create agents later from the Agents view.")).toBeDefined(); - expect(mockCreateAgent).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText("Get Started")); - expect(onProjectRegistered).toHaveBeenCalledWith(mockProject); - }); - - it("does not show an ambiguous close button on the first-agent step", async () => { - const mockProject = buildMockProject(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - - const onProjectRegistered = vi.fn(); - render( - - ); - - expect(await registerProjectFromWizard()).toBeDefined(); - - expect(screen.queryByLabelText("Close wizard")).toBeNull(); - - fireEvent.click(screen.getByText("Skip for now")); - expect(await screen.findByText("All Set!")).toBeDefined(); - expect(screen.getByText("You can create agents later from the Agents view.")).toBeDefined(); - expect(onProjectRegistered).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText("Get Started")); - expect(onProjectRegistered).toHaveBeenCalledWith(mockProject); - }); - - it("creates the default CEO agent before finishing setup", async () => { - const mockProject = buildMockProject(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - mockCreateAgent.mockResolvedValueOnce(buildMockAgent({ - id: "agent_ceo", - projectId: mockProject.id, - name: "CEO", - }) as any); - - const onProjectRegistered = vi.fn(); - render( - - ); - - expect(await registerProjectFromWizard()).toBeDefined(); - - fireEvent.click(screen.getByText("Create Agent")); - - await waitFor(() => { - expect(mockCreateAgent).toHaveBeenCalledWith( - expect.objectContaining({ - name: "CEO", - role: "custom", - title: "Oversees project strategy, sets priorities, and coordinates between departments to ensure alignment with business goals.", - }), - mockProject.id, - ); - }); - expect(await screen.findByText("Your project is registered and your first agent is ready.")).toBeDefined(); - - fireEvent.click(screen.getByText("Get Started")); - expect(onProjectRegistered).toHaveBeenCalledWith(mockProject); - }); - - it("creates the selected non-CEO preset with the shared payload mapping", async () => { - const mockProject = buildMockProject(); - const engineerPreset = AGENT_PRESETS.find((preset) => preset.id === "engineer")!; - mockRegisterProject.mockResolvedValueOnce(mockProject); - mockCreateAgent.mockResolvedValueOnce(buildMockAgent({ - id: "agent_engineer", - projectId: mockProject.id, - name: engineerPreset.name, - role: engineerPreset.role, - }) as any); - - render( - - ); - - expect(await registerProjectFromWizard()).toBeDefined(); - fireEvent.click(screen.getByRole("radio", { name: engineerPreset.name })); - - expect(screen.getByRole("radio", { name: `${engineerPreset.name} selected` })).toHaveAttribute("aria-checked", "true"); - expect(screen.getAllByText(engineerPreset.description!).length).toBeGreaterThanOrEqual(1); - - fireEvent.click(screen.getByText("Create Agent")); - - await waitFor(() => { - expect(mockCreateAgent).toHaveBeenCalledWith( - buildAgentCreatePayload(mapPresetToAgentDraft(engineerPreset)), - mockProject.id, - ); - }); - }); - - it("supports arrow-key navigation in the first-agent template radio group", async () => { - const mockProject = buildMockProject(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - - render( - - ); - - expect(await registerProjectFromWizard()).toBeDefined(); - const ceoOption = screen.getByRole("radio", { name: "CEO selected" }); - ceoOption.focus(); - - fireEvent.keyDown(ceoOption, { key: "ArrowDown" }); - await waitFor(() => { - expect(screen.getByRole("radio", { name: "CTO selected" })).toHaveAttribute("aria-checked", "true"); - }); - - fireEvent.keyDown(screen.getByRole("radio", { name: "CTO selected" }), { key: "End" }); - const lastPreset = AGENT_PRESETS[AGENT_PRESETS.length - 1]!; - await waitFor(() => { - expect(screen.getByRole("radio", { name: `${lastPreset.name} selected` })).toHaveAttribute("aria-checked", "true"); - }); - }); - - it("keeps first-agent step available when agent creation fails", async () => { - const mockProject = buildMockProject(); - const onProjectRegistered = vi.fn(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - mockCreateAgent.mockRejectedValueOnce(new Error("Agent quota reached")); - - render( - - ); - - expect(await registerProjectFromWizard()).toBeDefined(); - fireEvent.click(screen.getByText("Create Agent")); - - const alert = await screen.findByRole("alert"); - expect(alert).toHaveTextContent("Agent quota reached"); - expect(document.activeElement).toBe(alert); - expect(screen.getByText("Create Agent")).toBeDefined(); - expect(screen.getByText("Skip for now")).toBeDefined(); - expect(screen.getByRole("radio", { name: "CEO selected" })).toHaveAttribute("aria-checked", "true"); - expect(onProjectRegistered).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText("Skip for now")); - expect(await screen.findByText("You can create agents later from the Agents view.")).toBeDefined(); - }); - - it("treats an already-exists agent as success instead of blocking setup", async () => { - // FNXC:Onboarding 2026-07-03: the default "CEO" can already exist when this step runs — reached via - // the unified ModelOnboarding agent step or a prior attempt; agent names are unique per store, so a - // redundant create returns 409 "already exists". That must NOT block first-run: the desired end - // state (a first agent exists) already holds, so the wizard advances to completion. - const mockProject = buildMockProject(); - const onProjectRegistered = vi.fn(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - mockCreateAgent.mockRejectedValueOnce( - new Error('Agent with name "CEO" already exists (agentId: agent_ceo)'), - ); - - render( - - ); - - expect(await registerProjectFromWizard()).toBeDefined(); - fireEvent.click(screen.getByText("Create Agent")); - - // Advances to the completion step; no blocking error alert. - expect(await screen.findByText("Your project is registered and your first agent is ready.")).toBeDefined(); - expect(screen.queryByRole("alert")).toBeNull(); - - fireEvent.click(screen.getByText("Get Started")); - expect(onProjectRegistered).toHaveBeenCalledWith(mockProject); - }); - - it("applies an AI interview draft and waits for explicit creation", async () => { - const mockProject = buildMockProject(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - mockCreateAgent.mockResolvedValueOnce(buildMockAgent({ - id: "agent_launch", - projectId: mockProject.id, - name: "Launch Coordinator", - }) as any); - - render( - - ); - - expect(await registerProjectFromWizard()).toBeDefined(); - expect(screen.getByText("AI Interview")).toBeDefined(); - - fireEvent.click(screen.getByText("AI Interview")); - expect(await screen.findByTestId("agent-interview-modal")).toBeDefined(); - - fireEvent.click(screen.getByText("Use Draft")); - - expect(await screen.findByText("Launch Coordinator")).toBeDefined(); - expect(screen.getByText("Launch Planning Agent")).toBeDefined(); - const ceoRadio = screen.getByRole("radio", { name: "CEO" }); - expect(ceoRadio).toHaveAttribute("tabIndex", "0"); - expect(ceoRadio).toHaveAttribute("aria-checked", "false"); - expect(mockCreateAgent).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText("Create Agent")); - - await waitFor(() => { - expect(mockCreateAgent).toHaveBeenCalledWith( - expect.objectContaining({ - name: "Launch Coordinator", - role: "custom", - title: "Launch Planning Agent", - instructionsText: "Coordinate launch tasks.", - soul: "Strategic launch planner.", - metadata: { skills: ["planning", "review"] }, - runtimeConfig: { - runtimeHint: "codex-local", - maxTurns: 24, - thinkingLevel: "medium", - }, - }), - mockProject.id, - ); - }); - }); - - it("close button calls onClose", () => { - const onClose = vi.fn(); - render( - - ); - - fireEvent.click(screen.getByLabelText("Close wizard")); - expect(onClose).toHaveBeenCalled(); - }); - - it("reveals advanced settings on demand", () => { - render( - - ); - - expect(screen.queryByText("Runtime Node")).toBeNull(); - expect(screen.queryByText("In-Process")).toBeNull(); - - fireEvent.click(screen.getByText("Advanced settings")); - - // FNXC:SetupWizard 2026-07-10-11:00: with no non-local node registered there is - // nothing to choose, so the Runtime Node selector stays hidden in advanced settings. - expect(screen.queryByText("Runtime Node")).toBeNull(); - expect(screen.getByText("In-Process")).toBeDefined(); - expect(screen.getByText("Child-Process")).toBeDefined(); - expect(screen.getByText("Recommended")).toBeDefined(); - }); - - it("can switch isolation mode", () => { - render( - - ); - - fireEvent.click(screen.getByText("Advanced settings")); - - // Initially in-process is selected - const inProcessRadio = screen.getByDisplayValue("in-process") as HTMLInputElement; - const childProcessRadio = screen.getByDisplayValue("child-process") as HTMLInputElement; - - expect(inProcessRadio.checked).toBe(true); - expect(childProcessRadio.checked).toBe(false); - - fireEvent.click(childProcessRadio); - expect(childProcessRadio.checked).toBe(true); - }); - - describe("node selector", () => { - const localNode = { - id: "local-1", - name: "Local Node", - type: "local" as const, - status: "online" as const, - maxConcurrent: 2, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - const remoteNode = { - id: "remote-1", - name: "Remote Node", - type: "remote" as const, - url: "http://localhost:3001", - status: "online" as const, - maxConcurrent: 2, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - it("node selector is present when nodes load", () => { - mockUseNodes.mockImplementation(() => ({ - nodes: [localNode, remoteNode], - loading: false, - error: null, - refresh: vi.fn(), - register: vi.fn(), - update: vi.fn(), - unregister: vi.fn(), - healthCheck: vi.fn(), - })); - - render( - - ); - - fireEvent.click(screen.getByText("Advanced settings")); - - expect(screen.getByText("Runtime Node")).toBeDefined(); - const select = screen.getByRole("combobox") as HTMLSelectElement; - expect(select.value).toBe(""); - - // FNXC:SetupWizard 2026-07-10-11:00: registered local-type node records must NOT - // render as a second near-identical "local (local)" option — the built-in - // "Local node" default option already represents the local machine. - const options = Array.from(select.querySelectorAll("option")); - const optionValues = options.map((opt) => opt.value); - expect(optionValues).not.toContain("local-1"); - expect(optionValues).toContain("remote-1"); - expect(optionValues.filter((value) => value === "")).toHaveLength(1); - // Plain-language explanation of what a runtime node is. - expect(screen.getByText(/A runtime node is the machine where this project's tasks run/)).toBeDefined(); - }); - - it("registration includes selected nodeId", async () => { - mockUseNodes.mockImplementation(() => ({ - nodes: [localNode, remoteNode], - loading: false, - error: null, - refresh: vi.fn(), - register: vi.fn(), - update: vi.fn(), - unregister: vi.fn(), - healthCheck: vi.fn(), - })); - - const mockProject = buildMockProject(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - - render( - - ); - - fireEvent.click(screen.getByText("Advanced settings")); - - // Select the remote node - const select = screen.getByRole("combobox") as HTMLSelectElement; - fireEvent.change(select, { target: { value: "remote-1" } }); - - // Fill path and name - fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { - target: { value: "/home/user/project" }, - }); - fireEvent.change(screen.getByPlaceholderText("my-project"), { - target: { value: "test-project" }, - }); - - // Register - fireEvent.click(screen.getByText("Register Project")); - - await waitFor(() => { - expect(mockRegisterProject).toHaveBeenCalledWith( - expect.objectContaining({ - name: "test-project", - path: "/home/user/project", - isolationMode: "in-process", - nodeId: "remote-1", - }) - ); - }); - }); - - it("registration includes undefined nodeId when only a local node exists (selector hidden)", async () => { - mockUseNodes.mockImplementation(() => ({ - nodes: [localNode], - loading: false, - error: null, - refresh: vi.fn(), - register: vi.fn(), - update: vi.fn(), - unregister: vi.fn(), - healthCheck: vi.fn(), - })); - - const mockProject = buildMockProject(); - mockRegisterProject.mockResolvedValueOnce(mockProject); - - render( - - ); - - fireEvent.click(screen.getByText("Advanced settings")); - - // FNXC:SetupWizard 2026-07-10-11:00: a single-local-node install has no runtime - // choice to make, so the selector is hidden and registration runs locally. - expect(screen.queryByText("Runtime Node")).toBeNull(); - expect(screen.queryByRole("combobox")).toBeNull(); - - // Fill path and name - fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), { - target: { value: "/home/user/project" }, - }); - fireEvent.change(screen.getByPlaceholderText("my-project"), { - target: { value: "test-project" }, - }); - - // Register - fireEvent.click(screen.getByText("Register Project")); - - await waitFor(() => { - expect(mockRegisterProject).toHaveBeenCalledWith( - expect.objectContaining({ - name: "test-project", - path: "/home/user/project", - isolationMode: "in-process", - nodeId: undefined, - }) - ); - }); - }); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts deleted file mode 100644 index f45eea2f3a..0000000000 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts +++ /dev/null @@ -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 / 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*;/); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx deleted file mode 100644 index 1489635d8c..0000000000 --- a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx +++ /dev/null @@ -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[] }) => ( -
- )), -})); - -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(); - - 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(); - - 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(); - - /* - * 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(); - 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(); - - 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 = 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( - , - ); - - 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( - , - ); - - 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(); - - 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(); - - 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( - , - ); - - 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(); - }); -}); diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx deleted file mode 100644 index 7dceb27f24..0000000000 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx +++ /dev/null @@ -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( - - - , - ); -} - -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 }) => 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( - , - ); - - 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"); - }); -}); diff --git a/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts b/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts index 76ad73520f..ae15afcb55 100644 --- a/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts +++ b/packages/dashboard/src/__tests__/dashboard-test-config-guard.test.ts @@ -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"'); }); diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 8059799d36..ee73610922 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -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, diff --git a/packages/engine/src/__tests__/reliability-interactions/_helpers.ts b/packages/engine/src/__tests__/reliability-interactions/_helpers.ts index dcfad76105..21ce90e9b7 100644 --- a/packages/engine/src/__tests__/reliability-interactions/_helpers.ts +++ b/packages/engine/src/__tests__/reliability-interactions/_helpers.ts @@ -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); diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index 1ef714f99e..f9f8fca220 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -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" } ] }