@argos-ci/cli 0.4.4 → 0.4.6-alpha.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.
Files changed (2) hide show
  1. package/dist/index.mjs +1 -573
  2. package/package.json +3 -3
package/dist/index.mjs CHANGED
@@ -2,581 +2,9 @@ import { readFile } from 'node:fs/promises';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { resolve } from 'node:path';
4
4
  import { program } from 'commander';
5
- import convict from 'convict';
6
- import { execSync } from 'node:child_process';
7
- import { existsSync, readFileSync, createReadStream } from 'node:fs';
8
- import createDebug from 'debug';
9
- import envCi from 'env-ci';
10
- import glob from 'fast-glob';
11
- import { promisify } from 'node:util';
12
- import sharp from 'sharp';
13
- import tmp from 'tmp';
14
- import { createHash } from 'node:crypto';
15
- import axios from 'axios';
5
+ import { upload } from '@argos-ci/core';
16
6
  import ora from 'ora';
17
7
 
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: null,
48
- format: mustBeCommit
49
- },
50
- branch: {
51
- env: "ARGOS_BRANCH",
52
- default: null,
53
- format: String
54
- },
55
- token: {
56
- env: "ARGOS_TOKEN",
57
- default: null,
58
- format: mustBeArgosToken
59
- },
60
- buildName: {
61
- env: "ARGOS_BUILD_NAME",
62
- default: null,
63
- format: String,
64
- nullable: true
65
- },
66
- prNumber: {
67
- env: "ARGOS_PR_NUMBER",
68
- format: Number,
69
- default: null,
70
- nullable: true
71
- },
72
- parallel: {
73
- env: "ARGOS_PARALLEL",
74
- default: false,
75
- format: Boolean
76
- },
77
- parallelNonce: {
78
- env: "ARGOS_PARALLEL_NONCE",
79
- format: String,
80
- default: null,
81
- nullable: true
82
- },
83
- parallelTotal: {
84
- env: "ARGOS_PARALLEL_TOTAL",
85
- format: "nat",
86
- default: null,
87
- nullable: true
88
- },
89
- ciService: {
90
- format: String,
91
- default: null,
92
- nullable: true
93
- },
94
- jobId: {
95
- format: String,
96
- default: null,
97
- nullable: true
98
- },
99
- runId: {
100
- format: String,
101
- default: null,
102
- nullable: true
103
- },
104
- owner: {
105
- format: String,
106
- default: null,
107
- nullable: true
108
- },
109
- repository: {
110
- format: String,
111
- default: null,
112
- nullable: true
113
- }
114
- };
115
- const createConfig = ()=>{
116
- return convict(schema, {
117
- args: []
118
- });
119
- };
120
-
121
- /**
122
- * Returns the head commit.
123
- */ const head = ()=>{
124
- try {
125
- return execSync("git rev-parse HEAD").toString().trim();
126
- } catch {
127
- return null;
128
- }
129
- };
130
- /**
131
- * Returns the current branch.
132
- */ const branch = ()=>{
133
- try {
134
- const headRef = execSync("git rev-parse --abbrev-ref HEAD").toString().trim();
135
- if (headRef === "HEAD") {
136
- return null;
137
- }
138
- return headRef;
139
- } catch {
140
- return null;
141
- }
142
- };
143
-
144
- const service$4 = {
145
- name: "Buildkite",
146
- detect: ({ env })=>Boolean(env.BUILDKITE),
147
- config: ({ env })=>{
148
- return {
149
- // Buildkite doesn't work well so we fallback to git to ensure we have commit and branch
150
- commit: env.BUILDKITE_COMMIT || head() || null,
151
- branch: env.BUILDKITE_BRANCH || branch() || null,
152
- owner: env.BUILDKITE_ORGANIZATION_SLUG || null,
153
- repository: env.BUILDKITE_PROJECT_SLUG || null,
154
- jobId: null,
155
- runId: null,
156
- prNumber: env.BUILDKITE_PULL_REQUEST ? Number(env.BUILDKITE_PULL_REQUEST) : null
157
- };
158
- }
159
- };
160
-
161
- const service$3 = {
162
- name: "Heroku",
163
- detect: ({ env })=>Boolean(env.HEROKU_TEST_RUN_ID),
164
- config: ({ env })=>({
165
- commit: env.HEROKU_TEST_RUN_COMMIT_VERSION || null,
166
- branch: env.HEROKU_TEST_RUN_BRANCH || null,
167
- owner: null,
168
- repository: null,
169
- jobId: null,
170
- runId: null,
171
- prNumber: null
172
- })
173
- };
174
-
175
- const getBranch = ({ env })=>{
176
- if (env.GITHUB_HEAD_REF) {
177
- return env.GITHUB_HEAD_REF;
178
- }
179
- const branchRegex = /refs\/heads\/(.*)/;
180
- const matches = branchRegex.exec(env.GITHUB_REF || "");
181
- if (matches) {
182
- return matches[1];
183
- }
184
- return null;
185
- };
186
- const getRepository$1 = ({ env })=>{
187
- if (!env.GITHUB_REPOSITORY) return null;
188
- return env.GITHUB_REPOSITORY.split("/")[1];
189
- };
190
- const readEventPayload = ({ env })=>{
191
- if (!env.GITHUB_EVENT_PATH) return null;
192
- if (!existsSync(env.GITHUB_EVENT_PATH)) return null;
193
- return JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf-8"));
194
- };
195
- const service$2 = {
196
- name: "GitHub Actions",
197
- detect: ({ env })=>Boolean(env.GITHUB_ACTIONS),
198
- config: ({ env })=>{
199
- const payload = readEventPayload({
200
- env
201
- });
202
- return {
203
- commit: payload?.pull_request?.head.sha || process.env.GITHUB_SHA || null,
204
- branch: payload?.pull_request?.head.ref || getBranch({
205
- env
206
- }) || null,
207
- owner: env.GITHUB_REPOSITORY_OWNER || null,
208
- repository: getRepository$1({
209
- env
210
- }),
211
- jobId: env.GITHUB_JOB || null,
212
- runId: env.GITHUB_RUN_ID || null,
213
- prNumber: payload?.pull_request?.number || null
214
- };
215
- }
216
- };
217
-
218
- const getPrNumber$1 = ({ env })=>{
219
- const branchRegex = /pull\/(\d+)/;
220
- const matches = branchRegex.exec(env.CIRCLE_PULL_REQUEST || "");
221
- if (matches) {
222
- return Number(matches[1]);
223
- }
224
- return null;
225
- };
226
- const service$1 = {
227
- name: "CircleCI",
228
- detect: ({ env })=>Boolean(env.CIRCLECI),
229
- config: ({ env })=>{
230
- return {
231
- commit: env.CIRCLE_SHA1 || null,
232
- branch: env.CIRCLE_BRANCH || null,
233
- owner: env.CIRCLE_PROJECT_USERNAME || null,
234
- repository: env.CIRCLE_PROJECT_REPONAME || null,
235
- jobId: null,
236
- runId: null,
237
- prNumber: getPrNumber$1({
238
- env
239
- })
240
- };
241
- }
242
- };
243
-
244
- const getOwner = ({ env })=>{
245
- if (!env.TRAVIS_REPO_SLUG) return null;
246
- return env.TRAVIS_REPO_SLUG.split("/")[0] || null;
247
- };
248
- const getRepository = ({ env })=>{
249
- if (!env.TRAVIS_REPO_SLUG) return null;
250
- return env.TRAVIS_REPO_SLUG.split("/")[1] || null;
251
- };
252
- const getPrNumber = ({ env })=>{
253
- if (env.TRAVIS_PULL_REQUEST) return Number(env.TRAVIS_PULL_REQUEST);
254
- return null;
255
- };
256
- const service = {
257
- name: "Travis CI",
258
- detect: ({ env })=>Boolean(env.TRAVIS),
259
- config: (ctx)=>{
260
- const { env } = ctx;
261
- return {
262
- commit: env.TRAVIS_COMMIT || null,
263
- branch: env.TRAVIS_BRANCH || null,
264
- owner: getOwner(ctx),
265
- repository: getRepository(ctx),
266
- jobId: null,
267
- runId: null,
268
- prNumber: getPrNumber(ctx)
269
- };
270
- }
271
- };
272
-
273
- const KEY = "@argos-ci/core";
274
- const debug = createDebug(KEY);
275
- const debugTime = (arg)=>{
276
- const enabled = createDebug.enabled(KEY);
277
- if (enabled) {
278
- console.time(arg);
279
- }
280
- };
281
- const debugTimeEnd = (arg)=>{
282
- const enabled = createDebug.enabled(KEY);
283
- if (enabled) {
284
- console.timeEnd(arg);
285
- }
286
- };
287
-
288
- const getCiEnvironmentFromEnvCi = (ctx)=>{
289
- const ciContext = envCi(ctx);
290
- const name = ciContext.isCi ? ciContext.name ?? null : ciContext.commit ? "Git" : null;
291
- const commit = ciContext.commit ?? null;
292
- const branch = (ciContext.branch || ciContext.prBranch) ?? null;
293
- const slug = ciContext.slug ? ciContext.slug.split("/") : null;
294
- const owner = slug ? slug[0] : null;
295
- const repository = slug ? slug[1] : null;
296
- const jobId = ciContext.job ?? null;
297
- const runId = null;
298
- const prNumber = null;
299
- return commit ? {
300
- name,
301
- commit,
302
- branch,
303
- owner,
304
- repository,
305
- jobId,
306
- runId,
307
- prNumber
308
- } : null;
309
- };
310
-
311
- const services = [
312
- service$3,
313
- service$2,
314
- service$1,
315
- service,
316
- service$4
317
- ];
318
- const getCiEnvironment = ({ env =process.env } = {})=>{
319
- const ctx = {
320
- env
321
- };
322
- debug("Detecting CI environment", {
323
- env
324
- });
325
- const service = services.find((service)=>service.detect(ctx));
326
- // Service matched
327
- if (service) {
328
- debug("Internal service matched", service.name);
329
- const variables = service.config(ctx);
330
- const ciEnvironment = {
331
- name: service.name,
332
- ...variables
333
- };
334
- debug("CI environment", ciEnvironment);
335
- return ciEnvironment;
336
- }
337
- // We fallback on "env-ci" library, not very good but it's better than nothing
338
- debug("Falling back on env-ci");
339
- const ciEnvironment1 = getCiEnvironmentFromEnvCi(ctx);
340
- debug("CI environment", ciEnvironment1);
341
- return ciEnvironment1;
342
- };
343
-
344
- const discoverScreenshots = async (patterns, { root =process.cwd() , ignore } = {})=>{
345
- const matches = await glob(patterns, {
346
- onlyFiles: true,
347
- ignore,
348
- cwd: root
349
- });
350
- return matches.map((match)=>({
351
- name: match,
352
- path: resolve(root, match)
353
- }));
354
- };
355
-
356
- const tmpFile = promisify(tmp.file);
357
- const optimizeScreenshot = async (filepath)=>{
358
- const resultFilePath = await tmpFile();
359
- await sharp(filepath).resize(2048, 20480, {
360
- fit: "inside",
361
- withoutEnlargement: true
362
- }).png({
363
- force: true
364
- }).toFile(resultFilePath);
365
- return resultFilePath;
366
- };
367
-
368
- const hashFile = async (filepath)=>{
369
- const fileStream = createReadStream(filepath);
370
- const hash = createHash("sha256");
371
- await new Promise((resolve, reject)=>{
372
- fileStream.on("error", reject);
373
- hash.on("error", reject);
374
- hash.on("finish", resolve);
375
- fileStream.pipe(hash);
376
- });
377
- return hash.digest("hex");
378
- };
379
-
380
- const base64Encode = (obj)=>Buffer.from(JSON.stringify(obj), "utf8").toString("base64");
381
- const getBearerToken = ({ token , ciService , owner , repository , jobId , runId , prNumber })=>{
382
- if (token) return `Bearer ${token}`;
383
- switch(ciService){
384
- case "GitHub Actions":
385
- {
386
- if (!owner || !repository || !jobId || !runId) {
387
- throw new Error(`Automatic ${ciService} variables detection failed. Please add the 'ARGOS_TOKEN'`);
388
- }
389
- return `Bearer tokenless-github-${base64Encode({
390
- owner,
391
- repository,
392
- jobId,
393
- runId,
394
- prNumber
395
- })}`;
396
- }
397
- default:
398
- throw new Error("Missing Argos repository token 'ARGOS_TOKEN'");
399
- }
400
- };
401
- const createArgosApiClient = (options)=>{
402
- const axiosInstance = axios.create({
403
- baseURL: options.baseUrl,
404
- headers: {
405
- Authorization: options.bearerToken,
406
- "Content-Type": "application/json",
407
- Accept: "application/json"
408
- }
409
- });
410
- const call = async (method, path, data)=>{
411
- try {
412
- debug("Sending request", {
413
- method,
414
- path,
415
- data
416
- });
417
- const response = await axiosInstance.request({
418
- method,
419
- url: path,
420
- data
421
- });
422
- debug("Getting response", {
423
- status: response.status,
424
- data: response.data
425
- });
426
- return response.data;
427
- } catch (error) {
428
- if (error?.response?.data?.error?.message) {
429
- // @ts-ignore
430
- throw new Error(error.response.data.error.message, {
431
- cause: error
432
- });
433
- }
434
- throw error;
435
- }
436
- };
437
- return {
438
- createBuild: async (input)=>{
439
- return call("POST", "/builds", input);
440
- },
441
- updateBuild: async (input)=>{
442
- const { buildId , ...body } = input;
443
- return call("PUT", `/builds/${buildId}`, body);
444
- }
445
- };
446
- };
447
-
448
- const upload$1 = async (input)=>{
449
- const file = await readFile(input.path);
450
- await axios({
451
- method: "PUT",
452
- url: input.url,
453
- data: file,
454
- headers: {
455
- "Content-Type": "image/png"
456
- }
457
- });
458
- };
459
-
460
- /**
461
- * Split an array into chunks of a given size.
462
- */ const chunk = (collection, size)=>{
463
- const result = [];
464
- // add each chunk to the result
465
- for(let x = 0; x < Math.ceil(collection.length / size); x++){
466
- let start = x * size;
467
- let end = start + size;
468
- result.push(collection.slice(start, end));
469
- }
470
- return result;
471
- };
472
-
473
- /**
474
- * Size of the chunks used to upload screenshots to Argos.
475
- */ const CHUNK_SIZE = 10;
476
- const getConfigFromOptions = (options)=>{
477
- const config = createConfig();
478
- const ciEnv = getCiEnvironment();
479
- config.load({
480
- apiBaseUrl: config.get("apiBaseUrl") ?? options.apiBaseUrl,
481
- commit: config.get("commit") ?? options.commit ?? ciEnv?.commit ?? null,
482
- branch: config.get("branch") ?? options.branch ?? ciEnv?.branch ?? null,
483
- token: config.get("token") ?? options.token ?? null,
484
- buildName: config.get("buildName") ?? options.buildName ?? null,
485
- prNumber: config.get("prNumber") ?? options.prNumber ?? ciEnv?.prNumber ?? null,
486
- ciService: ciEnv?.name ?? null,
487
- owner: ciEnv?.owner ?? null,
488
- repository: ciEnv?.repository ?? null,
489
- jobId: ciEnv?.jobId ?? null,
490
- runId: ciEnv?.runId ?? null
491
- });
492
- if (options.parallel) {
493
- config.load({
494
- parallel: Boolean(options.parallel),
495
- parallelNonce: options.parallel ? options.parallel.nonce : null,
496
- parallelTotal: options.parallel ? options.parallel.total : null
497
- });
498
- }
499
- config.validate();
500
- return config.get();
501
- };
502
- /**
503
- * Upload screenshots to argos-ci.com.
504
- */ const upload = async (params)=>{
505
- debug("Starting upload with params", params);
506
- // Read config
507
- const config = getConfigFromOptions(params);
508
- const files = params.files ?? [
509
- "**/*.{png,jpg,jpeg}"
510
- ];
511
- debug("Using config and files", config, files);
512
- const apiClient = createArgosApiClient({
513
- baseUrl: config.apiBaseUrl,
514
- bearerToken: getBearerToken(config)
515
- });
516
- // Collect screenshots
517
- const foundScreenshots = await discoverScreenshots(files, {
518
- root: params.root,
519
- ignore: params.ignore
520
- });
521
- debug("Found screenshots", foundScreenshots);
522
- // Optimize & compute hashes
523
- const screenshots = await Promise.all(foundScreenshots.map(async (screenshot)=>{
524
- const optimizedPath = await optimizeScreenshot(screenshot.path);
525
- const hash = await hashFile(optimizedPath);
526
- return {
527
- ...screenshot,
528
- optimizedPath,
529
- hash
530
- };
531
- }));
532
- // Create build
533
- debug("Creating build");
534
- const result = await apiClient.createBuild({
535
- commit: config.commit,
536
- branch: config.branch,
537
- name: config.buildName,
538
- parallel: config.parallel,
539
- parallelNonce: config.parallelNonce,
540
- screenshotKeys: Array.from(new Set(screenshots.map((screenshot)=>screenshot.hash))),
541
- prNumber: config.prNumber
542
- });
543
- debug("Got screenshots", result);
544
- debug(`Split screenshots in chunks of ${CHUNK_SIZE}`);
545
- const chunks = chunk(result.screenshots, CHUNK_SIZE);
546
- debug(`Starting upload of ${chunks.length} chunks`);
547
- for(let i = 0; i < chunks.length; i++){
548
- debug(`Uploading chunk ${i + 1}/${chunks.length}`);
549
- const timeLabel = `Chunk ${i + 1}/${chunks.length}`;
550
- debugTime(timeLabel);
551
- await Promise.all(chunks[i].map(async ({ key , putUrl })=>{
552
- const screenshot = screenshots.find((s)=>s.hash === key);
553
- if (!screenshot) {
554
- throw new Error(`Invariant: screenshot with hash ${key} not found`);
555
- }
556
- await upload$1({
557
- url: putUrl,
558
- path: screenshot.optimizedPath
559
- });
560
- }));
561
- debugTimeEnd(timeLabel);
562
- }
563
- // Update build
564
- debug("Updating build");
565
- await apiClient.updateBuild({
566
- buildId: result.build.id,
567
- screenshots: screenshots.map((screenshot)=>({
568
- key: screenshot.hash,
569
- name: screenshot.name
570
- })),
571
- parallel: config.parallel,
572
- parallelTotal: config.parallelTotal
573
- });
574
- return {
575
- build: result.build,
576
- screenshots
577
- };
578
- };
579
-
580
8
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
581
9
  const rawPkg = await readFile(resolve(__dirname, "..", "package.json"), "utf8");
582
10
  const pkg = JSON.parse(rawPkg);
package/package.json CHANGED
@@ -1,7 +1,7 @@
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.4.4",
4
+ "version": "0.4.6-alpha.0+2c79db4",
5
5
  "bin": {
6
6
  "argos": "./bin/argos-cli.js"
7
7
  },
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@argos-ci/core": "^0.8.0",
43
+ "@argos-ci/core": "^0.8.2-alpha.0+2c79db4",
44
44
  "commander": "^9.4.1",
45
45
  "ora": "^6.1.2",
46
46
  "update-notifier": "^6.0.2"
@@ -49,5 +49,5 @@
49
49
  "rollup": "^2.79.1",
50
50
  "rollup-plugin-swc3": "^0.6.0"
51
51
  },
52
- "gitHead": "bad0f5b5bf1dc1672b05cf08a4fec5d011ac4b0a"
52
+ "gitHead": "2c79db40e62d541cca2c3e6b124ffe2063e8c838"
53
53
  }