console fill play-app-record: read the real account chooser, match names loosely
With the copied Chrome profile, `console fill play-app-record` stopped with "no developer account named "<name>" (play.developerAccount) on the chooser" although the name was there: the real "Choose developer account" rows are not plain /developers/<id> text links (nested spans, icon-font glyphs, role=button rows with the id in a data attribute, an avatar row whose id only its click reveals). - readChooser: every visible clickable row in the page content (header and navigation left out); the name is the first visible line (icon fonts, images and aria-hidden parts skipped); the id comes from /developers/<id> in any attribute, else a data-*id attribute, else '' (the row is then opened through the account-switch gate and the id is read from the URL). "Create a new developer account" is not an account. - matchAccount: exact spelling, then spaces and case aside (Turkish I/ı folded), then a unique prefix. A case pair (ACME / Acme) needs the exact spelling; otherwise it stops and lists both with their ids. - One developer account: picked (a different play.developerAccount is said). Not found: every visible account is listed with its id. - The resolved developerId is remembered as before. Fixtures play-chooser-real.html (the owner's chooser shape, placeholder names) and play-chooser-one.html; tests for exact, case pair, prefix, single account and not found.
This commit is contained in:
parent
81313b6fa4
commit
8b669f5c86
5 changed files with 244 additions and 32 deletions
|
|
@ -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/<id> 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/<id> 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<PlayAccount[]> {
|
||||
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<Element, string>();
|
||||
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<PlayAccount[]> {
|
||||
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<A extends { name: string }>(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<string | null> {
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue