@sentinelqa/uploader 0.1.3 → 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,16 +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
- });
489
- if (!res.ok) {
490
- 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}`);
491
498
  }
492
499
  }, `upload ${artifact.objectKey}`);
493
500
  artifact.objectKey = item.objectKey;
@@ -500,17 +507,39 @@ const main = async () => {
500
507
  }
501
508
  }
502
509
  catch (err) {
503
- 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.");
504
527
  fail(err?.message || String(err));
505
528
  }
506
529
  const uploadDurationMs = Date.now() - uploadStart;
507
- 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`, {
508
538
  method: "POST",
509
539
  headers: {
510
- "content-type": "application/json",
511
540
  authorization: `Bearer ${ingestToken}`
512
541
  },
513
- body: JSON.stringify({
542
+ body: {
514
543
  status,
515
544
  finishedAt: new Date().toISOString(),
516
545
  tests,
@@ -522,42 +551,21 @@ const main = async () => {
522
551
  contentType: artifact.contentType,
523
552
  testKey: null
524
553
  }))
525
- })
526
- }), "POST /api/runs/:runId/complete");
527
- if (!completeRes.ok) {
528
- const body = await completeRes.text();
529
- fail(`POST /api/runs/:runId/complete failed (${completeRes.status}): ${body}`);
530
- }
531
- let shareUrl = null;
532
- try {
533
- const data = await completeRes.json();
534
- shareUrl = data.shareUrl || null;
535
- }
536
- catch {
537
- shareUrl = null;
538
- }
539
- const internalUrl = `${appUrl}/runs/${runId}`;
540
- console.log(JSON.stringify({
541
- event: "uploader_summary",
542
- runId,
543
- tests: tests.length,
544
- artifacts: artifacts.length,
545
- uploadDurationMs
546
- }));
547
- console.log(`Uploaded ${artifacts.length} artifacts and completed run ${runId}.`);
548
- console.log(internalUrl);
549
- if (shareUrl)
550
- console.log(shareUrl);
551
- if (status === "failed") {
552
- const total = tests.length;
553
- const failed = tests.filter((t) => t.status === "failed").length;
554
- const useColor = !process.env.NO_COLOR;
555
- const bold = useColor ? "\u001b[1m" : "";
556
- const red = useColor ? "\u001b[31m" : "";
557
- const reset = useColor ? "\u001b[0m" : "";
558
- console.log(`${bold}${red}CI Debug Report${reset}`);
559
- 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}`);
560
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);
561
569
  };
562
570
  main().catch((err) => {
563
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.3",
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": {