@sentinelqa/uploader 0.1.0 → 0.1.2

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/cli.js +142 -72
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -73,6 +73,38 @@ const listFilesRecursive = (dirPath) => {
73
73
  }
74
74
  return results;
75
75
  };
76
+ const findLatestMatch = (dirPath, matcher) => {
77
+ if (!fs_1.default.existsSync(dirPath))
78
+ return null;
79
+ if (!fs_1.default.statSync(dirPath).isDirectory())
80
+ return null;
81
+ const entries = fs_1.default.readdirSync(dirPath, { withFileTypes: true });
82
+ const files = entries
83
+ .filter((e) => e.isFile() && matcher(e.name))
84
+ .map((e) => path_1.default.join(dirPath, e.name));
85
+ if (files.length === 0)
86
+ return null;
87
+ files.sort((a, b) => fs_1.default.statSync(b).mtimeMs - fs_1.default.statSync(a).mtimeMs);
88
+ return files[0];
89
+ };
90
+ const detectPlaywrightJson = () => {
91
+ const envPath = readEnv("PLAYWRIGHT_JSON_PATH");
92
+ if (envPath && fs_1.default.existsSync(envPath) && fs_1.default.statSync(envPath).isFile()) {
93
+ return envPath;
94
+ }
95
+ const defaultPath = DEFAULT_JSON_PATH;
96
+ if (fs_1.default.existsSync(defaultPath) && fs_1.default.statSync(defaultPath).isFile()) {
97
+ return defaultPath;
98
+ }
99
+ const reportPattern = (name) => name.startsWith("report-") && name.endsWith(".json");
100
+ const latestReport = findLatestMatch(DEFAULT_TEST_RESULTS_DIR, reportPattern);
101
+ if (latestReport)
102
+ return latestReport;
103
+ const anyJson = findLatestMatch(DEFAULT_TEST_RESULTS_DIR, (name) => name.endsWith(".json"));
104
+ if (anyJson)
105
+ return anyJson;
106
+ return null;
107
+ };
76
108
  const normalizeError = (value) => {
77
109
  if (!value)
78
110
  return null;
@@ -158,6 +190,26 @@ const extractTestsFromReport = (reportJson) => {
158
190
  const computeRunStatus = (tests) => {
159
191
  return tests.some((t) => t.status === "failed") ? "failed" : "passed";
160
192
  };
193
+ const bestEffortComplete = async (appUrl, ingestToken, runId, status, tests) => {
194
+ try {
195
+ await fetch(`${appUrl}/api/runs/${runId}/complete`, {
196
+ method: "POST",
197
+ headers: {
198
+ "content-type": "application/json",
199
+ authorization: `Bearer ${ingestToken}`
200
+ },
201
+ body: JSON.stringify({
202
+ status,
203
+ finishedAt: new Date().toISOString(),
204
+ tests,
205
+ artifacts: []
206
+ })
207
+ });
208
+ }
209
+ catch {
210
+ // Ignore best-effort failures; original error will be surfaced.
211
+ }
212
+ };
161
213
  const detectGitLabEnv = () => {
162
214
  const isGitLab = readEnv("GITLAB_CI") === "true" || !!readEnv("CI_PROJECT_ID");
163
215
  if (!isGitLab)
@@ -261,9 +313,18 @@ const main = async () => {
261
313
  const ingestToken = readEnv("SENTINEL_TOKEN") || readEnv("PROJECT_INGEST_TOKEN");
262
314
  if (!ingestToken)
263
315
  fail("SENTINEL_TOKEN is required.");
264
- const playwrightJsonPath = getArgValue(args, "--playwright-json-path") ||
265
- readEnv("PLAYWRIGHT_JSON_PATH") ||
266
- DEFAULT_JSON_PATH;
316
+ const cliJsonPath = getArgValue(args, "--playwright-json-path");
317
+ const playwrightJsonPath = cliJsonPath || detectPlaywrightJson();
318
+ if (!playwrightJsonPath) {
319
+ fail([
320
+ "PLAYWRIGHT_JSON_PATH not found.",
321
+ "Checked:",
322
+ `- ${readEnv("PLAYWRIGHT_JSON_PATH") || "(env not set)"}`,
323
+ `- ${DEFAULT_JSON_PATH}`,
324
+ `- ${DEFAULT_TEST_RESULTS_DIR}/report-*.json`,
325
+ `- ${DEFAULT_TEST_RESULTS_DIR}/*.json`
326
+ ].join("\n"));
327
+ }
267
328
  const playwrightReportDir = getArgValue(args, "--playwright-report-dir") ||
268
329
  readEnv("PLAYWRIGHT_REPORT_DIR") ||
269
330
  DEFAULT_PLAYWRIGHT_REPORT_DIR;
@@ -333,77 +394,86 @@ const main = async () => {
333
394
  return { ...artifact, sizeBytes: stat.size, contentType };
334
395
  });
335
396
  let finalArtifacts = artifactsWithMeta;
336
- if (byoBucket) {
337
- const s3Region = readEnv("SENTINEL_S3_REGION") || readEnv("AWS_REGION") || readEnv("S3_REGION");
338
- if (!s3Region)
339
- fail("SENTINEL_S3_REGION is required for BYO S3.");
340
- const s3Endpoint = readEnv("SENTINEL_S3_ENDPOINT") || readEnv("S3_ENDPOINT");
341
- const accessKeyId = readEnv("SENTINEL_S3_ACCESS_KEY_ID") || readEnv("AWS_ACCESS_KEY_ID");
342
- const secretAccessKey = readEnv("SENTINEL_S3_SECRET_ACCESS_KEY") || readEnv("AWS_SECRET_ACCESS_KEY");
343
- if (!accessKeyId || !secretAccessKey) {
344
- fail("SENTINEL_S3_ACCESS_KEY_ID and SENTINEL_S3_SECRET_ACCESS_KEY are required.");
345
- }
346
- const s3Client = new client_s3_1.S3Client({
347
- region: s3Region,
348
- ...(s3Endpoint ? { endpoint: s3Endpoint } : {}),
349
- credentials: { accessKeyId, secretAccessKey }
350
- });
351
- await withRetry(async () => {
352
- await uploadArtifacts(s3Client, byoBucket, finalArtifacts);
353
- }, "upload artifacts");
354
- finalArtifacts = finalArtifacts.map((artifact) => ({
355
- ...artifact,
356
- bucket: byoBucket
357
- }));
358
- }
359
- else {
360
- const presignRes = await withRetry(() => fetch(`${appUrl}/api/uploads/presign`, {
361
- method: "POST",
362
- headers: {
363
- "content-type": "application/json",
364
- authorization: `Bearer ${ingestToken}`
365
- },
366
- body: JSON.stringify({
367
- items: finalArtifacts.map((artifact) => ({
368
- relPath: artifact.objectKey,
369
- contentType: artifact.contentType,
370
- sizeBytes: artifact.sizeBytes,
371
- kind: artifact.type
372
- }))
373
- })
374
- }), "POST /api/uploads/presign");
375
- if (!presignRes.ok) {
376
- const body = await presignRes.text();
377
- fail(`POST /api/uploads/presign failed (${presignRes.status}): ${body}`);
397
+ try {
398
+ if (byoBucket) {
399
+ const s3Region = readEnv("SENTINEL_S3_REGION") ||
400
+ readEnv("AWS_REGION") ||
401
+ readEnv("S3_REGION");
402
+ if (!s3Region)
403
+ fail("SENTINEL_S3_REGION is required for BYO S3.");
404
+ const s3Endpoint = readEnv("SENTINEL_S3_ENDPOINT") || readEnv("S3_ENDPOINT");
405
+ const accessKeyId = readEnv("SENTINEL_S3_ACCESS_KEY_ID") || readEnv("AWS_ACCESS_KEY_ID");
406
+ const secretAccessKey = readEnv("SENTINEL_S3_SECRET_ACCESS_KEY") ||
407
+ readEnv("AWS_SECRET_ACCESS_KEY");
408
+ if (!accessKeyId || !secretAccessKey) {
409
+ fail("SENTINEL_S3_ACCESS_KEY_ID and SENTINEL_S3_SECRET_ACCESS_KEY are required.");
410
+ }
411
+ const s3Client = new client_s3_1.S3Client({
412
+ region: s3Region,
413
+ ...(s3Endpoint ? { endpoint: s3Endpoint } : {}),
414
+ credentials: { accessKeyId, secretAccessKey }
415
+ });
416
+ await withRetry(async () => {
417
+ await uploadArtifacts(s3Client, byoBucket, finalArtifacts);
418
+ }, "upload artifacts");
419
+ finalArtifacts = finalArtifacts.map((artifact) => ({
420
+ ...artifact,
421
+ bucket: byoBucket
422
+ }));
378
423
  }
379
- const presignData = await presignRes.json();
380
- const uploadMap = new Map((presignData.items || []).map((item) => [item.relPath, item]));
381
- await withRetry(async () => {
382
- for (const artifact of finalArtifacts) {
383
- const item = uploadMap.get(artifact.objectKey);
384
- if (!item?.uploadUrl || !item?.objectKey) {
385
- throw new Error(`Missing upload URL for ${artifact.objectKey}`);
386
- }
387
- await withRetry(async () => {
388
- const body = fs_1.default.createReadStream(artifact.filePath);
389
- const res = await fetch(item.uploadUrl, {
390
- method: "PUT",
391
- headers: {
392
- "content-type": artifact.contentType
393
- },
394
- body
395
- });
396
- if (!res.ok) {
397
- throw new Error(`Upload failed (${res.status}) for ${artifact.objectKey}`);
398
- }
399
- }, `upload ${artifact.objectKey}`);
400
- artifact.objectKey = item.objectKey;
424
+ else {
425
+ const presignRes = await withRetry(() => fetch(`${appUrl}/api/uploads/presign`, {
426
+ method: "POST",
427
+ headers: {
428
+ "content-type": "application/json",
429
+ authorization: `Bearer ${ingestToken}`
430
+ },
431
+ body: JSON.stringify({
432
+ items: finalArtifacts.map((artifact) => ({
433
+ relPath: artifact.objectKey,
434
+ contentType: artifact.contentType,
435
+ sizeBytes: artifact.sizeBytes,
436
+ kind: artifact.type
437
+ }))
438
+ })
439
+ }), "POST /api/uploads/presign");
440
+ if (!presignRes.ok) {
441
+ const body = await presignRes.text();
442
+ fail(`POST /api/uploads/presign failed (${presignRes.status}): ${body}`);
401
443
  }
402
- }, "upload artifacts");
403
- finalArtifacts = finalArtifacts.map((artifact) => ({
404
- ...artifact,
405
- bucket: presignData.bucket
406
- }));
444
+ const presignData = await presignRes.json();
445
+ const uploadMap = new Map((presignData.items || []).map((item) => [item.relPath, item]));
446
+ await withRetry(async () => {
447
+ for (const artifact of finalArtifacts) {
448
+ const item = uploadMap.get(artifact.objectKey);
449
+ if (!item?.uploadUrl || !item?.objectKey) {
450
+ throw new Error(`Missing upload URL for ${artifact.objectKey}`);
451
+ }
452
+ await withRetry(async () => {
453
+ const body = fs_1.default.createReadStream(artifact.filePath);
454
+ const res = await fetch(item.uploadUrl, {
455
+ method: "PUT",
456
+ headers: {
457
+ "content-type": artifact.contentType
458
+ },
459
+ body
460
+ });
461
+ if (!res.ok) {
462
+ throw new Error(`Upload failed (${res.status}) for ${artifact.objectKey}`);
463
+ }
464
+ }, `upload ${artifact.objectKey}`);
465
+ artifact.objectKey = item.objectKey;
466
+ }
467
+ }, "upload artifacts");
468
+ finalArtifacts = finalArtifacts.map((artifact) => ({
469
+ ...artifact,
470
+ bucket: presignData.bucket
471
+ }));
472
+ }
473
+ }
474
+ catch (err) {
475
+ await bestEffortComplete(appUrl, ingestToken, runId, "failed", tests);
476
+ fail(err?.message || String(err));
407
477
  }
408
478
  const uploadDurationMs = Date.now() - uploadStart;
409
479
  const completeRes = await withRetry(() => fetch(`${appUrl}/api/runs/${runId}/complete`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/uploader",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "description": "Sentinel uploader CLI for CI/CD debugging artifacts",
6
6
  "license": "MIT",