@terreno/agent-plugin 0.0.0-stage → 58.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude-plugin/plugin.json +32 -0
  2. package/CHANGELOG.md +2213 -0
  3. package/LICENSE +21 -0
  4. package/README.md +57 -2
  5. package/agents/pre-commit.md +65 -0
  6. package/agents/ui-verifier.md +93 -0
  7. package/package.json +32 -4
  8. package/references/async-review-bots.md +76 -0
  9. package/references/documentation-contract.md +61 -0
  10. package/references/execution-state.schema.json +77 -0
  11. package/references/github-attention-contract.md +165 -0
  12. package/references/independent-review.md +31 -0
  13. package/references/lifecycle-contract.md +211 -0
  14. package/references/loop-engineering.md +125 -0
  15. package/references/pick-roast-loop.md +112 -0
  16. package/references/pr-deployments.md +69 -0
  17. package/references/product-ci.md +164 -0
  18. package/references/push-ownership.md +91 -0
  19. package/references/stage-result.schema.json +82 -0
  20. package/references/subagent-briefing.md +43 -0
  21. package/references/testing-plan.md +163 -0
  22. package/references/user-guide.md +171 -0
  23. package/skills/1-grow/SKILL.md +145 -0
  24. package/skills/1-grow/references/grilling.md +243 -0
  25. package/skills/2-pick/SKILL.md +170 -0
  26. package/skills/2-pick/references/mocking.md +34 -0
  27. package/skills/2-pick/references/testing.md +43 -0
  28. package/skills/3-roast/SKILL.md +168 -0
  29. package/skills/4-brew/SKILL.md +201 -0
  30. package/skills/5-taste/SKILL.md +311 -0
  31. package/skills/ai-prompt-governance/SKILL.md +100 -0
  32. package/skills/attach-pr-media/SKILL.md +89 -0
  33. package/skills/attach-pr-media/scripts/upload-pr-media.sh +151 -0
  34. package/skills/backend-test-env/SKILL.md +64 -0
  35. package/skills/backfill-regression/SKILL.md +101 -0
  36. package/skills/backfill-user-guide/SKILL.md +96 -0
  37. package/skills/bootstrap-mcp-server/SKILL.md +244 -0
  38. package/skills/building-admin-interfaces/SKILL.md +86 -0
  39. package/skills/building-admin-interfaces/agents/openai.yaml +4 -0
  40. package/skills/building-admin-interfaces/references/nav-and-shell.md +42 -0
  41. package/skills/building-admin-interfaces/references/patterns-and-tools.md +74 -0
  42. package/skills/building-admin-interfaces/references/screens-and-routes.md +50 -0
  43. package/skills/building-terreno-apps/SKILL.md +294 -0
  44. package/skills/building-terreno-apps/agents/openai.yaml +4 -0
  45. package/skills/building-terreno-apps/references/navigation.md +108 -0
  46. package/skills/building-terreno-apps/references/route-structure.md +104 -0
  47. package/skills/building-terreno-apps/references/screen-patterns.md +174 -0
  48. package/skills/debug-syncdb-with-mcp/SKILL.md +75 -0
  49. package/skills/deploy-gcp/SKILL.md +73 -0
  50. package/skills/deploy-gcp/references/troubleshooting.md +29 -0
  51. package/skills/generate-release-announcements/SKILL.md +145 -0
  52. package/skills/generate-sdk/SKILL.md +165 -0
  53. package/skills/mongoose-schema-safety/SKILL.md +164 -0
  54. package/skills/pick-roast-loop/SKILL.md +152 -0
  55. package/skills/planning-loop/SKILL.md +161 -0
  56. package/skills/prd/SKILL.md +138 -0
  57. package/skills/set-up-social-login/SKILL.md +76 -0
  58. package/skills/store-assets/SKILL.md +221 -0
  59. package/skills/store-assets/examples/screenshots.yaml +31 -0
  60. package/skills/store-assets/examples/storeAssets.config.json +44 -0
  61. package/skills/store-assets/scripts/banner.ts +165 -0
  62. package/skills/store-assets/scripts/config.ts +255 -0
  63. package/skills/store-assets/scripts/devClient.ts +635 -0
  64. package/skills/store-assets/scripts/devices.ts +286 -0
  65. package/skills/store-assets/scripts/icon.ts +81 -0
  66. package/skills/store-assets/scripts/maestro/goHome.yaml +21 -0
  67. package/skills/store-assets/scripts/maestro/login.yaml +27 -0
  68. package/skills/store-assets/scripts/maestro/openApp.yaml +26 -0
  69. package/skills/store-assets/scripts/screenshots.ts +848 -0
  70. package/skills/taste-sweep/SKILL.md +172 -0
  71. package/skills/terreno-backend-api/SKILL.md +355 -0
  72. package/skills/terreno-backend-api/agents/openai.yaml +4 -0
  73. package/skills/terreno-backend-api/references/custom-routes.md +132 -0
  74. package/skills/terreno-backend-api/references/model-router.md +112 -0
  75. package/skills/terreno-backend-api/references/server-setup.md +116 -0
  76. package/skills/terreno-backend-tests/SKILL.md +176 -0
  77. package/skills/terreno-data-fetching/SKILL.md +288 -0
  78. package/skills/terreno-data-fetching/agents/openai.yaml +4 -0
  79. package/skills/terreno-data-fetching/references/auth-and-tokens.md +89 -0
  80. package/skills/terreno-data-fetching/references/realtime.md +50 -0
  81. package/skills/terreno-data-fetching/references/sdk-customization.md +55 -0
  82. package/skills/terreno-frontend-tests/SKILL.md +190 -0
  83. package/skills/terreno-mobile-tests/SKILL.md +127 -0
  84. package/skills/terreno-test-setup/SKILL.md +81 -0
  85. package/skills/terreno-test-setup/references/backend.md +131 -0
  86. package/skills/terreno-test-setup/references/frontend.md +169 -0
  87. package/skills/terreno-test-setup/references/mobile.md +134 -0
  88. package/skills/terreno-ui/SKILL.md +252 -0
  89. package/skills/terreno-ui/agents/openai.yaml +4 -0
  90. package/skills/terreno-ui/references/data-display.md +87 -0
  91. package/skills/terreno-ui/references/feedback.md +122 -0
  92. package/skills/terreno-ui/references/forms.md +119 -0
  93. package/skills/terreno-ui/references/layout.md +124 -0
  94. package/skills/terreno-ui/references/theming.md +87 -0
  95. package/skills/update-docs/SKILL.md +82 -0
  96. package/skills/upgrading-terreno/SKILL.md +93 -0
  97. package/skills/upgrading-terreno/references/ordering.md +40 -0
  98. package/skills/verify-ui-changes/SKILL.md +605 -0
@@ -0,0 +1,848 @@
1
+ #!/usr/bin/env bun
2
+ //
3
+ // Capture App Store / Play Store screenshots by running each app's Maestro screenshot flow on
4
+ // store-sized simulators and emulators.
5
+ //
6
+ // Usage:
7
+ // bun run screenshots:setup
8
+ // bun run screenshots:devices <app> [--device iphone|ipad|android] [--build]
9
+ // bun run screenshots <app> [--device iphone|ipad|android] [--build] [--keep-metro]
10
+ // bun run screenshots:capture <app> <iphone|ipad|android> <name>
11
+ //
12
+ // Apps come from storeAssets.config.json. Final shots: <appDir>/storeScreenshots/<device>/*.png.
13
+ // Maestro artifacts and Metro logs: .storeAssets/<app>/ (git-ignored). Without --build, a device
14
+ // whose dev client needs a new EAS build is skipped instead of spending Expo credits.
15
+
16
+ import {
17
+ copyFileSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ readFileSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import {homedir} from "node:os";
26
+ import {basename, join} from "node:path";
27
+ import {
28
+ type AppConfig,
29
+ EXIT_BUILD_NEEDED,
30
+ EXIT_BUILD_PENDING,
31
+ type ExpoInfo,
32
+ fail as failTagged,
33
+ getApiPort,
34
+ getApp,
35
+ getExpoInfo,
36
+ isMetroRunning,
37
+ loadConfig,
38
+ REPO_ROOT,
39
+ run,
40
+ runOrFail as runOrFailTagged,
41
+ type StoreAssetsConfig,
42
+ say as sayTagged,
43
+ warn as warnTagged,
44
+ } from "./config";
45
+ import {
46
+ bootEmulator,
47
+ bootIosSimulator,
48
+ findEmulatorSerial,
49
+ launchDevClientIntoMetro,
50
+ listIosRuntimes,
51
+ listIosSimulators,
52
+ reversePorts,
53
+ } from "./devices";
54
+
55
+ const TAG = "screenshots";
56
+ const WORK_ROOT = join(REPO_ROOT, ".storeAssets");
57
+ const MAESTRO_HOME = join(homedir(), ".maestro");
58
+ const MAESTRO_BIN = join(MAESTRO_HOME, "maestro", "bin", "maestro");
59
+ // Pinned and checksum-verified; bump both together after testing a new release.
60
+ const MAESTRO_VERSION = "2.11.0";
61
+ const MAESTRO_ZIP_URL = `https://github.com/mobile-dev-inc/maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip`;
62
+ const MAESTRO_ZIP_SHA256 = "5384593cb4e7a106489e75a821d157dd43f4e438df6bc308b72e82c685e1283a";
63
+ // First run on a fresh emulator installs Maestro's driver APKs, which takes over a minute.
64
+ const MAESTRO_DRIVER_STARTUP_TIMEOUT_MS = 180_000;
65
+ const METRO_START_SECONDS = 90;
66
+ const NOTIFICATION_SNOOZE_MS = 86_400_000;
67
+ const EXIT_SIGTERM = 143;
68
+ const EXIT_SIGINT = 130;
69
+ const JAVA_HOME_CANDIDATES = [
70
+ process.env.JAVA_HOME,
71
+ "/opt/homebrew/opt/openjdk@17",
72
+ "/opt/homebrew/opt/openjdk",
73
+ "/usr/local/opt/openjdk@17",
74
+ ];
75
+
76
+ const say = (message: string): void => sayTagged({message, tag: TAG});
77
+ const warn = (message: string): void => warnTagged({message, tag: TAG});
78
+ const fail = (message: string): never => failTagged({message, tag: TAG});
79
+ const runOrFail = (args: Omit<Parameters<typeof runOrFailTagged>[0], "tag">): Promise<string> =>
80
+ runOrFailTagged({...args, tag: TAG});
81
+
82
+ interface AndroidScreenSize {
83
+ width: number;
84
+ height: number;
85
+ density: number;
86
+ }
87
+
88
+ interface IosDeviceSpec {
89
+ label: string;
90
+ platform: "ios";
91
+ name: string;
92
+ deviceType: string;
93
+ // Accepted App Store sizes (portrait).
94
+ storeSizes: [number, number][];
95
+ }
96
+
97
+ interface AndroidDeviceSpec {
98
+ label: string;
99
+ platform: "android";
100
+ // AVD name.
101
+ name: string;
102
+ size: AndroidScreenSize;
103
+ }
104
+
105
+ type DeviceSpec = IosDeviceSpec | AndroidDeviceSpec;
106
+
107
+ // App Store Connect "6.5-inch" accepts 1284x2778 and 1242x2688; "13-inch iPad" accepts
108
+ // 2048x2732 and 2064x2752. Play rejects screenshots longer than 2:1, so the Android phone is a
109
+ // 16:9 1080x1920 AVD rather than a modern 20:9 Pixel.
110
+ const DEVICES: Record<string, DeviceSpec> = {
111
+ android: {
112
+ label: "Android phone 1080x1920",
113
+ name: "Store_Screenshots_Phone",
114
+ platform: "android",
115
+ size: {density: 420, height: 1920, width: 1080},
116
+ },
117
+ ipad: {
118
+ deviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-12-9-inch-6th-generation-8GB",
119
+ label: "13-inch iPad",
120
+ name: "Store Screenshots iPad 13",
121
+ platform: "ios",
122
+ storeSizes: [
123
+ [2048, 2732],
124
+ [2064, 2752],
125
+ ],
126
+ },
127
+ iphone: {
128
+ deviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-14-Plus",
129
+ label: "6.5-inch iPhone",
130
+ name: "Store Screenshots iPhone 6.5",
131
+ platform: "ios",
132
+ storeSizes: [
133
+ [1284, 2778],
134
+ [1242, 2688],
135
+ ],
136
+ },
137
+ };
138
+
139
+ interface Context {
140
+ apiPort: number;
141
+ apiUrl: string;
142
+ app: AppConfig;
143
+ appKey: string;
144
+ config: StoreAssetsConfig;
145
+ expo: ExpoInfo;
146
+ }
147
+
148
+ interface Options {
149
+ device?: string;
150
+ isBuild: boolean;
151
+ isKeepMetro: boolean;
152
+ }
153
+
154
+ interface BootedDevice {
155
+ // simctl UDID or adb serial
156
+ id: string;
157
+ key: string;
158
+ spec: DeviceSpec;
159
+ }
160
+
161
+ const getJavaHome = (): string | undefined => {
162
+ return JAVA_HOME_CANDIDATES.find((path) => path && existsSync(join(path, "bin", "java")));
163
+ };
164
+
165
+ const getMaestroEnv = (): Record<string, string> => {
166
+ const javaHome = getJavaHome();
167
+ if (!javaHome) {
168
+ return fail("Java not found. Run: brew install openjdk@17");
169
+ }
170
+ return {
171
+ JAVA_HOME: javaHome,
172
+ MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true",
173
+ MAESTRO_CLI_NO_ANALYTICS: "1",
174
+ MAESTRO_DRIVER_STARTUP_TIMEOUT: String(MAESTRO_DRIVER_STARTUP_TIMEOUT_MS),
175
+ PATH: `${join(javaHome, "bin")}:${process.env.PATH}`,
176
+ };
177
+ };
178
+
179
+ const commandSetup = async (): Promise<void> => {
180
+ if (!getJavaHome()) {
181
+ fail("Java not found. Run: brew install openjdk@17");
182
+ }
183
+ if (!existsSync(MAESTRO_BIN)) {
184
+ // The release zip avoids Homebrew, which refuses tap formulae when Command Line Tools are stale.
185
+ say(`Installing Maestro ${MAESTRO_VERSION} into ${MAESTRO_HOME}`);
186
+ mkdirSync(MAESTRO_HOME, {recursive: true});
187
+ const zipPath = join(MAESTRO_HOME, "maestro.zip");
188
+ await runOrFail({cmd: ["curl", "-fsSL", "-o", zipPath, MAESTRO_ZIP_URL]});
189
+ const digest = new Bun.CryptoHasher("sha256")
190
+ .update(await Bun.file(zipPath).arrayBuffer())
191
+ .digest("hex");
192
+ if (digest !== MAESTRO_ZIP_SHA256) {
193
+ rmSync(zipPath);
194
+ fail(`Maestro download checksum mismatch (got ${digest}); refusing to install`);
195
+ }
196
+ await runOrFail({cmd: ["unzip", "-qo", zipPath, "-d", MAESTRO_HOME]});
197
+ rmSync(zipPath);
198
+ }
199
+ const output = await runOrFail({
200
+ cmd: [MAESTRO_BIN, "--version"],
201
+ env: getMaestroEnv(),
202
+ isQuiet: true,
203
+ });
204
+ const version = output.trim().split("\n").pop() ?? "";
205
+ if (version !== MAESTRO_VERSION) {
206
+ warn(`Maestro ${version} is installed; flows are tested with ${MAESTRO_VERSION}`);
207
+ }
208
+ say(`Maestro ${version} ready`);
209
+ };
210
+
211
+ // --- iOS simulators ---
212
+
213
+ const applyIosStatusBar = async ({udid}: {udid: string}): Promise<void> => {
214
+ // prettier-ignore
215
+ const overrides = [
216
+ "--time",
217
+ "9:41",
218
+ "--dataNetwork",
219
+ "wifi",
220
+ "--wifiMode",
221
+ "active",
222
+ "--wifiBars",
223
+ "3",
224
+ "--cellularMode",
225
+ "active",
226
+ "--cellularBars",
227
+ "4",
228
+ "--batteryState",
229
+ "discharging",
230
+ "--batteryLevel",
231
+ "100",
232
+ ];
233
+ await runOrFail({
234
+ cmd: ["xcrun", "simctl", "status_bar", udid, "override", ...overrides],
235
+ isQuiet: true,
236
+ });
237
+ };
238
+
239
+ const ensureSimulator = async ({spec}: {spec: IosDeviceSpec}): Promise<string> => {
240
+ const simulators = await listIosSimulators({filter: "available", tag: TAG});
241
+ let udid = simulators.find((sim) => sim.name === spec.name)?.udid;
242
+ if (!udid) {
243
+ const [runtime] = await listIosRuntimes({tag: TAG});
244
+ if (!runtime) {
245
+ return fail("No iOS simulator runtime installed");
246
+ }
247
+ say(`Creating simulator "${spec.name}" on ${runtime}`);
248
+ udid = (
249
+ await runOrFail({
250
+ cmd: ["xcrun", "simctl", "create", spec.name, spec.deviceType, runtime],
251
+ isQuiet: true,
252
+ })
253
+ ).trim();
254
+ }
255
+ await bootIosSimulator({tag: TAG, udid});
256
+ await applyIosStatusBar({udid});
257
+ return udid;
258
+ };
259
+
260
+ // --- Android emulators ---
261
+
262
+ const getAvdHome = (): string => process.env.ANDROID_AVD_HOME ?? join(homedir(), ".android", "avd");
263
+
264
+ // Creates the screenshot AVD by cloning an existing AVD's system image settings, so the script
265
+ // doesn't need avdmanager.
266
+ const ensureAvd = ({spec}: {spec: AndroidDeviceSpec}): void => {
267
+ const avdHome = getAvdHome();
268
+ const avdDir = join(avdHome, `${spec.name}.avd`);
269
+ if (existsSync(join(avdDir, "config.ini"))) {
270
+ return;
271
+ }
272
+ const template = existsSync(avdHome)
273
+ ? readdirSync(avdHome)
274
+ .filter((name) => name.endsWith(".avd") && name !== `${spec.name}.avd`)
275
+ .map((name) => join(avdHome, name, "config.ini"))
276
+ .find((path) => existsSync(path))
277
+ : undefined;
278
+ if (!template) {
279
+ fail("No existing Android AVD to clone. Create any phone AVD in Android Studio first.");
280
+ return;
281
+ }
282
+ const {width, height, density} = spec.size;
283
+ const overrides: Record<string, string> = {
284
+ AvdId: spec.name,
285
+ "avd.ini.displayname": spec.name.replaceAll("_", " "),
286
+ "hw.device.name": "pixel_2",
287
+ "hw.keyboard": "yes",
288
+ "hw.lcd.density": String(density),
289
+ "hw.lcd.height": String(height),
290
+ "hw.lcd.width": String(width),
291
+ // The Play Store system images crawl at the 2 GB many templates default to.
292
+ "hw.ramSize": "4096",
293
+ "skin.dynamic": "no",
294
+ "skin.name": `${width}x${height}`,
295
+ };
296
+ const templateText = readFileSync(template, "utf8");
297
+ const lines = templateText
298
+ .split("\n")
299
+ .filter((line) => line && !line.startsWith("skin.path"))
300
+ .filter((line) => !((line.split("=")[0] ?? "").trim() in overrides));
301
+ const config = [
302
+ ...lines,
303
+ ...Object.entries(overrides).map(([key, value]) => `${key}=${value}`),
304
+ ].join("\n");
305
+ const target = templateText.match(/image\.sysdir\.1=system-images\/([^/]+)/)?.[1];
306
+
307
+ say(`Creating AVD ${spec.name} (${width}x${height}) from ${template}`);
308
+ mkdirSync(avdDir, {recursive: true});
309
+ writeFileSync(join(avdDir, "config.ini"), `${config}\n`);
310
+ writeFileSync(
311
+ join(avdHome, `${spec.name}.ini`),
312
+ `avd.ini.encoding=UTF-8\npath=${avdDir}\npath.rel=avd/${spec.name}.avd\ntarget=${target ?? "android"}\n`
313
+ );
314
+ };
315
+
316
+ const adbShell = async ({serial, args}: {serial: string; args: string[]}): Promise<void> => {
317
+ await run({cmd: ["adb", "-s", serial, "shell", ...args], isQuiet: true});
318
+ };
319
+
320
+ // Turns off autofill (it covers login forms) and sets a clean demo-mode status bar.
321
+ const applyAndroidStatusBar = async ({serial}: {serial: string}): Promise<void> => {
322
+ await adbShell({args: ["settings", "put", "secure", "autofill_service", "null"], serial});
323
+ // Hidden for good on this dedicated AVD. Demo mode's `mobile` command draws a second Wi-Fi icon
324
+ // on recent Android, so the mobile slot is blocklisted instead.
325
+ await adbShell({
326
+ args: ["settings", "put", "secure", "icon_blacklist", "mobile,rotate,location"],
327
+ serial,
328
+ });
329
+ await adbShell({args: ["settings", "put", "global", "sysui_demo_allowed", "1"], serial});
330
+ const demo = ({extra}: {extra: string[]}): Promise<void> =>
331
+ adbShell({args: ["am", "broadcast", "-a", "com.android.systemui.demo", ...extra], serial});
332
+ // Re-enter so state from a previous run doesn't linger.
333
+ await demo({extra: ["-e", "command", "exit"]});
334
+ await demo({extra: ["-e", "command", "enter"]});
335
+ await demo({extra: ["-e", "command", "clock", "-e", "hhmm", "0941"]});
336
+ await demo({extra: ["-e", "command", "battery", "-e", "level", "100", "-e", "plugged", "false"]});
337
+ await demo({
338
+ extra: [
339
+ "-e",
340
+ "command",
341
+ "network",
342
+ "-e",
343
+ "wifi",
344
+ "show",
345
+ "-e",
346
+ "level",
347
+ "4",
348
+ "-e",
349
+ "fully",
350
+ "true",
351
+ ],
352
+ });
353
+ await demo({extra: ["-e", "command", "notifications", "-e", "visible", "false"]});
354
+
355
+ // Demo mode no longer hides notification icons (e.g. Safety Center's "Set a screen lock").
356
+ const {stdout} = await run({
357
+ cmd: ["adb", "-s", serial, "shell", "cmd", "notification", "list"],
358
+ isQuiet: true,
359
+ });
360
+ const keys = stdout
361
+ .split("\n")
362
+ .map((line) => line.trim())
363
+ .filter((line) => /^[\w|=.:+/-]+$/.test(line));
364
+ for (const key of keys) {
365
+ await adbShell({
366
+ args: ["cmd", "notification", "snooze", "--for", String(NOTIFICATION_SNOOZE_MS), `'${key}'`],
367
+ serial,
368
+ });
369
+ }
370
+ };
371
+
372
+ const ensureEmulator = async ({
373
+ spec,
374
+ context,
375
+ }: {
376
+ spec: AndroidDeviceSpec;
377
+ context: Context;
378
+ }): Promise<string> => {
379
+ ensureAvd({spec});
380
+ if (!(await findEmulatorSerial({avd: spec.name, tag: TAG}))) {
381
+ say(`Starting emulator ${spec.name}`);
382
+ }
383
+ const serial = await bootEmulator({avd: spec.name, tag: TAG});
384
+ await reversePorts({ports: [context.apiPort, context.app.metroPort], serial, tag: TAG});
385
+ await applyAndroidStatusBar({serial});
386
+ return serial;
387
+ };
388
+
389
+ const ensureDevice = async ({
390
+ key,
391
+ context,
392
+ }: {
393
+ key: string;
394
+ context: Context;
395
+ }): Promise<BootedDevice> => {
396
+ const spec = DEVICES[key];
397
+ if (!spec) {
398
+ return fail(`Unknown device "${key}". Use one of: ${Object.keys(DEVICES).join(", ")}`);
399
+ }
400
+ const id =
401
+ spec.platform === "ios" ? await ensureSimulator({spec}) : await ensureEmulator({context, spec});
402
+ return {id, key, spec};
403
+ };
404
+
405
+ // --- Dev client, backend, Metro ---
406
+
407
+ // Every run logs in fresh: a session restored from an earlier run can fail its token refresh
408
+ // (e.g. after a backend restart) and leave an error overlay in the shots. The screenshot
409
+ // devices are dedicated, so wiping app data and the simulator keychain is safe; the dev client
410
+ // is reinstalled right after.
411
+ const resetAppState = async ({
412
+ context,
413
+ device,
414
+ }: {
415
+ context: Context;
416
+ device: BootedDevice;
417
+ }): Promise<void> => {
418
+ if (device.spec.platform === "ios") {
419
+ await run({
420
+ cmd: ["xcrun", "simctl", "uninstall", device.id, context.expo.bundleId],
421
+ isQuiet: true,
422
+ });
423
+ await run({cmd: ["xcrun", "simctl", "keychain", device.id, "reset"], isQuiet: true});
424
+ return;
425
+ }
426
+ await run({
427
+ cmd: ["adb", "-s", device.id, "shell", "pm", "clear", context.expo.androidPackage],
428
+ isQuiet: true,
429
+ });
430
+ };
431
+
432
+ const ensureDevClient = async ({
433
+ context,
434
+ device,
435
+ options,
436
+ }: {
437
+ context: Context;
438
+ device: BootedDevice;
439
+ options: Options;
440
+ }): Promise<boolean> => {
441
+ // devClient takes a simulator UDID on iOS and an AVD name on Android.
442
+ const target = device.spec.platform === "ios" ? device.id : device.spec.name;
443
+ const {exitCode} = await run({
444
+ cmd: [
445
+ "bun",
446
+ join(import.meta.dir, "devClient.ts"),
447
+ "ensure",
448
+ context.appKey,
449
+ device.spec.platform,
450
+ "--device",
451
+ target,
452
+ ...(options.isBuild ? [] : ["--no-build"]),
453
+ ],
454
+ });
455
+ if (exitCode === EXIT_BUILD_PENDING) {
456
+ warn(
457
+ `Dev client build for ${context.appKey} ${device.spec.platform} is still running; skipping ${device.key}`
458
+ );
459
+ return false;
460
+ }
461
+ if (exitCode === EXIT_BUILD_NEEDED) {
462
+ warn(
463
+ `${context.appKey} ${device.spec.platform} needs a new dev client build; skipping ${device.key} (pass --build)`
464
+ );
465
+ return false;
466
+ }
467
+ if (exitCode !== 0) {
468
+ fail(`Could not install the ${context.appKey} dev client on ${device.key}`);
469
+ }
470
+ return true;
471
+ };
472
+
473
+ // Probes a route only this repo's backend serves, so another project's server on the same port
474
+ // isn't mistaken for ours.
475
+ const ensureBackend = async ({context}: {context: Context}): Promise<void> => {
476
+ const {probe, startCommand} = context.config.api;
477
+ const status = await fetch(`${context.apiUrl}${probe.path}`, {
478
+ body: probe.method === "POST" ? (probe.body ?? "{}") : undefined,
479
+ headers: {"Content-Type": "application/json"},
480
+ method: probe.method,
481
+ })
482
+ .then((response) => response.status)
483
+ .catch(() => 0);
484
+ if (status === 0) {
485
+ fail(
486
+ `Backend is not reachable at ${context.apiUrl}. Start it (${startCommand}) and seed test data.`
487
+ );
488
+ }
489
+ if (status === 404) {
490
+ fail(
491
+ `${context.apiUrl} does not serve ${probe.path}, so it is not this repo's backend. Stop the other server, ` +
492
+ "or run this backend on another port and set STORE_ASSETS_API_PORT."
493
+ );
494
+ }
495
+ };
496
+
497
+ const startMetro = async ({context}: {context: Context}): Promise<(() => void) | undefined> => {
498
+ const {app, appKey, apiUrl} = context;
499
+ if (await isMetroRunning({port: app.metroPort})) {
500
+ warn(
501
+ `Reusing Metro already running on :${app.metroPort}. Dev-mode overlays may show in screenshots.`
502
+ );
503
+ return undefined;
504
+ }
505
+ const workDir = join(WORK_ROOT, appKey);
506
+ mkdirSync(workDir, {recursive: true});
507
+ const logPath = join(workDir, "metro.log");
508
+ say(`Starting Metro for ${app.appDir} on :${app.metroPort} (production JS, log: ${logPath})`);
509
+ // EXPO_PUBLIC_* values are inlined into cached transforms, so clear Metro's cache only when the
510
+ // API URL changed; a cold production build of an app takes several minutes.
511
+ const envMarker = join(workDir, ".metro-api-url");
512
+ const isApiUrlChanged = !existsSync(envMarker) || readFileSync(envMarker, "utf8") !== apiUrl;
513
+ // --no-dev --minify serves a production bundle so LogBox and dev warnings stay out of the shots.
514
+ const proc = Bun.spawn(
515
+ [
516
+ "bun",
517
+ "expo",
518
+ "start",
519
+ "--dev-client",
520
+ "--port",
521
+ String(app.metroPort),
522
+ "--no-dev",
523
+ "--minify",
524
+ ...(isApiUrlChanged ? ["--clear"] : []),
525
+ ],
526
+ {
527
+ cwd: join(REPO_ROOT, app.appDir),
528
+ env: {...process.env, CI: "1", EXPO_PUBLIC_API_URL: apiUrl},
529
+ stderr: Bun.file(logPath),
530
+ stdin: "ignore",
531
+ stdout: Bun.file(logPath),
532
+ }
533
+ );
534
+ // fail() exits the process directly, so stop Metro from an exit hook rather than a finally.
535
+ const stop = (): void => {
536
+ proc.kill();
537
+ };
538
+ process.on("exit", stop);
539
+ process.once("SIGINT", () => process.exit(EXIT_SIGINT));
540
+ process.once("SIGTERM", () => process.exit(EXIT_SIGTERM));
541
+ for (let attempt = 0; attempt < METRO_START_SECONDS; attempt++) {
542
+ if (await isMetroRunning({port: app.metroPort})) {
543
+ // Recorded only once Metro is up, so a failed start still clears the cache next time.
544
+ writeFileSync(envMarker, apiUrl);
545
+ return stop;
546
+ }
547
+ await Bun.sleep(1_000);
548
+ }
549
+ return fail(`Metro did not start; see ${logPath}`);
550
+ };
551
+
552
+ // Builds the platform bundle before the app asks for it, so a cold multi-minute Metro build
553
+ // doesn't trip Maestro's driver timeouts.
554
+ const prewarmBundle = async ({
555
+ context,
556
+ platform,
557
+ }: {
558
+ context: Context;
559
+ platform: string;
560
+ }): Promise<void> => {
561
+ const port = context.app.metroPort;
562
+ const manifest = await fetch(`http://localhost:${port}/`, {
563
+ headers: {accept: "application/expo+json,application/json", "expo-platform": platform},
564
+ })
565
+ .then((response) => response.json())
566
+ .catch(() => undefined);
567
+ const bundleUrl = (manifest as {launchAsset?: {url?: string}} | undefined)?.launchAsset?.url;
568
+ if (!bundleUrl) {
569
+ warn("Could not read the Metro manifest; skipping bundle prewarm");
570
+ return;
571
+ }
572
+ say(`Building ${platform} bundle (first build after a cache clear can take several minutes)`);
573
+ const response = await fetch(bundleUrl.replace(/^http:\/\/[^/]+/, `http://localhost:${port}`));
574
+ await response.arrayBuffer();
575
+ if (!response.ok) {
576
+ fail(
577
+ `Metro bundle failed (HTTP ${response.status}); see ${join(WORK_ROOT, context.appKey, "metro.log")}`
578
+ );
579
+ }
580
+ };
581
+
582
+ // --- Capture + validation ---
583
+
584
+ const getImageSize = async ({path}: {path: string}): Promise<[number, number]> => {
585
+ const output = await runOrFail({
586
+ cmd: ["sips", "-g", "pixelWidth", "-g", "pixelHeight", path],
587
+ isQuiet: true,
588
+ });
589
+ return [
590
+ Number(output.match(/pixelWidth: (\d+)/)?.[1]),
591
+ Number(output.match(/pixelHeight: (\d+)/)?.[1]),
592
+ ];
593
+ };
594
+
595
+ const isValidStoreSize = ({
596
+ spec,
597
+ width,
598
+ height,
599
+ }: {
600
+ spec: DeviceSpec;
601
+ width: number;
602
+ height: number;
603
+ }): boolean => {
604
+ const long = Math.max(width, height);
605
+ const short = Math.min(width, height);
606
+ if (spec.platform === "ios") {
607
+ return spec.storeSizes.some(([w, h]) => w === short && h === long);
608
+ }
609
+ return short >= 320 && long <= 3840 && long <= short * 2;
610
+ };
611
+
612
+ const validateScreenshots = async ({
613
+ dir,
614
+ spec,
615
+ }: {
616
+ dir: string;
617
+ spec: DeviceSpec;
618
+ }): Promise<string[]> => {
619
+ const problems: string[] = [];
620
+ const files = existsSync(dir) ? readdirSync(dir).filter((name) => name.endsWith(".png")) : [];
621
+ if (files.length === 0) {
622
+ problems.push(`No screenshots in ${dir}`);
623
+ }
624
+ for (const file of files.sort()) {
625
+ const [width, height] = await getImageSize({path: join(dir, file)});
626
+ const isValid = isValidStoreSize({height, spec, width});
627
+ say(`${isValid ? "ok " : "BAD"} ${file} ${width}x${height}`);
628
+ if (!isValid) {
629
+ problems.push(`${file} is ${width}x${height}, not a valid ${spec.label} size`);
630
+ }
631
+ }
632
+ return problems;
633
+ };
634
+
635
+ const getOutputDir = ({app, deviceKey}: {app: AppConfig; deviceKey: string}): string =>
636
+ join(REPO_ROOT, app.appDir, "storeScreenshots", deviceKey);
637
+
638
+ const runFlow = async ({
639
+ context,
640
+ device,
641
+ }: {
642
+ context: Context;
643
+ device: BootedDevice;
644
+ }): Promise<string[]> => {
645
+ const {app, appKey, config, expo} = context;
646
+ const flow = join(REPO_ROOT, app.appDir, "maestro", "screenshots.yaml");
647
+ if (!existsSync(flow)) {
648
+ return [`Missing flow ${flow}`];
649
+ }
650
+ const outDir = getOutputDir({app, deviceKey: device.key});
651
+ rmSync(outDir, {force: true, recursive: true});
652
+ mkdirSync(outDir, {recursive: true});
653
+
654
+ const flowEnv: Record<string, string> = {
655
+ APP_ID: device.spec.platform === "ios" ? expo.bundleId : expo.androidPackage,
656
+ DEVICE: device.key,
657
+ EMAIL: process.env.SCREENSHOT_EMAIL ?? app.account.email,
658
+ HOME_SCREEN_ID: config.homeScreenTestId ?? "home-screen",
659
+ PASSWORD: process.env.SCREENSHOT_PASSWORD ?? app.account.password,
660
+ PLATFORM: device.spec.platform,
661
+ };
662
+ await prewarmBundle({context, platform: device.spec.platform});
663
+ await launchDevClientIntoMetro({
664
+ deviceId: device.id,
665
+ devMenu: "hideAll",
666
+ expo,
667
+ metroPort: app.metroPort,
668
+ platform: device.spec.platform,
669
+ tag: TAG,
670
+ });
671
+ say(`Running ${appKey} screenshot flow on ${device.spec.label} (${device.id})`);
672
+ const maestroDir = join(WORK_ROOT, appKey, `maestro-${device.key}`);
673
+ rmSync(maestroDir, {force: true, recursive: true});
674
+ const {exitCode} = await run({
675
+ cmd: [
676
+ MAESTRO_BIN,
677
+ "--device",
678
+ device.id,
679
+ "test",
680
+ flow,
681
+ "--test-output-dir",
682
+ maestroDir,
683
+ ...Object.entries(flowEnv).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
684
+ ],
685
+ env: getMaestroEnv(),
686
+ });
687
+ // Maestro only writes takeScreenshot files inside its own run folder; copy the named shots out.
688
+ const shots = existsSync(maestroDir)
689
+ ? readdirSync(maestroDir, {recursive: true})
690
+ .map(String)
691
+ .filter((path) => /(^|\/)\d{2}-[^/]+\.png$/.test(path))
692
+ : [];
693
+ for (const shot of shots) {
694
+ copyFileSync(join(maestroDir, shot), join(outDir, basename(shot)));
695
+ }
696
+ const problems =
697
+ exitCode === 0
698
+ ? []
699
+ : [`Maestro flow failed on ${device.key} (exit ${exitCode}); see ${maestroDir}`];
700
+ return [...problems, ...(await validateScreenshots({dir: outDir, spec: device.spec}))];
701
+ };
702
+
703
+ const getDeviceKeys = ({context, options}: {context: Context; options: Options}): string[] => {
704
+ const {app, appKey} = context;
705
+ if (!options.device) {
706
+ return app.devices;
707
+ }
708
+ if (!app.devices.includes(options.device)) {
709
+ return fail(`${appKey} screenshots use ${app.devices.join(", ")}; not ${options.device}`);
710
+ }
711
+ return [options.device];
712
+ };
713
+
714
+ const commandDevices = async ({
715
+ context,
716
+ options,
717
+ }: {
718
+ context: Context;
719
+ options: Options;
720
+ }): Promise<void> => {
721
+ for (const key of getDeviceKeys({context, options})) {
722
+ const device = await ensureDevice({context, key});
723
+ await ensureDevClient({context, device, options});
724
+ say(`${device.spec.label}: ${device.id}`);
725
+ }
726
+ };
727
+
728
+ const commandRun = async ({
729
+ context,
730
+ options,
731
+ }: {
732
+ context: Context;
733
+ options: Options;
734
+ }): Promise<void> => {
735
+ if (!existsSync(MAESTRO_BIN)) {
736
+ fail("Maestro not installed. Run: bun run screenshots:setup");
737
+ }
738
+ await ensureBackend({context});
739
+ const stopMetro = await startMetro({context});
740
+ const problems: string[] = [];
741
+ try {
742
+ for (const key of getDeviceKeys({context, options})) {
743
+ const device = await ensureDevice({context, key});
744
+ await resetAppState({context, device});
745
+ if (!(await ensureDevClient({context, device, options}))) {
746
+ problems.push(`${key}: dev client not available`);
747
+ continue;
748
+ }
749
+ problems.push(...(await runFlow({context, device})).map((problem) => `${key}: ${problem}`));
750
+ }
751
+ } finally {
752
+ if (stopMetro && options.isKeepMetro) {
753
+ process.removeListener("exit", stopMetro);
754
+ } else {
755
+ stopMetro?.();
756
+ }
757
+ }
758
+ say(`Screenshots in ${join(REPO_ROOT, context.app.appDir, "storeScreenshots")}`);
759
+ if (problems.length > 0) {
760
+ fail(`Problems:\n ${problems.join("\n ")}`);
761
+ }
762
+ };
763
+
764
+ const commandCapture = async ({
765
+ context,
766
+ args,
767
+ }: {
768
+ context: Context;
769
+ args: string[];
770
+ }): Promise<void> => {
771
+ const [deviceKey, name] = args;
772
+ if (!deviceKey || !name) {
773
+ fail("Usage: bun run screenshots:capture <app> <iphone|ipad|android> <name>");
774
+ return;
775
+ }
776
+ const device = await ensureDevice({context, key: deviceKey});
777
+ const outDir = getOutputDir({app: context.app, deviceKey: device.key});
778
+ mkdirSync(outDir, {recursive: true});
779
+ const path = join(outDir, `${name}.png`);
780
+ if (device.spec.platform === "ios") {
781
+ await runOrFail({cmd: ["xcrun", "simctl", "io", device.id, "screenshot", path], isQuiet: true});
782
+ } else {
783
+ const proc = Bun.spawn(["adb", "-s", device.id, "exec-out", "screencap", "-p"], {
784
+ stderr: "pipe",
785
+ stdout: "pipe",
786
+ });
787
+ const [bytes, exitCode] = await Promise.all([
788
+ new Response(proc.stdout).arrayBuffer(),
789
+ proc.exited,
790
+ ]);
791
+ if (exitCode !== 0 || bytes.byteLength === 0) {
792
+ fail(`adb screencap failed (exit ${exitCode})`);
793
+ }
794
+ await Bun.write(path, bytes);
795
+ }
796
+ await validateScreenshots({dir: outDir, spec: device.spec});
797
+ say(`Saved ${path}`);
798
+ };
799
+
800
+ const main = async (): Promise<void> => {
801
+ const [command, appKey, ...rest] = process.argv.slice(2);
802
+ if (command === "setup") {
803
+ await commandSetup();
804
+ return;
805
+ }
806
+ const config = await loadConfig({tag: TAG});
807
+ const app = getApp({appKey, config, tag: TAG});
808
+ const expo = await getExpoInfo({app, tag: TAG});
809
+ // Override when another project already owns the configured API port.
810
+ const apiPort = getApiPort({config});
811
+ const context: Context = {
812
+ apiPort,
813
+ apiUrl: `http://localhost:${apiPort}`,
814
+ app,
815
+ appKey: appKey as string,
816
+ config,
817
+ expo,
818
+ };
819
+
820
+ if (command === "capture") {
821
+ await commandCapture({args: rest, context});
822
+ return;
823
+ }
824
+
825
+ const options: Options = {isBuild: false, isKeepMetro: false};
826
+ for (let i = 0; i < rest.length; i++) {
827
+ const flag = rest[i];
828
+ if (flag === "--device") {
829
+ options.device = rest[++i] ?? fail("--device requires a value");
830
+ } else if (flag === "--build") {
831
+ options.isBuild = true;
832
+ } else if (flag === "--keep-metro") {
833
+ options.isKeepMetro = true;
834
+ } else {
835
+ fail(`Unknown flag "${flag}"`);
836
+ }
837
+ }
838
+
839
+ if (command === "devices") {
840
+ await commandDevices({context, options});
841
+ } else if (command === "run") {
842
+ await commandRun({context, options});
843
+ } else {
844
+ fail(`Unknown command "${command ?? ""}". Use one of: setup, devices, run, capture`);
845
+ }
846
+ };
847
+
848
+ await main();