@pixeyedev/core 0.4.3 → 0.4.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.
package/dist/client.d.ts CHANGED
@@ -3,7 +3,23 @@ export declare class PixeyeError extends Error {
3
3
  status: number;
4
4
  path: string;
5
5
  body: string;
6
- constructor(status: number, path: string, body: string);
6
+ /**
7
+ * Where the failure came from. `'api'` = the Pixeye control-plane (a 4xx
8
+ * here can be a customer misconfig — token/plan/permissions). `'r2'` = a
9
+ * data-plane PUT to R2 via a presigned URL (a 403/404 here is a Pixeye-side
10
+ * fault — expired URL, bucket/credential, clock skew — NEVER the customer's).
11
+ * Used by `isUploadMisconfig` so an R2 outage can't fail a customer's CI.
12
+ */
13
+ origin: 'api' | 'r2';
14
+ constructor(status: number, path: string, body: string,
15
+ /**
16
+ * Where the failure came from. `'api'` = the Pixeye control-plane (a 4xx
17
+ * here can be a customer misconfig — token/plan/permissions). `'r2'` = a
18
+ * data-plane PUT to R2 via a presigned URL (a 403/404 here is a Pixeye-side
19
+ * fault — expired URL, bucket/credential, clock skew — NEVER the customer's).
20
+ * Used by `isUploadMisconfig` so an R2 outage can't fail a customer's CI.
21
+ */
22
+ origin?: 'api' | 'r2');
7
23
  }
