From 2c14e5318a1ec8d301f6328999ac5e094c3df4ad Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 08:29:26 +0800 Subject: [PATCH] apple: no app info on App Store Connect is a clear error (it crashed apple listing / appinfo with "undefined is not an object (evaluating 'info.id')"); asset uploads use basename (a Windows path sent the whole C:\... path as fileName), reviews send --only and previews names split both separators; tests/matrix-bugs.test.ts --- cli/apple/asc.ts | 15 ++++++++++++--- cli/apple/listing.ts | 6 ++++-- cli/apple/previews.ts | 2 +- cli/reviews.ts | 10 +++++----- tests/matrix-bugs.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 tests/matrix-bugs.test.ts diff --git a/cli/apple/asc.ts b/cli/apple/asc.ts index 0af1831..badb721 100644 --- a/cli/apple/asc.ts +++ b/cli/apple/asc.ts @@ -2,6 +2,7 @@ // API; role Admin or App Manager), paging, retries, the asset upload protocol, and the app / version lookups. import { createHash, createPrivateKey, sign } from 'node:crypto'; import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; import { ascKey } from '../lib/credentials'; import { errorBody } from '../lib/http'; import { noteAsc } from '../lib/quota'; @@ -55,6 +56,10 @@ export function ascJwt(root: string): { jwt: () => string; keyId: string } { return { jwt, keyId: k.keyId }; } +/** App Store Connect answered no appInfos for the app (an app record always has one): a clear error, not a TypeError. */ +export const noAppInfo = (appId: string): string => + `App Store Connect returned no app info for app ${appId}: the app record is not readable with this key (check apple.appId and the key's app access in Users and Access), or the record was just created and is not listed yet (run it again in a minute)`; + export async function ascClient(root: string, bundleId: string, appIdHint?: string) { const k = ascJwt(root); const jwt = k.jwt; @@ -141,15 +146,19 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri } async function editableAppInfo(): Promise { - const infos = await all(`/v1/apps/${await app()}/appInfos`); - return infos.find(i => !['READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_INFO'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0]; + const id = await app(); + const infos = await all(`/v1/apps/${id}/appInfos`); + const info = infos.find(i => !['READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_INFO'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0]; + if (!info) throw new Error(noAppInfo(id)); + return info; } /** Reserve -> PUT the parts -> commit with the MD5 (screenshots, IAP review screenshots, app previews). `attrs` go into * the reservation (app previews: mimeType, previewFrameTimeCode). */ async function uploadAsset(type: string, rel: Record, file: string, attrs: Record = {}): Promise { const buf = readFileSync(file); - const name = file.split('/').pop()!; + // basename, not split('/'): a Windows path has backslashes, and the store would get the whole path as the file name + const name = basename(file); const res = (await asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: name, fileSize: buf.length, ...attrs }, relationships: rel } })).data; for (const op of res.attributes.uploadOperations as J[]) { const headers: Record = {}; diff --git a/cli/apple/listing.ts b/cli/apple/listing.ts index d02bf72..64470a3 100644 --- a/cli/apple/listing.ts +++ b/cli/apple/listing.ts @@ -22,7 +22,8 @@ import { appleName } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; -import { ascClient, EDITABLE, type J } from './asc'; +import { ascClient, EDITABLE, type J, noAppInfo } from './asc'; +import { stdinIsTTY } from '../lib/tty'; export function readAppleCopy(ctx: Ctx) { const { config, resolve } = ctx.loaded; @@ -48,6 +49,7 @@ async function pull(ctx: Ctx): Promise { const info = ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(live) ? infos.find((i: J) => ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0] : await c.editableAppInfo(); + if (!info) throw new Error(noAppInfo(appId)); const remote: Record = {}; for (const l of await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)) remote[l.attributes.locale] = { name: l.attributes.name ?? undefined, subtitle: l.attributes.subtitle ?? undefined }; for (const l of await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=200`)) { @@ -191,7 +193,7 @@ export async function run(ctx: Ctx): Promise { if (refused.length) { if (dry) { process.exitCode = 1; warn('apple', `dry run: would refuse: ${refused.join('; ')}`); return; } throw new Error(refused.join('; ')); } if (dry) { done('apple', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; } if (!p.changes) { done('apple', 'listing already up to date'); return; } - if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; } + if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; } const vp = p.versionPlan; let versionId = vp.id; diff --git a/cli/apple/previews.ts b/cli/apple/previews.ts index a2b854a..182f77c 100644 --- a/cli/apple/previews.ts +++ b/cli/apple/previews.ts @@ -73,7 +73,7 @@ export async function run(ctx: Ctx): Promise { try { c = await ascClient(root, bundleId, config.apple?.appId); } catch (e) { if (!dry) throw e; - for (const [loc, per] of local) for (const [, type, fs] of per) log('apple', `${loc} ${type}: would sync ${fs.length} (${fs.map(f => f.split('/').pop()).join(', ')})`); + for (const [loc, per] of local) for (const [, type, fs] of per) log('apple', `${loc} ${type}: would sync ${fs.length} (${fs.map(f => f.split(/[\\/]/).pop()).join(', ')})`); return done('apple', `previews planned (dry run, no App Store Connect comparison: ${(e as Error).message.split('\n')[0]})`); } const version = await c.editableVersion(); diff --git a/cli/reviews.ts b/cli/reviews.ts index ec2e917..80a75c0 100644 --- a/cli/reviews.ts +++ b/cli/reviews.ts @@ -126,8 +126,8 @@ export async function run(ctx: Ctx): Promise { const drafts = only === undefined ? all : all.filter(d => d.file.split(/[\\/]/).pop() === only); if (only !== undefined && !drafts.length) throw new Error(`no draft ${only} in ${dir}`); const plan = sendPlan(drafts); - for (const s of plan.skip) log(TAG, `skip ${s.file.split('/').pop()}: ${s.why}`); - for (const d of plan.send) for (const i of checkReply(d.reply, d.store)) warn(TAG, `${d.file.split('/').pop()}: ${i.msg}`); + for (const s of plan.skip) log(TAG, `skip ${s.file.split(/[\\/]/).pop()}: ${s.why}`); + for (const d of plan.send) for (const i of checkReply(d.reply, d.store)) warn(TAG, `${d.file.split(/[\\/]/).pop()}: ${i.msg}`); if (!plan.send.length) { done(TAG, 'nothing to send'); return; } console.log(textTable(['store', 'review', 'chars', 'reply'], plan.send.map(d => [d.store, d.reviewId, [...d.reply].length, d.reply.replace(/\s+/g, ' ').slice(0, 70)]))); if (a.has('dry')) { done(TAG, `${plan.send.length} reply(ies) would be sent (dry run)`); return; } @@ -144,13 +144,13 @@ export async function run(ctx: Ctx): Promise { if (d.store === 'apple') { try { await c!.asc(req.method, req.url, req.body); } catch (e) { if (!isConflict(e)) throw e; - warn(TAG, `${d.file.split('/').pop()}: the review already has a response on the App Store (an earlier run sent it); noted as sent, not sent again`); + warn(TAG, `${d.file.split(/[\\/]/).pop()}: the review already has a response on the App Store (an earlier run sent it); noted as sent, not sent again`); } } else await play!.call(req.method, req.url, req.body); writeFileSync(d.file, markSent(texts.get(d.file)!, new Date().toISOString())); sent++; - log(TAG, `sent ${d.file.split('/').pop()}`); - } catch (e) { warn(TAG, `${d.file.split('/').pop()}: ${(e as Error).message.slice(0, 300)}`); } + log(TAG, `sent ${d.file.split(/[\\/]/).pop()}`); + } catch (e) { warn(TAG, `${d.file.split(/[\\/]/).pop()}: ${(e as Error).message.slice(0, 300)}`); } } done(TAG, `${sent}/${plan.send.length} sent (Apple shows replies within about 24 hours)`); return; diff --git a/tests/matrix-bugs.test.ts b/tests/matrix-bugs.test.ts new file mode 100644 index 0000000..2265885 --- /dev/null +++ b/tests/matrix-bugs.test.ts @@ -0,0 +1,38 @@ +// Bugs the release acceptance matrix (tools/acceptance.ts) found, each pinned: a missing app info on App Store Connect +// is a clear error (it was "undefined is not an object (evaluating 'info.id')"), and asset uploads and review draft +// names take the base name of Windows paths (split('/') left the whole C:\\... path as the file name). +import { afterEach, describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { run as appleAppinfo } from '../cli/apple/appinfo'; +import { run as appleListing } from '../cli/apple/listing'; +import { CRASH } from '../tools/acceptance'; +import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock'; + +const ROOT = join(import.meta.dir, '..'); +afterEach(restore); + +describe('bugs the matrix found', () => { + test('no app info on App Store Connect: a clear error, not "undefined is not an object"', async () => { + ascEnv(); + const cfg = { ...BASE, apple: { ...BASE.apple!, info: { free: true } } }; + for (const [name, runIt, argv] of [['appinfo', appleAppinfo, ['--dry']], ['listing', appleListing, ['--version', '1.0.0', '--dry']]] as const) { + const p = project(cfg, { 'store/appstore.md': readFileSync(join(ROOT, 'templates/appstore.md'), 'utf8') }); + mockFetch([ + { match: /^\/v1\/apps\/6400000101\/appInfos/, answer: { data: [] } }, + { match: /^\/v1\/apps\/6400000101\/appStoreVersions/, answer: { data: [{ type: 'appStoreVersions', id: 'v1', attributes: { versionString: '1.0.0', platform: 'IOS', appVersionState: 'PREPARE_FOR_SUBMISSION' } }] } }, + { match: /./, answer: { data: [] } }, + ]); + let msg = ''; + try { await capture(() => runIt(p.ctx([...argv]))); } catch (e) { msg = (e as Error).message; } + expect(msg, name).toMatch(/App Store Connect returned no app info for app 6400000101/); + expect(msg, name).not.toMatch(CRASH); + } + }); + + test('asset uploads and review draft names take the base name of Windows paths too', () => { + const asc = readFileSync(join(ROOT, 'cli/apple/asc.ts'), 'utf8'); + expect(asc).toMatch(/const name = basename\(file\);/); + for (const f of ['cli/reviews.ts', 'cli/apple/previews.ts']) expect(readFileSync(join(ROOT, f), 'utf8'), f).not.toMatch(/\.split\('\/'\)\.pop\(\)/); + }); +});