@argos-ci/cli 0.2.4-alpha.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.mjs +478 -2
  2. package/package.json +7 -7
package/dist/index.mjs CHANGED
@@ -1,9 +1,485 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { resolve } from 'node:path';
2
4
  import { program } from 'commander';
3
- import { upload } from '@argos-ci/core';
5
+ import convict from 'convict';
6
+ import envCi from 'env-ci';
7
+ import { execSync } from 'child_process';
8
+ import glob from 'fast-glob';
9
+ import { promisify } from 'node:util';
10
+ import sharp from 'sharp';
11
+ import tmp from 'tmp';
12
+ import { createReadStream } from 'node:fs';
13
+ import { createHash } from 'node:crypto';
14
+ import axios from 'axios';
15
+ import createDebug from 'debug';
4
16
  import ora from 'ora';
5
17
 
6
- const rawPkg = await readFile(new URL("../package.json", import.meta.url).pathname, "utf8");
18
+ const mustBeApiBaseUrl = (value)=>{
19
+ const URL_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;
20
+ if (!URL_REGEX.test(value)) {
21
+ throw new Error("Invalid Argos API base URL");
22
+ }
23
+ };
24
+ const mustBeCommit = (value)=>{
25
+ const SHA1_REGEX = /^[0-9a-f]{40}$/;
26
+ if (!SHA1_REGEX.test(value)) {
27
+ const SHA1_SHORT_REGEX = /^[0-9a-f]{7}$/;
28
+ if (SHA1_SHORT_REGEX.test(value)) {
29
+ throw new Error("Short SHA1 is not allowed");
30
+ }
31
+ throw new Error("Invalid commit");
32
+ }
33
+ };
34
+ const mustBeArgosToken = (value)=>{
35
+ if (value && value.length !== 40) {
36
+ throw new Error("Must be a valid Argos repository token");
37
+ }
38
+ };
39
+ const schema = {
40
+ apiBaseUrl: {
41
+ env: "ARGOS_API_BASE_URL",
42
+ default: "https://api.argos-ci.com/v2/",
43
+ format: mustBeApiBaseUrl
44
+ },
45
+ commit: {
46
+ env: "ARGOS_COMMIT",
47
+ default: "",
48
+ format: mustBeCommit
49
+ },
50
+ branch: {
51
+ env: "ARGOS_BRANCH",
52
+ default: null,
53
+ format: String,
54
+ nullable: true
55
+ },
56
+ token: {
57
+ env: "ARGOS_TOKEN",
58
+ default: null,
59
+ format: mustBeArgosToken
60
+ },
61
+ buildName: {
62
+ env: "ARGOS_BUILD_NAME",
63
+ default: null,
64
+ format: String,
65
+ nullable: true
66
+ },
67
+ parallel: {
68
+ env: "ARGOS_PARALLEL",
69
+ default: false,
70
+ format: Boolean
71
+ },
72
+ parallelNonce: {
73
+ env: "ARGOS_PARALLEL_NONCE",
74
+ format: String,
75
+ default: null,
76
+ nullable: true
77
+ },
78
+ parallelTotal: {
79
+ env: "ARGOS_PARALLEL_TOTAL",
80
+ format: "nat",
81
+ default: null,
82
+ nullable: true
83
+ },
84
+ ciService: {
85
+ format: String,
86
+ default: null,
87
+ nullable: true
88
+ },
89
+ jobId: {
90
+ format: String,
91
+ default: null,
92
+ nullable: true
93
+ },
94
+ runId: {
95
+ format: String,
96
+ default: null,
97
+ nullable: true
98
+ },
99
+ owner: {
100
+ format: String,
101
+ default: null,
102
+ nullable: true
103
+ },
104
+ repository: {
105
+ format: String,
106
+ default: null,
107
+ nullable: true
108
+ }
109
+ };
110
+ const createConfig = ()=>{
111
+ return convict(schema, {
112
+ args: []
113
+ });
114
+ };
115
+
116
+ /**
117
+ * Omit undefined properties from an object.
118
+ */ const omitUndefined = (obj)=>{
119
+ const result = {};
120
+ Object.keys(obj).forEach((key)=>{
121
+ if (obj[key] !== undefined) {
122
+ result[key] = obj[key];
123
+ }
124
+ });
125
+ return result;
126
+ };
127
+
128
+ const service$1 = {
129
+ detect: ({ env })=>Boolean(env.HEROKU_TEST_RUN_ID),
130
+ config: ({ env })=>({
131
+ name: "Heroku",
132
+ commit: env.HEROKU_TEST_RUN_COMMIT_VERSION || null,
133
+ branch: env.HEROKU_TEST_RUN_BRANCH || null,
134
+ owner: null,
135
+ repository: null,
136
+ jobId: env.HEROKU_TEST_RUN_ID || null,
137
+ runId: null
138
+ })
139
+ };
140
+
141
+ const getSha = ({ env })=>{
142
+ const isPr = env.GITHUB_EVENT_NAME === "pull_request" || env.GITHUB_EVENT_NAME === "pull_request_target";
143
+ if (isPr) {
144
+ const mergeCommitRegex = /^[a-z0-9]{40} [a-z0-9]{40}$/;
145
+ const mergeCommitMessage = execSync("git show --no-patch --format=%P").toString().trim();
146
+ // console.log(
147
+ // `Handling PR with parent hash(es) '${mergeCommitMessage}' of current commit.`
148
+ // );
149
+ if (mergeCommitRegex.exec(mergeCommitMessage)) {
150
+ const mergeCommit = mergeCommitMessage.split(" ")[1];
151
+ // console.log(
152
+ // `Fixing merge commit SHA ${process.env.GITHUB_SHA} -> ${mergeCommit}`
153
+ // );
154
+ return mergeCommit;
155
+ } else if (mergeCommitMessage === "") {
156
+ console.error(`Error: automatic detection of commit SHA failed.
157
+
158
+ Please run "actions/checkout" with "fetch-depth: 2". Example:
159
+
160
+ steps:
161
+ - uses: actions/checkout@v3
162
+ with:
163
+ fetch-depth: 2
164
+
165
+ `);
166
+ process.exit(1);
167
+ } else {
168
+ console.error(`Commit with SHA ${process.env.GITHUB_SHA} is not a valid commit`);
169
+ process.exit(1);
170
+ }
171
+ }
172
+ return process.env.GITHUB_SHA ?? null;
173
+ };
174
+ function getBranch({ env }) {
175
+ if (env.GITHUB_HEAD_REF) {
176
+ return env.GITHUB_HEAD_REF;
177
+ }
178
+ const branchRegex = /refs\/heads\/(.*)/;
179
+ const branchMatches = branchRegex.exec(env.GITHUB_REF || "");
180
+ if (branchMatches) {
181
+ return branchMatches[1];
182
+ }
183
+ return null;
184
+ }
185
+ function getRepository({ env }) {
186
+ if (!env.GITHUB_REPOSITORY_OWNER) return null;
187
+ const [, ...repositoryParts] = env.GITHUB_REPOSITORY_OWNER.split("/");
188
+ return repositoryParts.join("/");
189
+ }
190
+ const service = {
191
+ detect: ({ env })=>Boolean(env.GITHUB_ACTIONS),
192
+ config: ({ env })=>({
193
+ name: "GitHub Actions",
194
+ commit: getSha({
195
+ env
196
+ }),
197
+ branch: getBranch({
198
+ env
199
+ }),
200
+ owner: env.GITHUB_REPOSITORY_OWNER || null,
201
+ repository: getRepository({
202
+ env
203
+ }),
204
+ jobId: env.GITHUB_JOB || null,
205
+ runId: env.GITHUB_RUN_ID || null
206
+ })
207
+ };
208
+
209
+ const services = [
210
+ service$1,
211
+ service
212
+ ];
213
+ const getCiEnvironment = ({ env =process.env } = {})=>{
214
+ const ctx = {
215
+ env
216
+ };
217
+ const service = services.find((service)=>service.detect(ctx));
218
+ // Internal service matched
219
+ if (service) {
220
+ return service.config(ctx);
221
+ }
222
+ // Fallback on env-ci detection
223
+ const ciContext = envCi(ctx);
224
+ const name = ciContext.isCi ? ciContext.name ?? null : ciContext.commit ? "Git" : null;
225
+ const commit = ciContext.commit ?? null;
226
+ const branch = (ciContext.branch || ciContext.prBranch) ?? null;
227
+ const slug = ciContext.slug ? ciContext.slug.split("/") : null;
228
+ const owner = slug ? slug[0] : null;
229
+ const repository = slug ? slug[1] : null;
230
+ const jobId = ciContext.job ?? null;
231
+ const runId = null;
232
+ return commit ? {
233
+ name,
234
+ commit,
235
+ branch,
236
+ owner,
237
+ repository,
238
+ jobId,
239
+ runId
240
+ } : null;
241
+ };
242
+
243
+ const discoverScreenshots = async (patterns, { root =process.cwd() , ignore } = {})=>{
244
+ const matches = await glob(patterns, {
245
+ onlyFiles: true,
246
+ ignore,
247
+ cwd: root
248
+ });
249
+ return matches.map((match)=>({
250
+ name: match,
251
+ path: resolve(root, match)
252
+ }));
253
+ };
254
+
255
+ const tmpFile = promisify(tmp.file);
256
+ const getImageFormat = async (filepath)=>{
257
+ const metadata = await sharp(filepath).metadata();
258
+ if (!metadata.format) {
259
+ throw new Error(`Could not get image format for ${filepath}`);
260
+ }
261
+ return metadata.format;
262
+ };
263
+ const optimizeScreenshot = async (filepath, format)=>{
264
+ const resultFilePath = await tmpFile();
265
+ const optimization = sharp(filepath).resize(2048, 20480, {
266
+ fit: "inside",
267
+ withoutEnlargement: true
268
+ });
269
+ switch(format){
270
+ case "jpeg":
271
+ case "jpg":
272
+ {
273
+ optimization.jpeg();
274
+ break;
275
+ }
276
+ case "png":
277
+ {
278
+ optimization.png();
279
+ break;
280
+ }
281
+ }
282
+ await optimization.toFile(resultFilePath);
283
+ return resultFilePath;
284
+ };
285
+
286
+ const hashFile = async (filepath)=>{
287
+ const fileStream = createReadStream(filepath);
288
+ const hash = createHash("sha256");
289
+ await new Promise((resolve, reject)=>{
290
+ fileStream.on("error", reject);
291
+ hash.on("error", reject);
292
+ hash.on("finish", resolve);
293
+ fileStream.pipe(hash);
294
+ });
295
+ return hash.read().toString("hex");
296
+ };
297
+
298
+ const base64Encode = (obj)=>Buffer.from(JSON.stringify(obj), "utf8").toString("base64");
299
+ const getBearerToken = ({ token , ciService , owner , repository , jobId , runId })=>{
300
+ if (token) return `Bearer ${token}`;
301
+ switch(ciService){
302
+ case "GitHub Actions":
303
+ {
304
+ if (!owner || !repository || !jobId || !runId) {
305
+ throw new Error(`Automatic ${ciService} variables detection failed. Please add the 'ARGOS_TOKEN'`);
306
+ }
307
+ return `Bearer tokenless-github-${base64Encode({
308
+ owner,
309
+ repository,
310
+ jobId,
311
+ runId
312
+ })}`;
313
+ }
314
+ default:
315
+ throw new Error("Missing Argos repository token 'ARGOS_TOKEN'");
316
+ }
317
+ };
318
+ const createArgosApiClient = (options)=>{
319
+ const axiosInstance = axios.create({
320
+ baseURL: options.baseUrl,
321
+ headers: {
322
+ Authorization: options.bearerToken,
323
+ "Content-Type": "application/json",
324
+ Accept: "application/json"
325
+ }
326
+ });
327
+ const call = async (method, path, data)=>{
328
+ try {
329
+ const response = await axiosInstance.request({
330
+ method,
331
+ url: path,
332
+ data
333
+ });
334
+ return response.data;
335
+ } catch (error) {
336
+ if (error?.response?.data?.error?.message) {
337
+ // @ts-ignore
338
+ throw new Error(error.response.data.error.message, {
339
+ cause: error
340
+ });
341
+ }
342
+ throw error;
343
+ }
344
+ };
345
+ return {
346
+ createBuild: async (input)=>{
347
+ return call("POST", "/builds", input);
348
+ },
349
+ updateBuild: async (input)=>{
350
+ const { buildId , ...body } = input;
351
+ return call("PUT", `/builds/${buildId}`, body);
352
+ }
353
+ };
354
+ };
355
+
356
+ const formatToContentType = (format)=>{
357
+ switch(format){
358
+ case "jpeg":
359
+ case "jpg":
360
+ return "image/jpeg";
361
+ case "png":
362
+ return "image/png";
363
+ default:
364
+ throw new Error(`Unsupported format ${format}`);
365
+ }
366
+ };
367
+ const upload$1 = async (input)=>{
368
+ const file = await readFile(input.path);
369
+ await axios({
370
+ method: "PUT",
371
+ url: input.url,
372
+ data: file,
373
+ headers: {
374
+ "Content-Type": formatToContentType(input.format)
375
+ }
376
+ });
377
+ };
378
+
379
+ const debug = createDebug("@argos-ci/core");
380
+
381
+ const getConfigFromOptions = (options)=>{
382
+ const { apiBaseUrl , commit , branch , token , buildName , parallel } = options;
383
+ const config = createConfig();
384
+ config.load(omitUndefined({
385
+ apiBaseUrl,
386
+ commit,
387
+ branch,
388
+ token,
389
+ buildName,
390
+ parallel: Boolean(parallel),
391
+ parallelNonce: parallel ? parallel.nonce : null,
392
+ parallelTotal: parallel ? parallel.total : null
393
+ }));
394
+ if (!config.get("commit")) {
395
+ const ciEnv = getCiEnvironment();
396
+ if (ciEnv) {
397
+ config.load(omitUndefined({
398
+ commit: ciEnv.commit,
399
+ branch: ciEnv.branch,
400
+ ciService: ciEnv.name,
401
+ owner: ciEnv.owner,
402
+ repository: ciEnv.repository,
403
+ jobId: ciEnv.jobId,
404
+ runId: ciEnv.runId
405
+ }));
406
+ }
407
+ }
408
+ config.validate();
409
+ return config.get();
410
+ };
411
+ /**
412
+ * Upload screenshots to argos-ci.com.
413
+ */ const upload = async (params)=>{
414
+ // Read config
415
+ const config = getConfigFromOptions(params);
416
+ const files = params.files ?? [
417
+ "**/*.{png,jpg,jpeg}"
418
+ ];
419
+ const apiClient = createArgosApiClient({
420
+ baseUrl: config.apiBaseUrl,
421
+ bearerToken: getBearerToken(config)
422
+ });
423
+ // Collect screenshots
424
+ const foundScreenshots = await discoverScreenshots(files, {
425
+ root: params.root,
426
+ ignore: params.ignore
427
+ });
428
+ // Optimize & compute hashes
429
+ const screenshots = await Promise.all(foundScreenshots.map(async (screenshot)=>{
430
+ const format = await getImageFormat(screenshot.path);
431
+ const optimizedPath = await optimizeScreenshot(screenshot.path, format);
432
+ const hash = await hashFile(optimizedPath);
433
+ return {
434
+ ...screenshot,
435
+ optimizedPath,
436
+ format,
437
+ hash
438
+ };
439
+ }));
440
+ // Create build
441
+ debug("Creating build");
442
+ const result = await apiClient.createBuild({
443
+ commit: config.commit,
444
+ branch: config.branch,
445
+ name: config.buildName,
446
+ parallel: config.parallel,
447
+ parallelNonce: config.parallelNonce,
448
+ screenshotKeys: Array.from(new Set(screenshots.map((screenshot)=>screenshot.hash)))
449
+ });
450
+ debug("Got screenshots", result);
451
+ // Upload screenshots
452
+ debug("Uploading screenshots");
453
+ await Promise.all(result.screenshots.map(async ({ key , putUrl })=>{
454
+ const screenshot = screenshots.find((s)=>s.hash === key);
455
+ if (!screenshot) {
456
+ throw new Error(`Invariant: screenshot with hash ${key} not found`);
457
+ }
458
+ await upload$1({
459
+ url: putUrl,
460
+ path: screenshot.optimizedPath,
461
+ format: screenshot.format
462
+ });
463
+ }));
464
+ // Update build
465
+ debug("Updating build");
466
+ await apiClient.updateBuild({
467
+ buildId: result.build.id,
468
+ screenshots: screenshots.map((screenshot)=>({
469
+ key: screenshot.hash,
470
+ name: screenshot.name
471
+ })),
472
+ parallel: config.parallel,
473
+ parallelTotal: config.parallelTotal
474
+ });
475
+ return {
476
+ build: result.build,
477
+ screenshots
478
+ };
479
+ };
480
+
481
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
482
+ const rawPkg = await readFile(resolve(__dirname, "..", "package.json"), "utf8");
7
483
  const pkg = JSON.parse(rawPkg);
8
484
  program.name(pkg.name).description("Interact with and upload screenshots to argos-ci.com via command line.").version(pkg.version);
9
485
  program.command("upload").argument("<directory>", "Directory to upload").description("Upload screenshots to argos-ci.com").option("-f, --files <patterns...>", "One or more globs matching image file paths to upload", "**/*.{png,jpg,jpeg}").option("-i, --ignore <patterns...>", 'One or more globs matching image file paths to ignore (ex: "**/*.png **/diff.jpg")').option("--token <token>", "Repository token").option("--build-name <string>", "Name of the build, in case you want to run multiple Argos builds in a single CI job").option("--parallel", "Enable parallel mode. Run multiple Argos builds and combine them at the end").option("--parallel-total <number>", "The number of parallel nodes being ran").option("--parallel-nonce <string>", "A unique ID for this parallel build").action(async (directory, options)=>{
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@argos-ci/cli",
3
3
  "description": "Visual testing solution to avoid visual regression. Argos CLI is used to interact with and upload screenshots to argos-ci.com via command line.",
4
- "version": "0.2.4-alpha.3+ac9a461",
4
+ "version": "0.2.4",
5
5
  "bin": {
6
6
  "argos": "./bin/argos-cli.js"
7
7
  },
8
8
  "scripts": {
9
9
  "prebuild": "rm -rf dist",
10
10
  "build": "rollup -c",
11
- "e2e": "npx @argos-ci/cli upload ../../__fixtures__ --build-name \"argos-cli-e2e-node-$NODE_VERSION\""
11
+ "e2e": "npx @argos-ci/cli upload ../../__fixtures__ --build-name \"argos-cli-e2e-node-$NODE_VERSION-$OS\""
12
12
  },
13
13
  "type": "module",
14
14
  "exports": {
@@ -40,14 +40,14 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@argos-ci/core": "^0.4.2-alpha.5+ac9a461",
44
- "commander": "^9.4.0",
43
+ "@argos-ci/core": "^0.5.0",
44
+ "commander": "^9.4.1",
45
45
  "ora": "^6.1.2",
46
46
  "update-notifier": "^6.0.2"
47
47
  },
48
48
  "devDependencies": {
49
- "rollup": "^2.78.0",
50
- "rollup-plugin-swc3": "^0.3.0"
49
+ "rollup": "^2.79.1",
50
+ "rollup-plugin-swc3": "^0.6.0"
51
51
  },
52
- "gitHead": "ac9a46182e464e332ca72dfca03a5665586f7aff"
52
+ "gitHead": "c085c7e514623e54a86b2f311ff988709be391d0"
53
53
  }