@pixeyedev/core 0.4.5 → 0.4.7

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pixeye (pixeye.dev)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/ci.d.ts CHANGED
@@ -5,10 +5,14 @@ export interface CIEnvironment {
5
5
  baseBranch?: string;
6
6
  prNumber?: number;
7
7
  repository?: string;
8
+ trigger?: CITrigger;
9
+ }
10
+ export interface CITrigger {
11
+ runId?: string;
12
+ runAttempt?: string;
13
+ actor?: string;
14
+ workflow?: string;
15
+ eventName?: string;
16
+ runUrl?: string;
8
17
  }
9
- /**
10
- * Try to detect the base branch using git merge-base.
11
- * Checks common default branch names (main, master, develop).
12
- */
13
- export declare const detectBaseBranchFromGit: () => string | undefined;
14
18
  export declare const detectCIEnvironment: () => CIEnvironment | null;
package/dist/ci.js CHANGED
@@ -1,33 +1,49 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { readFileSync } from 'node:fs';
2
+ const readGitHubEvent = () => {
3
+ const path = process.env.GITHUB_EVENT_PATH;
4
+ if (!path) {
5
+ return null;
6
+ }
7
+ try {
8
+ return JSON.parse(readFileSync(path, 'utf8'));
9
+ }
10
+ catch (err) {
11
+ console.warn(`pixeye: could not read the GitHub event payload at ${path}: ${String(err)}`);
12
+ return null;
13
+ }
14
+ };
2
15
  const detectGitHubActions = () => {
3
16
  if (!process.env.GITHUB_ACTIONS) {
4
17
  return null;
5
18
  }
6
- const commit = process.env.GITHUB_SHA;
7
- const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
19
+ const event = readGitHubEvent();
20
+ const eventPr = event?.pull_request ?? event?.workflow_run?.pull_requests?.[0];
21
+ const commit = event?.pull_request?.head?.sha ?? event?.workflow_run?.head_sha ?? process.env.GITHUB_SHA;
22
+ const branch = process.env.GITHUB_HEAD_REF || event?.workflow_run?.head_branch || process.env.GITHUB_REF_NAME;
8
23
  const runId = process.env.GITHUB_RUN_ID;
9
24
  if (!commit || !branch || !runId) {
10
25
  return null;
11
26
  }
12
- // PR number: explicit env > GITHUB_REF on pull_request events (refs/pull/123/merge)
13
27
  const prRaw = process.env.PIXEYE_PR;
14
- let prNumber = prRaw ? parseInt(prRaw, 10) : undefined;
15
- if (!prNumber) {
16
- const ref = process.env.GITHUB_REF ?? '';
17
- const prMatch = ref.match(/^refs\/pull\/(\d+)\//);
18
- if (prMatch) {
19
- prNumber = parseInt(prMatch[1], 10);
20
- }
21
- }
22
- // PR target branch: GITHUB_BASE_REF is set on pull_request events
23
- const baseBranch = process.env.GITHUB_BASE_REF || undefined;
28
+ const prNumber = prRaw ? parseInt(prRaw, 10) : eventPr?.number;
29
+ const baseBranch = process.env.GITHUB_BASE_REF || eventPr?.base?.ref || undefined;
30
+ const repository = process.env.GITHUB_REPOSITORY;
31
+ const serverUrl = process.env.GITHUB_SERVER_URL;
24
32
  return {
25
33
  provider: 'github-actions',
26
34
  commit,
27
35
  branch,
28
36
  baseBranch,
29
37
  prNumber: prNumber && !isNaN(prNumber) ? prNumber : undefined,
30
- repository: process.env.GITHUB_REPOSITORY
38
+ repository,
39
+ trigger: {
40
+ runId,
41
+ runAttempt: process.env.GITHUB_RUN_ATTEMPT || undefined,
42
+ actor: process.env.GITHUB_ACTOR || undefined,
43
+ workflow: process.env.GITHUB_WORKFLOW || undefined,
44
+ eventName: process.env.GITHUB_EVENT_NAME || undefined,
45
+ runUrl: serverUrl && repository ? `${serverUrl}/${repository}/actions/runs/${runId}` : undefined
46
+ }
31
47
  };
32
48
  };
33
49
  const detectGitLabCI = () => {
@@ -47,32 +63,14 @@ const detectGitLabCI = () => {
47
63
  branch,
48
64
  baseBranch: process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || undefined,
49
65
  prNumber: mrIid ? parseInt(mrIid, 10) : undefined,
50
- repository: undefined
51
- };
52
- };
53
- /**
54
- * Try to detect the base branch using git merge-base.
55
- * Checks common default branch names (main, master, develop).
56
- */
57
- export const detectBaseBranchFromGit = () => {
58
- const candidates = ['main', 'master', 'develop'];
59
- for (const candidate of candidates) {
60
- try {
61
- execFileSync('git', ['rev-parse', '--verify', `origin/${candidate}`], { stdio: 'ignore' });
62
- execFileSync('git', ['merge-base', 'HEAD', `origin/${candidate}`], { stdio: 'ignore' });
63
- return candidate;
64
- }
65
- catch {
66
- // Branch doesn't exist or no common ancestor
66
+ repository: process.env.CI_PROJECT_PATH || undefined,
67
+ trigger: {
68
+ runId: pipelineId,
69
+ actor: process.env.GITLAB_USER_LOGIN || undefined,
70
+ workflow: process.env.CI_JOB_NAME || undefined,
71
+ eventName: process.env.CI_PIPELINE_SOURCE || undefined,
72
+ runUrl: process.env.CI_PIPELINE_URL || undefined
67
73
  }
68
- }
69
- return undefined;
70
- };
71
- export const detectCIEnvironment = () => {
72
- const ci = detectGitHubActions() ?? detectGitLabCI() ?? null;
73
- // If CI detected but no baseBranch, try git merge-base
74
- if (ci && !ci.baseBranch) {
75
- ci.baseBranch = detectBaseBranchFromGit();
76
- }
77
- return ci;
74
+ };
78
75
  };
76
+ export const detectCIEnvironment = () => detectGitHubActions() ?? detectGitLabCI() ?? null;
package/dist/client.d.ts CHANGED
@@ -1,35 +1,29 @@
1
- import type { CreateBuildResponse, ListBuildsResponse, PipelineFinalizeResponse, PresignUploadResponse, ScreenshotManifestEntry, SubmitShardResponse } from '@pixeyedev/shared';
1
+ import type { CreateBuildResponse, ListBuildsResponse, PipelineFinalizeResponse, PresignUploadResponse, ScreenshotFormat, ScreenshotManifestEntry, SubmitShardResponse } from '@pixeyedev/shared';
2
2
  export declare class PixeyeError extends Error {
3
3
  status: number;
4
4
  path: string;
5
5
  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
6
  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
+ retryAfterMs?: number | undefined;
8
+ constructor(status: number, path: string, body: string, origin?: 'api' | 'r2', retryAfterMs?: number | undefined);
23
9
  }
24
10
  export interface PixeyeClientOptions {
25
11
  serverUrl: string;
26
12
  token: string;
13
+ timeoutMs?: number;
14
+ attempts?: number;
27
15
  }
28
16
  export declare class PixeyeClient {
29
17
  private serverUrl;
30
18
  private token;
19
+ private timeoutMs;
20
+ private attempts;
21
+ private reporting?;
22
+ get observes(): boolean;
31
23
  constructor(options: PixeyeClientOptions);
24
+ private send;
32
25
  private request;
26
+ private retryOptions;
33
27
  createBuild(opts: {
34
28
  projectToken: string;
35
29
  commitSha: string;
@@ -46,7 +40,7 @@ export declare class PixeyeClient {
46
40
  presignUpload(opts: {
47
41
  buildId: string;
48
42
  hashes: string[];
49
- formats?: Record<string, 'png' | 'webp'>;
43
+ formats?: Record<string, ScreenshotFormat>;
50
44
  }): Promise<PresignUploadResponse>;
51
45
  uploadFile(presignedUrl: string, buffer: Buffer, contentType: string): Promise<void>;
52
46
  submitShard(opts: {
@@ -59,6 +53,6 @@ export declare class PixeyeClient {
59
53
  branch: string;
60
54
  }): Promise<ListBuildsResponse>;
61
55
  finalizePipeline(pipelineId: string): Promise<PipelineFinalizeResponse>;
62
- checkFileExists(hash: string): Promise<boolean>;
63
- uploadLegacy(buffer: Buffer, hash: string, filename: string): Promise<void>;
56
+ checkFileExists(hash: string, format?: ScreenshotFormat): Promise<boolean>;
57
+ uploadLegacy(buffer: Buffer, hash: string, filename: string, format?: ScreenshotFormat): Promise<void>;
64
58
  }
package/dist/client.js CHANGED
@@ -1,50 +1,75 @@
1
- import { CreateBuildResponseSchema, ListBuildsResponseSchema, PipelineFinalizeResponseSchema, PresignUploadResponseSchema, SubmitShardResponseSchema } from '@pixeyedev/shared';
1
+ import { createRequire } from 'node:module';
2
+ import { CONTENT_TYPE_BY_FORMAT, CreateBuildResponseSchema, ListBuildsResponseSchema, PipelineFinalizeResponseSchema, PRESIGN_BATCH_MAX, PresignUploadResponseSchema, SubmitShardResponseSchema } from '@pixeyedev/shared';
3
+ import { readClientEnv } from './config.js';
4
+ import { DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_UPLOAD_ATTEMPTS, RETRY_AFTER_MAX_MS } from './constants.js';
2
5
  import { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
3
6
  export class PixeyeError extends Error {
4
7
  status;
5
8
  path;
6
9
  body;
7
10
  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') {
11
+ retryAfterMs;
12
+ constructor(status, path, body, origin = 'api', retryAfterMs) {
17
13
  super(`Pixeye API ${path} failed (${status}): ${body}`);
18
14
  this.status = status;
19
15
  this.path = path;
20
16
  this.body = body;
21
17
  this.origin = origin;
18
+ this.retryAfterMs = retryAfterMs;
22
19
  this.name = 'PixeyeError';
23
20
  }
24
21
  }
22
+ const parseRetryAfter = (response) => {
23
+ const seconds = Number(response.headers.get('retry-after'));
24
+ return Number.isFinite(seconds) && seconds > 0
25
+ ? Math.min(seconds * 1000, RETRY_AFTER_MAX_MS)
26
+ : undefined;
27
+ };
28
+ const { version } = createRequire(import.meta.url)('../package.json');
29
+ const USER_AGENT = `@pixeyedev/core/${version} node/${process.versions.node}`;
25
30
  export class PixeyeClient {
26
31
  serverUrl;
27
32
  token;
33
+ timeoutMs;
34
+ attempts;
35
+ reporting;
36
+ get observes() {
37
+ return this.reporting === 'observe';
38
+ }
28
39
  constructor(options) {
40
+ const env = readClientEnv();
29
41
  this.serverUrl = options.serverUrl.replace(/\/$/, '');
30
42
  this.token = options.token;
43
+ this.timeoutMs = options.timeoutMs ?? env.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
44
+ this.attempts = options.attempts ?? env.attempts ?? DEFAULT_UPLOAD_ATTEMPTS;
31
45
  }
32
- async request(path, options = {}) {
33
- const url = `${this.serverUrl}${path}`;
34
- const response = await fetch(url, {
46
+ async send(path, options = {}) {
47
+ const response = await fetch(`${this.serverUrl}${path}`, {
35
48
  ...options,
49
+ signal: AbortSignal.timeout(this.timeoutMs),
36
50
  headers: {
37
51
  'Content-Type': 'application/json',
38
52
  Authorization: `Bearer ${this.token}`,
53
+ 'User-Agent': USER_AGENT,
39
54
  ...options.headers
40
55
  }
41
56
  });
42
57
  if (!response.ok) {
43
- const body = await response.text();
44
- throw new PixeyeError(response.status, path, body);
58
+ throw new PixeyeError(response.status, path.split('?')[0], await response.text(), 'api', parseRetryAfter(response));
45
59
  }
46
60
  return response.json();
47
61
  }
62
+ request(path, options = {}) {
63
+ return withRetry(() => this.send(path, options), isRetryableHttpError, this.retryOptions(path.split('?')[0]));
64
+ }
65
+ retryOptions(what) {
66
+ return {
67
+ maxAttempts: this.attempts,
68
+ onRetry: (attempt, err) => {
69
+ console.warn(`[pixeye] ${what} retry ${attempt}/${this.attempts}: ${err.message}`);
70
+ }
71
+ };
72
+ }
48
73
  async createBuild(opts) {
49
74
  const body = { ...opts };
50
75
  if (opts.runner) {
@@ -54,19 +79,39 @@ export class PixeyeClient {
54
79
  method: 'POST',
55
80
  body: JSON.stringify(body)
56
81
  });
57
- return CreateBuildResponseSchema.parse(raw);
82
+ const parsed = CreateBuildResponseSchema.parse(raw);
83
+ this.reporting = parsed.reporting;
84
+ return parsed;
58
85
  }
59
86
  async presignUpload(opts) {
60
- const raw = await this.request('/api/v1/upload/presign', {
61
- method: 'POST',
62
- body: JSON.stringify({
63
- token: this.token,
64
- buildId: opts.buildId,
65
- hashes: opts.hashes,
66
- ...(opts.formats && Object.keys(opts.formats).length > 0 ? { formats: opts.formats } : {})
67
- })
68
- });
69
- return PresignUploadResponseSchema.parse(raw);
87
+ const merged = {
88
+ urls: {},
89
+ existing: [],
90
+ uploadCount: 0,
91
+ skippedCount: 0,
92
+ warnings: []
93
+ };
94
+ for (let start = 0; start < opts.hashes.length; start += PRESIGN_BATCH_MAX) {
95
+ const hashes = opts.hashes.slice(start, start + PRESIGN_BATCH_MAX);
96
+ const inChunk = new Set(hashes);
97
+ const formats = Object.fromEntries(Object.entries(opts.formats ?? {}).filter(([hash]) => inChunk.has(hash)));
98
+ const raw = await this.request('/api/v1/upload/presign', {
99
+ method: 'POST',
100
+ body: JSON.stringify({
101
+ token: this.token,
102
+ buildId: opts.buildId,
103
+ hashes,
104
+ ...(Object.keys(formats).length > 0 ? { formats } : {})
105
+ })
106
+ });
107
+ const part = PresignUploadResponseSchema.parse(raw);
108
+ Object.assign(merged.urls, part.urls);
109
+ merged.existing.push(...part.existing);
110
+ merged.uploadCount += part.uploadCount;
111
+ merged.skippedCount += part.skippedCount;
112
+ merged.warnings.push(...part.warnings);
113
+ }
114
+ return merged;
70
115
  }
71
116
  async uploadFile(presignedUrl, buffer, contentType) {
72
117
  const maskedUrl = maskPresignedUrl(presignedUrl);
@@ -74,23 +119,16 @@ export class PixeyeClient {
74
119
  const res = await fetch(presignedUrl, {
75
120
  method: 'PUT',
76
121
  body: new Uint8Array(buffer),
122
+ signal: AbortSignal.timeout(this.timeoutMs),
77
123
  headers: { 'Content-Type': contentType }
78
124
  });
79
125
  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
126
  throw new PixeyeError(res.status, maskedUrl, await res.text(), 'r2');
83
127
  }
84
- }, isRetryableHttpError, {
85
- maxAttempts: 3,
86
- baseDelayMs: 1000,
87
- onRetry: (attempt, err) => {
88
- console.warn(`[pixeye] Upload retry ${attempt}/3: ${err.message}`);
89
- }
90
- });
128
+ }, isRetryableHttpError, this.retryOptions('Upload'));
91
129
  }
92
130
  async submitShard(opts) {
93
- const raw = await this.request(`/api/v1/builds/${opts.buildId}/shards`, {
131
+ const raw = await this.send(`/api/v1/builds/${opts.buildId}/shards`, {
94
132
  method: 'POST',
95
133
  body: JSON.stringify({
96
134
  shardIndex: opts.shardIndex,
@@ -114,18 +152,24 @@ export class PixeyeClient {
114
152
  });
115
153
  return PipelineFinalizeResponseSchema.parse(raw);
116
154
  }
117
- async checkFileExists(hash) {
118
- const result = await this.request(`/api/v1/upload/${hash}?token=${encodeURIComponent(this.token)}`);
155
+ async checkFileExists(hash, format = 'png') {
156
+ const result = await this.request(`/api/v1/upload/${encodeURIComponent(hash)}?token=${encodeURIComponent(this.token)}` +
157
+ `&format=${encodeURIComponent(format)}`);
119
158
  return result.exists;
120
159
  }
121
- async uploadLegacy(buffer, hash, filename) {
122
- const blob = new Blob([new Uint8Array(buffer)], { type: 'image/png' });
160
+ async uploadLegacy(buffer, hash, filename, format = 'png') {
161
+ const blob = new Blob([new Uint8Array(buffer)], { type: CONTENT_TYPE_BY_FORMAT[format] });
123
162
  const formData = new FormData();
124
163
  formData.append('file', blob, filename);
125
164
  formData.append('hash', hash);
165
+ formData.append('format', format);
126
166
  formData.append('token', this.token);
127
167
  const url = `${this.serverUrl}/api/v1/upload`;
128
- const response = await fetch(url, { method: 'POST', body: formData });
168
+ const response = await fetch(url, {
169
+ method: 'POST',
170
+ body: formData,
171
+ signal: AbortSignal.timeout(this.timeoutMs)
172
+ });
129
173
  if (!response.ok) {
130
174
  const body = await response.text();
131
175
  throw new PixeyeError(response.status, '/api/v1/upload', body);
package/dist/config.d.ts CHANGED
@@ -30,8 +30,13 @@ export declare const readGlobalConfig: () => Promise<PixeyeConfig>;
30
30
  export declare const writeGlobalConfig: (config: PixeyeConfig) => Promise<void>;
31
31
  export declare const readProjectConfig: (cwd: string) => Promise<ProjectConfig | null>;
32
32
  export declare const writeProjectConfig: (cwd: string, config: ProjectConfig) => Promise<void>;
33
+ export declare const integerSetting: (name: string, raw: string | number | undefined, min: number) => number | undefined;
34
+ export declare const readClientEnv: () => {
35
+ timeoutMs?: number;
36
+ attempts?: number;
37
+ };
38
+ export declare const resolveUploadConcurrency: (option: number | undefined) => number;
33
39
  export declare const readEnvConfig: () => EnvConfig;
34
- /** @internal Exported for testing */
35
40
  export declare const parseYamlLike: (content: string) => ProjectConfig;
36
41
  export declare const resolveConfig: (overrides?: Partial<ResolvedConfig> & {
37
42
  cwd?: string;
package/dist/config.js CHANGED
@@ -2,7 +2,8 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { detectCIEnvironment } from './ci.js';
5
- import { DEFAULT_SERVER_URL } from './constants.js';
5
+ import { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
6
+ import { CustomerError } from './uploadError.js';
6
7
  const GLOBAL_CONFIG_DIR = join(homedir(), '.pixeye');
7
8
  const GLOBAL_CONFIG_PATH = join(GLOBAL_CONFIG_DIR, 'config.json');
8
9
  const PROJECT_CONFIG_NAME = '.pixeye.yml';
@@ -36,24 +37,33 @@ export const writeProjectConfig = async (cwd, config) => {
36
37
  ];
37
38
  await writeFile(join(cwd, PROJECT_CONFIG_NAME), lines.join('\n') + '\n');
38
39
  };
40
+ export const integerSetting = (name, raw, min) => {
41
+ if (raw === undefined || raw === '') {
42
+ return undefined;
43
+ }
44
+ const n = Number(raw);
45
+ if (!Number.isInteger(n) || n < min) {
46
+ throw new CustomerError(`${name} must be an integer of at least ${min}, got "${raw}".`);
47
+ }
48
+ return n;
49
+ };
50
+ export const readClientEnv = () => ({
51
+ timeoutMs: integerSetting('PIXEYE_HTTP_TIMEOUT_MS', process.env.PIXEYE_HTTP_TIMEOUT_MS, 1),
52
+ attempts: integerSetting('PIXEYE_UPLOAD_ATTEMPTS', process.env.PIXEYE_UPLOAD_ATTEMPTS, 1)
53
+ });
54
+ export const resolveUploadConcurrency = (option) => integerSetting('uploadConcurrency', option, 1) ??
55
+ integerSetting('PIXEYE_UPLOAD_CONCURRENCY', process.env.PIXEYE_UPLOAD_CONCURRENCY, 1) ??
56
+ DEFAULT_UPLOAD_CONCURRENCY;
39
57
  export const readEnvConfig = () => {
40
- const intOrUndef = (val) => {
41
- if (val === undefined) {
42
- return undefined;
43
- }
44
- const n = parseInt(val, 10);
45
- return isNaN(n) ? undefined : n;
46
- };
47
58
  return {
48
59
  server: process.env.PIXEYE_SERVER || undefined,
49
60
  token: process.env.PIXEYE_TOKEN || undefined,
50
61
  commit: process.env.PIXEYE_COMMIT || undefined,
51
62
  branch: process.env.PIXEYE_BRANCH || undefined,
52
- pr: intOrUndef(process.env.PIXEYE_PR),
63
+ pr: integerSetting('PIXEYE_PR', process.env.PIXEYE_PR, 1),
53
64
  runner: process.env.PIXEYE_RUNNER || undefined
54
65
  };
55
66
  };
56
- /** @internal Exported for testing */
57
67
  export const parseYamlLike = (content) => {
58
68
  const result = {};
59
69
  for (const line of content.split('\n')) {
@@ -62,12 +72,10 @@ export const parseYamlLike = (content) => {
62
72
  let value = match[2].trim();
63
73
  const isQuoted = (value.startsWith('"') && value.endsWith('"')) ||
64
74
  (value.startsWith("'") && value.endsWith("'"));
65
- // Strip surrounding quotes
66
75
  if (isQuoted) {
67
76
  value = value.slice(1, -1);
68
77
  }
69
78
  else {
70
- // Remove inline comments only for unquoted values
71
79
  value = value.replace(/\s+#.*$/, '');
72
80
  }
73
81
  result[match[1]] = value;
@@ -1,4 +1,5 @@
1
- /** Default Pixeye server URL. */
2
1
  export declare const DEFAULT_SERVER_URL = "https://pixeye.dev";
3
- /** Default number of concurrent screenshot uploads. Overridable via PIXEYE_UPLOAD_CONCURRENCY env. */
4
2
  export declare const DEFAULT_UPLOAD_CONCURRENCY = 32;
3
+ export declare const DEFAULT_HTTP_TIMEOUT_MS = 60000;
4
+ export declare const DEFAULT_UPLOAD_ATTEMPTS = 3;
5
+ export declare const RETRY_AFTER_MAX_MS = 60000;
package/dist/constants.js CHANGED
@@ -1,4 +1,5 @@
1
- /** Default Pixeye server URL. */
2
1
  export const DEFAULT_SERVER_URL = 'https://pixeye.dev';
3
- /** Default number of concurrent screenshot uploads. Overridable via PIXEYE_UPLOAD_CONCURRENCY env. */
4
2
  export const DEFAULT_UPLOAD_CONCURRENCY = 32;
3
+ export const DEFAULT_HTTP_TIMEOUT_MS = 60_000;
4
+ export const DEFAULT_UPLOAD_ATTEMPTS = 3;
5
+ export const RETRY_AFTER_MAX_MS = 60_000;
@@ -1,10 +1,12 @@
1
+ import type { ScreenshotFormat } from '@pixeyedev/shared';
1
2
  export interface DiscoveredFile {
2
3
  filePath: string;
3
4
  name: string;
4
5
  fileHash: string;
5
- format: 'png' | 'webp';
6
+ format: ScreenshotFormat;
6
7
  width: number;
7
8
  height: number;
8
9
  metadata?: Record<string, unknown>;
10
+ library?: string;
9
11
  }
10
12
  export declare const discoverScreenshots: (directory: string) => Promise<DiscoveredFile[]>;
package/dist/discover.js CHANGED
@@ -1,70 +1,94 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFile } from 'node:fs/promises';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
3
4
  import { glob } from 'glob';
4
- const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
5
- const WEBP_RIFF = Buffer.from('RIFF');
6
- const WEBP_WEBP = Buffer.from('WEBP');
7
- const readPngDimensions = (buffer) => {
8
- if (buffer.length < 24 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
9
- throw new Error('Invalid PNG file: missing or incorrect PNG signature');
5
+ import { probeImage, swapImageExtension } from './imageProbe.js';
6
+ const METADATA_SIDECAR = '.meta.json';
7
+ const SIDECAR_SUFFIX_RE = /\.[^.]+\.json$/;
8
+ const FAILED_NAME_SUFFIX = ' (failed)';
9
+ const sidecarsByOwner = (siblings) => {
10
+ const byOwner = new Map();
11
+ for (const name of siblings) {
12
+ if (SIDECAR_SUFFIX_RE.test(name)) {
13
+ const owner = name.replace(SIDECAR_SUFFIX_RE, '');
14
+ byOwner.set(owner, [...(byOwner.get(owner) ?? []), name]);
15
+ }
10
16
  }
11
- const width = buffer.readUInt32BE(16);
12
- const height = buffer.readUInt32BE(20);
13
- return { width, height };
17
+ return byOwner;
14
18
  };
15
- const readWebpDimensions = (buffer) => {
16
- if (buffer.length < 30 ||
17
- !buffer.subarray(0, 4).equals(WEBP_RIFF) ||
18
- !buffer.subarray(8, 12).equals(WEBP_WEBP)) {
19
- throw new Error('Invalid WebP file: missing RIFF/WEBP signature');
20
- }
21
- // VP8 lossy: chunk starts at offset 12, dimensions at offset 26-29
22
- const chunk = buffer.subarray(12, 16).toString('ascii');
23
- if (chunk === 'VP8 ') {
24
- const width = buffer.readUInt16LE(26) & 0x3fff;
25
- const height = buffer.readUInt16LE(28) & 0x3fff;
26
- return { width, height };
19
+ const readSidecar = async (path) => {
20
+ try {
21
+ return JSON.parse(await readFile(path, 'utf-8'));
27
22
  }
28
- // VP8L lossless: chunk starts at offset 12, dimensions encoded at offset 21
29
- if (chunk === 'VP8L') {
30
- const bits = buffer.readUInt32LE(21);
31
- const width = (bits & 0x3fff) + 1;
32
- const height = ((bits >> 14) & 0x3fff) + 1;
33
- return { width, height };
23
+ catch (err) {
24
+ console.warn(`pixeye: ignoring unreadable sidecar ${path}: ${String(err)}`);
25
+ return undefined;
34
26
  }
35
- // VP8X extended: dimensions at offset 24-29
36
- if (chunk === 'VP8X') {
37
- const width = (buffer.readUIntLE(24, 3) & 0xffffff) + 1;
38
- const height = (buffer.readUIntLE(27, 3) & 0xffffff) + 1;
39
- return { width, height };
27
+ };
28
+ const testOf = (sidecar) => {
29
+ const test = sidecar.test;
30
+ if (!test || typeof test !== 'object') {
31
+ return undefined;
40
32
  }
41
- throw new Error(`Unsupported WebP chunk type: ${chunk}`);
33
+ const { id, title, titlePath, location } = test;
34
+ const file = location && typeof location === 'object' ? location.file : undefined;
35
+ const fields = Object.fromEntries(Object.entries({ testId: id, testName: title, testFile: file }).filter(([, v]) => typeof v === 'string'));
36
+ const key = Array.isArray(titlePath) ? titlePath.join(' › ') : fields.testName;
37
+ return key ? { key, fields } : undefined;
42
38
  };
43
- const readDimensions = (buffer, format) => {
44
- return format === 'png' ? readPngDimensions(buffer) : readWebpDimensions(buffer);
39
+ const libraryOf = (sidecar) => {
40
+ const library = sidecar.automationLibrary;
41
+ const name = library && typeof library === 'object' ? library.name : undefined;
42
+ return typeof name === 'string' ? name : undefined;
45
43
  };
46
44
  export const discoverScreenshots = async (directory) => {
47
45
  const files = await glob('**/*.{png,webp}', { cwd: directory, posix: true });
48
46
  files.sort();
49
- const results = [];
47
+ const sidecarsByDir = new Map();
48
+ const found = [];
49
+ const failedTests = new Set();
50
50
  for (const relativePath of files) {
51
51
  const filePath = `${directory}/${relativePath}`;
52
52
  const fileBuffer = await readFile(filePath);
53
- const format = relativePath.endsWith('.webp') ? 'webp' : 'png';
54
53
  const fileHash = createHash('sha256').update(fileBuffer).digest('hex');
55
- const { width, height } = readDimensions(fileBuffer, format);
56
- const name = relativePath.replace(/\.(png|webp)$/, '');
57
- // Check for companion metadata file
58
- const metadataPath = filePath.replace(/\.(png|webp)$/, '.pixeye.json');
54
+ const { format, width, height } = probeImage(fileBuffer, relativePath);
55
+ const name = swapImageExtension(relativePath, '');
56
+ const dir = dirname(filePath);
57
+ const sidecars = sidecarsByDir.get(dir) ?? sidecarsByOwner(await readdir(dir));
58
+ sidecarsByDir.set(dir, sidecars);
59
+ const fileName = basename(filePath);
60
+ const own = [
61
+ ...(sidecars.get(swapImageExtension(fileName, '')) ?? []),
62
+ ...(sidecars.get(fileName) ?? [])
63
+ ];
59
64
  let metadata;
60
- try {
61
- const metadataContent = await readFile(metadataPath, 'utf-8');
62
- metadata = JSON.parse(metadataContent);
65
+ let test;
66
+ let library;
67
+ for (const sidecarName of own) {
68
+ const sidecar = await readSidecar(join(dir, sidecarName));
69
+ if (!sidecar) {
70
+ continue;
71
+ }
72
+ if (sidecarName === swapImageExtension(fileName, METADATA_SIDECAR)) {
73
+ metadata = sidecar;
74
+ }
75
+ else {
76
+ test ??= testOf(sidecar);
77
+ }
78
+ library ??= libraryOf(sidecar);
63
79
  }
64
- catch {
65
- // No metadata file or invalid JSON — skip
80
+ if (test && metadata === undefined && name.endsWith(FAILED_NAME_SUFFIX)) {
81
+ failedTests.add(test.key);
66
82
  }
67
- results.push({ filePath, name, fileHash, format, width, height, metadata });
83
+ found.push({ filePath, name, fileHash, format, width, height, metadata, test, library });
68
84
  }
85
+ const results = found.map(({ test, metadata, ...file }) => ({
86
+ ...file,
87
+ metadata: metadata ??
88
+ (test && {
89
+ ...test.fields,
90
+ ...(failedTests.has(test.key) ? { testStatus: 'failed' } : {})
91
+ })
92
+ }));
69
93
  return results;
70
94
  };
@@ -0,0 +1,9 @@
1
+ import type { ScreenshotFormat } from '@pixeyedev/shared';
2
+ export interface ProbedImage {
3
+ format: ScreenshotFormat;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ export declare const swapImageExtension: (filePath: string, replacement: string) => string;
8
+ export declare const sniffImageFormat: (buffer: Buffer) => ScreenshotFormat | null;
9
+ export declare const probeImage: (buffer: Buffer, filePath: string) => ProbedImage;
@@ -0,0 +1,102 @@
1
+ import { CustomerError } from './uploadError.js';
2
+ const IMAGE_EXTENSION_RE = /\.(png|webp)$/;
3
+ export const swapImageExtension = (filePath, replacement) => filePath.replace(IMAGE_EXTENSION_RE, replacement);
4
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
5
+ const WEBP_RIFF = Buffer.from('RIFF');
6
+ const WEBP_WEBP = Buffer.from('WEBP');
7
+ const isPng = (buffer) => buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE);
8
+ const isJpeg = (buffer) => buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
9
+ const isWebp = (buffer) => buffer.length >= 12 &&
10
+ buffer.subarray(0, 4).equals(WEBP_RIFF) &&
11
+ buffer.subarray(8, 12).equals(WEBP_WEBP);
12
+ export const sniffImageFormat = (buffer) => {
13
+ if (isPng(buffer)) {
14
+ return 'png';
15
+ }
16
+ if (isJpeg(buffer)) {
17
+ return 'jpeg';
18
+ }
19
+ if (isWebp(buffer)) {
20
+ return 'webp';
21
+ }
22
+ return null;
23
+ };
24
+ const readPngDimensions = (buffer) => {
25
+ if (buffer.length < 24) {
26
+ throw new Error('PNG header is truncated');
27
+ }
28
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
29
+ };
30
+ const JPEG_SOF_EXCLUDED = new Set([0xc4, 0xc8, 0xcc]);
31
+ const readJpegDimensions = (buffer) => {
32
+ let offset = 2;
33
+ while (offset + 9 < buffer.length) {
34
+ if (buffer[offset] !== 0xff) {
35
+ offset++;
36
+ continue;
37
+ }
38
+ const marker = buffer[offset + 1];
39
+ if (marker === 0xff) {
40
+ offset++;
41
+ continue;
42
+ }
43
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
44
+ offset += 2;
45
+ continue;
46
+ }
47
+ const segmentLength = buffer.readUInt16BE(offset + 2);
48
+ if (marker >= 0xc0 && marker <= 0xcf && !JPEG_SOF_EXCLUDED.has(marker)) {
49
+ return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
50
+ }
51
+ if (segmentLength < 2) {
52
+ break;
53
+ }
54
+ offset += 2 + segmentLength;
55
+ }
56
+ throw new Error('JPEG has no start-of-frame marker');
57
+ };
58
+ const readWebpDimensions = (buffer) => {
59
+ if (buffer.length < 30) {
60
+ throw new Error('WebP header is truncated');
61
+ }
62
+ const chunk = buffer.subarray(12, 16).toString('ascii');
63
+ if (chunk === 'VP8 ') {
64
+ return {
65
+ width: buffer.readUInt16LE(26) & 0x3fff,
66
+ height: buffer.readUInt16LE(28) & 0x3fff
67
+ };
68
+ }
69
+ if (chunk === 'VP8L') {
70
+ const bits = buffer.readUInt32LE(21);
71
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
72
+ }
73
+ if (chunk === 'VP8X') {
74
+ return {
75
+ width: (buffer.readUIntLE(24, 3) & 0xffffff) + 1,
76
+ height: (buffer.readUIntLE(27, 3) & 0xffffff) + 1
77
+ };
78
+ }
79
+ throw new Error(`Unsupported WebP chunk type: ${chunk}`);
80
+ };
81
+ export const probeImage = (buffer, filePath) => {
82
+ const format = sniffImageFormat(buffer);
83
+ if (format === null) {
84
+ throw new CustomerError(`${filePath} is not a PNG, JPEG or WebP image (the file is corrupt, ` +
85
+ `truncated, or a different format saved under an image extension)`);
86
+ }
87
+ try {
88
+ const dimensions = format === 'png'
89
+ ? readPngDimensions(buffer)
90
+ : format === 'jpeg'
91
+ ? readJpegDimensions(buffer)
92
+ : readWebpDimensions(buffer);
93
+ if (dimensions.width <= 0 || dimensions.height <= 0) {
94
+ throw new Error(`reported ${dimensions.width}x${dimensions.height}`);
95
+ }
96
+ return { format, ...dimensions };
97
+ }
98
+ catch (err) {
99
+ throw new CustomerError(`${filePath} looks like ${format.toUpperCase()} but its dimensions could not be ` +
100
+ `read (${err.message}) — the file is probably truncated`);
101
+ }
102
+ };
package/dist/index.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  export type { PixeyeClientOptions } from './client.js';
2
2
  export { PixeyeClient, PixeyeError } from './client.js';
3
3
  export type { EnvConfig, PixeyeConfig, ProjectConfig, ResolvedConfig } from './config.js';
4
- export { parseYamlLike, readEnvConfig, readGlobalConfig, readProjectConfig, resolveConfig, writeGlobalConfig, writeProjectConfig } from './config.js';
4
+ export { integerSetting, parseYamlLike, readEnvConfig, readGlobalConfig, readProjectConfig, resolveConfig, resolveUploadConcurrency, writeGlobalConfig, writeProjectConfig } from './config.js';
5
5
  export type { CIEnvironment } from './ci.js';
6
- export { detectBaseBranchFromGit, detectCIEnvironment } from './ci.js';
6
+ export { detectCIEnvironment } from './ci.js';
7
7
  export type { DiscoveredFile } from './discover.js';
8
8
  export { discoverScreenshots } from './discover.js';
9
+ export type { ProbedImage } from './imageProbe.js';
10
+ export { probeImage, sniffImageFormat, swapImageExtension } from './imageProbe.js';
9
11
  export { runParallel } from './parallel.js';
10
12
  export type { RetryOptions } from './retry.js';
11
13
  export { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
12
- export { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
13
- export { isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
14
+ export { DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_SERVER_URL, DEFAULT_UPLOAD_ATTEMPTS, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
15
+ export { CustomerError, isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
package/dist/index.js CHANGED
@@ -1,10 +1,9 @@
1
1
  export { PixeyeClient, PixeyeError } from './client.js';
2
- export { parseYamlLike, readEnvConfig, readGlobalConfig, readProjectConfig, resolveConfig, writeGlobalConfig, writeProjectConfig } from './config.js';
3
- export { detectBaseBranchFromGit, detectCIEnvironment } from './ci.js';
2
+ export { integerSetting, parseYamlLike, readEnvConfig, readGlobalConfig, readProjectConfig, resolveConfig, resolveUploadConcurrency, writeGlobalConfig, writeProjectConfig } from './config.js';
3
+ export { detectCIEnvironment } from './ci.js';
4
4
  export { discoverScreenshots } from './discover.js';
5
- // Parallel execution
5
+ export { probeImage, sniffImageFormat, swapImageExtension } from './imageProbe.js';
6
6
  export { runParallel } from './parallel.js';
7
7
  export { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
8
- // Constants
9
- export { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
10
- export { isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
8
+ export { DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_SERVER_URL, DEFAULT_UPLOAD_ATTEMPTS, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
9
+ export { CustomerError, isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
@@ -1,4 +1 @@
1
- /**
2
- * Run async tasks in parallel with a concurrency limit.
3
- */
4
1
  export declare const runParallel: <T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>) => Promise<void>;
package/dist/parallel.js CHANGED
@@ -1,6 +1,3 @@
1
- /**
2
- * Run async tasks in parallel with a concurrency limit.
3
- */
4
1
  export const runParallel = async (items, concurrency, fn) => {
5
2
  let index = 0;
6
3
  const worker = async () => {
package/dist/retry.d.ts CHANGED
@@ -3,12 +3,6 @@ export interface RetryOptions {
3
3
  baseDelayMs?: number;
4
4
  onRetry?: (attempt: number, error: Error) => void;
5
5
  }
6
- /**
7
- * Retry an async function with exponential backoff + jitter.
8
- * Only retries on errors where shouldRetry returns true.
9
- */
10
6
  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
7
  export declare const isRetryableHttpError: (error: unknown) => boolean;
13
- /** Mask presigned URL signature for safe error logging. */
14
8
  export declare const maskPresignedUrl: (url: string) => string;
package/dist/retry.js CHANGED
@@ -1,7 +1,3 @@
1
- /**
2
- * Retry an async function with exponential backoff + jitter.
3
- * Only retries on errors where shouldRetry returns true.
4
- */
5
1
  export const withRetry = async (fn, shouldRetry, opts = {}) => {
6
2
  const maxAttempts = opts.maxAttempts ?? 3;
7
3
  const baseDelayMs = opts.baseDelayMs ?? 1000;
@@ -13,28 +9,30 @@ export const withRetry = async (fn, shouldRetry, opts = {}) => {
13
9
  if (attempt === maxAttempts || !shouldRetry(error)) {
14
10
  throw error;
15
11
  }
16
- // Exponential backoff with equal jitter (half deterministic + half random)
17
12
  const exponential = baseDelayMs * Math.pow(2, attempt - 1);
18
- const delay = exponential * (0.5 + 0.5 * Math.random());
13
+ const delay = retryAfterMsOf(error) ?? exponential * (0.5 + 0.5 * Math.random());
19
14
  opts.onRetry?.(attempt, error instanceof Error ? error : new Error(String(error)));
20
15
  await new Promise(r => setTimeout(r, delay));
21
16
  }
22
17
  }
23
18
  throw new Error('Unreachable');
24
19
  };
20
+ const retryAfterMsOf = (error) => {
21
+ const ms = error?.retryAfterMs;
22
+ return typeof ms === 'number' ? ms : undefined;
23
+ };
25
24
  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
25
  export const isRetryableHttpError = (error) => {
28
- // Check status code first (authoritative when present)
26
+ if (error instanceof Error && error.name === 'TimeoutError') {
27
+ return true;
28
+ }
29
29
  const status = error?.status;
30
30
  if (status !== undefined) {
31
31
  return status === 429 || (status >= 500 && status < 600);
32
32
  }
33
- // Fall back to message-based detection for genuine network errors (no HTTP response)
34
33
  if (error instanceof Error && TRANSIENT_NETWORK_RE.test(error.message)) {
35
34
  return true;
36
35
  }
37
36
  return false;
38
37
  };
39
- /** Mask presigned URL signature for safe error logging. */
40
38
  export const maskPresignedUrl = (url) => url.split('?')[0] + '?[signature-masked]';
@@ -1,35 +1,9 @@
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
- */
1
+ export declare class CustomerError extends Error {
2
+ constructor(message: string);
3
+ }
14
4
  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
5
  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
6
  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
7
  export declare const renderUploadFailureBanner: (err: unknown, opts: {
34
8
  willFail: boolean;
35
9
  }) => string;
@@ -1,42 +1,23 @@
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];
1
+ export class CustomerError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'CustomerError';
5
+ }
6
+ }
17
7
  const statusOf = (err) => {
18
8
  const status = err?.status;
19
9
  return typeof status === 'number' ? status : undefined;
20
10
  };
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
11
  export const isUploadMisconfig = (err) => {
12
+ if (err instanceof CustomerError) {
13
+ return true;
14
+ }
28
15
  if (err?.origin === 'r2') {
29
16
  return false;
30
17
  }
31
18
  const status = statusOf(err);
32
- return status !== undefined && MISCONFIG_STATUSES.includes(status);
19
+ return status !== undefined && status >= 400 && status < 500 && status !== 409 && status !== 429;
33
20
  };
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
21
  export const uploadErrorSummary = (err) => {
41
22
  const status = statusOf(err);
42
23
  const body = err?.body;
@@ -47,34 +28,35 @@ export const uploadErrorSummary = (err) => {
47
28
  : String(err);
48
29
  return status !== undefined ? `HTTP ${status} — ${detail}` : detail;
49
30
  };
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
31
  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
32
  export const renderUploadFailureBanner = (err, opts) => {
62
33
  const line = '━'.repeat(64);
34
+ const customer = isUploadMisconfig(err);
35
+ const cause = customer
36
+ ? [
37
+ ' This is a problem in your configuration or account (a setting, the',
38
+ ' API token, subscription/quota, or project permissions) — not a Pixeye',
39
+ ' outage. Fix it and re-run this step.'
40
+ ]
41
+ : [
42
+ ' This is a failure on the Pixeye side, not in your configuration: the',
43
+ ' upload was retried and still did not get through. Re-run this step;',
44
+ ' if it keeps failing, write to support@pixeye.dev with this output.'
45
+ ];
63
46
  const footer = opts.willFail
64
47
  ? [
65
- ' Failing this step so it cannot pass silently. Fix the above, or pass',
48
+ ' Failing this step so lost coverage cannot pass silently. Pass',
66
49
  ' failOnError: false (reporter) / --no-fail-on-error (CLI) to opt out.'
67
50
  ]
68
- : [' failOnError is OFF — this step passes despite the lost coverage.'];
51
+ : [' Not failing this step: the project is in observe mode, or failOnError is off.'];
69
52
  return [
70
53
  '',
71
54
  `\x1b[31m\x1b[1m${line}`,
72
- ` ✗ PIXEYE UPLOAD REJECTED — ${uploadErrorSummary(err)}`,
55
+ ` ✗ PIXEYE UPLOAD ${customer ? 'REJECTED' : 'FAILED'} — ${uploadErrorSummary(err)}`,
73
56
  '',
74
57
  ' 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.',
58
+ ' LOST.',
59
+ ...cause,
78
60
  '',
79
61
  ...footer,
80
62
  `${line}\x1b[0m`,
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@pixeyedev/core",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
+ "license": "MIT",
4
5
  "private": false,
5
6
  "type": "module",
6
7
  "files": [
@@ -16,6 +17,7 @@
16
17
  },
17
18
  "scripts": {
18
19
  "build": "tsc",
20
+ "prepack": "rm -rf dist && npm run build",
19
21
  "typecheck": "tsc --noEmit",
20
22
  "test": "vitest run",
21
23
  "lint": "echo 'ok'",
@@ -23,7 +25,7 @@
23
25
  "publish:dry": "npm run build && npm publish --access public --dry-run"
24
26
  },
25
27
  "dependencies": {
26
- "@pixeyedev/shared": "^0.4.0",
28
+ "@pixeyedev/shared": "^0.4.7",
27
29
  "glob": "^11.0.0"
28
30
  },
29
31
  "devDependencies": {
@@ -1,23 +0,0 @@
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;
@@ -1,41 +0,0 @@
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
- };