| 1 | #!/usr/bin/env node |
| 2 | // @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Host-side simulator and emulator automation uses Node subprocess and timing APIs directly. |
| 3 | |
| 4 | import * as NodeChildProcess from "node:child_process"; |
| 5 | import * as NodeFSP from "node:fs/promises"; |
| 6 | import * as NodeNet from "node:net"; |
| 7 | import * as NodeOS from "node:os"; |
| 8 | import * as NodePath from "node:path"; |
| 9 | import * as NodeProcess from "node:process"; |
| 10 | import * as NodeURL from "node:url"; |
| 11 | |
| 12 | import { PNG } from "pngjs"; |
| 13 | |
| 14 | import showcaseConfig, { |
| 15 | type ShowcaseAppearance, |
| 16 | type ShowcaseAndroidDevice, |
| 17 | type ShowcaseConfig, |
| 18 | type ShowcaseDevice, |
| 19 | type ShowcaseIosDevice, |
| 20 | type ShowcaseStoreAssetSpec, |
| 21 | SHOWCASE_SCENES, |
| 22 | type ShowcaseScene, |
| 23 | SHOWCASE_THEMES, |
| 24 | type ShowcaseTheme, |
| 25 | } from "./mobile-showcase.config.ts"; |
| 26 | import { |
| 27 | SHOWCASE_ENVIRONMENTS, |
| 28 | SHOWCASE_PROJECTS, |
| 29 | SHOWCASE_THREADS, |
| 30 | seedShowcaseEnvironment, |
| 31 | } from "./mobile-showcase-environment.ts"; |
| 32 | |
| 33 | const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); |
| 34 | const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); |
| 35 | const ANDROID_PACKAGE = "com.t3tools.t3code"; |
| 36 | const APP_SCHEME = "t3code"; |
| 37 | // expo-dev-launcher reads these off the manifest URL and updates the dev menu |
| 38 | // preferences before the app loads, keeping captures free of dev chrome. |
| 39 | const DEV_CLIENT_LAUNCH_FLAGS = "disableOnboarding=1&disableFab=1&disableAutoLaunch=1"; |
| 40 | const IOS_READY_FILENAME = "T3ShowcaseReadyScene"; |
| 41 | const SERVER_HOST = "0.0.0.0"; |
| 42 | const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64"; |
| 43 | const IOS_APP_PATH = NodePath.join( |
| 44 | MOBILE_ROOT, |
| 45 | ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/TheKnife.app", |
| 46 | ); |
| 47 | const ANDROID_APK_PATH = NodePath.join( |
| 48 | MOBILE_ROOT, |
| 49 | "android/app/build/outputs/apk/debug/app-debug.apk", |
| 50 | ); |
| 51 | export function resolveAndroidSdkRoot( |
| 52 | environment: Readonly<Record<string, string | undefined>>, |
| 53 | platform: NodeJS.Platform = NodeProcess.platform, |
| 54 | ): string { |
| 55 | const configured = environment.ANDROID_HOME ?? environment.ANDROID_SDK_ROOT; |
| 56 | if (configured) return configured; |
| 57 | const home = environment.HOME ?? environment.USERPROFILE ?? ""; |
| 58 | const path = platform === "win32" ? NodePath.win32 : NodePath.posix; |
| 59 | return path.join(home, platform === "darwin" ? "Library/Android/sdk" : "Android/Sdk"); |
| 60 | } |
| 61 | |
| 62 | const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env); |
| 63 | const MOBILE_BUILD_ENV = { |
| 64 | ...NodeProcess.env, |
| 65 | ANDROID_HOME: ANDROID_SDK_ROOT, |
| 66 | APP_VARIANT: "production", |
| 67 | EXPO_NO_GIT_STATUS: "1", |
| 68 | // Lets the capture build require full screen on iPad so the app can rotate |
| 69 | // itself to landscape (see app.config.ts). |
| 70 | T3_SHOWCASE_CAPTURE_BUILD: "1", |
| 71 | JAVA_HOME: |
| 72 | NodeProcess.env.JAVA_HOME ?? |
| 73 | (NodeProcess.platform === "darwin" |
| 74 | ? "/Applications/Android Studio.app/Contents/jbr/Contents/Home" |
| 75 | : undefined), |
| 76 | NODE_ENV: "development", |
| 77 | }; |
| 78 | |
| 79 | interface CliOptions { |
| 80 | readonly platforms: ReadonlySet<ShowcaseDevice["platform"]>; |
| 81 | readonly deviceIds: ReadonlySet<string>; |
| 82 | readonly scenes: ReadonlySet<ShowcaseScene>; |
| 83 | readonly appearances: ReadonlySet<ShowcaseAppearance>; |
| 84 | readonly themes: ReadonlySet<ShowcaseTheme>; |
| 85 | readonly skipBuild: boolean; |
| 86 | readonly skipMetro: boolean; |
| 87 | readonly keepRunning: boolean; |
| 88 | readonly validateOnly: boolean; |
| 89 | readonly list: boolean; |
| 90 | } |
| 91 | |
| 92 | export interface ShowcaseCapture { |
| 93 | readonly device: ShowcaseDevice; |
| 94 | readonly scenes: ReadonlyArray<ShowcaseScene>; |
| 95 | readonly appearance: ShowcaseAppearance; |
| 96 | readonly theme: ShowcaseTheme; |
| 97 | } |
| 98 | |
| 99 | interface IosCaptureCleanup { |
| 100 | readonly udid: string; |
| 101 | readonly startedByRunner: boolean; |
| 102 | readonly createdByRunner: boolean; |
| 103 | } |
| 104 | |
| 105 | interface AndroidCaptureCleanup { |
| 106 | readonly device: ShowcaseAndroidDevice; |
| 107 | readonly serial: string; |
| 108 | readonly startedByRunner: boolean; |
| 109 | } |
| 110 | |
| 111 | interface NetworkAddress { |
| 112 | readonly address: string; |
| 113 | readonly family: string; |
| 114 | readonly internal: boolean; |
| 115 | } |
| 116 | |
| 117 | export function selectLanIpv4Address(addresses: ReadonlyArray<NetworkAddress>): string | null { |
| 118 | return ( |
| 119 | addresses.find( |
| 120 | ({ address, family, internal }) => |
| 121 | family === "IPv4" && !internal && !address.startsWith("169.254."), |
| 122 | )?.address ?? null |
| 123 | ); |
| 124 | } |
| 125 | |
| 126 | function lanIpv4Address(): string { |
| 127 | const address = selectLanIpv4Address( |
| 128 | Object.values(NodeOS.networkInterfaces()).flatMap((addresses) => addresses ?? []), |
| 129 | ); |
| 130 | if (!address) { |
| 131 | throw new Error("No LAN IPv4 address is available for the iOS Simulator to reach Metro."); |
| 132 | } |
| 133 | return address; |
| 134 | } |
| 135 | |
| 136 | export interface PngMetadata { |
| 137 | readonly width: number; |
| 138 | readonly height: number; |
| 139 | readonly bitDepth: number; |
| 140 | readonly colorType: number; |
| 141 | readonly hasAlpha: boolean; |
| 142 | } |
| 143 | |
| 144 | export function readPngMetadata(bytes: Uint8Array): PngMetadata { |
| 145 | const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]; |
| 146 | if (bytes.byteLength < 26 || !pngSignature.every((value, index) => bytes[index] === value)) { |
| 147 | throw new Error("Captured file is not a valid PNG."); |
| 148 | } |
| 149 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| 150 | const colorType = view.getUint8(25); |
| 151 | return { |
| 152 | width: view.getUint32(16), |
| 153 | height: view.getUint32(20), |
| 154 | bitDepth: view.getUint8(24), |
| 155 | colorType, |
| 156 | hasAlpha: colorType === 4 || colorType === 6, |
| 157 | }; |
| 158 | } |
| 159 | |
| 160 | export function readPngDimensions(bytes: Uint8Array): { |
| 161 | readonly width: number; |
| 162 | readonly height: number; |
| 163 | } { |
| 164 | const { width, height } = readPngMetadata(bytes); |
| 165 | return { width, height }; |
| 166 | } |
| 167 | |
| 168 | export function normalizeStorePng(bytes: Uint8Array): Buffer { |
| 169 | const png = PNG.sync.read(Buffer.from(bytes)); |
| 170 | return PNG.sync.write(png, { |
| 171 | bitDepth: 8, |
| 172 | colorType: 2, |
| 173 | inputColorType: 6, |
| 174 | inputHasAlpha: true, |
| 175 | }); |
| 176 | } |
| 177 | |
| 178 | export function validateStoreAsset( |
| 179 | spec: ShowcaseStoreAssetSpec, |
| 180 | bytes: Uint8Array, |
| 181 | label = "Screenshot", |
| 182 | ): PngMetadata { |
| 183 | const metadata = readPngMetadata(bytes); |
| 184 | if (metadata.width !== spec.width || metadata.height !== spec.height) { |
| 185 | throw new Error( |
| 186 | `${label} is ${metadata.width}×${metadata.height}; ${spec.store} requires ${spec.width}×${spec.height}.`, |
| 187 | ); |
| 188 | } |
| 189 | if (metadata.bitDepth !== 8 || metadata.colorType !== 2 || metadata.hasAlpha) { |
| 190 | throw new Error( |
| 191 | `${label} must be an 8-bit, 24-bit RGB PNG without alpha (found bit depth ${metadata.bitDepth}, color type ${metadata.colorType}).`, |
| 192 | ); |
| 193 | } |
| 194 | if (spec.maximumFileSizeBytes && bytes.byteLength > spec.maximumFileSizeBytes) { |
| 195 | throw new Error( |
| 196 | `${label} is ${bytes.byteLength} bytes; ${spec.store} allows at most ${spec.maximumFileSizeBytes} bytes.`, |
| 197 | ); |
| 198 | } |
| 199 | if (spec.store === "google-play") { |
| 200 | const shortestSide = Math.min(metadata.width, metadata.height); |
| 201 | const longestSide = Math.max(metadata.width, metadata.height); |
| 202 | if (shortestSide < 320 || longestSide > 3_840 || longestSide > shortestSide * 2) { |
| 203 | throw new Error( |
| 204 | `${label} does not meet Google Play's 320–3,840 px bounds and 2:1 maximum aspect ratio.`, |
| 205 | ); |
| 206 | } |
| 207 | if (metadata.width * 16 !== metadata.height * 9) { |
| 208 | throw new Error(`${label} must use Google Play's recommended portrait 9:16 aspect ratio.`); |
| 209 | } |
| 210 | } |
| 211 | return metadata; |
| 212 | } |
| 213 | |
| 214 | export function validateStoreAssetCount( |
| 215 | spec: ShowcaseStoreAssetSpec, |
| 216 | count: number, |
| 217 | requireMinimum: boolean, |
| 218 | ): void { |
| 219 | if (count > spec.maximumUploadCount) { |
| 220 | throw new Error( |
| 221 | `${spec.directory} contains ${count} screenshots; ${spec.store} allows at most ${spec.maximumUploadCount}.`, |
| 222 | ); |
| 223 | } |
| 224 | if (requireMinimum && count < spec.minimumUploadCount) { |
| 225 | throw new Error( |
| 226 | `${spec.directory} contains ${count} screenshots; ${spec.store} requires at least ${spec.minimumUploadCount}.`, |
| 227 | ); |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | export function showcaseCaptureDirectory( |
| 232 | outputDirectory: string, |
| 233 | capture: Pick<ShowcaseCapture, "device" | "appearance" | "theme">, |
| 234 | ): string { |
| 235 | // Each palette owns a leaf folder so one upload slot never mixes themes and |
| 236 | // every folder keeps a store-legal screenshot count of its own. |
| 237 | return NodePath.join( |
| 238 | outputDirectory, |
| 239 | capture.device.storeAsset.directory, |
| 240 | capture.appearance, |
| 241 | capture.theme, |
| 242 | ); |
| 243 | } |
| 244 | |
| 245 | async function finalizeCapture(destination: string, device: ShowcaseDevice): Promise<void> { |
| 246 | const normalized = normalizeStorePng(await NodeFSP.readFile(destination)); |
| 247 | await NodeFSP.writeFile(destination, normalized); |
| 248 | const metadata = validateStoreAsset( |
| 249 | device.storeAsset, |
| 250 | normalized, |
| 251 | NodePath.basename(destination), |
| 252 | ); |
| 253 | NodeProcess.stdout.write( |
| 254 | `Captured ${NodePath.relative(REPO_ROOT, destination)} (${metadata.width}×${metadata.height}, 24-bit RGB, validated for ${device.storeAsset.store})\n`, |
| 255 | ); |
| 256 | } |
| 257 | |
| 258 | async function validateCaptureSet( |
| 259 | capture: ShowcaseCapture, |
| 260 | outputDirectory: string, |
| 261 | requireMinimum: boolean, |
| 262 | ): Promise<void> { |
| 263 | const directory = showcaseCaptureDirectory(outputDirectory, capture); |
| 264 | const files = (await NodeFSP.readdir(directory)).filter((file) => file.endsWith(".png")).sort(); |
| 265 | const expectedFiles = capture.scenes.map((scene) => `${scene}.png`).sort(); |
| 266 | const missingFiles = expectedFiles.filter((file) => !files.includes(file)); |
| 267 | if (missingFiles.length > 0) { |
| 268 | throw new Error(`${capture.device.id} is missing ${missingFiles.join(", ")} in ${directory}.`); |
| 269 | } |
| 270 | validateStoreAssetCount(capture.device.storeAsset, files.length, requireMinimum); |
| 271 | for (const file of files) { |
| 272 | const bytes = await NodeFSP.readFile(NodePath.join(directory, file)); |
| 273 | validateStoreAsset(capture.device.storeAsset, bytes, `${capture.device.id}/${file}`); |
| 274 | } |
| 275 | NodeProcess.stdout.write( |
| 276 | `Validated ${files.length} upload-ready ${capture.device.storeAsset.store} screenshots in ${NodePath.relative(REPO_ROOT, directory)}/\n`, |
| 277 | ); |
| 278 | } |
| 279 | |
| 280 | function argumentValue(args: ReadonlyArray<string>, index: number, flag: string): string { |
| 281 | const value = args[index + 1]; |
| 282 | if (!value || value.startsWith("--")) { |
| 283 | throw new Error(`${flag} requires a value.`); |
| 284 | } |
| 285 | return value; |
| 286 | } |
| 287 | |
| 288 | export function parseShowcaseCliArgs(args: ReadonlyArray<string>): CliOptions { |
| 289 | const platforms = new Set<ShowcaseDevice["platform"]>(); |
| 290 | const deviceIds = new Set<string>(); |
| 291 | const scenes = new Set<ShowcaseScene>(); |
| 292 | const appearances = new Set<ShowcaseAppearance>(); |
| 293 | const themes = new Set<ShowcaseTheme>(); |
| 294 | let skipBuild = false; |
| 295 | let skipMetro = false; |
| 296 | let keepRunning = false; |
| 297 | let validateOnly = false; |
| 298 | let list = false; |
| 299 | |
| 300 | for (let index = 0; index < args.length; index += 1) { |
| 301 | const argument = args[index]; |
| 302 | if (argument === "--platform") { |
| 303 | const value = argumentValue(args, index, argument); |
| 304 | if (value !== "ios" && value !== "android" && value !== "all") { |
| 305 | throw new Error(`Unsupported platform '${value}'. Use ios, android, or all.`); |
| 306 | } |
| 307 | if (value === "all") { |
| 308 | platforms.add("ios"); |
| 309 | platforms.add("android"); |
| 310 | } else { |
| 311 | platforms.add(value); |
| 312 | } |
| 313 | index += 1; |
| 314 | } else if (argument === "--device") { |
| 315 | deviceIds.add(argumentValue(args, index, argument)); |
| 316 | index += 1; |
| 317 | } else if (argument === "--scene") { |
| 318 | const value = argumentValue(args, index, argument); |
| 319 | if (!SHOWCASE_SCENES.includes(value as ShowcaseScene)) { |
| 320 | throw new Error(`Unsupported scene '${value}'. Use ${SHOWCASE_SCENES.join(", ")}.`); |
| 321 | } |
| 322 | scenes.add(value as ShowcaseScene); |
| 323 | index += 1; |
| 324 | } else if (argument === "--appearance") { |
| 325 | const value = argumentValue(args, index, argument); |
| 326 | if (value !== "light" && value !== "dark" && value !== "both") { |
| 327 | throw new Error(`Unsupported appearance '${value}'. Use light, dark, or both.`); |
| 328 | } |
| 329 | if (value === "both") { |
| 330 | appearances.add("light"); |
| 331 | appearances.add("dark"); |
| 332 | } else { |
| 333 | appearances.add(value); |
| 334 | } |
| 335 | index += 1; |
| 336 | } else if (argument === "--theme") { |
| 337 | const value = argumentValue(args, index, argument); |
| 338 | if (value === "all") { |
| 339 | for (const theme of SHOWCASE_THEMES) themes.add(theme); |
| 340 | } else if (SHOWCASE_THEMES.some((theme) => theme === value)) { |
| 341 | themes.add(value as ShowcaseTheme); |
| 342 | } else { |
| 343 | // The app silently falls back to its default palette for an unknown id, |
| 344 | // so reject it here rather than shipping a mislabeled screenshot. |
| 345 | throw new Error(`Unsupported theme '${value}'. Use ${SHOWCASE_THEMES.join(", ")}, or all.`); |
| 346 | } |
| 347 | index += 1; |
| 348 | } else if (argument === "--skip-build") { |
| 349 | skipBuild = true; |
| 350 | } else if (argument === "--skip-metro") { |
| 351 | skipMetro = true; |
| 352 | } else if (argument === "--keep-running") { |
| 353 | keepRunning = true; |
| 354 | } else if (argument === "--validate-only") { |
| 355 | validateOnly = true; |
| 356 | } else if (argument === "--list") { |
| 357 | list = true; |
| 358 | } else if (argument === "--help" || argument === "-h") { |
| 359 | list = true; |
| 360 | } else { |
| 361 | throw new Error(`Unknown option '${argument}'.`); |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | return { |
| 366 | platforms, |
| 367 | deviceIds, |
| 368 | scenes, |
| 369 | appearances, |
| 370 | themes, |
| 371 | skipBuild, |
| 372 | skipMetro, |
| 373 | keepRunning, |
| 374 | validateOnly, |
| 375 | list, |
| 376 | }; |
| 377 | } |
| 378 | |
| 379 | export function planShowcaseCaptures( |
| 380 | config: ShowcaseConfig, |
| 381 | options: Pick<CliOptions, "platforms" | "deviceIds" | "scenes" | "appearances" | "themes">, |
| 382 | ): ReadonlyArray<ShowcaseCapture> { |
| 383 | const captures = config.devices |
| 384 | .filter((device) => options.platforms.size === 0 || options.platforms.has(device.platform)) |
| 385 | .filter((device) => options.deviceIds.size === 0 || options.deviceIds.has(device.id)) |
| 386 | .flatMap((device) => { |
| 387 | const appearances = |
| 388 | options.appearances.size === 0 ? [device.appearance] : options.appearances; |
| 389 | const themes = options.themes.size === 0 ? [device.theme] : options.themes; |
| 390 | return [...appearances].flatMap((appearance) => |
| 391 | [...themes].map((theme) => ({ |
| 392 | device, |
| 393 | appearance, |
| 394 | theme, |
| 395 | scenes: |
| 396 | options.scenes.size === 0 |
| 397 | ? device.scenes |
| 398 | : device.scenes.filter((scene) => options.scenes.has(scene)), |
| 399 | })), |
| 400 | ); |
| 401 | }) |
| 402 | .filter((capture) => capture.scenes.length > 0); |
| 403 | |
| 404 | const knownDeviceIds = new Set(config.devices.map((device) => device.id)); |
| 405 | for (const id of options.deviceIds) { |
| 406 | if (!knownDeviceIds.has(id)) { |
| 407 | throw new Error(`Unknown device '${id}'. Run with --list to see configured devices.`); |
| 408 | } |
| 409 | } |
| 410 | if (captures.length === 0) { |
| 411 | throw new Error("No captures match the selected platform, device, and scene filters."); |
| 412 | } |
| 413 | return captures; |
| 414 | } |
| 415 | |
| 416 | function printUsage(config: ShowcaseConfig): void { |
| 417 | NodeProcess.stdout.write(`App screenshot showcase |
| 418 | |
| 419 | Usage: |
| 420 | pnpm --filter @t3tools/mobile screenshots [options] |
| 421 | |
| 422 | Options: |
| 423 | --platform ios|android|all Capture one platform (repeatable) |
| 424 | --device <id> Capture one configured device (repeatable) |
| 425 | --scene <name> Capture one scene (repeatable) |
| 426 | --appearance light|dark|both |
| 427 | Override the configured appearance |
| 428 | --theme <id>|all Override the configured palette (repeatable) |
| 429 | --skip-build Reuse the existing simulator app / debug APK |
| 430 | --skip-metro Reuse an already running showcase Metro server |
| 431 | --keep-running Leave devices and Metro running after capture |
| 432 | --validate-only Validate existing upload assets without capturing |
| 433 | --list Print this help and the configured matrix |
| 434 | |
| 435 | Scenes: ${SHOWCASE_SCENES.join(", ")} |
| 436 | Themes: ${SHOWCASE_THEMES.join(", ")} |
| 437 | |
| 438 | Configured devices: |
| 439 | ${config.devices |
| 440 | .map((device) => { |
| 441 | const target = device.platform === "ios" ? device.simulator : device.avd; |
| 442 | return ` ${device.id.padEnd(18)} ${device.platform.padEnd(8)} ${target} -> ${device.storeAsset.directory}/{light|dark}/<theme> (${device.storeAsset.width}×${device.storeAsset.height}, default ${device.appearance} ${device.theme}) [${device.scenes.join(", ")}]`; |
| 443 | }) |
| 444 | .join("\n")} |
| 445 | `); |
| 446 | } |
| 447 | |
| 448 | function spawnProcess( |
| 449 | command: string, |
| 450 | args: ReadonlyArray<string>, |
| 451 | options: NodeChildProcess.SpawnOptions = {}, |
| 452 | ): NodeChildProcess.ChildProcess { |
| 453 | return NodeChildProcess.spawn(command, args, { |
| 454 | cwd: REPO_ROOT, |
| 455 | env: NodeProcess.env, |
| 456 | stdio: "inherit", |
| 457 | ...options, |
| 458 | }); |
| 459 | } |
| 460 | |
| 461 | async function runCommand( |
| 462 | command: string, |
| 463 | args: ReadonlyArray<string>, |
| 464 | options: NodeChildProcess.SpawnOptions = {}, |
| 465 | ): Promise<void> { |
| 466 | await new Promise<void>((resolve, reject) => { |
| 467 | const child = spawnProcess(command, args, options); |
| 468 | child.once("error", reject); |
| 469 | child.once("exit", (code, signal) => { |
| 470 | if (code === 0) { |
| 471 | resolve(); |
| 472 | } else { |
| 473 | reject( |
| 474 | new Error( |
| 475 | `${command} ${args.join(" ")} failed ${signal ? `with signal ${signal}` : `with code ${String(code)}`}.`, |
| 476 | ), |
| 477 | ); |
| 478 | } |
| 479 | }); |
| 480 | }); |
| 481 | } |
| 482 | |
| 483 | async function commandOutput( |
| 484 | command: string, |
| 485 | args: ReadonlyArray<string>, |
| 486 | options: NodeChildProcess.ExecFileOptions = {}, |
| 487 | ): Promise<string> { |
| 488 | return await new Promise<string>((resolve, reject) => { |
| 489 | NodeChildProcess.execFile( |
| 490 | command, |
| 491 | [...args], |
| 492 | { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, ...options }, |
| 493 | (error, stdout) => { |
| 494 | if (error) reject(error); |
| 495 | else resolve(String(stdout)); |
| 496 | }, |
| 497 | ); |
| 498 | }); |
| 499 | } |
| 500 | |
| 501 | function delay(milliseconds: number): Promise<void> { |
| 502 | return new Promise((resolve) => setTimeout(resolve, milliseconds)); |
| 503 | } |
| 504 | |
| 505 | async function stopProcess(child: NodeChildProcess.ChildProcess): Promise<void> { |
| 506 | if (child.exitCode !== null || child.signalCode !== null) return; |
| 507 | |
| 508 | const exited = new Promise<void>((resolve) => { |
| 509 | child.once("exit", () => resolve()); |
| 510 | }); |
| 511 | child.kill("SIGTERM"); |
| 512 | await Promise.race([exited, delay(5_000)]); |
| 513 | if (child.exitCode !== null || child.signalCode !== null) return; |
| 514 | |
| 515 | child.kill("SIGKILL"); |
| 516 | await Promise.race([exited, delay(1_000)]); |
| 517 | } |
| 518 | |
| 519 | async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise<void> { |
| 520 | const deadline = Date.now() + timeoutMs; |
| 521 | while (Date.now() < deadline) { |
| 522 | const open = await new Promise<boolean>((resolve) => { |
| 523 | const socket = NodeNet.createConnection({ host: "127.0.0.1", port }); |
| 524 | socket.once("connect", () => { |
| 525 | socket.destroy(); |
| 526 | resolve(true); |
| 527 | }); |
| 528 | socket.once("error", () => resolve(false)); |
| 529 | socket.setTimeout(500, () => { |
| 530 | socket.destroy(); |
| 531 | resolve(false); |
| 532 | }); |
| 533 | }); |
| 534 | if (open) return; |
| 535 | await delay(500); |
| 536 | } |
| 537 | throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`); |
| 538 | } |
| 539 | |
| 540 | async function waitForFileContent( |
| 541 | filePath: string, |
| 542 | label: string, |
| 543 | timeoutMs = 60_000, |
| 544 | ): Promise<string> { |
| 545 | const deadline = Date.now() + timeoutMs; |
| 546 | while (Date.now() < deadline) { |
| 547 | const content = await NodeFSP.readFile(filePath, "utf8").then( |
| 548 | (value) => value.trim(), |
| 549 | () => "", |
| 550 | ); |
| 551 | if (content) return content; |
| 552 | await delay(250); |
| 553 | } |
| 554 | throw new Error(`${label} was not written to ${filePath} within ${timeoutMs}ms.`); |
| 555 | } |
| 556 | |
| 557 | async function reserveAvailablePort(): Promise<number> { |
| 558 | return await new Promise<number>((resolve, reject) => { |
| 559 | const server = NodeNet.createServer(); |
| 560 | server.once("error", reject); |
| 561 | server.listen(0, "127.0.0.1", () => { |
| 562 | const address = server.address(); |
| 563 | if (!address || typeof address === "string") { |
| 564 | server.close(); |
| 565 | reject(new Error("Could not reserve a local port for the showcase environment.")); |
| 566 | return; |
| 567 | } |
| 568 | server.close((error) => (error ? reject(error) : resolve(address.port))); |
| 569 | }); |
| 570 | }); |
| 571 | } |
| 572 | |
| 573 | async function createShowcaseShell(baseDir: string): Promise<string> { |
| 574 | const shellPath = NodePath.join(baseDir, "showcase-shell"); |
| 575 | await NodeFSP.writeFile( |
| 576 | shellPath, |
| 577 | `#!/bin/sh |
| 578 | if [ "$1" = "-ilc" ] || [ "$1" = "-lic" ]; then |
| 579 | exec /bin/sh -c "$2" |
| 580 | fi |
| 581 | exec /bin/cat |
| 582 | `, |
| 583 | { mode: 0o755 }, |
| 584 | ); |
| 585 | return shellPath; |
| 586 | } |
| 587 | |
| 588 | async function createShowcaseLabelProbe(baseDir: string, label: string): Promise<string> { |
| 589 | const binDirectory = NodePath.join(baseDir, "showcase-bin"); |
| 590 | await NodeFSP.mkdir(binDirectory, { recursive: true }); |
| 591 | const probeScript = `#!/bin/sh |
| 592 | if [ "$1" = "--get" ] && [ "$2" = "ComputerName" ]; then |
| 593 | printf '%s\\n' ${JSON.stringify(label)} |
| 594 | exit 0 |
| 595 | fi |
| 596 | if [ "$1" = "--pretty" ]; then |
| 597 | printf '%s\\n' ${JSON.stringify(label)} |
| 598 | exit 0 |
| 599 | fi |
| 600 | exit 1 |
| 601 | `; |
| 602 | await Promise.all( |
| 603 | ["scutil", "hostnamectl"].map((executable) => |
| 604 | NodeFSP.writeFile(NodePath.join(binDirectory, executable), probeScript, { mode: 0o755 }), |
| 605 | ), |
| 606 | ); |
| 607 | return binDirectory; |
| 608 | } |
| 609 | |
| 610 | function startShowcaseServer( |
| 611 | baseDir: string, |
| 612 | workspaceRoot: string, |
| 613 | port: number, |
| 614 | shellPath: string, |
| 615 | labelProbeDirectory: string, |
| 616 | ): NodeChildProcess.ChildProcess { |
| 617 | return spawnProcess( |
| 618 | "node", |
| 619 | [ |
| 620 | "apps/server/src/bin.ts", |
| 621 | "serve", |
| 622 | "--host", |
| 623 | SERVER_HOST, |
| 624 | "--port", |
| 625 | String(port), |
| 626 | "--base-dir", |
| 627 | baseDir, |
| 628 | "--no-browser", |
| 629 | "--log-level", |
| 630 | "error", |
| 631 | workspaceRoot, |
| 632 | ], |
| 633 | { |
| 634 | env: { |
| 635 | ...NodeProcess.env, |
| 636 | PATH: `${labelProbeDirectory}:${NodeProcess.env.PATH ?? ""}`, |
| 637 | SHELL: shellPath, |
| 638 | }, |
| 639 | }, |
| 640 | ); |
| 641 | } |
| 642 | |
| 643 | export function parsePairingCredentialOutput(output: string): string { |
| 644 | const jsonStart = output.indexOf("{"); |
| 645 | const jsonEnd = output.lastIndexOf("}"); |
| 646 | if (jsonStart === -1 || jsonEnd < jsonStart) { |
| 647 | throw new Error("Pairing credential command did not return JSON."); |
| 648 | } |
| 649 | const parsed = JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as { |
| 650 | readonly credential?: unknown; |
| 651 | }; |
| 652 | if (typeof parsed.credential !== "string" || parsed.credential.length === 0) { |
| 653 | throw new Error("Pairing credential command returned no credential."); |
| 654 | } |
| 655 | return parsed.credential; |
| 656 | } |
| 657 | |
| 658 | async function issuePairingCredential(baseDir: string): Promise<string> { |
| 659 | const output = await commandOutput( |
| 660 | "node", |
| 661 | ["apps/server/src/bin.ts", "auth", "pairing", "create", "--base-dir", baseDir, "--json"], |
| 662 | { env: { ...NodeProcess.env, NO_COLOR: "1" } }, |
| 663 | ); |
| 664 | return parsePairingCredentialOutput(output); |
| 665 | } |
| 666 | |
| 667 | function buildShowcasePairingUrl(host: string, port: number, credential: string): string { |
| 668 | const url = new URL(`http://${host}:${port}/`); |
| 669 | url.hash = new URLSearchParams([["token", credential]]).toString(); |
| 670 | return url.toString(); |
| 671 | } |
| 672 | |
| 673 | export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray<string>): string { |
| 674 | return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`; |
| 675 | } |
| 676 | |
| 677 | function startMetro(config: ShowcaseConfig): NodeChildProcess.ChildProcess { |
| 678 | return spawnProcess( |
| 679 | "pnpm", |
| 680 | ["exec", "expo", "start", "--dev-client", "--port", String(config.metroPort)], |
| 681 | { |
| 682 | cwd: MOBILE_ROOT, |
| 683 | env: { |
| 684 | ...MOBILE_BUILD_ENV, |
| 685 | EXPO_PUBLIC_SHOWCASE: "1", |
| 686 | }, |
| 687 | }, |
| 688 | ); |
| 689 | } |
| 690 | |
| 691 | async function warmMetroBundle( |
| 692 | platform: ShowcaseDevice["platform"], |
| 693 | host: string, |
| 694 | config: ShowcaseConfig, |
| 695 | ): Promise<void> { |
| 696 | const url = `http://${host}:${config.metroPort}/apps/mobile/index.bundle?platform=${platform}&dev=true&minify=false`; |
| 697 | await runCommand("curl", ["--fail", "--silent", "--show-error", "--output", "/dev/null", url]); |
| 698 | } |
| 699 | |
| 700 | async function buildIos(): Promise<string> { |
| 701 | const derivedData = NodePath.join(MOBILE_ROOT, ".showcase/ios-derived-data"); |
| 702 | await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "ios"], { |
| 703 | cwd: MOBILE_ROOT, |
| 704 | env: MOBILE_BUILD_ENV, |
| 705 | }); |
| 706 | await runCommand( |
| 707 | "xcodebuild", |
| 708 | [ |
| 709 | "-workspace", |
| 710 | NodePath.join(MOBILE_ROOT, "ios/TheKnife.xcworkspace"), |
| 711 | "-scheme", |
| 712 | "TheKnife", |
| 713 | "-configuration", |
| 714 | "Debug", |
| 715 | "-sdk", |
| 716 | "iphonesimulator", |
| 717 | "-derivedDataPath", |
| 718 | derivedData, |
| 719 | "-quiet", |
| 720 | `ARCHS=${IOS_SIMULATOR_ARCH}`, |
| 721 | "ONLY_ACTIVE_ARCH=YES", |
| 722 | "build", |
| 723 | ], |
| 724 | { cwd: MOBILE_ROOT, env: MOBILE_BUILD_ENV }, |
| 725 | ); |
| 726 | return IOS_APP_PATH; |
| 727 | } |
| 728 | |
| 729 | async function buildAndroid(abis: ReadonlyArray<string>): Promise<string> { |
| 730 | await runCommand("pnpm", ["exec", "expo", "prebuild", "--clean", "--platform", "android"], { |
| 731 | cwd: MOBILE_ROOT, |
| 732 | env: MOBILE_BUILD_ENV, |
| 733 | }); |
| 734 | await runCommand( |
| 735 | "./gradlew", |
| 736 | [ |
| 737 | "app:assembleDebug", |
| 738 | ...(abis.length > 0 ? [`-PreactNativeArchitectures=${abis.join(",")}`] : []), |
| 739 | ], |
| 740 | { |
| 741 | cwd: NodePath.join(MOBILE_ROOT, "android"), |
| 742 | env: MOBILE_BUILD_ENV, |
| 743 | }, |
| 744 | ); |
| 745 | return ANDROID_APK_PATH; |
| 746 | } |
| 747 | |
| 748 | async function existingArtifact(path: string): Promise<string | null> { |
| 749 | return await NodeFSP.access(path).then( |
| 750 | () => path, |
| 751 | () => null, |
| 752 | ); |
| 753 | } |
| 754 | |
| 755 | interface SimctlDevice { |
| 756 | readonly name: string; |
| 757 | readonly udid: string; |
| 758 | readonly state: "Booted" | "Shutdown" | string; |
| 759 | readonly isAvailable: boolean; |
| 760 | } |
| 761 | |
| 762 | async function findIosSimulator(name: string): Promise<SimctlDevice | null> { |
| 763 | const parsed = JSON.parse( |
| 764 | await commandOutput("xcrun", ["simctl", "list", "devices", "available", "-j"]), |
| 765 | ) as { |
| 766 | readonly devices: Readonly<Record<string, ReadonlyArray<SimctlDevice>>>; |
| 767 | }; |
| 768 | const candidates = Object.entries(parsed.devices) |
| 769 | .filter(([runtime]) => runtime.includes("iOS")) |
| 770 | .flatMap(([, devices]) => devices) |
| 771 | .filter((device) => device.isAvailable && device.name === name); |
| 772 | return candidates.at(-1) ?? null; |
| 773 | } |
| 774 | |
| 775 | async function ensureIosSimulator(device: ShowcaseIosDevice): Promise<{ |
| 776 | readonly simulator: SimctlDevice; |
| 777 | readonly createdByRunner: boolean; |
| 778 | }> { |
| 779 | const existing = await findIosSimulator(device.simulator); |
| 780 | if (existing) return { simulator: existing, createdByRunner: false }; |
| 781 | if (!device.simulatorDeviceType) { |
| 782 | throw new Error( |
| 783 | `iOS simulator '${device.simulator}' is not installed and has no simulatorDeviceType configured.`, |
| 784 | ); |
| 785 | } |
| 786 | const udid = ( |
| 787 | await commandOutput("xcrun", ["simctl", "create", device.simulator, device.simulatorDeviceType]) |
| 788 | ).trim(); |
| 789 | if (!udid) throw new Error(`Could not create iOS simulator '${device.simulator}'.`); |
| 790 | return { |
| 791 | simulator: { |
| 792 | name: device.simulator, |
| 793 | udid, |
| 794 | state: "Shutdown", |
| 795 | isAvailable: true, |
| 796 | }, |
| 797 | createdByRunner: true, |
| 798 | }; |
| 799 | } |
| 800 | |
| 801 | async function iosSimulatorDataPath(udid: string): Promise<string> { |
| 802 | const parsed = JSON.parse(await commandOutput("xcrun", ["simctl", "list", "devices", "-j"])) as { |
| 803 | readonly devices: Readonly< |
| 804 | Record<string, ReadonlyArray<SimctlDevice & { readonly dataPath: string }>> |
| 805 | >; |
| 806 | }; |
| 807 | const dataPath = Object.values(parsed.devices) |
| 808 | .flat() |
| 809 | .find((device) => device.udid === udid)?.dataPath; |
| 810 | if (!dataPath) throw new Error(`Could not resolve the data path of iOS simulator ${udid}.`); |
| 811 | return dataPath; |
| 812 | } |
| 813 | |
| 814 | // generativeexperiencesd posts a "Ready for Apple Intelligence" follow-up |
| 815 | // banner on an eligible device's first boot, and CoreFollowUp re-surfaces it |
| 816 | // on every boot until the user dismisses it. Stamping the readiness marker |
| 817 | // before boot makes the daemon skip the post, and dropping the CoreFollowUp |
| 818 | // store clears a banner that a previous boot already queued. Runs while the |
| 819 | // device is shut down so the files are read fresh on the next boot. |
| 820 | async function suppressIosSystemFollowUps(udid: string): Promise<void> { |
| 821 | const dataPath = await iosSimulatorDataPath(udid); |
| 822 | const preferences = NodePath.join(dataPath, "Library/Preferences"); |
| 823 | await NodeFSP.mkdir(preferences, { recursive: true }); |
| 824 | await NodeFSP.writeFile( |
| 825 | NodePath.join(preferences, "com.apple.generativeexperiences.corefollowup.plist"), |
| 826 | `<?xml version="1.0" encoding="UTF-8"?> |
| 827 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 828 | <plist version="1.0"> |
| 829 | <dict> |
| 830 | \t<key>DateOfLastAppleIntelligenceReadinessCFU</key> |
| 831 | \t<date>2020-01-01T00:00:00Z</date> |
| 832 | </dict> |
| 833 | </plist> |
| 834 | `, |
| 835 | ); |
| 836 | await NodeFSP.rm(NodePath.join(dataPath, "Library/CoreFollowUp"), { |
| 837 | recursive: true, |
| 838 | force: true, |
| 839 | }); |
| 840 | } |
| 841 | |
| 842 | async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: string): Promise<void> { |
| 843 | await runCommand("xcrun", ["simctl", "ui", udid, "appearance", appearance]); |
| 844 | // Always-on displays (Pro Max) dim a locked screen instead of turning it |
| 845 | // off, which the lock-screen wake cannot tell from a lit one. Without it the |
| 846 | // locked display goes dark and the wake lights it fully. |
| 847 | await runCommand("xcrun", [ |
| 848 | "simctl", |
| 849 | "spawn", |
| 850 | udid, |
| 851 | "defaults", |
| 852 | "write", |
| 853 | "com.apple.springboard", |
| 854 | "SBEnableAlwaysOn", |
| 855 | "-bool", |
| 856 | "false", |
| 857 | ]); |
| 858 | await runCommand("xcrun", [ |
| 859 | "simctl", |
| 860 | "status_bar", |
| 861 | udid, |
| 862 | "override", |
| 863 | "--time", |
| 864 | "9:41", |
| 865 | "--batteryState", |
| 866 | "charged", |
| 867 | "--batteryLevel", |
| 868 | "100", |
| 869 | "--wifiBars", |
| 870 | "3", |
| 871 | "--cellularBars", |
| 872 | "4", |
| 873 | ]); |
| 874 | } |
| 875 | |
| 876 | // iPadOS 26 windowing ("Chamois") runs UIRequiresFullScreen apps in a fixed |
| 877 | // portrait compatibility window, which defeats the in-app landscape rotation |
| 878 | // the capture build relies on. Switch the device to Full Screen Apps mode |
| 879 | // (Settings > Multitasking & Gestures) and restart SpringBoard to apply it. |
| 880 | async function ensureIosFullScreenAppsMode(udid: string): Promise<void> { |
| 881 | const current = await commandOutput("xcrun", [ |
| 882 | "simctl", |
| 883 | "spawn", |
| 884 | udid, |
| 885 | "defaults", |
| 886 | "read", |
| 887 | "com.apple.springboard", |
| 888 | "SBChamoisWindowingEnabled", |
| 889 | ]).catch(() => ""); |
| 890 | if (current.trim() === "0") return; |
| 891 | // The Settings toggle writes all three keys; SBChamoisWindowingEnabled |
| 892 | // alone is not honored on a freshly created device. |
| 893 | for (const key of [ |
| 894 | "SBChamoisWindowingEnabled", |
| 895 | "SBMedusaMultitaskingEnabled", |
| 896 | "SBFlexibleWindowingPreviouslyEnabledAutomaticStageCreation", |
| 897 | ]) { |
| 898 | await runCommand("xcrun", [ |
| 899 | "simctl", |
| 900 | "spawn", |
| 901 | udid, |
| 902 | "defaults", |
| 903 | "write", |
| 904 | "com.apple.springboard", |
| 905 | key, |
| 906 | "-bool", |
| 907 | "false", |
| 908 | ]); |
| 909 | } |
| 910 | // A SpringBoard restart is not enough on a freshly created simulator (the |
| 911 | // first CI run captured with windowing still active), so reboot the device |
| 912 | // and verify the mode actually stuck. |
| 913 | await runCommand("xcrun", ["simctl", "shutdown", udid]); |
| 914 | await runCommand("xcrun", ["simctl", "boot", udid]); |
| 915 | await runCommand("xcrun", ["simctl", "bootstatus", udid, "-b"]); |
| 916 | const applied = await commandOutput("xcrun", [ |
| 917 | "simctl", |
| 918 | "spawn", |
| 919 | udid, |
| 920 | "defaults", |
| 921 | "read", |
| 922 | "com.apple.springboard", |
| 923 | "SBChamoisWindowingEnabled", |
| 924 | ]).catch(() => ""); |
| 925 | if (applied.trim() !== "0") { |
| 926 | throw new Error(`Simulator ${udid} did not switch to Full Screen Apps mode.`); |
| 927 | } |
| 928 | } |
| 929 | |
| 930 | async function iosAppContainer(udid: string): Promise<string> { |
| 931 | return ( |
| 932 | await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) |
| 933 | ).trim(); |
| 934 | } |
| 935 | |
| 936 | async function waitForIosShowcaseScene( |
| 937 | udid: string, |
| 938 | scene: ShowcaseScene, |
| 939 | timeoutMs = 90_000, |
| 940 | ): Promise<void> { |
| 941 | const readyPath = NodePath.join( |
| 942 | await iosAppContainer(udid), |
| 943 | "Library/Caches", |
| 944 | IOS_READY_FILENAME, |
| 945 | ); |
| 946 | const deadline = Date.now() + timeoutMs; |
| 947 | while (Date.now() < deadline) { |
| 948 | const readyScene = await NodeFSP.readFile(readyPath, "utf8").catch(() => ""); |
| 949 | if (readyScene.trim() === scene) return; |
| 950 | await delay(500); |
| 951 | } |
| 952 | throw new Error(`iOS showcase scene '${scene}' did not render within ${timeoutMs}ms.`); |
| 953 | } |
| 954 | |
| 955 | function iosAxe(): string { |
| 956 | return NodeProcess.env.AXE_PATH ?? "axe"; |
| 957 | } |
| 958 | |
| 959 | async function runAxe(udid: string, args: ReadonlyArray<string>): Promise<void> { |
| 960 | // HID events sent right after the simulator settles are dropped without it. |
| 961 | await runCommand(iosAxe(), [...args, "--udid", udid], { |
| 962 | env: { ...NodeProcess.env, AXE_HID_STABILIZATION_MS: "3000" }, |
| 963 | }).catch((error: unknown) => { |
| 964 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { |
| 965 | throw new Error( |
| 966 | "The agent-activity scene drives the simulator with AXe. Install it with `brew tap cameroncooke/axe && brew install axe`, or set AXE_PATH.", |
| 967 | ); |
| 968 | } |
| 969 | throw error; |
| 970 | }); |
| 971 | } |
| 972 | |
| 973 | async function iosSimulatorLocked(udid: string): Promise<boolean> { |
| 974 | const state = await commandOutput("xcrun", [ |
| 975 | "simctl", |
| 976 | "spawn", |
| 977 | udid, |
| 978 | "notifyutil", |
| 979 | "-g", |
| 980 | "com.apple.springboard.lockstate", |
| 981 | ]).catch(() => ""); |
| 982 | return state.trim().endsWith(" 1"); |
| 983 | } |
| 984 | |
| 985 | /** |
| 986 | * The staging app asks for notification permission; simctl has no privacy |
| 987 | * service for it, so answer the prompt until the scene reports ready. |
| 988 | */ |
| 989 | async function allowIosNotificationsUntilReady(udid: string, ready: Promise<void>): Promise<void> { |
| 990 | const state = { settled: false }; |
| 991 | const tapping = (async () => { |
| 992 | while (!state.settled) { |
| 993 | await runAxe(udid, ["tap", "--label", "Allow"]).catch(() => undefined); |
| 994 | if (!state.settled) await delay(1_000); |
| 995 | } |
| 996 | })(); |
| 997 | try { |
| 998 | await ready; |
| 999 | } finally { |
| 1000 | state.settled = true; |
| 1001 | await tapping; |
| 1002 | } |
| 1003 | } |
| 1004 | |
| 1005 | /** |
| 1006 | * Locks the simulator over the staged Live Activity and delivers the alert a |
| 1007 | * relay push would, so the lock screen shows both. |
| 1008 | */ |
| 1009 | async function presentIosLockScreen(udid: string): Promise<void> { |
| 1010 | await runAxe(udid, ["button", "lock"]); |
| 1011 | const deadline = Date.now() + 15_000; |
| 1012 | while (!(await iosSimulatorLocked(udid))) { |
| 1013 | if (Date.now() > deadline) throw new Error(`Simulator ${udid} did not lock.`); |
| 1014 | await delay(500); |
| 1015 | } |
| 1016 | const alert = showcaseAgentAlert(); |
| 1017 | await new Promise<void>((resolve, reject) => { |
| 1018 | const child = NodeChildProcess.execFile( |
| 1019 | "xcrun", |
| 1020 | ["simctl", "push", udid, ANDROID_PACKAGE, "-"], |
| 1021 | { cwd: REPO_ROOT }, |
| 1022 | (error) => (error ? reject(error) : resolve()), |
| 1023 | ); |
| 1024 | child.stdin?.end( |
| 1025 | JSON.stringify({ |
| 1026 | aps: { alert: { title: alert.title, body: alert.body }, sound: "default" }, |
| 1027 | }), |
| 1028 | ); |
| 1029 | }); |
| 1030 | await wakeIosLockScreen(udid); |
| 1031 | // The first Live Activity on the lock screen asks to keep allowing them. |
| 1032 | await delay(2_000); |
| 1033 | await runAxe(udid, ["tap", "--label", "Allow"]).catch(() => undefined); |
| 1034 | } |
| 1035 | |
| 1036 | /** |
| 1037 | * The alert usually wakes the display, but not always. A home press on a dark |
| 1038 | * display only wakes it, so press only after a screenshot proves it is dark; |
| 1039 | * pressing on a lit lock screen would unlock the device instead. |
| 1040 | */ |
| 1041 | async function wakeIosLockScreen(udid: string): Promise<void> { |
| 1042 | const probe = NodePath.join(NodeOS.tmpdir(), `t3-showcase-wake-${udid}.png`); |
| 1043 | try { |
| 1044 | for (let attempt = 0; attempt < 5; attempt += 1) { |
| 1045 | await delay(2_000); |
| 1046 | await runCommand("xcrun", ["simctl", "io", udid, "screenshot", probe]); |
| 1047 | if (!pngIsBlack(await NodeFSP.readFile(probe))) return; |
| 1048 | await runAxe(udid, ["button", "home"]); |
| 1049 | } |
| 1050 | throw new Error(`Simulator ${udid} lock screen stayed dark.`); |
| 1051 | } finally { |
| 1052 | await NodeFSP.rm(probe, { force: true }); |
| 1053 | } |
| 1054 | } |
| 1055 | |
| 1056 | function pngIsBlack(bytes: Uint8Array): boolean { |
| 1057 | const { data } = PNG.sync.read(Buffer.from(bytes)); |
| 1058 | // Sample a sparse grid; a sleeping display is uniformly black. |
| 1059 | for (let offset = 0; offset < data.length; offset += 4 * 997) { |
| 1060 | if ((data[offset] ?? 0) > 8 || (data[offset + 1] ?? 0) > 8 || (data[offset + 2] ?? 0) > 8) { |
| 1061 | return false; |
| 1062 | } |
| 1063 | } |
| 1064 | return true; |
| 1065 | } |
| 1066 | |
| 1067 | async function unlockIosSimulator(udid: string): Promise<void> { |
| 1068 | // The first press wakes the display, the second dismisses the lock screen. |
| 1069 | await runAxe(udid, ["button", "home"]); |
| 1070 | await delay(2_000); |
| 1071 | await runAxe(udid, ["button", "home"]); |
| 1072 | } |
| 1073 | |
| 1074 | /** Mirrors the app's staged hero row (showcaseAgentActivity.ts). */ |
| 1075 | function showcaseAgentAlert(): { readonly title: string; readonly body: string } { |
| 1076 | const thread = SHOWCASE_THREADS.find((candidate) => candidate.id === "pocket-command-center"); |
| 1077 | const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === thread?.projectId); |
| 1078 | if (!thread || !project) throw new Error("The showcase fixture lost its agent-activity thread."); |
| 1079 | return { title: thread.title, body: `Approval: ${project.title}` }; |
| 1080 | } |
| 1081 | |
| 1082 | async function captureIos( |
| 1083 | capture: ShowcaseCapture & { readonly device: ShowcaseIosDevice }, |
| 1084 | appPath: string | null, |
| 1085 | outputDirectory: string, |
| 1086 | config: ShowcaseConfig, |
| 1087 | metroHost: string, |
| 1088 | pairingUrls: ReadonlyArray<string>, |
| 1089 | registerCleanup: (cleanup: IosCaptureCleanup) => void, |
| 1090 | ): Promise<void> { |
| 1091 | const { simulator, createdByRunner } = await ensureIosSimulator(capture.device); |
| 1092 | const startedByRunner = simulator.state !== "Booted"; |
| 1093 | registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner }); |
| 1094 | if (!startedByRunner) { |
| 1095 | // Clear transient SpringBoard state (permission prompts, stale URL-open |
| 1096 | // confirmations, keyboards) without erasing the developer's simulator. |
| 1097 | await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]); |
| 1098 | } |
| 1099 | await suppressIosSystemFollowUps(simulator.udid); |
| 1100 | await runCommand("xcrun", ["simctl", "boot", simulator.udid]); |
| 1101 | await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]); |
| 1102 | if (capture.device.orientation === "landscape") { |
| 1103 | await ensureIosFullScreenAppsMode(simulator.udid); |
| 1104 | } |
| 1105 | await normalizeIosSimulator(capture.appearance, simulator.udid); |
| 1106 | if (appPath) { |
| 1107 | await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch( |
| 1108 | () => undefined, |
| 1109 | ); |
| 1110 | await runCommand("xcrun", ["simctl", "install", simulator.udid, appPath]); |
| 1111 | } |
| 1112 | |
| 1113 | // The dev-client launch URL carries the dev menu preferences (SDK 58), so |
| 1114 | // nothing is written into the app container ahead of launch. |
| 1115 | const metroUrl = `http://${metroHost}:${config.metroPort}?${DEV_CLIENT_LAUNCH_FLAGS}`; |
| 1116 | const scenePath = NodePath.join( |
| 1117 | await iosAppContainer(simulator.udid), |
| 1118 | "Library/Caches/T3ShowcaseScene", |
| 1119 | ); |
| 1120 | const readyPath = NodePath.join( |
| 1121 | await iosAppContainer(simulator.udid), |
| 1122 | "Library/Caches", |
| 1123 | IOS_READY_FILENAME, |
| 1124 | ); |
| 1125 | const firstScene = capture.scenes[0] ?? "threads"; |
| 1126 | const launchShowcaseApp = async (terminateRunningProcess: boolean) => { |
| 1127 | await runCommand("xcrun", [ |
| 1128 | "simctl", |
| 1129 | "launch", |
| 1130 | ...(terminateRunningProcess ? ["--terminate-running-process"] : []), |
| 1131 | simulator.udid, |
| 1132 | ANDROID_PACKAGE, |
| 1133 | "--initialUrl", |
| 1134 | metroUrl, |
| 1135 | "--showcasePairingUrl", |
| 1136 | JSON.stringify(pairingUrls), |
| 1137 | "--showcaseScene", |
| 1138 | firstScene, |
| 1139 | "--showcaseTheme", |
| 1140 | capture.theme, |
| 1141 | // The app rotates itself; Simulator menu UI scripting needs macOS |
| 1142 | // Accessibility permission that CI runners do not grant to osascript. |
| 1143 | "--showcaseOrientation", |
| 1144 | capture.device.orientation ?? "portrait", |
| 1145 | ]); |
| 1146 | }; |
| 1147 | await NodeFSP.rm(readyPath, { force: true }); |
| 1148 | await NodeFSP.writeFile(scenePath, firstScene); |
| 1149 | await launchShowcaseApp(false); |
| 1150 | for (const [sceneIndex, scene] of capture.scenes.entries()) { |
| 1151 | if (sceneIndex > 0) await NodeFSP.rm(readyPath, { force: true }); |
| 1152 | await NodeFSP.writeFile(scenePath, scene); |
| 1153 | const waitForScene = (timeoutMs?: number) => { |
| 1154 | const ready = waitForIosShowcaseScene(simulator.udid, scene, timeoutMs); |
| 1155 | return scene === "agent-activity" |
| 1156 | ? allowIosNotificationsUntilReady(simulator.udid, ready) |
| 1157 | : ready; |
| 1158 | }; |
| 1159 | if (sceneIndex === 0) { |
| 1160 | for (let attempt = 0; attempt < 2; attempt += 1) { |
| 1161 | const isLastAttempt = attempt === 1; |
| 1162 | try { |
| 1163 | // A freshly installed Expo development build can spend well over 30s |
| 1164 | // applying an already-bundled update after it reaches 100%. Killing it |
| 1165 | // at that point sends the next capture back to the dev launcher. |
| 1166 | await waitForScene(120_000); |
| 1167 | break; |
| 1168 | } catch (error) { |
| 1169 | if (isLastAttempt) throw error; |
| 1170 | await launchShowcaseApp(true); |
| 1171 | } |
| 1172 | } |
| 1173 | } else { |
| 1174 | await waitForScene(); |
| 1175 | } |
| 1176 | if (scene === "agent-activity") await presentIosLockScreen(simulator.udid); |
| 1177 | await delay(scene === "review" ? Math.max(config.settleDelayMs, 8_000) : config.settleDelayMs); |
| 1178 | const destination = NodePath.join( |
| 1179 | showcaseCaptureDirectory(outputDirectory, capture), |
| 1180 | `${scene}.png`, |
| 1181 | ); |
| 1182 | await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); |
| 1183 | if (capture.device.orientation === "landscape") { |
| 1184 | // A headless simulator keeps its display portrait while the rotated app |
| 1185 | // renders sideways inside it; with Simulator.app attached the display |
| 1186 | // itself rotates. Only post-rotate the former. |
| 1187 | const { width, height } = readPngDimensions(await NodeFSP.readFile(destination)); |
| 1188 | if (height > width) { |
| 1189 | await runCommand("sips", ["--rotate", "270", destination]); |
| 1190 | } |
| 1191 | } |
| 1192 | if (scene === "agent-activity") await unlockIosSimulator(simulator.udid); |
| 1193 | await finalizeCapture(destination, capture.device); |
| 1194 | } |
| 1195 | } |
| 1196 | |
| 1197 | function androidSdkTool(relativePath: string): string { |
| 1198 | return NodePath.join(ANDROID_SDK_ROOT, relativePath); |
| 1199 | } |
| 1200 | |
| 1201 | async function adbOutput(serial: string, args: ReadonlyArray<string>): Promise<string> { |
| 1202 | return await commandOutput(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]); |
| 1203 | } |
| 1204 | |
| 1205 | async function runAdb(serial: string, args: ReadonlyArray<string>): Promise<void> { |
| 1206 | await runCommand(androidSdkTool("platform-tools/adb"), ["-s", serial, ...args]); |
| 1207 | } |
| 1208 | |
| 1209 | /** |
| 1210 | * Emulator images post their own ongoing notices (keyboard configured, serial |
| 1211 | * console enabled) that would share the shade with the app's. Snoozing hides |
| 1212 | * them for the capture; they come back on their own an hour later. |
| 1213 | */ |
| 1214 | async function snoozeAndroidSystemNotifications(serial: string): Promise<void> { |
| 1215 | const keys = (await adbOutput(serial, ["shell", "cmd", "notification", "list"])) |
| 1216 | .split("\n") |
| 1217 | .map((line) => line.trim()) |
| 1218 | .filter((key) => key.includes("|") && !key.includes(`|${ANDROID_PACKAGE}|`)); |
| 1219 | for (const key of keys) { |
| 1220 | // adb joins shell args with spaces, so the key needs quoting for the pipes. |
| 1221 | await runAdb(serial, ["shell", `cmd notification snooze --for 3600000 '${key}'`]); |
| 1222 | } |
| 1223 | } |
| 1224 | |
| 1225 | async function runningAndroidAvds(): Promise<ReadonlyMap<string, string>> { |
| 1226 | const adb = androidSdkTool("platform-tools/adb"); |
| 1227 | const devices = (await commandOutput(adb, ["devices"])) |
| 1228 | .split("\n") |
| 1229 | .map((line) => line.trim().split(/\s+/u)) |
| 1230 | .filter((parts) => parts[0]?.startsWith("emulator-") && parts[1] === "device") |
| 1231 | .map((parts) => parts[0] as string); |
| 1232 | const result = new Map<string, string>(); |
| 1233 | for (const serial of devices) { |
| 1234 | const avdName = (await adbOutput(serial, ["emu", "avd", "name"])).split("\n")[0]?.trim(); |
| 1235 | if (avdName) result.set(avdName, serial); |
| 1236 | } |
| 1237 | return result; |
| 1238 | } |
| 1239 | |
| 1240 | async function waitForAndroidSerial(avd: string, timeoutMs = 120_000): Promise<string> { |
| 1241 | const deadline = Date.now() + timeoutMs; |
| 1242 | while (Date.now() < deadline) { |
| 1243 | const serial = (await runningAndroidAvds()).get(avd); |
| 1244 | if (serial) { |
| 1245 | await runAdb(serial, ["wait-for-device"]); |
| 1246 | const bootCompleted = ( |
| 1247 | await adbOutput(serial, ["shell", "getprop", "sys.boot_completed"]) |
| 1248 | ).trim(); |
| 1249 | if (bootCompleted === "1") return serial; |
| 1250 | } |
| 1251 | await delay(1_000); |
| 1252 | } |
| 1253 | throw new Error(`Android AVD '${avd}' did not finish booting within ${timeoutMs}ms.`); |
| 1254 | } |
| 1255 | |
| 1256 | async function normalizeAndroidEmulator( |
| 1257 | device: ShowcaseAndroidDevice, |
| 1258 | appearance: ShowcaseAppearance, |
| 1259 | serial: string, |
| 1260 | ): Promise<void> { |
| 1261 | await runAdb(serial, ["shell", "settings", "put", "global", "window_animation_scale", "0"]); |
| 1262 | await runAdb(serial, ["shell", "settings", "put", "global", "transition_animation_scale", "0"]); |
| 1263 | await runAdb(serial, ["shell", "settings", "put", "global", "animator_duration_scale", "0"]); |
| 1264 | await runAdb(serial, ["shell", "cmd", "uimode", "night", appearance === "dark" ? "yes" : "no"]); |
| 1265 | await runAdb(serial, ["shell", "settings", "put", "system", "time_12_24", "12"]); |
| 1266 | await runAdb(serial, ["emu", "power", "capacity", "100"]); |
| 1267 | await runAdb(serial, ["shell", "settings", "put", "global", "sysui_demo_allowed", "1"]); |
| 1268 | await runAdb(serial, [ |
| 1269 | "shell", |
| 1270 | "am", |
| 1271 | "broadcast", |
| 1272 | "-a", |
| 1273 | "com.android.systemui.demo", |
| 1274 | "-e", |
| 1275 | "command", |
| 1276 | "enter", |
| 1277 | ]); |
| 1278 | await runAdb(serial, [ |
| 1279 | "shell", |
| 1280 | "am", |
| 1281 | "broadcast", |
| 1282 | "-a", |
| 1283 | "com.android.systemui.demo", |
| 1284 | "-e", |
| 1285 | "command", |
| 1286 | "clock", |
| 1287 | "-e", |
| 1288 | "hhmm", |
| 1289 | "0941", |
| 1290 | ]); |
| 1291 | await runAdb(serial, [ |
| 1292 | "shell", |
| 1293 | "am", |
| 1294 | "broadcast", |
| 1295 | "-a", |
| 1296 | "com.android.systemui.demo", |
| 1297 | "-e", |
| 1298 | "command", |
| 1299 | "battery", |
| 1300 | "-e", |
| 1301 | "level", |
| 1302 | "100", |
| 1303 | "-e", |
| 1304 | "plugged", |
| 1305 | "false", |
| 1306 | ]); |
| 1307 | if (device.viewport) { |
| 1308 | await runAdb(serial, [ |
| 1309 | "shell", |
| 1310 | "wm", |
| 1311 | "size", |
| 1312 | `${device.viewport.width}x${device.viewport.height}`, |
| 1313 | ]); |
| 1314 | if (device.viewport.density) { |
| 1315 | await runAdb(serial, ["shell", "wm", "density", String(device.viewport.density)]); |
| 1316 | } |
| 1317 | } |
| 1318 | } |
| 1319 | |
| 1320 | async function waitForAndroidShowcaseScene( |
| 1321 | serial: string, |
| 1322 | scene: ShowcaseScene, |
| 1323 | timeoutMs = 90_000, |
| 1324 | ): Promise<void> { |
| 1325 | const deadline = Date.now() + timeoutMs; |
| 1326 | while (Date.now() < deadline) { |
| 1327 | const readyScene = await adbOutput(serial, [ |
| 1328 | "shell", |
| 1329 | "run-as", |
| 1330 | ANDROID_PACKAGE, |
| 1331 | "cat", |
| 1332 | "files/t3-showcase-ready", |
| 1333 | ]).catch(() => ""); |
| 1334 | if (readyScene.trim() === scene) return; |
| 1335 | await delay(500); |
| 1336 | } |
| 1337 | throw new Error(`Android showcase scene '${scene}' did not render within ${timeoutMs}ms.`); |
| 1338 | } |
| 1339 | |
| 1340 | async function writeAndroidShowcaseScene(serial: string, scene: ShowcaseScene): Promise<void> { |
| 1341 | await runAdb(serial, [ |
| 1342 | "shell", |
| 1343 | `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p files && rm -f files/t3-showcase-ready && printf %s ${scene} > files/t3-showcase-scene'`, |
| 1344 | ]); |
| 1345 | } |
| 1346 | |
| 1347 | // Gesture and key-command toggles have no launch-URL flag, so they still go |
| 1348 | // through the preferences file; onboarding, auto-launch and the floating button |
| 1349 | // come from DEV_CLIENT_LAUNCH_FLAGS on the launch URL. |
| 1350 | async function prepareAndroidShowcaseApp(serial: string): Promise<void> { |
| 1351 | const preferences = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?> |
| 1352 | <map> |
| 1353 | <boolean name="motionGestureEnabled" value="false" /> |
| 1354 | <boolean name="touchGestureEnabled" value="false" /> |
| 1355 | <boolean name="keyCommandsEnabled" value="false" /> |
| 1356 | </map>`; |
| 1357 | const encodedPreferences = Buffer.from(preferences).toString("base64"); |
| 1358 | await runAdb(serial, [ |
| 1359 | "shell", |
| 1360 | `run-as ${ANDROID_PACKAGE} sh -c 'mkdir -p shared_prefs && printf %s ${encodedPreferences} | base64 -d > shared_prefs/expo.modules.devmenu.sharedpreferences.xml'`, |
| 1361 | ]); |
| 1362 | } |
| 1363 | |
| 1364 | async function captureAndroid( |
| 1365 | capture: ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, |
| 1366 | apkPath: string | null, |
| 1367 | outputDirectory: string, |
| 1368 | config: ShowcaseConfig, |
| 1369 | pairingUrls: ReadonlyArray<string>, |
| 1370 | registerCleanup: (cleanup: AndroidCaptureCleanup) => void, |
| 1371 | ): Promise<void> { |
| 1372 | const running = await runningAndroidAvds(); |
| 1373 | const existingSerial = running.get(capture.device.avd); |
| 1374 | const startedByRunner = !existingSerial; |
| 1375 | let launchedEmulator: NodeChildProcess.ChildProcess | null = null; |
| 1376 | if (startedByRunner) { |
| 1377 | const installedAvds = (await commandOutput(androidSdkTool("emulator/emulator"), ["-list-avds"])) |
| 1378 | .split("\n") |
| 1379 | .map((value) => value.trim()); |
| 1380 | if (!installedAvds.includes(capture.device.avd)) { |
| 1381 | throw new Error( |
| 1382 | `Android AVD '${capture.device.avd}' is not installed. Run emulator -list-avds.`, |
| 1383 | ); |
| 1384 | } |
| 1385 | launchedEmulator = spawnProcess( |
| 1386 | androidSdkTool("emulator/emulator"), |
| 1387 | ["-avd", capture.device.avd, "-no-snapshot-load", "-no-boot-anim"], |
| 1388 | { stdio: "ignore", detached: true }, |
| 1389 | ); |
| 1390 | launchedEmulator.unref(); |
| 1391 | } |
| 1392 | const serial = |
| 1393 | existingSerial ?? |
| 1394 | (await waitForAndroidSerial(capture.device.avd).catch(async (error: unknown) => { |
| 1395 | if (launchedEmulator) await stopProcess(launchedEmulator); |
| 1396 | throw error; |
| 1397 | })); |
| 1398 | registerCleanup({ device: capture.device, serial, startedByRunner }); |
| 1399 | await normalizeAndroidEmulator(capture.device, capture.appearance, serial); |
| 1400 | if (apkPath) { |
| 1401 | await runAdb(serial, ["install", "-r", apkPath]); |
| 1402 | } |
| 1403 | await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]); |
| 1404 | // The agent-activity scene posts notifications; granting up front keeps the |
| 1405 | // runtime prompt off every capture. |
| 1406 | await runAdb(serial, [ |
| 1407 | "shell", |
| 1408 | "pm", |
| 1409 | "grant", |
| 1410 | ANDROID_PACKAGE, |
| 1411 | "android.permission.POST_NOTIFICATIONS", |
| 1412 | ]); |
| 1413 | await prepareAndroidShowcaseApp(serial); |
| 1414 | await runAdb(serial, ["reverse", `tcp:${config.metroPort}`, `tcp:${config.metroPort}`]); |
| 1415 | const metroUrl = encodeURIComponent(`http://127.0.0.1:${config.metroPort}?disableOnboarding=1`); |
| 1416 | const firstScene = capture.scenes[0] ?? "threads"; |
| 1417 | await runAdb(serial, [ |
| 1418 | "shell", |
| 1419 | "am", |
| 1420 | "start", |
| 1421 | "-W", |
| 1422 | "-a", |
| 1423 | "android.intent.action.VIEW", |
| 1424 | "-d", |
| 1425 | `'${APP_SCHEME}://expo-development-client/?url=${metroUrl}&${DEV_CLIENT_LAUNCH_FLAGS}'`, |
| 1426 | "--es", |
| 1427 | "showcasePairingUrl", |
| 1428 | encodeAndroidPairingUrls(pairingUrls), |
| 1429 | "--es", |
| 1430 | "showcaseScene", |
| 1431 | firstScene, |
| 1432 | "--es", |
| 1433 | "showcaseTheme", |
| 1434 | capture.theme, |
| 1435 | ANDROID_PACKAGE, |
| 1436 | ]); |
| 1437 | for (const [sceneIndex, scene] of capture.scenes.entries()) { |
| 1438 | if (sceneIndex > 0) await writeAndroidShowcaseScene(serial, scene); |
| 1439 | await waitForAndroidShowcaseScene(serial, scene); |
| 1440 | if (scene === "agent-activity") { |
| 1441 | await snoozeAndroidSystemNotifications(serial); |
| 1442 | await runAdb(serial, ["shell", "cmd", "statusbar", "expand-notifications"]); |
| 1443 | } |
| 1444 | await delay(Math.max(config.settleDelayMs, scene === "review" ? 8_000 : 5_000)); |
| 1445 | const destination = NodePath.join( |
| 1446 | showcaseCaptureDirectory(outputDirectory, capture), |
| 1447 | `${scene}.png`, |
| 1448 | ); |
| 1449 | const png = await new Promise<Buffer>((resolve, reject) => { |
| 1450 | NodeChildProcess.execFile( |
| 1451 | androidSdkTool("platform-tools/adb"), |
| 1452 | ["-s", serial, "exec-out", "screencap", "-p"], |
| 1453 | { cwd: REPO_ROOT, encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, |
| 1454 | (error, stdout) => { |
| 1455 | if (error) reject(error); |
| 1456 | else resolve(stdout); |
| 1457 | }, |
| 1458 | ); |
| 1459 | }); |
| 1460 | await NodeFSP.writeFile(destination, png); |
| 1461 | if (scene === "agent-activity") { |
| 1462 | await runAdb(serial, ["shell", "cmd", "statusbar", "collapse"]); |
| 1463 | } |
| 1464 | await finalizeCapture(destination, capture.device); |
| 1465 | } |
| 1466 | } |
| 1467 | |
| 1468 | async function cleanupAndroidViewport( |
| 1469 | device: ShowcaseAndroidDevice, |
| 1470 | serial: string, |
| 1471 | ): Promise<void> { |
| 1472 | await runAdb(serial, [ |
| 1473 | "shell", |
| 1474 | "am", |
| 1475 | "broadcast", |
| 1476 | "-a", |
| 1477 | "com.android.systemui.demo", |
| 1478 | "-e", |
| 1479 | "command", |
| 1480 | "exit", |
| 1481 | ]); |
| 1482 | if (!device.viewport) return; |
| 1483 | await runAdb(serial, ["shell", "wm", "size", "reset"]); |
| 1484 | if (device.viewport.density) { |
| 1485 | await runAdb(serial, ["shell", "wm", "density", "reset"]); |
| 1486 | } |
| 1487 | } |
| 1488 | |
| 1489 | async function main(): Promise<void> { |
| 1490 | const options = parseShowcaseCliArgs(NodeProcess.argv.slice(2)); |
| 1491 | if (options.list) { |
| 1492 | printUsage(showcaseConfig); |
| 1493 | return; |
| 1494 | } |
| 1495 | const captures = planShowcaseCaptures(showcaseConfig, options); |
| 1496 | const outputDirectory = NodePath.resolve(REPO_ROOT, showcaseConfig.outputDirectory); |
| 1497 | if (options.validateOnly) { |
| 1498 | for (const capture of captures) { |
| 1499 | await validateCaptureSet( |
| 1500 | capture, |
| 1501 | outputDirectory, |
| 1502 | capture.scenes.length === capture.device.scenes.length, |
| 1503 | ); |
| 1504 | } |
| 1505 | return; |
| 1506 | } |
| 1507 | const hasIos = captures.some((capture) => capture.device.platform === "ios"); |
| 1508 | const hasAndroid = captures.some((capture) => capture.device.platform === "android"); |
| 1509 | const metroHost = hasIos ? lanIpv4Address() : "127.0.0.1"; |
| 1510 | await NodeFSP.mkdir(outputDirectory, { recursive: true }); |
| 1511 | for (const capture of captures) { |
| 1512 | const directory = showcaseCaptureDirectory(outputDirectory, capture); |
| 1513 | await NodeFSP.rm(directory, { recursive: true, force: true }); |
| 1514 | await NodeFSP.mkdir(directory, { recursive: true }); |
| 1515 | } |
| 1516 | |
| 1517 | const showcaseRootDir = await NodeFSP.mkdtemp( |
| 1518 | NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"), |
| 1519 | ); |
| 1520 | const showcaseServers: NodeChildProcess.ChildProcess[] = []; |
| 1521 | const showcaseEnvironments: Array<{ |
| 1522 | readonly baseDir: string; |
| 1523 | readonly environmentId: string; |
| 1524 | readonly label: string; |
| 1525 | readonly port: number; |
| 1526 | }> = []; |
| 1527 | let metro: NodeChildProcess.ChildProcess | null = null; |
| 1528 | const iosCleanups: IosCaptureCleanup[] = []; |
| 1529 | const androidCleanups: AndroidCaptureCleanup[] = []; |
| 1530 | |
| 1531 | try { |
| 1532 | for (const environment of SHOWCASE_ENVIRONMENTS) { |
| 1533 | const projectId = environment.projectIds[0]; |
| 1534 | const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId); |
| 1535 | if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`); |
| 1536 | |
| 1537 | const baseDir = NodePath.join(showcaseRootDir, "environments", environment.id); |
| 1538 | const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory); |
| 1539 | const port = await reserveAvailablePort(); |
| 1540 | await NodeFSP.mkdir(workspaceRoot, { recursive: true }); |
| 1541 | const shellPath = await createShowcaseShell(baseDir); |
| 1542 | const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label); |
| 1543 | const server = startShowcaseServer( |
| 1544 | baseDir, |
| 1545 | workspaceRoot, |
| 1546 | port, |
| 1547 | shellPath, |
| 1548 | labelProbeDirectory, |
| 1549 | ); |
| 1550 | showcaseServers.push(server); |
| 1551 | await waitForPort(port, `${environment.label} server`); |
| 1552 | await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); |
| 1553 | // The server begins listening before the ServerEnvironment layer |
| 1554 | // persists the environment id, so poll rather than read once. |
| 1555 | const environmentId = await waitForFileContent( |
| 1556 | NodePath.join(baseDir, "userdata", "environment-id"), |
| 1557 | `${environment.label} environment id`, |
| 1558 | ); |
| 1559 | showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port }); |
| 1560 | } |
| 1561 | |
| 1562 | if (!options.skipMetro) { |
| 1563 | metro = startMetro(showcaseConfig); |
| 1564 | await waitForPort(showcaseConfig.metroPort, "Metro"); |
| 1565 | await Promise.all([ |
| 1566 | hasIos ? warmMetroBundle("ios", metroHost, showcaseConfig) : Promise.resolve(), |
| 1567 | hasAndroid ? warmMetroBundle("android", "127.0.0.1", showcaseConfig) : Promise.resolve(), |
| 1568 | ]); |
| 1569 | } |
| 1570 | |
| 1571 | const iosAppPath = hasIos |
| 1572 | ? options.skipBuild |
| 1573 | ? await existingArtifact(IOS_APP_PATH) |
| 1574 | : await buildIos() |
| 1575 | : null; |
| 1576 | const androidAbis = captures.flatMap((capture) => |
| 1577 | capture.device.platform === "android" && capture.device.abi ? [capture.device.abi] : [], |
| 1578 | ); |
| 1579 | const androidApkPath = hasAndroid |
| 1580 | ? options.skipBuild |
| 1581 | ? await existingArtifact(ANDROID_APK_PATH) |
| 1582 | : await buildAndroid([...new Set(androidAbis)]) |
| 1583 | : null; |
| 1584 | |
| 1585 | for (const capture of captures) { |
| 1586 | const pairingHost = capture.device.platform === "ios" ? "127.0.0.1" : "10.0.2.2"; |
| 1587 | const pairingUrls = await Promise.all( |
| 1588 | showcaseEnvironments.map(async (environment) => { |
| 1589 | const credential = await issuePairingCredential(environment.baseDir); |
| 1590 | return buildShowcasePairingUrl(pairingHost, environment.port, credential); |
| 1591 | }), |
| 1592 | ); |
| 1593 | if (capture.device.platform === "ios") { |
| 1594 | await captureIos( |
| 1595 | capture as ShowcaseCapture & { readonly device: ShowcaseIosDevice }, |
| 1596 | iosAppPath, |
| 1597 | outputDirectory, |
| 1598 | showcaseConfig, |
| 1599 | metroHost, |
| 1600 | pairingUrls, |
| 1601 | (cleanup) => iosCleanups.push(cleanup), |
| 1602 | ); |
| 1603 | } else { |
| 1604 | await captureAndroid( |
| 1605 | capture as ShowcaseCapture & { readonly device: ShowcaseAndroidDevice }, |
| 1606 | androidApkPath, |
| 1607 | outputDirectory, |
| 1608 | showcaseConfig, |
| 1609 | pairingUrls, |
| 1610 | (cleanup) => androidCleanups.push(cleanup), |
| 1611 | ); |
| 1612 | } |
| 1613 | await validateCaptureSet( |
| 1614 | capture, |
| 1615 | outputDirectory, |
| 1616 | capture.scenes.length === capture.device.scenes.length, |
| 1617 | ); |
| 1618 | } |
| 1619 | |
| 1620 | NodeProcess.stdout.write( |
| 1621 | `\nDone. Upload-ready screenshots are in ${NodePath.relative(REPO_ROOT, outputDirectory)}/apple/ and ${NodePath.relative(REPO_ROOT, outputDirectory)}/google-play/.\n`, |
| 1622 | ); |
| 1623 | if (options.keepRunning) { |
| 1624 | const serverSummary = showcaseEnvironments |
| 1625 | .map((environment) => `${environment.label}:${environment.port}`) |
| 1626 | .join(", "); |
| 1627 | NodeProcess.stdout.write( |
| 1628 | `Showcase environments kept at ${showcaseRootDir} (${serverSummary}).\n`, |
| 1629 | ); |
| 1630 | } |
| 1631 | } finally { |
| 1632 | if (options.keepRunning) { |
| 1633 | metro?.unref(); |
| 1634 | for (const server of showcaseServers) server.unref(); |
| 1635 | } else { |
| 1636 | for (const cleanup of androidCleanups) { |
| 1637 | await cleanupAndroidViewport(cleanup.device, cleanup.serial).catch(() => undefined); |
| 1638 | if (cleanup.startedByRunner) { |
| 1639 | await runAdb(cleanup.serial, ["emu", "kill"]).catch(() => undefined); |
| 1640 | } |
| 1641 | } |
| 1642 | for (const cleanup of iosCleanups) { |
| 1643 | if (cleanup.startedByRunner || cleanup.createdByRunner) { |
| 1644 | await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined); |
| 1645 | } |
| 1646 | if (cleanup.createdByRunner) { |
| 1647 | await runCommand("xcrun", ["simctl", "delete", cleanup.udid]).catch(() => undefined); |
| 1648 | } |
| 1649 | } |
| 1650 | await Promise.all([ |
| 1651 | ...(metro ? [stopProcess(metro)] : []), |
| 1652 | ...showcaseServers.map((server) => stopProcess(server)), |
| 1653 | ]); |
| 1654 | await NodeFSP.rm(showcaseRootDir, { |
| 1655 | recursive: true, |
| 1656 | force: true, |
| 1657 | maxRetries: 5, |
| 1658 | retryDelay: 100, |
| 1659 | }); |
| 1660 | } |
| 1661 | } |
| 1662 | } |
| 1663 | |
| 1664 | if (import.meta.main) { |
| 1665 | void main().catch((error: unknown) => { |
| 1666 | // Stack over message: the harness only fails in CI, where the line that |
| 1667 | // threw is the whole diagnosis and there is nobody at a terminal to |
| 1668 | // re-run it with more output. |
| 1669 | NodeProcess.stderr.write( |
| 1670 | `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, |
| 1671 | ); |
| 1672 | NodeProcess.exit(1); |
| 1673 | }); |
| 1674 | } |