- `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.
1071 lines
73 KiB
TypeScript
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);
|
|
});
|