@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/README.md +215 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1778 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +1029 -0
- package/package.json +48 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1778 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
10
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
|
|
29
|
+
// ../shared/dist/types.js
|
|
30
|
+
var require_types = __commonJS({
|
|
31
|
+
"../shared/dist/types.js"(exports2) {
|
|
32
|
+
"use strict";
|
|
33
|
+
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ../shared/dist/constants.js
|
|
38
|
+
var require_constants = __commonJS({
|
|
39
|
+
"../shared/dist/constants.js"(exports2) {
|
|
40
|
+
"use strict";
|
|
41
|
+
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
42
|
+
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;
|
|
43
|
+
exports2.DEFAULT_CONFIG = {
|
|
44
|
+
storybook: {
|
|
45
|
+
port: 7007,
|
|
46
|
+
storiesPattern: "src/**/__stories__/**/*.stories.?(ts|tsx|js|jsx)"
|
|
47
|
+
},
|
|
48
|
+
simulator: {
|
|
49
|
+
device: "iPhone 15 Pro",
|
|
50
|
+
os: "iOS 17.0"
|
|
51
|
+
},
|
|
52
|
+
comparison: {
|
|
53
|
+
mode: "threshold",
|
|
54
|
+
threshold: 0.01,
|
|
55
|
+
includeMetrics: true
|
|
56
|
+
},
|
|
57
|
+
baselineDir: ".visual-baselines",
|
|
58
|
+
screenshotDir: ".visual-screenshots"
|
|
59
|
+
};
|
|
60
|
+
exports2.CONFIG_FILE_NAME = ".argus.json";
|
|
61
|
+
exports2.SIMULATOR_WAIT_TIMEOUT = 3e4;
|
|
62
|
+
exports2.STORY_RENDER_TIMEOUT = 5e3;
|
|
63
|
+
exports2.STORYBOOK_CONNECTION_TIMEOUT = 6e4;
|
|
64
|
+
exports2.IMAGE_FORMATS = {
|
|
65
|
+
screenshot: "png",
|
|
66
|
+
diff: "png"
|
|
67
|
+
};
|
|
68
|
+
exports2.DIFF_THRESHOLD = 0.01;
|
|
69
|
+
exports2.SSIM_THRESHOLD = 0.95;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ../shared/dist/index.js
|
|
74
|
+
var require_dist = __commonJS({
|
|
75
|
+
"../shared/dist/index.js"(exports2) {
|
|
76
|
+
"use strict";
|
|
77
|
+
var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
|
|
78
|
+
if (k2 === void 0) k2 = k;
|
|
79
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
80
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
81
|
+
desc = { enumerable: true, get: function() {
|
|
82
|
+
return m[k];
|
|
83
|
+
} };
|
|
84
|
+
}
|
|
85
|
+
Object.defineProperty(o, k2, desc);
|
|
86
|
+
}) : (function(o, m, k, k2) {
|
|
87
|
+
if (k2 === void 0) k2 = k;
|
|
88
|
+
o[k2] = m[k];
|
|
89
|
+
}));
|
|
90
|
+
var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
|
|
91
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
|
|
92
|
+
};
|
|
93
|
+
Object.defineProperty(exports2, "__esModule", { value: true });
|
|
94
|
+
__exportStar(require_types(), exports2);
|
|
95
|
+
__exportStar(require_constants(), exports2);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// src/cli.ts
|
|
100
|
+
var import_commander = require("commander");
|
|
101
|
+
var import_chalk2 = __toESM(require("chalk"));
|
|
102
|
+
|
|
103
|
+
// src/commands/capture.ts
|
|
104
|
+
var import_promises = require("fs/promises");
|
|
105
|
+
var import_path2 = require("path");
|
|
106
|
+
var import_ora = __toESM(require("ora"));
|
|
107
|
+
|
|
108
|
+
// src/utils/config.ts
|
|
109
|
+
var import_fs = require("fs");
|
|
110
|
+
var import_path = require("path");
|
|
111
|
+
var import_shared = __toESM(require_dist());
|
|
112
|
+
function loadConfig(cwd = process.cwd()) {
|
|
113
|
+
const configPath = (0, import_path.join)(cwd, import_shared.CONFIG_FILE_NAME);
|
|
114
|
+
if (!(0, import_fs.existsSync)(configPath)) {
|
|
115
|
+
console.warn(`No ${import_shared.CONFIG_FILE_NAME} found, using defaults`);
|
|
116
|
+
return import_shared.DEFAULT_CONFIG;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const configContent = (0, import_fs.readFileSync)(configPath, "utf-8");
|
|
120
|
+
const userConfig = JSON.parse(configContent);
|
|
121
|
+
return {
|
|
122
|
+
...import_shared.DEFAULT_CONFIG,
|
|
123
|
+
...userConfig,
|
|
124
|
+
storybook: {
|
|
125
|
+
...import_shared.DEFAULT_CONFIG.storybook,
|
|
126
|
+
...userConfig.storybook
|
|
127
|
+
},
|
|
128
|
+
simulator: {
|
|
129
|
+
...import_shared.DEFAULT_CONFIG.simulator,
|
|
130
|
+
...userConfig.simulator
|
|
131
|
+
},
|
|
132
|
+
comparison: {
|
|
133
|
+
...import_shared.DEFAULT_CONFIG.comparison,
|
|
134
|
+
...userConfig.comparison
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new Error(`Failed to parse ${import_shared.CONFIG_FILE_NAME}: ${error}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function validateConfig(config) {
|
|
142
|
+
if (!config.storybook?.port) {
|
|
143
|
+
throw new Error("storybook.port is required");
|
|
144
|
+
}
|
|
145
|
+
if (!config.simulator?.device) {
|
|
146
|
+
throw new Error("simulator.device is required");
|
|
147
|
+
}
|
|
148
|
+
if (!config.comparison?.threshold || config.comparison.threshold < 0 || config.comparison.threshold > 1) {
|
|
149
|
+
throw new Error("comparison.threshold must be between 0 and 1");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/utils/git.ts
|
|
154
|
+
var import_execa = require("execa");
|
|
155
|
+
async function getCurrentBranch() {
|
|
156
|
+
try {
|
|
157
|
+
const { stdout } = await (0, import_execa.execaCommand)("git rev-parse --abbrev-ref HEAD");
|
|
158
|
+
return stdout.trim();
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw new Error("Failed to get current branch. Is this a git repository?");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function getCurrentCommitHash() {
|
|
164
|
+
try {
|
|
165
|
+
const { stdout } = await (0, import_execa.execaCommand)("git rev-parse HEAD");
|
|
166
|
+
return stdout.trim();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new Error("Failed to get commit hash");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function getCommitMessage(hash) {
|
|
172
|
+
try {
|
|
173
|
+
const cmd = hash ? `git log -1 --pretty=%B ${hash}` : "git log -1 --pretty=%B";
|
|
174
|
+
const { stdout } = await (0, import_execa.execaCommand)(cmd);
|
|
175
|
+
return stdout.trim();
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return "";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/utils/logger.ts
|
|
182
|
+
var import_chalk = __toESM(require("chalk"));
|
|
183
|
+
var logger = {
|
|
184
|
+
info: (message) => {
|
|
185
|
+
console.log(import_chalk.default.blue("\u2139"), message);
|
|
186
|
+
},
|
|
187
|
+
success: (message) => {
|
|
188
|
+
console.log(import_chalk.default.green("\u2713"), message);
|
|
189
|
+
},
|
|
190
|
+
warn: (message) => {
|
|
191
|
+
console.log(import_chalk.default.yellow("\u26A0"), message);
|
|
192
|
+
},
|
|
193
|
+
error: (message) => {
|
|
194
|
+
console.log(import_chalk.default.red("\u2716"), message);
|
|
195
|
+
},
|
|
196
|
+
debug: (message) => {
|
|
197
|
+
if (process.env.DEBUG) {
|
|
198
|
+
console.log(import_chalk.default.gray("\u203A"), message);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// src/ios/simulator.ts
|
|
204
|
+
var import_execa2 = require("execa");
|
|
205
|
+
var import_shared2 = __toESM(require_dist());
|
|
206
|
+
async function findSimulator(config) {
|
|
207
|
+
try {
|
|
208
|
+
const { stdout } = await (0, import_execa2.execaCommand)("xcrun simctl list devices --json");
|
|
209
|
+
const data = JSON.parse(stdout);
|
|
210
|
+
const matchingDevices = [];
|
|
211
|
+
for (const [runtime, devices] of Object.entries(data.devices)) {
|
|
212
|
+
for (const device of devices) {
|
|
213
|
+
if (device.name === config.device && device.isAvailable !== false) {
|
|
214
|
+
matchingDevices.push({
|
|
215
|
+
udid: device.udid,
|
|
216
|
+
name: device.name,
|
|
217
|
+
state: device.state,
|
|
218
|
+
isAvailable: device.isAvailable !== false
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (matchingDevices.length === 0) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const bootedDevice = matchingDevices.find((d) => d.state === "Booted");
|
|
227
|
+
if (bootedDevice) {
|
|
228
|
+
return bootedDevice;
|
|
229
|
+
}
|
|
230
|
+
return matchingDevices[0];
|
|
231
|
+
} catch (error) {
|
|
232
|
+
logger.error(`Failed to find simulator: ${error}`);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function bootSimulator(udid) {
|
|
237
|
+
try {
|
|
238
|
+
const { stdout } = await (0, import_execa2.execaCommand)(`xcrun simctl list devices --json`);
|
|
239
|
+
const data = JSON.parse(stdout);
|
|
240
|
+
let deviceState = "Shutdown";
|
|
241
|
+
for (const [runtime, devices] of Object.entries(data.devices)) {
|
|
242
|
+
for (const device of devices) {
|
|
243
|
+
if (device.udid === udid) {
|
|
244
|
+
deviceState = device.state;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (deviceState === "Booted") {
|
|
250
|
+
logger.info("Simulator already booted");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
logger.info("Booting simulator...");
|
|
254
|
+
await (0, import_execa2.execaCommand)(`xcrun simctl boot ${udid}`);
|
|
255
|
+
await waitForSimulator(udid, import_shared2.SIMULATOR_WAIT_TIMEOUT);
|
|
256
|
+
logger.success("Simulator booted");
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error.message?.includes("current state: Booted")) {
|
|
259
|
+
logger.info("Simulator already booted");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
throw new Error(`Failed to boot simulator: ${error}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async function waitForSimulator(udid, timeout) {
|
|
266
|
+
const startTime = Date.now();
|
|
267
|
+
while (Date.now() - startTime < timeout) {
|
|
268
|
+
try {
|
|
269
|
+
const { stdout } = await (0, import_execa2.execaCommand)("xcrun simctl list devices --json");
|
|
270
|
+
const data = JSON.parse(stdout);
|
|
271
|
+
for (const [runtime, devices] of Object.entries(data.devices)) {
|
|
272
|
+
for (const device of devices) {
|
|
273
|
+
if (device.udid === udid && device.state === "Booted") {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
}
|
|
280
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
281
|
+
}
|
|
282
|
+
throw new Error("Simulator boot timeout");
|
|
283
|
+
}
|
|
284
|
+
async function shutdownSimulator(udid) {
|
|
285
|
+
try {
|
|
286
|
+
const { stdout } = await (0, import_execa2.execaCommand)("xcrun simctl list devices --json");
|
|
287
|
+
const data = JSON.parse(stdout);
|
|
288
|
+
let deviceState = "Shutdown";
|
|
289
|
+
for (const [runtime, devices] of Object.entries(data.devices)) {
|
|
290
|
+
for (const device of devices) {
|
|
291
|
+
if (device.udid === udid) {
|
|
292
|
+
deviceState = device.state;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (deviceState === "Shutdown") {
|
|
298
|
+
logger.info("Simulator already shutdown");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
logger.info("Shutting down simulator...");
|
|
302
|
+
await (0, import_execa2.execaCommand)(`xcrun simctl shutdown ${udid}`);
|
|
303
|
+
logger.success("Simulator shutdown");
|
|
304
|
+
} catch (error) {
|
|
305
|
+
logger.warn(`Failed to shutdown simulator: ${error}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function launchApp(udid, bundleId) {
|
|
309
|
+
try {
|
|
310
|
+
logger.info(`Launching app: ${bundleId}`);
|
|
311
|
+
await (0, import_execa2.execaCommand)(`xcrun simctl launch ${udid} ${bundleId}`);
|
|
312
|
+
logger.success("App launched");
|
|
313
|
+
await new Promise((resolve) => setTimeout(resolve, 3e3));
|
|
314
|
+
} catch (error) {
|
|
315
|
+
throw new Error(`Failed to launch app: ${error}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function terminateApp(udid, bundleId) {
|
|
319
|
+
try {
|
|
320
|
+
await (0, import_execa2.execaCommand)(`xcrun simctl terminate ${udid} ${bundleId}`);
|
|
321
|
+
logger.info("App terminated");
|
|
322
|
+
} catch (error) {
|
|
323
|
+
logger.debug(`Failed to terminate app: ${error}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async function captureScreenshot(udid, outputPath) {
|
|
327
|
+
try {
|
|
328
|
+
await (0, import_execa2.execa)("xcrun", ["simctl", "io", udid, "screenshot", outputPath]);
|
|
329
|
+
logger.debug(`Screenshot saved: ${outputPath}`);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
throw new Error(`Failed to capture screenshot: ${error}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/ios/storybook.ts
|
|
336
|
+
var import_ws = __toESM(require("ws"));
|
|
337
|
+
var import_shared3 = __toESM(require_dist());
|
|
338
|
+
function createStorybookClient(port) {
|
|
339
|
+
let ws = null;
|
|
340
|
+
const url = `ws://localhost:${port}`;
|
|
341
|
+
return {
|
|
342
|
+
async connect() {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
const timeout = setTimeout(() => {
|
|
345
|
+
reject(new Error("Storybook connection timeout"));
|
|
346
|
+
}, import_shared3.STORYBOOK_CONNECTION_TIMEOUT);
|
|
347
|
+
ws = new import_ws.default(url);
|
|
348
|
+
ws.on("open", () => {
|
|
349
|
+
clearTimeout(timeout);
|
|
350
|
+
logger.success("Connected to Storybook");
|
|
351
|
+
resolve();
|
|
352
|
+
});
|
|
353
|
+
ws.on("error", (error) => {
|
|
354
|
+
clearTimeout(timeout);
|
|
355
|
+
reject(new Error(`Storybook connection error: ${error.message}`));
|
|
356
|
+
});
|
|
357
|
+
ws.on("close", () => {
|
|
358
|
+
logger.debug("Storybook connection closed");
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
disconnect() {
|
|
363
|
+
if (ws) {
|
|
364
|
+
ws.close();
|
|
365
|
+
ws = null;
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
async getStories() {
|
|
369
|
+
if (!ws) {
|
|
370
|
+
throw new Error("Not connected to Storybook");
|
|
371
|
+
}
|
|
372
|
+
return new Promise((resolve, reject) => {
|
|
373
|
+
const timeout = setTimeout(() => {
|
|
374
|
+
reject(new Error("Failed to get stories"));
|
|
375
|
+
}, 1e4);
|
|
376
|
+
const messageHandler = (data) => {
|
|
377
|
+
try {
|
|
378
|
+
const message = JSON.parse(data.toString());
|
|
379
|
+
if (message.type === "setStories") {
|
|
380
|
+
clearTimeout(timeout);
|
|
381
|
+
ws?.off("message", messageHandler);
|
|
382
|
+
const stories = Object.entries(message.stories || {}).map(([id, story]) => ({
|
|
383
|
+
id,
|
|
384
|
+
componentName: story.kind || story.title,
|
|
385
|
+
storyName: story.name || story.story,
|
|
386
|
+
title: story.title || story.kind,
|
|
387
|
+
kind: story.kind || story.title
|
|
388
|
+
}));
|
|
389
|
+
resolve(stories);
|
|
390
|
+
}
|
|
391
|
+
} catch (error) {
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
ws?.on("message", messageHandler);
|
|
395
|
+
ws?.send(JSON.stringify({ type: "getStories" }));
|
|
396
|
+
});
|
|
397
|
+
},
|
|
398
|
+
async navigateToStory(storyId) {
|
|
399
|
+
if (!ws) {
|
|
400
|
+
throw new Error("Not connected to Storybook");
|
|
401
|
+
}
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
const timeout = setTimeout(() => {
|
|
404
|
+
reject(new Error("Story navigation timeout"));
|
|
405
|
+
}, import_shared3.STORY_RENDER_TIMEOUT);
|
|
406
|
+
const messageHandler = (data) => {
|
|
407
|
+
try {
|
|
408
|
+
const message = JSON.parse(data.toString());
|
|
409
|
+
if (message.type === "storyRendered" && message.storyId === storyId) {
|
|
410
|
+
clearTimeout(timeout);
|
|
411
|
+
ws?.off("message", messageHandler);
|
|
412
|
+
resolve();
|
|
413
|
+
}
|
|
414
|
+
} catch (error) {
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
ws?.on("message", messageHandler);
|
|
418
|
+
ws?.send(
|
|
419
|
+
JSON.stringify({
|
|
420
|
+
type: "selectStory",
|
|
421
|
+
storyId
|
|
422
|
+
})
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
},
|
|
426
|
+
async waitForStory(timeout = import_shared3.STORY_RENDER_TIMEOUT) {
|
|
427
|
+
await new Promise((resolve) => setTimeout(resolve, timeout));
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function waitForStorybookServer(port, timeout = 6e4) {
|
|
432
|
+
const startTime = Date.now();
|
|
433
|
+
while (Date.now() - startTime < timeout) {
|
|
434
|
+
try {
|
|
435
|
+
const response = await fetch(`http://localhost:${port}`);
|
|
436
|
+
if (response.ok) {
|
|
437
|
+
logger.success("Storybook server is ready");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
} catch (error) {
|
|
441
|
+
}
|
|
442
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
443
|
+
}
|
|
444
|
+
throw new Error("Storybook server timeout");
|
|
445
|
+
}
|
|
446
|
+
function createStoryId(componentName, storyName) {
|
|
447
|
+
return `${componentName}-${storyName}`.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").toLowerCase();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/ios/metrics.ts
|
|
451
|
+
async function measureRenderTime(fn) {
|
|
452
|
+
const startTime = performance.now();
|
|
453
|
+
const result = await fn();
|
|
454
|
+
const endTime = performance.now();
|
|
455
|
+
return {
|
|
456
|
+
result,
|
|
457
|
+
renderTime: Math.round(endTime - startTime)
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
function collectMetrics(renderTime) {
|
|
461
|
+
return {
|
|
462
|
+
renderTime,
|
|
463
|
+
timestamp: Date.now()
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/commands/capture.ts
|
|
468
|
+
async function captureCommand(options = {}) {
|
|
469
|
+
const spinner = (0, import_ora.default)("Loading configuration...").start();
|
|
470
|
+
try {
|
|
471
|
+
const config = loadConfig();
|
|
472
|
+
validateConfig(config);
|
|
473
|
+
const branch = options.branch || await getCurrentBranch();
|
|
474
|
+
const commitHash = await getCurrentCommitHash();
|
|
475
|
+
spinner.succeed("Configuration loaded");
|
|
476
|
+
logger.info(`Branch: ${branch}`);
|
|
477
|
+
logger.info(`Commit: ${commitHash.substring(0, 7)}`);
|
|
478
|
+
spinner.start("Finding simulator...");
|
|
479
|
+
const simulator = await findSimulator(config.simulator);
|
|
480
|
+
if (!simulator) {
|
|
481
|
+
throw new Error(`Simulator not found: ${config.simulator.device}`);
|
|
482
|
+
}
|
|
483
|
+
spinner.succeed(`Found simulator: ${simulator.name}`);
|
|
484
|
+
if (!options.skipBoot) {
|
|
485
|
+
spinner.start("Booting simulator...");
|
|
486
|
+
await bootSimulator(simulator.udid);
|
|
487
|
+
spinner.succeed("Simulator booted");
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const bundleId = config.simulator.bundleId || config.simulator.appScheme;
|
|
491
|
+
if (!bundleId) {
|
|
492
|
+
throw new Error("bundleId or appScheme must be specified in config");
|
|
493
|
+
}
|
|
494
|
+
spinner.start("Launching app...");
|
|
495
|
+
await launchApp(simulator.udid, bundleId);
|
|
496
|
+
spinner.succeed("App launched");
|
|
497
|
+
spinner.start("Waiting for Storybook...");
|
|
498
|
+
await waitForStorybookServer(config.storybook.port);
|
|
499
|
+
spinner.succeed("Storybook ready");
|
|
500
|
+
spinner.start("Connecting to Storybook...");
|
|
501
|
+
const storybookClient = createStorybookClient(config.storybook.port);
|
|
502
|
+
await storybookClient.connect();
|
|
503
|
+
spinner.succeed("Connected to Storybook");
|
|
504
|
+
try {
|
|
505
|
+
spinner.start("Loading stories...");
|
|
506
|
+
const stories = await storybookClient.getStories();
|
|
507
|
+
spinner.succeed(`Found ${stories.length} stories`);
|
|
508
|
+
if (stories.length === 0) {
|
|
509
|
+
logger.warn("No stories found");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const outputDir = (0, import_path2.join)(process.cwd(), config.screenshotDir, branch);
|
|
513
|
+
await (0, import_promises.mkdir)(outputDir, { recursive: true });
|
|
514
|
+
const screenshots = [];
|
|
515
|
+
let capturedCount = 0;
|
|
516
|
+
for (const story of stories) {
|
|
517
|
+
spinner.start(`Capturing ${story.componentName}/${story.storyName} (${capturedCount + 1}/${stories.length})`);
|
|
518
|
+
try {
|
|
519
|
+
const { renderTime } = await measureRenderTime(async () => {
|
|
520
|
+
await storybookClient.navigateToStory(story.id);
|
|
521
|
+
await storybookClient.waitForStory();
|
|
522
|
+
});
|
|
523
|
+
const storyId = createStoryId(story.componentName, story.storyName);
|
|
524
|
+
const filename = `${storyId}.png`;
|
|
525
|
+
const filePath = (0, import_path2.join)(outputDir, filename);
|
|
526
|
+
await captureScreenshot(simulator.udid, filePath);
|
|
527
|
+
const metrics = collectMetrics(renderTime);
|
|
528
|
+
screenshots.push({
|
|
529
|
+
storyId,
|
|
530
|
+
kind: story.kind || story.title,
|
|
531
|
+
componentName: story.componentName,
|
|
532
|
+
storyName: story.storyName,
|
|
533
|
+
filePath,
|
|
534
|
+
branch,
|
|
535
|
+
commitHash,
|
|
536
|
+
timestamp: Date.now(),
|
|
537
|
+
renderTime: metrics.renderTime
|
|
538
|
+
});
|
|
539
|
+
capturedCount++;
|
|
540
|
+
spinner.succeed(
|
|
541
|
+
`Captured ${story.componentName}/${story.storyName} (${capturedCount}/${stories.length}) - ${renderTime}ms`
|
|
542
|
+
);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
spinner.fail(`Failed to capture ${story.componentName}/${story.storyName}`);
|
|
545
|
+
logger.error(`${error}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const metadataPath = (0, import_path2.join)(outputDir, "metadata.json");
|
|
549
|
+
await (0, import_promises.writeFile)(
|
|
550
|
+
metadataPath,
|
|
551
|
+
JSON.stringify(
|
|
552
|
+
{
|
|
553
|
+
branch,
|
|
554
|
+
commitHash,
|
|
555
|
+
timestamp: Date.now(),
|
|
556
|
+
screenshots,
|
|
557
|
+
totalStories: stories.length,
|
|
558
|
+
capturedCount
|
|
559
|
+
},
|
|
560
|
+
null,
|
|
561
|
+
2
|
|
562
|
+
)
|
|
563
|
+
);
|
|
564
|
+
logger.success(`
|
|
565
|
+
Captured ${capturedCount}/${stories.length} screenshots`);
|
|
566
|
+
logger.info(`Screenshots saved to: ${outputDir}`);
|
|
567
|
+
} finally {
|
|
568
|
+
storybookClient.disconnect();
|
|
569
|
+
}
|
|
570
|
+
await terminateApp(simulator.udid, bundleId);
|
|
571
|
+
} finally {
|
|
572
|
+
if (!options.skipShutdown) {
|
|
573
|
+
spinner.start("Shutting down simulator...");
|
|
574
|
+
await shutdownSimulator(simulator.udid);
|
|
575
|
+
spinner.succeed("Simulator shutdown");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} catch (error) {
|
|
579
|
+
spinner.fail("Capture failed");
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// src/commands/compare.ts
|
|
585
|
+
var import_promises2 = require("fs/promises");
|
|
586
|
+
var import_fs4 = require("fs");
|
|
587
|
+
var import_path3 = require("path");
|
|
588
|
+
var import_ora2 = __toESM(require("ora"));
|
|
589
|
+
|
|
590
|
+
// src/comparison/odiff.ts
|
|
591
|
+
var import_execa3 = require("execa");
|
|
592
|
+
var import_fs2 = require("fs");
|
|
593
|
+
async function compareWithODiff(baselinePath, currentPath, diffPath, threshold = 0.01) {
|
|
594
|
+
try {
|
|
595
|
+
try {
|
|
596
|
+
await (0, import_execa3.execaCommand)("which odiff");
|
|
597
|
+
} catch {
|
|
598
|
+
logger.debug("ODiff not found, skipping");
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
let cmd = `odiff "${baselinePath}" "${currentPath}" --threshold ${threshold}`;
|
|
602
|
+
if (diffPath) {
|
|
603
|
+
cmd += ` --diff-image "${diffPath}"`;
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
const { stdout } = await (0, import_execa3.execaCommand)(cmd);
|
|
607
|
+
const diffMatch = stdout.match(/Difference: ([\d.]+)%/);
|
|
608
|
+
const diffPercentage = diffMatch ? parseFloat(diffMatch[1]) : 0;
|
|
609
|
+
return {
|
|
610
|
+
match: diffPercentage <= threshold * 100,
|
|
611
|
+
diffPercentage,
|
|
612
|
+
diffPixels: 0,
|
|
613
|
+
// ODiff doesn't provide this
|
|
614
|
+
diffImagePath: diffPath && diffPercentage > 0 ? diffPath : void 0
|
|
615
|
+
};
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (error.stdout) {
|
|
618
|
+
const diffMatch = error.stdout.match(/Difference: ([\d.]+)%/);
|
|
619
|
+
const diffPercentage = diffMatch ? parseFloat(diffMatch[1]) : 100;
|
|
620
|
+
return {
|
|
621
|
+
match: false,
|
|
622
|
+
diffPercentage,
|
|
623
|
+
diffPixels: 0,
|
|
624
|
+
diffImagePath: diffPath && (0, import_fs2.existsSync)(diffPath) ? diffPath : void 0
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
} catch (error) {
|
|
630
|
+
logger.warn(`ODiff comparison failed: ${error}`);
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function isODiffInstalled() {
|
|
635
|
+
try {
|
|
636
|
+
await (0, import_execa3.execaCommand)("which odiff");
|
|
637
|
+
return true;
|
|
638
|
+
} catch {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// src/comparison/pixelmatch.ts
|
|
644
|
+
var import_pixelmatch = __toESM(require("pixelmatch"));
|
|
645
|
+
var import_pngjs = require("pngjs");
|
|
646
|
+
var import_fs3 = require("fs");
|
|
647
|
+
async function compareWithPixelmatch(baselinePath, currentPath, diffPath, threshold = 0.1) {
|
|
648
|
+
try {
|
|
649
|
+
const baseline = import_pngjs.PNG.sync.read((0, import_fs3.readFileSync)(baselinePath));
|
|
650
|
+
const current = import_pngjs.PNG.sync.read((0, import_fs3.readFileSync)(currentPath));
|
|
651
|
+
if (baseline.width !== current.width || baseline.height !== current.height) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`Image dimensions don't match: ${baseline.width}x${baseline.height} vs ${current.width}x${current.height}`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
const { width, height } = baseline;
|
|
657
|
+
const totalPixels = width * height;
|
|
658
|
+
const diff = new import_pngjs.PNG({ width, height });
|
|
659
|
+
const mismatchedPixels = (0, import_pixelmatch.default)(baseline.data, current.data, diff.data, width, height, {
|
|
660
|
+
threshold,
|
|
661
|
+
includeAA: false,
|
|
662
|
+
alpha: 0,
|
|
663
|
+
diffColor: [255, 0, 0],
|
|
664
|
+
diffColorAlt: [255, 0, 255]
|
|
665
|
+
});
|
|
666
|
+
const diffPercentage = mismatchedPixels / totalPixels * 100;
|
|
667
|
+
let diffImagePath;
|
|
668
|
+
if (diffPath && mismatchedPixels > 0) {
|
|
669
|
+
(0, import_fs3.writeFileSync)(diffPath, import_pngjs.PNG.sync.write(diff));
|
|
670
|
+
diffImagePath = diffPath;
|
|
671
|
+
logger.debug(`Diff image saved: ${diffPath}`);
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
mismatchedPixels,
|
|
675
|
+
totalPixels,
|
|
676
|
+
diffPercentage,
|
|
677
|
+
diffImagePath
|
|
678
|
+
};
|
|
679
|
+
} catch (error) {
|
|
680
|
+
throw new Error(`Pixelmatch comparison failed: ${error}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// src/comparison/ssim.ts
|
|
685
|
+
var import_sharp = __toESM(require("sharp"));
|
|
686
|
+
var import_ssim = __toESM(require("ssim.js"));
|
|
687
|
+
async function calculateSSIM(baselinePath, currentPath) {
|
|
688
|
+
try {
|
|
689
|
+
const [baselineBuffer, currentBuffer] = await Promise.all([
|
|
690
|
+
(0, import_sharp.default)(baselinePath).raw().toBuffer({ resolveWithObject: true }),
|
|
691
|
+
(0, import_sharp.default)(currentPath).raw().toBuffer({ resolveWithObject: true })
|
|
692
|
+
]);
|
|
693
|
+
if (baselineBuffer.info.width !== currentBuffer.info.width || baselineBuffer.info.height !== currentBuffer.info.height) {
|
|
694
|
+
throw new Error("Image dimensions must match for SSIM calculation");
|
|
695
|
+
}
|
|
696
|
+
const result = (0, import_ssim.default)(
|
|
697
|
+
{
|
|
698
|
+
data: new Uint8ClampedArray(baselineBuffer.data),
|
|
699
|
+
width: baselineBuffer.info.width,
|
|
700
|
+
height: baselineBuffer.info.height
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
data: new Uint8ClampedArray(currentBuffer.data),
|
|
704
|
+
width: currentBuffer.info.width,
|
|
705
|
+
height: currentBuffer.info.height
|
|
706
|
+
}
|
|
707
|
+
);
|
|
708
|
+
return {
|
|
709
|
+
score: result.mssim,
|
|
710
|
+
mssim: result.mssim
|
|
711
|
+
};
|
|
712
|
+
} catch (error) {
|
|
713
|
+
throw new Error(`SSIM calculation failed: ${error}`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// src/commands/compare.ts
|
|
718
|
+
async function compareCommand(options = {}) {
|
|
719
|
+
const spinner = (0, import_ora2.default)("Loading configuration...").start();
|
|
720
|
+
try {
|
|
721
|
+
const config = loadConfig();
|
|
722
|
+
validateConfig(config);
|
|
723
|
+
const baseBranch = options.base || "main";
|
|
724
|
+
const currentBranch = options.current || await getCurrentBranch();
|
|
725
|
+
spinner.succeed("Configuration loaded");
|
|
726
|
+
logger.info(`Comparing ${currentBranch} against ${baseBranch}`);
|
|
727
|
+
const useODiff = await isODiffInstalled();
|
|
728
|
+
if (useODiff) {
|
|
729
|
+
logger.info("Using ODiff for fast comparison");
|
|
730
|
+
} else {
|
|
731
|
+
logger.info("Using Pixelmatch for comparison (install odiff for faster results)");
|
|
732
|
+
}
|
|
733
|
+
const baselineDir = (0, import_path3.join)(process.cwd(), config.baselineDir, "ios", config.simulator.device.replace(/\s+/g, ""));
|
|
734
|
+
const currentDir = (0, import_path3.join)(process.cwd(), config.screenshotDir, currentBranch);
|
|
735
|
+
if (!(0, import_fs4.existsSync)(baselineDir)) {
|
|
736
|
+
throw new Error(`Baseline directory not found: ${baselineDir}`);
|
|
737
|
+
}
|
|
738
|
+
if (!(0, import_fs4.existsSync)(currentDir)) {
|
|
739
|
+
throw new Error(`Screenshot directory not found: ${currentDir}`);
|
|
740
|
+
}
|
|
741
|
+
const metadataPath = (0, import_path3.join)(currentDir, "metadata.json");
|
|
742
|
+
let metadata = {};
|
|
743
|
+
if ((0, import_fs4.existsSync)(metadataPath)) {
|
|
744
|
+
const metadataContent = await (0, import_promises2.readFile)(metadataPath, "utf-8");
|
|
745
|
+
metadata = JSON.parse(metadataContent);
|
|
746
|
+
}
|
|
747
|
+
const currentFiles = (await (0, import_promises2.readdir)(currentDir)).filter((f) => f.endsWith(".png") && f !== "metadata.json");
|
|
748
|
+
const baselineFiles = (await (0, import_promises2.readdir)(baselineDir)).filter((f) => f.endsWith(".png"));
|
|
749
|
+
spinner.succeed(`Found ${currentFiles.length} current screenshots, ${baselineFiles.length} baselines`);
|
|
750
|
+
const diffDir = (0, import_path3.join)(currentDir, "diffs");
|
|
751
|
+
await (0, import_promises2.mkdir)(diffDir, { recursive: true });
|
|
752
|
+
const results = [];
|
|
753
|
+
const threshold = options.threshold ?? config.comparison.threshold;
|
|
754
|
+
let comparedCount = 0;
|
|
755
|
+
let changedCount = 0;
|
|
756
|
+
let passedCount = 0;
|
|
757
|
+
let failedCount = 0;
|
|
758
|
+
for (const filename of currentFiles) {
|
|
759
|
+
spinner.start(`Comparing ${filename} (${comparedCount + 1}/${currentFiles.length})`);
|
|
760
|
+
const currentPath = (0, import_path3.join)(currentDir, filename);
|
|
761
|
+
const baselinePath = (0, import_path3.join)(baselineDir, filename);
|
|
762
|
+
if (!(0, import_fs4.existsSync)(baselinePath)) {
|
|
763
|
+
logger.warn(`No baseline found for ${filename}`);
|
|
764
|
+
results.push({
|
|
765
|
+
storyId: filename.replace(".png", ""),
|
|
766
|
+
componentName: "",
|
|
767
|
+
storyName: "",
|
|
768
|
+
baselineUrl: "",
|
|
769
|
+
currentUrl: currentPath,
|
|
770
|
+
pixelDiff: 100,
|
|
771
|
+
ssimScore: 0,
|
|
772
|
+
hasDiff: true
|
|
773
|
+
});
|
|
774
|
+
failedCount++;
|
|
775
|
+
comparedCount++;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
const diffPath = (0, import_path3.join)(diffDir, filename);
|
|
780
|
+
let pixelDiff = 0;
|
|
781
|
+
let hasDiff = false;
|
|
782
|
+
if (useODiff) {
|
|
783
|
+
const odiffResult = await compareWithODiff(baselinePath, currentPath, diffPath, threshold);
|
|
784
|
+
if (odiffResult) {
|
|
785
|
+
pixelDiff = odiffResult.diffPercentage;
|
|
786
|
+
hasDiff = !odiffResult.match;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (!useODiff) {
|
|
790
|
+
const pixelmatchResult = await compareWithPixelmatch(baselinePath, currentPath, diffPath, 0.1);
|
|
791
|
+
pixelDiff = pixelmatchResult.diffPercentage;
|
|
792
|
+
hasDiff = pixelDiff > threshold * 100;
|
|
793
|
+
}
|
|
794
|
+
const ssimResult = await calculateSSIM(baselinePath, currentPath);
|
|
795
|
+
const storyMetadata = metadata.screenshots?.find((s) => s.filePath.endsWith(filename));
|
|
796
|
+
results.push({
|
|
797
|
+
storyId: filename.replace(".png", ""),
|
|
798
|
+
kind: storyMetadata?.kind || "",
|
|
799
|
+
componentName: storyMetadata?.componentName || "",
|
|
800
|
+
storyName: storyMetadata?.storyName || "",
|
|
801
|
+
baselineUrl: baselinePath,
|
|
802
|
+
currentUrl: currentPath,
|
|
803
|
+
diffUrl: hasDiff ? diffPath : void 0,
|
|
804
|
+
pixelDiff,
|
|
805
|
+
ssimScore: ssimResult.score,
|
|
806
|
+
hasDiff,
|
|
807
|
+
renderTime: storyMetadata?.renderTime
|
|
808
|
+
});
|
|
809
|
+
if (hasDiff) {
|
|
810
|
+
changedCount++;
|
|
811
|
+
failedCount++;
|
|
812
|
+
spinner.warn(`${filename}: ${pixelDiff.toFixed(2)}% different`);
|
|
813
|
+
} else {
|
|
814
|
+
passedCount++;
|
|
815
|
+
spinner.succeed(`${filename}: passed`);
|
|
816
|
+
}
|
|
817
|
+
comparedCount++;
|
|
818
|
+
} catch (error) {
|
|
819
|
+
spinner.fail(`Failed to compare ${filename}`);
|
|
820
|
+
logger.error(`${error}`);
|
|
821
|
+
failedCount++;
|
|
822
|
+
comparedCount++;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const resultsPath = (0, import_path3.join)(currentDir, "comparison-results.json");
|
|
826
|
+
await (0, import_promises2.writeFile)(
|
|
827
|
+
resultsPath,
|
|
828
|
+
JSON.stringify(
|
|
829
|
+
{
|
|
830
|
+
baseBranch,
|
|
831
|
+
currentBranch,
|
|
832
|
+
timestamp: Date.now(),
|
|
833
|
+
totalStories: currentFiles.length,
|
|
834
|
+
comparedCount,
|
|
835
|
+
changedCount,
|
|
836
|
+
passedCount,
|
|
837
|
+
failedCount,
|
|
838
|
+
results
|
|
839
|
+
},
|
|
840
|
+
null,
|
|
841
|
+
2
|
|
842
|
+
)
|
|
843
|
+
);
|
|
844
|
+
if (options.generateReport !== false) {
|
|
845
|
+
spinner.start("Generating HTML report...");
|
|
846
|
+
await generateHTMLReport(results, currentDir, config);
|
|
847
|
+
spinner.succeed("HTML report generated");
|
|
848
|
+
}
|
|
849
|
+
console.log("\n" + "=".repeat(50));
|
|
850
|
+
logger.success(`Comparison complete: ${comparedCount} stories`);
|
|
851
|
+
logger.info(` Passed: ${passedCount}`);
|
|
852
|
+
logger.info(` Changed: ${changedCount}`);
|
|
853
|
+
logger.info(` Failed: ${failedCount}`);
|
|
854
|
+
logger.info(`
|
|
855
|
+
Results saved to: ${resultsPath}`);
|
|
856
|
+
if (options.generateReport !== false) {
|
|
857
|
+
logger.info(`HTML report: ${(0, import_path3.join)(currentDir, "report.html")}`);
|
|
858
|
+
}
|
|
859
|
+
console.log("=".repeat(50));
|
|
860
|
+
if (changedCount > 0) {
|
|
861
|
+
process.exit(1);
|
|
862
|
+
}
|
|
863
|
+
} catch (error) {
|
|
864
|
+
spinner.fail("Comparison failed");
|
|
865
|
+
throw error;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
async function generateHTMLReport(results, outputDir, config) {
|
|
869
|
+
const changedResults = results.filter((r) => r.hasDiff);
|
|
870
|
+
const passedResults = results.filter((r) => !r.hasDiff);
|
|
871
|
+
const html = `
|
|
872
|
+
<!DOCTYPE html>
|
|
873
|
+
<html lang="en">
|
|
874
|
+
<head>
|
|
875
|
+
<meta charset="UTF-8">
|
|
876
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
877
|
+
<title>Visual Regression Test Report</title>
|
|
878
|
+
<style>
|
|
879
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
880
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; background: #f5f5f5; }
|
|
881
|
+
.container { max-width: 1400px; margin: 0 auto; }
|
|
882
|
+
h1 { margin-bottom: 20px; color: #333; }
|
|
883
|
+
.summary { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
884
|
+
.stats { display: flex; gap: 20px; margin-top: 15px; }
|
|
885
|
+
.stat { flex: 1; padding: 15px; border-radius: 6px; text-align: center; }
|
|
886
|
+
.stat-passed { background: #d4edda; color: #155724; }
|
|
887
|
+
.stat-changed { background: #fff3cd; color: #856404; }
|
|
888
|
+
.stat-failed { background: #f8d7da; color: #721c24; }
|
|
889
|
+
.stat-value { font-size: 32px; font-weight: bold; }
|
|
890
|
+
.stat-label { font-size: 14px; margin-top: 5px; }
|
|
891
|
+
.tabs { display: flex; gap: 10px; margin-bottom: 20px; }
|
|
892
|
+
.tab { padding: 10px 20px; background: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
|
893
|
+
.tab.active { background: #007bff; color: white; }
|
|
894
|
+
.results { display: none; }
|
|
895
|
+
.results.active { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 20px; }
|
|
896
|
+
.result { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
897
|
+
.result-header { padding: 15px; border-bottom: 1px solid #eee; }
|
|
898
|
+
.result-title { font-weight: 600; color: #333; }
|
|
899
|
+
.result-diff { color: #856404; font-size: 12px; margin-top: 5px; }
|
|
900
|
+
.result-images { display: grid; grid-template-columns: repeat(3, 1fr); }
|
|
901
|
+
.result-image { position: relative; aspect-ratio: 1; overflow: hidden; }
|
|
902
|
+
.result-image img { width: 100%; height: 100%; object-fit: cover; }
|
|
903
|
+
.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; }
|
|
904
|
+
.passed .result-header { border-left: 4px solid #28a745; }
|
|
905
|
+
.changed .result-header { border-left: 4px solid #ffc107; }
|
|
906
|
+
</style>
|
|
907
|
+
</head>
|
|
908
|
+
<body>
|
|
909
|
+
<div class="container">
|
|
910
|
+
<h1>Visual Regression Test Report</h1>
|
|
911
|
+
|
|
912
|
+
<div class="summary">
|
|
913
|
+
<div><strong>Branch:</strong> ${results[0]?.currentUrl.includes("/") ? results[0].currentUrl.split("/").slice(-2, -1)[0] : "unknown"}</div>
|
|
914
|
+
<div><strong>Total Stories:</strong> ${results.length}</div>
|
|
915
|
+
<div class="stats">
|
|
916
|
+
<div class="stat stat-passed">
|
|
917
|
+
<div class="stat-value">${passedResults.length}</div>
|
|
918
|
+
<div class="stat-label">Passed</div>
|
|
919
|
+
</div>
|
|
920
|
+
<div class="stat stat-changed">
|
|
921
|
+
<div class="stat-value">${changedResults.length}</div>
|
|
922
|
+
<div class="stat-label">Changed</div>
|
|
923
|
+
</div>
|
|
924
|
+
</div>
|
|
925
|
+
</div>
|
|
926
|
+
|
|
927
|
+
<div class="tabs">
|
|
928
|
+
<button class="tab active" onclick="showTab('changed')">Changed (${changedResults.length})</button>
|
|
929
|
+
<button class="tab" onclick="showTab('passed')">Passed (${passedResults.length})</button>
|
|
930
|
+
<button class="tab" onclick="showTab('all')">All (${results.length})</button>
|
|
931
|
+
</div>
|
|
932
|
+
|
|
933
|
+
<div id="changed-results" class="results active">
|
|
934
|
+
${changedResults.map((r) => generateResultHTML(r, "changed")).join("")}
|
|
935
|
+
</div>
|
|
936
|
+
|
|
937
|
+
<div id="passed-results" class="results">
|
|
938
|
+
${passedResults.map((r) => generateResultHTML(r, "passed")).join("")}
|
|
939
|
+
</div>
|
|
940
|
+
|
|
941
|
+
<div id="all-results" class="results">
|
|
942
|
+
${results.map((r) => generateResultHTML(r, r.hasDiff ? "changed" : "passed")).join("")}
|
|
943
|
+
</div>
|
|
944
|
+
</div>
|
|
945
|
+
|
|
946
|
+
<script>
|
|
947
|
+
function showTab(tab) {
|
|
948
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
949
|
+
document.querySelectorAll('.results').forEach(r => r.classList.remove('active'));
|
|
950
|
+
event.target.classList.add('active');
|
|
951
|
+
document.getElementById(tab + '-results').classList.add('active');
|
|
952
|
+
}
|
|
953
|
+
</script>
|
|
954
|
+
</body>
|
|
955
|
+
</html>
|
|
956
|
+
`;
|
|
957
|
+
await (0, import_promises2.writeFile)((0, import_path3.join)(outputDir, "report.html"), html.trim());
|
|
958
|
+
}
|
|
959
|
+
function generateResultHTML(result, type) {
|
|
960
|
+
return `
|
|
961
|
+
<div class="result ${type}">
|
|
962
|
+
<div class="result-header">
|
|
963
|
+
<div class="result-title">${result.componentName || result.storyId} / ${result.storyName || ""}</div>
|
|
964
|
+
${result.hasDiff ? `<div class="result-diff">${result.pixelDiff.toFixed(2)}% different | SSIM: ${result.ssimScore.toFixed(3)}</div>` : ""}
|
|
965
|
+
</div>
|
|
966
|
+
<div class="result-images">
|
|
967
|
+
<div class="result-image">
|
|
968
|
+
<img src="file://${result.baselineUrl}" alt="Baseline">
|
|
969
|
+
<div class="result-image-label">Baseline</div>
|
|
970
|
+
</div>
|
|
971
|
+
<div class="result-image">
|
|
972
|
+
<img src="file://${result.currentUrl}" alt="Current">
|
|
973
|
+
<div class="result-image-label">Current</div>
|
|
974
|
+
</div>
|
|
975
|
+
${result.diffUrl ? `
|
|
976
|
+
<div class="result-image">
|
|
977
|
+
<img src="file://${result.diffUrl}" alt="Diff">
|
|
978
|
+
<div class="result-image-label">Diff</div>
|
|
979
|
+
</div>
|
|
980
|
+
` : '<div class="result-image"><div class="result-image-label">No Diff</div></div>'}
|
|
981
|
+
</div>
|
|
982
|
+
</div>
|
|
983
|
+
`;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// src/commands/screenshot.ts
|
|
987
|
+
var import_promises3 = require("fs/promises");
|
|
988
|
+
var import_path4 = require("path");
|
|
989
|
+
var import_ora3 = __toESM(require("ora"));
|
|
990
|
+
async function screenshotCommand(options = {}) {
|
|
991
|
+
const spinner = (0, import_ora3.default)("Loading configuration...").start();
|
|
992
|
+
try {
|
|
993
|
+
const config = loadConfig();
|
|
994
|
+
validateConfig(config);
|
|
995
|
+
const branch = options.branch || await getCurrentBranch();
|
|
996
|
+
const commitHash = await getCurrentCommitHash();
|
|
997
|
+
spinner.succeed("Configuration loaded");
|
|
998
|
+
logger.info(`Branch: ${branch}`);
|
|
999
|
+
spinner.start("Finding simulator...");
|
|
1000
|
+
const simulator = await findSimulator(config.simulator);
|
|
1001
|
+
if (!simulator) {
|
|
1002
|
+
throw new Error(`Simulator not found: ${config.simulator.device}`);
|
|
1003
|
+
}
|
|
1004
|
+
if (simulator.state !== "Booted") {
|
|
1005
|
+
throw new Error(`Simulator is not booted. Please boot it first.`);
|
|
1006
|
+
}
|
|
1007
|
+
spinner.succeed(`Found simulator: ${simulator.name} (${simulator.state})`);
|
|
1008
|
+
const outputDir = (0, import_path4.join)(process.cwd(), config.screenshotDir, branch);
|
|
1009
|
+
await (0, import_promises3.mkdir)(outputDir, { recursive: true });
|
|
1010
|
+
const name = options.name || `screenshot-${Date.now()}`;
|
|
1011
|
+
const filename = `${name}.png`;
|
|
1012
|
+
const filePath = (0, import_path4.join)(outputDir, filename);
|
|
1013
|
+
spinner.start("Capturing screenshot...");
|
|
1014
|
+
await captureScreenshot(simulator.udid, filePath);
|
|
1015
|
+
spinner.succeed(`Screenshot saved: ${filePath}`);
|
|
1016
|
+
const metadataPath = (0, import_path4.join)(outputDir, "metadata.json");
|
|
1017
|
+
await (0, import_promises3.writeFile)(
|
|
1018
|
+
metadataPath,
|
|
1019
|
+
JSON.stringify(
|
|
1020
|
+
{
|
|
1021
|
+
branch,
|
|
1022
|
+
commitHash,
|
|
1023
|
+
timestamp: Date.now(),
|
|
1024
|
+
screenshots: [{ name, filePath, timestamp: Date.now() }]
|
|
1025
|
+
},
|
|
1026
|
+
null,
|
|
1027
|
+
2
|
|
1028
|
+
)
|
|
1029
|
+
);
|
|
1030
|
+
logger.success(`
|
|
1031
|
+
Screenshot captured successfully!`);
|
|
1032
|
+
logger.info(`File: ${filePath}`);
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
spinner.fail("Screenshot capture failed");
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/commands/list-stories.ts
|
|
1040
|
+
var import_ora4 = __toESM(require("ora"));
|
|
1041
|
+
|
|
1042
|
+
// src/storybook/parser.ts
|
|
1043
|
+
var import_promises4 = require("fs/promises");
|
|
1044
|
+
var import_glob = require("glob");
|
|
1045
|
+
var import_path5 = require("path");
|
|
1046
|
+
async function parseStoryFiles(projectPath, pattern) {
|
|
1047
|
+
const stories = [];
|
|
1048
|
+
try {
|
|
1049
|
+
const storyFiles = await (0, import_glob.glob)(pattern, { cwd: projectPath });
|
|
1050
|
+
logger.debug(`Found ${storyFiles.length} story files`);
|
|
1051
|
+
for (const file of storyFiles) {
|
|
1052
|
+
const filePath = (0, import_path5.join)(projectPath, file);
|
|
1053
|
+
const content = await (0, import_promises4.readFile)(filePath, "utf-8");
|
|
1054
|
+
const fileStories = extractStoriesFromFile(content, file);
|
|
1055
|
+
stories.push(...fileStories);
|
|
1056
|
+
}
|
|
1057
|
+
logger.debug(`Parsed ${stories.length} total stories`);
|
|
1058
|
+
return stories;
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
logger.error(`Failed to parse story files: ${error}`);
|
|
1061
|
+
return [];
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
function extractStoriesFromFile(content, filename) {
|
|
1065
|
+
const stories = [];
|
|
1066
|
+
const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/i);
|
|
1067
|
+
const title = titleMatch ? titleMatch[1] : "";
|
|
1068
|
+
if (!title) {
|
|
1069
|
+
logger.debug(`No title found in ${filename}`);
|
|
1070
|
+
return [];
|
|
1071
|
+
}
|
|
1072
|
+
const titleParts = title.split("/");
|
|
1073
|
+
const componentName = titleParts[titleParts.length - 1];
|
|
1074
|
+
const kind = title;
|
|
1075
|
+
const storyExportRegex = /export\s+const\s+(\w+)(?::\s*Story(?:Obj)?(?:<[^>]+>)?)?\s*=/g;
|
|
1076
|
+
let match;
|
|
1077
|
+
while ((match = storyExportRegex.exec(content)) !== null) {
|
|
1078
|
+
const storyName = match[1];
|
|
1079
|
+
if (storyName === "meta" || storyName === "default") continue;
|
|
1080
|
+
const id = generateStoryId(title, storyName);
|
|
1081
|
+
stories.push({
|
|
1082
|
+
id,
|
|
1083
|
+
componentName,
|
|
1084
|
+
storyName,
|
|
1085
|
+
title,
|
|
1086
|
+
kind
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
return stories;
|
|
1090
|
+
}
|
|
1091
|
+
function generateStoryId(title, storyName) {
|
|
1092
|
+
const normalizedTitle = title.toLowerCase().replace(/\s+/g, "-").replace(/\//g, "-");
|
|
1093
|
+
const normalizedStory = storyName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase().replace(/\s+/g, "-");
|
|
1094
|
+
return `${normalizedTitle}--${normalizedStory}`;
|
|
1095
|
+
}
|
|
1096
|
+
async function getAllStories(projectPath, pattern) {
|
|
1097
|
+
return parseStoryFiles(projectPath, pattern);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/commands/list-stories.ts
|
|
1101
|
+
async function listStoriesCommand(options = {}) {
|
|
1102
|
+
const spinner = (0, import_ora4.default)("Loading configuration...").start();
|
|
1103
|
+
try {
|
|
1104
|
+
const config = loadConfig();
|
|
1105
|
+
validateConfig(config);
|
|
1106
|
+
spinner.succeed("Configuration loaded");
|
|
1107
|
+
spinner.start("Parsing story files...");
|
|
1108
|
+
const stories = await getAllStories(process.cwd(), config.storybook.storiesPattern || "src/**/*.stories.tsx");
|
|
1109
|
+
spinner.succeed(`Found ${stories.length} stories`);
|
|
1110
|
+
if (options.json) {
|
|
1111
|
+
console.log(JSON.stringify(stories, null, 2));
|
|
1112
|
+
} else {
|
|
1113
|
+
const byComponent = {};
|
|
1114
|
+
for (const story of stories) {
|
|
1115
|
+
const key = story.title || story.componentName;
|
|
1116
|
+
if (!byComponent[key]) {
|
|
1117
|
+
byComponent[key] = [];
|
|
1118
|
+
}
|
|
1119
|
+
byComponent[key].push(story);
|
|
1120
|
+
}
|
|
1121
|
+
console.log("");
|
|
1122
|
+
for (const [component, componentStories] of Object.entries(byComponent)) {
|
|
1123
|
+
console.log(`\u{1F4C1} ${component}`);
|
|
1124
|
+
for (const story of componentStories) {
|
|
1125
|
+
console.log(` \u2514\u2500 ${story.storyName} (${story.id})`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
console.log("");
|
|
1129
|
+
logger.info(`Total: ${stories.length} stories in ${Object.keys(byComponent).length} components`);
|
|
1130
|
+
}
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
spinner.fail("Failed to list stories");
|
|
1133
|
+
throw error;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// src/commands/capture-all.ts
|
|
1138
|
+
var import_promises5 = require("fs/promises");
|
|
1139
|
+
var import_path6 = require("path");
|
|
1140
|
+
var import_ora5 = __toESM(require("ora"));
|
|
1141
|
+
var import_execa4 = require("execa");
|
|
1142
|
+
async function captureAllCommand(options = {}) {
|
|
1143
|
+
const spinner = (0, import_ora5.default)("Loading configuration...").start();
|
|
1144
|
+
try {
|
|
1145
|
+
const config = loadConfig();
|
|
1146
|
+
validateConfig(config);
|
|
1147
|
+
const branch = options.branch || await getCurrentBranch();
|
|
1148
|
+
const commitHash = await getCurrentCommitHash();
|
|
1149
|
+
spinner.succeed("Configuration loaded");
|
|
1150
|
+
logger.info(`Branch: ${branch}`);
|
|
1151
|
+
logger.info(`Commit: ${commitHash.substring(0, 7)}`);
|
|
1152
|
+
const scheme = options.scheme || config.storybook.scheme || "app.formhealth.io";
|
|
1153
|
+
logger.info(`Using URL scheme: ${scheme}`);
|
|
1154
|
+
spinner.start("Finding simulator...");
|
|
1155
|
+
const simulator = await findSimulator(config.simulator);
|
|
1156
|
+
if (!simulator) {
|
|
1157
|
+
throw new Error(`Simulator not found: ${config.simulator.device}`);
|
|
1158
|
+
}
|
|
1159
|
+
if (simulator.state !== "Booted") {
|
|
1160
|
+
throw new Error(`Simulator is not booted. Please boot it and launch the app with Storybook enabled.`);
|
|
1161
|
+
}
|
|
1162
|
+
spinner.succeed(`Found simulator: ${simulator.name} (${simulator.state})`);
|
|
1163
|
+
spinner.start("Parsing story files...");
|
|
1164
|
+
let stories = await getAllStories(
|
|
1165
|
+
process.cwd(),
|
|
1166
|
+
config.storybook.storiesPattern || "src/**/*.stories.tsx"
|
|
1167
|
+
);
|
|
1168
|
+
if (options.filter) {
|
|
1169
|
+
const filterRegex = new RegExp(options.filter, "i");
|
|
1170
|
+
stories = stories.filter(
|
|
1171
|
+
(s) => filterRegex.test(s.id) || filterRegex.test(s.componentName) || filterRegex.test(s.storyName) || filterRegex.test(s.title)
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
spinner.succeed(`Found ${stories.length} stories to capture`);
|
|
1175
|
+
if (stories.length === 0) {
|
|
1176
|
+
logger.warn("No stories found matching criteria");
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const outputDir = (0, import_path6.join)(process.cwd(), config.screenshotDir, branch);
|
|
1180
|
+
await (0, import_promises5.mkdir)(outputDir, { recursive: true });
|
|
1181
|
+
const delay = options.delay ?? 1500;
|
|
1182
|
+
const startDelay = options.startDelay ?? 3e3;
|
|
1183
|
+
const firstStory = stories[0];
|
|
1184
|
+
const firstUrl = `${scheme}://?STORYBOOK_STORY_ID=${firstStory.id}`;
|
|
1185
|
+
spinner.start("Navigating to first story...");
|
|
1186
|
+
await (0, import_execa4.execa)("xcrun", ["simctl", "openurl", simulator.udid, firstUrl]);
|
|
1187
|
+
spinner.succeed("App ready");
|
|
1188
|
+
spinner.start(`Waiting ${startDelay}ms for story to render...`);
|
|
1189
|
+
await new Promise((resolve) => setTimeout(resolve, startDelay));
|
|
1190
|
+
spinner.succeed("Ready to capture");
|
|
1191
|
+
const screenshots = [];
|
|
1192
|
+
let capturedCount = 0;
|
|
1193
|
+
let failedCount = 0;
|
|
1194
|
+
for (let i = 0; i < stories.length; i++) {
|
|
1195
|
+
const story = stories[i];
|
|
1196
|
+
spinner.start(
|
|
1197
|
+
`Capturing ${story.title}/${story.storyName} (${capturedCount + 1}/${stories.length})`
|
|
1198
|
+
);
|
|
1199
|
+
try {
|
|
1200
|
+
if (i > 0) {
|
|
1201
|
+
const url = `${scheme}://?STORYBOOK_STORY_ID=${story.id}`;
|
|
1202
|
+
await (0, import_execa4.execa)("xcrun", ["simctl", "openurl", simulator.udid, url]);
|
|
1203
|
+
}
|
|
1204
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1205
|
+
const filename = `${story.id}.png`;
|
|
1206
|
+
const filePath = (0, import_path6.join)(outputDir, filename);
|
|
1207
|
+
const startTime = performance.now();
|
|
1208
|
+
await captureScreenshot(simulator.udid, filePath);
|
|
1209
|
+
const renderTime = Math.round(performance.now() - startTime);
|
|
1210
|
+
screenshots.push({
|
|
1211
|
+
storyId: story.id,
|
|
1212
|
+
kind: story.kind || story.title,
|
|
1213
|
+
// Full path like "UI/Button"
|
|
1214
|
+
componentName: story.componentName,
|
|
1215
|
+
storyName: story.storyName,
|
|
1216
|
+
filePath,
|
|
1217
|
+
branch,
|
|
1218
|
+
commitHash,
|
|
1219
|
+
timestamp: Date.now(),
|
|
1220
|
+
renderTime
|
|
1221
|
+
});
|
|
1222
|
+
capturedCount++;
|
|
1223
|
+
spinner.succeed(
|
|
1224
|
+
`Captured ${story.title}/${story.storyName} (${capturedCount}/${stories.length})`
|
|
1225
|
+
);
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
failedCount++;
|
|
1228
|
+
spinner.fail(`Failed to capture ${story.title}/${story.storyName}`);
|
|
1229
|
+
logger.error(`${error}`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const metadataPath = (0, import_path6.join)(outputDir, "metadata.json");
|
|
1233
|
+
await (0, import_promises5.writeFile)(
|
|
1234
|
+
metadataPath,
|
|
1235
|
+
JSON.stringify(
|
|
1236
|
+
{
|
|
1237
|
+
branch,
|
|
1238
|
+
commitHash,
|
|
1239
|
+
timestamp: Date.now(),
|
|
1240
|
+
screenshots,
|
|
1241
|
+
totalStories: stories.length,
|
|
1242
|
+
capturedCount,
|
|
1243
|
+
failedCount
|
|
1244
|
+
},
|
|
1245
|
+
null,
|
|
1246
|
+
2
|
|
1247
|
+
)
|
|
1248
|
+
);
|
|
1249
|
+
console.log("\n" + "=".repeat(50));
|
|
1250
|
+
logger.success(`Capture complete!`);
|
|
1251
|
+
logger.info(` Total stories: ${stories.length}`);
|
|
1252
|
+
logger.info(` Captured: ${capturedCount}`);
|
|
1253
|
+
logger.info(` Failed: ${failedCount}`);
|
|
1254
|
+
logger.info(`
|
|
1255
|
+
Screenshots saved to: ${outputDir}`);
|
|
1256
|
+
console.log("=".repeat(50));
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
spinner.fail("Capture failed");
|
|
1259
|
+
throw error;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/commands/upload.ts
|
|
1264
|
+
var import_promises6 = require("fs/promises");
|
|
1265
|
+
var import_fs5 = require("fs");
|
|
1266
|
+
var import_path7 = require("path");
|
|
1267
|
+
var import_ora6 = __toESM(require("ora"));
|
|
1268
|
+
async function uploadCommand(options = {}) {
|
|
1269
|
+
const spinner = (0, import_ora6.default)("Loading configuration...").start();
|
|
1270
|
+
try {
|
|
1271
|
+
const config = loadConfig();
|
|
1272
|
+
validateConfig(config);
|
|
1273
|
+
const branch = options.branch || await getCurrentBranch();
|
|
1274
|
+
const apiUrl = options.apiUrl || config.apiUrl;
|
|
1275
|
+
if (!apiUrl) {
|
|
1276
|
+
throw new Error("API URL not configured. Set apiUrl in config or pass --api-url");
|
|
1277
|
+
}
|
|
1278
|
+
spinner.succeed("Configuration loaded");
|
|
1279
|
+
spinner.start("Getting git info...");
|
|
1280
|
+
const commitHash = await getCurrentCommitHash();
|
|
1281
|
+
const commitMessage = await getCommitMessage();
|
|
1282
|
+
spinner.succeed(`Branch: ${branch}, Commit: ${commitHash.slice(0, 7)}`);
|
|
1283
|
+
const currentDir = (0, import_path7.join)(process.cwd(), config.screenshotDir, branch);
|
|
1284
|
+
const resultsPath = (0, import_path7.join)(currentDir, "comparison-results.json");
|
|
1285
|
+
if (!(0, import_fs5.existsSync)(resultsPath)) {
|
|
1286
|
+
throw new Error(
|
|
1287
|
+
`Comparison results not found at ${resultsPath}. Run 'compare' command first.`
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
spinner.start("Loading comparison results...");
|
|
1291
|
+
const resultsContent = await (0, import_promises6.readFile)(resultsPath, "utf-8");
|
|
1292
|
+
const results = JSON.parse(resultsContent);
|
|
1293
|
+
spinner.succeed(`Loaded ${results.results.length} story results`);
|
|
1294
|
+
const payload = {
|
|
1295
|
+
branch,
|
|
1296
|
+
baseBranch: results.baseBranch,
|
|
1297
|
+
commitHash,
|
|
1298
|
+
commitMessage,
|
|
1299
|
+
stories: results.results.map((r) => ({
|
|
1300
|
+
storyId: r.storyId,
|
|
1301
|
+
kind: r.kind || extractKindFromStoryId(r.storyId),
|
|
1302
|
+
// Full path like "UI/Button"
|
|
1303
|
+
componentName: r.componentName || extractComponentName(r.storyId),
|
|
1304
|
+
storyName: r.storyName || extractStoryName(r.storyId),
|
|
1305
|
+
baselineUrl: r.baselineUrl || void 0,
|
|
1306
|
+
currentUrl: r.currentUrl,
|
|
1307
|
+
diffUrl: r.diffUrl || void 0,
|
|
1308
|
+
pixelDiff: r.pixelDiff,
|
|
1309
|
+
ssimScore: r.ssimScore,
|
|
1310
|
+
hasDiff: r.hasDiff,
|
|
1311
|
+
isNew: !r.baselineUrl
|
|
1312
|
+
}))
|
|
1313
|
+
};
|
|
1314
|
+
spinner.start(`Uploading to ${apiUrl}/api/upload...`);
|
|
1315
|
+
const response = await fetch(`${apiUrl}/api/upload`, {
|
|
1316
|
+
method: "POST",
|
|
1317
|
+
headers: {
|
|
1318
|
+
"Content-Type": "application/json"
|
|
1319
|
+
},
|
|
1320
|
+
body: JSON.stringify(payload)
|
|
1321
|
+
});
|
|
1322
|
+
if (!response.ok) {
|
|
1323
|
+
const errorText = await response.text();
|
|
1324
|
+
throw new Error(`Upload failed: ${response.status} - ${errorText}`);
|
|
1325
|
+
}
|
|
1326
|
+
const result = await response.json();
|
|
1327
|
+
spinner.succeed("Upload complete!");
|
|
1328
|
+
console.log("\n" + "=".repeat(50));
|
|
1329
|
+
logger.success("Test results uploaded successfully");
|
|
1330
|
+
logger.info(` Test ID: ${result.testId}`);
|
|
1331
|
+
logger.info(` View results: ${apiUrl}${result.url}`);
|
|
1332
|
+
console.log("=".repeat(50));
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
spinner.fail("Upload failed");
|
|
1335
|
+
throw error;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
function extractComponentName(storyId) {
|
|
1339
|
+
const parts = storyId.split("--");
|
|
1340
|
+
if (parts.length > 0) {
|
|
1341
|
+
return parts[0].split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
1342
|
+
}
|
|
1343
|
+
return storyId;
|
|
1344
|
+
}
|
|
1345
|
+
function extractStoryName(storyId) {
|
|
1346
|
+
const parts = storyId.split("--");
|
|
1347
|
+
if (parts.length > 1) {
|
|
1348
|
+
return parts[1].split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
1349
|
+
}
|
|
1350
|
+
return "Default";
|
|
1351
|
+
}
|
|
1352
|
+
function extractKindFromStoryId(storyId) {
|
|
1353
|
+
const parts = storyId.split("--");
|
|
1354
|
+
if (parts.length > 0) {
|
|
1355
|
+
const titleParts = parts[0].split("-");
|
|
1356
|
+
if (titleParts.length >= 2) {
|
|
1357
|
+
const dir = titleParts.slice(0, -1).join("/");
|
|
1358
|
+
const comp = titleParts[titleParts.length - 1].charAt(0).toUpperCase() + titleParts[titleParts.length - 1].slice(1);
|
|
1359
|
+
return `${dir}/${comp}`;
|
|
1360
|
+
}
|
|
1361
|
+
return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
|
1362
|
+
}
|
|
1363
|
+
return storyId;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// src/commands/init.ts
|
|
1367
|
+
var import_promises7 = require("fs/promises");
|
|
1368
|
+
var import_fs6 = require("fs");
|
|
1369
|
+
var import_path8 = require("path");
|
|
1370
|
+
var import_ora7 = __toESM(require("ora"));
|
|
1371
|
+
var import_child_process = require("child_process");
|
|
1372
|
+
var import_shared4 = __toESM(require_dist());
|
|
1373
|
+
async function initCommand(options = {}) {
|
|
1374
|
+
const configPath = (0, import_path8.join)(process.cwd(), import_shared4.CONFIG_FILE_NAME);
|
|
1375
|
+
if ((0, import_fs6.existsSync)(configPath) && !options.force) {
|
|
1376
|
+
logger.warn(`${import_shared4.CONFIG_FILE_NAME} already exists. Use --force to overwrite.`);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
console.log("\n\u{1F441}\uFE0F Argus - Visual Regression Testing for React Native\n");
|
|
1380
|
+
console.log("Setting up visual testing for your project...\n");
|
|
1381
|
+
const spinner = (0, import_ora7.default)("Detecting project configuration...").start();
|
|
1382
|
+
let storybookPort = 7007;
|
|
1383
|
+
let storiesPattern = "src/**/*.stories.?(ts|tsx|js|jsx)";
|
|
1384
|
+
const storybookConfigPaths = [
|
|
1385
|
+
".storybook/main.ts",
|
|
1386
|
+
".storybook/main.js",
|
|
1387
|
+
".rnstorybook/main.ts",
|
|
1388
|
+
".rnstorybook/main.js"
|
|
1389
|
+
];
|
|
1390
|
+
for (const configPath2 of storybookConfigPaths) {
|
|
1391
|
+
if ((0, import_fs6.existsSync)((0, import_path8.join)(process.cwd(), configPath2))) {
|
|
1392
|
+
spinner.text = `Found Storybook config at ${configPath2}`;
|
|
1393
|
+
try {
|
|
1394
|
+
const content = await (0, import_promises7.readFile)((0, import_path8.join)(process.cwd(), configPath2), "utf-8");
|
|
1395
|
+
const storiesMatch = content.match(/stories:\s*\[['"]([^'"]+)['"]/);
|
|
1396
|
+
if (storiesMatch) {
|
|
1397
|
+
storiesPattern = storiesMatch[1];
|
|
1398
|
+
}
|
|
1399
|
+
} catch {
|
|
1400
|
+
}
|
|
1401
|
+
break;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
let deviceName = "iPhone 15 Pro";
|
|
1405
|
+
let osVersion = "iOS 17.0";
|
|
1406
|
+
try {
|
|
1407
|
+
const simulators = (0, import_child_process.execSync)("xcrun simctl list devices available -j", { encoding: "utf-8" });
|
|
1408
|
+
const data = JSON.parse(simulators);
|
|
1409
|
+
const runtimes = Object.keys(data.devices).filter((r) => r.includes("iOS")).sort().reverse();
|
|
1410
|
+
if (runtimes.length > 0) {
|
|
1411
|
+
const latestRuntime = runtimes[0];
|
|
1412
|
+
osVersion = latestRuntime.replace("com.apple.CoreSimulator.SimRuntime.", "").replace("-", " ").replace(".", " ");
|
|
1413
|
+
const devices = data.devices[latestRuntime];
|
|
1414
|
+
if (devices && devices.length > 0) {
|
|
1415
|
+
const proDevice = devices.find((d) => d.name.includes("Pro") && !d.name.includes("Max"));
|
|
1416
|
+
deviceName = proDevice ? proDevice.name : devices[0].name;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
spinner.succeed("Detected iOS simulators");
|
|
1420
|
+
} catch {
|
|
1421
|
+
spinner.warn("Could not detect simulators, using defaults");
|
|
1422
|
+
}
|
|
1423
|
+
let bundleId = "com.example.app";
|
|
1424
|
+
let scheme = "myapp";
|
|
1425
|
+
if ((0, import_fs6.existsSync)((0, import_path8.join)(process.cwd(), "app.json"))) {
|
|
1426
|
+
try {
|
|
1427
|
+
const appJson = JSON.parse(await (0, import_promises7.readFile)((0, import_path8.join)(process.cwd(), "app.json"), "utf-8"));
|
|
1428
|
+
if (appJson.expo?.ios?.bundleIdentifier) {
|
|
1429
|
+
bundleId = appJson.expo.ios.bundleIdentifier;
|
|
1430
|
+
}
|
|
1431
|
+
if (appJson.expo?.scheme) {
|
|
1432
|
+
scheme = appJson.expo.scheme;
|
|
1433
|
+
}
|
|
1434
|
+
spinner.succeed("Detected Expo configuration");
|
|
1435
|
+
} catch {
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
const config = {
|
|
1439
|
+
storybook: {
|
|
1440
|
+
port: storybookPort,
|
|
1441
|
+
storiesPattern,
|
|
1442
|
+
scheme
|
|
1443
|
+
},
|
|
1444
|
+
simulator: {
|
|
1445
|
+
device: deviceName,
|
|
1446
|
+
os: osVersion,
|
|
1447
|
+
bundleId
|
|
1448
|
+
},
|
|
1449
|
+
comparison: {
|
|
1450
|
+
mode: "threshold",
|
|
1451
|
+
threshold: 0.01,
|
|
1452
|
+
includeMetrics: true
|
|
1453
|
+
},
|
|
1454
|
+
baselineDir: ".visual-baselines",
|
|
1455
|
+
screenshotDir: ".visual-screenshots"
|
|
1456
|
+
};
|
|
1457
|
+
spinner.start("Creating configuration file...");
|
|
1458
|
+
await (0, import_promises7.writeFile)(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
1459
|
+
spinner.succeed(`Created ${import_shared4.CONFIG_FILE_NAME}`);
|
|
1460
|
+
const gitignorePath = (0, import_path8.join)(process.cwd(), ".gitignore");
|
|
1461
|
+
if ((0, import_fs6.existsSync)(gitignorePath)) {
|
|
1462
|
+
const gitignore = await (0, import_promises7.readFile)(gitignorePath, "utf-8");
|
|
1463
|
+
const additions = [];
|
|
1464
|
+
if (!gitignore.includes(".visual-screenshots")) {
|
|
1465
|
+
additions.push(".visual-screenshots/");
|
|
1466
|
+
}
|
|
1467
|
+
if (additions.length > 0) {
|
|
1468
|
+
await (0, import_promises7.writeFile)(gitignorePath, gitignore + "\n# Argus\n" + additions.join("\n") + "\n");
|
|
1469
|
+
spinner.succeed("Updated .gitignore");
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const packageJsonPath = (0, import_path8.join)(process.cwd(), "package.json");
|
|
1473
|
+
if ((0, import_fs6.existsSync)(packageJsonPath)) {
|
|
1474
|
+
const packageJson = JSON.parse(await (0, import_promises7.readFile)(packageJsonPath, "utf-8"));
|
|
1475
|
+
console.log("\n\u{1F4DD} Add these scripts to your package.json:\n");
|
|
1476
|
+
console.log(' "scripts": {');
|
|
1477
|
+
console.log(' "visual:test": "argus test",');
|
|
1478
|
+
console.log(' "visual:capture": "argus capture-all",');
|
|
1479
|
+
console.log(' "visual:compare": "argus compare",');
|
|
1480
|
+
console.log(' "visual:baseline": "argus baseline --update"');
|
|
1481
|
+
console.log(" }");
|
|
1482
|
+
}
|
|
1483
|
+
console.log("\n" + "=".repeat(50));
|
|
1484
|
+
console.log("\n\u2705 Argus initialized!\n");
|
|
1485
|
+
console.log("Configuration saved to:", import_shared4.CONFIG_FILE_NAME);
|
|
1486
|
+
console.log("\nDetected settings:");
|
|
1487
|
+
console.log(` \u2022 Simulator: ${deviceName} (${osVersion})`);
|
|
1488
|
+
console.log(` \u2022 Bundle ID: ${bundleId}`);
|
|
1489
|
+
console.log(` \u2022 URL Scheme: ${scheme}`);
|
|
1490
|
+
console.log(` \u2022 Stories: ${storiesPattern}`);
|
|
1491
|
+
console.log("\nNext steps:");
|
|
1492
|
+
console.log(" 1. Review and edit", import_shared4.CONFIG_FILE_NAME);
|
|
1493
|
+
console.log(" 2. Run: argus capture-all");
|
|
1494
|
+
console.log(" 3. Run: argus baseline --update");
|
|
1495
|
+
console.log(" 4. Make UI changes and run: argus test");
|
|
1496
|
+
console.log("\n" + "=".repeat(50) + "\n");
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// src/commands/baseline.ts
|
|
1500
|
+
var import_promises8 = require("fs/promises");
|
|
1501
|
+
var import_fs7 = require("fs");
|
|
1502
|
+
var import_path9 = require("path");
|
|
1503
|
+
var import_ora8 = __toESM(require("ora"));
|
|
1504
|
+
async function baselineCommand(options = {}) {
|
|
1505
|
+
const spinner = (0, import_ora8.default)("Loading configuration...").start();
|
|
1506
|
+
try {
|
|
1507
|
+
const config = loadConfig();
|
|
1508
|
+
validateConfig(config);
|
|
1509
|
+
spinner.succeed("Configuration loaded");
|
|
1510
|
+
const deviceDir = config.simulator.device.replace(/\s+/g, "");
|
|
1511
|
+
const baselineDir = (0, import_path9.join)(process.cwd(), config.baselineDir, "ios", deviceDir);
|
|
1512
|
+
const branch = options.branch || await getCurrentBranch();
|
|
1513
|
+
const screenshotDir = (0, import_path9.join)(process.cwd(), config.screenshotDir, branch);
|
|
1514
|
+
if (options.clear) {
|
|
1515
|
+
if (!(0, import_fs7.existsSync)(baselineDir)) {
|
|
1516
|
+
logger.warn("No baselines to clear");
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
spinner.start("Clearing baselines...");
|
|
1520
|
+
await (0, import_promises8.rm)(baselineDir, { recursive: true, force: true });
|
|
1521
|
+
spinner.succeed("Baselines cleared");
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
if (options.update) {
|
|
1525
|
+
if (!(0, import_fs7.existsSync)(screenshotDir)) {
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
`Screenshot directory not found: ${screenshotDir}
|
|
1528
|
+
Run 'argus capture-all' first to capture screenshots.`
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
spinner.start("Updating baselines from current screenshots...");
|
|
1532
|
+
await (0, import_promises8.mkdir)(baselineDir, { recursive: true });
|
|
1533
|
+
const files = (await (0, import_promises8.readdir)(screenshotDir)).filter(
|
|
1534
|
+
(f) => f.endsWith(".png") && !f.startsWith(".")
|
|
1535
|
+
);
|
|
1536
|
+
if (files.length === 0) {
|
|
1537
|
+
throw new Error("No screenshots found to use as baselines");
|
|
1538
|
+
}
|
|
1539
|
+
let copied = 0;
|
|
1540
|
+
for (const file of files) {
|
|
1541
|
+
const src = (0, import_path9.join)(screenshotDir, file);
|
|
1542
|
+
const dest = (0, import_path9.join)(baselineDir, file);
|
|
1543
|
+
await (0, import_promises8.copyFile)(src, dest);
|
|
1544
|
+
copied++;
|
|
1545
|
+
spinner.text = `Copying baselines... ${copied}/${files.length}`;
|
|
1546
|
+
}
|
|
1547
|
+
spinner.succeed(`Updated ${copied} baselines`);
|
|
1548
|
+
console.log("\n" + "=".repeat(50));
|
|
1549
|
+
logger.success("Baselines updated!");
|
|
1550
|
+
logger.info(` Location: ${baselineDir}`);
|
|
1551
|
+
logger.info(` Files: ${copied} screenshots`);
|
|
1552
|
+
console.log("\nDon't forget to commit your baselines:");
|
|
1553
|
+
console.log(` git add ${config.baselineDir}`);
|
|
1554
|
+
console.log(' git commit -m "chore: update visual baselines"');
|
|
1555
|
+
console.log("=".repeat(50) + "\n");
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
spinner.stop();
|
|
1559
|
+
console.log("\n\u{1F4CA} Baseline Status\n");
|
|
1560
|
+
if (!(0, import_fs7.existsSync)(baselineDir)) {
|
|
1561
|
+
console.log("No baselines found.\n");
|
|
1562
|
+
console.log("To create baselines from current screenshots:");
|
|
1563
|
+
console.log(" 1. Run: argus capture-all");
|
|
1564
|
+
console.log(" 2. Run: argus baseline --update\n");
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
const baselineFiles = (await (0, import_promises8.readdir)(baselineDir)).filter((f) => f.endsWith(".png"));
|
|
1568
|
+
console.log(`Baseline directory: ${baselineDir}`);
|
|
1569
|
+
console.log(`Total baselines: ${baselineFiles.length}
|
|
1570
|
+
`);
|
|
1571
|
+
if ((0, import_fs7.existsSync)(screenshotDir)) {
|
|
1572
|
+
const screenshotFiles = (await (0, import_promises8.readdir)(screenshotDir)).filter(
|
|
1573
|
+
(f) => f.endsWith(".png") && !f.startsWith(".")
|
|
1574
|
+
);
|
|
1575
|
+
const newStories = screenshotFiles.filter((f) => !baselineFiles.includes(f));
|
|
1576
|
+
const missingScreenshots = baselineFiles.filter((f) => !screenshotFiles.includes(f));
|
|
1577
|
+
if (newStories.length > 0) {
|
|
1578
|
+
console.log(`New stories (no baseline): ${newStories.length}`);
|
|
1579
|
+
}
|
|
1580
|
+
if (missingScreenshots.length > 0) {
|
|
1581
|
+
console.log(`Missing screenshots: ${missingScreenshots.length}`);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
console.log("\nCommands:");
|
|
1585
|
+
console.log(" argus baseline --update Update baselines from screenshots");
|
|
1586
|
+
console.log(" argus baseline --clear Remove all baselines\n");
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
spinner.fail("Baseline operation failed");
|
|
1589
|
+
throw error;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// src/commands/test.ts
|
|
1594
|
+
async function testCommand(options = {}) {
|
|
1595
|
+
console.log("\n\u{1F441}\uFE0F Argus - Visual Regression Test\n");
|
|
1596
|
+
console.log("=".repeat(50));
|
|
1597
|
+
const config = loadConfig();
|
|
1598
|
+
validateConfig(config);
|
|
1599
|
+
const branch = options.branch || await getCurrentBranch();
|
|
1600
|
+
const baseBranch = options.base || "main";
|
|
1601
|
+
console.log(`Branch: ${branch}`);
|
|
1602
|
+
console.log(`Comparing against: ${baseBranch}`);
|
|
1603
|
+
console.log("=".repeat(50) + "\n");
|
|
1604
|
+
const startTime = Date.now();
|
|
1605
|
+
try {
|
|
1606
|
+
if (!options.skipCapture) {
|
|
1607
|
+
console.log("\n\u{1F4F8} Step 1: Capturing Screenshots\n");
|
|
1608
|
+
await captureAllCommand({
|
|
1609
|
+
branch,
|
|
1610
|
+
skipShutdown: true
|
|
1611
|
+
// Keep simulator running in case of re-runs
|
|
1612
|
+
});
|
|
1613
|
+
} else {
|
|
1614
|
+
console.log("\n\u{1F4F8} Step 1: Capture (skipped)\n");
|
|
1615
|
+
}
|
|
1616
|
+
console.log("\n\u{1F50D} Step 2: Comparing Against Baselines\n");
|
|
1617
|
+
let hasChanges = false;
|
|
1618
|
+
try {
|
|
1619
|
+
await compareCommand({
|
|
1620
|
+
base: baseBranch,
|
|
1621
|
+
current: branch,
|
|
1622
|
+
threshold: options.threshold,
|
|
1623
|
+
generateReport: true
|
|
1624
|
+
});
|
|
1625
|
+
} catch (error) {
|
|
1626
|
+
if (error.message?.includes("changes detected") || process.exitCode === 1) {
|
|
1627
|
+
hasChanges = true;
|
|
1628
|
+
process.exitCode = 0;
|
|
1629
|
+
} else {
|
|
1630
|
+
throw error;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
if (config.apiUrl && !options.skipUpload) {
|
|
1634
|
+
console.log("\n\u{1F4E4} Step 3: Uploading Results\n");
|
|
1635
|
+
try {
|
|
1636
|
+
await uploadCommand({ branch });
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
logger.warn("Upload failed - results saved locally");
|
|
1639
|
+
}
|
|
1640
|
+
} else if (!config.apiUrl) {
|
|
1641
|
+
console.log("\n\u{1F4E4} Step 3: Upload (skipped - no apiUrl configured)\n");
|
|
1642
|
+
} else {
|
|
1643
|
+
console.log("\n\u{1F4E4} Step 3: Upload (skipped)\n");
|
|
1644
|
+
}
|
|
1645
|
+
const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
1646
|
+
console.log("\n" + "=".repeat(50));
|
|
1647
|
+
if (hasChanges) {
|
|
1648
|
+
logger.warn(`Visual test completed with changes (${duration}s)`);
|
|
1649
|
+
console.log("\nVisual differences were detected.");
|
|
1650
|
+
console.log("Review the changes and update baselines if intended:");
|
|
1651
|
+
console.log(" argus baseline --update");
|
|
1652
|
+
console.log("=".repeat(50) + "\n");
|
|
1653
|
+
process.exitCode = 1;
|
|
1654
|
+
} else {
|
|
1655
|
+
logger.success(`Visual test passed! (${duration}s)`);
|
|
1656
|
+
console.log("\nNo visual differences detected.");
|
|
1657
|
+
console.log("=".repeat(50) + "\n");
|
|
1658
|
+
}
|
|
1659
|
+
} catch (error) {
|
|
1660
|
+
const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
1661
|
+
console.log("\n" + "=".repeat(50));
|
|
1662
|
+
logger.error(`Visual test failed (${duration}s)`);
|
|
1663
|
+
console.log("=".repeat(50) + "\n");
|
|
1664
|
+
throw error;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// src/cli.ts
|
|
1669
|
+
var program = new import_commander.Command();
|
|
1670
|
+
program.name("argus").description("Argus - Visual Regression Testing for React Native").version("0.1.0");
|
|
1671
|
+
program.command("test").description("Run complete visual test: capture, compare, and upload").option("-b, --branch <branch>", "Override current git branch").option("--base <branch>", "Base branch for comparison (default: main)").option("--skip-capture", "Skip screenshot capture (use existing)").option("--skip-upload", "Skip uploading results").option("-t, --threshold <threshold>", "Difference threshold (0-1)", parseFloat).action(async (options) => {
|
|
1672
|
+
try {
|
|
1673
|
+
await testCommand(options);
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
console.error(import_chalk2.default.red("\n\u2716 Error:"), error.message);
|
|
1676
|
+
if (process.env.DEBUG) {
|
|
1677
|
+
console.error(error.stack);
|
|
1678
|
+
}
|
|
1679
|
+
process.exit(1);
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
program.command("init").description("Initialize visual testing in current project").option("-f, --force", "Overwrite existing configuration").action(async (options) => {
|
|
1683
|
+
try {
|
|
1684
|
+
await initCommand(options);
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
console.error(import_chalk2.default.red("\n\u2716 Error:"), error.message);
|
|
1687
|
+
if (process.env.DEBUG) {
|
|
1688
|
+
console.error(error.stack);
|
|
1689
|
+
}
|
|
1690
|
+
process.exit(1);
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
program.command("baseline").description("Manage visual baselines").option("--update", "Update baselines from current screenshots").option("--clear", "Clear all baselines").option("-b, --branch <branch>", "Branch to use for screenshots (default: current)").action(async (options) => {
|
|
1694
|
+
try {
|
|
1695
|
+
await baselineCommand(options);
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
console.error(import_chalk2.default.red("\n\u2716 Error:"), error.message);
|
|
1698
|
+
if (process.env.DEBUG) {
|
|
1699
|
+
console.error(error.stack);
|
|
1700
|
+
}
|
|
1701
|
+
process.exit(1);
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
program.command("capture-all").description("Capture screenshots of all stories").option("-b, --branch <branch>", "Override current git branch").option("-s, --scheme <scheme>", "URL scheme for deep linking").option("-d, --delay <ms>", "Delay between captures in ms (default: 1500)", parseInt).option("-f, --filter <pattern>", "Filter stories by regex pattern").option("--skip-shutdown", "Keep simulator running after capture").action(async (options) => {
|
|
1705
|
+
try {
|
|
1706
|
+
await captureAllCommand(options);
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
console.error(import_chalk2.default.red("\n\u2716 Error:"), error.message);
|
|
1709
|
+
if (process.env.DEBUG) {
|
|
1710
|
+
console.error(error.stack);
|
|
1711
|
+
}
|
|
1712
|
+
process.exit(1);
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
program.command("compare").description("Compare screenshots against baselines").option("--base <branch>", "Base branch for comparison (default: main)").option("--current <branch>", "Current branch (default: current git branch)").option("-t, --threshold <threshold>", "Difference threshold (0-1)", parseFloat).option("--no-report", "Skip HTML report generation").action(async (options) => {
|
|
1716
|
+
try {
|
|
1717
|
+
await compareCommand(options);
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
console.error(import_chalk2.default.red("\n\u2716 Error:"), error.message);
|
|
1720
|
+
if (process.env.DEBUG) {
|
|
1721
|
+
console.error(error.stack);
|
|
1722
|
+
}
|
|
1723
|
+
process.exit(1);
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
program.command("upload").description("Upload comparison results to the web dashboard").option("-b, --branch <branch>", "Override current git branch").option("-u, --api-url <url>", "Override API URL from config").action(async (options) => {
|
|
1727
|
+
try {
|
|
1728
|
+
await uploadCommand(options);
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
console.error(import_chalk2.default.red("\n\u2716 Error:"), error.message);
|
|
1731
|
+
if (process.env.DEBUG) {
|
|
1732
|
+
console.error(error.stack);
|
|
1733
|
+
}
|
|
1734
|
+
process.exit(1);
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
program.command("capture").description("Capture screenshots (legacy, use capture-all instead)").option("-b, --branch <branch>", "Override current git branch").option("-d, --device <device>", "Override simulator device").option("--skip-boot", "Skip booting the simulator").option("--skip-shutdown", "Skip shutting down the simulator").action(async (options) => {
|
|
1738
|
+
try {
|
|
1739
|
+
await captureCommand(options);
|
|
1740
|
+
} catch (error) {
|
|
1741
|
+
console.error(import_chalk2.default.red("\n\u2716 Error:"), error.message);
|
|
1742
|
+
if (process.env.DEBUG) {
|
|
1743
|
+
console.error(error.stack);
|
|
1744
|
+
}
|
|
1745
|
+
process.exit(1);
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
program.command("screenshot").description("Capture a single screenshot").option("-n, --name <name>", "Name for the screenshot").option("-b, --branch <branch>", "Override current git branch").action(async (options) => {
|
|
1749
|
+
try {
|
|
1750
|
+
await screenshotCommand(options);
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
console.error(import_chalk2.default.red("\n\u2716 Error:"), error.message);
|
|
1753
|
+
if (process.env.DEBUG) {
|
|
1754
|
+
console.error(error.stack);
|
|
1755
|
+
}
|
|
1756
|
+
process.exit(1);
|
|
1757
|
+
}
|
|
1758
|
+
});
|
|
1759
|
+
program.command("list-stories").description("List all stories in the project").option("--json", "Output as JSON").action(async (options) => {
|
|
1760
|
+
try {
|
|
1761
|
+
await listStoriesCommand(options);
|
|
1762
|
+
} catch (error) {
|
|
1763
|
+
console.error(import_chalk2.default.red("\n\u2716 Error:"), error.message);
|
|
1764
|
+
if (process.env.DEBUG) {
|
|
1765
|
+
console.error(error.stack);
|
|
1766
|
+
}
|
|
1767
|
+
process.exit(1);
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
program.exitOverride();
|
|
1771
|
+
try {
|
|
1772
|
+
program.parse();
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
if (error.code !== "commander.help" && error.code !== "commander.version") {
|
|
1775
|
+
console.error(import_chalk2.default.red("\u2716 Error:"), error.message);
|
|
1776
|
+
process.exit(1);
|
|
1777
|
+
}
|
|
1778
|
+
}
|