109 lines
8.1 KiB
JavaScript
Executable File
109 lines
8.1 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Foreground transient-failure regression check over the real Bun server and
|
|
// shaped local proxy. Injects one 503/connection drop, then requires recovery
|
|
// without exposing the application's manual Retry/error affordances.
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { cpSync, mkdirSync, rmSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { createServer } from 'node:net';
|
|
import { chromium, webkit } from 'playwright';
|
|
import { createThrottleProxy } from './proxy.mjs';
|
|
|
|
const repo = resolve(new URL('..', import.meta.url).pathname);
|
|
const scratch = join(repo, 'perf/.tmp/retry');
|
|
const profile = process.argv.includes('--profile') ? process.argv[process.argv.indexOf('--profile') + 1] : 'lossy';
|
|
const engines = process.argv.includes('--browser') ? process.argv[process.argv.indexOf('--browser') + 1] : 'all';
|
|
assert.ok(['all', 'chromium', 'webkit'].includes(engines));
|
|
rmSync(scratch, { recursive: true, force: true }); mkdirSync(scratch, { recursive: true });
|
|
const results = [];
|
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
async function freePort() { const s = createServer(); await new Promise(r => s.listen(0, '127.0.0.1', r)); const p = s.address().port; await new Promise(r => s.close(r)); return p; }
|
|
async function poll(fn, timeout = 30000) { const end = Date.now() + timeout; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; }
|
|
|
|
for (const engine of engines === 'all' ? ['chromium', 'webkit'] : [engines]) {
|
|
const cwd = join(scratch, engine); mkdirSync(cwd); cpSync(join(repo, 'frontend'), join(cwd, 'public'), { recursive: true });
|
|
const port = await freePort(); let log = '';
|
|
const child = spawn('bun', [join(repo, 'server/server.js')], { cwd, env: { ...process.env, PORT: String(port), ASSET_HASHING: '1', ASSET_SYNC: '1', BUILD_TAG: '', DB_PATH: join(cwd, 'db.sqlite'), DATA_DIR: join(cwd, 'data'), MEDIA_DIR: join(cwd, 'media'), UPLOAD_DIR: join(cwd, 'uploads') }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => log = (log + chunk).slice(-8000));
|
|
let browser, context, proxy;
|
|
const result = { engine, profile, injected: [], recovered: [], visibleErrors: [] }; results.push(result);
|
|
try {
|
|
if (!await poll(async () => { try { return (await fetch(`http://127.0.0.1:${port}/api/version`)).ok; } catch { return false; } })) throw Error(`server failed: ${log}`);
|
|
proxy = createThrottleProxy({ targetPort: port, profile }); proxy.startTracking();
|
|
const origin = `http://127.0.0.1:${await proxy.listen()}`;
|
|
browser = await ({ chromium, webkit })[engine].launch({ headless: true });
|
|
context = await browser.newContext({ serviceWorkers: 'block' });
|
|
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
|
|
const page = await context.newPage();
|
|
page.on('console', msg => { if (msg.type() === 'error') result.visibleErrors.push(msg.text()); });
|
|
page.on('pageerror', error => result.visibleErrors.push(error.message));
|
|
|
|
const searchUrl = '**/api/search?q=retry-transport-test*'; let searchCalls = 0;
|
|
await context.route(searchUrl, async route => {
|
|
searchCalls++; result.injected.push({ path: '/api/search', attempt: searchCalls, fault: searchCalls === 1 ? '503' : null });
|
|
if (searchCalls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary proxy fault' }) });
|
|
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: [{ id: 'abcdefghijk', title: 'Retry fixture result', channel: 'Harness', duration: 60, thumbnail: '' }] }) });
|
|
});
|
|
await page.goto(origin);
|
|
await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#searchInput'));
|
|
let versionCalls = 0;
|
|
await context.route('**/api/version', async route => {
|
|
versionCalls++; result.injected.push({ path: '/api/version', attempt: versionCalls, fault: versionCalls === 1 ? '503' : null });
|
|
if (versionCalls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary version check failure' }) });
|
|
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ buildTag: 'retry-harness' }) });
|
|
});
|
|
assert.equal(await page.evaluate(() => fetchServerBuild()), 'retry-harness');
|
|
assert.equal(versionCalls, 2, `${engine}: update version check did not retry`);
|
|
result.recovered.push({ path: '/api/version', attempts: versionCalls });
|
|
|
|
await page.locator('#searchInput').fill('retry-transport-test'); await page.locator('#searchForm').evaluate(form => form.requestSubmit());
|
|
await page.locator('.card').filter({ hasText: 'Retry fixture result' }).waitFor({ timeout: 25000 });
|
|
result.recovered.push({ path: '/api/search', attempts: searchCalls });
|
|
assert.equal(searchCalls, 2, `${engine}: search did not retry exactly once`);
|
|
assert.equal(await page.locator('.retry-btn,.related-retry').count(), 0, `${engine}: transient search exposed a manual retry`);
|
|
|
|
let streamCalls = 0;
|
|
await context.route('**/api/streams?v=abcdefghijk*', async route => {
|
|
streamCalls++; result.injected.push({ path: '/api/streams', attempt: streamCalls, fault: streamCalls === 1 ? '503' : null });
|
|
if (streamCalls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary upstream failure' }) });
|
|
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, data: { meta: { id: 'abcdefghijk', title: 'Stream fixture' }, qualities: [] } }) });
|
|
});
|
|
const streams = await page.evaluate(() => API.getStreams('abcdefghijk'));
|
|
assert.equal(streamCalls, 2, `${engine}: stream lookup did not retry`);
|
|
assert.equal(streams.ok, true);
|
|
result.recovered.push({ path: '/api/streams', attempts: streamCalls });
|
|
|
|
let relatedCalls = 0;
|
|
await context.route('**/api/related?*', async route => {
|
|
relatedCalls++; result.injected.push({ path: '/api/related', attempt: relatedCalls, fault: relatedCalls === 1 ? 'connection-reset' : null });
|
|
if (relatedCalls === 1) return route.abort('failed');
|
|
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: [{ id: 'lmnopqrstuv', title: 'Related recovered', channel: 'Harness' }] }) });
|
|
});
|
|
const relatedState = await page.evaluate(async () => {
|
|
current = { meta: { id: 'abcdefghijk', title: 'Retry fixture result', channel: 'Harness' } };
|
|
await loadRelated(); return relatedState.status;
|
|
});
|
|
assert.equal(relatedCalls, 2, `${engine}: related discovery did not retry`);
|
|
assert.equal(relatedState, 'ready');
|
|
assert.equal(await page.locator('.related-retry').count(), 0, 'transient failure must not expose manual Retry');
|
|
result.recovered.push({ path: '/api/related', attempts: relatedCalls });
|
|
|
|
let chunkCalls = 0;
|
|
await context.route('**/video-edit.js?v=*', async route => {
|
|
chunkCalls++; result.injected.push({ path: '/video-edit.js', attempt: chunkCalls, fault: chunkCalls === 1 ? 'connection-reset' : null });
|
|
if (chunkCalls === 1) return route.abort('failed');
|
|
return route.continue();
|
|
});
|
|
await page.evaluate(() => Lazy.load('feature:video-edit'));
|
|
assert.equal(chunkCalls, 2, `${engine}: lazy chunk did not retry`);
|
|
assert.equal(await page.locator('.retry-btn,.related-retry').count(), 0);
|
|
result.recovered.push({ path: '/video-edit.js', attempts: chunkCalls });
|
|
const unexpected = result.visibleErrors.filter(message => !/Failed to load resource|Load failed/.test(message));
|
|
assert.deepEqual(unexpected, [], `${engine}: page errors after transient recovery`);
|
|
console.log(JSON.stringify(result));
|
|
} catch (error) { console.error(engine, log); throw error; }
|
|
finally { await context?.close(); await browser?.close(); await proxy?.close?.(); child.kill('SIGTERM'); }
|
|
}
|
|
console.log(`Retry recovery passed (${results.length} engine(s), ${profile}).`);
|