FN-8095: repair dashboard API quality backfill
Stabilize the dashboard API backfill baseline and validate emitted plugin registry artifacts. - Align API integration-test mocks and assertions with current service contracts. - Move dist-dependent plugin registry coverage into the build test command. - Exclude the artifact-dependent test from API backfill shards. Files changed: packages/dashboard/package.json | 2 +- .../src/__tests__/chat-project-services.test.ts | 3 + .../gitlab-source-issue-reconciler.test.ts | 27 +++---- .../src/__tests__/mcp-helper-forwarding.test.ts | 44 ++++++++--- .../src/__tests__/plugin-registry-dist.test.ts | 6 ++ .../src/__tests__/process-lifecycle.test.ts | 8 +- .../register-git-github.gitlab-lifecycle.test.ts | 11 ++- .../src/__tests__/register-signal-routes.test.ts | 86 +++++++++++----------- .../routes-agent-prompt-sizes-integration.test.ts | 54 ++++++++------ .../src/__tests__/server-view-preload.test.ts | 45 +++++++---- .../task-effective-settings-route.test.ts | 12 ++- .../routes/__tests__/agent-avatar-routes.test.ts | 3 + .../mission-workflow-triage-route.test.ts | 73 +++++++++--------- .../register-settings-memory-worktrunk.test.ts | 14 +++- ...sk-workflow-routes.merge-advance-events.test.ts | 18 +++-- ...r-task-workflow-routes.runtime-fallback.test.ts | 4 +- .../__tests__/tasks-overseer-controls.test.ts | 29 ++++---- .../__tests__/tasks-planner-overseer-state.test.ts | 56 +++++++------- .../__tests__/workflow-validate-route.test.ts | 21 ++---- packages/dashboard/vitest.config.ts | 4 + 20 files changed, 307 insertions(+), 213 deletions(-) Fusion-Task-Id: FN-8095 Fusion-Task-Lineage: 8f775431-2149-433a-a8b6-e6106eab661b Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -92,7 +92,7 @@
|
||||
"test:api": "FUSION_DASHBOARD_DEEP=1 vitest run --project dashboard-api --silent=passed-only --reporter=dot",
|
||||
"test:deep": "FUSION_DASHBOARD_DEEP=1 vitest run --project dashboard-app --project dashboard-api --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'",
|
||||
"test:browser-smoke": "node scripts/browser-layout-smoke.mjs",
|
||||
"test:build": "FUSION_DASHBOARD_DEEP=1 vitest run --project dashboard-app --silent=passed-only --reporter=dot app/__tests__/build-output.test.ts",
|
||||
"test:build": "pnpm build && FUSION_DASHBOARD_DEEP=1 vitest run --project dashboard-app --project dashboard-api --silent=passed-only --reporter=dot app/__tests__/build-output.test.ts src/__tests__/plugin-registry-dist.test.ts",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json",
|
||||
"test:quality:api:backfill-1": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-api-quality-backfill --silent=passed-only --reporter=dot --shard=1/2",
|
||||
"test:quality:api:backfill-2": "node scripts/run-vitest-with-heap.mjs --heap=6144 run --project dashboard-api-quality-backfill --silent=passed-only --reporter=dot --shard=2/2"
|
||||
|
||||
@@ -10,6 +10,9 @@ function createStore(fusionDir = "/tmp/fusion-project") {
|
||||
getRootDir: vi.fn(() => "/tmp/project"),
|
||||
getSettings: vi.fn(async () => ({})),
|
||||
getDatabase: vi.fn(() => ({})),
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: dashboard service doubles must
|
||||
// expose the AsyncDataLayer contract used by AgentStore after SQLite removal.
|
||||
getAsyncLayer: vi.fn(() => ({})),
|
||||
} as any;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore, type Task } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import { GitLabSourceIssueReconciler } from "../gitlab-source-issue-reconciler.js";
|
||||
|
||||
const { mockResolveGitLabClient, mockGetProjectIssue, mockGetMergeRequest } = vi.hoisted(() => ({
|
||||
@@ -157,13 +154,14 @@ describe("GitLabSourceIssueReconciler.backfillSourceIssueClosedAt", () => {
|
||||
expect(store.logEntry as any).toHaveBeenCalledWith("FN-9", "Skipped GitLab source issue closed-at backfill", "GitLab token missing");
|
||||
});
|
||||
|
||||
it("excludes real archived TaskStore rows instead of mutating archiveDb entries", async () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "kb-gitlab-backfill-archive-test-"));
|
||||
const globalDir = mkdtempSync(join(tmpdir(), "kb-gitlab-backfill-archive-global-"));
|
||||
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
pgDescribe("archived TaskStore rows", () => {
|
||||
it("excludes real archived TaskStore rows instead of mutating archiveDb entries", async () => {
|
||||
// FNXC:PostgresCutover 2026-07-16-06:50: archived-task reconciliation must
|
||||
// use the production async-store archive path after SQLite removal.
|
||||
const harness = await createTaskStoreForTest();
|
||||
const store = harness.store;
|
||||
|
||||
try {
|
||||
await store.init();
|
||||
try {
|
||||
const task = await store.createTask({ description: "Archived GitLab issue", sourceIssue: gitlabTask("template").sourceIssue });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
@@ -178,10 +176,9 @@ describe("GitLabSourceIssueReconciler.backfillSourceIssueClosedAt", () => {
|
||||
expect(result).toEqual({ scanned: 0, filled: 0, skipped: 0, errors: 0, hasMore: false });
|
||||
expect(mockGetProjectIssue).not.toHaveBeenCalled();
|
||||
expect(restored.sourceIssue?.closedAt).toBeUndefined();
|
||||
} finally {
|
||||
store.close();
|
||||
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
} finally {
|
||||
await harness.teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import express from "express";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { InsightStore, TaskStore as TaskStoreClass, type Task, type TaskStore } from "@fusion/core";
|
||||
import { type Task, type TaskStore } from "@fusion/core";
|
||||
import * as coreModule from "@fusion/core";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
@@ -72,10 +68,36 @@ function createTask(): Task {
|
||||
} as Task;
|
||||
}
|
||||
|
||||
async function createInsightTaskStore(mode: boolean | "no-settings-scope"): Promise<{ root: string; store: TaskStore }> {
|
||||
const root = mkdtempSync(join(tmpdir(), "kb-mcp-helper-forwarding-"));
|
||||
const store = new TaskStoreClass(root, join(root, ".fusion-global-settings"), { inMemoryDb: true }) as TaskStore & { mcpEnabledForTest?: boolean; getSettingsByScope?: unknown };
|
||||
await store.init();
|
||||
function createInsightTaskStore(mode: boolean | "no-settings-scope"): { root: string; store: TaskStore } {
|
||||
const root = "/tmp/mcp-helper-forwarding";
|
||||
let run = {
|
||||
id: "INS-MCP",
|
||||
projectId: "",
|
||||
trigger: "manual",
|
||||
status: "pending",
|
||||
lifecycle: { attempt: 1, maxAttempts: 2 },
|
||||
};
|
||||
// FNXC:PostgresCutover 2026-07-16-07:20: MCP forwarding is a route-boundary
|
||||
// assertion, not TaskStore persistence coverage. Model the async insight-run
|
||||
// contract directly so this test no longer constructs the deleted SQLite mode.
|
||||
const insightStore = {
|
||||
findActiveRun: vi.fn(async () => undefined),
|
||||
createRunOrThrowConflict: vi.fn(async (projectId: string, input: Record<string, unknown>) => {
|
||||
run = { ...run, projectId, ...input } as typeof run;
|
||||
return run;
|
||||
}),
|
||||
updateRun: vi.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
run = { ...run, ...patch, lifecycle: { ...run.lifecycle, ...(patch.lifecycle as object ?? {}) } } as typeof run;
|
||||
return run;
|
||||
}),
|
||||
appendRunEvent: vi.fn(async () => undefined),
|
||||
listStalePendingRuns: vi.fn(async () => []),
|
||||
};
|
||||
const store = {
|
||||
getRootDir: () => root,
|
||||
getSettings: vi.fn(async () => ({})),
|
||||
getInsightStore: () => insightStore,
|
||||
} as TaskStore & { mcpEnabledForTest?: boolean; getSettingsByScope?: unknown };
|
||||
if (mode === true) store.mcpEnabledForTest = true;
|
||||
if (mode === "no-settings-scope") {
|
||||
Object.defineProperty(store, "getSettingsByScope", { value: undefined, configurable: true });
|
||||
@@ -242,7 +264,7 @@ describe("MCP forwarding for readonly dashboard helper seams", () => {
|
||||
extractedAt: "2026-06-26T00:00:00.000Z",
|
||||
});
|
||||
vi.spyOn(coreModule, "mergeInsights").mockReturnValue("# merged insights");
|
||||
const { root, store } = await createInsightTaskStore(mode);
|
||||
const { root, store } = createInsightTaskStore(mode);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = createInsightsRouter(store) as ReturnType<typeof createInsightsRouter> & { __disposeSweeper?: () => void };
|
||||
@@ -257,8 +279,6 @@ describe("MCP forwarding for readonly dashboard helper seams", () => {
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({ tools: "readonly", mcpServers: expectedServers }));
|
||||
} finally {
|
||||
router.__disposeSweeper?.();
|
||||
await store.close();
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,12 @@ async function readDashboardFile(relativePath: string): Promise<string> {
|
||||
return readFile(path.join(dashboardRoot, relativePath), "utf-8");
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:DashboardDistArtifacts 2026-07-16-08:20:
|
||||
This is an emitted-server-output assertion, not an API test. The explicit test:build
|
||||
command builds the dashboard before collecting it, keeping this test bounded and
|
||||
preventing API backfill shards from synchronously running a full package build.
|
||||
*/
|
||||
describe("plugin registry production output", () => {
|
||||
it("does not emit a Node 22-invalid static JSON import for registry-manifest.json", async () => {
|
||||
const pluginRoutesDist = await readDashboardFile("dist/plugin-routes.js");
|
||||
|
||||
@@ -50,8 +50,14 @@ describe("dashboard process lifecycle cleanup", () => {
|
||||
}
|
||||
|
||||
const addedListeners = process.listenerCount("beforeExit") - baselineListeners;
|
||||
/*
|
||||
FNXC:DashboardProcessLifecycle 2026-07-16-08:28:
|
||||
This suite owns the dashboard's beforeExit registration invariant. The host
|
||||
process may independently register exit cleanup listeners, so only a
|
||||
beforeExit MaxListeners warning proves this module-evaluation regression.
|
||||
*/
|
||||
const maxListenerWarnings = warnings.filter(
|
||||
(warning) => warning.name === "MaxListenersExceededWarning"
|
||||
(warning) => warning.name === "MaxListenersExceededWarning" && (warning as { type?: string }).type === "beforeExit",
|
||||
);
|
||||
|
||||
expect(addedListeners).toBeLessThanOrEqual(1);
|
||||
|
||||
@@ -11,10 +11,16 @@ function createStore() {
|
||||
return Object.assign(emitter, {
|
||||
getRootDir: vi.fn().mockReturnValue(process.cwd()),
|
||||
getFusionDir: vi.fn().mockReturnValue(`${process.cwd()}/.fusion`),
|
||||
// FNXC:PostgresCutover 2026-07-16-06:55: refresh listeners expect the
|
||||
// backend accessor even when their test double does not persist index rows.
|
||||
getAsyncLayer: vi.fn().mockReturnValue(undefined),
|
||||
getSettings: vi.fn().mockResolvedValue({ gitlabAuthToken: "token", gitlabInstanceUrl: "https://gitlab.example.com", gitlabCommentOnDone: true, gitlabCloseSourceIssueOnDone: true }),
|
||||
getGlobalSettingsStore: () => ({ getSettings: vi.fn().mockResolvedValue({}) }),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
// FNXC:PostgresCutover 2026-07-16-06:55: lifecycle refresh reads the
|
||||
// current task asynchronously before deriving GitLab follow-up work.
|
||||
getTask: vi.fn().mockResolvedValue(undefined),
|
||||
listTasksForGithubTrackingReconcile: vi.fn().mockResolvedValue({ tasks: [], hasMore: false }),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
@@ -45,7 +51,10 @@ describe("registerGitGitHubRoutes GitLab lifecycle services", () => {
|
||||
registerGitGitHubRoutes(createContext(store, disposers));
|
||||
store.emit("task:moved", { task, from: "todo", to: "done" });
|
||||
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(5));
|
||||
expect(fetchImpl.mock.calls.filter(([url]) => String(url).endsWith("/notes"))).toHaveLength(2);
|
||||
// FNXC:GitLabIssueComments 2026-07-16-07:25: an imported GitLab task can
|
||||
// carry both source and tracking links to one issue; its done transition
|
||||
// must produce exactly one comment, with tracking owning the richer payload.
|
||||
expect(fetchImpl.mock.calls.filter(([url]) => String(url).endsWith("/notes"))).toHaveLength(1);
|
||||
for (const dispose of disposers) dispose();
|
||||
store.emit("task:moved", { task, from: "done", to: "todo" });
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { aggregateSignalsAnalytics, Database, type Task, type TaskStore } from "@fusion/core";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { aggregateSignalsAnalytics, drizzleSql as sql, type AsyncDataLayer, type Task, type TaskStore } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import { DeliveryNonceCache, type SignalSource } from "../signal-source.js";
|
||||
import {
|
||||
ingestSignal,
|
||||
@@ -25,7 +23,7 @@ function sign(body: string, secret: string): string {
|
||||
}
|
||||
|
||||
/** Minimal fake task store implementing only what the ingestion path uses. */
|
||||
function makeStore(db?: Database) {
|
||||
function makeStore(layer?: AsyncDataLayer) {
|
||||
const tasks: Task[] = [];
|
||||
let counter = 0;
|
||||
const store = {
|
||||
@@ -45,26 +43,28 @@ function makeStore(db?: Database) {
|
||||
tasks.push(task);
|
||||
return task;
|
||||
},
|
||||
getDatabase() {
|
||||
if (!db) throw new Error("test database not configured");
|
||||
return db;
|
||||
getAsyncLayer() {
|
||||
if (!layer) throw new Error("test AsyncDataLayer not configured");
|
||||
return layer;
|
||||
},
|
||||
_tasks: tasks,
|
||||
};
|
||||
return store as unknown as TaskStore & { _tasks: Task[] };
|
||||
}
|
||||
|
||||
function makeDbStore() {
|
||||
const dir = mkdtempSync(join(tmpdir(), "kb-signal-routes-"));
|
||||
tempDirs.push(dir);
|
||||
const db = new Database(join(dir, ".fusion"));
|
||||
db.init();
|
||||
openDbs.push(db);
|
||||
return { db, store: makeStore(db) };
|
||||
async function makeDbStore() {
|
||||
const harness = await createTaskStoreForTest();
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: monitor writes are tenant-bound;
|
||||
// bind the isolated layer so incident ingestion exercises that invariant.
|
||||
(harness.layer as { projectId?: string }).projectId = "signal-routes-project";
|
||||
harnesses.push(harness);
|
||||
return { layer: harness.layer, store: makeStore(harness.layer) };
|
||||
}
|
||||
|
||||
function incidents(db: Database) {
|
||||
return db.prepare("SELECT groupingKey, source, severity, status, meta FROM incidents ORDER BY id ASC").all() as Array<{
|
||||
async function incidents(layer: AsyncDataLayer) {
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: inspect seeded connector rows via
|
||||
// the project schema instead of the removed synchronous SQLite Database API.
|
||||
return await layer.db.execute(sql`SELECT grouping_key AS "groupingKey", source, severity, status, meta::text AS meta FROM project.incidents WHERE project_id = ${layer.projectId} ORDER BY id ASC`) as Array<{
|
||||
groupingKey: string;
|
||||
source: string | null;
|
||||
severity: string | null;
|
||||
@@ -82,8 +82,7 @@ const SECRETS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const tempDirs: string[] = [];
|
||||
const openDbs: Database[] = [];
|
||||
const harnesses: PgTestHarness[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const [k, v] of Object.entries(SECRETS)) {
|
||||
@@ -92,13 +91,12 @@ beforeEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
for (const k of Object.keys(SECRETS)) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = savedEnv[k];
|
||||
}
|
||||
while (openDbs.length > 0) openDbs.pop()?.close();
|
||||
while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true });
|
||||
while (harnesses.length > 0) await harnesses.pop()?.teardown();
|
||||
});
|
||||
|
||||
function ctxFor(source: SignalSource, payload: object, headers: Record<string, string>) {
|
||||
@@ -127,7 +125,7 @@ function signedSignalContext(source: SignalSource, payload: object) {
|
||||
}
|
||||
}
|
||||
|
||||
describe("getSignalSource registry", () => {
|
||||
pgDescribe("getSignalSource registry", () => {
|
||||
it("resolves all five providers and rejects unknown", () => {
|
||||
expect(getSignalSource("webhook")).toBe(webhookSource);
|
||||
expect(getSignalSource("sentry")).toBe(sentrySource);
|
||||
@@ -138,7 +136,7 @@ describe("getSignalSource registry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingestSignal — generic webhook (must-work path)", () => {
|
||||
pgDescribe("ingestSignal — generic webhook (must-work path)", () => {
|
||||
it("creates one triage task for a valid signed payload", async () => {
|
||||
const store = makeStore();
|
||||
const ts = Date.now();
|
||||
@@ -282,7 +280,7 @@ describe("ingestSignal — generic webhook (must-work path)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingestSignal — Sentry adapter", () => {
|
||||
pgDescribe("ingestSignal — Sentry adapter", () => {
|
||||
it("creates one triage task with normalized title/severity/link + groupingKey from issue.id", async () => {
|
||||
const store = makeStore();
|
||||
const payload = {
|
||||
@@ -331,7 +329,7 @@ describe("ingestSignal — Sentry adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingestSignal — Datadog & PagerDuty adapters (groupingKey from native primitive)", () => {
|
||||
pgDescribe("ingestSignal — Datadog & PagerDuty adapters (groupingKey from native primitive)", () => {
|
||||
it("Datadog uses aggreg_key as groupingKey", async () => {
|
||||
const store = makeStore();
|
||||
const payload = { aggreg_key: "agg-7", event_id: "ev-7", title: "High CPU", alert_type: "error" };
|
||||
@@ -436,7 +434,7 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
describe("ingestSignal — GitLab adapter", () => {
|
||||
pgDescribe("ingestSignal — GitLab adapter", () => {
|
||||
it("creates a triage task for a valid project issue webhook from a self-managed URL", async () => {
|
||||
const store = makeStore();
|
||||
const payload = gitlabIssuePayload();
|
||||
@@ -488,7 +486,7 @@ describe("ingestSignal — GitLab adapter", () => {
|
||||
});
|
||||
|
||||
it("maps issue and merge-request lifecycle actions without suppressing recovery events", async () => {
|
||||
const { db, store } = makeDbStore();
|
||||
const { layer, store } = await makeDbStore();
|
||||
const open = gitlabIssuePayload({ object_attributes: { action: "open", state: "opened", updated_at: "2026-03-04T00:00:00.000Z" } });
|
||||
const close = gitlabIssuePayload({ object_attributes: { action: "close", state: "closed", updated_at: "2026-03-04T00:05:00.000Z" } });
|
||||
|
||||
@@ -506,7 +504,7 @@ describe("ingestSignal — GitLab adapter", () => {
|
||||
})).status).toBe(201);
|
||||
|
||||
expect(store._tasks).toHaveLength(2);
|
||||
expect(incidents(db)).toMatchObject([{ groupingKey: "gitlab:gitlabhq/gitlab-test:issue:23", source: "gitlab", status: "resolved" }]);
|
||||
expect(await incidents(layer)).toMatchObject([{ groupingKey: "gitlab:gitlabhq/gitlab-test:issue:23", source: "gitlab", status: "resolved" }]);
|
||||
const updateSignal = gitlabSource.normalize(gitlabIssuePayload({ object_attributes: { action: "update", state: "opened" } }), ctxFor(gitlabSource, open, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET }));
|
||||
const reopenSignal = gitlabSource.normalize(gitlabIssuePayload({ object_attributes: { action: "reopen", state: "opened" } }), ctxFor(gitlabSource, open, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET }));
|
||||
const mergedSignal = gitlabSource.normalize(gitlabMergeRequestPayload({ object_attributes: { action: "merge", state: "merged" } }), ctxFor(gitlabSource, open, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET }));
|
||||
@@ -578,7 +576,7 @@ describe("ingestSignal — GitLab adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingestSignal — incident capture", () => {
|
||||
pgDescribe("ingestSignal — incident capture", () => {
|
||||
it("writes source and normalized severity for all configured providers", async () => {
|
||||
const cases = [
|
||||
{
|
||||
@@ -634,7 +632,7 @@ describe("ingestSignal — incident capture", () => {
|
||||
] as const;
|
||||
|
||||
for (const c of cases) {
|
||||
const { db, store } = makeDbStore();
|
||||
const { layer, store } = await makeDbStore();
|
||||
const raw = JSON.stringify(c.payload);
|
||||
const res = await ingestSignal({
|
||||
source: c.source,
|
||||
@@ -645,7 +643,7 @@ describe("ingestSignal — incident capture", () => {
|
||||
nonceCache: new DeliveryNonceCache(),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(incidents(db)).toMatchObject([{
|
||||
expect(await incidents(layer)).toMatchObject([{
|
||||
groupingKey: c.expected.groupingKey,
|
||||
source: c.expected.source,
|
||||
severity: c.expected.severity,
|
||||
@@ -655,7 +653,7 @@ describe("ingestSignal — incident capture", () => {
|
||||
});
|
||||
|
||||
it("absorbs re-fires by grouping key without inserting duplicate incident rows", async () => {
|
||||
const { db, store } = makeDbStore();
|
||||
const { layer, store } = await makeDbStore();
|
||||
const mk = (id: string) => {
|
||||
const payload = { id, title: "Same outage", severity: "error", groupingKey: "same-outage" };
|
||||
const raw = JSON.stringify(payload);
|
||||
@@ -675,7 +673,7 @@ describe("ingestSignal — incident capture", () => {
|
||||
expect((await ingestSignal(mk("refire-1"))).status).toBe(201);
|
||||
expect((await ingestSignal(mk("refire-2"))).status).toBe(201);
|
||||
|
||||
const rows = incidents(db);
|
||||
const rows = await incidents(layer);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(JSON.parse(rows[0].meta ?? "{}")).toMatchObject({ occurrences: 2 });
|
||||
});
|
||||
@@ -745,7 +743,7 @@ describe("ingestSignal — incident capture", () => {
|
||||
] as const;
|
||||
|
||||
for (const c of cases) {
|
||||
const { db, store } = makeDbStore();
|
||||
const { layer, store } = await makeDbStore();
|
||||
expect((await ingestSignal({
|
||||
source: c.source,
|
||||
store,
|
||||
@@ -759,12 +757,12 @@ describe("ingestSignal — incident capture", () => {
|
||||
nonceCache: new DeliveryNonceCache(),
|
||||
})).status).toBe(201);
|
||||
|
||||
expect(incidents(db)).toMatchObject([{ groupingKey: c.groupingKey, source: c.expectedSource, status: "resolved" }]);
|
||||
expect(await incidents(layer)).toMatchObject([{ groupingKey: c.groupingKey, source: c.expectedSource, status: "resolved" }]);
|
||||
}
|
||||
});
|
||||
|
||||
it("also resolves PagerDuty incidents when only data.status is resolved", async () => {
|
||||
const { db, store } = makeDbStore();
|
||||
const { layer, store } = await makeDbStore();
|
||||
const openedAt = new Date().toISOString();
|
||||
const openPayload = {
|
||||
event: {
|
||||
@@ -796,7 +794,7 @@ describe("ingestSignal — incident capture", () => {
|
||||
nonceCache: new DeliveryNonceCache(),
|
||||
})).status).toBe(201);
|
||||
|
||||
expect(incidents(db)).toMatchObject([{ groupingKey: "pd-status-resolve", source: "pagerduty", status: "resolved" }]);
|
||||
expect(await incidents(layer)).toMatchObject([{ groupingKey: "pd-status-resolve", source: "pagerduty", status: "resolved" }]);
|
||||
});
|
||||
|
||||
it("keeps connector acceptance successful when the best-effort incident write fails", async () => {
|
||||
@@ -822,7 +820,7 @@ describe("ingestSignal — incident capture", () => {
|
||||
});
|
||||
|
||||
it("feeds connector-recorded incidents into aggregateSignalsAnalytics breakdowns", async () => {
|
||||
const { db, store } = makeDbStore();
|
||||
const { layer, store } = await makeDbStore();
|
||||
const sentryPayload = {
|
||||
id: "sentry-analytics-open",
|
||||
data: { issue: { id: "sentry-analytics", title: "Sentry analytics", level: "fatal" } },
|
||||
@@ -849,7 +847,7 @@ describe("ingestSignal — incident capture", () => {
|
||||
nonceCache: new DeliveryNonceCache(),
|
||||
})).status).toBe(201);
|
||||
|
||||
const analytics = await aggregateSignalsAnalytics(db, {
|
||||
const analytics = await aggregateSignalsAnalytics(layer, {
|
||||
from: "2026-03-01T00:00:00.000Z",
|
||||
to: "2026-03-31T00:00:00.000Z",
|
||||
});
|
||||
@@ -866,7 +864,7 @@ describe("ingestSignal — incident capture", () => {
|
||||
});
|
||||
|
||||
it("does not write incidents for malformed or duplicate payloads", async () => {
|
||||
const { db, store } = makeDbStore();
|
||||
const { layer, store } = await makeDbStore();
|
||||
const malformed = { nope: true };
|
||||
const malformedRaw = JSON.stringify(malformed);
|
||||
expect((await ingestSignal({
|
||||
@@ -897,11 +895,11 @@ describe("ingestSignal — incident capture", () => {
|
||||
expect((await ingestSignal(mk())).status).toBe(201);
|
||||
expect((await ingestSignal(mk())).deduped).toBe(true);
|
||||
|
||||
expect(incidents(db)).toHaveLength(1);
|
||||
expect(await incidents(layer)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
pgDescribe("helpers", () => {
|
||||
it("resolveSignalSecret reads the provider env var", () => {
|
||||
expect(resolveSignalSecret(webhookSource)).toBe("wh-secret");
|
||||
expect(resolveSignalSecret(webhookSource, {})).toBeUndefined();
|
||||
|
||||
@@ -1,44 +1,56 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { AgentStore, TaskStore } from "@fusion/core";
|
||||
import { postgresSchema, TaskStore } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import { createServer } from "../server.js";
|
||||
import { get } from "../test-request.js";
|
||||
|
||||
describe("GET /api/agents/:id/prompt-sizes integration", () => {
|
||||
let rootDir: string;
|
||||
pgDescribe("GET /api/agents/:id/prompt-sizes integration", () => {
|
||||
let harness: PgTestHarness;
|
||||
let store: TaskStore;
|
||||
let agentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "fn-4928-prompt-sizes-"));
|
||||
store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"));
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: route integration fixtures use
|
||||
// the canonical PG harness because TaskStore no longer supports SQLite.
|
||||
harness = await createTaskStoreForTest();
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: AgentStore persists agent runs
|
||||
// by project, so bind the isolated harness layer before seeding telemetry.
|
||||
(harness.layer as { projectId?: string }).projectId = "prompt-sizes-project";
|
||||
store = harness.store;
|
||||
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.createAgent({
|
||||
agentId = "agent-prompt-sizes";
|
||||
const projectId = harness.layer.projectId!;
|
||||
await harness.layer.db.insert(postgresSchema.project.agents).values({
|
||||
projectId,
|
||||
id: agentId,
|
||||
name: "Prompt Sizes Agent",
|
||||
role: "executor",
|
||||
metadata: {},
|
||||
state: "active",
|
||||
createdAt: "2026-05-17T12:00:00.000Z",
|
||||
updatedAt: "2026-05-17T12:00:00.000Z",
|
||||
data: {},
|
||||
});
|
||||
agentId = agent.id;
|
||||
|
||||
await agentStore.saveRun({
|
||||
await harness.layer.db.insert(postgresSchema.project.agentRuns).values({
|
||||
projectId,
|
||||
id: "run-prompt-size-1",
|
||||
agentId,
|
||||
startedAt: "2026-05-17T12:00:00.000Z",
|
||||
endedAt: "2026-05-17T12:00:01.000Z",
|
||||
status: "completed",
|
||||
systemPrompt: "sys prompt",
|
||||
executionPrompt: "execute now",
|
||||
data: {
|
||||
id: "run-prompt-size-1",
|
||||
agentId,
|
||||
startedAt: "2026-05-17T12:00:00.000Z",
|
||||
endedAt: "2026-05-17T12:00:01.000Z",
|
||||
status: "completed",
|
||||
systemPrompt: "sys prompt",
|
||||
executionPrompt: "execute now",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await harness.teardown();
|
||||
});
|
||||
|
||||
it("returns prompt-size rows derived from startedAt and run JSON", async () => {
|
||||
|
||||
@@ -5,8 +5,9 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import { buildViewPreloadInjection, createServer } from "../server.js";
|
||||
import { createLoopbackIntegrationTest } from "./loopback-integration-test.js";
|
||||
|
||||
@@ -54,24 +55,35 @@ function runPreloadBootstrap(injection: string, taskView: string, projectId?: st
|
||||
}
|
||||
|
||||
async function startServerWithFixture(clientDir: string) {
|
||||
const rootDir = makeTempDir("fn-4782-root-");
|
||||
const globalDir = makeTempDir("fn-4782-global-");
|
||||
const store = new TaskStore(rootDir, globalDir);
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-16-06:50: preload integration starts the
|
||||
// real server against an isolated async TaskStore, never a retired SQLite store.
|
||||
const harness = await createTaskStoreForTest();
|
||||
const store = harness.store;
|
||||
|
||||
const previousClientDir = process.env.FUSION_CLIENT_DIR;
|
||||
process.env.FUSION_CLIENT_DIR = clientDir;
|
||||
|
||||
const app = createServer(store);
|
||||
// FNXC:PostgresCutover 2026-07-16-07:30: preload tests exercise static HTML
|
||||
// injection, not AI-session recovery. Supply the async satellite contract so
|
||||
// its fire-and-forget recovery cannot outlive this fixture's isolated database.
|
||||
const aiSessionStore = {
|
||||
on: () => undefined,
|
||||
off: () => undefined,
|
||||
recoverStaleSessions: async () => 0,
|
||||
listRecoverable: async () => [],
|
||||
cleanupStaleSessions: async () => ({ terminalDeleted: 0, orphanedDeleted: 0 }),
|
||||
stopScheduledCleanup: () => undefined,
|
||||
};
|
||||
const app = createServer(store, { aiSessionStore: aiSessionStore as never });
|
||||
const server = await new Promise<import("node:http").Server>((resolve) => {
|
||||
const s = app.listen(0, "127.0.0.1", () => resolve(s));
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
restoreEnv: () => {
|
||||
process.env.FUSION_CLIENT_DIR = previousClientDir;
|
||||
},
|
||||
teardown: () => harness.teardown(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,7 +94,7 @@ afterEach(() => {
|
||||
tempRoots = [];
|
||||
});
|
||||
|
||||
describe("server index preload injection", () => {
|
||||
pgDescribe("server index preload injection", () => {
|
||||
serverViewPreloadIntegrationTest("injects view chunk map and modulepreload bootstrap", async () => {
|
||||
const clientDir = makeTempDir("fn-4782-client-");
|
||||
mkdirSync(join(clientDir, ".vite"), { recursive: true });
|
||||
@@ -98,7 +110,7 @@ describe("server index preload injection", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { server, restoreEnv } = await startServerWithFixture(clientDir);
|
||||
const { server, restoreEnv, teardown } = await startServerWithFixture(clientDir);
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing server address");
|
||||
@@ -117,6 +129,7 @@ describe("server index preload injection", () => {
|
||||
} finally {
|
||||
restoreEnv();
|
||||
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -164,7 +177,7 @@ describe("server index preload injection", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { server, restoreEnv } = await startServerWithFixture(clientDir);
|
||||
const { server, restoreEnv, teardown } = await startServerWithFixture(clientDir);
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing server address");
|
||||
@@ -180,6 +193,7 @@ describe("server index preload injection", () => {
|
||||
} finally {
|
||||
restoreEnv();
|
||||
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -229,7 +243,7 @@ describe("server index preload injection", () => {
|
||||
JSON.stringify({ "components/AgentsView.tsx": { file: "assets/AgentsView-marker.js" } }),
|
||||
);
|
||||
|
||||
const { server, restoreEnv } = await startServerWithFixture(clientDir);
|
||||
const { server, restoreEnv, teardown } = await startServerWithFixture(clientDir);
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing server address");
|
||||
@@ -242,6 +256,7 @@ describe("server index preload injection", () => {
|
||||
} finally {
|
||||
restoreEnv();
|
||||
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -249,7 +264,7 @@ describe("server index preload injection", () => {
|
||||
const clientDir = makeTempDir("fn-4782-client-no-manifest-");
|
||||
writeFileSync(join(clientDir, "index.html"), "<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>");
|
||||
|
||||
const { server, restoreEnv } = await startServerWithFixture(clientDir);
|
||||
const { server, restoreEnv, teardown } = await startServerWithFixture(clientDir);
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing server address");
|
||||
@@ -262,6 +277,7 @@ describe("server index preload injection", () => {
|
||||
} finally {
|
||||
restoreEnv();
|
||||
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -274,7 +290,7 @@ describe("server index preload injection", () => {
|
||||
JSON.stringify({ "components/AgentsView.tsx": { file: "assets/AgentsView-</script>-abc.js" } }),
|
||||
);
|
||||
|
||||
const { server, restoreEnv } = await startServerWithFixture(clientDir);
|
||||
const { server, restoreEnv, teardown } = await startServerWithFixture(clientDir);
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing server address");
|
||||
@@ -288,6 +304,7 @@ describe("server index preload injection", () => {
|
||||
} finally {
|
||||
restoreEnv();
|
||||
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,17 @@ class MockStore extends EventEmitter {
|
||||
|
||||
getRootDir(): string { return "/repo"; }
|
||||
getFusionDir(): string { return "/repo/.fusion"; }
|
||||
getDatabase() { return { exec: vi.fn(), prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), all: vi.fn().mockReturnValue([]), get: vi.fn() }) }; }
|
||||
// FNXC:PostgresCutover 2026-07-16-06:55: server setup probes the async
|
||||
// layer, so this route double exposes the production-shaped backend seam.
|
||||
getAsyncLayer = vi.fn(() => ({
|
||||
db: {
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ returning: vi.fn(async () => []) })),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
getSettings = vi.fn(async () => this.getSettingsFast());
|
||||
getSettingsFast = vi.fn(async (): Promise<Settings> => ({
|
||||
defaultProvider: "base-default-provider",
|
||||
|
||||
@@ -80,6 +80,9 @@ function createMockStore(fusionDir: string) {
|
||||
return {
|
||||
getRootDir: vi.fn().mockReturnValue(path.dirname(fusionDir)),
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: avatar routes construct their
|
||||
// AgentStore through the backend accessor, so doubles retain that shape.
|
||||
getAsyncLayer: vi.fn().mockReturnValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
searchTasks: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -5,12 +5,10 @@ FNXC:MissionWorkflows 2026-06-25-00:00:
|
||||
Mission route tests encode the invariant that both single-feature and slice bulk triage honor a supplied workflowId, preserve default inheritance when it is omitted, and reject invalid workflow selections before linking features.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import type { WorkflowIr } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
@@ -31,119 +29,120 @@ function linearIr(name: string): WorkflowIr {
|
||||
};
|
||||
}
|
||||
|
||||
describe("mission triage routes workflowId", () => {
|
||||
pgDescribe("mission triage routes workflowId", () => {
|
||||
let harness: PgTestHarness;
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "mission-wf-route-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "mission-wf-route-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-16-06:50: mission triage route assertions
|
||||
// need a real async store because the in-memory SQLite fixture was removed.
|
||||
harness = await createTaskStoreForTest();
|
||||
store = harness.store;
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await harness.teardown();
|
||||
});
|
||||
|
||||
const post = (path: string, body: unknown) =>
|
||||
REQUEST(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" });
|
||||
|
||||
function createMissionFeature(title = "Feature") {
|
||||
async function createMissionFeature(title = "Feature") {
|
||||
const missionStore = store.getMissionStore();
|
||||
const mission = missionStore.createMission({ title: "Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = missionStore.addFeature(slice.id, { title });
|
||||
// FNXC:PostgresCutover 2026-07-16-06:55: mission store mutations and
|
||||
// reads are asynchronous against PostgreSQL; await each linked entity.
|
||||
const mission = await missionStore.createMission({ title: "Mission" });
|
||||
const milestone = await missionStore.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = await missionStore.addSlice(milestone.id, { title: "Slice" });
|
||||
const feature = await missionStore.addFeature(slice.id, { title });
|
||||
return { missionStore, mission, milestone, slice, feature };
|
||||
}
|
||||
|
||||
it("POST /missions/features/:featureId/triage assigns the supplied workflowId", async () => {
|
||||
const workflow = await store.createWorkflowDefinition({ name: "Mission Feature Route", ir: linearIr("mission-feature-route") });
|
||||
const { feature } = createMissionFeature("Route Feature");
|
||||
const { feature } = await createMissionFeature("Route Feature");
|
||||
|
||||
const res = await post(`/api/missions/features/${feature.id}/triage`, { workflowId: workflow.id });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const taskId = (res.body as { taskId: string }).taskId;
|
||||
expect(store.getTaskWorkflowSelection(taskId)?.workflowId).toBe(workflow.id);
|
||||
expect((await store.getTaskWorkflowSelectionAsync(taskId))?.workflowId).toBe(workflow.id);
|
||||
});
|
||||
|
||||
it("POST /missions/slices/:sliceId/triage-all assigns the supplied workflowId to all created tasks", async () => {
|
||||
const workflow = await store.createWorkflowDefinition({ name: "Mission Slice Route", ir: linearIr("mission-slice-route") });
|
||||
const { missionStore, slice } = createMissionFeature("Route Feature 1");
|
||||
missionStore.addFeature(slice.id, { title: "Route Feature 2" });
|
||||
const { missionStore, slice } = await createMissionFeature("Route Feature 1");
|
||||
await missionStore.addFeature(slice.id, { title: "Route Feature 2" });
|
||||
|
||||
const res = await post(`/api/missions/slices/${slice.id}/triage-all`, { workflowId: workflow.id });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const triaged = (res.body as { triaged: Array<{ taskId: string }> }).triaged;
|
||||
expect(triaged).toHaveLength(2);
|
||||
expect(triaged.map((feature) => store.getTaskWorkflowSelection(feature.taskId)?.workflowId)).toEqual([workflow.id, workflow.id]);
|
||||
expect(await Promise.all(triaged.map(async (feature) => (
|
||||
await store.getTaskWorkflowSelectionAsync(feature.taskId)
|
||||
)?.workflowId))).toEqual([workflow.id, workflow.id]);
|
||||
});
|
||||
|
||||
it("omitting workflowId preserves default workflow inheritance for feature triage", async () => {
|
||||
const workflow = await store.createWorkflowDefinition({ name: "Mission Route Default", ir: linearIr("mission-route-default") });
|
||||
await store.setDefaultWorkflowId(workflow.id);
|
||||
const { feature } = createMissionFeature("Default Route Feature");
|
||||
const { feature } = await createMissionFeature("Default Route Feature");
|
||||
|
||||
const res = await post(`/api/missions/features/${feature.id}/triage`, {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.getTaskWorkflowSelection((res.body as { taskId: string }).taskId)?.workflowId).toBe(workflow.id);
|
||||
expect((await store.getTaskWorkflowSelectionAsync((res.body as { taskId: string }).taskId))?.workflowId).toBe(workflow.id);
|
||||
});
|
||||
|
||||
it("omitting workflowId preserves default workflow inheritance for slice bulk triage", async () => {
|
||||
const workflow = await store.createWorkflowDefinition({ name: "Mission Bulk Route Default", ir: linearIr("mission-bulk-route-default") });
|
||||
await store.setDefaultWorkflowId(workflow.id);
|
||||
const { slice } = createMissionFeature("Default Bulk Route Feature");
|
||||
const { slice } = await createMissionFeature("Default Bulk Route Feature");
|
||||
|
||||
const res = await post(`/api/missions/slices/${slice.id}/triage-all`, {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const triaged = (res.body as { triaged: Array<{ taskId: string }> }).triaged;
|
||||
expect(triaged).toHaveLength(1);
|
||||
expect(store.getTaskWorkflowSelection(triaged[0].taskId)?.workflowId).toBe(workflow.id);
|
||||
expect((await store.getTaskWorkflowSelectionAsync(triaged[0].taskId))?.workflowId).toBe(workflow.id);
|
||||
});
|
||||
|
||||
it("invalid workflowId type returns 400 without linking the feature", async () => {
|
||||
const { missionStore, feature } = createMissionFeature("Invalid Workflow Feature");
|
||||
const { missionStore, feature } = await createMissionFeature("Invalid Workflow Feature");
|
||||
|
||||
const res = await post(`/api/missions/features/${feature.id}/triage`, { workflowId: 42 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(missionStore.getFeature(feature.id)?.taskId).toBeUndefined();
|
||||
expect((await missionStore.getFeature(feature.id))?.taskId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invalid workflowId type for slice bulk triage returns 400 without linking features", async () => {
|
||||
const { missionStore, slice } = createMissionFeature("Invalid Bulk Workflow Feature");
|
||||
const { missionStore, slice } = await createMissionFeature("Invalid Bulk Workflow Feature");
|
||||
|
||||
const res = await post(`/api/missions/slices/${slice.id}/triage-all`, { workflowId: 42 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(missionStore.listFeatures(slice.id).every((candidate) => !candidate.taskId)).toBe(true);
|
||||
expect((await missionStore.listFeatures(slice.id)).every((candidate) => !candidate.taskId)).toBe(true);
|
||||
});
|
||||
|
||||
it("unknown workflowId returns a 4xx without linking feature or slice tasks", async () => {
|
||||
const { missionStore, slice, feature } = createMissionFeature("Unknown Workflow Feature");
|
||||
missionStore.addFeature(slice.id, { title: "Unknown Workflow Slice Feature" });
|
||||
const { missionStore, slice, feature } = await createMissionFeature("Unknown Workflow Feature");
|
||||
await missionStore.addFeature(slice.id, { title: "Unknown Workflow Slice Feature" });
|
||||
|
||||
const single = await post(`/api/missions/features/${feature.id}/triage`, { workflowId: "WF-MISSING" });
|
||||
expect(single.status).toBeGreaterThanOrEqual(400);
|
||||
expect(single.status).toBeLessThan(500);
|
||||
expect(missionStore.getFeature(feature.id)?.taskId).toBeUndefined();
|
||||
expect((await missionStore.getFeature(feature.id))?.taskId).toBeUndefined();
|
||||
|
||||
const bulk = await post(`/api/missions/slices/${slice.id}/triage-all`, { workflowId: "WF-MISSING" });
|
||||
expect(bulk.status).toBeGreaterThanOrEqual(400);
|
||||
expect(bulk.status).toBeLessThan(500);
|
||||
expect(missionStore.listFeatures(slice.id).every((candidate) => !candidate.taskId)).toBe(true);
|
||||
expect((await missionStore.listFeatures(slice.id)).every((candidate) => !candidate.taskId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,9 @@ function createApp(pluginRunner?: Record<string, unknown>) {
|
||||
getSettings: vi.fn(async () => ({ worktrunk: { enabled: false }, memoryDreamsEnabled: true })),
|
||||
getRootDir: vi.fn(() => "/tmp/project"),
|
||||
getFusionDir: vi.fn(() => "/tmp/project/.fusion"),
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: all scoped-store doubles expose
|
||||
// the backend accessor even when the mocked AgentStore does not consume it.
|
||||
getAsyncLayer: vi.fn(() => undefined),
|
||||
updateSettings: vi.fn(async (patch: Record<string, unknown>) => patch),
|
||||
};
|
||||
|
||||
@@ -73,7 +76,16 @@ function createApp(pluginRunner?: Record<string, unknown>) {
|
||||
options: { pluginRunner },
|
||||
store: {} as any,
|
||||
runtimeLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as any,
|
||||
getProjectContext: vi.fn(async () => ({ store: scopedStore, projectId: "p1" })),
|
||||
getProjectContext: vi.fn(async () => ({
|
||||
store: scopedStore,
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: memory routes resolve the
|
||||
// project identity through the engine before constructing async agents.
|
||||
engine: {
|
||||
getProjectId: () => "p1",
|
||||
getRoutineStore: () => undefined,
|
||||
},
|
||||
projectId: "p1",
|
||||
})),
|
||||
rethrowAsApiError: (err: unknown) => {
|
||||
throw err;
|
||||
},
|
||||
|
||||
@@ -25,7 +25,9 @@ describe("merge advance events route", () => {
|
||||
it("returns empty events when audit store is empty", async () => {
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents: vi.fn(() => []),
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: API routes use the async
|
||||
// run-audit accessor when backed by PostgreSQL, including test doubles.
|
||||
getRunAuditEventsAsync: vi.fn(async () => []),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
@@ -75,7 +77,7 @@ describe("merge advance events route", () => {
|
||||
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents,
|
||||
getRunAuditEventsAsync: vi.fn(async (filters?: { mutationType?: string }) => getRunAuditEvents(filters)),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
@@ -154,7 +156,7 @@ describe("merge advance events route", () => {
|
||||
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents: vi.fn((filters?: { mutationType?: string }) => {
|
||||
getRunAuditEventsAsync: vi.fn(async (filters?: { mutationType?: string }) => {
|
||||
if (filters?.mutationType === "merge:integration-ref-advance") return [advance];
|
||||
if (filters?.mutationType === "merge:auto-sync") return [clean, conflict, stale];
|
||||
return [];
|
||||
@@ -193,7 +195,7 @@ describe("merge advance events route", () => {
|
||||
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents: vi.fn((filters?: { mutationType?: string }) =>
|
||||
getRunAuditEventsAsync: vi.fn(async (filters?: { mutationType?: string }) =>
|
||||
filters?.mutationType === "merge:integration-ref-advance" ? [advance] : []),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
@@ -208,7 +210,7 @@ describe("merge advance events route", () => {
|
||||
it("defaults limit to 20, clamps max to 100, rejects invalid limit", async () => {
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents: vi.fn(() => []),
|
||||
getRunAuditEventsAsync: vi.fn(async () => []),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
@@ -223,9 +225,9 @@ describe("merge advance events route", () => {
|
||||
const badRes = await REQUEST(app, "GET", "/api/tasks/merge-advance-events?limit=abc");
|
||||
expect(badRes.status).toBe(400);
|
||||
|
||||
const getRunAuditEvents = store.getRunAuditEvents as unknown as ReturnType<typeof vi.fn>;
|
||||
const firstAdvanceCall = getRunAuditEvents.mock.calls.find((call) => call[0]?.mutationType === "merge:integration-ref-advance");
|
||||
const secondAdvanceCall = [...getRunAuditEvents.mock.calls].reverse().find((call) => call[0]?.mutationType === "merge:integration-ref-advance");
|
||||
const getRunAuditEventsAsync = store.getRunAuditEventsAsync as unknown as ReturnType<typeof vi.fn>;
|
||||
const firstAdvanceCall = getRunAuditEventsAsync.mock.calls.find((call) => call[0]?.mutationType === "merge:integration-ref-advance");
|
||||
const secondAdvanceCall = [...getRunAuditEventsAsync.mock.calls].reverse().find((call) => call[0]?.mutationType === "merge:integration-ref-advance");
|
||||
expect(firstAdvanceCall?.[0]?.limit).toBe(20);
|
||||
expect(secondAdvanceCall?.[0]?.limit).toBe(100);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,9 @@ const createHarness = (taskState: any, events: RunAuditEvent[]) => {
|
||||
}
|
||||
return taskState;
|
||||
}),
|
||||
getRunAuditEvents: vi.fn((options: Record<string, unknown> = {}) => {
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: runtime fallback status reads
|
||||
// audit events asynchronously from the PostgreSQL-backed task store.
|
||||
getRunAuditEventsAsync: vi.fn(async (options: Record<string, unknown> = {}) => {
|
||||
let filtered = events;
|
||||
if (options.taskId) {
|
||||
filtered = filtered.filter((e) => e.taskId === options.taskId);
|
||||
|
||||
@@ -10,36 +10,35 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import type { ProjectEngine } from "@fusion/engine";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
describe("task-detail planner-overseer control routes", () => {
|
||||
pgDescribe("task-detail planner-overseer control routes", () => {
|
||||
let harness: PgTestHarness;
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "overseer-controls-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "overseer-controls-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: planner-overseer route coverage
|
||||
// uses an isolated real backend rather than the retired in-memory SQLite mode.
|
||||
harness = await createTaskStoreForTest();
|
||||
store = harness.store;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await harness.teardown();
|
||||
});
|
||||
|
||||
function buildApp(engine: Partial<ProjectEngine> | undefined): express.Express {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, engine ? { engine: engine as unknown as ProjectEngine } : undefined));
|
||||
app.use("/api", createApiRoutes(store, engine ? {
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: project-aware routes resolve
|
||||
// engine services only when the test double advertises its project id.
|
||||
engine: { getProjectId: () => "test-project", ...engine } as unknown as ProjectEngine,
|
||||
} : undefined));
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,38 +6,37 @@
|
||||
// snapshot, omit entirely (byte-identical payload) otherwise, and never
|
||||
// fail the board load even when the accessor throws.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import type { ProjectEngine } from "@fusion/engine";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
describe("GET /tasks — plannerOverseerState enrichment", () => {
|
||||
pgDescribe("GET /tasks — plannerOverseerState enrichment", () => {
|
||||
let harness: PgTestHarness;
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-16-06:50: planner state API coverage must
|
||||
// exercise the PostgreSQL TaskStore rather than the removed SQLite mode.
|
||||
harness = await createTaskStoreForTest();
|
||||
store = harness.store;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await harness.teardown();
|
||||
});
|
||||
|
||||
function buildApp(engine: Partial<ProjectEngine> | undefined): express.Express {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, engine ? { engine: engine as unknown as ProjectEngine } : undefined));
|
||||
app.use("/api", createApiRoutes(store, engine ? {
|
||||
// FNXC:PostgresCutover 2026-07-16-06:55: project-aware route enrichment
|
||||
// resolves engine state only when the test double supplies its project id.
|
||||
engine: { getProjectId: () => "test-project", ...engine } as unknown as ProjectEngine,
|
||||
} : undefined));
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -118,28 +117,29 @@ describe("GET /tasks — plannerOverseerState enrichment", () => {
|
||||
// modal's Overseer/Nudge controls read the snapshot from the full-detail
|
||||
// payload, not the list payload, so the detail route previously never
|
||||
// carried it and Nudge always showed the periodic-observation disabled copy.
|
||||
describe("GET /tasks/:id — plannerOverseerState enrichment", () => {
|
||||
pgDescribe("GET /tasks/:id — plannerOverseerState enrichment", () => {
|
||||
let harness: PgTestHarness;
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-detail-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-detail-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-16-06:50: detail-route state enrichment
|
||||
// shares the isolated async-store fixture used by the board-list surface.
|
||||
harness = await createTaskStoreForTest();
|
||||
store = harness.store;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
afterEach(async () => {
|
||||
await harness.teardown();
|
||||
});
|
||||
|
||||
function buildApp(engine: Partial<ProjectEngine> | undefined): express.Express {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, engine ? { engine: engine as unknown as ProjectEngine } : undefined));
|
||||
app.use("/api", createApiRoutes(store, engine ? {
|
||||
// FNXC:PostgresCutover 2026-07-16-06:55: project-aware route enrichment
|
||||
// resolves engine state only when the test double supplies its project id.
|
||||
engine: { getProjectId: () => "test-project", ...engine } as unknown as ProjectEngine,
|
||||
} : undefined));
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe, type PgTestHarness } from "../../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import type { WorkflowIr } from "@fusion/core";
|
||||
import { registerWorkflowRoutes } from "../register-workflow-routes.js";
|
||||
import { ApiError, sendErrorResponse } from "../../api-error.js";
|
||||
@@ -23,17 +21,16 @@ function linearIr(): WorkflowIr {
|
||||
} as WorkflowIr;
|
||||
}
|
||||
|
||||
describe("POST /api/workflows/validate", () => {
|
||||
pgDescribe("POST /api/workflows/validate", () => {
|
||||
let harness: PgTestHarness;
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "wf-validate-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "wf-validate-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
// FNXC:PostgresCutover 2026-07-16-06:30: validation routes exercise the
|
||||
// production async persistence contract rather than removed SQLite fixtures.
|
||||
harness = await createTaskStoreForTest();
|
||||
store = harness.store;
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
@@ -54,9 +51,7 @@ describe("POST /api/workflows/validate", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await store.close?.();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
await harness.teardown();
|
||||
});
|
||||
|
||||
async function userDefCount(): Promise<number> {
|
||||
|
||||
@@ -377,6 +377,10 @@ const qualityAppBackfillTests = ["app/**/*.test.{ts,tsx}"];
|
||||
|
||||
const backfillApiExclude = [
|
||||
...qualityApiTests,
|
||||
// FNXC:DashboardDistArtifacts 2026-07-16-08:20: plugin-registry-dist asserts
|
||||
// emitted server files and runs through the explicit test:build command after
|
||||
// its dist bootstrap, rather than adding a full build to API backfill shards.
|
||||
"src/__tests__/plugin-registry-dist.test.ts",
|
||||
...skipListDashboardGlobs.filter((file) => file.startsWith("src/")),
|
||||
];
|
||||
const qualityApiBackfillTests = ["src/**/*.test.{ts,tsx}"];
|
||||
|
||||
Reference in New Issue
Block a user