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