diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 48eeb18..c06eaa8 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -179,6 +179,84 @@ 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; + } + + // 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; + }, + async checkForExistingUpload( baseUrl: string, auth: AuthContext, @@ -219,6 +297,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 +768,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', + }); + }); + }); });