feat(FN-3593): add test isolation CI enforcement, fix stuck-requeue race, a
This merge lands five FN-3593 commits establishing a test isolation contract with a new `scripts/check-test-isolation.mjs` guard that scans for accidental `beforeEach`/`afterEach`/`beforeAll`/`afterAll` in setup helpers, plus per-package `setup-test-isolation.ts` bootstraps that canonicalize the pat Fusion-Task-Id: FN-3593
This commit is contained in:
@@ -3,8 +3,10 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
Add a sender-side "wake recipient immediately" override for messages. The
|
Add a sender-side "wake recipient immediately" override for messages. The
|
||||||
message composer now offers a checkbox (when sending to an agent) and the
|
message composer now offers a checkbox (when sending to an agent) that sets
|
||||||
`fn_send_message` agent tool gains a `wake_recipient` boolean parameter.
|
`metadata.wakeRecipient: true` on the message. When honored, the recipient
|
||||||
When set, the recipient agent is woken on receipt regardless of their own
|
agent is woken on receipt regardless of their own `messageResponseMode`
|
||||||
`messageResponseMode` setting. Carried as `metadata.wakeRecipient: true` on
|
setting. To prevent agents from forcing wakes on each other, only
|
||||||
the message; ignored when the recipient is a user.
|
human-originated messages (`fromType: "user"`) trigger the override —
|
||||||
|
agent-to-agent traffic continues to respect the recipient's configured
|
||||||
|
behavior.
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ GitHub Actions now runs deterministic test sharding via `pnpm test:ci:shard --sh
|
|||||||
|
|
||||||
`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected package test scripts using safe package-first filtering (`pnpm --filter <pkg> test`). It automatically falls back to the full suite when the run is forced (CI / `--full`), the git comparison base or diff cannot be resolved, no changes are detected, or shared/root test infrastructure changes.
|
`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected package test scripts using safe package-first filtering (`pnpm --filter <pkg> test`). It automatically falls back to the full suite when the run is forced (CI / `--full`), the git comparison base or diff cannot be resolved, no changes are detected, or shared/root test infrastructure changes.
|
||||||
|
|
||||||
|
### Test isolation contract (required)
|
||||||
|
|
||||||
|
Fusion tests must run against disposable test data, never live local state:
|
||||||
|
|
||||||
|
- The canonical Vitest bootstrap is `packages/core/src/__test-utils__/vitest-setup.ts`.
|
||||||
|
- Workspace/package Vitest configs should use package-local `src/__tests__/setup-test-isolation.ts` shims that call into the shared core bootstrap rather than re-implementing HOME/cwd isolation.
|
||||||
|
- Test runs must use temp HOME and temp workspace/project roots so global settings resolve under temporary directories instead of real `~/.fusion`.
|
||||||
|
- The repository `.fusion` directory is treated as protected live data; root test entrypoints run `scripts/check-test-isolation.mjs` to fail if tests mutate protected Fusion data paths.
|
||||||
|
|
||||||
|
If you add or change test entrypoints, keep this isolation guard path intact so cached/changed-package routes remain protected.
|
||||||
|
|
||||||
## Quality Gate Checklist
|
## Quality Gate Checklist
|
||||||
|
|
||||||
Before submitting changes, verify:
|
Before submitting changes, verify:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function buildCliWithRealDashboardAssets() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot);
|
||||||
runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot);
|
runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot);
|
||||||
runBuildCommand("pnpm build", cliRoot);
|
runBuildCommand("pnpm build", cliRoot);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Global test isolation for CLI package.
|
* Deprecated shim: canonical test isolation is in @fusion/core vitest-setup.
|
||||||
* @see packages/core/src/__tests__/setup-test-isolation.ts
|
|
||||||
*/
|
*/
|
||||||
import { mkdtempSync } from "node:fs";
|
import "../../../core/src/__test-utils__/vitest-setup";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
|
||||||
process.env.HOME = tempHome;
|
|
||||||
process.env.USERPROFILE = tempHome;
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
|
|
||||||
if (match) {
|
|
||||||
process.env.HOMEDRIVE = match[1];
|
|
||||||
process.env.HOMEPATH = match[2] || "\\";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export default defineConfig({
|
|||||||
// run with file parallelism enabled.
|
// run with file parallelism enabled.
|
||||||
exclude: ["**/node_modules/**", "**/dist/**", "src/__tests__/build-exe*.test.ts"],
|
exclude: ["**/node_modules/**", "**/dist/**", "src/__tests__/build-exe*.test.ts"],
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
"./src/__tests__/setup-test-isolation.ts",
|
|
||||||
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")],
|
||||||
|
|||||||
@@ -106,6 +106,36 @@ describe("MessageStore", () => {
|
|||||||
expect(store.getMessage(reply.id)?.metadata).toEqual({ replyTo: { messageId: original.id } });
|
expect(store.getMessage(reply.id)?.metadata).toEqual({ replyTo: { messageId: original.id } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists wakeRecipient metadata through storage roundtrip", () => {
|
||||||
|
const message = store.sendMessage({
|
||||||
|
fromId: "user:dashboard",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
content: "urgent",
|
||||||
|
type: "user-to-agent",
|
||||||
|
metadata: { wakeRecipient: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message.metadata).toEqual({ wakeRecipient: true });
|
||||||
|
expect(store.getMessage(message.id)?.metadata).toEqual({ wakeRecipient: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-boolean wakeRecipient metadata", () => {
|
||||||
|
expect(() => {
|
||||||
|
store.sendMessage({
|
||||||
|
fromId: "user:dashboard",
|
||||||
|
fromType: "user",
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
content: "Bad metadata",
|
||||||
|
type: "user-to-agent",
|
||||||
|
// @ts-expect-error intentional bad type for runtime validation
|
||||||
|
metadata: { wakeRecipient: "yes" },
|
||||||
|
});
|
||||||
|
}).toThrow("metadata.wakeRecipient must be a boolean");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects malformed reply metadata", () => {
|
it("rejects malformed reply metadata", () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
store.sendMessage({
|
store.sendMessage({
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { Database } from "../db.js";
|
|||||||
|
|
||||||
const TEMP_HOME_PREFIX = "fn-test-home-";
|
const TEMP_HOME_PREFIX = "fn-test-home-";
|
||||||
|
|
||||||
describe("test isolation setup", () => {
|
describe("shared test isolation setup", () => {
|
||||||
it("process.env.HOME is overridden to a temp directory", () => {
|
it("overrides HOME to a temp fn-test-home directory", () => {
|
||||||
const home = process.env.HOME;
|
const home = process.env.HOME;
|
||||||
const userProfile = process.env.USERPROFILE;
|
const userProfile = process.env.USERPROFILE;
|
||||||
|
|
||||||
@@ -43,11 +43,12 @@ describe("test isolation setup", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cwd is not inside the repository .fusion directory", () => {
|
it("records protected repository root and avoids repo .fusion cwd", () => {
|
||||||
const thisFile = fileURLToPath(import.meta.url);
|
const thisFile = fileURLToPath(import.meta.url);
|
||||||
const repoRoot = resolve(dirname(thisFile), "../../../../");
|
const repoRoot = resolve(dirname(thisFile), "../../../../");
|
||||||
const repoFusionDir = join(repoRoot, ".fusion");
|
const repoFusionDir = join(repoRoot, ".fusion");
|
||||||
|
|
||||||
|
expect(process.env.FUSION_TEST_REAL_ROOT).toBeDefined();
|
||||||
expect(process.cwd().startsWith(repoFusionDir)).toBe(false);
|
expect(process.cwd().startsWith(repoFusionDir)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Global test isolation: prevents tests from writing to the real ~/.fusion/ directory.
|
* Deprecated shim: shared test isolation now lives in ../__test-utils__/vitest-setup.ts.
|
||||||
*
|
* Keep this file for compatibility with any ad-hoc vitest configs that still reference it.
|
||||||
* Vitest runs setupFiles in each worker thread. By overriding process.env.HOME
|
|
||||||
* to a temp directory, all calls to homedir() (and derived paths like ~/.fusion)
|
|
||||||
* resolve to isolated temp locations instead of the user's real home directory.
|
|
||||||
*
|
|
||||||
* This protects against tests accidentally creating projects, databases, or
|
|
||||||
* settings files in the production ~/.fusion/ directory.
|
|
||||||
*/
|
*/
|
||||||
import { mkdtempSync } from "node:fs";
|
import "../__test-utils__/vitest-setup";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
|
||||||
process.env.HOME = tempHome;
|
|
||||||
process.env.USERPROFILE = tempHome;
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
|
|
||||||
if (match) {
|
|
||||||
process.env.HOMEDRIVE = match[1];
|
|
||||||
process.env.HOMEPATH = match[2] || "\\";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3163,11 +3163,36 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
} else if (updates.blockedBy !== undefined) {
|
} else if (updates.blockedBy !== undefined) {
|
||||||
task.blockedBy = updates.blockedBy;
|
task.blockedBy = updates.blockedBy;
|
||||||
}
|
}
|
||||||
|
const previousAssignedAgentId = task.assignedAgentId;
|
||||||
if (updates.assignedAgentId === null) {
|
if (updates.assignedAgentId === null) {
|
||||||
task.assignedAgentId = undefined;
|
task.assignedAgentId = undefined;
|
||||||
} else if (updates.assignedAgentId !== undefined) {
|
} else if (updates.assignedAgentId !== undefined) {
|
||||||
task.assignedAgentId = updates.assignedAgentId;
|
task.assignedAgentId = updates.assignedAgentId;
|
||||||
}
|
}
|
||||||
|
// If the agent that paused this task is being unassigned (or replaced),
|
||||||
|
// auto-unpause: the pause was tied to that agent's lifecycle, and now
|
||||||
|
// there's no longer a relationship that justifies keeping the task paused.
|
||||||
|
const assignmentChanged =
|
||||||
|
updates.assignedAgentId !== undefined && task.assignedAgentId !== previousAssignedAgentId;
|
||||||
|
if (
|
||||||
|
assignmentChanged &&
|
||||||
|
task.paused &&
|
||||||
|
task.pausedByAgentId &&
|
||||||
|
task.pausedByAgentId === previousAssignedAgentId
|
||||||
|
) {
|
||||||
|
task.paused = undefined;
|
||||||
|
task.pausedByAgentId = undefined;
|
||||||
|
if (task.column === "in-progress" || task.column === "in-review") {
|
||||||
|
if (task.status === "paused") {
|
||||||
|
task.status = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.log.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
action: `Task unpaused (agent ${previousAssignedAgentId} unassigned)`,
|
||||||
|
...(runContext ? { runContext } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (updates.pausedByAgentId === null) {
|
if (updates.pausedByAgentId === null) {
|
||||||
task.pausedByAgentId = undefined;
|
task.pausedByAgentId = undefined;
|
||||||
} else if (updates.pausedByAgentId !== undefined) {
|
} else if (updates.pausedByAgentId !== undefined) {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts"],
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
"./src/__tests__/setup-test-isolation.ts",
|
|
||||||
"./src/__test-utils__/vitest-setup.ts",
|
"./src/__test-utils__/vitest-setup.ts",
|
||||||
],
|
],
|
||||||
globalSetup: ["./src/__test-utils__/vitest-teardown.ts"],
|
globalSetup: ["./src/__test-utils__/vitest-teardown.ts"],
|
||||||
@@ -25,10 +24,8 @@ export default defineConfig({
|
|||||||
// worker never gets its isolated cwd. Tests that rely on cwd being a
|
// worker never gets its isolated cwd. Tests that rely on cwd being a
|
||||||
// disposable temp dir would silently operate in the repo root.
|
// disposable temp dir would silently operate in the repo root.
|
||||||
//
|
//
|
||||||
// 2. setup-test-isolation.ts:15-16 — `process.env.HOME` is written
|
// 2. Some suites rely on fork-level process/env isolation for setup side effects,
|
||||||
// unconditionally in every setupFile invocation. Threads share
|
// and cannot safely share mutable process state under worker_threads.
|
||||||
// `process.env`, so concurrent workers race on HOME and the last writer
|
|
||||||
// wins, breaking isolation for all other workers in the same run.
|
|
||||||
pool: "forks",
|
pool: "forks",
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
|
|||||||
@@ -197,6 +197,71 @@ describe("MessageComposer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards wakeRecipient metadata when the wake checkbox is ticked for an agent recipient", async () => {
|
||||||
|
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
||||||
|
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
|
||||||
|
target: { value: "agent-001" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId("message-composer-content"), {
|
||||||
|
target: { value: "wake up" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("message-composer-wake"));
|
||||||
|
fireEvent.click(screen.getByTestId("message-composer-send"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: { wakeRecipient: true },
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges wakeRecipient with replyTo metadata when replying", async () => {
|
||||||
|
render(
|
||||||
|
<MessageComposer
|
||||||
|
{...defaultProps}
|
||||||
|
agents={mockAgents}
|
||||||
|
recipient={{ id: "agent-001", type: "agent" }}
|
||||||
|
replyContext={{ messageId: "msg-orig", preview: "earlier message" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByTestId("message-composer-content"), {
|
||||||
|
target: { value: "follow up" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("message-composer-wake"));
|
||||||
|
fireEvent.click(screen.getByTestId("message-composer-send"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: {
|
||||||
|
replyTo: { messageId: "msg-orig" },
|
||||||
|
wakeRecipient: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits wakeRecipient metadata when the checkbox is left unchecked", async () => {
|
||||||
|
render(<MessageComposer {...defaultProps} agents={mockAgents} />);
|
||||||
|
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
|
||||||
|
target: { value: "agent-001" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId("message-composer-content"), {
|
||||||
|
target: { value: "regular" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("message-composer-send"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const callArgs = mockSendMessage.mock.calls[0][0];
|
||||||
|
expect(callArgs.metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("passes projectId to sendMessage", async () => {
|
it("passes projectId to sendMessage", async () => {
|
||||||
render(<MessageComposer {...defaultProps} agents={mockAgents} projectId="proj-1" />);
|
render(<MessageComposer {...defaultProps} agents={mockAgents} projectId="proj-1" />);
|
||||||
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
|
fireEvent.change(screen.getByTestId("message-composer-recipient"), {
|
||||||
|
|||||||
@@ -1,20 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Global test isolation: prevents dashboard tests from writing to the real ~/.fusion/ directory.
|
* Deprecated shim: canonical test isolation is in @fusion/core vitest-setup.
|
||||||
*
|
|
||||||
* This runs in every Vitest worker before shared setup. By forcing process.env.HOME
|
|
||||||
* to a fresh temp directory, homedir()-derived paths resolve to isolated locations.
|
|
||||||
*/
|
*/
|
||||||
import { mkdtempSync } from "node:fs";
|
import "../../../core/src/__test-utils__/vitest-setup";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
|
||||||
process.env.HOME = tempHome;
|
|
||||||
process.env.USERPROFILE = tempHome;
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
|
|
||||||
if (match) {
|
|
||||||
process.env.HOMEDRIVE = match[1];
|
|
||||||
process.env.HOMEPATH = match[2] || "\\";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
include: ["app/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
include: ["app/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
"./src/__tests__/setup-test-isolation.ts",
|
|
||||||
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
|
||||||
"./vitest.setup.ts",
|
"./vitest.setup.ts",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -220,6 +220,160 @@ describe("wake-on-message", () => {
|
|||||||
customMonitor.stop();
|
customMonitor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forces a wake when metadata.wakeRecipient overrides on-heartbeat mode", () => {
|
||||||
|
let messageHook: ((message: Message) => void) | undefined;
|
||||||
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
messageHook = hook;
|
||||||
|
});
|
||||||
|
const configStore = createMockStore({
|
||||||
|
getCachedAgent: vi.fn().mockReturnValue({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "active",
|
||||||
|
runtimeConfig: { messageResponseMode: "on-heartbeat" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customMonitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
agentStore: configStore,
|
||||||
|
messageStore,
|
||||||
|
});
|
||||||
|
const executeHeartbeatSpy = vi
|
||||||
|
.spyOn(customMonitor, "executeHeartbeat")
|
||||||
|
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||||
|
|
||||||
|
customMonitor.start();
|
||||||
|
messageHook?.(
|
||||||
|
createMessage({
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
metadata: { wakeRecipient: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(executeHeartbeatSpy).toHaveBeenCalledWith({
|
||||||
|
agentId: "agent-1",
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "wake-on-message-forced",
|
||||||
|
});
|
||||||
|
|
||||||
|
customMonitor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces a wake even when messageResponseMode is unset", () => {
|
||||||
|
let messageHook: ((message: Message) => void) | undefined;
|
||||||
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
messageHook = hook;
|
||||||
|
});
|
||||||
|
const configStore = createMockStore({
|
||||||
|
getCachedAgent: vi.fn().mockReturnValue({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "idle",
|
||||||
|
runtimeConfig: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customMonitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
agentStore: configStore,
|
||||||
|
messageStore,
|
||||||
|
});
|
||||||
|
const executeHeartbeatSpy = vi
|
||||||
|
.spyOn(customMonitor, "executeHeartbeat")
|
||||||
|
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||||
|
|
||||||
|
customMonitor.start();
|
||||||
|
messageHook?.(
|
||||||
|
createMessage({
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
metadata: { wakeRecipient: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(executeHeartbeatSpy).toHaveBeenCalledWith({
|
||||||
|
agentId: "agent-1",
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "wake-on-message-forced",
|
||||||
|
});
|
||||||
|
|
||||||
|
customMonitor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores wakeRecipient metadata when sender is an agent (only humans may force wakes)", () => {
|
||||||
|
let messageHook: ((message: Message) => void) | undefined;
|
||||||
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
messageHook = hook;
|
||||||
|
});
|
||||||
|
const configStore = createMockStore({
|
||||||
|
getCachedAgent: vi.fn().mockReturnValue({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "active",
|
||||||
|
runtimeConfig: { messageResponseMode: "on-heartbeat" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customMonitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
agentStore: configStore,
|
||||||
|
messageStore,
|
||||||
|
});
|
||||||
|
const executeHeartbeatSpy = vi
|
||||||
|
.spyOn(customMonitor, "executeHeartbeat")
|
||||||
|
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||||
|
|
||||||
|
customMonitor.start();
|
||||||
|
messageHook?.(
|
||||||
|
createMessage({
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
fromId: "agent-2",
|
||||||
|
fromType: "agent",
|
||||||
|
metadata: { wakeRecipient: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
customMonitor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still respects state gating when wakeRecipient is set (paused agent stays paused)", () => {
|
||||||
|
let messageHook: ((message: Message) => void) | undefined;
|
||||||
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
messageHook = hook;
|
||||||
|
});
|
||||||
|
const configStore = createMockStore({
|
||||||
|
getCachedAgent: vi.fn().mockReturnValue({
|
||||||
|
id: "agent-1",
|
||||||
|
state: "paused",
|
||||||
|
runtimeConfig: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customMonitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
agentStore: configStore,
|
||||||
|
messageStore,
|
||||||
|
});
|
||||||
|
const executeHeartbeatSpy = vi
|
||||||
|
.spyOn(customMonitor, "executeHeartbeat")
|
||||||
|
.mockResolvedValue({ id: "run-1" } as AgentHeartbeatRun);
|
||||||
|
|
||||||
|
customMonitor.start();
|
||||||
|
messageHook?.(
|
||||||
|
createMessage({
|
||||||
|
toId: "agent-1",
|
||||||
|
toType: "agent",
|
||||||
|
metadata: { wakeRecipient: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
customMonitor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("registers the message hook on start and clears it on stop", () => {
|
it("registers the message hook on start and clears it on stop", () => {
|
||||||
const hooks: Array<(message: Message) => void> = [];
|
const hooks: Array<(message: Message) => void> = [];
|
||||||
const messageStore = createMockMessageStore((hook) => {
|
const messageStore = createMockMessageStore((hook) => {
|
||||||
|
|||||||
@@ -1,20 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Global test isolation: prevents engine tests from writing to the real ~/.fusion/ directory.
|
* Deprecated shim: canonical test isolation is in @fusion/core vitest-setup.
|
||||||
*
|
|
||||||
* This runs in every Vitest worker before shared setup. By forcing process.env.HOME
|
|
||||||
* to a fresh temp directory, homedir()-derived paths resolve to isolated locations.
|
|
||||||
*/
|
*/
|
||||||
import { mkdtempSync } from "node:fs";
|
import "../../../core/src/__test-utils__/vitest-setup";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
|
||||||
process.env.HOME = tempHome;
|
|
||||||
process.env.USERPROFILE = tempHome;
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
|
|
||||||
if (match) {
|
|
||||||
process.env.HOMEDRIVE = match[1];
|
|
||||||
process.env.HOMEPATH = match[2] || "\\";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1149,7 +1149,12 @@ export class HeartbeatMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runtimeConfig = agent.runtimeConfig as AgentHeartbeatConfig | undefined;
|
const runtimeConfig = agent.runtimeConfig as AgentHeartbeatConfig | undefined;
|
||||||
const senderForcedWake = message.metadata?.wakeRecipient === true;
|
// Only human-originated (user) messages may override an agent's
|
||||||
|
// messageResponseMode setting. Agent-to-agent traffic must respect the
|
||||||
|
// recipient's configured behavior to prevent agents from forcing wakes
|
||||||
|
// on each other.
|
||||||
|
const senderForcedWake =
|
||||||
|
message.metadata?.wakeRecipient === true && message.fromType === "user";
|
||||||
if (!senderForcedWake && runtimeConfig?.messageResponseMode !== "immediate") {
|
if (!senderForcedWake && runtimeConfig?.messageResponseMode !== "immediate") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,13 +113,6 @@ export const sendMessageParams = Type.Object({
|
|||||||
reply_to_message_id: Type.Optional(
|
reply_to_message_id: Type.Optional(
|
||||||
Type.String({ description: "Optional ID of the message you are replying to (use IDs from fn_read_messages output)" }),
|
Type.String({ description: "Optional ID of the message you are replying to (use IDs from fn_read_messages output)" }),
|
||||||
),
|
),
|
||||||
wake_recipient: Type.Optional(
|
|
||||||
Type.Boolean({
|
|
||||||
description:
|
|
||||||
"If true, wake the recipient agent immediately on receipt regardless of their messageResponseMode. " +
|
|
||||||
"Use sparingly for urgent messages. Ignored when the recipient is a user.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const readMessagesParams = Type.Object({
|
export const readMessagesParams = Type.Object({
|
||||||
@@ -1417,8 +1410,7 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
|||||||
label: "Send Message",
|
label: "Send Message",
|
||||||
description:
|
description:
|
||||||
"Send a message to another agent or user. The recipient will be woken if they have " +
|
"Send a message to another agent or user. The recipient will be woken if they have " +
|
||||||
"`messageResponseMode: 'immediate'` configured, or if you set `wake_recipient: true` " +
|
"`messageResponseMode: 'immediate'` configured. When replying to an existing message, " +
|
||||||
"to override their setting for an urgent message. When replying to an existing message, " +
|
|
||||||
"include `reply_to_message_id` to preserve threading.",
|
"include `reply_to_message_id` to preserve threading.",
|
||||||
parameters: sendMessageParams,
|
parameters: sendMessageParams,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -1453,15 +1445,6 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const wakeRecipient = params.wake_recipient === true && recipient.type === "agent";
|
|
||||||
const metadata =
|
|
||||||
replyToMessageId || wakeRecipient
|
|
||||||
? {
|
|
||||||
...(replyToMessageId ? { replyTo: { messageId: replyToMessageId } } : {}),
|
|
||||||
...(wakeRecipient ? { wakeRecipient: true } : {}),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const message = messageStore.sendMessage({
|
const message = messageStore.sendMessage({
|
||||||
fromId: fromAgentId,
|
fromId: fromAgentId,
|
||||||
fromType: "agent",
|
fromType: "agent",
|
||||||
@@ -1469,7 +1452,7 @@ export function createSendMessageTool(messageStore: MessageStore, fromAgentId: s
|
|||||||
toType: recipient.type,
|
toType: recipient.type,
|
||||||
content,
|
content,
|
||||||
type: messageType,
|
type: messageType,
|
||||||
...(metadata ? { metadata } : {}),
|
...(replyToMessageId ? { metadata: { replyTo: { messageId: replyToMessageId } } } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts"],
|
||||||
setupFiles: [
|
setupFiles: [
|
||||||
"./src/__tests__/setup-test-isolation.ts",
|
|
||||||
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")],
|
||||||
|
|||||||
84
scripts/__tests__/check-test-isolation.test.mjs
Normal file
84
scripts/__tests__/check-test-isolation.test.mjs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
const scriptPath = path.resolve("scripts/check-test-isolation.mjs");
|
||||||
|
|
||||||
|
function withFixture(fn) {
|
||||||
|
const cwd = mkdtempSync(path.join(tmpdir(), "check-isolation-cwd-"));
|
||||||
|
const home = mkdtempSync(path.join(tmpdir(), "check-isolation-home-"));
|
||||||
|
mkdirSync(path.join(cwd, ".fusion"), { recursive: true });
|
||||||
|
mkdirSync(path.join(home, ".fusion"), { recursive: true });
|
||||||
|
try {
|
||||||
|
fn({ cwd, home });
|
||||||
|
} finally {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(args, options) {
|
||||||
|
return spawnSync(process.execPath, [scriptPath, ...args], {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: { ...process.env, HOME: options.home, USERPROFILE: options.home },
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("passes when baseline and current state match", () => {
|
||||||
|
withFixture(({ cwd, home }) => {
|
||||||
|
const before = runScript(["--before"], { cwd, home });
|
||||||
|
assert.equal(before.status, 0);
|
||||||
|
const after = runScript([], { cwd, home });
|
||||||
|
assert.equal(after.status, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails when a tracked temp leak appears after baseline", () => {
|
||||||
|
withFixture(({ cwd, home }) => {
|
||||||
|
const before = runScript(["--before"], { cwd, home });
|
||||||
|
assert.equal(before.status, 0);
|
||||||
|
mkdirSync(path.join(tmpdir(), "fusion-test-leak-check-script"), { recursive: true });
|
||||||
|
const after = runScript([], { cwd, home });
|
||||||
|
assert.equal(after.status, 1);
|
||||||
|
assert.match(after.stderr, /leaked temp director/i);
|
||||||
|
rmSync(path.join(tmpdir(), "fusion-test-leak-check-script"), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails when protected repo .fusion data changes after baseline", () => {
|
||||||
|
withFixture(({ cwd, home }) => {
|
||||||
|
const before = runScript(["--before"], { cwd, home });
|
||||||
|
assert.equal(before.status, 0);
|
||||||
|
writeFileSync(path.join(cwd, ".fusion", "mutated.txt"), "x");
|
||||||
|
const after = runScript([], { cwd, home });
|
||||||
|
assert.equal(after.status, 1);
|
||||||
|
assert.match(after.stderr, /protected live \.fusion data changed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails when protected HOME .fusion data changes after baseline", () => {
|
||||||
|
withFixture(({ cwd, home }) => {
|
||||||
|
const before = runScript(["--before"], { cwd, home });
|
||||||
|
assert.equal(before.status, 0);
|
||||||
|
writeFileSync(path.join(home, ".fusion", "home-mutated.txt"), "x");
|
||||||
|
const after = runScript([], { cwd, home });
|
||||||
|
assert.equal(after.status, 1);
|
||||||
|
assert.match(after.stderr, /protected live \.fusion data changed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails when protected .fusion existence changes after baseline", () => {
|
||||||
|
withFixture(({ cwd, home }) => {
|
||||||
|
rmSync(path.join(cwd, ".fusion"), { recursive: true, force: true });
|
||||||
|
const before = runScript(["--before"], { cwd, home });
|
||||||
|
assert.equal(before.status, 0);
|
||||||
|
mkdirSync(path.join(cwd, ".fusion"), { recursive: true });
|
||||||
|
const after = runScript([], { cwd, home });
|
||||||
|
assert.equal(after.status, 1);
|
||||||
|
assert.match(after.stderr, /protected live \.fusion data changed/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
applyCacheToPlan,
|
applyCacheToPlan,
|
||||||
recordCachePass,
|
recordCachePass,
|
||||||
cacheFilePath,
|
cacheFilePath,
|
||||||
|
shouldRunIsolationGuard,
|
||||||
} from "../test-changed.mjs";
|
} from "../test-changed.mjs";
|
||||||
|
|
||||||
import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
@@ -91,6 +92,10 @@ test("shouldForceFullSuite: returns true when scripts/test-changed.mjs changed",
|
|||||||
assert.equal(shouldForceFullSuite(["scripts/test-changed.mjs"]), true);
|
assert.equal(shouldForceFullSuite(["scripts/test-changed.mjs"]), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldForceFullSuite: returns true when scripts/check-test-isolation.mjs changed", () => {
|
||||||
|
assert.equal(shouldForceFullSuite(["scripts/check-test-isolation.mjs"]), true);
|
||||||
|
});
|
||||||
|
|
||||||
test("shouldForceFullSuite: returns true when a GitHub workflow changed", () => {
|
test("shouldForceFullSuite: returns true when a GitHub workflow changed", () => {
|
||||||
assert.equal(shouldForceFullSuite([".github/workflows/ci.yml"]), true);
|
assert.equal(shouldForceFullSuite([".github/workflows/ci.yml"]), true);
|
||||||
});
|
});
|
||||||
@@ -550,3 +555,11 @@ test("cacheFilePath: ends with .fusion/test-cache.json", () => {
|
|||||||
const p = cacheFilePath();
|
const p = cacheFilePath();
|
||||||
assert.ok(p.endsWith(path.join(".fusion", "test-cache.json")), `got: ${p}`);
|
assert.ok(p.endsWith(path.join(".fusion", "test-cache.json")), `got: ${p}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldRunIsolationGuard: enabled by default", () => {
|
||||||
|
assert.equal(shouldRunIsolationGuard({}), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldRunIsolationGuard: disabled when env flag is set", () => {
|
||||||
|
assert.equal(shouldRunIsolationGuard({ FUSION_TEST_DISABLE_ISOLATION_GUARD: "1" }), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,28 +1,10 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
import { readdirSync, statSync, existsSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
|
||||||
* Verifies that the test suite doesn't leak temp directories or touch the
|
import { homedir, tmpdir } from "node:os";
|
||||||
* real .fusion directory.
|
import { join, resolve, sep } from "node:path";
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/check-test-isolation.mjs [--before]
|
|
||||||
*
|
|
||||||
* --before Record baseline state before running tests (writes /tmp/.fusion-isolation-baseline).
|
|
||||||
* (default) Compare current state to baseline and fail on leaks.
|
|
||||||
*
|
|
||||||
* Integration:
|
|
||||||
* node scripts/check-test-isolation.mjs --before
|
|
||||||
* pnpm test
|
|
||||||
* node scripts/check-test-isolation.mjs
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readdirSync, statSync, existsSync, writeFileSync, readFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const BASELINE_FILE = join(tmpdir(), ".fusion-isolation-baseline");
|
const BASELINE_FILE = join(tmpdir(), ".fusion-isolation-baseline");
|
||||||
|
|
||||||
// Prefixes the test suite is allowed to create under /tmp. Any dir matching
|
|
||||||
// one of these must be cleaned up by the end of the test run.
|
|
||||||
const TRACKED_PREFIXES = [
|
const TRACKED_PREFIXES = [
|
||||||
"fusion-worker-",
|
"fusion-worker-",
|
||||||
"fusion-test-",
|
"fusion-test-",
|
||||||
@@ -40,6 +22,14 @@ const TRACKED_PREFIXES = [
|
|||||||
"kb-first-run-test-",
|
"kb-first-run-test-",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function stablePath(pathValue) {
|
||||||
|
try {
|
||||||
|
return realpathSync(pathValue);
|
||||||
|
} catch {
|
||||||
|
return resolve(pathValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function snapshotTmp() {
|
function snapshotTmp() {
|
||||||
const entries = readdirSync(tmpdir());
|
const entries = readdirSync(tmpdir());
|
||||||
const matching = [];
|
const matching = [];
|
||||||
@@ -48,46 +38,106 @@ function snapshotTmp() {
|
|||||||
const full = join(tmpdir(), name);
|
const full = join(tmpdir(), name);
|
||||||
try {
|
try {
|
||||||
const stat = statSync(full);
|
const stat = statSync(full);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) matching.push({ name, mtime: stat.mtimeMs });
|
||||||
matching.push({ name, mtime: stat.mtimeMs });
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore — could be gone already.
|
// Ignore transient file-system races while scanning /tmp.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return matching;
|
return matching;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listProtectedFusionDirs() {
|
||||||
|
const dirs = new Set();
|
||||||
|
dirs.add(stablePath(join(process.cwd(), ".fusion")));
|
||||||
|
dirs.add(stablePath(join(process.env.HOME || process.env.USERPROFILE || homedir(), ".fusion")));
|
||||||
|
return [...dirs];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFusionSignature(rootDir, out = []) {
|
||||||
|
if (!existsSync(rootDir)) return out;
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = statSync(rootDir);
|
||||||
|
} catch {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (!stat.isDirectory()) return out;
|
||||||
|
|
||||||
|
const entries = readdirSync(rootDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(rootDir, entry.name);
|
||||||
|
const relPath = fullPath.slice(rootDir.length + (rootDir.endsWith(sep) ? 0 : 1));
|
||||||
|
let entryStat;
|
||||||
|
try {
|
||||||
|
entryStat = statSync(fullPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(`${relPath}|${entry.isDirectory() ? "d" : "f"}|${entryStat.size}|${Math.floor(entryStat.mtimeMs)}`);
|
||||||
|
if (entry.isDirectory()) collectFusionSignature(fullPath, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotProtectedFusion() {
|
||||||
|
return listProtectedFusionDirs().map((dir) => ({
|
||||||
|
dir,
|
||||||
|
exists: existsSync(dir),
|
||||||
|
entries: collectFusionSignature(dir).sort(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function recordBaseline() {
|
function recordBaseline() {
|
||||||
const baseline = snapshotTmp();
|
const payload = {
|
||||||
writeFileSync(BASELINE_FILE, JSON.stringify(baseline.map((e) => e.name)));
|
tmpNames: snapshotTmp().map((e) => e.name),
|
||||||
console.log(`[test-isolation] Baseline recorded: ${baseline.length} existing dir(s) matched patterns.`);
|
protectedFusion: snapshotProtectedFusion(),
|
||||||
|
};
|
||||||
|
writeFileSync(BASELINE_FILE, JSON.stringify(payload));
|
||||||
|
console.log(`[test-isolation] Baseline recorded: ${payload.tmpNames.length} temp dir(s), ${payload.protectedFusion.length} protected .fusion root(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAgainstBaseline() {
|
function checkAgainstBaseline() {
|
||||||
let baselineNames = new Set();
|
let baseline = { tmpNames: [], protectedFusion: [] };
|
||||||
if (existsSync(BASELINE_FILE)) {
|
if (existsSync(BASELINE_FILE)) {
|
||||||
try {
|
try {
|
||||||
baselineNames = new Set(JSON.parse(readFileSync(BASELINE_FILE, "utf-8")));
|
baseline = JSON.parse(readFileSync(BASELINE_FILE, "utf-8"));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed baseline.
|
// Ignore malformed baseline payloads and treat as empty baseline.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const current = snapshotTmp();
|
|
||||||
const leaks = current.filter((e) => !baselineNames.has(e.name));
|
const baselineNames = new Set(baseline.tmpNames ?? []);
|
||||||
if (leaks.length === 0) {
|
const leaks = snapshotTmp().filter((e) => !baselineNames.has(e.name));
|
||||||
console.log("[test-isolation] No leaked temp directories detected.");
|
|
||||||
|
const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry]));
|
||||||
|
const currentProtected = snapshotProtectedFusion();
|
||||||
|
const protectedViolations = [];
|
||||||
|
for (const current of currentProtected) {
|
||||||
|
const base = baselineByDir.get(current.dir) ?? { exists: false, entries: [] };
|
||||||
|
const changedExistence = Boolean(base.exists) !== Boolean(current.exists);
|
||||||
|
const changedEntries = JSON.stringify(base.entries) !== JSON.stringify(current.entries);
|
||||||
|
if (changedExistence || changedEntries) {
|
||||||
|
protectedViolations.push(current.dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaks.length === 0 && protectedViolations.length === 0) {
|
||||||
|
console.log("[test-isolation] No temp leaks or live .fusion mutations detected.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
console.error(`[test-isolation] FAIL: ${leaks.length} leaked temp director${leaks.length === 1 ? "y" : "ies"}:`);
|
|
||||||
for (const leak of leaks) {
|
if (leaks.length > 0) {
|
||||||
console.error(` ${join(tmpdir(), leak.name)}`);
|
console.error(`[test-isolation] FAIL: ${leaks.length} leaked temp director${leaks.length === 1 ? "y" : "ies"}:`);
|
||||||
|
for (const leak of leaks) console.error(` ${join(tmpdir(), leak.name)}`);
|
||||||
|
console.error("");
|
||||||
}
|
}
|
||||||
console.error("");
|
|
||||||
console.error("Tests must clean up their temp directories. Use helpers from");
|
if (protectedViolations.length > 0) {
|
||||||
console.error(" packages/core/src/__test-utils__/workspace.ts (@fusion/test-utils)");
|
console.error("[test-isolation] FAIL: protected live .fusion data changed during tests:");
|
||||||
console.error(" - tempWorkspace(prefix) — auto-cleaned in afterEach");
|
for (const dir of protectedViolations) console.error(` ${dir}`);
|
||||||
console.error(" - useIsolatedCwd(prefix) — auto-cleaned + cwd restored");
|
console.error("Tests must use temp HOME / temp workspaces and never write repo or user .fusion data.");
|
||||||
|
}
|
||||||
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { ensureTestArtifacts } from "./ensure-test-artifacts.mjs";
|
import { ensureTestArtifacts } from "./ensure-test-artifacts.mjs";
|
||||||
|
|
||||||
|
const currentFilePath = fileURLToPath(import.meta.url);
|
||||||
|
const scriptDir = path.dirname(currentFilePath);
|
||||||
|
const checkIsolationScript = path.join(scriptDir, "check-test-isolation.mjs");
|
||||||
|
|
||||||
const rootDir = process.env.FUSION_PROJECT_DIR
|
const rootDir = process.env.FUSION_PROJECT_DIR
|
||||||
? path.resolve(process.env.FUSION_PROJECT_DIR)
|
? path.resolve(process.env.FUSION_PROJECT_DIR)
|
||||||
: process.cwd();
|
: process.cwd();
|
||||||
@@ -32,6 +36,26 @@ function run(command, commandArgs, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runIsolationCheck(before = false) {
|
||||||
|
const args = [checkIsolationScript];
|
||||||
|
if (before) args.push("--before");
|
||||||
|
run(process.execPath, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRunIsolationGuard(env = process.env) {
|
||||||
|
return env.FUSION_TEST_DISABLE_ISOLATION_GUARD !== "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMaybeIsolated(command, commandArgs, options = {}) {
|
||||||
|
const enabled = shouldRunIsolationGuard();
|
||||||
|
if (enabled) runIsolationCheck(true);
|
||||||
|
try {
|
||||||
|
run(command, commandArgs, options);
|
||||||
|
} finally {
|
||||||
|
if (enabled) runIsolationCheck(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function gitOutput(gitArgs) {
|
function gitOutput(gitArgs) {
|
||||||
const result = spawnSync("git", gitArgs, {
|
const result = spawnSync("git", gitArgs, {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
@@ -414,7 +438,7 @@ const fullSuiteEnv = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function runFullSuite(forwardedArgs) {
|
function runFullSuite(forwardedArgs) {
|
||||||
run("pnpm", [`-r`, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
|
runMaybeIsolated("pnpm", [`-r`, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decideExecutionPlan({
|
export function decideExecutionPlan({
|
||||||
@@ -495,6 +519,10 @@ export function main(argv = process.argv.slice(2)) {
|
|||||||
console.log(
|
console.log(
|
||||||
`[test-changed] all changed packages are cache-fresh (${cachedPackages.join(", ")}); nothing to run.`,
|
`[test-changed] all changed packages are cache-fresh (${cachedPackages.join(", ")}); nothing to run.`,
|
||||||
);
|
);
|
||||||
|
if (shouldRunIsolationGuard()) {
|
||||||
|
runIsolationCheck(true);
|
||||||
|
runIsolationCheck(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,13 +532,12 @@ export function main(argv = process.argv.slice(2)) {
|
|||||||
console.log(`[test-changed] skipping cached packages: ${cachedPackages.join(", ")}`);
|
console.log(`[test-changed] skipping cached packages: ${cachedPackages.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
run("pnpm", [...filterArgs, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
|
runMaybeIsolated("pnpm", [...filterArgs, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
|
||||||
|
|
||||||
// Tests passed — record in cache (never cache failures; process.exit on failure above).
|
// Tests passed — record in cache (never cache failures; process.exit on failure above).
|
||||||
recordCachePass(activePackages, packageDirByName, { noCache });
|
recordCachePass(activePackages, packageDirByName, { noCache });
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentFilePath = fileURLToPath(import.meta.url);
|
|
||||||
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
|
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
|
||||||
main();
|
main();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user