openuispec 0.2.19 → 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 (38) hide show
  1. package/dist/check/audit.js +392 -0
  2. package/dist/check/index.js +216 -0
  3. package/dist/cli/configure-target.js +391 -0
  4. package/dist/cli/index.js +510 -0
  5. package/dist/cli/init.js +1047 -0
  6. package/dist/drift/index.js +903 -0
  7. package/dist/mcp-server/index.js +886 -0
  8. package/dist/mcp-server/preview-render.js +1761 -0
  9. package/dist/mcp-server/preview.js +233 -0
  10. package/dist/mcp-server/screenshot-android.js +458 -0
  11. package/dist/mcp-server/screenshot-ios.js +639 -0
  12. package/dist/mcp-server/screenshot-shared.js +180 -0
  13. package/dist/mcp-server/screenshot.js +459 -0
  14. package/dist/prepare/index.js +1216 -0
  15. package/dist/runtime/package-paths.js +33 -0
  16. package/dist/schema/semantic-lint.js +564 -0
  17. package/dist/schema/validate.js +689 -0
  18. package/dist/status/index.js +194 -0
  19. package/package.json +12 -13
  20. package/check/audit.ts +0 -426
  21. package/check/index.ts +0 -320
  22. package/cli/configure-target.ts +0 -523
  23. package/cli/index.ts +0 -537
  24. package/cli/init.ts +0 -1253
  25. package/drift/index.ts +0 -1165
  26. package/mcp-server/index.ts +0 -1041
  27. package/mcp-server/preview-render.ts +0 -1922
  28. package/mcp-server/preview.ts +0 -292
  29. package/mcp-server/screenshot-android.ts +0 -621
  30. package/mcp-server/screenshot-ios.ts +0 -753
  31. package/mcp-server/screenshot-shared.ts +0 -237
  32. package/mcp-server/screenshot.ts +0 -563
  33. package/prepare/index.ts +0 -1530
  34. package/schema/semantic-lint.ts +0 -692
  35. package/schema/validate.ts +0 -870
  36. package/scripts/regenerate-previews.ts +0 -136
  37. package/scripts/take-all-screenshots.ts +0 -507
  38. package/status/index.ts +0 -275
