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
|
|
@ -53,14 +53,17 @@ describe('capture helpers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---- a local fixture web app: /scene/<name>?lang=xx, a SPA (popstate) that counts its boots ----
|
||||
let boots = 0;
|
||||
// ---- a local fixture web app: /<key>/scene/<name>?lang=xx, a SPA (popstate) that counts its boots per key: each
|
||||
// browser test has its own key, so they run at the same time (test.concurrent) ----
|
||||
const boots = new Map<string, number>();
|
||||
const booted = (key: string) => boots.get(key) ?? 0;
|
||||
const server = Bun.serve({
|
||||
port: 0, hostname: '127.0.0.1',
|
||||
fetch(req) {
|
||||
const u = new URL(req.url);
|
||||
if (u.pathname === '/app.js') {
|
||||
boots++;
|
||||
const key = u.searchParams.get('k') ?? '';
|
||||
boots.set(key, booted(key) + 1);
|
||||
return new Response(`
|
||||
const COLORS = { en: '#1e66f5', de: '#40a02b', tr: '#d20f39' };
|
||||
function show() {
|
||||
|
|
@ -74,7 +77,8 @@ const server = Bun.serve({
|
|||
addEventListener('popstate', show);
|
||||
show();`, { headers: { 'content-type': 'text/javascript' } });
|
||||
}
|
||||
return new Response('<!doctype html><html><head><meta charset="utf-8"><script src="/app.js" defer></script></head><body></body></html>', { headers: { 'content-type': 'text/html' } });
|
||||
const key = encodeURIComponent(u.pathname.split('/')[1] ?? '');
|
||||
return new Response(`<!doctype html><html><head><meta charset="utf-8"><script src="/app.js?k=${key}" defer></script></head><body></body></html>`, { headers: { 'content-type': 'text/html' } });
|
||||
},
|
||||
});
|
||||
afterAll(() => server.stop(true));
|
||||
|
|
@ -99,15 +103,16 @@ function project(cfg: ShipConfig, files: Record<string, string>): { root: string
|
|||
}
|
||||
|
||||
const shot = (name: string, extra: object = {}) => ({ path: `/scene/${name}`, waitFor: `window.scene === '${name}'`, delayMs: 20, ...extra });
|
||||
function config(capture: Partial<ShotsCapture> = {}): ShipConfig {
|
||||
/** `key`: the fixture app's boot counter this config's runs count on (one per browser test). */
|
||||
function config(capture: Partial<ShotsCapture> = {}, key = 'app'): ShipConfig {
|
||||
return { ...BASE, shots: { copy: 'store/shots/copy.json', frames: 'store/shots/frames', out: 'store/renders', scenes: [{ id: 'hero', palette: 'fire', frames: [{ src: 'hero.png' }] }], phone: { w: 180, h: 240, dpr: 1 },
|
||||
capture: { source: 'web', baseUrl: `http://127.0.0.1:${server.port}`, query: 'lang={lang}', build: 'src', shots: { hero: shot('hero'), battle: shot('battle'), king: shot('king') }, ...capture } } };
|
||||
capture: { source: 'web', baseUrl: `http://127.0.0.1:${server.port}/${key}`, query: 'lang={lang}', build: 'src', shots: { hero: shot('hero'), battle: shot('battle'), king: shot('king') }, ...capture } } };
|
||||
}
|
||||
const files = { 'store/shots/copy.json': '{}', 'src/main.js': 'v1' };
|
||||
const frames = (root: string) => ['en', 'de', 'tr'].flatMap(l => ['hero', 'battle', 'king'].map(n => join(root, 'store/shots/frames', l, 'phone', `${n}.png`)));
|
||||
|
||||
describe.skipIf(!canLaunch)('shots capture (web) with a fixture app', () => {
|
||||
test('concurrency 3 gives the same files as serial', async () => {
|
||||
test.concurrent('concurrency 3 gives the same files as serial', async () => {
|
||||
const p = project(config(), files);
|
||||
try {
|
||||
await p.cli(['--device', 'phone', '--concurrency', '1', '--force']);
|
||||
|
|
@ -120,34 +125,31 @@ describe.skipIf(!canLaunch)('shots capture (web) with a fixture app', () => {
|
|||
} finally { rmSync(p.root, { recursive: true, force: true }); }
|
||||
}, 120_000);
|
||||
|
||||
test('per-language session boots the app once per language; per-shot once per frame', async () => {
|
||||
const p = project(config({ session: 'per-language' }), files);
|
||||
test.concurrent('per-language session boots the app once per language; per-shot once per frame', async () => {
|
||||
const p = project(config({ session: 'per-language' }, 'lang'), files);
|
||||
try {
|
||||
boots = 0;
|
||||
const r = await p.cli(['--device', 'phone', '--concurrency', '2', '--force']);
|
||||
expect(boots).toBe(3);
|
||||
expect(booted('lang')).toBe(3);
|
||||
expect(r.out).toContain('(reused page)');
|
||||
const report = JSON.parse(readFileSync(join(p.root, '.storebound/capture-report.json'), 'utf8')) as CaptureReport;
|
||||
expect(report.frames.filter(f => f.reused).length).toBe(6);
|
||||
expect(frames(p.root).every(f => existsSync(f))).toBe(true);
|
||||
// a shot whose precondition fails in the reused page (it needs a freshly booted page) falls back to a fresh page
|
||||
const bad = project(config({ session: 'per-language', reuseTimeoutMs: 300, shots: { hero: shot('hero'), fresh: { path: '/scene/fresh', waitFor: "window.scene === 'fresh' && window.shows === 1", delayMs: 20 } } }), files);
|
||||
const bad = project(config({ session: 'per-language', reuseTimeoutMs: 300, shots: { hero: shot('hero'), fresh: { path: '/scene/fresh', waitFor: "window.scene === 'fresh' && window.shows === 1", delayMs: 20 } } }, 'fresh'), files);
|
||||
try {
|
||||
boots = 0;
|
||||
const r2 = await bad.cli(['--device', 'phone', '--lang', 'en', '--force']);
|
||||
expect(r2.err).toContain('en/phone/fresh: reused page failed');
|
||||
expect(r2.out).toContain('capture done: 2 frames');
|
||||
expect(boots).toBe(2);
|
||||
expect(booted('fresh')).toBe(2);
|
||||
} finally { rmSync(bad.root, { recursive: true, force: true }); }
|
||||
boots = 0;
|
||||
const q = project(config(), files);
|
||||
const q = project(config({}, 'shot'), files);
|
||||
await q.cli(['--device', 'phone', '--lang', 'en', '--force']);
|
||||
rmSync(q.root, { recursive: true, force: true });
|
||||
expect(boots).toBe(3);
|
||||
expect(booted('shot')).toBe(3);
|
||||
} finally { rmSync(p.root, { recursive: true, force: true }); }
|
||||
}, 180_000);
|
||||
|
||||
test('cache skips unchanged frames, re-runs changed ones; --only-missing; timing breakdown', async () => {
|
||||
test.concurrent('cache skips unchanged frames, re-runs changed ones; --only-missing; timing breakdown', async () => {
|
||||
const p = project(config(), files);
|
||||
try {
|
||||
let r = await p.cli(['--device', 'phone']);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { balance, depsOf, GLOBAL, HEAVY, junitTimes, listTests, relatedTests, rustNeeded, shardCount } from '../tools/ci-plan';
|
||||
import { balance, depsOf, GLOBAL, HEAVY, junitTimes, listTests, mergeTimings, relatedTests, rustNeeded, shardCount } from '../tools/ci-plan';
|
||||
|
||||
describe('tools/ci-plan: test shards', () => {
|
||||
test('shard count: cores - 1, between 1 and 8, env override', () => {
|
||||
|
|
@ -27,6 +27,11 @@ describe('tools/ci-plan: test shards', () => {
|
|||
<testsuite name="tests/b.test.ts" file="tests/b.test.ts" time="0.25"></testsuite></testsuites>`;
|
||||
expect(junitTimes(xml)).toEqual({ 'tests/a.test.ts': 1.5, 'tests/b.test.ts': 0.25 });
|
||||
});
|
||||
test('recorded timings: a new file takes its time; a known one moves halfway, never up past the new time', () => {
|
||||
const t = mergeTimings({ 'a.test.ts': 10, 'b.test.ts': 40, 'keep.test.ts': 3 }, { 'a.test.ts': 30, 'b.test.ts': 20, 'new.test.ts': 5 });
|
||||
expect(t).toEqual({ 'a.test.ts': 20, 'b.test.ts': 20, 'keep.test.ts': 3, 'new.test.ts': 5 });
|
||||
});
|
||||
|
||||
test('every heavy suite exists and every test file is found', () => {
|
||||
const all = listTests();
|
||||
for (const h of HEAVY) expect(all).toContain(h);
|
||||
|
|
|
|||
|
|
@ -3,21 +3,22 @@
|
|||
// the public help pages, not recorded) served on 127.0.0.1. No network. Browser tests skip when no Chromium is
|
||||
// installed (a launch that times out fails instead). Load-proofing (tests/helpers/browser.ts): one launch with a long
|
||||
// launch timeout, every context closed right after its test, event-driven waits for log lines, explicit test timeouts.
|
||||
// Slow on purpose (marked SLOW): the sign-in / watching tests and the account tests wait out real "not here" timeouts
|
||||
// of the engine (navTimeoutMs / navMs, scaled down through the options). The relaunch of storebound's own profile
|
||||
// Speed: the browser tests run BROWSER_CONCURRENCY at a time (scope.concurrent, each with its own contexts) and the
|
||||
// engine's waits are the test ones (TEST_WAITS / TEST_TICK_MS: 20 ms rounds, a missing step ends once its page is
|
||||
// loaded and quiet). SLOW tests still wait out some "not here" timeouts of the engine (navMs, scaled down). The relaunch of storebound's own profile
|
||||
// (session cookies kept) needs a real Chrome: tests/console-persist.test.ts.
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { matchAccount, openAcross, readAccounts, rememberApple, rememberPlay, withAccountIds } from '../cli/console/accounts';
|
||||
import { createAppHere, fillPage, fillSession, navClick, navigate, resumeAt, run, safeClick, signInPage, signInReason } from '../cli/console/fill';
|
||||
import { createAppHere, engineWaits, fillPage, fillSession, navClick, navigate, resumeAt, run, safeClick, signInPage, signInReason } from '../cli/console/fill';
|
||||
import type { ConsoleBrowser } from '../cli/console/session';
|
||||
import { parseArgs } from '../cli/lib/args';
|
||||
import { type ShipConfig, validateExtras } from '../cli/lib/config';
|
||||
import { AGE_RATING_API, consoleAnswers, consoleResult, fillValue, FORM_IDS, type FormId, formMeta } from '../cli/lib/console-answers';
|
||||
import { consoleReached, CREATE_APP_OPENERS, createAppAllowed, formDef, FORM_OPENERS, forbiddenControl, hasToken, isSignInText, isSignInUrl, navAllowed, navPlan, playDeveloperId, switchAllowed } from '../cli/lib/console-forms';
|
||||
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 config: ShipConfig = {
|
||||
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
|
||||
|
|
@ -208,7 +209,9 @@ beforeAll(() => {
|
|||
});
|
||||
// bounded: every context is closed per test (afterEach) and each close is capped (tests/helpers/browser.ts)
|
||||
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 open(file: string) {
|
||||
const page = await scope.newPage();
|
||||
|
|
@ -221,7 +224,7 @@ const flagged = (page: any) => page.evaluate(() => document.querySelectorAll('[d
|
|||
const clicks = (page: any) => page.evaluate(() => (window as any).__clicks);
|
||||
|
||||
describe.skipIf(!browser)('engine (headless, modeled pages)', () => {
|
||||
test('app record: certain fields filled, uncertain empty + highlighted, Create never clicked', async () => {
|
||||
scope.concurrent('app record: certain fields filled, uncertain empty + highlighted, Create never clicked', async () => {
|
||||
const page = await open('apple-app-record.html');
|
||||
const def = formDef('apple-app-record', pageAnswers('apple-app-record'));
|
||||
const s = await fillPage(page, def, pageAnswers('apple-app-record'));
|
||||
|
|
@ -242,7 +245,7 @@ describe.skipIf(!browser)('engine (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('User Access under a plain heading (real App Store Connect): the radios are found in the dialog', async () => {
|
||||
scope.concurrent('User Access under a plain heading (real App Store Connect): the radios are found in the dialog', async () => {
|
||||
const page = await open('apple-app-record-heading.html');
|
||||
const s = await fillPage(page, formDef('apple-app-record', pageAnswers('apple-app-record')), pageAnswers('apple-app-record'));
|
||||
expect(s.fallback).toBe(false);
|
||||
|
|
@ -252,7 +255,7 @@ describe.skipIf(!browser)('engine (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('a lasting partial miss fills every field that was found and leaves only the missing one', async () => {
|
||||
scope.concurrent('a lasting partial miss fills every field that was found and leaves only the missing one', async () => {
|
||||
const page = await open('apple-app-record-renamed.html');
|
||||
const def = formDef('apple-app-record', pageAnswers('apple-app-record'));
|
||||
const s = await fillPage(page, def, pageAnswers('apple-app-record'), new Set(), true);
|
||||
|
|
@ -265,7 +268,7 @@ describe.skipIf(!browser)('engine (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('a renamed label stops filling and falls back to guidance', async () => {
|
||||
scope.concurrent('a renamed label stops filling and falls back to guidance', async () => {
|
||||
const page = await open('apple-app-record-renamed.html');
|
||||
const s = await fillPage(page, formDef('apple-app-record', pageAnswers('apple-app-record')), pageAnswers('apple-app-record'));
|
||||
expect(s.fallback).toBe(true);
|
||||
|
|
@ -277,7 +280,7 @@ describe.skipIf(!browser)('engine (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('app privacy (fillPage alone): section by section as the page changes; fillPage itself never clicks Next / Save / Publish', async () => {
|
||||
scope.concurrent('app privacy (fillPage alone): section by section as the page changes; fillPage itself never clicks Next / Save / Publish', async () => {
|
||||
const page = await open('apple-app-privacy.html');
|
||||
const ans = pageAnswers('apple-app-privacy');
|
||||
const def = formDef('apple-app-privacy', ans);
|
||||
|
|
@ -311,7 +314,7 @@ describe.skipIf(!browser)('engine (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('content rating: full email in the page, Game, purchases yes, the rest empty + highlighted', async () => {
|
||||
scope.concurrent('content rating: full email in the page, Game, purchases yes, the rest empty + highlighted', async () => {
|
||||
const page = await open('play-content-rating.html');
|
||||
const ans = pageAnswers('play-content-rating');
|
||||
const def = formDef('play-content-rating', ans);
|
||||
|
|
@ -332,7 +335,7 @@ describe.skipIf(!browser)('engine (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('target audience: ages, appeal highlighted, ads yes', async () => {
|
||||
scope.concurrent('target audience: ages, appeal highlighted, ads yes', async () => {
|
||||
const page = await open('play-target-audience.html');
|
||||
const ans = pageAnswers('play-target-audience');
|
||||
const def = formDef('play-target-audience', ans);
|
||||
|
|
@ -349,7 +352,7 @@ describe.skipIf(!browser)('engine (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('sign-in detector and safeClick', async () => {
|
||||
scope.concurrent('sign-in detector and safeClick', async () => {
|
||||
const signin = await open('signin.html');
|
||||
expect(await signInPage(signin)).toBe(true);
|
||||
await signin.close();
|
||||
|
|
@ -419,7 +422,7 @@ describe.skipIf(!browser)('navigation (headless, modeled pages)', () => {
|
|||
const noneDenied = (names: string[]) => { for (const n of names) expect(navAllowed(n, 'link')).toBe(true); };
|
||||
const fresh = async () => { const p = await scope.newPage(); await p.goto(base + 'blank.html'); await p.evaluate(() => sessionStorage.clear()); return p; };
|
||||
|
||||
test('App Store Connect (EN): + > New App opens the dialog and it is filled; Create and Submit for Review never clicked', async () => {
|
||||
scope.concurrent('App Store Connect (EN): + > New App opens the dialog and it is filled; Create and Submit for Review never clicked', async () => {
|
||||
const page = await fresh();
|
||||
const r = await navigate(page, navPlan('apple-app-record', config, bases()), quiet);
|
||||
expect(r).toMatchObject({ ok: true, steps: ['open Apps', 'the add button (+)', 'New App in the menu', 'the New App dialog'] });
|
||||
|
|
@ -431,7 +434,7 @@ describe.skipIf(!browser)('navigation (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('App Store Connect (TR): Uygulama Ekle > Yeni Uygulama; Oluştur and İncelemeye Gönder never clicked', async () => {
|
||||
scope.concurrent('App Store Connect (TR): Uygulama Ekle > Yeni Uygulama; Oluştur and İncelemeye Gönder never clicked', async () => {
|
||||
const page = await fresh();
|
||||
const r = await navigate(page, navPlan('apple-app-record', config, bases('asc-tr')), quiet);
|
||||
expect(r.ok).toBe(true);
|
||||
|
|
@ -440,7 +443,7 @@ describe.skipIf(!browser)('navigation (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('a missing step stops navigation, names the step and clicks nothing', async () => {
|
||||
scope.concurrent('a missing step stops navigation, names the step and clicks nothing', async () => {
|
||||
const page = await fresh();
|
||||
const said: string[] = [];
|
||||
const r = await navigate(page, navPlan('apple-app-record', config, bases('asc-noplus')), { timeoutMs: 800, say: m => said.push(m) });
|
||||
|
|
@ -451,7 +454,7 @@ describe.skipIf(!browser)('navigation (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('App Privacy: appId deep link, or app list > the app > App Privacy; two apps of one name -> stop and list', async () => {
|
||||
scope.concurrent('App Privacy: appId deep link, or app list > the app > App Privacy; two apps of one name -> stop and list', async () => {
|
||||
let page = await fresh();
|
||||
let r = await navigate(page, navPlan('apple-app-privacy', { ...config, apple: { ...config.apple!, appId: '123' } } as ShipConfig, bases()), quiet);
|
||||
expect(r).toMatchObject({ ok: true, steps: ['open App Privacy of the app (apple.appId)', 'the App Privacy page'] });
|
||||
|
|
@ -469,7 +472,7 @@ describe.skipIf(!browser)('navigation (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('App Privacy: an app card that names the app twice (icon aria-label + title link, same /apps/<id>) is one app', async () => {
|
||||
scope.concurrent('App Privacy: an app card that names the app twice (icon aria-label + title link, same /apps/<id>) is one app', async () => {
|
||||
const page = await fresh();
|
||||
const r = await navigate(page, navPlan('apple-app-privacy', config, bases('asc-card')), quiet);
|
||||
expect(r).toMatchObject({ ok: true });
|
||||
|
|
@ -479,7 +482,7 @@ describe.skipIf(!browser)('navigation (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('Play: the app by package name > Policy and programs > App content > Start / Manage; nothing deny-listed clicked', async () => {
|
||||
scope.concurrent('Play: the app by package name > Policy and programs > App content > Start / Manage; nothing deny-listed clicked', async () => {
|
||||
for (const [form, link, heading] of [['play-content-rating', 'Start', 'Content rating'], ['play-target-audience', 'Manage', 'Target audience and content']] as const) {
|
||||
const page = await fresh();
|
||||
const r = await navigate(page, navPlan(form, config, bases()), quiet);
|
||||
|
|
@ -492,7 +495,7 @@ describe.skipIf(!browser)('navigation (headless, modeled pages)', () => {
|
|||
}
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('navClick refuses a Start button, Start rollout, Save and Publish even when pointed at them', async () => {
|
||||
scope.concurrent('navClick refuses a Start button, Start rollout, Save and Publish even when pointed at them', async () => {
|
||||
const page = await open('play-app-content.html');
|
||||
expect((await navClick(page.getByRole('button', { name: 'Start' }))).clicked).toBe(false);
|
||||
expect((await navClick(page.getByRole('button', { name: 'Save' }))).clicked).toBe(false);
|
||||
|
|
@ -504,7 +507,7 @@ describe.skipIf(!browser)('navigation (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('already on the form: nothing is clicked', async () => {
|
||||
scope.concurrent('already on the form: nothing is clicked', async () => {
|
||||
const page = await open('play-content-rating.html');
|
||||
const r = await navigate(page, navPlan('play-content-rating', config, bases()), quiet);
|
||||
expect(r).toMatchObject({ ok: true, steps: ['already open'] });
|
||||
|
|
@ -535,14 +538,14 @@ afterAll(() => { for (const d of tmpDirs) rmSync(d, { recursive: true, force: tr
|
|||
const tmpAccounts = () => { const d = mkdtempSync(join(tmpdir(), 'storebound-accounts-')); tmpDirs.push(d); return join(d, 'console-accounts.json'); };
|
||||
/**
|
||||
* The engine's timings scaled down for tests (production: tick 1 s, 20 s per navigation step, 10 min for a sign-in).
|
||||
* navTimeoutMs is both how long a step that IS there may take to show up (headroom for a loaded machine) and what a
|
||||
* step that is NOT there costs (the watching tests wait that out once), so it stays a few seconds, not FOUND_MS.
|
||||
* navTimeoutMs is how long a step that IS there may take to show up (headroom for a loaded machine); a step that is
|
||||
* NOT there ends earlier, once its loaded page stays unchanged for TEST_WAITS.quietMs (tests/helpers/browser.ts).
|
||||
*/
|
||||
const fast = (log: ReturnType<typeof logLines>) => ({ say: log.say, tickMs: 100, navTimeoutMs: 5000, signInWaitMs: 30_000, accountsFile: tmpAccounts(), env: {} });
|
||||
const fast = (log: ReturnType<typeof logLines>) => ({ say: log.say, tickMs: TEST_TICK_MS, navTimeoutMs: 5000, signInWaitMs: 30_000, accountsFile: tmpAccounts(), env: {} });
|
||||
const FILLED = (lines: string[]) => lines.some(m => /^New App: filled \d+/.test(m));
|
||||
|
||||
describe.skipIf(!browser)('sign-in and watching (headless, modeled pages on the real console hosts, routed locally)', () => {
|
||||
test('ASC /login with an idmsa frame and no password field: detected mid-navigation, never touched; after sign-in navigation restarts at step 1', async () => {
|
||||
scope.concurrent('ASC /login with an idmsa frame and no password field: detected mid-navigation, never touched; after sign-in navigation restarts at step 1', async () => {
|
||||
const b = await consoleBrowser((u, cookie) => u.hostname === 'idmsa.apple.com' ? 'idmsa-signin.html'
|
||||
: u.hostname !== 'appstoreconnect.apple.com' ? null
|
||||
: u.pathname === '/login' ? 'asc-login.html' : u.pathname === '/apps' ? (cookie.includes('myacinfo=') ? 'asc-apps.html' : 'asc-shell.html') : null);
|
||||
|
|
@ -565,7 +568,7 @@ describe.skipIf(!browser)('sign-in and watching (headless, modeled pages on the
|
|||
expect((await run).filled).toBeGreaterThan(0);
|
||||
}, SLOW_TEST_MS);
|
||||
|
||||
test('after the printed steps storebound keeps watching: the owner opens Apps by hand, navigation resumes at step 2 and fills', async () => {
|
||||
scope.concurrent('after the printed steps storebound keeps watching: the owner opens Apps by hand, navigation resumes at step 2 and fills', async () => {
|
||||
const b = await consoleBrowser(u => u.hostname === 'appstoreconnect.apple.com' && u.pathname === '/apps' ? (u.search.includes('owner') ? 'asc-apps.html' : 'asc-apps-noplus.html') : null);
|
||||
const log = logLines(), said = log.lines;
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-record', config, ...fast(log) }));
|
||||
|
|
@ -580,7 +583,7 @@ describe.skipIf(!browser)('sign-in and watching (headless, modeled pages on the
|
|||
await run;
|
||||
}, SLOW_TEST_MS);
|
||||
|
||||
test('after the printed steps: the owner opens the dialog by hand, storebound sees it and fills it', async () => {
|
||||
scope.concurrent('after the printed steps: the owner opens the dialog by hand, storebound sees it and fills it', async () => {
|
||||
const b = await consoleBrowser(u => u.hostname === 'appstoreconnect.apple.com' && u.pathname === '/apps' ? (u.search.includes('dialog') ? 'apple-app-record.html' : 'asc-apps-noplus.html') : null);
|
||||
const log = logLines();
|
||||
const run = scope.run(fillSession(b, { form: 'apple-app-record', config, ...fast(log) }));
|
||||
|
|
@ -592,7 +595,7 @@ describe.skipIf(!browser)('sign-in and watching (headless, modeled pages on the
|
|||
await run;
|
||||
}, SLOW_TEST_MS);
|
||||
|
||||
test('resumeAt: the furthest step visible now, never the initial page load', async () => {
|
||||
scope.concurrent('resumeAt: the furthest step visible now, never the initial page load', async () => {
|
||||
const page = await open('asc-apps.html');
|
||||
const plan = navPlan('apple-app-record', config, bases());
|
||||
expect(await resumeAt(page, plan)).toBe(1);
|
||||
|
|
@ -657,7 +660,7 @@ describe.skipIf(!browser)('accounts (headless, modeled pages)', () => {
|
|||
const fresh = async () => { const p = await scope.newPage(); await p.goto(base + 'blank.html'); await p.evaluate(() => sessionStorage.clear()); return p; };
|
||||
const DENIED = ['Sign Out', 'Edit Profile', 'Create', 'Submit for Review', 'Create app', 'Create developer account', 'Save', 'Publish'];
|
||||
|
||||
test('App Store Connect, 3 teams, the bundle id in the 2nd: switched through the account menu, remembered, then picked first', async () => {
|
||||
scope.concurrent('App Store Connect, 3 teams, the bundle id in the 2nd: switched through the account menu, remembered, then picked first', async () => {
|
||||
const file = tmpAccounts(), said: string[] = [];
|
||||
let page = await fresh();
|
||||
await page.goto(base + 'teams/apps');
|
||||
|
|
@ -684,7 +687,7 @@ describe.skipIf(!browser)('accounts (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, SLOW_TEST_MS);
|
||||
|
||||
test('Play, one login in 5 developer accounts, the app in the 4th: found by package name, remembered, then opened directly', async () => {
|
||||
scope.concurrent('Play, one login in 5 developer accounts, the app in the 4th: found by package name, remembered, then opened directly', async () => {
|
||||
const file = tmpAccounts(), said: string[] = [];
|
||||
const pb = { asc: base + 'asc', play: base + 'playm' };
|
||||
let page = await fresh();
|
||||
|
|
@ -711,7 +714,7 @@ describe.skipIf(!browser)('accounts (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, SLOW_TEST_MS);
|
||||
|
||||
test('Play: a package in none of the developer accounts is said plainly; nothing deny-listed clicked', async () => {
|
||||
scope.concurrent('Play: a package in none of the developer accounts is said plainly; nothing deny-listed clicked', async () => {
|
||||
const page = await fresh();
|
||||
const absent = { ...config, play: { packageName: 'com.example.absent' } } as ShipConfig;
|
||||
const r = await openAcross(page, { form: 'play-content-rating', config: absent, say: () => {}, bases: { asc: base + 'asc', play: base + 'playm' }, navMs: 800, file: tmpAccounts(), env: {} });
|
||||
|
|
@ -828,7 +831,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
}, BROWSER_TEST_MS);
|
||||
}
|
||||
|
||||
test('an agreement answer that says yes is still never ticked', async () => {
|
||||
scope.concurrent('an agreement answer that says yes is still never ticked', async () => {
|
||||
const page = await open('play-create-app.html');
|
||||
const yes = ans().map(a => (a.agreement ? { ...a, value: true, certain: true } : a));
|
||||
const s = await fillPage(page, def(), yes);
|
||||
|
|
@ -838,7 +841,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('a renamed field: partial fill, the missing one left to the owner; nothing ticked or submitted', async () => {
|
||||
scope.concurrent('a renamed field: partial fill, the missing one left to the owner; nothing ticked or submitted', async () => {
|
||||
const page = await open('play-create-app-renamed.html');
|
||||
expect((await fillPage(page, def(), ans())).fallback).toBe(true); // first rounds: wait (the page may be loading)
|
||||
const s = await fillPage(page, def(), ans(), new Set(), true);
|
||||
|
|
@ -851,7 +854,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('the opener inside a dialog, or on a page that is not the app list, is refused', async () => {
|
||||
scope.concurrent('the opener inside a dialog, or on a page that is not the app list, is refused', async () => {
|
||||
const page = await open('blank.html');
|
||||
await page.setContent('<h1>All apps</h1><div role="dialog" aria-label="x"><button type="button">Create app</button></div>');
|
||||
expect((await navClick(page.getByRole('button', { name: 'Create app' }), true)).clicked).toBe(false);
|
||||
|
|
@ -862,7 +865,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('accounts: several and none picked -> stop and list; by play.developerAccount -> that one, remembered; by play.developerId -> directly', async () => {
|
||||
scope.concurrent('accounts: several and none picked -> stop and list; by play.developerAccount -> that one, remembered; by play.developerId -> directly', async () => {
|
||||
const go = async (c: ShipConfig, file: string, said: string[], env: Record<string, string> = {}) => {
|
||||
const page = await fresh();
|
||||
const r = await openAcross(page, { form: 'play-app-record', config: c, say: m => { said.push(m); }, bases: pb('playn'), navMs: FOUND_MS, file, env });
|
||||
|
|
@ -904,7 +907,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, SLOW_TEST_MS);
|
||||
|
||||
test('the chooser as it really looks (nested spans, icon fonts, role=button rows with data ids, an avatar row without an id): exact, case pair, prefix, not found', async () => {
|
||||
scope.concurrent('the chooser as it really looks (nested spans, icon fonts, role=button rows with data ids, an avatar row without an id): exact, case pair, prefix, not found', async () => {
|
||||
const go = async (account: string | undefined, said: string[], play = 'playr') => {
|
||||
const page = await fresh();
|
||||
const c = { ...config, play: { ...config.play!, ...(account ? { developerAccount: account } : {}) } } as ShipConfig;
|
||||
|
|
@ -955,7 +958,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
|
|||
await page.close();
|
||||
}, SLOW_TEST_MS);
|
||||
|
||||
test('fillSession: notes logged, form filled, the run ends without any submit', async () => {
|
||||
scope.concurrent('fillSession: notes logged, form filled, the run ends without any submit', async () => {
|
||||
const b = await consoleBrowser(u => (u.hostname !== 'play.google.com' ? null : u.pathname === '/console' || u.pathname.endsWith('/app-list') ? 'play-home.html' : u.pathname === '/play-create-app.html' || u.pathname.endsWith('/create-new-app') ? 'play-create-app.html' : null));
|
||||
const log = logLines(), said = log.lines;
|
||||
const c = { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig;
|
||||
|
|
@ -1007,7 +1010,7 @@ async function until(pred: () => boolean): Promise<void> {
|
|||
}
|
||||
|
||||
describe.skipIf(!browser)('sign-in detection: only the top-level URL, the main frame\'s form or an Apple sign-in frame', () => {
|
||||
test('the Play account chooser with the account widget (accounts.google.com frames, avatar and email header) is NOT a sign-in page', async () => {
|
||||
scope.concurrent('the Play account chooser with the account widget (accounts.google.com frames, avatar and email header) is NOT a sign-in page', async () => {
|
||||
const b = await consoleBrowser(playOgb);
|
||||
await b.page.goto('https://play.google.com/console/u/0/developers');
|
||||
await until(() => frameOn(b.page, 'accounts.google.com') && frameOn(b.page, 'ogs.google.com'));
|
||||
|
|
@ -1016,7 +1019,7 @@ describe.skipIf(!browser)('sign-in detection: only the top-level URL, the main f
|
|||
await b.context.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('Play home with the widget frame is NOT a sign-in page', async () => {
|
||||
scope.concurrent('Play home with the widget frame is NOT a sign-in page', async () => {
|
||||
const b = await consoleBrowser(playOgb);
|
||||
await b.page.goto('https://play.google.com/console/u/0/developers/7000000001/app-list');
|
||||
await until(() => frameOn(b.page, 'accounts.google.com') && frameOn(b.page, 'ogs.google.com'));
|
||||
|
|
@ -1024,7 +1027,7 @@ describe.skipIf(!browser)('sign-in detection: only the top-level URL, the main f
|
|||
await b.context.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('a top-level accounts.google.com page IS a sign-in page, and the rule says why (URL without its query)', async () => {
|
||||
scope.concurrent('a top-level accounts.google.com page IS a sign-in page, and the rule says why (URL without its query)', async () => {
|
||||
const b = await consoleBrowser(playOgb);
|
||||
await b.page.goto('https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fplay.google.com%2Fconsole');
|
||||
expect(await signInReason(b.page)).toEqual({ rule: 'the top-level URL is a sign-in page', url: 'https://accounts.google.com/v3/signin/identifier' });
|
||||
|
|
@ -1035,7 +1038,7 @@ describe.skipIf(!browser)('sign-in detection: only the top-level URL, the main f
|
|||
await page.close();
|
||||
}, BROWSER_TEST_MS);
|
||||
|
||||
test('fillSession play-app-record: the chooser with the widget frames is passed, the form filled, no sign-in said', async () => {
|
||||
scope.concurrent('fillSession play-app-record: the chooser with the widget frames is passed, the form filled, no sign-in said', async () => {
|
||||
const b = await consoleBrowser(playOgb);
|
||||
const log = logLines(), said = log.lines;
|
||||
const done = scope.run(fillSession(b, { form: 'play-app-record', config, ...fast(log) }));
|
||||
|
|
@ -1047,7 +1050,7 @@ describe.skipIf(!browser)('sign-in detection: only the top-level URL, the main f
|
|||
await done;
|
||||
}, SLOW_TEST_MS);
|
||||
|
||||
test('a copied Chrome session that lands on accounts.google.com: said plainly with the rule; --debug-dump dumps the sign-in wait (at most 3), the watchdog once', async () => {
|
||||
scope.concurrent('a copied Chrome session that lands on accounts.google.com: said plainly with the rule; --debug-dump dumps the sign-in wait (at most 3), the watchdog once', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-signin-debug-'));
|
||||
tmpDirs.push(dir);
|
||||
const b: ConsoleBrowser = { ...await consoleBrowser(u => (u.hostname === 'play.google.com' ? 'play-signed-out.html' : playOgb(u))), mode: 'copy' };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ afterAll(() => { server?.stop(true); });
|
|||
|
||||
describe.skipIf(!real.label)(`storebound's own profile (${real.label ?? 'no real Chrome'})`, () => {
|
||||
for (const store of ['apple', 'play'] as const) {
|
||||
test(`${store}: a session cookie survives closing and relaunching storebound's own profile`, async () => {
|
||||
test.concurrent(`${store}: a session cookie survives closing and relaunching storebound's own profile`, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), `storebound-profile-${store}-`));
|
||||
const root = join(import.meta.dir, '..');
|
||||
const { chromium } = await import('playwright');
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ const hasChrome = process.platform === 'darwin' && existsSync(CHROME);
|
|||
if (!hasChrome) console.warn('console-session integration test skipped: Google Chrome is not installed (or not macOS)');
|
||||
|
||||
describe.skipIf(!hasChrome)('copy mode with real Chrome (throwaway profile, local site)', () => {
|
||||
test('a cookie set in the source profile is there in the copy; another host\'s is filtered out; the copy is deleted on close', async () => {
|
||||
test.concurrent('a cookie set in the source profile is there in the copy; another host\'s is filtered out; the copy is deleted on close', async () => {
|
||||
let chromium: any;
|
||||
try { chromium = (await import('playwright')).chromium; } catch { console.warn('skipped: playwright not installed'); return; }
|
||||
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
|
||||
|
|
@ -238,7 +238,7 @@ describe.skipIf(!hasChrome)('copy mode with real Chrome (throwaway profile, loca
|
|||
});
|
||||
|
||||
describe.skipIf(!hasChrome)('the console window has no automation markers (real Chrome, throwaway profile, local page)', () => {
|
||||
test('navigator.webdriver is false and the real command line has no --enable-automation', async () => {
|
||||
test.concurrent('navigator.webdriver is false and the real command line has no --enable-automation', async () => {
|
||||
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response(
|
||||
'<p id="r"></p><script>document.getElementById("r").textContent = JSON.stringify({ webdriver: navigator.webdriver, ua: navigator.userAgent })</script>',
|
||||
{ headers: { 'content-type': 'text/html' } }) });
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
// - polling sleeps in the waits for log lines: event-driven now (resolved on each new line)
|
||||
// Hook timeouts are bounded by the numbers below (each close is capped), never open-ended.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { test } from 'bun:test';
|
||||
|
||||
/** Launching Chromium: well above a cold start on a loaded machine (seconds), far below "hung". */
|
||||
export const LAUNCH_TIMEOUT_MS = 60_000;
|
||||
|
|
@ -25,6 +27,16 @@ export const CLOSE_TIMEOUT_MS = 15_000;
|
|||
export const AFTER_EACH_MS = 2 * CLOSE_TIMEOUT_MS + 5_000;
|
||||
export const AFTER_ALL_MS = CLOSE_TIMEOUT_MS + 5_000;
|
||||
|
||||
/**
|
||||
* The console engine's timings for tests on local fixture pages (production: a 1 s watch round, 200 ms poll rounds,
|
||||
* a missing step waited out for the whole step timeout). A round of 20 ms; a poll ends as "not found" once the page
|
||||
* is loaded and unchanged for 1.5 s (well above the one timer a fixture page has, asc-shell.html's 700 ms redirect),
|
||||
* so a step that is not there no longer costs the whole navTimeoutMs, while a step that is there is still waited for
|
||||
* as long as the machine needs. Set with engineWaits (cli/console/fill.ts) in beforeAll, restored in afterAll.
|
||||
*/
|
||||
export const TEST_TICK_MS = 20;
|
||||
export const TEST_WAITS = { pollMs: 50, quietMs: 1500, settleMs: 50, missMs: 300 };
|
||||
|
||||
/** Waits for `p` (its error ignored) or `ms`, whichever is first: true when `p` settled in time. Never rejects. */
|
||||
export async function capped(p: Promise<unknown>, ms: number): Promise<boolean> {
|
||||
let t: ReturnType<typeof setTimeout> | undefined;
|
||||
|
|
@ -88,11 +100,8 @@ export async function persistentBrowser(): Promise<{ label: string | null; skipW
|
|||
: { label: null, skipWhy: 'neither Google Chrome nor Playwright\'s full Chromium is installed (the headless shell drops session cookies on relaunch); install Google Chrome or run `bunx playwright install chromium`' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The contexts and engine runs of the current test. `newPage` opens a page in a fresh context (as browser.newPage
|
||||
* does) and tracks it; `track` adds one opened elsewhere; `settle` (afterEach) closes them all and waits for the runs.
|
||||
*/
|
||||
export function testScope(browser: () => any) {
|
||||
/** The contexts and engine runs of one test (see testScope). */
|
||||
function plainScope(browser: () => any) {
|
||||
const contexts = new Set<any>();
|
||||
const runs = new Set<Promise<unknown>>();
|
||||
return {
|
||||
|
|
@ -113,6 +122,33 @@ export function testScope(browser: () => any) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The contexts and engine runs of the current test. `newPage` opens a page in a fresh context (as browser.newPage
|
||||
* does) and tracks it; `track` adds one opened elsewhere; `settle` (afterEach) closes them all and waits for the runs.
|
||||
* `concurrent` declares a browser test that runs at the same time as the file's other concurrent tests
|
||||
* (test.concurrent, at most --max-concurrency at once: tools/ci.ts passes BROWSER_CONCURRENCY from tools/ci-plan.ts): it gets a scope of
|
||||
* its own (AsyncLocalStorage, so newPage / track / run inside it reach that scope), settled when it ends, inside its
|
||||
* timeout. The tests share the one browser; each has its own contexts, pages and log.
|
||||
*/
|
||||
export function testScope(browser: () => any) {
|
||||
type Scope = ReturnType<typeof plainScope>;
|
||||
const shared = plainScope(browser);
|
||||
const own = new AsyncLocalStorage<Scope>();
|
||||
const cur = (): Scope => own.getStore() ?? shared;
|
||||
return {
|
||||
newPage: (opts?: Record<string, unknown>): Promise<any> => cur().newPage(opts),
|
||||
track: <T>(ctx: T): T => cur().track(ctx),
|
||||
run: <T>(p: Promise<T>): Promise<T> => cur().run(p),
|
||||
settle: (): Promise<void> => shared.settle(),
|
||||
concurrent(name: string, fn: () => Promise<void>, ms: number): void {
|
||||
test.concurrent(name, () => {
|
||||
const s = plainScope(browser);
|
||||
return own.run(s, async () => { try { await fn(); } finally { await s.settle(); } });
|
||||
}, ms + AFTER_EACH_MS);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The log lines of an engine run (`say`), with event-driven waits: `wait` resolves on the line that makes `pred`
|
||||
* true (checked on every new line, no polling) and fails after `ms` with every line seen so far.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue