@argus-vrt/cli 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/dist/index.js ADDED
@@ -0,0 +1,1029 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __commonJS = (cb, mod) => function __require() {
9
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
+
33
+ // ../shared/dist/types.js
34
+ var require_types = __commonJS({
35
+ "../shared/dist/types.js"(exports2) {
36
+ "use strict";
37
+ Object.defineProperty(exports2, "__esModule", { value: true });
38
+ }
39
+ });
40
+
41
+ // ../shared/dist/constants.js
42
+ var require_constants = __commonJS({
43
+ "../shared/dist/constants.js"(exports2) {
44
+ "use strict";
45
+ Object.defineProperty(exports2, "__esModule", { value: true });
46
+ exports2.SSIM_THRESHOLD = exports2.DIFF_THRESHOLD = exports2.IMAGE_FORMATS = exports2.STORYBOOK_CONNECTION_TIMEOUT = exports2.STORY_RENDER_TIMEOUT = exports2.SIMULATOR_WAIT_TIMEOUT = exports2.CONFIG_FILE_NAME = exports2.DEFAULT_CONFIG = void 0;
47
+ exports2.DEFAULT_CONFIG = {
48
+ storybook: {
49
+ port: 7007,
50
+ storiesPattern: "src/**/__stories__/**/*.stories.?(ts|tsx|js|jsx)"
51
+ },
52
+ simulator: {
53
+ device: "iPhone 15 Pro",
54
+ os: "iOS 17.0"
55
+ },
56
+ comparison: {
57
+ mode: "threshold",
58
+ threshold: 0.01,
59
+ includeMetrics: true
60
+ },
61
+ baselineDir: ".visual-baselines",
62
+ screenshotDir: ".visual-screenshots"
63
+ };
64
+ exports2.CONFIG_FILE_NAME = ".argus.json";
65
+ exports2.SIMULATOR_WAIT_TIMEOUT = 3e4;
66
+ exports2.STORY_RENDER_TIMEOUT = 5e3;
67
+ exports2.STORYBOOK_CONNECTION_TIMEOUT = 6e4;
68
+ exports2.IMAGE_FORMATS = {
69
+ screenshot: "png",
70
+ diff: "png"
71
+ };
72
+ exports2.DIFF_THRESHOLD = 0.01;
73
+ exports2.SSIM_THRESHOLD = 0.95;
74
+ }
75
+ });
76
+
77
+ // ../shared/dist/index.js
78
+ var require_dist = __commonJS({
79
+ "../shared/dist/index.js"(exports2) {
80
+ "use strict";
81
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
82
+ if (k2 === void 0) k2 = k;
83
+ var desc = Object.getOwnPropertyDescriptor(m, k);
84
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
85
+ desc = { enumerable: true, get: function() {
86
+ return m[k];
87
+ } };
88
+ }
89
+ Object.defineProperty(o, k2, desc);
90
+ }) : (function(o, m, k, k2) {
91
+ if (k2 === void 0) k2 = k;
92
+ o[k2] = m[k];
93
+ }));
94
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
95
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
96
+ };
97
+ Object.defineProperty(exports2, "__esModule", { value: true });
98
+ __exportStar(require_types(), exports2);
99
+ __exportStar(require_constants(), exports2);
100
+ }
101
+ });
102
+
103
+ // src/index.ts
104
+ var index_exports = {};
105
+ __export(index_exports, {
106
+ captureCommand: () => captureCommand,
107
+ compareCommand: () => compareCommand,
108
+ getChangedFiles: () => getChangedFiles,
109
+ getCommitMessage: () => getCommitMessage,
110
+ getCurrentBranch: () => getCurrentBranch,
111
+ getCurrentCommitHash: () => getCurrentCommitHash,
112
+ isWorkingDirectoryClean: () => isWorkingDirectoryClean,
113
+ loadConfig: () => loadConfig,
114
+ logger: () => logger,
115
+ validateConfig: () => validateConfig
116
+ });
117
+ module.exports = __toCommonJS(index_exports);
118
+
119
+ // src/commands/capture.ts
120
+ var import_promises = require("fs/promises");
121
+ var import_path2 = require("path");
122
+ var import_ora = __toESM(require("ora"));
123
+
124
+ // src/utils/config.ts
125
+ var import_fs = require("fs");
126
+ var import_path = require("path");
127
+ var import_shared = __toESM(require_dist());
128
+ function loadConfig(cwd = process.cwd()) {
129
+ const configPath = (0, import_path.join)(cwd, import_shared.CONFIG_FILE_NAME);
130
+ if (!(0, import_fs.existsSync)(configPath)) {
131
+ console.warn(`No ${import_shared.CONFIG_FILE_NAME} found, using defaults`);
132
+ return import_shared.DEFAULT_CONFIG;
133
+ }
134
+ try {
135
+ const configContent = (0, import_fs.readFileSync)(configPath, "utf-8");
136
+ const userConfig = JSON.parse(configContent);
137
+ return {
138
+ ...import_shared.DEFAULT_CONFIG,
139
+ ...userConfig,
140
+ storybook: {
141
+ ...import_shared.DEFAULT_CONFIG.storybook,
142
+ ...userConfig.storybook
143
+ },
144
+ simulator: {
145
+ ...import_shared.DEFAULT_CONFIG.simulator,
146
+ ...userConfig.simulator
147
+ },
148
+ comparison: {
149
+ ...import_shared.DEFAULT_CONFIG.comparison,
150
+ ...userConfig.comparison
151
+ }
152
+ };
153
+ } catch (error) {
154
+ throw new Error(`Failed to parse ${import_shared.CONFIG_FILE_NAME}: ${error}`);
155
+ }
156
+ }
157
+ function validateConfig(config) {
158
+ if (!config.storybook?.port) {
159
+ throw new Error("storybook.port is required");
160
+ }
161
+ if (!config.simulator?.device) {
162
+ throw new Error("simulator.device is required");
163
+ }
164
+ if (!config.comparison?.threshold || config.comparison.threshold < 0 || config.comparison.threshold > 1) {
165
+ throw new Error("comparison.threshold must be between 0 and 1");
166
+ }
167
+ }
168
+
169
+ // src/utils/git.ts
170
+ var import_execa = require("execa");
171
+ async function getCurrentBranch() {
172
+ try {
173
+ const { stdout } = await (0, import_execa.execaCommand)("git rev-parse --abbrev-ref HEAD");
174
+ return stdout.trim();
175
+ } catch (error) {
176
+ throw new Error("Failed to get current branch. Is this a git repository?");
177
+ }
178
+ }
179
+ async function getCurrentCommitHash() {
180
+ try {
181
+ const { stdout } = await (0, import_execa.execaCommand)("git rev-parse HEAD");
182
+ return stdout.trim();
183
+ } catch (error) {
184
+ throw new Error("Failed to get commit hash");
185
+ }
186
+ }
187
+ async function getCommitMessage(hash) {
188
+ try {
189
+ const cmd = hash ? `git log -1 --pretty=%B ${hash}` : "git log -1 --pretty=%B";
190
+ const { stdout } = await (0, import_execa.execaCommand)(cmd);
191
+ return stdout.trim();
192
+ } catch (error) {
193
+ return "";
194
+ }
195
+ }
196
+ async function isWorkingDirectoryClean() {
197
+ try {
198
+ const { stdout } = await (0, import_execa.execaCommand)("git status --porcelain");
199
+ return stdout.trim() === "";
200
+ } catch (error) {
201
+ return false;
202
+ }
203
+ }
204
+ async function getChangedFiles(baseBranch, currentBranch) {
205
+ try {
206
+ const { stdout } = await (0, import_execa.execaCommand)(`git diff ${baseBranch}...${currentBranch} --name-only`);
207
+ return stdout.split("\n").filter(Boolean);
208
+ } catch (error) {
209
+ return [];
210
+ }
211
+ }
212
+
213
+ // src/utils/logger.ts
214
+ var import_chalk = __toESM(require("chalk"));
215
+ var logger = {
216
+ info: (message) => {
217
+ console.log(import_chalk.default.blue("\u2139"), message);
218
+ },
219
+ success: (message) => {
220
+ console.log(import_chalk.default.green("\u2713"), message);
221
+ },
222
+ warn: (message) => {
223
+ console.log(import_chalk.default.yellow("\u26A0"), message);
224
+ },
225
+ error: (message) => {
226
+ console.log(import_chalk.default.red("\u2716"), message);
227
+ },
228
+ debug: (message) => {
229
+ if (process.env.DEBUG) {
230
+ console.log(import_chalk.default.gray("\u203A"), message);
231
+ }
232
+ }
233
+ };
234
+
235
+ // src/ios/simulator.ts
236
+ var import_execa2 = require("execa");
237
+ var import_shared2 = __toESM(require_dist());
238
+ async function findSimulator(config) {
239
+ try {
240
+ const { stdout } = await (0, import_execa2.execaCommand)("xcrun simctl list devices --json");
241
+ const data = JSON.parse(stdout);
242
+ const matchingDevices = [];
243
+ for (const [runtime, devices] of Object.entries(data.devices)) {
244
+ for (const device of devices) {
245
+ if (device.name === config.device && device.isAvailable !== false) {
246
+ matchingDevices.push({
247
+ udid: device.udid,
248
+ name: device.name,
249
+ state: device.state,
250
+ isAvailable: device.isAvailable !== false
251
+ });
252
+ }
253
+ }
254
+ }
255
+ if (matchingDevices.length === 0) {
256
+ return null;
257
+ }
258
+ const bootedDevice = matchingDevices.find((d) => d.state === "Booted");
259
+ if (bootedDevice) {
260
+ return bootedDevice;
261
+ }
262
+ return matchingDevices[0];
263
+ } catch (error) {
264
+ logger.error(`Failed to find simulator: ${error}`);
265
+ return null;
266
+ }
267
+ }
268
+ async function bootSimulator(udid) {
269
+ try {
270
+ const { stdout } = await (0, import_execa2.execaCommand)(`xcrun simctl list devices --json`);
271
+ const data = JSON.parse(stdout);
272
+ let deviceState = "Shutdown";
273
+ for (const [runtime, devices] of Object.entries(data.devices)) {
274
+ for (const device of devices) {
275
+ if (device.udid === udid) {
276
+ deviceState = device.state;
277
+ break;
278
+ }
279
+ }
280
+ }
281
+ if (deviceState === "Booted") {
282
+ logger.info("Simulator already booted");
283
+ return;
284
+ }
285
+ logger.info("Booting simulator...");
286
+ await (0, import_execa2.execaCommand)(`xcrun simctl boot ${udid}`);
287
+ await waitForSimulator(udid, import_shared2.SIMULATOR_WAIT_TIMEOUT);
288
+ logger.success("Simulator booted");
289
+ } catch (error) {
290
+ if (error.message?.includes("current state: Booted")) {
291
+ logger.info("Simulator already booted");
292
+ return;
293
+ }
294
+ throw new Error(`Failed to boot simulator: ${error}`);
295
+ }
296
+ }
297
+ async function waitForSimulator(udid, timeout) {
298
+ const startTime = Date.now();
299
+ while (Date.now() - startTime < timeout) {
300
+ try {
301
+ const { stdout } = await (0, import_execa2.execaCommand)("xcrun simctl list devices --json");
302
+ const data = JSON.parse(stdout);
303
+ for (const [runtime, devices] of Object.entries(data.devices)) {
304
+ for (const device of devices) {
305
+ if (device.udid === udid && device.state === "Booted") {
306
+ return;
307
+ }
308
+ }
309
+ }
310
+ } catch (error) {
311
+ }
312
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
313
+ }
314
+ throw new Error("Simulator boot timeout");
315
+ }
316
+ async function shutdownSimulator(udid) {
317
+ try {
318
+ const { stdout } = await (0, import_execa2.execaCommand)("xcrun simctl list devices --json");
319
+ const data = JSON.parse(stdout);
320
+ let deviceState = "Shutdown";
321
+ for (const [runtime, devices] of Object.entries(data.devices)) {
322
+ for (const device of devices) {
323
+ if (device.udid === udid) {
324
+ deviceState = device.state;
325
+ break;
326
+ }
327
+ }
328
+ }
329
+ if (deviceState === "Shutdown") {
330
+ logger.info("Simulator already shutdown");
331
+ return;
332
+ }
333
+ logger.info("Shutting down simulator...");
334
+ await (0, import_execa2.execaCommand)(`xcrun simctl shutdown ${udid}`);
335
+ logger.success("Simulator shutdown");
336
+ } catch (error) {
337
+ logger.warn(`Failed to shutdown simulator: ${error}`);
338
+ }
339
+ }
340
+ async function launchApp(udid, bundleId) {
341
+ try {
342
+ logger.info(`Launching app: ${bundleId}`);
343
+ await (0, import_execa2.execaCommand)(`xcrun simctl launch ${udid} ${bundleId}`);
344
+ logger.success("App launched");
345
+ await new Promise((resolve) => setTimeout(resolve, 3e3));
346
+ } catch (error) {
347
+ throw new Error(`Failed to launch app: ${error}`);
348
+ }
349
+ }
350
+ async function terminateApp(udid, bundleId) {
351
+ try {
352
+ await (0, import_execa2.execaCommand)(`xcrun simctl terminate ${udid} ${bundleId}`);
353
+ logger.info("App terminated");
354
+ } catch (error) {
355
+ logger.debug(`Failed to terminate app: ${error}`);
356
+ }
357
+ }
358
+ async function captureScreenshot(udid, outputPath) {
359
+ try {
360
+ await (0, import_execa2.execa)("xcrun", ["simctl", "io", udid, "screenshot", outputPath]);
361
+ logger.debug(`Screenshot saved: ${outputPath}`);
362
+ } catch (error) {
363
+ throw new Error(`Failed to capture screenshot: ${error}`);
364
+ }
365
+ }
366
+
367
+ // src/ios/storybook.ts
368
+ var import_ws = __toESM(require("ws"));
369
+ var import_shared3 = __toESM(require_dist());
370
+ function createStorybookClient(port) {
371
+ let ws = null;
372
+ const url = `ws://localhost:${port}`;
373
+ return {
374
+ async connect() {
375
+ return new Promise((resolve, reject) => {
376
+ const timeout = setTimeout(() => {
377
+ reject(new Error("Storybook connection timeout"));
378
+ }, import_shared3.STORYBOOK_CONNECTION_TIMEOUT);
379
+ ws = new import_ws.default(url);
380
+ ws.on("open", () => {
381
+ clearTimeout(timeout);
382
+ logger.success("Connected to Storybook");
383
+ resolve();
384
+ });
385
+ ws.on("error", (error) => {
386
+ clearTimeout(timeout);
387
+ reject(new Error(`Storybook connection error: ${error.message}`));
388
+ });
389
+ ws.on("close", () => {
390
+ logger.debug("Storybook connection closed");
391
+ });
392
+ });
393
+ },
394
+ disconnect() {
395
+ if (ws) {
396
+ ws.close();
397
+ ws = null;
398
+ }
399
+ },
400
+ async getStories() {
401
+ if (!ws) {
402
+ throw new Error("Not connected to Storybook");
403
+ }
404
+ return new Promise((resolve, reject) => {
405
+ const timeout = setTimeout(() => {
406
+ reject(new Error("Failed to get stories"));
407
+ }, 1e4);
408
+ const messageHandler = (data) => {
409
+ try {
410
+ const message = JSON.parse(data.toString());
411
+ if (message.type === "setStories") {
412
+ clearTimeout(timeout);
413
+ ws?.off("message", messageHandler);
414
+ const stories = Object.entries(message.stories || {}).map(([id, story]) => ({
415
+ id,
416
+ componentName: story.kind || story.title,
417
+ storyName: story.name || story.story,
418
+ title: story.title || story.kind,
419
+ kind: story.kind || story.title
420
+ }));
421
+ resolve(stories);
422
+ }
423
+ } catch (error) {
424
+ }
425
+ };
426
+ ws?.on("message", messageHandler);
427
+ ws?.send(JSON.stringify({ type: "getStories" }));
428
+ });
429
+ },
430
+ async navigateToStory(storyId) {
431
+ if (!ws) {
432
+ throw new Error("Not connected to Storybook");
433
+ }
434
+ return new Promise((resolve, reject) => {
435
+ const timeout = setTimeout(() => {
436
+ reject(new Error("Story navigation timeout"));
437
+ }, import_shared3.STORY_RENDER_TIMEOUT);
438
+ const messageHandler = (data) => {
439
+ try {
440
+ const message = JSON.parse(data.toString());
441
+ if (message.type === "storyRendered" && message.storyId === storyId) {
442
+ clearTimeout(timeout);
443
+ ws?.off("message", messageHandler);
444
+ resolve();
445
+ }
446
+ } catch (error) {
447
+ }
448
+ };
449
+ ws?.on("message", messageHandler);
450
+ ws?.send(
451
+ JSON.stringify({
452
+ type: "selectStory",
453
+ storyId
454
+ })
455
+ );
456
+ });
457
+ },
458
+ async waitForStory(timeout = import_shared3.STORY_RENDER_TIMEOUT) {
459
+ await new Promise((resolve) => setTimeout(resolve, timeout));
460
+ }
461
+ };
462
+ }
463
+ async function waitForStorybookServer(port, timeout = 6e4) {
464
+ const startTime = Date.now();
465
+ while (Date.now() - startTime < timeout) {
466
+ try {
467
+ const response = await fetch(`http://localhost:${port}`);
468
+ if (response.ok) {
469
+ logger.success("Storybook server is ready");
470
+ return;
471
+ }
472
+ } catch (error) {
473
+ }
474
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
475
+ }
476
+ throw new Error("Storybook server timeout");
477
+ }
478
+ function createStoryId(componentName, storyName) {
479
+ return `${componentName}-${storyName}`.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").toLowerCase();
480
+ }
481
+
482
+ // src/ios/metrics.ts
483
+ async function measureRenderTime(fn) {
484
+ const startTime = performance.now();
485
+ const result = await fn();
486
+ const endTime = performance.now();
487
+ return {
488
+ result,
489
+ renderTime: Math.round(endTime - startTime)
490
+ };
491
+ }
492
+ function collectMetrics(renderTime) {
493
+ return {
494
+ renderTime,
495
+ timestamp: Date.now()
496
+ };
497
+ }
498
+
499
+ // src/commands/capture.ts
500
+ async function captureCommand(options = {}) {
501
+ const spinner = (0, import_ora.default)("Loading configuration...").start();
502
+ try {
503
+ const config = loadConfig();
504
+ validateConfig(config);
505
+ const branch = options.branch || await getCurrentBranch();
506
+ const commitHash = await getCurrentCommitHash();
507
+ spinner.succeed("Configuration loaded");
508
+ logger.info(`Branch: ${branch}`);
509
+ logger.info(`Commit: ${commitHash.substring(0, 7)}`);
510
+ spinner.start("Finding simulator...");
511
+ const simulator = await findSimulator(config.simulator);
512
+ if (!simulator) {
513
+ throw new Error(`Simulator not found: ${config.simulator.device}`);
514
+ }
515
+ spinner.succeed(`Found simulator: ${simulator.name}`);
516
+ if (!options.skipBoot) {
517
+ spinner.start("Booting simulator...");
518
+ await bootSimulator(simulator.udid);
519
+ spinner.succeed("Simulator booted");
520
+ }
521
+ try {
522
+ const bundleId = config.simulator.bundleId || config.simulator.appScheme;
523
+ if (!bundleId) {
524
+ throw new Error("bundleId or appScheme must be specified in config");
525
+ }
526
+ spinner.start("Launching app...");
527
+ await launchApp(simulator.udid, bundleId);
528
+ spinner.succeed("App launched");
529
+ spinner.start("Waiting for Storybook...");
530
+ await waitForStorybookServer(config.storybook.port);
531
+ spinner.succeed("Storybook ready");
532
+ spinner.start("Connecting to Storybook...");
533
+ const storybookClient = createStorybookClient(config.storybook.port);
534
+ await storybookClient.connect();
535
+ spinner.succeed("Connected to Storybook");
536
+ try {
537
+ spinner.start("Loading stories...");
538
+ const stories = await storybookClient.getStories();
539
+ spinner.succeed(`Found ${stories.length} stories`);
540
+ if (stories.length === 0) {
541
+ logger.warn("No stories found");
542
+ return;
543
+ }
544
+ const outputDir = (0, import_path2.join)(process.cwd(), config.screenshotDir, branch);
545
+ await (0, import_promises.mkdir)(outputDir, { recursive: true });
546
+ const screenshots = [];
547
+ let capturedCount = 0;
548
+ for (const story of stories) {
549
+ spinner.start(`Capturing ${story.componentName}/${story.storyName} (${capturedCount + 1}/${stories.length})`);
550
+ try {
551
+ const { renderTime } = await measureRenderTime(async () => {
552
+ await storybookClient.navigateToStory(story.id);
553
+ await storybookClient.waitForStory();
554
+ });
555
+ const storyId = createStoryId(story.componentName, story.storyName);
556
+ const filename = `${storyId}.png`;
557
+ const filePath = (0, import_path2.join)(outputDir, filename);
558
+ await captureScreenshot(simulator.udid, filePath);
559
+ const metrics = collectMetrics(renderTime);
560
+ screenshots.push({
561
+ storyId,
562
+ kind: story.kind || story.title,
563
+ componentName: story.componentName,
564
+ storyName: story.storyName,
565
+ filePath,
566
+ branch,
567
+ commitHash,
568
+ timestamp: Date.now(),
569
+ renderTime: metrics.renderTime
570
+ });
571
+ capturedCount++;
572
+ spinner.succeed(
573
+ `Captured ${story.componentName}/${story.storyName} (${capturedCount}/${stories.length}) - ${renderTime}ms`
574
+ );
575
+ } catch (error) {
576
+ spinner.fail(`Failed to capture ${story.componentName}/${story.storyName}`);
577
+ logger.error(`${error}`);
578
+ }
579
+ }
580
+ const metadataPath = (0, import_path2.join)(outputDir, "metadata.json");
581
+ await (0, import_promises.writeFile)(
582
+ metadataPath,
583
+ JSON.stringify(
584
+ {
585
+ branch,
586
+ commitHash,
587
+ timestamp: Date.now(),
588
+ screenshots,
589
+ totalStories: stories.length,
590
+ capturedCount
591
+ },
592
+ null,
593
+ 2
594
+ )
595
+ );
596
+ logger.success(`
597
+ Captured ${capturedCount}/${stories.length} screenshots`);
598
+ logger.info(`Screenshots saved to: ${outputDir}`);
599
+ } finally {
600
+ storybookClient.disconnect();
601
+ }
602
+ await terminateApp(simulator.udid, bundleId);
603
+ } finally {
604
+ if (!options.skipShutdown) {
605
+ spinner.start("Shutting down simulator...");
606
+ await shutdownSimulator(simulator.udid);
607
+ spinner.succeed("Simulator shutdown");
608
+ }
609
+ }
610
+ } catch (error) {
611
+ spinner.fail("Capture failed");
612
+ throw error;
613
+ }
614
+ }
615
+
616
+ // src/commands/compare.ts
617
+ var import_promises2 = require("fs/promises");
618
+ var import_fs4 = require("fs");
619
+ var import_path3 = require("path");
620
+ var import_ora2 = __toESM(require("ora"));
621
+
622
+ // src/comparison/odiff.ts
623
+ var import_execa3 = require("execa");
624
+ var import_fs2 = require("fs");
625
+ async function compareWithODiff(baselinePath, currentPath, diffPath, threshold = 0.01) {
626
+ try {
627
+ try {
628
+ await (0, import_execa3.execaCommand)("which odiff");
629
+ } catch {
630
+ logger.debug("ODiff not found, skipping");
631
+ return null;
632
+ }
633
+ let cmd = `odiff "${baselinePath}" "${currentPath}" --threshold ${threshold}`;
634
+ if (diffPath) {
635
+ cmd += ` --diff-image "${diffPath}"`;
636
+ }
637
+ try {
638
+ const { stdout } = await (0, import_execa3.execaCommand)(cmd);
639
+ const diffMatch = stdout.match(/Difference: ([\d.]+)%/);
640
+ const diffPercentage = diffMatch ? parseFloat(diffMatch[1]) : 0;
641
+ return {
642
+ match: diffPercentage <= threshold * 100,
643
+ diffPercentage,
644
+ diffPixels: 0,
645
+ // ODiff doesn't provide this
646
+ diffImagePath: diffPath && diffPercentage > 0 ? diffPath : void 0
647
+ };
648
+ } catch (error) {
649
+ if (error.stdout) {
650
+ const diffMatch = error.stdout.match(/Difference: ([\d.]+)%/);
651
+ const diffPercentage = diffMatch ? parseFloat(diffMatch[1]) : 100;
652
+ return {
653
+ match: false,
654
+ diffPercentage,
655
+ diffPixels: 0,
656
+ diffImagePath: diffPath && (0, import_fs2.existsSync)(diffPath) ? diffPath : void 0
657
+ };
658
+ }
659
+ throw error;
660
+ }
661
+ } catch (error) {
662
+ logger.warn(`ODiff comparison failed: ${error}`);
663
+ return null;
664
+ }
665
+ }
666
+ async function isODiffInstalled() {
667
+ try {
668
+ await (0, import_execa3.execaCommand)("which odiff");
669
+ return true;
670
+ } catch {
671
+ return false;
672
+ }
673
+ }
674
+
675
+ // src/comparison/pixelmatch.ts
676
+ var import_pixelmatch = __toESM(require("pixelmatch"));
677
+ var import_pngjs = require("pngjs");
678
+ var import_fs3 = require("fs");
679
+ async function compareWithPixelmatch(baselinePath, currentPath, diffPath, threshold = 0.1) {
680
+ try {
681
+ const baseline = import_pngjs.PNG.sync.read((0, import_fs3.readFileSync)(baselinePath));
682
+ const current = import_pngjs.PNG.sync.read((0, import_fs3.readFileSync)(currentPath));
683
+ if (baseline.width !== current.width || baseline.height !== current.height) {
684
+ throw new Error(
685
+ `Image dimensions don't match: ${baseline.width}x${baseline.height} vs ${current.width}x${current.height}`
686
+ );
687
+ }
688
+ const { width, height } = baseline;
689
+ const totalPixels = width * height;
690
+ const diff = new import_pngjs.PNG({ width, height });
691
+ const mismatchedPixels = (0, import_pixelmatch.default)(baseline.data, current.data, diff.data, width, height, {
692
+ threshold,
693
+ includeAA: false,
694
+ alpha: 0,
695
+ diffColor: [255, 0, 0],
696
+ diffColorAlt: [255, 0, 255]
697
+ });
698
+ const diffPercentage = mismatchedPixels / totalPixels * 100;
699
+ let diffImagePath;
700
+ if (diffPath && mismatchedPixels > 0) {
701
+ (0, import_fs3.writeFileSync)(diffPath, import_pngjs.PNG.sync.write(diff));
702
+ diffImagePath = diffPath;
703
+ logger.debug(`Diff image saved: ${diffPath}`);
704
+ }
705
+ return {
706
+ mismatchedPixels,
707
+ totalPixels,
708
+ diffPercentage,
709
+ diffImagePath
710
+ };
711
+ } catch (error) {
712
+ throw new Error(`Pixelmatch comparison failed: ${error}`);
713
+ }
714
+ }
715
+
716
+ // src/comparison/ssim.ts
717
+ var import_sharp = __toESM(require("sharp"));
718
+ var import_ssim = __toESM(require("ssim.js"));
719
+ async function calculateSSIM(baselinePath, currentPath) {
720
+ try {
721
+ const [baselineBuffer, currentBuffer] = await Promise.all([
722
+ (0, import_sharp.default)(baselinePath).raw().toBuffer({ resolveWithObject: true }),
723
+ (0, import_sharp.default)(currentPath).raw().toBuffer({ resolveWithObject: true })
724
+ ]);
725
+ if (baselineBuffer.info.width !== currentBuffer.info.width || baselineBuffer.info.height !== currentBuffer.info.height) {
726
+ throw new Error("Image dimensions must match for SSIM calculation");
727
+ }
728
+ const result = (0, import_ssim.default)(
729
+ {
730
+ data: new Uint8ClampedArray(baselineBuffer.data),
731
+ width: baselineBuffer.info.width,
732
+ height: baselineBuffer.info.height
733
+ },
734
+ {
735
+ data: new Uint8ClampedArray(currentBuffer.data),
736
+ width: currentBuffer.info.width,
737
+ height: currentBuffer.info.height
738
+ }
739
+ );
740
+ return {
741
+ score: result.mssim,
742
+ mssim: result.mssim
743
+ };
744
+ } catch (error) {
745
+ throw new Error(`SSIM calculation failed: ${error}`);
746
+ }
747
+ }
748
+
749
+ // src/commands/compare.ts
750
+ async function compareCommand(options = {}) {
751
+ const spinner = (0, import_ora2.default)("Loading configuration...").start();
752
+ try {
753
+ const config = loadConfig();
754
+ validateConfig(config);
755
+ const baseBranch = options.base || "main";
756
+ const currentBranch = options.current || await getCurrentBranch();
757
+ spinner.succeed("Configuration loaded");
758
+ logger.info(`Comparing ${currentBranch} against ${baseBranch}`);
759
+ const useODiff = await isODiffInstalled();
760
+ if (useODiff) {
761
+ logger.info("Using ODiff for fast comparison");
762
+ } else {
763
+ logger.info("Using Pixelmatch for comparison (install odiff for faster results)");
764
+ }
765
+ const baselineDir = (0, import_path3.join)(process.cwd(), config.baselineDir, "ios", config.simulator.device.replace(/\s+/g, ""));
766
+ const currentDir = (0, import_path3.join)(process.cwd(), config.screenshotDir, currentBranch);
767
+ if (!(0, import_fs4.existsSync)(baselineDir)) {
768
+ throw new Error(`Baseline directory not found: ${baselineDir}`);
769
+ }
770
+ if (!(0, import_fs4.existsSync)(currentDir)) {
771
+ throw new Error(`Screenshot directory not found: ${currentDir}`);
772
+ }
773
+ const metadataPath = (0, import_path3.join)(currentDir, "metadata.json");
774
+ let metadata = {};
775
+ if ((0, import_fs4.existsSync)(metadataPath)) {
776
+ const metadataContent = await (0, import_promises2.readFile)(metadataPath, "utf-8");
777
+ metadata = JSON.parse(metadataContent);
778
+ }
779
+ const currentFiles = (await (0, import_promises2.readdir)(currentDir)).filter((f) => f.endsWith(".png") && f !== "metadata.json");
780
+ const baselineFiles = (await (0, import_promises2.readdir)(baselineDir)).filter((f) => f.endsWith(".png"));
781
+ spinner.succeed(`Found ${currentFiles.length} current screenshots, ${baselineFiles.length} baselines`);
782
+ const diffDir = (0, import_path3.join)(currentDir, "diffs");
783
+ await (0, import_promises2.mkdir)(diffDir, { recursive: true });
784
+ const results = [];
785
+ const threshold = options.threshold ?? config.comparison.threshold;
786
+ let comparedCount = 0;
787
+ let changedCount = 0;
788
+ let passedCount = 0;
789
+ let failedCount = 0;
790
+ for (const filename of currentFiles) {
791
+ spinner.start(`Comparing ${filename} (${comparedCount + 1}/${currentFiles.length})`);
792
+ const currentPath = (0, import_path3.join)(currentDir, filename);
793
+ const baselinePath = (0, import_path3.join)(baselineDir, filename);
794
+ if (!(0, import_fs4.existsSync)(baselinePath)) {
795
+ logger.warn(`No baseline found for ${filename}`);
796
+ results.push({
797
+ storyId: filename.replace(".png", ""),
798
+ componentName: "",
799
+ storyName: "",
800
+ baselineUrl: "",
801
+ currentUrl: currentPath,
802
+ pixelDiff: 100,
803
+ ssimScore: 0,
804
+ hasDiff: true
805
+ });
806
+ failedCount++;
807
+ comparedCount++;
808
+ continue;
809
+ }
810
+ try {
811
+ const diffPath = (0, import_path3.join)(diffDir, filename);
812
+ let pixelDiff = 0;
813
+ let hasDiff = false;
814
+ if (useODiff) {
815
+ const odiffResult = await compareWithODiff(baselinePath, currentPath, diffPath, threshold);
816
+ if (odiffResult) {
817
+ pixelDiff = odiffResult.diffPercentage;
818
+ hasDiff = !odiffResult.match;
819
+ }
820
+ }
821
+ if (!useODiff) {
822
+ const pixelmatchResult = await compareWithPixelmatch(baselinePath, currentPath, diffPath, 0.1);
823
+ pixelDiff = pixelmatchResult.diffPercentage;
824
+ hasDiff = pixelDiff > threshold * 100;
825
+ }
826
+ const ssimResult = await calculateSSIM(baselinePath, currentPath);
827
+ const storyMetadata = metadata.screenshots?.find((s) => s.filePath.endsWith(filename));
828
+ results.push({
829
+ storyId: filename.replace(".png", ""),
830
+ kind: storyMetadata?.kind || "",
831
+ componentName: storyMetadata?.componentName || "",
832
+ storyName: storyMetadata?.storyName || "",
833
+ baselineUrl: baselinePath,
834
+ currentUrl: currentPath,
835
+ diffUrl: hasDiff ? diffPath : void 0,
836
+ pixelDiff,
837
+ ssimScore: ssimResult.score,
838
+ hasDiff,
839
+ renderTime: storyMetadata?.renderTime
840
+ });
841
+ if (hasDiff) {
842
+ changedCount++;
843
+ failedCount++;
844
+ spinner.warn(`${filename}: ${pixelDiff.toFixed(2)}% different`);
845
+ } else {
846
+ passedCount++;
847
+ spinner.succeed(`${filename}: passed`);
848
+ }
849
+ comparedCount++;
850
+ } catch (error) {
851
+ spinner.fail(`Failed to compare ${filename}`);
852
+ logger.error(`${error}`);
853
+ failedCount++;
854
+ comparedCount++;
855
+ }
856
+ }
857
+ const resultsPath = (0, import_path3.join)(currentDir, "comparison-results.json");
858
+ await (0, import_promises2.writeFile)(
859
+ resultsPath,
860
+ JSON.stringify(
861
+ {
862
+ baseBranch,
863
+ currentBranch,
864
+ timestamp: Date.now(),
865
+ totalStories: currentFiles.length,
866
+ comparedCount,
867
+ changedCount,
868
+ passedCount,
869
+ failedCount,
870
+ results
871
+ },
872
+ null,
873
+ 2
874
+ )
875
+ );
876
+ if (options.generateReport !== false) {
877
+ spinner.start("Generating HTML report...");
878
+ await generateHTMLReport(results, currentDir, config);
879
+ spinner.succeed("HTML report generated");
880
+ }
881
+ console.log("\n" + "=".repeat(50));
882
+ logger.success(`Comparison complete: ${comparedCount} stories`);
883
+ logger.info(` Passed: ${passedCount}`);
884
+ logger.info(` Changed: ${changedCount}`);
885
+ logger.info(` Failed: ${failedCount}`);
886
+ logger.info(`
887
+ Results saved to: ${resultsPath}`);
888
+ if (options.generateReport !== false) {
889
+ logger.info(`HTML report: ${(0, import_path3.join)(currentDir, "report.html")}`);
890
+ }
891
+ console.log("=".repeat(50));
892
+ if (changedCount > 0) {
893
+ process.exit(1);
894
+ }
895
+ } catch (error) {
896
+ spinner.fail("Comparison failed");
897
+ throw error;
898
+ }
899
+ }
900
+ async function generateHTMLReport(results, outputDir, config) {
901
+ const changedResults = results.filter((r) => r.hasDiff);
902
+ const passedResults = results.filter((r) => !r.hasDiff);
903
+ const html = `
904
+ <!DOCTYPE html>
905
+ <html lang="en">
906
+ <head>
907
+ <meta charset="UTF-8">
908
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
909
+ <title>Visual Regression Test Report</title>
910
+ <style>
911
+ * { margin: 0; padding: 0; box-sizing: border-box; }
912
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f5f5f5; }
913
+ .container { max-width: 1400px; margin: 0 auto; }
914
+ h1 { margin-bottom: 20px; color: #333; }
915
+ .summary { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
916
+ .stats { display: flex; gap: 20px; margin-top: 15px; }
917
+ .stat { flex: 1; padding: 15px; border-radius: 6px; text-align: center; }
918
+ .stat-passed { background: #d4edda; color: #155724; }
919
+ .stat-changed { background: #fff3cd; color: #856404; }
920
+ .stat-failed { background: #f8d7da; color: #721c24; }
921
+ .stat-value { font-size: 32px; font-weight: bold; }
922
+ .stat-label { font-size: 14px; margin-top: 5px; }
923
+ .tabs { display: flex; gap: 10px; margin-bottom: 20px; }
924
+ .tab { padding: 10px 20px; background: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
925
+ .tab.active { background: #007bff; color: white; }
926
+ .results { display: none; }
927
+ .results.active { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 20px; }
928
+ .result { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
929
+ .result-header { padding: 15px; border-bottom: 1px solid #eee; }
930
+ .result-title { font-weight: 600; color: #333; }
931
+ .result-diff { color: #856404; font-size: 12px; margin-top: 5px; }
932
+ .result-images { display: grid; grid-template-columns: repeat(3, 1fr); }
933
+ .result-image { position: relative; aspect-ratio: 1; overflow: hidden; }
934
+ .result-image img { width: 100%; height: 100%; object-fit: cover; }
935
+ .result-image-label { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.7); color: white; padding: 5px; font-size: 11px; text-align: center; }
936
+ .passed .result-header { border-left: 4px solid #28a745; }
937
+ .changed .result-header { border-left: 4px solid #ffc107; }
938
+ </style>
939
+ </head>
940
+ <body>
941
+ <div class="container">
942
+ <h1>Visual Regression Test Report</h1>
943
+
944
+ <div class="summary">
945
+ <div><strong>Branch:</strong> ${results[0]?.currentUrl.includes("/") ? results[0].currentUrl.split("/").slice(-2, -1)[0] : "unknown"}</div>
946
+ <div><strong>Total Stories:</strong> ${results.length}</div>
947
+ <div class="stats">
948
+ <div class="stat stat-passed">
949
+ <div class="stat-value">${passedResults.length}</div>
950
+ <div class="stat-label">Passed</div>
951
+ </div>
952
+ <div class="stat stat-changed">
953
+ <div class="stat-value">${changedResults.length}</div>
954
+ <div class="stat-label">Changed</div>
955
+ </div>
956
+ </div>
957
+ </div>
958
+
959
+ <div class="tabs">
960
+ <button class="tab active" onclick="showTab('changed')">Changed (${changedResults.length})</button>
961
+ <button class="tab" onclick="showTab('passed')">Passed (${passedResults.length})</button>
962
+ <button class="tab" onclick="showTab('all')">All (${results.length})</button>
963
+ </div>
964
+
965
+ <div id="changed-results" class="results active">
966
+ ${changedResults.map((r) => generateResultHTML(r, "changed")).join("")}
967
+ </div>
968
+
969
+ <div id="passed-results" class="results">
970
+ ${passedResults.map((r) => generateResultHTML(r, "passed")).join("")}
971
+ </div>
972
+
973
+ <div id="all-results" class="results">
974
+ ${results.map((r) => generateResultHTML(r, r.hasDiff ? "changed" : "passed")).join("")}
975
+ </div>
976
+ </div>
977
+
978
+ <script>
979
+ function showTab(tab) {
980
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
981
+ document.querySelectorAll('.results').forEach(r => r.classList.remove('active'));
982
+ event.target.classList.add('active');
983
+ document.getElementById(tab + '-results').classList.add('active');
984
+ }
985
+ </script>
986
+ </body>
987
+ </html>
988
+ `;
989
+ await (0, import_promises2.writeFile)((0, import_path3.join)(outputDir, "report.html"), html.trim());
990
+ }
991
+ function generateResultHTML(result, type) {
992
+ return `
993
+ <div class="result ${type}">
994
+ <div class="result-header">
995
+ <div class="result-title">${result.componentName || result.storyId} / ${result.storyName || ""}</div>
996
+ ${result.hasDiff ? `<div class="result-diff">${result.pixelDiff.toFixed(2)}% different | SSIM: ${result.ssimScore.toFixed(3)}</div>` : ""}
997
+ </div>
998
+ <div class="result-images">
999
+ <div class="result-image">
1000
+ <img src="file://${result.baselineUrl}" alt="Baseline">
1001
+ <div class="result-image-label">Baseline</div>
1002
+ </div>
1003
+ <div class="result-image">
1004
+ <img src="file://${result.currentUrl}" alt="Current">
1005
+ <div class="result-image-label">Current</div>
1006
+ </div>
1007
+ ${result.diffUrl ? `
1008
+ <div class="result-image">
1009
+ <img src="file://${result.diffUrl}" alt="Diff">
1010
+ <div class="result-image-label">Diff</div>
1011
+ </div>
1012
+ ` : '<div class="result-image"><div class="result-image-label">No Diff</div></div>'}
1013
+ </div>
1014
+ </div>
1015
+ `;
1016
+ }
1017
+ // Annotate the CommonJS export names for ESM import in node:
1018
+ 0 && (module.exports = {
1019
+ captureCommand,
1020
+ compareCommand,
1021
+ getChangedFiles,
1022
+ getCommitMessage,
1023
+ getCurrentBranch,
1024
+ getCurrentCommitHash,
1025
+ isWorkingDirectoryClean,
1026
+ loadConfig,
1027
+ logger,
1028
+ validateConfig
1029
+ });