PSA/tools/workflow-harness/generate-biz-counterparts.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

276 lines
11 KiB
JavaScript

#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('node:fs');
const path = require('node:path');
function usage() {
console.error(`
Generate business-relevant counterparts for notification-only workflow-harness fixtures.
Usage:
node tools/workflow-harness/generate-biz-counterparts.cjs \\
--plan planning/adhoc/2026-01-27-biz-test-coverage-analysis/needed-biz-tests.json \\
[--domain ticket]
`);
}
function parseArgs(argv) {
const args = { _: [] };
let i = 0;
while (i < argv.length) {
const t = argv[i];
if (t === '--help' || t === '-h') {
args.help = true;
i += 1;
continue;
}
if (t.startsWith('--')) {
const key = t.slice(2);
const value = argv[i + 1];
if (!value || value.startsWith('--')) throw new Error(`Missing value for --${key}`);
args[key] = value;
i += 2;
continue;
}
args._.push(t);
i += 1;
}
return args;
}
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function replaceDeep(value, from, to) {
if (Array.isArray(value)) return value.map((v) => replaceDeep(v, from, to));
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) out[k] = replaceDeep(v, from, to);
return out;
}
if (typeof value === 'string') return value.split(from).join(to);
return value;
}
function isCallWorkflowBundle(bundle) {
const workflows = Array.isArray(bundle?.workflows) ? bundle.workflows : [];
const hasCall = (steps) => {
if (!Array.isArray(steps)) return false;
for (const s of steps) {
if (!s || typeof s !== 'object') continue;
if (s.type === 'control.callWorkflow') return true;
for (const key of ['then', 'else', 'body', 'try', 'catch']) {
if (hasCall(s[key])) return true;
}
}
return false;
};
return workflows.some((w) => hasCall(w?.draft?.definition?.steps));
}
function walkSteps(steps, visitor) {
if (!Array.isArray(steps)) return;
for (const step of steps) {
if (!step || typeof step !== 'object') continue;
visitor(step);
for (const key of ['then', 'else', 'body', 'try', 'catch']) {
if (Array.isArray(step[key])) walkSteps(step[key], visitor);
}
}
}
function ticketIdExprForEvent(eventName) {
if (eventName === 'TICKET_MERGED') return 'payload.sourceTicketId';
if (eventName === 'TICKET_SPLIT') return 'payload.originalTicketId';
return 'payload.ticketId';
}
function projectIdExprForEvent(eventName) {
if (String(eventName || '').startsWith('PROJECT_')) return 'payload.projectId';
if (eventName === 'TASK_COMMENT_ADDED' || eventName === 'TASK_COMMENT_UPDATED') return 'payload.projectId';
if (String(eventName || '').startsWith('INVOICE_')) return 'payload.invoiceId';
if (String(eventName || '').startsWith('PAYMENT_')) return 'payload.paymentId';
if (String(eventName || '').startsWith('CONTRACT_')) return 'payload.contractId';
if (String(eventName || '').startsWith('COMPANY_')) return 'payload.companyId';
if (String(eventName || '').startsWith('APPOINTMENT_')) return 'payload.appointmentId';
if (String(eventName || '').startsWith('TECHNICIAN_')) return 'payload.appointmentId';
if (String(eventName || '').startsWith('TIME_ENTRY_')) return 'payload.timeEntryId';
if (String(eventName || '').startsWith('SCHEDULE_BLOCK_')) return 'payload.scheduleBlockId';
if (String(eventName || '').startsWith('SCHEDULE_ENTRY_')) return 'payload.entryId';
if (eventName === 'CAPACITY_THRESHOLD_REACHED') return 'payload.teamId';
if (String(eventName || '').startsWith('INTEGRATION_')) return 'payload.integrationId';
if (eventName === 'EMAIL_PROVIDER_CONNECTED') return 'payload.providerId';
return 'payload.projectId';
}
function replaceNotificationActionsInWorkflow({ workflow, kind, fixtureName }) {
const eventName =
workflow?.metadata?.trigger?.eventName ??
workflow?.draft?.definition?.trigger?.eventName ??
workflow?.publishedVersions?.[0]?.definition?.trigger?.eventName ??
null;
const markerFallback = `[fixture ${fixtureName}]`;
const markerExpr = `(vars.marker ? vars.marker : '${markerFallback}')`;
const commentBodyExpr = `${markerExpr} & ' ' & (vars.body ? vars.body : (vars.title ? vars.title : ''))`;
const taskTitleExpr = `${markerExpr} & ' ' & (vars.body ? vars.body : (vars.title ? vars.title : ''))`;
const actionId = kind === 'ticket_comment' ? 'tickets.add_comment' : 'projects.create_task';
const makeActionConfig = () => {
if (kind === 'ticket_comment') {
return {
actionId,
version: 1,
inputMapping: {
ticket_id: { $expr: ticketIdExprForEvent(eventName) },
body: { $expr: commentBodyExpr },
visibility: 'internal'
}
};
}
return {
actionId,
version: 1,
inputMapping: {
project_id: { $expr: projectIdExprForEvent(eventName) },
title: { $expr: taskTitleExpr }
}
};
};
const replaceInSteps = (def) => {
if (!def?.steps) return;
walkSteps(def.steps, (step) => {
if (step.type !== 'action.call') return;
if (step?.config?.actionId !== 'notifications.send_in_app') return;
step.config = makeActionConfig();
});
};
replaceInSteps(workflow?.draft?.definition);
if (Array.isArray(workflow?.publishedVersions)) {
for (const pv of workflow.publishedVersions) replaceInSteps(pv?.definition);
}
const deps = workflow.dependencies ?? {};
const existing = Array.isArray(deps.actions) ? deps.actions.filter((a) => a && a.actionId !== 'notifications.send_in_app') : [];
const hasAction = existing.some((a) => a.actionId === actionId);
deps.actions = hasAction ? existing : [...existing, { actionId, version: 1 }];
workflow.dependencies = deps;
}
function writeIfMissing(filePath, contents) {
if (fs.existsSync(filePath)) throw new Error(`Refusing to overwrite existing file: ${filePath}`);
fs.writeFileSync(filePath, contents, 'utf8');
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function detectPatternFromTestSource(source) {
const match = String(source ?? '').match(/\bpattern\s*:\s*['"]([A-Za-z0-9_]+)['"]/);
return match ? match[1] : null;
}
function testTemplate({ fixtureName, eventName, schemaRef, kind, isCallWorkflow, pattern }) {
if (isCallWorkflow) {
return `const { runCallWorkflowBizFixture } = require('../_lib/biz-fixture.cjs');\n\nmodule.exports = async function run(ctx) {\n return runCallWorkflowBizFixture(ctx, {\n fixtureName: ${JSON.stringify(fixtureName)},\n eventName: ${JSON.stringify(eventName)},\n schemaRef: ${JSON.stringify(schemaRef)},\n kind: ${JSON.stringify(kind)}\n });\n};\n`;
}
if (kind === 'ticket_comment') {
const patternLine = pattern && pattern !== 'default' ? `,\n pattern: ${JSON.stringify(pattern)}` : '';
return `const { runTicketCommentFixture } = require('../_lib/biz-fixture.cjs');\n\nmodule.exports = async function run(ctx) {\n return runTicketCommentFixture(ctx, {\n fixtureName: ${JSON.stringify(fixtureName)},\n eventName: ${JSON.stringify(eventName)},\n schemaRef: ${JSON.stringify(schemaRef)}${patternLine}\n });\n};\n`;
}
const patternLine = pattern && pattern !== 'default' ? `,\n pattern: ${JSON.stringify(pattern)}` : '';
return `const { runProjectTaskFixture } = require('../_lib/biz-fixture.cjs');\n\nmodule.exports = async function run(ctx) {\n return runProjectTaskFixture(ctx, {\n fixtureName: ${JSON.stringify(fixtureName)},\n eventName: ${JSON.stringify(eventName)},\n schemaRef: ${JSON.stringify(schemaRef)}${patternLine}\n });\n};\n`;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
process.exit(0);
}
const planPath = args.plan || 'planning/adhoc/2026-01-27-biz-test-coverage-analysis/needed-biz-tests.json';
const domainFilter = args.domain || null;
const planAbs = path.resolve(process.cwd(), planPath);
const plan = JSON.parse(fs.readFileSync(planAbs, 'utf8'));
const fixturesByDomain = plan.fixturesByDomain || {};
const root = path.resolve(process.cwd(), 'ee/test-data/workflow-harness');
for (const domain of Object.keys(fixturesByDomain)) {
if (domainFilter && domain !== domainFilter) continue;
const entries = Array.isArray(fixturesByDomain[domain]) ? fixturesByDomain[domain] : [];
const sorted = [...entries].sort((a, b) => String(a.current).localeCompare(String(b.current)));
for (const entry of sorted) {
const current = entry.current;
const suggestedBiz = entry.suggestedBiz;
if (!current || !suggestedBiz) throw new Error(`Invalid entry: ${JSON.stringify(entry)}`);
const srcDir = path.join(root, current);
const dstDir = path.join(root, suggestedBiz);
const srcBundlePath = path.join(srcDir, 'bundle.json');
const srcTestPath = path.join(srcDir, 'test.cjs');
const dstBundlePath = path.join(dstDir, 'bundle.json');
const dstTestPath = path.join(dstDir, 'test.cjs');
if (!fs.existsSync(srcBundlePath)) throw new Error(`Missing source bundle.json: ${srcBundlePath}`);
if (fs.existsSync(dstDir)) {
// eslint-disable-next-line no-console
console.log(`Skipping ${suggestedBiz} (already exists)`);
continue;
}
const originalBundle = JSON.parse(fs.readFileSync(srcBundlePath, 'utf8'));
const callWorkflow = isCallWorkflowBundle(originalBundle);
const kind = domain === 'ticket' ? 'ticket_comment' : 'project_task';
let bundle = deepClone(originalBundle);
bundle.exportedAt = new Date().toISOString();
bundle = replaceDeep(bundle, current, suggestedBiz);
if (!Array.isArray(bundle.workflows) || !bundle.workflows.length) {
throw new Error(`Invalid bundle.workflows for ${current}`);
}
for (const workflow of bundle.workflows) {
replaceNotificationActionsInWorkflow({ workflow, kind, fixtureName: suggestedBiz });
}
const eventName = bundle.workflows[0]?.metadata?.trigger?.eventName ?? null;
const schemaRef = bundle.workflows[0]?.metadata?.payloadSchemaRef ?? null;
if (!eventName || !schemaRef) {
throw new Error(`Unable to determine eventName/schemaRef for ${suggestedBiz}`);
}
const originalTestSource = fs.existsSync(srcTestPath) ? fs.readFileSync(srcTestPath, 'utf8') : '';
const pattern = detectPatternFromTestSource(originalTestSource) ?? 'default';
ensureDir(dstDir);
writeIfMissing(dstBundlePath, `${JSON.stringify(bundle, null, 2)}\n`);
writeIfMissing(dstTestPath, testTemplate({ fixtureName: suggestedBiz, eventName, schemaRef, kind, isCallWorkflow: callWorkflow, pattern }));
// eslint-disable-next-line no-console
console.log(`Generated ${suggestedBiz} (from ${current})`);
}
}
}
main().catch((err) => {
console.error(err?.stack ?? err?.message ?? String(err));
usage();
process.exit(1);
});