@pixeyedev/core 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,6 +5,18 @@ 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
+ trigger?: CITrigger;
12
+ }
13
+ export interface CITrigger {
14
+ runId?: string;
15
+ runAttempt?: string;
16
+ actor?: string;
17
+ workflow?: string;
18
+ eventName?: string;
19
+ runUrl?: string;
8
20
  }
9
21
  /**
10
22
  * Try to detect the base branch using git merge-base.
package/dist/ci.js CHANGED
@@ -1,33 +1,57 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ 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
+ const readGitHubEvent = () => {
6
+ const path = process.env.GITHUB_EVENT_PATH;
7
+ if (!path) {
8
+ return null;
9
+ }
10
+ try {
11
+ return JSON.parse(readFileSync(path, 'utf8'));
12
+ }
13
+ catch (err) {
14
+ console.warn(`pixeye: could not read the GitHub event payload at ${path}: ${String(err)}`);
15
+ return null;
16
+ }
17
+ };
2
18
  const detectGitHubActions = () => {
3
19
  if (!process.env.GITHUB_ACTIONS) {
4
20
  return null;
5
21
  }
6
- const commit = process.env.GITHUB_SHA;
7
- const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
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
+ const event = readGitHubEvent();
25
+ const eventPr = event?.pull_request ?? event?.workflow_run?.pull_requests?.[0];
26
+ const commit = event?.pull_request?.head?.sha ?? event?.workflow_run?.head_sha ?? process.env.GITHUB_SHA;
27
+ const branch = process.env.GITHUB_HEAD_REF || event?.workflow_run?.head_branch || process.env.GITHUB_REF_NAME;
8
28
  const runId = process.env.GITHUB_RUN_ID;
9
29
  if (!commit || !branch || !runId) {
10
30
  return null;
11
31
  }
12
- // PR number: explicit env > GITHUB_REF on pull_request events (refs/pull/123/merge)
32
+ // PR number: explicit env, else the event payload.
13
33
  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;
34
+ 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
+ const baseBranch = process.env.GITHUB_BASE_REF || eventPr?.base?.ref || undefined;
38
+ const repository = process.env.GITHUB_REPOSITORY;
39
+ const serverUrl = process.env.GITHUB_SERVER_URL;
24
40
  return {
25
41
  provider: 'github-actions',
26
42
  commit,
27
43
  branch,
28
44
  baseBranch,
29
45
  prNumber: prNumber && !isNaN(prNumber) ? prNumber : undefined,
30
- repository: process.env.GITHUB_REPOSITORY
46
+ repository,
47
+ trigger: {
48
+ runId,
49
+ runAttempt: process.env.GITHUB_RUN_ATTEMPT || undefined,
50
+ actor: process.env.GITHUB_ACTOR || undefined,
51
+ workflow: process.env.GITHUB_WORKFLOW || undefined,
52
+ eventName: process.env.GITHUB_EVENT_NAME || undefined,
53
+ runUrl: serverUrl && repository ? `${serverUrl}/${repository}/actions/runs/${runId}` : undefined
54
+ }
31
55
  };
32
56
  };
33
57
  const detectGitLabCI = () => {
@@ -47,7 +71,14 @@ const detectGitLabCI = () => {
47
71
  branch,
48
72
  baseBranch: process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || undefined,
49
73
  prNumber: mrIid ? parseInt(mrIid, 10) : undefined,
50
- repository: undefined
74
+ repository: process.env.CI_PROJECT_PATH || undefined,
75
+ trigger: {
76
+ runId: pipelineId,
77
+ actor: process.env.GITLAB_USER_LOGIN || undefined,
78
+ workflow: process.env.CI_JOB_NAME || undefined,
79
+ eventName: process.env.CI_PIPELINE_SOURCE || undefined,
80
+ runUrl: process.env.CI_PIPELINE_URL || undefined
81
+ }
51
82
  };
52
83
  };
53
84
  /**
package/dist/client.d.ts CHANGED
@@ -1,9 +1,25 @@
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
- constructor(status: number, path: string, body: string);
6
+ /**
7
+ * Where the failure came from. `'api'` = the Pixeye control-plane (a 4xx
8
+ * here can be a customer misconfig — token/plan/permissions). `'r2'` = a
9
+ * data-plane PUT to R2 via a presigned URL (a 403/404 here is a Pixeye-side
10
+ * fault — expired URL, bucket/credential, clock skew — NEVER the customer's).
11
+ * Used by `isUploadMisconfig` so an R2 outage can't fail a customer's CI.
12
+ */
13
+ origin: 'api' | 'r2';
14
+ constructor(status: number, path: string, body: string,
15
+ /**
16
+ * Where the failure came from. `'api'` = the Pixeye control-plane (a 4xx
17
+ * here can be a customer misconfig — token/plan/permissions). `'r2'` = a
18
+ * data-plane PUT to R2 via a presigned URL (a 403/404 here is a Pixeye-side
19
+ * fault — expired URL, bucket/credential, clock skew — NEVER the customer's).
20
+ * Used by `isUploadMisconfig` so an R2 outage can't fail a customer's CI.
21
+ */
22
+ origin?: 'api' | 'r2');
7
23
  }
8
24
  export interface PixeyeClientOptions {
9
25
  serverUrl: string;
@@ -30,7 +46,7 @@ export declare class PixeyeClient {
30
46
  presignUpload(opts: {
31
47
  buildId: string;
32
48
  hashes: string[];
33
- formats?: Record<string, 'png' | 'webp'>;
49
+ formats?: Record<string, ScreenshotFormat>;
34
50
  }): Promise<PresignUploadResponse>;
35
51
  uploadFile(presignedUrl: string, buffer: Buffer, contentType: string): Promise<void>;
36
52
  submitShard(opts: {
@@ -43,6 +59,12 @@ export declare class PixeyeClient {
43
59
  branch: string;
44
60
  }): Promise<ListBuildsResponse>;
45
61
  finalizePipeline(pipelineId: string): Promise<PipelineFinalizeResponse>;
46
- checkFileExists(hash: string): Promise<boolean>;
47
- uploadLegacy(buffer: Buffer, hash: string, filename: string): Promise<void>;
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
+ 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
+ uploadLegacy(buffer: Buffer, hash: string, filename: string, format?: ScreenshotFormat): Promise<void>;
48
70
  }
package/dist/client.js CHANGED
@@ -1,17 +1,32 @@
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, PresignUploadResponseSchema, SubmitShardResponseSchema } from '@pixeyedev/shared';
2
3
  import { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
3
4
  export class PixeyeError extends Error {
4
5
  status;
5
6
  path;
6
7
  body;
7
- constructor(status, path, body) {
8
+ 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') {
8
18
  super(`Pixeye API ${path} failed (${status}): ${body}`);
9
19
  this.status = status;
10
20
  this.path = path;
11
21
  this.body = body;
22
+ this.origin = origin;
12
23
  this.name = 'PixeyeError';
13
24
  }
14
25
  }
26
+ 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
+ const USER_AGENT = `@pixeyedev/core/${version} node/${process.versions.node}`;
15
30
  export class PixeyeClient {
16
31
  serverUrl;
17
32
  token;
@@ -26,6 +41,7 @@ export class PixeyeClient {
26
41
  headers: {
27
42
  'Content-Type': 'application/json',
28
43
  Authorization: `Bearer ${this.token}`,
44
+ 'User-Agent': USER_AGENT,
29
45
  ...options.headers
30
46
  }
31
47
  });
@@ -67,7 +83,9 @@ export class PixeyeClient {
67
83
  headers: { 'Content-Type': contentType }
68
84
  });
69
85
  if (!res.ok) {
70
- throw new PixeyeError(res.status, maskedUrl, await res.text());
86
+ // 'r2' origin — a PUT failure here is Pixeye-side (expired presigned
87
+ // URL, bucket/creds, clock skew), never the customer's misconfig.
88
+ throw new PixeyeError(res.status, maskedUrl, await res.text(), 'r2');
71
89
  }
72
90
  }, isRetryableHttpError, {
73
91
  maxAttempts: 3,
@@ -102,15 +120,23 @@ export class PixeyeClient {
102
120
  });
