openuispec 0.2.18 → 0.2.20

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 (45) hide show
  1. package/README.md +2 -10
  2. package/dist/check/audit.js +392 -0
  3. package/dist/check/index.js +216 -0
  4. package/dist/cli/configure-target.js +391 -0
  5. package/dist/cli/index.js +510 -0
  6. package/dist/cli/init.js +1047 -0
  7. package/dist/drift/index.js +903 -0
  8. package/dist/mcp-server/index.js +886 -0
  9. package/dist/mcp-server/preview-render.js +1761 -0
  10. package/dist/mcp-server/preview.js +233 -0
  11. package/dist/mcp-server/screenshot-android.js +458 -0
  12. package/dist/mcp-server/screenshot-ios.js +639 -0
  13. package/dist/mcp-server/screenshot-shared.js +180 -0
  14. package/dist/mcp-server/screenshot.js +459 -0
  15. package/dist/prepare/index.js +1216 -0
  16. package/dist/runtime/package-paths.js +33 -0
  17. package/dist/schema/semantic-lint.js +564 -0
  18. package/dist/schema/validate.js +689 -0
  19. package/dist/status/index.js +194 -0
  20. package/docs/images/how-it-works.svg +56 -0
  21. package/docs/images/workflows.svg +76 -0
  22. package/package.json +12 -13
  23. package/check/audit.ts +0 -426
  24. package/check/index.ts +0 -320
  25. package/cli/configure-target.ts +0 -523
  26. package/cli/index.ts +0 -537
  27. package/cli/init.ts +0 -1253
  28. package/docs/images/how-it-works-dark.png +0 -0
  29. package/docs/images/how-it-works-light.png +0 -0
  30. package/docs/images/workflows-dark.png +0 -0
  31. package/docs/images/workflows-light.png +0 -0
  32. package/drift/index.ts +0 -1165
  33. package/mcp-server/index.ts +0 -1041
  34. package/mcp-server/preview-render.ts +0 -1922
  35. package/mcp-server/preview.ts +0 -292
  36. package/mcp-server/screenshot-android.ts +0 -621
  37. package/mcp-server/screenshot-ios.ts +0 -753
  38. package/mcp-server/screenshot-shared.ts +0 -237
  39. package/mcp-server/screenshot.ts +0 -563
  40. package/prepare/index.ts +0 -1530
  41. package/schema/semantic-lint.ts +0 -692
  42. package/schema/validate.ts +0 -870
  43. package/scripts/regenerate-previews.ts +0 -136
  44. package/scripts/take-all-screenshots.ts +0 -507
  45. package/status/index.ts +0 -275
