@sentinelqa/uploader 0.1.5 → 0.1.6

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 CHANGED
@@ -26,7 +26,7 @@ If you want to run without installing, use:
26
26
  npx @sentinelqa/uploader playwright
27
27
  ```
28
28
 
29
- ### Minimal GitLab CI
29
+ ### Minimal GitLab CI (preserves test exit code)
30
30
 
31
31
  ```yaml
32
32
  e2e:
@@ -34,8 +34,12 @@ e2e:
34
34
  script:
35
35
  - npm ci
36
36
  - npx playwright install --with-deps
37
- - npm run test:e2e || true
37
+ - set +e
38
+ - npx playwright test --reporter=line,json --output=test-results
39
+ - TEST_EXIT=$?
40
+ - export SENTINEL_TEST_EXIT_CODE=$TEST_EXIT
38
41
  - npx @sentinelqa/uploader playwright
42
+ - exit $TEST_EXIT
39
43
  artifacts:
40
44
  when: always
41
45
  paths:
@@ -43,7 +47,7 @@ e2e:
43
47
  - test-results/
44
48
  ```
45
49
 
46
- ### Minimal GitHub Actions
50
+ ### Minimal GitHub Actions (preserves test exit code)
47
51
 
48
52
  ```yaml
49
53
  name: E2E
@@ -58,14 +62,20 @@ jobs:
58
62
  node-version: "20"
59
63
  - run: npm ci
60
64
  - run: npx playwright install --with-deps
61
- - run: npm run test:e2e || true
62
- - run: npx @sentinelqa/uploader playwright
65
+ - run: |
66
+ set +e
67
+ npx playwright test --reporter=line,json --output=test-results
68
+ TEST_EXIT=$?
69
+ echo "SENTINEL_TEST_EXIT_CODE=$TEST_EXIT" >> $GITHUB_ENV
70
+ npx @sentinelqa/uploader playwright
71
+ exit $TEST_EXIT
63
72
  ```
64
73
 
65
74
  ## Required Environment Variables
66
75
 
67
76
  - `SENTINEL_TOKEN` (project ingest token)
68
77
  - `SENTINEL_URL` (optional; defaults to `https://app.sentinelqa.com`)
78
+ - `SENTINEL_TEST_EXIT_CODE` (optional; used to preserve CI test exit code)
69
79
 
70
80
  ## Optional: BYO S3 (Advanced)
71
81
 
@@ -92,5 +102,8 @@ Set these to upload directly to your own bucket:
92
102
  **No CI metadata**
93
103
  - The uploader detects GitLab or GitHub. If running locally, set the CI env vars or run in CI.
94
104
 
105
+ **No failed tests**
106
+ - Ensure `@playwright/test` is installed and the JSON reporter is enabled.
107
+
95
108
  **BYO uploads failing**
96
109
  - Verify `SENTINEL_S3_*` values and permissions.
package/dist/cli.js CHANGED
@@ -7,6 +7,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const client_s3_1 = require("@aws-sdk/client-s3");
10
+ const httpClient_1 = require("./httpClient");
11
+ const output_1 = require("./output");
10
12
  const DEFAULT_APP_URL = "https://app.sentinelqa.com";
11
13
  const DEFAULT_JSON_PATH = "playwright-report/report.json";
12
14
  const DEFAULT_PLAYWRIGHT_REPORT_DIR = "playwright-report";
@@ -15,9 +17,19 @@ const readEnv = (key) => {
15
17
  const value = process.env[key];
16
18
  return value && value.trim().length > 0 ? value.trim() : null;
17
19
  };
20
+ const getPreservedExitCode = () => {
21
+ const raw = readEnv("SENTINEL_TEST_EXIT_CODE");
22
+ if (!raw)
23
+ return null;
24
+ const num = Number.parseInt(raw, 10);
25
+ if (!Number.isFinite(num) || num <= 0)
26
+ return null;
27
+ return num;
28
+ };
18
29
  const fail = (message) => {
19
30
  console.error(`Error: ${message}`);
20
- process.exit(1);
31
+ const preserved = getPreservedExitCode();
32
+ process.exit(preserved ?? 1);
21
33
  };
