412 lines
12 KiB
JavaScript
412 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import ts from "typescript";
|
|
|
|
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const failures = [];
|
|
|
|
function loadTsModule(sourcePath, requireOverrides = {}) {
|
|
const source = readFileSync(sourcePath, "utf8");
|
|
const compiled = ts.transpileModule(source, {
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
});
|
|
|
|
const moduleExports = {};
|
|
const module = { exports: moduleExports };
|
|
const localRequire = (specifier) => {
|
|
if (specifier in requireOverrides) {
|
|
return requireOverrides[specifier];
|
|
}
|
|
throw new Error(`Unexpected runtime import in model test: ${specifier}`);
|
|
};
|
|
|
|
new Function("exports", "module", "require", compiled.outputText)(
|
|
moduleExports,
|
|
module,
|
|
localRequire,
|
|
);
|
|
return module.exports;
|
|
}
|
|
|
|
function loadExpectedModule(sourcePath, label, expectedExports = []) {
|
|
if (!existsSync(sourcePath)) {
|
|
const expected = expectedExports.join(", ");
|
|
failures.push(
|
|
`${label} is missing at ${path.relative(process.cwd(), sourcePath)}; expected exports: ${expected}`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return loadTsModule(sourcePath);
|
|
} catch (error) {
|
|
failures.push(`${label} failed to load: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function expectFunction(moduleExports, exportName, label) {
|
|
if (!moduleExports) {
|
|
return null;
|
|
}
|
|
|
|
const value = moduleExports?.[exportName];
|
|
if (typeof value !== "function") {
|
|
failures.push(`${label} must export ${exportName}()`);
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function recordAssertion(label, assertion) {
|
|
try {
|
|
await assertion();
|
|
} catch (error) {
|
|
failures.push(`${label}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
const workflowModelPath = path.resolve(
|
|
testDir,
|
|
"../src/features/admin-workflows/model.ts",
|
|
);
|
|
const apiPath = path.resolve(testDir, "../src/shared/pipeline-api.ts");
|
|
const navigationModelPath = path.resolve(
|
|
testDir,
|
|
"../src/widgets/role-navigation/model.ts",
|
|
);
|
|
|
|
const expectedWorkflowModelExports = [
|
|
"defaultWorkflowDraft",
|
|
"buildCreateWorkflowPayload",
|
|
"defaultWorkflowStageDraft",
|
|
"buildCreateWorkflowStagePayload",
|
|
"moveWorkflowStage",
|
|
"buildReorderWorkflowStagesPayload",
|
|
];
|
|
|
|
const workflowDraft = {
|
|
name: "Article production workflow",
|
|
slug: "article-production-v1",
|
|
description: "Reusable editorial workflow for longform article production.",
|
|
};
|
|
const workflowId = "11111111-1111-1111-1111-111111111111";
|
|
const intakeStageId = "22222222-2222-2222-2222-222222222222";
|
|
const draftStageId = "33333333-3333-3333-3333-333333333333";
|
|
const workflowPart = {
|
|
key: "required-context-checklist",
|
|
type: "checklist",
|
|
title: "Required context checklist",
|
|
payload: {
|
|
prompt: "Identify missing audience, keyword, and source constraints.",
|
|
config: {
|
|
required_fields: ["audience", "primary_keyword", "source_rules"],
|
|
block_on_missing: true,
|
|
},
|
|
},
|
|
acceptance_criteria: [
|
|
"Missing required context is listed explicitly.",
|
|
"No free-text parsing is needed to read the checklist.",
|
|
],
|
|
};
|
|
const intakeStageDraft = {
|
|
stableKey: "intake-boundary-questions",
|
|
displayName: "Boundary question intake",
|
|
description: "Collect required editorial context before planning.",
|
|
position: 1,
|
|
ownerRole: "EDITOR",
|
|
runnerProfileKey: "boundary-question-agent-v1",
|
|
requiredInputs: ["article_brief", "target_site"],
|
|
expectedOutputs: ["answered_boundary_questions"],
|
|
acceptanceCriteria: ["All required boundary questions are answered."],
|
|
requiresHumanApproval: true,
|
|
retryPolicy: { max_attempts: 1, backoff_seconds: 0 },
|
|
parts: [workflowPart],
|
|
};
|
|
const expectedIntakeStagePayload = {
|
|
stable_key: "intake-boundary-questions",
|
|
display_name: "Boundary question intake",
|
|
description: "Collect required editorial context before planning.",
|
|
position: 1,
|
|
owner_role: "EDITOR",
|
|
runner_profile_key: "boundary-question-agent-v1",
|
|
required_inputs: ["article_brief", "target_site"],
|
|
expected_outputs: ["answered_boundary_questions"],
|
|
acceptance_criteria: ["All required boundary questions are answered."],
|
|
requires_human_approval: true,
|
|
retry_policy: { max_attempts: 1, backoff_seconds: 0 },
|
|
parts: [workflowPart],
|
|
};
|
|
|
|
const workflowModelExports = loadExpectedModule(
|
|
workflowModelPath,
|
|
"features/admin-workflows/model",
|
|
expectedWorkflowModelExports,
|
|
);
|
|
const apiExports = loadExpectedModule(apiPath, "shared/pipeline-api");
|
|
const navigationExports = loadExpectedModule(
|
|
navigationModelPath,
|
|
"widgets/role-navigation/model",
|
|
);
|
|
|
|
const defaultWorkflowDraft = expectFunction(
|
|
workflowModelExports,
|
|
"defaultWorkflowDraft",
|
|
"features/admin-workflows/model",
|
|
);
|
|
const buildCreateWorkflowPayload = expectFunction(
|
|
workflowModelExports,
|
|
"buildCreateWorkflowPayload",
|
|
"features/admin-workflows/model",
|
|
);
|
|
const defaultWorkflowStageDraft = expectFunction(
|
|
workflowModelExports,
|
|
"defaultWorkflowStageDraft",
|
|
"features/admin-workflows/model",
|
|
);
|
|
const buildCreateWorkflowStagePayload = expectFunction(
|
|
workflowModelExports,
|
|
"buildCreateWorkflowStagePayload",
|
|
"features/admin-workflows/model",
|
|
);
|
|
const moveWorkflowStage = expectFunction(
|
|
workflowModelExports,
|
|
"moveWorkflowStage",
|
|
"features/admin-workflows/model",
|
|
);
|
|
const buildReorderWorkflowStagesPayload = expectFunction(
|
|
workflowModelExports,
|
|
"buildReorderWorkflowStagesPayload",
|
|
"features/admin-workflows/model",
|
|
);
|
|
const createWorkflowTemplate = expectFunction(
|
|
apiExports,
|
|
"createWorkflowTemplate",
|
|
"shared/pipeline-api",
|
|
);
|
|
const addWorkflowStage = expectFunction(
|
|
apiExports,
|
|
"addWorkflowStage",
|
|
"shared/pipeline-api",
|
|
);
|
|
const reorderWorkflowStages = expectFunction(
|
|
apiExports,
|
|
"reorderWorkflowStages",
|
|
"shared/pipeline-api",
|
|
);
|
|
const getRoleNavigationItems = expectFunction(
|
|
navigationExports,
|
|
"getRoleNavigationItems",
|
|
"widgets/role-navigation/model",
|
|
);
|
|
|
|
if (
|
|
defaultWorkflowDraft &&
|
|
buildCreateWorkflowPayload &&
|
|
defaultWorkflowStageDraft &&
|
|
buildCreateWorkflowStagePayload &&
|
|
moveWorkflowStage &&
|
|
buildReorderWorkflowStagesPayload
|
|
) {
|
|
await recordAssertion("workflow draft payload maps model state to API request", () => {
|
|
const createPayload = buildCreateWorkflowPayload({
|
|
...defaultWorkflowDraft(),
|
|
...workflowDraft,
|
|
});
|
|
assert.deepEqual(createPayload, workflowDraft);
|
|
});
|
|
|
|
await recordAssertion("workflow stage payload preserves structured parts", () => {
|
|
const stagePayload = buildCreateWorkflowStagePayload({
|
|
...defaultWorkflowStageDraft(),
|
|
...intakeStageDraft,
|
|
});
|
|
assert.deepEqual(stagePayload, expectedIntakeStagePayload);
|
|
});
|
|
|
|
await recordAssertion("workflow stage reorder returns ordered IDs for API", () => {
|
|
const stages = [
|
|
{ id: intakeStageId, stableKey: "intake-boundary-questions", position: 1 },
|
|
{ id: draftStageId, stableKey: "draft-assembly", position: 2 },
|
|
];
|
|
const reorderedStages = moveWorkflowStage(stages, draftStageId, 0);
|
|
assert.deepEqual(
|
|
reorderedStages.map((stage) => ({ id: stage.id, position: stage.position })),
|
|
[
|
|
{ id: draftStageId, position: 1 },
|
|
{ id: intakeStageId, position: 2 },
|
|
],
|
|
);
|
|
assert.deepEqual(buildReorderWorkflowStagesPayload(reorderedStages), {
|
|
stage_ids: [draftStageId, intakeStageId],
|
|
});
|
|
});
|
|
}
|
|
|
|
if (createWorkflowTemplate && addWorkflowStage && reorderWorkflowStages) {
|
|
await recordAssertion("workflow API client posts create/add/reorder requests", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const requests = [];
|
|
const responses = [
|
|
{
|
|
workflow: {
|
|
id: workflowId,
|
|
...workflowDraft,
|
|
version: 1,
|
|
status: "DRAFT",
|
|
stages: [],
|
|
created_at: "2026-05-22T00:00:00Z",
|
|
updated_at: "2026-05-22T00:00:00Z",
|
|
},
|
|
},
|
|
{
|
|
stage: {
|
|
id: intakeStageId,
|
|
workflow_id: workflowId,
|
|
...expectedIntakeStagePayload,
|
|
},
|
|
},
|
|
{
|
|
stage: {
|
|
id: draftStageId,
|
|
workflow_id: workflowId,
|
|
...expectedIntakeStagePayload,
|
|
stable_key: "draft-assembly",
|
|
display_name: "Draft assembly",
|
|
position: 2,
|
|
},
|
|
},
|
|
{
|
|
workflow: {
|
|
id: workflowId,
|
|
...workflowDraft,
|
|
version: 1,
|
|
status: "DRAFT",
|
|
stages: [
|
|
{ id: draftStageId, stable_key: "draft-assembly", position: 1 },
|
|
{
|
|
id: intakeStageId,
|
|
stable_key: "intake-boundary-questions",
|
|
position: 2,
|
|
},
|
|
],
|
|
created_at: "2026-05-22T00:00:00Z",
|
|
updated_at: "2026-05-22T00:00:00Z",
|
|
},
|
|
},
|
|
];
|
|
|
|
globalThis.fetch = async (url, options) => {
|
|
requests.push({
|
|
path: new URL(String(url)).pathname,
|
|
method: options.method,
|
|
userEmail: options.headers["X-Demo-User-Email"],
|
|
body: options.body ? JSON.parse(options.body) : undefined,
|
|
});
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
statusText: "OK",
|
|
text: async () => JSON.stringify(responses.shift()),
|
|
};
|
|
};
|
|
|
|
try {
|
|
const created = await createWorkflowTemplate(workflowDraft);
|
|
const intakeStage = await addWorkflowStage(
|
|
created.workflow.id,
|
|
expectedIntakeStagePayload,
|
|
);
|
|
const draftStage = await addWorkflowStage(created.workflow.id, {
|
|
...expectedIntakeStagePayload,
|
|
stable_key: "draft-assembly",
|
|
display_name: "Draft assembly",
|
|
position: 2,
|
|
});
|
|
const reordered = await reorderWorkflowStages(created.workflow.id, {
|
|
stage_ids: [draftStage.stage.id, intakeStage.stage.id],
|
|
});
|
|
|
|
assert.equal(created.workflow.status, "DRAFT");
|
|
assert.deepEqual(
|
|
reordered.workflow.stages.map((stage) => stage.id),
|
|
[draftStageId, intakeStageId],
|
|
);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
assert.deepEqual(requests, [
|
|
{
|
|
path: "/api/admin/workflows",
|
|
method: "POST",
|
|
userEmail: "admin@example.com",
|
|
body: workflowDraft,
|
|
},
|
|
{
|
|
path: `/api/admin/workflows/${workflowId}/stages`,
|
|
method: "POST",
|
|
userEmail: "admin@example.com",
|
|
body: expectedIntakeStagePayload,
|
|
},
|
|
{
|
|
path: `/api/admin/workflows/${workflowId}/stages`,
|
|
method: "POST",
|
|
userEmail: "admin@example.com",
|
|
body: {
|
|
...expectedIntakeStagePayload,
|
|
stable_key: "draft-assembly",
|
|
display_name: "Draft assembly",
|
|
position: 2,
|
|
},
|
|
},
|
|
{
|
|
path: `/api/admin/workflows/${workflowId}/stages/reorder`,
|
|
method: "POST",
|
|
userEmail: "admin@example.com",
|
|
body: { stage_ids: [draftStageId, intakeStageId] },
|
|
},
|
|
]);
|
|
});
|
|
}
|
|
|
|
if (getRoleNavigationItems) {
|
|
await recordAssertion("Admin navigation exposes Workflows builder route", () => {
|
|
const adminItems = getRoleNavigationItems("ADMIN");
|
|
assert.deepEqual(
|
|
adminItems.find((item) => item.id === "workflows"),
|
|
{
|
|
id: "workflows",
|
|
label: "Workflows",
|
|
href: "/admin/workflows",
|
|
roles: ["ADMIN"],
|
|
},
|
|
);
|
|
});
|
|
|
|
await recordAssertion("Editor navigation does not expose Workflows", () => {
|
|
const editorItems = getRoleNavigationItems("EDITOR");
|
|
assert.equal(
|
|
editorItems.some(
|
|
(item) =>
|
|
item.id === "workflows" ||
|
|
item.href === "/admin/workflows" ||
|
|
item.label === "Workflows",
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
assert.fail(
|
|
`Admin workflow builder frontend pre-requirements missing:\n- ${failures.join("\n- ")}`,
|
|
);
|
|
}
|