openuispec 0.2.3 → 0.2.4

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 (23) hide show
  1. package/README.md +42 -3
  2. package/cli/init.ts +2 -0
  3. package/examples/social-app/AGENTS.md +1 -1
  4. package/examples/social-app/CLAUDE.md +1 -1
  5. package/examples/social-app/generated/android/social-app/app/.paparazzi-hashes.json +3 -0
  6. package/examples/social-app/generated/android/social-app/app/build.gradle.kts +2 -0
  7. package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/HomeFeedScreen.kt +12 -0
  8. package/examples/social-app/generated/android/social-app/app/src/test/kotlin/com/social/app/screenshots/HomeFeedScreenshotTest.kt +34 -0
  9. package/examples/social-app/generated/android/social-app/build.gradle.kts +1 -0
  10. package/examples/social-app/generated/android/social-app/gradle/libs.versions.toml +2 -0
  11. package/examples/social-app/generated/android/social-app/gradle/wrapper/gradle-wrapper.jar +0 -0
  12. package/examples/social-app/generated/android/social-app/gradlew +239 -16
  13. package/examples/social-app/generated/android/social-app/settings.gradle.kts +4 -0
  14. package/examples/todo-orbit/generated/ios/Todo Orbit/.screenshot-uitest/Sources/ScreenshotUITest.swift +36 -0
  15. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +204 -212
  16. package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +1 -0
  17. package/mcp-server/index.ts +64 -1
  18. package/mcp-server/screenshot-android.ts +462 -0
  19. package/mcp-server/screenshot-ios.ts +541 -0
  20. package/mcp-server/screenshot-shared.ts +200 -0
  21. package/mcp-server/screenshot.ts +15 -1
  22. package/package.json +3 -2
  23. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +0 -79