@@ -0,0 +1,639 @@
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
+ import { exec as execCb, execSync } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+ import { existsSync, readFileSync, readdirSync, mkdirSync, copyFileSync, unlinkSync, statSync, writeFileSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { findPlatformAppDir, buildScreenshotResponse, } from "./screenshot-shared.js";
16
+ const exec = promisify(execCb);
17
+ let iosScreenshotQueue = Promise.resolve();
18
+ async function withIOSScreenshotLock(run) {
19
+ const previousRun = iosScreenshotQueue;
20
+ let releaseQueue;
21
+ const currentRun = new Promise((resolve) => {
22
+ releaseQueue = resolve;
23
+ });
24
+ const queuedRun = previousRun.then(() => currentRun);
25
+ iosScreenshotQueue = queuedRun;
26
+ await previousRun;
27
+ try {
28
+ return await run();
29
+ }
30
+ finally {
31
+ releaseQueue?.();
32
+ if (iosScreenshotQueue === queuedRun) {
33
+ iosScreenshotQueue = Promise.resolve();
34
+ }
35
+ }
36
+ }
37
+ // ── iOS app directory discovery ─────────────────────────────────────
38
+ function hasXcodeProject(dir) {
39
+ try {
40
+ return readdirSync(dir).some((e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace") || e === "Package.swift");
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ export function findIOSAppDir(projectCwd, directDir) {
47
+ return findPlatformAppDir(projectCwd, "ios", hasXcodeProject, directDir);
48
+ }
49
+ function extractDeploymentTarget(pbxprojContent) {
50
+ const match = pbxprojContent.match(/IPHONEOS_DEPLOYMENT_TARGET\s*=\s*"?([0-9.]+)"?/);
51
+ return match ? match[1] : null;
52
+ }
53
+ export function extractAppInfo(iosDir, overrides) {
54
+ const entries = readdirSync(iosDir);
55
+ const xcodeproj = entries.find((e) => e.endsWith(".xcodeproj")) ?? null;
56
+ const xcworkspace = entries.find((e) => e.endsWith(".xcworkspace")) ?? null;
57
+ const hasXcodegen = entries.includes("project.yml");
58
+ const projectName = xcodeproj?.replace(".xcodeproj", "") ??
59
+ xcworkspace?.replace(".xcworkspace", "") ?? "App";
60
+ let schemeName = overrides?.scheme ?? projectName;
61
+ let bundleId = overrides?.bundle_id ??
62
+ `com.example.${projectName.toLowerCase().replace(/\s+/g, "")}`;
63
+ let deploymentTarget = "17.0";
64
+ if (xcodeproj) {
65
+ const pbxprojPath = join(iosDir, xcodeproj, "project.pbxproj");
66
+ try {
67
+ const content = readFileSync(pbxprojPath, "utf-8");
68
+ if (!overrides?.bundle_id) {
69
+ const bundleMatch = content.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";]+)/);
70
+ if (bundleMatch)
71
+ bundleId = bundleMatch[1];
72
+ }
73
+ const detectedTarget = extractDeploymentTarget(content);
74
+ if (detectedTarget)
75
+ deploymentTarget = detectedTarget;
76
+ }
77
+ catch { /* use defaults */ }
78
+ if (!overrides?.scheme) {
79
+ const schemesDir = join(iosDir, xcodeproj, "xcshareddata", "xcschemes");
80
+ try {
81
+ const schemes = readdirSync(schemesDir).filter(f => f.endsWith(".xcscheme"));
82
+ if (schemes.length > 0)
83
+ schemeName = schemes[0].replace(".xcscheme", "");
84
+ }
85
+ catch { /* use project name */ }
86
+ }
87
+ }
88
+ return { projectName, schemeName, bundleId, xcodeproj, xcworkspace, hasXcodegen, deploymentTarget };
89
+ }
90
+ export function findSimulator(deviceName) {
91
+ let output;
92
+ try {
93
+ output = execSync("xcrun simctl list devices available -j", { stdio: "pipe", encoding: "utf-8" });
94
+ }
95
+ catch {
96
+ throw new Error("Failed to list simulators. Ensure Xcode is installed.");
97
+ }
98
+ const data = JSON.parse(output);
99
+ const devices = data.devices;
100
+ if (deviceName) {
101
+ const shortName = deviceName.replace(/ \(.*\)/, "");
102
+ for (const [runtime, devicesInRuntime] of Object.entries(devices)) {
103
+ if (!runtime.includes("iOS"))
104
+ continue;
105
+ for (const device of devicesInRuntime) {
106
+ if (device.name === deviceName || device.name.includes(shortName)) {
107
+ return device;
108
+ }
109
+ }
110
+ }
111
+ throw new Error(`No simulator found matching "${deviceName}". Run 'xcrun simctl list devices available' to see options.`);
112
+ }
113
+ // Default: find any booted iPhone, or first available iPhone
114
+ let firstIphone = null;
115
+ for (const [runtime, devicesInRuntime] of Object.entries(devices)) {
116
+ if (!runtime.includes("iOS"))
117
+ continue;
118
+ for (const device of devicesInRuntime) {
119
+ if (!device.name.includes("iPhone") && !device.name.includes("iPad"))
120
+ continue;
121
+ if (device.state === "Booted")
122
+ return device;
123
+ if (!firstIphone && device.name.includes("iPhone"))
124
+ firstIphone = device;
125
+ }
126
+ }
127
+ if (firstIphone)
128
+ return firstIphone;
129
+ throw new Error("No iOS Simulator found. Install a simulator runtime via Xcode.");
130
+ }
131
+ export async function ensureSimulatorBooted(udid) {
132
+ try {
133
+ await exec(`xcrun simctl boot ${udid}`);
134
+ await new Promise(r => setTimeout(r, 3000));
135
+ }
136
+ catch (err) {
137
+ if (!err.stderr?.includes("Booted"))
138
+ throw err;
139
+ }
140
+ }
141
+ // ── build app ───────────────────────────────────────────────────────
142
+ export async function buildApp(iosDir, appInfo, simulatorUdid) {
143
+ const buildDir = join(iosDir, ".build", "screenshot");
144
+ mkdirSync(buildDir, { recursive: true });
145
+ const projectFlag = appInfo.xcworkspace
146
+ ? `-workspace "${join(iosDir, appInfo.xcworkspace)}"`
147
+ : appInfo.xcodeproj
148
+ ? `-project "${join(iosDir, appInfo.xcodeproj)}"`
149
+ : "";
150
+ try {
151
+ await exec(`xcodebuild build ` +
152
+ `${projectFlag} ` +
153
+ `-scheme "${appInfo.schemeName}" ` +
154
+ `-destination "id=${simulatorUdid}" ` +
155
+ `-derivedDataPath "${buildDir}" ` +
156
+ `-quiet 2>&1`, { cwd: iosDir, timeout: 300_000 });
157
+ }
158
+ catch (err) {
159
+ const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).slice(-500);
160
+ throw new Error(`Xcode build failed:\n${output}`);
161
+ }
162
+ const productsDir = join(buildDir, "Build", "Products");
163
+ const appBundle = findAppBundle(productsDir);
164
+ if (!appBundle)
165
+ throw new Error("App bundle (.app) not found after build.");
166
+ return appBundle;
167
+ }
168
+ export function findAppBundle(dir) {
169
+ if (!existsSync(dir))
170
+ return null;
171
+ for (const entry of readdirSync(dir)) {
172
+ const fullPath = join(dir, entry);
173
+ if (entry.endsWith(".app"))
174
+ return fullPath;
175
+ try {
176
+ if (statSync(fullPath).isDirectory()) {
177
+ const found = findAppBundle(fullPath);
178
+ if (found)
179
+ return found;
180
+ }
181
+ }
182
+ catch { /* skip */ }
183
+ }
184
+ return null;
185
+ }
186
+ // ── install & launch ────────────────────────────────────────────────
187
+ export async function installAndLaunch(udid, appBundlePath, bundleId) {
188
+ await exec(`xcrun simctl install ${udid} "${appBundlePath}"`, { timeout: 60_000 });
189
+ try {
190
+ await exec(`xcrun simctl terminate ${udid} ${bundleId}`);
191
+ }
192
+ catch { /* not running */ }
193
+ await exec(`xcrun simctl launch ${udid} ${bundleId}`, { timeout: 30_000 });
194
+ }
195
+ // ── theme control ───────────────────────────────────────────────────
196
+ async function setAppearance(udid, theme) {
197
+ await exec(`xcrun simctl ui ${udid} appearance ${theme}`);
198
+ }
199
+ // ── XCUITest-based navigation + screenshot ──────────────────────────
200
+ const UITEST_TARGET = "ScreenshotUITests";
201
+ const UITEST_DIR = ".screenshot-uitest";
202
+ export function generateUITestTargetYml(appInfo, sourcePath, includeProductName = false) {
203
+ const productLines = includeProductName
204
+ ? `\n PRODUCT_NAME: ${UITEST_TARGET}\n PRODUCT_MODULE_NAME: ${UITEST_TARGET}`
205
+ : "";
206
+ return ` ${UITEST_TARGET}:
207
+ type: bundle.ui-testing
208
+ platform: iOS
209
+ deploymentTarget: "${appInfo.deploymentTarget}"
210
+ sources:
211
+ - path: ${sourcePath}
212
+ dependencies:
213
+ - target: ${appInfo.schemeName}
214
+ embed: false
215
+ settings:
216
+ base:${productLines}
217
+ TEST_TARGET_NAME: ${appInfo.schemeName}
218
+ PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
219
+ GENERATE_INFOPLIST_FILE: YES`;
220
+ }
221
+ export function insertUITestTarget(yml, targetYml) {
222
+ if (yml.includes("\nschemes:")) {
223
+ return yml.replace("\nschemes:", `\n${targetYml}\nschemes:`);
224
+ }
225
+ return yml + "\n" + targetYml + "\n";
226
+ }
227
+ export function ensureInfoPlistFlag(yml) {
228
+ if (yml.includes("GENERATE_INFOPLIST_FILE"))
229
+ return yml;
230
+ return yml.replace(/(PRODUCT_BUNDLE_IDENTIFIER:[^\n]+\n)/, "$1 GENERATE_INFOPLIST_FILE: YES\n");
231
+ }
232
+ function generateUITestSwift(bundleId, navSteps, waitMs, outputPath) {
233
+ const taps = navSteps.map((step, i) => {
234
+ const escaped = step.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
235
+ return `
236
+ // Tap "${escaped}"
237
+ let target_${i} = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@ OR title == %@", "${escaped}", "${escaped}")).firstMatch
238
+ if target_${i}.waitForExistence(timeout: 5) {
239
+ target_${i}.tap()
240
+ Thread.sleep(forTimeInterval: 0.8)
241
+ }`;
242
+ }).join("\n");
243
+ return `import XCTest
244
+
245
+ final class ScreenshotUITest: XCTestCase {
246
+ func testNavigateAndScreenshot() {
247
+ let app = XCUIApplication()
248
+ app.launchArguments = ["-AppleLanguages", "(en)"]
249
+ app.launch()
250
+
251
+ // Wait for app to load
252
+ Thread.sleep(forTimeInterval: ${(waitMs / 1000).toFixed(1)})
253
+ ${taps}
254
+
255
+ // Wait for navigation to settle
256
+ Thread.sleep(forTimeInterval: 0.5)
257
+
258
+ // Capture screenshot
259
+ let screenshot = XCUIScreen.main.screenshot()
260
+ let pngData = screenshot.pngRepresentation
261
+ let outputPath = "${outputPath.replace(/"/g, '\\"')}"
262
+ try! pngData.write(to: URL(fileURLWithPath: outputPath))
263
+ }
264
+ }
265
+ `;
266
+ }
267
+ function generateXcodegenConfig(appInfo, testSourcesDir) {
268
+ return `name: ${UITEST_TARGET}
269
+ targets:
270
+ ${UITEST_TARGET}:
271
+ type: bundle.ui-testing
272
+ platform: iOS
273
+ deploymentTarget: "${appInfo.deploymentTarget}"
274
+ sources:
275
+ - path: ${testSourcesDir}
276
+ dependencies:
277
+ - target: ${appInfo.schemeName}
278
+ embed: false
279
+ settings:
280
+ base:
281
+ TEST_TARGET_NAME: ${appInfo.schemeName}
282
+ PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
283
+ GENERATE_INFOPLIST_FILE: YES
284
+ `;
285
+ }
286
+ async function runXCUITest(iosDir, appInfo, simulatorUdid, navSteps, waitMs, screenshotOutputPath) {
287
+ const uitestDir = join(iosDir, UITEST_DIR);
288
+ const sourcesDir = join(uitestDir, "Sources");
289
+ mkdirSync(sourcesDir, { recursive: true });
290
+ // Generate the UI test Swift file
291
+ const testSwift = generateUITestSwift(appInfo.bundleId, navSteps, waitMs, screenshotOutputPath);
292
+ writeFileSync(join(sourcesDir, "ScreenshotUITest.swift"), testSwift);
293
+ // For xcodegen projects: add UI test target to project.yml temporarily
294
+ // For non-xcodegen projects: generate a standalone XCUITest xcode project in .screenshot-uitest/
295
+ const projectYmlPath = join(iosDir, "project.yml");
296
+ let originalProjectYml = null;
297
+ let standaloneProjectDir = null;
298
+ if (appInfo.hasXcodegen) {
299
+ originalProjectYml = readFileSync(projectYmlPath, "utf-8");
300
+ let modifiedYml = ensureInfoPlistFlag(originalProjectYml);
301
+ modifiedYml = insertUITestTarget(modifiedYml, generateUITestTargetYml(appInfo, `${UITEST_DIR}/Sources`));
302
+ writeFileSync(projectYmlPath, modifiedYml);
303
+ try {
304
+ await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
305
+ }
306
+ catch (err) {
307
+ writeFileSync(projectYmlPath, originalProjectYml);
308
+ throw new Error(`xcodegen failed: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
309
+ }
310
+ }
311
+ else {
312
+ // Non-xcodegen project: create a standalone XCUITest project in .screenshot-uitest/
313
+ standaloneProjectDir = uitestDir;
314
+ const standaloneYml = `name: ${UITEST_TARGET}
315
+ targets:
316
+ ${UITEST_TARGET}:
317
+ type: bundle.ui-testing
318
+ platform: iOS
319
+ deploymentTarget: "${appInfo.deploymentTarget}"
320
+ sources:
321
+ - path: Sources
322
+ settings:
323
+ base:
324
+ TEST_TARGET_NAME: ${appInfo.schemeName}
325
+ PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
326
+ GENERATE_INFOPLIST_FILE: YES
327
+ `;
328
+ writeFileSync(join(uitestDir, "project.yml"), standaloneYml);
329
+ try {
330
+ await exec(`xcodegen generate`, { cwd: uitestDir, timeout: 30_000 });
331
+ }
332
+ catch (err) {
333
+ throw new Error(`xcodegen failed for standalone UI test project: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
334
+ }
335
+ }
336
+ const buildDir = join(iosDir, ".build", "screenshot");
337
+ let projectFlag;
338
+ let testCwd;
339
+ if (standaloneProjectDir) {
340
+ // Use the standalone project generated in .screenshot-uitest/
341
+ projectFlag = `-project "${join(standaloneProjectDir, `${UITEST_TARGET}.xcodeproj`)}"`;
342
+ testCwd = standaloneProjectDir;
343
+ }
344
+ else {
345
+ projectFlag = appInfo.xcodeproj
346
+ ? `-project "${join(iosDir, appInfo.xcodeproj)}"`
347
+ : "";
348
+ testCwd = iosDir;
349
+ }
350
+ try {
351
+ await exec(`xcodebuild test ` +
352
+ `${projectFlag} ` +
353
+ `-scheme "${UITEST_TARGET}" ` +
354
+ `-destination "id=${simulatorUdid}" ` +
355
+ `-derivedDataPath "${buildDir}" ` +
356
+ `-only-testing:${UITEST_TARGET}/ScreenshotUITest/testNavigateAndScreenshot ` +
357
+ `2>&1`, { cwd: testCwd, timeout: 300_000 });
358
+ }
359
+ catch (err) {
360
+ const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).slice(-500);
361
+ // The test might "fail" but still produce the screenshot
362
+ if (!existsSync(screenshotOutputPath)) {
363
+ throw new Error(`XCUITest failed:\n${output}`);
364
+ }
365
+ }
366
+ finally {
367
+ // Restore original project.yml for xcodegen projects
368
+ if (originalProjectYml) {
369
+ writeFileSync(projectYmlPath, originalProjectYml);
370
+ // Regenerate to restore original xcodeproj
371
+ try {
372
+ await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
373
+ }
374
+ catch { /* best effort */ }
375
+ }
376
+ }
377
+ }
378
+ // ── screenshot capture (simple, no nav) ─────────────────────────────
379
+ export async function captureScreenshot(udid, localPath) {
380
+ await exec(`xcrun simctl io ${udid} screenshot "${localPath}"`, { timeout: 15_000 });
381
+ }
382
+ // ── wait for app ready ──────────────────────────────────────────────
383
+ async function waitForAppReady(udid, bundleId, waitMs) {
384
+ const startTime = Date.now();
385
+ const timeout = Math.min(waitMs, 15_000);
386
+ while (Date.now() - startTime < timeout) {
387
+ try {
388
+ const { stdout } = await exec(`xcrun simctl spawn ${udid} launchctl list`);
389
+ if (stdout.includes(bundleId)) {
390
+ const elapsed = Date.now() - startTime;
391
+ const remaining = Math.max(waitMs - elapsed, 500);
392
+ await new Promise(r => setTimeout(r, remaining));
393
+ return;
394
+ }
395
+ }
396
+ catch { /* not ready yet */ }
397
+ await new Promise(r => setTimeout(r, 500));
398
+ }
399
+ await new Promise(r => setTimeout(r, waitMs));
400
+ }
401
+ // ── main entry point ────────────────────────────────────────────────
402
+ export async function takeIOSScreenshot(projectCwd, options) {
403
+ return withIOSScreenshotLock(async () => {
404
+ const { screen, device, nav, theme, wait_for = 3000, output_dir, project_dir, scheme, bundle_id, } = options;
405
+ // 1. Find iOS project
406
+ const iosDir = findIOSAppDir(projectCwd, project_dir);
407
+ const appInfo = extractAppInfo(iosDir, { scheme, bundle_id });
408
+ // 2. Find and boot simulator
409
+ const sim = findSimulator(device);
410
+ await ensureSimulatorBooted(sim.udid);
411
+ // 3. Set theme if requested
412
+ if (theme) {
413
+ await setAppearance(sim.udid, theme);
414
+ }
415
+ // 4. Capture screenshot
416
+ const screenLabel = screen ?? "main";
417
+ const themeLabel = theme ?? "default";
418
+ const filename = `${screenLabel}_${themeLabel}.png`;
419
+ const tmpPath = join(iosDir, ".openuispec-screenshot.png");
420
+ if (nav && nav.length > 0) {
421
+ // Use XCUITest for navigation + screenshot (builds both targets via xcodebuild test)
422
+ await runXCUITest(iosDir, appInfo, sim.udid, nav, wait_for, tmpPath);
423
+ }
424
+ else {
425
+ // Simple: build, install, launch, wait, screencap
426
+ const appBundlePath = await buildApp(iosDir, appInfo, sim.udid);
427
+ await installAndLaunch(sim.udid, appBundlePath, appInfo.bundleId);
428
+ await waitForAppReady(sim.udid, appInfo.bundleId, wait_for);
429
+ await captureScreenshot(sim.udid, tmpPath);
430
+ }
431
+ if (!existsSync(tmpPath)) {
432
+ return {
433
+ content: [{ type: "text", text: "No screenshot was captured. Check Xcode and Simulator output." }],
434
+ isError: true,
435
+ };
436
+ }
437
+ // 6. Save to output_dir if specified
438
+ let savedPath;
439
+ if (output_dir) {
440
+ const outDir = resolve(iosDir, output_dir);
441
+ mkdirSync(outDir, { recursive: true });
442
+ savedPath = join(outDir, filename);
443
+ copyFileSync(tmpPath, savedPath);
444
+ }
445
+ // 7. Read and return
446
+ try {
447
+ const data = readFileSync(tmpPath).toString("base64");
448
+ const snapshots = [{
449
+ screen: screenLabel,
450
+ path: savedPath ?? filename,
451
+ data,
452
+ }];
453
+ return buildScreenshotResponse(snapshots, (s) => ({
454
+ screen: s.screen,
455
+ path: savedPath ?? null,
456
+ simulator: sim.name,
457
+ theme: themeLabel,
458
+ bundleId: appInfo.bundleId,
459
+ }));
460
+ }
461
+ finally {
462
+ try {
463
+ unlinkSync(tmpPath);
464
+ }
465
+ catch { /* ignore */ }
466
+ }
467
+ });
468
+ }
469
+ // ── batch screenshot ─────────────────────────────────────────────────
470
+ export async function takeIOSScreenshotBatch(projectCwd, options) {
471
+ return withIOSScreenshotLock(async () => {
472
+ const { captures, device, theme, output_dir, project_dir, scheme, bundle_id } = options;
473
+ if (captures.length === 0) {
474
+ return { content: [{ type: "text", text: "No iOS captures specified." }], isError: true };
475
+ }
476
+ const iosDir = findIOSAppDir(projectCwd, project_dir);
477
+ const appInfo = extractAppInfo(iosDir, { scheme, bundle_id });
478
+ const sim = findSimulator(device);
479
+ await ensureSimulatorBooted(sim.udid);
480
+ if (theme) {
481
+ await setAppearance(sim.udid, theme);
482
+ }
483
+ const themeLabel = theme ?? "default";
484
+ const snapshots = [];
485
+ // Separate captures: no-nav (simctl screenshot) vs nav (XCUITest batch)
486
+ const noNavCaptures = captures.filter((c) => !c.nav || c.nav.length === 0);
487
+ const navCaptures = captures.filter((c) => c.nav && c.nav.length > 0);
488
+ // Build + install once for all captures
489
+ const appBundlePath = await buildApp(iosDir, appInfo, sim.udid);
490
+ await installAndLaunch(sim.udid, appBundlePath, appInfo.bundleId);
491
+ // Pre-create output dir once
492
+ if (output_dir)
493
+ mkdirSync(resolve(iosDir, output_dir), { recursive: true });
494
+ // No-nav captures: relaunch, wait, simctl screenshot
495
+ for (const capture of noNavCaptures) {
496
+ // Relaunch without reinstalling
497
+ try {
498
+ await exec(`xcrun simctl terminate ${sim.udid} ${appInfo.bundleId}`);
499
+ }
500
+ catch { /* not running */ }
501
+ await exec(`xcrun simctl launch ${sim.udid} ${appInfo.bundleId}`, { timeout: 30_000 });
502
+ await waitForAppReady(sim.udid, appInfo.bundleId, capture.wait_for ?? 3000);
503
+ const filename = `${capture.screen}_${themeLabel}.png`;
504
+ const tmpPath = join(iosDir, `.openuispec-screenshot-${capture.screen}.png`);
505
+ await captureScreenshot(sim.udid, tmpPath);
506
+ if (!existsSync(tmpPath))
507
+ continue;
508
+ let savedPath = filename;
509
+ if (output_dir) {
510
+ savedPath = join(resolve(iosDir, output_dir), filename);
511
+ copyFileSync(tmpPath, savedPath);
512
+ }
513
+ snapshots.push({ screen: capture.screen, path: savedPath, data: readFileSync(tmpPath).toString("base64") });
514
+ try {
515
+ unlinkSync(tmpPath);
516
+ }
517
+ catch { /* ignore */ }
518
+ }
519
+ // Nav captures: batch into a single XCUITest run
520
+ if (navCaptures.length > 0) {
521
+ const uitestDir = join(iosDir, ".screenshot-uitest");
522
+ const sourcesDir = join(uitestDir, "Sources");
523
+ mkdirSync(sourcesDir, { recursive: true });
524
+ // Build output paths map
525
+ const outputPaths = {};
526
+ for (const capture of navCaptures) {
527
+ const filename = `${capture.screen}_${themeLabel}.png`;
528
+ if (output_dir) {
529
+ const outDir = resolve(iosDir, output_dir);
530
+ mkdirSync(outDir, { recursive: true });
531
+ outputPaths[capture.screen] = join(outDir, filename);
532
+ }
533
+ else {
534
+ outputPaths[capture.screen] = join(iosDir, `.openuispec-screenshot-${capture.screen}.png`);
535
+ }
536
+ }
537
+ // Generate multi-test Swift file
538
+ const testCases = navCaptures.map((capture, i) => {
539
+ const taps = (capture.nav ?? []).map((step, j) => {
540
+ const escaped = step.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
541
+ return `
542
+ let target_${i}_${j} = app.descendants(matching: .any).matching(NSPredicate(format: "label ==[c] %@ OR title ==[c] %@", "${escaped}", "${escaped}")).firstMatch
543
+ if target_${i}_${j}.waitForExistence(timeout: 5) {
544
+ target_${i}_${j}.tap()
545
+ Thread.sleep(forTimeInterval: 0.8)
546
+ }`;
547
+ }).join("\n");
548
+ const outPath = outputPaths[capture.screen].replace(/"/g, '\\"');
549
+ return `
550
+ func test_${String(i + 1).padStart(2, "0")}_${capture.screen}() {
551
+ let app = XCUIApplication()
552
+ app.launchArguments = ["-AppleLanguages", "(en)"]
553
+ app.launch()
554
+ Thread.sleep(forTimeInterval: ${((capture.wait_for ?? 3000) / 1000).toFixed(1)})
555
+ ${taps}
556
+ Thread.sleep(forTimeInterval: 0.5)
557
+ let screenshot = XCUIScreen.main.screenshot()
558
+ try! screenshot.pngRepresentation.write(to: URL(fileURLWithPath: "${outPath}"))
559
+ }`;
560
+ }).join("\n");
561
+ writeFileSync(join(sourcesDir, "ScreenshotUITest.swift"), `import XCTest\n\nfinal class ScreenshotUITest: XCTestCase {\n${testCases}\n}\n`);
562
+ // Set up xcodegen
563
+ const UITEST_TARGET = "ScreenshotUITests";
564
+ const hasXcodegen = existsSync(join(iosDir, "project.yml"));
565
+ const projectYmlPath = join(iosDir, "project.yml");
566
+ let originalProjectYml = null;
567
+ const buildDir = join(iosDir, ".build", "screenshot");
568
+ if (hasXcodegen) {
569
+ originalProjectYml = readFileSync(projectYmlPath, "utf-8");
570
+ let modifiedYml = ensureInfoPlistFlag(originalProjectYml);
571
+ modifiedYml = insertUITestTarget(modifiedYml, generateUITestTargetYml(appInfo, ".screenshot-uitest/Sources", true));
572
+ writeFileSync(projectYmlPath, modifiedYml);
573
+ await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
574
+ }
575
+ else {
576
+ writeFileSync(join(uitestDir, "project.yml"), `name: ${UITEST_TARGET}
577
+ targets:
578
+ ${UITEST_TARGET}:
579
+ type: bundle.ui-testing
580
+ platform: iOS
581
+ deploymentTarget: "${appInfo.deploymentTarget}"
582
+ sources:
583
+ - path: Sources
584
+ settings:
585
+ base:
586
+ TEST_TARGET_NAME: ${appInfo.schemeName}
587
+ PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
588
+ GENERATE_INFOPLIST_FILE: YES
589
+ `);
590
+ await exec(`xcodegen generate`, { cwd: uitestDir, timeout: 30_000 });
591
+ }
592
+ const testProjectFlag = hasXcodegen
593
+ ? (appInfo.xcodeproj ? `-project "${join(iosDir, appInfo.xcodeproj)}"` : "")
594
+ : `-project "${join(uitestDir, `${UITEST_TARGET}.xcodeproj`)}"`;
595
+ const testCwd = hasXcodegen ? iosDir : uitestDir;
596
+ try {
597
+ await exec(`xcodebuild test ${testProjectFlag} -scheme "${UITEST_TARGET}" -destination "id=${sim.udid}" -derivedDataPath "${buildDir}" -only-testing:${UITEST_TARGET}/ScreenshotUITest 2>&1`, { cwd: testCwd, timeout: 300_000 });
598
+ }
599
+ catch {
600
+ // Tests may "fail" but still produce screenshots
601
+ }
602
+ finally {
603
+ if (originalProjectYml) {
604
+ writeFileSync(projectYmlPath, originalProjectYml);
605
+ try {
606
+ await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
607
+ }
608
+ catch { /* best effort */ }
609
+ }
610
+ }
611
+ // Collect results
612
+ for (const capture of navCaptures) {
613
+ const outPath = outputPaths[capture.screen];
614
+ if (existsSync(outPath)) {
615
+ snapshots.push({
616
+ screen: capture.screen,
617
+ path: output_dir ? outPath : `${capture.screen}_${themeLabel}.png`,
618
+ data: readFileSync(outPath).toString("base64"),
619
+ });
620
+ if (!output_dir) {
621
+ try {
622
+ unlinkSync(outPath);
623
+ }
624
+ catch { /* ignore */ }
625
+ }
626
+ }
627
+ }
628
+ }
629
+ if (snapshots.length === 0) {
630
+ return { content: [{ type: "text", text: "No screenshots were captured. Check Xcode and Simulator output." }], isError: true };
631
+ }
632
+ const content = [];
633
+ for (const s of snapshots) {
634
+ content.push({ type: "image", data: s.data, mimeType: "image/png" });
635
+ content.push({ type: "text", text: JSON.stringify({ screen: s.screen, path: s.path, simulator: sim.name, theme: themeLabel, bundleId: appInfo.bundleId }, null, 2) });
636
+ }
637
+ return { content };
638
+ });
639
+ }