snapdrive-ios 0.1.0
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/LICENSE +21 -0
- package/README.ja.md +95 -0
- package/README.md +95 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +265 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/command-executor.d.ts +15 -0
- package/dist/core/command-executor.d.ts.map +1 -0
- package/dist/core/command-executor.js +64 -0
- package/dist/core/command-executor.js.map +1 -0
- package/dist/core/element-finder.d.ts +81 -0
- package/dist/core/element-finder.d.ts.map +1 -0
- package/dist/core/element-finder.js +246 -0
- package/dist/core/element-finder.js.map +1 -0
- package/dist/core/idb-client.d.ts +68 -0
- package/dist/core/idb-client.d.ts.map +1 -0
- package/dist/core/idb-client.js +327 -0
- package/dist/core/idb-client.js.map +1 -0
- package/dist/core/image-differ.d.ts +55 -0
- package/dist/core/image-differ.d.ts.map +1 -0
- package/dist/core/image-differ.js +211 -0
- package/dist/core/image-differ.js.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +9 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/report-generator.d.ts +31 -0
- package/dist/core/report-generator.d.ts.map +1 -0
- package/dist/core/report-generator.js +675 -0
- package/dist/core/report-generator.js.map +1 -0
- package/dist/core/scenario-runner.d.ts +54 -0
- package/dist/core/scenario-runner.d.ts.map +1 -0
- package/dist/core/scenario-runner.js +701 -0
- package/dist/core/scenario-runner.js.map +1 -0
- package/dist/core/simctl-client.d.ts +64 -0
- package/dist/core/simctl-client.d.ts.map +1 -0
- package/dist/core/simctl-client.js +214 -0
- package/dist/core/simctl-client.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/config.interface.d.ts +37 -0
- package/dist/interfaces/config.interface.d.ts.map +1 -0
- package/dist/interfaces/config.interface.js +14 -0
- package/dist/interfaces/config.interface.js.map +1 -0
- package/dist/interfaces/element.interface.d.ts +49 -0
- package/dist/interfaces/element.interface.d.ts.map +1 -0
- package/dist/interfaces/element.interface.js +5 -0
- package/dist/interfaces/element.interface.js.map +1 -0
- package/dist/interfaces/index.d.ts +7 -0
- package/dist/interfaces/index.d.ts.map +1 -0
- package/dist/interfaces/index.js +7 -0
- package/dist/interfaces/index.js.map +1 -0
- package/dist/interfaces/scenario.interface.d.ts +101 -0
- package/dist/interfaces/scenario.interface.d.ts.map +1 -0
- package/dist/interfaces/scenario.interface.js +5 -0
- package/dist/interfaces/scenario.interface.js.map +1 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +943 -0
- package/dist/server.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +24 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +50 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario runner - executes test scenarios step by step
|
|
3
|
+
*/
|
|
4
|
+
import { readFile, readdir, mkdir, unlink } from 'node:fs/promises';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { parse as parseYaml } from 'yaml';
|
|
8
|
+
// Scroll safety constants to prevent modal dismissal
|
|
9
|
+
const SCROLL_SAFE_MARGIN = 100; // Margin from screen edges (px)
|
|
10
|
+
const SCROLL_DISTANCE_DETECT = 100; // Short distance for scroll detection
|
|
11
|
+
const SCROLL_DISTANCE_CAPTURE = 200; // Distance for full page capture scrolling
|
|
12
|
+
const DEFAULT_SCREEN_HEIGHT = 800; // Default screen height for calculations
|
|
13
|
+
const SCROLL_SETTLE_WAIT_MS = 2000; // Wait time after drag scroll before taking screenshot
|
|
14
|
+
const DRAG_DURATION = 1.0; // Duration for drag scroll (longer = no inertia)
|
|
15
|
+
const EDGE_TAP_X = 5; // X position for edge tap to stop inertial scrolling (left edge, no buttons)
|
|
16
|
+
export class ScenarioRunner {
|
|
17
|
+
deps;
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.deps = deps;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Load and parse a scenario YAML file
|
|
23
|
+
*/
|
|
24
|
+
async loadScenario(scenarioPath) {
|
|
25
|
+
if (!existsSync(scenarioPath)) {
|
|
26
|
+
throw new Error(`Scenario file not found: ${scenarioPath}`);
|
|
27
|
+
}
|
|
28
|
+
const content = await readFile(scenarioPath, 'utf-8');
|
|
29
|
+
const parsed = parseYaml(content);
|
|
30
|
+
if (!parsed.name || !Array.isArray(parsed.steps)) {
|
|
31
|
+
throw new Error(`Invalid scenario format: ${scenarioPath}`);
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Load a test case from a directory
|
|
37
|
+
*/
|
|
38
|
+
async loadTestCase(testCasePath) {
|
|
39
|
+
const scenarioPath = join(testCasePath, 'scenario.yaml');
|
|
40
|
+
const scenario = await this.loadScenario(scenarioPath);
|
|
41
|
+
const id = testCasePath.split('/').pop() ?? 'unknown';
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
path: testCasePath,
|
|
45
|
+
scenario,
|
|
46
|
+
baselinesDir: join(testCasePath, 'baselines'),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* List all test cases in a .snapdrive directory
|
|
51
|
+
*/
|
|
52
|
+
async listTestCases(snapdriveDir) {
|
|
53
|
+
const testCasesDir = join(snapdriveDir, 'test-cases');
|
|
54
|
+
if (!existsSync(testCasesDir)) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const entries = await readdir(testCasesDir, { withFileTypes: true });
|
|
58
|
+
const testCases = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
const testCasePath = join(testCasesDir, entry.name);
|
|
62
|
+
const scenarioPath = join(testCasePath, 'scenario.yaml');
|
|
63
|
+
if (existsSync(scenarioPath)) {
|
|
64
|
+
try {
|
|
65
|
+
const testCase = await this.loadTestCase(testCasePath);
|
|
66
|
+
testCases.push(testCase);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
this.deps.logger.warn(`Failed to load test case: ${testCasePath}`, {
|
|
70
|
+
error: String(error),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return testCases;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Run a single test case
|
|
80
|
+
*/
|
|
81
|
+
async runTestCase(testCase, options) {
|
|
82
|
+
const startTime = new Date();
|
|
83
|
+
const steps = [];
|
|
84
|
+
const checkpoints = [];
|
|
85
|
+
let success = true;
|
|
86
|
+
// Ensure results directories exist
|
|
87
|
+
const screenshotsDir = join(options.resultsDir, 'screenshots', testCase.id);
|
|
88
|
+
const diffsDir = join(options.resultsDir, 'diffs', testCase.id);
|
|
89
|
+
await mkdir(screenshotsDir, { recursive: true });
|
|
90
|
+
await mkdir(diffsDir, { recursive: true });
|
|
91
|
+
// Ensure baselines directory exists
|
|
92
|
+
await mkdir(testCase.baselinesDir, { recursive: true });
|
|
93
|
+
const deviceUdid = options.deviceUdid ?? testCase.scenario.deviceUdid;
|
|
94
|
+
this.deps.logger.info(`Running test case: ${testCase.scenario.name}`);
|
|
95
|
+
for (let i = 0; i < testCase.scenario.steps.length; i++) {
|
|
96
|
+
const step = testCase.scenario.steps[i];
|
|
97
|
+
const stepStartTime = Date.now();
|
|
98
|
+
try {
|
|
99
|
+
const checkpoint = await this.executeStep(step, {
|
|
100
|
+
deviceUdid,
|
|
101
|
+
baselinesDir: testCase.baselinesDir,
|
|
102
|
+
screenshotsDir,
|
|
103
|
+
diffsDir,
|
|
104
|
+
updateBaselines: options.updateBaselines ?? false,
|
|
105
|
+
});
|
|
106
|
+
const stepResult = {
|
|
107
|
+
stepIndex: i,
|
|
108
|
+
action: step.action,
|
|
109
|
+
success: true,
|
|
110
|
+
duration: Date.now() - stepStartTime,
|
|
111
|
+
};
|
|
112
|
+
if (checkpoint) {
|
|
113
|
+
stepResult.checkpoint = checkpoint;
|
|
114
|
+
checkpoints.push(checkpoint);
|
|
115
|
+
if (!checkpoint.match && !options.updateBaselines) {
|
|
116
|
+
stepResult.success = false;
|
|
117
|
+
success = false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
steps.push(stepResult);
|
|
121
|
+
this.deps.logger.debug(`Step ${i + 1}/${testCase.scenario.steps.length}: ${step.action} - OK`);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
steps.push({
|
|
125
|
+
stepIndex: i,
|
|
126
|
+
action: step.action,
|
|
127
|
+
success: false,
|
|
128
|
+
error: String(error),
|
|
129
|
+
duration: Date.now() - stepStartTime,
|
|
130
|
+
});
|
|
131
|
+
success = false;
|
|
132
|
+
this.deps.logger.error(`Step ${i + 1} failed: ${step.action}`, { error: String(error) });
|
|
133
|
+
// Continue with remaining steps or break on error
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const endTime = new Date();
|
|
138
|
+
return {
|
|
139
|
+
testCaseId: testCase.id,
|
|
140
|
+
testCaseName: testCase.scenario.name,
|
|
141
|
+
startTime: startTime.toISOString(),
|
|
142
|
+
endTime: endTime.toISOString(),
|
|
143
|
+
durationMs: endTime.getTime() - startTime.getTime(),
|
|
144
|
+
success,
|
|
145
|
+
steps,
|
|
146
|
+
checkpoints,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Execute a single step
|
|
151
|
+
*/
|
|
152
|
+
async executeStep(step, context) {
|
|
153
|
+
const { idbClient, simctlClient, elementFinder, imageDiffer } = this.deps;
|
|
154
|
+
switch (step.action) {
|
|
155
|
+
case 'launch_app': {
|
|
156
|
+
if (!step.bundleId)
|
|
157
|
+
throw new Error('launch_app requires bundleId');
|
|
158
|
+
await simctlClient.launchApp(step.bundleId, { terminateExisting: true }, context.deviceUdid);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'terminate_app': {
|
|
162
|
+
if (!step.bundleId)
|
|
163
|
+
throw new Error('terminate_app requires bundleId');
|
|
164
|
+
await simctlClient.terminateApp(step.bundleId, context.deviceUdid);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case 'tap': {
|
|
168
|
+
if (step.x === undefined || step.y === undefined) {
|
|
169
|
+
throw new Error('tap requires x/y coordinates');
|
|
170
|
+
}
|
|
171
|
+
await idbClient.tap(step.x, step.y, { duration: step.duration, deviceUdid: context.deviceUdid });
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'swipe': {
|
|
175
|
+
let sX, sY, eX, eY;
|
|
176
|
+
if (step.direction) {
|
|
177
|
+
const centerX = 200;
|
|
178
|
+
const centerY = 400;
|
|
179
|
+
const distance = step.distance ?? 300;
|
|
180
|
+
switch (step.direction) {
|
|
181
|
+
case 'up':
|
|
182
|
+
sX = eX = centerX;
|
|
183
|
+
sY = centerY + distance / 2;
|
|
184
|
+
eY = centerY - distance / 2;
|
|
185
|
+
break;
|
|
186
|
+
case 'down':
|
|
187
|
+
sX = eX = centerX;
|
|
188
|
+
sY = centerY - distance / 2;
|
|
189
|
+
eY = centerY + distance / 2;
|
|
190
|
+
break;
|
|
191
|
+
case 'left':
|
|
192
|
+
sY = eY = centerY;
|
|
193
|
+
sX = centerX + distance / 2;
|
|
194
|
+
eX = centerX - distance / 2;
|
|
195
|
+
break;
|
|
196
|
+
case 'right':
|
|
197
|
+
sY = eY = centerY;
|
|
198
|
+
sX = centerX - distance / 2;
|
|
199
|
+
eX = centerX + distance / 2;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (step.startX !== undefined &&
|
|
204
|
+
step.startY !== undefined &&
|
|
205
|
+
step.endX !== undefined &&
|
|
206
|
+
step.endY !== undefined) {
|
|
207
|
+
sX = step.startX;
|
|
208
|
+
sY = step.startY;
|
|
209
|
+
eX = step.endX;
|
|
210
|
+
eY = step.endY;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
throw new Error('swipe requires direction or start/end coordinates');
|
|
214
|
+
}
|
|
215
|
+
await idbClient.swipe(sX, sY, eX, eY, { deviceUdid: context.deviceUdid });
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case 'type_text': {
|
|
219
|
+
if (!step.text)
|
|
220
|
+
throw new Error('type_text requires text');
|
|
221
|
+
await idbClient.typeText(step.text, context.deviceUdid);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'wait': {
|
|
225
|
+
const seconds = step.seconds ?? 1;
|
|
226
|
+
await this.wait(seconds * 1000);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case 'scroll_to_top': {
|
|
230
|
+
// Scroll up until no change is detected (reached top)
|
|
231
|
+
const scrollDistance = step.scrollAmount ?? SCROLL_DISTANCE_CAPTURE;
|
|
232
|
+
const maxScrolls = step.maxScrolls ?? 20;
|
|
233
|
+
// Get scroll region
|
|
234
|
+
let centerX = step.startX;
|
|
235
|
+
let centerY = step.startY;
|
|
236
|
+
if (centerX === undefined || centerY === undefined) {
|
|
237
|
+
const uiTree = await idbClient.describeAll(context.deviceUdid);
|
|
238
|
+
const scrollRegion = elementFinder.findScrollRegion(uiTree.elements);
|
|
239
|
+
centerX = scrollRegion?.centerX ?? 200;
|
|
240
|
+
centerY = scrollRegion?.centerY ?? 400;
|
|
241
|
+
}
|
|
242
|
+
// Safe Y range
|
|
243
|
+
const minSafeY = SCROLL_SAFE_MARGIN + scrollDistance / 2;
|
|
244
|
+
const maxSafeY = DEFAULT_SCREEN_HEIGHT - SCROLL_SAFE_MARGIN - scrollDistance / 2;
|
|
245
|
+
const safeCenterY = Math.max(minSafeY, Math.min(maxSafeY, centerY));
|
|
246
|
+
// Take initial screenshot
|
|
247
|
+
const tempPath = join(context.screenshotsDir, '_scroll_to_top_temp.png');
|
|
248
|
+
await simctlClient.screenshot(tempPath, context.deviceUdid);
|
|
249
|
+
let prevData = await imageDiffer.toBase64(tempPath);
|
|
250
|
+
for (let i = 0; i < maxScrolls; i++) {
|
|
251
|
+
// Scroll UP (finger drags from top to bottom)
|
|
252
|
+
await idbClient.swipe(centerX, safeCenterY - scrollDistance / 2, centerX, safeCenterY + scrollDistance / 2, { deviceUdid: context.deviceUdid, duration: DRAG_DURATION });
|
|
253
|
+
// Tap left edge to stop inertial scrolling
|
|
254
|
+
await idbClient.tap(EDGE_TAP_X, safeCenterY, { deviceUdid: context.deviceUdid });
|
|
255
|
+
await this.wait(SCROLL_SETTLE_WAIT_MS);
|
|
256
|
+
// Check if we've reached the top
|
|
257
|
+
await simctlClient.screenshot(tempPath, context.deviceUdid);
|
|
258
|
+
const currentData = await imageDiffer.toBase64(tempPath);
|
|
259
|
+
if (currentData === prevData) {
|
|
260
|
+
this.deps.logger.debug(`scroll_to_top: reached top after ${i} scroll(s)`);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
prevData = currentData;
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case 'scroll_to_bottom': {
|
|
268
|
+
// Scroll down until no change is detected (reached bottom)
|
|
269
|
+
const scrollDistance = step.scrollAmount ?? SCROLL_DISTANCE_CAPTURE;
|
|
270
|
+
const maxScrolls = step.maxScrolls ?? 20;
|
|
271
|
+
// Get scroll region
|
|
272
|
+
let centerX = step.startX;
|
|
273
|
+
let centerY = step.startY;
|
|
274
|
+
if (centerX === undefined || centerY === undefined) {
|
|
275
|
+
const uiTree = await idbClient.describeAll(context.deviceUdid);
|
|
276
|
+
const scrollRegion = elementFinder.findScrollRegion(uiTree.elements);
|
|
277
|
+
centerX = scrollRegion?.centerX ?? 200;
|
|
278
|
+
centerY = scrollRegion?.centerY ?? 400;
|
|
279
|
+
}
|
|
280
|
+
// Safe Y range
|
|
281
|
+
const minSafeY = SCROLL_SAFE_MARGIN + scrollDistance / 2;
|
|
282
|
+
const maxSafeY = DEFAULT_SCREEN_HEIGHT - SCROLL_SAFE_MARGIN - scrollDistance / 2;
|
|
283
|
+
const safeCenterY = Math.max(minSafeY, Math.min(maxSafeY, centerY));
|
|
284
|
+
// Take initial screenshot
|
|
285
|
+
const tempPath = join(context.screenshotsDir, '_scroll_to_bottom_temp.png');
|
|
286
|
+
await simctlClient.screenshot(tempPath, context.deviceUdid);
|
|
287
|
+
let prevData = await imageDiffer.toBase64(tempPath);
|
|
288
|
+
for (let i = 0; i < maxScrolls; i++) {
|
|
289
|
+
// Scroll DOWN (finger drags from bottom to top)
|
|
290
|
+
await idbClient.swipe(centerX, safeCenterY + scrollDistance / 2, centerX, safeCenterY - scrollDistance / 2, { deviceUdid: context.deviceUdid, duration: DRAG_DURATION });
|
|
291
|
+
// Tap left edge to stop inertial scrolling
|
|
292
|
+
await idbClient.tap(EDGE_TAP_X, safeCenterY, { deviceUdid: context.deviceUdid });
|
|
293
|
+
await this.wait(SCROLL_SETTLE_WAIT_MS);
|
|
294
|
+
// Check if we've reached the bottom
|
|
295
|
+
await simctlClient.screenshot(tempPath, context.deviceUdid);
|
|
296
|
+
const currentData = await imageDiffer.toBase64(tempPath);
|
|
297
|
+
if (currentData === prevData) {
|
|
298
|
+
this.deps.logger.debug(`scroll_to_bottom: reached bottom after ${i} scroll(s)`);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
prevData = currentData;
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case 'checkpoint': {
|
|
306
|
+
if (!step.name)
|
|
307
|
+
throw new Error('checkpoint requires name');
|
|
308
|
+
const actualPath = join(context.screenshotsDir, `${step.name}.png`);
|
|
309
|
+
const baselinePath = join(context.baselinesDir, `${step.name}.png`);
|
|
310
|
+
const diffPath = join(context.diffsDir, `${step.name}_diff.png`);
|
|
311
|
+
// Take screenshot
|
|
312
|
+
await simctlClient.screenshot(actualPath, context.deviceUdid);
|
|
313
|
+
// Update baseline if requested
|
|
314
|
+
if (context.updateBaselines) {
|
|
315
|
+
await imageDiffer.updateBaseline(actualPath, baselinePath);
|
|
316
|
+
return {
|
|
317
|
+
name: step.name,
|
|
318
|
+
match: true,
|
|
319
|
+
differencePercent: 0,
|
|
320
|
+
baselinePath,
|
|
321
|
+
actualPath,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
// Compare with baseline
|
|
325
|
+
if (!existsSync(baselinePath)) {
|
|
326
|
+
return {
|
|
327
|
+
name: step.name,
|
|
328
|
+
match: false,
|
|
329
|
+
differencePercent: 100,
|
|
330
|
+
baselinePath,
|
|
331
|
+
actualPath,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const compareResult = await imageDiffer.compare(actualPath, baselinePath, {
|
|
335
|
+
tolerance: step.tolerance ?? 0,
|
|
336
|
+
generateDiff: true,
|
|
337
|
+
diffOutputPath: diffPath,
|
|
338
|
+
});
|
|
339
|
+
return {
|
|
340
|
+
name: step.name,
|
|
341
|
+
match: compareResult.match,
|
|
342
|
+
differencePercent: compareResult.differenceRatio * 100,
|
|
343
|
+
baselinePath,
|
|
344
|
+
actualPath,
|
|
345
|
+
diffPath: compareResult.diffImagePath,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
case 'full_page_checkpoint': {
|
|
349
|
+
if (!step.name)
|
|
350
|
+
throw new Error('full_page_checkpoint requires name');
|
|
351
|
+
const maxScrolls = step.maxScrolls ?? 50;
|
|
352
|
+
const stitchImages = step.stitchImages ?? true;
|
|
353
|
+
const tolerance = step.tolerance ?? 0;
|
|
354
|
+
// Get scroll region - use provided coordinates or detect from UI tree
|
|
355
|
+
let centerX = step.startX;
|
|
356
|
+
let centerY = step.startY;
|
|
357
|
+
if (centerX === undefined || centerY === undefined) {
|
|
358
|
+
const fpUiTree = await idbClient.describeAll(context.deviceUdid);
|
|
359
|
+
const scrollRegion = elementFinder.findScrollRegion(fpUiTree.elements);
|
|
360
|
+
centerX = scrollRegion?.centerX ?? 200;
|
|
361
|
+
centerY = scrollRegion?.centerY ?? 400;
|
|
362
|
+
this.deps.logger.info(`full_page_checkpoint: detected scroll region at (${centerX}, ${centerY})`);
|
|
363
|
+
}
|
|
364
|
+
// Capture screenshots while scrolling
|
|
365
|
+
const segmentPaths = [];
|
|
366
|
+
const scrollDistance = step.scrollAmount ?? SCROLL_DISTANCE_CAPTURE;
|
|
367
|
+
// Clamp centerY to safe range to avoid triggering modal dismiss gestures
|
|
368
|
+
const minSafeY = SCROLL_SAFE_MARGIN + scrollDistance / 2;
|
|
369
|
+
const maxSafeY = DEFAULT_SCREEN_HEIGHT - SCROLL_SAFE_MARGIN - scrollDistance / 2;
|
|
370
|
+
const safeCenterY = Math.max(minSafeY, Math.min(maxSafeY, centerY));
|
|
371
|
+
this.deps.logger.debug(`full_page_checkpoint: using safeCenterY=${safeCenterY} (original: ${centerY})`);
|
|
372
|
+
// Capture screenshots while scrolling DOWN only (finger drag from bottom to top)
|
|
373
|
+
// Pattern: screenshot first segment, then (scroll down → wait → screenshot) until bottom
|
|
374
|
+
let prevScreenshotData = '';
|
|
375
|
+
let scrollCount = 0;
|
|
376
|
+
// Capture first segment (current position, before any scrolling)
|
|
377
|
+
const firstSegmentPath = join(context.screenshotsDir, `${step.name}_segment_0.png`);
|
|
378
|
+
await simctlClient.screenshot(firstSegmentPath, context.deviceUdid);
|
|
379
|
+
segmentPaths.push(firstSegmentPath);
|
|
380
|
+
prevScreenshotData = await this.deps.imageDiffer.toBase64(firstSegmentPath);
|
|
381
|
+
scrollCount = 1;
|
|
382
|
+
// Scroll down → Wait → Screenshot → Correct loop until reaching bottom
|
|
383
|
+
while (scrollCount < maxScrolls) {
|
|
384
|
+
// Scroll DOWN (finger drags from bottom to top to reveal content below)
|
|
385
|
+
await idbClient.swipe(centerX, safeCenterY + scrollDistance / 2, // Start: lower position
|
|
386
|
+
centerX, safeCenterY - scrollDistance / 2, // End: upper position
|
|
387
|
+
{
|
|
388
|
+
deviceUdid: context.deviceUdid,
|
|
389
|
+
duration: DRAG_DURATION, // Slow drag = no inertia
|
|
390
|
+
});
|
|
391
|
+
// Tap left edge to stop inertial scrolling
|
|
392
|
+
await idbClient.tap(EDGE_TAP_X, safeCenterY, { deviceUdid: context.deviceUdid });
|
|
393
|
+
// Wait for content to settle
|
|
394
|
+
await this.wait(SCROLL_SETTLE_WAIT_MS);
|
|
395
|
+
// Screenshot
|
|
396
|
+
const segmentPath = join(context.screenshotsDir, `${step.name}_segment_${scrollCount}.png`);
|
|
397
|
+
await simctlClient.screenshot(segmentPath, context.deviceUdid);
|
|
398
|
+
// Check if we've reached the bottom (screenshot is same as previous)
|
|
399
|
+
const currentData = await this.deps.imageDiffer.toBase64(segmentPath);
|
|
400
|
+
if (currentData === prevScreenshotData) {
|
|
401
|
+
// Same as previous - discard duplicate and stop
|
|
402
|
+
await unlink(segmentPath).catch(() => { }); // Delete duplicate file
|
|
403
|
+
this.deps.logger.debug(`full_page_checkpoint: reached bottom at segment ${scrollCount}, discarded duplicate`);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
// Content is different - save this segment
|
|
407
|
+
segmentPaths.push(segmentPath);
|
|
408
|
+
prevScreenshotData = await this.deps.imageDiffer.toBase64(segmentPath);
|
|
409
|
+
scrollCount++;
|
|
410
|
+
}
|
|
411
|
+
this.deps.logger.info(`Captured ${segmentPaths.length} scroll segments for ${step.name}`);
|
|
412
|
+
const actualPath = join(context.screenshotsDir, `${step.name}.png`);
|
|
413
|
+
const baselinePath = join(context.baselinesDir, `${step.name}.png`);
|
|
414
|
+
const diffPath = join(context.diffsDir, `${step.name}_diff.png`);
|
|
415
|
+
if (stitchImages && segmentPaths.length > 0) {
|
|
416
|
+
// Stitch all segments into one image
|
|
417
|
+
await imageDiffer.stitchVertically(segmentPaths, actualPath);
|
|
418
|
+
if (context.updateBaselines) {
|
|
419
|
+
await imageDiffer.updateBaseline(actualPath, baselinePath);
|
|
420
|
+
return {
|
|
421
|
+
name: step.name,
|
|
422
|
+
match: true,
|
|
423
|
+
differencePercent: 0,
|
|
424
|
+
baselinePath,
|
|
425
|
+
actualPath,
|
|
426
|
+
isFullPage: true,
|
|
427
|
+
segmentPaths,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (!existsSync(baselinePath)) {
|
|
431
|
+
return {
|
|
432
|
+
name: step.name,
|
|
433
|
+
match: false,
|
|
434
|
+
differencePercent: 100,
|
|
435
|
+
baselinePath,
|
|
436
|
+
actualPath,
|
|
437
|
+
isFullPage: true,
|
|
438
|
+
segmentPaths,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const compareResult = await imageDiffer.compare(actualPath, baselinePath, {
|
|
442
|
+
tolerance,
|
|
443
|
+
generateDiff: true,
|
|
444
|
+
diffOutputPath: diffPath,
|
|
445
|
+
});
|
|
446
|
+
return {
|
|
447
|
+
name: step.name,
|
|
448
|
+
match: compareResult.match,
|
|
449
|
+
differencePercent: compareResult.differenceRatio * 100,
|
|
450
|
+
baselinePath,
|
|
451
|
+
actualPath,
|
|
452
|
+
diffPath: compareResult.diffImagePath,
|
|
453
|
+
isFullPage: true,
|
|
454
|
+
segmentPaths,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
// Compare each segment separately
|
|
459
|
+
let totalDiffPercent = 0;
|
|
460
|
+
let allMatch = true;
|
|
461
|
+
for (let i = 0; i < segmentPaths.length; i++) {
|
|
462
|
+
const segmentActual = segmentPaths[i];
|
|
463
|
+
const segmentBaseline = join(context.baselinesDir, `${step.name}_segment_${i}.png`);
|
|
464
|
+
const segmentDiff = join(context.diffsDir, `${step.name}_segment_${i}_diff.png`);
|
|
465
|
+
if (context.updateBaselines) {
|
|
466
|
+
await imageDiffer.updateBaseline(segmentActual, segmentBaseline);
|
|
467
|
+
}
|
|
468
|
+
else if (existsSync(segmentBaseline)) {
|
|
469
|
+
const result = await imageDiffer.compare(segmentActual, segmentBaseline, {
|
|
470
|
+
tolerance,
|
|
471
|
+
generateDiff: true,
|
|
472
|
+
diffOutputPath: segmentDiff,
|
|
473
|
+
});
|
|
474
|
+
if (!result.match)
|
|
475
|
+
allMatch = false;
|
|
476
|
+
totalDiffPercent += result.differenceRatio * 100;
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
allMatch = false;
|
|
480
|
+
totalDiffPercent += 100;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const avgDiffPercent = segmentPaths.length > 0 ? totalDiffPercent / segmentPaths.length : 0;
|
|
484
|
+
return {
|
|
485
|
+
name: step.name,
|
|
486
|
+
match: context.updateBaselines ? true : allMatch,
|
|
487
|
+
differencePercent: avgDiffPercent,
|
|
488
|
+
baselinePath: join(context.baselinesDir, `${step.name}_segment_0.png`),
|
|
489
|
+
actualPath: segmentPaths[0] ?? actualPath,
|
|
490
|
+
isFullPage: true,
|
|
491
|
+
segmentPaths,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
case 'smart_checkpoint': {
|
|
496
|
+
if (!step.name)
|
|
497
|
+
throw new Error('smart_checkpoint requires name');
|
|
498
|
+
// Get UI tree and find best scroll region
|
|
499
|
+
const uiTree = await idbClient.describeAll(context.deviceUdid);
|
|
500
|
+
const scrollRegion = elementFinder.findScrollRegion(uiTree.elements);
|
|
501
|
+
const centerX = scrollRegion?.centerX ?? 200;
|
|
502
|
+
const centerY = scrollRegion?.centerY ?? 400;
|
|
503
|
+
this.deps.logger.info(`smart_checkpoint: ${step.name} - scroll region at (${centerX}, ${centerY})`);
|
|
504
|
+
// Detect scrollable content by checking if scroll changes the screen
|
|
505
|
+
this.deps.logger.info(`smart_checkpoint: ${step.name} - checking scroll by screenshot diff`);
|
|
506
|
+
let hasScrollable = false;
|
|
507
|
+
const scrollDistance = SCROLL_DISTANCE_DETECT; // Short distance for detection
|
|
508
|
+
// Clamp centerY to safe range to avoid triggering modal dismiss gestures
|
|
509
|
+
const minSafeY = SCROLL_SAFE_MARGIN + scrollDistance / 2;
|
|
510
|
+
const maxSafeY = DEFAULT_SCREEN_HEIGHT - SCROLL_SAFE_MARGIN - scrollDistance / 2;
|
|
511
|
+
const safeCenterY = Math.max(minSafeY, Math.min(maxSafeY, centerY));
|
|
512
|
+
this.deps.logger.debug(`smart_checkpoint: using safeCenterY=${safeCenterY} (original: ${centerY})`);
|
|
513
|
+
// Take initial screenshot (baseline)
|
|
514
|
+
const tempBeforePath = join(context.screenshotsDir, `${step.name}_scroll_detect_before.png`);
|
|
515
|
+
await simctlClient.screenshot(tempBeforePath, context.deviceUdid);
|
|
516
|
+
const baselineData = await imageDiffer.toBase64(tempBeforePath);
|
|
517
|
+
// Try scrolling DOWN first (drag up to reveal content below)
|
|
518
|
+
await idbClient.swipe(centerX, safeCenterY + scrollDistance / 2, centerX, safeCenterY - scrollDistance / 2, {
|
|
519
|
+
deviceUdid: context.deviceUdid,
|
|
520
|
+
duration: DRAG_DURATION, // Slow drag = no inertia
|
|
521
|
+
});
|
|
522
|
+
// Tap left edge to stop inertial scrolling
|
|
523
|
+
await idbClient.tap(EDGE_TAP_X, safeCenterY, { deviceUdid: context.deviceUdid });
|
|
524
|
+
await this.wait(SCROLL_SETTLE_WAIT_MS);
|
|
525
|
+
const tempAfterDownPath = join(context.screenshotsDir, `${step.name}_scroll_detect_after_down.png`);
|
|
526
|
+
await simctlClient.screenshot(tempAfterDownPath, context.deviceUdid);
|
|
527
|
+
const afterDownData = await imageDiffer.toBase64(tempAfterDownPath);
|
|
528
|
+
if (afterDownData !== baselineData) {
|
|
529
|
+
hasScrollable = true;
|
|
530
|
+
this.deps.logger.info(`smart_checkpoint: ${step.name} - scroll DOWN detected content change`);
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
// Scrolling down didn't change content - we might be at the bottom
|
|
534
|
+
// Try scrolling UP (swipe down to reveal content above) without repeated attempts
|
|
535
|
+
this.deps.logger.debug(`smart_checkpoint: ${step.name} - scroll DOWN had no effect, trying UP`);
|
|
536
|
+
await idbClient.swipe(centerX, safeCenterY - scrollDistance / 2, centerX, safeCenterY + scrollDistance / 2, {
|
|
537
|
+
deviceUdid: context.deviceUdid,
|
|
538
|
+
duration: DRAG_DURATION, // Slow drag = no inertia
|
|
539
|
+
});
|
|
540
|
+
// Tap left edge to stop inertial scrolling
|
|
541
|
+
await idbClient.tap(EDGE_TAP_X, safeCenterY, { deviceUdid: context.deviceUdid });
|
|
542
|
+
await this.wait(SCROLL_SETTLE_WAIT_MS);
|
|
543
|
+
const tempAfterUpPath = join(context.screenshotsDir, `${step.name}_scroll_detect_after_up.png`);
|
|
544
|
+
await simctlClient.screenshot(tempAfterUpPath, context.deviceUdid);
|
|
545
|
+
const afterUpData = await imageDiffer.toBase64(tempAfterUpPath);
|
|
546
|
+
if (afterUpData !== afterDownData) {
|
|
547
|
+
hasScrollable = true;
|
|
548
|
+
this.deps.logger.info(`smart_checkpoint: ${step.name} - scroll UP detected content change`);
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
// Neither direction caused a change - no scrollable content
|
|
552
|
+
this.deps.logger.info(`smart_checkpoint: ${step.name} - no scrollable content detected`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
this.deps.logger.info(`smart_checkpoint: ${step.name} - scrollable content ${hasScrollable ? 'detected' : 'not found'}`);
|
|
556
|
+
if (hasScrollable) {
|
|
557
|
+
// Scroll back to top before full_page_checkpoint to ensure consistent starting position
|
|
558
|
+
this.deps.logger.debug(`smart_checkpoint: ${step.name} - scrolling to top before capture`);
|
|
559
|
+
const scrollToTopStep = {
|
|
560
|
+
action: 'scroll_to_top',
|
|
561
|
+
startX: centerX,
|
|
562
|
+
startY: centerY,
|
|
563
|
+
maxScrolls: 20,
|
|
564
|
+
};
|
|
565
|
+
await this.executeStep(scrollToTopStep, context);
|
|
566
|
+
// Use full_page_checkpoint logic with detected scroll region
|
|
567
|
+
const scrollStep = {
|
|
568
|
+
...step,
|
|
569
|
+
action: 'full_page_checkpoint',
|
|
570
|
+
maxScrolls: step.maxScrolls ?? 50,
|
|
571
|
+
stitchImages: step.stitchImages ?? true,
|
|
572
|
+
// Pass scroll region center coordinates
|
|
573
|
+
startX: centerX,
|
|
574
|
+
startY: centerY,
|
|
575
|
+
};
|
|
576
|
+
return this.executeStep(scrollStep, context);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
// Use regular checkpoint logic
|
|
580
|
+
const checkpointStep = {
|
|
581
|
+
...step,
|
|
582
|
+
action: 'checkpoint',
|
|
583
|
+
};
|
|
584
|
+
return this.executeStep(checkpointStep, context);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
case 'open_url': {
|
|
588
|
+
if (!step.url)
|
|
589
|
+
throw new Error('open_url requires url');
|
|
590
|
+
await simctlClient.openUrl(step.url, context.deviceUdid);
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
case 'set_location': {
|
|
594
|
+
if (step.latitude === undefined || step.longitude === undefined) {
|
|
595
|
+
throw new Error('set_location requires latitude and longitude');
|
|
596
|
+
}
|
|
597
|
+
await simctlClient.setLocation(step.latitude, step.longitude, context.deviceUdid);
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
case 'clear_location': {
|
|
601
|
+
await simctlClient.clearLocation(context.deviceUdid);
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
case 'simulate_route': {
|
|
605
|
+
if (!step.waypoints || step.waypoints.length === 0) {
|
|
606
|
+
throw new Error('simulate_route requires waypoints array');
|
|
607
|
+
}
|
|
608
|
+
const captureAtWaypoints = step.captureAtWaypoints ?? false;
|
|
609
|
+
const intervalMs = step.intervalMs ?? 3000; // 3 seconds default for map rendering
|
|
610
|
+
const captureDelayMs = step.captureDelayMs ?? 2000; // 2 seconds default for map tile loading
|
|
611
|
+
const checkpointName = step.waypointCheckpointName ?? step.name ?? 'route';
|
|
612
|
+
if (captureAtWaypoints) {
|
|
613
|
+
// Manual route simulation with screenshot capture at each waypoint
|
|
614
|
+
const waypointResults = [];
|
|
615
|
+
const tolerance = step.tolerance ?? 0;
|
|
616
|
+
for (let i = 0; i < step.waypoints.length; i++) {
|
|
617
|
+
const wp = step.waypoints[i];
|
|
618
|
+
await simctlClient.setLocation(wp.latitude, wp.longitude, context.deviceUdid);
|
|
619
|
+
// Wait for map to render before capturing
|
|
620
|
+
await this.wait(captureDelayMs);
|
|
621
|
+
// Capture screenshot at this waypoint
|
|
622
|
+
const actualPath = join(context.screenshotsDir, `${checkpointName}_waypoint_${i}.png`);
|
|
623
|
+
const baselinePath = join(context.baselinesDir, `${checkpointName}_waypoint_${i}.png`);
|
|
624
|
+
const diffPath = join(context.diffsDir, `${checkpointName}_waypoint_${i}_diff.png`);
|
|
625
|
+
await simctlClient.screenshot(actualPath, context.deviceUdid);
|
|
626
|
+
this.deps.logger.debug(`Captured waypoint ${i + 1}/${step.waypoints.length} at (${wp.latitude}, ${wp.longitude})`);
|
|
627
|
+
// Update baseline or compare
|
|
628
|
+
if (context.updateBaselines) {
|
|
629
|
+
await imageDiffer.updateBaseline(actualPath, baselinePath);
|
|
630
|
+
waypointResults.push({
|
|
631
|
+
index: i,
|
|
632
|
+
actualPath,
|
|
633
|
+
baselinePath,
|
|
634
|
+
match: true,
|
|
635
|
+
differencePercent: 0,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
else if (!existsSync(baselinePath)) {
|
|
639
|
+
waypointResults.push({
|
|
640
|
+
index: i,
|
|
641
|
+
actualPath,
|
|
642
|
+
baselinePath,
|
|
643
|
+
match: false,
|
|
644
|
+
differencePercent: 100,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
const compareResult = await imageDiffer.compare(actualPath, baselinePath, {
|
|
649
|
+
tolerance,
|
|
650
|
+
generateDiff: true,
|
|
651
|
+
diffOutputPath: diffPath,
|
|
652
|
+
});
|
|
653
|
+
waypointResults.push({
|
|
654
|
+
index: i,
|
|
655
|
+
actualPath,
|
|
656
|
+
baselinePath,
|
|
657
|
+
diffPath: compareResult.diffImagePath,
|
|
658
|
+
match: compareResult.match,
|
|
659
|
+
differencePercent: compareResult.differenceRatio * 100,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
// Wait between waypoints (except after the last one)
|
|
663
|
+
if (i < step.waypoints.length - 1) {
|
|
664
|
+
await this.wait(intervalMs);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
this.deps.logger.info(`Route simulation completed: ${waypointResults.length} waypoint screenshots captured`);
|
|
668
|
+
// Calculate overall match status
|
|
669
|
+
const allMatch = waypointResults.every(r => r.match);
|
|
670
|
+
const avgDiffPercent = waypointResults.length > 0
|
|
671
|
+
? waypointResults.reduce((sum, r) => sum + r.differencePercent, 0) / waypointResults.length
|
|
672
|
+
: 0;
|
|
673
|
+
// Use last waypoint as the main checkpoint result
|
|
674
|
+
const lastResult = waypointResults[waypointResults.length - 1];
|
|
675
|
+
return {
|
|
676
|
+
name: checkpointName,
|
|
677
|
+
match: allMatch,
|
|
678
|
+
differencePercent: avgDiffPercent,
|
|
679
|
+
baselinePath: lastResult?.baselinePath ?? '',
|
|
680
|
+
actualPath: lastResult?.actualPath ?? '',
|
|
681
|
+
diffPath: lastResult?.diffPath,
|
|
682
|
+
isRouteSimulation: true,
|
|
683
|
+
waypointResults,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
// Simple route simulation without screenshot capture
|
|
688
|
+
await simctlClient.simulateRoute(step.waypoints, { intervalMs: step.intervalMs }, context.deviceUdid);
|
|
689
|
+
}
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
default:
|
|
693
|
+
throw new Error(`Unknown action: ${step.action}`);
|
|
694
|
+
}
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
wait(ms) {
|
|
698
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
//# sourceMappingURL=scenario-runner.js.map
|