@pixeyedev/core 0.4.5 → 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,4 +1,4 @@
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;
@@ -46,7 +46,7 @@ export declare class PixeyeClient {
46
46
  presignUpload(opts: {
47
47
  buildId: string;
48
48
  hashes: string[];
49
- formats?: Record<string, 'png' | 'webp'>;
49
+ formats?: Record<string, ScreenshotFormat>;
50
50
  }): Promise<PresignUploadResponse>;
51
51
  uploadFile(presignedUrl: string, buffer: Buffer, contentType: string): Promise<void>;
52
52
  submitShard(opts: {
@@ -59,6 +59,12 @@ export declare class PixeyeClient {
59
59
  branch: string;
60
60
  }): Promise<ListBuildsResponse>;
61
61
  finalizePipeline(pipelineId: string): Promise<PipelineFinalizeResponse>;
62
- checkFileExists(hash: string): Promise<boolean>;
63
- 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>;
64
70
  }
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
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;
@@ -22,6 +23,10 @@ export class PixeyeError extends Error {
22
23
  this.name = 'PixeyeError';
23
24
  }
24
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}`;
25
30
  export class PixeyeClient {
26
31
  serverUrl;
27
32
  token;
@@ -36,6 +41,7 @@ export class PixeyeClient {
36
41
  headers: {
37
42
  'Content-Type': 'application/json',
38
43
  Authorization: `Bearer ${this.token}`,
44
+ 'User-Agent': USER_AGENT,
39
45
  ...options.headers
40
46
  }
41
47
  });
@@ -114,15 +120,23 @@ export class PixeyeClient {
114
120
  });
115
121
  return PipelineFinalizeResponseSchema.parse(raw);
116
122
  }
117
- async checkFileExists(hash) {
118
- 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)}`);
119
129
  return result.exists;
120
130
  }
121
- async uploadLegacy(buffer, hash, filename) {
122
- 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] });
123
136
  const formData = new FormData();
124
137
  formData.append('file', blob, filename);
125
138
  formData.append('hash', hash);
139
+ formData.append('format', format);
126
140
  formData.append('token', this.token);
127
141
  const url = `${this.serverUrl}/api/v1/upload`;
128
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,6 +6,8 @@ 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';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ 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';
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@pixeyedev/core",
3
- "version": "0.4.5",
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'",
@@ -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
- };