#!/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 canonicalStages = ['new', 'contacted', 'qualified', 'proposal', 'negotiation', 'won', 'lost']; const crmStagesMatch = js.match(/const crmStages = \[([^\]]+)\]/); const crmStages = crmStagesMatch ? [...crmStagesMatch[1].matchAll(/["']([^"']+)["']/g)].map(match => match[1]) : []; check('crm.stage-contract', 'CRM stage definitions exactly match the canonical pipeline', JSON.stringify(crmStages) === JSON.stringify(canonicalStages), `actual=${JSON.stringify(crmStages)}`); const pipelineControl = js.match(/