storebound/tests/console-fill.test.ts
CRT c9a7f095c4 console: agent-first mode (console plan / console verify), verified App Privacy and Play Create app facts
- `storebound console plan <form> [--json] [--lookup]`: read-only plan for an AI agent that drives the browser
  itself: start URL (deep links), ordered steps, answer sheet with the console's exact labels (certain / ask owner,
  curly quotes noted), never-press list, verified UI facts, driving guidance (in-page async loop + window.__log),
  verify instructions. All six forms.
- `storebound console verify <form> [--from-text <file|->]`: App Privacy per-type summaries from page text, Play
  Create app from input states, content rating / target audience summaries, App Store record and age rating from
  the API; exit 1 on a mismatch.
- MCP: console_plan, console_verify (read-only); console fill stays off MCP.
- App Privacy (verified 2026-10-12): two information steps, unlabeled purpose checkboxes (nearest parent text),
  "Definitions and Examples" never pressed; fixture and engine follow.
- Play Create app (verified 2026-10-12): /developers/<id>/create-new-app, App package name + read-only Check
  availability, material-radio inner [role=radio] click, app dashboard URL and play.appId, service account
  "Add app" as an owner step.
- console-autofill skill rewritten agent-first; agents-core rule 10; README; pitfalls 12, 18 (confirmed sign-out),
  19; CHANGELOG; play-first-release.
2026-10-12 08:39:12 +08:00

1071 lines
73 KiB
TypeScript