8
24
  export interface PixeyeClientOptions {
9
25
  serverUrl: string;
package/dist/client.js CHANGED
@@ -1,13 +1,24 @@
1
1
  import { CreateBuildResponseSchema, ListBuildsResponseSchema, PipelineFinalizeResponseSchema, PresignUploadResponseSchema, SubmitShardResponseSchema } from '@pixeyedev/shared';
2
+ import { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
2
3
  export class PixeyeError extends Error {
3
4
  status;
4
5
  path;
5
6
  body;
6
- constructor(status, path, body) {
7
+ origin;
8
+ constructor(status, path, body,
9
+ /**
10
+ * Where the failure came from. `'api'` = the Pixeye control-plane (a 4xx
11
+ * here can be a customer misconfig — token/plan/permissions). `'r2'` = a
12
+ * data-plane PUT to R2 via a presigned URL (a 403/404 here is a Pixeye-side
13
+ * fault — expired URL, bucket/credential, clock skew — NEVER the customer's).
14
+ * Used by `isUploadMisconfig` so an R2 outage can't fail a customer's CI.
15
+ */
16
+ origin = 'api') {
7
17
  super(`Pixeye API ${path} failed (${status}): ${body}`);
8
18
  this.status = status;
9
19
  this.path = path;
10
20
  this.body = body;
21
+ this.origin = origin;
11
22
  this.name = 'PixeyeError';
12
23
  }
13
24
  }
@@ -58,14 +69,25 @@ export class PixeyeClient {
58
69
  return PresignUploadResponseSchema.parse(raw);
59
70
  }
60
71
  async uploadFile(presignedUrl, buffer, contentType) {
61
- const res = await fetch(presignedUrl, {
62
- method: 'PUT',
63
- body: new Uint8Array(buffer),
64
- headers: { 'Content-Type': contentType }
72
+ const maskedUrl = maskPresignedUrl(presignedUrl);
73
+ await withRetry(async () => {
74
+ const res = await fetch(presignedUrl, {
75
+ method: 'PUT',
76
+ body: new Uint8Array(buffer),
77
+ headers: { 'Content-Type': contentType }
78
+ });
79
+ if (!res.ok) {
80
+ // 'r2' origin — a PUT failure here is Pixeye-side (expired presigned
81
+ // URL, bucket/creds, clock skew), never the customer's misconfig.
82
+ throw new PixeyeError(res.status, maskedUrl, await res.text(), 'r2');
83
+ }
84
+ }, isRetryableHttpError, {
85
+ maxAttempts: 3,
86
+ baseDelayMs: 1000,
87
+ onRetry: (attempt, err) => {
88
+ console.warn(`[pixeye] Upload retry ${attempt}/3: ${err.message}`);
89
+ }
65
90
  });
66
- if (!res.ok) {
67
- throw new PixeyeError(res.status, presignedUrl, await res.text());
68
- }
69
91
  }
70
92
  async submitShard(opts) {
71
93
  const raw = await this.request(`/api/v1/builds/${opts.buildId}/shards`, {
package/dist/index.d.ts CHANGED
@@ -7,4 +7,7 @@ export { detectBaseBranchFromGit, detectCIEnvironment } from './ci.js';
7
7
  export type { DiscoveredFile } from './discover.js';
8
8
  export { discoverScreenshots } from './discover.js';
9
9
  export { runParallel } from './parallel.js';
10
+ export type { RetryOptions } from './retry.js';
11
+ export { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
10
12
  export { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
13
+ export { isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
package/dist/index.js CHANGED
@@ -4,5 +4,7 @@ export { detectBaseBranchFromGit, detectCIEnvironment } from './ci.js';
4
4
  export { discoverScreenshots } from './discover.js';
5
5
  // Parallel execution
6
6
  export { runParallel } from './parallel.js';
7
+ export { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
7
8
  // Constants
8
9
  export { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
10
+ export { isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
@@ -0,0 +1,14 @@
1
+ export interface RetryOptions {
2
+ maxAttempts?: number;
3
+ baseDelayMs?: number;
4
+ onRetry?: (attempt: number, error: Error) => void;
5
+ }
6
+ /**
7
+ * Retry an async function with exponential backoff + jitter.
8
+ * Only retries on errors where shouldRetry returns true.
9
+ */
10
+ export declare const withRetry: <T>(fn: () => Promise<T>, shouldRetry: (error: unknown) => boolean, opts?: RetryOptions) => Promise<T>;
11
+ /** Returns true for HTTP 429/5xx responses or transient network errors. */
12
+ export declare const isRetryableHttpError: (error: unknown) => boolean;
13
+ /** Mask presigned URL signature for safe error logging. */
14
+ export declare const maskPresignedUrl: (url: string) => string;
package/dist/retry.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Retry an async function with exponential backoff + jitter.
3
+ * Only retries on errors where shouldRetry returns true.
4
+ */
5
+ export const withRetry = async (fn, shouldRetry, opts = {}) => {
6
+ const maxAttempts = opts.maxAttempts ?? 3;
7
+ const baseDelayMs = opts.baseDelayMs ?? 1000;
8
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
9
+ try {
10
+ return await fn();
11
+ }
12
+ catch (error) {
13
+ if (attempt === maxAttempts || !shouldRetry(error)) {
14
+ throw error;
15
+ }
16
+ // Exponential backoff with equal jitter (half deterministic + half random)
17
+ const exponential = baseDelayMs * Math.pow(2, attempt - 1);
18
+ const delay = exponential * (0.5 + 0.5 * Math.random());
19
+ opts.onRetry?.(attempt, error instanceof Error ? error : new Error(String(error)));
20
+ await new Promise(r => setTimeout(r, delay));
21
+ }
22
+ }
23
+ throw new Error('Unreachable');
24
+ };
25
+ const TRANSIENT_NETWORK_RE = /fetch failed|ECONNRESET|ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EPIPE|socket hang up/i;
26
+ /** Returns true for HTTP 429/5xx responses or transient network errors. */
27
+ export const isRetryableHttpError = (error) => {
28
+ // Check status code first (authoritative when present)
29
+ const status = error?.status;
30
+ if (status !== undefined) {
31
+ return status === 429 || (status >= 500 && status < 600);
32
+ }
33
+ // Fall back to message-based detection for genuine network errors (no HTTP response)
34
+ if (error instanceof Error && TRANSIENT_NETWORK_RE.test(error.message)) {
35
+ return true;
36
+ }
37
+ return false;
38
+ };
39
+ /** Mask presigned URL signature for safe error logging. */
40
+ export const maskPresignedUrl = (url) => url.split('?')[0] + '?[signature-masked]';
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Classify an upload failure and render a loud banner — shared by the Playwright
3
+ * reporter and the CLI so a lapsed plan / bad token surfaces identically (P0-2).
4
+ *
5
+ * The rule: distinguish the CUSTOMER's misconfiguration (they must act) from a
6
+ * transient / our-side fault (their CI should not break over our hiccup).
7
+ */
8
+ /**
9
+ * True when the failure is the customer's configuration/billing, not ours.
10
+ * A `PixeyeError` from the R2 data-plane (`origin: 'r2'`) is EXCLUDED — a
11
+ * 403/404 on a presigned PUT (expired URL, bucket/creds, clock skew) is a
12
+ * Pixeye-side fault and must never fail the customer's CI.
13
+ */
14
+ export declare const isUploadMisconfig: (err: unknown) => boolean;
15
+ /**
16
+ * One-line `HTTP <status> — <detail>` summary. Prefers the error's `body`
17
+ * (the meaningful server code, e.g. `SUBSCRIPTION_CANCELLED`) over the full
18
+ * `PixeyeError.message`, which already embeds the status (avoids `HTTP 402 —
19
+ * Pixeye API … failed (402): …`).
20
+ */
21
+ export declare const uploadErrorSummary: (err: unknown) => string;
22
+ /**
23
+ * Resolve the fail-on-error setting identically for the reporter and CLI: fail
24
+ * by default; opt out via EITHER an explicit `false` option OR
25
+ * `PIXEYE_FAIL_ON_ERROR=false`. (The CLI's commander always supplies a boolean
26
+ * option; the reporter's is optional — this keeps both consistent.)
27
+ */
28
+ export declare const resolveFailOnError: (option: boolean | undefined, env: string | undefined) => boolean;
29
+ /**
30
+ * A bright, unmissable banner for a rejected upload. `willFail` toggles the
31
+ * closing line between "failing this step" and "opted out — passing anyway".
32
+ */
33
+ export declare const renderUploadFailureBanner: (err: unknown, opts: {
34
+ willFail: boolean;
35
+ }) => string;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Classify an upload failure and render a loud banner — shared by the Playwright
3
+ * reporter and the CLI so a lapsed plan / bad token surfaces identically (P0-2).
4
+ *
5
+ * The rule: distinguish the CUSTOMER's misconfiguration (they must act) from a
6
+ * transient / our-side fault (their CI should not break over our hiccup).
7
+ */
8
+ /**
9
+ * HTTP statuses that mean the CUSTOMER must act — a bad/absent token (401),
10
+ * a lapsed subscription or exceeded quota (402), insufficient permissions
11
+ * (403), or a wrong project/endpoint (404). These fail CI by default so a run
12
+ * can't pass green while zero screenshots reached Pixeye. EVERYTHING else
13
+ * (429 rate-limit, 5xx, network, timeouts, no status) is transient / our fault
14
+ * → warn, never break their CI.
15
+ */
16
+ const MISCONFIG_STATUSES = [401, 402, 403, 404];
17
+ const statusOf = (err) => {
18
+ const status = err?.status;
19
+ return typeof status === 'number' ? status : undefined;
20
+ };
21
+ /**
22
+ * True when the failure is the customer's configuration/billing, not ours.
23
+ * A `PixeyeError` from the R2 data-plane (`origin: 'r2'`) is EXCLUDED — a
24
+ * 403/404 on a presigned PUT (expired URL, bucket/creds, clock skew) is a
25
+ * Pixeye-side fault and must never fail the customer's CI.
26
+ */
27
+ export const isUploadMisconfig = (err) => {
28
+ if (err?.origin === 'r2') {
29
+ return false;
30
+ }
31
+ const status = statusOf(err);
32
+ return status !== undefined && MISCONFIG_STATUSES.includes(status);
33
+ };
34
+ /**
35
+ * One-line `HTTP <status> — <detail>` summary. Prefers the error's `body`
36
+ * (the meaningful server code, e.g. `SUBSCRIPTION_CANCELLED`) over the full
37
+ * `PixeyeError.message`, which already embeds the status (avoids `HTTP 402 —
38
+ * Pixeye API … failed (402): …`).
39
+ */
40
+ export const uploadErrorSummary = (err) => {
41
+ const status = statusOf(err);
42
+ const body = err?.body;
43
+ const detail = typeof body === 'string' && body.length > 0
44
+ ? body
45
+ : err instanceof Error
46
+ ? err.message
47
+ : String(err);
48
+ return status !== undefined ? `HTTP ${status} — ${detail}` : detail;
49
+ };
50
+ /**
51
+ * Resolve the fail-on-error setting identically for the reporter and CLI: fail
52
+ * by default; opt out via EITHER an explicit `false` option OR
53
+ * `PIXEYE_FAIL_ON_ERROR=false`. (The CLI's commander always supplies a boolean
54
+ * option; the reporter's is optional — this keeps both consistent.)
55
+ */
56
+ export const resolveFailOnError = (option, env) => option !== false && env !== 'false';
57
+ /**
58
+ * A bright, unmissable banner for a rejected upload. `willFail` toggles the
59
+ * closing line between "failing this step" and "opted out — passing anyway".
60
+ */
61
+ export const renderUploadFailureBanner = (err, opts) => {
62
+ const line = '━'.repeat(64);
63
+ const footer = opts.willFail
64
+ ? [
65
+ ' Failing this step so it cannot pass silently. Fix the above, or pass',
66
+ ' failOnError: false (reporter) / --no-fail-on-error (CLI) to opt out.'
67
+ ]
68
+ : [' failOnError is OFF — this step passes despite the lost coverage.'];
69
+ return [
70
+ '',
71
+ `\x1b[31m\x1b[1m${line}`,
72
+ ` ✗ PIXEYE UPLOAD REJECTED — ${uploadErrorSummary(err)}`,
73
+ '',
74
+ ' Your screenshots did NOT reach Pixeye. Visual coverage for this run is',
75
+ ' LOST. This is a configuration or billing problem on your account (API',
76
+ ' token, subscription/quota, or project permissions) — not a transient',
77
+ ' Pixeye outage.',
78
+ '',
79
+ ...footer,
80
+ `${line}\x1b[0m`,
81
+ ''
82
+ ].join('\n');
83
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * F-095: parse shard-related env vars (PIXEYE_SHARD_INDEX,
3
+ * PIXEYE_SHARD_TOTAL, CYPRESS_SPLIT_INDEX, CURRENTS_PARALLEL_INDEX, …)
4
+ * with a NaN guard.
5
+ *
6
+ * Bare `parseInt('abc', 10)` returns NaN; `JSON.stringify({ index: NaN })`
7
+ * coerces to `null` and the API rejects with a confusing
8
+ * "Validation failed: index must be a number" error 30s deep into a CI
9
+ * pipeline. Failing fast at parse time with a clear message identifying
10
+ * the offending env var lets users fix `PIXEYE_SHARD_INDEX=` typos
11
+ * without running the test suite.
12
+ *
13
+ * The helper is intentionally permissive about the shape: callers pass
14
+ * the env var name so the error message is self-explanatory. Returns
15
+ * `undefined` when the value is empty/missing (so callers can default).
16
+ */
17
+ export declare const parseShardInt: (value: string | undefined | null, name: string) => number;
18
+ /**
19
+ * Same as `parseShardInt` but returns `undefined` when the value is
20
+ * missing — useful when the env var is optional and the caller will
21
+ * fall back to a default. Still throws on a non-numeric explicit value.
22
+ */
23
+ export declare const parseShardIntOptional: (value: string | undefined | null, name: string) => number | undefined;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * F-095: parse shard-related env vars (PIXEYE_SHARD_INDEX,
3
+ * PIXEYE_SHARD_TOTAL, CYPRESS_SPLIT_INDEX, CURRENTS_PARALLEL_INDEX, …)
4
+ * with a NaN guard.
5
+ *
6
+ * Bare `parseInt('abc', 10)` returns NaN; `JSON.stringify({ index: NaN })`
7
+ * coerces to `null` and the API rejects with a confusing
8
+ * "Validation failed: index must be a number" error 30s deep into a CI
9
+ * pipeline. Failing fast at parse time with a clear message identifying
10
+ * the offending env var lets users fix `PIXEYE_SHARD_INDEX=` typos
11
+ * without running the test suite.
12
+ *
13
+ * The helper is intentionally permissive about the shape: callers pass
14
+ * the env var name so the error message is self-explanatory. Returns
15
+ * `undefined` when the value is empty/missing (so callers can default).
16
+ */
17
+ export const parseShardInt = (value, name) => {
18
+ if (value === undefined || value === null || value === '') {
19
+ throw new Error(`${name} must be set`);
20
+ }
21
+ const parsed = parseInt(value, 10);
22
+ if (Number.isNaN(parsed)) {
23
+ throw new Error(`${name} must be an integer (got ${JSON.stringify(value)})`);
24
+ }
25
+ return parsed;
26
+ };
27
+ /**
28
+ * Same as `parseShardInt` but returns `undefined` when the value is
29
+ * missing — useful when the env var is optional and the caller will
30
+ * fall back to a default. Still throws on a non-numeric explicit value.
31
+ */
32
+ export const parseShardIntOptional = (value, name) => {
33
+ if (value === undefined || value === null || value === '') {
34
+ return undefined;
35
+ }
36
+ const parsed = parseInt(value, 10);
37
+ if (Number.isNaN(parsed)) {
38
+ throw new Error(`${name} must be an integer (got ${JSON.stringify(value)})`);
39
+ }
40
+ return parsed;
41
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixeyedev/core",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [