Files
fusion/packages/dashboard/app/__tests__/spinner-animation.css.test.ts
gsxdsm 0744ab3814 FN-6961: restore dashboard spinner animations
Restore Fusion dashboard loading indicators by preventing spinner keyframe collisions.

- Move shared spinner utilities onto the collision-proof fusion-spinner-spin keyframe.
- Rename component-local spin keyframes and scoped spinner utility overrides so lazy CSS chunks cannot override global loaders.
- Extend spinner CSS contract coverage across shared utilities, CSS-only loaders, and renamed loading affordances.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-6961-spinner-animation.md            |   7 +
 .../app/__tests__/spinner-animation.css.test.ts    | 149 +++++++++++++++++----
 .../dashboard/app/components/AgentMentionPopup.css |   6 -
 .../app/components/CustomProvidersSection.css      |   2 +-
 .../app/components/CustomProvidersSection.tsx      |   6 +-
 .../dashboard/app/components/FileMentionPopup.css  |   8 +-
 .../dashboard/app/components/GitHubImportModal.css |   2 +-
 packages/dashboard/app/components/Header.css       |   4 +-
 .../dashboard/app/components/IssueMentionPopup.css |   8 +-
 packages/dashboard/app/components/NodesView.css    |   8 +-
 packages/dashboard/app/components/ScriptsModal.css |   8 +-
 packages/dashboard/app/components/TaskCard.css     |  15 ++-
 .../dashboard/app/components/TaskDetailModal.css   |   4 +-
 .../dashboard/app/components/TerminalModal.css     |   6 -
 packages/dashboard/app/components/TodoView.css     |   4 +-
 .../app/components/WorkflowResultsTab.css          |   4 +-
 packages/dashboard/app/styles.css                  |  13 +-
 17 files changed, 178 insertions(+), 76 deletions(-)

Fusion-Task-Id: FN-6961

Fusion-Task-Lineage: 464e0a50-52c3-4b89-ac72-2f0227d97ce2
2026-06-25 08:34:44 -07:00

164 lines
7.1 KiB
TypeScript

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",
]));
});
});