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

@ -7,13 +7,13 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fillSession } from '../cli/console/fill';
import { engineWaits, fillSession } from '../cli/console/fill';
import type { ConsoleBrowser } from '../cli/console/session';
import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../cli/lib/app-privacy';
import type { ShipConfig } from '../cli/lib/config';
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, setUpAllowed, setUpTexts, TYPE_ALIASES } from '../cli/lib/console-forms';
import { APPLE_PRIVACY_TYPES } from '../cli/lib/app-privacy';
import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, testScope } from './helpers/browser';
import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, TEST_TICK_MS, TEST_WAITS, testScope } from './helpers/browser';
const FIX = join(import.meta.dir, 'fixtures', 'console');
const base0: ShipConfig = {
@ -52,7 +52,9 @@ beforeAll(() => {
base = `http://127.0.0.1:${server.port}/`;
});
afterEach(() => scope.settle(), AFTER_EACH_MS);
afterAll(async () => { await shared.close(); server?.stop(true); }, AFTER_ALL_MS);
let restoreWaits = () => {};
beforeAll(() => { restoreWaits = engineWaits(TEST_WAITS); });
afterAll(async () => { restoreWaits(); await shared.close(); server?.stop(true); }, AFTER_ALL_MS);
async function local(): Promise<ConsoleBrowser> {
const ctx = scope.track(await browser.newContext());
@ -61,7 +63,7 @@ async function local(): Promise<ConsoleBrowser> {
const done = new Promise<void>(r => ctx.on('close', () => { closed = true; r(); }));
return { mode: 'profile', context: ctx, page, pages: () => ctx.pages(), closed: done, isClosed: () => closed, close: () => ctx.close(), cleanup: () => {} };
}
const fast = (log: ReturnType<typeof logLines>) => ({ say: log.say, tickMs: 100, navTimeoutMs: 5000, signInWaitMs: 30_000, env: {} });
const fast = (log: ReturnType<typeof logLines>) => ({ say: log.say, tickMs: TEST_TICK_MS, navTimeoutMs: 5000, signInWaitMs: 30_000, env: {} });
const clicks = (b: ConsoleBrowser): Promise<string[]> => b.page.evaluate(() => (window as any).__clicks);
const navClicks = (b: ConsoleBrowser): Promise<string[]> => b.page.evaluate(() => (window as any).__navClicks());
/** Guardrail: a line that tells the owner to click a navigation control themselves. */
@ -139,7 +141,7 @@ describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
});
describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-app-privacy-flow.html)', () => {
test('first time: Get Started > yes > Next > data types > Save > each type (purposes > Next > linked > Next > tracking > Save); Publish never clicked', async () => {
scope.concurrent('first time: Get Started > yes > Next > data types > Save > each type (purposes > Next > linked > Next > tracking > Save); Publish never clicked', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
@ -159,7 +161,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
expect((await run).fallback).toBe(false);
}, TEST_MS);
test('answers exist and the list differs from the config: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => {
scope.concurrent('answers exist and the list differs from the config: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => {
const b = await local();
const log = logLines();
const config = privacy({ COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS'], linked: false, tracking: false } });
@ -174,7 +176,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
await run;
}, TEST_MS);
test('an uncertain answer stops the walk on its page until the owner sets it, then it goes on', async () => {
scope.concurrent('an uncertain answer stops the walk on its page until the owner sets it, then it goes on', async () => {
const b = await local();
const log = logLines();
const config = privacy({ DEVICE_ID: { collected: true, purposes: ['ANALYTICS'], tracking: false } }); // linked: not in the config
@ -194,7 +196,7 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a
await run;
}, TEST_MS);
test('no data collected: "No" and the dialog\'s Save, then the summary', async () => {
scope.concurrent('no data collected: "No" and the dialog\'s Save, then the summary', async () => {
const b = await local();
const log = logLines();
const types = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
@ -224,7 +226,7 @@ const SETUP = 'apple-app-privacy-setup.html';
const answersOf = (b: ConsoleBrowser) => b.page.evaluate(() => (window as any).__answers);
describe.skipIf(!browser)('App Privacy: each data type set up from the real page ("Set Up <type>" as plain text; fixture apple-app-privacy-setup.html)', () => {
test('every collected type is set up through its dialog (purposes > Next > linked > Next > two info steps > Next > Next > tracking > Save; purpose checkboxes without an accessible name); "?", Publish and the Edits never clicked; every step dumped', async () => {
scope.concurrent('every collected type is set up through its dialog (purposes > Next > linked > Next > two info steps > Next > Next > tracking > Save; purpose checkboxes without an accessible name); "?", Publish and the Edits never clicked; every step dumped', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
try {
const b = await local();
@ -258,7 +260,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
} finally { rmSync(dir, { recursive: true, force: true }); }
}, TEST_MS);
test('an uncertain answer ("you decide") pauses on its step, names it, and goes on once the owner set it', async () => {
scope.concurrent('an uncertain answer ("you decide") pauses on its step, names it, and goes on once the owner set it', async () => {
const b = await local();
const log = logLines();
const config = six({ PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], tracking: false } }); // linked: the owner's
@ -279,7 +281,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
await run;
}, TEST_MS);
test('an unknown dialog step: a debug dump, a pause that names the data type, within the step timeout; nothing clicked past it', async () => {
scope.concurrent('an unknown dialog step: a debug dump, a pause that names the data type, within the step timeout; nothing clicked past it', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
try {
const b = await local();
@ -294,7 +296,8 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
const said = log.lines.join('\n');
expect(said).toMatch(/the "Device ID" dialog step after "Next" is not a page storebound knows .*Buttons in the dialog: "Back", "Cancel", "Next"/);
expect(said).toContain(`Debug saved to ${dir}`);
const sub = readdirSync(dir).find(n => /unknown/.test(n))!;
// the Device ID pause's dump (on a loaded machine a slow earlier step can get a dump of its own first)
const sub = readdirSync(dir).find(n => /unknown/.test(n) && /device-id/.test(n))!;
const pj = JSON.parse(readFileSync(join(dir, sub, 'page.json'), 'utf8'));
expect(pj.dialogs).toContain('Device ID');
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Account identity', checked: false });
@ -308,7 +311,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
} finally { rmSync(dir, { recursive: true, force: true }); }
}, TEST_MS);
test('types set up before: the same purposes are left as they are, other purposes are opened with their Edit', async () => {
scope.concurrent('types set up before: the same purposes are left as they are, other purposes are opened with their Edit', async () => {
const b = await local();
const log = logLines();
const url = `${base}${SETUP}?preset=${encodeURIComponent('Crash Data:App Functionality')}&preset=${encodeURIComponent('Device ID:Analytics')}`;
@ -327,7 +330,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
});
describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => {
test('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; its Save never clicked', async () => {
scope.concurrent('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; its Save never clicked', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-content-rating', config: base0, startUrl: base + 'play-content-rating.html', ...fast(log) }));
@ -348,7 +351,7 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
await run;
}, TEST_MS);
test('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next; Save stays the owner\'s', async () => {
scope.concurrent('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next; Save stays the owner\'s', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-target-audience', config: base0, startUrl: base + 'play-target-audience.html', ...fast(log) }));
@ -362,7 +365,7 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
await run;
}, TEST_MS);
test('New App (App Store) and Create app (Play): filled, the final button is the owner\'s, no navigation asked of the owner', async () => {
scope.concurrent('New App (App Store) and Create app (Play): filled, the final button is the owner\'s, no navigation asked of the owner', async () => {
for (const [form, file, final] of [['apple-app-record', 'apple-app-record.html', 'Create'], ['play-app-record', 'play-create-app.html', 'Create app']] as const) {
const b = await local();
const log = logLines();
@ -378,7 +381,7 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
});
describe.skipIf(!browser)('watchdog: an unknown page after a click never means a silent wait', () => {
test('Next leads to a page storebound does not know: heartbeat, a sanitized debug dump, the manual steps, a bounded stop', async () => {
scope.concurrent('Next leads to a page storebound does not know: heartbeat, a sanitized debug dump, the manual steps, a bounded stop', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
try {
const b = await local();
@ -399,7 +402,8 @@ describe.skipIf(!browser)('watchdog: an unknown page after a click never means a
expect(said).toContain(`Debug saved to ${dir}`);
expect(said).toContain('this part is left to the calling agent with its browser tool (`storebound console plan apple-app-privacy`');
expect(said).toContain('it stops here');
const [sub] = readdirSync(dir);
// the dump of the unknown page (on a loaded machine a slow earlier step can get a dump of its own first)
const sub = readdirSync(dir).find(n => /unknown-the-page-after-next/.test(n));
const files = readdirSync(join(dir, sub!)).sort();
expect(files).toEqual(['page.html', 'page.json', 'screen.jpg']);
const pj = JSON.parse(readFileSync(join(dir, sub!, 'page.json'), 'utf8'));
@ -412,7 +416,7 @@ describe.skipIf(!browser)('watchdog: an unknown page after a click never means a
} finally { rmSync(dir, { recursive: true, force: true }); }
}, TEST_MS);
test('--debug-dump: a dump at the start, after each click and after each filled page', async () => {
scope.concurrent('--debug-dump: a dump at the start, after each click and after each filled page', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
try {
const b = await local();