import { createHash } from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; import { startPreview } from '../bin/preview.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const skillRoot = path.resolve(here, '..'); function sha256(file) { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } async function stateAt(url) { const response = await fetch(new URL('/state', url)); assert.equal(response.status, 200); return response.json(); } async function waitForState(url, predicate, message, timeoutMs = 12000) { const started = Date.now(); let latest; while (Date.now() - started < timeoutMs) { latest = await stateAt(url); if (predicate(latest)) return latest; await new Promise((resolve) => setTimeout(resolve, 40)); } assert.fail(`${message}; latest state: ${JSON.stringify(latest)}`); } function rawRequest(url, { method = 'GET', pathname = '/', hostHeader } = {}) { const target = new URL(url); return new Promise((resolve, reject) => { const request = http.request({ hostname: target.hostname, port: target.port, method, path: pathname, headers: hostHeader ? { Host: hostHeader } : undefined, }, (response) => { let body = ''; response.setEncoding('utf8'); response.on('data', (chunk) => { body += chunk; }); response.on('end', () => resolve({ status: response.statusCode, body, headers: response.headers })); }); request.on('error', reject); request.end(); }); } test('preview: rejects destructive or unsupported startup targets before watching', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-startup-')); const input = path.join(tmp, 'diagram.json'); fs.writeFileSync(input, '{}'); await assert.rejects( startPreview({ type: 'architecture', input, output: input, open: false }), /must not replace its JSON input/i, ); await assert.rejects( startPreview({ type: 'mindmap', input, output: path.join(tmp, 'out.html'), open: false }), /Unknown diagram type/i, ); await assert.rejects( startPreview({ type: 'architecture', input, output: path.join(tmp, 'out.html'), quality: 'pretty', open: false }), /Unknown quality profile/i, ); const realDirectory = path.join(tmp, 'real'); const linkedDirectory = path.join(tmp, 'linked'); fs.mkdirSync(realDirectory); fs.symlinkSync(realDirectory, linkedDirectory, 'dir'); await assert.rejects( startPreview({ type: 'architecture', input: path.join(realDirectory, 'future.json'), output: path.join(linkedDirectory, 'future.json'), open: false, }), /must not replace its JSON input/i, ); assert.deepEqual(fs.readdirSync(tmp).filter((name) => name.startsWith('.archify-preview-')), []); }); test('preview: invalid candidates preserve the last verified artifact and repair automatically', { timeout: 30000 }, async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-last-good-')); const input = path.join(tmp, 'diagram.architecture.json'); const output = path.join(tmp, 'diagram.html'); const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8')); source.meta.title = 'Last Good One'; fs.writeFileSync(input, JSON.stringify(source)); const preview = await startPreview({ type: 'architecture', input, output, quality: 'showcase', open: false, debounceMs: 60, pollMs: 80, }); try { const first = await waitForState(preview.url, (state) => state.status === 'verified' && state.revision === 1, 'first revision did not verify'); assert.equal(first.generation, 1); assert.equal(first.lastVerified.sha256, sha256(output)); const firstSha = sha256(output); const firstArtifact = await (await fetch(new URL('/artifact.html', preview.url))).text(); assert.match(firstArtifact, /Last Good One/); fs.rmSync(input); const missing = await waitForState(preview.url, (state) => state.status === 'needs-fix' && state.generation === 2, 'deleted source did not report failure'); assert.equal(missing.failure.stage, 'input'); assert.equal(missing.revision, 1); assert.equal(sha256(output), firstSha, 'deleted input replaced the last verified output'); fs.writeFileSync(input, '{"meta":'); const failed = await waitForState(preview.url, (state) => state.status === 'needs-fix' && state.generation === 3, 'invalid source did not report failure'); assert.equal(failed.revision, 1); assert.equal(failed.failure.stage, 'input'); assert.match(failed.failure.message, /Could not read delivery input/); assert.doesNotMatch(JSON.stringify(failed), new RegExp(input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); assert.equal(sha256(output), firstSha, 'invalid input replaced the last verified output'); assert.equal(await (await fetch(new URL('/artifact.html', preview.url))).text(), firstArtifact); source.components[0].unexpected = true; fs.writeFileSync(input, JSON.stringify(source)); const schemaFailed = await waitForState(preview.url, (state) => state.status === 'needs-fix' && state.generation === 4, 'schema failure did not report render stage'); assert.equal(schemaFailed.failure.stage, 'render'); assert.match(schemaFailed.failure.message, /\/components\/0.*additional properties/i); assert.doesNotMatch(schemaFailed.failure.message, /file:\/\/|\/Users\/|node:internal/); assert.equal(sha256(output), firstSha, 'schema failure replaced the last verified output'); delete source.components[0].unexpected; source.meta.title = 'Verified Repair'; source.components[0].label = 'Repaired Browser'; fs.writeFileSync(input, JSON.stringify(source)); const repaired = await waitForState(preview.url, (state) => state.status === 'verified' && state.revision === 2, 'repaired source did not publish'); assert.equal(repaired.generation, 5); assert.notEqual(repaired.lastVerified.sha256, firstSha); const repairedArtifact = await (await fetch(new URL('/artifact.html', preview.url))).text(); assert.match(repairedArtifact, /Verified Repair/); assert.match(repairedArtifact, /Repaired Browser/); assert.equal(repaired.lastVerified.sha256, sha256(output)); const page = await rawRequest(preview.url); assert.equal(page.status, 200); assert.match(page.body, /Archify Live Preview/); assert.match(page.body, /View diagnostic<\/summary>/); assert.match(page.headers['content-security-policy'], /default-src 'none'/); const script = page.body.match(/