ci speed: desktop boot check runs its cases in parallel (one browser, killed at the end; a page per case; condition waits instead of fixed sleeps: element, text or a quiet window of host calls and DOM); shots capture / render close the browser with a 3 s cap, then kill it; console engine waits injectable for tests (WAITS / engineWaits: poll rounds, a missing step ends once its loaded page stays quiet, team switch settle, field-miss grace), fillPage looks up each data type's dialog once per round and skips dialog sections when no dialog is open; browser tests run 4 at a time per file (testScope().concurrent with a scope per test, --max-concurrency in ci), 20 ms rounds; capture-web counts boots per test and runs its browser tests concurrently; console-session / -persist Chrome tests concurrent; recorded test timings smoothed (mergeTimings) so one loaded run does not skew the shard split

This commit is contained in:
CRTkafa 2026-10-13 02:20:03 +08:00
parent f2d7062bcb
commit 9ba8471002
16 changed files with 390 additions and 178 deletions

View file

@ -16,6 +16,11 @@ export const HEAVY = [
'tests/capture-web.test.ts', 'tests/console-session.test.ts', 'tests/console-persist.test.ts', 'tests/console-flow.test.ts',
];
/** Browser tests of one file at the same time (bun test --max-concurrency for the shards with a HEAVY file; the
* tests opt in with testScope().concurrent, tests/helpers/browser.ts): enough to overlap their waits, few enough that
* a loaded machine does not stretch each one towards its timeout. */
export const BROWSER_CONCURRENCY = 4;
/** Changes that can affect every test: run the whole suite. */
export const GLOBAL = /^(package\.json|bun\.lock|bunfig\.toml|tsconfig\.json|tests\/helpers\/preload\.ts)$/;
@ -50,6 +55,17 @@ export function writeTimings(t: Record<string, number>, file = TIMINGS): void {
try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${JSON.stringify(t, null, 1)}\n`); } catch { /* cache only */ }
}
/**
* The recorded time of each file after a run: a new file takes its measurement; a known one moves halfway towards it
* and never above it. The machine is shared, so one run under heavy load (other sessions' CI, cargo) must not make a
* file look several times slower for the next split, while a file that really got faster or slower still converges.
*/
export function mergeTimings(old: Record<string, number>, fresh: Record<string, number>): Record<string, number> {
const out = { ...old };
for (const [f, t] of Object.entries(fresh)) out[f] = old[f] === undefined ? t : Math.min(t, (old[f]! + t) / 2);
return out;
}
/** Per-file seconds from bun's JUnit report (the top-level <testsuite> of each file). */
export function junitTimes(xml: string): Record<string, number> {
const out: Record<string, number> = {};

View file

@ -5,8 +5,9 @@
// bun run ci:quick inner loop: typecheck + the tests related to the change (git diff against origin/main,
// mapped through imports) + scrub
// The tests run as shards: N = CPU cores - 1 (max 8; STOREBOUND_CI_SHARDS=n overrides) parallel `bun test <files>`
// processes, balanced by the durations recorded after the last run (node_modules/.cache/storebound-ci/, falling back
// to file size). Browser suites (tools/ci-plan.ts HEAVY) each get a shard of their own.
// processes, balanced by the durations recorded after earlier runs (node_modules/.cache/storebound-ci/, smoothed by
// mergeTimings so one run on a loaded machine does not skew the split; falling back to file size). Browser suites
// (tools/ci-plan.ts HEAVY) each get a shard of their own and run their opted-in tests BROWSER_CONCURRENCY at a time.
// `bun test` covers the CLI and the desktop webview (apps/desktop/tests), the site overflow check and the
// no-hand-written-SVG guard; the desktop boot check (tools/desktop-boot-check.ts: the built window in headless
// Chromium, needs playwright's browser or Google Chrome) runs after the desktop build.
@ -17,7 +18,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { hostTarget, OUT_DIR, outName } from './build-sidecars';
import { changedFiles, HEAVY, junitTimes, listTests, planShards, readTimings, relatedTests, ROOT, rustNeeded, writeTimings } from './ci-plan';
import { BROWSER_CONCURRENCY, changedFiles, HEAVY, junitTimes, listTests, mergeTimings, planShards, readTimings, relatedTests, ROOT, rustNeeded, writeTimings } from './ci-plan';
interface Task { name: string; cmd: string[]; cwd?: string; env?: Record<string, string>; after?: string[]; junit?: string }
@ -39,7 +40,8 @@ function addTests(files: string[]) {
// the built-bundle check in apps/desktop/tests/webview.test.ts runs as "desktop bundle check" once the build is
// done; here it would read dist/ while vite rewrites it
// browser suites share the machine with the other steps: a browser start or close can pass bun's 5 s default
const timeout = shard.some(f => HEAVY.includes(f)) ? ['--timeout=30000'] : [];
// browser tests that opt in (testScope().concurrent) run BROWSER_CONCURRENCY at a time within their file
const timeout = shard.some(f => HEAVY.includes(f)) ? ['--timeout=30000', `--max-concurrency=${BROWSER_CONCURRENCY}`] : [];
add({ name: `tests ${i + 1}/${all.length}`, cmd: ['bun', 'test', ...timeout, '--reporter=junit', `--reporter-outfile=${junit}`, ...shard.map(f => `./${f}`)],
env: { STOREBOUND_SKIP_DIST_CHECK: '1' }, junit });
});
@ -156,12 +158,12 @@ while ((pending.size || active.size) && !failed) {
if (failed) await Promise.allSettled([...active]);
// record per-file durations for the next split (only from shards that finished)
const timings = readTimings();
const fresh: Record<string, number> = {};
for (const r of runs.values()) {
if (!r.task.junit || r.end === undefined) continue;
try { Object.assign(timings, junitTimes(readFileSync(r.task.junit, 'utf8'))); } catch { /* no report */ }
try { Object.assign(fresh, junitTimes(readFileSync(r.task.junit, 'utf8'))); } catch { /* no report */ }
}
writeTimings(timings);
writeTimings(mergeTimings(readTimings(), fresh));
rmSync(junitDir, { recursive: true, force: true });
console.log(`\n${quick ? 'ci:quick' : 'ci'} summary (wall ${((Date.now() - t0) / 1000).toFixed(1)}s)`);

View file

@ -13,8 +13,11 @@
// build picker, the plan panel and a console plan (the real sidecar) by DOM text, then every tab of the app and the
// Dependencies screen.
// bun tools/desktop-boot-check.ts [--dist apps/desktop/dist] [--only d1-missing-folders] [--json]
// Uses playwright: Google Chrome when installed (channel "chrome"), else playwright's Chromium
// (bunx playwright install chromium). Part of `bun run ci` and of `tools/desktop-release.ts mac`.
// Speed: one browser (killed at the end, never closed gracefully), one page per case, the cases in parallel
// (STOREBOUND_BOOT_CHECK_JOBS, default 8) against the one build; no fixed sleeps: after each action it waits for a
// condition (the element, the text, or settle(): no host call in flight and no DOM change for QUIET_MS).
// Uses playwright: its headless shell (bunx playwright install chromium-headless-shell), else Google Chrome
// (channel "chrome"), else its full Chromium. Part of `bun run ci` and of `tools/desktop-release.ts mac`.
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { extname, join, resolve } from 'node:path';
import type { Browser, Page } from 'playwright';
@ -173,12 +176,21 @@ const initScript = (freeze: boolean) => `(() => {
unregisterCallback(id) { delete cbs[id]; },
convertFileSrc(p) { return p; },
async invoke(cmd, args) {
const r = await window.__bootCheckHost(cmd, args || {});
if ('err' in r) throw r.err;
return r.ok;
window.__bootInflight = (window.__bootInflight || 0) + 1;
window.__bootChanged = performance.now();
try {
const r = await window.__bootCheckHost(cmd, args || {});
if ('err' in r) throw r.err;
return r.ok;
} finally { window.__bootInflight--; window.__bootChanged = performance.now(); }
},
};
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} };
// settle(): the last host call or DOM change, so the check waits for a quiet window instead of a fixed time
window.__bootInflight = 0;
window.__bootChanged = performance.now();
new MutationObserver(() => { window.__bootChanged = performance.now(); })
.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });
})();`;
// ---------------------------------------------------------------------------------------------------------------------
@ -211,6 +223,32 @@ async function state(page: Page) {
}));
}
/** A quiet window: no host call in flight and no DOM change for this long means the last action is done. */
const QUIET_MS = 150;
/** Upper bound of one wait for a condition (a loaded machine); a wait that runs out is a failure or a check as-is. */
const WAIT_MS = 15_000;
/**
* Waits until the window is quiet (initScript's counters: no host call in flight, no DOM change for QUIET_MS): what a
* click or the boot started has drawn. Condition-based, no fixed sleep; a window that never gets quiet (an animation
* written by script) is checked as it is after `ms`.
*/
async function settle(page: Page, ms = WAIT_MS): Promise<void> {
await page.waitForFunction((q: number) => {
const w = window as unknown as { __bootInflight: number; __bootChanged: number };
return w.__bootInflight === 0 && performance.now() - w.__bootChanged >= q;
}, QUIET_MS, { timeout: ms, polling: 50 }).catch(() => {});
}
/** Waits until `sel` is gone (a dialog or panel closed); false when it is still there after `ms`. */
const gone = (page: Page, sel: string, ms = WAIT_MS): Promise<boolean> =>
page.waitForSelector(sel, { state: 'detached', timeout: ms }).then(() => true, () => false);
/** Waits until the first `sel` contains `text` (whitespace folded); false after `ms`. */
const shows = (page: Page, sel: string, text: string, ms = WAIT_MS): Promise<boolean> =>
page.waitForFunction(([s, t]) => (document.querySelector(s!) as HTMLElement | null)?.innerText.replace(/\s+/g, ' ').includes(t!) ?? false,
[sel, text] as const, { timeout: ms, polling: 50 }).then(() => true, () => false);
async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { breakBundle?: boolean } = {}): Promise<CaseResult> {
const freeze = conf.app.security.freezePrototype === true;
const server = serve(DIST, cspHeader(conf.app.security.csp), !!o.breakBundle);
@ -232,13 +270,13 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
if (o.breakBundle) {
await page.waitForSelector('#boot-fallback', { timeout: 8000 }).catch(() => {});
await page.waitForSelector('#boot-fallback', { timeout: WAIT_MS }).catch(() => {});
const s = await state(page);
const ok = s.crash.some(c => c.startsWith('boot'));
return { name, ok, problems: ok ? [] : ['the boot.js error panel did not appear for a missing bundle'], visited: 0, notes: [`boot.js error panel shown: ${ok}`, ...logged.map(l => `webview.log: ${l}`)] };
}
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: 15000 }).catch(() => problems.push('React never drew the window (no data-booted)'));
await page.waitForTimeout(1500);
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)'));
await settle(page);
const check = async (where: string) => {
const s = await state(page);
if (freeze && !s.frozen) problems.push('Object.prototype is not frozen in the page (the check would not see the override mistake)');
@ -251,7 +289,7 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
if (await wizard.count()) {
notes.push('welcome guide shown');
await wizard.press('Escape').catch(() => {});
await page.waitForTimeout(300);
await gone(page, '.wizard[role="dialog"]');
if (await wizard.count()) problems.push('the welcome guide does not close with Escape');
}
// every sidebar entry: the portfolio, each app (folders that are gone included), each workspace screen
@ -259,8 +297,8 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
for (let i = 0; i < count; i++) {
const b = page.locator('nav.side button.nav').nth(i);
const label = ((await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim();
await b.click({ timeout: 5000 }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(700);
await b.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`));
await settle(page);
await check(label);
visited++;
}
@ -268,8 +306,9 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
for (const p of fx.projects_list.filter(x => !isDir(x.path))) {
const item = page.locator('nav.side .nav-app', { hasText: p.name }).first();
if (!(await item.count())) { problems.push(`${p.name}: not in the sidebar`); continue; }
await item.click();
await page.waitForTimeout(500);
await item.click({ timeout: WAIT_MS });
await shows(page, '#content', 'Folder not found');
await settle(page);
const text = await page.locator('#content').innerText();
if (!text.includes('Folder not found') || !text.includes('Locate…') || !text.includes('Remove from list')) problems.push(`${p.name}: no "Folder not found" panel with Locate… / Remove`);
else notes.push(`${p.name}: Folder not found, Locate… / Remove`);
@ -321,18 +360,18 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full'
const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); };
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: 15000 }).catch(() => problems.push('React never drew the window (no data-booted)'));
await page.waitForTimeout(1200);
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)'));
await settle(page);
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await page.waitForTimeout(300); }
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); }
const app = fx.projects_list.find(p => isDir(p.path))!;
// the sidebar names the app by its config name once the offline portfolio is read
const item = page.locator('nav.side .nav-app').filter({ hasText: base === 'd3-full' ? /Prism Puddle|demo-game/ : new RegExp(app.name) }).first();
await item.click({ timeout: 8000 }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(800);
await page.locator('[role="tab"]#tab-releases').click({ timeout: 5000 }).catch(() => {});
await page.waitForSelector('[data-section="submit"]', { timeout: 8000 }).catch(() => problems.push('Releases: no send-for-review section'));
await page.waitForTimeout(600);
await item.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`));
await settle(page);
await page.locator('[role="tab"]#tab-releases').click({ timeout: WAIT_MS }).catch(() => {});
await page.waitForSelector('[data-section="submit"]', { timeout: WAIT_MS }).catch(() => problems.push('Releases: no send-for-review section'));
await settle(page);
const rel = await text('[data-section="submit"]');
for (const want of SUBMIT_EXPECT[variant]) if (!rel.includes(want)) problems.push(`Releases (${variant}): missing "${want}"`);
if (rel.includes('storebound never submits for review')) problems.push('Releases: the old "submit stays in the console" note is still there');
@ -346,15 +385,16 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full'
const action = page.locator('[data-section="submit"] button', { hasText: variant === 'ready' ? 'Submit for review' : 'Resubmit' }).first();
if (!(await action.isDisabled())) problems.push('the submit button is enabled before a build is picked');
await page.locator('[data-section="submit"] select').first().selectOption('52');
await action.click({ timeout: 5000 });
await page.waitForSelector('.oa-panel', { timeout: 8000 }).catch(() => problems.push('no plan panel after the submit button'));
await action.click({ timeout: WAIT_MS });
await page.waitForSelector('.oa-panel', { timeout: WAIT_MS }).catch(() => problems.push('no plan panel after the submit button'));
await settle(page);
const panel = await text('.oa-panel');
for (const want of ['Plan (dry run, nothing sent yet)', 'STEP', 'attach build 52', 'a system dialog shows this plan again']) if (!panel.includes(want)) problems.push(`plan panel: missing "${want}"`);
await page.locator('.oa-panel .lb-foot button', { hasText: 'Continue' }).click({ timeout: 5000 }).catch(e => problems.push(`Continue: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(500);
await page.locator('.oa-panel .lb-foot button', { hasText: 'Continue' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`Continue: ${String(e).split('\n')[0]}`));
await shows(page, '.oa-panel', 'nothing was sent');
if (!(await text('.oa-panel')).includes('nothing was sent')) problems.push('plan panel: no "nothing was sent" after the dialog said Cancel');
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await gone(page, '.oa-panel');
if (await page.locator('.oa-panel').count()) problems.push('plan panel does not close with Escape');
const want = variant === 'ready' ? 'owner.apple_submit' : 'owner.apple_resubmit';
if (!live.calls.some(c => c.startsWith(`dry ${want} {"build":"52","version":"2.4.0"}`))) problems.push(`no dry run of ${want} with build 52 and version 2.4.0: ${live.calls.join(' | ')}`);
@ -364,27 +404,29 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full'
const codeOpts = await codes.locator('option').evaluateAll(os => os.map(o => `${(o as HTMLOptionElement).value}${(o as HTMLOptionElement).disabled ? '!' : ''}`));
if (JSON.stringify(codeOpts) !== JSON.stringify(['', '24', '23!', '22'])) problems.push(`versionCode picker: ${JSON.stringify(codeOpts)}`);
await codes.selectOption('24');
await page.locator('[data-section="submit"] button', { hasText: 'Send to production' }).first().click({ timeout: 5000 });
await page.waitForSelector('.oa-panel', { timeout: 8000 }).catch(() => problems.push('no plan panel after Send to production'));
await page.locator('[data-section="submit"] button', { hasText: 'Send to production' }).first().click({ timeout: WAIT_MS });
await page.waitForSelector('.oa-panel', { timeout: WAIT_MS }).catch(() => problems.push('no plan panel after Send to production'));
await settle(page);
const pp = await text('.oa-panel');
for (const w of ['--version-code 24 --production completed', 'managed publishing is off']) if (!pp.includes(w)) problems.push(`production plan: missing "${w}"`);
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await gone(page, '.oa-panel');
if (!live.calls.some(c => c.startsWith('dry owner.play_production {"version_code":"24","status":"completed"}'))) problems.push(`no dry run of owner.play_production 24: ${live.calls.join(' | ')}`);
// a console plan from the real sidecar (offline): Play's Send changes for review
await page.locator('[data-section="submit"] button', { hasText: 'Send changes for review' }).first().click({ timeout: 5000 });
await page.waitForSelector('.cp-panel', { timeout: 10000 }).catch(() => problems.push('no console plan panel'));
await page.locator('[data-section="submit"] button', { hasText: 'Send changes for review' }).first().click({ timeout: WAIT_MS });
await page.waitForSelector('.cp-panel', { timeout: WAIT_MS }).catch(() => problems.push('no console plan panel'));
await settle(page);
const cp = await text('.cp-panel');
for (const w of ['send the first production release for review', 'Send changes for review', 'Check the result']) if (!cp.includes(w)) problems.push(`console plan: missing "${w}"`);
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await gone(page, '.cp-panel');
await crashCheck('Releases');
visited++;
// every tab of the app, then App info's console steps and the Dependencies screen
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
for (const id of tabs) {
await page.locator(`[role="tab"]#${id}`).click({ timeout: 5000 }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(500);
await page.locator(`[role="tab"]#${id}`).click({ timeout: WAIT_MS }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`));
await settle(page);
await crashCheck(id);
visited++;
if (id === 'tab-appinfo') {
@ -395,8 +437,9 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full'
}
notes.push(`tabs: ${tabs.join(', ')}`);
const deps = page.locator('nav.side button.nav', { hasText: 'Dependencies' }).first();
await deps.click({ timeout: 5000 }).catch(() => {});
await page.waitForTimeout(800);
await deps.click({ timeout: WAIT_MS }).catch(() => {});
await shows(page, '#content', 'Android SDK');
await settle(page);
if (!(await text()).includes('Android SDK')) problems.push('Dependencies: no Android SDK card');
await crashCheck('Dependencies');
visited++;
@ -412,27 +455,47 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full'
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
async function launch(): Promise<Browser> {
/**
* One headless browser for every case, as a browser server that is KILLED at the end (a throwaway profile, nothing to
* flush): Google Chrome's graceful close takes 10-30 s after http pages (tests/helpers/browser.ts). Playwright's
* headless shell first (fast to start), else Google Chrome (channel "chrome"), else playwright's full Chromium.
*/
async function launch(): Promise<{ browser: Browser; kill: () => Promise<void> }> {
const { chromium } = await import('playwright');
try { return await chromium.launch({ channel: 'chrome', headless: true }); } catch { /* no Google Chrome */ }
return chromium.launch({ headless: true });
const opts = { headless: true, timeout: 60_000 };
let server: Awaited<ReturnType<typeof chromium.launchServer>> | undefined, first: unknown;
for (const o of [{}, { channel: 'chrome' }, { channel: 'chromium' }]) {
try { server = await chromium.launchServer({ ...opts, ...o }); break; } catch (e) { first ??= e; }
}
if (!server) throw first;
const s = server;
const browser = await chromium.connect(s.wsEndpoint(), { timeout: 60_000 }).catch(async (e: unknown) => { await s.kill(); throw e; });
return { browser, kill: async () => { await Promise.race([browser.close().catch(() => {}), Bun.sleep(5000)]); await s.kill(); } };
}
/** Cases at once (a page each, one browser); STOREBOUND_BOOT_CHECK_JOBS overrides. */
const JOBS = Math.max(1, Number(process.env.STOREBOUND_BOOT_CHECK_JOBS) || 8);
export async function bootCheck(): Promise<CaseResult[]> {
if (!existsSync(join(DIST, 'index.html'))) throw new Error(`no built frontend at ${DIST} (bun run --cwd apps/desktop build)`);
const names = readdirSync(FIXTURES).filter(f => f.endsWith('.host.json')).map(f => f.replace(/\.host\.json$/, '')).sort();
if (names.length < 3) throw new Error(`expected the state fixtures in ${FIXTURES}`);
const sidecar = new Sidecar();
await sidecar.start();
const browser = await launch();
const results: CaseResult[] = [];
const [, { browser, kill }] = await Promise.all([sidecar.start(), launch()]);
// every case in its own context and page, JOBS at a time, against the one build; results keep the case order
const cases: (() => Promise<CaseResult>)[] = [];
for (const n of names) if (!ONLY.length || ONLY.includes(n)) cases.push(() => runCase(browser, n, sidecar));
if (!ONLY.length || ONLY.includes('missing-bundle')) cases.push(() => runCase(browser, 'missing-bundle', sidecar, { breakBundle: true }));
for (const base of ['d3-full', 'd1-missing-folders'] as const) for (const v of ['ready', 'unresolved'] as const)
if (!ONLY.length || ONLY.includes('submit') || ONLY.includes(`submit-${v}`)) cases.push(() => runSubmitCase(browser, sidecar, base, v));
const results: CaseResult[] = new Array(cases.length);
let next = 0;
try {
for (const n of names) if (!ONLY.length || ONLY.includes(n)) results.push(await runCase(browser, n, sidecar));
if (!ONLY.length || ONLY.includes('missing-bundle')) results.push(await runCase(browser, 'missing-bundle', sidecar, { breakBundle: true }));
for (const base of ['d3-full', 'd1-missing-folders'] as const) for (const v of ['ready', 'unresolved'] as const)
if (!ONLY.length || ONLY.includes('submit') || ONLY.includes(`submit-${v}`)) results.push(await runSubmitCase(browser, sidecar, base, v));
await Promise.all(Array.from({ length: Math.min(JOBS, cases.length) }, async () => {
while (next < cases.length) { const i = next++; results[i] = await cases[i]!(); }
}));
} finally {
await browser.close();
await kill();
sidecar.stop();
}
return results;