A real run (sanitized debug dump) showed why the walk stopped after Next:
the data types step is the same "Data Collection" dialog (URL unchanged),
and each checkbox's accessible name is its label run together with its
help text ("NameIncluding first or last name"). Some labels also differ
from Apple's help page ("Purchases", "Other Data").
- labelNames: the exact name, then the label as a prefix that ends where
the help text starts (no lowercase letter follows), so "Name" never
matches "Phone Number...", "Head" never "Health...", "Contact" never
"Contacts...". Used for the data types and the purposes.
TYPE_ALIASES maps Purchase History / Other Data Types to the console's
labels.
- Dialog per step: a section's dialog is also found as the one visible
dialog with a heading of that name.
- Every click and check is bounded (ACTION_MS, 10 s; the scan test now
also rejects clicks without a timeout).
- The flow fixture mirrors the real dialog's structure (dialog/h2 title,
group h3s, input name = Apple's enum, <label> with label + help <p>s,
Save disabled until a type is ticked); no data from the dump. A test
checks every Apple type matches exactly one real-style checkbox.
- Pitfall 15.
Publish is still never clicked; unknown steps still get the watchdog dump.
335 lines
22 KiB
TypeScript
335 lines
22 KiB
TypeScript
// storebound console fill walks a whole form: it clicks every navigation control (Next, App Privacy's Get Started,
|
|
// Edit next to Data Types, each data type) and App Privacy's in-dialog draft Save itself, stops only for an uncertain
|
|
// answer (until the owner set it), an agreement or the final button (Publish, Submit, Save, Create), which stays the
|
|
// owner's. Runs fillSession against MODELED pages (tests/fixtures/console) on 127.0.0.1. No network.
|
|
// Guardrail: no log line ever asks the owner to click a navigation control.
|
|
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 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, 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';
|
|
|
|
const FIX = join(import.meta.dir, 'fixtures', 'console');
|
|
const base0: 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' },
|
|
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
|
|
apple: { bundleId: 'com.example.game', primaryLocale: 'en-US' },
|
|
play: { packageName: 'com.example.game' },
|
|
ads: { appAdsTxt: ['example-network.com, pub-0000, DIRECT, abc123'] },
|
|
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: false, data: { PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] } } },
|
|
console: { contentRating: { answers: { violence: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'] } },
|
|
} as ShipConfig;
|
|
/** Every App Privacy answer certain: Device ID and Crash Data collected, nothing else. */
|
|
function privacy(over: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {}): ShipConfig {
|
|
const types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
|
|
types.DEVICE_ID = { collected: true, purposes: ['THIRD_PARTY_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true };
|
|
types.CRASH_DATA = { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false };
|
|
return { ...base0, dataSafety: undefined, appPrivacy: { types: { ...types, ...over } } } as unknown as ShipConfig;
|
|
}
|
|
|
|
const shared = await launchTestBrowser('playwright');
|
|
const { browser, skipWhy } = shared;
|
|
if (!browser) console.warn(`console-flow browser tests skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
|
|
const scope = testScope(() => browser);
|
|
const TEST_MS = 60_000;
|
|
|
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
let base = '';
|
|
beforeAll(() => {
|
|
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
|
|
const name = new URL(req.url).pathname.slice(1);
|
|
if (!/^[a-z_-]+\.(html|js)$/.test(name)) return new Response('not found', { status: 404 });
|
|
return new Response(Bun.file(join(FIX, name)));
|
|
} });
|
|
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);
|
|
|
|
async function local(): Promise<ConsoleBrowser> {
|
|
const ctx = scope.track(await browser.newContext());
|
|
const page = await ctx.newPage();
|
|
let closed = false;
|
|
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 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. */
|
|
const NAV_YOURSELF = /\b(Next|Continue|Get Started|Edit|Back|Set Up|Start|Manage|New App)\b[^.]*\byourself\b|\byourself\b[^.]*\b(Next|Continue|Get Started|Edit next|Set Up)\b/;
|
|
const navAsked = (lines: string[]) => lines.filter(l => NAV_YOURSELF.test(l));
|
|
const PUBLISH = 'review the summary, then press Publish yourself';
|
|
|
|
describe('App Privacy data types as the real Data Collection dialog names them (label + help text, no separator)', () => {
|
|
// the labels in the order of a real dialog (October 2026); each name is the label run together with its help text
|
|
const REAL = ['Name', 'Email Address', 'Phone Number', 'Physical Address', 'Other User Contact Info', 'Health', 'Fitness', 'Payment Info', 'Credit Info',
|
|
'Other Financial Info', 'Precise Location', 'Coarse Location', 'Sensitive Info', 'Contacts', 'Emails or Text Messages', 'Photos or Videos', 'Audio Data',
|
|
'Gameplay Content', 'Customer Support', 'Other User Content', 'Browsing History', 'Search History', 'User ID', 'Device ID', 'Purchases',
|
|
'Product Interaction', 'Advertising Data', 'Other Usage Data', 'Crash Data', 'Performance Data', 'Other Diagnostic Data', 'Environment Scanning', 'Hands',
|
|
'Head', 'Other Data'];
|
|
const names = REAL.map(l => `${l}Such as a placeholder help text`);
|
|
test('every Apple data type matches exactly one checkbox; "Name" never matches "Phone Number...", "Head" never "Health..."', () => {
|
|
for (const [, label] of Object.values(APPLE_PRIVACY_TYPES)) {
|
|
const cands = labelNames(label, TYPE_ALIASES[label]);
|
|
// what find() does: the first candidate name that matches anything decides, and it must match one checkbox
|
|
const hits = cands.map(c => names.filter(n => (typeof c === 'string' ? n === c : c.test(n)))).find(h => h.length) ?? [];
|
|
expect({ label, hits: hits.length }).toEqual({ label, hits: 1 });
|
|
expect(hits[0]!.startsWith(TYPE_ALIASES[label]?.[0] ?? label)).toBe(true);
|
|
}
|
|
expect(labelNames('Contact').some(c => typeof c !== 'string' && c.test('ContactsSuch as a list'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
|
|
const at = (w: Partial<FlowWhere> = {}): FlowWhere => ({ privacyPage: true, inDialog: false, inDataTypes: false, dataTypes: ['Device ID'], ...w });
|
|
test('App Privacy: openers, Next and the in-dialog Save are clicked; Publish never', () => {
|
|
expect(flowAllowed('apple-app-privacy', 'Get Started', 'button', at())).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Başlayın', 'button', at())).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Edit', 'button', at({ inDataTypes: true }))).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Edit', 'button', at())).toBe(false); // the Privacy Policy URL's Edit
|
|
expect(flowAllowed('apple-app-privacy', 'Set Up Device ID', 'button', at({ inDataTypes: true }))).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Device ID', 'link', at({ inDataTypes: true }))).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Next', 'button', at({ inDialog: true }))).toBe(true);
|
|
expect(flowClass('apple-app-privacy', 'Save', 'button', at({ inDialog: true }))).toBe('draft-save');
|
|
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at({ inDialog: true }))).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at())).toBe(false); // not in a questionnaire dialog
|
|
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at({ inDialog: true, privacyPage: false }))).toBe(false);
|
|
for (const n of ['Publish', 'Yayınla', 'Submit for Review']) expect(flowAllowed('apple-app-privacy', n, 'button', at({ inDialog: true, inDataTypes: true }))).toBe(false);
|
|
expect(flowAllowed('apple-app-privacy', 'Get Started', 'checkbox', at())).toBe(false);
|
|
});
|
|
test('other forms: Next is navigation; the content rating\'s Save draft is clicked; Save, Submit, Create, Done stay the owner\'s', () => {
|
|
const w = at({ privacyPage: false });
|
|
for (const f of ['play-content-rating', 'play-target-audience', 'apple-app-record', 'play-app-record'] as const) {
|
|
expect(flowAllowed(f, 'Next', 'button', w)).toBe(true);
|
|
expect(flowAllowed(f, 'İleri', 'button', w)).toBe(true);
|
|
expect(flowAllowed(f, 'Get Started', 'button', w)).toBe(false);
|
|
expect(flowAllowed(f, 'Save draft', 'button', w)).toBe(f === 'play-content-rating');
|
|
expect(flowAllowed(f, 'Taslağı kaydet', 'button', w)).toBe(f === 'play-content-rating');
|
|
for (const n of ['Save', 'Submit', 'Create', 'Create app', 'Done', 'Start rollout', 'Publish']) expect(flowAllowed(f, n, 'button', w)).toBe(false);
|
|
}
|
|
expect(flowClass('play-content-rating', 'Save draft', 'button', w)).toBe('draft-save');
|
|
expect(flowClass('play-target-audience', 'Save', 'button', w)).toBe('final-submit');
|
|
expect(flowClass('play-content-rating', 'Submit', 'button', w)).toBe('final-submit');
|
|
});
|
|
test('every form has its buttons classified; only navigation and the agreed draft saves (App Privacy, content rating) are automatic', () => {
|
|
for (const [f, rules] of Object.entries(FORM_BUTTONS)) {
|
|
expect(rules.length).toBeGreaterThan(0);
|
|
for (const r of rules) if (r.auto) expect(r.class === 'navigation' || (['apple-app-privacy', 'play-content-rating'].includes(f) && r.class === 'draft-save')).toBe(true);
|
|
expect(rules.some(r => r.class === 'final-submit' && !r.auto)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
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 () => {
|
|
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) }));
|
|
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
|
const c = await clicks(b);
|
|
expect(c).not.toContain('Publish');
|
|
expect(c).not.toContain('See Details');
|
|
expect(c).not.toContain('Edit'); // neither the Privacy Policy Edit nor (first time) the Data Types one
|
|
expect(c.filter(x => x === 'Get Started')).toHaveLength(1);
|
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3); // the type list + Device ID + Crash Data, drafts
|
|
expect(await navClicks(b)).toContain('Crash Data'); // the link of the second type
|
|
expect(c).toContain('Set Up Device ID');
|
|
expect(log.lines).toContain('clicked "Save" (draft-save)');
|
|
expect(log.lines.some(m => m.startsWith('answer needed'))).toBe(false);
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
expect((await run).fallback).toBe(false);
|
|
}, TEST_MS);
|
|
|
|
test('answers exist: Edit next to Data Types (never the Privacy Policy Edit), then every type', 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?existing=1', ...fast(log) }));
|
|
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
|
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1);
|
|
expect(log.lines.some(m => m.startsWith('Data collection was answered before'))).toBe(true);
|
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
|
expect(await clicks(b)).not.toContain('Publish');
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
await run;
|
|
}, TEST_MS);
|
|
|
|
test('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
|
|
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
|
await log.wait('the stop', l => l.some(m => m.startsWith('answer needed on "Device ID: linked to the user"')));
|
|
await Bun.sleep(600); // several rounds: Next must not be clicked past the open question
|
|
const linked = b.page.getByRole('radio', { name: /^No, Device ID .* not linked/ });
|
|
expect(await linked.isVisible()).toBe(true);
|
|
expect(await b.page.getByRole('radio', { name: /^No, we do not use Device ID/ }).isVisible()).toBe(false);
|
|
await linked.check(); // the owner answers
|
|
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
|
expect(log.lines).toContain('"Device ID: linked to the user" answered: storebound goes on');
|
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
|
expect(await clicks(b)).not.toContain('Publish');
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
await run;
|
|
}, TEST_MS);
|
|
|
|
test('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 }]));
|
|
const config = { ...base0, dataSafety: undefined, appPrivacy: { types } } as unknown as ShipConfig;
|
|
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
|
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(1);
|
|
expect(await clicks(b)).not.toContain('Next');
|
|
expect(await clicks(b)).not.toContain('Publish');
|
|
await b.context.close();
|
|
await run;
|
|
}, TEST_MS);
|
|
});
|
|
|
|
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; Submit 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) }));
|
|
await log.wait('the questionnaire stop', l => l.some(m => m.startsWith('answer needed on "Questionnaire"')));
|
|
expect(log.lines).toContain('clicked "Save draft" (draft-save)');
|
|
expect(log.lines).toContain('clicked "Next" (navigation)');
|
|
expect(await clicks(b)).toEqual(['Save draft', 'Next']);
|
|
await Bun.sleep(500);
|
|
expect(await clicks(b)).toEqual(['Save draft', 'Next']); // nothing saved or moved past the open questions
|
|
// the owner answers every outlined question
|
|
const open = b.page.locator('[role="radiogroup"][data-storebound-check]');
|
|
for (let i = 0, n = await open.count(); i < n; i++) await open.nth(i).getByRole('radio', { name: 'No' }).check();
|
|
await log.wait('the final line', l => l.some(m => m.includes('press Submit yourself')));
|
|
expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next']);
|
|
expect(await b.page.getByRole('heading', { name: 'Summary' }).isVisible()).toBe(true);
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
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 () => {
|
|
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) }));
|
|
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
|
|
expect(await clicks(b)).toEqual(['Next']);
|
|
await b.page.getByRole('radiogroup', { name: /appeal to children/ }).getByRole('radio', { name: 'No' }).check();
|
|
await log.wait('the final line', l => l.some(m => m.includes('press Save yourself')));
|
|
expect(await clicks(b)).toEqual(['Next', 'Next']);
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
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 () => {
|
|
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();
|
|
const run = scope.run(fillSession(b, { form, config: base0, startUrl: base + file, ...fast(log) }));
|
|
await log.wait('the final line', l => l.some(m => m.includes(`press ${final} yourself`)));
|
|
expect(await clicks(b)).toEqual([]);
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
await run;
|
|
}
|
|
}, TEST_MS);
|
|
});
|
|
|
|
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 () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
|
try {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const started = Date.now();
|
|
const out = await scope.run(fillSession(b, {
|
|
form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-unknown.html', ...fast(log),
|
|
stepTimeoutMs: 1500, idleLimitMs: 5000, heartbeatMs: 700, debugDir: dir,
|
|
}));
|
|
expect(Date.now() - started).toBeLessThan(30_000);
|
|
expect(out.timedOut).toBe(true);
|
|
expect(out.debugDir).toBe(dir);
|
|
expect(b.isClosed()).toBe(true);
|
|
const said = log.lines.join('\n');
|
|
expect(said).toContain('clicked "Next" (navigation)');
|
|
expect(said).toMatch(/still waiting for the page after "Next", \d+ s/);
|
|
expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons there: .*"Done"/);
|
|
expect(said).toContain(`Debug saved to ${dir}`);
|
|
expect(said).toContain('do this part yourself');
|
|
expect(said).toContain('it stops here');
|
|
const [sub] = readdirSync(dir);
|
|
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'));
|
|
expect(pj.dialogs).toContain('Data Collection');
|
|
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Contact details', checked: false });
|
|
expect(pj.url).toBe(`${base}apple-app-privacy-unknown.html`);
|
|
const all = readFileSync(join(dir, sub!, 'page.html'), 'utf8') + JSON.stringify(pj);
|
|
for (const leak of ['sb-token-should-not-leak', 'sb-csrf-should-not-leak', 'typed-by-owner-should-not-leak', 'owner@example.com', '010-9999', 'Example Owner']) expect(all).not.toContain(leak);
|
|
expect(all).toContain('[email]');
|
|
} 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 () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
|
try {
|
|
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), debugDir: dir, debugEveryStep: true }));
|
|
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
|
|
await Bun.sleep(500);
|
|
const names = readdirSync(dir).sort();
|
|
expect(names[0]).toBe('01-start');
|
|
expect(names.some(n => /-filled-target-age$/.test(n))).toBe(true);
|
|
expect(names.some(n => /-after-next$/.test(n))).toBe(true);
|
|
await b.context.close();
|
|
await run;
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
}, TEST_MS);
|
|
});
|
|
|
|
describe('every wait of the console engine is bounded (code scan)', () => {
|
|
const SRC = join(import.meta.dir, '..', 'cli', 'console');
|
|
const files = readdirSync(SRC).filter(f => f.endsWith('.ts'));
|
|
/** The text from `open` to its matching `close` (brackets balanced). */
|
|
const span = (s: string, at: number, open: string, close: string): string => {
|
|
let d = 0;
|
|
for (let i = s.indexOf(open, at); i >= 0 && i < s.length; i++) { if (s[i] === open) d++; else if (s[i] === close && --d === 0) return s.slice(at, i + 1); }
|
|
return s.slice(at);
|
|
};
|
|
test('a while loop always checks a deadline (Date.now / end) or the watchdog', () => {
|
|
const bad: string[] = [];
|
|
for (const f of files) {
|
|
const s = readFileSync(join(SRC, f), 'utf8');
|
|
for (const m of s.matchAll(/\bwhile \(/g)) {
|
|
const cond = span(s, m.index!, '(', ')'), body = span(s, m.index! + cond.length, '{', '}');
|
|
if (!/Date\.now\(\)|\bend\b|\bwatch\(/.test(cond + body)) bad.push(`${f}: ${cond.slice(0, 80)}`);
|
|
}
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
test('no Playwright wait or click without a timeout, and the window\'s close is awaited only after a bounded wait or a close()', () => {
|
|
const bad: string[] = [];
|
|
for (const f of files) {
|
|
const lines = readFileSync(join(SRC, f), 'utf8').split('\n');
|
|
lines.forEach((l, i) => {
|
|
if (/\.waitFor(Selector|URL|Event|LoadState|Function)?\(/.test(l) && !/timeout/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
|
if (/\.(click|setChecked|check|uncheck)\(/.test(l) && !/timeout|ACTION_MS/.test(l) && !/^\s*(\/\/|\*)/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
|
if (/await [\w.]+\.closed\s*;/.test(l) && !lines.slice(Math.max(0, i - 3), i).some(p => /close\(\)/.test(p))) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
|
});
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
});
|