#!/usr/bin/env node /** * Deterministic, Chromium-free frontend pilot smoke harness. * * Usage: * node scripts/smoke-frontend.mjs http://127.0.0.1:8080 * * The final stdout value is JSON so CI can consume it directly. Exit code is * non-zero when any check fails. The checks intentionally inspect source HTML, * JavaScript, and CSS instead of executing the application or making API calls. */ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const baseUrl = (process.argv[2] || 'http://127.0.0.1:8080').replace(/\/$/, ''); const files = Object.fromEntries(await Promise.all( ['index.html', 'app.js', 'styles.css', 'config.js', 'health.html', 'error.html', 'healthz', 'smoke-test.html'] .map(async name => [name, await readFile(resolve(webRoot, name), 'utf8')]) )); const results = []; function check(id, description, pass, details = '') { results.push({ id, description, pass: Boolean(pass), ...(details ? { details } : {}) }); } function has(text, needles) { return needles.every(needle => text.includes(needle)); } function ids(html) { return [...html.matchAll(/\bid=["']([^"']+)["']/g)].map(match => match[1]); } function smokeMarkers(html) { return [...html.matchAll(/data-smoke=["']([^"']+)["']/g)].map(match => match[1]); } const html = files['index.html']; const js = files['app.js']; const css = files['styles.css']; const htmlIds = new Set(ids(html)); const markers = new Set(smokeMarkers(html)); check('dom.operator-markers', 'operator-critical DOM markers are present', has(html, [ 'id="loginScreen"', 'id="dashboardShell"', 'id="explorer"', 'id="detailPanel"', 'id="reviewQueueCount"', 'id="jobsList"', 'id="sourcesList"', 'id="pipelineBoard"', 'id="interactionState"', 'id="pipelineReport"', 'id="suppressionState"', 'id="providerPolicyPanel"', 'id="aiProviderSettings"', 'id="aiProviderForm"', 'id="aiProviderStatus"', 'id="scoreRulesPanel"', 'id="scoreDistributionPanel"' ])); check('dom.safety-markers', 'safety and approval sections have stable smoke markers', [ 'saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', 'suppression-center', 'outreach-provider-policy', 'ai-provider-settings', 'score-rules', 'score-distribution' ].every(marker => markers.has(marker))); check('dom.required-controls', 'operator controls have stable IDs', [ 'loginEmail', 'loginPassword', 'logoutBtn', 'nextPageBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'savedFilterForm', 'pipelineViewToggle', 'interactionForm', 'suppressionForm', 'outreachPolicyRefreshBtn', 'aiProviderRefreshBtn', 'testAiProviderBtn', 'saveAiProviderBtn' ].every(id => htmlIds.has(id)) && has(js, [ 'scanWebsiteBtn', 'extractContactsBtn', 'recalculateScoreBtn', 'generateAiSuggestionBtn', 'createOutreachDraftBtn' ])); check('css.responsive', 'responsive CSS is present for mobile operator layouts', /@media\s*\(max-width\s*:\s*700px\)/.test(css) && has(css, ['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.ai-provider-grid'])); check('css.layout-contracts', 'critical layout selectors are defined', has(css, [ '.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.pipeline-board', '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row', '.ai-provider-status-row' ])); check('safety.no-send-copy', 'persistent no-send and approval safety copy is present', has(`${html}\n${js}`, [ 'No outreach from this workspace.', 'Suppression always wins.', 'Sending is disabled by default.', 'Approval does not send a message.', 'no outreach will be sent', 'send:false', 'autonomous_action:false' ])); check('safety.no-send-controls', 'static HTML exposes no send or delivery button', ![...html.matchAll(/]*>([\s\S]*?)<\/button>/gi)] .some(match => /\b(send|deliver|campaign)\b/i.test(match[1]))); check('safety.no-secrets', 'frontend source has no obvious hardcoded secrets', !/(api[_-]?key|secret|token|password|private[_-]?key|authorization)\s*[:=]\s*["'][^"']+/i.test( `${html}\n${js}\n${files['config.js']}` )); check('safety.approval-gated', 'approval is explicitly human-confirmed and non-delivering', has(js, ['window.confirm', 'human_approval:true', 'send:false', 'Approval does not send a message.'])); check('ai-provider.write-only', 'AI provider credentials are write-only and never browser-persisted', has(`${html}\n${js}`, ['type="password"', 'autocomplete="new-password"', "input.value=''", 'input[type="password"]']) && !/localStorage[^\n]*(?:nous|firecrawl|api[_-]?key)/i.test(`${html}\n${js}`)); check('ai-provider.states', 'AI provider status states and admin messaging are represented', has(`${html}\n${js}`, ['/api/v1/ai/provider-config', '/api/v1/admin/ai-provider-config/test', 'Not configured', 'Invalid configuration', 'Administrator access required'])); const routes = [ '/api/v1/auth/me', '/api/v1/auth/login', '/api/v1/auth/logout', '/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-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' ]; check('routes.contracts', 'operator API route contracts are referenced by the client', routes.every(route => js.includes(route)), routes.filter(route => !js.includes(route)).join(', ')); check('routes.authenticated', 'API requests use cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest')); check('routes.no-target-fetch', 'client does not directly fetch arbitrary target URLs', !/fetch\s*\(\s*(?:url|target|website|href)\b/i.test(js)); const linkedAssets = new Set(['index.html', 'config.js', 'app.js', 'styles.css', 'health.html', 'error.html', 'healthz', 'smoke-test.html']); for (const match of html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)) { const asset = match[1]; if (!asset.startsWith('http') && !asset.startsWith('data:')) linkedAssets.add(asset.replace(/^\.\//, '')); } check('assets.local', 'all linked static assets exist and are non-empty', [...linkedAssets].every(asset => files[asset]?.length > 0), [...linkedAssets].filter(asset => !files[asset]?.length).join(', ')); async function verifyHttp() { for (const asset of [...linkedAssets].sort()) { const url = `${baseUrl}/${asset}`; try { const response = await fetch(url); const body = await response.text(); const contentType = response.headers.get('content-type') || ''; const expectedType = asset.endsWith('.html') ? 'text/html' : asset.endsWith('.css') ? 'text/css' : asset.endsWith('.js') ? 'javascript' : null; const pass = response.ok && body.length > 0 && (!expectedType || contentType.includes(expectedType)); check(`http.${asset}`, `HTTP delivery: ${asset}`, pass, pass ? `${response.status} ${contentType}` : `${response.status} ${contentType || 'missing content-type'}`); } catch (error) { check(`http.${asset}`, `HTTP delivery: ${asset}`, false, error.message); } } } await verifyHttp(); const failed = results.filter(result => !result.pass); const report = { schema: 1, harness: 'prospectos-frontend-pilot-smoke', base_url: baseUrl, chromium_required: false, 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)); if (failed.length) process.exitCode = 1;