storebound/tests/web-assets.test.ts

235 lines
16 KiB
TypeScript

// Capacitor web assets in the native builds (pitfall 20): build.prebuild, the staleness guard, check's warning, the
// web bundle in the plan, and one prebuild per flow run.
import { afterAll, describe, expect, test } from 'bun:test';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { validateConfig } from '../cli/lib/config';
import { prebuildRan } from '../cli/flow';
import { capacitorCandidates, capacitorProject, COPIED_INDEX, lastSourceCommit, prebuildFor, prebuildsDone, staleMessage, webBundle, webDirOf, webStaleness } from '../cli/lib/web-assets';
import { makeAab } from './build-fixtures';
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'), 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' },
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }] };
/** A Capacitor project (webDir dist) with both native projects and a fake gradlew that leaves a real AAB. */
function project(name: string, extra: Record<string, unknown> = {}, o: { fresh?: boolean; sub?: string } = {}): string {
const d = join(tmp, name);
mkdirSync(d, { recursive: true });
const cfg = { ...BASE, apple: { bundleId: 'com.example.game', signing: { teamId: 'TEAMID1234' } }, play: { packageName: 'com.example.game' }, ...extra };
touch(d, 'storebound.config.ts', `export default ${JSON.stringify(cfg)};\n`);
touch(d, '.gitignore', '.storebound/\n');
const c = o.sub ? join(d, o.sub) : d; // the Capacitor project (a monorepo package with sub)
touch(c, 'capacitor.config.json', JSON.stringify({ appId: 'com.example.game', appName: 'Example', webDir: 'dist' }));
touch(c, 'ios/App/App.xcodeproj/project.pbxproj');
touch(c, 'android/settings.gradle'); touch(c, 'android/app/build.gradle');
writeFileSync(join(c, 'fixture.aab'), makeAab('com.example.game', 5));
touch(c, 'android/gradlew', '#!/bin/sh\nmkdir -p app/build/outputs/bundle/release && cp ../fixture.aab app/build/outputs/bundle/release/app-release.aab\n');
chmodSync(join(c, 'android/gradlew'), 0o755);
const now = Date.now();
touch(c, 'dist/index.html', '<html>new game</html>');
age(c, 'dist/index.html', now - 60_000);
for (const s of ['apple', 'play'] as const) {
touch(c, COPIED_INDEX[s], '<html>old game</html>');
age(c, COPIED_INDEX[s], o.fresh ? now - 1_000 : now - 3.5 * 3_600_000);
}
return d;
}
describe('pure parts', () => {
test('webDir from ts, js and json configs; default www', () => {
expect(webDirOf('{"appId":"x","webDir":"dist"}')).toBe('dist');
expect(webDirOf("const config: CapacitorConfig = {\n appId: 'x',\n webDir: 'build/web',\n};")).toBe('build/web');
expect(webDirOf('module.exports = { webDir: `out` }')).toBe('out');
expect(webDirOf('export default { appId: "x" }')).toBe('www');
});
test('staleness: missing, older than a commit or the web build, fresh, unknown source', () => {
expect(webStaleness({ copied: null, commit: 10, webIndex: null })).toEqual({ stale: true, missing: true, source: 10 });
expect(webStaleness({ copied: 100, commit: 200, webIndex: 50 })).toEqual({ stale: true, missing: false, source: 200 });
expect(webStaleness({ copied: 100, commit: 50, webIndex: 150 })).toEqual({ stale: true, missing: false, source: 150 });
expect(webStaleness({ copied: 300, commit: 200, webIndex: 250 }).stale).toBe(false);
expect(webStaleness({ copied: 200, commit: 200, webIndex: null }).stale).toBe(false);
expect(webStaleness({ copied: 1, commit: null, webIndex: null })).toEqual({ stale: false, missing: false, source: null });
const msg = staleMessage(COPIED_INDEX.apple, webStaleness({ copied: 0, commit: 3_600_000, webIndex: null }), 0);
expect(msg).toStartWith('web assets are older than the source (copied 1970-01-01 00:00:00Z, source 1970-01-01 01:00:00Z): run your web build + cap sync, or set build.prebuild');
expect(msg).toContain('--allow-stale-web');
});
test('prebuild per store, flow marker, config validation', () => {
const c = { ...BASE, build: { prebuild: 'npm run build && npx cap sync' }, apple: { bundleId: 'com.example.game', prebuild: 'npm run ios' }, play: { packageName: 'com.example.game' } } as never;
expect(prebuildFor(c, 'apple')).toBe('npm run ios');
expect(prebuildFor(c, 'play')).toBe('npm run build && npx cap sync');
expect(prebuildRan(['play', 'build', '--version-code', '3'], c)).toBe('npm run build && npx cap sync');
expect(prebuildRan(['play', 'build', '--dry'], c)).toBeNull();
expect(prebuildRan(['play', 'build', '--no-prebuild'], c)).toBeNull();
expect(prebuildRan(['apple', 'upload'], c)).toBeNull();
expect([...prebuildsDone({ STOREBOUND_PREBUILD_DONE: 'a\nb\n' })]).toEqual(['a', 'b']);
expect(validateConfig({ ...BASE, build: { prebuild: '' } }).join()).toContain('build.prebuild');
expect(validateConfig({ ...BASE, play: { packageName: 'com.example.game', prebuild: 3 } }).join()).toContain('play.prebuild');
});
test('last source commit ignores ios/ android/ node_modules/ store/ .storebound/', () => {
const d = join(tmp, 'git');
mkdirSync(d);
const git = (date: string, ...a: string[]) => Bun.spawnSync(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false', ...a], { cwd: d, env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date } });
git('2026-01-01T00:00:00Z', 'init', '-q');
expect(lastSourceCommit(d)).toBeNull();
touch(d, 'src/game.ts', 'x');
git('2026-01-01T00:00:00Z', 'add', '.'); git('2026-01-01T00:00:00Z', 'commit', '-qm', 'src');
touch(d, 'ios/App/x.swift', 'y'); touch(d, 'store/listing.md', 'z');
git('2026-02-01T00:00:00Z', 'add', '.'); git('2026-02-01T00:00:00Z', 'commit', '-qm', 'native');
expect(lastSourceCommit(d)).toBe(Date.parse('2026-01-01T00:00:00Z'));
});
});
describe('builds', () => {
test('stale copied assets stop play build (no Gradle run); --allow-stale-web builds; the dry plan shows the bundle', () => {
const d = project('stale');
const dry = sb(d, 'play', 'build', '--dry', '--json', '--version-code', '5');
expect(dry.exitCode).toBe(0);
const j = JSON.parse(dry.stdout.toString());
const b = webBundle(d, 'play', capacitorProject(d)!)!;
expect(j.web).toMatchObject({ prebuild: null, capacitor: '.', stale: true, bundle: { path: COPIED_INDEX.play, sha256: b.sha256 } });
expect(b.sha256).toMatch(/^[0-9a-f]{12}$/);
const adry = JSON.parse(sb(d, 'apple', 'build', '--dry', '--json', '--build-number', '1').stdout.toString());
expect(adry.web).toMatchObject({ stale: true, bundle: { path: COPIED_INDEX.apple } });
const r = sb(d, 'play', 'build', '--version-code', '5');
expect(r.exitCode).toBe(1);
expect(text(r)).toContain('web assets are older than the source (copied ');
expect(text(r)).toContain('run your web build + cap sync, or set build.prebuild');
expect(existsSync(join(d, 'android/app/build'))).toBe(false);
const ok = sb(d, 'play', 'build', '--version-code', '5', '--allow-stale-web');
expect(ok.exitCode).toBe(0);
expect(text(ok)).toContain(`web bundle: ${COPIED_INDEX.play} sha256 ${b.sha256}`);
});
test('missing copied index.html stops the build', () => {
const d = project('missing');
rmSync(join(d, COPIED_INDEX.play));
const r = sb(d, 'play', 'build', '--version-code', '5');
expect(r.exitCode).toBe(1);
expect(text(r)).toContain('copied never');
expect(text(r)).toContain('is missing');
});
test('prebuild runs first and refreshes the assets; --dry and --no-prebuild never run it; a failing prebuild stops', () => {
const pre = `echo ran >> prebuild.log && echo '<html>fresh</html>' > ${COPIED_INDEX.play} && echo '<html>fresh</html>' > ${COPIED_INDEX.apple}`;
const d = project('pre', { build: { prebuild: pre } });
const dry = sb(d, 'play', 'build', '--dry', '--version-code', '5');
expect(dry.exitCode).toBe(0);
expect(text(dry)).toContain(`would run prebuild (in the project root): ${pre}`);
expect(existsSync(join(d, 'prebuild.log'))).toBe(false);
expect(sb(d, 'play', 'build', '--version-code', '5', '--no-prebuild').exitCode).toBe(1); // skipped, so still stale
expect(existsSync(join(d, 'prebuild.log'))).toBe(false);
const r = sb(d, 'play', 'build', '--version-code', '5');
expect(text(r)).toContain(`prebuild: $ ${pre}`);
expect(r.exitCode).toBe(0);
expect(readFileSync(join(d, 'prebuild.log'), 'utf8')).toBe('ran\n');
expect(text(r)).toContain(`web bundle: ${COPIED_INDEX.play} sha256 ${webBundle(d, 'play', capacitorProject(d)!)!.sha256}`);
const bad = project('pre-bad', { play: { packageName: 'com.example.game', prebuild: 'echo broken >&2; exit 3' } });
const f = sb(bad, 'play', 'build', '--version-code', '5');
expect(f.exitCode).toBe(1);
expect(text(f)).toContain('prebuild failed (exit 3)');
expect(existsSync(join(bad, 'android/app/build'))).toBe(false);
});
test('a flow run runs the shared prebuild once, not once per build step', () => {
const pre = `echo ran >> prebuild.log && touch ${COPIED_INDEX.play} ${COPIED_INDEX.apple}`;
const d = project('flow', { build: { prebuild: pre } });
touch(d, 'store/workflows/ship.yml', 'steps:\n - id: one\n run: play build --version-code 5\n - id: two\n run: play build --version-code 5 --out build/android2\n');
const dry = sb(d, 'flow', 'run', 'ship');
expect(dry.exitCode).toBe(0);
expect(existsSync(join(d, 'prebuild.log'))).toBe(false);
const r = sb(d, 'flow', 'run', 'ship', '--apply');
expect(text(r)).toContain('prebuild skipped (already ran in this flow run)');
expect(r.exitCode).toBe(0);
expect(readFileSync(join(d, 'prebuild.log'), 'utf8')).toBe('ran\n');
expect(existsSync(join(d, 'build/android2/com.example.game-5.aab'))).toBe(true);
});
});
describe('monorepo: the Capacitor project in apps/native', () => {
test('found one level down, through workspaces, apps/* or packages/*; several ask for the config; the config wins', () => {
const one = project('mono-one', {}, { sub: 'apps/native' });
expect(capacitorCandidates(one)).toEqual(['apps/native']);
expect(capacitorProject(one)).toEqual({ dir: 'apps/native', config: 'capacitor.config.json', webDir: 'dist' });
const ws = join(tmp, 'mono-ws');
touch(ws, 'package.json', JSON.stringify({ workspaces: ['clients/*'] }));
touch(ws, 'clients/mobile/capacitor.config.ts', "export default { webDir: 'build' };");
touch(ws, 'node_modules/x/capacitor.config.ts', '');
expect(capacitorProject(ws)).toMatchObject({ dir: 'clients/mobile', webDir: 'build' });
const two = project('mono-two', {}, { sub: 'apps/native' });
touch(two, 'packages/kiosk/capacitor.config.json', '{}');
expect(() => capacitorProject(two)).toThrow('several Capacitor projects (apps/native, packages/kiosk): set play.build.dir');
expect(capacitorProject(two, 'packages/kiosk/android')).toMatchObject({ dir: 'packages/kiosk', webDir: 'www' });
expect(capacitorProject(two, 'apps/native/ios/App')).toMatchObject({ dir: 'apps/native' });
});
test('play build and apple build use apps/native/android and apps/native/ios, say so, and check its web assets', () => {
const d = project('mono-build', {}, { sub: 'apps/native' });
const dry = sb(d, 'play', 'build', '--dry', '--json', '--version-code', '5');
expect(dry.exitCode).toBe(0);
const j = JSON.parse(dry.stdout.toString());
expect(j).toMatchObject({ framework: 'capacitor', dir: 'apps/native/android', web: { capacitor: 'apps/native', stale: true, bundle: { path: `apps/native/${COPIED_INDEX.play}` } } });
expect(j.notes[0]).toContain('Capacitor project: apps/native');
const a = JSON.parse(sb(d, 'apple', 'build', '--dry', '--json', '--build-number', '1').stdout.toString());
expect(a).toMatchObject({ framework: 'capacitor', project: 'apps/native/ios/App/App.xcodeproj', scheme: 'App', web: { capacitor: 'apps/native', bundle: { path: `apps/native/${COPIED_INDEX.apple}` } } });
const stale = sb(d, 'play', 'build', '--version-code', '5');
expect(stale.exitCode).toBe(1);
expect(text(stale)).toContain(`apps/native/${COPIED_INDEX.play}; --allow-stale-web`);
const ok = sb(d, 'play', 'build', '--version-code', '5', '--allow-stale-web');
expect(ok.exitCode).toBe(0);
expect(text(ok)).toContain('Capacitor project: apps/native');
expect(existsSync(join(d, 'build/android/com.example.game-5.aab'))).toBe(true);
expect(checkJson(sb(d, 'check', '--json')).warnings.some(w => w.includes(`apps/native/${COPIED_INDEX.play}`))).toBe(true);
// several: the build asks for the folder; an explicit play.build.dir wins
touch(d, 'packages/kiosk/capacitor.config.json', '{}');
expect(text(sb(d, 'play', 'build', '--dry', '--version-code', '5'))).toContain('several Capacitor projects (apps/native, packages/kiosk)');
expect(sb(d, 'play', 'build', '--dry', '--version-code', '5', '--dir', 'apps/native/android').exitCode).toBe(0);
});
});
describe('check', () => {
test('warns about stale web assets when no build.prebuild is set', () => {
const d = project('check');
const r = sb(d, 'check', '--json');
const warnings: string[] = checkJson(r).warnings;
expect(warnings.some(w => w.startsWith('play: web assets are older than the source') && w.includes('Set build.prebuild'))).toBe(true);
const fresh = project('check-fresh', {}, { fresh: true });
expect(checkJson(sb(fresh, 'check', '--json')).warnings.join()).not.toContain('web assets');
const withPre = project('check-pre', { build: { prebuild: 'npx cap sync' } });
expect(checkJson(sb(withPre, 'check', '--json')).warnings.join()).not.toContain('web assets');
});
test('warns when android/app/build.gradle does not read -PversionCode / -PversionName (literal or env var), with the line to use', () => {
const d = project('check-vc', { build: { prebuild: 'npx cap sync' } });
touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode System.getenv('VERSION_CODE') as Integer ?: 1\n versionName '1.0'\n }\n}\n");
const w = checkJson(sb(d, 'check', '--json')).warnings.filter(x => x.startsWith('play: android/app/build.gradle'));
expect(w).toHaveLength(2);
expect(w[0]).toContain('android/app/build.gradle:3: versionCode does not come from the project property versionCode');
expect(w[0]).toContain("versionCode((project.findProperty('versionCode') ?: 1) as Integer)");
expect(w[1]).toContain("versionName project.findProperty('versionName') ?: '1.0'");
touch(d, 'android/app/build.gradle', "android {\n defaultConfig {\n versionCode((project.findProperty('versionCode') ?: 1) as Integer)\n versionName project.findProperty('versionName') ?: '1.0'\n }\n}\n");
expect(checkJson(sb(d, 'check', '--json')).warnings.join()).not.toContain('build.gradle');
});
});
/** check --json prints its log lines first, then the result object. */
function checkJson(r: { stdout: Buffer }): { warnings: string[] } {
const out = r.stdout.toString();
return JSON.parse(out.slice(out.indexOf('\n{') + 1)) as { warnings: string[] };
}