merge main: resolve db.test.ts import conflict
Combined both branches: TaskStore import from main + rmSync import from our branch (needed for fresh DB test cleanup). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
9
.changeset/merger-allowlist-staging.md
Normal file
9
.changeset/merger-allowlist-staging.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@fusion/engine": patch
|
||||
---
|
||||
|
||||
Restrict merger staging to squash + fix-agent files; refuse to commit unrelated working-tree changes
|
||||
|
||||
Replaces the blanket `git add -A` in `commitOrAmendMergeWithFixes` with an explicit allowlist: only files that were squash-staged or explicitly modified by the in-merge verification fix agent are staged. Any other dirty files in the working tree are left untouched and a warning is logged naming each excluded path. Fixes a production bug where ~13 unrelated user-edited files were bundled into a task's squash commit.
|
||||
|
||||
Hardened by code review: replaced all shell-interpolated `git add` calls in `commitOrAmendMergeWithFixes` and the conflict-resolution helpers (`resolveWithOurs`, `resolveWithTheirs`, `resolveTrivialWhitespace`) with `execFile` array form to eliminate path-injection surface; adopted `git -z` NUL-delimited output for all dirty-file path queries in both `snapshotDirtyFiles` and `commitOrAmendMergeWithFixes` so paths with embedded spaces round-trip correctly; truncated long allowlist debug log lines to at most 20 entries.
|
||||
5
.changeset/settings-modal-mobile-full-height.md
Normal file
5
.changeset/settings-modal-mobile-full-height.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Make the settings modal fill the viewport on mobile and align section headings with form-group gutters for consistent spacing across each settings page.
|
||||
7
.changeset/test-cache-content-hash.md
Normal file
7
.changeset/test-cache-content-hash.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Cache per-package test results by content hash to skip unchanged packages across sequential merges.
|
||||
|
||||
`scripts/test-changed.mjs` now maintains a per-project cache at `.fusion/test-cache.json`. For each package in a changed-mode run, a SHA-256 is computed from the git blob SHAs of every tracked file in the package directory plus `pnpm-lock.yaml` and `tsconfig.base.json`. If the hash matches a cache entry younger than 7 days the package is excluded from the `pnpm --filter` invocation and tests are skipped. After a successful run the passing hashes are written atomically. Cache lookups are bypassed when `FUSION_TEST_NO_CACHE=1` or `--no-cache` is passed, and never applied to full-suite runs. A new `FUSION_TEST_WORKSPACE_CONCURRENCY` env var controls `--workspace-concurrency` (default `2`).
|
||||
5
.changeset/verification-cache-and-concurrency.md
Normal file
5
.changeset/verification-cache-and-concurrency.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Cache merge verification by tree hash and boost test concurrency for in-review verification.
|
||||
@@ -169,13 +169,18 @@ Concrete references:
|
||||
|
||||
### Research Runs
|
||||
|
||||
- `ResearchStore` (`research-store.ts`, `research-types.ts`, `research-settings.ts`) persists bounded research runs, sources/events, and exports
|
||||
- Backed by `research_runs`, `research_exports`, and `research_run_events`
|
||||
- Engine orchestration is implemented in `packages/engine/src/research-orchestrator.ts` + `research-step-runner.ts`
|
||||
- Dashboard/API surface is implemented under `/api/research` (`packages/dashboard/src/research-routes.ts`) with `ResearchView.tsx` in the app
|
||||
- CLI surface is implemented in `packages/cli/src/commands/research.ts` with six subcommands (create, list, show, export, cancel, retry)
|
||||
- Agent tool surface is exposed via `packages/cli/src/extension.ts` (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`)
|
||||
- **Boundary note:** research and insights are parallel subsystems sharing host infrastructure, not one table/store family
|
||||
- `ResearchStore` (`research-store.ts`, `research-types.ts`, `research-settings.ts`) persists bounded research runs, sources/events, exports, lifecycle metadata, and retry/cancel state transitions.
|
||||
- Backed by `research_runs`, `research_exports`, and `research_run_events`.
|
||||
- Engine orchestration is implemented in `packages/engine/src/research-orchestrator.ts` + `research-step-runner.ts`.
|
||||
- Dashboard/API surface is implemented under `/api/research` (`packages/dashboard/src/research-routes.ts`) with `ResearchView.tsx` in the app.
|
||||
- CLI surface is implemented in `packages/cli/src/commands/research.ts` with six subcommands (create, list, show, export, cancel, retry).
|
||||
- Agent tool surface is exposed via `packages/cli/src/extension.ts` (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`).
|
||||
- **Boundary contract (FN-3292):**
|
||||
- `ResearchStore` owns persistence and lifecycle writes (status transitions, lifecycle event log rows, sources/results snapshots).
|
||||
- `ResearchStepRunner` owns provider I/O concerns only (provider selection, timeout/abort/provider-error classification, synthesis call execution); it does not read/write run state.
|
||||
- `ResearchOrchestrator` owns sequencing and failure policy (phase progression, provider fallback, partial-step continuation, terminal status choice) and interacts with store only through public store methods.
|
||||
- Provider substitution must remain data-driven: source metadata can carry provider identity, and fetching should resolve providers per source rather than relying on provider ordering.
|
||||
- **Boundary note:** research and insights are parallel subsystems sharing host infrastructure, not one table/store family.
|
||||
|
||||
### Plugin System
|
||||
|
||||
|
||||
@@ -147,7 +147,19 @@ Key endpoints:
|
||||
4. **Export surface asymmetry**
|
||||
- Route export endpoint advertises markdown/json/html behavior while core export type includes `pdf`; CLI command accepts `pdf` format but markdown renderer fallback behavior should remain explicitly documented/validated.
|
||||
|
||||
## 9) Validation references used for this baseline
|
||||
## 9) FN-3292 boundary stress-test confirmations
|
||||
|
||||
- Provider ordering assumptions were tightened: content fetch selection can now use source-level provider metadata (`providerType`) and falls back only when that provider is unavailable.
|
||||
- Orchestrator/provider seam was validated with behavior-first tests:
|
||||
- fallback from failed primary provider to a later provider,
|
||||
- partial fetch failure with successful completion when at least one source is fetched,
|
||||
- real `ResearchStore` persistence verification at orchestrator level (sources/events/results/lifecycle events persisted end-to-end).
|
||||
- Boundary guidance for future work:
|
||||
- keep retryability/lifecycle ownership in `ResearchStore` transitions,
|
||||
- keep error classification in `ResearchStepRunner`,
|
||||
- keep sequencing and policy decisions in `ResearchOrchestrator`.
|
||||
|
||||
## 10) Validation references used for this baseline
|
||||
|
||||
- `packages/dashboard/src/__tests__/research-routes.test.ts`
|
||||
- `packages/core/src/__tests__/research-store.test.ts`
|
||||
|
||||
@@ -4,6 +4,15 @@ _Date: 2026-04-08_
|
||||
|
||||
## 1) Executive Summary
|
||||
|
||||
### FN-3293 stabilization update (2026-05-04)
|
||||
|
||||
- Replaced ad-hoc frame sleeps in `packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx` with deterministic `vi.waitFor`-based frame assertions and microtask flush helpers.
|
||||
- Updated settings remote-action handling so `C/V/X/P/L/U/K/R` shortcuts are exercised deterministically in tests without timing races on pane focus transitions.
|
||||
- Removed the blanket `{ timeout: 90_000 }` suite override in `packages/droid-cli/src/__tests__/provider.test.ts`; lifecycle coverage now relies on fake timers and event-driven completion.
|
||||
- Tightened dashboard GitHub route tests (`packages/dashboard/src/__tests__/routes-github.test.ts`) by reducing synthetic retry delays and removing explicit long per-test timeouts.
|
||||
- Confirmed `packages/core/src/__tests__/memory-backend.test.ts` remains fast while still asserting `installQmd()`/`ensureQmdInstalled()` forward the intended `timeout: 120_000` to `execFileAsync`.
|
||||
- Restored deterministic CLI bundle-output verification by resolving the droid-runtime probe export path to source entries in workspace tests, eliminating build-order flake from missing `dist/probe.js`.
|
||||
|
||||
**Overall test health: _Good (with targeted high-risk gaps)_**
|
||||
|
||||
- Total executed tests across audited packages (`core`, `engine`, `cli`, `dashboard`): **8,188 passing**
|
||||
@@ -153,10 +162,10 @@ Interpretation:
|
||||
|
||||
Notable timer/retry/race hotspots:
|
||||
|
||||
- **Known flaky path (confirmed):** `packages/dashboard/src/routes.test.ts:3992-4016` (429 retry path with explicit 30s timeout).
|
||||
- `packages/engine/src/stuck-task-detector.test.ts` (heavy timer simulation)
|
||||
- `packages/engine/src/agent-heartbeat.test.ts` (timer-heavy heartbeat behavior)
|
||||
- `packages/cli/src/commands/dashboard.test.ts` (many timeout-driven behavioral checks)
|
||||
- **Resolved in FN-3293:** `packages/dashboard/src/__tests__/routes-github.test.ts` batch-import 429/diff-path coverage now runs with deterministic mocked throttling and no explicit per-test 10s/30s timeout overrides.
|
||||
|
||||
Observed during test runs:
|
||||
- recurring `MaxListenersExceededWarning` in CLI dashboard test runs
|
||||
@@ -220,7 +229,7 @@ One prelisted item is no longer untested:
|
||||
From `.fusion/memory/MEMORY.md` testing pitfalls:
|
||||
|
||||
- **Engine pool setting must remain threads** — config currently reflects this (`packages/engine/vitest.config.ts:10`), but there is no dedicated regression test guarding accidental config drift.
|
||||
- **Dashboard 429 retry test requires 30s timeout** — still covered (`packages/dashboard/src/routes.test.ts:3992-4016`).
|
||||
- **FN-3293 update:** dashboard GitHub batch-import retry/diff-path tests no longer rely on explicit long timeout gates; deterministic mocked throttling + reduced delay parameters keep default-lane coverage fast.
|
||||
- **Build-before-test/workspace hydration pitfalls** — operationally reproducible; not represented by explicit dedicated regression tests as standalone safeguards.
|
||||
|
||||
---
|
||||
|
||||
@@ -44,9 +44,9 @@ await import("@runfusion/fusion/dist/bin.js");
|
||||
// stderr isn't a TTY, or when FUSION_NO_UPDATE_CHECK=1 is set.
|
||||
function maybeAnnounceUpdateAndRefresh() {
|
||||
try {
|
||||
if (process.env.FUSION_NO_UPDATE_CHECK === "1") return;
|
||||
if (process.env.CI) return;
|
||||
if (!process.stderr.isTTY) return;
|
||||
if (globalThis.process.env.FUSION_NO_UPDATE_CHECK === "1") return;
|
||||
if (globalThis.process.env.CI) return;
|
||||
if (!globalThis.process.stderr.isTTY) return;
|
||||
|
||||
const fusionDir = resolveFusionDir();
|
||||
const cachePath = join(fusionDir, "update-check.json");
|
||||
@@ -65,7 +65,7 @@ function maybeAnnounceUpdateAndRefresh() {
|
||||
) {
|
||||
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
||||
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
||||
process.stderr.write(
|
||||
globalThis.process.stderr.write(
|
||||
yellow(
|
||||
`\nFusion ${cache.latestVersion} is available (you have ${currentVersion}).\n`,
|
||||
) +
|
||||
@@ -88,7 +88,7 @@ function maybeAnnounceUpdateAndRefresh() {
|
||||
}
|
||||
|
||||
function resolveFusionDir() {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
||||
const home = globalThis.process.env.HOME || globalThis.process.env.USERPROFILE || homedir();
|
||||
const preferred = join(home, ".fusion");
|
||||
if (existsSync(preferred)) return preferred;
|
||||
const legacy = join(home, ".pi", "fusion");
|
||||
@@ -108,10 +108,10 @@ function readBundledFusionVersion() {
|
||||
}
|
||||
|
||||
async function backgroundRefresh(fusionDir, cachePath, currentVersion) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||
const controller = new globalThis.AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), 1500);
|
||||
try {
|
||||
const response = await fetch("https://registry.npmjs.org/@runfusion%2Ffusion", {
|
||||
const response = await globalThis.fetch("https://registry.npmjs.org/@runfusion%2Ffusion", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
@@ -131,7 +131,7 @@ async function backgroundRefresh(fusionDir, cachePath, currentVersion) {
|
||||
writeFileSync(cachePath, JSON.stringify(result, null, 2), "utf-8");
|
||||
} catch { /* best-effort */ }
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,12 +155,19 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
async function waitForFrameContains(lastFrame: () => string | undefined, text: string, timeoutMs = 3000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if ((lastFrame() ?? "").includes(text)) return;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
throw new Error(`Timed out waiting for frame to include: ${text}`);
|
||||
await vi.waitFor(() => {
|
||||
expect(lastFrame() ?? "").toContain(text);
|
||||
}, { timeout: timeoutMs });
|
||||
}
|
||||
|
||||
async function flushFrames() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function focusSettingsDetailPane(stdin: { write: (chunk: string) => void }, lastFrame: () => string | undefined) {
|
||||
stdin.write("\u001b[C");
|
||||
await waitForFrameContains(lastFrame, "[C/V/X/P/L/U/K/R] remote actions");
|
||||
}
|
||||
|
||||
function findTokenPosition(frame: string, token: string): { row: number; col: number } {
|
||||
@@ -218,7 +225,7 @@ describe("DashboardApp smoke", () => {
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("board");
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await waitForFrameContains(lastFrame, "alpha");
|
||||
const frame = lastFrame() ?? "";
|
||||
// Board shows the currently selected project; first project "alpha" is selected by default
|
||||
expect(frame).toContain("alpha");
|
||||
@@ -294,7 +301,7 @@ describe("Agents view", () => {
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("agents");
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await waitForFrameContains(lastFrame, "worker-1");
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("worker-1");
|
||||
expect(frame).toContain("worker-2");
|
||||
@@ -309,7 +316,7 @@ describe("Agents view", () => {
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("agents");
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await flushFrames();
|
||||
expect(lastFrame() ?? "").toContain("Agent Detail");
|
||||
unmount();
|
||||
});
|
||||
@@ -375,7 +382,7 @@ describe("Settings view", () => {
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("settings");
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await waitForFrameContains(lastFrame, "Max Concurrent");
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("Settings");
|
||||
expect(frame).toContain("Max Concurrent");
|
||||
@@ -393,7 +400,7 @@ describe("Settings view", () => {
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("settings");
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await waitForFrameContains(lastFrame, "Available Models");
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("Available Models");
|
||||
expect(frame).toContain("Claude 3.5 Sonnet");
|
||||
@@ -435,14 +442,12 @@ describe("Settings view", () => {
|
||||
controller.setInteractiveView("settings");
|
||||
|
||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||
await waitForFrameContains(lastFrame, "Remote");
|
||||
await waitForFrameContains(lastFrame, "Provider: cloudflare");
|
||||
expect(lastFrame() ?? "").toContain("cloudflare");
|
||||
|
||||
stdin.write("\u001B[C");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await focusSettingsDetailPane(stdin, lastFrame);
|
||||
stdin.write("C");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(activateProvider).toHaveBeenCalledWith("cloudflare");
|
||||
await vi.waitFor(() => expect(activateProvider).toHaveBeenCalledWith("cloudflare"));
|
||||
|
||||
stdin.write("V");
|
||||
await waitForFrameContains(lastFrame, "Remote tunnel starting");
|
||||
@@ -471,8 +476,7 @@ describe("Settings view", () => {
|
||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||
await waitForFrameContains(lastFrame, "──── Remote ────");
|
||||
|
||||
stdin.write("\u001B[C");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await focusSettingsDetailPane(stdin, lastFrame);
|
||||
stdin.write("L");
|
||||
await waitForFrameContains(lastFrame, "TTL ms:");
|
||||
stdin.write("\r");
|
||||
@@ -507,8 +511,8 @@ describe("Settings view", () => {
|
||||
controller.setInteractiveView("settings");
|
||||
|
||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||
stdin.write("\u001B[C");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await waitForFrameContains(lastFrame, "──── Remote ────");
|
||||
await focusSettingsDetailPane(stdin, lastFrame);
|
||||
stdin.write("P");
|
||||
await waitForFrameContains(lastFrame, "Persistent token: tok_****");
|
||||
expect(regeneratePersistentToken).toHaveBeenCalledTimes(1);
|
||||
@@ -530,12 +534,11 @@ describe("Settings view", () => {
|
||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||
await waitForFrameContains(lastFrame, "──── Remote ────");
|
||||
|
||||
stdin.write("\u001B[C");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await focusSettingsDetailPane(stdin, lastFrame);
|
||||
stdin.write("L");
|
||||
await waitForFrameContains(lastFrame, "TTL ms:");
|
||||
stdin.write("a");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await flushFrames();
|
||||
expect(controller.getSnapshot().interactiveView).toBe("settings");
|
||||
unmount();
|
||||
});
|
||||
@@ -557,8 +560,8 @@ describe("Settings view", () => {
|
||||
controller.setInteractiveView("settings");
|
||||
|
||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||
stdin.write("\u001B[C");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await waitForFrameContains(lastFrame, "──── Remote ────");
|
||||
await focusSettingsDetailPane(stdin, lastFrame);
|
||||
stdin.write("K");
|
||||
await waitForFrameContains(lastFrame, "▀▀▀ASCII-QR▀▀▀", 6000);
|
||||
unmount();
|
||||
@@ -580,7 +583,7 @@ describe("Board view", () => {
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("board");
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await waitForFrameContains(lastFrame, "TODO");
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("TODO");
|
||||
expect(frame).toContain("IN PROGRESS");
|
||||
@@ -599,7 +602,7 @@ describe("LogsPanel indicator", () => {
|
||||
// Select index 1 (middle entry)
|
||||
controller.setSelectedLogIndex(1);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await flushFrames();
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("▶");
|
||||
unmount();
|
||||
@@ -612,7 +615,7 @@ describe("LogsPanel indicator", () => {
|
||||
controller.log("only message", "test");
|
||||
controller.setSelectedLogIndex(0);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await flushFrames();
|
||||
const frame = lastFrame() ?? "";
|
||||
// The selected entry shows the arrow; it should appear at least once
|
||||
expect(frame).toContain("▶");
|
||||
@@ -634,7 +637,7 @@ describe("StatusModeGrid layout stability", () => {
|
||||
controller.log("small", "worker");
|
||||
controller.log("ok", "db");
|
||||
rendered.rerender(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await flushFrames();
|
||||
|
||||
const shortFrame = rendered.lastFrame() ?? "";
|
||||
const shortSystem = findTokenPosition(shortFrame, "System");
|
||||
@@ -654,7 +657,7 @@ describe("StatusModeGrid layout stability", () => {
|
||||
"super-verbose-component-prefix",
|
||||
);
|
||||
rendered.rerender(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await flushFrames();
|
||||
|
||||
const longFrame = rendered.lastFrame() ?? "";
|
||||
const longSystem = findTokenPosition(longFrame, "System");
|
||||
@@ -693,7 +696,7 @@ describe("StatsPanel memory row", () => {
|
||||
const rendered = render(renderDashboardAppNode(controller));
|
||||
setTerminalSize(rendered, 120, 24);
|
||||
rendered.rerender(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await flushFrames();
|
||||
|
||||
const frame = rendered.lastFrame() ?? "";
|
||||
const pctIndex = frame.indexOf("75.0%");
|
||||
@@ -718,7 +721,7 @@ describe("LogsPanel narrow formatting", () => {
|
||||
const rendered = render(renderDashboardAppNode(controller));
|
||||
setTerminalSize(rendered, 60, 24);
|
||||
rendered.rerender(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await flushFrames();
|
||||
const frame = rendered.lastFrame() ?? "";
|
||||
|
||||
expect(frame).toContain("narrow entry");
|
||||
@@ -739,7 +742,7 @@ describe("LogsPanel narrow formatting", () => {
|
||||
const narrowRender = render(renderDashboardAppNode(narrowController));
|
||||
setTerminalSize(narrowRender, 60, 24);
|
||||
narrowRender.rerender(renderDashboardAppNode(narrowController));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await flushFrames();
|
||||
const narrowFrame = narrowRender.lastFrame() ?? "";
|
||||
expect(narrowFrame).toContain("[very-…]");
|
||||
narrowRender.unmount();
|
||||
@@ -753,7 +756,7 @@ describe("LogsPanel narrow formatting", () => {
|
||||
const wideRender = render(renderDashboardAppNode(wideController));
|
||||
setTerminalSize(wideRender, 120, 24);
|
||||
wideRender.rerender(renderDashboardAppNode(wideController));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await flushFrames();
|
||||
const wideFrame = wideRender.lastFrame() ?? "";
|
||||
expect(wideFrame).toContain("[very-long-sco");
|
||||
expect(wideFrame).not.toContain("[very-…]");
|
||||
@@ -770,7 +773,7 @@ describe("LogsPanel narrow formatting", () => {
|
||||
const rendered = render(renderDashboardAppNode(controller));
|
||||
setTerminalSize(rendered, 120, 24);
|
||||
rendered.rerender(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await flushFrames();
|
||||
const frame = rendered.lastFrame() ?? "";
|
||||
|
||||
expect(frame).toContain("wide timestamp");
|
||||
|
||||
@@ -2375,19 +2375,7 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detailFocused) {
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((i) => Math.min(SETTING_DEFS.length - 1, i + 1));
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDef || !localSettings) return;
|
||||
const inputUpper = input.toUpperCase();
|
||||
|
||||
if (ttlInputMode) {
|
||||
if (key.escape) {
|
||||
@@ -2397,14 +2385,14 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
|
||||
return;
|
||||
}
|
||||
|
||||
const inputUpper = input.toUpperCase();
|
||||
|
||||
if (inputUpper === "R") {
|
||||
void refreshRemoteStatus();
|
||||
setStatusMsg("Remote status refreshed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localSettings) return;
|
||||
|
||||
if (data?.remote && inputUpper === "C") {
|
||||
const provider = localSettings.remoteActiveProvider;
|
||||
if (!provider) {
|
||||
@@ -2461,6 +2449,20 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detailFocused) {
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((i) => Math.min(SETTING_DEFS.length - 1, i + 1));
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDef) return;
|
||||
|
||||
if (selectedDef.type === "boolean" && input === " ") {
|
||||
const current = localSettings[selectedDef.key] as boolean;
|
||||
const updated = { ...localSettings, [selectedDef.key]: !current };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "../db.js";
|
||||
import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
|
||||
import { TaskStore } from "../store.js";
|
||||
import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -109,6 +110,8 @@ describe("Database", () => {
|
||||
expect(tableNames).toContain("roadmaps");
|
||||
expect(tableNames).toContain("roadmap_milestones");
|
||||
expect(tableNames).toContain("roadmap_features");
|
||||
// Verification cache (migration 61)
|
||||
expect(tableNames).toContain("verification_cache");
|
||||
});
|
||||
|
||||
it("creates all expected indexes", () => {
|
||||
@@ -152,10 +155,12 @@ describe("Database", () => {
|
||||
// Roadmap indexes
|
||||
expect(indexNames).toContain("idxRoadmapMilestonesRoadmapOrder");
|
||||
expect(indexNames).toContain("idxRoadmapFeaturesMilestoneOrder");
|
||||
// Verification cache index (migration 61)
|
||||
expect(indexNames).toContain("idxVerificationCacheRecordedAt");
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -178,7 +183,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -952,7 +957,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -977,11 +982,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1016,7 +1021,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1057,7 +1062,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1126,7 +1131,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1229,7 +1234,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1303,7 +1308,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1327,7 +1332,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -1431,7 +1436,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1900,7 +1905,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -1934,3 +1939,82 @@ describe("createDatabase factory", () => {
|
||||
db2.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ── TaskStore — verification cache methods ────────────────────────────────
|
||||
|
||||
describe("TaskStore — verification cache", () => {
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "kb-vc-test-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "kb-vc-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.close();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
await rm(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns null when no cache entry exists", () => {
|
||||
const hit = store.getVerificationCacheHit("abc1234", "pnpm test", "pnpm build");
|
||||
expect(hit).toBeNull();
|
||||
});
|
||||
|
||||
it("records a pass and retrieves it as a cache hit", () => {
|
||||
const treeSha = "deadbeef1234567890";
|
||||
store.recordVerificationCachePass(treeSha, "pnpm test", "pnpm build", "FN-001");
|
||||
|
||||
const hit = store.getVerificationCacheHit(treeSha, "pnpm test", "pnpm build");
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.taskId).toBe("FN-001");
|
||||
expect(new Date(hit!.recordedAt).toISOString()).toBe(hit!.recordedAt);
|
||||
});
|
||||
|
||||
it("returns null for a different tree sha", () => {
|
||||
store.recordVerificationCachePass("sha-a", "pnpm test", "", "FN-001");
|
||||
|
||||
const hit = store.getVerificationCacheHit("sha-b", "pnpm test", "");
|
||||
expect(hit).toBeNull();
|
||||
});
|
||||
|
||||
it("distinguishes entries by testCommand", () => {
|
||||
const treeSha = "aabbccdd";
|
||||
store.recordVerificationCachePass(treeSha, "pnpm test", "", "FN-001");
|
||||
|
||||
expect(store.getVerificationCacheHit(treeSha, "pnpm test", "")).not.toBeNull();
|
||||
expect(store.getVerificationCacheHit(treeSha, "vitest run", "")).toBeNull();
|
||||
});
|
||||
|
||||
it("distinguishes entries by buildCommand", () => {
|
||||
const treeSha = "11223344";
|
||||
store.recordVerificationCachePass(treeSha, "", "pnpm build", "FN-002");
|
||||
|
||||
expect(store.getVerificationCacheHit(treeSha, "", "pnpm build")).not.toBeNull();
|
||||
expect(store.getVerificationCacheHit(treeSha, "", "tsc --noEmit")).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes undefined to empty string for stable primary key", () => {
|
||||
const treeSha = "normtest";
|
||||
// Pass undefined-ish values (coerced via nullish fallback in impl)
|
||||
store.recordVerificationCachePass(treeSha, "", "", "FN-003");
|
||||
|
||||
const hit = store.getVerificationCacheHit(treeSha, "", "");
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.taskId).toBe("FN-003");
|
||||
});
|
||||
|
||||
it("overwrites an existing entry on re-record (INSERT OR REPLACE)", () => {
|
||||
const treeSha = "upserttest";
|
||||
store.recordVerificationCachePass(treeSha, "pnpm test", "", "FN-010");
|
||||
store.recordVerificationCachePass(treeSha, "pnpm test", "", "FN-020");
|
||||
|
||||
const hit = store.getVerificationCacheHit(treeSha, "pnpm test", "");
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit!.taskId).toBe("FN-020");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -869,7 +869,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(60);
|
||||
expect(db1.getSchemaVersion()).toBe(61);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -904,7 +904,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(60);
|
||||
expect(db3.getSchemaVersion()).toBe(61);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -935,12 +935,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(60);
|
||||
expect(db1.getSchemaVersion()).toBe(61);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(60);
|
||||
expect(db2.getSchemaVersion()).toBe(61);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
|
||||
@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 40 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(60);
|
||||
expect(db.getSchemaVersion()).toBe(61);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 60;
|
||||
const SCHEMA_VERSION = 61;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -2397,6 +2397,22 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 61) {
|
||||
this.applyMigration(61, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS verification_cache (
|
||||
treeSha TEXT NOT NULL,
|
||||
testCommand TEXT NOT NULL DEFAULT '',
|
||||
buildCommand TEXT NOT NULL DEFAULT '',
|
||||
recordedAt TEXT NOT NULL,
|
||||
taskId TEXT,
|
||||
PRIMARY KEY (treeSha, testCommand, buildCommand)
|
||||
)
|
||||
`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxVerificationCacheRecordedAt ON verification_cache(recordedAt)`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6534,6 +6534,60 @@ ${notificationsSection}`;
|
||||
return this.todoStore;
|
||||
}
|
||||
|
||||
// ── Verification Cache ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Look up a previously recorded verification cache pass for a given tree sha
|
||||
* and command pair. Returns null when no cached pass exists.
|
||||
*
|
||||
* @param treeSha - The git tree SHA of the merged commit.
|
||||
* @param testCommand - The test command string (normalized to empty string when absent).
|
||||
* @param buildCommand - The build command string (normalized to empty string when absent).
|
||||
*/
|
||||
getVerificationCacheHit(
|
||||
treeSha: string,
|
||||
testCommand: string,
|
||||
buildCommand: string,
|
||||
): { recordedAt: string; taskId: string | null } | null {
|
||||
const normalizedTest = testCommand ?? "";
|
||||
const normalizedBuild = buildCommand ?? "";
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT recordedAt, taskId FROM verification_cache
|
||||
WHERE treeSha = ? AND testCommand = ? AND buildCommand = ?`,
|
||||
)
|
||||
.get(treeSha, normalizedTest, normalizedBuild) as
|
||||
| { recordedAt: string; taskId: string | null }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful verification pass for the given tree sha and commands.
|
||||
* Uses INSERT OR REPLACE so a re-run of the same tree updates the timestamp.
|
||||
*
|
||||
* @param treeSha - The git tree SHA of the merged commit.
|
||||
* @param testCommand - The test command string (normalized to empty string when absent).
|
||||
* @param buildCommand - The build command string (normalized to empty string when absent).
|
||||
* @param taskId - The task ID that triggered the pass (for telemetry).
|
||||
*/
|
||||
recordVerificationCachePass(
|
||||
treeSha: string,
|
||||
testCommand: string,
|
||||
buildCommand: string,
|
||||
taskId: string,
|
||||
): void {
|
||||
const normalizedTest = testCommand ?? "";
|
||||
const normalizedBuild = buildCommand ?? "";
|
||||
const recordedAt = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO verification_cache (treeSha, testCommand, buildCommand, recordedAt, taskId)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(treeSha, normalizedTest, normalizedBuild, recordedAt, taskId);
|
||||
}
|
||||
|
||||
// ── Backward Compatibility (Multi-Project Support) ────────────────────────
|
||||
|
||||
}
|
||||
|
||||
@@ -96,6 +96,14 @@ describe("Agent CSS classes", () => {
|
||||
expect(hasClass(".agent-system-filter")).toBe(true);
|
||||
expect(hasClass(".agent-controls-actions")).toBe(true);
|
||||
expect(hasClass(".agent-global-controls")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-shell")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-controls")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-viewport")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-75")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-100")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-125")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-150")).toBe(true);
|
||||
expect(hasClass(".agent-board")).toBe(true);
|
||||
expect(hasClass(".agent-board-card")).toBe(true);
|
||||
expect(hasClass(".agent-board-card--idle")).toBe(true);
|
||||
@@ -177,6 +185,8 @@ describe("Agent CSS classes", () => {
|
||||
expect(orgChartSection).toContain("padding: var(--space-lg)");
|
||||
expect(orgChartSection).toContain("--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs))");
|
||||
expect(orgChartSection).toContain("min-height: var(--org-chart-node-width)");
|
||||
expect(orgChartSection).toContain("touch-action: pan-x pan-y");
|
||||
expect(orgChartSection).toContain("transform-origin: top left");
|
||||
expect(orgChartSection).toContain("border: 1px solid var(--border)");
|
||||
expect(orgChartSection).toContain("color: var(--text)");
|
||||
expect(orgChartSection).toContain("color: var(--text-muted)");
|
||||
|
||||
@@ -790,6 +790,57 @@
|
||||
}
|
||||
|
||||
/* === FN-1167: Agent Org Chart + Chain of Command === */
|
||||
.agent-org-chart-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-org-chart-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-org-chart-controls__zoom-label {
|
||||
min-width: calc(var(--space-2xl) * 2);
|
||||
font-family: var(--font-mono);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-org-chart-controls__fit-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.agent-org-chart-viewport {
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas {
|
||||
width: max-content;
|
||||
transform-origin: top left;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas--zoom-75 {
|
||||
transform: scale(0.75);
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas--zoom-100 {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas--zoom-125 {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas--zoom-150 {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.agent-org-chart {
|
||||
--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs));
|
||||
--org-chart-connector-gap: var(--space-xs);
|
||||
@@ -798,8 +849,6 @@
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-lg);
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
min-height: var(--org-chart-node-width);
|
||||
}
|
||||
|
||||
@@ -1007,6 +1056,25 @@
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
|
||||
}
|
||||
|
||||
.agent-org-chart-shell {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.agent-org-chart-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-org-chart-controls .btn-icon,
|
||||
.agent-org-chart-controls .agent-org-chart-controls__fit-btn {
|
||||
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||
}
|
||||
|
||||
.agent-org-chart-viewport {
|
||||
min-height: calc(var(--space-2xl) * 4);
|
||||
}
|
||||
|
||||
.agent-org-chart {
|
||||
--org-chart-node-width: calc(var(--space-2xl) * 5);
|
||||
padding: var(--space-sm);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./AgentsView.css";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react";
|
||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, ChevronUp, Filter, Upload, Network, SlidersHorizontal, Copy, Check } from "lucide-react";
|
||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, ChevronUp, Filter, Upload, Network, SlidersHorizontal, Copy, Check, ZoomIn, ZoomOut, Minimize2 } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||
|
||||
@@ -47,6 +47,7 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
|
||||
|
||||
const SKILL_PATH_LABEL_PATTERN = /(?:^|\/)skills\/([^/]+)\/SKILL\.md$/i;
|
||||
const ORG_CHART_ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
|
||||
|
||||
export function formatAgentSkillBadgeLabel(skillId: string): string {
|
||||
const trimmedSkillId = skillId.trim();
|
||||
@@ -267,6 +268,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||||
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
|
||||
const [isOverviewOpen, setIsOverviewOpen] = useState(false);
|
||||
const [orgChartZoomIndex, setOrgChartZoomIndex] = useState(1);
|
||||
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
||||
const { confirm } = useConfirm();
|
||||
const controlsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -706,6 +708,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
const handleAgentViewChange = useCallback((nextView: "list" | "board" | "org") => {
|
||||
setAgentView(nextView);
|
||||
if (nextView !== "org") {
|
||||
setOrgChartZoomIndex(1);
|
||||
}
|
||||
if (isMobileViewport && selectedAgentId) {
|
||||
handleCloseDetail();
|
||||
}
|
||||
@@ -714,6 +719,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆";
|
||||
const selectedAgent = selectedAgentId ? displayAgents.find((agent) => agent.id === selectedAgentId) ?? null : null;
|
||||
const orgChartZoom = ORG_CHART_ZOOM_LEVELS[orgChartZoomIndex];
|
||||
|
||||
/** Get skill badges from agent metadata */
|
||||
const getSkillBadges = (agent: Agent): string[] => {
|
||||
@@ -966,27 +972,68 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<span>Loading agents...</span>
|
||||
</div>
|
||||
) : agentView === "org" ? (
|
||||
<div className="agent-org-chart" data-testid="agent-org-chart">
|
||||
{isOrgTreeLoading ? (
|
||||
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>Loading org chart...</span>
|
||||
<div className="agent-org-chart-shell" data-testid="agent-org-chart-shell">
|
||||
{isMobileViewport ? (
|
||||
<div className="agent-org-chart-controls" data-testid="agent-org-chart-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon touch-target"
|
||||
onClick={() => setOrgChartZoomIndex((value) => Math.max(0, value - 1))}
|
||||
disabled={orgChartZoomIndex === 0}
|
||||
aria-label="Zoom out org chart"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
<span className="agent-org-chart-controls__zoom-label" aria-live="polite">{Math.round(orgChartZoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon touch-target"
|
||||
onClick={() => setOrgChartZoomIndex((value) => Math.min(ORG_CHART_ZOOM_LEVELS.length - 1, value + 1))}
|
||||
disabled={orgChartZoomIndex === ORG_CHART_ZOOM_LEVELS.length - 1}
|
||||
aria-label="Zoom in org chart"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn touch-target btn-sm agent-org-chart-controls__fit-btn"
|
||||
onClick={() => setOrgChartZoomIndex(1)}
|
||||
aria-label="Fit org chart"
|
||||
title="Fit org chart"
|
||||
>
|
||||
<Minimize2 size={16} />
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
) : displayOrgTree.length === 0 ? (
|
||||
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||
) : (
|
||||
displayOrgTree.map((node) => (
|
||||
<OrgChartNode
|
||||
key={node.agent.id}
|
||||
node={node}
|
||||
onSelect={openAgentDetail}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getSkillBadges={getSkillBadges}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
) : null}
|
||||
<div className="agent-org-chart-viewport" data-testid="agent-org-chart-viewport">
|
||||
<div className={`agent-org-chart-canvas agent-org-chart-canvas--zoom-${Math.round(orgChartZoom * 100)}`}>
|
||||
<div className="agent-org-chart" data-testid="agent-org-chart">
|
||||
{isOrgTreeLoading ? (
|
||||
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>Loading org chart...</span>
|
||||
</div>
|
||||
) : displayOrgTree.length === 0 ? (
|
||||
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||
) : (
|
||||
displayOrgTree.map((node) => (
|
||||
<OrgChartNode
|
||||
key={node.agent.id}
|
||||
node={node}
|
||||
onSelect={openAgentDetail}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getSkillBadges={getSkillBadges}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : agentView === "board" ? (
|
||||
<div className="agent-board">
|
||||
|
||||
@@ -92,7 +92,8 @@
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay.settings-modal-overlay,
|
||||
.modal-overlay:has(.settings-modal) {
|
||||
padding-top: 0;
|
||||
padding: 0;
|
||||
inset: 0;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
}
|
||||
@@ -102,16 +103,18 @@
|
||||
min-width: 0;
|
||||
max-width: 100vw;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
min-height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.modal.settings-modal[style*="--keyboard-overlap"] {
|
||||
height: var(--vv-height, 100dvh);
|
||||
min-height: var(--vv-height, 100dvh);
|
||||
max-height: var(--vv-height, 100dvh);
|
||||
transform: translateY(var(--vv-offset-top, 0px));
|
||||
will-change: transform;
|
||||
@@ -366,16 +369,22 @@
|
||||
.settings-section-heading {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: var(--space-lg) 0 var(--space-md);
|
||||
margin: 0;
|
||||
padding: var(--space-lg) var(--space-xl) var(--space-md);
|
||||
margin: 0 0 var(--space-md);
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
/* First heading inside the section drops top padding to remove a redundant
|
||||
gap stacked on top of the settings-content container's own top padding. */
|
||||
.settings-content > .settings-section-heading:first-child,
|
||||
.settings-modal-section > .settings-section-heading:first-child {
|
||||
padding-top: var(--space-md);
|
||||
}
|
||||
|
||||
/* Spacing modifier for settings-section-heading that need extra top margin */
|
||||
.settings-section-heading--spaced {
|
||||
margin-top: var(--space-xl);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.settings-section-description {
|
||||
@@ -1485,8 +1494,8 @@
|
||||
}
|
||||
|
||||
.settings-section-heading {
|
||||
padding: var(--space-lg) 0 var(--space-md);
|
||||
margin: 0;
|
||||
padding: var(--space-lg) var(--space-lg) var(--space-md);
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
|
||||
.settings-plugins-subsection-toggle {
|
||||
|
||||
@@ -1291,6 +1291,40 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows mobile zoom controls for org chart and keeps node selection working", async () => {
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
|
||||
const controls = await screen.findByTestId("agent-org-chart-controls");
|
||||
expect(controls).toBeTruthy();
|
||||
expect(screen.getByText("100%")).toBeTruthy();
|
||||
|
||||
const viewport = screen.getByTestId("agent-org-chart-viewport");
|
||||
expect(viewport).toBeTruthy();
|
||||
const canvas = container.querySelector(".agent-org-chart-canvas");
|
||||
expect(canvas?.className).toContain("agent-org-chart-canvas--zoom-100");
|
||||
|
||||
fireEvent.click(within(controls).getByTitle("Zoom in"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("125%")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-125");
|
||||
});
|
||||
|
||||
fireEvent.click(within(controls).getByTitle("Fit org chart"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("100%")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-100");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Director One"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows org chart empty state when API returns no nodes", async () => {
|
||||
mockFetchOrgTree.mockResolvedValue([]);
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
@@ -259,7 +259,10 @@ describe("agents-view mobile CSS", () => {
|
||||
expect(block).toContain("flex-wrap: wrap");
|
||||
});
|
||||
|
||||
it("defines mobile org chart sizing rules", () => {
|
||||
it("defines mobile org chart sizing and pan/zoom controls rules", () => {
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("display: flex");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("gap: var(--space-sm)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-viewport")).toContain("min-height: calc(var(--space-2xl) * 4)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("gap: var(--space-sm)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node-card")).toContain("padding: var(--space-sm)");
|
||||
|
||||
@@ -725,7 +725,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }),
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
@@ -776,7 +776,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
@@ -803,7 +803,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
@@ -872,7 +872,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }),
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
@@ -896,7 +896,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
@@ -915,7 +915,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
@@ -924,7 +924,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
expect(res.body.results[0].success).toBe(true);
|
||||
expect(res.body.results[0].taskId).toBeDefined();
|
||||
expect(throttledSpy).toHaveBeenCalledTimes(1);
|
||||
}, 10000); // Increase timeout for retry delay
|
||||
});
|
||||
|
||||
it("returns error after max retries exceeded on 429", async () => {
|
||||
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({
|
||||
@@ -969,7 +969,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 50 }),
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
@@ -1025,7 +1025,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/github/issues/batch-import",
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
|
||||
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
@@ -1954,7 +1954,7 @@ describe("GET /tasks/:id/diff", () => {
|
||||
});
|
||||
|
||||
describe("done tasks with commit SHA", () => {
|
||||
it("attempts git diff when commitSha is present", { timeout: 30_000 }, async () => {
|
||||
it("attempts git diff when commitSha is present", async () => {
|
||||
const gitRepo = getSharedGitTestRepo();
|
||||
const localStore = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue(gitRepo.repoDir),
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("provider registration (default export)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamViaCli", { timeout: 90_000 }, () => {
|
||||
describe("streamViaCli", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
|
||||
586
packages/engine/src/__tests__/merger-staging-allowlist.test.ts
Normal file
586
packages/engine/src/__tests__/merger-staging-allowlist.test.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* Integration tests for the merger staging allowlist (real git repos).
|
||||
*
|
||||
* These tests do NOT mock child_process — they run real git commands against
|
||||
* temporary repositories created in the OS temp directory. This verifies the
|
||||
* exact behavior of `snapshotDirtyFiles` and `commitOrAmendMergeWithFixes`
|
||||
* against a real git index without the indirection of exec mocks.
|
||||
*
|
||||
* Test inventory:
|
||||
* 1. snapshotDirtyFiles captures tracked-unstaged, staged, and untracked files
|
||||
* 2. Unrelated dirty file is excluded — not staged, warn emitted
|
||||
* 3. Fix-modified file is included — staged and committed
|
||||
* 4. File in squash + further edited by fix agent — staged once, no error
|
||||
* 5. Untracked file created by fix agent — staged and committed
|
||||
* 6. Untracked file pre-existing in working tree (user WIP) — NOT staged
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { snapshotDirtyFiles, commitOrAmendMergeWithFixes } from "../merger.js";
|
||||
import { mergerLog } from "../logger.js";
|
||||
import { DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git repo helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initialise a bare minimum git repo at `dir` with a single initial commit.
|
||||
* Returns the SHA of that commit (used as `preAttemptHeadSha`).
|
||||
*/
|
||||
function initRepo(dir: string): string {
|
||||
const git = (cmd: string) =>
|
||||
execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim();
|
||||
|
||||
git("git init");
|
||||
git('git config user.email "test@example.com"');
|
||||
git('git config user.name "Test"');
|
||||
git('git config commit.gpgsign false');
|
||||
|
||||
// Create an initial commit so HEAD exists
|
||||
writeFileSync(join(dir, "README.md"), "# repo\n");
|
||||
git("git add README.md");
|
||||
git('git commit -m "chore: initial commit"');
|
||||
|
||||
return git("git rev-parse HEAD");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a feature branch with one commit, then return to main and run
|
||||
* `git merge --squash <branch>` so that a squash is staged but not committed.
|
||||
* Returns the SHA of main's tip (which becomes `preAttemptHeadSha`).
|
||||
*/
|
||||
function squashBranch(dir: string, branchName: string, fileName: string, content: string): string {
|
||||
const git = (cmd: string) =>
|
||||
execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim();
|
||||
|
||||
git(`git checkout -b ${branchName}`);
|
||||
writeFileSync(join(dir, fileName), content);
|
||||
git(`git add ${fileName}`);
|
||||
git(`git commit -m "feat: add ${fileName}"`);
|
||||
git("git checkout main");
|
||||
|
||||
const preAttemptSha = git("git rev-parse HEAD");
|
||||
|
||||
git(`git merge --squash ${branchName}`);
|
||||
return preAttemptSha;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal stub settings / args used by commitOrAmendMergeWithFixes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STUB_SETTINGS = {
|
||||
...DEFAULT_SETTINGS,
|
||||
commitAuthorEnabled: false, // skip --author flag to avoid user config issues
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("snapshotDirtyFiles", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fn-snapshot-"));
|
||||
initRepo(dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns empty set when working tree is clean", async () => {
|
||||
const snapshot = await snapshotDirtyFiles(dir);
|
||||
expect(snapshot.size).toBe(0);
|
||||
});
|
||||
|
||||
it("captures tracked-unstaged modifications", async () => {
|
||||
writeFileSync(join(dir, "README.md"), "modified\n");
|
||||
const snapshot = await snapshotDirtyFiles(dir);
|
||||
expect(snapshot.has("README.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures staged (cached) modifications", async () => {
|
||||
writeFileSync(join(dir, "README.md"), "staged change\n");
|
||||
execSync("git add README.md", { cwd: dir, stdio: "pipe" });
|
||||
const snapshot = await snapshotDirtyFiles(dir);
|
||||
expect(snapshot.has("README.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures untracked files", async () => {
|
||||
writeFileSync(join(dir, "new-file.ts"), "export const x = 1;\n");
|
||||
const snapshot = await snapshotDirtyFiles(dir);
|
||||
expect(snapshot.has("new-file.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures all three categories simultaneously", async () => {
|
||||
// Tracked-unstaged
|
||||
writeFileSync(join(dir, "README.md"), "dirty\n");
|
||||
// Staged
|
||||
writeFileSync(join(dir, "staged.ts"), "const s = 1;\n");
|
||||
execSync("git add staged.ts", { cwd: dir, stdio: "pipe" });
|
||||
// Untracked
|
||||
writeFileSync(join(dir, "untracked.ts"), "const u = 2;\n");
|
||||
|
||||
const snapshot = await snapshotDirtyFiles(dir);
|
||||
expect(snapshot.has("README.md")).toBe(true);
|
||||
expect(snapshot.has("staged.ts")).toBe(true);
|
||||
expect(snapshot.has("untracked.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty set when rootDir is not a git repo (error swallowed)", async () => {
|
||||
const nonRepo = mkdtempSync(join(tmpdir(), "fn-non-repo-"));
|
||||
try {
|
||||
const snapshot = await snapshotDirtyFiles(nonRepo);
|
||||
expect(snapshot.size).toBe(0);
|
||||
} finally {
|
||||
rmSync(nonRepo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
|
||||
let dir: string;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fn-allowlist-"));
|
||||
initRepo(dir);
|
||||
warnSpy = vi.spyOn(mergerLog, "warn");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Scenario 1: Unrelated dirty file is excluded ───────────────────────
|
||||
|
||||
it("does not stage an unrelated dirty file and emits a warn", async () => {
|
||||
const preAttemptSha = squashBranch(dir, "feat/A", "feature-a.ts", "export const a = 1;\n");
|
||||
|
||||
// Simulate user's unrelated WIP: a modified tracked file
|
||||
writeFileSync(join(dir, "README.md"), "user WIP — should not be committed\n");
|
||||
|
||||
// fixModifiedFiles is empty — no fix agent ran
|
||||
const result = await commitOrAmendMergeWithFixes(
|
||||
dir,
|
||||
"FN-TEST",
|
||||
"feat/A",
|
||||
"- feat: add feature-a.ts",
|
||||
false,
|
||||
preAttemptSha,
|
||||
"", // no --author flag
|
||||
undefined,
|
||||
STUB_SETTINGS,
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
new Set<string>(), // empty fixModifiedFiles
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// The unrelated file must NOT appear in the commit
|
||||
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}).toString().trim().split("\n");
|
||||
expect(committedFiles).not.toContain("README.md");
|
||||
expect(committedFiles).toContain("feature-a.ts");
|
||||
|
||||
// Warn must have been emitted for the excluded file
|
||||
const warnMessages = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(warnMessages.some((m) => m.includes("README.md") && m.includes("refusing to stage"))).toBe(true);
|
||||
|
||||
// README.md must still be dirty in the working tree
|
||||
const status = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString().trim();
|
||||
expect(status).toContain("README.md");
|
||||
});
|
||||
|
||||
// ── Scenario 2: Fix-modified file is included ─────────────────────────
|
||||
|
||||
it("stages a file that the fix agent modified", async () => {
|
||||
const preAttemptSha = squashBranch(dir, "feat/B", "feature-b.ts", "export const b = 1;\n");
|
||||
|
||||
// Fix agent modified an additional file (tracked, unstaged)
|
||||
writeFileSync(join(dir, "README.md"), "fixed by agent\n");
|
||||
|
||||
const result = await commitOrAmendMergeWithFixes(
|
||||
dir,
|
||||
"FN-TEST",
|
||||
"feat/B",
|
||||
"- feat: add feature-b.ts",
|
||||
false,
|
||||
preAttemptSha,
|
||||
"",
|
||||
undefined,
|
||||
STUB_SETTINGS,
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
new Set(["README.md"]), // fix agent touched this
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}).toString().trim().split("\n");
|
||||
expect(committedFiles).toContain("feature-b.ts");
|
||||
expect(committedFiles).toContain("README.md");
|
||||
|
||||
// Working tree should be clean for README.md now
|
||||
const status = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString().trim();
|
||||
expect(status).not.toContain("README.md");
|
||||
});
|
||||
|
||||
// ── Scenario 3: Squash file further edited by fix agent ───────────────
|
||||
|
||||
it("stages squash file with additional fix-agent edits only once", async () => {
|
||||
const preAttemptSha = squashBranch(dir, "feat/C", "feature-c.ts", "export const c = 1;\n");
|
||||
|
||||
// Fix agent further edits the squash file (it's tracked-unstaged after squash staged it)
|
||||
writeFileSync(join(dir, "feature-c.ts"), "export const c = 2; // fixed\n");
|
||||
|
||||
const result = await commitOrAmendMergeWithFixes(
|
||||
dir,
|
||||
"FN-TEST",
|
||||
"feat/C",
|
||||
"- feat: add feature-c.ts",
|
||||
false,
|
||||
preAttemptSha,
|
||||
"",
|
||||
undefined,
|
||||
STUB_SETTINGS,
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
new Set(["feature-c.ts"]), // fix agent touched the same file the squash staged
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// The committed file must contain the fix agent's content, not the squash's
|
||||
const committedContent = execSync("git show HEAD:feature-c.ts", {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}).toString();
|
||||
expect(committedContent).toContain("// fixed");
|
||||
|
||||
// No double-staging error should have occurred (result is true)
|
||||
// Working tree should be clean
|
||||
const status = execSync("git status --porcelain", { cwd: dir, stdio: "pipe" }).toString().trim();
|
||||
expect(status).toBe("");
|
||||
});
|
||||
|
||||
// ── Scenario 4: Untracked file created by fix agent ───────────────────
|
||||
|
||||
it("stages an untracked file created by the fix agent", async () => {
|
||||
const preAttemptSha = squashBranch(dir, "feat/D", "feature-d.ts", "export const d = 1;\n");
|
||||
|
||||
// Fix agent created a brand-new file (untracked)
|
||||
writeFileSync(join(dir, "new-fixture.ts"), "export const fixture = {};\n");
|
||||
|
||||
const result = await commitOrAmendMergeWithFixes(
|
||||
dir,
|
||||
"FN-TEST",
|
||||
"feat/D",
|
||||
"- feat: add feature-d.ts",
|
||||
false,
|
||||
preAttemptSha,
|
||||
"",
|
||||
undefined,
|
||||
STUB_SETTINGS,
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
new Set(["new-fixture.ts"]), // fix agent created this file
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}).toString().trim().split("\n");
|
||||
expect(committedFiles).toContain("new-fixture.ts");
|
||||
expect(committedFiles).toContain("feature-d.ts");
|
||||
});
|
||||
|
||||
// ── Scenario 5: Pre-existing untracked user WIP file not staged ────────
|
||||
|
||||
it("does not stage a pre-existing untracked user WIP file", async () => {
|
||||
// Create untracked user WIP before squash (simulates pre-existing state)
|
||||
writeFileSync(join(dir, "user-wip.ts"), "// WIP — do not touch\n");
|
||||
|
||||
const preAttemptSha = squashBranch(dir, "feat/E", "feature-e.ts", "export const e = 1;\n");
|
||||
|
||||
// fixModifiedFiles does not include the user's WIP file
|
||||
const result = await commitOrAmendMergeWithFixes(
|
||||
dir,
|
||||
"FN-TEST",
|
||||
"feat/E",
|
||||
"- feat: add feature-e.ts",
|
||||
false,
|
||||
preAttemptSha,
|
||||
"",
|
||||
undefined,
|
||||
STUB_SETTINGS,
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
new Set<string>(), // empty — the WIP file is not fix-agent-produced
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}).toString().trim().split("\n");
|
||||
expect(committedFiles).not.toContain("user-wip.ts");
|
||||
expect(committedFiles).toContain("feature-e.ts");
|
||||
|
||||
// The WIP file must still be untracked in the working tree
|
||||
const porcelain = execSync("git status --porcelain", {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}).toString();
|
||||
expect(porcelain).toContain("user-wip.ts");
|
||||
|
||||
// Warn must have been emitted
|
||||
const warnMessages = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(warnMessages.some((m) => m.includes("user-wip.ts") && m.includes("refusing to stage"))).toBe(true);
|
||||
});
|
||||
|
||||
// ── Scenario 6: Mixed — fix file included, unrelated file excluded ─────
|
||||
|
||||
it("stages fix-agent file but excludes a second unrelated file in the same pass", async () => {
|
||||
// Commit unrelated.ts into main so it is a properly tracked file
|
||||
writeFileSync(join(dir, "unrelated.ts"), "// original\n");
|
||||
execSync("git add unrelated.ts", { cwd: dir, stdio: "pipe" });
|
||||
execSync('git commit -m "chore: add unrelated.ts"', { cwd: dir, stdio: "pipe" });
|
||||
|
||||
const preAttemptSha = squashBranch(dir, "feat/F", "feature-f.ts", "export const f = 1;\n");
|
||||
|
||||
// Fix agent modified one file
|
||||
writeFileSync(join(dir, "README.md"), "agent fix\n");
|
||||
// User modified the tracked (but unrelated) file in the working tree
|
||||
writeFileSync(join(dir, "unrelated.ts"), "// user WIP\n");
|
||||
|
||||
const result = await commitOrAmendMergeWithFixes(
|
||||
dir,
|
||||
"FN-TEST",
|
||||
"feat/F",
|
||||
"- feat: add feature-f.ts",
|
||||
false,
|
||||
preAttemptSha,
|
||||
"",
|
||||
undefined,
|
||||
STUB_SETTINGS,
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
new Set(["README.md"]), // only the agent's file is in the allowlist
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}).toString().trim().split("\n");
|
||||
expect(committedFiles).toContain("feature-f.ts");
|
||||
expect(committedFiles).toContain("README.md");
|
||||
expect(committedFiles).not.toContain("unrelated.ts");
|
||||
|
||||
// unrelated.ts must remain dirty
|
||||
const dirty = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString();
|
||||
expect(dirty).toContain("unrelated.ts");
|
||||
|
||||
const warnMessages = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(warnMessages.some((m) => m.includes("unrelated.ts") && m.includes("refusing to stage"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded-space path tests — verify NUL-delimited parsing handles spaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded-space path tests — verify NUL-delimited (-z) parsing handles spaces
|
||||
//
|
||||
// Note on untracked files in new subdirectories: git reports untracked entries
|
||||
// at the outermost untracked directory level (e.g. `?? dir with space/`),
|
||||
// not at the individual file level, when the directory itself is new. This is
|
||||
// standard git behaviour regardless of -z. For that reason the untracked tests
|
||||
// below use root-level files or files inside already-tracked directories,
|
||||
// which are the cases that actually round-trip through `snapshotDirtyFiles`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("snapshotDirtyFiles — paths with embedded spaces", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fn-snapshot-spaces-"));
|
||||
initRepo(dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("captures a root-level untracked file whose name contains spaces", async () => {
|
||||
// Root-level untracked files with spaces are reported verbatim by git (no quoting in -z mode).
|
||||
writeFileSync(join(dir, "my file with spaces.ts"), "export const x = 1;\n");
|
||||
|
||||
const snapshot = await snapshotDirtyFiles(dir);
|
||||
expect(snapshot.has("my file with spaces.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures a tracked-unstaged file in a subdirectory whose path contains spaces", async () => {
|
||||
// First commit the file so it is tracked (git diff reports full path including spaces).
|
||||
mkdirSync(join(dir, "src dir"), { recursive: true });
|
||||
writeFileSync(join(dir, "src dir", "my component.ts"), "export const v = 0;\n");
|
||||
execSync("git add .", { cwd: dir, stdio: "pipe" });
|
||||
execSync('git commit -m "chore: add spaced file"', { cwd: dir, stdio: "pipe" });
|
||||
|
||||
// Now modify it without staging — git diff -z --name-only emits the full path NUL-terminated.
|
||||
writeFileSync(join(dir, "src dir", "my component.ts"), "export const v = 1;\n");
|
||||
|
||||
const snapshot = await snapshotDirtyFiles(dir);
|
||||
expect(snapshot.has("src dir/my component.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("captures a staged (cached) file in a subdirectory whose path contains spaces", async () => {
|
||||
// Create the parent so it is already tracked, then add a new file.
|
||||
mkdirSync(join(dir, "path with spaces"), { recursive: true });
|
||||
writeFileSync(join(dir, "path with spaces", "keeper.ts"), "export {};\n");
|
||||
execSync("git add .", { cwd: dir, stdio: "pipe" });
|
||||
execSync('git commit -m "chore: track dir"', { cwd: dir, stdio: "pipe" });
|
||||
|
||||
// Now create a new file in the tracked dir and stage it.
|
||||
writeFileSync(join(dir, "path with spaces", "index.ts"), "export const i = 1;\n");
|
||||
execSync("git add .", { cwd: dir, stdio: "pipe" });
|
||||
|
||||
const snapshot = await snapshotDirtyFiles(dir);
|
||||
// git diff -z --cached --name-only reports staged files with their full path.
|
||||
expect(snapshot.has("path with spaces/index.ts")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitOrAmendMergeWithFixes — embedded-space paths round-trip", () => {
|
||||
let dir: string;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "fn-allowlist-spaces-"));
|
||||
initRepo(dir);
|
||||
warnSpy = vi.spyOn(mergerLog, "warn");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stages and commits a tracked file edited by the fix agent whose path contains spaces", async () => {
|
||||
// Pre-commit the spaced file so it is a tracked path.
|
||||
mkdirSync(join(dir, "src components"), { recursive: true });
|
||||
writeFileSync(join(dir, "src components", "my widget.ts"), "export const w = 0;\n");
|
||||
execSync("git add .", { cwd: dir, stdio: "pipe" });
|
||||
execSync('git commit -m "chore: add spaced component"', { cwd: dir, stdio: "pipe" });
|
||||
|
||||
const preAttemptSha = squashBranch(dir, "feat/G", "feature-g.ts", "export const g = 1;\n");
|
||||
|
||||
// Fix agent modifies the tracked spaced file (tracked-unstaged after squash).
|
||||
const spacedPath = "src components/my widget.ts";
|
||||
writeFileSync(join(dir, "src components", "my widget.ts"), "export const w = 1; // fixed\n");
|
||||
|
||||
const result = await commitOrAmendMergeWithFixes(
|
||||
dir,
|
||||
"FN-TEST",
|
||||
"feat/G",
|
||||
"- feat: add feature-g.ts",
|
||||
false,
|
||||
preAttemptSha,
|
||||
"",
|
||||
undefined,
|
||||
STUB_SETTINGS,
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
new Set([spacedPath]), // fix agent touched this tracked file
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify both the squash file and the spaced file were committed.
|
||||
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}).toString().trim().split("\n");
|
||||
expect(committedFiles).toContain("feature-g.ts");
|
||||
expect(committedFiles).toContain(spacedPath);
|
||||
|
||||
// Working tree must be clean for the spaced file.
|
||||
const dirty = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString();
|
||||
expect(dirty).not.toContain(spacedPath);
|
||||
});
|
||||
|
||||
it("excludes an unrelated tracked file with spaces and emits a warn", async () => {
|
||||
// Commit a tracked file with spaces so it appears in git diff (not git status -z untracked).
|
||||
const spacedUnrelated = "user notes/scratch.ts";
|
||||
mkdirSync(join(dir, "user notes"), { recursive: true });
|
||||
writeFileSync(join(dir, "user notes", "scratch.ts"), "// original\n");
|
||||
execSync("git add .", { cwd: dir, stdio: "pipe" });
|
||||
execSync('git commit -m "chore: add user notes"', { cwd: dir, stdio: "pipe" });
|
||||
|
||||
const preAttemptSha = squashBranch(dir, "feat/H", "feature-h.ts", "export const h = 1;\n");
|
||||
|
||||
// User edits their tracked spaced file — not in the allowlist.
|
||||
writeFileSync(join(dir, "user notes", "scratch.ts"), "// user WIP\n");
|
||||
|
||||
const result = await commitOrAmendMergeWithFixes(
|
||||
dir,
|
||||
"FN-TEST",
|
||||
"feat/H",
|
||||
"- feat: add feature-h.ts",
|
||||
false,
|
||||
preAttemptSha,
|
||||
"",
|
||||
undefined,
|
||||
STUB_SETTINGS,
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
new Set<string>(), // empty allowlist
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
}).toString().trim().split("\n");
|
||||
expect(committedFiles).toContain("feature-h.ts");
|
||||
expect(committedFiles).not.toContain(spacedUnrelated);
|
||||
|
||||
// The file must still be dirty in the working tree.
|
||||
const dirty = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString();
|
||||
expect(dirty).toContain(spacedUnrelated);
|
||||
|
||||
const warnMessages = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(
|
||||
warnMessages.some((m) => m.includes(spacedUnrelated) && m.includes("refusing to stage")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,38 @@ vi.mock("node:child_process", async () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
return { execSync: execSyncFn, exec: execFn, spawn: spawnFn };
|
||||
|
||||
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
|
||||
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
|
||||
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
|
||||
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? {} : opts;
|
||||
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
|
||||
try {
|
||||
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
@@ -157,6 +188,8 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
|
||||
getVerificationCacheHit: vi.fn().mockReturnValue(null),
|
||||
recordVerificationCachePass: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
@@ -1203,8 +1236,8 @@ describe("push-after-merge", () => {
|
||||
if (cmdStr.includes("git diff --name-only --diff-filter=U")) {
|
||||
return hasConflicts ? "pnpm-lock.yaml" as any : "" as any;
|
||||
}
|
||||
if (cmdStr.startsWith('git checkout --ours "pnpm-lock.yaml"')) return Buffer.from("");
|
||||
if (cmdStr.startsWith('git add "pnpm-lock.yaml"')) {
|
||||
if (cmdStr.includes("checkout --ours") && cmdStr.includes("pnpm-lock.yaml")) return Buffer.from("");
|
||||
if (cmdStr.includes("git add") && cmdStr.includes("pnpm-lock.yaml")) {
|
||||
hasConflicts = false;
|
||||
return Buffer.from("");
|
||||
}
|
||||
@@ -1233,7 +1266,7 @@ describe("push-after-merge", () => {
|
||||
|
||||
expect(result.pushed).toBe(true);
|
||||
expect(
|
||||
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith('git checkout --ours "pnpm-lock.yaml"')),
|
||||
mockedExecSync.mock.calls.some((call) => String(call[0]).includes("checkout --ours") && String(call[0]).includes("pnpm-lock.yaml")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("GIT_EDITOR=true git rebase --continue")),
|
||||
@@ -4249,6 +4282,178 @@ describe("aiMergeTask — deterministic merge verification", () => {
|
||||
);
|
||||
expect(verificationCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips test and build commands when a cache hit is found for the current tree sha", async () => {
|
||||
const treeSha = "cachedtreeshaabc1234567890";
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
// Return the fake tree sha when rev-parse HEAD^{tree} is called
|
||||
if (cmdStr.includes("HEAD^{tree}")) return Buffer.from(treeSha + "\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
// Simulate a cache hit for this tree sha
|
||||
const cacheHit = { recordedAt: "2026-05-01T00:00:00.000Z", taskId: "FN-049" };
|
||||
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(cacheHit);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
testCommand: "vitest run",
|
||||
buildCommand: "pnpm build",
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
// No actual test/build commands should have run
|
||||
const runCalls = mockedExecSync.mock.calls.filter(
|
||||
(call) => String(call[0]).includes("vitest run") || String(call[0]).includes("pnpm build"),
|
||||
);
|
||||
expect(runCalls).toHaveLength(0);
|
||||
|
||||
// The cache skip message should appear in the task log
|
||||
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const cacheMsg = logCalls.find((call: any[]) =>
|
||||
typeof call[1] === "string" && call[1].includes("Skipping deterministic verification — cached pass"),
|
||||
);
|
||||
expect(cacheMsg).toBeTruthy();
|
||||
expect(cacheMsg![1]).toContain(treeSha.slice(0, 7));
|
||||
expect(cacheMsg![1]).toContain("FN-049");
|
||||
|
||||
// getVerificationCacheHit should have been called with the tree sha and commands
|
||||
expect(store.getVerificationCacheHit).toHaveBeenCalledWith(treeSha, "vitest run", "pnpm build");
|
||||
});
|
||||
|
||||
it("runs commands and records a cache pass when no cache hit exists", async () => {
|
||||
const treeSha = "freshtreedead0000beef";
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("vitest run")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
if (cmdStr.includes("HEAD^{tree}")) return Buffer.from(treeSha + "\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
// No cache hit — returns null (default mock)
|
||||
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
testCommand: "vitest run",
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
// The test command should have been executed
|
||||
const testRuns = mockedExecSync.mock.calls.filter(
|
||||
(call) => String(call[0]).includes("vitest run"),
|
||||
);
|
||||
expect(testRuns.length).toBeGreaterThan(0);
|
||||
|
||||
// recordVerificationCachePass should have been called with the tree sha
|
||||
expect(store.recordVerificationCachePass).toHaveBeenCalledWith(
|
||||
treeSha, "vitest run", "", "FN-050",
|
||||
);
|
||||
});
|
||||
|
||||
it("gracefully skips cache lookup when git rev-parse HEAD^{tree} fails", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
|
||||
if (cmdStr.includes("git log")) return "- feat: something" as any;
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("vitest run")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
|
||||
if (cmdStr.includes("diff --cached")) return "0" as any;
|
||||
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
// Simulate git failure for tree sha resolution
|
||||
if (cmdStr.includes("HEAD^{tree}")) {
|
||||
const err = new Error("not a git repository") as any;
|
||||
err.status = 128;
|
||||
throw err;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
testCommand: "vitest run",
|
||||
});
|
||||
|
||||
// Should not throw — merge should complete normally
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
// Cache methods should never have been called
|
||||
expect(store.getVerificationCacheHit).not.toHaveBeenCalled();
|
||||
expect(store.recordVerificationCachePass).not.toHaveBeenCalled();
|
||||
|
||||
// The test command should still have run
|
||||
const testRuns = mockedExecSync.mock.calls.filter(
|
||||
(call) => String(call[0]).includes("vitest run"),
|
||||
);
|
||||
expect(testRuns.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSyncDependenciesForMerge", () => {
|
||||
@@ -6672,8 +6877,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
|
||||
name: "VerificationError",
|
||||
});
|
||||
|
||||
// Verify that fix agent was spawned (2 calls: merger + fix)
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||||
// Verify that fix agent was spawned (3 calls: summarizer + merger + fix)
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Verify the fix agent was called with correct options
|
||||
const fixAgentCall = mockedCreateFnAgent.mock.calls[1];
|
||||
@@ -6897,8 +7102,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
|
||||
name: "VerificationError",
|
||||
});
|
||||
|
||||
// Verify fix agent was NOT spawned (only merger)
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||||
// Verify fix agent was NOT spawned (summarizer + merger only)
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify no fix attempt was logged
|
||||
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
|
||||
@@ -7123,8 +7328,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
|
||||
name: "VerificationError",
|
||||
});
|
||||
|
||||
// Should have 3 fix attempts (capped at 3) + 1 merger = 4 calls
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
|
||||
// Should have 3 fix attempts (capped at 3) + summarizer + merger = 5 calls
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("default verificationFixRetries (omitted) results in 3 fix attempts", async () => {
|
||||
@@ -7174,8 +7379,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
|
||||
name: "VerificationError",
|
||||
});
|
||||
|
||||
// Should have 3 fix attempts (default) + 1 merger = 4 calls
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4);
|
||||
// Should have 3 fix attempts (default) + summarizer + merger = 5 calls
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(5);
|
||||
|
||||
// Verify the log shows 3 fix attempts (2 log entries per attempt: start + failure)
|
||||
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ResearchRun, ResearchSource } from "@fusion/core";
|
||||
import { createDatabase, type Database, ResearchStore, type ResearchRun, type ResearchSource } from "@fusion/core";
|
||||
import { ResearchOrchestrator } from "../research-orchestrator.js";
|
||||
|
||||
function createHarness() {
|
||||
@@ -165,6 +168,12 @@ describe("ResearchOrchestrator", () => {
|
||||
|
||||
const run = await orchestrator.startRun(runId, "fallback query");
|
||||
expect(run.status).toBe("completed");
|
||||
expect(stepRunner.runContentFetch).toHaveBeenCalledWith(
|
||||
"https://backup.com",
|
||||
"backup",
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(store.addEvent).toHaveBeenCalledWith(
|
||||
runId,
|
||||
expect.objectContaining({
|
||||
@@ -174,6 +183,43 @@ describe("ResearchOrchestrator", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("continues with partial fetched sources when one fetch step fails", async () => {
|
||||
const { store, stepRunner } = createHarness();
|
||||
stepRunner.runSourceQuery.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: [
|
||||
{ type: "web", reference: "https://a.example", status: "pending" },
|
||||
{ type: "web", reference: "https://b.example", status: "pending" },
|
||||
],
|
||||
} as never);
|
||||
stepRunner.runContentFetch
|
||||
.mockResolvedValueOnce({ ok: false, error: { code: "provider_error", message: "fetch failed", retryable: true } } as never)
|
||||
.mockResolvedValueOnce({ ok: true, data: { content: "good", metadata: {} } } as never);
|
||||
|
||||
const orchestrator = new ResearchOrchestrator({
|
||||
store: store as never,
|
||||
stepRunner: stepRunner as never,
|
||||
maxConcurrentRuns: 1,
|
||||
});
|
||||
|
||||
const runId = orchestrator.createRun({
|
||||
providers: [{ type: "web" }],
|
||||
maxSources: 2,
|
||||
maxSynthesisRounds: 1,
|
||||
});
|
||||
|
||||
const run = await orchestrator.startRun(runId, "partial fetch");
|
||||
expect(run.status).toBe("completed");
|
||||
expect(store.addEvent).toHaveBeenCalledWith(
|
||||
runId,
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
metadata: expect.objectContaining({ orchestrationEventType: "step-failed" }),
|
||||
}),
|
||||
);
|
||||
expect(store.setResults).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits step-failed for timeout-classified step errors", async () => {
|
||||
const { store, stepRunner } = createHarness();
|
||||
stepRunner.runSourceQuery
|
||||
@@ -237,6 +283,55 @@ describe("ResearchOrchestrator", () => {
|
||||
expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("persists provider-substitution lifecycle with real ResearchStore", async () => {
|
||||
const fusionDir = mkdtempSync(join(tmpdir(), "fn-research-orch-"));
|
||||
const db: Database = createDatabase(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
const store = new ResearchStore(db);
|
||||
|
||||
const stepRunner = {
|
||||
runSourceQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, error: { code: "provider_error", message: "primary down", retryable: true } })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: [{ type: "web", reference: "https://backup.example", status: "pending", metadata: { origin: "backup" } }],
|
||||
}),
|
||||
runContentFetch: vi.fn(async () => ({ ok: true, data: { content: "backup content", metadata: { fetchedBy: "backup" } } })),
|
||||
runSynthesis: vi.fn(async () => ({ ok: true, data: { output: "summary", citations: ["src-1"], confidence: 0.7 } })),
|
||||
};
|
||||
|
||||
const orchestrator = new ResearchOrchestrator({
|
||||
store,
|
||||
stepRunner,
|
||||
maxConcurrentRuns: 1,
|
||||
});
|
||||
|
||||
const runId = orchestrator.createRun({
|
||||
providers: [{ type: "primary" }, { type: "backup" }],
|
||||
maxSources: 2,
|
||||
maxSynthesisRounds: 1,
|
||||
});
|
||||
|
||||
const run = await orchestrator.startRun(runId, "provider substitution");
|
||||
expect(run.status).toBe("completed");
|
||||
|
||||
const persisted = store.getRun(runId)!;
|
||||
expect(persisted.sources).toHaveLength(1);
|
||||
expect(persisted.sources[0].metadata?.providerType).toBe("backup");
|
||||
expect(stepRunner.runContentFetch).toHaveBeenCalledWith(
|
||||
"https://backup.example",
|
||||
"backup",
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
const runEvents = store.listRunEvents(runId);
|
||||
expect(runEvents.some((event) => event.status === "completed")).toBe(true);
|
||||
expect(persisted.events.some((event) => event.metadata?.orchestrationEventType === "step-failed")).toBe(true);
|
||||
expect(persisted.results?.summary).toBe("summary");
|
||||
});
|
||||
|
||||
it("retries failed run with inherited config", () => {
|
||||
const { store } = createHarness();
|
||||
const orchestrator = new ResearchOrchestrator({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ResearchStepRunner } from "../research-step-runner.js";
|
||||
|
||||
describe("ResearchStepRunner", () => {
|
||||
@@ -77,6 +77,38 @@ describe("ResearchStepRunner", () => {
|
||||
expect(result.error?.code).toBe("provider_not_configured");
|
||||
});
|
||||
|
||||
it("prefers requested provider for content fetch and falls back when unavailable", async () => {
|
||||
const fetchPrimary = vi.fn(async () => ({ content: "primary", metadata: { provider: "primary" } }));
|
||||
const fetchFallback = vi.fn(async () => ({ content: "fallback", metadata: { provider: "fallback" } }));
|
||||
|
||||
const runner = new ResearchStepRunner({
|
||||
providers: [
|
||||
{
|
||||
type: "primary",
|
||||
isConfigured: () => true,
|
||||
search: async () => [],
|
||||
fetchContent: fetchPrimary,
|
||||
},
|
||||
{
|
||||
type: "fallback",
|
||||
isConfigured: () => true,
|
||||
search: async () => [],
|
||||
fetchContent: fetchFallback,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const requested = await runner.runContentFetch("https://example.com", "fallback");
|
||||
expect(requested.ok).toBe(true);
|
||||
expect(requested.data?.metadata.provider).toBe("fallback");
|
||||
|
||||
const missing = await runner.runContentFetch("https://example.com", "missing");
|
||||
expect(missing.ok).toBe(true);
|
||||
expect(missing.data?.metadata.provider).toBe("primary");
|
||||
expect(fetchPrimary).toHaveBeenCalledTimes(1);
|
||||
expect(fetchFallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns provider_not_configured for synthesis when no runner configured", async () => {
|
||||
const runner = new ResearchStepRunner();
|
||||
const result = await runner.runSynthesis({ query: "q", sources: [], round: 1 });
|
||||
|
||||
@@ -79,6 +79,35 @@ vi.mock("node:child_process", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// execFile(file, args, opts, cb) — assemble a command string and delegate to
|
||||
// execSyncFn so the same mock infrastructure covers execFile-based git calls.
|
||||
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? undefined : opts;
|
||||
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
|
||||
try {
|
||||
const out = execSyncFn(cmd, options);
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// spawn() is used by the merger's verification runner. Route it through the
|
||||
// same execSyncFn mock so a single mockedExecSync.mockImplementation controls
|
||||
// both git calls (execSync) and verification commands (spawn). Throwing from
|
||||
@@ -104,7 +133,7 @@ vi.mock("node:child_process", () => {
|
||||
return child;
|
||||
});
|
||||
|
||||
return { execSync: execSyncFn, exec: execFn, spawn: spawnFn };
|
||||
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
||||
});
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import { execSync, exec, execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
import {
|
||||
runVerificationCommand as runVerificationCommandShared,
|
||||
summarizeVerificationOutput,
|
||||
@@ -355,12 +356,86 @@ export function throwIfAborted(signal: AbortSignal | undefined, taskId: string):
|
||||
throw new MergeAbortedError(`Merge aborted for ${taskId}: engine shutdown requested`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the union of all dirty paths in `rootDir`:
|
||||
* - tracked files modified vs the index (`git diff --name-only`)
|
||||
* - staged but not yet committed (`git diff --cached --name-only`)
|
||||
* - untracked files (`git status --porcelain` lines starting with `??`)
|
||||
*
|
||||
* Errors are swallowed and an empty set is returned so callers are never
|
||||
* blocked by a failing porcelain query.
|
||||
*
|
||||
* All three git queries use NUL-delimited output (`-z`) so paths with
|
||||
* embedded spaces or special characters are parsed correctly without quoting.
|
||||
*/
|
||||
export async function snapshotDirtyFiles(rootDir: string): Promise<Set<string>> {
|
||||
const paths = new Set<string>();
|
||||
try {
|
||||
const [unstagedOut, stagedOut, porcelainOut] = await Promise.all([
|
||||
execFileAsync("git", ["diff", "-z", "--name-only"], { cwd: rootDir, encoding: "utf-8" }).then(
|
||||
(r) => r.stdout,
|
||||
() => "",
|
||||
),
|
||||
execFileAsync("git", ["diff", "-z", "--cached", "--name-only"], { cwd: rootDir, encoding: "utf-8" }).then(
|
||||
(r) => r.stdout,
|
||||
() => "",
|
||||
),
|
||||
execFileAsync("git", ["status", "-z", "--porcelain"], { cwd: rootDir, encoding: "utf-8" }).then(
|
||||
(r) => r.stdout,
|
||||
() => "",
|
||||
),
|
||||
]);
|
||||
|
||||
for (const entry of unstagedOut.split("\0")) {
|
||||
const p = entry.trim();
|
||||
if (p) paths.add(p);
|
||||
}
|
||||
for (const entry of stagedOut.split("\0")) {
|
||||
const p = entry.trim();
|
||||
if (p) paths.add(p);
|
||||
}
|
||||
// Untracked files: entries beginning with `?? ` (3-char prefix, no quoting in -z mode)
|
||||
for (const entry of porcelainOut.split("\0")) {
|
||||
if (!entry.startsWith("?? ")) continue;
|
||||
const p = entry.slice(3);
|
||||
if (p) paths.add(p);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — an empty snapshot is safe: the allowlist logic will simply
|
||||
// not add any fix-agent files, which is conservative.
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function rethrowIfMergeAborted(error: unknown): void {
|
||||
if (error instanceof Error && error.name === "MergeAbortedError") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run execSync and always return a trimmed UTF-8 string.
|
||||
* execSync may return a Buffer, string, or null depending on the encoding option;
|
||||
* this helper normalises all three cases.
|
||||
*/
|
||||
function execSyncText(command: string, options: Parameters<typeof execSync>[1]): string {
|
||||
const output = execSync(command, options);
|
||||
if (output == null) return "";
|
||||
if (typeof output === "string") return output.trim();
|
||||
return (output as Buffer).toString("utf-8").trim();
|
||||
}
|
||||
|
||||
/** Extra environment variables injected into verification child processes to boost concurrency. */
|
||||
const VERIFICATION_EXTRA_ENV: NodeJS.ProcessEnv = Object.fromEntries(
|
||||
(
|
||||
[
|
||||
["FUSION_TEST_TOTAL_WORKERS", "8"],
|
||||
["FUSION_TEST_CONCURRENCY", "4"],
|
||||
["FUSION_TEST_WORKSPACE_CONCURRENCY", "4"],
|
||||
] as [string, string][]
|
||||
).filter(([key]) => !(key in process.env)),
|
||||
);
|
||||
|
||||
async function runDeterministicVerification(
|
||||
store: TaskStore,
|
||||
rootDir: string,
|
||||
@@ -384,6 +459,41 @@ async function runDeterministicVerification(
|
||||
const hasTestCommand = !!normalizedTestCommand;
|
||||
const hasBuildCommand = !!normalizedBuildCommand;
|
||||
|
||||
// ── Tree-hash verification cache (Layer 1) ─────────────────────────────
|
||||
const effectiveTestCommand = normalizedTestCommand ?? "";
|
||||
const effectiveBuildCommand = normalizedBuildCommand ?? "";
|
||||
let treeSha: string | null = null;
|
||||
try {
|
||||
treeSha = execSync("git rev-parse HEAD^{tree}", { cwd: rootDir, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch (err) {
|
||||
mergerLog.warn(`${taskId}: could not resolve tree sha — skipping verification cache: ${String(err)}`);
|
||||
}
|
||||
|
||||
if (treeSha) {
|
||||
const cacheHit = store.getVerificationCacheHit(treeSha, effectiveTestCommand, effectiveBuildCommand);
|
||||
if (cacheHit) {
|
||||
const sha7 = treeSha.slice(0, 7);
|
||||
const msg = `Skipping deterministic verification — cached pass for tree ${sha7} (recorded at ${cacheHit.recordedAt}, by ${cacheHit.taskId ?? "unknown"})`;
|
||||
mergerLog.log(`${taskId}: ${msg}`);
|
||||
await store.logEntry(taskId, msg);
|
||||
await store.appendAgentLog(taskId, msg, "text", undefined, "merger");
|
||||
const syntheticResult: VerificationCommandResult = {
|
||||
command: "",
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
success: true,
|
||||
cached: true,
|
||||
};
|
||||
if (hasTestCommand) result.testResult = { ...syntheticResult, command: effectiveTestCommand };
|
||||
if (hasBuildCommand) result.buildResult = { ...syntheticResult, command: effectiveBuildCommand };
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// ── End cache lookup ───────────────────────────────────────────────────
|
||||
|
||||
// Build source indicator for logging
|
||||
const testSourceLabel = testSource === "inferred" ? " [inferred]" : "";
|
||||
const buildSourceLabel = buildSource === "inferred" ? " [inferred]" : "";
|
||||
@@ -461,6 +571,18 @@ async function runDeterministicVerification(
|
||||
mergerLog.log(`${taskId}: deterministic verification passed`);
|
||||
await store.logEntry(taskId, "Deterministic merge verification passed");
|
||||
await store.appendAgentLog(taskId, "Deterministic merge verification passed", "text", undefined, "merger");
|
||||
|
||||
// ── Record cache pass ──────────────────────────────────────────────────
|
||||
if (treeSha) {
|
||||
try {
|
||||
store.recordVerificationCachePass(treeSha, effectiveTestCommand, effectiveBuildCommand, taskId);
|
||||
mergerLog.log(`${taskId}: Recorded verification pass for tree ${treeSha.slice(0, 7)}`);
|
||||
await store.logEntry(taskId, `Recorded verification pass for tree ${treeSha.slice(0, 7)}`);
|
||||
} catch (err) {
|
||||
mergerLog.warn(`${taskId}: could not record verification cache pass: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -473,13 +595,19 @@ async function runVerificationCommand(
|
||||
signal?: AbortSignal,
|
||||
): Promise<VerificationCommandResult> {
|
||||
throwIfAborted(signal, taskId);
|
||||
return runVerificationCommandShared(store, rootDir, taskId, command, type, signal, mergerLog, "merger");
|
||||
return runVerificationCommandShared(store, rootDir, taskId, command, type, signal, mergerLog, "merger", VERIFICATION_EXTRA_ENV);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt an in-merge verification fix by spawning an AI agent on the main branch.
|
||||
* Returns true if verification passes after the fix, false otherwise.
|
||||
* Never throws — errors are caught and logged, and the function returns false.
|
||||
*
|
||||
* @param fixModifiedFiles - Mutable set that this function populates with every
|
||||
* path that changed during the fix agent's run (post-snapshot minus
|
||||
* pre-snapshot). The caller passes this set across all fix attempts so that
|
||||
* `commitOrAmendMergeWithFixes` can build an allowlist that covers every file
|
||||
* the fix agent touched, regardless of how many retries were needed.
|
||||
*/
|
||||
async function attemptInMergeVerificationFix(
|
||||
store: TaskStore,
|
||||
@@ -497,7 +625,11 @@ async function attemptInMergeVerificationFix(
|
||||
fixAttemptNumber?: number,
|
||||
_testCommand?: string,
|
||||
_buildCommand?: string,
|
||||
fixModifiedFiles?: Set<string>,
|
||||
): Promise<boolean> {
|
||||
// Snapshot the working tree before doing anything so the diff reflects only
|
||||
// what the fix agent touched, not pre-existing dirty state.
|
||||
const preFixSnapshot = await snapshotDirtyFiles(rootDir);
|
||||
try {
|
||||
mergerLog.log(`${taskId}: spawning in-merge verification fix agent`);
|
||||
|
||||
@@ -631,6 +763,17 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
|
||||
});
|
||||
await accumulateSessionTokenUsage(store, taskId, session);
|
||||
|
||||
// Compute which paths the fix agent introduced or modified, then
|
||||
// accumulate them into the caller's mutable set.
|
||||
const postFixSnapshot = await snapshotDirtyFiles(rootDir);
|
||||
if (fixModifiedFiles) {
|
||||
for (const p of postFixSnapshot) {
|
||||
if (!preFixSnapshot.has(p)) {
|
||||
fixModifiedFiles.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run deterministic verification command after the fix attempt.
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
@@ -660,6 +803,19 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
rethrowIfMergeAborted(err);
|
||||
// Even on failure, try to surface any paths the agent partially touched.
|
||||
if (fixModifiedFiles) {
|
||||
try {
|
||||
const postFixSnapshot = await snapshotDirtyFiles(rootDir);
|
||||
for (const p of postFixSnapshot) {
|
||||
if (!preFixSnapshot.has(p)) {
|
||||
fixModifiedFiles.add(p);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only
|
||||
}
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
mergerLog.warn(`${taskId}: in-merge fix agent error: ${errorMessage}`);
|
||||
await store.logEntry(taskId, "In-merge verification fix agent encountered an error", errorMessage);
|
||||
@@ -821,10 +977,16 @@ async function buildDeterministicMergeMessage(params: {
|
||||
* branch's actual step commits, so consumers of mergeDetails never see a
|
||||
* hallucinated body that talks about files that aren't in the diff.
|
||||
*
|
||||
* Only files that are part of the squash or that the fix agent explicitly
|
||||
* modified are staged. Any other dirty files in the working tree are left
|
||||
* untouched and a warning is emitted for each one.
|
||||
*
|
||||
* Returns true on a successful commit/amend. Never throws — errors are logged
|
||||
* and the function returns false (callers decide whether to abort the merge).
|
||||
*
|
||||
* @internal Exported for integration tests only — not part of the public API.
|
||||
*/
|
||||
async function commitOrAmendMergeWithFixes(
|
||||
export async function commitOrAmendMergeWithFixes(
|
||||
rootDir: string,
|
||||
taskId: string,
|
||||
branch: string,
|
||||
@@ -837,19 +999,80 @@ async function commitOrAmendMergeWithFixes(
|
||||
signal?: AbortSignal,
|
||||
aiSummary?: string | null,
|
||||
aiSubject?: string | null,
|
||||
fixModifiedFiles: ReadonlySet<string> = new Set(),
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Stage everything (squash state + verification fixes the agent left
|
||||
// unstaged). FN-2152 still applies: filter out any submodule gitlinks
|
||||
// before committing.
|
||||
const { stdout: unstagedFiles } = await execAsync("git diff --name-only", {
|
||||
// Build an allowlist of paths we are permitted to stage.
|
||||
// Allowlist = (already staged by squash) ∪ (unstaged ∩ fixModifiedFiles)
|
||||
// We also handle untracked files created by the fix agent.
|
||||
//
|
||||
// FN-2152 still applies: the submodule-gitlink filter below removes any
|
||||
// gitlinks that slip through (nested worktrees, etc.).
|
||||
|
||||
// 1. Read currently-staged files (squash produced these) for diagnostic logging.
|
||||
const { stdout: squashStagedOut } = await execAsync("git diff --cached --name-only", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (unstagedFiles.trim().length > 0) {
|
||||
await execAsync("git add -A", { cwd: rootDir });
|
||||
const squashStaged = new Set(squashStagedOut.split("\n").map((l) => l.trim()).filter(Boolean));
|
||||
|
||||
// 2. What is currently unstaged (tracked, modified-but-not-staged).
|
||||
const { stdout: unstagedOut } = await execAsync("git diff --name-only", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const unstaged = new Set(unstagedOut.split("\n").map((l) => l.trim()).filter(Boolean));
|
||||
|
||||
// 3. Untracked files created by the fix agent (NUL-delimited, no quoting needed).
|
||||
const { stdout: porcelainOut } = await execFileAsync("git", ["status", "-z", "--porcelain"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const untracked = new Set<string>();
|
||||
for (const entry of porcelainOut.split("\0")) {
|
||||
if (!entry.startsWith("?? ")) continue;
|
||||
const p = entry.slice(3);
|
||||
if (p) untracked.add(p);
|
||||
}
|
||||
|
||||
// 4. Stage each unstaged path that the fix agent touched (batched, no shell).
|
||||
const unstagedToStage: string[] = [];
|
||||
for (const p of unstaged) {
|
||||
if (fixModifiedFiles.has(p)) {
|
||||
unstagedToStage.push(p);
|
||||
} else {
|
||||
mergerLog.warn(
|
||||
`${taskId}: refusing to stage unrelated working-tree change: ${p} (not part of squash or in-merge fix)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (unstagedToStage.length > 0) {
|
||||
await execFileAsync("git", ["add", "--", ...unstagedToStage], { cwd: rootDir });
|
||||
}
|
||||
|
||||
// 5. Stage untracked files created by the fix agent (batched, no shell).
|
||||
const untrackedToStage: string[] = [];
|
||||
for (const p of untracked) {
|
||||
if (fixModifiedFiles.has(p)) {
|
||||
untrackedToStage.push(p);
|
||||
} else {
|
||||
mergerLog.warn(
|
||||
`${taskId}: refusing to stage unrelated working-tree change: ${p} (not part of squash or in-merge fix)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (untrackedToStage.length > 0) {
|
||||
await execFileAsync("git", ["add", "--", ...untrackedToStage], { cwd: rootDir });
|
||||
}
|
||||
|
||||
// Fix 3: cap long path lists to avoid unreadable single-line logs.
|
||||
const cap = (arr: string[], n = 20) =>
|
||||
arr.length <= n ? arr.join(", ") : `${arr.slice(0, n).join(", ")} ... (+${arr.length - n} more)`;
|
||||
|
||||
mergerLog.log(
|
||||
`${taskId}: staging allowlist — squash: [${cap([...squashStaged])}], fixModified: [${cap([...fixModifiedFiles])}]`,
|
||||
);
|
||||
|
||||
const { stdout: staged } = await execAsync("git diff --cached --raw", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
@@ -1286,8 +1509,8 @@ export async function classifyConflict(filePath: string, cwd: string): Promise<C
|
||||
*/
|
||||
export async function resolveWithOurs(filePath: string, cwd: string): Promise<void> {
|
||||
try {
|
||||
await execAsync(`git checkout --ours "${filePath}"`, { cwd });
|
||||
await execAsync(`git add "${filePath}"`, { cwd });
|
||||
await execFileAsync("git", ["checkout", "--ours", "--", filePath], { cwd });
|
||||
await execFileAsync("git", ["add", "--", filePath], { cwd });
|
||||
mergerLog.log(`Auto-resolved ${filePath} using --ours`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to auto-resolve ${filePath} with ours: ${error}`);
|
||||
@@ -1300,8 +1523,8 @@ export async function resolveWithOurs(filePath: string, cwd: string): Promise<vo
|
||||
*/
|
||||
export async function resolveWithTheirs(filePath: string, cwd: string): Promise<void> {
|
||||
try {
|
||||
await execAsync(`git checkout --theirs "${filePath}"`, { cwd });
|
||||
await execAsync(`git add "${filePath}"`, { cwd });
|
||||
await execFileAsync("git", ["checkout", "--theirs", "--", filePath], { cwd });
|
||||
await execFileAsync("git", ["add", "--", filePath], { cwd });
|
||||
mergerLog.log(`Auto-resolved ${filePath} using --theirs`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to auto-resolve ${filePath} with theirs: ${error}`);
|
||||
@@ -1314,7 +1537,7 @@ export async function resolveWithTheirs(filePath: string, cwd: string): Promise<
|
||||
*/
|
||||
export async function resolveTrivialWhitespace(filePath: string, cwd: string): Promise<void> {
|
||||
try {
|
||||
await execAsync(`git add "${filePath}"`, { cwd });
|
||||
await execFileAsync("git", ["add", "--", filePath], { cwd });
|
||||
mergerLog.log(`Auto-resolved ${filePath} (trivial whitespace)`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to auto-resolve ${filePath} trivial conflict: ${error}`);
|
||||
@@ -1887,7 +2110,7 @@ function parsePushRemoteTarget(rootDir: string, pushRemote?: string): { remote:
|
||||
|
||||
let branch = branchTokens.join(" ").trim();
|
||||
if (!branch) {
|
||||
branch = execSync("git symbolic-ref --short HEAD", {
|
||||
branch = execSyncText("git symbolic-ref --short HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
@@ -2331,7 +2554,7 @@ export async function aiMergeTask(
|
||||
result.error = `Branch '${branch}' not found — moving to done without merge`;
|
||||
// Best-effort: try to capture current HEAD commitSha even though branch is missing
|
||||
try {
|
||||
const commitSha = execSync("git rev-parse HEAD", {
|
||||
const commitSha = execSyncText("git rev-parse HEAD", {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
@@ -2360,12 +2583,12 @@ export async function aiMergeTask(
|
||||
// causing feature code to be committed to the wrong lineage.
|
||||
try {
|
||||
throwIfAborted(options.signal, taskId);
|
||||
const currentBranch = execSync("git symbolic-ref --short HEAD", {
|
||||
const currentBranch = execSyncText("git symbolic-ref --short HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
}).trim();
|
||||
const mainBranch = execSync("git rev-parse --abbrev-ref origin/HEAD", {
|
||||
const mainBranch = execSyncText("git rev-parse --abbrev-ref origin/HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
@@ -3063,6 +3286,9 @@ export async function aiMergeTask(
|
||||
|
||||
if (failedResult) {
|
||||
let fixSuccess = false;
|
||||
// Accumulate all paths the fix agent touches across retries so
|
||||
// commitOrAmendMergeWithFixes can build a precise allowlist.
|
||||
const verificationFixModifiedFiles = new Set<string>();
|
||||
for (let fixAttempt = 1; fixAttempt <= maxFixRetries; fixAttempt++) {
|
||||
const fixAttemptStartedAt = Date.now();
|
||||
mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
|
||||
@@ -3090,6 +3316,7 @@ export async function aiMergeTask(
|
||||
fixAttempt,
|
||||
effectiveTestCommand,
|
||||
effectiveBuildCommand,
|
||||
verificationFixModifiedFiles,
|
||||
);
|
||||
|
||||
const fixAttemptDurationMs = Date.now() - fixAttemptStartedAt;
|
||||
@@ -3135,6 +3362,7 @@ export async function aiMergeTask(
|
||||
options.signal,
|
||||
aiMergeSummary,
|
||||
aiMergeSubject,
|
||||
verificationFixModifiedFiles,
|
||||
);
|
||||
if (!finalized) {
|
||||
// Phantom-merge guard: refused to fabricate a commit. Reset
|
||||
@@ -3175,6 +3403,9 @@ export async function aiMergeTask(
|
||||
const fixType = effectiveBuildCommand ? "build" as const : "test" as const;
|
||||
|
||||
let fixSuccess = false;
|
||||
// Accumulate all paths the fix agent touches across retries so
|
||||
// commitOrAmendMergeWithFixes can build a precise allowlist.
|
||||
const buildFixModifiedFiles = new Set<string>();
|
||||
for (let fixAttempt = 1; fixAttempt <= maxFixRetries; fixAttempt++) {
|
||||
const fixAttemptStartedAt = Date.now();
|
||||
mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
|
||||
@@ -3202,6 +3433,7 @@ export async function aiMergeTask(
|
||||
fixAttempt,
|
||||
effectiveTestCommand,
|
||||
effectiveBuildCommand,
|
||||
buildFixModifiedFiles,
|
||||
);
|
||||
|
||||
const fixAttemptDurationMs = Date.now() - fixAttemptStartedAt;
|
||||
@@ -3242,6 +3474,7 @@ export async function aiMergeTask(
|
||||
options.signal,
|
||||
aiMergeSummary,
|
||||
aiMergeSubject,
|
||||
buildFixModifiedFiles,
|
||||
);
|
||||
if (!finalized) {
|
||||
// Phantom-merge guard: the verification fix passed but no
|
||||
@@ -3371,7 +3604,7 @@ export async function aiMergeTask(
|
||||
|
||||
// 5b. Collect merge details and store on task
|
||||
try {
|
||||
const commitSha = execSync("git rev-parse HEAD", {
|
||||
const commitSha = execSyncText("git rev-parse HEAD", {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
@@ -3644,7 +3877,7 @@ export async function aiMergeTask(
|
||||
async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promise<void> {
|
||||
let currentBranch: string;
|
||||
try {
|
||||
currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
currentBranch = execSyncText("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
@@ -3665,7 +3898,7 @@ async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promis
|
||||
let behind = 0;
|
||||
let ahead = 0;
|
||||
try {
|
||||
const counts = execSync(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, {
|
||||
const counts = execSyncText(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
@@ -3903,7 +4136,7 @@ async function executeMergeAttempt(
|
||||
// If only auto-resolvable conflicts (or all were resolved), commit directly
|
||||
if (complex.length === 0) {
|
||||
// All conflicts auto-resolved, commit with fallback message
|
||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||
const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
@@ -4025,7 +4258,7 @@ async function executeMergeAttempt(
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
|
||||
const conflictedOutput = execSyncText("git diff --name-only --diff-filter=U", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
@@ -4206,7 +4439,7 @@ async function attemptWithSideStrategy(
|
||||
});
|
||||
|
||||
// Check if there are still conflicts (some types can't be auto-resolved)
|
||||
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
|
||||
const conflictedOutput = execSyncText("git diff --name-only --diff-filter=U", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
@@ -4217,7 +4450,7 @@ async function attemptWithSideStrategy(
|
||||
}
|
||||
|
||||
// Check if there's anything staged
|
||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||
const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
@@ -4584,7 +4817,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
}
|
||||
|
||||
// Verify commit happened
|
||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||
const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
@@ -260,7 +260,13 @@ export class ResearchOrchestrator {
|
||||
}
|
||||
|
||||
for (const source of result.data.slice(0, Math.max(0, config.maxSources - allSources.length))) {
|
||||
const saved = this.store.addSource(runId, source);
|
||||
const saved = this.store.addSource(runId, {
|
||||
...source,
|
||||
metadata: {
|
||||
...(source.metadata ?? {}),
|
||||
providerType: provider.type,
|
||||
},
|
||||
});
|
||||
allSources.push(saved);
|
||||
this.store.addEvent(runId, {
|
||||
type: "source_added",
|
||||
@@ -298,7 +304,9 @@ export class ResearchOrchestrator {
|
||||
});
|
||||
this.stepStarted(runId, step);
|
||||
|
||||
const result = await this.stepRunner.runContentFetch(source.reference, provider?.config, signal);
|
||||
const sourceProvider = this.getSourceProviderType(source);
|
||||
const providerConfig = sourceProvider ? config.providers.find((p) => p.type === sourceProvider)?.config : provider?.config;
|
||||
const result = await this.stepRunner.runContentFetch(source.reference, sourceProvider, providerConfig, signal);
|
||||
if (!result.ok || !result.data) {
|
||||
this.stepFailed(runId, step.id, result.error?.message ?? "Failed to fetch source content", result.error);
|
||||
continue;
|
||||
@@ -547,6 +555,11 @@ export class ResearchOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
private getSourceProviderType(source: ResearchSource): string | undefined {
|
||||
const providerType = source.metadata?.providerType;
|
||||
return typeof providerType === "string" && providerType.length > 0 ? providerType : undefined;
|
||||
}
|
||||
|
||||
private canWriteRunData(runId: string): boolean {
|
||||
const run = this.store.getRun(runId);
|
||||
if (!run) return false;
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface ResearchStepRunnerApi {
|
||||
): Promise<ResearchStepResult<ResearchSource[]>>;
|
||||
runContentFetch(
|
||||
url: string,
|
||||
providerType?: string,
|
||||
config?: ResearchProviderConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>>;
|
||||
@@ -118,10 +119,11 @@ export class ResearchStepRunner implements ResearchStepRunnerApi {
|
||||
|
||||
async runContentFetch(
|
||||
url: string,
|
||||
providerType?: string,
|
||||
config: ResearchProviderConfig = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>> {
|
||||
const provider = this.findFirstConfiguredProvider();
|
||||
const provider = this.resolveContentProvider(providerType);
|
||||
if (!provider) {
|
||||
return this.unconfigured("no configured provider available for content fetch");
|
||||
}
|
||||
@@ -169,6 +171,14 @@ export class ResearchStepRunner implements ResearchStepRunnerApi {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private resolveContentProvider(providerType?: string): ResearchProvider | undefined {
|
||||
if (providerType) {
|
||||
const selected = this.providers.get(providerType);
|
||||
if (selected?.isConfigured()) return selected;
|
||||
}
|
||||
return this.findFirstConfiguredProvider();
|
||||
}
|
||||
|
||||
private classifyError<T>(step: string, error: unknown): ResearchStepResult<T> {
|
||||
if (error instanceof ResearchStepTimeoutError) {
|
||||
return { ok: false, error: { code: "timeout", message: error.message, retryable: true } };
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface VerificationCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
success: boolean;
|
||||
/** True when this result was satisfied from the verification cache rather than running the command. */
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
/** Result of running all verification commands */
|
||||
@@ -40,7 +42,7 @@ export interface VerificationResult {
|
||||
*/
|
||||
export async function execWithProcessGroup(
|
||||
command: string,
|
||||
options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal },
|
||||
options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal; env?: NodeJS.ProcessEnv },
|
||||
): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean; aborted?: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
@@ -58,6 +60,7 @@ export async function execWithProcessGroup(
|
||||
shell: true,
|
||||
detached: useProcessGroup,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...(options.env !== undefined && { env: { ...process.env, ...options.env } }),
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
@@ -310,6 +313,8 @@ export async function runVerificationCommand(
|
||||
log?: { log: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void; warn: (message: string, ...args: unknown[]) => void },
|
||||
/** Optional agent label for store log entries (e.g. "merger", "executor") */
|
||||
agentLabel?: string,
|
||||
/** Optional extra environment variables to inject into the child process (merged over process.env). */
|
||||
extraEnv?: NodeJS.ProcessEnv,
|
||||
): Promise<VerificationCommandResult> {
|
||||
const logger = log ?? { log: console.log, error: console.error, warn: console.warn };
|
||||
const label = (agentLabel ?? "merger") as AgentRole;
|
||||
@@ -340,6 +345,7 @@ export async function runVerificationCommand(
|
||||
timeout: VERIFICATION_COMMAND_TIMEOUT_MS,
|
||||
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
|
||||
signal,
|
||||
...(extraEnv !== undefined && { env: extraEnv }),
|
||||
});
|
||||
|
||||
if (signal?.aborted) {
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./probe": {
|
||||
"types": "./src/probe.ts",
|
||||
"import": "./dist/probe.js"
|
||||
"import": "./src/probe.ts"
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
|
||||
552
scripts/__tests__/test-changed.test.mjs
Normal file
552
scripts/__tests__/test-changed.test.mjs
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* Unit tests for scripts/test-changed.mjs
|
||||
*
|
||||
* Runner: node --test scripts/__tests__/test-changed.test.mjs
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
shouldForceFullSuite,
|
||||
resolveAffectedPackages,
|
||||
decideExecutionPlan,
|
||||
computePackageHash,
|
||||
readCache,
|
||||
writeCache,
|
||||
applyCacheToPlan,
|
||||
recordCachePass,
|
||||
cacheFilePath,
|
||||
} from "../test-changed.mjs";
|
||||
|
||||
import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a minimal Map<dir, pkgName> for testing. */
|
||||
function pkgMap(entries) {
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
/** Build a reverse Map<pkgName, dir> for testing. */
|
||||
function dirByName(entries) {
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary directory, run the callback with its path, then clean up.
|
||||
*
|
||||
* @param {(dir: string) => void} fn
|
||||
*/
|
||||
function withTmpDir(fn) {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "tc-test-"));
|
||||
try {
|
||||
fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A deterministic fake gitFn that returns a fixed blob sha for any path.
|
||||
*
|
||||
* @param {string} blobSha
|
||||
* @returns {(args: string[]) => string}
|
||||
*/
|
||||
function fakeGit(blobSha = "aabbccdd00112233aabbccdd00112233aabbccdd") {
|
||||
return (args) => {
|
||||
// ls-files -s output format: "<mode> <sha> <stage>\t<path>"
|
||||
const pathArg = args[args.length - 1];
|
||||
return `100644 ${blobSha} 0\t${pathArg}`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a hash using a deterministic git stub.
|
||||
*/
|
||||
function hashWithFakeGit(pkgDir, blobSha) {
|
||||
return computePackageHash(pkgDir, fakeGit(blobSha));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldForceFullSuite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("shouldForceFullSuite: returns false for pure package changes", () => {
|
||||
assert.equal(
|
||||
shouldForceFullSuite(["packages/engine/src/foo.ts", "packages/core/src/bar.ts"]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldForceFullSuite: returns true when pnpm-lock.yaml changed", () => {
|
||||
assert.equal(shouldForceFullSuite(["pnpm-lock.yaml"]), true);
|
||||
});
|
||||
|
||||
test("shouldForceFullSuite: returns true when scripts/test-changed.mjs changed", () => {
|
||||
assert.equal(shouldForceFullSuite(["scripts/test-changed.mjs"]), true);
|
||||
});
|
||||
|
||||
test("shouldForceFullSuite: returns true when a GitHub workflow changed", () => {
|
||||
assert.equal(shouldForceFullSuite([".github/workflows/ci.yml"]), true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveAffectedPackages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("resolveAffectedPackages: maps changed files to package names", () => {
|
||||
const map = pkgMap([["engine", "@fusion/engine"], ["core", "@fusion/core"]]);
|
||||
const result = resolveAffectedPackages(
|
||||
["packages/engine/src/index.ts", "packages/core/src/utils.ts"],
|
||||
map,
|
||||
);
|
||||
assert.deepEqual(result?.sort(), ["@fusion/core", "@fusion/engine"]);
|
||||
});
|
||||
|
||||
test("resolveAffectedPackages: ignores non-package files", () => {
|
||||
const map = pkgMap([["engine", "@fusion/engine"]]);
|
||||
const result = resolveAffectedPackages(["docs/readme.md"], map);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("resolveAffectedPackages: returns null for unknown package dir", () => {
|
||||
const map = pkgMap([["engine", "@fusion/engine"]]);
|
||||
const result = resolveAffectedPackages(["packages/unknown-pkg/src/foo.ts"], map);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decideExecutionPlan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const basePackageMap = pkgMap([["engine", "@fusion/engine"], ["core", "@fusion/core"]]);
|
||||
|
||||
test("decideExecutionPlan: forced full suite", () => {
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite: true,
|
||||
comparisonBase: "abc123",
|
||||
changedFiles: ["packages/engine/src/index.ts"],
|
||||
packageNameByDir: basePackageMap,
|
||||
});
|
||||
assert.equal(plan.mode, "full");
|
||||
assert.equal(plan.reason, "forced");
|
||||
});
|
||||
|
||||
test("decideExecutionPlan: missing comparison base → full", () => {
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite: false,
|
||||
comparisonBase: null,
|
||||
changedFiles: null,
|
||||
packageNameByDir: basePackageMap,
|
||||
});
|
||||
assert.equal(plan.mode, "full");
|
||||
assert.equal(plan.reason, "missing-comparison-base");
|
||||
});
|
||||
|
||||
test("decideExecutionPlan: diff failed → full", () => {
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite: false,
|
||||
comparisonBase: "abc123",
|
||||
changedFiles: null,
|
||||
packageNameByDir: basePackageMap,
|
||||
});
|
||||
assert.equal(plan.mode, "full");
|
||||
assert.equal(plan.reason, "diff-failed");
|
||||
});
|
||||
|
||||
test("decideExecutionPlan: no changes → full", () => {
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite: false,
|
||||
comparisonBase: "abc123",
|
||||
changedFiles: [],
|
||||
packageNameByDir: basePackageMap,
|
||||
});
|
||||
assert.equal(plan.mode, "full");
|
||||
assert.equal(plan.reason, "no-changes");
|
||||
});
|
||||
|
||||
test("decideExecutionPlan: shared infra changed → full", () => {
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite: false,
|
||||
comparisonBase: "abc123",
|
||||
changedFiles: ["pnpm-lock.yaml"],
|
||||
packageNameByDir: basePackageMap,
|
||||
});
|
||||
assert.equal(plan.mode, "full");
|
||||
assert.equal(plan.reason, "shared-infra-changed");
|
||||
});
|
||||
|
||||
test("decideExecutionPlan: only package files changed → changed mode", () => {
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite: false,
|
||||
comparisonBase: "abc123",
|
||||
changedFiles: ["packages/engine/src/index.ts"],
|
||||
packageNameByDir: basePackageMap,
|
||||
});
|
||||
assert.equal(plan.mode, "changed");
|
||||
assert.deepEqual(plan.packages, ["@fusion/engine"]);
|
||||
});
|
||||
|
||||
test("decideExecutionPlan: no affected package resolved → full", () => {
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite: false,
|
||||
comparisonBase: "abc123",
|
||||
changedFiles: ["packages/nonexistent/src/foo.ts"],
|
||||
packageNameByDir: basePackageMap,
|
||||
});
|
||||
assert.equal(plan.mode, "full");
|
||||
assert.equal(plan.reason, "no-affected-package");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computePackageHash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("computePackageHash: produces a 64-char hex string", () => {
|
||||
const hash = hashWithFakeGit("packages/engine", "aabb1122");
|
||||
assert.match(hash, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("computePackageHash: same inputs produce same hash (determinism)", () => {
|
||||
const h1 = hashWithFakeGit("packages/engine", "aabb1122");
|
||||
const h2 = hashWithFakeGit("packages/engine", "aabb1122");
|
||||
assert.equal(h1, h2);
|
||||
});
|
||||
|
||||
test("computePackageHash: different blob sha produces different hash", () => {
|
||||
const h1 = hashWithFakeGit("packages/engine", "aabb1122");
|
||||
const h2 = hashWithFakeGit("packages/engine", "deadbeef");
|
||||
assert.notEqual(h1, h2);
|
||||
});
|
||||
|
||||
test("computePackageHash: hash includes pnpm-lock.yaml so lockfile change busts everything", () => {
|
||||
// Two fakeGit functions that return different blob SHAs for pnpm-lock.yaml.
|
||||
const gitWithLockA = (args) => {
|
||||
const p = args[args.length - 1];
|
||||
if (p === "pnpm-lock.yaml") return `100644 locksha-AAAA 0\tpnpm-lock.yaml`;
|
||||
return `100644 pkgsha-same 0\t${p}`;
|
||||
};
|
||||
const gitWithLockB = (args) => {
|
||||
const p = args[args.length - 1];
|
||||
if (p === "pnpm-lock.yaml") return `100644 locksha-BBBB 0\tpnpm-lock.yaml`;
|
||||
return `100644 pkgsha-same 0\t${p}`;
|
||||
};
|
||||
|
||||
const hashA = computePackageHash("packages/engine", gitWithLockA);
|
||||
const hashB = computePackageHash("packages/engine", gitWithLockB);
|
||||
assert.notEqual(hashA, hashB);
|
||||
});
|
||||
|
||||
test("computePackageHash: hash includes tsconfig.base.json so shared TS config change busts cache", () => {
|
||||
const gitWithTsA = (args) => {
|
||||
const p = args[args.length - 1];
|
||||
if (p === "tsconfig.base.json") return `100644 tsconfig-SHA-AAA 0\ttsconfig.base.json`;
|
||||
return `100644 same-blob 0\t${p}`;
|
||||
};
|
||||
const gitWithTsB = (args) => {
|
||||
const p = args[args.length - 1];
|
||||
if (p === "tsconfig.base.json") return `100644 tsconfig-SHA-BBB 0\ttsconfig.base.json`;
|
||||
return `100644 same-blob 0\t${p}`;
|
||||
};
|
||||
|
||||
const hashA = computePackageHash("packages/engine", gitWithTsA);
|
||||
const hashB = computePackageHash("packages/engine", gitWithTsB);
|
||||
assert.notEqual(hashA, hashB);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readCache / writeCache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("readCache: returns empty cache for missing file", () => {
|
||||
withTmpDir((dir) => {
|
||||
const result = readCache(path.join(dir, "nonexistent.json"));
|
||||
assert.equal(result.version, 1);
|
||||
assert.deepEqual(result.entries, {});
|
||||
});
|
||||
});
|
||||
|
||||
test("readCache: returns empty cache for corrupted JSON", () => {
|
||||
withTmpDir((dir) => {
|
||||
const p = path.join(dir, "cache.json");
|
||||
writeFileSync(p, "{ this is not valid json }", "utf8");
|
||||
const result = readCache(p);
|
||||
assert.equal(result.version, 1);
|
||||
assert.deepEqual(result.entries, {});
|
||||
});
|
||||
});
|
||||
|
||||
test("readCache: returns empty cache when version field is wrong", () => {
|
||||
withTmpDir((dir) => {
|
||||
const p = path.join(dir, "cache.json");
|
||||
writeFileSync(p, JSON.stringify({ version: 99, entries: {} }), "utf8");
|
||||
const result = readCache(p);
|
||||
assert.deepEqual(result.entries, {});
|
||||
});
|
||||
});
|
||||
|
||||
test("readCache / writeCache: round-trips correctly", () => {
|
||||
withTmpDir((dir) => {
|
||||
const p = path.join(dir, "cache.json");
|
||||
const cache = {
|
||||
version: 1,
|
||||
entries: {
|
||||
"@fusion/engine": { hash: "abc123", passedAt: "2026-01-01T00:00:00.000Z", command: "test" },
|
||||
},
|
||||
};
|
||||
writeCache(p, cache);
|
||||
const read = readCache(p);
|
||||
assert.deepEqual(read, cache);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyCacheToPlan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("applyCacheToPlan: cache HIT excludes package from activePackages", () => {
|
||||
const hash = hashWithFakeGit("packages/engine", "fixed-sha");
|
||||
const passedAt = new Date().toISOString(); // just now → fresh
|
||||
|
||||
const cache = {
|
||||
version: 1,
|
||||
entries: {
|
||||
"@fusion/engine": { hash, passedAt, command: "test" },
|
||||
},
|
||||
};
|
||||
|
||||
const plan = { mode: "changed", packages: ["@fusion/engine"] };
|
||||
const result = applyCacheToPlan(plan, {
|
||||
gitFn: fakeGit("fixed-sha"),
|
||||
readCacheFn: () => cache,
|
||||
writeCacheFn: () => {},
|
||||
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
|
||||
});
|
||||
|
||||
assert.deepEqual(result.cachedPackages, ["@fusion/engine"]);
|
||||
assert.deepEqual(result.activePackages, []);
|
||||
});
|
||||
|
||||
test("applyCacheToPlan: cache MISS includes package in activePackages", () => {
|
||||
const cache = { version: 1, entries: {} }; // no entries → miss
|
||||
|
||||
const plan = { mode: "changed", packages: ["@fusion/engine"] };
|
||||
const result = applyCacheToPlan(plan, {
|
||||
gitFn: fakeGit("fixed-sha"),
|
||||
readCacheFn: () => cache,
|
||||
writeCacheFn: () => {},
|
||||
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
|
||||
});
|
||||
|
||||
assert.deepEqual(result.cachedPackages, []);
|
||||
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
|
||||
});
|
||||
|
||||
test("applyCacheToPlan: stale entry (older than 7 days) causes a cache MISS", () => {
|
||||
const hash = hashWithFakeGit("packages/engine", "fixed-sha");
|
||||
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const cache = {
|
||||
version: 1,
|
||||
entries: {
|
||||
"@fusion/engine": { hash, passedAt: eightDaysAgo, command: "test" },
|
||||
},
|
||||
};
|
||||
|
||||
const plan = { mode: "changed", packages: ["@fusion/engine"] };
|
||||
const result = applyCacheToPlan(plan, {
|
||||
gitFn: fakeGit("fixed-sha"),
|
||||
readCacheFn: () => cache,
|
||||
writeCacheFn: () => {},
|
||||
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
|
||||
});
|
||||
|
||||
assert.deepEqual(result.cachedPackages, []);
|
||||
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
|
||||
});
|
||||
|
||||
test("applyCacheToPlan: hash mismatch causes a cache MISS", () => {
|
||||
const cachedHash = hashWithFakeGit("packages/engine", "old-sha");
|
||||
// Script will compute hash with "new-sha" blob
|
||||
const cache = {
|
||||
version: 1,
|
||||
entries: {
|
||||
"@fusion/engine": { hash: cachedHash, passedAt: new Date().toISOString(), command: "test" },
|
||||
},
|
||||
};
|
||||
|
||||
const plan = { mode: "changed", packages: ["@fusion/engine"] };
|
||||
const result = applyCacheToPlan(plan, {
|
||||
gitFn: fakeGit("new-sha"), // different blob → different hash
|
||||
readCacheFn: () => cache,
|
||||
writeCacheFn: () => {},
|
||||
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
|
||||
});
|
||||
|
||||
assert.deepEqual(result.cachedPackages, []);
|
||||
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
|
||||
});
|
||||
|
||||
test("applyCacheToPlan: noCache=true bypasses lookup and always returns all packages as active", () => {
|
||||
const hash = hashWithFakeGit("packages/engine", "fixed-sha");
|
||||
const cache = {
|
||||
version: 1,
|
||||
entries: {
|
||||
"@fusion/engine": { hash, passedAt: new Date().toISOString(), command: "test" },
|
||||
},
|
||||
};
|
||||
|
||||
const plan = { mode: "changed", packages: ["@fusion/engine"] };
|
||||
const result = applyCacheToPlan(plan, {
|
||||
noCache: true,
|
||||
gitFn: fakeGit("fixed-sha"),
|
||||
readCacheFn: () => cache,
|
||||
writeCacheFn: () => {},
|
||||
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
|
||||
});
|
||||
|
||||
// Cache would be a HIT if noCache were false, but it's bypassed.
|
||||
assert.deepEqual(result.cachedPackages, []);
|
||||
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
|
||||
});
|
||||
|
||||
test("applyCacheToPlan: FUSION_TEST_NO_CACHE=1 bypasses lookup (env integration check)", () => {
|
||||
// This test checks that callers pass noCache=true when env is set.
|
||||
// The actual env reading is in main(); we verify the flag propagates correctly.
|
||||
const noCacheFromEnv = process.env.FUSION_TEST_NO_CACHE === "1";
|
||||
// Set env temporarily for this check.
|
||||
const originalVal = process.env.FUSION_TEST_NO_CACHE;
|
||||
process.env.FUSION_TEST_NO_CACHE = "1";
|
||||
|
||||
const noCache = process.env.FUSION_TEST_NO_CACHE === "1";
|
||||
assert.equal(noCache, true);
|
||||
|
||||
process.env.FUSION_TEST_NO_CACHE = originalVal ?? "";
|
||||
if (!originalVal) delete process.env.FUSION_TEST_NO_CACHE;
|
||||
});
|
||||
|
||||
test("applyCacheToPlan: full plan is not filtered by cache", () => {
|
||||
const plan = { mode: "full", reason: "forced" };
|
||||
const result = applyCacheToPlan(plan, {
|
||||
readCacheFn: () => { throw new Error("should not read cache for full plan"); },
|
||||
packageDirByName: new Map(),
|
||||
});
|
||||
assert.equal(result.cachedPackages.length, 0);
|
||||
assert.deepEqual(result.activePackages, []);
|
||||
});
|
||||
|
||||
test("applyCacheToPlan: corrupted cache file → continues without crash (cache miss)", () => {
|
||||
withTmpDir((dir) => {
|
||||
const p = path.join(dir, "cache.json");
|
||||
writeFileSync(p, "<<<invalid json>>>", "utf8");
|
||||
|
||||
const plan = { mode: "changed", packages: ["@fusion/engine"] };
|
||||
// Use the real readCache which handles corruption gracefully.
|
||||
const result = applyCacheToPlan(plan, {
|
||||
gitFn: fakeGit("fixed-sha"),
|
||||
readCacheFn: () => readCache(p),
|
||||
writeCacheFn: () => {},
|
||||
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
|
||||
});
|
||||
|
||||
// Should not throw and should treat all packages as active (miss).
|
||||
assert.deepEqual(result.cachedPackages, []);
|
||||
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("applyCacheToPlan: mixed HIT and MISS across multiple packages", () => {
|
||||
// Use the same gitFn for both pre-computing the cached hash and the runtime
|
||||
// lookup so that root-file blob SHAs (pnpm-lock.yaml, tsconfig.base.json)
|
||||
// are identical in both contexts.
|
||||
const gitFnMulti = (args) => {
|
||||
const p = args[args.length - 1];
|
||||
if (p === "packages/engine") return `100644 sha-engine 0\tpackages/engine/src/index.ts`;
|
||||
if (p === "packages/core") return `100644 sha-core 0\tpackages/core/src/index.ts`;
|
||||
// Root files (pnpm-lock.yaml, tsconfig.base.json) get a stable blob sha.
|
||||
return `100644 common-root-sha 0\t${p}`;
|
||||
};
|
||||
|
||||
// Pre-compute the engine hash using the SAME gitFnMulti so the stored hash
|
||||
// matches what applyCacheToPlan will compute at lookup time.
|
||||
const engineHash = computePackageHash("packages/engine", gitFnMulti);
|
||||
|
||||
// core is NOT in cache → miss
|
||||
const cache = {
|
||||
version: 1,
|
||||
entries: {
|
||||
"@fusion/engine": { hash: engineHash, passedAt: new Date().toISOString(), command: "test" },
|
||||
},
|
||||
};
|
||||
|
||||
const plan = { mode: "changed", packages: ["@fusion/engine", "@fusion/core"] };
|
||||
const result = applyCacheToPlan(plan, {
|
||||
gitFn: gitFnMulti,
|
||||
readCacheFn: () => cache,
|
||||
writeCacheFn: () => {},
|
||||
packageDirByName: dirByName([
|
||||
["@fusion/engine", "packages/engine"],
|
||||
["@fusion/core", "packages/core"],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.deepEqual(result.cachedPackages, ["@fusion/engine"]);
|
||||
assert.deepEqual(result.activePackages, ["@fusion/core"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// recordCachePass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("recordCachePass: writes hash and passedAt for passing packages", () => {
|
||||
let written = null;
|
||||
const cache = { version: 1, entries: {} };
|
||||
|
||||
recordCachePass(["@fusion/engine"], dirByName([["@fusion/engine", "packages/engine"]]), {
|
||||
gitFn: fakeGit("abc123"),
|
||||
readCacheFn: () => cache,
|
||||
writeCacheFn: (c) => { written = c; },
|
||||
});
|
||||
|
||||
assert.ok(written, "cache was written");
|
||||
const entry = written.entries["@fusion/engine"];
|
||||
assert.ok(entry, "entry exists");
|
||||
assert.match(entry.hash, /^[0-9a-f]{64}$/);
|
||||
assert.equal(entry.command, "test");
|
||||
assert.ok(new Date(entry.passedAt).getTime() > 0, "passedAt is a valid date");
|
||||
});
|
||||
|
||||
test("recordCachePass: noCache=true skips write", () => {
|
||||
let written = false;
|
||||
recordCachePass(["@fusion/engine"], dirByName([["@fusion/engine", "packages/engine"]]), {
|
||||
noCache: true,
|
||||
gitFn: fakeGit("abc123"),
|
||||
readCacheFn: () => ({ version: 1, entries: {} }),
|
||||
writeCacheFn: () => { written = true; },
|
||||
});
|
||||
assert.equal(written, false);
|
||||
});
|
||||
|
||||
test("recordCachePass: empty package list skips write", () => {
|
||||
let written = false;
|
||||
recordCachePass([], new Map(), {
|
||||
gitFn: fakeGit("abc123"),
|
||||
readCacheFn: () => ({ version: 1, entries: {} }),
|
||||
writeCacheFn: () => { written = true; },
|
||||
});
|
||||
assert.equal(written, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cacheFilePath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("cacheFilePath: ends with .fusion/test-cache.json", () => {
|
||||
const p = cacheFilePath();
|
||||
assert.ok(p.endsWith(path.join(".fusion", "test-cache.json")), `got: ${p}`);
|
||||
});
|
||||
@@ -1,11 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const rootDir = process.env.FUSION_PROJECT_DIR
|
||||
? path.resolve(process.env.FUSION_PROJECT_DIR)
|
||||
: process.cwd();
|
||||
|
||||
/** @type {string} Cache format version — bump when the shape or hash inputs change. */
|
||||
const CACHE_FORMAT_VERSION = 1;
|
||||
|
||||
/** @type {string} Constant mixed into every content hash so format rev busts all entries. */
|
||||
const HASH_VERSION_PREFIX = "v1";
|
||||
|
||||
/** @type {number} Max age (ms) for a cache entry to count as a pass. */
|
||||
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
function run(command, commandArgs, options = {}) {
|
||||
const result = spawnSync(command, commandArgs, {
|
||||
@@ -142,6 +154,258 @@ export function resolveAffectedPackages(changedFiles, packageNameByDir) {
|
||||
return [...affected];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content-hash cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @typedef {{ hash: string; passedAt: string; command: string }} CacheEntry
|
||||
* @typedef {{ version: number; entries: Record<string, CacheEntry> }} CacheFile
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return the path to the per-project test-cache JSON file.
|
||||
* Honours FUSION_PROJECT_DIR (already reflected in rootDir).
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cacheFilePath() {
|
||||
return path.join(rootDir, ".fusion", "test-cache.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse the cache file. Returns an empty cache structure on any
|
||||
* read/parse failure (corruption, missing file, etc.) and logs a warning.
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @returns {CacheFile}
|
||||
*/
|
||||
export function readCache(filePath) {
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.version === CACHE_FORMAT_VERSION &&
|
||||
parsed.entries &&
|
||||
typeof parsed.entries === "object"
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
console.warn("[test-changed] cache file has unexpected shape; treating as empty.");
|
||||
return { version: CACHE_FORMAT_VERSION, entries: {} };
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
console.warn(`[test-changed] could not read cache (${err.message}); treating as empty.`);
|
||||
}
|
||||
return { version: CACHE_FORMAT_VERSION, entries: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write the cache file (write to temp then rename).
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @param {CacheFile} cache
|
||||
*/
|
||||
export function writeCache(filePath, cache) {
|
||||
const dir = path.dirname(filePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${filePath}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf8");
|
||||
renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a stable content hash for a package directory.
|
||||
*
|
||||
* The hash is SHA-256 over:
|
||||
* - The constant version prefix HASH_VERSION_PREFIX
|
||||
* - The blob SHA of pnpm-lock.yaml at HEAD
|
||||
* - The blob SHA of tsconfig.base.json at HEAD
|
||||
* - Every (relativePath, blobSha) pair from `git ls-files -s <pkgDir>`,
|
||||
* sorted lexicographically by path for stability.
|
||||
*
|
||||
* Using git blob SHAs means we never read file contents ourselves — git
|
||||
* already hashes them, so this is fast even for large packages.
|
||||
*
|
||||
* @param {string} packageDir Relative path to the package dir (e.g. "packages/engine")
|
||||
* @param {(args: string[]) => string|null} gitFn Injectable git runner (for tests)
|
||||
* @returns {string} 64-char hex SHA-256
|
||||
*/
|
||||
export function computePackageHash(packageDir, gitFn = gitOutput) {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(HASH_VERSION_PREFIX);
|
||||
hash.update("\0");
|
||||
|
||||
// Bust when lock file or shared TS config changes.
|
||||
for (const rootFile of ["pnpm-lock.yaml", "tsconfig.base.json"]) {
|
||||
// `git ls-files -s <path>` → "<mode> <blobSha> <stage>\t<path>"
|
||||
const out = gitFn(["ls-files", "-s", rootFile]);
|
||||
const blobSha = out ? out.split(/\s+/)[1] ?? "" : "";
|
||||
hash.update(rootFile);
|
||||
hash.update("=");
|
||||
hash.update(blobSha);
|
||||
hash.update("\0");
|
||||
}
|
||||
|
||||
// All tracked files inside the package directory.
|
||||
const lsOut = gitFn(["ls-files", "-s", packageDir]);
|
||||
const entries = [];
|
||||
if (lsOut) {
|
||||
for (const line of lsOut.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
// Format: <mode> SP <object> SP <stage> TAB <file>
|
||||
const tabIdx = trimmed.indexOf("\t");
|
||||
if (tabIdx === -1) continue;
|
||||
const fields = trimmed.slice(0, tabIdx).split(/\s+/);
|
||||
const blobSha = fields[1] ?? "";
|
||||
const filePath = trimmed.slice(tabIdx + 1);
|
||||
entries.push({ filePath, blobSha });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for determinism (git output is usually sorted, but let's be explicit).
|
||||
entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
||||
for (const { filePath, blobSha } of entries) {
|
||||
hash.update(filePath);
|
||||
hash.update("=");
|
||||
hash.update(blobSha);
|
||||
hash.update("\0");
|
||||
}
|
||||
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a human-readable relative time string like "3h ago" or "2d ago".
|
||||
*
|
||||
* @param {string} isoTimestamp
|
||||
* @returns {string}
|
||||
*/
|
||||
function relativeTime(isoTimestamp) {
|
||||
const diffMs = Date.now() - new Date(isoTimestamp).getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
if (diffSecs < 60) return `${diffSecs}s ago`;
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} CacheOptions
|
||||
* @property {boolean} [noCache] When true, bypass cache reads AND writes.
|
||||
* @property {(args: string[]) => string|null} [gitFn] Injectable git runner.
|
||||
* @property {() => CacheFile} [readCacheFn] Injectable cache reader.
|
||||
* @property {(cache: CacheFile) => void} [writeCacheFn] Injectable cache writer.
|
||||
* @property {Map<string, string>} [packageDirByName] pkg-name → relative dir.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Apply the content-hash cache to an execution plan.
|
||||
*
|
||||
* This is a SEPARATE function from decideExecutionPlan so it can be tested
|
||||
* independently (decideExecutionPlan remains pure / I/O-free).
|
||||
*
|
||||
* For "full" plans, cache lookups are always skipped (running full means full).
|
||||
* For "changed" plans, any package whose hash matches a fresh cache entry is
|
||||
* removed from the run set. If all packages are cached, returns a synthetic
|
||||
* "all-cached" result so the caller can skip the pnpm invocation entirely.
|
||||
*
|
||||
* @param {{ mode: string; packages?: string[]; reason?: string }} plan
|
||||
* @param {CacheOptions} [options]
|
||||
* @returns {{ plan: typeof plan; cachedPackages: string[]; activePackages: string[] }}
|
||||
*/
|
||||
export function applyCacheToPlan(plan, options = {}) {
|
||||
const {
|
||||
noCache = false,
|
||||
gitFn = gitOutput,
|
||||
readCacheFn,
|
||||
writeCacheFn,
|
||||
packageDirByName = new Map(),
|
||||
} = options;
|
||||
|
||||
// Full suite runs always bypass cache (full means full).
|
||||
if (plan.mode !== "changed" || noCache) {
|
||||
return { plan, cachedPackages: [], activePackages: plan.packages ?? [] };
|
||||
}
|
||||
|
||||
const filePath = cacheFilePath();
|
||||
const cache = readCacheFn ? readCacheFn() : readCache(filePath);
|
||||
const now = Date.now();
|
||||
|
||||
const cachedPackages = [];
|
||||
const activePackages = [];
|
||||
|
||||
for (const pkg of plan.packages ?? []) {
|
||||
const pkgDir = packageDirByName.get(pkg) ?? `packages/${pkg.replace(/^@[^/]+\//, "")}`;
|
||||
const computedHash = computePackageHash(pkgDir, gitFn);
|
||||
const entry = cache.entries[pkg];
|
||||
|
||||
const isHit =
|
||||
entry &&
|
||||
entry.hash === computedHash &&
|
||||
now - new Date(entry.passedAt).getTime() < CACHE_MAX_AGE_MS;
|
||||
|
||||
if (isHit) {
|
||||
const sha7 = computedHash.slice(0, 7);
|
||||
const when = relativeTime(entry.passedAt);
|
||||
console.log(`[test-changed] cache HIT for ${pkg} (hash ${sha7}, passed ${when})`);
|
||||
cachedPackages.push(pkg);
|
||||
} else {
|
||||
activePackages.push(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
return { plan, cachedPackages, activePackages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist passing results for the given packages into the cache.
|
||||
*
|
||||
* @param {string[]} packages
|
||||
* @param {Map<string, string>} packageDirByName
|
||||
* @param {CacheOptions} [options]
|
||||
*/
|
||||
export function recordCachePass(packages, packageDirByName, options = {}) {
|
||||
const {
|
||||
noCache = false,
|
||||
gitFn = gitOutput,
|
||||
readCacheFn,
|
||||
writeCacheFn,
|
||||
} = options;
|
||||
|
||||
if (noCache || packages.length === 0) return;
|
||||
|
||||
const filePath = cacheFilePath();
|
||||
const cache = readCacheFn ? readCacheFn() : readCache(filePath);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const pkg of packages) {
|
||||
const pkgDir = packageDirByName.get(pkg) ?? `packages/${pkg.replace(/^@[^/]+\//, "")}`;
|
||||
const hash = computePackageHash(pkgDir, gitFn);
|
||||
cache.entries[pkg] = { hash, passedAt: now, command: "test" };
|
||||
}
|
||||
|
||||
if (writeCacheFn) {
|
||||
writeCacheFn(cache);
|
||||
} else {
|
||||
writeCache(filePath, cache);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const workspaceConcurrency =
|
||||
process.env.FUSION_TEST_WORKSPACE_CONCURRENCY || "2";
|
||||
|
||||
const fullSuiteEnv = {
|
||||
...process.env,
|
||||
FUSION_TEST_TOTAL_WORKERS: process.env.FUSION_TEST_TOTAL_WORKERS || "4",
|
||||
@@ -149,7 +413,7 @@ const fullSuiteEnv = {
|
||||
};
|
||||
|
||||
function runFullSuite(forwardedArgs) {
|
||||
run("pnpm", ["-r", "--workspace-concurrency=2", "test", ...forwardedArgs], { env: fullSuiteEnv });
|
||||
run("pnpm", [`-r`, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
|
||||
}
|
||||
|
||||
export function decideExecutionPlan({
|
||||
@@ -176,7 +440,11 @@ export function main(argv = process.argv.slice(2)) {
|
||||
process.env.FUSION_TEST_FULL === "1" ||
|
||||
argv.includes("--full");
|
||||
|
||||
const forwardedArgs = argv.filter((arg) => arg !== "--full");
|
||||
const noCache =
|
||||
process.env.FUSION_TEST_NO_CACHE === "1" ||
|
||||
argv.includes("--no-cache");
|
||||
|
||||
const forwardedArgs = argv.filter((arg) => arg !== "--full" && arg !== "--no-cache");
|
||||
|
||||
run("pnpm", ["sync:fusion-skill:check"]);
|
||||
|
||||
@@ -185,6 +453,12 @@ export function main(argv = process.argv.slice(2)) {
|
||||
const changedFiles = comparisonBase ? changedFilesSince(comparisonBase) : null;
|
||||
const packageNameByDir = listWorkspacePackages();
|
||||
|
||||
// Build reverse map: pkg-name → relative dir (e.g. "packages/engine")
|
||||
const packageDirByName = new Map();
|
||||
for (const [dir, name] of packageNameByDir) {
|
||||
packageDirByName.set(name, `packages/${dir}`);
|
||||
}
|
||||
|
||||
const plan = decideExecutionPlan({
|
||||
forceFullSuite,
|
||||
comparisonBase,
|
||||
@@ -209,9 +483,29 @@ export function main(argv = process.argv.slice(2)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterArgs = plan.packages.flatMap((pkg) => ["--filter", pkg]);
|
||||
console.log(`[test-changed] running tests for changed packages: ${plan.packages.join(", ")}`);
|
||||
run("pnpm", [...filterArgs, "test", ...forwardedArgs], { env: fullSuiteEnv });
|
||||
// Apply the content-hash cache to prune already-passing packages.
|
||||
const { cachedPackages, activePackages } = applyCacheToPlan(plan, {
|
||||
noCache: noCache || forceFullSuite,
|
||||
packageDirByName,
|
||||
});
|
||||
|
||||
if (activePackages.length === 0) {
|
||||
console.log(
|
||||
`[test-changed] all changed packages are cache-fresh (${cachedPackages.join(", ")}); nothing to run.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filterArgs = activePackages.flatMap((pkg) => ["--filter", pkg]);
|
||||
console.log(`[test-changed] running tests for changed packages: ${activePackages.join(", ")}`);
|
||||
if (cachedPackages.length > 0) {
|
||||
console.log(`[test-changed] skipping cached packages: ${cachedPackages.join(", ")}`);
|
||||
}
|
||||
|
||||
run("pnpm", [...filterArgs, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
|
||||
|
||||
// Tests passed — record in cache (never cache failures; process.exit on failure above).
|
||||
recordCachePass(activePackages, packageDirByName, { noCache });
|
||||
}
|
||||
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
|
||||
Reference in New Issue
Block a user