#!/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', 'discoveryWorkspace', 'directDiscoveryForm', 'directDiscoveryRunsState', 'directDiscoveryResultState', 'directDiscoverySources', 'directDiscoveryDailyLimit', '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/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-only', 'sources contain governed registry configuration, not discovery criteria or prompt/query controls', all(['sourceOwner', 'sourceTermsUrl', 'sourceTermsStatus', 'sourceRateLimit', 'sourceDailyQuota', 'sourceProviderSettings', 'sourceSaveBtn', 'sourceTestBtn', 'sourceEnableBtn', 'sourceStatus'], id => htmlIds.has(id)) && !html.includes('id="discoveryForm"') && !html.includes('name="query"') && !js.includes('function runDiscovery(')); check('sources.registry-payload', 'source save sends only registry/configuration fields and explicitly starts disabled', all(['terms_status:fields.terms_status', 'daily_quota:Number(fields.daily_quota)', 'provider_settings:fields.provider_settings', 'enabled:false'], token => js.includes(token)) && !/criteria\s*:|query\s*:|prompt\s*:/i.test(js.match(/async function saveSource[\s\S]*?(?=\n function findRegisteredSource)/)?.[0] || '')); check('sources.lifecycle-states', 'source save, test, and enable controls expose loading, success, error, and disabled explanations', all(['Saving source configuration…', 'Source configuration saved.', 'Testing source…', 'Enable is unavailable until terms, owner, limits, and a successful test are recorded.', 'Unable to save source configuration.'], token => `${html}\n${js}`.includes(token))); check('sources.optional-gated', 'source registry reads optional adapter readiness without treating it as an enabled source', all(['/api/v1/sources/adapters', 'optional:true', 'available:Boolean'], token => `${html}\n${js}`.includes(token))); check('discovery.canonical-builder', 'discovery exposes canonical criteria, source selection, bounded limits, website priorities, and dry-run controls', all(['directDiscoveryCategory', 'directDiscoveryKeywords', 'directDiscoveryCity', 'directDiscoveryProvince', 'directDiscoveryCountry', 'directDiscoveryLanguage', 'directDiscoverySources', 'directDiscoveryMaxResults', 'directDiscoveryMaxPages', 'directDiscoveryDailyLimit', 'directDiscoveryDryRun', 'directDiscoveryPriorities', 'directDiscoveryIncludeWebsites', 'directDiscoveryExcludeWebsites'], id => htmlIds.has(id))); check('discovery.payload-contract', 'discovery submits canonical criteria and does not send source configuration fields', all(['category:fields.category.trim()', 'language:fields.language.trim()', 'website_analysis:', 'include_websites:', 'exclude_websites:', 'max_results:Number(fields.max_results)', 'source_ids:selectedSourceIds()'], token => js.includes(token)) && !/terms_status|provider_settings|owner/.test(js.match(/async function submitDirectDiscovery[\s\S]*?(?=\n\n function parseCsv)/)?.[0] || '')); check('discovery.run-controls', 'run history, events, provenance, retry, and only route-backed cancel controls are represented', all(['data-run-action="cancel"', 'data-run-action="retry"', 'live-log', 'source-health', 'provenance', '/api/v1/discovery-runs/${encodeURIComponent(run.id)}/cancel', '/api/v1/jobs/${encodeURIComponent(run.job_id)}/retry'], token => `${html}\n${js}\n${css}`.includes(token)) && !`${html}\n${js}`.includes('data-run-action="pause"') && !`${html}\n${js}`.includes('data-run-action="resume"')); check('discovery.control-states', 'discovery controls communicate loading, success, error, and disabled explanations', all(['Starting bounded discovery job…', 'Discovery accepted. Tracking job status and partial results below.', 'Select at least one enabled source before starting a discovery run.', 'No enabled sources are available. Configure, test, and enable a source first.', 'Unable to load discovery runs'], token => `${html}\n${js}`.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('ui.no-demo-labels', 'static frontend contains no demo-labelled job or failure controls', !/\bdemo\b|DEMO_FAILURE/i.test(`${html}\n${js}`)); 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(/