play build: find the Android SDK like Android Studio (ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir, default folder), pass it in the env, stop before Gradle when missing; doctor shows it. apple upload: size, per-part lines, 20 s heartbeat (api, altool, processing wait), per-part and per-call timeouts, --timeout exits 7 with the resume hint. Pitfall 25
This commit is contained in:
parent
366362d62b
commit
fc524cd2d4
15 changed files with 460 additions and 34 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -2,6 +2,20 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Fix (`play build`): the Android SDK is found the way Android Studio finds it, so a non-interactive shell (no
|
||||
`ANDROID_HOME`) with a gitignored `local.properties` no longer ends in Gradle's "SDK location not found". Order:
|
||||
`ANDROID_HOME`, `ANDROID_SDK_ROOT`, `sdk.dir` in the Gradle project's `local.properties`, then
|
||||
`~/Library/Android/sdk` (macOS) / `~/Android/Sdk` (Linux) / `%LOCALAPPDATA%\Android\Sdk` (Windows); only a folder
|
||||
with `platforms/` or `platform-tools/` counts. Gradle gets it through `ANDROID_HOME` and `ANDROID_SDK_ROOT`;
|
||||
`local.properties` is never written. None found: the build stops before Gradle and lists every place checked. The
|
||||
dry run and `storebound doctor` (also `--json`: `androidSdk`) show the SDK path and where it came from. Pitfall 25.
|
||||
- Fix (`apple upload`): never silent. The IPA size at the start, a line per part (every 10th from 20 parts), a
|
||||
heartbeat every 20 s while a part is in flight ("still uploading, 4m30s, 312/890 MB (35%), part 4/12"), the same
|
||||
heartbeat while `--via altool` runs (now in the background, stopped at the deadline) and while the build is
|
||||
processed (`apple testflight wait` too). Each part has a 15 min limit and is retried; `--timeout` now also covers
|
||||
the parts and stops with exit 7 and the hint to run the same command again (it resumes the build upload). Every App
|
||||
Store Connect API call has a 2 min limit (a hung connection is retried when safe, never a silent wait).
|
||||
|
||||
- Fix (`flow run`, every confirming command): a declined confirmation is no longer a success. `confirm()` prints
|
||||
`declined: <reason>` and exits 3 when the owner answers no, when there is no terminal and no `--yes`, or under
|
||||
`serve` without the owner's dialog; `flow run` stops at that step with "step N declined: <reason>" (status
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import { errorBody } from '../lib/http';
|
|||
import { AmbiguousWriteError, retrySafe } from '../lib/upsert';
|
||||
|
||||
const b64 = (b: Buffer | string): string => Buffer.from(b).toString('base64url');
|
||||
/** One App Store Connect API call may take at most this long (then retried when safe, else AmbiguousWriteError). */
|
||||
export const ASC_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
export const md5 = (b: Buffer | Uint8Array): string => createHash('md5').update(b).digest('hex');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -66,6 +69,7 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
|||
try {
|
||||
r = await fetch(path.startsWith('http') ? path : 'https://api.appstoreconnect.apple.com' + path, {
|
||||
method, headers: { Authorization: 'Bearer ' + jwt(), 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(ASC_REQUEST_TIMEOUT_MS), // a hung connection is a network error, never a silent wait
|
||||
});
|
||||
} catch (e) {
|
||||
if (safe && attempt < 5) { await Bun.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
// (once the group has a build), so `testers` asks first; `--remove` takes testers out of the managed groups.
|
||||
// `distribute --submit-review` sends the build to Beta App Review (external groups) after a confirmation; neither
|
||||
// testers nor external distribution nor the review submission are MCP tools. Pure planning: cli/lib/testflight.ts.
|
||||
import { elapsed, heartbeat, type HeartbeatOptions } from '../lib/progress';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
|
|
@ -51,10 +52,12 @@ export interface BuildWait { outcome: WaitOutcome; timedOut: boolean; seen: Buil
|
|||
* Polls build `number` until processing ends (VALID, or FAILED / INVALID) or `timeoutMs` passes; logs each state change.
|
||||
* `once` reads the state a single time. Shared by `testflight wait` and `apple upload` (which waits after the upload).
|
||||
*/
|
||||
export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; timeoutMs: number; once?: boolean; tag?: string }): Promise<BuildWait> {
|
||||
export async function waitForBuild(c: Asc, appId: string, number: string, o: { marketing?: string; timeoutMs: number; once?: boolean; tag?: string } & HeartbeatOptions): Promise<BuildWait> {
|
||||
const started = Date.now();
|
||||
let delay: number | null = null, last = '';
|
||||
for (;;) {
|
||||
// polls are up to 2 minutes apart: a heartbeat in between says the wait is alive
|
||||
const beat = o.once ? null : heartbeat(ms => log(o.tag ?? TAG, `still waiting for build ${number}: ${last || 'first check'}, ${elapsed(ms)} of ${elapsed(o.timeoutMs)}`), o);
|
||||
try { for (;;) {
|
||||
const seen = await seeBuild(c, appId, number, o.marketing);
|
||||
const outcome = waitOutcome(seen);
|
||||
const line = outcome === 'missing' ? 'not listed yet (App Store Connect lists it once processing starts)' : `${seen.processingState}${seen.internal ? `, internal ${seen.internal}` : ''}`;
|
||||
|
|
@ -65,7 +68,7 @@ export async function waitForBuild(c: Asc, appId: string, number: string, o: { m
|
|||
delay = waitDelay(delay);
|
||||
if (Date.now() + delay - started > o.timeoutMs) return result(true);
|
||||
await Bun.sleep(delay);
|
||||
}
|
||||
} } finally { beat?.stop(); }
|
||||
}
|
||||
|
||||
/** The closing line for a finished wait (VALID with the next step, or FAILED / INVALID with what to do). */
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import { type Asc, ascClient, type J } from './asc';
|
|||
import { waitForBuild, waitMessage } from './testflight';
|
||||
import { describeReq } from './jsonapi';
|
||||
import { createOnce } from '../lib/upsert';
|
||||
import { elapsed, heartbeat, type HeartbeatOptions, mb, share, withHeartbeat } from '../lib/progress';
|
||||
import { pump } from '../lib/runner';
|
||||
|
||||
const TAG = 'upload';
|
||||
|
||||
|
|
@ -42,7 +44,84 @@ function slice(path: string, offset: number, length: number): Buffer {
|
|||
try { const b = Buffer.alloc(length); readSync(fd, b, 0, length, offset); return b; } finally { closeSync(fd); }
|
||||
}
|
||||
|
||||
async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<typeof ipaInfo>, deadline: number): Promise<void> {
|
||||
/** One part of a build upload file, as App Store Connect reserves it (uploadOperations). */
|
||||
export interface UploadOp { method: string; url: string; offset: number; length: number; requestHeaders?: { name: string; value: string }[] }
|
||||
|
||||
export interface SendPartsOptions extends HeartbeatOptions {
|
||||
file: string;
|
||||
size: number;
|
||||
ops: UploadOp[];
|
||||
/** Epoch ms: past it the upload stops with a timeout error (exit 7). */
|
||||
deadline: number;
|
||||
/** One part may take at most this long before it is retried (default 15 min). */
|
||||
partTimeoutMs?: number;
|
||||
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
say?: (msg: string) => void;
|
||||
}
|
||||
|
||||
const PART_TIMEOUT_MS = 15 * 60_000;
|
||||
|
||||
/** The timeout error of an upload: what was sent, and how to go on. Exit 7 like every storebound wait. */
|
||||
export function uploadTimeout(sent: number, size: number, partsDone: number, parts: number, ms: number): Error {
|
||||
return Object.assign(new Error(`upload stopped at the timeout after ${elapsed(ms)}: ${share(sent, size)} sent, ${partsDone}/${parts} parts. `
|
||||
+ 'Run the same command again with a longer --timeout (e.g. --timeout 120m): it resumes this build upload instead of making a new one. '
|
||||
+ 'On a slow or unstable connection, --via altool (macOS) is an alternative.'), { exit: WAIT_EXIT.timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUTs the reserved parts of a build upload file. Never silent: the size at the start, a line per part (every 10th
|
||||
* from 20 parts up), and a heartbeat ("still uploading, 4m30s, 312/890 MB (35%), part 4/12") while a part is in flight.
|
||||
* Every part has its own timeout and is retried (5xx, 429, network, part timeout) up to 4 times; past the deadline it
|
||||
* stops with exit 7 and the hint to resume.
|
||||
*/
|
||||
export async function sendParts(o: SendPartsOptions): Promise<void> {
|
||||
const say = o.say ?? ((m: string) => log(TAG, m));
|
||||
const doFetch = o.fetch ?? ((u: string, i: RequestInit) => fetch(u, i));
|
||||
const sleep = o.sleep ?? Bun.sleep;
|
||||
const now = o.now ?? Date.now;
|
||||
const started = now();
|
||||
const n = o.ops.length;
|
||||
let sent = 0, current = 0;
|
||||
say(`uploading ${mb(o.size)} in ${n} part(s)`);
|
||||
const beat = heartbeat(ms => say(`still uploading, ${elapsed(ms)}, ${share(sent, o.size)}${n > 1 ? `, part ${current}/${n}` : ''}`), o);
|
||||
try {
|
||||
for (const [i, op] of o.ops.entries()) {
|
||||
current = i + 1;
|
||||
const headers: Record<string, string> = {};
|
||||
for (const h of op.requestHeaders ?? []) headers[h.name] = h.value;
|
||||
const body = new Uint8Array(slice(o.file, op.offset, op.length));
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const left = o.deadline - now();
|
||||
if (left <= 0) throw uploadTimeout(sent, o.size, i, n, now() - started);
|
||||
const partLimit = o.partTimeoutMs ?? PART_TIMEOUT_MS;
|
||||
const partMs = Math.min(partLimit, left);
|
||||
let r: Response | null = null, problem = '';
|
||||
try {
|
||||
r = await doFetch(op.url, { method: op.method, headers, body, signal: AbortSignal.timeout(partMs) });
|
||||
} catch (e) {
|
||||
const name = (e as Error).name;
|
||||
if (name !== 'TimeoutError' && name !== 'AbortError') problem = (e as Error).message;
|
||||
else if (partMs < partLimit) throw uploadTimeout(sent, o.size, i, n, now() - started); // cut by the deadline
|
||||
else problem = `no answer in ${elapsed(partMs)}`;
|
||||
}
|
||||
if (r) {
|
||||
await r.body?.cancel().catch(() => {});
|
||||
if (r.ok) break;
|
||||
problem = `HTTP ${r.status}`;
|
||||
if (r.status < 500 && r.status !== 429) throw new Error(`part ${i + 1}/${n} -> ${r.status}`);
|
||||
}
|
||||
if (attempt >= 4) throw new Error(`part ${i + 1}/${n} failed 4 times (last: ${problem})`);
|
||||
say(`part ${i + 1}/${n}: ${problem}, retry ${attempt}/3`);
|
||||
await sleep(2000 * attempt);
|
||||
}
|
||||
sent += op.length;
|
||||
if (n < 20 || (i + 1) % 10 === 0 || i === n - 1) say(`${i + 1}/${n} parts, ${share(sent, o.size)}, ${elapsed(now() - started)}`);
|
||||
}
|
||||
} finally { beat.stop(); }
|
||||
}
|
||||
|
||||
async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<typeof ipaInfo>, deadline: number, o: HeartbeatOptions = {}): Promise<void> {
|
||||
const size = statSync(ipa).size;
|
||||
const reqs = buildUploadReqs(appId, info, basename(ipa), size);
|
||||
// one build upload per version + build number: an earlier one that did not fail is resumed, never doubled
|
||||
|
|
@ -59,21 +138,8 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<ty
|
|||
create: async () => (await c.asc('POST', reqs.file(up.id).path, reqs.file(up.id).body)).data })).value;
|
||||
if (fileNow && !(fileNow.attributes?.uploadOperations ?? []).length)
|
||||
throw new Error(`build upload ${up.id} already has ${basename(ipa)} (${fileNow.attributes?.assetDeliveryState?.state ?? 'state unknown'}) and App Store Connect gives no upload parts for it: wait for it (storebound apple testflight wait --build ${info.build}), or raise the build number (storebound apple build)`);
|
||||
const ops = (file.attributes.uploadOperations ?? []) as J[];
|
||||
log(TAG, `uploading ${(size / 1048576).toFixed(1)} MB in ${ops.length} part(s)`);
|
||||
let sent = 0;
|
||||
for (const [i, op] of ops.entries()) {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const h of op.requestHeaders ?? []) headers[h.name] = h.value;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const r = await fetch(op.url, { method: op.method, headers, body: new Uint8Array(slice(ipa, op.offset, op.length)) });
|
||||
if (r.ok) break;
|
||||
if (attempt >= 4 || (r.status < 500 && r.status !== 429)) throw new Error(`part ${i + 1}/${ops.length} -> ${r.status}`);
|
||||
await Bun.sleep(2000 * attempt);
|
||||
}
|
||||
sent += op.length;
|
||||
if ((i + 1) % 10 === 0 || i === ops.length - 1) log(TAG, `${i + 1}/${ops.length} parts (${Math.round((sent / size) * 100)}%)`);
|
||||
}
|
||||
const ops = (file.attributes.uploadOperations ?? []) as UploadOp[];
|
||||
await sendParts({ file: ipa, size, ops, deadline, ...o });
|
||||
const commit = reqs.commit(file.id, md5File(ipa));
|
||||
await c.asc('PATCH', commit.path, commit.body);
|
||||
log(TAG, 'file committed; waiting for App Store Connect to accept the upload');
|
||||
|
|
@ -87,7 +153,7 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<ty
|
|||
log(TAG, 'upload accepted');
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) throw Object.assign(new Error(`upload still ${st.state ?? 'pending'} at the timeout`), { exit: WAIT_EXIT.timeout });
|
||||
if (Date.now() > deadline) throw Object.assign(new Error(`upload still ${st.state ?? 'pending'} at the timeout; the file is sent, App Store Connect is still checking it: storebound apple testflight wait --build ${info.build} --version ${info.version} goes on waiting`), { exit: WAIT_EXIT.timeout });
|
||||
delay = nextPollDelay(delay, { first: 15_000, max: 60_000 });
|
||||
log(TAG, `upload ${st.state ?? 'pending'}, next check in ${Math.round(delay / 1000)} s`);
|
||||
await Bun.sleep(delay);
|
||||
|
|
@ -98,15 +164,29 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<ty
|
|||
const uploadsOf = (c: Asc, appId: string, info: { version: string; build: string }): Promise<J[]> =>
|
||||
c.all(`/v1/apps/${appId}/buildUploads?filter[cfBundleShortVersionString]=${encodeURIComponent(info.version)}&filter[cfBundleVersion]=${encodeURIComponent(info.build)}&filter[platform]=IOS&limit=20`);
|
||||
|
||||
async function altoolUpload(root: string, ipa: string): Promise<void> {
|
||||
/**
|
||||
* `xcrun altool --upload-package`: runs in the background with a heartbeat (it prints nothing until it is done) and is
|
||||
* stopped at the deadline (exit 7 with the hint).
|
||||
*/
|
||||
async function altoolUpload(root: string, ipa: string, deadline: number, o: HeartbeatOptions = {}): Promise<void> {
|
||||
if (process.platform !== 'darwin' || !Bun.which('xcrun')) throw new Error('--via altool needs macOS with Xcode; use --via api elsewhere');
|
||||
const k = ascKeyFile(root);
|
||||
try {
|
||||
const argv = altoolArgs(ipa, k);
|
||||
const size = statSync(ipa).size;
|
||||
log(TAG, `$ ${showCmd(argv, k)}`);
|
||||
const r = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe' });
|
||||
const outText = (r.stdout.toString() + '\n' + r.stderr.toString()).trim();
|
||||
if (r.exitCode !== 0) throw new Error(`altool failed (exit ${r.exitCode}): ${outText.split('\n').filter(l => /error|fail/i.test(l)).slice(0, 8).join('\n') || outText.slice(-800)}`);
|
||||
log(TAG, `uploading ${mb(size)} with altool (it reports only at the end)`);
|
||||
const p = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' });
|
||||
let timedOut = false;
|
||||
const kill = setTimeout(() => { timedOut = true; p.kill(); }, Math.max(0, deadline - Date.now()));
|
||||
const lines: string[] = [];
|
||||
const out = await withHeartbeat(ms => log(TAG, `still uploading with altool, ${elapsed(ms)}, ${mb(size)}`), async () => {
|
||||
await Promise.all([pump(p.stdout, 'stdout', l => lines.push(l)), pump(p.stderr, 'stderr', l => lines.push(l))]);
|
||||
return p.exited;
|
||||
}, o).finally(() => clearTimeout(kill));
|
||||
const outText = lines.join('\n').trim();
|
||||
if (timedOut) throw Object.assign(new Error('altool stopped at the timeout: run the same command again with a longer --timeout (e.g. --timeout 120m), or use --via api (sent in parts, with progress)'), { exit: WAIT_EXIT.timeout });
|
||||
if (out !== 0) throw new Error(`altool failed (exit ${out}): ${outText.split('\n').filter(l => /error|fail/i.test(l)).slice(0, 8).join('\n') || outText.slice(-800)}`);
|
||||
const id = /"?delivery-uuid"?\s*[:=]\s*"?([0-9a-f-]{36})/i.exec(outText)?.[1];
|
||||
log(TAG, `altool: upload done${id ? ` (delivery ${id})` : ''}`);
|
||||
} finally { k.cleanup(); }
|
||||
|
|
@ -165,9 +245,12 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
if (!(await confirm(ctx, `Upload ${info.bundleId} ${info.version} (${info.build}) to App Store Connect? It shows up in TestFlight once processed.`))) return;
|
||||
const deadline = Date.now() + timeout;
|
||||
log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build}), ${mb(size)}, via ${via}, timeout ${a.str('timeout', '60m')}`);
|
||||
if (via === 'api') {
|
||||
try { await apiUpload(c, appId, ipa, info, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); process.exit((e as { exit: number }).exit); } throw e; }
|
||||
} else await altoolUpload(root, ipa);
|
||||
} else {
|
||||
try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); process.exit((e as { exit: number }).exit); } throw e; }
|
||||
}
|
||||
if (!wait) { if (json) printJson({ ...summary, uploaded: true }); done(TAG, `build ${info.build} uploaded; storebound apple status shows when it is VALID`); return; }
|
||||
// the same poll as `storebound apple testflight wait`, within what is left of --timeout
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG });
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { Args } from './lib/args';
|
|||
import { CONFIG_NAMES } from './lib/config';
|
||||
import { log } from './lib/log';
|
||||
import { textTable } from './lib/table';
|
||||
import { detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
|
||||
import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
|
||||
|
||||
export interface DoctorRow {
|
||||
id: ToolStatus['id'] | 'playwright';
|
||||
|
|
@ -23,7 +23,11 @@ export interface DoctorRow {
|
|||
install: string[];
|
||||
url: string | null;
|
||||
}
|
||||
export interface DoctorResult { storebound: string; bun: string; platform: string; project: string | null; tools: DoctorRow[]; missing: number }
|
||||
export interface DoctorResult {
|
||||
storebound: string; bun: string; platform: string; project: string | null; tools: DoctorRow[]; missing: number;
|
||||
/** The Android SDK `play build` hands to Gradle, where it came from, and every place looked at. */
|
||||
androidSdk: AndroidSdkLookup;
|
||||
}
|
||||
|
||||
export function doctor(cwd: string, env: Record<string, string | undefined> = process.env): DoctorResult {
|
||||
const tools: DoctorRow[] = detectTools(env).map(t => ({ id: t.id, name: t.name, found: t.found, version: t.version, path: t.path, source: t.source, usedFor: t.usedFor,
|
||||
|
|
@ -32,7 +36,8 @@ export function doctor(cwd: string, env: Record<string, string | undefined> = pr
|
|||
tools.splice(3, 0, { id: 'playwright', name: 'playwright', found: !!pw, version: null, path: pw, source: pw ? 'system' : null,
|
||||
usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' });
|
||||
const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null;
|
||||
return { storebound: pkg.version, bun: Bun.version, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length };
|
||||
const sdk = androidSdk([join(cwd, 'android'), cwd], env);
|
||||
return { storebound: pkg.version, bun: Bun.version, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk };
|
||||
}
|
||||
|
||||
export async function run(args: Args): Promise<void> {
|
||||
|
|
@ -40,6 +45,8 @@ export async function run(args: Args): Promise<void> {
|
|||
if (args.has('json')) { console.log(JSON.stringify(r, null, 2)); return; }
|
||||
log('doctor', `storebound ${r.storebound} · bun ${r.bun} · ${r.platform}${r.project ? ' · project config found' : ''}`);
|
||||
console.log(textTable(['tool', 'status', 'version', 'used for'], r.tools.map(t => [t.name, t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.usedFor])));
|
||||
log('doctor', r.androidSdk.path ? `Android SDK: ${r.androidSdk.path} (from ${r.androidSdk.source})`
|
||||
: `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`);
|
||||
const missing = r.tools.filter(t => !t.found);
|
||||
if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; }
|
||||
console.log('\nTo install what is missing (pick one per tool; storebound never runs these for you):');
|
||||
|
|
|
|||
47
cli/lib/progress.ts
Normal file
47
cli/lib/progress.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Progress for long blocking steps (uploads, store processing waits, external tools): a heartbeat line every few
|
||||
// seconds so a run is never silent for minutes, and the size / elapsed formats those lines use. The timer is
|
||||
// injectable (Ticker), so tests drive beats by hand instead of sleeping.
|
||||
|
||||
/** Calls fn every ms until the returned stop function is called. */
|
||||
export type Ticker = (fn: () => void, ms: number) => () => void;
|
||||
|
||||
export const realTicker: Ticker = (fn, ms) => {
|
||||
const t = setInterval(fn, ms);
|
||||
(t as { unref?: () => void }).unref?.();
|
||||
return () => clearInterval(t);
|
||||
};
|
||||
|
||||
/** Default heartbeat interval of uploads and waits. */
|
||||
export const HEARTBEAT_MS = 20_000;
|
||||
|
||||
/** 4.2 MB, 312 MB (whole MB from 10 MB up). */
|
||||
export const mb = (bytes: number): string => `${(bytes / 1048576).toFixed(bytes < 10 * 1048576 ? 1 : 0)} MB`;
|
||||
|
||||
/** 45s, 4m30s, 1h02m. */
|
||||
export function elapsed(ms: number): string {
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`;
|
||||
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
/** "312/890 MB (35%)". */
|
||||
export const share = (done: number, total: number): string =>
|
||||
`${(done / 1048576).toFixed(total < 10 * 1048576 ? 1 : 0)}/${mb(total)} (${total ? Math.floor((done / total) * 100) : 100}%)`;
|
||||
|
||||
export interface HeartbeatOptions { everyMs?: number; ticker?: Ticker; now?: () => number }
|
||||
|
||||
/** Runs `say(elapsedMs)` every everyMs while a step runs; stop() ends it. */
|
||||
export function heartbeat(say: (elapsedMs: number) => void, o: HeartbeatOptions = {}): { stop: () => void } {
|
||||
const now = o.now ?? Date.now;
|
||||
const started = now();
|
||||
const stop = (o.ticker ?? realTicker)(() => say(now() - started), o.everyMs ?? HEARTBEAT_MS);
|
||||
return { stop };
|
||||
}
|
||||
|
||||
/** Runs fn with a heartbeat; the heartbeat stops however fn ends. */
|
||||
export async function withHeartbeat<T>(say: (elapsedMs: number) => void, fn: () => Promise<T>, o: HeartbeatOptions = {}): Promise<T> {
|
||||
const h = heartbeat(say, o);
|
||||
try { return await fn(); } finally { h.stop(); }
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
// STOREBOUND_TOOL_PATH folders put in front of PATH for every command (app-managed ffmpeg / ffprobe)
|
||||
// STOREBOUND_CHROME Chrome / Chrome for Testing executable for renders and web captures
|
||||
// Nothing is installed system-wide. Sources and licenses: docs/desktop.md.
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { delimiter, join } from 'node:path';
|
||||
|
||||
export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git';
|
||||
|
|
@ -121,6 +121,88 @@ export function installCommands(id: ToolId | 'playwright', platform: NodeJS.Plat
|
|||
}
|
||||
}
|
||||
|
||||
// ---- Android SDK ----
|
||||
|
||||
/** Where the Android SDK path came from, in the order Android Studio and the Android Gradle plugin look. */
|
||||
export type AndroidSdkSource = 'ANDROID_HOME' | 'ANDROID_SDK_ROOT' | 'local.properties' | 'default';
|
||||
export interface AndroidSdkLookup {
|
||||
/** The SDK folder (has platforms/ or platform-tools/), or null. */
|
||||
path: string | null;
|
||||
source: AndroidSdkSource | null;
|
||||
/** Every place looked at, in order, with what was there ("not set", "does not exist", ...). */
|
||||
checked: string[];
|
||||
}
|
||||
export interface AndroidSdkInputs {
|
||||
env: Record<string, string | undefined>;
|
||||
platform: string;
|
||||
/** The user's home folder ('' when unknown). */
|
||||
home: string;
|
||||
/** Gradle project folders whose local.properties may name sdk.dir (the first one that names it counts). */
|
||||
gradleDirs?: string[];
|
||||
exists: (path: string) => boolean;
|
||||
/** Text of a file, or null when unreadable. */
|
||||
readText: (path: string) => string | null;
|
||||
}
|
||||
|
||||
/** sdk.dir of a local.properties (Java properties: `\:` and `\\` escapes, `=` or `:` separator), or null. */
|
||||
export function sdkDirOf(properties: string): string | null {
|
||||
for (const raw of properties.split(/\r?\n/)) {
|
||||
const m = /^\s*sdk\.dir\s*[=:]\s*(.*?)\s*$/.exec(raw);
|
||||
if (m?.[1]) return m[1].replace(/\\(.)/g, '$1');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the Android SDK the way Android Studio does: ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir in the Gradle project's
|
||||
* local.properties, then the folder Android Studio installs to (macOS ~/Library/Android/sdk, Linux ~/Android/Sdk,
|
||||
* Windows %LOCALAPPDATA%\Android\Sdk). A candidate counts only when it has platforms/ or platform-tools/. Pure: the
|
||||
* file system comes in through `exists` / `readText`. Nothing is written (never local.properties).
|
||||
*/
|
||||
export function resolveAndroidSdk(i: AndroidSdkInputs): AndroidSdkLookup {
|
||||
const checked: string[] = [];
|
||||
const sep = i.platform === 'win32' ? '\\' : '/';
|
||||
const at = (...p: string[]): string => p.join(sep).replace(/[\\/]+/g, sep);
|
||||
const isSdk = (d: string): boolean => i.exists(at(d, 'platforms')) || i.exists(at(d, 'platform-tools'));
|
||||
const tryDir = (source: AndroidSdkSource, label: string, dir: string | undefined | null): AndroidSdkLookup | null => {
|
||||
if (!dir) { checked.push(`${label}: not set`); return null; }
|
||||
if (isSdk(dir)) { checked.push(`${label}: ${dir}`); return { path: dir, source, checked }; }
|
||||
checked.push(`${label}: ${dir} (${i.exists(dir) ? 'no platforms/ or platform-tools/ in it' : 'does not exist'})`);
|
||||
return null;
|
||||
};
|
||||
const fromEnv = tryDir('ANDROID_HOME', 'ANDROID_HOME', i.env.ANDROID_HOME) ?? tryDir('ANDROID_SDK_ROOT', 'ANDROID_SDK_ROOT', i.env.ANDROID_SDK_ROOT);
|
||||
if (fromEnv) return fromEnv;
|
||||
const files = (i.gradleDirs ?? []).map(d => at(d, 'local.properties'));
|
||||
let dir: string | null = null, file: string | null = null;
|
||||
for (const f of files) {
|
||||
const t = i.exists(f) ? i.readText(f) : null;
|
||||
const d = t ? sdkDirOf(t) : null;
|
||||
if (d) { dir = d; file = f; break; }
|
||||
}
|
||||
const fromProps = tryDir('local.properties', `sdk.dir in ${file ?? (files.length ? files.join(' / ') : 'local.properties (no Gradle project)')}`, dir);
|
||||
if (fromProps) return fromProps;
|
||||
const local = i.env.LOCALAPPDATA ?? (i.home ? at(i.home, 'AppData/Local') : null);
|
||||
const def = i.platform === 'darwin' ? (i.home ? at(i.home, 'Library/Android/sdk') : null)
|
||||
: i.platform === 'win32' ? (local ? at(local, 'Android/Sdk') : null)
|
||||
: (i.home ? at(i.home, 'Android/Sdk') : null);
|
||||
return tryDir('default', 'Android Studio default folder', def) ?? { path: null, source: null, checked };
|
||||
}
|
||||
|
||||
/** resolveAndroidSdk on this machine. */
|
||||
export function androidSdk(gradleDirs: string[] = [], env: Record<string, string | undefined> = process.env, platform: string = process.platform): AndroidSdkLookup {
|
||||
return resolveAndroidSdk({ env, platform, home: env.HOME ?? env.USERPROFILE ?? '', gradleDirs, exists: existsSync,
|
||||
readText: p => { try { return readFileSync(p, 'utf8'); } catch { return null; } } });
|
||||
}
|
||||
|
||||
/** The environment that hands a found SDK to Gradle (both names: older plugins read ANDROID_SDK_ROOT). */
|
||||
export const androidSdkEnv = (l: AndroidSdkLookup): Record<string, string> => (l.path ? { ANDROID_HOME: l.path, ANDROID_SDK_ROOT: l.path } : {});
|
||||
|
||||
/** The error when no Android SDK is found, with every place checked and how to fix it. */
|
||||
export function androidSdkMissing(l: AndroidSdkLookup): string {
|
||||
return ['no Android SDK found (Gradle would stop with "SDK location not found"). Checked:', ...l.checked.map(c => ` - ${c}`),
|
||||
'Install it with Android Studio (Settings > Android SDK), or set ANDROID_HOME to the SDK folder (the one with platforms/ and platform-tools/).'].join('\n');
|
||||
}
|
||||
|
||||
/** Where playwright resolves from (the project, then the storebound folder), or null. */
|
||||
export function findPlaywright(bases: string[]): string | null {
|
||||
for (const base of bases) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { pump } from '../lib/runner';
|
|||
import { aabInfo, findBundles, gradleArgs, pickGradle, signingEnv } from '../lib/gradle';
|
||||
import { nextVersionCode } from '../lib/release';
|
||||
import { capacitorProject, webStep } from '../lib/web-assets';
|
||||
import { androidSdk, androidSdkEnv, androidSdkMissing } from '../lib/tools';
|
||||
import { playClient } from './client';
|
||||
import { usedVersionCodes } from './next-version-code';
|
||||
|
||||
|
|
@ -57,20 +58,26 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const hasWrapper = existsSync(join(t.dir, 'gradlew'));
|
||||
const argv = gradleArgs(t, { versionCode: code || undefined, versionName, flavor, hasWrapper });
|
||||
const cwd = t.framework === 'flutter' ? root : t.dir;
|
||||
// the SDK as Android Studio finds it (ANDROID_HOME is often unset in non-interactive shells and local.properties is
|
||||
// gitignored): handed to Gradle through the environment, local.properties is never written
|
||||
const sdk = androidSdk([t.dir]);
|
||||
const plan = { framework: t.framework, dir: relative(root, t.dir) || '.', module: t.module, task: t.framework === 'flutter' ? 'flutter build appbundle' : t.task,
|
||||
versionCode: code || null, versionName: versionName ?? null, signing: signing ? `upload key from ${process.env.ANDROID_KEYSTORE ? 'env' : '.storebound/credentials.json'} (alias ${signing.keyAlias})` : 'the project\'s own signingConfig',
|
||||
command: argv.join(' '), out: relative(root, out) || '.', notes: t.notes };
|
||||
androidSdk: sdk.path ? `${sdk.path} (${sdk.source})` : null, command: argv.join(' '), out: relative(root, out) || '.', notes: t.notes };
|
||||
const webOpts = { config, root, cap, store: 'play' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') };
|
||||
if (dry) {
|
||||
const web = await webStep({ ...webOpts, dry: true, quiet: json });
|
||||
if (json) { printJson({ ...plan, web }); return; }
|
||||
log(TAG, `${t.framework}: ${plan.dir} ${plan.task}, versionCode ${code || '?'}${versionName ? `, versionName ${versionName}` : ''}`);
|
||||
log(TAG, `signing: ${plan.signing}`);
|
||||
if (sdk.path) log(TAG, `Android SDK: ${sdk.path} (from ${sdk.source})`); else warn(TAG, androidSdkMissing(sdk));
|
||||
for (const n of t.notes) log(TAG, n);
|
||||
log(TAG, `would run (in ${relative(root, cwd) || '.'}): ${plan.command}`);
|
||||
done(TAG, 'build planned (dry run)');
|
||||
return;
|
||||
}
|
||||
if (!sdk.path) throw new Error(androidSdkMissing(sdk));
|
||||
log(TAG, `Android SDK: ${sdk.path} (from ${sdk.source})`);
|
||||
const web = await webStep({ ...webOpts, dry: false });
|
||||
if (signing && !existsSync(signing.storeFile)) throw new Error('ANDROID_KEYSTORE: the keystore file does not exist');
|
||||
if (!signing) warn(TAG, 'no ANDROID_KEYSTORE: the project\'s own release signingConfig must sign, else Play refuses the bundle');
|
||||
|
|
@ -78,7 +85,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
log(TAG, `$ ${plan.command}`);
|
||||
const started = Date.now();
|
||||
mkdirSync(out, { recursive: true });
|
||||
const p = Bun.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...signingEnv(signing) } });
|
||||
const p = Bun.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...androidSdkEnv(sdk), ...signingEnv(signing) } });
|
||||
const lines: string[] = [];
|
||||
const each = (line: string): void => {
|
||||
lines.push(line);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 25 | 2026-10 | In a non-interactive shell `play build` stopped in Gradle with "SDK location not found" (no `ANDROID_HOME` there, `local.properties` gitignored) although the SDK was in `~/Library/Android/sdk`; `apple upload --no-wait --yes` sent the IPA for over 10 minutes without a line (progress every 10th part only, no per-part or per-call timeout). | `resolveAndroidSdk` (ANDROID_HOME, ANDROID_SDK_ROOT, `sdk.dir` in local.properties, the Android Studio default folder; only folders with platforms/ or platform-tools/) hands the SDK to Gradle in the environment, never writes local.properties, stops before Gradle with the places checked; `doctor` shows it (`cli/lib/tools.ts`, `cli/play/build.ts`, `cli/doctor.ts`). `apple upload` prints the size, a line per part and a 20 s heartbeat (api, altool, processing wait), per-part and per-call timeouts, `--timeout` exits 7 with the resume hint (`sendParts` in `cli/apple/upload.ts`, `cli/lib/progress.ts`; `tests/tools-path.test.ts`, `tests/build-sign.test.ts`, `tests/upload-progress.test.ts`). |
|
||||
| 24 | 2026-10 | `flow run release --apply --yes` ran `apple upload --no-wait`, which printed "not a terminal: re-run with --yes to confirm" and declined, yet the flow logged "4/13 upload: ok" and went on to `apple testflight wait` for a build that was never uploaded: a declined confirmation exited 0, and the flow's `--yes` never reached the steps. | `confirm()` (`cli/lib/ctx.ts`): every decline (answered no, not a terminal without `--yes`, under serve) prints `declined: <reason>` and exits 3 (`DECLINED_EXIT`). `flow run` reports "step N declined: <reason>" (status `declined`) and stops; `--apply --yes` adds `--yes` to write steps only (`planStep`, `declinedReason`; `tests/flow.test.ts`). |
|
||||
| 23 | 2026-10 | Capacitor: native build ships old web assets. `flow run release --apply` went straight into `apple build` (xcodebuild archive) with `ios/App/App/public` and `android/app/src/main/assets/public` 3.5 hours older than the latest source commit: the native projects hold the copy `npx cap sync` made last, and nothing ran the web build + cap sync first, so the old game would have shipped. | New `build.prebuild` (per store `apple.prebuild` / `play.prebuild`): `apple build` / `play build` run it first (printed, streamed, nonzero exit stops; `--dry` only prints; `--no-prebuild`; once per `flow run` through `STOREBOUND_PREBUILD_DONE`). After it, a Capacitor project's copied `public/index.html` must be newer than the last commit outside ios/ android/ node_modules/ store/ .storebound/ and `<webDir>/index.html`, else the build stops ("web assets are older than the source ..."; `--allow-stale-web` overrides). `check` warns when stale without a prebuild; build output and dry plan show the bundle's sha256 + mtime. Monorepo (`apps/native` with its own capacitor.config.ts, android/, ios/; `play build` failed with "no Gradle project ... in android or ."): the one Capacitor project one or two levels down is found and named, several ask for `play.build.dir` / `apple.build.*` (`cli/lib/web-assets.ts`, `tests/web-assets.test.ts`). |
|
||||
| 22 | 2026-10 | `play build` built a good AAB (Capacitor 8 / AGP), then failed its own check with "AAB manifest without package / versionCode". The aapt2 proto manifest was read with element field numbers one too high (name 4, attribute 5, child 6 instead of Resources.proto's 3, 4, 5). The test fixture came from the same wrong encoder, so the tests passed. | `aabInfo` / `encodeProtoManifest` in `cli/lib/gradle.ts` follow Resources.proto. `tests/build-sign.test.ts` builds the manifest bytes by tag, without the encoder, and the parser was checked against real AGP bundles. Lesson: a parser's fixture must not come from the same code's encoder. |
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
|
|||
(exit 0), INVALID / FAILED (exit 1, Apple emails the ITMS reason) or `--timeout` (default 60m, exit 7). Run long
|
||||
uploads in the background with a log: `storebound apple upload --yes > .storebound/upload.log 2>&1 &` (only
|
||||
after the owner's yes).
|
||||
The upload is never silent: the size at the start, a line per part, a heartbeat every 20 s ("still uploading,
|
||||
4m30s, 312/890 MB (35%), part 4/12"), the same while altool runs and while the build is processed. Past
|
||||
`--timeout` it stops with exit 7; the same command again resumes the build upload (raise `--timeout`).
|
||||
6. **Next.** `storebound apple encryption --build N`, `storebound apple testflight distribute --build N --group Team
|
||||
--dry` (`testflight` skill), `storebound apple listing --version x.y.z --build N` (`appstore-first-release`).
|
||||
|
||||
|
|
@ -80,6 +83,11 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
|
|||
(or `--version-code N`) and where the key comes from. Then without `--dry`. The AAB lands in
|
||||
`build/android/<package>-<versionCode>.aab`; storebound reads the versionCode back from the bundle and warns when
|
||||
the build file ignored the one it passed (read `-PversionCode` there, or `android.injected.version.code`).
|
||||
The Android SDK is found the way Android Studio finds it: `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `sdk.dir` in the
|
||||
Gradle project's `local.properties`, then `~/Library/Android/sdk` (macOS) / `~/Android/Sdk` (Linux) /
|
||||
`%LOCALAPPDATA%\Android\Sdk` (Windows), and handed to Gradle through `ANDROID_HOME` / `ANDROID_SDK_ROOT`;
|
||||
`local.properties` is never written. None found: it stops before Gradle and lists the places checked.
|
||||
`storebound doctor` shows the SDK path and where it came from.
|
||||
3. **Upload.** `storebound play upload --track internal --dry` (read-only: checks the versionCode is free, shows
|
||||
what is on the track now and the release notes languages). Then without `--dry` after a yes. Tracks: internal,
|
||||
alpha (closed), beta (open). Production stays `storebound play release --version-code N --production draft` plus
|
||||
|
|
|
|||
|
|
@ -250,6 +250,7 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
|
|||
- **Sources:** https://support.google.com/googleplay/android-developer/answer/10787469 (Data safety form, CSV import),
|
||||
https://support.google.com/googleplay/android-developer/answer/13327111 (account deletion: delete-account link;
|
||||
deleting other data is a separate question).
|
||||
|
||||
## Adding a lesson
|
||||
Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test
|
||||
that fails without the fix (see CONTRIBUTING.md).
|
||||
|
|
@ -296,3 +297,21 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
`flow run` stops with "step N declined: <reason>" (status `declined`, never `ok`) and adds `--yes` to write steps
|
||||
only with `--apply --yes` (`planStep` in `cli/lib/flow.ts`, `declinedReason` in `cli/flow.ts`).
|
||||
`tests/flow.test.ts` (non-TTY flow with a confirming step and no `--yes` fails at that step with "declined").
|
||||
|
||||
## 25. Builds and uploads in a non-interactive shell: no SDK, no output
|
||||
- **Symptom:** `storebound play build` stopped in Gradle with "SDK location not found. Define ANDROID_HOME or sdk.dir
|
||||
in local.properties" although the SDK was in `~/Library/Android/sdk`. `storebound apple upload --no-wait --yes` ran
|
||||
for over 10 minutes with no line at all while it sent the IPA.
|
||||
- **Cause:** agents and scripts run non-interactive shells: `ANDROID_HOME` from `.zshrc` is not set there, and
|
||||
`local.properties` (where Android Studio writes `sdk.dir`) is gitignored, so a fresh checkout has none. The upload
|
||||
logged only every 10th part and had no timeout per part or per API call, so one large or stalled part was silence.
|
||||
- **Do:** nothing special now. `play build` finds the SDK like Android Studio (ANDROID_HOME, ANDROID_SDK_ROOT,
|
||||
`sdk.dir` in local.properties, the Android Studio default folder per OS), passes it to Gradle in the environment and
|
||||
never writes local.properties; without one it stops before Gradle and lists what it checked (`storebound doctor`
|
||||
shows the SDK and its source). `apple upload` prints the size, a line per part and a heartbeat every 20 s (also for
|
||||
altool and the processing wait); `--timeout` stops it with exit 7, and running the same command again resumes.
|
||||
Still run long uploads in the background with a log and report its lines.
|
||||
- **Guard:** `resolveAndroidSdk` / `androidSdkEnv` in `cli/lib/tools.ts`, `cli/play/build.ts`, `cli/doctor.ts`;
|
||||
`sendParts` in `cli/apple/upload.ts`, `cli/lib/progress.ts` (injectable heartbeat), `waitForBuild` in
|
||||
`cli/apple/testflight.ts`, a 2-minute limit per App Store Connect call (`ASC_REQUEST_TIMEOUT_MS`);
|
||||
`tests/tools-path.test.ts`, `tests/build-sign.test.ts`, `tests/upload-progress.test.ts`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { createPrivateKey, createPublicKey, verify } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { installedProfiles } from '../cli/apple/build';
|
||||
|
|
@ -349,6 +349,29 @@ describe('CLI dry runs (no store access)', () => {
|
|||
expect(j).toMatchObject({ framework: 'capacitor', dir: 'android', task: ':app:bundleRelease', versionCode: 12, signing: "the project's own signingConfig" });
|
||||
expect(j.command).toContain('-Pandroid.injected.version.code=12');
|
||||
});
|
||||
test('play build: the Android SDK goes to Gradle through the environment; without one it stops before Gradle', () => {
|
||||
const d = project('cli-play-sdk');
|
||||
const marker = join(d, 'gradle-env.txt');
|
||||
touch(d, 'android/gradlew', `#!/bin/sh\necho "$ANDROID_HOME|$ANDROID_SDK_ROOT" > "${marker}"\nexit 3\n`);
|
||||
chmodSync(join(d, 'android/gradlew'), 0o755);
|
||||
const env = { ANDROID_HOME: '', ANDROID_SDK_ROOT: '', ANDROID_KEYSTORE: '' };
|
||||
const run = () => Bun.spawnSync([process.execPath, join(ROOT, 'cli/index.ts'), 'play', 'build', '--version-code', '12', '--no-prebuild', '--allow-stale-web'], { cwd: d, stdout: 'pipe', stderr: 'pipe',
|
||||
env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', HOME: dir('clihome-sdk'), ...env } });
|
||||
let r = run();
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr.toString()).toContain('no Android SDK found');
|
||||
expect(r.stderr.toString()).toContain('android/local.properties');
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
// sdk.dir in the (gitignored) local.properties is found and handed over; the file is never written
|
||||
const sdk = dir('sdk-folder');
|
||||
mkdirSync(join(sdk, 'platform-tools'), { recursive: true });
|
||||
const props = `sdk.dir=${sdk}\n`;
|
||||
touch(d, 'android/local.properties', props);
|
||||
r = run();
|
||||
expect(r.stdout.toString()).toContain(`Android SDK: ${sdk} (from local.properties)`);
|
||||
expect(readFileSync(marker, 'utf8').trim()).toBe(`${sdk}|${sdk}`);
|
||||
expect(readFileSync(join(d, 'android/local.properties'), 'utf8')).toBe(props);
|
||||
});
|
||||
test('play upload refuses production', () => {
|
||||
const d = project('cli-up');
|
||||
writeFileSync(join(d, 'app.aab'), makeAab('com.example.game', 3));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||
import { mkdirSync, mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { toolPath, wellKnownToolDirs } from '../cli/lib/tools';
|
||||
import { androidSdkEnv, androidSdkMissing, resolveAndroidSdk, sdkDirOf, toolPath, wellKnownToolDirs } from '../cli/lib/tools';
|
||||
|
||||
describe('tool folders outside PATH', () => {
|
||||
test('finds the Android SDK Android Studio installs, after the own PATH', () => {
|
||||
|
|
@ -16,6 +16,36 @@ describe('tool folders outside PATH', () => {
|
|||
expect(p).toContain(join(home, 'Library/Android/sdk/emulator'));
|
||||
});
|
||||
|
||||
test('Android SDK: env, then local.properties, then the Android Studio folder; only real SDK folders count', () => {
|
||||
const fs = (dirs: string[], files: Record<string, string> = {}) => ({
|
||||
exists: (p: string) => dirs.includes(p) || p in files || dirs.some(d => d.startsWith(p + '/')), readText: (p: string) => files[p] ?? null });
|
||||
const home = '/x/home';
|
||||
const studio = `${home}/Library/Android/sdk`;
|
||||
// non-interactive shell on macOS: no ANDROID_HOME, local.properties gitignored -> Android Studio's folder
|
||||
let r = resolveAndroidSdk({ env: {}, platform: 'darwin', home, gradleDirs: ['/p/android'], ...fs([`${studio}/platform-tools`]) });
|
||||
expect(r).toMatchObject({ path: studio, source: 'default' });
|
||||
expect(r.checked).toEqual(['ANDROID_HOME: not set', 'ANDROID_SDK_ROOT: not set', 'sdk.dir in /p/android/local.properties: not set', `Android Studio default folder: ${studio}`]);
|
||||
// ANDROID_HOME pointing at an empty folder is skipped, ANDROID_SDK_ROOT is used
|
||||
r = resolveAndroidSdk({ env: { ANDROID_HOME: '/empty', ANDROID_SDK_ROOT: '/sdk' }, platform: 'linux', home, ...fs(['/empty', '/sdk/platforms']) });
|
||||
expect(r).toMatchObject({ path: '/sdk', source: 'ANDROID_SDK_ROOT' });
|
||||
expect(r.checked[0]).toBe('ANDROID_HOME: /empty (no platforms/ or platform-tools/ in it)');
|
||||
// sdk.dir with Java properties escapes
|
||||
r = resolveAndroidSdk({ env: {}, platform: 'linux', home, gradleDirs: ['/p/android'], ...fs(['/opt/and:sdk/platforms'], { '/p/android/local.properties': '## comment\nsdk.dir=/opt/and\\:sdk\n' }) });
|
||||
expect(r).toMatchObject({ path: '/opt/and:sdk', source: 'local.properties' });
|
||||
expect(sdkDirOf('sdk.dir=C\\:\\\\Users\\\\dev\\\\AppData\\\\Local\\\\Android\\\\Sdk')).toBe('C:\\Users\\dev\\AppData\\Local\\Android\\Sdk');
|
||||
// Linux and Windows defaults
|
||||
expect(resolveAndroidSdk({ env: {}, platform: 'linux', home: '/x/linuxhome', ...fs(['/x/linuxhome/Android/Sdk/platforms']) }).path).toBe('/x/linuxhome/Android/Sdk');
|
||||
expect(resolveAndroidSdk({ env: { LOCALAPPDATA: 'C:\\Users\\dev\\AppData\\Local' }, platform: 'win32', home: 'C:\\Users\\dev', exists: p => p === 'C:\\Users\\dev\\AppData\\Local\\Android\\Sdk\\platform-tools', readText: () => null }).path)
|
||||
.toBe('C:\\Users\\dev\\AppData\\Local\\Android\\Sdk');
|
||||
// nothing anywhere: every place checked is named in the error, and nothing goes to Gradle
|
||||
r = resolveAndroidSdk({ env: {}, platform: 'darwin', home, gradleDirs: ['/p/android'], ...fs([]) });
|
||||
expect(r.path).toBeNull();
|
||||
expect(androidSdkEnv(r)).toEqual({});
|
||||
const msg = androidSdkMissing(r);
|
||||
for (const where of ['ANDROID_HOME', 'ANDROID_SDK_ROOT', '/p/android/local.properties', studio]) expect(msg).toContain(where);
|
||||
expect(androidSdkEnv({ path: '/sdk', source: 'default', checked: [] })).toEqual({ ANDROID_HOME: '/sdk', ANDROID_SDK_ROOT: '/sdk' });
|
||||
});
|
||||
|
||||
test('ANDROID_HOME wins, missing folders are skipped, app folders come first', () => {
|
||||
const sdk = mkdtempSync(join(tmpdir(), 'sb-sdk-'));
|
||||
mkdirSync(join(sdk, 'platform-tools'));
|
||||
|
|
|
|||
95
tests/upload-progress.test.ts
Normal file
95
tests/upload-progress.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sendParts, type UploadOp } from '../cli/apple/upload';
|
||||
import { elapsed, heartbeat, mb, share, type Ticker } from '../cli/lib/progress';
|
||||
|
||||
/** A ticker driven by hand: beat() runs the heartbeat once, no timers. */
|
||||
function manualTicker(): { ticker: Ticker; beat: () => void; stopped: () => boolean } {
|
||||
let fn: (() => void) | null = null, stopped = false;
|
||||
return { ticker: f => { fn = f; return () => { stopped = true; fn = null; }; }, beat: () => fn?.(), stopped: () => stopped };
|
||||
}
|
||||
|
||||
function ipa(bytes: number): string {
|
||||
const f = join(mkdtempSync(join(tmpdir(), 'sb-upl-')), 'app.ipa');
|
||||
writeFileSync(f, Buffer.alloc(bytes, 7));
|
||||
return f;
|
||||
}
|
||||
|
||||
const MB = 1048576;
|
||||
const parts = (size: number, n: number): UploadOp[] => Array.from({ length: n }, (_, i) => {
|
||||
const len = Math.ceil(size / n), offset = i * len;
|
||||
return { method: 'PUT', url: `https://upload.example.com/p${i}`, offset, length: Math.min(len, size - offset) };
|
||||
});
|
||||
|
||||
describe('progress formats', () => {
|
||||
test('sizes, elapsed time, share', () => {
|
||||
expect(mb(4.2 * MB)).toBe('4.2 MB');
|
||||
expect(mb(890 * MB)).toBe('890 MB');
|
||||
expect(elapsed(45_000)).toBe('45s');
|
||||
expect(elapsed(270_000)).toBe('4m30s');
|
||||
expect(elapsed(3_720_000)).toBe('1h02m');
|
||||
expect(share(312 * MB, 890 * MB)).toBe('312/890 MB (35%)');
|
||||
});
|
||||
test('heartbeat says the elapsed time and stops', () => {
|
||||
const t = manualTicker();
|
||||
let now = 1000;
|
||||
const said: number[] = [];
|
||||
const h = heartbeat(ms => said.push(ms), { ticker: t.ticker, now: () => now });
|
||||
now += 30_000; t.beat();
|
||||
h.stop(); t.beat();
|
||||
expect(said).toEqual([30_000]);
|
||||
expect(t.stopped()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple upload: parts are never silent', () => {
|
||||
test('size at the start, a heartbeat while a part is in flight, a line per part', async () => {
|
||||
const size = 3 * MB, file = ipa(size), t = manualTicker(), said: string[] = [];
|
||||
let now = 0;
|
||||
const fetch = async (url: string, init: RequestInit): Promise<Response> => {
|
||||
expect(init.signal).toBeDefined();
|
||||
if (url.endsWith('p1')) { now += 270_000; t.beat(); }
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
||||
await sendParts({ file, size, ops: parts(size, 3), deadline: 3_600_000, fetch, ticker: t.ticker, now: () => now, say: m => said.push(m) });
|
||||
expect(said[0]).toBe('uploading 3.0 MB in 3 part(s)');
|
||||
expect(said).toContain('still uploading, 4m30s, 1.0/3.0 MB (33%), part 2/3');
|
||||
expect(said.filter(l => /^\d\/3 parts, /.test(l))).toHaveLength(3);
|
||||
expect(said.at(-1)).toStartWith('3/3 parts, 3.0/3.0 MB (100%)');
|
||||
expect(t.stopped()).toBe(true);
|
||||
});
|
||||
|
||||
test('5xx and a part timeout are retried; 4xx stops at once', async () => {
|
||||
const size = MB, file = ipa(size), said: string[] = [];
|
||||
let calls = 0;
|
||||
const flaky = async (): Promise<Response> => {
|
||||
calls++;
|
||||
if (calls === 1) return new Response('busy', { status: 503 });
|
||||
if (calls === 2) throw new DOMException('timed out', 'TimeoutError');
|
||||
return new Response(null, { status: 200 });
|
||||
};
|
||||
await sendParts({ file, size, ops: parts(size, 1), deadline: Date.now() + 3_600_000, partTimeoutMs: 60_000, fetch: flaky, sleep: async () => {}, ticker: manualTicker().ticker, say: m => said.push(m) });
|
||||
expect(calls).toBe(3);
|
||||
expect(said.filter(l => l.includes('retry'))).toEqual(['part 1/1: HTTP 503, retry 1/3', 'part 1/1: no answer in 1m00s, retry 2/3']);
|
||||
const denied = async (): Promise<Response> => new Response('no', { status: 403 });
|
||||
await expect(sendParts({ file, size, ops: parts(size, 1), deadline: Date.now() + 3_600_000, fetch: denied, sleep: async () => {}, ticker: manualTicker().ticker, say: () => {} }))
|
||||
.rejects.toThrow('part 1/1 -> 403');
|
||||
});
|
||||
|
||||
test('the overall timeout stops the upload with exit 7 and the hint to resume', async () => {
|
||||
const size = 2 * MB, file = ipa(size);
|
||||
let now = 0;
|
||||
const slow = async (url: string): Promise<Response> => {
|
||||
if (url.endsWith('p0')) { now += 50_000; return new Response(null, { status: 200 }); }
|
||||
now += 10_000; throw new DOMException('timed out', 'TimeoutError'); // cut by the deadline
|
||||
};
|
||||
const err = await sendParts({ file, size, ops: parts(size, 2), deadline: 60_000, partTimeoutMs: 15 * 60_000, fetch: slow, now: () => now, ticker: manualTicker().ticker, say: () => {} })
|
||||
.then(() => null, (e: Error & { exit?: number }) => e);
|
||||
expect(err?.exit).toBe(7);
|
||||
expect(err?.message).toContain('1.0/2.0 MB (50%) sent, 1/2 parts');
|
||||
expect(err?.message).toContain('--timeout');
|
||||
expect(err?.message).toContain('resumes this build upload');
|
||||
});
|
||||
});
|
||||
|
|
@ -13,10 +13,13 @@ const ROOT = join(import.meta.dir, '..');
|
|||
const CLI = join(ROOT, 'cli/index.ts');
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'sb-web-'));
|
||||
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
// play build needs an Android SDK before Gradle runs (cli/lib/tools.ts resolveAndroidSdk): an empty stand-in
|
||||
const SDK = join(tmp, 'android-sdk');
|
||||
mkdirSync(join(SDK, 'platform-tools'), { recursive: true });
|
||||
const touch = (base: string, rel: string, text = ''): void => { mkdirSync(join(base, rel, '..'), { recursive: true }); writeFileSync(join(base, rel), text); };
|
||||
const age = (base: string, rel: string, ms: number): void => { const t = new Date(ms); utimesSync(join(base, rel), t, t); };
|
||||
const sb = (cwd: string, ...args: string[]) => Bun.spawnSync([process.execPath, CLI, ...args], { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
||||
env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ANDROID_KEYSTORE: '', STOREBOUND_PREBUILD_DONE: '', HOME: join(tmp, 'home') } });
|
||||
env: { ...process.env, ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ANDROID_KEYSTORE: '', STOREBOUND_PREBUILD_DONE: '', HOME: join(tmp, 'home'), ANDROID_HOME: SDK, ANDROID_SDK_ROOT: '' } });
|
||||
const text = (r: { stdout: Buffer; stderr: Buffer }) => r.stdout.toString() + r.stderr.toString();
|
||||
|
||||
const BASE = { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue