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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +95 -0
  3. package/README.md +95 -0
  4. package/dist/cli.d.ts +7 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +265 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/core/command-executor.d.ts +15 -0
  9. package/dist/core/command-executor.d.ts.map +1 -0
  10. package/dist/core/command-executor.js +64 -0
  11. package/dist/core/command-executor.js.map +1 -0
  12. package/dist/core/element-finder.d.ts +81 -0
  13. package/dist/core/element-finder.d.ts.map +1 -0
  14. package/dist/core/element-finder.js +246 -0
  15. package/dist/core/element-finder.js.map +1 -0
  16. package/dist/core/idb-client.d.ts +68 -0
  17. package/dist/core/idb-client.d.ts.map +1 -0
  18. package/dist/core/idb-client.js +327 -0
  19. package/dist/core/idb-client.js.map +1 -0
  20. package/dist/core/image-differ.d.ts +55 -0
  21. package/dist/core/image-differ.d.ts.map +1 -0
  22. package/dist/core/image-differ.js +211 -0
  23. package/dist/core/image-differ.js.map +1 -0
  24. package/dist/core/index.d.ts +9 -0
  25. package/dist/core/index.d.ts.map +1 -0
  26. package/dist/core/index.js +9 -0
  27. package/dist/core/index.js.map +1 -0
  28. package/dist/core/report-generator.d.ts +31 -0
  29. package/dist/core/report-generator.d.ts.map +1 -0
  30. package/dist/core/report-generator.js +675 -0
  31. package/dist/core/report-generator.js.map +1 -0
  32. package/dist/core/scenario-runner.d.ts +54 -0
  33. package/dist/core/scenario-runner.d.ts.map +1 -0
  34. package/dist/core/scenario-runner.js +701 -0
  35. package/dist/core/scenario-runner.js.map +1 -0
  36. package/dist/core/simctl-client.d.ts +64 -0
  37. package/dist/core/simctl-client.d.ts.map +1 -0
  38. package/dist/core/simctl-client.js +214 -0
  39. package/dist/core/simctl-client.js.map +1 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +11 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/interfaces/config.interface.d.ts +37 -0
  45. package/dist/interfaces/config.interface.d.ts.map +1 -0
  46. package/dist/interfaces/config.interface.js +14 -0
  47. package/dist/interfaces/config.interface.js.map +1 -0
  48. package/dist/interfaces/element.interface.d.ts +49 -0
  49. package/dist/interfaces/element.interface.d.ts.map +1 -0
  50. package/dist/interfaces/element.interface.js +5 -0
  51. package/dist/interfaces/element.interface.js.map +1 -0
  52. package/dist/interfaces/index.d.ts +7 -0
  53. package/dist/interfaces/index.d.ts.map +1 -0
  54. package/dist/interfaces/index.js +7 -0
  55. package/dist/interfaces/index.js.map +1 -0
  56. package/dist/interfaces/scenario.interface.d.ts +101 -0
  57. package/dist/interfaces/scenario.interface.d.ts.map +1 -0
  58. package/dist/interfaces/scenario.interface.js +5 -0
  59. package/dist/interfaces/scenario.interface.js.map +1 -0
  60. package/dist/server.d.ts +28 -0
  61. package/dist/server.d.ts.map +1 -0
  62. package/dist/server.js +943 -0
  63. package/dist/server.js.map +1 -0
  64. package/dist/utils/index.d.ts +5 -0
  65. package/dist/utils/index.d.ts.map +1 -0
  66. package/dist/utils/index.js +5 -0
  67. package/dist/utils/index.js.map +1 -0
  68. package/dist/utils/logger.d.ts +24 -0
  69. package/dist/utils/logger.d.ts.map +1 -0
  70. package/dist/utils/logger.js +50 -0
  71. package/dist/utils/logger.js.map +1 -0
  72. 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