@@ -0,0 +1,541 @@
1
+ /**
2
+ * iOS screenshot tool — builds the app, installs on a simulator,
3
+ * and captures real screenshots via xcrun simctl or XCUITest.
4
+ *
5
+ * When `nav` steps are provided, generates an XCUITest that taps elements
6
+ * by accessibility label and captures with XCUIScreen.main.screenshot().
7
+ * Without `nav`, uses xcrun simctl io screenshot directly.
8
+ *
9
+ * Requires Xcode and an iOS Simulator runtime.
10
+ */
11
+
12
+ import { exec as execCb, execSync } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import { existsSync, readFileSync, readdirSync, mkdirSync, copyFileSync, unlinkSync, statSync, writeFileSync } from "node:fs";
15
+ import { join, resolve } from "node:path";
16
+ import {
17
+ type ScreenshotResult,
18
+ findPlatformAppDir,
19
+ buildScreenshotResponse,
20
+ } from "./screenshot-shared.js";
21
+
22
+ const exec = promisify(execCb);
23
+
24
+ // ── types ───────────────────────────────────────────────────────────
25
+
26
+ export interface IOSScreenshotOptions {
27
+ screen?: string;
28
+ device?: string;
29
+ nav?: string[];
30
+ theme?: "light" | "dark";
31
+ wait_for?: number;
32
+ output_dir?: string;
33
+ project_dir?: string;
34
+ scheme?: string;
35
+ bundle_id?: string;
36
+ }
37
+
38
+ // ── iOS app directory discovery ─────────────────────────────────────
39
+
40
+ function hasXcodeProject(dir: string): boolean {
41
+ try {
42
+ return readdirSync(dir).some(
43
+ (e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace") || e === "Package.swift",
44
+ );
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ export function findIOSAppDir(projectCwd: string, directDir?: string): string {
51
+ return findPlatformAppDir(projectCwd, "ios", hasXcodeProject, directDir);
52
+ }
53
+
54
+ // ── Xcode project info extraction ───────────────────────────────────
55
+
56
+ export interface IOSAppInfo {
57
+ projectName: string;
58
+ schemeName: string;
59
+ bundleId: string;
60
+ xcodeproj: string | null;
61
+ xcworkspace: string | null;
62
+ hasXcodegen: boolean;
63
+ deploymentTarget: string;
64
+ }
65
+
66
+ function extractDeploymentTarget(pbxprojContent: string): string | null {
67
+ const match = pbxprojContent.match(/IPHONEOS_DEPLOYMENT_TARGET\s*=\s*"?([0-9.]+)"?/);
68
+ return match ? match[1] : null;
69
+ }
70
+
71
+ export function extractAppInfo(
72
+ iosDir: string,
73
+ overrides?: { scheme?: string; bundle_id?: string },
74
+ ): IOSAppInfo {
75
+ const entries = readdirSync(iosDir);
76
+ const xcodeproj = entries.find((e) => e.endsWith(".xcodeproj")) ?? null;
77
+ const xcworkspace = entries.find((e) => e.endsWith(".xcworkspace")) ?? null;
78
+ const hasXcodegen = entries.includes("project.yml");
79
+ const projectName = xcodeproj?.replace(".xcodeproj", "") ??
80
+ xcworkspace?.replace(".xcworkspace", "") ?? "App";
81
+
82
+ let schemeName = overrides?.scheme ?? projectName;
83
+ let bundleId = overrides?.bundle_id ??
84
+ `com.example.${projectName.toLowerCase().replace(/\s+/g, "")}`;
85
+ let deploymentTarget = "17.0";
86
+
87
+ if (xcodeproj) {
88
+ const pbxprojPath = join(iosDir, xcodeproj, "project.pbxproj");
89
+ try {
90
+ const content = readFileSync(pbxprojPath, "utf-8");
91
+ if (!overrides?.bundle_id) {
92
+ const bundleMatch = content.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";]+)/);
93
+ if (bundleMatch) bundleId = bundleMatch[1];
94
+ }
95
+ const detectedTarget = extractDeploymentTarget(content);
96
+ if (detectedTarget) deploymentTarget = detectedTarget;
97
+ } catch { /* use defaults */ }
98
+
99
+ if (!overrides?.scheme) {
100
+ const schemesDir = join(iosDir, xcodeproj, "xcshareddata", "xcschemes");
101
+ try {
102
+ const schemes = readdirSync(schemesDir).filter(f => f.endsWith(".xcscheme"));
103
+ if (schemes.length > 0) schemeName = schemes[0].replace(".xcscheme", "");
104
+ } catch { /* use project name */ }
105
+ }
106
+ }
107
+
108
+ return { projectName, schemeName, bundleId, xcodeproj, xcworkspace, hasXcodegen, deploymentTarget };
109
+ }
110
+
111
+ // ── simulator helpers ───────────────────────────────────────────────
112
+
113
+ export interface SimDevice {
114
+ name: string;
115
+ udid: string;
116
+ state: string;
117
+ }
118
+
119
+ export function findSimulator(deviceName?: string): SimDevice {
120
+ let output: string;
121
+ try {
122
+ output = execSync("xcrun simctl list devices available -j", { stdio: "pipe", encoding: "utf-8" });
123
+ } catch {
124
+ throw new Error("Failed to list simulators. Ensure Xcode is installed.");
125
+ }
126
+
127
+ const data = JSON.parse(output);
128
+ const devices: Record<string, SimDevice[]> = data.devices;
129
+
130
+ if (deviceName) {
131
+ const shortName = deviceName.replace(/ \(.*\)/, "");
132
+ for (const [runtime, devicesInRuntime] of Object.entries(devices)) {
133
+ if (!runtime.includes("iOS")) continue;
134
+ for (const device of devicesInRuntime) {
135
+ if (device.name === deviceName || device.name.includes(shortName)) {
136
+ return device;
137
+ }
138
+ }
139
+ }
140
+ throw new Error(
141
+ `No simulator found matching "${deviceName}". Run 'xcrun simctl list devices available' to see options.`,
142
+ );
143
+ }
144
+
145
+ // Default: find any booted iPhone, or first available iPhone
146
+ let firstIphone: SimDevice | null = null;
147
+ for (const [runtime, devicesInRuntime] of Object.entries(devices)) {
148
+ if (!runtime.includes("iOS")) continue;
149
+ for (const device of devicesInRuntime) {
150
+ if (!device.name.includes("iPhone") && !device.name.includes("iPad")) continue;
151
+ if (device.state === "Booted") return device;
152
+ if (!firstIphone && device.name.includes("iPhone")) firstIphone = device;
153
+ }
154
+ }
155
+
156
+ if (firstIphone) return firstIphone;
157
+ throw new Error("No iOS Simulator found. Install a simulator runtime via Xcode.");
158
+ }
159
+
160
+ export async function ensureSimulatorBooted(udid: string): Promise<void> {
161
+ try {
162
+ await exec(`xcrun simctl boot ${udid}`);
163
+ await new Promise(r => setTimeout(r, 3000));
164
+ } catch (err: any) {
165
+ if (!err.stderr?.includes("Booted")) throw err;
166
+ }
167
+ }
168
+
169
+ // ── build app ───────────────────────────────────────────────────────
170
+
171
+ export async function buildApp(iosDir: string, appInfo: IOSAppInfo, simulatorUdid: string): Promise<string> {
172
+ const buildDir = join(iosDir, ".build", "screenshot");
173
+ mkdirSync(buildDir, { recursive: true });
174
+
175
+ const projectFlag = appInfo.xcworkspace
176
+ ? `-workspace "${join(iosDir, appInfo.xcworkspace)}"`
177
+ : appInfo.xcodeproj
178
+ ? `-project "${join(iosDir, appInfo.xcodeproj)}"`
179
+ : "";
180
+
181
+ try {
182
+ await exec(
183
+ `xcodebuild build ` +
184
+ `${projectFlag} ` +
185
+ `-scheme "${appInfo.schemeName}" ` +
186
+ `-destination "id=${simulatorUdid}" ` +
187
+ `-derivedDataPath "${buildDir}" ` +
188
+ `-quiet 2>&1`,
189
+ { cwd: iosDir, timeout: 300_000 },
190
+ );
191
+ } catch (err: any) {
192
+ const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).slice(-500);
193
+ throw new Error(`Xcode build failed:\n${output}`);
194
+ }
195
+
196
+ const productsDir = join(buildDir, "Build", "Products");
197
+ const appBundle = findAppBundle(productsDir);
198
+ if (!appBundle) throw new Error("App bundle (.app) not found after build.");
199
+ return appBundle;
200
+ }
201
+
202
+ export function findAppBundle(dir: string): string | null {
203
+ if (!existsSync(dir)) return null;
204
+ for (const entry of readdirSync(dir)) {
205
+ const fullPath = join(dir, entry);
206
+ if (entry.endsWith(".app")) return fullPath;
207
+ try {
208
+ if (statSync(fullPath).isDirectory()) {
209
+ const found = findAppBundle(fullPath);
210
+ if (found) return found;
211
+ }
212
+ } catch { /* skip */ }
213
+ }
214
+ return null;
215
+ }
216
+
217
+ // ── install & launch ────────────────────────────────────────────────
218
+
219
+ export async function installAndLaunch(udid: string, appBundlePath: string, bundleId: string): Promise<void> {
220
+ await exec(`xcrun simctl install ${udid} "${appBundlePath}"`, { timeout: 60_000 });
221
+ try { await exec(`xcrun simctl terminate ${udid} ${bundleId}`); } catch { /* not running */ }
222
+ await exec(`xcrun simctl launch ${udid} ${bundleId}`, { timeout: 30_000 });
223
+ }
224
+
225
+ // ── theme control ───────────────────────────────────────────────────
226
+
227
+ async function setAppearance(udid: string, theme: "light" | "dark"): Promise<void> {
228
+ await exec(`xcrun simctl ui ${udid} appearance ${theme}`);
229
+ }
230
+
231
+ // ── XCUITest-based navigation + screenshot ──────────────────────────
232
+
233
+ const UITEST_TARGET = "ScreenshotUITests";
234
+ const UITEST_DIR = ".screenshot-uitest";
235
+
236
+ function generateUITestSwift(
237
+ bundleId: string,
238
+ navSteps: string[],
239
+ waitMs: number,
240
+ outputPath: string,
241
+ ): string {
242
+ const taps = navSteps.map((step, i) => {
243
+ const escaped = step.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
244
+ return `
245
+ // Tap "${escaped}"
246
+ let target_${i} = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@ OR title == %@", "${escaped}", "${escaped}")).firstMatch
247
+ if target_${i}.waitForExistence(timeout: 5) {
248
+ target_${i}.tap()
249
+ Thread.sleep(forTimeInterval: 0.8)
250
+ }`;
251
+ }).join("\n");
252
+
253
+ return `import XCTest
254
+
255
+ final class ScreenshotUITest: XCTestCase {
256
+ func testNavigateAndScreenshot() {
257
+ let app = XCUIApplication()
258
+ app.launchArguments = ["-AppleLanguages", "(en)"]
259
+ app.launch()
260
+
261
+ // Wait for app to load
262
+ Thread.sleep(forTimeInterval: ${(waitMs / 1000).toFixed(1)})
263
+ ${taps}
264
+
265
+ // Wait for navigation to settle
266
+ Thread.sleep(forTimeInterval: 0.5)
267
+
268
+ // Capture screenshot
269
+ let screenshot = XCUIScreen.main.screenshot()
270
+ let pngData = screenshot.pngRepresentation
271
+ let outputPath = "${outputPath.replace(/"/g, '\\"')}"
272
+ try! pngData.write(to: URL(fileURLWithPath: outputPath))
273
+ }
274
+ }
275
+ `;
276
+ }
277
+
278
+ function generateXcodegenConfig(
279
+ appInfo: IOSAppInfo,
280
+ testSourcesDir: string,
281
+ ): string {
282
+ return `name: ${UITEST_TARGET}
283
+ targets:
284
+ ${UITEST_TARGET}:
285
+ type: bundle.ui-testing
286
+ platform: iOS
287
+ deploymentTarget: "${appInfo.deploymentTarget}"
288
+ sources:
289
+ - path: ${testSourcesDir}
290
+ dependencies:
291
+ - target: ${appInfo.schemeName}
292
+ embed: false
293
+ settings:
294
+ base:
295
+ TEST_TARGET_NAME: ${appInfo.schemeName}
296
+ PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
297
+ GENERATE_INFOPLIST_FILE: YES
298
+ `;
299
+ }
300
+
301
+ async function runXCUITest(
302
+ iosDir: string,
303
+ appInfo: IOSAppInfo,
304
+ simulatorUdid: string,
305
+ navSteps: string[],
306
+ waitMs: number,
307
+ screenshotOutputPath: string,
308
+ ): Promise<void> {
309
+ const uitestDir = join(iosDir, UITEST_DIR);
310
+ const sourcesDir = join(uitestDir, "Sources");
311
+ mkdirSync(sourcesDir, { recursive: true });
312
+
313
+ // Generate the UI test Swift file
314
+ const testSwift = generateUITestSwift(appInfo.bundleId, navSteps, waitMs, screenshotOutputPath);
315
+ writeFileSync(join(sourcesDir, "ScreenshotUITest.swift"), testSwift);
316
+
317
+ // For xcodegen projects: add UI test target to project.yml temporarily
318
+ // For non-xcodegen projects: generate a standalone XCUITest xcode project in .screenshot-uitest/
319
+ const projectYmlPath = join(iosDir, "project.yml");
320
+ let originalProjectYml: string | null = null;
321
+ let standaloneProjectDir: string | null = null;
322
+
323
+ if (appInfo.hasXcodegen) {
324
+ originalProjectYml = readFileSync(projectYmlPath, "utf-8");
325
+
326
+ // Ensure main target has GENERATE_INFOPLIST_FILE and append UI test target
327
+ let modifiedYml = originalProjectYml;
328
+ if (!modifiedYml.includes("GENERATE_INFOPLIST_FILE")) {
329
+ // Add after the first PRODUCT_BUNDLE_IDENTIFIER line
330
+ modifiedYml = modifiedYml.replace(
331
+ /(PRODUCT_BUNDLE_IDENTIFIER:[^\n]+\n)/,
332
+ "$1 GENERATE_INFOPLIST_FILE: YES\n",
333
+ );
334
+ }
335
+
336
+ const uitestConfig = `
337
+ ${UITEST_TARGET}:
338
+ type: bundle.ui-testing
339
+ platform: iOS
340
+ deploymentTarget: "${appInfo.deploymentTarget}"
341
+ sources:
342
+ - path: ${UITEST_DIR}/Sources
343
+ dependencies:
344
+ - target: ${appInfo.schemeName}
345
+ embed: false
346
+ settings:
347
+ base:
348
+ TEST_TARGET_NAME: ${appInfo.schemeName}
349
+ PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
350
+ GENERATE_INFOPLIST_FILE: YES
351
+ `;
352
+ writeFileSync(projectYmlPath, modifiedYml + uitestConfig);
353
+
354
+ // Regenerate Xcode project
355
+ try {
356
+ await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
357
+ } catch (err: any) {
358
+ // Restore original project.yml
359
+ writeFileSync(projectYmlPath, originalProjectYml);
360
+ throw new Error(`xcodegen failed: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
361
+ }
362
+ } else {
363
+ // Non-xcodegen project: create a standalone XCUITest project in .screenshot-uitest/
364
+ standaloneProjectDir = uitestDir;
365
+ const standaloneYml = `name: ${UITEST_TARGET}
366
+ targets:
367
+ ${UITEST_TARGET}:
368
+ type: bundle.ui-testing
369
+ platform: iOS
370
+ deploymentTarget: "${appInfo.deploymentTarget}"
371
+ sources:
372
+ - path: Sources
373
+ settings:
374
+ base:
375
+ TEST_TARGET_NAME: ${appInfo.schemeName}
376
+ PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
377
+ GENERATE_INFOPLIST_FILE: YES
378
+ `;
379
+ writeFileSync(join(uitestDir, "project.yml"), standaloneYml);
380
+
381
+ try {
382
+ await exec(`xcodegen generate`, { cwd: uitestDir, timeout: 30_000 });
383
+ } catch (err: any) {
384
+ throw new Error(`xcodegen failed for standalone UI test project: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
385
+ }
386
+ }
387
+
388
+ const buildDir = join(iosDir, ".build", "screenshot");
389
+ let projectFlag: string;
390
+ let testCwd: string;
391
+
392
+ if (standaloneProjectDir) {
393
+ // Use the standalone project generated in .screenshot-uitest/
394
+ projectFlag = `-project "${join(standaloneProjectDir, `${UITEST_TARGET}.xcodeproj`)}"`;
395
+ testCwd = standaloneProjectDir;
396
+ } else {
397
+ projectFlag = appInfo.xcodeproj
398
+ ? `-project "${join(iosDir, appInfo.xcodeproj)}"`
399
+ : "";
400
+ testCwd = iosDir;
401
+ }
402
+
403
+ try {
404
+ await exec(
405
+ `xcodebuild test ` +
406
+ `${projectFlag} ` +
407
+ `-scheme "${UITEST_TARGET}" ` +
408
+ `-destination "id=${simulatorUdid}" ` +
409
+ `-derivedDataPath "${buildDir}" ` +
410
+ `-only-testing:${UITEST_TARGET}/ScreenshotUITest/testNavigateAndScreenshot ` +
411
+ `2>&1`,
412
+ { cwd: testCwd, timeout: 300_000 },
413
+ );
414
+ } catch (err: any) {
415
+ const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).slice(-500);
416
+ // The test might "fail" but still produce the screenshot
417
+ if (!existsSync(screenshotOutputPath)) {
418
+ throw new Error(`XCUITest failed:\n${output}`);
419
+ }
420
+ } finally {
421
+ // Restore original project.yml for xcodegen projects
422
+ if (originalProjectYml) {
423
+ writeFileSync(projectYmlPath, originalProjectYml);
424
+ // Regenerate to restore original xcodeproj
425
+ try { await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 }); } catch { /* best effort */ }
426
+ }
427
+ }
428
+ }
429
+
430
+ // ── screenshot capture (simple, no nav) ─────────────────────────────
431
+
432
+ export async function captureScreenshot(udid: string, localPath: string): Promise<void> {
433
+ await exec(`xcrun simctl io ${udid} screenshot "${localPath}"`, { timeout: 15_000 });
434
+ }
435
+
436
+ // ── wait for app ready ──────────────────────────────────────────────
437
+
438
+ async function waitForAppReady(udid: string, bundleId: string, waitMs: number): Promise<void> {
439
+ const startTime = Date.now();
440
+ const timeout = Math.min(waitMs, 15_000);
441
+
442
+ while (Date.now() - startTime < timeout) {
443
+ try {
444
+ const { stdout } = await exec(`xcrun simctl spawn ${udid} launchctl list`);
445
+ if (stdout.includes(bundleId)) {
446
+ const elapsed = Date.now() - startTime;
447
+ const remaining = Math.max(waitMs - elapsed, 500);
448
+ await new Promise(r => setTimeout(r, remaining));
449
+ return;
450
+ }
451
+ } catch { /* not ready yet */ }
452
+ await new Promise(r => setTimeout(r, 500));
453
+ }
454
+
455
+ await new Promise(r => setTimeout(r, waitMs));
456
+ }
457
+
458
+ // ── main entry point ────────────────────────────────────────────────
459
+
460
+ export async function takeIOSScreenshot(
461
+ projectCwd: string,
462
+ options: IOSScreenshotOptions,
463
+ ): Promise<ScreenshotResult> {
464
+ const {
465
+ screen,
466
+ device,
467
+ nav,
468
+ theme,
469
+ wait_for = 3000,
470
+ output_dir,
471
+ project_dir,
472
+ scheme,
473
+ bundle_id,
474
+ } = options;
475
+
476
+ // 1. Find iOS project
477
+ const iosDir = findIOSAppDir(projectCwd, project_dir);
478
+ const appInfo = extractAppInfo(iosDir, { scheme, bundle_id });
479
+
480
+ // 2. Find and boot simulator
481
+ const sim = findSimulator(device);
482
+ await ensureSimulatorBooted(sim.udid);
483
+
484
+ // 3. Set theme if requested
485
+ if (theme) {
486
+ await setAppearance(sim.udid, theme);
487
+ }
488
+
489
+ // 4. Capture screenshot
490
+ const screenLabel = screen ?? "main";
491
+ const themeLabel = theme ?? "default";
492
+ const filename = `${screenLabel}_${themeLabel}.png`;
493
+ const tmpPath = join(iosDir, ".openuispec-screenshot.png");
494
+
495
+ if (nav && nav.length > 0) {
496
+ // Use XCUITest for navigation + screenshot (builds both targets via xcodebuild test)
497
+ await runXCUITest(iosDir, appInfo, sim.udid, nav, wait_for, tmpPath);
498
+ } else {
499
+ // Simple: build, install, launch, wait, screencap
500
+ const appBundlePath = await buildApp(iosDir, appInfo, sim.udid);
501
+ await installAndLaunch(sim.udid, appBundlePath, appInfo.bundleId);
502
+ await waitForAppReady(sim.udid, appInfo.bundleId, wait_for);
503
+ await captureScreenshot(sim.udid, tmpPath);
504
+ }
505
+
506
+ if (!existsSync(tmpPath)) {
507
+ return {
508
+ content: [{ type: "text", text: "No screenshot was captured. Check Xcode and Simulator output." }],
509
+ isError: true,
510
+ };
511
+ }
512
+
513
+ // 6. Save to output_dir if specified
514
+ let savedPath: string | undefined;
515
+ if (output_dir) {
516
+ const outDir = resolve(iosDir, output_dir);
517
+ mkdirSync(outDir, { recursive: true });
518
+ savedPath = join(outDir, filename);
519
+ copyFileSync(tmpPath, savedPath);
520
+ }
521
+
522
+ // 7. Read and return
523
+ try {
524
+ const data = readFileSync(tmpPath).toString("base64");
525
+ const snapshots = [{
526
+ screen: screenLabel,
527
+ path: savedPath ?? filename,
528
+ data,
529
+ }];
530
+
531
+ return buildScreenshotResponse(snapshots, (s) => ({
532
+ screen: s.screen,
533
+ path: savedPath ?? null,
534
+ simulator: sim.name,
535
+ theme: themeLabel,
536
+ bundleId: appInfo.bundleId,
537
+ }));
538
+ } finally {
539
+ try { unlinkSync(tmpPath); } catch { /* ignore */ }
540
+ }
541
+ }