test(FN-000): harden local test suite
This commit is contained in:
25
docs/bugs/testing-suite-hardening.md
Normal file
25
docs/bugs/testing-suite-hardening.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Testing Suite Hardening Bugs
|
||||||
|
|
||||||
|
_Started: 2026-05-05_
|
||||||
|
|
||||||
|
This is the living bug log for the testing-suite hardening work described in
|
||||||
|
`docs/testing-suite-quality-prd.md`. Each entry should stay factual: what broke,
|
||||||
|
why it broke, how it was fixed, and what command verified the fix.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
| ID | Area | Symptom | Root cause | Planned fix | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| TSH-007 | Dashboard test runtime/noise | `pnpm test:full` passes but dashboard tests take about 10 minutes locally and emit repeated SQLite experimental warnings plus git default-branch hints. | The suite exercises many SQLite-backed and git-backed paths; Node emits SQLite warnings globally and fixture git repos use default branch initialization. | Consider a follow-up cleanup to quiet expected warnings and split expensive dashboard lanes without reducing coverage. | Open |
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
| ID | Area | Symptom | Root cause | Fix | Verification |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| TSH-001 | Local test selection | `pnpm test` could under-test shared package edits. | Changed-package resolution selected direct changed workspaces but not dependent workspaces that import them. | `scripts/test-changed.mjs` now expands affected packages through the workspace reverse-dependency graph. | `node --test scripts/__tests__/test-changed.test.mjs` and `pnpm test:full` |
|
||||||
|
| TSH-002 | Local test cache | Cached package passes could hide dirty worktree edits. | Cache keys were based on tracked blob SHAs and did not account for modified or untracked working-tree content. | Dirty affected files now bypass package cache reuse so local edits are exercised before PR. | `node --test scripts/__tests__/test-changed.test.mjs` and `pnpm test:full` |
|
||||||
|
| TSH-003 | PR CI coverage | PR sharding omitted plugin packages and `@fusion/pi-llama-cpp`. | The CI shard package list was hard-coded instead of derived from the workspace graph. | `scripts/ci-test-shard.mjs` now derives shard candidates from workspace packages with test scripts. | `node --test scripts/__tests__/test-changed.test.mjs scripts/__tests__/test-governance.test.mjs` and `pnpm test:full` |
|
||||||
|
| TSH-004 | Test governance | Runtime package Vitest configs bypassed shared worker budgeting/isolation conventions. | Configs had drifted independently across Droid, Pi Claude, and Pi Llama packages. | The configs now share the same thread pool sizing and isolation defaults, with a governance test to keep them aligned. | `node --test scripts/__tests__/test-governance.test.mjs` and `pnpm test:full` |
|
||||||
|
| TSH-005 | Plugin changed-test targeting | Plugin edits still forced the entire suite after package-aware targeting was added. | `shouldForceFullSuite()` treated every `plugins/**` path as broad repo surface area. | Plugin workspace paths are now resolved like package paths, so plugin edits run the relevant plugin tests instead of automatically falling back to all tests. | `node --test scripts/__tests__/test-changed.test.mjs` |
|
||||||
|
| TSH-006 | Skipped-test inventory | The skipped-test inventory documented the wrong gate ownership for the new and legacy extension suites. | The maintained extension integration lane and legacy exhaustive gate were renamed during implementation, but the inventory text still reflected the earlier split. | `docs/skipped-test-inventory.md` now lists `FUSION_TEST_EXTENSION_INTEGRATION` for the maintained built-extension test and `FUSION_TEST_LEGACY_EXTENSION_INTEGRATION` for the legacy suite. | `git diff --check`, `node --test scripts/__tests__/test-governance.test.mjs`, and `pnpm test:full` |
|
||||||
|
| TSH-008 | Test isolation | `custom-providers.test.ts` wrote to the real `~/.fusion/settings.json` path during the first local full-suite run. | The test used `os.homedir()` directly instead of a fixture home directory. | `readCustomProviders()` now accepts an injectable home directory, and the test uses a temp HOME fixture that is removed after each case. | `pnpm --filter @fusion/engine test -- src/__tests__/custom-providers.test.ts` (ran the full engine lane: 108 files, 3339 tests) |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Skipped Test Inventory
|
# Skipped Test Inventory
|
||||||
|
|
||||||
_Last audited: 2026-04-23 (FN-2346)_
|
_Last audited: 2026-05-05_
|
||||||
|
|
||||||
This document tracks intentional skip usage in test suites so stale follow-up backlog items can be retired quickly.
|
This document tracks intentional skip usage in test suites so stale follow-up backlog items can be retired quickly.
|
||||||
|
|
||||||
@@ -9,18 +9,13 @@ This document tracks intentional skip usage in test suites so stale follow-up ba
|
|||||||
Audit commands:
|
Audit commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rg -n "\b(it|test|describe)\.skip\b|\bskipIf\b|createLoopbackIntegrationTest\(" packages --glob "**/*.{test,spec}.{ts,tsx}"
|
rg -n "\b(it|test|describe)\.skip\b|\bskipIf\b|createLoopbackIntegrationTest\(" packages plugins scripts --glob "**/*.{test,spec}.{ts,tsx,mjs}"
|
||||||
rg -n "\?\s*it\s*:\s*it\.skip|detectLoopbackBinding" packages/dashboard/src --glob "**/*.{test,spec}.{ts,tsx}"
|
rg -n "\?\s*it\s*:\s*it\.skip|detectLoopbackBinding|const itPosix" packages plugins scripts --glob "**/*.{test,spec}.{ts,tsx,mjs}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Current results:
|
Current results:
|
||||||
|
|
||||||
1. **Intentional cross-coverage alias**
|
1. **Environment-gated integration aliases (loopback gated)**
|
||||||
- `packages/engine/src/executor.test.ts`
|
|
||||||
- `it.skip("step-session skill selection covered in step-session-executor.test.ts", ...)`
|
|
||||||
- Rationale: dedicated coverage exists in `step-session-executor.test.ts`; this marker documents ownership.
|
|
||||||
|
|
||||||
2. **Environment-gated integration aliases (loopback gated)**
|
|
||||||
- Canonical helper: `packages/dashboard/src/__tests__/loopback-integration-test.ts`
|
- Canonical helper: `packages/dashboard/src/__tests__/loopback-integration-test.ts`
|
||||||
- Helper consumers:
|
- Helper consumers:
|
||||||
- `packages/dashboard/src/server-static-assets.test.ts`
|
- `packages/dashboard/src/server-static-assets.test.ts`
|
||||||
@@ -30,18 +25,60 @@ Current results:
|
|||||||
- Rationale: these suites require real loopback binding support (`127.0.0.1`) and are intentionally environment-gated.
|
- Rationale: these suites require real loopback binding support (`127.0.0.1`) and are intentionally environment-gated.
|
||||||
- Auditability: when loopback binding is unavailable, skipped test names include a standardized reason and the suite scope label (`...; scope: <suite scope>`), making coverage gaps explicit in CI/test output.
|
- Auditability: when loopback binding is unavailable, skipped test names include a standardized reason and the suite scope label (`...; scope: <suite scope>`), making coverage gaps explicit in CI/test output.
|
||||||
|
|
||||||
3. **Build-output checks are now deterministic (no skip gate)**
|
2. **CLI slow-lane agent export gate**
|
||||||
|
- `packages/cli/src/commands/__tests__/agent-export.test.ts`
|
||||||
|
- Pattern: `describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", ...)`
|
||||||
|
- Gate: `FUSION_TEST_SLOW_CLI=1` or `FUSION_TEST_SLOW_CLI=true`
|
||||||
|
- Rationale: these tests perform real workspace and `AgentStore` round-trips and are kept out of the default CLI unit lane.
|
||||||
|
- Replacement owner: the explicit slow lane (`pnpm --filter @runfusion/fusion test:slow-cli`) is responsible for running them when slow CLI coverage is requested.
|
||||||
|
|
||||||
|
3. **CLI pi extension integration gate**
|
||||||
|
- `packages/cli/src/__tests__/extension-integration.test.ts`
|
||||||
|
- Pattern: `describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integration", ...)`
|
||||||
|
- Gate: `FUSION_TEST_EXTENSION_INTEGRATION=1` or `FUSION_TEST_EXTENSION_INTEGRATION=true`
|
||||||
|
- Rationale: this built-extension suite requires compiled CLI artifacts, so it is run through the explicit local release lane instead of every unit run.
|
||||||
|
- Replacement owner: `pnpm --filter @runfusion/fusion test:extension-integration`.
|
||||||
|
|
||||||
|
4. **CLI legacy pi extension gate**
|
||||||
|
- `packages/cli/src/__tests__/extension.test.ts`
|
||||||
|
- Pattern: `describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (legacy exhaustive suite)", ...)`
|
||||||
|
- Gate: `FUSION_TEST_LEGACY_EXTENSION_INTEGRATION=1` or `FUSION_TEST_LEGACY_EXTENSION_INTEGRATION=true`
|
||||||
|
- Rationale: this exhaustive suite is intentionally excluded from default and release lanes while it remains useful only for historical debugging.
|
||||||
|
- Replacement owner: `packages/cli/src/__tests__/extension-integration.test.ts` owns maintained built-extension coverage.
|
||||||
|
|
||||||
|
5. **CLI native binary build gate**
|
||||||
|
- `packages/cli/src/__tests__/build-exe-cross.test.ts`
|
||||||
|
- Pattern: four `describe.skipIf(!SHOULD_RUN_BUILD_EXE)(...)` suites for single-target, Windows-target, all-target, and default-target binary builds.
|
||||||
|
- Gate: `FUSION_TEST_BUILD_EXE=1`, `FUSION_TEST_BUILD_EXE=true`, or `CI=true`
|
||||||
|
- Rationale: cross-compiling native binaries is intentionally expensive and belongs in the binary/pre-release lane, not every local unit run.
|
||||||
|
- Replacement owner: `packages/cli/src/__tests__/build-exe.test.ts` and bundle-output tests cover the default fast package contract; `build-exe-cross.test.ts` owns full cross-target coverage when the gate is enabled.
|
||||||
|
|
||||||
|
6. **POSIX-only shell syntax cases**
|
||||||
|
- `packages/engine/src/__tests__/run-verification-command.test.ts`
|
||||||
|
- `packages/engine/src/__tests__/verification-utils.test.ts`
|
||||||
|
- Pattern: `const itPosix = onPosix ? it : it.skip`
|
||||||
|
- Gate: skipped only on `process.platform === "win32"`
|
||||||
|
- Rationale: a subset of tests uses POSIX shell syntax (`printf`, pipes, and shell quoting). The implementation still uses Node's portable `shell: true`; these specific fixtures are not portable to `cmd.exe`.
|
||||||
|
- Replacement owner: platform-neutral cases in the same suite continue to run on Windows.
|
||||||
|
|
||||||
|
7. **Build-output checks are deterministic (no skip gate)**
|
||||||
- `packages/cli/src/__tests__/bundle-output.test.ts`
|
- `packages/cli/src/__tests__/bundle-output.test.ts`
|
||||||
- `packages/dashboard/app/__tests__/build-output.test.ts`
|
- `packages/dashboard/app/__tests__/build-output.test.ts`
|
||||||
- Pattern: each suite builds required artifacts in `beforeAll` and then runs chunking/bundle assertions unconditionally.
|
- Pattern: each suite builds required artifacts in `beforeAll` and then runs chunking/bundle assertions unconditionally.
|
||||||
- Rationale: clean worktrees and CI environments should execute real output-contract assertions instead of silently skipping when `dist/` is absent.
|
- Rationale: clean worktrees and CI environments should execute real output-contract assertions instead of silently skipping when `dist/` is absent.
|
||||||
|
|
||||||
|
8. **Skip gate string assertions are not skip markers**
|
||||||
|
- `packages/cli/src/__tests__/ci-workflow.test.ts`
|
||||||
|
- Pattern: assertions check that CI workflow text contains `describe.skipIf(...)` gate strings.
|
||||||
|
- Rationale: this file does not skip tests itself; it verifies the workflow keeps the intentionally gated suites wired.
|
||||||
|
|
||||||
## Older Follow-up Reconciliation
|
## Older Follow-up Reconciliation
|
||||||
|
|
||||||
Previously tracked actionable skip follow-ups are now resolved and should not be treated as open backlog:
|
Previously tracked actionable skip follow-ups are now resolved and should not be treated as open backlog:
|
||||||
|
|
||||||
- **FN-2085**: wildcard proxy POST body forwarding coverage is active.
|
- **FN-2085**: wildcard proxy POST body forwarding coverage is active.
|
||||||
- **FN-2076 / FN-2106 / FN-2109**: NewAgentDialog and MissionInterviewModal rollback favorite-toggle regressions are active interaction tests.
|
- **FN-2076 / FN-2106 / FN-2109**: NewAgentDialog and MissionInterviewModal rollback favorite-toggle regressions are active interaction tests.
|
||||||
|
- The former engine `executor.test.ts` step-session alias skip is no longer present; ownership now lives in active engine tests.
|
||||||
|
|
||||||
Searches for those IDs in repository test code and docs now return no active TODO/skip markers tied to unresolved work.
|
Searches for those IDs in repository test code and docs now return no active TODO/skip markers tied to unresolved work.
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --silent=passed-only --reporter=dot",
|
"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: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: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"
|
"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");
|
readmeContent = readFileSync(join(workspaceRoot, "README.md"), "utf-8");
|
||||||
cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8");
|
cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8");
|
||||||
extensionSuiteContent = readFileSync(
|
extensionSuiteContent = readFileSync(
|
||||||
join(workspaceRoot, "packages", "cli", "src", "__tests__", "extension.test.ts"),
|
join(workspaceRoot, "packages", "cli", "src", "__tests__", "extension-integration.test.ts"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
agentExportSuiteContent = readFileSync(
|
agentExportSuiteContent = readFileSync(
|
||||||
@@ -124,11 +124,13 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
|||||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1");
|
expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1");
|
||||||
expect(cliPackageJsonContent).toContain('"test:extension-integration"');
|
expect(cliPackageJsonContent).toContain('"test:extension-integration"');
|
||||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1");
|
expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1");
|
||||||
|
expect(cliPackageJsonContent).toContain("extension-integration.test.ts");
|
||||||
expect(cliPackageJsonContent).toContain('"test:build-exe"');
|
expect(cliPackageJsonContent).toContain('"test:build-exe"');
|
||||||
expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1");
|
expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1");
|
||||||
|
|
||||||
expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)");
|
expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)");
|
||||||
expect(extensionSuiteContent).toContain("FUSION_TEST_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("describe.skipIf(!SHOULD_RUN_SLOW_CLI)");
|
||||||
expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI");
|
expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI");
|
||||||
|
|||||||
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 ──────────────────────────────────────────────────────────
|
// ── Tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Audited in FN-3189: this suite is expensive (~62s) and currently stale
|
// Audited in FN-3189: this exhaustive suite is expensive (~62s) and stale
|
||||||
// against modern extension behavior/tooling (see FN-3204). Keep an explicit,
|
// against modern extension behavior/tooling (see FN-3204). The maintained
|
||||||
// discoverable gate so it never silently disappears behind unconditional skip,
|
// release lane lives in extension-integration.test.ts and uses
|
||||||
// but do not include it in the default slow lane until the failures are fixed.
|
// FUSION_TEST_EXTENSION_INTEGRATION. Keep this under a separate legacy gate for
|
||||||
const SHOULD_RUN_EXTENSION_INTEGRATION =
|
// historical debugging only.
|
||||||
process.env.FUSION_TEST_EXTENSION_INTEGRATION === "1" ||
|
const SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION =
|
||||||
process.env.FUSION_TEST_EXTENSION_INTEGRATION === "true";
|
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 tmpDir: string;
|
||||||
let api: ReturnType<typeof createMockAPI>;
|
let api: ReturnType<typeof createMockAPI>;
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"dev": "pnpm build && pnpm typecheck && pnpm dev:serve",
|
"dev": "pnpm build && pnpm typecheck && pnpm dev:serve",
|
||||||
"dev:serve": "vite dev",
|
"dev:serve": "vite dev",
|
||||||
"test": "vitest run --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'",
|
"test": "vitest run --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'",
|
||||||
|
"test:browser-smoke": "node scripts/browser-layout-smoke.mjs",
|
||||||
"test:build": "vitest run --silent=passed-only --reporter=dot app/__tests__/build-output.test.ts",
|
"test:build": "vitest run --silent=passed-only --reporter=dot app/__tests__/build-output.test.ts",
|
||||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
|
||||||
},
|
},
|
||||||
|
|||||||
579
packages/dashboard/scripts/browser-layout-smoke.mjs
Normal file
579
packages/dashboard/scripts/browser-layout-smoke.mjs
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* global WebSocket, URL, fetch, console, setTimeout, clearTimeout */
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readdir, 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 componentCssRoot = path.join(appRoot, "components");
|
||||||
|
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() {
|
||||||
|
// Runtime CSS order matters for mobile layout. `main.tsx` imports `App`
|
||||||
|
// before `styles.css`, so component CSS is discovered first and the global
|
||||||
|
// stylesheet lands last in the app bundle.
|
||||||
|
const files = [];
|
||||||
|
const componentEntries = await readdir(componentCssRoot);
|
||||||
|
files.push(
|
||||||
|
...componentEntries
|
||||||
|
.filter((entry) => entry.endsWith(".css"))
|
||||||
|
.sort()
|
||||||
|
.map((entry) => path.join(componentCssRoot, entry)),
|
||||||
|
);
|
||||||
|
files.push(path.join(appRoot, "styles.css"));
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
for (const file of files) {
|
||||||
|
chunks.push(`\n/* ${path.relative(dashboardRoot, file)} */\n${await readFile(file, "utf8")}`);
|
||||||
|
}
|
||||||
|
return chunks.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
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"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wsUrl = await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Timed out waiting for the browser DevTools endpoint."));
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
const onData = (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/);
|
||||||
|
if (match) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(match[1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
browser.stdout.on("data", onData);
|
||||||
|
browser.stderr.on("data", onData);
|
||||||
|
browser.once("error", (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
browser.once("exit", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error(`Browser exited before DevTools was ready (code ${code}).`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { browser, userDataDir, wsUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
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(componentCssRoot)) {
|
||||||
|
fail(`Dashboard component CSS directory not found: ${componentCssRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fixture = await startFixtureServer();
|
||||||
|
const launched = await launchBrowser(executable);
|
||||||
|
let page;
|
||||||
|
try {
|
||||||
|
page = await createPage(launched.wsUrl);
|
||||||
|
await runSmokeChecks(page, fixture.url);
|
||||||
|
} finally {
|
||||||
|
page?.close();
|
||||||
|
fixture.server.close();
|
||||||
|
launched.browser.kill();
|
||||||
|
await rm(launched.userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`[dashboard-browser-smoke] ${error.stack ?? error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
import { computeMaxWorkers } from "../core/src/__test-utils__/vitest-workers";
|
||||||
|
|
||||||
|
const maxWorkers = computeMaxWorkers();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -9,6 +12,10 @@ export default defineConfig({
|
|||||||
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||||
],
|
],
|
||||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
|
pool: "forks",
|
||||||
|
maxWorkers,
|
||||||
|
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
|
fileParallelism: true,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text", "json-summary"],
|
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 { join } from "node:path";
|
||||||
import type { CustomProvider } from "@fusion/core";
|
import type { CustomProvider } from "@fusion/core";
|
||||||
|
|
||||||
export function readCustomProviders(): CustomProvider[] {
|
export function readCustomProviders(homeDir = homedir()): CustomProvider[] {
|
||||||
try {
|
try {
|
||||||
const settingsPath = join(homedir(), ".fusion", "settings.json");
|
const settingsPath = join(homeDir, ".fusion", "settings.json");
|
||||||
const raw = readFileSync(settingsPath, "utf-8");
|
const raw = readFileSync(settingsPath, "utf-8");
|
||||||
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
|
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
|
||||||
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];
|
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
import { computeMaxWorkers } from "../core/src/__test-utils__/vitest-workers";
|
||||||
|
|
||||||
|
const maxWorkers = computeMaxWorkers();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -9,6 +12,10 @@ export default defineConfig({
|
|||||||
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||||
],
|
],
|
||||||
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
|
||||||
|
pool: "forks",
|
||||||
|
maxWorkers,
|
||||||
|
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
|
fileParallelism: true,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text", "json-summary"],
|
reporter: ["text", "json-summary"],
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
98
scripts/__tests__/test-governance.test.mjs
Normal file
98
scripts/__tests__/test-governance.test.mjs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const repoRoot = path.resolve(__dirname, "../..");
|
||||||
|
|
||||||
|
const CONFIG_EXCEPTIONS = new Map([
|
||||||
|
// package name -> reason
|
||||||
|
]);
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWorkspacePackageDirs() {
|
||||||
|
const workspaceFile = path.join(repoRoot, "pnpm-workspace.yaml");
|
||||||
|
const workspaceYaml = readFileSync(workspaceFile, "utf8");
|
||||||
|
const patterns = [...workspaceYaml.matchAll(/^\s*-\s+"([^"]+)"\s*$/gm)].map((match) => match[1]);
|
||||||
|
const dirs = new Set();
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.endsWith("/*")) {
|
||||||
|
const parentDir = path.join(repoRoot, pattern.slice(0, -2));
|
||||||
|
for (const entry of readdirSync(parentDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const packageDir = path.join(parentDir, entry.name);
|
||||||
|
if (existsSync(path.join(packageDir, "package.json"))) {
|
||||||
|
dirs.add(packageDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageDir = path.join(repoRoot, pattern);
|
||||||
|
if (existsSync(path.join(packageDir, "package.json"))) {
|
||||||
|
dirs.add(packageDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...dirs].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSharedIsolation(config) {
|
||||||
|
return (
|
||||||
|
/\bsetupFiles\b/.test(config) &&
|
||||||
|
/\bglobalSetup\b/.test(config) &&
|
||||||
|
/__test-utils__\/vitest-setup\.ts/.test(config) &&
|
||||||
|
/__test-utils__\/vitest-teardown\.ts/.test(config)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSharedWorkerBudget(config) {
|
||||||
|
return (
|
||||||
|
/computeMaxWorkers/.test(config) &&
|
||||||
|
/\bmaxWorkers\b/.test(config) &&
|
||||||
|
/\bpoolOptions\b/.test(config) &&
|
||||||
|
/\bmax(?:Threads|Forks)\s*:\s*maxWorkers\b/.test(config)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("workspace packages with test scripts use shared Vitest governance", () => {
|
||||||
|
const failures = [];
|
||||||
|
const testedPackages = [];
|
||||||
|
|
||||||
|
for (const packageDir of readWorkspacePackageDirs()) {
|
||||||
|
const manifest = readJson(path.join(packageDir, "package.json"));
|
||||||
|
if (!manifest.scripts?.test) continue;
|
||||||
|
|
||||||
|
testedPackages.push(manifest.name);
|
||||||
|
|
||||||
|
const exception = CONFIG_EXCEPTIONS.get(manifest.name);
|
||||||
|
if (exception) {
|
||||||
|
assert.match(exception, /\S{12,}/, `${manifest.name} exception must include a reason`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(packageDir, "vitest.config.ts");
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
failures.push(`${manifest.name}: missing vitest.config.ts`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = readFileSync(configPath, "utf8");
|
||||||
|
if (!hasSharedIsolation(config)) {
|
||||||
|
failures.push(`${manifest.name}: missing shared vitest setup/teardown isolation`);
|
||||||
|
}
|
||||||
|
if (!hasSharedWorkerBudget(config)) {
|
||||||
|
failures.push(`${manifest.name}: missing shared computeMaxWorkers/maxWorkers poolOptions budget`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(testedPackages.length > 0, "expected at least one workspace package with a test script");
|
||||||
|
assert.deepEqual(failures, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user