This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/packages/icd-test-runner/js/crud/workflows.js
Jeffrey Smith 446edb8333 Feat v0.9.6 deprecate stage_type, collapse stage_mode
stage_type no longer validated — starlark_hook presence determines
automation. stage_mode collapsed from 4→3 values (form/delegated/
automated); "review" mapped to "form" on input for backward compat.
Migration 018 converts existing rows. Review surface removed (~110
lines). 4 package manifests updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:41:55 +00:00

739 lines
34 KiB
JavaScript

/**
* ICD Test Runner — CRUD: Workflows
* Admin workflow lifecycle (create → stages → activate → publish →
* start instance → advance → reject → delete), visitor E2E,
* assignment claim/complete lifecycle.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.workflows = async function (testTag) {
// ── Workflows CRUD (admin — requires workflow.create permission) ──
if (T.user.role === 'admin') {
var wfId = null;
var wfSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-');
await T.test('crud', 'workflows', 'POST /workflows (create)', async function () {
var d = await T.apiPost('/workflows', {
name: testTag + '-workflow',
slug: wfSlug,
description: 'ICD integration test workflow',
entry_mode: 'team_only',
branding: { accent_color: '#2563eb', tagline: 'ICD Test' },
retention: { mode: 'archive', delete_after_days: 1 }
});
T.assertShape(d, T.S.workflow, 'workflow');
T.assert(d.slug === wfSlug, 'slug mismatch: expected ' + wfSlug + ', got ' + d.slug);
wfId = d.id;
T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); });
});
if (wfId) {
await T.test('crud', 'workflows', 'GET /workflows/:id (read)', async function () {
var d = await T.apiGet('/workflows/' + wfId);
T.assertShape(d, T.S.workflow, 'workflow');
T.assert(d.id === wfId, 'id mismatch');
T.assert(d.name.indexOf(testTag) !== -1, 'name mismatch');
});
await T.test('crud', 'workflows', 'PATCH /workflows/:id (update)', async function () {
var d = await T.apiPatch('/workflows/' + wfId, {
description: 'Updated by ICD test runner',
webhook_url: 'https://example.com/hook'
});
T.assert(typeof d === 'object', 'expected object');
});
// ── Stages ──
var stageIds = [];
await T.test('crud', 'workflows', 'POST /workflows/:id/stages (create #1)', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
name: 'Intake',
ordinal: 0,
persona_id: null,
form_template: { fields: ['name', 'email'] },
history_mode: 'full',
auto_transition: false,
transition_rules: {}
});
T.assertShape(d, T.S.workflowStage, 'stage');
T.assert(d.name === 'Intake', 'name mismatch');
stageIds.push(d.id);
});
await T.test('crud', 'workflows', 'POST /workflows/:id/stages (create #2)', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/stages', {
name: 'Review',
ordinal: 1,
persona_id: null,
form_template: {},
history_mode: 'summary',
auto_transition: false,
transition_rules: {}
});
T.assertShape(d, T.S.workflowStage, 'stage');
stageIds.push(d.id);
});
await T.test('crud', 'workflows', 'GET /workflows/:id/stages (list)', async function () {
var d = await T.apiGet('/workflows/' + wfId + '/stages');
T.assertHasKey(d, 'data', '/stages');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(d.data.length >= 2, 'expected at least 2 stages, got ' + d.data.length);
T.assertShape(d.data[0], T.S.workflowStage, 'stage[0]');
});
await T.test('crud', 'workflows', 'PUT /workflows/:id/stages/:sid (update)', async function () {
var d = await T.apiPut('/workflows/' + wfId + '/stages/' + stageIds[0], {
name: 'Intake (updated)',
ordinal: 0,
persona_id: null,
form_template: { fields: ['name', 'email', 'phone'] },
history_mode: 'full',
auto_transition: false,
transition_rules: {}
});
T.assert(typeof d === 'object', 'expected object');
});
if (stageIds.length >= 2) {
await T.test('crud', 'workflows', 'PATCH /workflows/:id/stages/reorder', async function () {
var reversed = stageIds.slice().reverse();
var d = await T.apiPatch('/workflows/' + wfId + '/stages/reorder', {
ordered_ids: reversed
});
T.assert(typeof d === 'object', 'expected object');
});
// Restore original order for subsequent tests
await T.apiPatch('/workflows/' + wfId + '/stages/reorder', { ordered_ids: stageIds });
}
// ── Activate workflow (required before starting instances) ──
await T.test('crud', 'workflows', 'PATCH /workflows/:id (activate)', async function () {
var d = await T.apiPatch('/workflows/' + wfId, { is_active: true });
T.assert(typeof d === 'object', 'expected object');
});
// ── Publish version ──
var versionNum = null;
await T.test('crud', 'workflows', 'POST /workflows/:id/publish', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/publish', {});
T.assertShape(d, T.S.workflowVersion, 'version');
T.assertHasKey(d, 'snapshot', 'version');
versionNum = d.version_number;
});
if (versionNum !== null) {
await T.test('crud', 'workflows', 'GET /workflows/:id/versions/:v (read)', async function () {
var d = await T.apiGet('/workflows/' + wfId + '/versions/' + versionNum);
T.assertShape(d, T.S.workflowVersion, 'version');
T.assert(d.version_number === versionNum, 'version mismatch');
T.assertHasKey(d, 'snapshot', 'version');
});
await T.test('crud', 'workflows', 'POST /workflows/:id/publish (409 duplicate)', async function () {
try {
await T.apiPost('/workflows/' + wfId + '/publish', {});
throw new Error('expected 409 conflict, got success');
} catch (e) {
// 409 is the expected result
if (e.message.indexOf('409') === -1 && e.message.indexOf('conflict') === -1 &&
e.message.indexOf('already') === -1 && e.message.indexOf('expected 409') !== -1)
throw e;
// Otherwise: got the expected rejection
}
});
}
// ── Start instance → status → advance → reject ──
var instanceChannelId = null;
await T.test('crud', 'workflows', 'POST /workflows/:id/start (instance)', async function () {
var d = await T.apiPost('/workflows/' + wfId + '/start', {});
// Response may be: direct channel { id }, or { channel_id }, or { channel: { id } }, or { data: { id } }
var cid = d.id || d.channel_id || (d.channel && d.channel.id) || (d.data && d.data.id);
T.assert(cid, 'no channel ID in start response, got keys: ' + Object.keys(d).join(', '));
instanceChannelId = cid;
T.registerCleanup(function () { if (instanceChannelId) return T.safeDelete('/channels/' + instanceChannelId); });
});
if (instanceChannelId) {
await T.test('crud', 'workflows', 'GET /channels/:id/workflow/status', async function () {
var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status');
T.assertShape(d, T.S.workflowStatus, 'status');
T.assert(d.current_stage === 0, 'expected stage 0, got ' + d.current_stage);
T.assert(d.status === 'active', 'expected active, got ' + d.status);
});
await T.test('crud', 'workflows', 'POST /channels/:id/workflow/advance', async function () {
var d = await T.apiPost('/channels/' + instanceChannelId + '/workflow/advance', {
data: { name: 'ICD Test', email: 'test@icd.local' }
});
T.assert(typeof d === 'object', 'expected object');
});
await T.test('crud', 'workflows', 'GET workflow/status (after advance)', async function () {
var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status');
T.assertShape(d, T.S.workflowStatus, 'status');
// Should be at stage 1 (or completed if only 2 stages and auto-completed)
T.assert(d.current_stage >= 1 || d.status === 'completed',
'expected stage >= 1 or completed, got stage=' + d.current_stage + ' status=' + d.status);
});
// Reject back to stage 0 (only if still active and not at stage 0)
await T.test('crud', 'workflows', 'POST /channels/:id/workflow/reject', async function () {
var st = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status');
if (st.status === 'completed' || st.current_stage === 0) {
// Can't reject from stage 0 or completed — skip gracefully
return;
}
var d = await T.apiPost('/channels/' + instanceChannelId + '/workflow/reject', {
reason: 'ICD test: rejecting back to previous stage'
});
T.assert(typeof d === 'object', 'expected object');
});
// Verify reject went back
await T.test('crud', 'workflows', 'GET workflow/status (after reject)', async function () {
var d = await T.apiGet('/channels/' + instanceChannelId + '/workflow/status');
T.assertShape(d, T.S.workflowStatus, 'status');
// Should be back at 0 or still active
T.assert(d.status === 'active' || d.status === 'completed',
'expected active or completed, got ' + d.status);
});
// Clean up instance channel before workflow delete (cascade may do it, but be explicit)
await T.test('crud', 'workflows', 'DELETE instance channel', async function () {
await T.safeDelete('/channels/' + instanceChannelId);
instanceChannelId = null;
});
}
// ── Stage delete ──
if (stageIds.length >= 2) {
await T.test('crud', 'workflows', 'DELETE /workflows/:id/stages/:sid', async function () {
await T.safeDelete('/workflows/' + wfId + '/stages/' + stageIds[stageIds.length - 1]);
stageIds.pop();
});
}
// ── Workflow delete ──
await T.test('crud', 'workflows', 'DELETE /workflows/:id', async function () {
await T.safeDelete('/workflows/' + wfId);
wfId = null;
});
}
}
// ── Visitor Workflow E2E ──────────────────────────────────
// Tests the anonymous session flow: create public_link workflow → visitor
// starts via entry API (no JWT) → sends messages via session cookie.
await (async function () {
var vwfId = null;
var vwfSlug = testTag + '-visitor-wf';
var visitorChannelId = null;
await T.test('crud', 'workflows', 'POST visitor workflow (public_link)', async function () {
var d = await T.apiPost('/workflows', {
name: testTag + ' Visitor WF',
slug: vwfSlug,
entry_mode: 'public_link'
});
T.assertShape(d, T.S.workflow, 'visitor workflow');
vwfId = d.id;
T.registerCleanup(function () { if (vwfId) return T.safeDelete('/workflows/' + vwfId); });
});
if (vwfId) {
await T.test('crud', 'workflows', 'POST visitor workflow stage', async function () {
await T.apiPost('/workflows/' + vwfId + '/stages', {
name: 'Intake', ordinal: 0, history_mode: 'full'
});
});
await T.test('crud', 'workflows', 'PATCH+publish visitor workflow', async function () {
await T.apiPatch('/workflows/' + vwfId, { is_active: true });
var pub = await T.apiPost('/workflows/' + vwfId + '/publish', {});
T.assert(pub._status === undefined || pub._status === 201 || pub.version_number,
'publish failed');
});
await T.test('crud', 'workflows', 'POST /workflow-entry (visitor start, no auth)', async function () {
var d = await T.publicPost('/workflow-entry/global/' + vwfSlug, {});
T.assert(d._status === 201, 'expected 201, got ' + d._status);
T.assertHasKey(d, 'channel_id', 'visitor start');
T.assertHasKey(d, 'session_id', 'visitor start');
T.assertHasKey(d, 'redirect_to', 'visitor start');
visitorChannelId = d.channel_id;
T.registerCleanup(function () {
if (visitorChannelId) return T.safeDelete('/channels/' + visitorChannelId);
});
});
if (visitorChannelId) {
await T.test('crud', 'workflows', 'POST /w/:id/messages (visitor session)', async function () {
var d = await T.sessionPost('/w/' + visitorChannelId + '/messages', {
content: 'Hello from visitor test',
role: 'user'
});
T.assert(d._status === 200 || d._status === 201, 'visitor message: got ' + d._status);
});
await T.test('crud', 'workflows', 'GET /w/:id/messages (visitor session)', async function () {
var d = await T.sessionGet('/w/' + visitorChannelId + '/messages');
T.assert(d._status === 200, 'visitor list messages: got ' + d._status);
var msgs = d.data || d.messages || d;
T.assert(Array.isArray(msgs), 'expected messages array');
T.assert(msgs.length > 0, 'no messages returned for visitor channel');
});
await T.test('crud', 'workflows', 'GET workflow/status (visitor instance)', async function () {
var d = await T.apiGet('/channels/' + visitorChannelId + '/workflow/status');
T.assertShape(d, T.S.workflowStatus, 'visitor workflow status');
T.assert(d.status === 'active', 'visitor workflow should be active');
});
}
await T.test('crud', 'workflows', 'DELETE visitor workflow', async function () {
if (visitorChannelId) { await T.safeDelete('/channels/' + visitorChannelId); visitorChannelId = null; }
if (vwfId) { await T.safeDelete('/workflows/' + vwfId); vwfId = null; }
});
}
})();
// ── Assignment Claim / Complete ────────────────────────────
// Tests the full assignment lifecycle: advance creates assignment →
// claim → double-claim 409 → complete → double-complete 409.
await (async function () {
var awfId = null;
var aTeamId = null;
var aChannelId = null;
var assignmentId = null;
await T.test('crud', 'workflows', 'assignment: create team + workflow', async function () {
var team = await T.apiPost('/admin/teams', {
name: testTag + '-assign-team', description: 'Assignment test'
});
aTeamId = team.id;
T.registerCleanup(function () { if (aTeamId) return T.safeDelete('/admin/teams/' + aTeamId); });
try { await T.apiPost('/admin/teams/' + aTeamId + '/members', { user_id: T.user.id, role: 'admin' }); }
catch (e) { /* auto-add */ }
var wf = await T.apiPost('/workflows', {
name: testTag + '-assign-wf', entry_mode: 'team_only'
});
awfId = wf.id;
T.registerCleanup(function () { if (awfId) return T.safeDelete('/workflows/' + awfId); });
await T.apiPost('/workflows/' + awfId + '/stages', {
name: 'Collect', ordinal: 0, history_mode: 'full'
});
await T.apiPost('/workflows/' + awfId + '/stages', {
name: 'Review', ordinal: 1, history_mode: 'full',
assignment_team_id: aTeamId
});
await T.apiPatch('/workflows/' + awfId, { is_active: true });
await T.apiPost('/workflows/' + awfId + '/publish', {});
});
if (awfId) {
await T.test('crud', 'workflows', 'assignment: start + advance to review stage', async function () {
var inst = await T.apiPost('/workflows/' + awfId + '/start', {});
T.assertHasKey(inst, 'channel_id', 'instance start');
aChannelId = inst.channel_id;
T.registerCleanup(function () { if (aChannelId) return T.safeDelete('/channels/' + aChannelId); });
var adv = await T.apiPost('/channels/' + aChannelId + '/workflow/advance', {
data: { intake: 'done' }
});
T.assert(adv.current_stage === 1, 'should be at stage 1 after advance');
});
await T.test('crud', 'workflows', 'assignment: GET /workflow-assignments/mine', async function () {
var d = await T.apiGet('/workflow-assignments/mine');
var arr = d.data || [];
T.assert(arr.length > 0, 'expected at least 1 assignment for my teams');
var ours = arr.find(function (a) { return a.team_id === aTeamId; });
T.assert(ours, 'no assignment found for test team');
assignmentId = ours.id;
});
if (assignmentId) {
await T.test('crud', 'workflows', 'assignment: POST claim', async function () {
var d = await T.apiPost('/workflow-assignments/' + assignmentId + '/claim', {});
T.assert(d.claimed === true, 'claim should return claimed=true');
});
await T.test('crud', 'workflows', 'assignment: POST claim (duplicate → 409)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'POST',
'/workflow-assignments/' + assignmentId + '/claim', {});
T.assert(d._status === 409, 'double claim should return 409, got ' + d._status);
});
await T.test('crud', 'workflows', 'assignment: POST complete', async function () {
var d = await T.apiPost('/workflow-assignments/' + assignmentId + '/complete', {});
T.assert(d.completed === true, 'complete should return completed=true');
});
await T.test('crud', 'workflows', 'assignment: POST complete (duplicate → 409)', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'POST',
'/workflow-assignments/' + assignmentId + '/complete', {});
T.assert(d._status === 409, 'double complete should return 409, got ' + d._status);
});
}
await T.test('crud', 'workflows', 'assignment: cleanup', async function () {
if (aChannelId) { await T.safeDelete('/channels/' + aChannelId); aChannelId = null; }
if (awfId) { await T.safeDelete('/workflows/' + awfId); awfId = null; }
if (aTeamId) { await T.safeDelete('/admin/teams/' + aTeamId); aTeamId = null; }
});
}
})();
// ── Form Submission Lifecycle (v0.29.3) ──────────────────
// Tests typed form_template with stage_mode, form validation,
// and the /w/:id/form-submit endpoint.
if (T.user.role === 'admin') {
await (async function () {
var fwfId = null;
var fwfSlug = testTag + '-form-wf';
var fChannelId = null;
await T.test('crud', 'workflows', 'form: create form workflow', async function () {
var wf = await T.apiPost('/workflows', {
name: testTag + ' Form WF',
slug: fwfSlug,
entry_mode: 'public_link'
});
T.assertShape(wf, T.S.workflow, 'form workflow');
fwfId = wf.id;
T.registerCleanup(function () { if (fwfId) return T.safeDelete('/workflows/' + fwfId); });
await T.apiPost('/workflows/' + fwfId + '/stages', {
name: 'Contact Info',
ordinal: 0,
history_mode: 'full',
stage_mode: 'form',
form_template: {
fields: [
{ key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } },
{ key: 'email', type: 'email', label: 'Email', required: true },
{ key: 'dept', type: 'select', label: 'Department', options: [
{ value: 'eng', label: 'Engineering' },
{ value: 'sales', label: 'Sales' }
]}
]
}
});
await T.apiPost('/workflows/' + fwfId + '/stages', {
name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'form'
});
await T.apiPatch('/workflows/' + fwfId, { is_active: true });
await T.apiPost('/workflows/' + fwfId + '/publish', {});
});
if (fwfId) {
await T.test('crud', 'workflows', 'form: start instance', async function () {
var inst = await T.apiPost('/workflows/' + fwfId + '/start', {});
fChannelId = inst.channel_id || inst.id;
T.assert(fChannelId, 'no channel_id in form start');
T.registerCleanup(function () { if (fChannelId) return T.safeDelete('/channels/' + fChannelId); });
});
if (fChannelId) {
await T.test('crud', 'workflows', 'form: GET /w/:id/form (template)', async function () {
var d = await T.sessionGet('/w/' + fChannelId + '/form');
T.assert(d._status === 200, 'expected 200, got ' + d._status);
T.assertHasKey(d, 'stage_mode', 'form response');
T.assert(d.stage_mode === 'form', 'expected form, got ' + d.stage_mode);
T.assertHasKey(d, 'form_template', 'form response');
T.assert(d.form_template.fields && d.form_template.fields.length === 3,
'expected 3 fields, got ' + (d.form_template.fields ? d.form_template.fields.length : 0));
});
await T.test('crud', 'workflows', 'form: submit missing required → 400', async function () {
var d = await T.sessionPost('/w/' + fChannelId + '/form-submit', {
dept: 'eng'
});
T.assert(d._status === 400, 'expected 400, got ' + d._status);
T.assertHasKey(d, 'errors', 'validation response');
T.assert(Array.isArray(d.errors) && d.errors.length > 0, 'expected field errors');
});
await T.test('crud', 'workflows', 'form: submit invalid email → 400', async function () {
var d = await T.sessionPost('/w/' + fChannelId + '/form-submit', {
name: 'Test User',
email: 'not-an-email'
});
T.assert(d._status === 400, 'expected 400, got ' + d._status);
T.assertHasKey(d, 'errors', 'validation response');
var emailErr = d.errors.find(function (e) { return e.key === 'email'; });
T.assert(emailErr, 'expected email validation error');
});
await T.test('crud', 'workflows', 'form: submit valid → 200', async function () {
var d = await T.sessionPost('/w/' + fChannelId + '/form-submit', {
name: 'ICD Test User',
email: 'test@icd.local',
dept: 'eng'
});
T.assert(d._status === 200, 'expected 200, got ' + d._status);
});
await T.test('crud', 'workflows', 'form: verify stage_data after submit', async function () {
var d = await T.apiGet('/channels/' + fChannelId + '/workflow/status');
T.assertShape(d, T.S.workflowStatus, 'status');
// stage_data should contain submitted form values
if (d.stage_data) {
var sd = typeof d.stage_data === 'string' ? JSON.parse(d.stage_data) : d.stage_data;
T.assert(sd.name === 'ICD Test User' || sd.form_name === 'ICD Test User',
'stage_data should contain form values');
}
});
}
await T.test('crud', 'workflows', 'form: cleanup', async function () {
if (fChannelId) { await T.safeDelete('/channels/' + fChannelId); fChannelId = null; }
if (fwfId) { await T.safeDelete('/workflows/' + fwfId); fwfId = null; }
});
}
})();
}
// ── Cross-Visitor Isolation (v0.29.3) ─────────────────────
// Two instances of the same workflow should have isolated stage_data
// and one visitor cannot access the other's form endpoint.
if (T.user.role === 'admin') {
await (async function () {
var xwfId = null;
var xwfSlug = testTag + '-xvisitor';
var chA = null, chB = null;
await T.test('crud', 'workflows', 'xvisitor: setup form workflow', async function () {
var wf = await T.apiPost('/workflows', {
name: testTag + ' XVisitor',
slug: xwfSlug,
entry_mode: 'public_link'
});
xwfId = wf.id;
T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); });
await T.apiPost('/workflows/' + xwfId + '/stages', {
name: 'Form', ordinal: 0, stage_mode: 'form', history_mode: 'full',
form_template: {
fields: [{ key: 'name', type: 'text', label: 'Name', required: true }]
}
});
await T.apiPost('/workflows/' + xwfId + '/stages', {
name: 'Done', ordinal: 1, history_mode: 'full'
});
await T.apiPatch('/workflows/' + xwfId, { is_active: true });
await T.apiPost('/workflows/' + xwfId + '/publish', {});
});
if (xwfId) {
await T.test('crud', 'workflows', 'xvisitor: start two instances', async function () {
var a = await T.apiPost('/workflows/' + xwfId + '/start', {});
chA = a.channel_id || a.id;
T.assert(chA, 'no channel A');
T.registerCleanup(function () { if (chA) return T.safeDelete('/channels/' + chA); });
var b = await T.apiPost('/workflows/' + xwfId + '/start', {});
chB = b.channel_id || b.id;
T.assert(chB, 'no channel B');
T.registerCleanup(function () { if (chB) return T.safeDelete('/channels/' + chB); });
T.assert(chA !== chB, 'channels should be different');
});
if (chA && chB) {
await T.test('crud', 'workflows', 'xvisitor: submit different data to each', async function () {
// Submit to A (admin token)
var dA = await T.apiPost('/channels/' + chA + '/workflow/advance', {
data: { name: 'Alice' }
});
T.assert(typeof dA === 'object', 'advance A');
// Submit to B
var dB = await T.apiPost('/channels/' + chB + '/workflow/advance', {
data: { name: 'Bob' }
});
T.assert(typeof dB === 'object', 'advance B');
});
await T.test('crud', 'workflows', 'xvisitor: verify stage_data isolated', async function () {
var stA = await T.apiGet('/channels/' + chA + '/workflow/status');
var stB = await T.apiGet('/channels/' + chB + '/workflow/status');
// Parse stage_data
var sdA = stA.stage_data;
if (typeof sdA === 'string') sdA = JSON.parse(sdA);
var sdB = stB.stage_data;
if (typeof sdB === 'string') sdB = JSON.parse(sdB);
// A should have Alice, B should have Bob
var aName = sdA && (sdA.name || sdA.form_name);
var bName = sdB && (sdB.name || sdB.form_name);
T.assert(aName === 'Alice', 'channel A stage_data should have Alice, got ' + aName);
T.assert(bName === 'Bob', 'channel B stage_data should have Bob, got ' + bName);
});
}
await T.test('crud', 'workflows', 'xvisitor: cleanup', async function () {
if (chA) { await T.safeDelete('/channels/' + chA); chA = null; }
if (chB) { await T.safeDelete('/channels/' + chB); chB = null; }
if (xwfId) { await T.safeDelete('/workflows/' + xwfId); xwfId = null; }
});
}
})();
}
// ── Workflow Package Export/Import (v0.30.2) ──
if (T.user.role === 'admin') {
await (async function () {
var wpWfId = null;
var wpStage1Id = null;
var wpStage2Id = null;
var wpPkgId = null;
var wpSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-') + '-wp';
await T.test('crud', 'workflows', 'wfpkg: create workflow for export', async function () {
var d = await T.apiPost('/workflows', {
name: testTag + '-wfpkg',
slug: wpSlug,
description: 'Workflow package export/import test',
entry_mode: 'public_link'
});
T.assertShape(d, T.S.workflow, 'workflow');
wpWfId = d.id;
T.registerCleanup(function () {
if (wpWfId) return T.safeDelete('/workflows/' + wpWfId);
});
});
if (wpWfId) {
await T.test('crud', 'workflows', 'wfpkg: add stages with form_template', async function () {
var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
name: 'Intake',
ordinal: 0,
stage_mode: 'form',
history_mode: 'full',
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
});
T.assertShape(s1, T.S.workflowStage, 'stage1');
wpStage1Id = s1.id;
var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', {
name: 'Review',
ordinal: 1,
stage_mode: 'form',
history_mode: 'summary'
});
T.assertShape(s2, T.S.workflowStage, 'stage2');
wpStage2Id = s2.id;
});
// Test surface_pkg_id round-trip
await T.test('crud', 'workflows', 'wfpkg: surface_pkg_id persists on update', async function () {
// Stage update is PUT (full replace), so send all required fields.
// Use the ICD test runner's own package ID (always installed when tests run).
var realPkgId = 'icd-test-runner';
var stageBase = {
name: 'Intake', ordinal: 0, stage_mode: 'form', history_mode: 'full',
form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] }
};
// Set surface_pkg_id
var withPkg = Object.assign({}, stageBase, { surface_pkg_id: realPkgId });
var updated = await T.apiPut('/workflows/' + wpWfId + '/stages/' + wpStage1Id, withPkg);
T.assert(updated.surface_pkg_id === realPkgId,
'surface_pkg_id should persist, got: ' + updated.surface_pkg_id);
// Clear it
var cleared = await T.apiPut('/workflows/' + wpWfId + '/stages/' + wpStage1Id, stageBase);
T.assert(!cleared.surface_pkg_id,
'surface_pkg_id should be cleared, got: ' + cleared.surface_pkg_id);
});
// Export workflow as .pkg
var exportBlob = null;
await T.test('crud', 'workflows', 'wfpkg: GET export .pkg', async function () {
var token = await T.getAuthToken();
var resp = await fetch(T.base + '/api/v1/admin/workflows/' + wpWfId + '/export', {
headers: { 'Authorization': 'Bearer ' + token }
});
T.assert(resp.ok, 'export should return 200, got ' + resp.status);
var ct = resp.headers.get('content-type') || '';
T.assert(ct.indexOf('zip') !== -1 || ct.indexOf('octet') !== -1,
'content-type should be zip/octet, got ' + ct);
exportBlob = await resp.blob();
T.assert(exportBlob.size > 0, 'export blob should not be empty');
});
// Delete original workflow before import to test clean install
await T.test('crud', 'workflows', 'wfpkg: delete original workflow', async function () {
await T.apiDelete('/workflows/' + wpWfId);
wpWfId = null;
});
// Import the .pkg
if (exportBlob) {
await T.test('crud', 'workflows', 'wfpkg: POST install .pkg (re-import)', async function () {
var token = await T.getAuthToken();
var formData = new FormData();
formData.append('file', exportBlob, 'test-workflow.pkg');
var resp = await fetch(T.base + '/api/v1/admin/packages/install', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
var result = await resp.json();
T.assert(resp.ok, 'install should return 200, got ' + resp.status + ': ' + (result.error || ''));
T.assert(result.id, 'install should return package id');
wpPkgId = result.id;
T.registerCleanup(function () {
if (wpPkgId) return T.safeDelete('/admin/packages/' + wpPkgId);
});
});
// Verify the workflow was recreated
await T.test('crud', 'workflows', 'wfpkg: verify imported workflow has stages', async function () {
var wfs = await T.apiGet('/workflows');
var list = wfs.data || wfs || [];
var found = list.find(function (w) { return w.slug === wpSlug; });
T.assert(found, 'imported workflow with slug ' + wpSlug + ' should exist');
wpWfId = found.id;
var stages = await T.apiGet('/workflows/' + wpWfId + '/stages');
var stList = stages.data || stages || [];
T.assert(stList.length === 2, 'should have 2 stages, got ' + stList.length);
T.assert(stList[0].name === 'Intake', 'stage 0 name should be Intake');
T.assert(stList[1].name === 'Review', 'stage 1 name should be Review');
T.assert(stList[0].stage_mode === 'form', 'stage 0 mode should be form');
T.assert(stList[1].stage_mode === 'form', 'stage 1 mode should be form');
});
}
// Cleanup
await T.test('crud', 'workflows', 'wfpkg: cleanup', async function () {
if (wpPkgId) { await T.safeDelete('/admin/packages/' + wpPkgId); wpPkgId = null; }
if (wpWfId) { await T.safeDelete('/workflows/' + wpWfId); wpWfId = null; }
});
}
})();
}
};
})();