@pixeyedev/core 0.4.5 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/ci.d.ts +9 -5
- package/dist/ci.js +40 -42
- package/dist/client.d.ts +14 -20
- package/dist/client.js +85 -41
- package/dist/config.d.ts +6 -1
- package/dist/config.js +20 -12
- package/dist/constants.d.ts +3 -2
- package/dist/constants.js +3 -2
- package/dist/discover.d.ts +3 -1
- package/dist/discover.js +72 -48
- package/dist/imageProbe.d.ts +9 -0
- package/dist/imageProbe.js +102 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +5 -6
- package/dist/parallel.d.ts +0 -3
- package/dist/parallel.js +0 -3
- package/dist/retry.d.ts +0 -6
- package/dist/retry.js +8 -10
- package/dist/uploadError.d.ts +3 -29
- package/dist/uploadError.js +27 -45
- package/package.json +4 -2
- package/dist/utils/parseShardInt.d.ts +0 -23
- package/dist/utils/parseShardInt.js +0 -41
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pixeye (pixeye.dev)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/ci.d.ts
CHANGED
|
@@ -5,10 +5,14 @@ export interface CIEnvironment {
|
|
|
5
5
|
baseBranch?: string;
|
|
6
6
|
prNumber?: number;
|
|
7
7
|
repository?: string;
|
|
8
|
+
trigger?: CITrigger;
|
|
9
|
+
}
|
|
10
|
+
export interface CITrigger {
|
|
11
|
+
runId?: string;
|
|
12
|
+
runAttempt?: string;
|
|
13
|
+
actor?: string;
|
|
14
|
+
workflow?: string;
|
|
15
|
+
eventName?: string;
|
|
16
|
+
runUrl?: string;
|
|
8
17
|
}
|
|
9
|
-
/**
|
|
10
|
-
* Try to detect the base branch using git merge-base.
|
|
11
|
-
* Checks common default branch names (main, master, develop).
|
|
12
|
-
*/
|
|
13
|
-
export declare const detectBaseBranchFromGit: () => string | undefined;
|
|
14
18
|
export declare const detectCIEnvironment: () => CIEnvironment | null;
|
package/dist/ci.js
CHANGED
|
@@ -1,33 +1,49 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
const readGitHubEvent = () => {
|
|
3
|
+
const path = process.env.GITHUB_EVENT_PATH;
|
|
4
|
+
if (!path) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
console.warn(`pixeye: could not read the GitHub event payload at ${path}: ${String(err)}`);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
2
15
|
const detectGitHubActions = () => {
|
|
3
16
|
if (!process.env.GITHUB_ACTIONS) {
|
|
4
17
|
return null;
|
|
5
18
|
}
|
|
6
|
-
const
|
|
7
|
-
const
|
|
19
|
+
const event = readGitHubEvent();
|
|
20
|
+
const eventPr = event?.pull_request ?? event?.workflow_run?.pull_requests?.[0];
|
|
21
|
+
const commit = event?.pull_request?.head?.sha ?? event?.workflow_run?.head_sha ?? process.env.GITHUB_SHA;
|
|
22
|
+
const branch = process.env.GITHUB_HEAD_REF || event?.workflow_run?.head_branch || process.env.GITHUB_REF_NAME;
|
|
8
23
|
const runId = process.env.GITHUB_RUN_ID;
|
|
9
24
|
if (!commit || !branch || !runId) {
|
|
10
25
|
return null;
|
|
11
26
|
}
|
|
12
|
-
// PR number: explicit env > GITHUB_REF on pull_request events (refs/pull/123/merge)
|
|
13
27
|
const prRaw = process.env.PIXEYE_PR;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (prMatch) {
|
|
19
|
-
prNumber = parseInt(prMatch[1], 10);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
// PR target branch: GITHUB_BASE_REF is set on pull_request events
|
|
23
|
-
const baseBranch = process.env.GITHUB_BASE_REF || undefined;
|
|
28
|
+
const prNumber = prRaw ? parseInt(prRaw, 10) : eventPr?.number;
|
|
29
|
+
const baseBranch = process.env.GITHUB_BASE_REF || eventPr?.base?.ref || undefined;
|
|
30
|
+
const repository = process.env.GITHUB_REPOSITORY;
|
|
31
|
+
const serverUrl = process.env.GITHUB_SERVER_URL;
|
|
24
32
|
return {
|
|
25
33
|
provider: 'github-actions',
|
|
26
34
|
commit,
|
|
27
35
|
branch,
|
|
28
36
|
baseBranch,
|
|
29
37
|
prNumber: prNumber && !isNaN(prNumber) ? prNumber : undefined,
|
|
30
|
-
repository
|
|
38
|
+
repository,
|
|
39
|
+
trigger: {
|
|
40
|
+
runId,
|
|
41
|
+
runAttempt: process.env.GITHUB_RUN_ATTEMPT || undefined,
|
|
42
|
+
actor: process.env.GITHUB_ACTOR || undefined,
|
|
43
|
+
workflow: process.env.GITHUB_WORKFLOW || undefined,
|
|
44
|
+
eventName: process.env.GITHUB_EVENT_NAME || undefined,
|
|
45
|
+
runUrl: serverUrl && repository ? `${serverUrl}/${repository}/actions/runs/${runId}` : undefined
|
|
46
|
+
}
|
|
31
47
|
};
|
|
32
48
|
};
|
|
33
49
|
const detectGitLabCI = () => {
|
|
@@ -47,32 +63,14 @@ const detectGitLabCI = () => {
|
|
|
47
63
|
branch,
|
|
48
64
|
baseBranch: process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || undefined,
|
|
49
65
|
prNumber: mrIid ? parseInt(mrIid, 10) : undefined,
|
|
50
|
-
repository: undefined
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export const detectBaseBranchFromGit = () => {
|
|
58
|
-
const candidates = ['main', 'master', 'develop'];
|
|
59
|
-
for (const candidate of candidates) {
|
|
60
|
-
try {
|
|
61
|
-
execFileSync('git', ['rev-parse', '--verify', `origin/${candidate}`], { stdio: 'ignore' });
|
|
62
|
-
execFileSync('git', ['merge-base', 'HEAD', `origin/${candidate}`], { stdio: 'ignore' });
|
|
63
|
-
return candidate;
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
// Branch doesn't exist or no common ancestor
|
|
66
|
+
repository: process.env.CI_PROJECT_PATH || undefined,
|
|
67
|
+
trigger: {
|
|
68
|
+
runId: pipelineId,
|
|
69
|
+
actor: process.env.GITLAB_USER_LOGIN || undefined,
|
|
70
|
+
workflow: process.env.CI_JOB_NAME || undefined,
|
|
71
|
+
eventName: process.env.CI_PIPELINE_SOURCE || undefined,
|
|
72
|
+
runUrl: process.env.CI_PIPELINE_URL || undefined
|
|
67
73
|
}
|
|
68
|
-
}
|
|
69
|
-
return undefined;
|
|
70
|
-
};
|
|
71
|
-
export const detectCIEnvironment = () => {
|
|
72
|
-
const ci = detectGitHubActions() ?? detectGitLabCI() ?? null;
|
|
73
|
-
// If CI detected but no baseBranch, try git merge-base
|
|
74
|
-
if (ci && !ci.baseBranch) {
|
|
75
|
-
ci.baseBranch = detectBaseBranchFromGit();
|
|
76
|
-
}
|
|
77
|
-
return ci;
|
|
74
|
+
};
|
|
78
75
|
};
|
|
76
|
+
export const detectCIEnvironment = () => detectGitHubActions() ?? detectGitLabCI() ?? null;
|
package/dist/client.d.ts
CHANGED
|
@@ -1,35 +1,29 @@
|
|
|
1
|
-
import type { CreateBuildResponse, ListBuildsResponse, PipelineFinalizeResponse, PresignUploadResponse, ScreenshotManifestEntry, SubmitShardResponse } from '@pixeyedev/shared';
|
|
1
|
+
import type { CreateBuildResponse, ListBuildsResponse, PipelineFinalizeResponse, PresignUploadResponse, ScreenshotFormat, ScreenshotManifestEntry, SubmitShardResponse } from '@pixeyedev/shared';
|
|
2
2
|
export declare class PixeyeError extends Error {
|
|
3
3
|
status: number;
|
|
4
4
|
path: string;
|
|
5
5
|
body: string;
|
|
6
|
-
/**
|
|
7
|
-
* Where the failure came from. `'api'` = the Pixeye control-plane (a 4xx
|
|
8
|
-
* here can be a customer misconfig — token/plan/permissions). `'r2'` = a
|
|
9
|
-
* data-plane PUT to R2 via a presigned URL (a 403/404 here is a Pixeye-side
|
|
10
|
-
* fault — expired URL, bucket/credential, clock skew — NEVER the customer's).
|
|
11
|
-
* Used by `isUploadMisconfig` so an R2 outage can't fail a customer's CI.
|
|
12
|
-
*/
|
|
13
6
|
origin: 'api' | 'r2';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* Where the failure came from. `'api'` = the Pixeye control-plane (a 4xx
|
|
17
|
-
* here can be a customer misconfig — token/plan/permissions). `'r2'` = a
|
|
18
|
-
* data-plane PUT to R2 via a presigned URL (a 403/404 here is a Pixeye-side
|
|
19
|
-
* fault — expired URL, bucket/credential, clock skew — NEVER the customer's).
|
|
20
|
-
* Used by `isUploadMisconfig` so an R2 outage can't fail a customer's CI.
|
|
21
|
-
*/
|
|
22
|
-
origin?: 'api' | 'r2');
|
|
7
|
+
retryAfterMs?: number | undefined;
|
|
8
|
+
constructor(status: number, path: string, body: string, origin?: 'api' | 'r2', retryAfterMs?: number | undefined);
|
|
23
9
|
}
|
|
24
10
|
export interface PixeyeClientOptions {
|
|
25
11
|
serverUrl: string;
|
|
26
12
|
token: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
attempts?: number;
|
|
27
15
|
}
|
|
28
16
|
export declare class PixeyeClient {
|
|
29
17
|
private serverUrl;
|
|
30
18
|
private token;
|
|
19
|
+
private timeoutMs;
|
|
20
|
+
private attempts;
|
|
21
|
+
private reporting?;
|
|
22
|
+
get observes(): boolean;
|
|
31
23
|
constructor(options: PixeyeClientOptions);
|
|
24
|
+
private send;
|
|
32
25
|
private request;
|
|
26
|
+
private retryOptions;
|
|
33
27
|
createBuild(opts: {
|
|
34
28
|
projectToken: string;
|
|
35
29
|
commitSha: string;
|
|
@@ -46,7 +40,7 @@ export declare class PixeyeClient {
|
|
|
46
40
|
presignUpload(opts: {
|
|
47
41
|
buildId: string;
|
|
48
42
|
hashes: string[];
|
|
49
|
-
formats?: Record<string,
|
|
43
|
+
formats?: Record<string, ScreenshotFormat>;
|
|
50
44
|
}): Promise<PresignUploadResponse>;
|
|
51
45
|
uploadFile(presignedUrl: string, buffer: Buffer, contentType: string): Promise<void>;
|
|
52
46
|
submitShard(opts: {
|
|
@@ -59,6 +53,6 @@ export declare class PixeyeClient {
|
|
|
59
53
|
branch: string;
|
|
60
54
|
}): Promise<ListBuildsResponse>;
|
|
61
55
|
finalizePipeline(pipelineId: string): Promise<PipelineFinalizeResponse>;
|
|
62
|
-
checkFileExists(hash: string): Promise<boolean>;
|
|
63
|
-
uploadLegacy(buffer: Buffer, hash: string, filename: string): Promise<void>;
|
|
56
|
+
checkFileExists(hash: string, format?: ScreenshotFormat): Promise<boolean>;
|
|
57
|
+
uploadLegacy(buffer: Buffer, hash: string, filename: string, format?: ScreenshotFormat): Promise<void>;
|
|
64
58
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,50 +1,75 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { CONTENT_TYPE_BY_FORMAT, CreateBuildResponseSchema, ListBuildsResponseSchema, PipelineFinalizeResponseSchema, PRESIGN_BATCH_MAX, PresignUploadResponseSchema, SubmitShardResponseSchema } from '@pixeyedev/shared';
|
|
3
|
+
import { readClientEnv } from './config.js';
|
|
4
|
+
import { DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_UPLOAD_ATTEMPTS, RETRY_AFTER_MAX_MS } from './constants.js';
|
|
2
5
|
import { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
|
|
3
6
|
export class PixeyeError extends Error {
|
|
4
7
|
status;
|
|
5
8
|
path;
|
|
6
9
|
body;
|
|
7
10
|
origin;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* Where the failure came from. `'api'` = the Pixeye control-plane (a 4xx
|
|
11
|
-
* here can be a customer misconfig — token/plan/permissions). `'r2'` = a
|
|
12
|
-
* data-plane PUT to R2 via a presigned URL (a 403/404 here is a Pixeye-side
|
|
13
|
-
* fault — expired URL, bucket/credential, clock skew — NEVER the customer's).
|
|
14
|
-
* Used by `isUploadMisconfig` so an R2 outage can't fail a customer's CI.
|
|
15
|
-
*/
|
|
16
|
-
origin = 'api') {
|
|
11
|
+
retryAfterMs;
|
|
12
|
+
constructor(status, path, body, origin = 'api', retryAfterMs) {
|
|
17
13
|
super(`Pixeye API ${path} failed (${status}): ${body}`);
|
|
18
14
|
this.status = status;
|
|
19
15
|
this.path = path;
|
|
20
16
|
this.body = body;
|
|
21
17
|
this.origin = origin;
|
|
18
|
+
this.retryAfterMs = retryAfterMs;
|
|
22
19
|
this.name = 'PixeyeError';
|
|
23
20
|
}
|
|
24
21
|
}
|
|
22
|
+
const parseRetryAfter = (response) => {
|
|
23
|
+
const seconds = Number(response.headers.get('retry-after'));
|
|
24
|
+
return Number.isFinite(seconds) && seconds > 0
|
|
25
|
+
? Math.min(seconds * 1000, RETRY_AFTER_MAX_MS)
|
|
26
|
+
: undefined;
|
|
27
|
+
};
|
|
28
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
29
|
+
const USER_AGENT = `@pixeyedev/core/${version} node/${process.versions.node}`;
|
|
25
30
|
export class PixeyeClient {
|
|
26
31
|
serverUrl;
|
|
27
32
|
token;
|
|
33
|
+
timeoutMs;
|
|
34
|
+
attempts;
|
|
35
|
+
reporting;
|
|
36
|
+
get observes() {
|
|
37
|
+
return this.reporting === 'observe';
|
|
38
|
+
}
|
|
28
39
|
constructor(options) {
|
|
40
|
+
const env = readClientEnv();
|
|
29
41
|
this.serverUrl = options.serverUrl.replace(/\/$/, '');
|
|
30
42
|
this.token = options.token;
|
|
43
|
+
this.timeoutMs = options.timeoutMs ?? env.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
|
|
44
|
+
this.attempts = options.attempts ?? env.attempts ?? DEFAULT_UPLOAD_ATTEMPTS;
|
|
31
45
|
}
|
|
32
|
-
async
|
|
33
|
-
const
|
|
34
|
-
const response = await fetch(url, {
|
|
46
|
+
async send(path, options = {}) {
|
|
47
|
+
const response = await fetch(`${this.serverUrl}${path}`, {
|
|
35
48
|
...options,
|
|
49
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
36
50
|
headers: {
|
|
37
51
|
'Content-Type': 'application/json',
|
|
38
52
|
Authorization: `Bearer ${this.token}`,
|
|
53
|
+
'User-Agent': USER_AGENT,
|
|
39
54
|
...options.headers
|
|
40
55
|
}
|
|
41
56
|
});
|
|
42
57
|
if (!response.ok) {
|
|
43
|
-
|
|
44
|
-
throw new PixeyeError(response.status, path, body);
|
|
58
|
+
throw new PixeyeError(response.status, path.split('?')[0], await response.text(), 'api', parseRetryAfter(response));
|
|
45
59
|
}
|
|
46
60
|
return response.json();
|
|
47
61
|
}
|
|
62
|
+
request(path, options = {}) {
|
|
63
|
+
return withRetry(() => this.send(path, options), isRetryableHttpError, this.retryOptions(path.split('?')[0]));
|
|
64
|
+
}
|
|
65
|
+
retryOptions(what) {
|
|
66
|
+
return {
|
|
67
|
+
maxAttempts: this.attempts,
|
|
68
|
+
onRetry: (attempt, err) => {
|
|
69
|
+
console.warn(`[pixeye] ${what} retry ${attempt}/${this.attempts}: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
48
73
|
async createBuild(opts) {
|
|
49
74
|
const body = { ...opts };
|
|
50
75
|
if (opts.runner) {
|
|
@@ -54,19 +79,39 @@ export class PixeyeClient {
|
|
|
54
79
|
method: 'POST',
|
|
55
80
|
body: JSON.stringify(body)
|
|
56
81
|
});
|
|
57
|
-
|
|
82
|
+
const parsed = CreateBuildResponseSchema.parse(raw);
|
|
83
|
+
this.reporting = parsed.reporting;
|
|
84
|
+
return parsed;
|
|
58
85
|
}
|
|
59
86
|
async presignUpload(opts) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
87
|
+
const merged = {
|
|
88
|
+
urls: {},
|
|
89
|
+
existing: [],
|
|
90
|
+
uploadCount: 0,
|
|
91
|
+
skippedCount: 0,
|
|
92
|
+
warnings: []
|
|
93
|
+
};
|
|
94
|
+
for (let start = 0; start < opts.hashes.length; start += PRESIGN_BATCH_MAX) {
|
|
95
|
+
const hashes = opts.hashes.slice(start, start + PRESIGN_BATCH_MAX);
|
|
96
|
+
const inChunk = new Set(hashes);
|
|
97
|
+
const formats = Object.fromEntries(Object.entries(opts.formats ?? {}).filter(([hash]) => inChunk.has(hash)));
|
|
98
|
+
const raw = await this.request('/api/v1/upload/presign', {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
token: this.token,
|
|
102
|
+
buildId: opts.buildId,
|
|
103
|
+
hashes,
|
|
104
|
+
...(Object.keys(formats).length > 0 ? { formats } : {})
|
|
105
|
+
})
|
|
106
|
+
});
|
|
107
|
+
const part = PresignUploadResponseSchema.parse(raw);
|
|
108
|
+
Object.assign(merged.urls, part.urls);
|
|
109
|
+
merged.existing.push(...part.existing);
|
|
110
|
+
merged.uploadCount += part.uploadCount;
|
|
111
|
+
merged.skippedCount += part.skippedCount;
|
|
112
|
+
merged.warnings.push(...part.warnings);
|
|
113
|
+
}
|
|
114
|
+
return merged;
|
|
70
115
|
}
|
|
71
116
|
async uploadFile(presignedUrl, buffer, contentType) {
|
|
72
117
|
const maskedUrl = maskPresignedUrl(presignedUrl);
|
|
@@ -74,23 +119,16 @@ export class PixeyeClient {
|
|
|
74
119
|
const res = await fetch(presignedUrl, {
|
|
75
120
|
method: 'PUT',
|
|
76
121
|
body: new Uint8Array(buffer),
|
|
122
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
77
123
|
headers: { 'Content-Type': contentType }
|
|
78
124
|
});
|
|
79
125
|
if (!res.ok) {
|
|
80
|
-
// 'r2' origin — a PUT failure here is Pixeye-side (expired presigned
|
|
81
|
-
// URL, bucket/creds, clock skew), never the customer's misconfig.
|
|
82
126
|
throw new PixeyeError(res.status, maskedUrl, await res.text(), 'r2');
|
|
83
127
|
}
|
|
84
|
-
}, isRetryableHttpError,
|
|
85
|
-
maxAttempts: 3,
|
|
86
|
-
baseDelayMs: 1000,
|
|
87
|
-
onRetry: (attempt, err) => {
|
|
88
|
-
console.warn(`[pixeye] Upload retry ${attempt}/3: ${err.message}`);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
128
|
+
}, isRetryableHttpError, this.retryOptions('Upload'));
|
|
91
129
|
}
|
|
92
130
|
async submitShard(opts) {
|
|
93
|
-
const raw = await this.
|
|
131
|
+
const raw = await this.send(`/api/v1/builds/${opts.buildId}/shards`, {
|
|
94
132
|
method: 'POST',
|
|
95
133
|
body: JSON.stringify({
|
|
96
134
|
shardIndex: opts.shardIndex,
|
|
@@ -114,18 +152,24 @@ export class PixeyeClient {
|
|
|
114
152
|
});
|
|
115
153
|
return PipelineFinalizeResponseSchema.parse(raw);
|
|
116
154
|
}
|
|
117
|
-
async checkFileExists(hash) {
|
|
118
|
-
const result = await this.request(`/api/v1/upload/${hash}?token=${encodeURIComponent(this.token)}`
|
|
155
|
+
async checkFileExists(hash, format = 'png') {
|
|
156
|
+
const result = await this.request(`/api/v1/upload/${encodeURIComponent(hash)}?token=${encodeURIComponent(this.token)}` +
|
|
157
|
+
`&format=${encodeURIComponent(format)}`);
|
|
119
158
|
return result.exists;
|
|
120
159
|
}
|
|
121
|
-
async uploadLegacy(buffer, hash, filename) {
|
|
122
|
-
const blob = new Blob([new Uint8Array(buffer)], { type:
|
|
160
|
+
async uploadLegacy(buffer, hash, filename, format = 'png') {
|
|
161
|
+
const blob = new Blob([new Uint8Array(buffer)], { type: CONTENT_TYPE_BY_FORMAT[format] });
|
|
123
162
|
const formData = new FormData();
|
|
124
163
|
formData.append('file', blob, filename);
|
|
125
164
|
formData.append('hash', hash);
|
|
165
|
+
formData.append('format', format);
|
|
126
166
|
formData.append('token', this.token);
|
|
127
167
|
const url = `${this.serverUrl}/api/v1/upload`;
|
|
128
|
-
const response = await fetch(url, {
|
|
168
|
+
const response = await fetch(url, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
body: formData,
|
|
171
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
172
|
+
});
|
|
129
173
|
if (!response.ok) {
|
|
130
174
|
const body = await response.text();
|
|
131
175
|
throw new PixeyeError(response.status, '/api/v1/upload', body);
|
package/dist/config.d.ts
CHANGED
|
@@ -30,8 +30,13 @@ export declare const readGlobalConfig: () => Promise<PixeyeConfig>;
|
|
|
30
30
|
export declare const writeGlobalConfig: (config: PixeyeConfig) => Promise<void>;
|
|
31
31
|
export declare const readProjectConfig: (cwd: string) => Promise<ProjectConfig | null>;
|
|
32
32
|
export declare const writeProjectConfig: (cwd: string, config: ProjectConfig) => Promise<void>;
|
|
33
|
+
export declare const integerSetting: (name: string, raw: string | number | undefined, min: number) => number | undefined;
|
|
34
|
+
export declare const readClientEnv: () => {
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
attempts?: number;
|
|
37
|
+
};
|
|
38
|
+
export declare const resolveUploadConcurrency: (option: number | undefined) => number;
|
|
33
39
|
export declare const readEnvConfig: () => EnvConfig;
|
|
34
|
-
/** @internal Exported for testing */
|
|
35
40
|
export declare const parseYamlLike: (content: string) => ProjectConfig;
|
|
36
41
|
export declare const resolveConfig: (overrides?: Partial<ResolvedConfig> & {
|
|
37
42
|
cwd?: string;
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,8 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { detectCIEnvironment } from './ci.js';
|
|
5
|
-
import { DEFAULT_SERVER_URL } from './constants.js';
|
|
5
|
+
import { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
|
|
6
|
+
import { CustomerError } from './uploadError.js';
|
|
6
7
|
const GLOBAL_CONFIG_DIR = join(homedir(), '.pixeye');
|
|
7
8
|
const GLOBAL_CONFIG_PATH = join(GLOBAL_CONFIG_DIR, 'config.json');
|
|
8
9
|
const PROJECT_CONFIG_NAME = '.pixeye.yml';
|
|
@@ -36,24 +37,33 @@ export const writeProjectConfig = async (cwd, config) => {
|
|
|
36
37
|
];
|
|
37
38
|
await writeFile(join(cwd, PROJECT_CONFIG_NAME), lines.join('\n') + '\n');
|
|
38
39
|
};
|
|
40
|
+
export const integerSetting = (name, raw, min) => {
|
|
41
|
+
if (raw === undefined || raw === '') {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const n = Number(raw);
|
|
45
|
+
if (!Number.isInteger(n) || n < min) {
|
|
46
|
+
throw new CustomerError(`${name} must be an integer of at least ${min}, got "${raw}".`);
|
|
47
|
+
}
|
|
48
|
+
return n;
|
|
49
|
+
};
|
|
50
|
+
export const readClientEnv = () => ({
|
|
51
|
+
timeoutMs: integerSetting('PIXEYE_HTTP_TIMEOUT_MS', process.env.PIXEYE_HTTP_TIMEOUT_MS, 1),
|
|
52
|
+
attempts: integerSetting('PIXEYE_UPLOAD_ATTEMPTS', process.env.PIXEYE_UPLOAD_ATTEMPTS, 1)
|
|
53
|
+
});
|
|
54
|
+
export const resolveUploadConcurrency = (option) => integerSetting('uploadConcurrency', option, 1) ??
|
|
55
|
+
integerSetting('PIXEYE_UPLOAD_CONCURRENCY', process.env.PIXEYE_UPLOAD_CONCURRENCY, 1) ??
|
|
56
|
+
DEFAULT_UPLOAD_CONCURRENCY;
|
|
39
57
|
export const readEnvConfig = () => {
|
|
40
|
-
const intOrUndef = (val) => {
|
|
41
|
-
if (val === undefined) {
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
const n = parseInt(val, 10);
|
|
45
|
-
return isNaN(n) ? undefined : n;
|
|
46
|
-
};
|
|
47
58
|
return {
|
|
48
59
|
server: process.env.PIXEYE_SERVER || undefined,
|
|
49
60
|
token: process.env.PIXEYE_TOKEN || undefined,
|
|
50
61
|
commit: process.env.PIXEYE_COMMIT || undefined,
|
|
51
62
|
branch: process.env.PIXEYE_BRANCH || undefined,
|
|
52
|
-
pr:
|
|
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;
|
package/dist/constants.d.ts
CHANGED
|
@@ -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;
|
package/dist/discover.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import type { ScreenshotFormat } from '@pixeyedev/shared';
|
|
1
2
|
export interface DiscoveredFile {
|
|
2
3
|
filePath: string;
|
|
3
4
|
name: string;
|
|
4
5
|
fileHash: string;
|
|
5
|
-
format:
|
|
6
|
+
format: ScreenshotFormat;
|
|
6
7
|
width: number;
|
|
7
8
|
height: number;
|
|
8
9
|
metadata?: Record<string, unknown>;
|
|
10
|
+
library?: string;
|
|
9
11
|
}
|
|
10
12
|
export declare const discoverScreenshots: (directory: string) => Promise<DiscoveredFile[]>;
|
package/dist/discover.js
CHANGED
|
@@ -1,70 +1,94 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
4
|
import { glob } from 'glob';
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import { probeImage, swapImageExtension } from './imageProbe.js';
|
|
6
|
+
const METADATA_SIDECAR = '.meta.json';
|
|
7
|
+
const SIDECAR_SUFFIX_RE = /\.[^.]+\.json$/;
|
|
8
|
+
const FAILED_NAME_SUFFIX = ' (failed)';
|
|
9
|
+
const sidecarsByOwner = (siblings) => {
|
|
10
|
+
const byOwner = new Map();
|
|
11
|
+
for (const name of siblings) {
|
|
12
|
+
if (SIDECAR_SUFFIX_RE.test(name)) {
|
|
13
|
+
const owner = name.replace(SIDECAR_SUFFIX_RE, '');
|
|
14
|
+
byOwner.set(owner, [...(byOwner.get(owner) ?? []), name]);
|
|
15
|
+
}
|
|
10
16
|
}
|
|
11
|
-
|
|
12
|
-
const height = buffer.readUInt32BE(20);
|
|
13
|
-
return { width, height };
|
|
17
|
+
return byOwner;
|
|
14
18
|
};
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
!buffer.subarray(8, 12).equals(WEBP_WEBP)) {
|
|
19
|
-
throw new Error('Invalid WebP file: missing RIFF/WEBP signature');
|
|
20
|
-
}
|
|
21
|
-
// VP8 lossy: chunk starts at offset 12, dimensions at offset 26-29
|
|
22
|
-
const chunk = buffer.subarray(12, 16).toString('ascii');
|
|
23
|
-
if (chunk === 'VP8 ') {
|
|
24
|
-
const width = buffer.readUInt16LE(26) & 0x3fff;
|
|
25
|
-
const height = buffer.readUInt16LE(28) & 0x3fff;
|
|
26
|
-
return { width, height };
|
|
19
|
+
const readSidecar = async (path) => {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
27
22
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const width = (bits & 0x3fff) + 1;
|
|
32
|
-
const height = ((bits >> 14) & 0x3fff) + 1;
|
|
33
|
-
return { width, height };
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.warn(`pixeye: ignoring unreadable sidecar ${path}: ${String(err)}`);
|
|
25
|
+
return undefined;
|
|
34
26
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
27
|
+
};
|
|
28
|
+
const testOf = (sidecar) => {
|
|
29
|
+
const test = sidecar.test;
|
|
30
|
+
if (!test || typeof test !== 'object') {
|
|
31
|
+
return undefined;
|
|
40
32
|
}
|
|
41
|
-
|
|
33
|
+
const { id, title, titlePath, location } = test;
|
|
34
|
+
const file = location && typeof location === 'object' ? location.file : undefined;
|
|
35
|
+
const fields = Object.fromEntries(Object.entries({ testId: id, testName: title, testFile: file }).filter(([, v]) => typeof v === 'string'));
|
|
36
|
+
const key = Array.isArray(titlePath) ? titlePath.join(' › ') : fields.testName;
|
|
37
|
+
return key ? { key, fields } : undefined;
|
|
42
38
|
};
|
|
43
|
-
const
|
|
44
|
-
|
|
39
|
+
const libraryOf = (sidecar) => {
|
|
40
|
+
const library = sidecar.automationLibrary;
|
|
41
|
+
const name = library && typeof library === 'object' ? library.name : undefined;
|
|
42
|
+
return typeof name === 'string' ? name : undefined;
|
|
45
43
|
};
|
|
46
44
|
export const discoverScreenshots = async (directory) => {
|
|
47
45
|
const files = await glob('**/*.{png,webp}', { cwd: directory, posix: true });
|
|
48
46
|
files.sort();
|
|
49
|
-
const
|
|
47
|
+
const sidecarsByDir = new Map();
|
|
48
|
+
const found = [];
|
|
49
|
+
const failedTests = new Set();
|
|
50
50
|
for (const relativePath of files) {
|
|
51
51
|
const filePath = `${directory}/${relativePath}`;
|
|
52
52
|
const fileBuffer = await readFile(filePath);
|
|
53
|
-
const format = relativePath.endsWith('.webp') ? 'webp' : 'png';
|
|
54
53
|
const fileHash = createHash('sha256').update(fileBuffer).digest('hex');
|
|
55
|
-
const { width, height } =
|
|
56
|
-
const name = relativePath
|
|
57
|
-
|
|
58
|
-
const
|
|
54
|
+
const { format, width, height } = probeImage(fileBuffer, relativePath);
|
|
55
|
+
const name = swapImageExtension(relativePath, '');
|
|
56
|
+
const dir = dirname(filePath);
|
|
57
|
+
const sidecars = sidecarsByDir.get(dir) ?? sidecarsByOwner(await readdir(dir));
|
|
58
|
+
sidecarsByDir.set(dir, sidecars);
|
|
59
|
+
const fileName = basename(filePath);
|
|
60
|
+
const own = [
|
|
61
|
+
...(sidecars.get(swapImageExtension(fileName, '')) ?? []),
|
|
62
|
+
...(sidecars.get(fileName) ?? [])
|
|
63
|
+
];
|
|
59
64
|
let metadata;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
let test;
|
|
66
|
+
let library;
|
|
67
|
+
for (const sidecarName of own) {
|
|
68
|
+
const sidecar = await readSidecar(join(dir, sidecarName));
|
|
69
|
+
if (!sidecar) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (sidecarName === swapImageExtension(fileName, METADATA_SIDECAR)) {
|
|
73
|
+
metadata = sidecar;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
test ??= testOf(sidecar);
|
|
77
|
+
}
|
|
78
|
+
library ??= libraryOf(sidecar);
|
|
63
79
|
}
|
|
64
|
-
|
|
65
|
-
|
|
80
|
+
if (test && metadata === undefined && name.endsWith(FAILED_NAME_SUFFIX)) {
|
|
81
|
+
failedTests.add(test.key);
|
|
66
82
|
}
|
|
67
|
-
|
|
83
|
+
found.push({ filePath, name, fileHash, format, width, height, metadata, test, library });
|
|
68
84
|
}
|
|
85
|
+
const results = found.map(({ test, metadata, ...file }) => ({
|
|
86
|
+
...file,
|
|
87
|
+
metadata: metadata ??
|
|
88
|
+
(test && {
|
|
89
|
+
...test.fields,
|
|
90
|
+
...(failedTests.has(test.key) ? { testStatus: 'failed' } : {})
|
|
91
|
+
})
|
|
92
|
+
}));
|
|
69
93
|
return results;
|
|
70
94
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ScreenshotFormat } from '@pixeyedev/shared';
|
|
2
|
+
export interface ProbedImage {
|
|
3
|
+
format: ScreenshotFormat;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const swapImageExtension: (filePath: string, replacement: string) => string;
|
|
8
|
+
export declare const sniffImageFormat: (buffer: Buffer) => ScreenshotFormat | null;
|
|
9
|
+
export declare const probeImage: (buffer: Buffer, filePath: string) => ProbedImage;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { CustomerError } from './uploadError.js';
|
|
2
|
+
const IMAGE_EXTENSION_RE = /\.(png|webp)$/;
|
|
3
|
+
export const swapImageExtension = (filePath, replacement) => filePath.replace(IMAGE_EXTENSION_RE, replacement);
|
|
4
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
5
|
+
const WEBP_RIFF = Buffer.from('RIFF');
|
|
6
|
+
const WEBP_WEBP = Buffer.from('WEBP');
|
|
7
|
+
const isPng = (buffer) => buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE);
|
|
8
|
+
const isJpeg = (buffer) => buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
|
9
|
+
const isWebp = (buffer) => buffer.length >= 12 &&
|
|
10
|
+
buffer.subarray(0, 4).equals(WEBP_RIFF) &&
|
|
11
|
+
buffer.subarray(8, 12).equals(WEBP_WEBP);
|
|
12
|
+
export const sniffImageFormat = (buffer) => {
|
|
13
|
+
if (isPng(buffer)) {
|
|
14
|
+
return 'png';
|
|
15
|
+
}
|
|
16
|
+
if (isJpeg(buffer)) {
|
|
17
|
+
return 'jpeg';
|
|
18
|
+
}
|
|
19
|
+
if (isWebp(buffer)) {
|
|
20
|
+
return 'webp';
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
const readPngDimensions = (buffer) => {
|
|
25
|
+
if (buffer.length < 24) {
|
|
26
|
+
throw new Error('PNG header is truncated');
|
|
27
|
+
}
|
|
28
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
29
|
+
};
|
|
30
|
+
const JPEG_SOF_EXCLUDED = new Set([0xc4, 0xc8, 0xcc]);
|
|
31
|
+
const readJpegDimensions = (buffer) => {
|
|
32
|
+
let offset = 2;
|
|
33
|
+
while (offset + 9 < buffer.length) {
|
|
34
|
+
if (buffer[offset] !== 0xff) {
|
|
35
|
+
offset++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const marker = buffer[offset + 1];
|
|
39
|
+
if (marker === 0xff) {
|
|
40
|
+
offset++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
44
|
+
offset += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const segmentLength = buffer.readUInt16BE(offset + 2);
|
|
48
|
+
if (marker >= 0xc0 && marker <= 0xcf && !JPEG_SOF_EXCLUDED.has(marker)) {
|
|
49
|
+
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
|
|
50
|
+
}
|
|
51
|
+
if (segmentLength < 2) {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
offset += 2 + segmentLength;
|
|
55
|
+
}
|
|
56
|
+
throw new Error('JPEG has no start-of-frame marker');
|
|
57
|
+
};
|
|
58
|
+
const readWebpDimensions = (buffer) => {
|
|
59
|
+
if (buffer.length < 30) {
|
|
60
|
+
throw new Error('WebP header is truncated');
|
|
61
|
+
}
|
|
62
|
+
const chunk = buffer.subarray(12, 16).toString('ascii');
|
|
63
|
+
if (chunk === 'VP8 ') {
|
|
64
|
+
return {
|
|
65
|
+
width: buffer.readUInt16LE(26) & 0x3fff,
|
|
66
|
+
height: buffer.readUInt16LE(28) & 0x3fff
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (chunk === 'VP8L') {
|
|
70
|
+
const bits = buffer.readUInt32LE(21);
|
|
71
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
|
|
72
|
+
}
|
|
73
|
+
if (chunk === 'VP8X') {
|
|
74
|
+
return {
|
|
75
|
+
width: (buffer.readUIntLE(24, 3) & 0xffffff) + 1,
|
|
76
|
+
height: (buffer.readUIntLE(27, 3) & 0xffffff) + 1
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`Unsupported WebP chunk type: ${chunk}`);
|
|
80
|
+
};
|
|
81
|
+
export const probeImage = (buffer, filePath) => {
|
|
82
|
+
const format = sniffImageFormat(buffer);
|
|
83
|
+
if (format === null) {
|
|
84
|
+
throw new CustomerError(`${filePath} is not a PNG, JPEG or WebP image (the file is corrupt, ` +
|
|
85
|
+
`truncated, or a different format saved under an image extension)`);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const dimensions = format === 'png'
|
|
89
|
+
? readPngDimensions(buffer)
|
|
90
|
+
: format === 'jpeg'
|
|
91
|
+
? readJpegDimensions(buffer)
|
|
92
|
+
: readWebpDimensions(buffer);
|
|
93
|
+
if (dimensions.width <= 0 || dimensions.height <= 0) {
|
|
94
|
+
throw new Error(`reported ${dimensions.width}x${dimensions.height}`);
|
|
95
|
+
}
|
|
96
|
+
return { format, ...dimensions };
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
throw new CustomerError(`${filePath} looks like ${format.toUpperCase()} but its dimensions could not be ` +
|
|
100
|
+
`read (${err.message}) — the file is probably truncated`);
|
|
101
|
+
}
|
|
102
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
export type { PixeyeClientOptions } from './client.js';
|
|
2
2
|
export { PixeyeClient, PixeyeError } from './client.js';
|
|
3
3
|
export type { EnvConfig, PixeyeConfig, ProjectConfig, ResolvedConfig } from './config.js';
|
|
4
|
-
export { parseYamlLike, readEnvConfig, readGlobalConfig, readProjectConfig, resolveConfig, writeGlobalConfig, writeProjectConfig } from './config.js';
|
|
4
|
+
export { integerSetting, parseYamlLike, readEnvConfig, readGlobalConfig, readProjectConfig, resolveConfig, resolveUploadConcurrency, writeGlobalConfig, writeProjectConfig } from './config.js';
|
|
5
5
|
export type { CIEnvironment } from './ci.js';
|
|
6
|
-
export {
|
|
6
|
+
export { detectCIEnvironment } from './ci.js';
|
|
7
7
|
export type { DiscoveredFile } from './discover.js';
|
|
8
8
|
export { discoverScreenshots } from './discover.js';
|
|
9
|
+
export type { ProbedImage } from './imageProbe.js';
|
|
10
|
+
export { probeImage, sniffImageFormat, swapImageExtension } from './imageProbe.js';
|
|
9
11
|
export { runParallel } from './parallel.js';
|
|
10
12
|
export type { RetryOptions } from './retry.js';
|
|
11
13
|
export { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
|
|
12
|
-
export { DEFAULT_SERVER_URL, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
|
|
13
|
-
export { isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
|
|
14
|
+
export { DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_SERVER_URL, DEFAULT_UPLOAD_ATTEMPTS, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
|
|
15
|
+
export { CustomerError, isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
export { PixeyeClient, PixeyeError } from './client.js';
|
|
2
|
-
export { parseYamlLike, readEnvConfig, readGlobalConfig, readProjectConfig, resolveConfig, writeGlobalConfig, writeProjectConfig } from './config.js';
|
|
3
|
-
export {
|
|
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
6
|
export { runParallel } from './parallel.js';
|
|
7
7
|
export { isRetryableHttpError, maskPresignedUrl, withRetry } from './retry.js';
|
|
8
|
-
|
|
9
|
-
export {
|
|
10
|
-
export { isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
|
|
8
|
+
export { DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_SERVER_URL, DEFAULT_UPLOAD_ATTEMPTS, DEFAULT_UPLOAD_CONCURRENCY } from './constants.js';
|
|
9
|
+
export { CustomerError, isUploadMisconfig, renderUploadFailureBanner, resolveFailOnError, uploadErrorSummary } from './uploadError.js';
|
package/dist/parallel.d.ts
CHANGED
package/dist/parallel.js
CHANGED
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
|
-
|
|
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]';
|
package/dist/uploadError.d.ts
CHANGED
|
@@ -1,35 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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;
|
package/dist/uploadError.js
CHANGED
|
@@ -1,42 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
: ['
|
|
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.
|
|
76
|
-
|
|
77
|
-
' Pixeye outage.',
|
|
58
|
+
' LOST.',
|
|
59
|
+
...cause,
|
|
78
60
|
'',
|
|
79
61
|
...footer,
|
|
80
62
|
`${line}\x1b[0m`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixeyedev/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
|
+
"license": "MIT",
|
|
4
5
|
"private": false,
|
|
5
6
|
"type": "module",
|
|
6
7
|
"files": [
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
},
|
|
17
18
|
"scripts": {
|
|
18
19
|
"build": "tsc",
|
|
20
|
+
"prepack": "rm -rf dist && npm run build",
|
|
19
21
|
"typecheck": "tsc --noEmit",
|
|
20
22
|
"test": "vitest run",
|
|
21
23
|
"lint": "echo 'ok'",
|
|
@@ -23,7 +25,7 @@
|
|
|
23
25
|
"publish:dry": "npm run build && npm publish --access public --dry-run"
|
|
24
26
|
},
|
|
25
27
|
"dependencies": {
|
|
26
|
-
"@pixeyedev/shared": "^0.4.
|
|
28
|
+
"@pixeyedev/shared": "^0.4.7",
|
|
27
29
|
"glob": "^11.0.0"
|
|
28
30
|
},
|
|
29
31
|
"devDependencies": {
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* F-095: parse shard-related env vars (PIXEYE_SHARD_INDEX,
|
|
3
|
-
* PIXEYE_SHARD_TOTAL, CYPRESS_SPLIT_INDEX, CURRENTS_PARALLEL_INDEX, …)
|
|
4
|
-
* with a NaN guard.
|
|
5
|
-
*
|
|
6
|
-
* Bare `parseInt('abc', 10)` returns NaN; `JSON.stringify({ index: NaN })`
|
|
7
|
-
* coerces to `null` and the API rejects with a confusing
|
|
8
|
-
* "Validation failed: index must be a number" error 30s deep into a CI
|
|
9
|
-
* pipeline. Failing fast at parse time with a clear message identifying
|
|
10
|
-
* the offending env var lets users fix `PIXEYE_SHARD_INDEX=` typos
|
|
11
|
-
* without running the test suite.
|
|
12
|
-
*
|
|
13
|
-
* The helper is intentionally permissive about the shape: callers pass
|
|
14
|
-
* the env var name so the error message is self-explanatory. Returns
|
|
15
|
-
* `undefined` when the value is empty/missing (so callers can default).
|
|
16
|
-
*/
|
|
17
|
-
export declare const parseShardInt: (value: string | undefined | null, name: string) => number;
|
|
18
|
-
/**
|
|
19
|
-
* Same as `parseShardInt` but returns `undefined` when the value is
|
|
20
|
-
* missing — useful when the env var is optional and the caller will
|
|
21
|
-
* fall back to a default. Still throws on a non-numeric explicit value.
|
|
22
|
-
*/
|
|
23
|
-
export declare const parseShardIntOptional: (value: string | undefined | null, name: string) => number | undefined;
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* F-095: parse shard-related env vars (PIXEYE_SHARD_INDEX,
|
|
3
|
-
* PIXEYE_SHARD_TOTAL, CYPRESS_SPLIT_INDEX, CURRENTS_PARALLEL_INDEX, …)
|
|
4
|
-
* with a NaN guard.
|
|
5
|
-
*
|
|
6
|
-
* Bare `parseInt('abc', 10)` returns NaN; `JSON.stringify({ index: NaN })`
|
|
7
|
-
* coerces to `null` and the API rejects with a confusing
|
|
8
|
-
* "Validation failed: index must be a number" error 30s deep into a CI
|
|
9
|
-
* pipeline. Failing fast at parse time with a clear message identifying
|
|
10
|
-
* the offending env var lets users fix `PIXEYE_SHARD_INDEX=` typos
|
|
11
|
-
* without running the test suite.
|
|
12
|
-
*
|
|
13
|
-
* The helper is intentionally permissive about the shape: callers pass
|
|
14
|
-
* the env var name so the error message is self-explanatory. Returns
|
|
15
|
-
* `undefined` when the value is empty/missing (so callers can default).
|
|
16
|
-
*/
|
|
17
|
-
export const parseShardInt = (value, name) => {
|
|
18
|
-
if (value === undefined || value === null || value === '') {
|
|
19
|
-
throw new Error(`${name} must be set`);
|
|
20
|
-
}
|
|
21
|
-
const parsed = parseInt(value, 10);
|
|
22
|
-
if (Number.isNaN(parsed)) {
|
|
23
|
-
throw new Error(`${name} must be an integer (got ${JSON.stringify(value)})`);
|
|
24
|
-
}
|
|
25
|
-
return parsed;
|
|
26
|
-
};
|
|
27
|
-
/**
|
|
28
|
-
* Same as `parseShardInt` but returns `undefined` when the value is
|
|
29
|
-
* missing — useful when the env var is optional and the caller will
|
|
30
|
-
* fall back to a default. Still throws on a non-numeric explicit value.
|
|
31
|
-
*/
|
|
32
|
-
export const parseShardIntOptional = (value, name) => {
|
|
33
|
-
if (value === undefined || value === null || value === '') {
|
|
34
|
-
return undefined;
|
|
35
|
-
}
|
|
36
|
-
const parsed = parseInt(value, 10);
|
|
37
|
-
if (Number.isNaN(parsed)) {
|
|
38
|
-
throw new Error(`${name} must be an integer (got ${JSON.stringify(value)})`);
|
|
39
|
-
}
|
|
40
|
-
return parsed;
|
|
41
|
-
};
|