openuispec 0.2.9 → 0.2.10

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.
package/cli/index.ts CHANGED
@@ -24,6 +24,7 @@
24
24
  * openuispec spec-schema <type> Get JSON schema for a spec type
25
25
  * openuispec screenshot [--route /path] Screenshot the web app
26
26
  * openuispec screenshot-android [opts] Screenshot Android app on emulator
27
+ * openuispec screenshot-android-batch [opts] Batch screenshot Android app on emulator
27
28
  * openuispec screenshot-ios [opts] Screenshot iOS app on simulator
28
29
  */
29
30
 
@@ -47,6 +48,25 @@ function getPositional(argv: string[], startIdx = 0): string[] {
47
48
  return argv.slice(startIdx).filter((a) => !a.startsWith("--"));
48
49
  }
49
50
 
51
+ function readJsonFile(filePath: string): any {
52
+ return JSON.parse(readFileSync(resolve(filePath), "utf-8"));
53
+ }
54
+
55
+ function parseBatchConfig(rest: string[], usage: string): { config: any; captures: any[] } {
56
+ const configPath = getOption(rest, "--config");
57
+ if (!configPath) {
58
+ console.error(usage);
59
+ process.exit(1);
60
+ }
61
+ const config = readJsonFile(configPath);
62
+ const captures = Array.isArray(config) ? config : config.captures;
63
+ if (!Array.isArray(captures) || captures.length === 0) {
64
+ console.error("Batch config must be an array of captures or an object with a non-empty 'captures' array.");
65
+ process.exit(1);
66
+ }
67
+ return { config, captures };
68
+ }
69
+
50
70
  // ── rules version check ─────────────────────────────────────────────
51
71
 
52
72
  function checkRulesVersion(): void {
@@ -338,6 +358,37 @@ async function main(): Promise<void> {
338
358
  break;
339
359
  }
340
360
 
361
+ case "screenshot-android-batch": {
362
+ const { takeAndroidScreenshotBatch } = await import("../mcp-server/screenshot-android.js");
363
+ const { config, captures } = parseBatchConfig(rest,
364
+ "Usage: openuispec screenshot-android-batch --config <captures.json> [--project-dir path] [--module name] [--theme light|dark] [--output-dir dir]");
365
+
366
+ const result = await takeAndroidScreenshotBatch(cwd, {
367
+ captures,
368
+ theme: (getOption(rest, "--theme") ?? config.theme) as "light" | "dark" | undefined,
369
+ output_dir: getOption(rest, "--output-dir") ?? config.output_dir ?? undefined,
370
+ project_dir: getOption(rest, "--project-dir") ?? config.project_dir ?? undefined,
371
+ module: getOption(rest, "--module") ?? config.module ?? undefined,
372
+ });
373
+ printScreenshotResult(result);
374
+ break;
375
+ }
376
+
377
+ case "screenshot-web-batch": {
378
+ const { takeScreenshotBatch } = await import("../mcp-server/screenshot.js");
379
+ const { config, captures } = parseBatchConfig(rest,
380
+ "Usage: openuispec screenshot-web-batch --config <captures.json> [--theme light|dark] [--output-dir dir]");
381
+
382
+ const result = await takeScreenshotBatch(cwd, {
383
+ captures,
384
+ viewport: config.viewport,
385
+ theme: (getOption(rest, "--theme") ?? config.theme) as "light" | "dark" | undefined,
386
+ output_dir: getOption(rest, "--output-dir") ?? config.output_dir ?? undefined,
387
+ });
388
+ printScreenshotResult(result);
389
+ break;
390
+ }
391
+
341
392
  case "screenshot-ios": {
342
393
  const { takeIOSScreenshot } = await import("../mcp-server/screenshot-ios.js");
343
394
  const result = await takeIOSScreenshot(cwd, {
@@ -355,6 +406,24 @@ async function main(): Promise<void> {
355
406
  break;
356
407
  }
357
408
 
409
+ case "screenshot-ios-batch": {
410
+ const { takeIOSScreenshotBatch } = await import("../mcp-server/screenshot-ios.js");
411
+ const { config, captures } = parseBatchConfig(rest,
412
+ "Usage: openuispec screenshot-ios-batch --config <captures.json> [--project-dir path] [--scheme name] [--bundle-id id] [--device name] [--theme light|dark] [--output-dir dir]");
413
+
414
+ const result = await takeIOSScreenshotBatch(cwd, {
415
+ captures,
416
+ device: getOption(rest, "--device") ?? config.device ?? undefined,
417
+ theme: (getOption(rest, "--theme") ?? config.theme) as "light" | "dark" | undefined,
418
+ output_dir: getOption(rest, "--output-dir") ?? config.output_dir ?? undefined,
419
+ project_dir: getOption(rest, "--project-dir") ?? config.project_dir ?? undefined,
420
+ scheme: getOption(rest, "--scheme") ?? config.scheme ?? undefined,
421
+ bundle_id: getOption(rest, "--bundle-id") ?? config.bundle_id ?? undefined,
422
+ });
423
+ printScreenshotResult(result);
424
+ break;
425
+ }
426
+
358
427
  // ── help ────────────────────────────────────────────────────────
359
428
 
360
429
  case undefined:
@@ -389,13 +458,20 @@ Spec access:
389
458
  openuispec spec-types List available spec types
390
459
  openuispec spec-schema <type> Get full JSON schema for a spec type
391
460
 
392
- Screenshots:
461
+ Screenshots (single):
393
462
  openuispec screenshot [--route /path] [--theme light|dark] [--output-dir dir]
394
463
  openuispec screenshot-android [--screen name] [--project-dir path] [--module name]
395
464
  [--route deeplink] [--nav Step1,Step2] [--theme light|dark] [--output-dir dir]
396
465
  openuispec screenshot-ios [--screen name] [--project-dir path] [--scheme name]
397
466
  [--bundle-id id] [--device name] [--nav Step1,Step2] [--theme light|dark]
398
467
 
468
+ Screenshots (batch — build once, capture many):
469
+ openuispec screenshot-web-batch --config captures.json [--theme light|dark] [--output-dir dir]
470
+ openuispec screenshot-android-batch --config captures.json [--project-dir path]
471
+ [--module name] [--theme light|dark] [--output-dir dir]
472
+ openuispec screenshot-ios-batch --config captures.json [--project-dir path]
473
+ [--scheme name] [--bundle-id id] [--device name] [--theme light|dark] [--output-dir dir]
474
+
399
475
  Server:
400
476
  openuispec mcp Start MCP server (stdio transport)
401
477
 
package/docs/cli.md CHANGED
@@ -92,9 +92,15 @@ openuispec spec-schema <type> # Get JSON schema for a spec type
92
92
  ### Screenshots
93
93
 
94
94
  ```bash
95
+ # Single captures
95
96
  openuispec screenshot --route /home [--theme dark] [--output-dir dir]
96
97
  openuispec screenshot-android [--project-dir path] [--screen name] [--module name] [--route deeplink]
97
98
  openuispec screenshot-ios [--project-dir path] [--screen name] [--scheme name] [--bundle-id id]
99
+
100
+ # Batch — build once, capture many
101
+ openuispec screenshot-web-batch --config captures.json [--theme dark] [--output-dir dir]
102
+ openuispec screenshot-android-batch --config captures.json [--project-dir path] [--module name]
103
+ openuispec screenshot-ios-batch --config captures.json [--project-dir path] [--scheme name] [--bundle-id id]
98
104
  ```
99
105
 
100
106
  Screenshot tools work with **any** project — use `--project-dir` to skip manifest lookup.
@@ -111,6 +117,30 @@ Screenshot tools work with **any** project — use `--project-dir` to skip manif
111
117
  | `--device` | -- | yes | Simulator device name |
112
118
  | `--output-dir` | yes | yes | Save screenshot to directory |
113
119
 
120
+ ### Batch config
121
+
122
+ All batch commands accept `--config captures.json`. The JSON file has the same structure for all platforms:
123
+
124
+ ```json
125
+ {
126
+ "project_dir": "path/to/project",
127
+ "output_dir": "screenshots",
128
+ "theme": "light",
129
+ "captures": [
130
+ { "screen": "home", "route": "/home", "wait_for": 3000 },
131
+ { "screen": "settings", "nav": ["Settings"], "wait_for": 5000 }
132
+ ]
133
+ }
134
+ ```
135
+
136
+ Each capture supports:
137
+ - `screen`: output filename stem (required)
138
+ - `route`: deep link URI (Android) or URL path (web)
139
+ - `nav`: array of visible-text tap steps after launch (Android, iOS)
140
+ - `wait_for`: per-capture wait time in ms
141
+ - `selector`: CSS selector to screenshot a specific element (web only)
142
+ - `full_page`: capture full scrollable page (web only)
143
+
114
144
  ## Target Update Workflow
115
145
 
116
146
  When a shared spec change needs to be applied to a target:
@@ -24,3 +24,8 @@ targets:
24
24
  INFOPLIST_KEY_UIUserInterfaceStyle: Automatic
25
25
  SWIFT_VERSION: 5.10
26
26
  PRODUCT_BUNDLE_IDENTIFIER: uz.rsteam.taskflow
27
+ schemes:
28
+ TaskFlow:
29
+ build:
30
+ targets:
31
+ TaskFlow: all
@@ -19,16 +19,19 @@
19
19
  BC38D8705D84A35CA4597319 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB44040683A902497C58A015 /* SettingsView.swift */; };
20
20
  C48B6CB49E4975FA1F6E375B /* TaskEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F27CD847E25814CF020BD2 /* TaskEditorSheet.swift */; };
21
21
  C6106B18861BE3E491DF43E2 /* RecurringRuleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2ECC34C8FF28EDDC9779648 /* RecurringRuleSheet.swift */; };
22
+ F1510F7B66940A31758A2561 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BEE6DEA10F74F9459C452D3F /* Localizable.strings */; };
22
23
  /* End PBXBuildFile section */
23
24
 
24
25
  /* Begin PBXFileReference section */
25
26
  1549DC459C5275175F9C14DA /* SchedulePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulePreviewView.swift; sourceTree = "<group>"; };
26
27
  18F27CD847E25814CF020BD2 /* TaskEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskEditorSheet.swift; sourceTree = "<group>"; };
28
+ 2B9B6454B5BB98B387C7B5C4 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
27
29
  39885C677159185FF63FD439 /* TasksHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TasksHomeView.swift; sourceTree = "<group>"; };
28
30
  44D5BC7160E201639C3BB553 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
29
31
  4ED01AC556A631AD84466AB3 /* TrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendChartView.swift; sourceTree = "<group>"; };
30
32
  557759E88A2388894FA0C7BB /* TodoOrbit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = TodoOrbit.app; sourceTree = BUILT_PRODUCTS_DIR; };
31
33
  6998ADDB28617F993A8C0457 /* TodoOrbitApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoOrbitApp.swift; sourceTree = "<group>"; };
34
+ 6E3C87FA6CD54B76AEB1E84A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
32
35
  7A697422498544CC5739C094 /* Charts.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Charts.framework; path = System/Library/Frameworks/Charts.framework; sourceTree = SDKROOT; };
33
36
  8888C94EDF738EA4F84E3F63 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = "<group>"; };
34
37
  BB44040683A902497C58A015 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@@ -86,6 +89,7 @@
86
89
  20A8D65D5A36A0A1D83FC83B = {
87
90
  isa = PBXGroup;
88
91
  children = (
92
+ 4E25DE308842C29211993157 /* Resources */,
89
93
  F8B6569F634112DBF4BA31A0 /* TodoOrbit */,
90
94
  2076B302B77DCDE399DBA8FE /* Frameworks */,
91
95
  004F5B8035C988C3C942C7B6 /* Products */,
@@ -102,6 +106,14 @@
102
106
  path = Components;
103
107
  sourceTree = "<group>";
104
108
  };
109
+ 4E25DE308842C29211993157 /* Resources */ = {
110
+ isa = PBXGroup;
111
+ children = (
112
+ BEE6DEA10F74F9459C452D3F /* Localizable.strings */,
113
+ );
114
+ path = Resources;
115
+ sourceTree = "<group>";
116
+ };
105
117
  59DB494C80FF64B9BB3FD8AF /* Models */ = {
106
118
  isa = PBXGroup;
107
119
  children = (
@@ -149,6 +161,7 @@
149
161
  buildConfigurationList = 8F4D2BE44A3854271D9C066F /* Build configuration list for PBXNativeTarget "TodoOrbit" */;
150
162
  buildPhases = (
151
163
  6918283D91B9066E2F9DADD5 /* Sources */,
164
+ EBFB738E78B6AF48E8771031 /* Resources */,
152
165
  56326A91B4B75B21E246160E /* Frameworks */,
153
166
  );
154
167
  buildRules = (
@@ -179,6 +192,7 @@
179
192
  knownRegions = (
180
193
  Base,
181
194
  en,
195
+ ru,
182
196
  );
183
197
  mainGroup = 20A8D65D5A36A0A1D83FC83B;
184
198
  minimizedProjectReferenceProxies = 1;
@@ -192,6 +206,17 @@
192
206
  };
193
207
  /* End PBXProject section */
194
208
 
209
+ /* Begin PBXResourcesBuildPhase section */
210
+ EBFB738E78B6AF48E8771031 /* Resources */ = {
211
+ isa = PBXResourcesBuildPhase;
212
+ buildActionMask = 2147483647;
213
+ files = (
214
+ F1510F7B66940A31758A2561 /* Localizable.strings in Resources */,
215
+ );
216
+ runOnlyForDeploymentPostprocessing = 0;
217
+ };
218
+ /* End PBXResourcesBuildPhase section */
219
+
195
220
  /* Begin PBXSourcesBuildPhase section */
196
221
  6918283D91B9066E2F9DADD5 /* Sources */ = {
197
222
  isa = PBXSourcesBuildPhase;
@@ -213,6 +238,18 @@
213
238
  };
214
239
  /* End PBXSourcesBuildPhase section */
215
240
 
241
+ /* Begin PBXVariantGroup section */
242
+ BEE6DEA10F74F9459C452D3F /* Localizable.strings */ = {
243
+ isa = PBXVariantGroup;
244
+ children = (
245
+ 6E3C87FA6CD54B76AEB1E84A /* en */,
246
+ 2B9B6454B5BB98B387C7B5C4 /* ru */,
247
+ );
248
+ name = Localizable.strings;
249
+ sourceTree = "<group>";
250
+ };
251
+ /* End PBXVariantGroup section */
252
+
216
253
  /* Begin XCBuildConfiguration section */
217
254
  24892C5AA9CE821A3653389E /* Debug */ = {
218
255
  isa = XCBuildConfiguration;
@@ -265,14 +302,15 @@
265
302
  GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
266
303
  GCC_WARN_UNUSED_FUNCTION = YES;
267
304
  GCC_WARN_UNUSED_VARIABLE = YES;
305
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
268
306
  MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
269
307
  MTL_FAST_MATH = YES;
270
308
  ONLY_ACTIVE_ARCH = YES;
271
- PRODUCT_NAME = "$(TARGET_NAME)";
309
+ PRODUCT_NAME = TodoOrbit;
272
310
  SDKROOT = iphoneos;
273
311
  SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
274
312
  SWIFT_OPTIMIZATION_LEVEL = "-Onone";
275
- SWIFT_VERSION = 5.1;
313
+ SWIFT_VERSION = 5.0;
276
314
  };
277
315
  name = Debug;
278
316
  };
@@ -286,13 +324,13 @@
286
324
  INFOPLIST_KEY_CFBundleDisplayName = "Todo Orbit";
287
325
  INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
288
326
  INFOPLIST_KEY_UILaunchScreen_Generation = YES;
289
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
290
327
  LD_RUNPATH_SEARCH_PATHS = (
291
328
  "$(inherited)",
292
329
  "@executable_path/Frameworks",
293
330
  );
294
331
  PRODUCT_BUNDLE_IDENTIFIER = uz.rsteam.generated.todoorbit;
295
332
  SDKROOT = iphoneos;
333
+ SWIFT_VERSION = 5.1;
296
334
  TARGETED_DEVICE_FAMILY = "1,2";
297
335
  };
298
336
  name = Release;
@@ -307,13 +345,13 @@
307
345
  INFOPLIST_KEY_CFBundleDisplayName = "Todo Orbit";
308
346
  INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
309
347
  INFOPLIST_KEY_UILaunchScreen_Generation = YES;
310
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
311
348
  LD_RUNPATH_SEARCH_PATHS = (
312
349
  "$(inherited)",
313
350
  "@executable_path/Frameworks",
314
351
  );
315
352
  PRODUCT_BUNDLE_IDENTIFIER = uz.rsteam.generated.todoorbit;
316
353
  SDKROOT = iphoneos;
354
+ SWIFT_VERSION = 5.1;
317
355
  TARGETED_DEVICE_FAMILY = "1,2";
318
356
  };
319
357
  name = Debug;
@@ -363,13 +401,14 @@
363
401
  GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
364
402
  GCC_WARN_UNUSED_FUNCTION = YES;
365
403
  GCC_WARN_UNUSED_VARIABLE = YES;
404
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
366
405
  MTL_ENABLE_DEBUG_INFO = NO;
367
406
  MTL_FAST_MATH = YES;
368
- PRODUCT_NAME = "$(TARGET_NAME)";
407
+ PRODUCT_NAME = TodoOrbit;
369
408
  SDKROOT = iphoneos;
370
409
  SWIFT_COMPILATION_MODE = wholemodule;
371
410
  SWIFT_OPTIMIZATION_LEVEL = "-O";
372
- SWIFT_VERSION = 5.1;
411
+ SWIFT_VERSION = 5.0;
373
412
  };
374
413
  name = Release;
375
414
  };
@@ -0,0 +1,89 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <Scheme
3
+ LastUpgradeVersion = "1430"
4
+ version = "1.7">
5
+ <BuildAction
6
+ parallelizeBuildables = "YES"
7
+ buildImplicitDependencies = "YES"
8
+ runPostActionsOnFailure = "NO">
9
+ <BuildActionEntries>
10
+ <BuildActionEntry
11
+ buildForTesting = "YES"
12
+ buildForRunning = "YES"
13
+ buildForProfiling = "YES"
14
+ buildForArchiving = "YES"
15
+ buildForAnalyzing = "YES">
16
+ <BuildableReference
17
+ BuildableIdentifier = "primary"
18
+ BlueprintIdentifier = "8573DE2D9477FA23E2648D4F"
19
+ BuildableName = "TodoOrbit.app"
20
+ BlueprintName = "TodoOrbit"
21
+ ReferencedContainer = "container:TodoOrbit.xcodeproj">
22
+ </BuildableReference>
23
+ </BuildActionEntry>
24
+ </BuildActionEntries>
25
+ </BuildAction>
26
+ <TestAction
27
+ buildConfiguration = "Debug"
28
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
29
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30
+ shouldUseLaunchSchemeArgsEnv = "YES"
31
+ onlyGenerateCoverageForSpecifiedTargets = "NO">
32
+ <MacroExpansion>
33
+ <BuildableReference
34
+ BuildableIdentifier = "primary"
35
+ BlueprintIdentifier = "8573DE2D9477FA23E2648D4F"
36
+ BuildableName = "TodoOrbit.app"
37
+ BlueprintName = "TodoOrbit"
38
+ ReferencedContainer = "container:TodoOrbit.xcodeproj">
39
+ </BuildableReference>
40
+ </MacroExpansion>
41
+ <Testables>
42
+ </Testables>
43
+ </TestAction>
44
+ <LaunchAction
45
+ buildConfiguration = "Debug"
46
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
47
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
48
+ launchStyle = "0"
49
+ useCustomWorkingDirectory = "NO"
50
+ ignoresPersistentStateOnLaunch = "NO"
51
+ debugDocumentVersioning = "YES"
52
+ debugServiceExtension = "internal"
53
+ allowLocationSimulation = "YES">
54
+ <BuildableProductRunnable
55
+ runnableDebuggingMode = "0">
56
+ <BuildableReference
57
+ BuildableIdentifier = "primary"
58
+ BlueprintIdentifier = "8573DE2D9477FA23E2648D4F"
59
+ BuildableName = "TodoOrbit.app"
60
+ BlueprintName = "TodoOrbit"
61
+ ReferencedContainer = "container:TodoOrbit.xcodeproj">
62
+ </BuildableReference>
63
+ </BuildableProductRunnable>
64
+ </LaunchAction>
65
+ <ProfileAction
66
+ buildConfiguration = "Release"
67
+ shouldUseLaunchSchemeArgsEnv = "YES"
68
+ savedToolIdentifier = ""
69
+ useCustomWorkingDirectory = "NO"
70
+ debugDocumentVersioning = "YES">
71
+ <BuildableProductRunnable
72
+ runnableDebuggingMode = "0">
73
+ <BuildableReference
74
+ BuildableIdentifier = "primary"
75
+ BlueprintIdentifier = "8573DE2D9477FA23E2648D4F"
76
+ BuildableName = "TodoOrbit.app"
77
+ BlueprintName = "TodoOrbit"
78
+ ReferencedContainer = "container:TodoOrbit.xcodeproj">
79
+ </BuildableReference>
80
+ </BuildableProductRunnable>
81
+ </ProfileAction>
82
+ <AnalyzeAction
83
+ buildConfiguration = "Debug">
84
+ </AnalyzeAction>
85
+ <ArchiveAction
86
+ buildConfiguration = "Release"
87
+ revealArchiveInOrganizer = "YES">
88
+ </ArchiveAction>
89
+ </Scheme>
@@ -1,25 +1,32 @@
1
1
  name: TodoOrbit
2
2
  options:
3
3
  bundleIdPrefix: uz.rsteam.generated
4
+ deploymentTarget:
5
+ iOS: "17.0"
4
6
  settings:
5
7
  base:
6
- SWIFT_VERSION: 5.10
8
+ PRODUCT_NAME: TodoOrbit
7
9
  targets:
8
10
  TodoOrbit:
9
11
  type: application
10
12
  platform: iOS
11
- deploymentTarget: "17.0"
12
13
  sources:
13
14
  - path: Sources/TodoOrbit
14
- resources:
15
15
  - path: Resources
16
+ buildPhase: resources
16
17
  settings:
17
18
  base:
18
- PRODUCT_BUNDLE_IDENTIFIER: uz.rsteam.generated.todoorbit
19
19
  GENERATE_INFOPLIST_FILE: YES
20
20
  INFOPLIST_KEY_CFBundleDisplayName: Todo Orbit
21
- INFOPLIST_KEY_UILaunchScreen_Generation: YES
22
- INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
21
+ INFOPLIST_KEY_UILaunchScreen_Generation: true
22
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation: true
23
+ SWIFT_VERSION: 5.10
24
+ PRODUCT_BUNDLE_IDENTIFIER: uz.rsteam.generated.todoorbit
23
25
  DEVELOPMENT_ASSET_PATHS: "\"Sources/TodoOrbit/Preview Content\""
24
26
  dependencies:
25
27
  - sdk: Charts.framework
28
+ schemes:
29
+ TodoOrbit:
30
+ build:
31
+ targets:
32
+ TodoOrbit: all
@@ -25,9 +25,9 @@ import { loadTargetDrift } from "../drift/index.js";
25
25
  import { readFileSync as fsReadFileSync, existsSync, readdirSync } from "node:fs";
26
26
  import { relative, resolve } from "node:path";
27
27
  import YAML from "yaml";
28
- import { takeScreenshot } from "./screenshot.js";
29
- import { takeAndroidScreenshot } from "./screenshot-android.js";
30
- import { takeIOSScreenshot } from "./screenshot-ios.js";
28
+ import { takeScreenshot, takeScreenshotBatch } from "./screenshot.js";
29
+ import { takeAndroidScreenshot, takeAndroidScreenshotBatch } from "./screenshot-android.js";
30
+ import { takeIOSScreenshot, takeIOSScreenshotBatch } from "./screenshot-ios.js";
31
31
 
32
32
  // ── resolve project cwd ──────────────────────────────────────────────
33
33
 
@@ -777,6 +777,97 @@ server.registerTool(
777
777
  }
778
778
  );
779
779
 
780
+ // ── tool: openuispec_screenshot_web_batch ──────────────────────────────
781
+
782
+ const webBatchCaptureSchema = z.object({
783
+ screen: z.string().describe("Screen name for metadata and filename"),
784
+ route: z.string().describe("Route path (e.g. '/home', '/settings')"),
785
+ selector: z.string().optional().describe("CSS selector to screenshot a specific element"),
786
+ full_page: z.boolean().optional().describe("Capture full scrollable page"),
787
+ wait_for: z.number().optional().describe("Per-capture wait time in ms"),
788
+ });
789
+
790
+ server.registerTool(
791
+ "openuispec_screenshot_web_batch",
792
+ {
793
+ description: "Take multiple web screenshots in a single server session. Starts the dev server once, then captures all routes in sequence. Much faster than calling screenshot for each route individually.",
794
+ inputSchema: {
795
+ captures: z.array(webBatchCaptureSchema).describe("Array of captures — each with screen name and route"),
796
+ viewport: z.object({ width: z.number().default(1280), height: z.number().default(800) }).optional().describe("Viewport dimensions for all captures"),
797
+ theme: z.enum(["light", "dark"]).optional().describe("Force color scheme for all captures"),
798
+ output_dir: z.string().optional().describe("Directory to save all PNGs (relative to web app root)"),
799
+ },
800
+ },
801
+ async ({ captures, viewport, theme, output_dir }) => {
802
+ try {
803
+ return await takeScreenshotBatch(projectCwd, { captures, viewport, theme, output_dir });
804
+ } catch (err) {
805
+ return toolError(err);
806
+ }
807
+ }
808
+ );
809
+
810
+ // ── tool: openuispec_screenshot_android_batch ─────────────────────────
811
+
812
+ const androidBatchCaptureSchema = z.object({
813
+ screen: z.string().describe("Screen name for metadata and filename"),
814
+ route: z.string().optional().describe("Deep link URI to launch"),
815
+ nav: z.array(z.string()).optional().describe("UI tap steps after launch"),
816
+ wait_for: z.number().optional().describe("Per-capture wait time in ms"),
817
+ });
818
+
819
+ server.registerTool(
820
+ "openuispec_screenshot_android_batch",
821
+ {
822
+ description: "Take multiple Android screenshots in a single build+install cycle. Builds the APK once, installs once, then captures each screen in sequence via deep links or UI navigation. Much faster than calling screenshot_android for each screen individually.",
823
+ inputSchema: {
824
+ captures: z.array(androidBatchCaptureSchema).describe("Array of captures — each with screen name and optional route/nav"),
825
+ theme: z.enum(["light", "dark"]).optional().describe("Force light or dark mode for all captures"),
826
+ output_dir: z.string().optional().describe("Directory to save all PNGs (relative to Android project root)"),
827
+ project_dir: z.string().optional().describe("Direct path to Android project root"),
828
+ module: z.string().optional().describe("App module name (default: auto-detect)"),
829
+ },
830
+ },
831
+ async ({ captures, theme, output_dir, project_dir, module }) => {
832
+ try {
833
+ return await takeAndroidScreenshotBatch(projectCwd, { captures, theme, output_dir, project_dir, module });
834
+ } catch (err) {
835
+ return toolError(err);
836
+ }
837
+ }
838
+ );
839
+
840
+ // ── tool: openuispec_screenshot_ios_batch ──────────────────────────────
841
+
842
+ const iosBatchCaptureSchema = z.object({
843
+ screen: z.string().describe("Screen name for metadata and filename"),
844
+ nav: z.array(z.string()).optional().describe("UI tap steps after launch"),
845
+ wait_for: z.number().optional().describe("Per-capture wait time in ms"),
846
+ });
847
+
848
+ server.registerTool(
849
+ "openuispec_screenshot_ios_batch",
850
+ {
851
+ description: "Take multiple iOS screenshots in a single build+install cycle. Builds the app once, then captures each screen — no-nav screens via simctl, nav screens batched into a single XCUITest run. Much faster than calling screenshot_ios for each screen individually.",
852
+ inputSchema: {
853
+ captures: z.array(iosBatchCaptureSchema).describe("Array of captures — each with screen name and optional nav steps"),
854
+ device: z.string().optional().describe("Simulator device name"),
855
+ theme: z.enum(["light", "dark"]).optional().describe("Force light or dark appearance for all captures"),
856
+ output_dir: z.string().optional().describe("Directory to save all PNGs (relative to iOS project root)"),
857
+ project_dir: z.string().optional().describe("Direct path to iOS project root"),
858
+ scheme: z.string().optional().describe("Xcode scheme name"),
859
+ bundle_id: z.string().optional().describe("App bundle identifier"),
860
+ },
861
+ },
862
+ async ({ captures, device, theme, output_dir, project_dir, scheme, bundle_id }) => {
863
+ try {
864
+ return await takeIOSScreenshotBatch(projectCwd, { captures, device, theme, output_dir, project_dir, scheme, bundle_id });
865
+ } catch (err) {
866
+ return toolError(err);
867
+ }
868
+ }
869
+ );
870
+
780
871
  // ── start server ─────────────────────────────────────────────────────
781
872
 
782
873
  export async function startMcpServer() {
@@ -17,6 +17,7 @@ import {
17
17
  } from "./screenshot-shared.js";
18
18
 
19
19
  const exec = promisify(execCb);
20
+ const androidScreenshotQueues = new Map<string, Promise<void>>();
20
21
 
21
22
  // ── types ───────────────────────────────────────────────────────────
22
23
 
@@ -31,6 +32,21 @@ export interface AndroidScreenshotOptions {
31
32
  module?: string;
32
33
  }
33
34
 
35
+ export interface AndroidBatchCapture {
36
+ screen: string;
37
+ route?: string;
38
+ nav?: string[];
39
+ wait_for?: number;
40
+ }
41
+
42
+ export interface AndroidScreenshotBatchOptions {
43
+ captures: AndroidBatchCapture[];
44
+ theme?: "light" | "dark";
45
+ output_dir?: string;
46
+ project_dir?: string;
47
+ module?: string;
48
+ }
49
+
34
50
  // ── constants ───────────────────────────────────────────────────────
35
51
 
36
52
  const ADB_SCREENSHOT_PATH = "/sdcard/openuispec_screenshot.png";
@@ -282,6 +298,27 @@ export async function installAndLaunch(
282
298
  }
283
299
  }
284
300
 
301
+ export async function launchInstalledApp(
302
+ adb: string,
303
+ serial: string,
304
+ appInfo: AppInfo,
305
+ route?: string,
306
+ ): Promise<void> {
307
+ await adbShell(adb, serial, `am force-stop ${appInfo.applicationId}`);
308
+ // Clear saved nav state so deep links route correctly
309
+ try { await adbShell(adb, serial, `pm clear ${appInfo.applicationId}`); } catch { /* ignore */ }
310
+ if (route) {
311
+ await adbShell(
312
+ adb,
313
+ serial,
314
+ `am start -W -a android.intent.action.VIEW -d '${route}' ` +
315
+ `${appInfo.applicationId}/${appInfo.launchActivity}`,
316
+ );
317
+ } else {
318
+ await adbShell(adb, serial, `am start -W -n ${appInfo.applicationId}/${appInfo.launchActivity}`);
319
+ }
320
+ }
321
+
285
322
  // ── theme control ───────────────────────────────────────────────────
286
323
 
287
324
  export async function setTheme(adb: string, serial: string, theme: "light" | "dark"): Promise<void> {
@@ -358,9 +395,12 @@ export async function captureScreenshot(
358
395
  serial: string,
359
396
  localPath: string,
360
397
  ): Promise<void> {
361
- await adbShell(adb, serial, `screencap -p ${ADB_SCREENSHOT_PATH}`);
362
- await adbExec(adb, serial, `pull ${ADB_SCREENSHOT_PATH} "${localPath}"`);
363
- await adbShell(adb, serial, `rm ${ADB_SCREENSHOT_PATH}`);
398
+ try {
399
+ await exec(`${adb} -s ${serial} exec-out screencap -p > "${localPath}"`, { timeout: 60_000, shell: "/bin/bash" });
400
+ } catch (err: any) {
401
+ const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).trim();
402
+ throw new Error(`Android screenshot capture failed${output ? `:\n${output}` : "."}`);
403
+ }
364
404
  }
365
405
 
366
406
  // ── wait for app ready ──────────────────────────────────────────────
@@ -395,6 +435,47 @@ async function waitForAppReady(
395
435
  await new Promise(r => setTimeout(r, waitMs));
396
436
  }
397
437
 
438
+ async function takeSingleAndroidCapture(
439
+ adb: string,
440
+ serial: string,
441
+ androidDir: string,
442
+ appInfo: AppInfo,
443
+ capture: AndroidBatchCapture,
444
+ theme: "light" | "dark" | undefined,
445
+ defaultOutputDir: string | undefined,
446
+ ): Promise<{ screen: string; path: string; data: string }> {
447
+ await launchInstalledApp(adb, serial, appInfo, capture.route);
448
+ await waitForAppReady(adb, serial, appInfo.applicationId, capture.wait_for ?? 3000);
449
+
450
+ if (capture.nav && capture.nav.length > 0) {
451
+ await navigateByTaps(adb, serial, capture.nav);
452
+ }
453
+
454
+ const themeLabel = theme ?? "default";
455
+ const filename = `${capture.screen}_${themeLabel}.png`;
456
+ const tmpPath = join(androidDir, `.openuispec-screenshot-${capture.screen}.png`);
457
+ await captureScreenshot(adb, serial, tmpPath);
458
+
459
+ let savedPath = filename;
460
+ if (defaultOutputDir) {
461
+ const outDir = resolve(androidDir, defaultOutputDir);
462
+ mkdirSync(outDir, { recursive: true });
463
+ savedPath = join(outDir, filename);
464
+ copyFileSync(tmpPath, savedPath);
465
+ }
466
+
467
+ try {
468
+ const data = readFileSync(tmpPath).toString("base64");
469
+ return {
470
+ screen: capture.screen,
471
+ path: savedPath,
472
+ data,
473
+ };
474
+ } finally {
475
+ try { unlinkSync(tmpPath); } catch { /* ignore */ }
476
+ }
477
+ }
478
+
398
479
  // ── main entry point ────────────────────────────────────────────────
399
480
 
400
481
  export async function takeAndroidScreenshot(
@@ -420,61 +501,121 @@ export async function takeAndroidScreenshot(
420
501
  const adb = findAdb();
421
502
  const serial = await getConnectedEmulator(adb);
422
503
 
423
- // 3. Free emulator storage before build/install
424
- await cleanEmulatorStorage(adb, serial);
504
+ const previousRun = androidScreenshotQueues.get(serial) ?? Promise.resolve();
505
+ let releaseQueue: (() => void) | undefined;
506
+ const currentRun = new Promise<void>((resolve) => {
507
+ releaseQueue = resolve;
508
+ });
509
+ androidScreenshotQueues.set(serial, previousRun.then(() => currentRun));
425
510
 
426
- // 4. Build APK
427
- const apkPath = await buildApk(androidDir, appInfo.moduleName);
511
+ await previousRun;
428
512
 
429
- // 5. Set theme if requested
430
- if (theme) {
431
- await setTheme(adb, serial, theme);
432
- }
513
+ try {
514
+ // 3. Free emulator storage before build/install
515
+ await cleanEmulatorStorage(adb, serial);
433
516
 
434
- // 6. Install and launch
435
- await installAndLaunch(adb, serial, apkPath, appInfo, route);
517
+ // 4. Build APK
518
+ const apkPath = await buildApk(androidDir, appInfo.moduleName);
436
519
 
437
- // 7. Wait for app to be ready and content to load
438
- await waitForAppReady(adb, serial, appInfo.applicationId, wait_for);
520
+ // 5. Set theme if requested
521
+ if (theme) {
522
+ await setTheme(adb, serial, theme);
523
+ }
439
524
 
440
- // 8. Navigate via UI taps if specified
441
- if (nav && nav.length > 0) {
442
- await navigateByTaps(adb, serial, nav);
443
- }
525
+ // 6. Install fresh once, then capture
526
+ await installAndLaunch(adb, serial, apkPath, appInfo, route);
527
+
528
+ const snapshot = await takeSingleAndroidCapture(
529
+ adb,
530
+ serial,
531
+ androidDir,
532
+ appInfo,
533
+ { screen: screen ?? "main", route, nav, wait_for },
534
+ theme,
535
+ output_dir,
536
+ );
444
537
 
445
- // 9. Capture screenshot
446
- const screenLabel = screen ?? "main";
447
- const themeLabel = theme ?? "default";
448
- const filename = `${screenLabel}_${themeLabel}.png`;
449
- const tmpPath = join(androidDir, ".openuispec-screenshot.png");
450
- await captureScreenshot(adb, serial, tmpPath);
538
+ return buildScreenshotResponse([snapshot], (s) => ({
539
+ screen: s.screen,
540
+ path: snapshot.path ?? null,
541
+ emulator: serial,
542
+ theme: theme ?? "default",
543
+ applicationId: appInfo.applicationId,
544
+ }));
545
+ } finally {
546
+ releaseQueue?.();
547
+ if (androidScreenshotQueues.get(serial) === currentRun) {
548
+ androidScreenshotQueues.delete(serial);
549
+ }
550
+ }
551
+ }
451
552
 
452
- // 9. Save to output_dir if specified
453
- let savedPath: string | undefined;
454
- if (output_dir) {
455
- const outDir = resolve(androidDir, output_dir);
456
- mkdirSync(outDir, { recursive: true });
457
- savedPath = join(outDir, filename);
458
- copyFileSync(tmpPath, savedPath);
553
+ export async function takeAndroidScreenshotBatch(
554
+ projectCwd: string,
555
+ options: AndroidScreenshotBatchOptions,
556
+ ): Promise<ScreenshotResult> {
557
+ const { captures, theme, output_dir, project_dir, module } = options;
558
+ if (captures.length === 0) {
559
+ return {
560
+ content: [{ type: "text", text: "No Android captures specified." }],
561
+ isError: true,
562
+ };
459
563
  }
460
564
 
461
- // 10. Read and return
565
+ const androidDir = findAndroidAppDir(projectCwd, project_dir);
566
+ const appInfo = extractAppInfo(androidDir, module);
567
+ const adb = findAdb();
568
+ const serial = await getConnectedEmulator(adb);
569
+
570
+ const previousRun = androidScreenshotQueues.get(serial) ?? Promise.resolve();
571
+ let releaseQueue: (() => void) | undefined;
572
+ const currentRun = new Promise<void>((resolve) => {
573
+ releaseQueue = resolve;
574
+ });
575
+ androidScreenshotQueues.set(serial, previousRun.then(() => currentRun));
576
+
577
+ await previousRun;
578
+
462
579
  try {
463
- const data = readFileSync(tmpPath).toString("base64");
464
- const snapshots = [{
465
- screen: screenLabel,
466
- path: savedPath ?? filename,
467
- data,
468
- }];
580
+ await cleanEmulatorStorage(adb, serial);
581
+ const apkPath = await buildApk(androidDir, appInfo.moduleName);
469
582
 
470
- return buildScreenshotResponse(snapshots, (s) => ({
471
- screen: s.screen,
472
- path: savedPath ?? null,
583
+ if (theme) {
584
+ await setTheme(adb, serial, theme);
585
+ }
586
+
587
+ await installAndLaunch(adb, serial, apkPath, appInfo);
588
+
589
+ // Pre-create output dir once
590
+ if (output_dir) mkdirSync(resolve(androidDir, output_dir), { recursive: true });
591
+
592
+ const snapshots = [];
593
+ for (let index = 0; index < captures.length; index += 1) {
594
+ const capture = captures[index];
595
+ snapshots.push(
596
+ await takeSingleAndroidCapture(
597
+ adb,
598
+ serial,
599
+ androidDir,
600
+ appInfo,
601
+ capture,
602
+ theme,
603
+ output_dir,
604
+ ),
605
+ );
606
+ }
607
+
608
+ return buildScreenshotResponse(snapshots, (snapshot) => ({
609
+ screen: snapshot.screen,
610
+ path: snapshot.path,
473
611
  emulator: serial,
474
- theme: themeLabel,
612
+ theme: theme ?? "default",
475
613
  applicationId: appInfo.applicationId,
476
614
  }));
477
615
  } finally {
478
- try { unlinkSync(tmpPath); } catch { /* ignore */ }
616
+ releaseQueue?.();
617
+ if (androidScreenshotQueues.get(serial) === currentRun) {
618
+ androidScreenshotQueues.delete(serial);
619
+ }
479
620
  }
480
621
  }
@@ -233,6 +233,45 @@ async function setAppearance(udid: string, theme: "light" | "dark"): Promise<voi
233
233
  const UITEST_TARGET = "ScreenshotUITests";
234
234
  const UITEST_DIR = ".screenshot-uitest";
235
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
+
236
275
  function generateUITestSwift(
237
276
  bundleId: string,
238
277
  navSteps: string[],
@@ -322,40 +361,13 @@ async function runXCUITest(
322
361
 
323
362
  if (appInfo.hasXcodegen) {
324
363
  originalProjectYml = readFileSync(projectYmlPath, "utf-8");
364
+ let modifiedYml = ensureInfoPlistFlag(originalProjectYml);
365
+ modifiedYml = insertUITestTarget(modifiedYml, generateUITestTargetYml(appInfo, `${UITEST_DIR}/Sources`));
366
+ writeFileSync(projectYmlPath, modifiedYml);
325
367
 
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
368
  try {
356
369
  await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
357
370
  } catch (err: any) {
358
- // Restore original project.yml
359
371
  writeFileSync(projectYmlPath, originalProjectYml);
360
372
  throw new Error(`xcodegen failed: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
361
373
  }
@@ -539,3 +551,203 @@ export async function takeIOSScreenshot(
539
551
  try { unlinkSync(tmpPath); } catch { /* ignore */ }
540
552
  }
541
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
+ }
@@ -258,6 +258,86 @@ export async function takeScreenshot(
258
258
  }
259
259
  }
260
260
 
261
+ // ── batch types ──────────────────────────────────────────────────────
262
+
263
+ export interface WebBatchCapture {
264
+ screen: string;
265
+ route: string;
266
+ selector?: string;
267
+ full_page?: boolean;
268
+ wait_for?: number;
269
+ }
270
+
271
+ export interface WebScreenshotBatchOptions {
272
+ captures: WebBatchCapture[];
273
+ viewport?: { width: number; height: number };
274
+ theme?: "light" | "dark";
275
+ output_dir?: string;
276
+ }
277
+
278
+ // ── batch screenshot ─────────────────────────────────────────────────
279
+
280
+ export async function takeScreenshotBatch(
281
+ projectCwd: string,
282
+ options: WebScreenshotBatchOptions,
283
+ ): Promise<ScreenshotResult> {
284
+ const { captures, viewport = { width: 1280, height: 800 }, theme, output_dir } = options;
285
+
286
+ if (captures.length === 0) {
287
+ return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
288
+ }
289
+
290
+ const webDir = findWebAppDir(projectCwd);
291
+ const server = await startDevServer(webDir);
292
+ const browser = await getBrowser();
293
+ const page = await browser.newPage();
294
+
295
+ try {
296
+ await page.setViewport({ width: viewport.width, height: viewport.height });
297
+ if (theme) {
298
+ await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
299
+ }
300
+
301
+ const base = server.url.replace(/\/+$/, "");
302
+ const themeLabel = theme ?? "default";
303
+ const snapshots: Array<{ screen: string; path: string; data: string }> = [];
304
+
305
+ for (const capture of captures) {
306
+ const targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
307
+ await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
308
+ await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
309
+
310
+ let buffer: Buffer;
311
+ if (capture.selector) {
312
+ const el = await page.$(capture.selector);
313
+ buffer = el ? await el.screenshot({ type: "png" }) : await page.screenshot({ type: "png" });
314
+ } else {
315
+ buffer = await page.screenshot({ type: "png", fullPage: capture.full_page ?? false });
316
+ }
317
+
318
+ const filename = `${capture.screen}_${themeLabel}.png`;
319
+ let savedPath = filename;
320
+ if (output_dir) {
321
+ const outDir = resolve(webDir, output_dir);
322
+ mkdirSync(outDir, { recursive: true });
323
+ savedPath = join(outDir, filename);
324
+ writeFileSync(savedPath, buffer);
325
+ }
326
+
327
+ snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64") });
328
+ }
329
+
330
+ const content: ScreenshotResult["content"] = [];
331
+ for (const s of snapshots) {
332
+ content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
333
+ content.push({ type: "text" as const, text: JSON.stringify({ screen: s.screen, path: s.path, theme: themeLabel }, null, 2) });
334
+ }
335
+ return { content };
336
+ } finally {
337
+ await page.close();
338
+ }
339
+ }
340
+
261
341
  // ── cleanup ─────────────────────────────────────────────────────────
262
342
 
263
343
  export async function shutdownAll() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",