22
34
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23
35
  const withRetry = async (fn, label) => {
@@ -192,22 +204,24 @@ const computeRunStatus = (tests) => {
192
204
  };
193
205
  const bestEffortComplete = async (appUrl, ingestToken, runId, status, tests) => {
194
206
  try {
195
- await fetch(`${appUrl}/api/runs/${runId}/complete`, {
207
+ const res = await (0, httpClient_1.requestJson)(`${appUrl}/api/runs/${runId}/complete`, {
196
208
  method: "POST",
197
209
  headers: {
198
- "content-type": "application/json",
199
210
  authorization: `Bearer ${ingestToken}`
200
211
  },
201
- body: JSON.stringify({
212
+ body: {
202
213
  status,
203
214
  finishedAt: new Date().toISOString(),
204
215
  tests,
205
216
  artifacts: []
206
- })
207
- });
217
+ }
218
+ }, 0);
219
+ if (res.status >= 400)
220
+ return null;
221
+ return res.body;
208
222
  }
209
223
  catch {
210
- // Ignore best-effort failures; original error will be surfaced.
224
+ return null;
211
225
  }
212
226
  };
213
227
  const detectGitLabEnv = () => {
@@ -375,25 +389,25 @@ const main = async () => {
375
389
  if (!ci.workflow.id)
376
390
  fail("Workflow ID is required.");
377
391
  const appUrl = readEnv("SENTINEL_URL") || readEnv("APP_URL") || DEFAULT_APP_URL;
378
- const createRes = await withRetry(() => fetch(`${appUrl}/api/runs`, {
392
+ const createRes = await (0, httpClient_1.requestJson)(`${appUrl}/api/runs`, {
379
393
  method: "POST",
380
394
  headers: {
381
- "content-type": "application/json",
382
395
  authorization: `Bearer ${ingestToken}`
383
396
  },
384
- body: JSON.stringify({
397
+ body: {
385
398
  provider: ci.provider,
386
399
  commit: ci.commit,
387
400
  workflow: ci.workflow,
388
401
  job: ci.job,
389
402
  ci: ci.ci
390
- })
391
- }), "POST /api/runs");
392
- if (!createRes.ok) {
393
- const body = await createRes.text();
394
- fail(`POST /api/runs failed (${createRes.status}): ${body}`);
403
+ }
404
+ }, 2);
405
+ if (createRes.status >= 400) {
406
+ fail(`POST /api/runs failed (${createRes.status}): ${createRes.raw}`);
395
407
  }
396
- const { runId } = await createRes.json();
408
+ const runId = createRes.body?.runId;
409
+ const internalRunUrl = createRes.body?.internalRunUrl ||
410
+ `${appUrl}/runs/${runId}`;
397
411
  if (!runId)
398
412
  fail("Missing runId from /api/runs response.");
399
413
  const uploadStart = Date.now();
@@ -450,26 +464,24 @@ const main = async () => {
450
464
  }));
451
465
  }
452
466
  else {
453
- const presignRes = await withRetry(() => fetch(`${appUrl}/api/uploads/presign`, {
467
+ const presignRes = await (0, httpClient_1.requestJson)(`${appUrl}/api/uploads/presign`, {
454
468
  method: "POST",
455
469
  headers: {
456
- "content-type": "application/json",
457
470
  authorization: `Bearer ${ingestToken}`
458
471
  },
459
- body: JSON.stringify({
472
+ body: {
460
473
  items: finalArtifacts.map((artifact) => ({
461
474
  relPath: artifact.objectKey,
462
475
  contentType: artifact.contentType,
463
476
  sizeBytes: artifact.sizeBytes,
464
477
  kind: artifact.type
465
478
  }))
466
- })
467
- }), "POST /api/uploads/presign");
468
- if (!presignRes.ok) {
469
- const body = await presignRes.text();
470
- fail(`POST /api/uploads/presign failed (${presignRes.status}): ${body}`);
479
+ }
480
+ }, 2);
481
+ if (presignRes.status >= 400) {
482
+ fail(`POST /api/uploads/presign failed (${presignRes.status}): ${presignRes.raw}`);
471
483
  }
472
- const presignData = await presignRes.json();
484
+ const presignData = presignRes.body;
473
485
  const uploadMap = new Map((presignData.items || []).map((item) => [item.relPath, item]));
474
486
  await withRetry(async () => {
475
487
  for (const artifact of finalArtifacts) {
@@ -478,18 +490,11 @@ const main = async () => {
478
490
  throw new Error(`Missing upload URL for ${artifact.objectKey}`);
479
491
  }
480
492
  await withRetry(async () => {
481
- const body = fs_1.default.createReadStream(artifact.filePath);
482
- const res = await fetch(item.uploadUrl, {
483
- method: "PUT",
484
- headers: {
485
- "content-type": artifact.contentType
486
- },
487
- body,
488
- // Required by Node.js fetch when streaming a request body
489
- duplex: "half"
490
- });
491
- if (!res.ok) {
492
- throw new Error(`Upload failed (${res.status}) for ${artifact.objectKey}`);
493
+ const stat = fs_1.default.statSync(artifact.filePath);
494
+ const stream = fs_1.default.createReadStream(artifact.filePath);
495
+ const result = await (0, httpClient_1.putFile)(item.uploadUrl, stream, artifact.contentType, stat.size);
496
+ if (result.status >= 400) {
497
+ throw new Error(`Upload failed (${result.status}) for ${artifact.objectKey}: ${result.raw}`);
493
498
  }
494
499
  }, `upload ${artifact.objectKey}`);
495
500
  artifact.objectKey = item.objectKey;
@@ -502,17 +507,39 @@ const main = async () => {
502
507
  }
503
508
  }
504
509
  catch (err) {
505
- await bestEffortComplete(appUrl, ingestToken, runId, "failed", tests);
510
+ const fallback = await bestEffortComplete(appUrl, ingestToken, runId, "failed", tests);
511
+ if (fallback) {
512
+ const output = (0, output_1.formatCiOutput)({
513
+ failedCount: fallback.failedCount ??
514
+ tests.filter((t) => t.status === "failed").length,
515
+ totalCount: fallback.totalCount ?? tests.length,
516
+ internalRunUrl: fallback.internalRunUrl || internalRunUrl,
517
+ internalFirstFailureUrl: fallback.internalFirstFailureUrl || null,
518
+ shareRunUrl: fallback.shareRunUrl || null,
519
+ shareFirstFailureUrl: fallback.shareFirstFailureUrl || null
520
+ }, !process.env.NO_COLOR);
521
+ console.log(output);
522
+ }
523
+ else {
524
+ console.log(internalRunUrl);
525
+ }
526
+ console.error("Warning: Sentinel uploader failed to upload artifacts.");
506
527
  fail(err?.message || String(err));
507
528
  }
508
529
  const uploadDurationMs = Date.now() - uploadStart;
509
- const completeRes = await withRetry(() => fetch(`${appUrl}/api/runs/${runId}/complete`, {
530
+ console.log(JSON.stringify({
531
+ event: "uploader_summary",
532
+ runId,
533
+ tests: tests.length,
534
+ artifacts: artifacts.length,
535
+ uploadDurationMs
536
+ }));
537
+ const completeRes = await (0, httpClient_1.requestJson)(`${appUrl}/api/runs/${runId}/complete`, {
510
538
  method: "POST",
511
539
  headers: {
512
- "content-type": "application/json",
513
540
  authorization: `Bearer ${ingestToken}`
514
541
  },
515
- body: JSON.stringify({
542
+ body: {
516
543
  status,
517
544
  finishedAt: new Date().toISOString(),
518
545
  tests,
@@ -524,42 +551,21 @@ const main = async () => {
524
551
  contentType: artifact.contentType,
525
552
  testKey: null
526
553
  }))
527
- })
528
- }), "POST /api/runs/:runId/complete");
529
- if (!completeRes.ok) {
530
- const body = await completeRes.text();
531
- fail(`POST /api/runs/:runId/complete failed (${completeRes.status}): ${body}`);
532
- }
533
- let shareUrl = null;
534
- try {
535
- const data = await completeRes.json();
536
- shareUrl = data.shareUrl || null;
537
- }
538
- catch {
539
- shareUrl = null;
540
- }
541
- const internalUrl = `${appUrl}/runs/${runId}`;
542
- console.log(JSON.stringify({
543
- event: "uploader_summary",
544
- runId,
545
- tests: tests.length,
546
- artifacts: artifacts.length,
547
- uploadDurationMs
548
- }));
549
- console.log(`Uploaded ${artifacts.length} artifacts and completed run ${runId}.`);
550
- console.log(internalUrl);
551
- if (shareUrl)
552
- console.log(shareUrl);
553
- if (status === "failed") {
554
- const total = tests.length;
555
- const failed = tests.filter((t) => t.status === "failed").length;
556
- const useColor = !process.env.NO_COLOR;
557
- const bold = useColor ? "\u001b[1m" : "";
558
- const red = useColor ? "\u001b[31m" : "";
559
- const reset = useColor ? "\u001b[0m" : "";
560
- console.log(`${bold}${red}CI Debug Report${reset}`);
561
- console.log(`${bold}${failed} failed / ${total} total${reset}`);
554
+ }
555
+ }, 2);
556
+ if (completeRes.status >= 400) {
557
+ fail(`POST /api/runs/:runId/complete failed (${completeRes.status}): ${completeRes.raw}`);
562
558
  }
559
+ const response = completeRes.body || {};
560
+ const output = (0, output_1.formatCiOutput)({
561
+ failedCount: response.failedCount ?? tests.filter((t) => t.status === "failed").length,
562
+ totalCount: response.totalCount ?? tests.length,
563
+ internalRunUrl: response.internalRunUrl || internalRunUrl,
564
+ internalFirstFailureUrl: response.internalFirstFailureUrl || null,
565
+ shareRunUrl: response.shareRunUrl || null,
566
+ shareFirstFailureUrl: response.shareFirstFailureUrl || null
567
+ }, !process.env.NO_COLOR);
568
+ console.log(output);
563
569
  };
564
570
  main().catch((err) => {
565
571
  fail(err?.message || String(err));
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.putFile = exports.requestJson = void 0;
7
+ const http_1 = __importDefault(require("http"));
8
+ const https_1 = __importDefault(require("https"));
9
+ const requestText = (url, options) => new Promise((resolve, reject) => {
10
+ const target = new URL(url);
11
+ const client = target.protocol === "https:" ? https_1.default : http_1.default;
12
+ const headers = { ...(options.headers || {}) };
13
+ let body;
14
+ if (options.body !== undefined) {
15
+ body = JSON.stringify(options.body);
16
+ headers["content-type"] = headers["content-type"] || "application/json";
17
+ headers["content-length"] = Buffer.byteLength(body).toString();
18
+ }
19
+ const req = client.request({
20
+ method: options.method,
21
+ hostname: target.hostname,
22
+ port: target.port,
23
+ path: `${target.pathname}${target.search}`,
24
+ headers
25
+ }, (res) => {
26
+ const chunks = [];
27
+ res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
28
+ res.on("end", () => {
29
+ const raw = Buffer.concat(chunks).toString("utf8");
30
+ resolve({ status: res.statusCode || 0, raw });
31
+ });
32
+ });
33
+ const timeoutMs = options.timeoutMs ?? 15000;
34
+ req.setTimeout(timeoutMs, () => {
35
+ req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
36
+ });
37
+ req.on("error", reject);
38
+ if (body)
39
+ req.write(body);
40
+ req.end();
41
+ });
42
+ const requestJson = async (url, options, retries = 2) => {
43
+ let attempt = 0;
44
+ while (attempt <= retries) {
45
+ try {
46
+ const { status, raw } = await requestText(url, options);
47
+ const parsed = raw ? JSON.parse(raw) : null;
48
+ if (status >= 500 && attempt < retries) {
49
+ attempt += 1;
50
+ continue;
51
+ }
52
+ return { status, body: parsed, raw };
53
+ }
54
+ catch (error) {
55
+ if (attempt >= retries)
56
+ throw error;
57
+ attempt += 1;
58
+ }
59
+ }
60
+ throw new Error("Request failed");
61
+ };
62
+ exports.requestJson = requestJson;
63
+ const putFile = (url, fileStream, contentType, contentLength, timeoutMs = 60000) => new Promise((resolve, reject) => {
64
+ const target = new URL(url);
65
+ const client = target.protocol === "https:" ? https_1.default : http_1.default;
66
+ const req = client.request({
67
+ method: "PUT",
68
+ hostname: target.hostname,
69
+ port: target.port,
70
+ path: `${target.pathname}${target.search}`,
71
+ headers: {
72
+ "content-type": contentType,
73
+ "content-length": contentLength.toString()
74
+ }
75
+ }, (res) => {
76
+ const chunks = [];
77
+ res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
78
+ res.on("end", () => {
79
+ resolve({
80
+ status: res.statusCode || 0,
81
+ raw: Buffer.concat(chunks).toString("utf8")
82
+ });
83
+ });
84
+ });
85
+ req.setTimeout(timeoutMs, () => {
86
+ req.destroy(new Error(`Upload timed out after ${timeoutMs}ms`));
87
+ });
88
+ req.on("error", reject);
89
+ fileStream.on("error", reject);
90
+ fileStream.pipe(req);
91
+ });
92
+ exports.putFile = putFile;
package/dist/output.js ADDED
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCiOutput = void 0;
4
+ const colorize = (value, code, useColor) => {
5
+ if (!useColor)
6
+ return value;
7
+ return `\u001b[${code}m${value}\u001b[0m`;
8
+ };
9
+ const formatCiOutput = (links, useColor = true) => {
10
+ const lines = [];
11
+ const failedLine = `${links.failedCount} failed / ${links.totalCount} total`;
12
+ const header = links.failedCount > 0
13
+ ? colorize("❌ CI Debug Report", "1;31", useColor)
14
+ : colorize("✅ CI Debug Report", "1;32", useColor);
15
+ const countLine = colorize(failedLine, "1", useColor);
16
+ lines.push(header);
17
+ lines.push(countLine);
18
+ lines.push("");
19
+ if (links.internalFirstFailureUrl) {
20
+ lines.push("First failure (fastest):");
21
+ lines.push(links.internalFirstFailureUrl);
22
+ lines.push("");
23
+ }
24
+ lines.push("Full run:");
25
+ lines.push(links.internalRunUrl);
26
+ if (links.shareRunUrl) {
27
+ lines.push("");
28
+ lines.push("Share (expires in 72h):");
29
+ lines.push(links.shareFirstFailureUrl || links.shareRunUrl);
30
+ }
31
+ return lines.join("\n");
32
+ };
33
+ exports.formatCiOutput = formatCiOutput;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/uploader",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "Sentinel uploader CLI for CI/CD debugging artifacts",
6
6
  "license": "MIT",
@@ -32,6 +32,7 @@
32
32
  "main": "dist/cli.js",
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
35
+ "test": "npm run build && node test/output.test.js",
35
36
  "publish:dry": "npm run build && npm pack --dry-run"
36
37
  },
37
38
  "dependencies": {