diff --git a/cli/console/accounts.ts b/cli/console/accounts.ts index 35f8e01..507571a 100644 --- a/cli/console/accounts.ts +++ b/cli/console/accounts.ts @@ -72,28 +72,120 @@ function write(c: AccountsCache, file: string): void { // ---- Play: developer accounts ---- +/** One entry of the account chooser: its visible name, its developer id when the page carries it (link target or a + * data attribute; '' when only a click reveals it) and `key`, the data-storebound-account mark to click it by. */ +export interface PlayAccount { id: string; name: string; key?: string } + +/** Chooser entries that are not accounts (Create a new developer account ...). */ +const NOT_ACCOUNT = /^(create|new developer account|yeni geliştirici hesabı)|developer account oluştur|hesabı oluştur/iu; + /** - * The developer accounts the login can see: every link to /developers/ on the account chooser - * (/console/u/0/developers), as { id, name }. A login with one account lands in that account: its id only. + * Reads the account chooser robustly (a real chooser's rows may be links, role=button / option / row elements or plain + * list items with nested spans, an avatar image or an icon font): every visible clickable row in the page's content + * (header and navigation left out). Name: the row's first visible line of text, icon fonts, images and aria-hidden + * parts skipped. Id: /developers/ in a link target or any attribute of the row (or its link), else a data + * attribute holding the number; '' when there is none (then the row is clicked to open it). Rows are marked with + * data-storebound-account so they can be clicked. Read-only otherwise. */ -export async function playAccounts(page: Page, playBase: string, ms: number, stop?: Stop): Promise<{ id: string; name: string }[]> { - await page.goto(`${playBase.replace(/\/$/, '')}/u/0/developers`, { waitUntil: 'domcontentloaded' }).catch(() => { /* read what is there */ }); - const found = await poll(ms, async () => { - const links = vis(page.locator('a[href*="/developers/"]')); - const n: number = await links.count(); - const out: { id: string; name: string }[] = []; - for (let i = 0; i < n; i++) { - const href: string | null = await links.nth(i).getAttribute('href'); - const id = href ? playDeveloperId(new URL(href, page.url()).href) : null; - if (id && !out.some(o => o.id === id)) out.push({ id, name: await accName(links.nth(i)) }); +export async function readChooser(page: Page): Promise { + const rows: { name: string; id: string; key: string }[] = await page.evaluate(() => { + const CLICK = 'a[href], button, [role="link"], [role="button"], [role="option"], [role="row"], [role="menuitem"], [role="radio"]'; + const ROWS = `${CLICK}, [role="listitem"], li, [data-developer-id], [data-account-id], [data-id]`; + const SKIP = '[aria-hidden="true"], img, svg, .material-icons, .material-icons-outlined, .material-symbols-outlined, .google-material-icons, mat-icon'; + const shown = (el: Element) => !!(el as HTMLElement).getClientRects().length && getComputedStyle(el).visibility !== 'hidden'; + const idIn = (el: Element): string => { + const scan = [el, ...el.querySelectorAll('*'), el.parentElement, el.parentElement?.parentElement].filter(Boolean) as Element[]; + for (const e of scan) for (const a of e.attributes) { const m = a.value.match(/\/developers\/(\d{1,25})(?:[/?#]|$)/); if (m) return m[1]!; } + for (const e of scan) for (const a of e.attributes) if (/^data-/.test(a.name) && /id$/i.test(a.name) && /^\d{1,25}$/.test(a.value.trim())) return a.value.trim(); + return ''; + }; + const firstLine = (el: Element): string => { + // text pieces grouped by their nearest block-level box inside the row; the first group is the name + const groups = new Map(); + const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + for (let n = walk.nextNode(); n; n = walk.nextNode()) { + const p = n.parentElement; + if (!p || p.closest(SKIP) || !shown(p)) continue; // spaces between inline parts count ("Example" "AI") + let box: Element = p; + for (let up = 0; up < 50 && box !== el && getComputedStyle(box).display.startsWith('inline'); up++) box = box.parentElement ?? el; + groups.set(box, `${groups.get(box) ?? ''}${n.textContent}`); + } + for (const t of groups.values()) { const s = t.trim().replace(/\s+/g, ' '); if (s) return s; } + return ''; + }; + const out: { name: string; id: string; key: string }[] = []; + let k = 0; + for (const el of document.querySelectorAll(ROWS)) { + if (!shown(el) || el.closest('header, nav, footer, [role="banner"], [role="navigation"], [role="dialog"]')) continue; + if (el.querySelector(CLICK)) continue; // the innermost clickable element is the row + const id = idIn(el); + // a row without an id counts only inside a list (not a stray button of the page) + if (!id && !el.closest('[role="list"], [role="listbox"], [role="grid"], [role="table"], [role="radiogroup"], ul, ol, table')) continue; + const name = firstLine(el); + if (!name) continue; + if (out.some(o => (id && o.id === id) || (!id && !o.id && o.name === name))) continue; + const key = String(k++); + el.setAttribute('data-storebound-account', key); + out.push({ name, id, key }); } - return out.length ? out : null; - }, stop); + return out; + }); + return rows.filter(r => !NOT_ACCOUNT.test(r.name)); +} + +/** + * The developer accounts the login can see, read from the account chooser (/console/u/0/developers). A login with one + * account lands in that account: its id only. + */ +export async function playAccounts(page: Page, playBase: string, ms: number, stop?: Stop): Promise { + await page.goto(`${playBase.replace(/\/$/, '')}/u/0/developers`, { waitUntil: 'domcontentloaded' }).catch(() => { /* read what is there */ }); + const found = await poll(ms, async () => { const r = await readChooser(page); return r.length ? r : null; }, stop); if (found) return found; const here = playDeveloperId(page.url()); return here ? [{ id: here, name: '' }] : []; } +const norm = (s: string): string => s.trim().replace(/\s+/g, ' '); +/** Case folded for comparing names, English and Turkish alike (I / ı / İ / i all fold to i). */ +const fold = (s: string): string => s.normalize('NFD').replace(/\u0307/g, '').toLowerCase().replace(/ı/g, 'i'); + +/** + * The chooser entry named `want`: the exact name (case-sensitive) first, then the one case-insensitive exact match, + * then the one case-insensitive prefix match. "ACME" and "Acme" differ only in case: the case-sensitive match + * picks between them; a name that matches both only case-insensitively is ambiguous. Pure. + */ +export function matchAccount(want: string, all: A[]): { hit?: A; ambiguous?: A[] } { + const w = norm(want), wl = fold(w); + const named = all.filter(a => a.name); + for (const pick of [ + (a: A) => norm(a.name) === w, + (a: A) => fold(norm(a.name)) === wl, + (a: A) => fold(norm(a.name)).startsWith(wl), + ]) { + const hits = named.filter(pick); + if (hits.length === 1) return { hit: hits[0]! }; + if (hits.length > 1) return { ambiguous: hits }; + } + return {}; +} + +/** "Name (id)" for the log, every visible entry (an entry whose id only a click shows: "Name (id not shown)"). */ +export const accountList = (all: PlayAccount[]): string => all.map(a => (a.name ? `${a.name} (${a.id || 'id not shown'})` : a.id)).join(', '); + +/** + * The developer id of a chooser entry: its own when the page shows it, else it is opened (the account-switch gate: + * its exact name was just read from the chooser) and the id is read from the URL it lands on. null when that fails. + */ +async function accountId(page: Page, a: PlayAccount, listed: string[], ms: number, stop?: Stop): Promise { + if (a.id) return a.id; + if (!a.key) return null; + const row = page.locator(`[data-storebound-account="${a.key}"]`); + const role = await realRole(row).catch(() => ''); + if (!switchAllowed(a.name, ['link', 'menuitem', 'menuitemradio', 'option', 'radio', 'row', 'button'].includes(role) ? role : 'row', listed)) return null; + await row.click(); + return poll(ms, async () => playDeveloperId(page.url()), stop); +} + // ---- App Store Connect: teams ---- /** The account menu button at the top right (it lists the teams). */ @@ -195,9 +287,6 @@ export async function openAcross(page: Page, o: AcrossOpts): Promise<{ nav: NavR } } -/** Play account names compare by whole name, spaces and case aside. */ -const sameName = (a: string, b: string) => a.trim().replace(/\s+/g, ' ').toLocaleLowerCase('tr') === b.trim().replace(/\s+/g, ' ').toLocaleLowerCase('tr'); - /** * play-app-record: the developer account to create the app in. Never guessed among several: play.developerId (or the * workspace's STOREBOUND_PLAY_DEVELOPER_ID, or the remembered one for this package), else the only account the login @@ -215,15 +304,27 @@ async function pickPlayAccount(page: Page, config: ShipConfig, o: AcrossOpts, ca const known = config.play?.developerId ?? (pkg ? cache.play[pkg]?.developerId : undefined); if (known) { say(`creating the app in developer account ${known}${config.play?.developerId ? ' (play.developerId)' : ` (remembered in ${file})`}`); return go(known, pkg ? cache.play[pkg]?.account : undefined); } const all = await playAccounts(page, bases.play, quick, stop); - const list = all.map(a => a.name ? `${a.name} (${a.id})` : a.id).join(', '); - const stopHere = (why: string) => ({ nav: { ok: false, steps: [], failed: 'the developer account', why, ...(all.length ? { candidates: all.map(a => a.name || a.id) } : {}) } as NavResult, plan: planFor() }); + const list = accountList(all); + const stopHere = (why: string) => ({ nav: { ok: false, steps: [], failed: 'the developer account', why, ...(all.length ? { candidates: all.map(a => (a.name ? `${a.name} (${a.id || 'id not shown'})` : a.id)) } : {}) } as NavResult, plan: planFor() }); + /** Opens the chosen entry's account (its id read from the page, or from where its click lands). */ + const use = async (a: PlayAccount, why: string) => { + const id = await accountId(page, a, all.map(x => x.name).filter(Boolean), quick, stop); + if (!id) return stopHere(`could not read the developer id of "${a.name}" on the chooser (${list}): set play.developerId`); + say(`creating the app in developer account "${a.name || id}" (${id}, ${why})`); + return go(id, a.name || undefined); + }; + // one account: nothing to choose (a different play.developerAccount is said, not obeyed: there is no other) + if (all.length === 1) { + const only = all[0]!; + if (want && only.name && !matchAccount(want, all).hit) say(`play.developerAccount is "${want}", but this Google login has only the developer account "${only.name}": using it`); + return use(only, 'the only developer account of this login'); + } if (want) { - const hits = all.filter(a => a.name && sameName(a.name, want)); - if (hits.length === 1) { say(`creating the app in developer account "${hits[0]!.name}" (play.developerAccount)`); return go(hits[0]!.id, hits[0]!.name); } - return stopHere(hits.length ? `more than one developer account is named "${want}" (${list}): set play.developerId` + const m = matchAccount(want, all); + if (m.hit) return use(m.hit, 'play.developerAccount'); + return stopHere(m.ambiguous ? `"${want}" (play.developerAccount) matches more than one developer account: ${accountList(m.ambiguous)}. Write the name exactly as the chooser shows it (case counts when two differ only in case), or set play.developerId` : `no developer account named "${want}" (play.developerAccount) on the chooser${all.length ? `: ${list}` : ''}. Set play.developerAccount to one of them, or play.developerId`); } - if (all.length === 1) { say(`this Google login has one developer account: ${all[0]!.name || all[0]!.id}`); return go(all[0]!.id, all[0]!.name || undefined); } if (all.length > 1) return stopHere(`this Google login sees ${all.length} developer accounts (${list}): set play.developerAccount (the name) or play.developerId to pick the one for the new app`); return stopHere('could not read the developer accounts on the "Choose a developer account" page: set play.developerId (or play.developerAccount)'); } @@ -248,7 +349,7 @@ async function across(page: Page, o: AcrossOpts): Promise<{ nav: NavResult; plan if (r.ok || r.signIn || !notHere(r, plan)) return { nav: r, plan }; say(`${pkg} is not in developer account ${known}: looking in every developer account this Google login can see`); } - const all = (await playAccounts(page, bases.play, quick, stop)).filter(a => a.id !== known); + const all = (await playAccounts(page, bases.play, quick, stop)).filter(a => a.id && a.id !== known); if (!pkg || all.length <= 1) { const plan = planFor(all[0]?.id); const r = await nav(plan); diff --git a/skills/console-autofill/SKILL.md b/skills/console-autofill/SKILL.md index 1040787..2793f43 100644 --- a/skills/console-autofill/SKILL.md +++ b/skills/console-autofill/SKILL.md @@ -97,9 +97,11 @@ It is not available over MCP. parameter that picks a team, so storebound does not use one. - Play Create app (`play-app-record`) is a first create: no account has the package yet, so storebound does not search, it picks: `play.developerId` (or the workspace account's id, or the remembered one), else the only - account the login sees, else the chooser entry named exactly `play.developerAccount` (case aside, e.g. an - organisation account). Several accounts and none of these set, or a name that matches none or two: it stops - and lists the accounts. Ask the owner which one; never guess. The choice is remembered under + account the login sees (picked even when `play.developerAccount` names another; the log says so), else the + chooser entry named `play.developerAccount`: the exact spelling first, then spaces and case aside, then a unique + prefix ("ACME" and "Acme" are two accounts: only the exact spelling picks one). Several accounts and none of + these set, or a name that matches none or two: it stops and lists every account with its developer id. Ask the + owner which one; never guess. The choice is remembered under `play.packageName`. - Switching only changes what the console shows. Each switch click must be an account name storebound just read from the chooser or the menu, and never Sign Out or a deny-listed name (a studio called "Next Games" is left diff --git a/tests/console-fill.test.ts b/tests/console-fill.test.ts index 99309dc..99ca5d0 100644 --- a/tests/console-fill.test.ts +++ b/tests/console-fill.test.ts @@ -10,7 +10,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { openAcross, readAccounts, rememberApple, rememberPlay, withAccountIds } from '../cli/console/accounts'; +import { matchAccount, openAcross, readAccounts, rememberApple, rememberPlay, withAccountIds } from '../cli/console/accounts'; import { createAppHere, fillPage, fillSession, navClick, navigate, resumeAt, run, safeClick, signInPage } from '../cli/console/fill'; import type { ConsoleBrowser } from '../cli/console/session'; import { parseArgs } from '../cli/lib/args'; @@ -193,8 +193,9 @@ beforeAll(() => { 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(/^\/playn\/u\/0\/developers(?:\/(\d+)\/app-list)?$/); - const name = path.endsWith('/_common.js') ? '_common.js' : devNew ? (devNew[1] ? 'play-home.html' : 'play-chooser.html') + const devNew = path.match(/^\/play([nrs])\/u\/0\/developers(?:\/(\d+)\/app-list)?$/); + // 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)[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); @@ -605,6 +606,19 @@ describe.skipIf(!browser)('sign-in and watching (headless, modeled pages on the // ---- 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: {} }); @@ -852,7 +866,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => { 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', 'Studio Two', 'Studio Three', 'Example Games Ltd', 'Studio Five'] }); + 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({}); @@ -860,7 +874,7 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => { ({ 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" (play.developerAccount)'); + expect(said).toContain('creating the app in developer account "Example Games Ltd" (4444, play.developerAccount)'); expect(new URL(page.url()).pathname).toBe('/play-create-app.html'); expect(await navClicks(page)).toEqual(['Create app']); expect(readAccounts(file).play['com.example.game']).toMatchObject({ developerId: '4444', account: 'Example Games Ltd' }); @@ -885,6 +899,57 @@ describe.skipIf(!browser)('play-app-record (headless, modeled pages)', () => { await page.close(); }, SLOW_TEST_MS); + test('the chooser as it really looks (nested spans, icon fonts, role=button rows with data ids, an avatar row without an id): exact, case pair, prefix, not found', async () => { + 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).toBe('/play-create-app.html'); + 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' ? 'play-create-app.html' : null)); const log = logLines(), said = log.lines; diff --git a/tests/fixtures/console/play-chooser-one.html b/tests/fixtures/console/play-chooser-one.html new file mode 100644 index 0000000..4211f7a --- /dev/null +++ b/tests/fixtures/console/play-chooser-one.html @@ -0,0 +1,14 @@ + + +Choose developer account - Play Console + +
+

Choose developer account

+
+
+ + diff --git a/tests/fixtures/console/play-chooser-real.html b/tests/fixtures/console/play-chooser-real.html new file mode 100644 index 0000000..cbf64ef --- /dev/null +++ b/tests/fixtures/console/play-chooser-real.html @@ -0,0 +1,30 @@ + + +Choose developer account - Play Console + + +
+
+

Choose developer account

+
+ + +
account_circleNorthwind
+
account_circleExample Org Organization
+
Example AIExample AI
+ +
+
+ +