git-shots-cli 0.1.0 → 0.1.2
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/bash.exe.stackdump +9 -0
- package/dist/index.js +204 -0
- package/package.json +2 -2
- package/src/index.ts +51 -0
- package/src/pull-baselines.ts +87 -0
- package/src/review.ts +199 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Stack trace:
|
|
2
|
+
Frame Function Args
|
|
3
|
+
000FFFFBED8 0018006137E (0018026DF0D, 0018024E186, 000FFFFBED8, 000FFFFADD0)
|
|
4
|
+
000FFFFBED8 00180049229 (00000000000, 00000000000, 00000000000, 00000000000)
|
|
5
|
+
000FFFFBED8 00180049262 (0018026DFC9, 000FFFFBD88, 000FFFFBED8, 00000000000)
|
|
6
|
+
000FFFFBED8 001800B5C58 (00000000000, 00000000000, 00000000000, 00000000000)
|
|
7
|
+
000FFFFBED8 001800B5DDD (000FFFFBEF0, 00000000000, 00000000000, 00000000000)
|
|
8
|
+
000FFFFC1A0 001800B740C (000FFFFBEF0, 00000000000, 00000000000, 00000000000)
|
|
9
|
+
End of stack trace
|
package/dist/index.js
CHANGED
|
@@ -157,6 +157,183 @@ async function status(config) {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// src/pull-baselines.ts
|
|
161
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
162
|
+
import { resolve as resolve3, dirname as dirname2 } from "path";
|
|
163
|
+
import chalk4 from "chalk";
|
|
164
|
+
async function pullBaselines(config, options) {
|
|
165
|
+
const branch = options.branch ?? "main";
|
|
166
|
+
const outputDir = resolve3(process.cwd(), options.output ?? config.directory);
|
|
167
|
+
console.log(chalk4.dim(`Project: ${config.project}`));
|
|
168
|
+
console.log(chalk4.dim(`Branch: ${branch}`));
|
|
169
|
+
console.log(chalk4.dim(`Output: ${outputDir}`));
|
|
170
|
+
console.log();
|
|
171
|
+
const manifestUrl = `${config.server}/api/projects/${encodeURIComponent(config.project)}/snapshots?branch=${encodeURIComponent(branch)}`;
|
|
172
|
+
let snapshots;
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch(manifestUrl);
|
|
175
|
+
const data = await res.json();
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
console.error(chalk4.red(`Failed to fetch snapshots: ${JSON.stringify(data)}`));
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
snapshots = data.snapshots;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error(chalk4.red(`Request failed: ${err}`));
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
if (snapshots.length === 0) {
|
|
186
|
+
console.log(chalk4.yellow("No baseline snapshots found."));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
console.log(chalk4.dim(`Found ${snapshots.length} baselines to download`));
|
|
190
|
+
console.log();
|
|
191
|
+
const batchSize = 5;
|
|
192
|
+
let downloaded = 0;
|
|
193
|
+
for (let i = 0; i < snapshots.length; i += batchSize) {
|
|
194
|
+
const batch = snapshots.slice(i, i + batchSize);
|
|
195
|
+
await Promise.all(
|
|
196
|
+
batch.map(async (snap) => {
|
|
197
|
+
const imageUrl = `${config.server}/api/images/${snap.r2_key}`;
|
|
198
|
+
const res = await fetch(imageUrl);
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
console.error(chalk4.red(` Failed to download ${snap.screen_slug}: ${res.status}`));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
204
|
+
const subDir = snap.category ?? "";
|
|
205
|
+
const filePath = resolve3(outputDir, subDir, `${snap.screen_slug}.png`);
|
|
206
|
+
mkdirSync(dirname2(filePath), { recursive: true });
|
|
207
|
+
writeFileSync(filePath, buffer);
|
|
208
|
+
downloaded++;
|
|
209
|
+
console.log(
|
|
210
|
+
chalk4.green(` [${downloaded}/${snapshots.length}]`) + ` ${subDir ? subDir + "/" : ""}${snap.screen_slug}.png`
|
|
211
|
+
);
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
console.log();
|
|
216
|
+
console.log(chalk4.green(`Downloaded ${downloaded} baselines to ${outputDir}`));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/review.ts
|
|
220
|
+
import { execSync as execSync2 } from "child_process";
|
|
221
|
+
import { platform } from "os";
|
|
222
|
+
import chalk5 from "chalk";
|
|
223
|
+
function openBrowser(url) {
|
|
224
|
+
try {
|
|
225
|
+
const os = platform();
|
|
226
|
+
if (os === "win32") {
|
|
227
|
+
execSync2(`start "" "${url}"`, { stdio: "ignore" });
|
|
228
|
+
} else if (os === "darwin") {
|
|
229
|
+
execSync2(`open "${url}"`, { stdio: "ignore" });
|
|
230
|
+
} else {
|
|
231
|
+
execSync2(`xdg-open "${url}"`, { stdio: "ignore" });
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
console.log(chalk5.dim(`Could not open browser. Visit the URL manually.`));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function sleep(ms) {
|
|
238
|
+
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
239
|
+
}
|
|
240
|
+
async function review(config, options) {
|
|
241
|
+
const shouldOpen = options.open ?? true;
|
|
242
|
+
const shouldPoll = options.poll ?? true;
|
|
243
|
+
const timeoutSec = options.timeout ?? 300;
|
|
244
|
+
const branch = options.branch ?? execSync2("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
|
|
245
|
+
const sha = options.sha ?? execSync2("git rev-parse HEAD", { encoding: "utf-8" }).trim();
|
|
246
|
+
console.log(chalk5.dim(`Project: ${config.project}`));
|
|
247
|
+
console.log(chalk5.dim(`Branch: ${branch}`));
|
|
248
|
+
console.log(chalk5.dim(`SHA: ${sha.slice(0, 7)}`));
|
|
249
|
+
console.log();
|
|
250
|
+
console.log(chalk5.dim("Uploading screenshots..."));
|
|
251
|
+
await upload(config, { branch, sha });
|
|
252
|
+
console.log();
|
|
253
|
+
console.log(chalk5.dim("Creating review session..."));
|
|
254
|
+
const reviewUrl = `${config.server}/api/reviews`;
|
|
255
|
+
let reviewData;
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch(reviewUrl, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: { "Content-Type": "application/json", Origin: config.server },
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
project: config.project,
|
|
262
|
+
branch,
|
|
263
|
+
gitSha: sha
|
|
264
|
+
})
|
|
265
|
+
});
|
|
266
|
+
const data = await res.json();
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
console.error(chalk5.red(`Failed to create review: ${JSON.stringify(data)}`));
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
reviewData = data;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error(chalk5.red(`Request failed: ${err}`));
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
const allZero = reviewData.diffs.length === 0 || reviewData.diffs.every((d) => d.mismatchPercentage === 0);
|
|
277
|
+
if (allZero) {
|
|
278
|
+
console.log(chalk5.green("No visual changes detected."));
|
|
279
|
+
process.exit(0);
|
|
280
|
+
}
|
|
281
|
+
const sessionUrl = `${config.server}/reviews/${reviewData.review.id}`;
|
|
282
|
+
console.log();
|
|
283
|
+
console.log(chalk5.bold("Visual changes detected:"));
|
|
284
|
+
console.log();
|
|
285
|
+
for (const d of reviewData.diffs) {
|
|
286
|
+
const pct = d.mismatchPercentage.toFixed(2) + "%";
|
|
287
|
+
const color = d.mismatchPercentage > 10 ? chalk5.red : d.mismatchPercentage > 1 ? chalk5.yellow : chalk5.green;
|
|
288
|
+
console.log(` ${d.screen.padEnd(30)} ${color(pct)}`);
|
|
289
|
+
}
|
|
290
|
+
console.log();
|
|
291
|
+
console.log(`Review: ${chalk5.cyan(sessionUrl)}`);
|
|
292
|
+
if (shouldOpen) {
|
|
293
|
+
openBrowser(sessionUrl);
|
|
294
|
+
}
|
|
295
|
+
if (!shouldPoll) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
console.log();
|
|
299
|
+
console.log(chalk5.dim(`Polling for verdict (timeout: ${timeoutSec}s)...`));
|
|
300
|
+
const pollInterval = 3e3;
|
|
301
|
+
const startTime = Date.now();
|
|
302
|
+
const deadline = startTime + timeoutSec * 1e3;
|
|
303
|
+
while (Date.now() < deadline) {
|
|
304
|
+
await sleep(pollInterval);
|
|
305
|
+
try {
|
|
306
|
+
const res = await fetch(`${config.server}/api/reviews/${reviewData.review.id}`, {
|
|
307
|
+
headers: { Origin: config.server }
|
|
308
|
+
});
|
|
309
|
+
const data = await res.json();
|
|
310
|
+
if (!res.ok) continue;
|
|
311
|
+
if (data.review.status === "approved") {
|
|
312
|
+
console.log();
|
|
313
|
+
console.log(chalk5.green.bold("Review approved!"));
|
|
314
|
+
process.exit(0);
|
|
315
|
+
}
|
|
316
|
+
if (data.review.status === "rejected") {
|
|
317
|
+
console.log();
|
|
318
|
+
console.log(chalk5.red.bold("Review rejected."));
|
|
319
|
+
const rejected = data.diffs.filter((d) => d.status === "rejected");
|
|
320
|
+
if (rejected.length > 0) {
|
|
321
|
+
console.log(chalk5.dim("Rejected screens:"));
|
|
322
|
+
for (const d of rejected) {
|
|
323
|
+
console.log(chalk5.red(` - ${d.base.screen_slug}`));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
console.log();
|
|
332
|
+
console.log(chalk5.yellow(`Review timed out after ${timeoutSec}s.`));
|
|
333
|
+
console.log(chalk5.dim(`Visit ${sessionUrl} to complete the review.`));
|
|
334
|
+
process.exit(2);
|
|
335
|
+
}
|
|
336
|
+
|
|
160
337
|
// src/index.ts
|
|
161
338
|
var program = new Command();
|
|
162
339
|
program.name("git-shots").description("CLI for git-shots visual regression platform").version("0.1.0");
|
|
@@ -191,4 +368,31 @@ program.command("status").description("Show current diff status").option("-p, --
|
|
|
191
368
|
}
|
|
192
369
|
await status(config);
|
|
193
370
|
});
|
|
371
|
+
program.command("pull-baselines").description("Download baseline screenshots from git-shots").option("-p, --project <slug>", "Project slug").option("-s, --server <url>", "Server URL").option("-o, --output <path>", "Output directory").option("-b, --branch <name>", "Branch to pull baselines from (default: main)").action(async (options) => {
|
|
372
|
+
const config = loadConfig();
|
|
373
|
+
if (options.project) config.project = options.project;
|
|
374
|
+
if (options.server) config.server = options.server;
|
|
375
|
+
if (!config.project) {
|
|
376
|
+
console.error("Error: project slug required. Use --project or .git-shots.json");
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
await pullBaselines(config, { branch: options.branch, output: options.output });
|
|
380
|
+
});
|
|
381
|
+
program.command("review").description("Upload screenshots, create review session, and poll for verdict").option("-p, --project <slug>", "Project slug").option("-s, --server <url>", "Server URL").option("-d, --directory <path>", "Screenshots directory").option("-b, --branch <name>", "Git branch (auto-detected)").option("--sha <hash>", "Git SHA (auto-detected)").option("--open", "Open review URL in browser", true).option("--no-open", "Do not open review URL in browser").option("--poll", "Poll for verdict and exit with code", true).option("--no-poll", "Do not poll for verdict").option("--timeout <seconds>", "Polling timeout in seconds", parseInt, 300).action(async (options) => {
|
|
382
|
+
const config = loadConfig();
|
|
383
|
+
if (options.project) config.project = options.project;
|
|
384
|
+
if (options.server) config.server = options.server;
|
|
385
|
+
if (options.directory) config.directory = options.directory;
|
|
386
|
+
if (!config.project) {
|
|
387
|
+
console.error("Error: project slug required. Use --project or .git-shots.json");
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
await review(config, {
|
|
391
|
+
branch: options.branch,
|
|
392
|
+
sha: options.sha,
|
|
393
|
+
open: options.open,
|
|
394
|
+
poll: options.poll,
|
|
395
|
+
timeout: options.timeout
|
|
396
|
+
});
|
|
397
|
+
});
|
|
194
398
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-shots-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "CLI for git-shots visual regression platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"git-shots": "dist/index.js"
|
|
7
|
+
"git-shots": "./dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsup src/index.ts --format esm --dts",
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { loadConfig } from './config.js';
|
|
|
4
4
|
import { upload } from './upload.js';
|
|
5
5
|
import { compare } from './compare.js';
|
|
6
6
|
import { status } from './status.js';
|
|
7
|
+
import { pullBaselines } from './pull-baselines.js';
|
|
8
|
+
import { review } from './review.js';
|
|
7
9
|
|
|
8
10
|
const program = new Command();
|
|
9
11
|
|
|
@@ -67,4 +69,53 @@ program
|
|
|
67
69
|
await status(config);
|
|
68
70
|
});
|
|
69
71
|
|
|
72
|
+
program
|
|
73
|
+
.command('pull-baselines')
|
|
74
|
+
.description('Download baseline screenshots from git-shots')
|
|
75
|
+
.option('-p, --project <slug>', 'Project slug')
|
|
76
|
+
.option('-s, --server <url>', 'Server URL')
|
|
77
|
+
.option('-o, --output <path>', 'Output directory')
|
|
78
|
+
.option('-b, --branch <name>', 'Branch to pull baselines from (default: main)')
|
|
79
|
+
.action(async (options) => {
|
|
80
|
+
const config = loadConfig();
|
|
81
|
+
if (options.project) config.project = options.project;
|
|
82
|
+
if (options.server) config.server = options.server;
|
|
83
|
+
if (!config.project) {
|
|
84
|
+
console.error('Error: project slug required. Use --project or .git-shots.json');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
await pullBaselines(config, { branch: options.branch, output: options.output });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
program
|
|
91
|
+
.command('review')
|
|
92
|
+
.description('Upload screenshots, create review session, and poll for verdict')
|
|
93
|
+
.option('-p, --project <slug>', 'Project slug')
|
|
94
|
+
.option('-s, --server <url>', 'Server URL')
|
|
95
|
+
.option('-d, --directory <path>', 'Screenshots directory')
|
|
96
|
+
.option('-b, --branch <name>', 'Git branch (auto-detected)')
|
|
97
|
+
.option('--sha <hash>', 'Git SHA (auto-detected)')
|
|
98
|
+
.option('--open', 'Open review URL in browser', true)
|
|
99
|
+
.option('--no-open', 'Do not open review URL in browser')
|
|
100
|
+
.option('--poll', 'Poll for verdict and exit with code', true)
|
|
101
|
+
.option('--no-poll', 'Do not poll for verdict')
|
|
102
|
+
.option('--timeout <seconds>', 'Polling timeout in seconds', parseInt, 300)
|
|
103
|
+
.action(async (options) => {
|
|
104
|
+
const config = loadConfig();
|
|
105
|
+
if (options.project) config.project = options.project;
|
|
106
|
+
if (options.server) config.server = options.server;
|
|
107
|
+
if (options.directory) config.directory = options.directory;
|
|
108
|
+
if (!config.project) {
|
|
109
|
+
console.error('Error: project slug required. Use --project or .git-shots.json');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
await review(config, {
|
|
113
|
+
branch: options.branch,
|
|
114
|
+
sha: options.sha,
|
|
115
|
+
open: options.open,
|
|
116
|
+
poll: options.poll,
|
|
117
|
+
timeout: options.timeout
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
70
121
|
program.parse();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import type { GitShotsConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
interface Snapshot {
|
|
7
|
+
screen_slug: string;
|
|
8
|
+
category: string | null;
|
|
9
|
+
r2_key: string;
|
|
10
|
+
git_sha: string;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function pullBaselines(
|
|
16
|
+
config: GitShotsConfig,
|
|
17
|
+
options: { branch?: string; output?: string }
|
|
18
|
+
) {
|
|
19
|
+
const branch = options.branch ?? 'main';
|
|
20
|
+
const outputDir = resolve(process.cwd(), options.output ?? config.directory);
|
|
21
|
+
|
|
22
|
+
console.log(chalk.dim(`Project: ${config.project}`));
|
|
23
|
+
console.log(chalk.dim(`Branch: ${branch}`));
|
|
24
|
+
console.log(chalk.dim(`Output: ${outputDir}`));
|
|
25
|
+
console.log();
|
|
26
|
+
|
|
27
|
+
// Fetch manifest
|
|
28
|
+
const manifestUrl = `${config.server}/api/projects/${encodeURIComponent(config.project)}/snapshots?branch=${encodeURIComponent(branch)}`;
|
|
29
|
+
let snapshots: Snapshot[];
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(manifestUrl);
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
console.error(chalk.red(`Failed to fetch snapshots: ${JSON.stringify(data)}`));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
snapshots = data.snapshots;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(chalk.red(`Request failed: ${err}`));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (snapshots.length === 0) {
|
|
47
|
+
console.log(chalk.yellow('No baseline snapshots found.'));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(chalk.dim(`Found ${snapshots.length} baselines to download`));
|
|
52
|
+
console.log();
|
|
53
|
+
|
|
54
|
+
// Download in batches of 5
|
|
55
|
+
const batchSize = 5;
|
|
56
|
+
let downloaded = 0;
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < snapshots.length; i += batchSize) {
|
|
59
|
+
const batch = snapshots.slice(i, i + batchSize);
|
|
60
|
+
await Promise.all(
|
|
61
|
+
batch.map(async (snap) => {
|
|
62
|
+
const imageUrl = `${config.server}/api/images/${snap.r2_key}`;
|
|
63
|
+
const res = await fetch(imageUrl);
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
console.error(chalk.red(` Failed to download ${snap.screen_slug}: ${res.status}`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
70
|
+
const subDir = snap.category ?? '';
|
|
71
|
+
const filePath = resolve(outputDir, subDir, `${snap.screen_slug}.png`);
|
|
72
|
+
|
|
73
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
74
|
+
writeFileSync(filePath, buffer);
|
|
75
|
+
|
|
76
|
+
downloaded++;
|
|
77
|
+
console.log(
|
|
78
|
+
chalk.green(` [${downloaded}/${snapshots.length}]`) +
|
|
79
|
+
` ${subDir ? subDir + '/' : ''}${snap.screen_slug}.png`
|
|
80
|
+
);
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log();
|
|
86
|
+
console.log(chalk.green(`Downloaded ${downloaded} baselines to ${outputDir}`));
|
|
87
|
+
}
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { platform } from 'node:os';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import type { GitShotsConfig } from './config.js';
|
|
5
|
+
import { upload } from './upload.js';
|
|
6
|
+
|
|
7
|
+
interface ReviewOptions {
|
|
8
|
+
branch?: string;
|
|
9
|
+
sha?: string;
|
|
10
|
+
open?: boolean;
|
|
11
|
+
poll?: boolean;
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ReviewResponse {
|
|
16
|
+
review: {
|
|
17
|
+
id: number;
|
|
18
|
+
status: string;
|
|
19
|
+
url: string;
|
|
20
|
+
};
|
|
21
|
+
diffs: Array<{
|
|
22
|
+
screen: string;
|
|
23
|
+
mismatchPercentage: number;
|
|
24
|
+
mismatchPixels: number;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ReviewStatusResponse {
|
|
29
|
+
review: {
|
|
30
|
+
id: number;
|
|
31
|
+
status: string;
|
|
32
|
+
};
|
|
33
|
+
diffs: Array<{
|
|
34
|
+
base: { screen_slug: string };
|
|
35
|
+
mismatch_percentage: number;
|
|
36
|
+
status: string;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function openBrowser(url: string): void {
|
|
41
|
+
try {
|
|
42
|
+
const os = platform();
|
|
43
|
+
if (os === 'win32') {
|
|
44
|
+
execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
45
|
+
} else if (os === 'darwin') {
|
|
46
|
+
execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
47
|
+
} else {
|
|
48
|
+
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
console.log(chalk.dim(`Could not open browser. Visit the URL manually.`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sleep(ms: number): Promise<void> {
|
|
56
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function review(config: GitShotsConfig, options: ReviewOptions) {
|
|
60
|
+
const shouldOpen = options.open ?? true;
|
|
61
|
+
const shouldPoll = options.poll ?? true;
|
|
62
|
+
const timeoutSec = options.timeout ?? 300;
|
|
63
|
+
|
|
64
|
+
// Get git info
|
|
65
|
+
const branch =
|
|
66
|
+
options.branch ??
|
|
67
|
+
execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
|
|
68
|
+
const sha =
|
|
69
|
+
options.sha ?? execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
|
|
70
|
+
|
|
71
|
+
console.log(chalk.dim(`Project: ${config.project}`));
|
|
72
|
+
console.log(chalk.dim(`Branch: ${branch}`));
|
|
73
|
+
console.log(chalk.dim(`SHA: ${sha.slice(0, 7)}`));
|
|
74
|
+
console.log();
|
|
75
|
+
|
|
76
|
+
// Step 1: Upload screenshots
|
|
77
|
+
console.log(chalk.dim('Uploading screenshots...'));
|
|
78
|
+
await upload(config, { branch, sha });
|
|
79
|
+
console.log();
|
|
80
|
+
|
|
81
|
+
// Step 2: Create review session via POST /api/reviews
|
|
82
|
+
console.log(chalk.dim('Creating review session...'));
|
|
83
|
+
|
|
84
|
+
const reviewUrl = `${config.server}/api/reviews`;
|
|
85
|
+
let reviewData: ReviewResponse;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(reviewUrl, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json', Origin: config.server },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
project: config.project,
|
|
93
|
+
branch,
|
|
94
|
+
gitSha: sha
|
|
95
|
+
})
|
|
96
|
+
});
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
console.error(chalk.red(`Failed to create review: ${JSON.stringify(data)}`));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
reviewData = data as ReviewResponse;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(chalk.red(`Request failed: ${err}`));
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Step 3: Check if all diffs are 0% mismatch
|
|
111
|
+
const allZero =
|
|
112
|
+
reviewData.diffs.length === 0 ||
|
|
113
|
+
reviewData.diffs.every((d) => d.mismatchPercentage === 0);
|
|
114
|
+
|
|
115
|
+
if (allZero) {
|
|
116
|
+
console.log(chalk.green('No visual changes detected.'));
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Step 4: Print review URL and diff summary
|
|
121
|
+
const sessionUrl = `${config.server}/reviews/${reviewData.review.id}`;
|
|
122
|
+
console.log();
|
|
123
|
+
console.log(chalk.bold('Visual changes detected:'));
|
|
124
|
+
console.log();
|
|
125
|
+
|
|
126
|
+
for (const d of reviewData.diffs) {
|
|
127
|
+
const pct = d.mismatchPercentage.toFixed(2) + '%';
|
|
128
|
+
const color =
|
|
129
|
+
d.mismatchPercentage > 10
|
|
130
|
+
? chalk.red
|
|
131
|
+
: d.mismatchPercentage > 1
|
|
132
|
+
? chalk.yellow
|
|
133
|
+
: chalk.green;
|
|
134
|
+
console.log(` ${d.screen.padEnd(30)} ${color(pct)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log();
|
|
138
|
+
console.log(`Review: ${chalk.cyan(sessionUrl)}`);
|
|
139
|
+
|
|
140
|
+
// Step 5: Open in browser
|
|
141
|
+
if (shouldOpen) {
|
|
142
|
+
openBrowser(sessionUrl);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Step 6: Poll for verdict
|
|
146
|
+
if (!shouldPoll) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log();
|
|
151
|
+
console.log(chalk.dim(`Polling for verdict (timeout: ${timeoutSec}s)...`));
|
|
152
|
+
|
|
153
|
+
const pollInterval = 3000;
|
|
154
|
+
const startTime = Date.now();
|
|
155
|
+
const deadline = startTime + timeoutSec * 1000;
|
|
156
|
+
|
|
157
|
+
while (Date.now() < deadline) {
|
|
158
|
+
await sleep(pollInterval);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(`${config.server}/api/reviews/${reviewData.review.id}`, {
|
|
162
|
+
headers: { Origin: config.server }
|
|
163
|
+
});
|
|
164
|
+
const data = (await res.json()) as ReviewStatusResponse;
|
|
165
|
+
|
|
166
|
+
if (!res.ok) continue;
|
|
167
|
+
|
|
168
|
+
if (data.review.status === 'approved') {
|
|
169
|
+
console.log();
|
|
170
|
+
console.log(chalk.green.bold('Review approved!'));
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (data.review.status === 'rejected') {
|
|
175
|
+
console.log();
|
|
176
|
+
console.log(chalk.red.bold('Review rejected.'));
|
|
177
|
+
|
|
178
|
+
// Show which screens had issues
|
|
179
|
+
const rejected = data.diffs.filter((d) => d.status === 'rejected');
|
|
180
|
+
if (rejected.length > 0) {
|
|
181
|
+
console.log(chalk.dim('Rejected screens:'));
|
|
182
|
+
for (const d of rejected) {
|
|
183
|
+
console.log(chalk.red(` - ${d.base.screen_slug}`));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// Network error — keep polling
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Timeout
|
|
195
|
+
console.log();
|
|
196
|
+
console.log(chalk.yellow(`Review timed out after ${timeoutSec}s.`));
|
|
197
|
+
console.log(chalk.dim(`Visit ${sessionUrl} to complete the review.`));
|
|
198
|
+
process.exit(2);
|
|
199
|
+
}
|