#!/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', 'userGreetingName', 'reviewQueueCount', 'reviewQueueState', 'savedFilterForm', 'savedFilterSelect', 'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel', 'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'directDiscoverySources', 'directDiscoveryDailyLimit', 'directDiscoverySchedule', 'directDiscoveryDryRun', 'sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'crmPipeline', 'pipelineBoard', 'crmActivity', 'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport', 'suppressionCenter', 'suppressionForm', 'suppressionState', 'outreachSettings', 'providerPolicyPanel', 'aiProviderSettings', 'aiProviderForm', 'aiProviderStatus', 'aiProviderRefreshBtn', 'testAiProviderBtn', 'saveAiProviderBtn', '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', 'ai-provider-settings', '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', '/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test', '/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('auth.display-name-greeting', 'authenticated display name drives the greeting and identity', js.includes('currentUser.display_name') && js.includes("$('userGreetingName').textContent") && !html.includes('Good morning, Alex')); check('sources.registry-status', 'source registry exposes governed configuration and operational status fields', all(['source_code', 'display_name', 'configured', 'available', 'enabled', 'API credential', 'Terms', 'Owner', 'Rate limit', 'Daily quota', 'Last health', 'Success / error', 'Circuit', 'data-source-action="review"', 'data-source-action="test"'], token => `${html}\n${js}\n${css}`.includes(token))); check('sources.optional-gated', 'optional adapters are rendered unavailable until configured', all(['/api/v1/sources/adapters', 'configuration-gated', 'Optional adapter', 'source.optional', 'available:false'], token => `${html}\n${js}\n${css}`.includes(token))); check('sources.setup-affordances', 'manual and CSV setup affordances remain explicit and disabled by default', all(['Manual records', 'CSV import', 'CSV content is required', 'enabled:false', 'remains disabled'], token => `${html}\n${js}\n${css}`.includes(token))); check('discovery.operator-controls', 'discovery builder and run controls are represented', all(['data-run-action="pause"', 'data-run-action="resume"', 'data-run-action="cancel"', 'live-log', 'source-health', 'daily_limit', 'source_ids', 'schedule'], token => `${html}\n${js}\n${css}`.includes(token))); check('prospects.filter-contract', 'prospect explorer exposes source, geography, category, and contact filters', all(['sourceFilter', 'geographyFilter', 'categoryFilter', 'contactStatusFilter', 'contact_status'], token => `${html}\n${js}\n${css}`.includes(token))); 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(/