// storebound console fill: pure answer building (rule 5: only what the config proves is certain), the click gate, the
// sign-in detector, and the engine run headless against MODELED console pages (tests/fixtures/console, written from
// 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
// (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 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';
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' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
apple: { bundleId: 'com.example.game', primaryLocale: 'en-US' },
play: { packageName: 'com.example.game' },
products: [{ id: 'com.example.game.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No ads', description: 'Removes ads' } } }],
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'] }, PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] } },
},
console: { contentRating: { answers: { violence: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'] } },
} as ShipConfig;
const byId = (id: FormId, c: ShipConfig = config) => new Map(consoleAnswers(id, c).map(a => [a.id, a]));
describe('click gate', () => {
test('refuses save / submit / publish / next and the like, English and Turkish', () => {
for (const n of ['Save', 'Save draft', 'Submit', 'Publish', 'Create', 'Next', 'Send', 'Apply', 'Confirm', 'Done', 'Start rollout', 'Release to production',
'Delete', 'Remove', 'Continue', 'Add for Review', 'Sign in', 'Kaydet', 'KAYDET', 'Gönder', 'Yayınla', 'Oluştur', 'İleri', 'Sonraki', 'Devam et', 'Onayla', 'Tamam', 'Sil', 'Kaldır', 'Başlat', 'Uygula'])
expect(forbiddenControl(n)).toBe(true);
});
test('allows the answers themselves', () => {
for (const n of ['Yes', 'No', 'Evet', 'Hayır', 'iOS', 'Full Access', 'Ages 13-15', 'Purchase History', 'Device ID', 'Third-Party Advertising', 'Help',
'Yes, we collect data from this app', 'No, we do not use Device ID for tracking purposes', 'Yes, my app contains ads', 'Game'])
expect(forbiddenControl(n)).toBe(false);
});
});
describe('sign-in and console detection', () => {
test('urls', () => {
expect(isSignInUrl('https://appleid.apple.com/auth/authorize')).toBe(true);
expect(isSignInUrl('https://idmsa.apple.com/appleauth/auth/signin')).toBe(true);
expect(isSignInUrl('https://accounts.google.com/v3/signin/identifier')).toBe(true);
expect(isSignInUrl('https://appstoreconnect.apple.com/login')).toBe(true);
expect(isSignInUrl('https://appstoreconnect.apple.com/apps')).toBe(false);
expect(consoleReached('apple', 'https://appstoreconnect.apple.com/apps')).toBe(true);
expect(consoleReached('apple', 'https://appstoreconnect.apple.com/login')).toBe(false);
expect(consoleReached('play', 'https://play.google.com/console/u/0/developers')).toBe(true);
expect(consoleReached('play', 'https://accounts.google.com/')).toBe(false);
expect(consoleReached('play', 'http://127.0.0.1:1234/x.html', 'http://127.0.0.1:1234/')).toBe(true);
expect(isSignInUrl('https://appstoreconnect.apple.com/login?targetUrl=%2Fapps')).toBe(true);
expect(isSignInUrl('https://appstoreconnect.apple.com/loginhelp')).toBe(false);
expect(isSignInUrl('https://play.google.com/signin/v2/identifier')).toBe(true);
expect(isSignInUrl('https://www.google.com/accounts/ServiceLogin')).toBe(true);
expect(isSignInUrl('https://play.google.com/console/u/0/developers')).toBe(false);
});
test('sign-in texts (whole text; the weak ones only inside a frame)', () => {
for (const t of ['Sign in with Passkey', ' Email or Phone Number ', 'Keep me signed in', 'Geçiş Anahtarıyla Giriş Yap', 'E-posta veya Telefon Numarası']) expect(isSignInText(t)).toBe(true);
expect(isSignInText('Apple Account')).toBe(false);
expect(isSignInText('Apple Account', true)).toBe(true);
expect(isSignInText('Apple Hesabı', true)).toBe(true);
for (const t of ['Apps', 'New App', 'Email address', 'Contact email', 'Sign in with Passkey to continue later']) expect(isSignInText(t, true)).toBe(false);
});
});
describe('answers (only what the config proves is certain)', () => {
test('app record', () => {
const a = byId('apple-app-record');
expect(a.get('name')).toMatchObject({ value: 'Example Game', certain: true });
expect(a.get('language')).toMatchObject({ value: 'English (U.S.)', certain: true });
expect(a.get('bundleId')).toMatchObject({ value: 'com.example.game', certain: true });
expect(a.get('sku')).toMatchObject({ value: null, certain: false });
expect(a.get('access')).toMatchObject({ value: null, certain: false });
});
test('age rating is guide only and lists every API question', () => {
const r = consoleResult('apple-age-rating', config);
expect(r.answers.map(a => a.id)).toEqual(AGE_RATING_API);
expect(AGE_RATING_API).toContain('socialMedia');
expect(r.answers.every(a => !a.certain)).toBe(true);
expect(r.guidance[0]).toContain('storebound apple appinfo');
expect(formDef('apple-age-rating', r.answers).sections).toEqual([]);
const set = consoleResult('apple-age-rating', { ...config, apple: { ...config.apple!, info: { ageRating: { advertising: true } } } });
expect(set.answers.find(a => a.id === 'advertising')).toMatchObject({ value: true, certain: true });
expect(set.answers.find(a => a.id === 'violenceRealistic')).toMatchObject({ value: 'NONE', certain: true });
expect(set.answers.find(a => a.id === 'socialMedia')).toMatchObject({ value: false, certain: true });
});
test('app privacy from dataSafety; linked and tracking are never guessed', () => {
const a = byId('apple-app-privacy');
expect(a.get('collects')).toMatchObject({ value: true, certain: true });
expect(a.get('type:Device ID')).toMatchObject({ value: true, certain: true });
expect(a.get('type:Name')).toMatchObject({ value: false, certain: true });
expect(a.get('type:Advertising Data')).toMatchObject({ value: null, certain: false });
expect(a.get('purpose:Device ID:Third-Party Advertising')?.value).toBe(true);
expect(a.get('purpose:Device ID:Analytics')?.value).toBe(true);
expect(a.get('purpose:Device ID:App Functionality')?.value).toBe(false);
expect(a.get('linked:Device ID')).toMatchObject({ value: null, certain: false });
expect(a.get('tracking:Crash Data')).toMatchObject({ value: null, certain: false });
const none = consoleAnswers('apple-app-privacy', { ...config, dataSafety: undefined });
expect(none).toHaveLength(1);
expect(none[0]!.certain).toBe(false);
});
test('content rating: purchases from products, owner answers, the rest asked; email masked in output', () => {
const a = byId('play-content-rating');
expect(a.get('email')?.value).toBe('s***@example.com');
expect(fillValue('play-content-rating', a.get('email')!, config)).toBe('support@example.com');
expect(a.get('category')).toMatchObject({ value: 'Game', certain: true });
expect(a.get('q:digitalPurchases')).toMatchObject({ value: true, certain: true });
expect(a.get('q:violence')).toMatchObject({ value: false, certain: true });
expect(a.get('q:lootBoxes')).toMatchObject({ value: null, certain: false });
const noProfile = byId('play-content-rating', { ...config, app: { ...config.app, profile: undefined } });
expect(noProfile.get('category')?.certain).toBe(false);
});
test('target audience', () => {
const a = byId('play-target-audience');
expect(a.get('age:13-15')).toMatchObject({ value: true, certain: true });
expect(a.get('age:6-8')).toMatchObject({ value: false, certain: true });
expect(a.get('appeal')).toMatchObject({ value: null, certain: false });
expect(a.get('ads')).toMatchObject({ value: true, certain: true, source: 'ads.appAdsTxt' });
const bare = byId('play-target-audience', { ...config, console: undefined, ads: undefined });
expect([...bare.values()].every(x => !x.certain)).toBe(true);
});
test('every answer of a fillable form has a field', () => {
for (const f of FORM_IDS) {
const ans = consoleAnswers(f, config);
const ids = new Set(formDef(f, ans).sections.flatMap(s => s.fields.map(x => x.id)));
if (f !== 'apple-age-rating') for (const a of ans) expect(ids.has(a.id)).toBe(true);
}
});
test('config validation', () => {
expect(validateExtras({ console: { contentRating: { category: 'game', answers: { violence: false } }, targetAudience: { ages: ['13-15'] } } })).toEqual([]);
const e = validateExtras({ console: { appRecord: { sku: 'a b', userAccess: 'some' as 'full' }, contentRating: { category: 'x' as 'game', answers: { nope: true } as never },
targetAudience: { ages: ['3-4' as '6-8'], containsAds: 'yes' as unknown as boolean } } }).join('\n');
for (const k of ['appRecord.sku', 'appRecord.userAccess', 'contentRating.category', 'answers.nope', 'targetAudience.ages', 'containsAds']) expect(e).toContain(k);
});
});
describe('--dry --json', () => {
test('prints exactly one result and opens no browser', async () => {
const out: string[] = [];
const orig = console.log;
console.log = (s: string) => { out.push(s); };
try {
await run({ loaded: { config, root: '/tmp/none', path: '', resolve: p => p }, args: parseArgs(['play-target-audience', '--dry', '--json']) });
} finally { console.log = orig; }
expect(out).toHaveLength(1);
const r = JSON.parse(out[0]!);
expect(Object.keys(r)).toEqual(['form', 'store', 'title', 'url', 'answers', 'uncertain', 'guidance']);
expect(r).toMatchObject({ form: 'play-target-audience', store: 'play', uncertain: 1 });
expect(r.answers[0]).toMatchObject({ id: 'age:5-under', certain: true, source: 'console.targetAudience.ages' });
expect(r.guidance.at(-1)).toContain('press Save yourself');
await expect(run({ loaded: { config, root: '/tmp/none', path: '', resolve: p => p }, args: parseArgs(['play-target-audience', '--store', 'apple', '--dry']) })).rejects.toThrow('play form');
});
});
// ---- the engine against the modeled pages ----
// launched once for the whole file (module level: bun's hook timeouts do not apply, LAUNCH_TIMEOUT_MS does)
const shared = await launchTestBrowser('playwright');
const { browser, skipWhy } = shared;
if (!browser) console.warn(`console-fill browser tests skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
/** The contexts and engine runs of the current test, closed / awaited right after it (afterEach below). */
const scope = testScope(() => browser);
/** Browser tests: a few seconds each on an idle machine; the timeout leaves room for a loaded one. */
const BROWSER_TEST_MS = 30_000;
/** SLOW tests (engine "not here" timeouts, Chromium relaunches): ~5-10 s idle. */
const SLOW_TEST_MS = 90_000;
/** Engine waits that are expected to SUCCEED: they return as soon as the element is there, so a long cap costs nothing. */
const FOUND_MS = 15_000;
const FIX = join(import.meta.dir, 'fixtures', 'console');
/** Console URLs of the navigation plans -> modeled pages (bases: asc = <base>asc, play = <base>play). */
const ROUTES: Record<string, string> = {
'/asc/apps': 'asc-apps.html', '/asc-tr/apps': 'asc-apps-tr.html', '/asc-noplus/apps': 'asc-apps-noplus.html', '/asc-dup/apps': 'asc-apps-dup.html', '/asc-card/apps': 'asc-apps-card.html',
'/asc/apps/123/distribution/privacy': 'apple-app-privacy.html', '/play': 'play-apps.html', '/teams/apps': 'asc-teams.html',
'/playc': 'play-home.html', '/playc-tr': 'play-home-tr.html',
};
let server: ReturnType<typeof Bun.serve> | null = null;
let base = '';
beforeAll(() => {
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
const path = new URL(req.url).pathname;
const dev = path.match(/^\/playm\/u\/0\/developers(?:\/(\d+)\/app-list)?$/);
// playn: the same chooser; every account's app list has a Create app button that opens the Create app form
const devNew = path.match(/^\/play([nrs])\/u\/0\/developers(?:\/(\d+)\/app-list)?$/);
// the Create app form of one developer account has its own URL (verified 2026-10-12)
if (/^\/play[nrs]\/u\/0\/developers\/\d+\/create-new-app$/.test(path)) return new Response(Bun.file(join(FIX, 'play-create-app.html')));
// playn: the modeled chooser; playr: the chooser as the owner's screenshot shows it; plays: one account only
const name = path.endsWith('/_common.js') ? '_common.js' : devNew ? (devNew[2] ? 'play-home.html' : ({ n: 'play-chooser.html', r: 'play-chooser-real.html', s: 'play-chooser-one.html' } as Record<string, string>)[devNew[1]!]!)
: dev ? (!dev[1] ? 'play-chooser.html' : dev[1] === '4444' ? 'play-apps.html' : 'play-apps-other.html')
: /^\/asc-card\/apps\/\d+\/distribution\//.test(path) ? 'asc-app.html'
: ROUTES[path] ?? path.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}/`;
});
// 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);
async function open(file: string) {
const page = await scope.newPage();
await page.goto(base + file);
return page;
}
const pageAnswers = (f: FormId) => consoleAnswers(f, config).map(a => ({ ...a, value: fillValue(f, a, config) }));
const checked = (page: any, name: string | RegExp, role = 'checkbox') => page.getByRole(role, { name, exact: typeof name === 'string' }).filter({ visible: true }).isChecked();
const flagged = (page: any) => page.evaluate(() => document.querySelectorAll('[data-storebound-label]').length);
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 () => {
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'));
expect(s.fallback).toBe(false);
expect(s.filled.sort()).toEqual(['bundleId', 'language', 'name', 'platform:ios']);
expect(s.highlighted.sort()).toEqual(['access', 'sku']);
expect(await page.getByRole('textbox', { name: 'Name', exact: true }).inputValue()).toBe('Example Game');
expect(await page.getByRole('textbox', { name: 'SKU', exact: true }).inputValue()).toBe('');
expect(await page.getByRole('combobox', { name: 'Bundle ID' }).evaluate((e: HTMLSelectElement) => e.selectedOptions[0]!.textContent)).toBe('Example Game - com.example.game');
expect(await page.getByRole('combobox', { name: 'Primary Language' }).evaluate((e: HTMLSelectElement) => e.value)).toBe('English (U.S.)');
expect(await checked(page, 'iOS')).toBe(true);
expect(await checked(page, 'macOS')).toBe(false);
expect(await checked(page, 'Full Access', 'radio')).toBe(false);
expect(await flagged(page)).toBe(2); // SKU + the User Access group
expect(await clicks(page)).toEqual([]);
// the next poll finds nothing left to do
expect((await fillPage(page, def, pageAnswers('apple-app-record'), new Set([0]))).section).toBeNull();
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 () => {
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);
expect(s.missing).toEqual([]);
expect(s.filled).toContain('name');
expect(await clicks(page)).toEqual([]);
await page.close();
}, BROWSER_TEST_MS);
test('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);
expect(s.fallback).toBe(false);
expect(s.missing).toEqual(['name']);
expect(s.highlighted).toContain('name');
expect(s.filled).toContain('bundleId');
expect(await checked(page, 'iOS')).toBe(true);
expect(await clicks(page)).toEqual([]);
await page.close();
}, BROWSER_TEST_MS);
test('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);
expect(s.missing).toEqual(['name']);
expect(s.filled).toEqual([]);
expect(await checked(page, 'iOS')).toBe(false);
expect(await clicks(page)).toEqual([]);
expect(consoleResult('apple-app-record', config).guidance).toContain('Name: Example Game');
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 () => {
const page = await open('apple-app-privacy.html');
const ans = pageAnswers('apple-app-privacy');
const def = formDef('apple-app-privacy', ans);
const done = new Set<number>();
let s = await fillPage(page, def, ans, done);
expect(s).toMatchObject({ section: 0, filled: ['collects'], fallback: false });
expect(await checked(page, /^Yes, we collect data/, 'radio')).toBe(true);
expect((await fillPage(page, def, ans, done)).section).toBeNull(); // waits for the owner
await page.evaluate(() => (window as any).__next());
s = await fillPage(page, def, ans, done);
expect(s.section).toBe(1);
expect(await checked(page, 'Device ID')).toBe(true);
expect(await checked(page, 'Crash Data')).toBe(true);
expect(await checked(page, 'Purchase History')).toBe(false);
expect(s.highlighted).toContain('type:Advertising Data');
expect(await checked(page, 'Advertising Data')).toBe(false);
await page.evaluate(() => (window as any).__next());
s = await fillPage(page, def, ans, done);
expect(s.title).toBe('Device ID: purposes');
expect(s.fallback).toBe(false);
expect(await checked(page, 'Third-Party Advertising')).toBe(true);
expect(await checked(page, 'App Functionality')).toBe(false);
// one dialog page with all three parts: linked and tracking are the next sections of the same dialog
expect((await fillPage(page, def, ans, done))).toMatchObject({ title: 'Device ID: linked to the user', highlighted: ['linked:Device ID'] });
expect((await fillPage(page, def, ans, done))).toMatchObject({ title: 'Device ID: tracking', highlighted: ['tracking:Device ID'] });
expect(await page.getByRole('radio').filter({ visible: true }).evaluateAll((els: HTMLInputElement[]) => els.filter(e => e.checked).length)).toBe(0);
await page.evaluate(() => (window as any).__type('Crash Data'));
for (const part of ['purposes', 'linked to the user', 'tracking']) expect((await fillPage(page, def, ans, done)).title).toBe(`Crash Data: ${part}`);
expect(done.size).toBe(def.sections.length);
expect(await clicks(page)).toEqual([]);
await page.close();
}, BROWSER_TEST_MS);
test('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);
const done = new Set<number>();
let s = await fillPage(page, def, ans, done);
expect(s.filled.sort()).toEqual(['category', 'email']);
expect(await page.getByRole('textbox', { name: 'Email address' }).inputValue()).toBe('support@example.com');
expect(await checked(page, 'Game', 'radio')).toBe(true);
await page.evaluate(() => (window as any).__next());
s = await fillPage(page, def, ans, done);
expect(s.fallback).toBe(false);
expect(s.filled.sort()).toEqual(['q:digitalPurchases', 'q:violence']);
expect(s.highlighted).toHaveLength(11);
const yes = await page.getByRole('radiogroup', { name: /purchase digital goods/ }).getByRole('radio', { name: 'Yes' }).isChecked();
expect(yes).toBe(true);
expect(await page.getByRole('radiogroup', { name: /loot boxes/ }).getByRole('radio').evaluateAll((els: HTMLInputElement[]) => els.some(e => e.checked))).toBe(false);
expect(await clicks(page)).toEqual([]);
await page.close();
}, BROWSER_TEST_MS);
test('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);
const done = new Set<number>();
expect((await fillPage(page, def, ans, done)).filled).toHaveLength(6);
expect(await checked(page, 'Ages 13-15')).toBe(true);
expect(await checked(page, 'Ages 9-12')).toBe(false);
await page.evaluate(() => (window as any).__next());
expect((await fillPage(page, def, ans, done)).highlighted).toEqual(['appeal']);
await page.evaluate(() => (window as any).__next());
expect((await fillPage(page, def, ans, done)).filled).toEqual(['ads']);
expect(await checked(page, /^Yes, my app contains ads/, 'radio')).toBe(true);
expect(await clicks(page)).toEqual([]);
await page.close();
}, BROWSER_TEST_MS);
test('sign-in detector and safeClick', async () => {
const signin = await open('signin.html');
expect(await signInPage(signin)).toBe(true);
await signin.close();
const passkey = await open('signin-passkey.html'); // no password field, unknown host: the texts tell
expect(await signInPage(passkey)).toBe(true);
await passkey.close();
const page = await open('apple-app-record.html');
expect(await signInPage(page)).toBe(false);
expect(await safeClick(page.getByRole('button', { name: 'Create' }), 'Create')).toBe(false);
expect(await safeClick(page.getByRole('button', { name: 'Cancel' }), 'Cancel')).toBe(true);
expect(await clicks(page)).toEqual(['Cancel']);
await page.close();
}, BROWSER_TEST_MS);
});
// ---- navigation to the form after sign-in ----
const bases = (asc = 'asc') => ({ asc: base + asc, play: base + 'play' });
describe('navigation plan (pure)', () => {
test('apple-app-record: Apps > + > New App > the dialog', () => {
const p = navPlan('apple-app-record', config);
expect(p.map(s => s.kind)).toEqual(['goto', 'click', 'click', 'wait']);
expect(p[0]).toMatchObject({ url: 'https://appstoreconnect.apple.com/apps' });
});
test('apple-app-privacy: deep link with apple.appId, else the app list by name / bundle id', () => {
const withId = { ...config, apple: { ...config.apple!, appId: '123' } } as ShipConfig;
const deep = navPlan('apple-app-privacy', withId);
expect(deep[0]).toMatchObject({ kind: 'goto', url: 'https://appstoreconnect.apple.com/apps/123/distribution/privacy' });
expect(deep).toHaveLength(2);
expect(formMeta('apple-app-privacy', withId).url).toBe('https://appstoreconnect.apple.com/apps/123/distribution/privacy');
expect(navPlan('apple-app-privacy', config)[1]).toMatchObject({ kind: 'pickApp', tokens: ['com.example.game'], name: 'Example Game' });
});
test('play forms: app list by package name > App content > Start / Manage', () => {
for (const f of ['play-content-rating', 'play-target-audience'] as const) {
const p = navPlan(f, config);
expect(p.map(s => s.kind)).toEqual(['goto', 'pickApp', 'click', 'click', 'formLink', 'wait']);
expect(p[1]).toMatchObject({ tokens: ['com.example.game'] });
}
expect(navPlan('apple-age-rating', config)).toEqual([]);
});
test('no navigation step names a deny-listed control; Start / Manage pass only as links', () => {
const withId = { ...config, apple: { ...config.apple!, appId: '123' } } as ShipConfig;
for (const f of FORM_IDS) for (const s of [...navPlan(f, withId), ...navPlan(f, config)]) {
if (s.kind !== 'click') continue;
// the one exception: Play's Create app opener, gated by createAppAllowed (never by the plain navigation gate)
if (s.createApp) { expect(f).toBe('play-app-record'); expect(s.names).toEqual(CREATE_APP_OPENERS); continue; }
for (const n of s.names) if (typeof n === 'string') expect(forbiddenControl(n)).toBe(false);
}
for (const n of FORM_OPENERS) { expect(navAllowed(n, 'link')).toBe(true); expect(navAllowed(n, 'button')).toBe(false); }
for (const n of ['Save', 'Submit for Review', 'Publish', 'Create', 'Create app', 'Send 2 changes for review', 'Next', 'Kaydet', 'Gönder', 'Yayınla', 'Oluştur',
'İleri', 'İncelemeye Gönder', 'Start rollout to Production', 'Start new release', 'Başlat', ''])
for (const role of ['link', 'button', 'menuitem', 'row']) expect(navAllowed(n, role)).toBe(false);
for (const n of ['+', 'Add Apps', 'New App', 'Yeni Uygulama', 'Uygulama Ekle', 'App content', 'Uygulama içeriği', 'App Privacy', 'Policy and programs', 'Example Game'])
expect(navAllowed(n, 'button')).toBe(true);
for (const n of ['Uygula', 'Değişiklikleri uygula', 'Uygulandı']) expect(forbiddenControl(n)).toBe(true);
for (const n of ['Uygulama Ekle', 'Yeni Uygulama', 'Uygulama içeriği', 'Uygulamalar']) expect(forbiddenControl(n)).toBe(false);
expect(hasToken('Example Game com.example.game', 'com.example.game')).toBe(true);
expect(hasToken('Example Game Pro com.example.gamepro', 'com.example.game')).toBe(false);
expect(hasToken('com.example.game.beta', 'com.example.game')).toBe(false);
});
});
describe.skipIf(!browser)('navigation (headless, modeled pages)', () => {
const quiet = { timeoutMs: FOUND_MS, say: () => {} }; // every navigation with `quiet` is expected to succeed
const navClicks = (page: any): Promise<string[]> => page.evaluate(() => (window as any).__navClicks());
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 () => {
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'] });
expect(await navClicks(page)).toEqual(['Add Apps', 'New App']);
const s = await fillPage(page, formDef('apple-app-record', pageAnswers('apple-app-record')), pageAnswers('apple-app-record'));
expect(s.fallback).toBe(false);
expect(s.filled).toContain('name');
expect(await clicks(page)).toEqual(['+']); // the + button only, never Create / Submit for Review
await page.close();
}, BROWSER_TEST_MS);
test('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);
expect(await navClicks(page)).toEqual(['Uygulama Ekle', 'Yeni Uygulama']);
expect(await clicks(page)).toEqual(['+']);
await page.close();
}, BROWSER_TEST_MS);
test('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) });
expect(r).toMatchObject({ ok: false, failed: 'the add button (+)', why: 'not found', steps: ['open Apps'] });
expect(said.some(m => m.includes('step 2/4: the add button (+)'))).toBe(true);
expect(await navClicks(page)).toEqual([]);
expect(formMeta('apple-app-record', config).open[0]).toContain('New App');
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 () => {
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'] });
await page.close();
page = await fresh();
r = await navigate(page, navPlan('apple-app-privacy', config, bases()), quiet);
expect(r.ok).toBe(true);
expect(await navClicks(page)).toEqual(['Example Game', 'App Privacy']);
expect(await clicks(page)).toEqual([]); // Publish on that page never clicked
await page.close();
page = await fresh();
r = await navigate(page, navPlan('apple-app-privacy', config, bases('asc-dup')), quiet);
expect(r).toMatchObject({ ok: false, failed: 'the app in the list', candidates: ['Example Game', 'Example Game'] });
expect(await navClicks(page)).toEqual([]);
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 () => {
const page = await fresh();
const r = await navigate(page, navPlan('apple-app-privacy', config, bases('asc-card')), quiet);
expect(r).toMatchObject({ ok: true });
expect(r.candidates).toBeUndefined();
expect(await navClicks(page)).toEqual(['Example Game', 'App Privacy']);
expect(await clicks(page)).toEqual([]);
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 () => {
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);
expect(r.ok).toBe(true);
const names = await navClicks(page);
expect(names).toEqual(['Example Game', 'Policy and programs', 'App content', link]);
noneDenied(names);
expect(await page.getByRole('heading', { level: 1 }).textContent()).toBe(heading);
await page.close();
}
}, BROWSER_TEST_MS);
test('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);
expect(await clicks(page)).toEqual([]);
await page.goto(base + 'play-dashboard.html');
expect((await navClick(page.getByRole('link', { name: /Start rollout/ }))).clicked).toBe(false);
expect((await navClick(page.getByRole('button', { name: 'Publish' }))).clicked).toBe(false);
expect(await clicks(page)).toEqual([]);
await page.close();
}, BROWSER_TEST_MS);
test('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'] });
expect(await clicks(page)).toEqual([]);
await page.close();
}, BROWSER_TEST_MS);
});
// ---- sign-in during navigation, watching after the fallback, the session kept across relaunches ----
/** A browser context whose console hosts (appstoreconnect / idmsa / play.google.com) are served from the fixtures. */
async function consoleBrowser(map: (u: URL, cookie: string) => string | null): Promise<ConsoleBrowser> {
const ctx = scope.track(await browser.newContext());
await ctx.route(/^https?:\/\/(?!127\.0\.0\.1)/, async (route: any) => {
const u = new URL(route.request().url());
const cookie: string = (await route.request().headerValue('cookie')) ?? '';
const name = u.pathname.endsWith('/_common.js') ? '_common.js' : map(u, cookie);
if (!name) return route.abort(); // nothing leaves the machine
return route.fulfill({ path: join(FIX, name), contentType: name.endsWith('.js') ? 'text/javascript' : 'text/html' });
});
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 tmpDirs: string[] = [];
afterAll(() => { for (const d of tmpDirs) rmSync(d, { recursive: true, force: true }); });
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.
*/
const fast = (log: ReturnType<typeof logLines>) => ({ say: log.say, tickMs: 100, 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 () => {
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);
const log = logLines(), said = log.lines;
const run = scope.run(fillSession(b, { form: 'apple-app-record', config, ...fast(log) }));
await log.wait('the sign-in hint', l => l.some(m => m.startsWith('sign in in the browser window')));
expect(said.some(m => /^a sign-in page appeared at step/.test(m))).toBe(true); // the shell looked signed in, then /login
expect(said.some(m => m.includes('Keep me signed in'))).toBe(true);
expect(new URL(b.page.url()).pathname).toBe('/login');
expect(await signInPage(b.page)).toBe(true);
const frame = b.page.frames().find((f: any) => f.url().startsWith('https://idmsa.apple.com/'));
expect(await frame.evaluate(() => (window as any).__clicks)).toEqual([]); // nothing clicked on the sign-in form
expect(await frame.locator('#acc').inputValue()).toBe(''); // nothing typed
await b.page.evaluate(() => (window as any).__signIn()); // the owner signs in
await log.wait('the New App dialog filled', FILLED);
expect(said).toContain('signed in: opening the form again from step 1');
expect(await b.page.getByRole('textbox', { name: 'Name', exact: true }).inputValue()).toBe('Example Game');
expect(await clicks(b.page)).toEqual(['+']); // never Create / Submit for Review
await b.context.close();
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 () => {
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) }));
await log.wait('the printed steps', l => l.some(m => m.startsWith('open the form: Apps')));
expect(await clicks(b.page)).toEqual([]);
await b.page.evaluate(() => { location.href = '/apps?owner=1'; }); // the owner navigates
await log.wait('the New App dialog filled', FILLED);
expect(said).toContain('the page changed: storebound opens the form from step 2/4');
expect(await b.page.evaluate(() => (window as any).__navClicks())).toEqual(['Add Apps', 'New App']);
expect(await clicks(b.page)).toEqual(['+']);
await b.context.close();
await run;
}, SLOW_TEST_MS);
test('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) }));
await log.wait('the printed steps', l => l.some(m => m.startsWith('open the form: Apps')));
await b.page.evaluate(() => { location.href = '/apps?dialog=1'; });
await log.wait('the New App dialog filled', FILLED);
expect(await clicks(b.page)).toEqual([]); // not even + : the dialog was open
await b.context.close();
await run;
}, SLOW_TEST_MS);
test('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);
await page.getByRole('button', { name: 'Add Apps' }).click();
expect(await resumeAt(page, plan)).toBe(2);
await page.goto(base + 'asc-apps-noplus.html');
expect(await resumeAt(page, plan)).toBeNull();
await page.close();
}, BROWSER_TEST_MS);
// the session-cookie persistence of storebound's own profile: tests/console-persist.test.ts (real Chrome, own shard)
});
// ---- one login, several developer accounts / teams ----
describe('accounts (pure)', () => {
test('matchAccount: exact spelling, then case aside, then a unique prefix; a case pair needs the exact spelling', () => {
const all = [{ name: "Studio's Apps" }, { name: 'NORTHWIND' }, { name: 'Northwind' }, { name: 'Example Org' }, { name: 'Example AI' }, { name: 'IŞIK Oyun' }];
expect(matchAccount('Example AI', all).hit?.name).toBe('Example AI');
expect(matchAccount(' example ai ', all).hit?.name).toBe('Example AI');
expect(matchAccount('Northwind', all).hit?.name).toBe('Northwind');
expect(matchAccount('NORTHWIND', all).hit?.name).toBe('NORTHWIND');
expect(matchAccount('northwind', all).ambiguous?.map(a => a.name)).toEqual(['NORTHWIND', 'Northwind']);
expect(matchAccount('Example O', all).hit?.name).toBe('Example Org');
expect(matchAccount('Example', all).ambiguous).toHaveLength(2);
expect(matchAccount('ışık oyun', all).hit?.name).toBe('IŞIK Oyun');
expect(matchAccount('Nope', all)).toEqual({});
});
test('cache: ids only, malformed entries dropped, atomic rewrite', async () => {
const f = tmpAccounts();
expect(readAccounts(f)).toEqual({ version: 1, play: {}, apple: {} });
rememberPlay('com.example.game', { developerId: '4444', account: 'Example Games Ltd' }, f);
rememberApple('com.example.game', { team: 'Example Games Ltd', teamId: 'ABCDE12345' }, f);
const c = readAccounts(f);
expect(c.play['com.example.game']).toMatchObject({ developerId: '4444', account: 'Example Games Ltd' });
expect(c.apple['com.example.game']).toMatchObject({ team: 'Example Games Ltd', teamId: 'ABCDE12345' });
expect(JSON.stringify(c)).not.toMatch(/cookie|myacinfo|session/i);
await Bun.write(f, JSON.stringify({ play: { 'com.x': { developerId: 'abc' }, '../bad': { developerId: '1' } }, apple: { 'com.y': { team: '' } } }));
expect(readAccounts(f)).toEqual({ version: 1, play: {}, apple: {} });
});
test('play developer ids, env ids from a workspace account, and the switch gate', () => {
expect(playDeveloperId('https://play.google.com/console/u/0/developers/4444/app-list')).toBe('4444');
expect(playDeveloperId('https://play.google.com/console/u/0/developers')).toBeNull();
expect(navPlan('play-content-rating', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig)[0])
.toMatchObject({ kind: 'goto', url: 'https://play.google.com/console/u/0/developers/4444/app-list' });
expect(withAccountIds(config, { STOREBOUND_PLAY_DEVELOPER_ID: '4444', STOREBOUND_APPLE_TEAM_ID: 'ABCDE12345' })).toMatchObject({ play: { developerId: '4444' }, apple: { teamId: 'ABCDE12345' } });
expect(withAccountIds(config, { STOREBOUND_PLAY_DEVELOPER_ID: 'x' }).play?.developerId).toBeUndefined();
expect(validateExtras({ play: { packageName: 'com.example.game', developerId: 'abc' } })).toContain('play.developerId: the digits of play.google.com/console/u/0/developers/<id>');
const listed = ['Studio One', 'Example Games Ltd', 'Next Level Games', 'Sign Out'];
expect(switchAllowed('Example Games Ltd', 'menuitemradio', listed)).toBe(true);
expect(switchAllowed('Example Games Ltd', 'link', listed)).toBe(true);
expect(switchAllowed('Other Studio', 'menuitemradio', listed)).toBe(false); // not read from the list
expect(switchAllowed('Sign Out', 'menuitem', listed)).toBe(false);
expect(switchAllowed('Next Level Games', 'menuitemradio', listed)).toBe(false); // the click gate stays absolute
expect(switchAllowed('Edit Profile', 'menuitem', ['Edit Profile'])).toBe(false);
expect(switchAllowed('Example Games Ltd', 'textbox', listed)).toBe(false);
});
});
describe.skipIf(!browser)('accounts (headless, modeled pages)', () => {
const navClicks = (page: any): Promise<string[]> => page.evaluate(() => (window as any).__navClicks());
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 () => {
const file = tmpAccounts(), said: string[] = [];
let page = await fresh();
await page.goto(base + 'teams/apps');
const tb = { asc: base + 'teams', play: base + 'play' };
const r = await openAcross(page, { form: 'apple-app-record', config, say: m => { said.push(m); }, bases: tb, navMs: 1500, file, env: {} });
expect(r.nav.ok).toBe(true);
expect(said).toContain('this Apple Account is in 3 teams: looking for com.example.game in each');
expect(readAccounts(file).apple['com.example.game']?.team).toBe('Example Games Ltd');
expect(await page.evaluate(() => document.cookie)).toContain('team=Example%20Games%20Ltd');
const names = await navClicks(page);
expect(names).toContain('Example Games Ltd');
for (const n of DENIED) expect(names).not.toContain(n);
expect(await clicks(page)).toEqual(['+']); // in the right team: + only, never Create
const s = await fillPage(page, formDef('apple-app-record', pageAnswers('apple-app-record')), pageAnswers('apple-app-record'));
expect(s.filled).toContain('bundleId');
await page.close();
// the next run tries the remembered team first
const said2: string[] = [];
page = await fresh();
await page.goto(base + 'teams/apps');
expect((await openAcross(page, { form: 'apple-app-record', config, say: m => { said2.push(m); }, bases: tb, navMs: 1500, file, env: {} })).nav.ok).toBe(true);
expect(said2).toContain('team 1/3: Example Games Ltd');
for (const n of DENIED) expect(await navClicks(page)).not.toContain(n);
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 () => {
const file = tmpAccounts(), said: string[] = [];
const pb = { asc: base + 'asc', play: base + 'playm' };
let page = await fresh();
const r = await openAcross(page, { form: 'play-content-rating', config, say: m => { said.push(m); }, bases: pb, navMs: 1500, file, env: {} });
expect(r.nav.ok).toBe(true);
expect(said).toContain('this Google login sees 5 developer accounts: looking for com.example.game in each');
expect(said).toContain('developer account 4/5: Example Games Ltd');
expect(readAccounts(file).play['com.example.game']).toMatchObject({ developerId: '4444', account: 'Example Games Ltd' });
expect(await navClicks(page)).toEqual(['Example Game', 'Policy and programs', 'App content', 'Start']);
expect(await clicks(page)).toEqual([]);
await page.close();
// remembered: straight to that account's app list, no chooser
const said2: string[] = [];
page = await fresh();
expect((await openAcross(page, { form: 'play-content-rating', config, say: m => { said2.push(m); }, bases: pb, navMs: 1500, file, env: {} })).nav.ok).toBe(true);
expect(said2.some(m => m.includes('developer accounts'))).toBe(false);
expect(said2).toContain('opening the form, step 1/6: open the app list of the developer account');
await page.close();
// play.developerId from a workspace account (env): no chooser either, nothing cached needed
const said3: string[] = [];
page = await fresh();
expect((await openAcross(page, { form: 'play-target-audience', config, say: m => { said3.push(m); }, bases: pb, navMs: 1500, file: tmpAccounts(), env: { STOREBOUND_PLAY_DEVELOPER_ID: '4444' } })).nav.ok).toBe(true);
expect(said3.some(m => m.includes('developer accounts'))).toBe(false);
await page.close();
}, SLOW_TEST_MS);
test('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: {} });
expect(r.nav).toMatchObject({ ok: false, failed: 'the developer account' });
expect(r.nav.why).toContain('com.example.absent is in none of the 5 developer accounts');
expect(await clicks(page)).toEqual([]);
for (const n of DENIED) expect(await navClicks(page)).not.toContain(n);
await page.close();
}, SLOW_TEST_MS);
});
// ---- play-app-record: Play Console's Create app form (no API can create a Play app) ----
describe('play-app-record (pure)', () => {
test('answers: name, default language label, app or game, free as an owner check, declarations never answered', () => {
const a = byId('play-app-record');
expect(a.get('name')).toMatchObject({ value: 'Example Game', certain: true, source: 'app.name' });
expect(a.get('language')).toMatchObject({ value: 'English (United States) – en-US', certain: true, source: 'languages[0].play' });
expect(a.get('type')).toMatchObject({ value: 'Game', certain: true });
expect(a.get('price')).toMatchObject({ value: 'Free', certain: true, ownerCheck: true, source: 'products + ads.appAdsTxt' });
expect(a.get('price')!.note).toContain('can never become paid');
for (const id of ['agree:policies', 'agree:exportLaws']) expect(a.get(id)).toMatchObject({ value: null, certain: false, agreement: true });
const own = byId('play-app-record', { ...config, app: { ...config.app, profile: 'subscription-app' }, play: { packageName: 'com.example.game', name: 'Example', defaultLanguage: 'tr-TR', free: false } } as ShipConfig);
expect(own.get('name')).toMatchObject({ value: 'Example', source: 'play.name' });
expect(own.get('language')).toMatchObject({ value: 'Turkish – tr-TR', source: 'play.defaultLanguage' });
expect(own.get('type')?.value).toBe('App');
expect(own.get('price')).toMatchObject({ value: 'Paid', certain: true, ownerCheck: true, source: 'play.free' });
const bare = byId('play-app-record', { ...config, app: { ...config.app, name: 'An Example Game With A Very Long Name', profile: undefined }, languages: [{ id: 'x', play: 'xx-XX' }], products: undefined, ads: undefined } as ShipConfig);
expect(bare.get('name')).toMatchObject({ value: null, certain: false });
expect(bare.get('name')!.note).toContain('Play allows 30');
expect(bare.get('language')).toMatchObject({ value: null, certain: false });
expect(bare.get('type')?.certain).toBe(false);
expect(bare.get('price')).toMatchObject({ value: null, certain: false });
});
test('guidance and notes: the package name field and its Check availability, free is final, declarations are yours', () => {
const r = consoleResult('play-app-record', config);
const g = r.guidance.join('\n');
expect(g).not.toContain('first AAB upload');
expect(g).toContain('App package name: com.example.game');
expect(g).toContain('"Check availability" (read-only)');
expect(g).toContain('Package name available');
expect(g).toContain('Free or paid: Free (check: Owner check');
expect(g).toContain('Declarations: Developer Program Policies: you tick it');
expect(r.guidance.at(-1)).toContain('press Create app yourself');
expect(formMeta('play-app-record', config)).toMatchObject({ store: 'play', stopBefore: 'Create app', fillable: true });
});
test('config fields', () => {
expect(validateExtras({ play: { packageName: 'com.example.game', developerAccount: 'Example Games Ltd', name: 'Example', free: true } })).toEqual([]);
const e = validateExtras({ play: { packageName: 'com.example.game', developerAccount: ' ', name: 'x'.repeat(31), free: 'yes' as unknown as boolean } }).join('\n');
for (const k of ['play.developerAccount', 'play.name', 'play.free']) expect(e).toContain(k);
});
test('navigation plan: the app list > Create app > the form; with play.developerId the form\'s own URL', () => {
const p = navPlan('play-app-record', config);
expect(p.map(s => s.kind)).toEqual(['goto', 'click', 'wait']);
expect(p[1]).toMatchObject({ createApp: true, names: CREATE_APP_OPENERS });
const direct = navPlan('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig);
expect(direct.map(s => s.kind)).toEqual(['goto', 'wait']);
expect(direct[0]).toMatchObject({ url: 'https://play.google.com/console/u/0/developers/4444/create-new-app' });
expect(formMeta('play-app-record', { ...config, play: { ...config.play!, developerId: '4444' } } as ShipConfig).url).toBe('https://play.google.com/console/u/0/developers/4444/create-new-app');
});
test('the Create app allowlist: only the page-level opener on the app list, never inside the form (EN and TR)', () => {
const list = { appList: true, formOpen: false, inForm: false };
for (const n of ['Create app', 'Uygulama oluştur', ' Create app ', 'UYGULAMA OLUŞTUR', 'create app']) {
expect(createAppAllowed(n, 'button', list)).toBe(true);
expect(createAppAllowed(n, 'link', list)).toBe(true);
}
for (const n of ['Create app', 'Uygulama oluştur']) {
expect(createAppAllowed(n, 'button', { ...list, formOpen: true })).toBe(false); // the form is open: it is the submit
expect(createAppAllowed(n, 'button', { ...list, inForm: true })).toBe(false);
expect(createAppAllowed(n, 'button', { ...list, appList: false })).toBe(false);
expect(createAppAllowed(n, 'menuitem', list)).toBe(false);
expect(navAllowed(n, 'button')).toBe(false); // the plain navigation gate still refuses it
expect(forbiddenControl(n)).toBe(true); // and safeClick never presses it
}
for (const n of ['Create', 'Create app and continue', 'Create developer account', 'Oluştur', 'Uygulama oluştur ve devam et', 'Save', 'Create new app', ''])
expect(createAppAllowed(n, 'button', list)).toBe(false);
});
});
describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => {
const quiet = { timeoutMs: FOUND_MS, say: () => {} }; // every navigation here is expected to succeed
const navClicks = (page: any): Promise<string[]> => page.evaluate(() => (window as any).__navClicks());
const fresh = async () => { const p = await scope.newPage(); await p.goto(base + 'blank.html'); await p.evaluate(() => sessionStorage.clear()); return p; };
const pb = (play: string) => ({ asc: base + 'asc', play: base + play });
const ans = () => pageAnswers('play-app-record');
const def = () => formDef('play-app-record', ans());
const agreed = (page: any) => page.getByRole('checkbox').evaluateAll((els: HTMLInputElement[]) => els.filter(e => e.checked).length);
for (const [lang, play, opener, nameField, game, free] of [['EN', 'playc', 'Create app', 'App name', 'Game', 'Free'], ['TR', 'playc-tr', 'Uygulama oluştur', 'Uygulama adı', 'Oyun', 'Ücretsiz']] as const) {
test(`${lang}: All apps > ${opener} opens the form; filled; declarations never ticked; the final ${opener} never clicked`, async () => {
const page = await fresh();
const r = await navigate(page, navPlan('play-app-record', config, pb(play)), quiet);
expect(r).toMatchObject({ ok: true, steps: ['open Play Console', 'Create app on the app list', 'the Create app form'] });
expect(await navClicks(page)).toEqual([opener]);
const s = await fillPage(page, def(), ans());
expect(s.fallback).toBe(false);
expect(s.filled.sort()).toEqual(['language', 'name', 'packageName', 'price', 'type']);
expect(s.highlighted.sort()).toEqual(['agree:exportLaws', 'agree:policies']);
expect(await page.getByRole('textbox', { name: nameField, exact: true }).inputValue()).toBe('Example Game');
expect(await page.getByRole('combobox').evaluate((e: HTMLSelectElement) => e.selectedOptions[0]!.textContent)).toMatch(/– en-US$/);
expect(await checked(page, game, 'radio')).toBe(true);
expect(await checked(page, free, 'radio')).toBe(true);
expect(await agreed(page)).toBe(0);
expect(await page.evaluate(() => [...document.querySelectorAll('[data-storebound-label]')].map(e => e.textContent))).toEqual([
'storebound: owner check', 'storebound: you agree to this, never ticked', 'storebound: you agree to this, never ticked']);
expect(await clicks(page)).toEqual([]); // never the final Create app / Uygulama oluştur
// inside the form the same words are the submit: refused even when pointed at it
const submit = page.getByRole('button', { name: opener, exact: true });
expect(await createAppHere(submit, opener, 'button')).toBe(false);
expect((await navClick(submit, true)).clicked).toBe(false);
expect(await safeClick(submit, opener)).toBe(false);
expect(await clicks(page)).toEqual([]);
await page.close();
}, BROWSER_TEST_MS);
}
test('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);
expect(s.highlighted.sort()).toEqual(['agree:exportLaws', 'agree:policies']);
expect(await agreed(page)).toBe(0);
expect(await clicks(page)).toEqual([]);
await page.close();
}, BROWSER_TEST_MS);
test('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);
expect(s.fallback).toBe(false);
expect(s.missing).toEqual(['language']);
expect(s.filled.sort()).toEqual(['name', 'packageName', 'price', 'type']);
expect(s.highlighted).toContain('language');
expect(await agreed(page)).toBe(0);
expect(await clicks(page)).toEqual([]);
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 () => {
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);
await page.setContent('<h1>Dashboard</h1><button type="button">Create app</button>');
expect((await navClick(page.getByRole('button', { name: 'Create app' }), true)).clicked).toBe(false);
await page.setContent('<h1>All apps</h1><button type="button">Create app</button>');
expect(await createAppHere(page.getByRole('button', { name: 'Create app' }), 'Create app', 'button')).toBe(true);
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 () => {
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 });
return { r, page };
};
const file = tmpAccounts();
let said: string[] = [];
let { r, page } = await go(config, file, said);
expect(r.nav).toMatchObject({ ok: false, failed: 'the developer account', candidates: ['Studio One (1111)', 'Studio Two (2222)', 'Studio Three (3333)', 'Example Games Ltd (4444)', 'Studio Five (5555)'] });
expect(r.nav.why).toContain('set play.developerAccount');
expect(await navClicks(page)).toEqual([]);
expect(readAccounts(file).play).toEqual({});
await page.close();
({ r, page } = await go({ ...config, play: { ...config.play!, developerAccount: 'example games ltd' } } as ShipConfig, file, said = []));
expect(r.nav.ok).toBe(true);
expect(said).toContain('creating the app in developer account "Example Games Ltd" (4444, play.developerAccount)');
expect(new URL(page.url()).pathname).toMatch(/\/developers\/4444\/create-new-app$/);
expect(await navClicks(page)).toEqual([]); // the form's own URL: no Create app click at all
expect(readAccounts(file).play['com.example.game']).toMatchObject({ developerId: '4444', account: 'Example Games Ltd' });
await page.close();
({ r, page } = await go({ ...config, play: { ...config.play!, developerAccount: 'Nope Studio' } } as ShipConfig, tmpAccounts(), said = []));
expect(r.nav).toMatchObject({ ok: false, failed: 'the developer account' });
expect(r.nav.why).toContain('no developer account named "Nope Studio"');
await page.close();
// remembered for this package: straight to that account, no chooser
({ r, page } = await go(config, file, said = []));
expect(r.nav.ok).toBe(true);
expect(said.some(m => m.startsWith('creating the app in developer account 4444 (remembered'))).toBe(true);
await page.close();
// the workspace account's developerId (env)
({ r, page } = await go(config, tmpAccounts(), said = [], { STOREBOUND_PLAY_DEVELOPER_ID: '2222' }));
expect(r.nav.ok).toBe(true);
expect(said).toContain('creating the app in developer account 2222 (play.developerId)');
expect(said).toContain('opening the form, step 1/2: open the Create app form of the developer account');
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 () => {
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;
const r = await openAcross(page, { form: 'play-app-record', config: c, say: m => { said.push(m); }, bases: pb(play), navMs: FOUND_MS, file: tmpAccounts(), env: {} });
return { r, page };
};
// the avatar row: its id only shows after the click, the click goes through the account-switch gate
let said: string[] = [];
let { r, page } = await go('Example AI', said);
expect(r.nav.ok).toBe(true);
expect(said).toContain('creating the app in developer account "Example AI" (7000000005, play.developerAccount)');
expect(new URL(page.url()).pathname).toMatch(/\/developers\/7000000005\/create-new-app$/);
await page.close();
// spaces and case aside
({ r, page } = await go(' example ai ', said = []));
expect(said).toContain('creating the app in developer account "Example AI" (7000000005, play.developerAccount)');
await page.close();
// the case pair: the exact spelling picks; a spelling that matches both only case-insensitively stops and lists both
({ r, page } = await go('Northwind', said = []));
expect(said).toContain('creating the app in developer account "Northwind" (7000000003, play.developerAccount)');
await page.close();
({ r, page } = await go('NORTHWIND', said = []));
expect(said).toContain('creating the app in developer account "NORTHWIND" (7000000002, play.developerAccount)');
await page.close();
({ r, page } = await go('northwind', said = []));
expect(r.nav).toMatchObject({ ok: false, failed: 'the developer account' });
expect(r.nav.why).toContain('matches more than one developer account: NORTHWIND (7000000002), Northwind (7000000003)');
expect(await navClicks(page)).toEqual([]);
await page.close();
// a unique prefix; the id from a data attribute
({ r, page } = await go('Example O', said = []));
expect(said).toContain('creating the app in developer account "Example Org" (7000000004, play.developerAccount)');
await page.close();
// not found: every visible account with its id (the avatar row: "id not shown"); Create a new developer account is not one
({ r, page } = await go('Nope Studio', said = []));
expect(r.nav.why).toBe('no developer account named "Nope Studio" (play.developerAccount) on the chooser: Studio\'s Apps (7000000001), NORTHWIND (7000000002), Northwind (7000000003), Example Org (7000000004), Example AI (id not shown). Set play.developerAccount to one of them, or play.developerId');
expect(r.nav.candidates).toHaveLength(5);
expect(await navClicks(page)).toEqual([]);
await page.close();
// one developer account: picked, also when play.developerAccount names another
({ r, page } = await go(undefined, said = [], 'plays'));
expect(r.nav.ok).toBe(true);
expect(said).toContain('creating the app in developer account "Example AI" (7000000005, the only developer account of this login)');
await page.close();
({ r, page } = await go('Other Name', said = [], 'plays'));
expect(r.nav.ok).toBe(true);
expect(said[0]).toBe('play.developerAccount is "Other Name", but this Google login has only the developer account "Example AI": using it');
await page.close();
}, SLOW_TEST_MS);
test('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;
const done = scope.run(fillSession(b, { form: 'play-app-record', config: c, ...fast(log) }));
await log.wait('the Create app form filled', l => l.some(m => /^Create app: filled 5, check 2/.test(m)));
expect(said.some(m => m.includes('Check availability'))).toBe(true);
await log.wait('the availability check', l => l.includes('App package name: "Package name available"'));
expect(said).toContain('clicked "Check availability" (navigation)');
// the material radios: the inner [role=radio] was clicked, so the form's model has them and no "Select …" hint shows
expect(await b.page.evaluate(() => (window as any).__model)).toEqual({ type: 'Game', price: 'Free' });
expect(await b.page.getByText(/^Select (app or game|free or paid)$/).filter({ visible: true }).count()).toBe(0);
expect(said.some(m => m.includes('never become paid'))).toBe(true);
await log.wait('the final line', l => l.some(m => m.includes('press Create app yourself')));
expect(await clicks(b.page)).toEqual(['Check availability']); // read-only; never the final Create app
await b.context.close();
expect((await done).filled).toBe(5);
}, SLOW_TEST_MS);
});
test('ticks are gated on agreements only: help text with "saved" is fine, Developer Program Policies is not; buttons keep the full gate', async () => {
const { forbiddenTick, forbiddenControl } = await import('../cli/lib/console-forms');
const productInteraction = 'Product InteractionSuch as app launches, taps, clicks, scrolling information, music listening data, video views, saved place in a game, video, or song, or other information about how the user interacts with the app';
expect(forbiddenTick(productInteraction)).toBe(false);
expect(forbiddenTick('NameIncluding first or last name')).toBe(false);
expect(forbiddenTick('Developer Program Policies')).toBe(true);
expect(forbiddenTick('US export laws')).toBe(true);
expect(forbiddenTick('Kabul ediyorum')).toBe(true);
expect(forbiddenControl('Save')).toBe(true);
expect(forbiddenControl('Publish')).toBe(true);
});
// ---- sign-in detection is precise: Google's account widget / cookie rotation frames never make a page a sign-in page ----
// Real run (Play Console, copied Chrome cookies): the chooser carried an accounts.google.com frame and the old
// detector (any frame on a sign-in host) waited "for the sign-in" on a page that was signed in.
/** The Play Console routes of a signed-in owner whose pages carry the Google account widget frames. */
const playOgb = (u: URL): string | null => {
if (u.hostname === 'accounts.google.com' || u.hostname === 'ogs.google.com') return u.pathname.startsWith('/v3/signin') ? 'google-signin.html' : 'google-account-widget.html';
if (u.hostname !== 'play.google.com') return null;
if (u.pathname.endsWith('/app-list')) return 'play-home-ogb.html';
if (u.pathname === '/play-create-app.html' || u.pathname.endsWith('/create-new-app')) return 'play-create-app.html';
return u.pathname === '/console' || u.pathname === '/console/u/0/developers' ? 'play-chooser-ogb.html' : null;
};
const frameOn = (page: any, host: string): boolean => page.frames().some((f: any) => { try { return new URL(f.url()).hostname === host; } catch { return false; } });
/** Test helper: waits until `pred` holds (at most 10 s). */
async function until(pred: () => boolean): Promise<void> {
const end = Date.now() + 10_000;
while (!pred()) { if (Date.now() > end) throw new Error('until: timed out'); await Bun.sleep(50); }
}
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 () => {
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'));
expect(await signInReason(b.page)).toBeNull();
expect(await signInPage(b.page)).toBe(false);
await b.context.close();
}, BROWSER_TEST_MS);
test('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'));
expect(await signInReason(b.page)).toBeNull();
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 () => {
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' });
await b.context.close();
// a sign-in form in the main frame on a host storebound does not know: its texts tell
const page = await open('signin-passkey.html');
expect((await signInReason(page))?.rule).toMatch(/^a sign-in form in the page \("(Email or Phone Number|Sign in with Passkey)"\)$/);
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 () => {
const b = await consoleBrowser(playOgb);
const log = logLines(), said = log.lines;
const done = scope.run(fillSession(b, { form: 'play-app-record', config, ...fast(log) }));
await log.wait('the Create app form filled', l => l.some(m => /^Create app: filled \d+/.test(m)));
expect(said).toContain('creating the app in developer account "Example Studio" (7000000001, the only developer account of this login)');
expect(said.filter(m => /sign-in detected|sign in in the browser window|still waiting for the sign-in/.test(m))).toEqual([]);
expect(await clicks(b.page)).toEqual([]);
await b.context.close();
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 () => {
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' };
const log = logLines(), said = log.lines;
const done = scope.run(fillSession(b, { form: 'play-app-record', config, ...fast(log), heartbeatMs: 300, stepTimeoutMs: 1000, debugDir: dir, debugEveryStep: true }));
await log.wait('the watchdog of the sign-in wait', l => l.some(m => m.startsWith('the sign-in has not finished after')));
await log.wait('four heartbeats', l => l.filter(m => m.startsWith('still waiting for the sign-in')).length >= 4);
expect(said).toContain('sign-in detected because: the top-level URL is a sign-in page at https://accounts.google.com/v3/signin/identifier');
expect(said.filter(m => m.startsWith('sign-in detected because'))).toHaveLength(1);
expect(said).toContain('your copied Chrome session for Play Console did not carry over (expired or tied to your own Chrome); sign in once in this window, or run without --use-chrome-session');
expect(said.find(m => m.startsWith('still waiting for the sign-in'))).toMatch(/\(https:\/\/accounts\.google\.com\/v3\/signin\/identifier: a sign-in page \(the top-level URL is a sign-in page\)\)$/);
expect(said.find(m => m.startsWith('the sign-in has not finished after'))).toContain(`Debug saved to ${dir}`);
const names = readdirSync(dir).sort();
expect(names.filter(n => /-sign-in-wait$/.test(n))).toHaveLength(3);
expect(names.filter(n => /-sign-in-not-finished$/.test(n))).toHaveLength(1);
expect(await b.page.locator('#identifier').inputValue()).toBe(''); // nothing typed
expect(await clicks(b.page)).toEqual([]); // nothing clicked
await b.context.close();
await done;
}, SLOW_TEST_MS);
});