// 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 })); 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') } }); 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 = {}, 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', 'new game'); age(c, 'dist/index.html', now - 60_000); for (const s of ['apple', 'play'] as const) { touch(c, COPIED_INDEX[s], 'old game'); 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 'fresh' > ${COPIED_INDEX.play} && echo 'fresh' > ${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'); }); }); /** 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[] }; }