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.
@@ -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.8",
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",