137 lines
7.0 KiB
JavaScript
137 lines
7.0 KiB
JavaScript
#!/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="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', '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'
|
||
|
|
].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']));
|
||
|
|
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'
|
||
|
|
]));
|
||
|
|
|
||
|
|
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(/<button\b[^>]*>([\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.']));
|
||
|
|
|
||
|
|
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'
|
||
|
|
];
|
||
|
|
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;
|