@qoretechnologies/qlip 0.1.3-pr.30.gffaaa15 → 0.1.3-pr.32.g3ecd7a4
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/README.md +13 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/test-runner/cli/upload.d.ts.map +1 -1
- package/dist/test-runner/cli/upload.js +17 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/upload/autodetect.d.ts +23 -0
- package/dist/upload/autodetect.d.ts.map +1 -1
- package/dist/upload/autodetect.js +19 -0
- package/dist/upload/finalize.d.ts.map +1 -1
- package/dist/upload/finalize.js +12 -1
- package/dist/upload/processing-request.d.ts +15 -0
- package/dist/upload/processing-request.d.ts.map +1 -0
- package/dist/upload/processing-request.js +89 -0
- package/dist/upload/upload.d.ts +24 -2
- package/dist/upload/upload.d.ts.map +1 -1
- package/dist/upload/upload.js +100 -10
- package/dist-cjs/test-runner/cli/upload.js +17 -0
- package/dist-cjs/upload/autodetect.js +21 -1
- package/dist-cjs/upload/finalize.js +11 -0
- package/dist-cjs/upload/processing-request.js +96 -0
- package/dist-cjs/upload/upload.js +102 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -165,6 +165,15 @@ qlip prints a one-time warning when it detects a shallow clone. It
|
|
|
165
165
|
never fails the upload over this — accuracy degrades, the run still
|
|
166
166
|
succeeds.
|
|
167
167
|
|
|
168
|
+
### Who triggered the build
|
|
169
|
+
|
|
170
|
+
On GitHub Actions qlip also sends the user who triggered the run
|
|
171
|
+
(`$GITHUB_ACTOR`, with `$GITHUB_ACTOR_ID` for a rename-proof avatar), so
|
|
172
|
+
the dashboard shows who pushed each build. Nothing to configure; set
|
|
173
|
+
`upload.triggeredByLogin` (and optionally `upload.triggeredById`) to
|
|
174
|
+
override it, for example from a CI system that isn't GitHub Actions.
|
|
175
|
+
Outside CI the field is simply omitted.
|
|
176
|
+
|
|
168
177
|
## Manual screenshots inside play
|
|
169
178
|
|
|
170
179
|
Use the existing **"Logged In"** story as a real-world example:
|
|
@@ -308,6 +317,10 @@ Several `vitest run` invocations (or `test-storybook` shards) can capture into o
|
|
|
308
317
|
|
|
309
318
|
Add `--fail-on-partial-build` to `qlip-upload` (or `upload.failOnPartialBuild` on the plugin) to turn "screenshots on disk that never reached the manifest" from a warning into a red build.
|
|
310
319
|
|
|
320
|
+
### Large builds: processing timeout
|
|
321
|
+
|
|
322
|
+
The server diffs the whole build before it answers the final upload request, which takes minutes for a large Storybook (about 8–12 minutes for ~2,200 snapshots). `qlip-upload` waits up to 30 minutes for that answer; raise it with `--processing-timeout <seconds>` (or `$QLIP_PROCESSING_TIMEOUT`, or `upload.processingTimeoutMs` on the plugin). A timeout or network failure is reported with the failing phase and its cause; add `--fail-on-upload-error` (or `upload.failOnUploadError`) to make it fail the CI step instead of logging it.
|
|
323
|
+
|
|
311
324
|
## Manifest
|
|
312
325
|
|
|
313
326
|
Each run writes `manifest.json` inside the build folder with tool metadata, defaults, stats, and per-screenshot entries (auto + manual).
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { qlipVitestPlugin } from './plugin/vitestPlugin.js';
|
|
2
2
|
export { screenshot } from './runtime/screenshot.js';
|
|
3
|
-
export { uploadBuild, QlipUploadError, type QlipUploadInput, type QlipUploadResult, } from './upload/upload.js';
|
|
3
|
+
export { uploadBuild, QlipUploadError, QlipUploadNetworkError, DEFAULT_PROCESSING_TIMEOUT_MS, type QlipUploadInput, type QlipUploadResult, } from './upload/upload.js';
|
|
4
4
|
export { autodetectBranch, autodetectCommit, autodetectBaseBranch, autodetectAncestorCommits, } from './upload/autodetect.js';
|
|
5
5
|
export type { QlipConsoleLevel, QlipConsoleMessage, QlipManifest, QlipManifestEntry, QlipParameters, QlipPluginOptions, QlipResolvedDefaults, QlipScreenshotOptions, QlipUploadOptions, QlipViewport, } from './types.js';
|
|
6
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EACL,WAAW,EACX,eAAe,EACf,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,gBAAgB,EAChB,kBAAkB,EAClB,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EACjB,YAAY,GACb,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EACL,WAAW,EACX,eAAe,EACf,sBAAsB,EACtB,6BAA6B,EAC7B,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,EACpB,yBAAyB,GAC1B,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,gBAAgB,EAChB,kBAAkB,EAClB,YAAY,EACZ,iBAAiB,EACjB,cAAc,EACd,iBAAiB,EACjB,oBAAoB,EACpB,qBAAqB,EACrB,iBAAiB,EACjB,YAAY,GACb,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { qlipVitestPlugin } from './plugin/vitestPlugin.js';
|
|
2
2
|
export { screenshot } from './runtime/screenshot.js';
|
|
3
|
-
export { uploadBuild, QlipUploadError, } from './upload/upload.js';
|
|
3
|
+
export { uploadBuild, QlipUploadError, QlipUploadNetworkError, DEFAULT_PROCESSING_TIMEOUT_MS, } from './upload/upload.js';
|
|
4
4
|
export { autodetectBranch, autodetectCommit, autodetectBaseBranch, autodetectAncestorCommits, } from './upload/autodetect.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/test-runner/cli/upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;
|
|
1
|
+
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../../src/test-runner/cli/upload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AA2IH,MAAM,WAAW,gBAAgB;IAC/B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,eAAO,MAAM,SAAS,GACpB,MAAM,MAAM,EAAE,EACd,MAAK,MAAM,CAAC,UAAwB,EACpC,SAAQ;IAAE,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAY,KAC7E,OAAO,CAAC,gBAAgB,CAqF1B,CAAC"}
|
|
@@ -52,6 +52,9 @@ Options:
|
|
|
52
52
|
--token <token> Bearer token. Default: \$QLIP_UPLOAD_TOKEN.
|
|
53
53
|
--branch <branch> Override branch detection.
|
|
54
54
|
--commit <sha> Override commit detection.
|
|
55
|
+
--processing-timeout <s> Seconds to wait for the server to process
|
|
56
|
+
the build. Default:
|
|
57
|
+
\$QLIP_PROCESSING_TIMEOUT or 1800.
|
|
55
58
|
--fail-on-upload-error Exit non-zero on upload failure (default:
|
|
56
59
|
log error + exit 0).
|
|
57
60
|
--fail-on-partial-build Exit non-zero when screenshots on disk are
|
|
@@ -95,6 +98,9 @@ const parseArgs = (argv) => {
|
|
|
95
98
|
case '--commit':
|
|
96
99
|
args.commit = next();
|
|
97
100
|
break;
|
|
101
|
+
case '--processing-timeout':
|
|
102
|
+
args.processingTimeout = next();
|
|
103
|
+
break;
|
|
98
104
|
case '--fail-on-upload-error':
|
|
99
105
|
args.failOnUploadError = true;
|
|
100
106
|
break;
|
|
@@ -172,6 +178,16 @@ export const runUpload = async (argv, env = process.env, logger = console) => {
|
|
|
172
178
|
logger.error(`qlip-upload: ${err.message}`);
|
|
173
179
|
return { exitCode: 1 };
|
|
174
180
|
}
|
|
181
|
+
const processingTimeout = parsed.processingTimeout ?? env['QLIP_PROCESSING_TIMEOUT'];
|
|
182
|
+
let processingTimeoutMs;
|
|
183
|
+
if (processingTimeout !== undefined && processingTimeout !== '') {
|
|
184
|
+
const seconds = Number(processingTimeout);
|
|
185
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
186
|
+
logger.error(`qlip-upload: --processing-timeout must be a positive number of seconds, got "${processingTimeout}"`);
|
|
187
|
+
return { exitCode: 1 };
|
|
188
|
+
}
|
|
189
|
+
processingTimeoutMs = seconds * 1000;
|
|
190
|
+
}
|
|
175
191
|
const project = parsed.project ?? env['QLIP_PROJECT'] ?? 'default';
|
|
176
192
|
const token = parsed.token ?? env['QLIP_UPLOAD_TOKEN'];
|
|
177
193
|
const upload = {
|
|
@@ -180,6 +196,7 @@ export const runUpload = async (argv, env = process.env, logger = console) => {
|
|
|
180
196
|
...(token !== undefined && token !== '' ? { uploadToken: token } : {}),
|
|
181
197
|
...(parsed.branch !== undefined ? { branch: parsed.branch } : {}),
|
|
182
198
|
...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
|
|
199
|
+
...(processingTimeoutMs !== undefined ? { processingTimeoutMs } : {}),
|
|
183
200
|
failOnUploadError: parsed.failOnUploadError,
|
|
184
201
|
failOnPartialBuild: parsed.failOnPartialBuild,
|
|
185
202
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -149,6 +149,19 @@ export interface QlipUploadOptions {
|
|
|
149
149
|
* "View PR" link. See `design/UPLOAD.md`.
|
|
150
150
|
*/
|
|
151
151
|
pullRequestUrl?: string;
|
|
152
|
+
/**
|
|
153
|
+
* GitHub login of the user who triggered the build — shown as the
|
|
154
|
+
* build's author in the dashboard. Auto-detected from `$GITHUB_ACTOR`
|
|
155
|
+
* when omitted. Undefined (not sent) outside GitHub Actions; a
|
|
156
|
+
* dashboard upload is attributed from the signed-in session instead.
|
|
157
|
+
*/
|
|
158
|
+
triggeredByLogin?: string;
|
|
159
|
+
/**
|
|
160
|
+
* GitHub account id of that user (`$GITHUB_ACTOR_ID`). Stable across
|
|
161
|
+
* a login rename, so the server prefers it for the avatar. Only sent
|
|
162
|
+
* alongside `triggeredByLogin`.
|
|
163
|
+
*/
|
|
164
|
+
triggeredById?: number;
|
|
152
165
|
/** Skip upload entirely (handy for local-only runs). */
|
|
153
166
|
disabled?: boolean;
|
|
154
167
|
/**
|
|
@@ -157,6 +170,12 @@ export interface QlipUploadOptions {
|
|
|
157
170
|
* Set to `true` in CI when you want red builds on upload errors.
|
|
158
171
|
*/
|
|
159
172
|
failOnUploadError?: boolean;
|
|
173
|
+
/**
|
|
174
|
+
* How long to wait for the server to answer the requests it answers only
|
|
175
|
+
* after processing the build (finalize, or the legacy upload), in ms.
|
|
176
|
+
* Default: 30 minutes. See `design/UPLOAD.md` "Long-running requests".
|
|
177
|
+
*/
|
|
178
|
+
processingTimeoutMs?: number;
|
|
160
179
|
/**
|
|
161
180
|
* Whether a partial build should fail the run. A build is partial
|
|
162
181
|
* when PNGs exist on disk that no manifest entry references — i.e.
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;OAIG;IACH,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB;;;;;;;;;;;;;;;;;OAiBG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oBAAoB,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC1C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,yBAAyB,CAAC,EAAE,MAAM,EAAE,CAAC;CACtC;AAED,MAAM,WAAW,cAAe,SAAQ,kBAAkB;IACxD,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;OAUG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAkB,SAAQ,kBAAkB;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC,kBAAkB,CAAC,GAAG;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,2CAA2C;IAC3C,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,qBAAsB,SAAQ,kBAAkB;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE;QACX,IAAI,CAAC,EAAE,cAAc,CAAC;QACtB;;;;;;;WAOG;QACH,IAAI,CAAC,EAAE;YAAE,WAAW,CAAC,EAAE;gBAAE,KAAK,CAAC,EAAE,MAAM,CAAA;aAAE,CAAA;SAAE,CAAC;KAC7C,CAAC;CACH;AAED,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,MAAM,CAAC;AAEhD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,gBAAgB,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC;AACxD,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEhE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,aAAa,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,YAAY,CAAC;IACvB,MAAM,EAAE,eAAe,CAAC;IACxB,KAAK,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAClD,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,KAAK,EAAE;QACL,YAAY,EAAE,MAAM,CAAC;QACrB,YAAY,EAAE,MAAM,CAAC;QACrB,cAAc,EAAE,MAAM,CAAC;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;IACF,OAAO,EAAE,iBAAiB,EAAE,CAAC;CAC9B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,aAAa,CAAC;CACrB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,oBAAqB,SAAQ,YAAY;IACxD,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,qBAAqB,EAAE,CAAC;CACtC"}
|
|
@@ -38,6 +38,29 @@ export declare const autodetectBaseBranch: () => string | undefined;
|
|
|
38
38
|
* `qlip-server/design/UPLOAD.md`.
|
|
39
39
|
*/
|
|
40
40
|
export declare const autodetectPullRequestUrl: () => string | undefined;
|
|
41
|
+
/** The GitHub user whose action produced the build. */
|
|
42
|
+
export interface IUploadTriggeredBy {
|
|
43
|
+
/** GitHub login (`$GITHUB_ACTOR`). */
|
|
44
|
+
login: string;
|
|
45
|
+
/**
|
|
46
|
+
* GitHub account id (`$GITHUB_ACTOR_ID`). Stable across a rename, so
|
|
47
|
+
* the server prefers it for the avatar; omitted on an older runner
|
|
48
|
+
* that doesn't set it.
|
|
49
|
+
*/
|
|
50
|
+
id?: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Who triggered the CI run — the user the dashboard shows as the
|
|
54
|
+
* build's author. GitHub Actions sets `$GITHUB_ACTOR` (the login) and
|
|
55
|
+
* `$GITHUB_ACTOR_ID` (the numeric account id) on every run.
|
|
56
|
+
*
|
|
57
|
+
* Env-only by design: outside CI there is no GitHub identity to read
|
|
58
|
+
* (a git author is an email, not a login), and a dashboard upload is
|
|
59
|
+
* attributed server-side from the signed-in session. Returns undefined
|
|
60
|
+
* for local runs; the server stores it as nullable, so absence just
|
|
61
|
+
* means "author unknown". See `qlip-server/design/UPLOAD.md`.
|
|
62
|
+
*/
|
|
63
|
+
export declare const autodetectTriggeredBy: () => IUploadTriggeredBy | undefined;
|
|
41
64
|
/**
|
|
42
65
|
* Commit SHAs from HEAD backward, nearest-first, capped at
|
|
43
66
|
* MAX_ANCESTOR_COMMITS. The server walks this list to find the nearest
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"autodetect.d.ts","sourceRoot":"","sources":["../../src/upload/autodetect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAqBH,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAW5C,CAAC;AAkCF,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAS5C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,QAAO,MAAM,GAAG,SAGhD,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAAO,MAAM,GAAG,SAcpD,CAAC;AA0BF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,QAAO,MAAM,EAAE,GAAG,SAgBvD,CAAC"}
|
|
1
|
+
{"version":3,"file":"autodetect.d.ts","sourceRoot":"","sources":["../../src/upload/autodetect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAqBH,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAW5C,CAAC;AAkCF,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAS5C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,QAAO,MAAM,GAAG,SAGhD,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAAO,MAAM,GAAG,SAcpD,CAAC;AAEF,uDAAuD;AACvD,MAAM,WAAW,kBAAkB;IACjC,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,QAAO,kBAAkB,GAAG,SAM7D,CAAC;AA0BF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,QAAO,MAAM,EAAE,GAAG,SAgBvD,CAAC"}
|
|
@@ -127,6 +127,25 @@ export const autodetectPullRequestUrl = () => {
|
|
|
127
127
|
const base = serverUrl.replace(/\/+$/, '');
|
|
128
128
|
return `${base}/${repo}/pull/${prNumber}`;
|
|
129
129
|
};
|
|
130
|
+
/**
|
|
131
|
+
* Who triggered the CI run — the user the dashboard shows as the
|
|
132
|
+
* build's author. GitHub Actions sets `$GITHUB_ACTOR` (the login) and
|
|
133
|
+
* `$GITHUB_ACTOR_ID` (the numeric account id) on every run.
|
|
134
|
+
*
|
|
135
|
+
* Env-only by design: outside CI there is no GitHub identity to read
|
|
136
|
+
* (a git author is an email, not a login), and a dashboard upload is
|
|
137
|
+
* attributed server-side from the signed-in session. Returns undefined
|
|
138
|
+
* for local runs; the server stores it as nullable, so absence just
|
|
139
|
+
* means "author unknown". See `qlip-server/design/UPLOAD.md`.
|
|
140
|
+
*/
|
|
141
|
+
export const autodetectTriggeredBy = () => {
|
|
142
|
+
const login = process.env['GITHUB_ACTOR']?.trim();
|
|
143
|
+
if (!login || login.length === 0)
|
|
144
|
+
return undefined;
|
|
145
|
+
const rawId = process.env['GITHUB_ACTOR_ID']?.trim();
|
|
146
|
+
const id = rawId && /^\d+$/.test(rawId) ? Number(rawId) : undefined;
|
|
147
|
+
return id !== undefined ? { login, id } : { login };
|
|
148
|
+
};
|
|
130
149
|
let shallowWarningEmitted = false;
|
|
131
150
|
/**
|
|
132
151
|
* A shallow clone (CI default for `actions/checkout`) only has the tip
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"finalize.d.ts","sourceRoot":"","sources":["../../src/upload/finalize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"finalize.d.ts","sourceRoot":"","sources":["../../src/upload/finalize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAqBxE;;;;GAIG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AA6BD,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B;AAED,eAAO,MAAM,aAAa,GACxB,MAAM,qBAAqB,KAC1B,OAAO,CAAC,IAAI,CA2Hd,CAAC"}
|
package/dist/upload/finalize.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* dedupes — if both paths fire in the same run, only the first
|
|
18
18
|
* actually does the work.
|
|
19
19
|
*/
|
|
20
|
-
import { autodetectAncestorCommits, autodetectBaseBranch, autodetectBranch, autodetectCommit, autodetectPullRequestUrl, } from './autodetect.js';
|
|
20
|
+
import { autodetectAncestorCommits, autodetectBaseBranch, autodetectBranch, autodetectCommit, autodetectPullRequestUrl, autodetectTriggeredBy, } from './autodetect.js';
|
|
21
21
|
import { buildCaptureReport, describeBaselineCollisions, describeCollisions, describeMissingCaptures, describePartialBuild, writeCaptureReport, } from './capture-report.js';
|
|
22
22
|
import { mergeManifestFragments } from './manifest.js';
|
|
23
23
|
import { QlipUploadError, uploadBuild, resolveServerUrl } from './upload.js';
|
|
@@ -118,6 +118,15 @@ export const finalizeBuild = async (opts) => {
|
|
|
118
118
|
const baseBranch = opts.upload.baseBranch ?? autodetectBaseBranch();
|
|
119
119
|
const ancestorCommits = opts.upload.ancestorCommits ?? autodetectAncestorCommits();
|
|
120
120
|
const pullRequestUrl = opts.upload.pullRequestUrl ?? autodetectPullRequestUrl();
|
|
121
|
+
// An explicit login wins outright (with whatever id came with it);
|
|
122
|
+
// otherwise both come from the CI env together.
|
|
123
|
+
const detectedTriggeredBy = opts.upload.triggeredByLogin === undefined
|
|
124
|
+
? autodetectTriggeredBy()
|
|
125
|
+
: undefined;
|
|
126
|
+
const triggeredByLogin = opts.upload.triggeredByLogin ?? detectedTriggeredBy?.login;
|
|
127
|
+
const triggeredById = opts.upload.triggeredByLogin !== undefined
|
|
128
|
+
? opts.upload.triggeredById
|
|
129
|
+
: detectedTriggeredBy?.id;
|
|
121
130
|
const resolved = {
|
|
122
131
|
...opts.upload,
|
|
123
132
|
...(branch !== undefined ? { branch } : {}),
|
|
@@ -125,6 +134,8 @@ export const finalizeBuild = async (opts) => {
|
|
|
125
134
|
...(baseBranch !== undefined ? { baseBranch } : {}),
|
|
126
135
|
...(ancestorCommits !== undefined ? { ancestorCommits } : {}),
|
|
127
136
|
...(pullRequestUrl !== undefined ? { pullRequestUrl } : {}),
|
|
137
|
+
...(triggeredByLogin !== undefined ? { triggeredByLogin } : {}),
|
|
138
|
+
...(triggeredById !== undefined ? { triggeredById } : {}),
|
|
128
139
|
};
|
|
129
140
|
try {
|
|
130
141
|
const result = await uploadBuild({
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport for the upload requests qlip-server answers only once it
|
|
3
|
+
* has processed the whole build: `finalize` and the legacy multipart POST.
|
|
4
|
+
*
|
|
5
|
+
* Node's global `fetch` (undici) fails any request whose response headers
|
|
6
|
+
* take longer than 300 s (`headersTimeout`) with a bare "fetch failed", and
|
|
7
|
+
* the limit can't be raised without depending on the `undici` package. A
|
|
8
|
+
* large build's diff runs longer than that, so these requests go through
|
|
9
|
+
* `node:http`, which imposes no response deadline: the caller's `signal` is
|
|
10
|
+
* the only one. See design/UPLOAD.md "Long-running requests".
|
|
11
|
+
*/
|
|
12
|
+
/** The subset of `fetch` the upload client uses; `fetch` itself satisfies it. */
|
|
13
|
+
export type TFetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
14
|
+
export declare const fetchAwaitingProcessing: TFetchLike;
|
|
15
|
+
//# sourceMappingURL=processing-request.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"processing-request.d.ts","sourceRoot":"","sources":["../../src/upload/processing-request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,iFAAiF;AACjF,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAqC/E,eAAO,MAAM,uBAAuB,EAAE,UAsDrC,CAAC"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport for the upload requests qlip-server answers only once it
|
|
3
|
+
* has processed the whole build: `finalize` and the legacy multipart POST.
|
|
4
|
+
*
|
|
5
|
+
* Node's global `fetch` (undici) fails any request whose response headers
|
|
6
|
+
* take longer than 300 s (`headersTimeout`) with a bare "fetch failed", and
|
|
7
|
+
* the limit can't be raised without depending on the `undici` package. A
|
|
8
|
+
* large build's diff runs longer than that, so these requests go through
|
|
9
|
+
* `node:http`, which imposes no response deadline: the caller's `signal` is
|
|
10
|
+
* the only one. See design/UPLOAD.md "Long-running requests".
|
|
11
|
+
*/
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import https from 'node:https';
|
|
14
|
+
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
15
|
+
const encodeBody = async (body) => {
|
|
16
|
+
if (body === undefined || body === null) {
|
|
17
|
+
return { bytes: new Uint8Array(0), contentType: null };
|
|
18
|
+
}
|
|
19
|
+
// Response serializes every BodyInit (FormData gets its multipart boundary).
|
|
20
|
+
const encoded = new Response(body);
|
|
21
|
+
return {
|
|
22
|
+
bytes: new Uint8Array(await encoded.arrayBuffer()),
|
|
23
|
+
contentType: encoded.headers.get('content-type'),
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
const toResponse = (res, body) => {
|
|
27
|
+
const headers = new Headers();
|
|
28
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
continue;
|
|
31
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
32
|
+
headers.append(name, item);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const status = res.statusCode ?? 0;
|
|
36
|
+
return new Response(NULL_BODY_STATUSES.has(status) ? null : new Uint8Array(body), {
|
|
37
|
+
status,
|
|
38
|
+
statusText: res.statusMessage ?? '',
|
|
39
|
+
headers,
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
export const fetchAwaitingProcessing = async (url, init) => {
|
|
43
|
+
const target = new URL(url);
|
|
44
|
+
const transport = target.protocol === 'https:'
|
|
45
|
+
? https
|
|
46
|
+
: target.protocol === 'http:'
|
|
47
|
+
? http
|
|
48
|
+
: undefined;
|
|
49
|
+
if (transport === undefined) {
|
|
50
|
+
throw new TypeError(`unsupported protocol: ${target.protocol}`);
|
|
51
|
+
}
|
|
52
|
+
const { bytes, contentType } = await encodeBody(init.body);
|
|
53
|
+
const headers = {};
|
|
54
|
+
if (contentType !== null)
|
|
55
|
+
headers['content-type'] = contentType;
|
|
56
|
+
new Headers(init.headers).forEach((value, name) => {
|
|
57
|
+
headers[name] = value;
|
|
58
|
+
});
|
|
59
|
+
headers['content-length'] = String(bytes.byteLength);
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const req = transport.request(target, {
|
|
62
|
+
method: init.method ?? 'GET',
|
|
63
|
+
headers,
|
|
64
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
65
|
+
}, (res) => {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
res.on('data', (chunk) => {
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
res.on('error', reject);
|
|
71
|
+
// A connection dropped mid-body ends the stream without `end`.
|
|
72
|
+
res.on('close', () => {
|
|
73
|
+
if (!res.complete) {
|
|
74
|
+
reject(new Error('connection closed before the response completed'));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
res.on('end', () => {
|
|
78
|
+
try {
|
|
79
|
+
resolve(toResponse(res, Buffer.concat(chunks)));
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
reject(err);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
req.on('error', reject);
|
|
87
|
+
req.end(bytes);
|
|
88
|
+
});
|
|
89
|
+
};
|
package/dist/upload/upload.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Wire contract: `qlip-server/design/UPLOAD.md §0`.
|
|
4
4
|
*/
|
|
5
5
|
import type { QlipUploadOptions } from '../types.js';
|
|
6
|
+
import type { TFetchLike } from './processing-request.js';
|
|
6
7
|
export interface QlipUploadResult {
|
|
7
8
|
/** Build ID echoed by the server (matches the manifest's buildId). */
|
|
8
9
|
buildId: string;
|
|
@@ -20,13 +21,24 @@ export interface QlipUploadInput {
|
|
|
20
21
|
buildDir: string;
|
|
21
22
|
/** Resolved upload options (branch/commit already auto-detected). */
|
|
22
23
|
options: QlipUploadOptions;
|
|
23
|
-
/**
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Override for tests. Defaults to global fetch for create + blobs, and to
|
|
26
|
+
* a `node:http` transport for the finalize / legacy requests the server
|
|
27
|
+
* answers only after processing (see `processing-request.ts`). An
|
|
28
|
+
* override is used for every phase, so its own response timeouts apply.
|
|
29
|
+
*/
|
|
30
|
+
fetchImpl?: TFetchLike;
|
|
25
31
|
/** Base backoff (ms) between blob-PUT retries; test seam, attempt N waits N×. */
|
|
26
32
|
retryBaseDelayMs?: number;
|
|
27
33
|
}
|
|
28
34
|
/** Default server when `upload.serverUrl` is omitted: the hosted instance. */
|
|
29
35
|
export declare const DEFAULT_SERVER_URL = "https://qlip.qoretechnologies.com";
|
|
36
|
+
/**
|
|
37
|
+
* Default deadline for the finalize / legacy response. qorus-ide builds of
|
|
38
|
+
* ~2.1k snapshots measured 470–713 s of server processing (2026-10, see
|
|
39
|
+
* design/UPLOAD.md "Long-running requests"); 30 min leaves 2.5× headroom.
|
|
40
|
+
*/
|
|
41
|
+
export declare const DEFAULT_PROCESSING_TIMEOUT_MS: number;
|
|
30
42
|
export type TUploadPhase = 'create' | 'blob' | 'finalize' | 'legacy';
|
|
31
43
|
/**
|
|
32
44
|
* Resolve the effective server base URL (default applied, trailing slash
|
|
@@ -39,5 +51,15 @@ export declare class QlipUploadError extends Error {
|
|
|
39
51
|
readonly phase: TUploadPhase;
|
|
40
52
|
constructor(status: number, statusText: string, body: string, phase?: TUploadPhase);
|
|
41
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The request never produced an HTTP response: a network error, or no
|
|
56
|
+
* response before the client's deadline. `status`-less, unlike
|
|
57
|
+
* `QlipUploadError`.
|
|
58
|
+
*/
|
|
59
|
+
export declare class QlipUploadNetworkError extends Error {
|
|
60
|
+
readonly phase: TUploadPhase;
|
|
61
|
+
readonly cause: unknown;
|
|
62
|
+
constructor(phase: TUploadPhase, cause: unknown, detail?: string);
|
|
63
|
+
}
|
|
42
64
|
export declare const uploadBuild: (input: QlipUploadInput) => Promise<QlipUploadResult>;
|
|
43
65
|
//# sourceMappingURL=upload.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/upload/upload.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAAgB,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGnE,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,QAAQ,EAAE,IAAI,GAAG,QAAQ,CAAC;IAC1B,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,iBAAiB,CAAC;IAC3B
|
|
1
|
+
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../../src/upload/upload.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,OAAO,KAAK,EAAgB,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAE1D,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,QAAQ,EAAE,IAAI,GAAG,QAAQ,CAAC;IAC1B,wEAAwE;IACxE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,OAAO,EAAE,iBAAiB,CAAC;IAC3B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,UAAU,CAAC;IACvB,iFAAiF;IACjF,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,sCAAsC,CAAC;AAMtE;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,QAAc,CAAC;AAEzD,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,GAAG,QAAQ,CAAC;AAErE;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAC3B,SAAS,IAAI,CAAC,iBAAiB,EAAE,WAAW,CAAC,KAC5C,MAAsE,CAAC;AAE1E,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;gBAG3B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,GAAE,YAAuB;CAUjC;AA4BD;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;gBAEZ,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM;CAQjE;AAiND,eAAO,MAAM,WAAW,GACtB,OAAO,eAAe,KACrB,OAAO,CAAC,gBAAgB,CAgH1B,CAAC"}
|
package/dist/upload/upload.js
CHANGED
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
import { readFile } from 'node:fs/promises';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { hashFile } from './hash.js';
|
|
8
|
+
import { fetchAwaitingProcessing } from './processing-request.js';
|
|
8
9
|
/** Default server when `upload.serverUrl` is omitted: the hosted instance. */
|
|
9
10
|
export const DEFAULT_SERVER_URL = 'https://qlip.qoretechnologies.com';
|
|
10
11
|
const TRANSFER_CONCURRENCY = 10;
|
|
11
12
|
const BLOB_RETRIES = 2;
|
|
12
13
|
const BLOB_TIMEOUT_MS = 30000;
|
|
13
14
|
const DEFAULT_RETRY_BASE_DELAY_MS = 500;
|
|
15
|
+
/**
|
|
16
|
+
* Default deadline for the finalize / legacy response. qorus-ide builds of
|
|
17
|
+
* ~2.1k snapshots measured 470–713 s of server processing (2026-10, see
|
|
18
|
+
* design/UPLOAD.md "Long-running requests"); 30 min leaves 2.5× headroom.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_PROCESSING_TIMEOUT_MS = 30 * 60000;
|
|
14
21
|
/**
|
|
15
22
|
* Resolve the effective server base URL (default applied, trailing slash
|
|
16
23
|
* stripped). Exported so log lines report the URL the upload actually hits.
|
|
@@ -25,6 +32,76 @@ export class QlipUploadError extends Error {
|
|
|
25
32
|
this.phase = phase;
|
|
26
33
|
}
|
|
27
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* `fetch failed` alone hides why: the reason (a timeout, a reset, a DNS
|
|
37
|
+
* failure) lives on the `cause` chain, so the message spells that out.
|
|
38
|
+
*/
|
|
39
|
+
const describeCause = (err) => {
|
|
40
|
+
const parts = [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
let current = err;
|
|
43
|
+
while (current !== undefined && current !== null && !seen.has(current)) {
|
|
44
|
+
seen.add(current);
|
|
45
|
+
if (current instanceof Error) {
|
|
46
|
+
const code = current.code;
|
|
47
|
+
parts.push(typeof code === 'string' && code !== ''
|
|
48
|
+
? `${current.message} (${code})`
|
|
49
|
+
: current.message);
|
|
50
|
+
current = current.cause;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
parts.push(String(current));
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return parts.join(': ');
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* The request never produced an HTTP response: a network error, or no
|
|
61
|
+
* response before the client's deadline. `status`-less, unlike
|
|
62
|
+
* `QlipUploadError`.
|
|
63
|
+
*/
|
|
64
|
+
export class QlipUploadNetworkError extends Error {
|
|
65
|
+
constructor(phase, cause, detail) {
|
|
66
|
+
super(`qlip upload failed [${phase}]: ${detail ?? `network error — ${describeCause(cause)}`}`);
|
|
67
|
+
this.name = 'QlipUploadNetworkError';
|
|
68
|
+
this.phase = phase;
|
|
69
|
+
this.cause = cause;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** `fetchImpl`, with a transport failure reported as a phase-tagged error. */
|
|
73
|
+
const withPhase = (phase, fetchImpl) => async (url, init) => {
|
|
74
|
+
try {
|
|
75
|
+
return await fetchImpl(url, init);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
throw new QlipUploadNetworkError(phase, err);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Send a request the server answers only after processing the build. No
|
|
83
|
+
* retry: the server keeps processing after a dropped connection, and a
|
|
84
|
+
* second finalize would race the first.
|
|
85
|
+
*/
|
|
86
|
+
const sendAwaitingProcessing = async (phase, fetchImpl, url, init, timeoutMs) => {
|
|
87
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
88
|
+
try {
|
|
89
|
+
return await fetchImpl(url, { ...init, signal });
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (signal.aborted) {
|
|
93
|
+
throw new QlipUploadNetworkError(phase, err, `no response within ${String(timeoutMs / 1000)} s; the server may still be processing the build — check it in the dashboard, and raise the processing timeout if builds this size take longer`);
|
|
94
|
+
}
|
|
95
|
+
throw new QlipUploadNetworkError(phase, err);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const resolveProcessingTimeoutMs = (options) => {
|
|
99
|
+
const value = options.processingTimeoutMs ?? DEFAULT_PROCESSING_TIMEOUT_MS;
|
|
100
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
101
|
+
throw new RangeError(`upload.processingTimeoutMs must be a positive number of milliseconds, got ${String(value)}`);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
};
|
|
28
105
|
/** Contextual hint appended to 413s, tailored to the phase that hit it. */
|
|
29
106
|
const hintFor = (status, phase) => {
|
|
30
107
|
if (status !== 413)
|
|
@@ -97,10 +174,12 @@ const putBlob = async (fetchImpl, url, bytes, baseHeaders, retryBaseDelayMs) =>
|
|
|
97
174
|
if (attempt < BLOB_RETRIES)
|
|
98
175
|
await delay(retryBaseDelayMs * (attempt + 1));
|
|
99
176
|
}
|
|
100
|
-
|
|
177
|
+
if (lastError instanceof QlipUploadError)
|
|
178
|
+
throw lastError;
|
|
179
|
+
throw new QlipUploadNetworkError('blob', lastError);
|
|
101
180
|
};
|
|
102
181
|
/** Pre-v2 single-multipart upload; fallback when the server lacks v2 routes. */
|
|
103
|
-
const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest) => {
|
|
182
|
+
const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest, processingTimeoutMs) => {
|
|
104
183
|
const form = new FormData();
|
|
105
184
|
form.append('manifest', new Blob([JSON.stringify(manifest)], { type: 'application/json' }), 'manifest.json');
|
|
106
185
|
const captured = manifest.entries.filter((e) => e.status === 'captured');
|
|
@@ -125,18 +204,22 @@ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest
|
|
|
125
204
|
if (options.pullRequestUrl !== undefined) {
|
|
126
205
|
form.append('pullRequestUrl', options.pullRequestUrl);
|
|
127
206
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
207
|
+
if (options.triggeredByLogin !== undefined) {
|
|
208
|
+
form.append('triggeredByLogin', options.triggeredByLogin);
|
|
209
|
+
}
|
|
210
|
+
if (options.triggeredById !== undefined) {
|
|
211
|
+
form.append('triggeredById', String(options.triggeredById));
|
|
212
|
+
}
|
|
213
|
+
const response = await sendAwaitingProcessing('legacy', fetchImpl, `${baseUrl}/api/builds/upload`, { method: 'POST', body: form, headers: authHeaders(options) }, processingTimeoutMs);
|
|
133
214
|
if (!response.ok)
|
|
134
215
|
throw await errorFromResponse(response, 'legacy');
|
|
135
216
|
return { buildId: manifest.buildId, status: response.status, protocol: 'legacy' };
|
|
136
217
|
};
|
|
137
218
|
export const uploadBuild = async (input) => {
|
|
138
219
|
const { buildDir, options } = input;
|
|
220
|
+
const processingTimeoutMs = resolveProcessingTimeoutMs(options);
|
|
139
221
|
const fetchImpl = input.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
222
|
+
const processingFetch = input.fetchImpl ?? fetchAwaitingProcessing;
|
|
140
223
|
const retryBaseDelayMs = input.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
|
|
141
224
|
const baseUrl = resolveServerUrl(options);
|
|
142
225
|
const headers = authHeaders(options);
|
|
@@ -148,7 +231,8 @@ export const uploadBuild = async (input) => {
|
|
|
148
231
|
entry.sha256 = sha256;
|
|
149
232
|
entry.sizeBytes = sizeBytes;
|
|
150
233
|
});
|
|
151
|
-
const
|
|
234
|
+
const createFetch = withPhase('create', fetchImpl);
|
|
235
|
+
const createResponse = await createFetch(`${baseUrl}/api/builds`, {
|
|
152
236
|
method: 'POST',
|
|
153
237
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
154
238
|
body: JSON.stringify({
|
|
@@ -165,11 +249,17 @@ export const uploadBuild = async (input) => {
|
|
|
165
249
|
...(options.pullRequestUrl !== undefined
|
|
166
250
|
? { pullRequestUrl: options.pullRequestUrl }
|
|
167
251
|
: {}),
|
|
252
|
+
...(options.triggeredByLogin !== undefined
|
|
253
|
+
? { triggeredByLogin: options.triggeredByLogin }
|
|
254
|
+
: {}),
|
|
255
|
+
...(options.triggeredById !== undefined
|
|
256
|
+
? { triggeredById: options.triggeredById }
|
|
257
|
+
: {}),
|
|
168
258
|
}),
|
|
169
259
|
});
|
|
170
260
|
// 404 ⇒ server predates the v2 routes; fall back to legacy multipart.
|
|
171
261
|
if (createResponse.status === 404) {
|
|
172
|
-
return uploadBuildLegacy(
|
|
262
|
+
return uploadBuildLegacy(processingFetch, baseUrl, buildDir, options, manifest, processingTimeoutMs);
|
|
173
263
|
}
|
|
174
264
|
if (!createResponse.ok)
|
|
175
265
|
throw await errorFromResponse(createResponse, 'create');
|
|
@@ -189,7 +279,7 @@ export const uploadBuild = async (input) => {
|
|
|
189
279
|
const bytes = await readFile(path.join(buildDir, relPath));
|
|
190
280
|
await putBlob(fetchImpl, `${baseUrl}/api/builds/${buildPath}/blobs/${key}`, bytes, headers, retryBaseDelayMs);
|
|
191
281
|
});
|
|
192
|
-
const finalizeResponse = await
|
|
282
|
+
const finalizeResponse = await sendAwaitingProcessing('finalize', processingFetch, `${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers }, processingTimeoutMs);
|
|
193
283
|
if (!finalizeResponse.ok) {
|
|
194
284
|
throw await errorFromResponse(finalizeResponse, 'finalize');
|
|
195
285
|
}
|
|
@@ -58,6 +58,9 @@ Options:
|
|
|
58
58
|
--token <token> Bearer token. Default: \$QLIP_UPLOAD_TOKEN.
|
|
59
59
|
--branch <branch> Override branch detection.
|
|
60
60
|
--commit <sha> Override commit detection.
|
|
61
|
+
--processing-timeout <s> Seconds to wait for the server to process
|
|
62
|
+
the build. Default:
|
|
63
|
+
\$QLIP_PROCESSING_TIMEOUT or 1800.
|
|
61
64
|
--fail-on-upload-error Exit non-zero on upload failure (default:
|
|
62
65
|
log error + exit 0).
|
|
63
66
|
--fail-on-partial-build Exit non-zero when screenshots on disk are
|
|
@@ -101,6 +104,9 @@ const parseArgs = (argv) => {
|
|
|
101
104
|
case '--commit':
|
|
102
105
|
args.commit = next();
|
|
103
106
|
break;
|
|
107
|
+
case '--processing-timeout':
|
|
108
|
+
args.processingTimeout = next();
|
|
109
|
+
break;
|
|
104
110
|
case '--fail-on-upload-error':
|
|
105
111
|
args.failOnUploadError = true;
|
|
106
112
|
break;
|
|
@@ -178,6 +184,16 @@ const runUpload = async (argv, env = process.env, logger = console) => {
|
|
|
178
184
|
logger.error(`qlip-upload: ${err.message}`);
|
|
179
185
|
return { exitCode: 1 };
|
|
180
186
|
}
|
|
187
|
+
const processingTimeout = parsed.processingTimeout ?? env['QLIP_PROCESSING_TIMEOUT'];
|
|
188
|
+
let processingTimeoutMs;
|
|
189
|
+
if (processingTimeout !== undefined && processingTimeout !== '') {
|
|
190
|
+
const seconds = Number(processingTimeout);
|
|
191
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
192
|
+
logger.error(`qlip-upload: --processing-timeout must be a positive number of seconds, got "${processingTimeout}"`);
|
|
193
|
+
return { exitCode: 1 };
|
|
194
|
+
}
|
|
195
|
+
processingTimeoutMs = seconds * 1000;
|
|
196
|
+
}
|
|
181
197
|
const project = parsed.project ?? env['QLIP_PROJECT'] ?? 'default';
|
|
182
198
|
const token = parsed.token ?? env['QLIP_UPLOAD_TOKEN'];
|
|
183
199
|
const upload = {
|
|
@@ -186,6 +202,7 @@ const runUpload = async (argv, env = process.env, logger = console) => {
|
|
|
186
202
|
...(token !== undefined && token !== '' ? { uploadToken: token } : {}),
|
|
187
203
|
...(parsed.branch !== undefined ? { branch: parsed.branch } : {}),
|
|
188
204
|
...(parsed.commit !== undefined ? { commit: parsed.commit } : {}),
|
|
205
|
+
...(processingTimeoutMs !== undefined ? { processingTimeoutMs } : {}),
|
|
189
206
|
failOnUploadError: parsed.failOnUploadError,
|
|
190
207
|
failOnPartialBuild: parsed.failOnPartialBuild,
|
|
191
208
|
};
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `qlip-server/design/UPLOAD.md`.
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.autodetectAncestorCommits = exports.autodetectPullRequestUrl = exports.autodetectBaseBranch = exports.autodetectCommit = exports.autodetectBranch = void 0;
|
|
18
|
+
exports.autodetectAncestorCommits = exports.autodetectTriggeredBy = exports.autodetectPullRequestUrl = exports.autodetectBaseBranch = exports.autodetectCommit = exports.autodetectBranch = void 0;
|
|
19
19
|
const node_child_process_1 = require("node:child_process");
|
|
20
20
|
const node_fs_1 = require("node:fs");
|
|
21
21
|
/** Server-side cap on the ancestry list (`qlip-server/design/UPLOAD.md`). */
|
|
@@ -134,6 +134,26 @@ const autodetectPullRequestUrl = () => {
|
|
|
134
134
|
return `${base}/${repo}/pull/${prNumber}`;
|
|
135
135
|
};
|
|
136
136
|
exports.autodetectPullRequestUrl = autodetectPullRequestUrl;
|
|
137
|
+
/**
|
|
138
|
+
* Who triggered the CI run — the user the dashboard shows as the
|
|
139
|
+
* build's author. GitHub Actions sets `$GITHUB_ACTOR` (the login) and
|
|
140
|
+
* `$GITHUB_ACTOR_ID` (the numeric account id) on every run.
|
|
141
|
+
*
|
|
142
|
+
* Env-only by design: outside CI there is no GitHub identity to read
|
|
143
|
+
* (a git author is an email, not a login), and a dashboard upload is
|
|
144
|
+
* attributed server-side from the signed-in session. Returns undefined
|
|
145
|
+
* for local runs; the server stores it as nullable, so absence just
|
|
146
|
+
* means "author unknown". See `qlip-server/design/UPLOAD.md`.
|
|
147
|
+
*/
|
|
148
|
+
const autodetectTriggeredBy = () => {
|
|
149
|
+
const login = process.env['GITHUB_ACTOR']?.trim();
|
|
150
|
+
if (!login || login.length === 0)
|
|
151
|
+
return undefined;
|
|
152
|
+
const rawId = process.env['GITHUB_ACTOR_ID']?.trim();
|
|
153
|
+
const id = rawId && /^\d+$/.test(rawId) ? Number(rawId) : undefined;
|
|
154
|
+
return id !== undefined ? { login, id } : { login };
|
|
155
|
+
};
|
|
156
|
+
exports.autodetectTriggeredBy = autodetectTriggeredBy;
|
|
137
157
|
let shallowWarningEmitted = false;
|
|
138
158
|
/**
|
|
139
159
|
* A shallow clone (CI default for `actions/checkout`) only has the tip
|
|
@@ -122,6 +122,15 @@ const finalizeBuild = async (opts) => {
|
|
|
122
122
|
const baseBranch = opts.upload.baseBranch ?? (0, autodetect_js_1.autodetectBaseBranch)();
|
|
123
123
|
const ancestorCommits = opts.upload.ancestorCommits ?? (0, autodetect_js_1.autodetectAncestorCommits)();
|
|
124
124
|
const pullRequestUrl = opts.upload.pullRequestUrl ?? (0, autodetect_js_1.autodetectPullRequestUrl)();
|
|
125
|
+
// An explicit login wins outright (with whatever id came with it);
|
|
126
|
+
// otherwise both come from the CI env together.
|
|
127
|
+
const detectedTriggeredBy = opts.upload.triggeredByLogin === undefined
|
|
128
|
+
? (0, autodetect_js_1.autodetectTriggeredBy)()
|
|
129
|
+
: undefined;
|
|
130
|
+
const triggeredByLogin = opts.upload.triggeredByLogin ?? detectedTriggeredBy?.login;
|
|
131
|
+
const triggeredById = opts.upload.triggeredByLogin !== undefined
|
|
132
|
+
? opts.upload.triggeredById
|
|
133
|
+
: detectedTriggeredBy?.id;
|
|
125
134
|
const resolved = {
|
|
126
135
|
...opts.upload,
|
|
127
136
|
...(branch !== undefined ? { branch } : {}),
|
|
@@ -129,6 +138,8 @@ const finalizeBuild = async (opts) => {
|
|
|
129
138
|
...(baseBranch !== undefined ? { baseBranch } : {}),
|
|
130
139
|
...(ancestorCommits !== undefined ? { ancestorCommits } : {}),
|
|
131
140
|
...(pullRequestUrl !== undefined ? { pullRequestUrl } : {}),
|
|
141
|
+
...(triggeredByLogin !== undefined ? { triggeredByLogin } : {}),
|
|
142
|
+
...(triggeredById !== undefined ? { triggeredById } : {}),
|
|
132
143
|
};
|
|
133
144
|
try {
|
|
134
145
|
const result = await (0, upload_js_1.uploadBuild)({
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP transport for the upload requests qlip-server answers only once it
|
|
4
|
+
* has processed the whole build: `finalize` and the legacy multipart POST.
|
|
5
|
+
*
|
|
6
|
+
* Node's global `fetch` (undici) fails any request whose response headers
|
|
7
|
+
* take longer than 300 s (`headersTimeout`) with a bare "fetch failed", and
|
|
8
|
+
* the limit can't be raised without depending on the `undici` package. A
|
|
9
|
+
* large build's diff runs longer than that, so these requests go through
|
|
10
|
+
* `node:http`, which imposes no response deadline: the caller's `signal` is
|
|
11
|
+
* the only one. See design/UPLOAD.md "Long-running requests".
|
|
12
|
+
*/
|
|
13
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
14
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.fetchAwaitingProcessing = void 0;
|
|
18
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
19
|
+
const node_https_1 = __importDefault(require("node:https"));
|
|
20
|
+
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
|
21
|
+
const encodeBody = async (body) => {
|
|
22
|
+
if (body === undefined || body === null) {
|
|
23
|
+
return { bytes: new Uint8Array(0), contentType: null };
|
|
24
|
+
}
|
|
25
|
+
// Response serializes every BodyInit (FormData gets its multipart boundary).
|
|
26
|
+
const encoded = new Response(body);
|
|
27
|
+
return {
|
|
28
|
+
bytes: new Uint8Array(await encoded.arrayBuffer()),
|
|
29
|
+
contentType: encoded.headers.get('content-type'),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
const toResponse = (res, body) => {
|
|
33
|
+
const headers = new Headers();
|
|
34
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
38
|
+
headers.append(name, item);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const status = res.statusCode ?? 0;
|
|
42
|
+
return new Response(NULL_BODY_STATUSES.has(status) ? null : new Uint8Array(body), {
|
|
43
|
+
status,
|
|
44
|
+
statusText: res.statusMessage ?? '',
|
|
45
|
+
headers,
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
const fetchAwaitingProcessing = async (url, init) => {
|
|
49
|
+
const target = new URL(url);
|
|
50
|
+
const transport = target.protocol === 'https:'
|
|
51
|
+
? node_https_1.default
|
|
52
|
+
: target.protocol === 'http:'
|
|
53
|
+
? node_http_1.default
|
|
54
|
+
: undefined;
|
|
55
|
+
if (transport === undefined) {
|
|
56
|
+
throw new TypeError(`unsupported protocol: ${target.protocol}`);
|
|
57
|
+
}
|
|
58
|
+
const { bytes, contentType } = await encodeBody(init.body);
|
|
59
|
+
const headers = {};
|
|
60
|
+
if (contentType !== null)
|
|
61
|
+
headers['content-type'] = contentType;
|
|
62
|
+
new Headers(init.headers).forEach((value, name) => {
|
|
63
|
+
headers[name] = value;
|
|
64
|
+
});
|
|
65
|
+
headers['content-length'] = String(bytes.byteLength);
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const req = transport.request(target, {
|
|
68
|
+
method: init.method ?? 'GET',
|
|
69
|
+
headers,
|
|
70
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
71
|
+
}, (res) => {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
res.on('data', (chunk) => {
|
|
74
|
+
chunks.push(chunk);
|
|
75
|
+
});
|
|
76
|
+
res.on('error', reject);
|
|
77
|
+
// A connection dropped mid-body ends the stream without `end`.
|
|
78
|
+
res.on('close', () => {
|
|
79
|
+
if (!res.complete) {
|
|
80
|
+
reject(new Error('connection closed before the response completed'));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
res.on('end', () => {
|
|
84
|
+
try {
|
|
85
|
+
resolve(toResponse(res, Buffer.concat(chunks)));
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
reject(err);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
req.on('error', reject);
|
|
93
|
+
req.end(bytes);
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
exports.fetchAwaitingProcessing = fetchAwaitingProcessing;
|
|
@@ -7,16 +7,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
7
7
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
8
8
|
};
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.uploadBuild = exports.QlipUploadError = exports.resolveServerUrl = exports.DEFAULT_SERVER_URL = void 0;
|
|
10
|
+
exports.uploadBuild = exports.QlipUploadNetworkError = exports.QlipUploadError = exports.resolveServerUrl = exports.DEFAULT_PROCESSING_TIMEOUT_MS = exports.DEFAULT_SERVER_URL = void 0;
|
|
11
11
|
const promises_1 = require("node:fs/promises");
|
|
12
12
|
const node_path_1 = __importDefault(require("node:path"));
|
|
13
13
|
const hash_js_1 = require("./hash.js");
|
|
14
|
+
const processing_request_js_1 = require("./processing-request.js");
|
|
14
15
|
/** Default server when `upload.serverUrl` is omitted: the hosted instance. */
|
|
15
16
|
exports.DEFAULT_SERVER_URL = 'https://qlip.qoretechnologies.com';
|
|
16
17
|
const TRANSFER_CONCURRENCY = 10;
|
|
17
18
|
const BLOB_RETRIES = 2;
|
|
18
19
|
const BLOB_TIMEOUT_MS = 30000;
|
|
19
20
|
const DEFAULT_RETRY_BASE_DELAY_MS = 500;
|
|
21
|
+
/**
|
|
22
|
+
* Default deadline for the finalize / legacy response. qorus-ide builds of
|
|
23
|
+
* ~2.1k snapshots measured 470–713 s of server processing (2026-10, see
|
|
24
|
+
* design/UPLOAD.md "Long-running requests"); 30 min leaves 2.5× headroom.
|
|
25
|
+
*/
|
|
26
|
+
exports.DEFAULT_PROCESSING_TIMEOUT_MS = 30 * 60000;
|
|
20
27
|
/**
|
|
21
28
|
* Resolve the effective server base URL (default applied, trailing slash
|
|
22
29
|
* stripped). Exported so log lines report the URL the upload actually hits.
|
|
@@ -33,6 +40,77 @@ class QlipUploadError extends Error {
|
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
exports.QlipUploadError = QlipUploadError;
|
|
43
|
+
/**
|
|
44
|
+
* `fetch failed` alone hides why: the reason (a timeout, a reset, a DNS
|
|
45
|
+
* failure) lives on the `cause` chain, so the message spells that out.
|
|
46
|
+
*/
|
|
47
|
+
const describeCause = (err) => {
|
|
48
|
+
const parts = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
let current = err;
|
|
51
|
+
while (current !== undefined && current !== null && !seen.has(current)) {
|
|
52
|
+
seen.add(current);
|
|
53
|
+
if (current instanceof Error) {
|
|
54
|
+
const code = current.code;
|
|
55
|
+
parts.push(typeof code === 'string' && code !== ''
|
|
56
|
+
? `${current.message} (${code})`
|
|
57
|
+
: current.message);
|
|
58
|
+
current = current.cause;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
parts.push(String(current));
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return parts.join(': ');
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The request never produced an HTTP response: a network error, or no
|
|
69
|
+
* response before the client's deadline. `status`-less, unlike
|
|
70
|
+
* `QlipUploadError`.
|
|
71
|
+
*/
|
|
72
|
+
class QlipUploadNetworkError extends Error {
|
|
73
|
+
constructor(phase, cause, detail) {
|
|
74
|
+
super(`qlip upload failed [${phase}]: ${detail ?? `network error — ${describeCause(cause)}`}`);
|
|
75
|
+
this.name = 'QlipUploadNetworkError';
|
|
76
|
+
this.phase = phase;
|
|
77
|
+
this.cause = cause;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
exports.QlipUploadNetworkError = QlipUploadNetworkError;
|
|
81
|
+
/** `fetchImpl`, with a transport failure reported as a phase-tagged error. */
|
|
82
|
+
const withPhase = (phase, fetchImpl) => async (url, init) => {
|
|
83
|
+
try {
|
|
84
|
+
return await fetchImpl(url, init);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
throw new QlipUploadNetworkError(phase, err);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Send a request the server answers only after processing the build. No
|
|
92
|
+
* retry: the server keeps processing after a dropped connection, and a
|
|
93
|
+
* second finalize would race the first.
|
|
94
|
+
*/
|
|
95
|
+
const sendAwaitingProcessing = async (phase, fetchImpl, url, init, timeoutMs) => {
|
|
96
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
97
|
+
try {
|
|
98
|
+
return await fetchImpl(url, { ...init, signal });
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (signal.aborted) {
|
|
102
|
+
throw new QlipUploadNetworkError(phase, err, `no response within ${String(timeoutMs / 1000)} s; the server may still be processing the build — check it in the dashboard, and raise the processing timeout if builds this size take longer`);
|
|
103
|
+
}
|
|
104
|
+
throw new QlipUploadNetworkError(phase, err);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const resolveProcessingTimeoutMs = (options) => {
|
|
108
|
+
const value = options.processingTimeoutMs ?? exports.DEFAULT_PROCESSING_TIMEOUT_MS;
|
|
109
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
110
|
+
throw new RangeError(`upload.processingTimeoutMs must be a positive number of milliseconds, got ${String(value)}`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
};
|
|
36
114
|
/** Contextual hint appended to 413s, tailored to the phase that hit it. */
|
|
37
115
|
const hintFor = (status, phase) => {
|
|
38
116
|
if (status !== 413)
|
|
@@ -105,10 +183,12 @@ const putBlob = async (fetchImpl, url, bytes, baseHeaders, retryBaseDelayMs) =>
|
|
|
105
183
|
if (attempt < BLOB_RETRIES)
|
|
106
184
|
await delay(retryBaseDelayMs * (attempt + 1));
|
|
107
185
|
}
|
|
108
|
-
|
|
186
|
+
if (lastError instanceof QlipUploadError)
|
|
187
|
+
throw lastError;
|
|
188
|
+
throw new QlipUploadNetworkError('blob', lastError);
|
|
109
189
|
};
|
|
110
190
|
/** Pre-v2 single-multipart upload; fallback when the server lacks v2 routes. */
|
|
111
|
-
const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest) => {
|
|
191
|
+
const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest, processingTimeoutMs) => {
|
|
112
192
|
const form = new FormData();
|
|
113
193
|
form.append('manifest', new Blob([JSON.stringify(manifest)], { type: 'application/json' }), 'manifest.json');
|
|
114
194
|
const captured = manifest.entries.filter((e) => e.status === 'captured');
|
|
@@ -133,18 +213,22 @@ const uploadBuildLegacy = async (fetchImpl, baseUrl, buildDir, options, manifest
|
|
|
133
213
|
if (options.pullRequestUrl !== undefined) {
|
|
134
214
|
form.append('pullRequestUrl', options.pullRequestUrl);
|
|
135
215
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
216
|
+
if (options.triggeredByLogin !== undefined) {
|
|
217
|
+
form.append('triggeredByLogin', options.triggeredByLogin);
|
|
218
|
+
}
|
|
219
|
+
if (options.triggeredById !== undefined) {
|
|
220
|
+
form.append('triggeredById', String(options.triggeredById));
|
|
221
|
+
}
|
|
222
|
+
const response = await sendAwaitingProcessing('legacy', fetchImpl, `${baseUrl}/api/builds/upload`, { method: 'POST', body: form, headers: authHeaders(options) }, processingTimeoutMs);
|
|
141
223
|
if (!response.ok)
|
|
142
224
|
throw await errorFromResponse(response, 'legacy');
|
|
143
225
|
return { buildId: manifest.buildId, status: response.status, protocol: 'legacy' };
|
|
144
226
|
};
|
|
145
227
|
const uploadBuild = async (input) => {
|
|
146
228
|
const { buildDir, options } = input;
|
|
229
|
+
const processingTimeoutMs = resolveProcessingTimeoutMs(options);
|
|
147
230
|
const fetchImpl = input.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
231
|
+
const processingFetch = input.fetchImpl ?? processing_request_js_1.fetchAwaitingProcessing;
|
|
148
232
|
const retryBaseDelayMs = input.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
|
|
149
233
|
const baseUrl = (0, exports.resolveServerUrl)(options);
|
|
150
234
|
const headers = authHeaders(options);
|
|
@@ -156,7 +240,8 @@ const uploadBuild = async (input) => {
|
|
|
156
240
|
entry.sha256 = sha256;
|
|
157
241
|
entry.sizeBytes = sizeBytes;
|
|
158
242
|
});
|
|
159
|
-
const
|
|
243
|
+
const createFetch = withPhase('create', fetchImpl);
|
|
244
|
+
const createResponse = await createFetch(`${baseUrl}/api/builds`, {
|
|
160
245
|
method: 'POST',
|
|
161
246
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
162
247
|
body: JSON.stringify({
|
|
@@ -173,11 +258,17 @@ const uploadBuild = async (input) => {
|
|
|
173
258
|
...(options.pullRequestUrl !== undefined
|
|
174
259
|
? { pullRequestUrl: options.pullRequestUrl }
|
|
175
260
|
: {}),
|
|
261
|
+
...(options.triggeredByLogin !== undefined
|
|
262
|
+
? { triggeredByLogin: options.triggeredByLogin }
|
|
263
|
+
: {}),
|
|
264
|
+
...(options.triggeredById !== undefined
|
|
265
|
+
? { triggeredById: options.triggeredById }
|
|
266
|
+
: {}),
|
|
176
267
|
}),
|
|
177
268
|
});
|
|
178
269
|
// 404 ⇒ server predates the v2 routes; fall back to legacy multipart.
|
|
179
270
|
if (createResponse.status === 404) {
|
|
180
|
-
return uploadBuildLegacy(
|
|
271
|
+
return uploadBuildLegacy(processingFetch, baseUrl, buildDir, options, manifest, processingTimeoutMs);
|
|
181
272
|
}
|
|
182
273
|
if (!createResponse.ok)
|
|
183
274
|
throw await errorFromResponse(createResponse, 'create');
|
|
@@ -197,7 +288,7 @@ const uploadBuild = async (input) => {
|
|
|
197
288
|
const bytes = await (0, promises_1.readFile)(node_path_1.default.join(buildDir, relPath));
|
|
198
289
|
await putBlob(fetchImpl, `${baseUrl}/api/builds/${buildPath}/blobs/${key}`, bytes, headers, retryBaseDelayMs);
|
|
199
290
|
});
|
|
200
|
-
const finalizeResponse = await
|
|
291
|
+
const finalizeResponse = await sendAwaitingProcessing('finalize', processingFetch, `${baseUrl}/api/builds/${buildPath}/finalize`, { method: 'POST', headers }, processingTimeoutMs);
|
|
201
292
|
if (!finalizeResponse.ok) {
|
|
202
293
|
throw await errorFromResponse(finalizeResponse, 'finalize');
|
|
203
294
|
}
|