@@ -1,753 +0,0 @@
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
- export function generateUITestTargetYml(
237
- appInfo: IOSAppInfo,
238
- sourcePath: string,
239
- includeProductName = false,
240
- ): string {
241
- const productLines = includeProductName
242
- ? `\n PRODUCT_NAME: ${UITEST_TARGET}\n PRODUCT_MODULE_NAME: ${UITEST_TARGET}`
243
- : "";
244
- return ` ${UITEST_TARGET}:
245
- type: bundle.ui-testing
246
- platform: iOS
247
- deploymentTarget: "${appInfo.deploymentTarget}"
248
- sources:
249
- - path: ${sourcePath}
250
- dependencies:
251
- - target: ${appInfo.schemeName}
252
- embed: false
253
- settings:
254
- base:${productLines}
255
- TEST_TARGET_NAME: ${appInfo.schemeName}
256
- PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
257
- GENERATE_INFOPLIST_FILE: YES`;
258
- }
259
-
260
- export function insertUITestTarget(yml: string, targetYml: string): string {
261
- if (yml.includes("\nschemes:")) {
262
- return yml.replace("\nschemes:", `\n${targetYml}\nschemes:`);
263
- }
264
- return yml + "\n" + targetYml + "\n";
265
- }
266
-
267
- export function ensureInfoPlistFlag(yml: string): string {
268
- if (yml.includes("GENERATE_INFOPLIST_FILE")) return yml;
269
- return yml.replace(
270
- /(PRODUCT_BUNDLE_IDENTIFIER:[^\n]+\n)/,
271
- "$1 GENERATE_INFOPLIST_FILE: YES\n",
272
- );
273
- }
274
-
275
- function generateUITestSwift(
276
- bundleId: string,
277
- navSteps: string[],
278
- waitMs: number,
279
- outputPath: string,
280
- ): string {
281
- const taps = navSteps.map((step, i) => {
282
- const escaped = step.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
283
- return `
284
- // Tap "${escaped}"
285
- let target_${i} = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@ OR title == %@", "${escaped}", "${escaped}")).firstMatch
286
- if target_${i}.waitForExistence(timeout: 5) {
287
- target_${i}.tap()
288
- Thread.sleep(forTimeInterval: 0.8)
289
- }`;
290
- }).join("\n");
291
-
292
- return `import XCTest
293
-
294
- final class ScreenshotUITest: XCTestCase {
295
- func testNavigateAndScreenshot() {
296
- let app = XCUIApplication()
297
- app.launchArguments = ["-AppleLanguages", "(en)"]
298
- app.launch()
299
-
300
- // Wait for app to load
301
- Thread.sleep(forTimeInterval: ${(waitMs / 1000).toFixed(1)})
302
- ${taps}
303
-
304
- // Wait for navigation to settle
305
- Thread.sleep(forTimeInterval: 0.5)
306
-
307
- // Capture screenshot
308
- let screenshot = XCUIScreen.main.screenshot()
309
- let pngData = screenshot.pngRepresentation
310
- let outputPath = "${outputPath.replace(/"/g, '\\"')}"
311
- try! pngData.write(to: URL(fileURLWithPath: outputPath))
312
- }
313
- }
314
- `;
315
- }
316
-
317
- function generateXcodegenConfig(
318
- appInfo: IOSAppInfo,
319
- testSourcesDir: string,
320
- ): string {
321
- return `name: ${UITEST_TARGET}
322
- targets:
323
- ${UITEST_TARGET}:
324
- type: bundle.ui-testing
325
- platform: iOS
326
- deploymentTarget: "${appInfo.deploymentTarget}"
327
- sources:
328
- - path: ${testSourcesDir}
329
- dependencies:
330
- - target: ${appInfo.schemeName}
331
- embed: false
332
- settings:
333
- base:
334
- TEST_TARGET_NAME: ${appInfo.schemeName}
335
- PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
336
- GENERATE_INFOPLIST_FILE: YES
337
- `;
338
- }
339
-
340
- async function runXCUITest(
341
- iosDir: string,
342
- appInfo: IOSAppInfo,
343
- simulatorUdid: string,
344
- navSteps: string[],
345
- waitMs: number,
346
- screenshotOutputPath: string,
347
- ): Promise<void> {
348
- const uitestDir = join(iosDir, UITEST_DIR);
349
- const sourcesDir = join(uitestDir, "Sources");
350
- mkdirSync(sourcesDir, { recursive: true });
351
-
352
- // Generate the UI test Swift file
353
- const testSwift = generateUITestSwift(appInfo.bundleId, navSteps, waitMs, screenshotOutputPath);
354
- writeFileSync(join(sourcesDir, "ScreenshotUITest.swift"), testSwift);
355
-
356
- // For xcodegen projects: add UI test target to project.yml temporarily
357
- // For non-xcodegen projects: generate a standalone XCUITest xcode project in .screenshot-uitest/
358
- const projectYmlPath = join(iosDir, "project.yml");
359
- let originalProjectYml: string | null = null;
360
- let standaloneProjectDir: string | null = null;
361
-
362
- if (appInfo.hasXcodegen) {
363
- originalProjectYml = readFileSync(projectYmlPath, "utf-8");
364
- let modifiedYml = ensureInfoPlistFlag(originalProjectYml);
365
- modifiedYml = insertUITestTarget(modifiedYml, generateUITestTargetYml(appInfo, `${UITEST_DIR}/Sources`));
366
- writeFileSync(projectYmlPath, modifiedYml);
367
-
368
- try {
369
- await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
370
- } catch (err: any) {
371
- writeFileSync(projectYmlPath, originalProjectYml);
372
- throw new Error(`xcodegen failed: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
373
- }
374
- } else {
375
- // Non-xcodegen project: create a standalone XCUITest project in .screenshot-uitest/
376
- standaloneProjectDir = uitestDir;
377
- const standaloneYml = `name: ${UITEST_TARGET}
378
- targets:
379
- ${UITEST_TARGET}:
380
- type: bundle.ui-testing
381
- platform: iOS
382
- deploymentTarget: "${appInfo.deploymentTarget}"
383
- sources:
384
- - path: Sources
385
- settings:
386
- base:
387
- TEST_TARGET_NAME: ${appInfo.schemeName}
388
- PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
389
- GENERATE_INFOPLIST_FILE: YES
390
- `;
391
- writeFileSync(join(uitestDir, "project.yml"), standaloneYml);
392
-
393
- try {
394
- await exec(`xcodegen generate`, { cwd: uitestDir, timeout: 30_000 });
395
- } catch (err: any) {
396
- throw new Error(`xcodegen failed for standalone UI test project: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
397
- }
398
- }
399
-
400
- const buildDir = join(iosDir, ".build", "screenshot");
401
- let projectFlag: string;
402
- let testCwd: string;
403
-
404
- if (standaloneProjectDir) {
405
- // Use the standalone project generated in .screenshot-uitest/
406
- projectFlag = `-project "${join(standaloneProjectDir, `${UITEST_TARGET}.xcodeproj`)}"`;
407
- testCwd = standaloneProjectDir;
408
- } else {
409
- projectFlag = appInfo.xcodeproj
410
- ? `-project "${join(iosDir, appInfo.xcodeproj)}"`
411
- : "";
412
- testCwd = iosDir;
413
- }
414
-
415
- try {
416
- await exec(
417
- `xcodebuild test ` +
418
- `${projectFlag} ` +
419
- `-scheme "${UITEST_TARGET}" ` +
420
- `-destination "id=${simulatorUdid}" ` +
421
- `-derivedDataPath "${buildDir}" ` +
422
- `-only-testing:${UITEST_TARGET}/ScreenshotUITest/testNavigateAndScreenshot ` +
423
- `2>&1`,
424
- { cwd: testCwd, timeout: 300_000 },
425
- );
426
- } catch (err: any) {
427
- const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).slice(-500);
428
- // The test might "fail" but still produce the screenshot
429
- if (!existsSync(screenshotOutputPath)) {
430
- throw new Error(`XCUITest failed:\n${output}`);
431
- }
432
- } finally {
433
- // Restore original project.yml for xcodegen projects
434
- if (originalProjectYml) {
435
- writeFileSync(projectYmlPath, originalProjectYml);
436
- // Regenerate to restore original xcodeproj
437
- try { await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 }); } catch { /* best effort */ }
438
- }
439
- }
440
- }
441
-
442
- // ── screenshot capture (simple, no nav) ─────────────────────────────
443
-
444
- export async function captureScreenshot(udid: string, localPath: string): Promise<void> {
445
- await exec(`xcrun simctl io ${udid} screenshot "${localPath}"`, { timeout: 15_000 });
446
- }
447
-
448
- // ── wait for app ready ──────────────────────────────────────────────
449
-
450
- async function waitForAppReady(udid: string, bundleId: string, waitMs: number): Promise<void> {
451
- const startTime = Date.now();
452
- const timeout = Math.min(waitMs, 15_000);
453
-
454
- while (Date.now() - startTime < timeout) {
455
- try {
456
- const { stdout } = await exec(`xcrun simctl spawn ${udid} launchctl list`);
457
- if (stdout.includes(bundleId)) {
458
- const elapsed = Date.now() - startTime;
459
- const remaining = Math.max(waitMs - elapsed, 500);
460
- await new Promise(r => setTimeout(r, remaining));
461
- return;
462
- }
463
- } catch { /* not ready yet */ }
464
- await new Promise(r => setTimeout(r, 500));
465
- }
466
-
467
- await new Promise(r => setTimeout(r, waitMs));
468
- }
469
-
470
- // ── main entry point ────────────────────────────────────────────────
471
-
472
- export async function takeIOSScreenshot(
473
- projectCwd: string,
474
- options: IOSScreenshotOptions,
475
- ): Promise<ScreenshotResult> {
476
- const {
477
- screen,
478
- device,
479
- nav,
480
- theme,
481
- wait_for = 3000,
482
- output_dir,
483
- project_dir,
484
- scheme,
485
- bundle_id,
486
- } = options;
487
-
488
- // 1. Find iOS project
489
- const iosDir = findIOSAppDir(projectCwd, project_dir);
490
- const appInfo = extractAppInfo(iosDir, { scheme, bundle_id });
491
-
492
- // 2. Find and boot simulator
493
- const sim = findSimulator(device);
494
- await ensureSimulatorBooted(sim.udid);
495
-
496
- // 3. Set theme if requested
497
- if (theme) {
498
- await setAppearance(sim.udid, theme);
499
- }
500
-
501
- // 4. Capture screenshot
502
- const screenLabel = screen ?? "main";
503
- const themeLabel = theme ?? "default";
504
- const filename = `${screenLabel}_${themeLabel}.png`;
505
- const tmpPath = join(iosDir, ".openuispec-screenshot.png");
506
-
507
- if (nav && nav.length > 0) {
508
- // Use XCUITest for navigation + screenshot (builds both targets via xcodebuild test)
509
- await runXCUITest(iosDir, appInfo, sim.udid, nav, wait_for, tmpPath);
510
- } else {
511
- // Simple: build, install, launch, wait, screencap
512
- const appBundlePath = await buildApp(iosDir, appInfo, sim.udid);
513
- await installAndLaunch(sim.udid, appBundlePath, appInfo.bundleId);
514
- await waitForAppReady(sim.udid, appInfo.bundleId, wait_for);
515
- await captureScreenshot(sim.udid, tmpPath);
516
- }
517
-
518
- if (!existsSync(tmpPath)) {
519
- return {
520
- content: [{ type: "text", text: "No screenshot was captured. Check Xcode and Simulator output." }],
521
- isError: true,
522
- };
523
- }
524
-
525
- // 6. Save to output_dir if specified
526
- let savedPath: string | undefined;
527
- if (output_dir) {
528
- const outDir = resolve(iosDir, output_dir);
529
- mkdirSync(outDir, { recursive: true });
530
- savedPath = join(outDir, filename);
531
- copyFileSync(tmpPath, savedPath);
532
- }
533
-
534
- // 7. Read and return
535
- try {
536
- const data = readFileSync(tmpPath).toString("base64");
537
- const snapshots = [{
538
- screen: screenLabel,
539
- path: savedPath ?? filename,
540
- data,
541
- }];
542
-
543
- return buildScreenshotResponse(snapshots, (s) => ({
544
- screen: s.screen,
545
- path: savedPath ?? null,
546
- simulator: sim.name,
547
- theme: themeLabel,
548
- bundleId: appInfo.bundleId,
549
- }));
550
- } finally {
551
- try { unlinkSync(tmpPath); } catch { /* ignore */ }
552
- }
553
- }
554
-
555
- // ── batch types ──────────────────────────────────────────────────────
556
-
557
- export interface IOSBatchCapture {
558
- screen: string;
559
- nav?: string[];
560
- wait_for?: number;
561
- }
562
-
563
- export interface IOSScreenshotBatchOptions {
564
- captures: IOSBatchCapture[];
565
- device?: string;
566
- theme?: "light" | "dark";
567
- output_dir?: string;
568
- project_dir?: string;
569
- scheme?: string;
570
- bundle_id?: string;
571
- }
572
-
573
- // ── batch screenshot ─────────────────────────────────────────────────
574
-
575
- export async function takeIOSScreenshotBatch(
576
- projectCwd: string,
577
- options: IOSScreenshotBatchOptions,
578
- ): Promise<ScreenshotResult> {
579
- const { captures, device, theme, output_dir, project_dir, scheme, bundle_id } = options;
580
-
581
- if (captures.length === 0) {
582
- return { content: [{ type: "text", text: "No iOS captures specified." }], isError: true };
583
- }
584
-
585
- const iosDir = findIOSAppDir(projectCwd, project_dir);
586
- const appInfo = extractAppInfo(iosDir, { scheme, bundle_id });
587
- const sim = findSimulator(device);
588
- await ensureSimulatorBooted(sim.udid);
589
-
590
- if (theme) {
591
- await setAppearance(sim.udid, theme);
592
- }
593
-
594
- const themeLabel = theme ?? "default";
595
- const snapshots: Array<{ screen: string; path: string; data: string }> = [];
596
-
597
- // Separate captures: no-nav (simctl screenshot) vs nav (XCUITest batch)
598
- const noNavCaptures = captures.filter((c) => !c.nav || c.nav.length === 0);
599
- const navCaptures = captures.filter((c) => c.nav && c.nav.length > 0);
600
-
601
- // Build + install once for all captures
602
- const appBundlePath = await buildApp(iosDir, appInfo, sim.udid);
603
- await installAndLaunch(sim.udid, appBundlePath, appInfo.bundleId);
604
-
605
- // Pre-create output dir once
606
- if (output_dir) mkdirSync(resolve(iosDir, output_dir), { recursive: true });
607
-
608
- // No-nav captures: relaunch, wait, simctl screenshot
609
- for (const capture of noNavCaptures) {
610
- // Relaunch without reinstalling
611
- try { await exec(`xcrun simctl terminate ${sim.udid} ${appInfo.bundleId}`); } catch { /* not running */ }
612
- await exec(`xcrun simctl launch ${sim.udid} ${appInfo.bundleId}`, { timeout: 30_000 });
613
- await waitForAppReady(sim.udid, appInfo.bundleId, capture.wait_for ?? 3000);
614
-
615
- const filename = `${capture.screen}_${themeLabel}.png`;
616
- const tmpPath = join(iosDir, `.openuispec-screenshot-${capture.screen}.png`);
617
- await captureScreenshot(sim.udid, tmpPath);
618
-
619
- if (!existsSync(tmpPath)) continue;
620
-
621
- let savedPath = filename;
622
- if (output_dir) {
623
- savedPath = join(resolve(iosDir, output_dir), filename);
624
- copyFileSync(tmpPath, savedPath);
625
- }
626
-
627
- snapshots.push({ screen: capture.screen, path: savedPath, data: readFileSync(tmpPath).toString("base64") });
628
- try { unlinkSync(tmpPath); } catch { /* ignore */ }
629
- }
630
-
631
- // Nav captures: batch into a single XCUITest run
632
- if (navCaptures.length > 0) {
633
- const uitestDir = join(iosDir, ".screenshot-uitest");
634
- const sourcesDir = join(uitestDir, "Sources");
635
- mkdirSync(sourcesDir, { recursive: true });
636
-
637
- // Build output paths map
638
- const outputPaths: Record<string, string> = {};
639
- for (const capture of navCaptures) {
640
- const filename = `${capture.screen}_${themeLabel}.png`;
641
- if (output_dir) {
642
- const outDir = resolve(iosDir, output_dir);
643
- mkdirSync(outDir, { recursive: true });
644
- outputPaths[capture.screen] = join(outDir, filename);
645
- } else {
646
- outputPaths[capture.screen] = join(iosDir, `.openuispec-screenshot-${capture.screen}.png`);
647
- }
648
- }
649
-
650
- // Generate multi-test Swift file
651
- const testCases = navCaptures.map((capture, i) => {
652
- const taps = (capture.nav ?? []).map((step, j) => {
653
- const escaped = step.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
654
- return `
655
- let target_${i}_${j} = app.descendants(matching: .any).matching(NSPredicate(format: "label ==[c] %@ OR title ==[c] %@", "${escaped}", "${escaped}")).firstMatch
656
- if target_${i}_${j}.waitForExistence(timeout: 5) {
657
- target_${i}_${j}.tap()
658
- Thread.sleep(forTimeInterval: 0.8)
659
- }`;
660
- }).join("\n");
661
-
662
- const outPath = outputPaths[capture.screen].replace(/"/g, '\\"');
663
- return `
664
- func test_${String(i + 1).padStart(2, "0")}_${capture.screen}() {
665
- let app = XCUIApplication()
666
- app.launchArguments = ["-AppleLanguages", "(en)"]
667
- app.launch()
668
- Thread.sleep(forTimeInterval: ${((capture.wait_for ?? 3000) / 1000).toFixed(1)})
669
- ${taps}
670
- Thread.sleep(forTimeInterval: 0.5)
671
- let screenshot = XCUIScreen.main.screenshot()
672
- try! screenshot.pngRepresentation.write(to: URL(fileURLWithPath: "${outPath}"))
673
- }`;
674
- }).join("\n");
675
-
676
- writeFileSync(join(sourcesDir, "ScreenshotUITest.swift"),
677
- `import XCTest\n\nfinal class ScreenshotUITest: XCTestCase {\n${testCases}\n}\n`);
678
-
679
- // Set up xcodegen
680
- const UITEST_TARGET = "ScreenshotUITests";
681
- const hasXcodegen = existsSync(join(iosDir, "project.yml"));
682
- const projectYmlPath = join(iosDir, "project.yml");
683
- let originalProjectYml: string | null = null;
684
- const buildDir = join(iosDir, ".build", "screenshot");
685
-
686
- if (hasXcodegen) {
687
- originalProjectYml = readFileSync(projectYmlPath, "utf-8");
688
- let modifiedYml = ensureInfoPlistFlag(originalProjectYml);
689
- modifiedYml = insertUITestTarget(modifiedYml, generateUITestTargetYml(appInfo, ".screenshot-uitest/Sources", true));
690
- writeFileSync(projectYmlPath, modifiedYml);
691
- await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
692
- } else {
693
- writeFileSync(join(uitestDir, "project.yml"), `name: ${UITEST_TARGET}
694
- targets:
695
- ${UITEST_TARGET}:
696
- type: bundle.ui-testing
697
- platform: iOS
698
- deploymentTarget: "${appInfo.deploymentTarget}"
699
- sources:
700
- - path: Sources
701
- settings:
702
- base:
703
- TEST_TARGET_NAME: ${appInfo.schemeName}
704
- PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
705
- GENERATE_INFOPLIST_FILE: YES
706
- `);
707
- await exec(`xcodegen generate`, { cwd: uitestDir, timeout: 30_000 });
708
- }
709
-
710
- const testProjectFlag = hasXcodegen
711
- ? (appInfo.xcodeproj ? `-project "${join(iosDir, appInfo.xcodeproj)}"` : "")
712
- : `-project "${join(uitestDir, `${UITEST_TARGET}.xcodeproj`)}"`;
713
- const testCwd = hasXcodegen ? iosDir : uitestDir;
714
-
715
- try {
716
- await exec(
717
- `xcodebuild test ${testProjectFlag} -scheme "${UITEST_TARGET}" -destination "id=${sim.udid}" -derivedDataPath "${buildDir}" -only-testing:${UITEST_TARGET}/ScreenshotUITest 2>&1`,
718
- { cwd: testCwd, timeout: 300_000 },
719
- );
720
- } catch {
721
- // Tests may "fail" but still produce screenshots
722
- } finally {
723
- if (originalProjectYml) {
724
- writeFileSync(projectYmlPath, originalProjectYml);
725
- try { await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 }); } catch { /* best effort */ }
726
- }
727
- }
728
-
729
- // Collect results
730
- for (const capture of navCaptures) {
731
- const outPath = outputPaths[capture.screen];
732
- if (existsSync(outPath)) {
733
- snapshots.push({
734
- screen: capture.screen,
735
- path: output_dir ? outPath : `${capture.screen}_${themeLabel}.png`,
736
- data: readFileSync(outPath).toString("base64"),
737
- });
738
- if (!output_dir) { try { unlinkSync(outPath); } catch { /* ignore */ } }
739
- }
740
- }
741
- }
742
-
743
- if (snapshots.length === 0) {
744
- return { content: [{ type: "text", text: "No screenshots were captured. Check Xcode and Simulator output." }], isError: true };
745
- }
746
-
747
- const content: ScreenshotResult["content"] = [];
748
- for (const s of snapshots) {
749
- content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
750
- content.push({ type: "text" as const, text: JSON.stringify({ screen: s.screen, path: s.path, simulator: sim.name, theme: themeLabel, bundleId: appInfo.bundleId }, null, 2) });
751
- }
752
- return { content };
753
- }