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:
CRTkafa 2026-10-12 08:48:02 +08:00
parent 366362d62b
commit fc524cd2d4
15 changed files with 460 additions and 34 deletions

View file

@ -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));

View file

@ -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'));

View 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');
});
});

View file

@ -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' },