fix(phase10): harden responsive layouts and release validation

This commit is contained in:
DeCentN2Madness committed 2026-10-21 23:02:25 -07:00
1 parent d20372fe33
commit 704c8d27c9
7 files changed
+407 -2

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import fg from 'fast-glob';
const readText = async (file) => fs.readFile(file, 'utf8');
function hrefValues(html, rel) {
const values = [];
const pattern = new RegExp(`<link\\b[^>]*\\brel=["']${rel}["'][^>]*\\bhref=["']([^"']+)["']`, 'gi');
for (const match of html.matchAll(pattern)) values.push(match[1]);
return values;
}
function routeFromHtml(file, root) {
const relative = path.relative(root, file).replaceAll(path.sep, '/');
if (relative === 'index.html') return '/';
if (relative.endsWith('/index.html')) return `/${relative.slice(0, -'/index.html'.length)}`;
return `/${relative.replace(/\.html$/, '')}`;
}
function formActions(html) {
return [...html.matchAll(/<form\b([^>]*)>/gi)].map((match) =>
match[1].match(/\baction=["']([^"']*)["']/i)?.[1] ?? null);
}
function unsafeSignals(html) {
const signals = [];
if (/<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe');
if (/<script\b[^>]*\bsrc=["'][^"']*(?:jotform|googletagmanager|google-analytics|doubleclick)/i.test(html)) signals.push('active vendor tracking/form script');
if (/data-(?:form|forms)-mode=["']live["']/i.test(html)) signals.push('live form mode');
if (/api\.azinstitute4autism\.com/i.test(html)) signals.push('production API origin');
if (/AIza[0-9A-Za-z_-]{20,}|-----BEGIN (?:RSA|OPENSSH|EC) PRIVATE KEY-----|(?:api[_-]?key|secret|password)\s*[:=]/i.test(html)) signals.push('credential-like content');
return signals;
}
export async function verifyArtifact(root, mode = 'staging') {
const htmlFiles = (await fg('**/*.html', { cwd: root, absolute: true })).sort();
const routes = [];
const canonicalMap = new Map();
const errors = [];
const warnings = [];
let hreflangLinks = 0;
let activeForms = 0;
let secretSignals = [];
for (const file of htmlFiles) {
const html = await readText(file);
const route = routeFromHtml(file, root);
const robots = html.match(/<meta\b[^>]*\bname=["']robots["'][^>]*\bcontent=["']([^"']*)["']/i)?.[1] ?? null;
const canonical = html.match(/<link\b[^>]*\brel=["']canonical["'][^>]*\bhref=["']([^"']+)["']/i)?.[1] ?? null;
const title = html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim() ?? '';
const actions = formActions(html);
const nonSearchActions = actions.filter((action) => action && action !== '/search');
const signals = unsafeSignals(html);
if (!title) errors.push(`${route}: missing title`);
if (!canonical) errors.push(`${route}: missing canonical`);
if (!robots) errors.push(`${route}: missing robots directive`);
if (mode === 'staging' && robots !== 'noindex,nofollow') errors.push(`${route}: staging robots is ${robots ?? 'missing'}`);
if (canonical) {
const prior = canonicalMap.get(canonical);
if (prior) errors.push(`${route}: duplicate canonical ${canonical} (also ${prior})`);
canonicalMap.set(canonical, route);
}
hreflangLinks += hrefValues(html, 'alternate').filter((href) => href).length;
if (signals.length) secretSignals.push({ route, signals });
if (nonSearchActions.length) activeForms += nonSearchActions.length;
routes.push({ route, title, robots, canonical, nativeForms: actions.length, nonSearchActions: nonSearchActions.length });
}
const sitemapFile = path.join(root, 'sitemap.xml');
const sitemap = await fs.readFile(sitemapFile, 'utf8').catch(() => '');
const sitemapRoutes = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/gi)].map((match) => match[1]);
const reviewIndex = htmlFiles.some((file) => routeFromHtml(file, root) === '/lp');
if (mode === 'staging') {
if (sitemapRoutes.length) errors.push(`staging sitemap contains ${sitemapRoutes.length} URLs`);
if (hreflangLinks) errors.push(`staging emits ${hreflangLinks} hreflang links`);
if (!reviewIndex) errors.push('staging /lp review index is missing');
} else {
if (!sitemapRoutes.length) errors.push('indexing-enabled sitemap is empty');
if (reviewIndex) errors.push('indexing-enabled artifact contains staging-only /lp review index');
}
if (activeForms) errors.push(`found ${activeForms} non-search native form action(s)`);
if (secretSignals.length) errors.push(`unsafe artifact signals: ${JSON.stringify(secretSignals)}`);
const files = (await fg('**/*', { cwd: root, absolute: true, onlyFiles: true })).sort();
const digest = createHash('sha256');
for (const file of files) {
const relative = path.relative(root, file).replaceAll(path.sep, '/');
digest.update(relative);
digest.update('\0');
digest.update(createHash('sha256').update(await fs.readFile(file)).digest('hex'));
digest.update('\n');
}
return {
mode,
htmlRoutes: htmlFiles.length,
sitemapUrls: sitemapRoutes.length,
hreflangLinks,
reviewIndex,
activeNativeFormActions: activeForms,
artifactFiles: files.length,
artifactSha256: digest.digest('hex'),
routes,
errors,
warnings,
passed: errors.length === 0,
};
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) {
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice('--mode='.length) ?? 'staging';
const root = process.argv.find((arg) => arg.startsWith('--root='))?.slice('--root='.length) ?? 'dist';
if (!['staging', 'indexing'].includes(mode)) throw new Error(`Unsupported mode: ${mode}`);
const result = await verifyArtifact(path.resolve(root), mode);
console.log(JSON.stringify(result, null, 2));
if (!result.passed) process.exitCode = 1;
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { verifyArtifact } from './phase10-artifact-verify.mjs';
async function fixture(mode = 'staging') {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-phase10-artifact-'));
const pages = mode === 'staging'
? [['index.html', 'noindex,nofollow'], ['lp/index.html', 'noindex,nofollow']]
: [['index.html', 'index,follow']];
for (const [file, robots] of pages) {
const target = path.join(root, file);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, `<!doctype html><html><head><title>${file}</title><meta name="robots" content="${robots}"><link rel="canonical" href="https://www.azinstitute4autism.com/${file}"></head><body><main><h1>${file}</h1></main></body></html>`);
}
await fs.writeFile(path.join(root, 'sitemap.xml'), mode === 'staging' ? '<urlset></urlset>' : '<urlset><url><loc>https://www.azinstitute4autism.com/</loc></url></urlset>');
return root;
}
test('staging artifact requires noindex, empty sitemap, no hreflang and review index', async () => {
const result = await verifyArtifact(await fixture('staging'), 'staging');
assert.equal(result.passed, true);
assert.equal(result.sitemapUrls, 0);
assert.equal(result.reviewIndex, true);
});
test('indexing artifact requires sitemap and excludes review index', async () => {
const result = await verifyArtifact(await fixture('indexing'), 'indexing');
assert.equal(result.passed, true);
assert.equal(result.sitemapUrls, 1);
assert.equal(result.reviewIndex, false);
});
test('staging artifact rejects active native form actions', async () => {
const root = await fixture('staging');
await fs.appendFile(path.join(root, 'index.html'), '<form action="https://example.invalid/submit"></form>');
const result = await verifyArtifact(root, 'staging');
assert.equal(result.passed, false);
assert.ok(result.errors.some((error) => error.includes('native form action')));
});
test('artifact rejects live form mode and production API origin', async () => {
const root = await fixture('staging');
await fs.appendFile(path.join(root, 'index.html'), '<div data-form-mode="live">https://api.azinstitute4autism.com</div>');
const result = await verifyArtifact(root, 'staging');
assert.equal(result.passed, false);
assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals')));
});
test('artifact digest is deterministic for unchanged files', async () => {
const root = await fixture('staging');
const first = await verifyArtifact(root, 'staging');
const second = await verifyArtifact(root, 'staging');
assert.equal(first.artifactSha256, second.artifactSha256);
assert.equal(first.artifactFiles, second.artifactFiles);
});
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import fg from 'fast-glob';
import { setTimeout as delay } from 'node:timers/promises';
const root = fileURLToPath(new URL('../', import.meta.url));
const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright';
const executablePath = process.env.CHROMIUM_EXECUTABLE;
const output = await fs.mkdtemp('/tmp/aia-phase10-browser-');
const result = { startedAt: new Date().toISOString(), routeCount: 0, opened: 0, failures: [], pageErrors: [], consoleErrors: [], blocked: [], productionAttempts: [], screenshots: [], status: 'failed' };
let browser;
let preview;
function routeFromHtml(file) {
const relative = path.relative(path.join(root, 'dist'), file).replaceAll(path.sep, '/');
if (relative === 'index.html') return '/';
return `/${relative.endsWith('/index.html') ? relative.slice(0, -'/index.html'.length) : relative.replace(/\.html$/, '')}`;
}
function isProductionLike(url) {
return /(?:api\.azinstitute4autism\.com|jotform|google-analytics|googletagmanager|doubleclick|googleadservices|hubspot)/i.test(url.hostname + url.pathname);
}
const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port;
await new Promise((resolve) => reserve.close(resolve));
const origin = `http://127.0.0.1:${port}`;
try {
const htmlFiles = (await fg('**/*.html', { cwd: path.join(root, 'dist'), absolute: true })).sort();
const routes = htmlFiles.map(routeFromHtml);
result.routeCount = routes.length;
const { chromium } = await import(moduleName);
browser = await chromium.launch(executablePath ? { headless: true, executablePath } : { headless: true });
result.browser = browser.version();
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
cwd: root, env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' }, stdio: 'ignore'
});
const reachable = async () => new Promise((resolve) => {
const request = http.get(origin + '/', (response) => { response.resume(); resolve(response.statusCode === 200); });
request.setTimeout(500, () => request.destroy());
request.on('error', () => resolve(false));
});
for (let attempt = 0; attempt < 60 && !(await reachable()); attempt++) await delay(250);
assert.equal(await reachable(), true, 'Astro preview was not reachable');
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, serviceWorkers: 'block', acceptDownloads: false });
assert.equal(typeof context.routeWebSocket, 'function', 'Playwright lacks WebSocket isolation');
await context.routeWebSocket('**/*', (socket) => socket.close());
await context.route('**/*', (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin !== origin || !['GET', 'HEAD'].includes(request.method())) {
const attempt = { origin: url.origin, path: url.pathname, method: request.method() };
result.blocked.push(attempt);
if (isProductionLike(url)) result.productionAttempts.push(attempt);
return route.abort();
}
return route.continue();
});
const page = await context.newPage();
page.on('pageerror', (error) => result.pageErrors.push({ route: page.url(), message: String(error.message) }));
page.on('console', (message) => { if (message.type() === 'error') result.consoleErrors.push({ route: page.url(), message: message.text() }); });
const screenshotRoutes = new Set(['/', '/es', '/contact', '/ar/library/aba-school-readiness-guide', '/tour', '/lp/aba-therapy']);
for (const route of routes) {
try {
const response = await page.goto(origin + route, { waitUntil: 'domcontentloaded', timeout: 15000 });
assert.equal(response?.status(), 200);
assert.equal(new URL(page.url()).origin, origin);
assert.ok((await page.title()).trim(), 'missing title');
assert.equal(await page.locator('link[rel="canonical"]').count(), 1, 'canonical count');
assert.equal(await page.locator('meta[name="robots"]').getAttribute('content'), 'noindex,nofollow', 'staging robots');
const nonSearchForms = await page.locator('main form').evaluateAll((forms) => forms.filter((form) => {
const action = form.getAttribute('action');
return action && action !== '/search';
}).length);
assert.equal(nonSearchForms, 0, 'non-search form found');
const overflow = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }));
if (overflow.scrollWidth > overflow.clientWidth + 1) result.failures.push({ route, issue: 'page-level horizontal overflow', overflow });
if (screenshotRoutes.has(route)) {
const name = route === '/' ? 'home' : route.slice(1).replaceAll('/', '-') || 'root';
const screenshot = path.join(output, `${name}.png`);
await page.screenshot({ path: screenshot, fullPage: true });
result.screenshots.push({ route, path: screenshot });
}
result.opened++;
} catch (error) {
result.failures.push({ route, issue: String(error.message) });
}
}
result.status = result.failures.length || result.productionAttempts.length ? 'failed' : 'passed';
} catch (error) {
result.error = String(error.message);
} finally {
await browser?.close();
if (preview && preview.exitCode === null) {
preview.kill('SIGTERM');
await Promise.race([new Promise((resolve) => preview.once('exit', resolve)), delay(3000)]);
if (preview.exitCode === null) preview.kill('SIGKILL');
}
result.finishedAt = new Date().toISOString();
await fs.writeFile(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(JSON.stringify({ ...result, output }, null, 2));
if (result.status !== 'passed') process.exitCode = 1;
}
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { verifyArtifact } from './phase10-artifact-verify.mjs';
const root = fileURLToPath(new URL('../', import.meta.url));
const dist = path.join(root, 'dist');
const rehearsalRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-release-rehearsal-'));
const releases = path.join(rehearsalRoot, 'releases');
await fs.mkdir(releases, { recursive: true });
const releaseA = path.join(releases, 'release-a');
const releaseB = path.join(releases, 'release-b');
await fs.cp(dist, releaseA, { recursive: true });
await fs.cp(dist, releaseB, { recursive: true });
await fs.writeFile(path.join(releaseA, '__release-marker.txt'), 'release-a\n');
await fs.writeFile(path.join(releaseB, '__release-marker.txt'), 'release-b\n');
const current = path.join(rehearsalRoot, 'current');
await fs.symlink(releaseA, current);
const contentTypes = { '.html': 'text/html', '.xml': 'application/xml', '.txt': 'text/plain', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2' };
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://127.0.0.1').pathname);
const clean = pathname.replace(/^\/+/, '');
const candidates = [clean, clean.endsWith('/') ? `${clean}index.html` : `${clean}/index.html`, clean === '' ? 'index.html' : null].filter(Boolean);
let target;
for (const candidate of candidates) {
const candidatePath = path.resolve(current, candidate);
if (candidatePath.startsWith(path.resolve(current)) && await fs.stat(candidatePath).then((stat) => stat.isFile()).catch(() => false)) { target = candidatePath; break; }
}
if (!target) { response.writeHead(404, { 'content-type': 'text/plain' }); response.end('Not found'); return; }
const body = await fs.readFile(target);
response.writeHead(200, { 'content-type': contentTypes[path.extname(target)] ?? 'application/octet-stream', 'cache-control': path.extname(target) === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable' });
response.end(body);
} catch { response.writeHead(400); response.end('Bad request'); }
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
const origin = `http://127.0.0.1:${address.port}`;
const request = (pathname) => new Promise((resolve, reject) => {
const req = http.get(origin + pathname, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], cache: res.headers['cache-control'], body: Buffer.concat(chunks).toString('utf8') })); });
req.on('error', reject);
});
const checks = [];
const check = async (name, fn) => { await fn(); checks.push(name); };
await check('staging artifact verifier', async () => assert.equal((await verifyArtifact(releaseA, 'staging')).passed, true));
await check('root route', async () => assert.equal((await request('/')).status, 200));
await check('locale route', async () => assert.equal((await request('/es')).status, 200));
await check('slashless and trailing-slash routes', async () => { assert.equal((await request('/library')).status, 200); assert.equal((await request('/library/')).status, 200); });
await check('query strings preserved by static serving', async () => assert.equal((await request('/search?term=aba&lang=es')).status, 200));
await check('asset MIME type', async () => assert.match((await request('/assets/images/favicon.svg')).type, /image\/svg/));
await check('robots and empty staging sitemap', async () => { const robots = await request('/robots.txt'); const sitemap = await request('/sitemap.xml'); assert.equal(robots.status, 200); assert.equal((sitemap.body.match(/<url>/g) ?? []).length, 0); });
await check('real 404', async () => assert.equal((await request('/does-not-exist')).status, 404));
await check('atomic release switch', async () => { await fs.rename(current, `${current}.old`); await fs.symlink(releaseB, current); assert.match((await request('/__release-marker.txt')).body, /release-b/); });
await check('rollback restores prior release', async () => { await fs.unlink(current); await fs.symlink(releaseA, current); assert.match((await request('/__release-marker.txt')).body, /release-a/); });
await new Promise((resolve) => server.close(resolve));
console.log(JSON.stringify({ status: 'passed', origin, rehearsalRoot, checks, productionContact: false }, null, 2));
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
const root = fileURLToPath(new URL('../', import.meta.url));
const moduleName = process.env.PLAYWRIGHT_MODULE ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright';
const routes = ['/', '/es', '/contact', '/es/contact', '/client-consultation', '/es/client-consultation', '/referrals', '/aba-therapy', '/autism-evaluations', '/learner-social-club', '/library', '/library/page/6', '/ar/library/aba-school-readiness-guide', '/search', '/tour', '/privacy-policy', '/es/privacy-policy', '/lp/aba-therapy', '/lp/aba-insurance', '/lp/aba-ahcccs', '/lp/aba-private-pay', '/lp/early-intervention'];
const widths = [320, 390, 768, 1024, 1440];
const selected = new Set(['/', '/es', '/tour', '/ar/library/aba-school-readiness-guide', '/lp/aba-therapy']);
const output = await fs.mkdtemp('/tmp/aia-phase10-responsive-');
const evidence = { startedAt: new Date().toISOString(), routes: routes.length, widths, checks: 0, failures: [], blocked: [], productionAttempts: [], screenshots: [], status: 'failed' };
let browser; let preview;
const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port; await new Promise((resolve) => reserve.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const production = (url) => /(?:api\.azinstitute4autism\.com|jotform|google-analytics|googletagmanager|doubleclick|hubspot)/i.test(url.hostname + url.pathname);
try {
const { chromium } = await import(moduleName);
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE ? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE } : { headless: true });
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], { cwd: root, env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' }, stdio: 'ignore' });
const reachable = () => new Promise((resolve) => { const request = http.get(origin + '/', (response) => { response.resume(); resolve(response.statusCode === 200); }); request.setTimeout(500, () => request.destroy()); request.on('error', () => resolve(false)); });
for (let i = 0; i < 60 && !(await reachable()); i++) await delay(250);
assert.equal(await reachable(), true);
for (const width of widths) {
const context = await browser.newContext({ viewport: { width, height: width < 500 ? 844 : 900 }, deviceScaleFactor: 1, serviceWorkers: 'block' });
await context.routeWebSocket('**/*', (socket) => socket.close());
await context.route('**/*', (route) => { const request = route.request(); const url = new URL(request.url()); if (url.origin !== origin || !['GET', 'HEAD'].includes(request.method())) { const item = { origin: url.origin, path: url.pathname, method: request.method() }; evidence.blocked.push(item); if (production(url)) evidence.productionAttempts.push(item); return route.abort(); } return route.continue(); });
const page = await context.newPage();
for (const route of routes) {
evidence.checks++;
try {
const response = await page.goto(origin + route, { waitUntil: 'domcontentloaded', timeout: 15000 });
assert.equal(response?.status(), 200);
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(100);
const result = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth, lang: document.documentElement.lang, dir: document.documentElement.dir, h1: document.querySelectorAll('main h1').length }));
assert.equal(result.width, width);
assert.ok(result.scrollWidth <= width + 1, `overflow ${result.scrollWidth}`);
assert.equal(result.h1, 1, 'one main H1');
if (route === '/ar/library/aba-school-readiness-guide') { assert.equal(result.lang, 'ar'); assert.equal(result.dir, 'rtl'); }
if (selected.has(route) && (width === 320 || width === 1440)) { const file = `${route === '/' ? 'home' : route.slice(1).replaceAll('/', '-')}-${width}.png`; await page.screenshot({ path: path.join(output, file), fullPage: true }); evidence.screenshots.push({ route, width, file }); }
} catch (error) { evidence.failures.push({ route, width, error: String(error.message) }); }
}
await context.close();
}
evidence.status = evidence.failures.length || evidence.productionAttempts.length ? 'failed' : 'passed';
} catch (error) { evidence.error = String(error.message); }
finally {
await browser?.close();
if (preview && preview.exitCode === null) { preview.kill('SIGTERM'); await Promise.race([new Promise((resolve) => preview.once('exit', resolve)), delay(3000)]); if (preview.exitCode === null) preview.kill('SIGKILL'); }
evidence.finishedAt = new Date().toISOString(); await fs.writeFile(path.join(output, 'results.json'), JSON.stringify(evidence, null, 2) + '\n'); console.log(JSON.stringify({ ...evidence, output }, null, 2)); if (evidence.status !== 'passed') process.exitCode = 1;
}