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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
276 lines
11 KiB
JavaScript
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);
|
|
});
|