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 +77 -1
- package/docs/cli.md +30 -0
- package/examples/taskflow/generated/ios/TaskFlow/project.yml +5 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +45 -6
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +89 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +13 -6
- package/mcp-server/index.ts +94 -3
- package/mcp-server/screenshot-android.ts +185 -44
- package/mcp-server/screenshot-ios.ts +242 -30
- package/mcp-server/screenshot.ts +80 -0
- package/package.json +1 -1
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:
|
|
@@ -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 =
|
|
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.
|
|
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 =
|
|
407
|
+
PRODUCT_NAME = TodoOrbit;
|
|
369
408
|
SDKROOT = iphoneos;
|
|
370
409
|
SWIFT_COMPILATION_MODE = wholemodule;
|
|
371
410
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
|
372
|
-
SWIFT_VERSION = 5.
|
|
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
|
-
|
|
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:
|
|
22
|
-
INFOPLIST_KEY_UIApplicationSceneManifest_Generation:
|
|
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
|
package/mcp-server/index.ts
CHANGED
|
@@ -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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
427
|
-
const apkPath = await buildApk(androidDir, appInfo.moduleName);
|
|
511
|
+
await previousRun;
|
|
428
512
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
await
|
|
432
|
-
}
|
|
513
|
+
try {
|
|
514
|
+
// 3. Free emulator storage before build/install
|
|
515
|
+
await cleanEmulatorStorage(adb, serial);
|
|
433
516
|
|
|
434
|
-
|
|
435
|
-
|
|
517
|
+
// 4. Build APK
|
|
518
|
+
const apkPath = await buildApk(androidDir, appInfo.moduleName);
|
|
436
519
|
|
|
437
|
-
|
|
438
|
-
|
|
520
|
+
// 5. Set theme if requested
|
|
521
|
+
if (theme) {
|
|
522
|
+
await setTheme(adb, serial, theme);
|
|
523
|
+
}
|
|
439
524
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
const
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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:
|
|
612
|
+
theme: theme ?? "default",
|
|
475
613
|
applicationId: appInfo.applicationId,
|
|
476
614
|
}));
|
|
477
615
|
} finally {
|
|
478
|
-
|
|
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
|
+
}
|
package/mcp-server/screenshot.ts
CHANGED
|
@@ -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() {
|