#!/usr/bin/env node /** * Deterministic, dependency-free frontend final acceptance harness. * * Source checks run by default. HTTP checks are opt-in: * node scripts/final-acceptance.mjs --base-url http://127.0.0.1:8080 * node scripts/final-acceptance.mjs --start-server [--port 0] * * stdout is one machine-readable JSON document. Exit codes: * 0 = all requested checks passed * 1 = one or more acceptance checks failed * 2 = invalid CLI/configuration or unreadable source */ import { createHash } from 'node:crypto'; import { readFile, stat } from 'node:fs/promises'; import { spawn } from 'node:child_process'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const args = process.argv.slice(2); const valueFor = flag => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; }; const startServer = args.includes('--start-server'); const baseArg = valueFor('--base-url') || valueFor('--url'); const portArg = valueFor('--port') || '8080'; if (args.includes('--help') || args.includes('-h')) { console.log(JSON.stringify({ usage: 'node scripts/final-acceptance.mjs [--base-url URL | --start-server] [--port N]', exit_codes: { pass: 0, failed_checks: 1, usage_or_io_error: 2 } }, null, 2)); process.exit(0); } if (baseArg && startServer) { console.log(JSON.stringify({ schema: 1, harness: 'prospectos-frontend-final-acceptance', pass: false, error: '--base-url and --start-server are mutually exclusive' }, null, 2)); process.exit(2); } const baseUrl = baseArg?.replace(/\/$/, '') || null; const sourceNames = ['index.html', 'app.js', 'styles.css', 'config.js', 'health.html', 'error.html', 'healthz', 'smoke-test.html', 'asset-manifest.json', 'README.md']; const results = []; const check = (id, description, pass, details = '') => results.push({ id, description, pass: Boolean(pass), ...(details ? { details } : {}) }); const all = (items, predicate) => items.every(predicate); const listMissing = (items, predicate) => items.filter(item => !predicate(item)); const sha256 = bytes => `sha256-${createHash('sha256').update(bytes).digest('hex')}`; let files = {}; let manifest; try { files = Object.fromEntries(await Promise.all(sourceNames.map(async name => [name, await readFile(resolve(webRoot, name))]))); manifest = JSON.parse(files['asset-manifest.json'].toString('utf8')); check('files.readable', 'all frontend acceptance inputs are readable', true); } catch (error) { check('files.readable', 'all frontend acceptance inputs are readable', false, error.message); } const text = name => files[name]?.toString('utf8') || ''; const html = text('index.html'); const js = text('app.js'); const css = text('styles.css'); const config = text('config.js'); const expectedIds = [ 'loginScreen', 'loginForm', 'loginEmail', 'loginPassword', 'dashboardShell', 'logoutBtn', 'apiStatus', 'explorer', 'detailPanel', 'reviewQueueCount', 'reviewQueueState', 'savedFilterForm', 'savedFilterSelect', 'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel', 'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'crmPipeline', 'pipelineBoard', 'crmActivity', 'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport', 'suppressionCenter', 'suppressionForm', 'suppressionState', 'outreachSettings', 'providerPolicyPanel', 'scoreRules', 'scoreRulesPanel', 'scoreDistributionPanel', 'mergeDialog', 'confirmMergeBtn' ]; const htmlIds = new Set([...html.matchAll(/\bid=["']([^"']+)["']/g)].map(m => m[1])); check('dom.critical-ids', 'critical operator DOM IDs are present', all(expectedIds, id => htmlIds.has(id)), listMissing(expectedIds, id => htmlIds.has(id)).join(', ')); const expectedMarkers = ['saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', 'suppression-center', 'outreach-provider-policy', 'score-rules', 'score-distribution']; const smokeMarkers = new Set([...html.matchAll(/data-smoke=["']([^"']+)["']/g)].map(m => m[1])); check('dom.smoke-markers', 'critical sections expose stable smoke markers', all(expectedMarkers, marker => smokeMarkers.has(marker)), listMissing(expectedMarkers, marker => smokeMarkers.has(marker)).join(', ')); const dynamicMarkers = ['score-breakdown', 'deduplication']; const dynamicMarkerPresent = marker => js.includes(`dataset.smoke = '${marker}'`) || js.includes(`dataset.smoke='${marker}'`) || js.includes(`data-smoke="${marker}"`) || js.includes(`data-smoke='${marker}'`); check('dom.dynamic-markers', 'detail safety panels define dynamic smoke markers', all(dynamicMarkers, dynamicMarkerPresent), listMissing(dynamicMarkers, dynamicMarkerPresent).join(', ')); const routeContracts = [ '/api/v1/auth/me', '/api/v1/auth/login', '/api/v1/auth/logout', '/api/v1/dashboard/summary', '/api/v1/businesses', '/api/v1/review-queue', '/api/v1/saved-filters', '/api/v1/businesses/bulk-review', '/api/v1/jobs', '/api/v1/sources', '/api/v1/source-records', '/api/v1/discovery', '/api/v1/discovery-runs', '/api/v1/discovery-queries', '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules', '/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline', '/api/v1/reports/outcomes', '/api/v1/reports/activity', '/api/v1/suppressions', '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config', '/matches', '/domains/check', '/domain-candidates', '/websites/scan', '/contacts/extract', '/score/recalculate', '/pipeline', '/verify' ]; check('routes.contracts', 'all critical API route contracts are referenced by the client', all(routeContracts, route => js.includes(route)), listMissing(routeContracts, route => js.includes(route)).join(', ')); check('routes.authenticated', 'protected API requests include cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest')); check('routes.no-arbitrary-target-fetch', 'browser does not fetch arbitrary target URLs directly', !/fetch\s*\(\s*(?:url|target|website|href)\b/i.test(js)); const manifestAssets = manifest && [...new Set([...(manifest.entrypoints || []), ...(manifest.publicAssets || [])])]; check('manifest.schema', 'asset manifest has a supported schema and asset lists', Boolean(manifest && manifest.schema === 1 && Array.isArray(manifest.entrypoints) && Array.isArray(manifest.publicAssets) && manifest.integrity && manifestAssets.length), manifest ? '' : 'manifest unavailable'); if (manifestAssets) { check('manifest.assets-exist', 'every manifest asset exists and is non-empty', all(manifestAssets, asset => files[asset]?.length > 0), listMissing(manifestAssets, asset => files[asset]?.length > 0).join(', ')); check('manifest.integrity-complete', 'manifest integrity keys exactly cover manifest assets', manifestAssets.length === Object.keys(manifest.integrity).length && all(manifestAssets, asset => Object.hasOwn(manifest.integrity, asset)), `assets=${manifestAssets.length}, integrity=${Object.keys(manifest.integrity).length}`); const integrityFailures = manifestAssets.filter(asset => sha256(files[asset] || Buffer.alloc(0)) !== manifest.integrity[asset]); check('manifest.integrity', 'manifest SHA-256 digests match local release assets', integrityFailures.length === 0, integrityFailures.join(', ')); check('manifest.hash-format', 'manifest integrity values use sha256 hex format', all(manifestAssets, asset => /^sha256-[0-9a-f]{64}$/.test(manifest.integrity[asset] || '')), listMissing(manifestAssets, asset => /^sha256-[0-9a-f]{64}$/.test(manifest.integrity[asset] || '')).join(', ')); } const linkedAssets = [...new Set([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)].map(m => m[1]).filter(asset => !/^(?:https?:|data:|#)/i.test(asset)).map(asset => asset.replace(/^\.\//, '')) )]; check('assets.linked-local', 'all local HTML assets exist and are non-empty', all(linkedAssets, asset => files[asset]?.length > 0), listMissing(linkedAssets, asset => files[asset]?.length > 0).join(', ')); check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board'], selector => css.includes(selector))); check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector))); const combined = `${html}\n${js}\n${css}\n${config}\n${text('README.md')}`; const safetyCopy = ['No outreach from this workspace.', 'Suppression always wins.', 'Sending is disabled by default.', 'Approval does not send a message.', 'no outreach will be sent', 'AUTOMATED_OUTREACH_ENABLED=false']; check('safety.no-send-copy', 'persistent no-send and approval safety copy is present', all(safetyCopy, phrase => combined.includes(phrase)), listMissing(safetyCopy, phrase => combined.includes(phrase)).join(' | ')); const buttonLabels = [...html.matchAll(/]*>([\s\S]*?)<\/button>/gi)].map(m => m[1].replace(/<[^>]+>/g, ' ')); check('safety.no-send-controls', 'static HTML exposes no send, delivery, or campaign control', !buttonLabels.some(label => /\b(send|deliver|campaign|schedule delivery)\b/i.test(label))); const approvalTokens = ['window.confirm', 'human_approval:true', 'send:false', 'autonomous_action:false']; check('safety.approval-gated', 'approval actions require explicit human confirmation and remain non-delivering', all(approvalTokens, token => js.includes(token)), listMissing(approvalTokens, token => js.includes(token)).join(', ')); const suppressionTokens = ['statusOf', "st==='suppressed'", "renderAiState('suppressed')", "renderOutreachState('suppressed')", 'suppression', 'Do not contact']; check('safety.suppression-precedence', 'suppression disables AI/outreach and preserves do-not-contact state', all(suppressionTokens, token => combined.includes(token)), listMissing(suppressionTokens, token => combined.includes(token)).join(', ')); check('safety.source-disabled', 'discovery remains disabled until an approved source is enabled', all(['Discovery is disabled by default.', 'enabled:false', 'This source is disabled. Enable it only after review.'], token => combined.includes(token))); check('safety.no-external-network', 'client has no direct provider or target network origins', !/(?:fetch|XMLHttpRequest|WebSocket)\s*\(\s*[`'\"]https?:\/\//i.test(js)); const secretPatterns = [ /-----BEGIN(?: RSA| EC| OPENSSH)? PRIVATE KEY-----/i, /(?:api[_-]?key|client[_-]?secret|access[_-]?token|refresh[_-]?token|private[_-]?key|authorization)\s*[:=]\s*["'][^"']{8,}/i, /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, /\beyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\b/ ]; const secretHits = secretPatterns.flatMap(pattern => [...combined.matchAll(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`))].map(m => m[0].slice(0, 80))); check('safety.no-hardcoded-secrets', 'frontend source has no obvious hardcoded secrets', secretHits.length === 0, secretHits.join(' | ')); async function httpChecks(url) { for (const asset of [...new Set([...(manifestAssets || []), ...linkedAssets])].sort()) { try { const response = await fetch(`${url}/${asset}`); const body = Buffer.from(await response.arrayBuffer()); const expected = asset.endsWith('.html') ? 'text/html' : asset.endsWith('.css') ? 'text/css' : asset.endsWith('.js') ? 'javascript' : null; const contentType = response.headers.get('content-type') || ''; const integrityPass = !manifest?.integrity?.[asset] || sha256(body) === manifest.integrity[asset]; check(`http.${asset}`, `HTTP delivery and integrity: ${asset}`, response.ok && body.length > 0 && (!expected || contentType.includes(expected)) && integrityPass, `${response.status} ${contentType || 'missing content-type'}${integrityPass ? '' : '; integrity mismatch'}`); } catch (error) { check(`http.${asset}`, `HTTP delivery and integrity: ${asset}`, false, error.message); } } } let child; try { let httpUrl = baseUrl; if (startServer) { const port = Number(portArg); if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`invalid --port: ${portArg}`); child = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1', '--directory', webRoot], { stdio: ['ignore', 'ignore', 'ignore'] }); const actualPort = await new Promise((resolvePort, reject) => { const timer = setTimeout(() => reject(new Error('timed out waiting for static server')), 5000); const probe = async () => { const candidate = port; try { await fetch(`http://127.0.0.1:${candidate}/healthz`); clearTimeout(timer); resolvePort(candidate); } catch { setTimeout(probe, 50); } }; child.once('error', reject); probe(); }); httpUrl = `http://127.0.0.1:${actualPort}`; } if (httpUrl) await httpChecks(httpUrl.replace(/\/$/, '')); } catch (error) { check('http.server', 'optional static server is reachable', false, error.message); } finally { if (child) child.kill(); } const failed = results.filter(result => !result.pass); const report = { schema: 1, harness: 'prospectos-frontend-final-acceptance', root: webRoot, http_checks: Boolean(baseUrl || startServer), base_url: baseUrl || (startServer ? 'managed static server' : null), pass: failed.length === 0, totals: { checks: results.length, passed: results.length - failed.length, failed: failed.length }, checks: results }; console.log(JSON.stringify(report, null, 2)); process.exitCode = failed.length ? 1 : 0;