Merge pull request #51 from Runfusion/codex/testing-suite-quality-prd
test: harden and slim local test workflow
This commit is contained in:
@@ -50,7 +50,7 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --silent=passed-only --reporter=dot",
|
||||
"test:slow-cli": "cross-env FUSION_TEST_SLOW_CLI=1 vitest run src/commands/__tests__/agent-export.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:extension-integration": "cross-env FUSION_TEST_EXTENSION_INTEGRATION=1 vitest run src/__tests__/extension.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:extension-integration": "cross-env FUSION_TEST_EXTENSION_INTEGRATION=1 vitest run src/__tests__/extension-integration.test.ts --silent=passed-only --reporter=dot",
|
||||
"test:build-exe": "cross-env FUSION_TEST_BUILD_EXE=1 vitest run --config vitest.build-exe.config.ts --silent=passed-only --reporter=dot",
|
||||
"test:pre-release": "pnpm test:slow-cli && pnpm test:build-exe"
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
readmeContent = readFileSync(join(workspaceRoot, "README.md"), "utf-8");
|
||||
cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8");
|
||||
extensionSuiteContent = readFileSync(
|
||||
join(workspaceRoot, "packages", "cli", "src", "__tests__", "extension.test.ts"),
|
||||
join(workspaceRoot, "packages", "cli", "src", "__tests__", "extension-integration.test.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
agentExportSuiteContent = readFileSync(
|
||||
@@ -124,11 +124,13 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1");
|
||||
expect(cliPackageJsonContent).toContain('"test:extension-integration"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1");
|
||||
expect(cliPackageJsonContent).toContain("extension-integration.test.ts");
|
||||
expect(cliPackageJsonContent).toContain('"test:build-exe"');
|
||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1");
|
||||
|
||||
expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)");
|
||||
expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION");
|
||||
expect(extensionSuiteContent).toContain("dist/extension.js");
|
||||
|
||||
expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)");
|
||||
expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI");
|
||||
@@ -169,11 +171,30 @@ describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
|
||||
it("uses the same deterministic test sharding command as manual CI", () => {
|
||||
expect(workflow.jobs?.lint).toBeDefined();
|
||||
expect(workflow.jobs?.typecheck).toBeDefined();
|
||||
expect(workflow.jobs?.build).toBeDefined();
|
||||
expect(workflow.jobs?.["test-shards"]).toBeDefined();
|
||||
expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3]);
|
||||
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3");
|
||||
expect(content).not.toContain("run: pnpm test\n");
|
||||
});
|
||||
|
||||
it("keeps build coverage as an explicit PR gate", () => {
|
||||
const buildSteps = workflow.jobs?.build?.steps ?? [];
|
||||
expect(
|
||||
buildSteps.some(
|
||||
(step: any) => step.name === "Build" && typeof step.run === "string" && step.run.includes("pnpm build"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not spend PR action minutes on a pre-test workspace build", () => {
|
||||
const testSteps = workflow.jobs?.["test-shards"]?.steps ?? [];
|
||||
expect(
|
||||
testSteps.some(
|
||||
(step: any) => step.name === "Build" || (typeof step.run === "string" && step.run.includes("pnpm build")),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Version & Release workflow (.github/workflows/version.yml)", () => {
|
||||
|
||||
214
packages/cli/src/__tests__/extension-integration.test.ts
Normal file
214
packages/cli/src/__tests__/extension-integration.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { AgentStore, TaskStore } from "@fusion/core";
|
||||
import {
|
||||
buildCliWithRealDashboardAssets,
|
||||
extensionBundlePath,
|
||||
} from "./bundle-output-helpers";
|
||||
|
||||
vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 });
|
||||
|
||||
const SHOULD_RUN_EXTENSION_INTEGRATION =
|
||||
process.env.FUSION_TEST_EXTENSION_INTEGRATION === "1" ||
|
||||
process.env.FUSION_TEST_EXTENSION_INTEGRATION === "true";
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: any,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: ((update: any) => void) | undefined,
|
||||
ctx: any,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
type EventHandler = (...args: any[]) => unknown | Promise<unknown>;
|
||||
|
||||
interface MockExtensionApi {
|
||||
tools: Map<string, RegisteredTool>;
|
||||
commands: Map<string, any>;
|
||||
events: Map<string, EventHandler>;
|
||||
registerTool: (def: RegisteredTool) => void;
|
||||
registerCommand: (name: string, def: any) => void;
|
||||
registerShortcut: ReturnType<typeof vi.fn>;
|
||||
registerFlag: ReturnType<typeof vi.fn>;
|
||||
on: (event: string, handler: EventHandler) => void;
|
||||
}
|
||||
|
||||
function createMockAPI(): MockExtensionApi {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
const commands = new Map<string, any>();
|
||||
const events = new Map<string, EventHandler>();
|
||||
|
||||
return {
|
||||
registerTool(def: RegisteredTool) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand(name: string, def: any) {
|
||||
commands.set(name, def);
|
||||
},
|
||||
registerShortcut: vi.fn(),
|
||||
registerFlag: vi.fn(),
|
||||
on(event: string, handler: EventHandler) {
|
||||
events.set(event, handler);
|
||||
},
|
||||
tools,
|
||||
commands,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(cwd: string) {
|
||||
return { cwd } as any;
|
||||
}
|
||||
|
||||
async function importBuiltExtension() {
|
||||
const mod = await import(`${pathToFileURL(extensionBundlePath).href}?t=${Date.now()}`);
|
||||
const extension = mod.default;
|
||||
if (typeof extension !== "function") {
|
||||
throw new Error("dist/extension.js did not export the pi extension function");
|
||||
}
|
||||
return extension as (api: MockExtensionApi) => void;
|
||||
}
|
||||
|
||||
async function removeDirWithRetries(path: string) {
|
||||
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||
try {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOTEMPTY" && code !== "EBUSY") {
|
||||
throw error;
|
||||
}
|
||||
if (attempt === 4) {
|
||||
throw error;
|
||||
}
|
||||
await delay(25 * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAgent(cwd: string, options: { name: string; ephemeral?: boolean }) {
|
||||
const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") });
|
||||
await agentStore.init();
|
||||
return agentStore.createAgent({
|
||||
name: options.name,
|
||||
role: "executor",
|
||||
metadata: options.ephemeral ? { agentKind: "task-worker" } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integration", () => {
|
||||
let tmpDir: string;
|
||||
let api: MockExtensionApi;
|
||||
let extension: (api: MockExtensionApi) => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
buildCliWithRealDashboardAssets();
|
||||
extension = await importBuiltExtension();
|
||||
}, 300_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "fusion-built-ext-"));
|
||||
await mkdir(join(tmpDir, ".fusion"), { recursive: true });
|
||||
api = createMockAPI();
|
||||
extension(api);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const shutdown = api.events.get("session_shutdown");
|
||||
if (shutdown) {
|
||||
await shutdown();
|
||||
}
|
||||
await removeDirWithRetries(tmpDir);
|
||||
});
|
||||
|
||||
it("registers the current public extension surface from dist/extension.js", () => {
|
||||
expect(api.commands.has("fn")).toBe(true);
|
||||
expect(api.events.has("session_shutdown")).toBe(true);
|
||||
|
||||
for (const toolName of [
|
||||
"fn_task_create",
|
||||
"fn_task_list",
|
||||
"fn_task_show",
|
||||
"fn_list_agents",
|
||||
"fn_delegate_task",
|
||||
"fn_agent_show",
|
||||
"fn_research_run",
|
||||
"fn_skills_install",
|
||||
]) {
|
||||
expect(api.tools.has(toolName), `${toolName} should be registered`).toBe(true);
|
||||
}
|
||||
|
||||
for (const internalToolName of [
|
||||
"fn_task_move",
|
||||
"fn_task_update_step",
|
||||
"fn_task_log",
|
||||
"fn_task_merge",
|
||||
]) {
|
||||
expect(api.tools.has(internalToolName), `${internalToolName} should stay engine-internal`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and lists tasks through the built extension", async () => {
|
||||
const createTool = api.tools.get("fn_task_create")!;
|
||||
const created = await createTool.execute(
|
||||
"create-1",
|
||||
{ description: "Ship the packed CLI contract" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(created.details.taskId).toMatch(/^[A-Z]+-\d+$/);
|
||||
expect(created.details.column).toBe("triage");
|
||||
|
||||
const listTool = api.tools.get("fn_task_list")!;
|
||||
const listed = await listTool.execute("list-1", {}, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(listed.content[0].text).toContain(created.details.taskId);
|
||||
expect(listed.content[0].text).toContain("Ship the packed CLI contract");
|
||||
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const persisted = await store.getTask(created.details.taskId);
|
||||
expect(persisted?.description).toBe("Ship the packed CLI contract");
|
||||
});
|
||||
|
||||
it("delegates to real non-ephemeral agents and rejects runtime workers", async () => {
|
||||
const agent = await seedAgent(tmpDir, { name: "release-agent" });
|
||||
const runtimeWorker = await seedAgent(tmpDir, { name: "runtime-worker", ephemeral: true });
|
||||
|
||||
const listAgentsTool = api.tools.get("fn_list_agents")!;
|
||||
const listedAgents = await listAgentsTool.execute("agents-1", {}, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(listedAgents.content[0].text).toContain("release-agent");
|
||||
expect(listedAgents.content[0].text).not.toContain("runtime-worker");
|
||||
|
||||
const delegateTool = api.tools.get("fn_delegate_task")!;
|
||||
const delegated = await delegateTool.execute(
|
||||
"delegate-1",
|
||||
{ agent_id: agent.id, description: "Verify release locally" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
expect(delegated.details.agentId).toBe(agent.id);
|
||||
expect(delegated.content[0].text).toContain("release-agent");
|
||||
|
||||
const rejected = await delegateTool.execute(
|
||||
"delegate-2",
|
||||
{ agent_id: runtimeWorker.id, description: "Should not assign" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
expect(rejected.isError).toBe(true);
|
||||
expect(rejected.content[0].text).toContain("ephemeral/runtime agent");
|
||||
});
|
||||
});
|
||||
@@ -135,15 +135,16 @@ async function enableResearch(cwd: string): Promise<TaskStore> {
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
// Audited in FN-3189: this suite is expensive (~62s) and currently stale
|
||||
// against modern extension behavior/tooling (see FN-3204). Keep an explicit,
|
||||
// discoverable gate so it never silently disappears behind unconditional skip,
|
||||
// but do not include it in the default slow lane until the failures are fixed.
|
||||
const SHOULD_RUN_EXTENSION_INTEGRATION =
|
||||
process.env.FUSION_TEST_EXTENSION_INTEGRATION === "1" ||
|
||||
process.env.FUSION_TEST_EXTENSION_INTEGRATION === "true";
|
||||
// Audited in FN-3189: this exhaustive suite is expensive (~62s) and stale
|
||||
// against modern extension behavior/tooling (see FN-3204). The maintained
|
||||
// release lane lives in extension-integration.test.ts and uses
|
||||
// FUSION_TEST_EXTENSION_INTEGRATION. Keep this under a separate legacy gate for
|
||||
// historical debugging only.
|
||||
const SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION =
|
||||
process.env.FUSION_TEST_LEGACY_EXTENSION_INTEGRATION === "1" ||
|
||||
process.env.FUSION_TEST_LEGACY_EXTENSION_INTEGRATION === "true";
|
||||
|
||||
describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("fn pi extension", () => {
|
||||
describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (legacy exhaustive suite)", () => {
|
||||
let tmpDir: string;
|
||||
let api: ReturnType<typeof createMockAPI>;
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ function loadRootPackageJson(): any {
|
||||
return JSON.parse(readFileSync(path, "utf-8"));
|
||||
}
|
||||
|
||||
function hasProjectArg(script: string | undefined, project: string): boolean {
|
||||
const parts = script?.trim().split(/\s+/) ?? [];
|
||||
return parts.some((part, index) => part === "--project" && parts[index + 1] === project);
|
||||
}
|
||||
|
||||
describe("CLI package.json publishing config", () => {
|
||||
const pkg = loadPackageJson("cli");
|
||||
|
||||
@@ -210,6 +215,7 @@ describe("Scoped @fusion/* packages publishing config", () => {
|
||||
|
||||
describe("Workspace bootstrap script contract", () => {
|
||||
const rootPkg = loadRootPackageJson();
|
||||
const dashboardPkg = loadPackageJson("dashboard");
|
||||
|
||||
it("makes root test changed-only while keeping explicit full-suite and CI-shard commands", () => {
|
||||
expect(rootPkg.scripts?.test).toBe("node scripts/test-changed.mjs");
|
||||
@@ -246,6 +252,21 @@ describe("Workspace bootstrap script contract", () => {
|
||||
"pnpm --filter @fusion/dashboard build && pnpm --filter @fusion/mobile cap sync",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps dashboard's default test lane curated with explicit deep coverage", () => {
|
||||
const defaultTest = dashboardPkg.scripts?.test;
|
||||
const deepTest = dashboardPkg.scripts?.["test:deep"];
|
||||
|
||||
expect(hasProjectArg(defaultTest, "dashboard-app-quality")).toBe(true);
|
||||
expect(hasProjectArg(defaultTest, "dashboard-api-quality")).toBe(true);
|
||||
expect(hasProjectArg(defaultTest, "dashboard-app")).toBe(false);
|
||||
expect(hasProjectArg(defaultTest, "dashboard-api")).toBe(false);
|
||||
|
||||
expect(hasProjectArg(deepTest, "dashboard-app")).toBe(true);
|
||||
expect(hasProjectArg(deepTest, "dashboard-api")).toBe(true);
|
||||
expect(hasProjectArg(deepTest, "dashboard-app-quality")).toBe(false);
|
||||
expect(hasProjectArg(deepTest, "dashboard-api-quality")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow YAML validity", () => {
|
||||
|
||||
@@ -38,6 +38,27 @@ const fsPromises = requireFromHere("node:fs/promises") as FsPromisesModule;
|
||||
const childProcess = requireFromHere("node:child_process") as ChildProcessModule;
|
||||
const { mkdtempSync, mkdirSync, rmSync, realpathSync, existsSync } = fs;
|
||||
|
||||
type EmitWarningArgs = Parameters<typeof process.emitWarning>;
|
||||
type EmitWarningRestArgs = EmitWarningArgs extends [string | Error, ...infer Rest] ? Rest : never;
|
||||
|
||||
function installWarningFilter(): void {
|
||||
const warningState = globalThis as typeof globalThis & { __fusionTestWarningFilterInstalled?: boolean };
|
||||
if (warningState.__fusionTestWarningFilterInstalled) return;
|
||||
warningState.__fusionTestWarningFilterInstalled = true;
|
||||
|
||||
const originalEmitWarning = process.emitWarning.bind(process);
|
||||
process.emitWarning = ((warning: string | Error, ...args: EmitWarningRestArgs) => {
|
||||
const warningText = warning instanceof Error ? warning.message : warning;
|
||||
const warningType = typeof args[0] === "string" ? args[0] : undefined;
|
||||
if (warningType === "ExperimentalWarning" && warningText.includes("SQLite is an experimental feature")) {
|
||||
return;
|
||||
}
|
||||
return originalEmitWarning(warning, ...args);
|
||||
}) as typeof process.emitWarning;
|
||||
}
|
||||
|
||||
installWarningFilter();
|
||||
|
||||
const TEST_HOME_PREFIX = "fn-test-home-";
|
||||
const DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS = Math.max(
|
||||
1_000,
|
||||
@@ -46,16 +67,6 @@ const DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS = Math.max(
|
||||
const BLOCKED_TEST_CLI_PATTERN =
|
||||
/(^|[\s"'\\/])(?:claude|droid|paperclipai|hermes|openclaw)(?:\.(?:cmd|bat|ps1|exe))?(?=$|[\s"'\\/])/i;
|
||||
|
||||
const originalEmitWarning = process.emitWarning.bind(process);
|
||||
process.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
|
||||
const message = typeof warning === "string" ? warning : warning?.message ?? "";
|
||||
const type = typeof args[0] === "string" ? args[0] : (args[0] as { type?: string } | undefined)?.type;
|
||||
if (type === "ExperimentalWarning" && message.includes("SQLite is an experimental feature")) {
|
||||
return;
|
||||
}
|
||||
return (originalEmitWarning as (...a: unknown[]) => void)(warning, ...args);
|
||||
}) as typeof process.emitWarning;
|
||||
|
||||
const originalCwd = process.cwd.bind(process);
|
||||
|
||||
function ensureValidCwd(): string {
|
||||
|
||||
@@ -25,12 +25,6 @@ function hasClass(cls: string): boolean {
|
||||
return new RegExp(`${escaped}(?=[\\s,{:.#>+~])`).test(stylesContent);
|
||||
}
|
||||
|
||||
function extractSection(startMarker: string, endMarker: string): string {
|
||||
const start = stylesContent.indexOf(startMarker);
|
||||
const end = stylesContent.indexOf(endMarker, start + startMarker.length);
|
||||
return start >= 0 && end >= 0 ? stylesContent.slice(start, end) : "";
|
||||
}
|
||||
|
||||
describe("Agent CSS classes", () => {
|
||||
// Verify agent state CSS variables are defined in the global stylesheet
|
||||
it("should define --state-* CSS variables", () => {
|
||||
@@ -48,109 +42,6 @@ describe("Agent CSS classes", () => {
|
||||
expect(stylesContent).toContain("--state-error-border:");
|
||||
});
|
||||
|
||||
// Verify BEM button modifier classes exist
|
||||
it("should define BEM button modifier classes", () => {
|
||||
expect(hasClass(".btn--sm")).toBe(true);
|
||||
expect(hasClass(".btn--primary")).toBe(true);
|
||||
expect(hasClass(".btn--danger")).toBe(true);
|
||||
expect(hasClass(".btn--warning")).toBe(true);
|
||||
expect(hasClass(".btn--compact")).toBe(true);
|
||||
});
|
||||
|
||||
// Verify badge base class
|
||||
it("should define .badge base class", () => {
|
||||
expect(hasClass(".badge")).toBe(true);
|
||||
});
|
||||
|
||||
// Verify AgentMetricsBar classes
|
||||
it("should define AgentMetricsBar CSS classes", () => {
|
||||
expect(hasClass(".agent-metrics-bar")).toBe(true);
|
||||
expect(hasClass(".agent-metric-card")).toBe(true);
|
||||
expect(hasClass(".agent-metric-card--active")).toBe(true);
|
||||
expect(hasClass(".agent-metric-card--tasks")).toBe(true);
|
||||
expect(hasClass(".agent-metric-card--success")).toBe(true);
|
||||
expect(hasClass(".agent-metric-card--runs")).toBe(true);
|
||||
expect(hasClass(".agent-metric-info")).toBe(true);
|
||||
expect(hasClass(".agent-metric-value")).toBe(true);
|
||||
expect(hasClass(".agent-metric-label")).toBe(true);
|
||||
});
|
||||
|
||||
// Verify AgentsView classes
|
||||
it("should define AgentsView CSS classes", () => {
|
||||
expect(hasClass(".agents-view")).toBe(true);
|
||||
expect(hasClass(".agents-view-header")).toBe(true);
|
||||
expect(hasClass(".agents-view-title")).toBe(true);
|
||||
expect(hasClass(".agents-view-controls")).toBe(true);
|
||||
expect(hasClass(".agents-view-primary-actions")).toBe(true);
|
||||
expect(hasClass(".agents-view-content")).toBe(true);
|
||||
expect(hasClass(".agents-overview-bar")).toBe(true);
|
||||
expect(hasClass(".agents-overview-bar__toggle")).toBe(true);
|
||||
expect(hasClass(".agents-overview-bar__content")).toBe(true);
|
||||
expect(hasClass(".agent-controls-trigger")).toBe(true);
|
||||
expect(hasClass(".agent-controls-trigger--active")).toBe(true);
|
||||
expect(hasClass(".agent-controls-panel")).toBe(true);
|
||||
expect(hasClass(".agent-controls")).toBe(true);
|
||||
expect(hasClass(".agent-controls-filters")).toBe(true);
|
||||
expect(hasClass(".agent-state-filter")).toBe(true);
|
||||
expect(hasClass(".agent-state-filter-select")).toBe(true);
|
||||
expect(hasClass(".agent-system-filter")).toBe(true);
|
||||
expect(hasClass(".agent-controls-actions")).toBe(true);
|
||||
expect(hasClass(".agent-global-controls")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-shell")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-controls")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-viewport")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-75")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-100")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-125")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-150")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart--vertical")).toBe(true);
|
||||
expect(hasClass(".agent-board")).toBe(true);
|
||||
expect(hasClass(".agent-board-card")).toBe(true);
|
||||
expect(hasClass(".agent-board-card--idle")).toBe(true);
|
||||
expect(hasClass(".agent-board-card--active")).toBe(true);
|
||||
expect(hasClass(".agent-board-card--running")).toBe(true);
|
||||
expect(hasClass(".agent-board-card--paused")).toBe(true);
|
||||
expect(hasClass(".agent-board-card--error")).toBe(true);
|
||||
expect(hasClass(".agent-board-header")).toBe(true);
|
||||
expect(hasClass(".agent-board-icon")).toBe(true);
|
||||
expect(hasClass(".agent-board-badge")).toBe(true);
|
||||
expect(hasClass(".agent-badge--idle")).toBe(true);
|
||||
expect(hasClass(".agent-badge--active")).toBe(true);
|
||||
expect(hasClass(".agent-badge--running")).toBe(true);
|
||||
expect(hasClass(".agent-badge--paused")).toBe(true);
|
||||
expect(hasClass(".agent-badge--error")).toBe(true);
|
||||
expect(hasClass(".agent-board-health")).toBe(true);
|
||||
expect(hasClass(".agent-board-name")).toBe(true);
|
||||
expect(hasClass(".agent-board-id")).toBe(true);
|
||||
expect(hasClass(".agent-board-clickable")).toBe(true);
|
||||
expect(hasClass(".agent-board-actions")).toBe(true);
|
||||
expect(hasClass(".agent-list")).toBe(true);
|
||||
expect(hasClass(".agent-card")).toBe(true);
|
||||
expect(hasClass(".agent-card--idle")).toBe(true);
|
||||
expect(hasClass(".agent-card--active")).toBe(true);
|
||||
expect(hasClass(".agent-card--running")).toBe(true);
|
||||
expect(hasClass(".agent-card--paused")).toBe(true);
|
||||
expect(hasClass(".agent-card--error")).toBe(true);
|
||||
expect(hasClass(".agent-card-header")).toBe(true);
|
||||
expect(hasClass(".agent-card-body")).toBe(true);
|
||||
expect(hasClass(".agent-card-actions")).toBe(true);
|
||||
expect(hasClass(".agent-info")).toBe(true);
|
||||
expect(hasClass(".agent-info--clickable")).toBe(true);
|
||||
expect(hasClass(".agent-icon")).toBe(true);
|
||||
expect(hasClass(".agent-icon--clickable")).toBe(true);
|
||||
expect(hasClass(".agent-meta")).toBe(true);
|
||||
expect(hasClass(".agent-name")).toBe(true);
|
||||
expect(hasClass(".agent-id")).toBe(true);
|
||||
expect(hasClass(".agent-badges")).toBe(true);
|
||||
expect(hasClass(".agent-card-chevron")).toBe(true);
|
||||
expect(hasClass(".agent-task")).toBe(true);
|
||||
expect(hasClass(".agent-heartbeat")).toBe(true);
|
||||
expect(hasClass(".agent-role-select")).toBe(true);
|
||||
expect(hasClass(".agent-empty")).toBe(true);
|
||||
expect(hasClass(".spin")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps split layout as the scroll-constrained pane container", () => {
|
||||
const splitLayout = extractRuleBlock(".agents-split-layout");
|
||||
expect(splitLayout).toContain("flex: 1");
|
||||
@@ -218,7 +109,6 @@ describe("Agent CSS classes", () => {
|
||||
expect(orgChartSection).not.toMatch(/1\.5rem|0\.75rem|0\.72rem|0\.78rem|0\.65rem|120ms\s+ease|10px/);
|
||||
});
|
||||
|
||||
// Verify AgentDetailView classes
|
||||
it("encodes compact mobile agent detail header layout contracts", () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, "../components/AgentDetailView.css"), "utf-8");
|
||||
const mobileStart = css.indexOf("@media (max-width: 768px)");
|
||||
@@ -232,117 +122,6 @@ describe("Agent CSS classes", () => {
|
||||
expect(mobileCss).toContain(".agent-detail-mobile-icon-control .agent-detail-control-label {");
|
||||
});
|
||||
|
||||
it("should define AgentDetailView CSS classes", () => {
|
||||
expect(hasClass(".agent-detail-overlay")).toBe(true);
|
||||
expect(hasClass(".agent-detail-modal")).toBe(true);
|
||||
expect(hasClass(".agent-detail-loading")).toBe(true);
|
||||
expect(hasClass(".agent-detail-header")).toBe(true);
|
||||
expect(hasClass(".agent-detail-title")).toBe(true);
|
||||
expect(hasClass(".agent-detail-icon")).toBe(true);
|
||||
expect(hasClass(".agent-detail-info")).toBe(true);
|
||||
expect(hasClass(".agent-detail-badges")).toBe(true);
|
||||
expect(hasClass(".agent-detail-actions")).toBe(true);
|
||||
// Redesigned compact header structure
|
||||
expect(hasClass(".agent-detail-identity")).toBe(true);
|
||||
expect(hasClass(".agent-detail-inline-back")).toBe(true);
|
||||
expect(hasClass(".agent-detail-controls")).toBe(true);
|
||||
expect(hasClass(".agent-detail-utility-actions")).toBe(true);
|
||||
expect(hasClass(".agent-detail-tabs")).toBe(true);
|
||||
expect(hasClass(".agent-detail-tab")).toBe(true);
|
||||
expect(hasClass(".agent-detail-content")).toBe(true);
|
||||
expect(hasClass(".agent-detail-footer")).toBe(true);
|
||||
expect(hasClass(".agent-detail-id")).toBe(true);
|
||||
expect(hasClass(".dashboard-tab")).toBe(true);
|
||||
expect(hasClass(".dashboard-section")).toBe(true);
|
||||
expect(hasClass(".info-grid")).toBe(true);
|
||||
expect(hasClass(".info-item")).toBe(true);
|
||||
expect(hasClass(".info-label")).toBe(true);
|
||||
expect(hasClass(".info-value")).toBe(true);
|
||||
expect(hasClass(".inline-badge")).toBe(true);
|
||||
expect(hasClass(".stats-grid")).toBe(true);
|
||||
expect(hasClass(".stat-card")).toBe(true);
|
||||
expect(hasClass(".stat-value")).toBe(true);
|
||||
expect(hasClass(".stat-label")).toBe(true);
|
||||
expect(hasClass(".current-task")).toBe(true);
|
||||
expect(hasClass(".task-badge")).toBe(true);
|
||||
expect(hasClass(".metadata-json")).toBe(true);
|
||||
expect(hasClass(".logs-tab")).toBe(true);
|
||||
expect(hasClass(".logs-header")).toBe(true);
|
||||
expect(hasClass(".logs-count")).toBe(true);
|
||||
expect(hasClass(".streaming-indicator")).toBe(true);
|
||||
expect(hasClass(".streaming-dot")).toBe(true);
|
||||
expect(hasClass(".logs-empty")).toBe(true);
|
||||
expect(hasClass(".runs-tab")).toBe(true);
|
||||
expect(hasClass(".runs-empty")).toBe(true);
|
||||
expect(hasClass(".run-card")).toBe(true);
|
||||
expect(hasClass(".run-card--active")).toBe(true);
|
||||
expect(hasClass(".run-header")).toBe(true);
|
||||
expect(hasClass(".run-live-indicator")).toBe(true);
|
||||
expect(hasClass(".live-dot")).toBe(true);
|
||||
expect(hasClass(".run-id")).toBe(true);
|
||||
expect(hasClass(".run-status")).toBe(true);
|
||||
expect(hasClass(".run-details")).toBe(true);
|
||||
expect(hasClass(".config-tab")).toBe(true);
|
||||
expect(hasClass(".config-section")).toBe(true);
|
||||
expect(hasClass(".config-description")).toBe(true);
|
||||
expect(hasClass(".config-fields")).toBe(true);
|
||||
expect(hasClass(".config-field")).toBe(true);
|
||||
expect(hasClass(".config-hint")).toBe(true);
|
||||
expect(hasClass(".config-error")).toBe(true);
|
||||
expect(hasClass(".config-actions")).toBe(true);
|
||||
expect(hasClass(".config-saved-indicator")).toBe(true);
|
||||
expect(hasClass(".input--error")).toBe(true);
|
||||
});
|
||||
|
||||
it("should define AgentReflectionsTab and ratings CSS classes", () => {
|
||||
expect(hasClass(".reflections-tab")).toBe(true);
|
||||
expect(hasClass(".reflections-header")).toBe(true);
|
||||
expect(hasClass(".reflections-stats-grid")).toBe(true);
|
||||
expect(hasClass(".reflections-stat-card")).toBe(true);
|
||||
expect(hasClass(".reflections-no-data")).toBe(true);
|
||||
expect(hasClass(".reflections-loading-indicator")).toBe(true);
|
||||
expect(hasClass(".reflections-ratings-section")).toBe(true);
|
||||
expect(hasClass(".reflections-list")).toBe(true);
|
||||
expect(hasClass(".reflection-cards")).toBe(true);
|
||||
expect(hasClass(".reflection-card")).toBe(true);
|
||||
expect(hasClass(".reflection-card--expanded")).toBe(true);
|
||||
expect(hasClass(".reflection-card-header")).toBe(true);
|
||||
expect(hasClass(".reflection-trigger-badge")).toBe(true);
|
||||
expect(hasClass(".reflection-summary")).toBe(true);
|
||||
expect(hasClass(".reflection-details")).toBe(true);
|
||||
expect(hasClass(".reflection-empty")).toBe(true);
|
||||
|
||||
expect(hasClass(".rating-summary-card")).toBe(true);
|
||||
expect(hasClass(".rating-score-display")).toBe(true);
|
||||
expect(hasClass(".rating-average")).toBe(true);
|
||||
expect(hasClass(".rating-stats")).toBe(true);
|
||||
expect(hasClass(".rating-count")).toBe(true);
|
||||
expect(hasClass(".rating-trend-badge")).toBe(true);
|
||||
expect(hasClass(".trend-improving")).toBe(true);
|
||||
expect(hasClass(".trend-declining")).toBe(true);
|
||||
expect(hasClass(".trend-stable")).toBe(true);
|
||||
expect(hasClass(".trend-insufficient")).toBe(true);
|
||||
expect(hasClass(".category-breakdown")).toBe(true);
|
||||
expect(hasClass(".category-item")).toBe(true);
|
||||
expect(hasClass(".category-name")).toBe(true);
|
||||
expect(hasClass(".category-score")).toBe(true);
|
||||
expect(hasClass(".add-rating-form")).toBe(true);
|
||||
expect(hasClass(".add-rating-category-select")).toBe(true);
|
||||
expect(hasClass(".add-rating-comment-input")).toBe(true);
|
||||
expect(hasClass(".star-selector")).toBe(true);
|
||||
expect(hasClass(".star-btn")).toBe(true);
|
||||
expect(hasClass(".rating-stars")).toBe(true);
|
||||
expect(hasClass(".star-filled")).toBe(true);
|
||||
expect(hasClass(".star-empty")).toBe(true);
|
||||
expect(hasClass(".rating-history")).toBe(true);
|
||||
expect(hasClass(".rating-history-item")).toBe(true);
|
||||
expect(hasClass(".rating-item-header")).toBe(true);
|
||||
expect(hasClass(".rating-category-badge")).toBe(true);
|
||||
expect(hasClass(".rating-time")).toBe(true);
|
||||
expect(hasClass(".rating-delete-btn")).toBe(true);
|
||||
expect(hasClass(".rating-comment")).toBe(true);
|
||||
});
|
||||
|
||||
it("should apply accessible focus/hover styles for merged evaluation cards and actions", () => {
|
||||
expect(stylesContent).toContain(".star-btn:focus-visible");
|
||||
expect(extractRuleBlock(".star-btn:focus-visible")).toContain("box-shadow: var(--focus-ring-strong)");
|
||||
@@ -358,49 +137,6 @@ describe("Agent CSS classes", () => {
|
||||
expect(stylesContent).toContain("flex-wrap: wrap");
|
||||
});
|
||||
|
||||
// Verify ActiveAgentsPanel classes
|
||||
it("should define ActiveAgentsPanel CSS classes", () => {
|
||||
expect(hasClass(".active-agents-panel")).toBe(true);
|
||||
expect(hasClass(".active-agents-panel-header")).toBe(true);
|
||||
expect(hasClass(".active-agents-grid")).toBe(true);
|
||||
expect(hasClass(".live-agent-card")).toBe(true);
|
||||
expect(hasClass(".live-agent-card-header")).toBe(true);
|
||||
expect(hasClass(".live-agent-card-name")).toBe(true);
|
||||
expect(hasClass(".status-dot")).toBe(true);
|
||||
expect(hasClass(".live-agent-task")).toBe(true);
|
||||
expect(hasClass(".live-agent-card-transcript")).toBe(true);
|
||||
expect(hasClass(".live-agent-card-empty")).toBe(true);
|
||||
expect(hasClass(".live-agent-card-line")).toBe(true);
|
||||
expect(hasClass(".live-agent-card-footer")).toBe(true);
|
||||
expect(hasClass(".live-agent-streaming-dot")).toBe(true);
|
||||
});
|
||||
|
||||
// Verify NewAgentDialog classes
|
||||
it("should define NewAgentDialog CSS classes", () => {
|
||||
expect(hasClass(".agent-dialog-overlay")).toBe(true);
|
||||
expect(hasClass(".agent-dialog")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-header")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-header-title")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-body")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-footer")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-steps")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-step")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-field")).toBe(true);
|
||||
expect(hasClass(".agent-role-grid")).toBe(true);
|
||||
expect(hasClass(".agent-role-option")).toBe(true);
|
||||
expect(hasClass(".agent-role-option-icon")).toBe(true);
|
||||
expect(hasClass(".agent-role-option-label")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-summary")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-summary-row")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-summary-row-label")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-summary-row-value")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-required")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-optional")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-error")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-info")).toBe(true);
|
||||
expect(hasClass(".agent-dialog-loading")).toBe(true);
|
||||
});
|
||||
|
||||
it("should give role option buttons a tokenized focus-visible state", () => {
|
||||
expect(stylesContent).toContain(".agent-role-option:focus-visible");
|
||||
const roleFocusBlock = extractRuleBlock(".agent-role-option:focus-visible");
|
||||
@@ -408,10 +144,7 @@ describe("Agent CSS classes", () => {
|
||||
expect(roleFocusBlock).toContain("box-shadow: var(--focus-ring-strong)");
|
||||
});
|
||||
|
||||
it("should define shared AgentEmptyState component primitives", () => {
|
||||
expect(hasClass(".agent-empty-state__icon")).toBe(true);
|
||||
expect(hasClass(".agent-empty-state__title")).toBe(true);
|
||||
expect(hasClass(".agent-empty-state__description")).toBe(true);
|
||||
it("should keep the create-agent empty-state action copy", () => {
|
||||
expect(agentEmptyStateContent).toContain("Create Agent");
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const COMPONENTS_DIR = join(APP_DIR, "components");
|
||||
|
||||
let cached: string | null = null;
|
||||
let stylesCached: string | null = null;
|
||||
let baseOnlyCached: string | null = null;
|
||||
|
||||
export function loadStylesCss(): string {
|
||||
if (stylesCached !== null) return stylesCached;
|
||||
@@ -40,6 +41,7 @@ export function loadAllAppCss(): string {
|
||||
* against @media overrides that happen to come earlier in the source order.
|
||||
*/
|
||||
export function loadAllAppCssBaseOnly(): string {
|
||||
if (baseOnlyCached !== null) return baseOnlyCached;
|
||||
const src = loadAllAppCss();
|
||||
// Walk and excise any top-level @<rule> { ... } block (e.g. @media, @supports)
|
||||
let out = "";
|
||||
@@ -60,5 +62,6 @@ export function loadAllAppCssBaseOnly(): string {
|
||||
}
|
||||
out += src[i++];
|
||||
}
|
||||
return out;
|
||||
baseOnlyCached = out;
|
||||
return baseOnlyCached;
|
||||
}
|
||||
|
||||
@@ -52,8 +52,12 @@
|
||||
"build:client": "vite build",
|
||||
"dev": "pnpm build && pnpm typecheck && pnpm dev:serve",
|
||||
"dev:serve": "vite dev",
|
||||
"test": "vitest run --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'",
|
||||
"test:build": "vitest run --silent=passed-only --reporter=dot app/__tests__/build-output.test.ts",
|
||||
"test": "vitest run --project dashboard-app-quality --project dashboard-api-quality --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'",
|
||||
"test:app": "vitest run --project dashboard-app --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'",
|
||||
"test:api": "vitest run --project dashboard-api --silent=passed-only --reporter=dot",
|
||||
"test:deep": "vitest run --project dashboard-app --project dashboard-api --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'",
|
||||
"test:browser-smoke": "node scripts/browser-layout-smoke.mjs",
|
||||
"test:build": "vitest run --project dashboard-app --silent=passed-only --reporter=dot app/__tests__/build-output.test.ts",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
665
packages/dashboard/scripts/browser-layout-smoke.mjs
Normal file
665
packages/dashboard/scripts/browser-layout-smoke.mjs
Normal file
@@ -0,0 +1,665 @@
|
||||
#!/usr/bin/env node
|
||||
/* global WebSocket, URL, fetch, console, setTimeout, clearTimeout */
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, rm, stat, mkdtemp } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const dashboardRoot = path.resolve(import.meta.dirname, "..");
|
||||
const appRoot = path.join(dashboardRoot, "app");
|
||||
const clientDistRoot = path.join(dashboardRoot, "dist", "client");
|
||||
const requireBrowser = process.argv.includes("--require-browser") || process.env.FUSION_BROWSER_SMOKE_REQUIRE === "1";
|
||||
|
||||
function log(message) {
|
||||
console.log(`[dashboard-browser-smoke] ${message}`);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function loadDashboardCss() {
|
||||
try {
|
||||
return await readEmittedClientCss();
|
||||
} catch {
|
||||
await runCommand("pnpm", ["--filter", "@fusion/dashboard", "build:client"], dashboardRoot);
|
||||
return readEmittedClientCss();
|
||||
}
|
||||
}
|
||||
|
||||
async function readEmittedClientCss() {
|
||||
const indexHtml = await readFile(path.join(clientDistRoot, "index.html"), "utf8");
|
||||
const hrefs = [...indexHtml.matchAll(/<link\b[^>]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*>/gi)]
|
||||
.map((match) => match[1]);
|
||||
|
||||
if (hrefs.length === 0) {
|
||||
fail(`No emitted dashboard stylesheet links found in ${path.join(clientDistRoot, "index.html")}.`);
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for (const href of hrefs) {
|
||||
const file = path.join(clientDistRoot, href.replace(/^\//, ""));
|
||||
chunks.push(`\n/* ${path.relative(dashboardRoot, file)} */\n${await readFile(file, "utf8")}`);
|
||||
}
|
||||
return chunks.join("\n");
|
||||
}
|
||||
|
||||
function runCommand(command, commandArgs, cwd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, commandArgs, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${commandArgs.join(" ")} exited with code ${code}.`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createSmokeHtml() {
|
||||
const columns = [
|
||||
["triage", "Triage", "1"],
|
||||
["todo", "Todo", "2"],
|
||||
["in-progress", "In Progress", "1"],
|
||||
["in-review", "In Review", "1"],
|
||||
["done", "Done", "3"],
|
||||
["archived", "Archived", "0"],
|
||||
];
|
||||
|
||||
const columnMarkup = columns
|
||||
.map(([column, label, count]) => `
|
||||
<section class="column" data-column="${column}">
|
||||
<header class="column-header">
|
||||
<span class="column-dot dot-${column}"></span>
|
||||
<h2>${label} with long status heading copy</h2>
|
||||
<span class="column-count">${count}</span>
|
||||
</header>
|
||||
<p class="column-desc">Layout smoke data for ${label}</p>
|
||||
<div class="column-body">
|
||||
<article class="card" data-column="${column}">
|
||||
<div class="card-header">
|
||||
<span class="card-id">FN-${column.length}01</span>
|
||||
<h3 class="card-title">Responsive task card with a deliberately long title that should wrap cleanly</h3>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-status-badge card-status-badge--${column}">${label}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
`)
|
||||
.join("");
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Fusion dashboard browser smoke</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body data-theme="dark">
|
||||
<div id="root">
|
||||
<div class="header-wrapper">
|
||||
<header class="header" data-smoke="header">
|
||||
<div class="header-left">
|
||||
<svg class="header-logo" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle></svg>
|
||||
<div class="header-node-selector header-node-selector--mobile">
|
||||
<div class="node-status-indicator node-status-indicator--local">
|
||||
<span class="node-status-indicator__dot node-status-indicator__dot--online"></span>
|
||||
<span class="node-status-indicator__name">Local project with very long name</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="view-toggle" role="group" aria-label="Task view">
|
||||
<button class="view-toggle-btn active" data-smoke="show-board" type="button" aria-label="Board view">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="6" height="6"></rect><rect x="14" y="4" width="6" height="6"></rect><rect x="4" y="14" width="6" height="6"></rect><rect x="14" y="14" width="6" height="6"></rect></svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" data-smoke="show-list" type="button" aria-label="List view">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-icon mobile-search-trigger" type="button" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m16 16 4 4"></path></svg>
|
||||
</button>
|
||||
<button class="btn-icon" data-smoke="open-modal" type="button" aria-label="Settings">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M12 2v4M12 18v4M2 12h4M18 12h4"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main class="project-content project-content--with-footer project-content--with-mobile-nav">
|
||||
<section class="board" data-smoke="board">${columnMarkup}</section>
|
||||
<section class="list-view" data-smoke="list" hidden>
|
||||
<div class="list-create-area">
|
||||
<div class="quick-entry-box quick-entry-box--collapsed" data-testid="quick-entry-box">
|
||||
<div class="quick-entry-main-row">
|
||||
<textarea class="quick-entry-input" data-smoke="quick-entry-input" placeholder="Add a task"></textarea>
|
||||
<button class="quick-entry-toggle btn btn-icon" type="button" aria-label="Quick entry options">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-table-container">
|
||||
<table class="list-table">
|
||||
<thead><tr><th class="list-header-cell">Task</th><th class="list-header-cell">Status</th></tr></thead>
|
||||
<tbody><tr class="list-row"><td class="list-cell list-cell-title">FN-101 Smoke task</td><td class="list-cell">Todo</td></tr></tbody>
|
||||
</table>
|
||||
<div class="list-cards">
|
||||
<article class="card list-card"><h3 class="card-title">FN-101 Smoke task</h3></article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="executor-status-bar">
|
||||
<div class="executor-status-bar__segment">
|
||||
<span class="executor-status-bar__indicator executor-status-bar__indicator--running"></span>
|
||||
<span class="executor-status-bar__count">1</span>
|
||||
<span class="executor-status-bar__label">running</span>
|
||||
</div>
|
||||
<div class="executor-status-bar__divider"></div>
|
||||
<div class="executor-status-bar__segment executor-status-bar__segment--project-directory">
|
||||
<button class="executor-status-bar__folder-toggle" type="button">Project</button>
|
||||
<span class="executor-status-bar__project-path">/very/long/path/to/fusion/dashboard/project</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<nav class="mobile-nav-bar mobile-nav-bar--with-footer" role="tablist" aria-label="Primary navigation">
|
||||
<button class="mobile-nav-tab mobile-nav-tab--active" type="button"><span class="mobile-nav-tab-label">Tasks</span></button>
|
||||
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">Agents</span></button>
|
||||
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">Missions</span></button>
|
||||
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">Chat</span></button>
|
||||
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">Mailbox</span></button>
|
||||
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">More</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="modal-overlay" data-smoke="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div class="modal modal-md" data-smoke="modal">
|
||||
<header class="modal-header">
|
||||
<h3>Smoke Modal</h3>
|
||||
<button class="modal-close" data-smoke="close-modal" type="button" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<label class="form-group">
|
||||
<span>Modal input</span>
|
||||
<input class="input" type="text" value="browser layout smoke" />
|
||||
</label>
|
||||
</div>
|
||||
<footer class="modal-actions">
|
||||
<button class="btn btn-secondary" type="button">Cancel</button>
|
||||
<button class="btn btn-primary" type="button">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const board = document.querySelector('[data-smoke="board"]');
|
||||
const list = document.querySelector('[data-smoke="list"]');
|
||||
const boardButton = document.querySelector('[data-smoke="show-board"]');
|
||||
const listButton = document.querySelector('[data-smoke="show-list"]');
|
||||
const modalOverlay = document.querySelector('[data-smoke="modal-overlay"]');
|
||||
const nav = document.querySelector('.mobile-nav-bar');
|
||||
|
||||
function setView(view) {
|
||||
const isList = view === 'list';
|
||||
board.hidden = isList;
|
||||
list.hidden = !isList;
|
||||
boardButton.classList.toggle('active', !isList);
|
||||
listButton.classList.toggle('active', isList);
|
||||
}
|
||||
|
||||
boardButton.addEventListener('click', () => setView('board'));
|
||||
listButton.addEventListener('click', () => setView('list'));
|
||||
document.querySelector('[data-smoke="open-modal"]').addEventListener('click', () => {
|
||||
modalOverlay.classList.add('open');
|
||||
nav.hidden = true;
|
||||
});
|
||||
document.querySelector('[data-smoke="close-modal"]').addEventListener('click', () => {
|
||||
modalOverlay.classList.remove('open');
|
||||
nav.hidden = false;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function startFixtureServer() {
|
||||
const css = await loadDashboardCss();
|
||||
const html = createSmokeHtml();
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/app.css") {
|
||||
res.writeHead(200, { "content-type": "text/css; charset=utf-8" });
|
||||
res.end(css);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
url: `http://127.0.0.1:${server.address().port}/`,
|
||||
};
|
||||
}
|
||||
|
||||
async function findBrowserExecutable() {
|
||||
const envCandidates = [
|
||||
process.env.FUSION_BROWSER_SMOKE_BROWSER,
|
||||
process.env.CHROME_BIN,
|
||||
process.env.CHROMIUM_BIN,
|
||||
process.env.BROWSER,
|
||||
].filter(Boolean);
|
||||
|
||||
const platformCandidates = process.platform === "darwin"
|
||||
? [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
]
|
||||
: process.platform === "win32"
|
||||
? [
|
||||
path.join(process.env.PROGRAMFILES ?? "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"),
|
||||
path.join(process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Microsoft\\Edge\\Application\\msedge.exe"),
|
||||
]
|
||||
: [
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/microsoft-edge",
|
||||
];
|
||||
|
||||
for (const candidate of [...envCandidates, ...platformCandidates]) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
const info = await stat(candidate);
|
||||
if (info.isFile()) return candidate;
|
||||
} catch {
|
||||
// Try the next known browser path.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function launchBrowser(executable) {
|
||||
const userDataDir = await mkdtemp(path.join(os.tmpdir(), "fusion-dashboard-browser-smoke-"));
|
||||
const browser = spawn(executable, [
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--disable-dev-shm-usage",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--remote-debugging-port=0",
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"about:blank",
|
||||
], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
try {
|
||||
const wsUrl = await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
rejectReady(new Error("Timed out waiting for the browser DevTools endpoint."));
|
||||
}, 15_000);
|
||||
|
||||
const cleanupListeners = () => {
|
||||
clearTimeout(timeout);
|
||||
browser.stdout.off("data", onData);
|
||||
browser.stderr.off("data", onData);
|
||||
browser.off("error", rejectReady);
|
||||
browser.off("exit", onExit);
|
||||
};
|
||||
|
||||
const resolveReady = (url) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupListeners();
|
||||
resolve(url);
|
||||
};
|
||||
|
||||
function rejectReady(error) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupListeners();
|
||||
reject(error);
|
||||
}
|
||||
|
||||
function onData(data) {
|
||||
const text = data.toString();
|
||||
const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/);
|
||||
if (match) {
|
||||
resolveReady(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
function onExit(code) {
|
||||
rejectReady(new Error(`Browser exited before DevTools was ready (code ${code}).`));
|
||||
}
|
||||
|
||||
browser.stdout.on("data", onData);
|
||||
browser.stderr.on("data", onData);
|
||||
browser.once("error", rejectReady);
|
||||
browser.once("exit", onExit);
|
||||
});
|
||||
|
||||
return { browser, userDataDir, wsUrl };
|
||||
} catch (error) {
|
||||
await stopBrowser(browser);
|
||||
await rm(userDataDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopBrowser(browser) {
|
||||
if (browser.exitCode !== null || browser.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exited = new Promise((resolve) => {
|
||||
browser.once("exit", resolve);
|
||||
});
|
||||
|
||||
if (!browser.killed) {
|
||||
browser.kill();
|
||||
}
|
||||
|
||||
const exitedCleanly = await Promise.race([
|
||||
exited.then(() => true),
|
||||
new Promise((resolve) => setTimeout(() => resolve(false), 5_000)),
|
||||
]);
|
||||
|
||||
if (!exitedCleanly && browser.exitCode === null && browser.signalCode === null) {
|
||||
browser.kill("SIGKILL");
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
|
||||
function cdpConnect(wsUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(wsUrl);
|
||||
const pending = new Map();
|
||||
const listeners = new Map();
|
||||
let nextId = 1;
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
resolve({
|
||||
send(method, params = {}) {
|
||||
const id = nextId++;
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((resolveCommand, rejectCommand) => {
|
||||
pending.set(id, { resolve: resolveCommand, reject: rejectCommand });
|
||||
});
|
||||
},
|
||||
once(method) {
|
||||
return new Promise((resolveEvent) => {
|
||||
const list = listeners.get(method) ?? [];
|
||||
list.push(resolveEvent);
|
||||
listeners.set(method, list);
|
||||
});
|
||||
},
|
||||
close() {
|
||||
socket.close();
|
||||
},
|
||||
});
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.id && pending.has(message.id)) {
|
||||
const command = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
if (message.error) {
|
||||
command.reject(new Error(`${message.error.message}: ${message.error.data ?? ""}`));
|
||||
} else {
|
||||
command.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method && listeners.has(message.method)) {
|
||||
const list = listeners.get(message.method);
|
||||
const listener = list.shift();
|
||||
if (list.length === 0) listeners.delete(message.method);
|
||||
listener?.(message.params);
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function createPage(browserWsUrl) {
|
||||
const browserEndpoint = new URL(browserWsUrl);
|
||||
const targetUrl = new URL(`/json/new?${encodeURIComponent("about:blank")}`, `http://127.0.0.1:${browserEndpoint.port}`);
|
||||
let response = await fetch(targetUrl, { method: "PUT" });
|
||||
if (!response.ok) {
|
||||
response = await fetch(targetUrl);
|
||||
}
|
||||
if (!response.ok) {
|
||||
fail(`Unable to create browser target: HTTP ${response.status}`);
|
||||
}
|
||||
const target = await response.json();
|
||||
return cdpConnect(target.webSocketDebuggerUrl);
|
||||
}
|
||||
|
||||
async function evaluate(page, expression) {
|
||||
const result = await page.send("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
fail(result.exceptionDetails.text ?? "Browser evaluation failed");
|
||||
}
|
||||
return result.result.value;
|
||||
}
|
||||
|
||||
function assertSmokeResult(name, passed, details) {
|
||||
if (!passed) {
|
||||
fail(`${name} failed: ${details}`);
|
||||
}
|
||||
log(`ok: ${name}`);
|
||||
}
|
||||
|
||||
async function runSmokeChecks(page, pageUrl) {
|
||||
await page.send("Page.enable");
|
||||
await page.send("Runtime.enable");
|
||||
await page.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 390,
|
||||
height: 844,
|
||||
deviceScaleFactor: 2,
|
||||
mobile: true,
|
||||
});
|
||||
|
||||
const loaded = page.once("Page.loadEventFired");
|
||||
await page.send("Page.navigate", { url: pageUrl });
|
||||
await loaded;
|
||||
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
|
||||
|
||||
const initialLayout = await evaluate(page, `(() => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const nav = document.querySelector('.mobile-nav-bar').getBoundingClientRect();
|
||||
const footer = document.querySelector('.executor-status-bar').getBoundingClientRect();
|
||||
const header = document.querySelector('[data-smoke="header"]').getBoundingClientRect();
|
||||
const content = document.querySelector('.project-content');
|
||||
const contentStyle = getComputedStyle(content);
|
||||
const tabs = [...document.querySelectorAll('.mobile-nav-tab')].map((tab) => tab.getBoundingClientRect());
|
||||
const board = document.querySelector('[data-smoke="board"]');
|
||||
const columns = [...document.querySelectorAll('.board > .column')].map((column) => column.getBoundingClientRect());
|
||||
return {
|
||||
viewportWidth,
|
||||
documentOverflow: document.documentElement.scrollWidth - viewportWidth,
|
||||
headerLeft: header.left,
|
||||
headerRight: header.right,
|
||||
navDisplay: getComputedStyle(document.querySelector('.mobile-nav-bar')).display,
|
||||
navLeft: nav.left,
|
||||
navRight: nav.right,
|
||||
navBottomGap: Math.abs(window.innerHeight - nav.bottom),
|
||||
footerBottomGap: Math.abs(nav.top - footer.bottom),
|
||||
contentPaddingBottom: parseFloat(contentStyle.paddingBottom),
|
||||
navHeight: nav.height,
|
||||
footerHeight: footer.height,
|
||||
tabMinWidth: Math.min(...tabs.map((tab) => tab.width)),
|
||||
boardOverflow: board.scrollWidth - board.clientWidth,
|
||||
boardOverflowX: getComputedStyle(board).overflowX,
|
||||
columnWidths: columns.map((column) => Math.round(column.width)),
|
||||
};
|
||||
})()`);
|
||||
|
||||
assertSmokeResult(
|
||||
"mobile nav/header/footer fit viewport",
|
||||
initialLayout.navDisplay === "flex"
|
||||
&& initialLayout.documentOverflow <= 1
|
||||
&& initialLayout.headerLeft >= 0
|
||||
&& initialLayout.headerRight <= initialLayout.viewportWidth + 1
|
||||
&& initialLayout.navLeft >= 0
|
||||
&& initialLayout.navRight <= initialLayout.viewportWidth + 1
|
||||
&& initialLayout.navBottomGap <= 1
|
||||
&& initialLayout.footerBottomGap <= 1
|
||||
&& initialLayout.contentPaddingBottom >= initialLayout.navHeight + initialLayout.footerHeight - 1
|
||||
&& initialLayout.tabMinWidth >= 36,
|
||||
JSON.stringify(initialLayout),
|
||||
);
|
||||
|
||||
assertSmokeResult(
|
||||
"mobile board uses contained horizontal scrolling",
|
||||
initialLayout.boardOverflow > 300
|
||||
&& initialLayout.boardOverflowX === "auto"
|
||||
&& initialLayout.columnWidths.every((width) => width === 300),
|
||||
JSON.stringify(initialLayout),
|
||||
);
|
||||
|
||||
const listLayout = await evaluate(page, `(() => {
|
||||
document.querySelector('[data-smoke="show-list"]').click();
|
||||
const board = document.querySelector('[data-smoke="board"]');
|
||||
const list = document.querySelector('[data-smoke="list"]');
|
||||
const table = document.querySelector('.list-table');
|
||||
const cards = document.querySelector('.list-cards');
|
||||
const input = document.querySelector('[data-smoke="quick-entry-input"]');
|
||||
return {
|
||||
boardHidden: board.hidden,
|
||||
listHidden: list.hidden,
|
||||
listActive: document.querySelector('[data-smoke="show-list"]').classList.contains('active'),
|
||||
tableDisplay: getComputedStyle(table).display,
|
||||
cardsDisplay: getComputedStyle(cards).display,
|
||||
inputFontSize: getComputedStyle(input).fontSize,
|
||||
inputHeight: input.getBoundingClientRect().height,
|
||||
inputRight: input.getBoundingClientRect().right,
|
||||
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
|
||||
};
|
||||
})()`);
|
||||
|
||||
assertSmokeResult(
|
||||
"board/list switch exposes mobile list cards and contained input",
|
||||
listLayout.boardHidden === true
|
||||
&& listLayout.listHidden === false
|
||||
&& listLayout.listActive === true
|
||||
&& listLayout.tableDisplay === "none"
|
||||
&& listLayout.cardsDisplay === "flex"
|
||||
&& listLayout.inputHeight >= 30
|
||||
&& listLayout.inputRight <= 391
|
||||
&& listLayout.documentOverflow <= 1,
|
||||
JSON.stringify(listLayout),
|
||||
);
|
||||
|
||||
const modalLayout = await evaluate(page, `(() => {
|
||||
document.querySelector('[data-smoke="open-modal"]').click();
|
||||
const overlay = document.querySelector('[data-smoke="modal-overlay"]');
|
||||
const modal = document.querySelector('[data-smoke="modal"]');
|
||||
const close = document.querySelector('[data-smoke="close-modal"]');
|
||||
const nav = document.querySelector('.mobile-nav-bar');
|
||||
const modalRect = modal.getBoundingClientRect();
|
||||
const closeRect = close.getBoundingClientRect();
|
||||
return {
|
||||
overlayDisplay: getComputedStyle(overlay).display,
|
||||
modalWidth: Math.round(modalRect.width),
|
||||
modalHeight: Math.round(modalRect.height),
|
||||
modalRadius: getComputedStyle(modal).borderRadius,
|
||||
closeTop: closeRect.top,
|
||||
closeRight: closeRect.right,
|
||||
navHidden: nav.hidden,
|
||||
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
|
||||
};
|
||||
})()`);
|
||||
|
||||
assertSmokeResult(
|
||||
"mobile modal fills viewport without horizontal overflow",
|
||||
modalLayout.overlayDisplay === "flex"
|
||||
&& modalLayout.modalWidth === 390
|
||||
&& modalLayout.modalHeight === 844
|
||||
&& modalLayout.modalRadius === "0px"
|
||||
&& modalLayout.closeTop >= 0
|
||||
&& modalLayout.closeRight <= 390
|
||||
&& modalLayout.navHidden === true
|
||||
&& modalLayout.documentOverflow <= 1,
|
||||
JSON.stringify(modalLayout),
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(appRoot)) {
|
||||
fail(`Dashboard app directory not found: ${appRoot}`);
|
||||
}
|
||||
|
||||
if (typeof WebSocket === "undefined") {
|
||||
fail("This smoke script requires Node's global WebSocket support.");
|
||||
}
|
||||
|
||||
const executable = await findBrowserExecutable();
|
||||
if (!executable) {
|
||||
const message = "No local Chrome/Chromium/Edge executable found. Set FUSION_BROWSER_SMOKE_BROWSER=/path/to/browser to run the real-browser smoke. This lane is local-only and fixture-based; it verifies layout overflow with real dashboard CSS, not full API routing.";
|
||||
if (requireBrowser) fail(message);
|
||||
log(`skip: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log("using local browser; this fixture smoke checks real CSS layout but does not replace full dashboard E2E coverage.");
|
||||
const launched = await launchBrowser(executable);
|
||||
let fixture;
|
||||
let page;
|
||||
try {
|
||||
fixture = await startFixtureServer();
|
||||
page = await createPage(launched.wsUrl);
|
||||
await runSmokeChecks(page, fixture.url);
|
||||
} finally {
|
||||
page?.close();
|
||||
if (fixture) {
|
||||
await closeServer(fixture.server);
|
||||
}
|
||||
await stopBrowser(launched.browser);
|
||||
await rm(launched.userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[dashboard-browser-smoke] ${error.stack ?? error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe("Session files endpoint", () => {
|
||||
mkdirSync(testWorktree, { recursive: true });
|
||||
|
||||
// Initialize git repo
|
||||
execSync("git init", { cwd: testWorktree });
|
||||
execSync("git init --initial-branch=main", { cwd: testWorktree });
|
||||
execSync("git config user.email test@test.com", { cwd: testWorktree });
|
||||
execSync("git config user.name Test", { cwd: testWorktree });
|
||||
|
||||
|
||||
@@ -716,7 +716,7 @@ describe("POST /api/projects route handler", () => {
|
||||
const cloneDestination = join(tempRoot, "cloned-project");
|
||||
|
||||
try {
|
||||
execFileSync("git", ["init", "--bare", bareRepo]);
|
||||
execFileSync("git", ["init", "--bare", "--initial-branch=main", bareRepo]);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
|
||||
@@ -307,8 +307,8 @@ function getSharedGitTestRepo(): GitTestRepo {
|
||||
const repoDir = join(root, "repo");
|
||||
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "--bare", remoteDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", "--bare", "--initial-branch=main", remoteDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", "--initial-branch=main", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
||||
writeFileSync(join(repoDir, "README.md"), "# Test Repo\n");
|
||||
@@ -1485,4 +1485,3 @@ describe("Workspace File Routes", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -307,8 +307,8 @@ function getSharedGitTestRepo(): GitTestRepo {
|
||||
const repoDir = join(root, "repo");
|
||||
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "--bare", remoteDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", "--bare", "--initial-branch=main", remoteDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", "--initial-branch=main", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
||||
writeFileSync(join(repoDir, "README.md"), "# Test Repo\n");
|
||||
@@ -2029,4 +2029,3 @@ describe("GET /tasks/:id/file-diffs", () => {
|
||||
|
||||
// --- Git Management route tests ---
|
||||
// These are integration tests that run against the actual git repository
|
||||
|
||||
|
||||
@@ -694,8 +694,8 @@ function getSharedGitTestRepo(): GitTestRepo {
|
||||
const repoDir = join(root, "repo");
|
||||
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execFileSync("git", ["init", "--bare", remoteDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", "--bare", "--initial-branch=main", remoteDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["init", "--initial-branch=main", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", repoDir, "config", "user.name", "KB Tests"], { stdio: "pipe" });
|
||||
writeFileSync(join(repoDir, "README.md"), "# Test Repo\n");
|
||||
@@ -720,4 +720,3 @@ afterAll(() => {
|
||||
afterEach(() => {
|
||||
resetDiagnosticsSink();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,26 @@ import { computeMaxWorkers } from "../core/src/__test-utils__/vitest-workers";
|
||||
|
||||
const maxWorkers = computeMaxWorkers({ defaultCap: 3 });
|
||||
|
||||
const qualityAppTests = [
|
||||
// Top-level API-client, mobile layout, styling, auth, and shell regressions.
|
||||
"app/__tests__/*.test.{ts,tsx}",
|
||||
"app/api/**/*.test.ts",
|
||||
// Representative workflow/component coverage. Exhaustive modal/view suites
|
||||
// stay available in the full `dashboard-app` project.
|
||||
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
|
||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||
"app/context/**/*.test.tsx",
|
||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
||||
"app/utils/**/*.test.{ts,tsx}",
|
||||
];
|
||||
|
||||
const qualityApiTests = [
|
||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
|
||||
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-tasks,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
|
||||
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes}.test.ts",
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
@@ -36,20 +56,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
// `app/**` is React UI — needs jsdom + CSS. `src/**` is the Express
|
||||
// backend, mostly Node-only logic; running it in node env trims jsdom
|
||||
// env+CSS-include cost. The handful of src tests that genuinely need DOM
|
||||
// opt-in via `// @vitest-environment jsdom`.
|
||||
environment: "node",
|
||||
environmentMatchGlobs: [
|
||||
["app/**", "jsdom"],
|
||||
],
|
||||
// Process CSS imports only for jsdom-based tests that assert on
|
||||
// getComputedStyle. Node-env tests under src/** don't need CSS rules and
|
||||
// skipping the transform there cuts a large slice of total wall time.
|
||||
css: { include: [/app\//] },
|
||||
globals: true,
|
||||
include: ["app/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
||||
setupFiles: [
|
||||
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||
"./vitest.setup.ts",
|
||||
@@ -66,6 +73,46 @@ export default defineConfig({
|
||||
// 5s default under workspace-concurrent runs.
|
||||
testTimeout: 15_000,
|
||||
hookTimeout: 15_000,
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "dashboard-app-quality",
|
||||
environment: "jsdom",
|
||||
include: qualityAppTests,
|
||||
css: { include: [/app\//] },
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "dashboard-api-quality",
|
||||
environment: "node",
|
||||
include: qualityApiTests,
|
||||
css: { include: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "dashboard-app",
|
||||
environment: "jsdom",
|
||||
include: ["app/**/*.test.{ts,tsx}"],
|
||||
// Process CSS imports only for jsdom tests that assert on
|
||||
// getComputedStyle. Node API tests do not need CSS transforms.
|
||||
css: { include: [/app\//] },
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "dashboard-api",
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
css: { include: [] },
|
||||
},
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
enabled: false,
|
||||
reporter: ["text", "html", "json"],
|
||||
|
||||
@@ -12,6 +12,7 @@ const clearDaemonAuthEnv = () => {
|
||||
clearDaemonAuthEnv();
|
||||
|
||||
const noisyOutputMarkers = [
|
||||
"ExperimentalWarning: SQLite is an experimental feature",
|
||||
"Subagent result watcher failed",
|
||||
"pi-async-subagent-results",
|
||||
"[pi] createFnAgent called",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "node:path";
|
||||
import { computeMaxWorkers } from "../core/src/__test-utils__/vitest-workers";
|
||||
|
||||
const maxWorkers = computeMaxWorkers();
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
@@ -9,6 +12,10 @@ export default defineConfig({
|
||||
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||
],
|
||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||
pool: "forks",
|
||||
maxWorkers,
|
||||
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||
fileParallelism: true,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json-summary"],
|
||||
|
||||
60
packages/engine/src/__tests__/custom-providers.test.ts
Normal file
60
packages/engine/src/__tests__/custom-providers.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { readCustomProviders } from "../custom-providers.js";
|
||||
|
||||
describe("readCustomProviders", () => {
|
||||
let homeDir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), "fn-custom-providers-home-"));
|
||||
settingsPath = join(homeDir, ".fusion", "settings.json");
|
||||
await mkdir(join(homeDir, ".fusion"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns an empty list when settings are missing or malformed", async () => {
|
||||
expect(readCustomProviders(homeDir)).toEqual([]);
|
||||
|
||||
await writeFile(settingsPath, "{ invalid json", "utf-8");
|
||||
expect(readCustomProviders(homeDir)).toEqual([]);
|
||||
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({ customProviders: { id: "not-an-array" } }),
|
||||
"utf-8",
|
||||
);
|
||||
expect(readCustomProviders(homeDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns custom provider arrays from user settings", async () => {
|
||||
const providers = [
|
||||
{
|
||||
id: "local-openai",
|
||||
name: "Local OpenAI",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
apiKey: "local-key",
|
||||
models: [{ id: "qwen3", name: "Qwen 3" }],
|
||||
},
|
||||
{
|
||||
id: "anthropic-proxy",
|
||||
name: "Anthropic Proxy",
|
||||
apiType: "anthropic-compatible",
|
||||
baseUrl: "https://anthropic.example.test",
|
||||
},
|
||||
];
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({ customProviders: providers }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
expect(readCustomProviders(homeDir)).toEqual(providers);
|
||||
});
|
||||
});
|
||||
39
packages/engine/src/__tests__/task-completion.test.ts
Normal file
39
packages/engine/src/__tests__/task-completion.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
import { getTaskCompletionBlockerForStore } from "../task-completion.js";
|
||||
|
||||
function createTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: "FN-100",
|
||||
description: "Task",
|
||||
prompt: "Task prompt",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getTaskCompletionBlockerForStore", () => {
|
||||
it("treats dependency lookup failures as unresolved dependencies", async () => {
|
||||
const getTask = vi.fn(async (taskId: string) => {
|
||||
if (taskId === "FN-DONE") {
|
||||
return createTask({ id: taskId, column: "done" });
|
||||
}
|
||||
throw new Error("database temporarily unavailable");
|
||||
});
|
||||
|
||||
await expect(getTaskCompletionBlockerForStore(
|
||||
{ getTask },
|
||||
createTask({ dependencies: ["FN-DONE", "FN-MISSING"] }),
|
||||
)).resolves.toBe("task has unresolved dependencies: FN-MISSING");
|
||||
|
||||
expect(getTask).toHaveBeenCalledWith("FN-DONE");
|
||||
expect(getTask).toHaveBeenCalledWith("FN-MISSING");
|
||||
});
|
||||
});
|
||||
81
packages/engine/src/__tests__/verification-utils.test.ts
Normal file
81
packages/engine/src/__tests__/verification-utils.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { access, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { execWithProcessGroup } from "../verification-utils.js";
|
||||
|
||||
const onPosix = process.platform !== "win32";
|
||||
const itPosix = onPosix ? it : it.skip;
|
||||
|
||||
describe("execWithProcessGroup", { timeout: 10_000 }, () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "fn-verification-utils-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reports buffer overflow while preserving capped stdout", async () => {
|
||||
const result = await execWithProcessGroup(
|
||||
`${JSON.stringify(process.execPath)} -e "process.stdout.write('x'.repeat(128))"`,
|
||||
{ cwd: tempDir, timeout: 1_000, maxBuffer: 12 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
stdout: "x".repeat(12),
|
||||
stderr: "",
|
||||
bufferOverflow: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects and kills the command when the abort signal fires", async () => {
|
||||
const controller = new AbortController();
|
||||
const promise = execWithProcessGroup(
|
||||
`${JSON.stringify(process.execPath)} -e "setInterval(() => {}, 1000)"`,
|
||||
{ cwd: tempDir, timeout: 5_000, maxBuffer: 1_024, signal: controller.signal },
|
||||
);
|
||||
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
code: "ABORT_ERR",
|
||||
aborted: true,
|
||||
killed: true,
|
||||
});
|
||||
});
|
||||
|
||||
itPosix("times out and terminates child processes in the spawned process group", async () => {
|
||||
const markerPath = join(tempDir, "descendant-survived.txt");
|
||||
const parentScriptPath = join(tempDir, "spawn-descendant.cjs");
|
||||
await writeFile(
|
||||
parentScriptPath,
|
||||
`
|
||||
const { spawn } = require("node:child_process");
|
||||
spawn(process.execPath, [
|
||||
"-e",
|
||||
"setTimeout(() => require('node:fs').writeFileSync(process.env.MARKER, 'survived'), 450)",
|
||||
], {
|
||||
env: { ...process.env, MARKER: process.argv[2] },
|
||||
stdio: "ignore",
|
||||
}).unref();
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(execWithProcessGroup(
|
||||
`${JSON.stringify(process.execPath)} ${JSON.stringify(parentScriptPath)} ${JSON.stringify(markerPath)}`,
|
||||
{ cwd: tempDir, timeout: 75, maxBuffer: 1_024 },
|
||||
)).rejects.toMatchObject({
|
||||
code: "ETIMEDOUT",
|
||||
killed: true,
|
||||
});
|
||||
|
||||
await delay(700);
|
||||
await expect(access(markerPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,9 @@ import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CustomProvider } from "@fusion/core";
|
||||
|
||||
export function readCustomProviders(): CustomProvider[] {
|
||||
export function readCustomProviders(homeDir = homedir()): CustomProvider[] {
|
||||
try {
|
||||
const settingsPath = join(homedir(), ".fusion", "settings.json");
|
||||
const settingsPath = join(homeDir, ".fusion", "settings.json");
|
||||
const raw = readFileSync(settingsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
|
||||
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "node:path";
|
||||
import { computeMaxWorkers } from "../core/src/__test-utils__/vitest-workers";
|
||||
|
||||
const maxWorkers = computeMaxWorkers();
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
@@ -9,6 +12,10 @@ export default defineConfig({
|
||||
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||
],
|
||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||
pool: "forks",
|
||||
maxWorkers,
|
||||
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||
fileParallelism: true,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json-summary"],
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "node:path";
|
||||
import { computeMaxWorkers } from "../core/src/__test-utils__/vitest-workers";
|
||||
|
||||
const maxWorkers = computeMaxWorkers();
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: [resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts")],
|
||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||
pool: "forks",
|
||||
maxWorkers,
|
||||
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||
fileParallelism: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user