@pixeyedev/core 0.4.6 → 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/dist/ci.d.ts CHANGED
@@ -5,9 +5,6 @@ export interface CIEnvironment {
5
5
  baseBranch?: string;
6
6
  prNumber?: number;
7
7
  repository?: string;
8
- /** What triggered the run — recorded on the build so "which CI run made
9
- * this build, started by whom" is answerable from the admin without
10
- * opening the provider. All optional: providers differ in what they expose. */
11
8
  trigger?: CITrigger;
12
9
  }
13
10
  export interface CITrigger {
@@ -18,9 +15,4 @@ export interface CITrigger {
18
15
  eventName?: string;
19
16
  runUrl?: string;
20
17
  }
21
- /**
22
- * Try to detect the base branch using git merge-base.
23
- * Checks common default branch names (main, master, develop).
24
- */
25
- export declare const detectBaseBranchFromGit: () => string | undefined;
26
18
  export declare const detectCIEnvironment: () => CIEnvironment | null;
package/dist/ci.js CHANGED
@@ -1,7 +1,4 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { readFileSync } from 'node:fs';
3
- /** GITHUB_EVENT_PATH is the run's event payload; absent or unreadable means
4
- * the env vars alone describe the run. */
5
2
  const readGitHubEvent = () => {
6
3
  const path = process.env.GITHUB_EVENT_PATH;
7
4
  if (!path) {
@@ -19,8 +16,6 @@ const detectGitHubActions = () => {
19
16
  if (!process.env.GITHUB_ACTIONS) {
20
17
  return null;
21
18
  }
22
- // On a pull_request run GITHUB_SHA is GitHub's throwaway merge commit, one
23
- // per push to the base: the build must carry the PR's own head commit.
24
19
  const event = readGitHubEvent();
25
20
  const eventPr = event?.pull_request ?? event?.workflow_run?.pull_requests?.[0];
26
21
  const commit = event?.pull_request?.head?.sha ?? event?.workflow_run?.head_sha ?? process.env.GITHUB_SHA;
@@ -29,11 +24,8 @@ const detectGitHubActions = () => {
29
24
  if (!commit || !branch || !runId) {
30
25
  return null;
31
26
  }
32
- // PR number: explicit env, else the event payload.
33
27
  const prRaw = process.env.PIXEYE_PR;
34
28
  const prNumber = prRaw ? parseInt(prRaw, 10) : eventPr?.number;
35
- // PR target branch: GITHUB_BASE_REF is set on pull_request events; the
36
- // payload carries it for workflow_run too.
37
29
  const baseBranch = process.env.GITHUB_BASE_REF || eventPr?.base?.ref || undefined;
38
30
  const repository = process.env.GITHUB_REPOSITORY;
39
31
  const serverUrl = process.env.GITHUB_SERVER_URL;
@@ -81,29 +73,4 @@ const detectGitLabCI = () => {
81
73
  }
82
74
  };
83
75
  };
84
- /**
85
- * Try to detect the base branch using git merge-base.
86
- * Checks common default branch names (main, master, develop).
87
- */
88
- export const detectBaseBranchFromGit = () => {
89
- const candidates = ['main', 'master', 'develop'];
90
- for (const candidate of candidates) {
91
- try {
92
- execFileSync('git', ['rev-parse', '--verify', `origin/${candidate}`], { stdio: 'ignore' });
93
- execFileSync('git', ['merge-base', 'HEAD', `origin/${candidate}`], { stdio: 'ignore' });
94
- return candidate;
95
- }
96
- catch {
97
- // Branch doesn't exist or no common ancestor
98
- }
99
- }
100
- return undefined;
101
- };
102
- export const detectCIEnvironment = () => {
103
- const ci = detectGitHubActions() ?? detectGitLabCI() ?? null;
104
- // If CI detected but no baseBranch, try git merge-base
105
- if (ci && !ci.baseBranch) {
106
- ci.baseBranch = detectBaseBranchFromGit();
107
- }
108
- return ci;
109
- };
76
+ export const detectCIEnvironment = () => detectGitHubActions() ?? detectGitLabCI() ?? null;
package/dist/client.d.ts CHANGED
@@ -3,33 +3,27 @@ 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;
@@ -59,12 +53,6 @@ export declare class PixeyeClient {
59
53
  branch: string;
60
54
  }): Promise<ListBuildsResponse>;
61
55
  finalizePipeline(pipelineId: string): Promise<PipelineFinalizeResponse>;
62
- /** `format` is the SNIFFED format of the bytes we would upload, and it
63
- * scopes the probe to that exact key: the same hash stored under another
64
- * extension by an older CLI is not this file. */
65
56
  checkFileExists(hash: string, format?: ScreenshotFormat): Promise<boolean>;
66
- /** `format` decides the stored key's extension server-side, so it must be
67
- * the same sniffed value the shard manifest carries. Older servers ignore
68
- * the field and keep storing under `.png`. */
69
57
  uploadLegacy(buffer: Buffer, hash: string, filename: string, format?: ScreenshotFormat): Promise<void>;
70
58
  }
package/dist/client.js CHANGED
@@ -1,43 +1,52 @@
1
1
  import { createRequire } from 'node:module';
2
- import { CONTENT_TYPE_BY_FORMAT, CreateBuildResponseSchema, ListBuildsResponseSchema, PipelineFinalizeResponseSchema, PresignUploadResponseSchema, SubmitShardResponseSchema } from '@pixeyedev/shared';
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';
3
5
  import { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
4
6
  export class PixeyeError extends Error {
5
7
  status;
6
8
  path;
7
9
  body;
8
10
  origin;
9
- constructor(status, path, body,
10
- /**
11
- * Where the failure came from. `'api'` = the Pixeye control-plane (a 4xx
12
- * here can be a customer misconfig — token/plan/permissions). `'r2'` = a
13
- * data-plane PUT to R2 via a presigned URL (a 403/404 here is a Pixeye-side
14
- * fault — expired URL, bucket/credential, clock skew — NEVER the customer's).
15
- * Used by `isUploadMisconfig` so an R2 outage can't fail a customer's CI.
16
- */
17
- origin = 'api') {
11
+ retryAfterMs;
12
+ constructor(status, path, body, origin = 'api', retryAfterMs) {
18
13
  super(`Pixeye API ${path} failed (${status}): ${body}`);
19
14
  this.status = status;
20
15
  this.path = path;
21
16
  this.body = body;
22
17
  this.origin = origin;
18
+ this.retryAfterMs = retryAfterMs;
23
19
  this.name = 'PixeyeError';
24
20
  }
25
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
+ };
26
28
  const { version } = createRequire(import.meta.url)('../package.json');
27
- /** What the API records on a build so a failed run can be matched to the client
28
- * that sent it: this package's version and the Node it ran on. */
29
29
  const USER_AGENT = `@pixeyedev/core/${version} node/${process.versions.node}`;
30
30
  export class PixeyeClient {
31
31
  serverUrl;
32
32
  token;
33
+ timeoutMs;
34
+ attempts;
35
+ reporting;
36
+ get observes() {
37
+ return this.reporting === 'observe';
38
+ }
33
39
  constructor(options) {
40
+ const env = readClientEnv();
34
41
  this.serverUrl = options.serverUrl.replace(/\/$/, '');
35
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;
36
45
  }
37
- async request(path, options = {}) {
38
- const url = `${this.serverUrl}${path}`;
39
- const response = await fetch(url, {
46
+ async send(path, options = {}) {
47
+ const response = await fetch(`${this.serverUrl}${path}`, {
40
48
  ...options,
49
+ signal: AbortSignal.timeout(this.timeoutMs),
41
50
  headers: {
42
51
  'Content-Type': 'application/json',
43
52
  Authorization: `Bearer ${this.token}`,
@@ -46,11 +55,21 @@ export class PixeyeClient {
46
55
  }
47
56
  });
48
57
  if (!response.ok) {
49
- const body = await response.text();
50
- throw new PixeyeError(response.status, path, body);
58
+ throw new PixeyeError(response.status, path.split('?')[0], await response.text(), 'api', parseRetryAfter(response));
51
59
  }
52
60
  return response.json();
53
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
+ }
54
73
  async createBuild(opts) {
55
74
  const body = { ...opts };
56
75
  if (opts.runner) {
@@ -60,19 +79,39 @@ export class PixeyeClient {
60
79
  method: 'POST',
61
80
  body: JSON.stringify(body)
62
81
  });
63
- return CreateBuildResponseSchema.parse(raw);
82
+ const parsed = CreateBuildResponseSchema.parse(raw);
83
+ this.reporting = parsed.reporting;
84
+ return parsed;
64
85
  }
65
86
  async presignUpload(opts) {
66
- const raw = await this.request('/api/v1/upload/presign', {
67
- method: 'POST',
68
- body: JSON.stringify({
69
- token: this.token,
70
- buildId: opts.buildId,
71
- hashes: opts.hashes,
72
- ...(opts.formats && Object.keys(opts.formats).length > 0 ? { formats: opts.formats } : {})
73
- })
74
- });
75
- 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;
76
115
  }
77
116
  async uploadFile(presignedUrl, buffer, contentType) {
78
117
  const maskedUrl = maskPresignedUrl(presignedUrl);
@@ -80,23 +119,16 @@ export class PixeyeClient {
80
119
  const res = await fetch(presignedUrl, {
81
120
  method: 'PUT',
82
121
  body: new Uint8Array(buffer),
122
+ signal: AbortSignal.timeout(this.timeoutMs),
83
123
  headers: { 'Content-Type': contentType }
84
124
  });
85
125
  if (!res.ok) {
86
- // 'r2' origin — a PUT failure here is Pixeye-side (expired presigned
87
- // URL, bucket/creds, clock skew), never the customer's misconfig.
88
126
  throw new PixeyeError(res.status, maskedUrl, await res.text(), 'r2');
89
127
  }
90
- }, isRetryableHttpError, {
91
- maxAttempts: 3,
92
- baseDelayMs: 1000,
93
- onRetry: (attempt, err) => {
94
- console.warn(`[pixeye] Upload retry ${attempt}/3: ${err.message}`);
95
- }
96
- });
128
+ }, isRetryableHttpError, this.retryOptions('Upload'));
97
129
  }
98
130
  async submitShard(opts) {
99
- const raw = await this.request(`/api/v1/builds/${opts.buildId}/shards`, {
131
+ const raw = await this.send(`/api/v1/builds/${opts.buildId}/shards`, {
100
132
  method: 'POST',
101
133
  body: JSON.stringify({
102
134
  shardIndex: opts.shardIndex,
@@ -120,17 +152,11 @@ export class PixeyeClient {
120
152
  });
121
153
  return PipelineFinalizeResponseSchema.parse(raw);
122
154
  }
123
- /** `format` is the SNIFFED format of the bytes we would upload, and it
124
- * scopes the probe to that exact key: the same hash stored under another
125
- * extension by an older CLI is not this file. */
126
155
  async checkFileExists(hash, format = 'png') {
127
156
  const result = await this.request(`/api/v1/upload/${encodeURIComponent(hash)}?token=${encodeURIComponent(this.token)}` +
128
157
  `&format=${encodeURIComponent(format)}`);
129
158
  return result.exists;
130
159
  }
131
- /** `format` decides the stored key's extension server-side, so it must be
132
- * the same sniffed value the shard manifest carries. Older servers ignore
133
- * the field and keep storing under `.png`. */
134
160
  async uploadLegacy(buffer, hash, filename, format = 'png') {
135
161
  const blob = new Blob([new Uint8Array(buffer)], { type: CONTENT_TYPE_BY_FORMAT[format] });
136
162
  const formData = new FormData();
@@ -139,7 +165,11 @@ export class PixeyeClient {
139
165
  formData.append('format', format);
140
166
  formData.append('token', this.token);
141
167
  const url = `${this.serverUrl}/api/v1/upload`;
142
- 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
+ });
143
173
  if (!response.ok) {
144
174
  const body = await response.text();
145
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;
@@ -3,10 +3,10 @@ export interface DiscoveredFile {
3
3
  filePath: string;
4
4
  name: string;
5
5
  fileHash: string;
6
- /** Sniffed from the bytes, not the extension — see `probeImage`. */
7
6
  format: ScreenshotFormat;
8
7
  width: number;
9
8
  height: number;
10
9
  metadata?: Record<string, unknown>;
10
+ library?: string;
11
11
  }
12
12
  export declare const discoverScreenshots: (directory: string) => Promise<DiscoveredFile[]>;
package/dist/discover.js CHANGED
@@ -1,28 +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
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
+ }
16
+ }
17
+ return byOwner;
18
+ };
19
+ const readSidecar = async (path) => {
20
+ try {
21
+ return JSON.parse(await readFile(path, 'utf-8'));
22
+ }
23
+ catch (err) {
24
+ console.warn(`pixeye: ignoring unreadable sidecar ${path}: ${String(err)}`);
25
+ return undefined;
26
+ }
27
+ };
28
+ const testOf = (sidecar) => {
29
+ const test = sidecar.test;
30
+ if (!test || typeof test !== 'object') {
31
+ return undefined;
32
+ }
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;
38
+ };
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;
43
+ };
5
44
  export const discoverScreenshots = async (directory) => {
6
45
  const files = await glob('**/*.{png,webp}', { cwd: directory, posix: true });
7
46
  files.sort();
8
- const results = [];
47
+ const sidecarsByDir = new Map();
48
+ const found = [];
49
+ const failedTests = new Set();
9
50
  for (const relativePath of files) {
10
51
  const filePath = `${directory}/${relativePath}`;
11
52
  const fileBuffer = await readFile(filePath);
12
53
  const fileHash = createHash('sha256').update(fileBuffer).digest('hex');
13
54
  const { format, width, height } = probeImage(fileBuffer, relativePath);
14
55
  const name = swapImageExtension(relativePath, '');
15
- // Check for companion metadata file
16
- const metadataPath = swapImageExtension(filePath, '.pixeye.json');
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
+ ];
17
64
  let metadata;
18
- try {
19
- const metadataContent = await readFile(metadataPath, 'utf-8');
20
- 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);
21
79
  }
22
- catch {
23
- // No metadata file or invalid JSON — skip
80
+ if (test && metadata === undefined && name.endsWith(FAILED_NAME_SUFFIX)) {
81
+ failedTests.add(test.key);
24
82
  }
25
- results.push({ filePath, name, fileHash, format, width, height, metadata });
83
+ found.push({ filePath, name, fileHash, format, width, height, metadata, test, library });
26
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
+ }));
27
93
  return results;
28
94
  };
@@ -1,22 +1,9 @@
1
1
  import type { ScreenshotFormat } from '@pixeyedev/shared';
2
- /**
3
- * Format + dimensions read from the FILE'S BYTES, never its name: Playwright
4
- * writes `type: 'jpeg'` screenshots under `.png` paths, and trusting the
5
- * extension failed the whole upload on "Invalid PNG file".
6
- */
7
2
  export interface ProbedImage {
8
3
  format: ScreenshotFormat;
9
4
  width: number;
10
5
  height: number;
11
6
  }
12
- /** Swap a screenshot's extension for `replacement` ('' strips it). One owner,
13
- * so the glob, the name strip and the sidecar lookup can never disagree. */
14
7
  export declare const swapImageExtension: (filePath: string, replacement: string) => string;
15
- /** Content sniff only — returns null when the bytes are none of the three. */
16
8
  export declare const sniffImageFormat: (buffer: Buffer) => ScreenshotFormat | null;
17
- /**
18
- * Identify an image from its bytes. Throws naming `filePath` and what we
19
- * accept — the CLI surfaces the message to a user staring at a CI log, so
20
- * it must say which file is wrong.
21
- */
22
9
  export declare const probeImage: (buffer: Buffer, filePath: string) => ProbedImage;
@@ -1,9 +1,5 @@
1
- /** Every extension `discoverScreenshots` globs. Deliberately NOT jpg/jpeg:
2
- * the ask is JPEG BYTES under a `.png` name, not every jpg in the output
3
- * directory (owner 2026-09-02). Content decides `format`, the name does not. */
1
+ import { CustomerError } from './uploadError.js';
4
2
  const IMAGE_EXTENSION_RE = /\.(png|webp)$/;
5
- /** Swap a screenshot's extension for `replacement` ('' strips it). One owner,
6
- * so the glob, the name strip and the sidecar lookup can never disagree. */
7
3
  export const swapImageExtension = (filePath, replacement) => filePath.replace(IMAGE_EXTENSION_RE, replacement);
8
4
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
9
5
  const WEBP_RIFF = Buffer.from('RIFF');
@@ -13,7 +9,6 @@ const isJpeg = (buffer) => buffer.length >= 3 && buffer[0] === 0xff && buffer[1]
13
9
  const isWebp = (buffer) => buffer.length >= 12 &&
14
10
  buffer.subarray(0, 4).equals(WEBP_RIFF) &&
15
11
  buffer.subarray(8, 12).equals(WEBP_WEBP);
16
- /** Content sniff only — returns null when the bytes are none of the three. */
17
12
  export const sniffImageFormat = (buffer) => {
18
13
  if (isPng(buffer)) {
19
14
  return 'png';
@@ -32,13 +27,7 @@ const readPngDimensions = (buffer) => {
32
27
  }
33
28
  return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
34
29
  };
35
- /** Markers that carry no frame header, so their payload is skipped whole. */
36
30
  const JPEG_SOF_EXCLUDED = new Set([0xc4, 0xc8, 0xcc]);
37
- /**
38
- * Walk the marker chain to the first Start-Of-Frame (SOF0 baseline, SOF2
39
- * progressive, the rest of 0xC0–0xCF) and read its height/width. There is no
40
- * fixed offset: the segments before it vary by encoder.
41
- */
42
31
  const readJpegDimensions = (buffer) => {
43
32
  let offset = 2;
44
33
  while (offset + 9 < buffer.length) {
@@ -47,8 +36,6 @@ const readJpegDimensions = (buffer) => {
47
36
  continue;
48
37
  }
49
38
  const marker = buffer[offset + 1];
50
- // 0xFF is a fill byte: the spec allows any number before a marker, and
51
- // reading it AS the marker walks the chain off into the entropy data.
52
39
  if (marker === 0xff) {
53
40
  offset++;
54
41
  continue;
@@ -73,19 +60,16 @@ const readWebpDimensions = (buffer) => {
73
60
  throw new Error('WebP header is truncated');
74
61
  }
75
62
  const chunk = buffer.subarray(12, 16).toString('ascii');
76
- // VP8 lossy: dimensions at offset 26-29, masked to 14 bits.
77
63
  if (chunk === 'VP8 ') {
78
64
  return {
79
65
  width: buffer.readUInt16LE(26) & 0x3fff,
80
66
  height: buffer.readUInt16LE(28) & 0x3fff
81
67
  };
82
68
  }
83
- // VP8L lossless: 14-bit width and height packed from offset 21.
84
69
  if (chunk === 'VP8L') {
85
70
  const bits = buffer.readUInt32LE(21);
86
71
  return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
87
72
  }
88
- // VP8X extended: 24-bit canvas dimensions at offset 24-29.
89
73
  if (chunk === 'VP8X') {
90
74
  return {
91
75
  width: (buffer.readUIntLE(24, 3) & 0xffffff) + 1,
@@ -94,15 +78,10 @@ const readWebpDimensions = (buffer) => {
94
78
  }
95
79
  throw new Error(`Unsupported WebP chunk type: ${chunk}`);
96
80
  };
97
- /**
98
- * Identify an image from its bytes. Throws naming `filePath` and what we
99
- * accept — the CLI surfaces the message to a user staring at a CI log, so
100
- * it must say which file is wrong.
101
- */
102
81
  export const probeImage = (buffer, filePath) => {
103
82
  const format = sniffImageFormat(buffer);
104
83
  if (format === null) {
105
- throw new Error(`${filePath} is not a PNG, JPEG or WebP image (the file is corrupt, ` +
84
+ throw new CustomerError(`${filePath} is not a PNG, JPEG or WebP image (the file is corrupt, ` +
106
85
  `truncated, or a different format saved under an image extension)`);
107
86
  }
108
87
  try {
@@ -117,7 +96,7 @@ export const probeImage = (buffer, filePath) => {
117
96
  return { format, ...dimensions };
118
97
  }
119
98
  catch (err) {
120
- throw new Error(`${filePath} looks like ${format.toUpperCase()} but its dimensions could not be ` +
99
+ throw new CustomerError(`${filePath} looks like ${format.toUpperCase()} but its dimensions could not be ` +
121
100
  `read (${err.message}) — the file is probably truncated`);
122
101
  }
123
102
  };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
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
9
  export type { ProbedImage } from './imageProbe.js';
@@ -11,5 +11,5 @@ export { probeImage, sniffImageFormat, swapImageExtension } from './imageProbe.j
11
11
  export { runParallel } from './parallel.js';
12
12
  export type { RetryOptions } from './retry.js';
13
13
  export { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
14
- export { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
15
- 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,11 +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
5
  export { probeImage, sniffImageFormat, swapImageExtension } from './imageProbe.js';
6
- // Parallel execution
7
6
  export { runParallel } from './parallel.js';
8
7
  export { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
9
- // Constants
10
- export { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
11
- 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,6 @@
1
1
  {
2
2
  "name": "@pixeyedev/core",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -25,7 +25,7 @@
25
25
  "publish:dry": "npm run build && npm publish --access public --dry-run"
26
26
  },
27
27
  "dependencies": {
28
- "@pixeyedev/shared": "^0.4.0",
28
+ "@pixeyedev/shared": "^0.4.7",
29
29
  "glob": "^11.0.0"
30
30
  },
31
31
  "devDependencies": {