|
| 1 | +import { execFileSync } from 'node:child_process'; |
| 2 | +import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; |
| 3 | +import { join } from 'node:path'; |
| 4 | +import { isRunning } from '../src/core/exec.ts'; |
| 5 | +import type { |
| 6 | + BuildKey, |
| 7 | + DeviceBackend, |
| 8 | + HostEntry, |
| 9 | + LocalLease, |
| 10 | + Platform, |
| 11 | + RuntimeProcess, |
| 12 | + ScratchPath, |
| 13 | +} from '../src/core/types.ts'; |
| 14 | +import { APP_ID } from '../src/fixture.ts'; |
| 15 | +import { host, metroPort } from '../src/host.ts'; |
| 16 | +import { sdkTool } from '../src/platform/android/sdk.ts'; |
| 17 | + |
| 18 | +const SKILL = join(import.meta.dirname, '..'); |
| 19 | +const WORKTREE = join(SKILL, '..', '..', '..'); |
| 20 | +const STATE = join(SKILL, '.verify', 'ci-device-proof.json'); |
| 21 | + |
| 22 | +interface State { |
| 23 | + readonly lease: LocalLease; |
| 24 | + readonly entry: HostEntry; |
| 25 | + readonly processes: readonly RuntimeProcess[]; |
| 26 | +} |
| 27 | + |
| 28 | +const log = (line: string) => console.log(line); |
| 29 | +const seconds = (since: number) => `${Math.round((Date.now() - since) / 1000)}s`; |
| 30 | +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); |
| 31 | + |
| 32 | +function backendFor(platform: Platform): DeviceBackend<LocalLease> { |
| 33 | + const backend = host.backends.find(b => b.kind === 'local' && b.platform === platform); |
| 34 | + if (backend === undefined) throw new Error(`no local ${platform} backend`); |
| 35 | + return backend as unknown as DeviceBackend<LocalLease>; |
| 36 | +} |
| 37 | + |
| 38 | +const readState = (): State => JSON.parse(readFileSync(STATE, 'utf8')) as State; |
| 39 | + |
| 40 | +async function metroStatus(lease: LocalLease): Promise<string> { |
| 41 | + const response = await fetch(`http://127.0.0.1:${metroPort(lease)}/status`, { signal: AbortSignal.timeout(5000) }); |
| 42 | + return response.text(); |
| 43 | +} |
| 44 | + |
| 45 | +async function up(platform: Platform): Promise<void> { |
| 46 | + const backend = backendFor(platform); |
| 47 | + const availability = backend.availability(); |
| 48 | + log(`local usable=${availability.usable} ${availability.why}`); |
| 49 | + if (!availability.usable) process.exit(1); |
| 50 | + const into = join(SKILL, '.verify', 'builds', 'proof') as ScratchPath; |
| 51 | + mkdirSync(into, { recursive: true }); |
| 52 | + let started = Date.now(); |
| 53 | + const app = await host.build(platform, `${platform}-proof0000000` as BuildKey, into, log); |
| 54 | + if (app.path === null) throw new Error('the build has no path'); |
| 55 | + log(`build ${seconds(started)} ${app.source} ${app.path}`); |
| 56 | + started = Date.now(); |
| 57 | + const acquired = await backend.acquire({ |
| 58 | + platform, |
| 59 | + worktree: WORKTREE, |
| 60 | + waitSeconds: 0, |
| 61 | + app, |
| 62 | + progress: log, |
| 63 | + retryWith: 'proof', |
| 64 | + }); |
| 65 | + log(`lease ${seconds(started)} ${acquired.deviceName} (${acquired.deviceId})`); |
| 66 | + mkdirSync(join(SKILL, '.verify', 'leases'), { recursive: true }); |
| 67 | + const leaseFile = join(SKILL, '.verify', 'leases', `${platform}.json`); |
| 68 | + writeFileSync(leaseFile, JSON.stringify(acquired, null, 2)); |
| 69 | + started = Date.now(); |
| 70 | + const lease = await backend.install(acquired, app, log); |
| 71 | + writeFileSync(leaseFile, JSON.stringify(lease, null, 2)); |
| 72 | + log(`install ${seconds(started)}`); |
| 73 | + log(`check ${await backend.check(lease)}`); |
| 74 | + if (host.runtime === undefined) throw new Error('the host has no runtime'); |
| 75 | + started = Date.now(); |
| 76 | + const runtime = await host.runtime(lease, log); |
| 77 | + log(`runtime ${seconds(started)} ${runtime.processes.map(p => `${p.what} ${p.pid}`).join(', ')}`); |
| 78 | + log(`entry ${JSON.stringify(runtime.entry)}`); |
| 79 | + writeFileSync(STATE, JSON.stringify({ lease, entry: runtime.entry, processes: runtime.processes }, null, 2)); |
| 80 | + if (runtime.entry.kind === 'dev-client') log(`metro :${metroPort(lease)} ${(await metroStatus(lease)).trim()}`); |
| 81 | +} |
| 82 | + |
| 83 | +async function alive(platform: Platform): Promise<void> { |
| 84 | + const state = readState(); |
| 85 | + const held = await backendFor(platform).check(state.lease); |
| 86 | + log(`check ${held}`); |
| 87 | + let ok = held === 'held'; |
| 88 | + for (const ref of state.processes) { |
| 89 | + const running = isRunning(ref); |
| 90 | + log(`${ref.what.padEnd(7)} pid ${ref.pid} ${running ? 'still running' : 'GONE'}`); |
| 91 | + ok &&= running; |
| 92 | + } |
| 93 | + if (state.entry.kind === 'dev-client') { |
| 94 | + const status = (await metroStatus(state.lease)).trim(); |
| 95 | + log(`metro :${metroPort(state.lease)} ${status}`); |
| 96 | + ok &&= status.includes('packager-status:running'); |
| 97 | + } |
| 98 | + if (!ok) { |
| 99 | + console.error('the device or the runtime did not outlive the step that started it'); |
| 100 | + process.exit(1); |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +async function launch(platform: Platform, shot: string): Promise<void> { |
| 105 | + const { lease, entry } = readState(); |
| 106 | + if (platform === 'ios') { |
| 107 | + const args = entry.kind === 'binary' ? ['-verifyScreen', 'home'] : entry.launchArguments; |
| 108 | + execFileSync('xcrun', ['simctl', 'launch', lease.deviceId, APP_ID, ...args], { stdio: 'inherit' }); |
| 109 | + } else { |
| 110 | + const start = |
| 111 | + entry.kind === 'binary' |
| 112 | + ? `am start -W -n ${APP_ID}/.MainActivity --es verifyScreen home` |
| 113 | + : `am start -a android.intent.action.VIEW -d '${entry.openLink}' -n ${APP_ID}/${entry.androidActivity}`; |
| 114 | + execFileSync(sdkTool('adb'), ['-s', lease.deviceId, 'shell', start], { stdio: 'inherit' }); |
| 115 | + } |
| 116 | + for (const n of [1, 2]) { |
| 117 | + await sleep(entry.kind === 'binary' ? 20_000 : 45_000); |
| 118 | + const file = shot.replace(/\.png$/, `-${n}.png`); |
| 119 | + if (platform === 'ios') execFileSync('xcrun', ['simctl', 'io', lease.deviceId, 'screenshot', file], { stdio: 'inherit' }); |
| 120 | + else |
| 121 | + writeFileSync( |
| 122 | + file, |
| 123 | + execFileSync(sdkTool('adb'), ['-s', lease.deviceId, 'exec-out', 'screencap', '-p'], { |
| 124 | + maxBuffer: 64 * 1024 * 1024, |
| 125 | + }), |
| 126 | + ); |
| 127 | + log(`shot ${file} ${statSync(file).size} bytes`); |
| 128 | + } |
| 129 | + if (platform === 'android') { |
| 130 | + const logcat = execFileSync(sdkTool('adb'), ['-s', lease.deviceId, 'logcat', '-d', '-s', 'ReactNativeJS:*'], { |
| 131 | + encoding: 'utf8', |
| 132 | + maxBuffer: 64 * 1024 * 1024, |
| 133 | + }); |
| 134 | + log('---- ReactNativeJS lines in logcat that name the missing key'); |
| 135 | + log(logcat.split('\n').filter(line => line.includes('EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY')).slice(0, 3).join('\n')); |
| 136 | + } |
| 137 | + if (entry.kind === 'binary') return; |
| 138 | + const metroLog = join(SKILL, '.verify', 'runtime', `metro-${metroPort(lease)}.log`); |
| 139 | + log(`---- tail of ${metroLog}`); |
| 140 | + log(readFileSync(metroLog, 'utf8').split('\n').slice(-25).join('\n')); |
| 141 | +} |
| 142 | + |
| 143 | +async function down(platform: Platform): Promise<void> { |
| 144 | + const state = readState(); |
| 145 | + await backendFor(platform).release(state.lease); |
| 146 | + log(`released ${state.lease.deviceName}`); |
| 147 | + for (const ref of state.processes) { |
| 148 | + if (isRunning(ref)) process.kill(ref.pid, 'SIGTERM'); |
| 149 | + log(`stopped ${ref.what} ${ref.pid}`); |
| 150 | + } |
| 151 | +} |
| 152 | + |
| 153 | +const [verb, platformArg, extra] = process.argv.slice(2); |
| 154 | +if (platformArg !== 'ios' && platformArg !== 'android') { |
| 155 | + console.error('usage: ci-device-proof.ts up|alive|launch|down ios|android [screenshot.png]'); |
| 156 | + process.exit(2); |
| 157 | +} |
| 158 | +try { |
| 159 | + if (verb === 'up') await up(platformArg); |
| 160 | + else if (verb === 'alive') await alive(platformArg); |
| 161 | + else if (verb === 'launch') await launch(platformArg, extra ?? 'launch.png'); |
| 162 | + else if (verb === 'down') await down(platformArg); |
| 163 | + else throw new Error(`unknown verb ${verb}`); |
| 164 | +} catch (error) { |
| 165 | + console.error(error); |
| 166 | + process.exit(1); |
| 167 | +} |
0 commit comments