This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cross-platform source smoke check for the desktop distribution contract.
|
||||
* It deliberately does not require Windows, a native shell, or a browser.
|
||||
* Run from the repository root: node apps/desktop/scripts/smoke-desktop.mjs
|
||||
*/
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = resolve(desktopRoot, '../..');
|
||||
const manifestPath = resolve(desktopRoot, 'desktop-manifest.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
const results = [];
|
||||
|
||||
function check(id, description, pass, details = '') {
|
||||
results.push({ id, description, pass: Boolean(pass), ...(details ? { details } : {}) });
|
||||
}
|
||||
function unique(values) { return [...new Set(values)]; }
|
||||
function sorted(values) { return [...values].sort(); }
|
||||
function relativeToRepo(path) { return resolve(desktopRoot, path).replace(`${repoRoot}/`, ''); }
|
||||
|
||||
check('manifest.schema', 'desktop manifest has the supported source-contract shape',
|
||||
manifest.schema === 1 && manifest.shell === 'web-ui' && manifest.status === 'source-contract' &&
|
||||
manifest.entrypoint === '../web/index.html' && Array.isArray(manifest.assets) &&
|
||||
Array.isArray(manifest.runtime_config_keys) && typeof manifest.routes_source === 'string');
|
||||
|
||||
const assetPaths = manifest.assets.map(relativeToRepo);
|
||||
const missingAssets = [];
|
||||
for (const path of assetPaths) {
|
||||
try {
|
||||
if (!(await stat(resolve(repoRoot, path))).isFile()) missingAssets.push(path);
|
||||
} catch {
|
||||
missingAssets.push(path);
|
||||
}
|
||||
}
|
||||
check('assets.present', 'all desktop-referenced web assets exist', missingAssets.length === 0, missingAssets.join(', '));
|
||||
check('assets.no-duplicates', 'desktop contract reuses web assets instead of maintaining a second UI copy',
|
||||
manifest.assets.every(asset => asset.startsWith('../web/')));
|
||||
|
||||
const webConfig = await readFile(resolve(repoRoot, 'apps/web/config.js'), 'utf8');
|
||||
check('config.runtime-keys', 'desktop supports only the non-secret web runtime configuration keys',
|
||||
manifest.runtime_config_keys.length === 2 && manifest.runtime_config_keys.includes('apiBase') &&
|
||||
manifest.runtime_config_keys.includes('assetVersion') && !/(token|password|secret|private.?key)\s*[:=]/i.test(webConfig));
|
||||
|
||||
const html = await readFile(resolve(repoRoot, 'apps/web/index.html'), 'utf8');
|
||||
const linkedAssets = unique([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)]
|
||||
.map(match => match[1]).filter(asset => !asset.startsWith('http') && !asset.startsWith('data:'))
|
||||
.map(asset => asset.replace(/^\.\//, '')));
|
||||
const manifestWebNames = new Set(manifest.assets.map(asset => asset.replace('../web/', '')));
|
||||
const missingLinkedAssets = linkedAssets.filter(asset => !manifestWebNames.has(asset));
|
||||
check('assets.html-parity', 'desktop manifest covers every local asset linked by web index.html',
|
||||
missingLinkedAssets.length === 0, missingLinkedAssets.join(', '));
|
||||
|
||||
const routeSource = await readFile(resolve(desktopRoot, manifest.routes_source), 'utf8');
|
||||
const routesBlock = routeSource.match(/const routes = \[(.*?)];/s)?.[1] || '';
|
||||
const webRoutes = unique([...routesBlock.matchAll(/['"](\/api\/v1\/[^'"]+)['"]/g)].map(match => match[1]));
|
||||
const desktopRoutes = Array.isArray(manifest.routes) ? unique(manifest.routes) : webRoutes;
|
||||
check('routes.source', 'route source contains the web API contract', webRoutes.length > 0);
|
||||
check('routes.parity', 'desktop route contract is exactly the web route contract',
|
||||
JSON.stringify(sorted(desktopRoutes)) === JSON.stringify(sorted(webRoutes)),
|
||||
`web=${webRoutes.length}, desktop=${desktopRoutes.length}`);
|
||||
|
||||
const failed = results.filter(result => !result.pass);
|
||||
const report = {
|
||||
schema: 1,
|
||||
harness: 'prospectos-desktop-source-smoke',
|
||||
chromium_required: false,
|
||||
windows_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;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const desktopDir = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
const repoDir = path.resolve(desktopDir, '..', '..');
|
||||
const webDir = path.join(repoDir, 'apps', 'web');
|
||||
const requiredDesktop = ['package.json', 'main.cjs', 'preload.cjs', 'url-validation.js', 'connection.html', 'desktop-manifest.json'];
|
||||
const secretAssignment = /(?:api[_-]?key|secret|token|password|private[_-]?key)\s*[:=]\s*['"][^'"\n]{8,}['"]/i;
|
||||
const fail = (message) => { throw new Error(message); };
|
||||
async function filesUnder(dir) {
|
||||
const output = [];
|
||||
async function walk(current) {
|
||||
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'release') continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) await walk(full); else output.push(full);
|
||||
}
|
||||
}
|
||||
await walk(dir); return output.sort();
|
||||
}
|
||||
const digest = (buffer) => `sha256-${createHash('sha256').update(buffer).digest('hex')}`;
|
||||
for (const file of requiredDesktop) await stat(path.join(desktopDir, file)).catch(() => fail(`Missing desktop entrypoint: ${file}`));
|
||||
const webManifest = JSON.parse(await readFile(path.join(webDir, 'asset-manifest.json'), 'utf8'));
|
||||
if (webManifest.schema !== 1 || !webManifest.version || !webManifest.integrity) fail('Invalid apps/web asset manifest');
|
||||
const listed = [...new Set([...(webManifest.entrypoints || []), ...(webManifest.publicAssets || [])])].sort();
|
||||
if (!listed.includes('index.html')) fail('Web manifest must include index.html');
|
||||
for (const [relative, expected] of Object.entries(webManifest.integrity)) {
|
||||
const full = path.join(webDir, relative);
|
||||
await stat(full).catch(() => fail(`Manifest asset is missing: ${relative}`));
|
||||
const actual = digest(await readFile(full));
|
||||
if (actual !== expected) fail(`Integrity mismatch for ${relative}`);
|
||||
}
|
||||
for (const relative of listed) if (!webManifest.integrity[relative]) fail(`Manifest asset lacks integrity: ${relative}`);
|
||||
const desktopManifest = JSON.parse(await readFile(path.join(desktopDir, 'desktop-manifest.json'), 'utf8'));
|
||||
for (const relative of desktopManifest.assets || []) await stat(path.resolve(desktopDir, relative)).catch(() => fail(`Desktop manifest asset is missing: ${relative}`));
|
||||
const files = [...await filesUnder(webDir), ...await filesUnder(desktopDir)];
|
||||
for (const file of files) {
|
||||
const buffer = await readFile(file);
|
||||
if (buffer.includes(0)) continue;
|
||||
if (secretAssignment.test(buffer.toString('utf8'))) fail(`Secret-like assignment found in ${path.relative(repoDir, file)}`);
|
||||
}
|
||||
const packageJson = JSON.parse(await readFile(path.join(desktopDir, 'package.json'), 'utf8'));
|
||||
if (packageJson.main !== 'main.cjs') fail('package.json main must be main.cjs');
|
||||
if (!packageJson.build?.extraResources?.some((item) => item.from === '../web' && item.to === 'web')) fail('Build must copy apps/web into packaged resources');
|
||||
const targets = packageJson.build?.win?.target || [];
|
||||
if (!targets.some((target) => target.target === 'nsis')) fail('Windows NSIS target is missing');
|
||||
if (!targets.some((target) => target.target === 'portable')) fail('Windows portable target is missing');
|
||||
console.log(`Packaging verification passed: ${listed.length} web assets, ${files.length} scanned source files, NSIS + portable targets.`);
|
||||
Reference in New Issue
Block a user