openuispec 0.2.19 → 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +1047 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +886 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +233 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +180 -0
- package/dist/mcp-server/screenshot.js +459 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/package.json +12 -13
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- package/status/index.ts +0 -275
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS screenshot tool — builds the app, installs on a simulator,
|
|
3
|
+
* and captures real screenshots via xcrun simctl or XCUITest.
|
|
4
|
+
*
|
|
5
|
+
* When `nav` steps are provided, generates an XCUITest that taps elements
|
|
6
|
+
* by accessibility label and captures with XCUIScreen.main.screenshot().
|
|
7
|
+
* Without `nav`, uses xcrun simctl io screenshot directly.
|
|
8
|
+
*
|
|
9
|
+
* Requires Xcode and an iOS Simulator runtime.
|
|
10
|
+
*/
|
|
11
|
+
import { exec as execCb, execSync } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, copyFileSync, unlinkSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { findPlatformAppDir, buildScreenshotResponse, } from "./screenshot-shared.js";
|
|
16
|
+
const exec = promisify(execCb);
|
|
17
|
+
let iosScreenshotQueue = Promise.resolve();
|
|
18
|
+
async function withIOSScreenshotLock(run) {
|
|
19
|
+
const previousRun = iosScreenshotQueue;
|
|
20
|
+
let releaseQueue;
|
|
21
|
+
const currentRun = new Promise((resolve) => {
|
|
22
|
+
releaseQueue = resolve;
|
|
23
|
+
});
|
|
24
|
+
const queuedRun = previousRun.then(() => currentRun);
|
|
25
|
+
iosScreenshotQueue = queuedRun;
|
|
26
|
+
await previousRun;
|
|
27
|
+
try {
|
|
28
|
+
return await run();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
releaseQueue?.();
|
|
32
|
+
if (iosScreenshotQueue === queuedRun) {
|
|
33
|
+
iosScreenshotQueue = Promise.resolve();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ── iOS app directory discovery ─────────────────────────────────────
|
|
38
|
+
function hasXcodeProject(dir) {
|
|
39
|
+
try {
|
|
40
|
+
return readdirSync(dir).some((e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace") || e === "Package.swift");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function findIOSAppDir(projectCwd, directDir) {
|
|
47
|
+
return findPlatformAppDir(projectCwd, "ios", hasXcodeProject, directDir);
|
|
48
|
+
}
|
|
49
|
+
function extractDeploymentTarget(pbxprojContent) {
|
|
50
|
+
const match = pbxprojContent.match(/IPHONEOS_DEPLOYMENT_TARGET\s*=\s*"?([0-9.]+)"?/);
|
|
51
|
+
return match ? match[1] : null;
|
|
52
|
+
}
|
|
53
|
+
export function extractAppInfo(iosDir, overrides) {
|
|
54
|
+
const entries = readdirSync(iosDir);
|
|
55
|
+
const xcodeproj = entries.find((e) => e.endsWith(".xcodeproj")) ?? null;
|
|
56
|
+
const xcworkspace = entries.find((e) => e.endsWith(".xcworkspace")) ?? null;
|
|
57
|
+
const hasXcodegen = entries.includes("project.yml");
|
|
58
|
+
const projectName = xcodeproj?.replace(".xcodeproj", "") ??
|
|
59
|
+
xcworkspace?.replace(".xcworkspace", "") ?? "App";
|
|
60
|
+
let schemeName = overrides?.scheme ?? projectName;
|
|
61
|
+
let bundleId = overrides?.bundle_id ??
|
|
62
|
+
`com.example.${projectName.toLowerCase().replace(/\s+/g, "")}`;
|
|
63
|
+
let deploymentTarget = "17.0";
|
|
64
|
+
if (xcodeproj) {
|
|
65
|
+
const pbxprojPath = join(iosDir, xcodeproj, "project.pbxproj");
|
|
66
|
+
try {
|
|
67
|
+
const content = readFileSync(pbxprojPath, "utf-8");
|
|
68
|
+
if (!overrides?.bundle_id) {
|
|
69
|
+
const bundleMatch = content.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";]+)/);
|
|
70
|
+
if (bundleMatch)
|
|
71
|
+
bundleId = bundleMatch[1];
|
|
72
|
+
}
|
|
73
|
+
const detectedTarget = extractDeploymentTarget(content);
|
|
74
|
+
if (detectedTarget)
|
|
75
|
+
deploymentTarget = detectedTarget;
|
|
76
|
+
}
|
|
77
|
+
catch { /* use defaults */ }
|
|
78
|
+
if (!overrides?.scheme) {
|
|
79
|
+
const schemesDir = join(iosDir, xcodeproj, "xcshareddata", "xcschemes");
|
|
80
|
+
try {
|
|
81
|
+
const schemes = readdirSync(schemesDir).filter(f => f.endsWith(".xcscheme"));
|
|
82
|
+
if (schemes.length > 0)
|
|
83
|
+
schemeName = schemes[0].replace(".xcscheme", "");
|
|
84
|
+
}
|
|
85
|
+
catch { /* use project name */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { projectName, schemeName, bundleId, xcodeproj, xcworkspace, hasXcodegen, deploymentTarget };
|
|
89
|
+
}
|
|
90
|
+
export function findSimulator(deviceName) {
|
|
91
|
+
let output;
|
|
92
|
+
try {
|
|
93
|
+
output = execSync("xcrun simctl list devices available -j", { stdio: "pipe", encoding: "utf-8" });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new Error("Failed to list simulators. Ensure Xcode is installed.");
|
|
97
|
+
}
|
|
98
|
+
const data = JSON.parse(output);
|
|
99
|
+
const devices = data.devices;
|
|
100
|
+
if (deviceName) {
|
|
101
|
+
const shortName = deviceName.replace(/ \(.*\)/, "");
|
|
102
|
+
for (const [runtime, devicesInRuntime] of Object.entries(devices)) {
|
|
103
|
+
if (!runtime.includes("iOS"))
|
|
104
|
+
continue;
|
|
105
|
+
for (const device of devicesInRuntime) {
|
|
106
|
+
if (device.name === deviceName || device.name.includes(shortName)) {
|
|
107
|
+
return device;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`No simulator found matching "${deviceName}". Run 'xcrun simctl list devices available' to see options.`);
|
|
112
|
+
}
|
|
113
|
+
// Default: find any booted iPhone, or first available iPhone
|
|
114
|
+
let firstIphone = null;
|
|
115
|
+
for (const [runtime, devicesInRuntime] of Object.entries(devices)) {
|
|
116
|
+
if (!runtime.includes("iOS"))
|
|
117
|
+
continue;
|
|
118
|
+
for (const device of devicesInRuntime) {
|
|
119
|
+
if (!device.name.includes("iPhone") && !device.name.includes("iPad"))
|
|
120
|
+
continue;
|
|
121
|
+
if (device.state === "Booted")
|
|
122
|
+
return device;
|
|
123
|
+
if (!firstIphone && device.name.includes("iPhone"))
|
|
124
|
+
firstIphone = device;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (firstIphone)
|
|
128
|
+
return firstIphone;
|
|
129
|
+
throw new Error("No iOS Simulator found. Install a simulator runtime via Xcode.");
|
|
130
|
+
}
|
|
131
|
+
export async function ensureSimulatorBooted(udid) {
|
|
132
|
+
try {
|
|
133
|
+
await exec(`xcrun simctl boot ${udid}`);
|
|
134
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (!err.stderr?.includes("Booted"))
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// ── build app ───────────────────────────────────────────────────────
|
|
142
|
+
export async function buildApp(iosDir, appInfo, simulatorUdid) {
|
|
143
|
+
const buildDir = join(iosDir, ".build", "screenshot");
|
|
144
|
+
mkdirSync(buildDir, { recursive: true });
|
|
145
|
+
const projectFlag = appInfo.xcworkspace
|
|
146
|
+
? `-workspace "${join(iosDir, appInfo.xcworkspace)}"`
|
|
147
|
+
: appInfo.xcodeproj
|
|
148
|
+
? `-project "${join(iosDir, appInfo.xcodeproj)}"`
|
|
149
|
+
: "";
|
|
150
|
+
try {
|
|
151
|
+
await exec(`xcodebuild build ` +
|
|
152
|
+
`${projectFlag} ` +
|
|
153
|
+
`-scheme "${appInfo.schemeName}" ` +
|
|
154
|
+
`-destination "id=${simulatorUdid}" ` +
|
|
155
|
+
`-derivedDataPath "${buildDir}" ` +
|
|
156
|
+
`-quiet 2>&1`, { cwd: iosDir, timeout: 300_000 });
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).slice(-500);
|
|
160
|
+
throw new Error(`Xcode build failed:\n${output}`);
|
|
161
|
+
}
|
|
162
|
+
const productsDir = join(buildDir, "Build", "Products");
|
|
163
|
+
const appBundle = findAppBundle(productsDir);
|
|
164
|
+
if (!appBundle)
|
|
165
|
+
throw new Error("App bundle (.app) not found after build.");
|
|
166
|
+
return appBundle;
|
|
167
|
+
}
|
|
168
|
+
export function findAppBundle(dir) {
|
|
169
|
+
if (!existsSync(dir))
|
|
170
|
+
return null;
|
|
171
|
+
for (const entry of readdirSync(dir)) {
|
|
172
|
+
const fullPath = join(dir, entry);
|
|
173
|
+
if (entry.endsWith(".app"))
|
|
174
|
+
return fullPath;
|
|
175
|
+
try {
|
|
176
|
+
if (statSync(fullPath).isDirectory()) {
|
|
177
|
+
const found = findAppBundle(fullPath);
|
|
178
|
+
if (found)
|
|
179
|
+
return found;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch { /* skip */ }
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
// ── install & launch ────────────────────────────────────────────────
|
|
187
|
+
export async function installAndLaunch(udid, appBundlePath, bundleId) {
|
|
188
|
+
await exec(`xcrun simctl install ${udid} "${appBundlePath}"`, { timeout: 60_000 });
|
|
189
|
+
try {
|
|
190
|
+
await exec(`xcrun simctl terminate ${udid} ${bundleId}`);
|
|
191
|
+
}
|
|
192
|
+
catch { /* not running */ }
|
|
193
|
+
await exec(`xcrun simctl launch ${udid} ${bundleId}`, { timeout: 30_000 });
|
|
194
|
+
}
|
|
195
|
+
// ── theme control ───────────────────────────────────────────────────
|
|
196
|
+
async function setAppearance(udid, theme) {
|
|
197
|
+
await exec(`xcrun simctl ui ${udid} appearance ${theme}`);
|
|
198
|
+
}
|
|
199
|
+
// ── XCUITest-based navigation + screenshot ──────────────────────────
|
|
200
|
+
const UITEST_TARGET = "ScreenshotUITests";
|
|
201
|
+
const UITEST_DIR = ".screenshot-uitest";
|
|
202
|
+
export function generateUITestTargetYml(appInfo, sourcePath, includeProductName = false) {
|
|
203
|
+
const productLines = includeProductName
|
|
204
|
+
? `\n PRODUCT_NAME: ${UITEST_TARGET}\n PRODUCT_MODULE_NAME: ${UITEST_TARGET}`
|
|
205
|
+
: "";
|
|
206
|
+
return ` ${UITEST_TARGET}:
|
|
207
|
+
type: bundle.ui-testing
|
|
208
|
+
platform: iOS
|
|
209
|
+
deploymentTarget: "${appInfo.deploymentTarget}"
|
|
210
|
+
sources:
|
|
211
|
+
- path: ${sourcePath}
|
|
212
|
+
dependencies:
|
|
213
|
+
- target: ${appInfo.schemeName}
|
|
214
|
+
embed: false
|
|
215
|
+
settings:
|
|
216
|
+
base:${productLines}
|
|
217
|
+
TEST_TARGET_NAME: ${appInfo.schemeName}
|
|
218
|
+
PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
|
|
219
|
+
GENERATE_INFOPLIST_FILE: YES`;
|
|
220
|
+
}
|
|
221
|
+
export function insertUITestTarget(yml, targetYml) {
|
|
222
|
+
if (yml.includes("\nschemes:")) {
|
|
223
|
+
return yml.replace("\nschemes:", `\n${targetYml}\nschemes:`);
|
|
224
|
+
}
|
|
225
|
+
return yml + "\n" + targetYml + "\n";
|
|
226
|
+
}
|
|
227
|
+
export function ensureInfoPlistFlag(yml) {
|
|
228
|
+
if (yml.includes("GENERATE_INFOPLIST_FILE"))
|
|
229
|
+
return yml;
|
|
230
|
+
return yml.replace(/(PRODUCT_BUNDLE_IDENTIFIER:[^\n]+\n)/, "$1 GENERATE_INFOPLIST_FILE: YES\n");
|
|
231
|
+
}
|
|
232
|
+
function generateUITestSwift(bundleId, navSteps, waitMs, outputPath) {
|
|
233
|
+
const taps = navSteps.map((step, i) => {
|
|
234
|
+
const escaped = step.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
235
|
+
return `
|
|
236
|
+
// Tap "${escaped}"
|
|
237
|
+
let target_${i} = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@ OR title == %@", "${escaped}", "${escaped}")).firstMatch
|
|
238
|
+
if target_${i}.waitForExistence(timeout: 5) {
|
|
239
|
+
target_${i}.tap()
|
|
240
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
241
|
+
}`;
|
|
242
|
+
}).join("\n");
|
|
243
|
+
return `import XCTest
|
|
244
|
+
|
|
245
|
+
final class ScreenshotUITest: XCTestCase {
|
|
246
|
+
func testNavigateAndScreenshot() {
|
|
247
|
+
let app = XCUIApplication()
|
|
248
|
+
app.launchArguments = ["-AppleLanguages", "(en)"]
|
|
249
|
+
app.launch()
|
|
250
|
+
|
|
251
|
+
// Wait for app to load
|
|
252
|
+
Thread.sleep(forTimeInterval: ${(waitMs / 1000).toFixed(1)})
|
|
253
|
+
${taps}
|
|
254
|
+
|
|
255
|
+
// Wait for navigation to settle
|
|
256
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
257
|
+
|
|
258
|
+
// Capture screenshot
|
|
259
|
+
let screenshot = XCUIScreen.main.screenshot()
|
|
260
|
+
let pngData = screenshot.pngRepresentation
|
|
261
|
+
let outputPath = "${outputPath.replace(/"/g, '\\"')}"
|
|
262
|
+
try! pngData.write(to: URL(fileURLWithPath: outputPath))
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
`;
|
|
266
|
+
}
|
|
267
|
+
function generateXcodegenConfig(appInfo, testSourcesDir) {
|
|
268
|
+
return `name: ${UITEST_TARGET}
|
|
269
|
+
targets:
|
|
270
|
+
${UITEST_TARGET}:
|
|
271
|
+
type: bundle.ui-testing
|
|
272
|
+
platform: iOS
|
|
273
|
+
deploymentTarget: "${appInfo.deploymentTarget}"
|
|
274
|
+
sources:
|
|
275
|
+
- path: ${testSourcesDir}
|
|
276
|
+
dependencies:
|
|
277
|
+
- target: ${appInfo.schemeName}
|
|
278
|
+
embed: false
|
|
279
|
+
settings:
|
|
280
|
+
base:
|
|
281
|
+
TEST_TARGET_NAME: ${appInfo.schemeName}
|
|
282
|
+
PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
|
|
283
|
+
GENERATE_INFOPLIST_FILE: YES
|
|
284
|
+
`;
|
|
285
|
+
}
|
|
286
|
+
async function runXCUITest(iosDir, appInfo, simulatorUdid, navSteps, waitMs, screenshotOutputPath) {
|
|
287
|
+
const uitestDir = join(iosDir, UITEST_DIR);
|
|
288
|
+
const sourcesDir = join(uitestDir, "Sources");
|
|
289
|
+
mkdirSync(sourcesDir, { recursive: true });
|
|
290
|
+
// Generate the UI test Swift file
|
|
291
|
+
const testSwift = generateUITestSwift(appInfo.bundleId, navSteps, waitMs, screenshotOutputPath);
|
|
292
|
+
writeFileSync(join(sourcesDir, "ScreenshotUITest.swift"), testSwift);
|
|
293
|
+
// For xcodegen projects: add UI test target to project.yml temporarily
|
|
294
|
+
// For non-xcodegen projects: generate a standalone XCUITest xcode project in .screenshot-uitest/
|
|
295
|
+
const projectYmlPath = join(iosDir, "project.yml");
|
|
296
|
+
let originalProjectYml = null;
|
|
297
|
+
let standaloneProjectDir = null;
|
|
298
|
+
if (appInfo.hasXcodegen) {
|
|
299
|
+
originalProjectYml = readFileSync(projectYmlPath, "utf-8");
|
|
300
|
+
let modifiedYml = ensureInfoPlistFlag(originalProjectYml);
|
|
301
|
+
modifiedYml = insertUITestTarget(modifiedYml, generateUITestTargetYml(appInfo, `${UITEST_DIR}/Sources`));
|
|
302
|
+
writeFileSync(projectYmlPath, modifiedYml);
|
|
303
|
+
try {
|
|
304
|
+
await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
writeFileSync(projectYmlPath, originalProjectYml);
|
|
308
|
+
throw new Error(`xcodegen failed: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
// Non-xcodegen project: create a standalone XCUITest project in .screenshot-uitest/
|
|
313
|
+
standaloneProjectDir = uitestDir;
|
|
314
|
+
const standaloneYml = `name: ${UITEST_TARGET}
|
|
315
|
+
targets:
|
|
316
|
+
${UITEST_TARGET}:
|
|
317
|
+
type: bundle.ui-testing
|
|
318
|
+
platform: iOS
|
|
319
|
+
deploymentTarget: "${appInfo.deploymentTarget}"
|
|
320
|
+
sources:
|
|
321
|
+
- path: Sources
|
|
322
|
+
settings:
|
|
323
|
+
base:
|
|
324
|
+
TEST_TARGET_NAME: ${appInfo.schemeName}
|
|
325
|
+
PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
|
|
326
|
+
GENERATE_INFOPLIST_FILE: YES
|
|
327
|
+
`;
|
|
328
|
+
writeFileSync(join(uitestDir, "project.yml"), standaloneYml);
|
|
329
|
+
try {
|
|
330
|
+
await exec(`xcodegen generate`, { cwd: uitestDir, timeout: 30_000 });
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
throw new Error(`xcodegen failed for standalone UI test project: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const buildDir = join(iosDir, ".build", "screenshot");
|
|
337
|
+
let projectFlag;
|
|
338
|
+
let testCwd;
|
|
339
|
+
if (standaloneProjectDir) {
|
|
340
|
+
// Use the standalone project generated in .screenshot-uitest/
|
|
341
|
+
projectFlag = `-project "${join(standaloneProjectDir, `${UITEST_TARGET}.xcodeproj`)}"`;
|
|
342
|
+
testCwd = standaloneProjectDir;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
projectFlag = appInfo.xcodeproj
|
|
346
|
+
? `-project "${join(iosDir, appInfo.xcodeproj)}"`
|
|
347
|
+
: "";
|
|
348
|
+
testCwd = iosDir;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
await exec(`xcodebuild test ` +
|
|
352
|
+
`${projectFlag} ` +
|
|
353
|
+
`-scheme "${UITEST_TARGET}" ` +
|
|
354
|
+
`-destination "id=${simulatorUdid}" ` +
|
|
355
|
+
`-derivedDataPath "${buildDir}" ` +
|
|
356
|
+
`-only-testing:${UITEST_TARGET}/ScreenshotUITest/testNavigateAndScreenshot ` +
|
|
357
|
+
`2>&1`, { cwd: testCwd, timeout: 300_000 });
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).slice(-500);
|
|
361
|
+
// The test might "fail" but still produce the screenshot
|
|
362
|
+
if (!existsSync(screenshotOutputPath)) {
|
|
363
|
+
throw new Error(`XCUITest failed:\n${output}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
// Restore original project.yml for xcodegen projects
|
|
368
|
+
if (originalProjectYml) {
|
|
369
|
+
writeFileSync(projectYmlPath, originalProjectYml);
|
|
370
|
+
// Regenerate to restore original xcodeproj
|
|
371
|
+
try {
|
|
372
|
+
await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
|
|
373
|
+
}
|
|
374
|
+
catch { /* best effort */ }
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// ── screenshot capture (simple, no nav) ─────────────────────────────
|
|
379
|
+
export async function captureScreenshot(udid, localPath) {
|
|
380
|
+
await exec(`xcrun simctl io ${udid} screenshot "${localPath}"`, { timeout: 15_000 });
|
|
381
|
+
}
|
|
382
|
+
// ── wait for app ready ──────────────────────────────────────────────
|
|
383
|
+
async function waitForAppReady(udid, bundleId, waitMs) {
|
|
384
|
+
const startTime = Date.now();
|
|
385
|
+
const timeout = Math.min(waitMs, 15_000);
|
|
386
|
+
while (Date.now() - startTime < timeout) {
|
|
387
|
+
try {
|
|
388
|
+
const { stdout } = await exec(`xcrun simctl spawn ${udid} launchctl list`);
|
|
389
|
+
if (stdout.includes(bundleId)) {
|
|
390
|
+
const elapsed = Date.now() - startTime;
|
|
391
|
+
const remaining = Math.max(waitMs - elapsed, 500);
|
|
392
|
+
await new Promise(r => setTimeout(r, remaining));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch { /* not ready yet */ }
|
|
397
|
+
await new Promise(r => setTimeout(r, 500));
|
|
398
|
+
}
|
|
399
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
400
|
+
}
|
|
401
|
+
// ── main entry point ────────────────────────────────────────────────
|
|
402
|
+
export async function takeIOSScreenshot(projectCwd, options) {
|
|
403
|
+
return withIOSScreenshotLock(async () => {
|
|
404
|
+
const { screen, device, nav, theme, wait_for = 3000, output_dir, project_dir, scheme, bundle_id, } = options;
|
|
405
|
+
// 1. Find iOS project
|
|
406
|
+
const iosDir = findIOSAppDir(projectCwd, project_dir);
|
|
407
|
+
const appInfo = extractAppInfo(iosDir, { scheme, bundle_id });
|
|
408
|
+
// 2. Find and boot simulator
|
|
409
|
+
const sim = findSimulator(device);
|
|
410
|
+
await ensureSimulatorBooted(sim.udid);
|
|
411
|
+
// 3. Set theme if requested
|
|
412
|
+
if (theme) {
|
|
413
|
+
await setAppearance(sim.udid, theme);
|
|
414
|
+
}
|
|
415
|
+
// 4. Capture screenshot
|
|
416
|
+
const screenLabel = screen ?? "main";
|
|
417
|
+
const themeLabel = theme ?? "default";
|
|
418
|
+
const filename = `${screenLabel}_${themeLabel}.png`;
|
|
419
|
+
const tmpPath = join(iosDir, ".openuispec-screenshot.png");
|
|
420
|
+
if (nav && nav.length > 0) {
|
|
421
|
+
// Use XCUITest for navigation + screenshot (builds both targets via xcodebuild test)
|
|
422
|
+
await runXCUITest(iosDir, appInfo, sim.udid, nav, wait_for, tmpPath);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
// Simple: build, install, launch, wait, screencap
|
|
426
|
+
const appBundlePath = await buildApp(iosDir, appInfo, sim.udid);
|
|
427
|
+
await installAndLaunch(sim.udid, appBundlePath, appInfo.bundleId);
|
|
428
|
+
await waitForAppReady(sim.udid, appInfo.bundleId, wait_for);
|
|
429
|
+
await captureScreenshot(sim.udid, tmpPath);
|
|
430
|
+
}
|
|
431
|
+
if (!existsSync(tmpPath)) {
|
|
432
|
+
return {
|
|
433
|
+
content: [{ type: "text", text: "No screenshot was captured. Check Xcode and Simulator output." }],
|
|
434
|
+
isError: true,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
// 6. Save to output_dir if specified
|
|
438
|
+
let savedPath;
|
|
439
|
+
if (output_dir) {
|
|
440
|
+
const outDir = resolve(iosDir, output_dir);
|
|
441
|
+
mkdirSync(outDir, { recursive: true });
|
|
442
|
+
savedPath = join(outDir, filename);
|
|
443
|
+
copyFileSync(tmpPath, savedPath);
|
|
444
|
+
}
|
|
445
|
+
// 7. Read and return
|
|
446
|
+
try {
|
|
447
|
+
const data = readFileSync(tmpPath).toString("base64");
|
|
448
|
+
const snapshots = [{
|
|
449
|
+
screen: screenLabel,
|
|
450
|
+
path: savedPath ?? filename,
|
|
451
|
+
data,
|
|
452
|
+
}];
|
|
453
|
+
return buildScreenshotResponse(snapshots, (s) => ({
|
|
454
|
+
screen: s.screen,
|
|
455
|
+
path: savedPath ?? null,
|
|
456
|
+
simulator: sim.name,
|
|
457
|
+
theme: themeLabel,
|
|
458
|
+
bundleId: appInfo.bundleId,
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
try {
|
|
463
|
+
unlinkSync(tmpPath);
|
|
464
|
+
}
|
|
465
|
+
catch { /* ignore */ }
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
// ── batch screenshot ─────────────────────────────────────────────────
|
|
470
|
+
export async function takeIOSScreenshotBatch(projectCwd, options) {
|
|
471
|
+
return withIOSScreenshotLock(async () => {
|
|
472
|
+
const { captures, device, theme, output_dir, project_dir, scheme, bundle_id } = options;
|
|
473
|
+
if (captures.length === 0) {
|
|
474
|
+
return { content: [{ type: "text", text: "No iOS captures specified." }], isError: true };
|
|
475
|
+
}
|
|
476
|
+
const iosDir = findIOSAppDir(projectCwd, project_dir);
|
|
477
|
+
const appInfo = extractAppInfo(iosDir, { scheme, bundle_id });
|
|
478
|
+
const sim = findSimulator(device);
|
|
479
|
+
await ensureSimulatorBooted(sim.udid);
|
|
480
|
+
if (theme) {
|
|
481
|
+
await setAppearance(sim.udid, theme);
|
|
482
|
+
}
|
|
483
|
+
const themeLabel = theme ?? "default";
|
|
484
|
+
const snapshots = [];
|
|
485
|
+
// Separate captures: no-nav (simctl screenshot) vs nav (XCUITest batch)
|
|
486
|
+
const noNavCaptures = captures.filter((c) => !c.nav || c.nav.length === 0);
|
|
487
|
+
const navCaptures = captures.filter((c) => c.nav && c.nav.length > 0);
|
|
488
|
+
// Build + install once for all captures
|
|
489
|
+
const appBundlePath = await buildApp(iosDir, appInfo, sim.udid);
|
|
490
|
+
await installAndLaunch(sim.udid, appBundlePath, appInfo.bundleId);
|
|
491
|
+
// Pre-create output dir once
|
|
492
|
+
if (output_dir)
|
|
493
|
+
mkdirSync(resolve(iosDir, output_dir), { recursive: true });
|
|
494
|
+
// No-nav captures: relaunch, wait, simctl screenshot
|
|
495
|
+
for (const capture of noNavCaptures) {
|
|
496
|
+
// Relaunch without reinstalling
|
|
497
|
+
try {
|
|
498
|
+
await exec(`xcrun simctl terminate ${sim.udid} ${appInfo.bundleId}`);
|
|
499
|
+
}
|
|
500
|
+
catch { /* not running */ }
|
|
501
|
+
await exec(`xcrun simctl launch ${sim.udid} ${appInfo.bundleId}`, { timeout: 30_000 });
|
|
502
|
+
await waitForAppReady(sim.udid, appInfo.bundleId, capture.wait_for ?? 3000);
|
|
503
|
+
const filename = `${capture.screen}_${themeLabel}.png`;
|
|
504
|
+
const tmpPath = join(iosDir, `.openuispec-screenshot-${capture.screen}.png`);
|
|
505
|
+
await captureScreenshot(sim.udid, tmpPath);
|
|
506
|
+
if (!existsSync(tmpPath))
|
|
507
|
+
continue;
|
|
508
|
+
let savedPath = filename;
|
|
509
|
+
if (output_dir) {
|
|
510
|
+
savedPath = join(resolve(iosDir, output_dir), filename);
|
|
511
|
+
copyFileSync(tmpPath, savedPath);
|
|
512
|
+
}
|
|
513
|
+
snapshots.push({ screen: capture.screen, path: savedPath, data: readFileSync(tmpPath).toString("base64") });
|
|
514
|
+
try {
|
|
515
|
+
unlinkSync(tmpPath);
|
|
516
|
+
}
|
|
517
|
+
catch { /* ignore */ }
|
|
518
|
+
}
|
|
519
|
+
// Nav captures: batch into a single XCUITest run
|
|
520
|
+
if (navCaptures.length > 0) {
|
|
521
|
+
const uitestDir = join(iosDir, ".screenshot-uitest");
|
|
522
|
+
const sourcesDir = join(uitestDir, "Sources");
|
|
523
|
+
mkdirSync(sourcesDir, { recursive: true });
|
|
524
|
+
// Build output paths map
|
|
525
|
+
const outputPaths = {};
|
|
526
|
+
for (const capture of navCaptures) {
|
|
527
|
+
const filename = `${capture.screen}_${themeLabel}.png`;
|
|
528
|
+
if (output_dir) {
|
|
529
|
+
const outDir = resolve(iosDir, output_dir);
|
|
530
|
+
mkdirSync(outDir, { recursive: true });
|
|
531
|
+
outputPaths[capture.screen] = join(outDir, filename);
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
outputPaths[capture.screen] = join(iosDir, `.openuispec-screenshot-${capture.screen}.png`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Generate multi-test Swift file
|
|
538
|
+
const testCases = navCaptures.map((capture, i) => {
|
|
539
|
+
const taps = (capture.nav ?? []).map((step, j) => {
|
|
540
|
+
const escaped = step.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
541
|
+
return `
|
|
542
|
+
let target_${i}_${j} = app.descendants(matching: .any).matching(NSPredicate(format: "label ==[c] %@ OR title ==[c] %@", "${escaped}", "${escaped}")).firstMatch
|
|
543
|
+
if target_${i}_${j}.waitForExistence(timeout: 5) {
|
|
544
|
+
target_${i}_${j}.tap()
|
|
545
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
546
|
+
}`;
|
|
547
|
+
}).join("\n");
|
|
548
|
+
const outPath = outputPaths[capture.screen].replace(/"/g, '\\"');
|
|
549
|
+
return `
|
|
550
|
+
func test_${String(i + 1).padStart(2, "0")}_${capture.screen}() {
|
|
551
|
+
let app = XCUIApplication()
|
|
552
|
+
app.launchArguments = ["-AppleLanguages", "(en)"]
|
|
553
|
+
app.launch()
|
|
554
|
+
Thread.sleep(forTimeInterval: ${((capture.wait_for ?? 3000) / 1000).toFixed(1)})
|
|
555
|
+
${taps}
|
|
556
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
557
|
+
let screenshot = XCUIScreen.main.screenshot()
|
|
558
|
+
try! screenshot.pngRepresentation.write(to: URL(fileURLWithPath: "${outPath}"))
|
|
559
|
+
}`;
|
|
560
|
+
}).join("\n");
|
|
561
|
+
writeFileSync(join(sourcesDir, "ScreenshotUITest.swift"), `import XCTest\n\nfinal class ScreenshotUITest: XCTestCase {\n${testCases}\n}\n`);
|
|
562
|
+
// Set up xcodegen
|
|
563
|
+
const UITEST_TARGET = "ScreenshotUITests";
|
|
564
|
+
const hasXcodegen = existsSync(join(iosDir, "project.yml"));
|
|
565
|
+
const projectYmlPath = join(iosDir, "project.yml");
|
|
566
|
+
let originalProjectYml = null;
|
|
567
|
+
const buildDir = join(iosDir, ".build", "screenshot");
|
|
568
|
+
if (hasXcodegen) {
|
|
569
|
+
originalProjectYml = readFileSync(projectYmlPath, "utf-8");
|
|
570
|
+
let modifiedYml = ensureInfoPlistFlag(originalProjectYml);
|
|
571
|
+
modifiedYml = insertUITestTarget(modifiedYml, generateUITestTargetYml(appInfo, ".screenshot-uitest/Sources", true));
|
|
572
|
+
writeFileSync(projectYmlPath, modifiedYml);
|
|
573
|
+
await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
writeFileSync(join(uitestDir, "project.yml"), `name: ${UITEST_TARGET}
|
|
577
|
+
targets:
|
|
578
|
+
${UITEST_TARGET}:
|
|
579
|
+
type: bundle.ui-testing
|
|
580
|
+
platform: iOS
|
|
581
|
+
deploymentTarget: "${appInfo.deploymentTarget}"
|
|
582
|
+
sources:
|
|
583
|
+
- path: Sources
|
|
584
|
+
settings:
|
|
585
|
+
base:
|
|
586
|
+
TEST_TARGET_NAME: ${appInfo.schemeName}
|
|
587
|
+
PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
|
|
588
|
+
GENERATE_INFOPLIST_FILE: YES
|
|
589
|
+
`);
|
|
590
|
+
await exec(`xcodegen generate`, { cwd: uitestDir, timeout: 30_000 });
|
|
591
|
+
}
|
|
592
|
+
const testProjectFlag = hasXcodegen
|
|
593
|
+
? (appInfo.xcodeproj ? `-project "${join(iosDir, appInfo.xcodeproj)}"` : "")
|
|
594
|
+
: `-project "${join(uitestDir, `${UITEST_TARGET}.xcodeproj`)}"`;
|
|
595
|
+
const testCwd = hasXcodegen ? iosDir : uitestDir;
|
|
596
|
+
try {
|
|
597
|
+
await exec(`xcodebuild test ${testProjectFlag} -scheme "${UITEST_TARGET}" -destination "id=${sim.udid}" -derivedDataPath "${buildDir}" -only-testing:${UITEST_TARGET}/ScreenshotUITest 2>&1`, { cwd: testCwd, timeout: 300_000 });
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// Tests may "fail" but still produce screenshots
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
if (originalProjectYml) {
|
|
604
|
+
writeFileSync(projectYmlPath, originalProjectYml);
|
|
605
|
+
try {
|
|
606
|
+
await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
|
|
607
|
+
}
|
|
608
|
+
catch { /* best effort */ }
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Collect results
|
|
612
|
+
for (const capture of navCaptures) {
|
|
613
|
+
const outPath = outputPaths[capture.screen];
|
|
614
|
+
if (existsSync(outPath)) {
|
|
615
|
+
snapshots.push({
|
|
616
|
+
screen: capture.screen,
|
|
617
|
+
path: output_dir ? outPath : `${capture.screen}_${themeLabel}.png`,
|
|
618
|
+
data: readFileSync(outPath).toString("base64"),
|
|
619
|
+
});
|
|
620
|
+
if (!output_dir) {
|
|
621
|
+
try {
|
|
622
|
+
unlinkSync(outPath);
|
|
623
|
+
}
|
|
624
|
+
catch { /* ignore */ }
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (snapshots.length === 0) {
|
|
630
|
+
return { content: [{ type: "text", text: "No screenshots were captured. Check Xcode and Simulator output." }], isError: true };
|
|
631
|
+
}
|
|
632
|
+
const content = [];
|
|
633
|
+
for (const s of snapshots) {
|
|
634
|
+
content.push({ type: "image", data: s.data, mimeType: "image/png" });
|
|
635
|
+
content.push({ type: "text", text: JSON.stringify({ screen: s.screen, path: s.path, simulator: sim.name, theme: themeLabel, bundleId: appInfo.bundleId }, null, 2) });
|
|
636
|
+
}
|
|
637
|
+
return { content };
|
|
638
|
+
});
|
|
639
|
+
}
|