PSA/tools/workflow-harness/convert-scaffolded-fixtures.cjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

513 lines
18 KiB
JavaScript

#!/usr/bin/env node
/* eslint-disable no-console */
/**
* One-time upgrader for scaffolded workflow-harness fixtures.
*
* Converts fixtures whose `test.cjs` uses `_lib/scaffolded-fixture.cjs` into
* business-valid notification-based fixtures with deterministic DB assertions.
*/
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '../../ee/test-data/workflow-harness');
const CALLWORKFLOW_FIXTURES = new Set([
'invoice-overdue-callworkflow-dunning',
'project-created-callworkflow-tasks',
'project-task-completed-callworkflow',
'ticket-created-call-notify-subworkflow',
'ticket-created-call-triage-subworkflow',
'ticket-created-callworkflow-onboarding',
'ticket-created-two-subworkflows',
]);
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, data) {
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
}
function pascalCaseEvent(eventName) {
return String(eventName)
.trim()
.toLowerCase()
.split(/_+/g)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
}
function schemaRefForEvent(eventName) {
return `payload.${pascalCaseEvent(eventName)}.v1`;
}
function titleFromFixtureName(fixtureName) {
return String(fixtureName)
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
function choosePattern(fixtureName) {
if (CALLWORKFLOW_FIXTURES.has(fixtureName)) return 'callWorkflow';
if (fixtureName.includes('trycatch')) return 'tryCatch';
if (fixtureName.includes('foreach')) return 'forEach';
if (fixtureName.includes('idempotent')) return 'idempotent';
if (fixtureName.includes('multi-branch')) return 'multiBranch';
return 'default';
}
function buildDependenciesNodeTypes(pattern) {
const base = ['action.call', 'control.return', 'state.set', 'transform.assign'];
if (pattern === 'forEach') return [...base, 'control.forEach'];
if (pattern === 'tryCatch') return [...base, 'control.tryCatch'];
if (pattern === 'multiBranch') return [...base, 'control.if'];
if (pattern === 'idempotent') return [...base, 'control.if'];
if (pattern === 'default') return [...base, 'control.if'];
if (pattern === 'callWorkflow') return [...base, 'control.callWorkflow'];
return base;
}
function notificationCallStep({ fixtureName, titleExpr, bodyExpr, recipientsExpr, dedupeExpr }) {
return {
id: 'notify',
type: 'action.call',
config: {
actionId: 'notifications.send_in_app',
version: 1,
inputMapping: {
recipients: { $expr: recipientsExpr },
title: { $expr: titleExpr },
body: { $expr: bodyExpr },
severity: 'info',
dedupe_key: { $expr: dedupeExpr ?? `'fixture.${fixtureName}:' & payload.fixtureDedupeKey` },
},
},
};
}
function buildSteps({ fixtureName, pattern }) {
const markerLiteral = `[fixture ${fixtureName}]`;
if (pattern === 'forEach') {
return [
{ id: 'state-fixture', type: 'state.set', config: { state: 'FIXTURE' } },
{
id: 'assign-text',
type: 'transform.assign',
config: {
assign: {
'vars.marker': { $expr: `'${markerLiteral}'` },
'vars.title': { $expr: `'${markerLiteral} ForEach notify'` },
'vars.body': { $expr: `'${markerLiteral} dedupe=' & payload.fixtureDedupeKey` },
},
},
},
{
id: 'for-each-item',
type: 'control.forEach',
items: {
$expr:
'[{ "i": 0, "userId": payload.fixtureNotifyUserId }, { "i": 1, "userId": payload.fixtureNotifyUserId }]',
},
itemVar: 'item',
body: [
notificationCallStep({
fixtureName,
recipientsExpr: '{ "user_ids": [vars.item.userId] }',
titleExpr: `vars.title & ' #' & vars.item.i`,
bodyExpr: `vars.body & ' item=' & vars.item.i`,
dedupeExpr: `'fixture.${fixtureName}:' & payload.fixtureDedupeKey & ':' & vars.item.i`,
}),
],
onItemError: fixtureName.includes('onitemerror-continue') ? 'continue' : 'fail',
},
{ id: 'done', type: 'control.return' },
];
}
if (pattern === 'tryCatch') {
return [
{ id: 'state-fixture', type: 'state.set', config: { state: 'FIXTURE' } },
{
id: 'assign-text',
type: 'transform.assign',
config: {
assign: {
'vars.marker': { $expr: `'${markerLiteral}'` },
'vars.title': { $expr: `'${markerLiteral} Try/Catch notify'` },
},
},
},
{
id: 'try-notify',
type: 'control.tryCatch',
captureErrorAs: 'caught',
try: [
notificationCallStep({
fixtureName,
recipientsExpr: '{ "user_ids": [payload.fixtureBadUserId] }',
titleExpr: `'${markerLiteral} Try'`,
bodyExpr: `'${markerLiteral} dedupe=' & payload.fixtureDedupeKey & ' attempt=try'`,
dedupeExpr: `'fixture.${fixtureName}:' & payload.fixtureDedupeKey & ':try'`,
}),
{ id: 'return-after-try', type: 'control.return' },
],
catch: [
notificationCallStep({
fixtureName,
recipientsExpr: '{ "user_ids": [payload.fixtureNotifyUserId] }',
titleExpr: `'${markerLiteral} Fallback'`,
bodyExpr:
`'${markerLiteral} dedupe=' & payload.fixtureDedupeKey & ' error=' & coalesce(vars.caught.message, 'unknown')`,
dedupeExpr: `'fixture.${fixtureName}:' & payload.fixtureDedupeKey & ':catch'`,
}),
{ id: 'return-after-catch', type: 'control.return' },
],
},
];
}
if (pattern === 'multiBranch') {
return [
{ id: 'state-fixture', type: 'state.set', config: { state: 'FIXTURE' } },
{
id: 'assign-text',
type: 'transform.assign',
config: {
assign: {
'vars.marker': { $expr: `'${markerLiteral}'` },
'vars.body': { $expr: `'${markerLiteral} dedupe=' & payload.fixtureDedupeKey` },
},
},
},
{
id: 'if-branch-a',
type: 'control.if',
condition: { $expr: "payload.fixtureVariant = 'A'" },
then: [
notificationCallStep({
fixtureName,
recipientsExpr: '{ "user_ids": [payload.fixtureNotifyUserId] }',
titleExpr: `'${markerLiteral} Branch A'`,
bodyExpr: `vars.body & ' branch=A'`,
dedupeExpr: `'fixture.${fixtureName}:' & payload.fixtureDedupeKey & ':A'`,
}),
{ id: 'return-a', type: 'control.return' },
],
else: [
{
id: 'if-branch-b',
type: 'control.if',
condition: { $expr: "payload.fixtureVariant = 'B'" },
then: [
notificationCallStep({
fixtureName,
recipientsExpr: '{ "user_ids": [payload.fixtureNotifyUserId] }',
titleExpr: `'${markerLiteral} Branch B'`,
bodyExpr: `vars.body & ' branch=B'`,
dedupeExpr: `'fixture.${fixtureName}:' & payload.fixtureDedupeKey & ':B'`,
}),
{ id: 'return-b', type: 'control.return' },
],
else: [{ id: 'return-default', type: 'control.return' }],
},
],
},
];
}
// default + idempotent share the same workflow (idempotency asserted in test).
return [
{ id: 'state-fixture', type: 'state.set', config: { state: 'FIXTURE' } },
{
id: 'assign-text',
type: 'transform.assign',
config: {
assign: {
'vars.marker': { $expr: `'${markerLiteral}'` },
'vars.title': { $expr: `'${markerLiteral} Notify'` },
'vars.body': { $expr: `'${markerLiteral} dedupe=' & payload.fixtureDedupeKey` },
},
},
},
{
id: 'if-notify',
type: 'control.if',
condition: { $expr: "(payload.fixtureMode ? payload.fixtureMode : 'notify') = 'notify'" },
then: [
notificationCallStep({
fixtureName,
recipientsExpr: '{ "user_ids": [payload.fixtureNotifyUserId] }',
titleExpr: 'vars.title',
bodyExpr: 'vars.body',
}),
{ id: 'return-after-notify', type: 'control.return' },
],
else: [{ id: 'return-after-skip', type: 'control.return' }],
},
];
}
function updateWorkflowCommon({ workflow, fixtureName, eventName, schemaRef, pattern }) {
const title = titleFromFixtureName(fixtureName);
const description =
pattern === 'forEach'
? `For each generated item, send an in-app notification (${eventName}).`
: pattern === 'tryCatch'
? `Exercise try/catch by attempting an invalid notification, then sending a fallback (${eventName}).`
: pattern === 'multiBranch'
? `Exercise multi-branch control flow by selecting Branch A/B via payload (${eventName}).`
: pattern === 'idempotent'
? `Send an in-app notification with a dedupe key (idempotency) (${eventName}).`
: `Send an in-app notification when the event fires (${eventName}).`;
workflow.metadata.name = `Fixture: ${title}`;
workflow.metadata.description = description;
workflow.metadata.payloadSchemaRef = schemaRef;
workflow.metadata.payloadSchemaMode = 'pinned';
workflow.metadata.pinnedPayloadSchemaRef = schemaRef;
workflow.metadata.trigger = { type: 'event', eventName };
workflow.dependencies.actions = [{ actionId: 'notifications.send_in_app', version: 1 }];
workflow.dependencies.nodeTypes = buildDependenciesNodeTypes(pattern);
workflow.dependencies.schemaRefs = [schemaRef];
const steps = buildSteps({ fixtureName, pattern });
const draftDef = workflow.draft.definition;
draftDef.name = `Fixture: ${title}`;
draftDef.description = description;
draftDef.payloadSchemaRef = schemaRef;
draftDef.trigger = { type: 'event', eventName };
draftDef.steps = steps;
if (!Array.isArray(workflow.publishedVersions) || workflow.publishedVersions.length === 0) {
workflow.publishedVersions = [];
}
// Keep a single published version for normal fixtures; callWorkflow fixtures are handled separately.
if (pattern !== 'callWorkflow') {
const published = workflow.publishedVersions[0] ?? { version: 1, definition: {}, payloadSchemaJson: null };
published.version = 1;
published.payloadSchemaJson = null;
const pubDef = published.definition;
pubDef.id = draftDef.id;
pubDef.version = 1;
pubDef.name = draftDef.name;
pubDef.description = draftDef.description;
pubDef.payloadSchemaRef = draftDef.payloadSchemaRef;
pubDef.trigger = draftDef.trigger;
pubDef.steps = draftDef.steps;
workflow.publishedVersions = [published];
}
}
function buildCallWorkflowBundle({ fixtureName, eventName, schemaRef, originalBundle }) {
const title = titleFromFixtureName(fixtureName);
const parentKey = `fixture.${fixtureName}`;
const childKey = `subfixture.${fixtureName}`;
const parentWorkflowId = originalBundle.workflows?.[0]?.draft?.definition?.id ?? randomFixtureUuid();
const marker = `[fixture ${fixtureName}]`;
const childMarker = `[fixture ${fixtureName} child]`;
const parentSteps = [
{ id: 'state-fixture', type: 'state.set', config: { state: 'FIXTURE' } },
{
id: 'assign-text',
type: 'transform.assign',
config: {
assign: {
'vars.marker': { $expr: `'${marker}'` },
'vars.title': { $expr: `'${marker} Parent'` },
'vars.body': { $expr: `'${marker} dedupe=' & payload.fixtureDedupeKey` },
},
},
},
{
id: 'call-child',
type: 'control.callWorkflow',
workflowId: '00000000-0000-0000-0000-000000000000',
workflowVersion: 1,
inputMapping: {
fixtureNotifyUserId: { $expr: 'payload.fixtureNotifyUserId' },
fixtureDedupeKey: { $expr: 'payload.fixtureDedupeKey' },
},
},
notificationCallStep({
fixtureName,
recipientsExpr: '{ "user_ids": [payload.fixtureNotifyUserId] }',
titleExpr: 'vars.title',
bodyExpr: 'vars.body',
dedupeExpr: `'fixture.${fixtureName}:' & payload.fixtureDedupeKey & ':parent'`,
}),
{ id: 'done', type: 'control.return' },
];
const childSteps = [
{ id: 'state-fixture', type: 'state.set', config: { state: 'FIXTURE' } },
{
id: 'assign-text',
type: 'transform.assign',
config: {
assign: {
'vars.marker': { $expr: `'${childMarker}'` },
'vars.title': { $expr: `'${childMarker} Child'` },
'vars.body': { $expr: `'${childMarker} dedupe=' & payload.fixtureDedupeKey` },
},
},
},
notificationCallStep({
fixtureName,
recipientsExpr: '{ "user_ids": [payload.fixtureNotifyUserId] }',
titleExpr: 'vars.title',
bodyExpr: 'vars.body',
dedupeExpr: `'fixture.${fixtureName}:' & payload.fixtureDedupeKey & ':child'`,
}),
{ id: 'done', type: 'control.return' },
];
const baseMeta = {
isSystem: false,
isVisible: true,
isPaused: false,
concurrencyLimit: null,
autoPauseOnFailure: false,
failureRateThreshold: null,
failureRateMinRuns: null,
retentionPolicyOverride: null,
};
const parent = {
key: parentKey,
metadata: {
name: `Fixture: ${title} (CallWorkflow)`,
description: `Call a sub-workflow and assert both parent+child side effects (${eventName}).`,
payloadSchemaRef: schemaRef,
payloadSchemaMode: 'pinned',
pinnedPayloadSchemaRef: schemaRef,
trigger: { type: 'event', eventName },
...baseMeta,
},
dependencies: {
actions: [{ actionId: 'notifications.send_in_app', version: 1 }],
nodeTypes: ['action.call', 'control.callWorkflow', 'control.return', 'state.set', 'transform.assign'],
schemaRefs: [schemaRef],
},
draft: {
draftVersion: 1,
definition: {
id: parentWorkflowId,
version: 1,
name: `Fixture: ${title} (CallWorkflow)`,
description: `Call a sub-workflow and assert both parent+child side effects (${eventName}).`,
payloadSchemaRef: schemaRef,
trigger: { type: 'event', eventName },
steps: parentSteps,
},
},
publishedVersions: [],
};
const child = {
key: childKey,
metadata: {
name: `Fixture: ${title} (Child)`,
description: `Child workflow for callWorkflow fixture ${fixtureName}.`,
payloadSchemaRef: 'payload.TicketCreated.v1',
payloadSchemaMode: 'pinned',
pinnedPayloadSchemaRef: 'payload.TicketCreated.v1',
trigger: null,
...baseMeta,
},
dependencies: {
actions: [{ actionId: 'notifications.send_in_app', version: 1 }],
nodeTypes: ['action.call', 'control.return', 'state.set', 'transform.assign'],
schemaRefs: ['payload.TicketCreated.v1'],
},
draft: {
draftVersion: 1,
definition: {
id: randomFixtureUuid(),
version: 1,
name: `Fixture: ${title} (Child)`,
description: `Child workflow for callWorkflow fixture ${fixtureName}.`,
payloadSchemaRef: 'payload.TicketCreated.v1',
steps: childSteps,
},
},
publishedVersions: [],
};
return {
...originalBundle,
exportedAt: new Date().toISOString(),
workflows: [parent, child],
};
}
function randomFixtureUuid() {
// Deterministic isn't required; this is a workflow definition id field that is overwritten on publish.
// Keep it valid UUID-shaped for consistency.
return '00000000-0000-0000-0000-00000000' + Math.floor(Math.random() * 0xffff)
.toString(16)
.padStart(4, '0');
}
function buildTestContents({ fixtureName, eventName, schemaRef, pattern }) {
if (pattern === 'callWorkflow') {
return `const { runCallWorkflowFixture } = require('../_lib/callworkflow-fixture.cjs');\n\nmodule.exports = async function run(ctx) {\n return runCallWorkflowFixture(ctx, {\n fixtureName: ${JSON.stringify(fixtureName)},\n eventName: ${JSON.stringify(eventName)},\n schemaRef: ${JSON.stringify(schemaRef)}\n });\n};\n`;
}
return `const { runNotificationFixture } = require('../_lib/notification-fixture.cjs');\n\nmodule.exports = async function run(ctx) {\n return runNotificationFixture(ctx, {\n fixtureName: ${JSON.stringify(fixtureName)},\n eventName: ${JSON.stringify(eventName)},\n schemaRef: ${JSON.stringify(schemaRef)},\n pattern: ${JSON.stringify(pattern)}\n });\n};\n`;
}
function main() {
const dirs = fs.readdirSync(ROOT).filter((name) => fs.statSync(path.join(ROOT, name)).isDirectory());
let converted = 0;
for (const fixtureName of dirs) {
const testPath = path.join(ROOT, fixtureName, 'test.cjs');
const bundlePath = path.join(ROOT, fixtureName, 'bundle.json');
if (!fs.existsSync(testPath) || !fs.existsSync(bundlePath)) continue;
const testSrc = fs.readFileSync(testPath, 'utf8');
if (!testSrc.includes('_lib/scaffolded-fixture.cjs')) continue;
const bundle = readJson(bundlePath);
const eventName = bundle?.workflows?.[0]?.metadata?.trigger?.eventName;
if (!eventName) {
throw new Error(`Missing metadata.trigger.eventName in ${bundlePath}`);
}
const schemaRef = schemaRefForEvent(eventName);
const pattern = choosePattern(fixtureName);
if (pattern === 'callWorkflow') {
const next = buildCallWorkflowBundle({ fixtureName, eventName, schemaRef, originalBundle: bundle });
writeJson(bundlePath, next);
} else {
const wf = bundle.workflows[0];
updateWorkflowCommon({ workflow: wf, fixtureName, eventName, schemaRef, pattern });
bundle.exportedAt = new Date().toISOString();
bundle.workflows = [wf];
writeJson(bundlePath, bundle);
}
fs.writeFileSync(testPath, buildTestContents({ fixtureName, eventName, schemaRef, pattern }), 'utf8');
converted += 1;
}
console.log(`Converted ${converted} scaffolded fixture(s).`);
}
main();