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:
parent
f2d7062bcb
commit
9ba8471002
16 changed files with 390 additions and 178 deletions
|
|
@ -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> = {};
|
||||
|
|
|
|||
16
tools/ci.ts
16
tools/ci.ts
|
|
@ -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)`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue