openuispec 0.2.8 → 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.
@@ -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";
@@ -213,18 +229,14 @@ export async function adbExec(adb: string, serial: string, args: string): Promis
213
229
  // ── emulator storage cleanup ─────────────────────────────────────────
214
230
 
215
231
  export async function cleanEmulatorStorage(adb: string, serial: string): Promise<void> {
216
- try {
217
- // Clear package manager cache
218
- await adbShell(adb, serial, `pm trim-caches 512M`);
219
- } catch { /* may require root, skip */ }
220
- try {
221
- // Remove leftover screenshot/temp files
222
- await adbShell(adb, serial, `rm -f /sdcard/openuispec_screenshot.png /sdcard/ui_dump.xml /sdcard/screenshot.png`);
223
- } catch { /* ignore */ }
224
- try {
225
- // Clear temp files
226
- await adbShell(adb, serial, `rm -rf /data/local/tmp/*.apk`);
227
- } catch { /* ignore */ }
232
+ const cmds = [
233
+ `pm trim-caches 2G`, // aggressively trim package cache
234
+ `rm -rf /data/local/tmp/*.apk`, // leftover APKs from previous installs
235
+ `rm -f /sdcard/openuispec_screenshot.png /sdcard/ui_dump.xml /sdcard/screenshot.png`,
236
+ ];
237
+ for (const cmd of cmds) {
238
+ try { await adbShell(adb, serial, cmd); } catch { /* ignore */ }
239
+ }
228
240
  }
229
241
 
230
242
  // ── build APK ───────────────────────────────────────────────────────
@@ -269,23 +281,41 @@ export async function installAndLaunch(
269
281
  appInfo: AppInfo,
270
282
  route?: string,
271
283
  ): Promise<void> {
272
- // Install (replace existing)
273
- await adbExec(adb, serial, `install -r "${apkPath}"`);
274
-
275
- // Force-stop and clear saved navigation state
284
+ // Force-stop and uninstall to free storage + wipe saved nav state
276
285
  await adbShell(adb, serial, `am force-stop ${appInfo.applicationId}`);
286
+ try { await adbShell(adb, serial, `pm uninstall ${appInfo.applicationId}`); } catch { /* not installed */ }
277
287
 
278
- // FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK = 0x10008000
279
- // Clears saved navigation state so deep links route correctly
280
- const clearFlags = `-f 0x10008000`;
288
+ // Install fresh (not -r replace, since we uninstalled)
289
+ await adbExec(adb, serial, `install "${apkPath}"`);
281
290
 
282
291
  if (route) {
283
292
  await adbShell(adb, serial,
284
- `am start -W -a android.intent.action.VIEW -d "${route}" ${clearFlags} ` +
293
+ `am start -W -a android.intent.action.VIEW -d '${route}' ` +
285
294
  `${appInfo.applicationId}/${appInfo.launchActivity}`);
286
295
  } else {
287
296
  await adbShell(adb, serial,
288
- `am start -W ${clearFlags} -n ${appInfo.applicationId}/${appInfo.launchActivity}`);
297
+ `am start -W -n ${appInfo.applicationId}/${appInfo.launchActivity}`);
298
+ }
299
+ }
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}`);
289
319
  }
290
320
  }
291
321
 
@@ -365,9 +395,12 @@ export async function captureScreenshot(
365
395
  serial: string,
366
396
  localPath: string,
367
397
  ): Promise<void> {
368
- await adbShell(adb, serial, `screencap -p ${ADB_SCREENSHOT_PATH}`);
369
- await adbExec(adb, serial, `pull ${ADB_SCREENSHOT_PATH} "${localPath}"`);
370
- 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
+ }
371
404
  }
372
405
 
373
406
  // ── wait for app ready ──────────────────────────────────────────────
@@ -402,6 +435,47 @@ async function waitForAppReady(
402
435
  await new Promise(r => setTimeout(r, waitMs));
403
436
  }
404
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
+
405
479
  // ── main entry point ────────────────────────────────────────────────
406
480
 
407
481
  export async function takeAndroidScreenshot(
@@ -427,61 +501,121 @@ export async function takeAndroidScreenshot(
427
501
  const adb = findAdb();
428
502
  const serial = await getConnectedEmulator(adb);
429
503
 
430
- // 3. Free emulator storage before build/install
431
- 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));
432
510
 
433
- // 4. Build APK
434
- const apkPath = await buildApk(androidDir, appInfo.moduleName);
511
+ await previousRun;
435
512
 
436
- // 5. Set theme if requested
437
- if (theme) {
438
- await setTheme(adb, serial, theme);
439
- }
513
+ try {
514
+ // 3. Free emulator storage before build/install
515
+ await cleanEmulatorStorage(adb, serial);
440
516
 
441
- // 6. Install and launch
442
- await installAndLaunch(adb, serial, apkPath, appInfo, route);
517
+ // 4. Build APK
518
+ const apkPath = await buildApk(androidDir, appInfo.moduleName);
443
519
 
444
- // 7. Wait for app to be ready and content to load
445
- await waitForAppReady(adb, serial, appInfo.applicationId, wait_for);
520
+ // 5. Set theme if requested
521
+ if (theme) {
522
+ await setTheme(adb, serial, theme);
523
+ }
446
524
 
447
- // 8. Navigate via UI taps if specified
448
- if (nav && nav.length > 0) {
449
- await navigateByTaps(adb, serial, nav);
450
- }
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
+ );
451
537
 
452
- // 9. Capture screenshot
453
- const screenLabel = screen ?? "main";
454
- const themeLabel = theme ?? "default";
455
- const filename = `${screenLabel}_${themeLabel}.png`;
456
- const tmpPath = join(androidDir, ".openuispec-screenshot.png");
457
- 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
+ }
458
552
 
459
- // 9. Save to output_dir if specified
460
- let savedPath: string | undefined;
461
- if (output_dir) {
462
- const outDir = resolve(androidDir, output_dir);
463
- mkdirSync(outDir, { recursive: true });
464
- savedPath = join(outDir, filename);
465
- 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
+ };
466
563
  }
467
564
 
468
- // 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
+
469
579
  try {
470
- const data = readFileSync(tmpPath).toString("base64");
471
- const snapshots = [{
472
- screen: screenLabel,
473
- path: savedPath ?? filename,
474
- data,
475
- }];
580
+ await cleanEmulatorStorage(adb, serial);
581
+ const apkPath = await buildApk(androidDir, appInfo.moduleName);
476
582
 
477
- return buildScreenshotResponse(snapshots, (s) => ({
478
- screen: s.screen,
479
- 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,
480
611
  emulator: serial,
481
- theme: themeLabel,
612
+ theme: theme ?? "default",
482
613
  applicationId: appInfo.applicationId,
483
614
  }));
484
615
  } finally {
485
- try { unlinkSync(tmpPath); } catch { /* ignore */ }
616
+ releaseQueue?.();
617
+ if (androidScreenshotQueues.get(serial) === currentRun) {
618
+ androidScreenshotQueues.delete(serial);
619
+ }
486
620
  }
487
621
  }