103
121
  return PipelineFinalizeResponseSchema.parse(raw);
104
122
  }
105
- async checkFileExists(hash) {
106
- const result = await this.request(`/api/v1/upload/${hash}?token=${encodeURIComponent(this.token)}`);
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
+ async checkFileExists(hash, format = 'png') {
127
+ const result = await this.request(`/api/v1/upload/${encodeURIComponent(hash)}?token=${encodeURIComponent(this.token)}` +
128
+ `&format=${encodeURIComponent(format)}`);
107
129
  return result.exists;
108
130
  }
109
- async uploadLegacy(buffer, hash, filename) {
110
- const blob = new Blob([new Uint8Array(buffer)], { type: 'image/png' });
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
+ async uploadLegacy(buffer, hash, filename, format = 'png') {
135
+ const blob = new Blob([new Uint8Array(buffer)], { type: CONTENT_TYPE_BY_FORMAT[format] });
111
136
  const formData = new FormData();
112
137
  formData.append('file', blob, filename);
113
138
  formData.append('hash', hash);
139
+ formData.append('format', format);
114
140
  formData.append('token', this.token);
115
141
  const url = `${this.serverUrl}/api/v1/upload`;
116
142
  const response = await fetch(url, { method: 'POST', body: formData });
@@ -1,8 +1,10 @@
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
+ /** Sniffed from the bytes, not the extension — see `probeImage`. */
7
+ format: ScreenshotFormat;
6
8
  width: number;
7
9
  height: number;
8
10
  metadata?: Record<string, unknown>;
package/dist/discover.js CHANGED
@@ -1,48 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  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');
10
- }
11
- const width = buffer.readUInt32BE(16);
12
- const height = buffer.readUInt32BE(20);
13
- return { width, height };
14
- };
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 };
27
- }
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 };
34
- }
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 };
40
- }
41
- throw new Error(`Unsupported WebP chunk type: ${chunk}`);
42
- };
43
- const readDimensions = (buffer, format) => {
44
- return format === 'png' ? readPngDimensions(buffer) : readWebpDimensions(buffer);
45
- };
4
+ import { probeImage, swapImageExtension } from './imageProbe.js';
46
5
  export const discoverScreenshots = async (directory) => {
47
6
  const files = await glob('**/*.{png,webp}', { cwd: directory, posix: true });
48
7
  files.sort();
@@ -50,12 +9,11 @@ export const discoverScreenshots = async (directory) => {
50
9
  for (const relativePath of files) {
51
10
  const filePath = `${directory}/${relativePath}`;
52
11
  const fileBuffer = await readFile(filePath);
53
- const format = relativePath.endsWith('.webp') ? 'webp' : 'png';
54
12
  const fileHash = createHash('sha256').update(fileBuffer).digest('hex');
55
- const { width, height } = readDimensions(fileBuffer, format);
56
- const name = relativePath.replace(/\.(png|webp)$/, '');
13
+ const { format, width, height } = probeImage(fileBuffer, relativePath);
14
+ const name = swapImageExtension(relativePath, '');
57
15
  // Check for companion metadata file
58
- const metadataPath = filePath.replace(/\.(png|webp)$/, '.pixeye.json');
16
+ const metadataPath = swapImageExtension(filePath, '.pixeye.json');
59
17
  let metadata;
60
18
  try {
61
19
  const metadataContent = await readFile(metadataPath, 'utf-8');
@@ -0,0 +1,22 @@
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
+ export interface ProbedImage {
8
+ format: ScreenshotFormat;
9
+ width: number;
10
+ height: number;
11
+ }
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
+ export declare const swapImageExtension: (filePath: string, replacement: string) => string;
15
+ /** Content sniff only — returns null when the bytes are none of the three. */
16
+ 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
+ export declare const probeImage: (buffer: Buffer, filePath: string) => ProbedImage;
@@ -0,0 +1,123 @@
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. */
4
+ 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
+ export const swapImageExtension = (filePath, replacement) => filePath.replace(IMAGE_EXTENSION_RE, replacement);
8
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
9
+ const WEBP_RIFF = Buffer.from('RIFF');
10
+ const WEBP_WEBP = Buffer.from('WEBP');
11
+ const isPng = (buffer) => buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE);
12
+ const isJpeg = (buffer) => buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
13
+ const isWebp = (buffer) => buffer.length >= 12 &&
14
+ buffer.subarray(0, 4).equals(WEBP_RIFF) &&
15
+ buffer.subarray(8, 12).equals(WEBP_WEBP);
16
+ /** Content sniff only — returns null when the bytes are none of the three. */
17
+ export const sniffImageFormat = (buffer) => {
18
+ if (isPng(buffer)) {
19
+ return 'png';
20
+ }
21
+ if (isJpeg(buffer)) {
22
+ return 'jpeg';
23
+ }
24
+ if (isWebp(buffer)) {
25
+ return 'webp';
26
+ }
27
+ return null;
28
+ };
29
+ const readPngDimensions = (buffer) => {
30
+ if (buffer.length < 24) {
31
+ throw new Error('PNG header is truncated');
32
+ }
33
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
34
+ };
35
+ /** Markers that carry no frame header, so their payload is skipped whole. */
36
+ 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
+ const readJpegDimensions = (buffer) => {
43
+ let offset = 2;
44
+ while (offset + 9 < buffer.length) {
45
+ if (buffer[offset] !== 0xff) {
46
+ offset++;
47
+ continue;
48
+ }
49
+ 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
+ if (marker === 0xff) {
53
+ offset++;
54
+ continue;
55
+ }
56
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
57
+ offset += 2;
58
+ continue;
59
+ }
60
+ const segmentLength = buffer.readUInt16BE(offset + 2);
61
+ if (marker >= 0xc0 && marker <= 0xcf && !JPEG_SOF_EXCLUDED.has(marker)) {
62
+ return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
63
+ }
64
+ if (segmentLength < 2) {
65
+ break;
66
+ }
67
+ offset += 2 + segmentLength;
68
+ }
69
+ throw new Error('JPEG has no start-of-frame marker');
70
+ };
71
+ const readWebpDimensions = (buffer) => {
72
+ if (buffer.length < 30) {
73
+ throw new Error('WebP header is truncated');
74
+ }
75
+ const chunk = buffer.subarray(12, 16).toString('ascii');
76
+ // VP8 lossy: dimensions at offset 26-29, masked to 14 bits.
77
+ if (chunk === 'VP8 ') {
78
+ return {
79
+ width: buffer.readUInt16LE(26) & 0x3fff,
80
+ height: buffer.readUInt16LE(28) & 0x3fff
81
+ };
82
+ }
83
+ // VP8L lossless: 14-bit width and height packed from offset 21.
84
+ if (chunk === 'VP8L') {
85
+ const bits = buffer.readUInt32LE(21);
86
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
87
+ }
88
+ // VP8X extended: 24-bit canvas dimensions at offset 24-29.
89
+ if (chunk === 'VP8X') {
90
+ return {
91
+ width: (buffer.readUIntLE(24, 3) & 0xffffff) + 1,
92
+ height: (buffer.readUIntLE(27, 3) & 0xffffff) + 1
93
+ };
94
+ }
95
+ throw new Error(`Unsupported WebP chunk type: ${chunk}`);
96
+ };
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
+ export const probeImage = (buffer, filePath) => {
103
+ const format = sniffImageFormat(buffer);
104
+ if (format === null) {
105
+ throw new Error(`${filePath} is not a PNG, JPEG or WebP image (the file is corrupt, ` +
106
+ `truncated, or a different format saved under an image extension)`);
107
+ }
108
+ try {
109
+ const dimensions = format === 'png'
110
+ ? readPngDimensions(buffer)
111
+ : format === 'jpeg'
112
+ ? readJpegDimensions(buffer)
113
+ : readWebpDimensions(buffer);
114
+ if (dimensions.width <= 0 || dimensions.height <= 0) {
115
+ throw new Error(`reported ${dimensions.width}x${dimensions.height}`);
116
+ }
117
+ return { format, ...dimensions };
118
+ }
119
+ catch (err) {
120
+ throw new Error(`${filePath} looks like ${format.toUpperCase()} but its dimensions could not be ` +
121
+ `read (${err.message}) — the file is probably truncated`);
122
+ }
123
+ };
package/dist/index.d.ts CHANGED
@@ -6,7 +6,10 @@ export type { CIEnvironment } from './ci.js';
6
6
  export { detectBaseBranchFromGit, 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
14
  export { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
15
+ export { isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
package/dist/index.js CHANGED
@@ -2,8 +2,10 @@ export { PixeyeClient, PixeyeError } from './client.js';
2
2
  export { parseYamlLike, readEnvConfig, readGlobalConfig, readProjectConfig, resolveConfig, writeGlobalConfig, writeProjectConfig } from './config.js';
3
3
  export { detectBaseBranchFromGit, detectCIEnvironment } from './ci.js';
4
4
  export { discoverScreenshots } from './discover.js';
5
+ export { probeImage, sniffImageFormat, swapImageExtension } from './imageProbe.js';
5
6
  // Parallel execution
6
7
  export { runParallel } from './parallel.js';
7
8
  export { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
8
9
  // Constants
9
10
  export { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
11
+ export { isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Classify an upload failure and render a loud banner — shared by the Playwright
3
+ * reporter and the CLI so a lapsed plan / bad token surfaces identically (P0-2).
4
+ *
5
+ * The rule: distinguish the CUSTOMER's misconfiguration (they must act) from a
6
+ * transient / our-side fault (their CI should not break over our hiccup).
7
+ */
8
+ /**
9
+ * True when the failure is the customer's configuration/billing, not ours.
10
+ * A `PixeyeError` from the R2 data-plane (`origin: 'r2'`) is EXCLUDED — a
11
+ * 403/404 on a presigned PUT (expired URL, bucket/creds, clock skew) is a
12
+ * Pixeye-side fault and must never fail the customer's CI.
13
+ */
14
+ export declare const isUploadMisconfig: (err: unknown) => boolean;
15
+ /**
16
+ * One-line `HTTP <status> — <detail>` summary. Prefers the error's `body`
17
+ * (the meaningful server code, e.g. `SUBSCRIPTION_CANCELLED`) over the full
18
+ * `PixeyeError.message`, which already embeds the status (avoids `HTTP 402 —
19
+ * Pixeye API … failed (402): …`).
20
+ */
21
+ export declare const uploadErrorSummary: (err: unknown) => string;
22
+ /**
23
+ * Resolve the fail-on-error setting identically for the reporter and CLI: fail
24
+ * by default; opt out via EITHER an explicit `false` option OR
25
+ * `PIXEYE_FAIL_ON_ERROR=false`. (The CLI's commander always supplies a boolean
26
+ * option; the reporter's is optional — this keeps both consistent.)
27
+ */
28
+ export declare const resolveFailOnError: (option: boolean | undefined, env: string | undefined) => boolean;
29
+ /**
30
+ * A bright, unmissable banner for a rejected upload. `willFail` toggles the
31
+ * closing line between "failing this step" and "opted out — passing anyway".
32
+ */
33
+ export declare const renderUploadFailureBanner: (err: unknown, opts: {
34
+ willFail: boolean;
35
+ }) => string;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Classify an upload failure and render a loud banner — shared by the Playwright
3
+ * reporter and the CLI so a lapsed plan / bad token surfaces identically (P0-2).
4
+ *
5
+ * The rule: distinguish the CUSTOMER's misconfiguration (they must act) from a
6
+ * transient / our-side fault (their CI should not break over our hiccup).
7
+ */
8
+ /**
9
+ * HTTP statuses that mean the CUSTOMER must act — a bad/absent token (401),
10
+ * a lapsed subscription or exceeded quota (402), insufficient permissions
11
+ * (403), or a wrong project/endpoint (404). These fail CI by default so a run
12
+ * can't pass green while zero screenshots reached Pixeye. EVERYTHING else
13
+ * (429 rate-limit, 5xx, network, timeouts, no status) is transient / our fault
14
+ * → warn, never break their CI.
15
+ */
16
+ const MISCONFIG_STATUSES = [401, 402, 403, 404];
17
+ const statusOf = (err) => {
18
+ const status = err?.status;
19
+ return typeof status === 'number' ? status : undefined;
20
+ };
21
+ /**
22
+ * True when the failure is the customer's configuration/billing, not ours.
23
+ * A `PixeyeError` from the R2 data-plane (`origin: 'r2'`) is EXCLUDED — a
24
+ * 403/404 on a presigned PUT (expired URL, bucket/creds, clock skew) is a
25
+ * Pixeye-side fault and must never fail the customer's CI.
26
+ */
27
+ export const isUploadMisconfig = (err) => {
28
+ if (err?.origin === 'r2') {
29
+ return false;
30
+ }
31
+ const status = statusOf(err);
32
+ return status !== undefined && MISCONFIG_STATUSES.includes(status);
33
+ };
34
+ /**
35
+ * One-line `HTTP <status> — <detail>` summary. Prefers the error's `body`
36
+ * (the meaningful server code, e.g. `SUBSCRIPTION_CANCELLED`) over the full
37
+ * `PixeyeError.message`, which already embeds the status (avoids `HTTP 402 —
38
+ * Pixeye API … failed (402): …`).
39
+ */
40
+ export const uploadErrorSummary = (err) => {
41
+ const status = statusOf(err);
42
+ const body = err?.body;
43
+ const detail = typeof body === 'string' && body.length > 0
44
+ ? body
45
+ : err instanceof Error
46
+ ? err.message
47
+ : String(err);
48
+ return status !== undefined ? `HTTP ${status} — ${detail}` : detail;
49
+ };
50
+ /**
51
+ * Resolve the fail-on-error setting identically for the reporter and CLI: fail
52
+ * by default; opt out via EITHER an explicit `false` option OR
53
+ * `PIXEYE_FAIL_ON_ERROR=false`. (The CLI's commander always supplies a boolean
54
+ * option; the reporter's is optional — this keeps both consistent.)
55
+ */
56
+ export const resolveFailOnError = (option, env) => option !== false && env !== 'false';
57
+ /**
58
+ * A bright, unmissable banner for a rejected upload. `willFail` toggles the
59
+ * closing line between "failing this step" and "opted out — passing anyway".
60
+ */
61
+ export const renderUploadFailureBanner = (err, opts) => {
62
+ const line = '━'.repeat(64);
63
+ const footer = opts.willFail
64
+ ? [
65
+ ' Failing this step so it cannot pass silently. Fix the above, or pass',
66
+ ' failOnError: false (reporter) / --no-fail-on-error (CLI) to opt out.'
67
+ ]
68
+ : [' failOnError is OFF — this step passes despite the lost coverage.'];
69
+ return [
70
+ '',
71
+ `\x1b[31m\x1b[1m${line}`,
72
+ ` ✗ PIXEYE UPLOAD REJECTED — ${uploadErrorSummary(err)}`,
73
+ '',
74
+ ' Your screenshots did NOT reach Pixeye. Visual coverage for this run is',
75
+ ' LOST. This is a configuration or billing problem on your account (API',
76
+ ' token, subscription/quota, or project permissions) — not a transient',
77
+ ' Pixeye outage.',
78
+ '',
79
+ ...footer,
80
+ `${line}\x1b[0m`,
81
+ ''
82
+ ].join('\n');
83
+ };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@pixeyedev/core",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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'",