Files
trackgo-fe/.agents/skills/archify/test/workflow-migration.test.mjs
T
shancheas 166e0d40ac feat: introduce archify skill for generating architecture diagrams
- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams.
- Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats.
- Integrated a user-friendly command-line interface for generating and previewing diagrams.
- Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing.
- Added unit tests to ensure reliability and functionality of the new skill.

These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
2026-08-31 11:31:29 +07:00

621 lines
25 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createHorizontalRankMapper,
migrateWorkflowDocument,
} from '../migrations/workflow-v2.mjs';
import {
createMappedWorkflowCandidate,
intrinsicWorkflow,
planningWorkflow,
} from '../renderers/workflow/workflow-migration-geometry.mjs';
import { compileWorkflow } from '../renderers/workflow/workflow-compiler.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const skillRoot = path.resolve(__dirname, '..');
const cli = path.join(skillRoot, 'bin', 'archify.mjs');
const fixture = path.join(
__dirname,
'fixtures',
'v1-workflow-explicit-coordinates.workflow.json',
);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-workflow-migration-'));
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function runMigration(source, destination, { importModule, env } = {}) {
return spawnSync(process.execPath, [
...(importModule ? ['--import', importModule] : []),
cli,
'migrate',
'workflow',
source,
destination,
'--to-schema',
'2',
'--json',
], {
encoding: 'utf8',
...(env ? { env: { ...process.env, ...env } } : {}),
});
}
function runValidation(source) {
return spawnSync(process.execPath, [cli, 'validate', 'workflow', source, '--json'], {
encoding: 'utf8',
});
}
function parseJsonOutput(result) {
assert.doesNotThrow(
() => JSON.parse(result.stdout),
`expected JSON stdout, received:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
return JSON.parse(result.stdout);
}
function copyFixture(name, mutate = (value) => value) {
const source = path.join(tmp, name);
const value = mutate(JSON.parse(fs.readFileSync(fixture, 'utf8')));
fs.writeFileSync(source, `${JSON.stringify(value, null, 2)}\n`);
return source;
}
function explicitPinConflictWorkflow() {
return {
schema_version: 1,
diagram_type: 'workflow',
meta: {
title: 'Unmappable explicit label pin',
viewBox: [900, 420],
legend: { mode: 'hidden' },
},
lanes: [{ id: 'main', label: 'Main' }],
nodes: [
{ id: 'a', lane: 'main', col: 0, type: 'backend', label: 'A' },
{ id: 'b', lane: 'main', col: 5, type: 'backend', label: 'B' },
],
edges: [{
id: 'ab',
from: 'a',
to: 'b',
label: 'outside',
labelAt: [365, -20],
}],
};
}
function heightConstrainedWorkflow() {
return {
schema_version: 1,
diagram_type: 'workflow',
meta: {
title: 'Legacy height capacity',
viewBox: [720, 240],
legend: { mode: 'hidden' },
},
lanes: [
{ id: 'first', label: 'First' },
{ id: 'second', label: 'Second' },
],
nodes: [
{ id: 'a', lane: 'first', col: 0, type: 'backend', label: 'A' },
{ id: 'b', lane: 'second', col: 2, type: 'database', label: 'B' },
],
edges: [],
};
}
function profileDivergenceWorkflow() {
return {
schema_version: 1,
diagram_type: 'workflow',
meta: {
title: 'profile divergence',
quality_profile: 'showcase',
legend: { mode: 'hidden' },
},
lanes: [
{ id: 'top', label: 'Top' },
{ id: 'middle', label: 'Middle' },
{ id: 'bottom', label: 'Bottom' },
{ id: 'issue', label: 'Issue' },
],
nodes: [
{ id: 'left', lane: 'middle', col: 0, type: 'backend', label: 'Left' },
{ id: 'right', lane: 'middle', col: 4, type: 'backend', label: 'Right' },
{ id: 'above', lane: 'top', col: 2, type: 'backend', label: 'Above' },
{ id: 'below', lane: 'bottom', col: 2, type: 'backend', label: 'Below' },
{ id: 'a', lane: 'issue', col: 1, type: 'backend', label: 'A' },
{ id: 'b', lane: 'issue', col: 2, type: 'backend', label: 'B' },
],
edges: [
{ id: 'ab', from: 'a', to: 'b' },
{ id: 'horizontal', from: 'left', to: 'right', via: [[244, 243]] },
{ id: 'vertical', from: 'above', to: 'below', via: [[300, 200]] },
],
};
}
test('migration geometry helpers construct mapped v2 candidates without mutating the source', () => {
const source = {
schema_version: 1,
diagram_type: 'workflow',
meta: { title: 'Pure migration geometry', viewBox: [720, 400], legend: { mode: 'hidden' } },
lanes: [{ id: 'main', label: 'Main' }],
nodes: [
{ id: 'a', lane: 'main', col: 1, type: 'backend', label: 'A' },
{ id: 'b', lane: 'main', col: 2, type: 'backend', label: 'B' },
],
mainPath: ['a', 'b'],
edges: [{
id: 'ab',
from: 'a',
to: 'b',
label: 'pin',
fromSide: 'top',
toSide: 'top',
via: [[220, 60], [260, 60], [300, 60]],
labelAt: [260, 42],
channelX: 260,
}],
};
const sourceBefore = JSON.parse(JSON.stringify(source));
const intrinsic = intrinsicWorkflow(source);
assert.equal(intrinsic.schema_version, 2);
assert.equal('viewBox' in intrinsic.meta, false);
assert.deepEqual(intrinsic.edges, source.edges);
const planning = planningWorkflow(source);
assert.deepEqual(planning.edges, []);
assert.equal('mainPath' in planning, false);
const mapX = createHorizontalRankMapper([88, 220, 300], [94, 214, 334]);
assert.equal(mapX(260), 274, 'the migration module must preserve the mapper re-export');
const mapped = createMappedWorkflowCandidate(
source,
[88, 220, 300],
[94, 214, 334],
);
assert.equal(mapped.document.schema_version, 2);
assert.deepEqual(mapped.document.meta.viewBox, [720, 400]);
assert.deepEqual(mapped.document.edges[0].via, [[214, 60], [274, 60], [334, 60]]);
assert.deepEqual(mapped.document.edges[0].labelAt, [274, 42]);
assert.equal(mapped.document.edges[0].channelX, 274);
assert.deepEqual(mapped.changedCoordinates, [
{ path: '/edges/0/via/0/0', from: 220, to: 214 },
{ path: '/edges/0/via/1/0', from: 260, to: 274 },
{ path: '/edges/0/via/2/0', from: 300, to: 334 },
{ path: '/edges/0/labelAt/0', from: 260, to: 274 },
{ path: '/edges/0/channelX', from: 260, to: 274 },
]);
assert.deepEqual(source, sourceBefore);
});
test('document migration treats a legacy explicit viewBox as capacity and expands it monotonically', () => {
const source = heightConstrainedWorkflow();
const sourceBefore = JSON.parse(JSON.stringify(source));
const migration = migrateWorkflowDocument(source);
assert.equal(migration.ok, true, JSON.stringify(migration, null, 2));
assert.deepEqual(source, sourceBefore, 'the document migration must not mutate its input');
assert.deepEqual(migration.document.meta.viewBox, [768, 404]);
assert.deepEqual(migration.document.meta.viewBox, migration.newRequiredViewBox);
assert.ok(migration.document.meta.viewBox[0] >= source.meta.viewBox[0]);
assert.ok(migration.document.meta.viewBox[1] >= source.meta.viewBox[1]);
assert.ok(migration.preExistingDiagnostics.some(({ message }) => (
/viewBox height 240/.test(message)
)), JSON.stringify(migration.preExistingDiagnostics, null, 2));
assert.deepEqual(migration.migrationDiagnostics, []);
assert.deepEqual(migration.newSchemaDiagnostics, []);
});
test('CLI atomically commits a capacity-expanded migration and preserves source bytes', () => {
const source = path.join(tmp, 'height-capacity-source.workflow.json');
const destination = path.join(tmp, 'height-capacity-destination.workflow.json');
const sourceBytes = Buffer.from(`${JSON.stringify(heightConstrainedWorkflow(), null, 2)}\n`);
fs.writeFileSync(source, sourceBytes);
fs.writeFileSync(destination, 'destination sentinel\n');
const result = runMigration(source, destination);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(fs.readFileSync(source), sourceBytes, 'the CLI must preserve source bytes');
const destinationBytes = fs.readFileSync(destination);
const migrated = JSON.parse(destinationBytes);
const report = parseJsonOutput(result);
assert.equal(report.ok, true);
assert.equal(migrated.schema_version, 2);
assert.deepEqual(migrated.meta.viewBox, [768, 404]);
assert.deepEqual(migrated.meta.viewBox, report.newRequiredViewBox);
assert.ok(migrated.meta.viewBox[0] >= 720);
assert.ok(migrated.meta.viewBox[1] >= 240);
assert.deepEqual(report.destination, {
path: path.resolve(destination),
sha256: sha256(destinationBytes),
bytes: destinationBytes.length,
});
assert.deepEqual(report.migrationDiagnostics, []);
assert.deepEqual(report.newSchemaDiagnostics, []);
});
test('column-capacity diagnostics do not advertise migration across a quality-profile divergence', () => {
const document = profileDivergenceWorkflow();
const standard = compileWorkflow({ workflow: document, qualityProfile: 'standard' });
assert.equal(standard.ok, false);
const capacity = standard.diagnostics.find(({ code }) => code === 'workflow/column-capacity');
assert.ok(capacity, JSON.stringify(standard.diagnostics, null, 2));
const migration = migrateWorkflowDocument(document);
assert.equal(migration.ok, false, 'the authored showcase migration must reject its pinned crossing');
assert.ok(migration.newSchemaDiagnostics.some(({ code, evidence }) => (
code === 'workflow/explicit-pin-conflict'
&& evidence?.invariant === 'explicit route-route crossing'
)), JSON.stringify(migration, null, 2));
assert.ok(
capacity.supportedFixes.every((fix) => !/migrate this workflow/i.test(fix)),
`a standard-only verification must not advertise the failing authored showcase migration: ${capacity.supportedFixes}`,
);
});
test('document migration uses the authored profile and returns an independently compilable document', () => {
const document = profileDivergenceWorkflow();
document.meta.quality_profile = 'standard';
const migration = migrateWorkflowDocument(document);
assert.equal(migration.ok, true, JSON.stringify(migration, null, 2));
assert.deepEqual(migration.migrationDiagnostics, []);
assert.deepEqual(migration.newSchemaDiagnostics, []);
const standalone = compileWorkflow({ workflow: migration.document });
assert.equal(standalone.ok, true, JSON.stringify(standalone.diagnostics, null, 2));
});
test('document migration defaults an omitted profile to standard', () => {
const document = profileDivergenceWorkflow();
delete document.meta.quality_profile;
const migration = migrateWorkflowDocument(document);
assert.equal(migration.ok, true, JSON.stringify(migration, null, 2));
const standalone = compileWorkflow({ workflow: migration.document });
assert.equal(standalone.ok, true, JSON.stringify(standalone.diagnostics, null, 2));
});
test('CLI migration ignores ambient standard for an authored showcase workflow', () => {
const source = path.join(tmp, 'authored-showcase-source.workflow.json');
const destination = path.join(tmp, 'authored-showcase-destination.workflow.json');
const sourceBytes = Buffer.from(`${JSON.stringify(profileDivergenceWorkflow(), null, 2)}\n`);
fs.writeFileSync(source, sourceBytes);
const migration = runMigration(source, destination, {
env: { ARCHIFY_QUALITY_PROFILE: 'standard' },
});
assert.notEqual(migration.status, 0);
assert.deepEqual(fs.readFileSync(source), sourceBytes);
assert.equal(fs.existsSync(destination), false);
const report = parseJsonOutput(migration);
assert.equal(report.ok, false);
assert.ok(report.newSchemaDiagnostics.some(({ code, evidence }) => (
code === 'workflow/explicit-pin-conflict'
&& evidence?.invariant === 'explicit route-route crossing'
)), JSON.stringify(report, null, 2));
});
test('CLI migration ignores ambient showcase for an authored standard workflow', () => {
const source = path.join(tmp, 'authored-standard-source.workflow.json');
const destination = path.join(tmp, 'authored-standard-destination.workflow.json');
const document = profileDivergenceWorkflow();
document.meta.quality_profile = 'standard';
const sourceBytes = Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
fs.writeFileSync(source, sourceBytes);
const migration = runMigration(source, destination, {
env: { ARCHIFY_QUALITY_PROFILE: 'showcase' },
});
assert.equal(migration.status, 0, migration.stderr || migration.stdout);
assert.deepEqual(fs.readFileSync(source), sourceBytes);
assert.equal(fs.existsSync(destination), true);
const report = parseJsonOutput(migration);
assert.equal(report.ok, true);
const migrated = JSON.parse(fs.readFileSync(destination, 'utf8'));
const standalone = compileWorkflow({ workflow: migrated });
assert.equal(standalone.ok, true, JSON.stringify(standalone.diagnostics, null, 2));
});
test('CLI migration makes the blocking new-schema diagnostic primary over pre-existing findings', () => {
const source = path.join(tmp, 'blocking-diagnostic-source.workflow.json');
const destination = path.join(tmp, 'blocking-diagnostic-destination.workflow.json');
fs.writeFileSync(source, `${JSON.stringify(profileDivergenceWorkflow(), null, 2)}\n`);
const migration = runMigration(source, destination, {
env: { ARCHIFY_QUALITY_PROFILE: 'showcase' },
});
assert.notEqual(migration.status, 0);
assert.equal(fs.existsSync(destination), false);
const report = parseJsonOutput(migration);
assert.equal(report.ok, false);
assert.equal(report.preExistingDiagnostics[0].code, 'workflow/column-capacity');
const blocker = report.newSchemaDiagnostics.find(({ code }) => code === 'workflow/explicit-pin-conflict');
assert.ok(blocker, JSON.stringify(report, null, 2));
assert.equal(report.error, blocker.message);
assert.deepEqual(report.diagnostics[0], blocker);
assert.ok(report.diagnostics.some(({ code }) => code === 'workflow/column-capacity'));
});
test('workflow migration remaps absolute x coordinates, expands only the constrained viewBox axis, and reports hashes', () => {
const source = copyFixture('pinned-source.workflow.json');
const destination = path.join(tmp, 'pinned-destination.workflow.json');
const sourceBefore = fs.readFileSync(source);
const result = runMigration(source, destination);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(fs.readFileSync(source), sourceBefore, 'migration must not mutate its source bytes');
assert.equal(fs.existsSync(destination), true);
const migratedBytes = fs.readFileSync(destination);
const migrated = JSON.parse(migratedBytes);
assert.equal(migrated.schema_version, 2);
assert.deepEqual(migrated.meta.viewBox, [768, 700], 'width grows to fit v2 while sufficient height is preserved');
assert.deepEqual(migrated.edges[0].via, [[214, 119]]);
assert.deepEqual(migrated.edges[1].labelAt, [394, 203]);
assert.equal(migrated.edges[2].channelX, 574);
const report = parseJsonOutput(result);
assert.equal(report.ok, true);
assert.equal(report.command, 'migrate');
assert.equal(report.type, 'workflow');
assert.equal(report.fromSchemaVersion, 1);
assert.equal(report.toSchemaVersion, 2);
assert.deepEqual(report.source, {
path: path.resolve(source),
sha256: sha256(sourceBefore),
bytes: sourceBefore.length,
});
assert.deepEqual(report.destination, {
path: path.resolve(destination),
sha256: sha256(migratedBytes),
bytes: migratedBytes.length,
});
assert.deepEqual(report.preExistingDiagnostics, []);
assert.ok(Array.isArray(report.migrationDiagnostics));
assert.deepEqual(report.newSchemaDiagnostics, []);
assert.deepEqual(report.changedCoordinates, [
{ path: '/edges/0/via/0/0', from: 220, to: 214 },
{ path: '/edges/1/labelAt/0', from: 365, to: 394 },
{ path: '/edges/2/channelX', from: 500, to: 574 },
]);
assert.deepEqual(report.oldRequiredViewBox, [720, 652]);
assert.deepEqual(report.newRequiredViewBox, [768, 652]);
});
test('workflow migration never shrinks an already spacious explicit viewBox', () => {
const source = copyFixture('spacious-source.workflow.json', (workflow) => {
workflow.meta.viewBox = [1600, 900];
return workflow;
});
const destination = path.join(tmp, 'spacious-destination.workflow.json');
const result = runMigration(source, destination);
assert.equal(result.status, 0, result.stderr || result.stdout);
const migrated = JSON.parse(fs.readFileSync(destination, 'utf8'));
assert.deepEqual(migrated.meta.viewBox, [1600, 900]);
assert.equal(parseJsonOutput(result).destination.sha256, sha256(fs.readFileSync(destination)));
});
test('workflow migration reports the measured legacy requirement separately from authored capacity', () => {
const source = {
schema_version: 1,
diagram_type: 'workflow',
meta: {
title: 'Spacious legacy capacity',
viewBox: [1600, 900],
legend: { mode: 'hidden' },
},
lanes: [{ id: 'main', label: 'Main' }],
nodes: [{ id: 'only', lane: 'main', col: 0, type: 'backend', label: 'Only' }],
edges: [],
};
const migration = migrateWorkflowDocument(source);
assert.equal(migration.ok, true, JSON.stringify(migration, null, 2));
assert.deepEqual(migration.oldRequiredViewBox, [720, 280]);
assert.deepEqual(migration.document.meta.viewBox, [1600, 900]);
assert.deepEqual(migration.newRequiredViewBox, [768, 280]);
});
test('workflow migration staging never aliases a destination named like its verification artifact', () => {
const source = copyFixture('artifact-name-source.workflow.json');
const destination = path.join(tmp, 'migration-check.html');
const result = runMigration(source, destination);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(JSON.parse(fs.readFileSync(destination, 'utf8')).schema_version, 2);
assert.equal(parseJsonOutput(result).destination.path, path.resolve(destination));
});
test('workflow migration cleanup failure warns without reversing a successful commit', () => {
const source = copyFixture('cleanup-warning-source.workflow.json');
const destination = path.join(tmp, 'cleanup-warning-destination.workflow.json');
const importModule = path.join(__dirname, 'fixtures', 'fail-migration-cleanup.mjs');
const result = runMigration(source, destination, { importModule });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(JSON.parse(fs.readFileSync(destination, 'utf8')).schema_version, 2);
assert.equal(parseJsonOutput(result).ok, true);
assert.match(result.stderr, /Warning: could not remove workflow migration staging directory/);
assert.match(result.stderr, /simulated migration cleanup failure/);
});
test('workflow migration is idempotent when its v2 destination is migrated again', () => {
const source = copyFixture('idempotent-source.workflow.json');
const firstDestination = path.join(tmp, 'idempotent-first.workflow.json');
const secondDestination = path.join(tmp, 'idempotent-second.workflow.json');
const first = runMigration(source, firstDestination);
assert.equal(first.status, 0, first.stderr || first.stdout);
const second = runMigration(firstDestination, secondDestination);
assert.equal(second.status, 0, second.stderr || second.stdout);
const firstBytes = fs.readFileSync(firstDestination);
const secondBytes = fs.readFileSync(secondDestination);
assert.deepEqual(secondBytes, firstBytes);
const report = parseJsonOutput(second);
assert.equal(report.fromSchemaVersion, 2);
assert.equal(report.toSchemaVersion, 2);
assert.deepEqual(report.changedCoordinates, []);
assert.deepEqual(report.preExistingDiagnostics, []);
assert.deepEqual(report.migrationDiagnostics, []);
assert.deepEqual(report.newSchemaDiagnostics, []);
assert.equal(report.destination.sha256, report.source.sha256);
assert.deepEqual(report.newRequiredViewBox, report.oldRequiredViewBox);
});
test('fallback planning preserves straight-edge rank constraints when mapping absolute pins', () => {
const source = path.join(tmp, 'fallback-rank-source.workflow.json');
const destination = path.join(tmp, 'fallback-rank-destination.workflow.json');
const document = {
schema_version: 1,
diagram_type: 'workflow',
meta: { title: 'Fallback rank mapping', viewBox: [720, 520], legend: { mode: 'hidden' } },
lanes: [
{ id: 'pin', label: 'Pinned label' },
{ id: 'straight', label: 'Straight constraint' },
{ id: 'blocker', label: 'Blocker' },
],
nodes: [
{ id: 'a', lane: 'pin', col: 0, type: 'backend', label: 'A' },
{ id: 'b', lane: 'pin', col: 1, type: 'backend', label: 'B' },
{ id: 'c', lane: 'straight', col: 0, type: 'backend', label: 'C' },
{ id: 'd', lane: 'straight', col: 1, type: 'backend', label: 'D' },
{ id: 'obstacle', lane: 'blocker', col: 4, type: 'database', label: 'Obstacle' },
],
mainPath: ['a', 'b'],
edges: [
{
id: 'pin-edge',
from: 'a',
to: 'b',
label: 'p',
labelAt: [562, 367],
route: 'bottom-channel',
fromSide: 'bottom',
toSide: 'bottom',
},
{ id: 'straight-edge', from: 'c', to: 'd', label: 'x', route: 'straight' },
],
};
fs.writeFileSync(source, `${JSON.stringify(document, null, 2)}\n`);
const result = runMigration(source, destination);
assert.equal(result.status, 0, result.stderr || result.stdout);
const migrated = JSON.parse(fs.readFileSync(destination, 'utf8'));
assert.deepEqual(migrated.edges[0].labelAt, [643.52, 367]);
const report = parseJsonOutput(result);
assert.deepEqual(report.changedCoordinates, [{
path: '/edges/0/labelAt/0',
from: 562,
to: 643.52,
}]);
assert.deepEqual(report.newSchemaDiagnostics, []);
});
for (const { example, expectedEdge } of [
{ example: 'incident-response.workflow.json', expectedEdge: ['alert', 'page'] },
{ example: 'release-delivery.workflow.json', expectedEdge: ['pull_request', 'build'] },
]) {
test(`workflow migration preserves causal route diagnostics for packaged ${example}`, () => {
const source = path.join(skillRoot, 'examples', example);
const destination = path.join(tmp, `migrated-${example}`);
const sourceBefore = fs.readFileSync(source);
const validation = runValidation(source);
assert.equal(validation.status, 0, validation.stderr || validation.stdout);
const result = runMigration(source, destination);
assert.notEqual(result.status, 0);
assert.equal(fs.existsSync(destination), false);
assert.deepEqual(fs.readFileSync(source), sourceBefore);
const failure = parseJsonOutput(result);
assert.equal(failure.diagnostics.length, 1, JSON.stringify(failure.diagnostics, null, 2));
assert.ok(failure.diagnostics.every(({ message }) => !/mainPath step .* no matching edge/.test(message)));
const [diagnostic] = failure.diagnostics;
assert.equal(diagnostic.code, 'workflow/route-preset-conflict');
assert.deepEqual([diagnostic.subject?.from, diagnostic.subject?.to], expectedEdge);
assert.equal(diagnostic.subject?.route, 'drop');
});
}
test('workflow migration rejects the source path as its destination without changing the file', () => {
const source = copyFixture('same-path.workflow.json');
const sourceBefore = fs.readFileSync(source);
const result = runMigration(source, source);
assert.notEqual(result.status, 0);
assert.deepEqual(fs.readFileSync(source), sourceBefore);
const failure = parseJsonOutput(result);
assert.equal(failure.ok, false);
assert.equal(failure.command, 'migrate');
assert.ok(Array.isArray(failure.diagnostics));
assert.equal(failure.diagnostics.length, 1);
assert.equal(failure.diagnostics[0].code, 'migration/source-destination');
assert.match(failure.error, /source|destination/i);
assert.ok(failure.diagnostics[0].supportedFixes.some((fix) => /different.*destination/i.test(fix)));
});
test('workflow migration reports a cyclic-symlink destination as structured JSON', () => {
const source = copyFixture('symlink-cycle-source.workflow.json');
const destination = path.join(tmp, 'migration-cycle-a.workflow.json');
const otherLink = path.join(tmp, 'migration-cycle-b.workflow.json');
fs.symlinkSync(otherLink, destination, 'file');
fs.symlinkSync(destination, otherLink, 'file');
const result = runMigration(source, destination);
assert.notEqual(result.status, 0);
assert.equal(result.stderr, '');
const failure = parseJsonOutput(result);
assert.equal(failure.ok, false);
assert.equal(failure.diagnostics[0].code, 'output/symlink-cycle');
assert.equal(failure.diagnostics[0].subject.output, path.resolve(destination));
});
test('failed workflow migration emits diagnostics and never writes its destination', () => {
const source = path.join(tmp, 'pin-conflict-source.workflow.json');
const destination = path.join(tmp, 'pin-conflict-destination.workflow.json');
fs.writeFileSync(source, `${JSON.stringify(explicitPinConflictWorkflow(), null, 2)}\n`);
assert.equal(fs.existsSync(destination), false);
const result = runMigration(source, destination);
assert.notEqual(result.status, 0);
assert.equal(fs.existsSync(destination), false, 'a failed migration must not leave a partial destination');
const failure = parseJsonOutput(result);
assert.equal(failure.ok, false);
assert.equal(failure.command, 'migrate');
assert.ok(Array.isArray(failure.diagnostics));
assert.ok(
failure.diagnostics.some(({ code }) => code === 'workflow/explicit-pin-conflict'),
JSON.stringify(failure.diagnostics, null, 2),
);
});
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));