@argos-ci/cli 0.2.4-alpha.4 → 0.2.5

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 +477 -2
  2. package/package.json +7 -7
package/dist/index.mjs CHANGED
@@ -1,9 +1,484 @@
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) return null;
187
+ return env.GITHUB_REPOSITORY.split("/")[1];
188
+ }
189
+ const service = {
190
+ detect: ({ env })=>Boolean(env.GITHUB_ACTIONS),
191
+ config: ({ env })=>({
192
+ name: "GitHub Actions",
193
+ commit: getSha({
194
+ env
195
+ }),
196
+ branch: getBranch({
197
+ env
198
+ }),
199
+ owner: env.GITHUB_REPOSITORY_OWNER || null,
200
+ repository: getRepository({
201
+ env
202
+ }),
203
+ jobId: env.GITHUB_JOB || null,
204
+ runId: env.GITHUB_RUN_ID || null
205
+ })
206
+ };
207
+
208
+ const services = [
209
+ service$1,
210
+ service
211
+ ];
212
+ const getCiEnvironment = ({ env =process.env } = {})=>{
213
+ const ctx = {
214
+ env
215
+ };
216
+ const service = services.find((service)=>service.detect(ctx));
217
+ // Internal service matched
218
+ if (service) {
219
+ return service.config(ctx);
220
+ }
221
+ // Fallback on env-ci detection
222
+ const ciContext = envCi(ctx);
223
+ const name = ciContext.isCi ? ciContext.name ?? null : ciContext.commit ? "Git" : null;
224
+ const commit = ciContext.commit ?? null;
225
+ const branch = (ciContext.branch || ciContext.prBranch) ?? null;
226
+ const slug = ciContext.slug ? ciContext.slug.split("/") : null;
227
+ const owner = slug ? slug[0] : null;
228
+ const repository = slug ? slug[1] : null;
229
+ const jobId = ciContext.job ?? null;
230
+ const runId = null;
231
+ return commit ? {
232
+ name,
233
+ commit,
234
+ branch,
235
+ owner,
236
+ repository,
237
+ jobId,
238
+ runId
239
+ } : null;
240
+ };
241
+
242
+ const discoverScreenshots = async (patterns, { root =process.cwd() , ignore } = {})=>{
243
+ const matches = await glob(patterns, {
244
+ onlyFiles: true,
245
+ ignore,
246
+ cwd: root
247
+ });
248
+ return matches.map((match)=>({
249
+ name: match,
250
+ path: resolve(root, match)
251
+ }));
252
+ };
253
+
254
+ const tmpFile = promisify(tmp.file);
255
+ const getImageFormat = async (filepath)=>{
256
+ const metadata = await sharp(filepath).metadata();
257
+ if (!metadata.format) {
258
+ throw new Error(`Could not get image format for ${filepath}`);
259
+ }
260
+ return metadata.format;
261
+ };
262
+ const optimizeScreenshot = async (filepath, format)=>{
263
+ const resultFilePath = await tmpFile();
264
+ const optimization = sharp(filepath).resize(2048, 20480, {
265
+ fit: "inside",
266
+ withoutEnlargement: true
267
+ });
268
+ switch(format){
269
+ case "jpeg":
270
+ case "jpg":
271
+ {
272
+ optimization.jpeg();
273
+ break;
274
+ }
275
+ case "png":
276
+ {
277
+ optimization.png();
278
+ break;
279
+ }
280
+ }
281
+ await optimization.toFile(resultFilePath);
282
+ return resultFilePath;
283
+ };
284
+
285
+ const hashFile = async (filepath)=>{
286
+ const fileStream = createReadStream(filepath);
287
+ const hash = createHash("sha256");
288
+ await new Promise((resolve, reject)=>{
289
+ fileStream.on("error", reject);
290
+ hash.on("error", reject);
291
+ hash.on("finish", resolve);
292
+ fileStream.pipe(hash);
293
+ });
294
+ return hash.read().toString("hex");
295
+ };
296
+
297
+ const base64Encode = (obj)=>Buffer.from(JSON.stringify(obj), "utf8").toString("base64");
298
+ const getBearerToken = ({ token , ciService , owner , repository , jobId , runId })=>{
299
+ if (token) return `Bearer ${token}`;
300
+ switch(ciService){
301
+ case "GitHub Actions":
302
+ {
303
+ if (!owner || !repository || !jobId || !runId) {
304
+ throw new Error(`Automatic ${ciService} variables detection failed. Please add the 'ARGOS_TOKEN'`);
305
+ }
306
+ return `Bearer tokenless-github-${base64Encode({
307
+ owner,
308
+ repository,
309
+ jobId,
310
+ runId
311
+ })}`;
312
+ }
313
+ default:
314
+ throw new Error("Missing Argos repository token 'ARGOS_TOKEN'");
315
+ }
316
+ };
317
+ const createArgosApiClient = (options)=>{
318
+ const axiosInstance = axios.create({
319
+ baseURL: options.baseUrl,
320
+ headers: {
321
+ Authorization: options.bearerToken,
322
+ "Content-Type": "application/json",
323
+ Accept: "application/json"
324
+ }
325
+ });
326
+ const call = async (method, path, data)=>{
327
+ try {
328
+ const response = await axiosInstance.request({
329
+ method,
330
+ url: path,
331
+ data
332
+ });
333
+ return response.data;
334
+ } catch (error) {
335
+ if (error?.response?.data?.error?.message) {
336
+ // @ts-ignore
337
+ throw new Error(error.response.data.error.message, {
338
+ cause: error
339
+ });
340
+ }
341
+ throw error;
342
+ }
343
+ };
344
+ return {
345
+ createBuild: async (input)=>{
346
+ return call("POST", "/builds", input);
347
+ },
348
+ updateBuild: async (input)=>{
349
+ const { buildId , ...body } = input;
350
+ return call("PUT", `/builds/${buildId}`, body);
351
+ }
352
+ };
353
+ };
354
+
355
+ const formatToContentType = (format)=>{
356
+ switch(format){
357
+ case "jpeg":
358
+ case "jpg":
359
+ return "image/jpeg";
360
+ case "png":
361
+ return "image/png";
362
+ default:
363
+ throw new Error(`Unsupported format ${format}`);
364
+ }
365
+ };
366
+ const upload$1 = async (input)=>{
367
+ const file = await readFile(input.path);
368
+ await axios({
369
+ method: "PUT",
370
+ url: input.url,
371
+ data: file,
372
+ headers: {
373
+ "Content-Type": formatToContentType(input.format)
374
+ }
375
+ });
376
+ };
377
+
378
+ const debug = createDebug("@argos-ci/core");
379
+
380
+ const getConfigFromOptions = (options)=>{
381
+ const { apiBaseUrl , commit , branch , token , buildName , parallel } = options;
382
+ const config = createConfig();
383
+ config.load(omitUndefined({
384
+ apiBaseUrl,
385
+ commit,
386
+ branch,
387
+ token,
388
+ buildName,
389
+ parallel: Boolean(parallel),
390
+ parallelNonce: parallel ? parallel.nonce : null,
391
+ parallelTotal: parallel ? parallel.total : null
392
+ }));
393
+ if (!config.get("commit")) {
394
+ const ciEnv = getCiEnvironment();
395
+ if (ciEnv) {
396
+ config.load(omitUndefined({
397
+ commit: ciEnv.commit,
398
+ branch: ciEnv.branch,
399
+ ciService: ciEnv.name,
400
+ owner: ciEnv.owner,
401
+ repository: ciEnv.repository,
402
+ jobId: ciEnv.jobId,
403
+ runId: ciEnv.runId
404
+ }));
405
+ }
406
+ }
407
+ config.validate();
408
+ return config.get();
409
+ };
410
+ /**
411
+ * Upload screenshots to argos-ci.com.
412
+ */ const upload = async (params)=>{
413
+ // Read config
414
+ const config = getConfigFromOptions(params);
415
+ const files = params.files ?? [
416
+ "**/*.{png,jpg,jpeg}"
417
+ ];
418
+ const apiClient = createArgosApiClient({
419
+ baseUrl: config.apiBaseUrl,
420
+ bearerToken: getBearerToken(config)
421
+ });
422
+ // Collect screenshots
423
+ const foundScreenshots = await discoverScreenshots(files, {
424
+ root: params.root,
425
+ ignore: params.ignore
426
+ });
427
+ // Optimize & compute hashes
428
+ const screenshots = await Promise.all(foundScreenshots.map(async (screenshot)=>{
429
+ const format = await getImageFormat(screenshot.path);
430
+ const optimizedPath = await optimizeScreenshot(screenshot.path, format);
431
+ const hash = await hashFile(optimizedPath);
432
+ return {
433
+ ...screenshot,
434
+ optimizedPath,
435
+ format,
436
+ hash
437
+ };
438
+ }));
439
+ // Create build
440
+ debug("Creating build");
441
+ const result = await apiClient.createBuild({
442
+ commit: config.commit,
443
+ branch: config.branch,
444
+ name: config.buildName,
445
+ parallel: config.parallel,
446
+ parallelNonce: config.parallelNonce,
447
+ screenshotKeys: Array.from(new Set(screenshots.map((screenshot)=>screenshot.hash)))
448
+ });
449
+ debug("Got screenshots", result);
450
+ // Upload screenshots
451
+ debug("Uploading screenshots");
452
+ await Promise.all(result.screenshots.map(async ({ key , putUrl })=>{
453
+ const screenshot = screenshots.find((s)=>s.hash === key);
454
+ if (!screenshot) {
455
+ throw new Error(`Invariant: screenshot with hash ${key} not found`);
456
+ }
457
+ await upload$1({
458
+ url: putUrl,
459
+ path: screenshot.optimizedPath,
460
+ format: screenshot.format
461
+ });
462
+ }));
463
+ // Update build
464
+ debug("Updating build");
465
+ await apiClient.updateBuild({
466
+ buildId: result.build.id,
467
+ screenshots: screenshots.map((screenshot)=>({
468
+ key: screenshot.hash,
469
+ name: screenshot.name
470
+ })),
471
+ parallel: config.parallel,
472
+ parallelTotal: config.parallelTotal
473
+ });
474
+ return {
475
+ build: result.build,
476
+ screenshots
477
+ };
478
+ };
479
+
480
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
481
+ const rawPkg = await readFile(resolve(__dirname, "..", "package.json"), "utf8");
7
482
  const pkg = JSON.parse(rawPkg);
8
483
  program.name(pkg.name).description("Interact with and upload screenshots to argos-ci.com via command line.").version(pkg.version);
9
484
  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.4+3ace924",
4
+ "version": "0.2.5",
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.6+3ace924",
44
- "commander": "^9.4.0",
43
+ "@argos-ci/core": "^0.5.1",
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": "3ace9249c7e55c5e0e2ae176b203f6807005b872"
52
+ "gitHead": "a8a781ba5db26acc5cc18863875c6be4264251bf"
53
53
  }