From 9ddc72b1cc62489fc05ba8fcf1d19dda4b9c5814 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Thu, 23 Jul 2026 15:01:00 +0100 Subject: [PATCH 1/2] feat(artifacts): prefer server-assembled bundle delivery for downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artifact and HTML-report downloads now try a bundle-delivery path first: the API returns a signed manifest plus a URL to a delivery service that streams the ZIP straight from storage, so large downloads don't flow through the API. The client relays the signed { manifest, sig } to that URL (no auth header — the manifest is the signed token) and streams the result to disk. Falls back to the existing inline download automatically when bundle delivery isn't offered (501) or anything about the path doesn't pan out, so behaviour is unchanged on older deployments. Applies to artifacts and the HTML report; junit and allure keep using their existing endpoints. Adds a shared tryBundleDownload helper in the API gateway and unit tests covering the bundle path, the no-auth relay, the 501 fallback, and the HTML report. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gateways/api-gateway.ts | 99 ++++++++++++++ test/unit/report-download.service.test.ts | 153 ++++++++++++++++++++++ 2 files changed, 252 insertions(+) diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 48eeb18..4ef0855 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -179,6 +179,76 @@ export const ApiGateway = { ); }, + /** + * Prefer server-assembled bundle delivery. The API returns a signed manifest + * plus a URL to a delivery service that streams the ZIP straight from + * storage, so large downloads don't flow through the API itself. The client + * just relays the signed `{ manifest, sig }` to that URL — no auth header, + * because the manifest is already signed. + * + * Returns true once the bundle has been streamed to disk. Returns false when + * the bundle path is unavailable or anything about it doesn't pan out — a + * `501` (deployment doesn't offer it), a non-OK manifest response, a body + * that isn't a manifest, or a delivery-service error — so the caller can fall + * back to the inline download endpoint. Definitive errors (e.g. not found) + * surface through that inline path instead. + */ + async tryBundleDownload( + baseUrl: string, + auth: AuthContext, + manifestEndpoint: string, + destinationPath: string, + operation: string, + ): Promise { + let manifestRes: Response; + try { + manifestRes = await fetch(`${baseUrl}${manifestEndpoint}`, { + headers: { ...auth.headers }, + method: 'GET', + }); + } catch { + return false; + } + + // 501 => this deployment has no bundle delivery; any other non-OK => let + // the inline path re-request and surface the real error. + if (!manifestRes.ok) { + return false; + } + + let bundle: { + bundleUrl?: string; + manifest?: string; + sig?: string; + }; + try { + bundle = (await manifestRes.json()) as typeof bundle; + } catch { + return false; + } + if (!bundle?.bundleUrl || !bundle?.manifest || !bundle?.sig) { + return false; + } + + let zipRes: Response; + try { + zipRes = await fetch(bundle.bundleUrl, { + body: JSON.stringify({ manifest: bundle.manifest, sig: bundle.sig }), + // No auth header: the manifest is signed and is the access token. + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + } catch { + return false; + } + if (!zipRes.ok) { + return false; + } + + await this.streamResponseToFile(zipRes, destinationPath, operation); + return true; + }, + async checkForExistingUpload( baseUrl: string, auth: AuthContext, @@ -219,6 +289,19 @@ export const ApiGateway = { results: 'ALL' | 'FAILED', artifactsPath: string = './artifacts.zip', ) { + // Prefer bundle delivery; fall back to the inline download below. + if ( + await this.tryBundleDownload( + baseUrl, + auth, + `/results/${uploadId}/artifacts-bundle?results=${results}`, + artifactsPath, + 'Failed to download artifacts', + ) + ) { + return; + } + try { const res = await fetch(`${baseUrl}/results/${uploadId}/download`, { body: JSON.stringify({ results }), @@ -677,6 +760,22 @@ export const ApiGateway = { const finalReportPath = reportPath || path.resolve(process.cwd(), defaultFilename); const url = `${baseUrl}${endpoint}`; + // The HTML report is a ZIP bundle; prefer bundle delivery when available. + // (junit is a single small file and allure has its own endpoint — both stay + // on the inline path.) + if ( + reportType === 'html' && + (await this.tryBundleDownload( + baseUrl, + auth, + `/results/${uploadId}/report-bundle`, + finalReportPath, + errorPrefix, + )) + ) { + return; + } + try { // Make the download request const res = await fetch(url, { diff --git a/test/unit/report-download.service.test.ts b/test/unit/report-download.service.test.ts index 2ed8566..8c75f13 100644 --- a/test/unit/report-download.service.test.ts +++ b/test/unit/report-download.service.test.ts @@ -303,4 +303,157 @@ describe('ReportDownloadService', () => { expect(warnings.join(' ')).to.match(/failed to download allure/i); }); }); + + // ------------------------------------------------------------------------- + // bundle delivery + // ------------------------------------------------------------------------- + + describe('bundle delivery', () => { + const BASE = { + auth: TEST_AUTH, + apiUrl: 'https://api.example.com', + uploadId: 'run-42', + }; + + let calls: Array<{ + headers: Record; + method: string; + url: string; + }>; + + /** + * Route fetch by URL: a `*-bundle` endpoint returns a signed manifest, and + * the manifest's `bundleUrl` streams the ZIP. Records every call so the + * flow (manifest GET, then bundle POST, no inline call) can be asserted. + */ + function mockBundleFetch( + opts: { manifestStatus?: number; zipBody?: string } = {}, + ) { + const { manifestStatus = 200, zipBody = 'bundle-zip' } = opts; + const encoder = new TextEncoder(); + const stream = (s: string) => + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(s)); + controller.close(); + }, + }); + + const impl = async ( + input: URL | string, + init?: RequestInit, + ): Promise => { + const url = input.toString(); + calls.push({ + headers: Object.fromEntries( + Object.entries((init?.headers as Record) ?? {}), + ), + method: (init?.method ?? 'GET').toUpperCase(), + url, + }); + + if (url.includes('artifacts-bundle') || url.includes('report-bundle')) { + return new Response( + stream( + JSON.stringify({ + bundleUrl: 'https://cdn.example.com/bundle', + entryCount: 3, + filename: 'artifacts-all.zip', + manifest: '{"version":1}', + sig: 'SIG', + }), + ), + { + headers: { 'content-type': 'application/json' }, + status: manifestStatus, + }, + ); + } + if (url === 'https://cdn.example.com/bundle') { + return new Response(stream(zipBody), { status: 200 }); + } + return new Response(stream('inline-zip'), { status: 200 }); + }; + globalThis.fetch = impl as typeof fetch; + } + + beforeEach(() => { + calls = []; + }); + + it('streams from the bundle URL and skips the inline endpoint', async () => { + mockBundleFetch(); + const outPath = path.join(tempDir, 'bundle.zip'); + + await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'ALL', + }); + + expect(calls[0]).to.include({ + method: 'GET', + url: 'https://api.example.com/results/run-42/artifacts-bundle?results=ALL', + }); + expect(calls[1]).to.include({ + method: 'POST', + url: 'https://cdn.example.com/bundle', + }); + expect(calls.some((c) => c.url.endsWith('/download'))).to.be.false; + expect(fs.readFileSync(outPath, 'utf8')).to.equal('bundle-zip'); + }); + + it('does not send the auth header to the (pre-signed) bundle URL', async () => { + mockBundleFetch(); + + await service.downloadArtifacts({ + ...BASE, + artifactsPath: path.join(tempDir, 'bundle-noauth.zip'), + downloadType: 'ALL', + }); + + const bundlePost = calls.find( + (c) => c.url === 'https://cdn.example.com/bundle', + ); + expect(bundlePost).to.not.be.undefined; + expect(bundlePost!.headers['x-app-api-key']).to.be.undefined; + }); + + it('falls back to the inline download when unavailable (501)', async () => { + mockBundleFetch({ manifestStatus: 501 }); + const outPath = path.join(tempDir, 'bundle-fallback.zip'); + + await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'ALL', + }); + + expect( + calls.some((c) => c.url.endsWith('/artifacts-bundle?results=ALL')), + ).to.be.true; + expect( + calls.some((c) => c.method === 'POST' && c.url.endsWith('/download')), + ).to.be.true; + expect(fs.readFileSync(outPath, 'utf8')).to.equal('inline-zip'); + }); + + it('uses bundle delivery for the html report', async () => { + mockBundleFetch(); + + await service.downloadReports({ + ...BASE, + htmlPath: path.join(tempDir, 'report-bundle.zip'), + reportType: 'html', + }); + + expect(calls[0].url).to.equal( + 'https://api.example.com/results/run-42/report-bundle', + ); + expect(calls[1]).to.include({ + method: 'POST', + url: 'https://cdn.example.com/bundle', + }); + }); + }); }); From 36ffdd0edfa98b98c8732225587cc8f7286b1aa8 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Thu, 23 Jul 2026 15:40:38 +0100 Subject: [PATCH 2/2] fix(artifacts): fall back to inline download on bundle stream failure streamResponseToFile threw past both tryBundleDownload call sites on a mid-stream failure or null body, escaping the inline fallback and leaving a partial file. Wrap it to return false like every other failure path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gateways/api-gateway.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 4ef0855..c06eaa8 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -245,7 +245,15 @@ export const ApiGateway = { return false; } - await this.streamResponseToFile(zipRes, destinationPath, operation); + // A mid-stream failure (dropped/truncated connection) or a null body on an + // otherwise-OK response throws here — fall back to the inline path rather + // than let it escape past the caller's fallback. The inline path re-opens + // the destination with flags: 'w', truncating any partial file left behind. + try { + await this.streamResponseToFile(zipRes, destinationPath, operation); + } catch { + return false; + } return true; },