feat(task-022): add admin workflow builder ui
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
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- ")}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user