From 35a40a383461292bd03195c0343fcc1d6ebd64bb Mon Sep 17 00:00:00 2001 From: Josh Duffney Date: Wed, 21 Oct 2026 15:00:27 -0500 Subject: [PATCH 1/2] Authenticate log streams and artifacts with progressive media delivery Extract final delivery implementation from source 1f8ea3c92f03b1516c9483fb02c0c47c6c4ccd48 onto agreed main 2101bd0d61c877caadb5c60d03056c67a0b08296. Retain existing auth, project scoping and report behavior; exclude RBAC and mutation-idempotency changes. Record isolated non-RBAC browser acceptance and remaining deployment/browser limits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f31285c-39b5-461e-9550-48f23fcf2882 --- AGENTS.md | 1 + NOTICE | 30 ++ apps/api/src/routes/requests/video.test.ts | 82 ++- apps/api/src/routes/requests/video.ts | 124 +++-- apps/portal/nginx.conf | 13 + apps/portal/package.json | 1 + apps/portal/public/scope-media-worker.js | 185 +++++++ .../ArtifactDownloadButton.stories.tsx | 64 +++ .../ArtifactDownloadButton.test.tsx | 105 ++++ .../src/components/ArtifactDownloadButton.tsx | 55 ++ .../components/ConversationView.stories.tsx | 41 ++ .../src/components/ConversationView.tsx | 5 +- .../components/HarNetworkViewer.stories.tsx | 32 +- .../src/components/HarNetworkViewer.tsx | 11 +- .../src/components/LogViewer.scroll.test.tsx | 16 +- .../src/components/TurnTimeline.stories.tsx | 37 ++ apps/portal/src/components/TurnTimeline.tsx | 48 +- .../src/components/VideoPlayer.play.test.tsx | 27 + .../src/components/VideoPlayer.stories.tsx | 72 +++ .../src/components/VideoPlayer.test.tsx | 131 +++++ apps/portal/src/components/VideoPlayer.tsx | 60 ++- .../components/artifact-consumers.test.tsx | 75 +++ .../components/artifact-stories.play.test.tsx | 70 +++ .../src/hooks/use-artifact-session.test.tsx | 91 ++++ apps/portal/src/hooks/use-artifact-session.ts | 37 ++ apps/portal/src/hooks/use-log-stream.test.ts | 340 ++++++------ apps/portal/src/hooks/use-log-stream.ts | 141 ++--- .../src/hooks/useHarExtraction.test.tsx | 126 +++++ apps/portal/src/hooks/useHarExtraction.ts | 81 ++- apps/portal/src/lib/api-client.ts | 4 + apps/portal/src/lib/api.ts | 28 +- apps/portal/src/lib/artifact-delivery.test.ts | 287 ++++++++++ apps/portal/src/lib/artifact-delivery.ts | 232 ++++++++ .../src/lib/authenticated-media.test.ts | 508 ++++++++++++++++++ apps/portal/src/lib/authenticated-media.ts | 228 ++++++++ apps/portal/src/lib/log-stream.test.ts | 171 ++++++ apps/portal/src/lib/log-stream.ts | 173 ++++++ apps/portal/src/pages/CodebaseDetail.tsx | 15 +- .../src/pages/RunDetail.artifacts.test.tsx | 89 +++ apps/portal/src/pages/RunDetail.tsx | 71 ++- apps/portal/src/pages/RunsList.tsx | 43 +- docs/architecture/authenticated-media.md | 188 +++++++ docs/architecture/codebases.md | 6 + docs/research/realtime-data-flow.md | 85 ++- pnpm-lock.yaml | 9 + 45 files changed, 3798 insertions(+), 440 deletions(-) create mode 100644 apps/portal/public/scope-media-worker.js create mode 100644 apps/portal/src/components/ArtifactDownloadButton.stories.tsx create mode 100644 apps/portal/src/components/ArtifactDownloadButton.test.tsx create mode 100644 apps/portal/src/components/ArtifactDownloadButton.tsx create mode 100644 apps/portal/src/components/ConversationView.stories.tsx create mode 100644 apps/portal/src/components/TurnTimeline.stories.tsx create mode 100644 apps/portal/src/components/VideoPlayer.play.test.tsx create mode 100644 apps/portal/src/components/VideoPlayer.stories.tsx create mode 100644 apps/portal/src/components/VideoPlayer.test.tsx create mode 100644 apps/portal/src/components/artifact-consumers.test.tsx create mode 100644 apps/portal/src/components/artifact-stories.play.test.tsx create mode 100644 apps/portal/src/hooks/use-artifact-session.test.tsx create mode 100644 apps/portal/src/hooks/use-artifact-session.ts create mode 100644 apps/portal/src/hooks/useHarExtraction.test.tsx create mode 100644 apps/portal/src/lib/artifact-delivery.test.ts create mode 100644 apps/portal/src/lib/artifact-delivery.ts create mode 100644 apps/portal/src/lib/authenticated-media.test.ts create mode 100644 apps/portal/src/lib/authenticated-media.ts create mode 100644 apps/portal/src/lib/log-stream.test.ts create mode 100644 apps/portal/src/lib/log-stream.ts create mode 100644 apps/portal/src/pages/RunDetail.artifacts.test.tsx create mode 100644 docs/architecture/authenticated-media.md diff --git a/AGENTS.md b/AGENTS.md index 409016367..d8327f62d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,6 +256,7 @@ not open the PR against the fork unless the user explicitly asks you to. | [docs/architecture/prompt-evaluations.md](docs/architecture/prompt-evaluations.md) | Static prompt quality, datasets, commands, and maintenance rules | | [docs/architecture/data-organization-projects.md](docs/architecture/data-organization-projects.md) | Projects (a single container) to isolate/group data within a cluster; composes with data-tags and auth-rbac | | [docs/architecture/auth-rbac.md](docs/architecture/auth-rbac.md) | Explicit-login IdP auth, Redis user-access cache, Portal handshake; deferred RBAC/internal-token roadmap | +| [docs/architecture/authenticated-media.md](docs/architecture/authenticated-media.md) | Client-bound authenticated video delivery, progressive ranges, lifecycle and browser/deployment acceptance | | [docs/architecture/vscode-web-worker.md](docs/architecture/vscode-web-worker.md) | XState chat machine, GitHub auth flow, ARIA snapshots | | [docs/architecture/token-manager.md](docs/architecture/token-manager.md) | Token storage, validation, round-robin distribution | | [docs/architecture/criteria-provider.md](docs/architecture/criteria-provider.md) | CriteriaProvider abstraction, filesystem vs REST backends | diff --git a/NOTICE b/NOTICE index 3773e8ed3..a088a96dc 100644 --- a/NOTICE +++ b/NOTICE @@ -8544,6 +8544,36 @@ SOFTWARE. ----------- +The following npm package may be included in this product: + + - eventsource-parser@3.0.6 + +This package contains the following license: + +MIT License + +Copyright (c) 2025 Espen Hovlandsdal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +----------- + The following npm package may be included in this product: - get-proto@1.0.1 diff --git a/apps/api/src/routes/requests/video.test.ts b/apps/api/src/routes/requests/video.test.ts index 629ab6cbd..a737d036a 100644 --- a/apps/api/src/routes/requests/video.test.ts +++ b/apps/api/src/routes/requests/video.test.ts @@ -7,6 +7,8 @@ * - GET /api/v1/requests/:id/runs/:runId/video */ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; +import { PassThrough } from "node:stream"; +import { get } from "node:http"; import supertest from "supertest"; import { app, _injectTestDependencies } from "../../index.js"; import { useTestServer } from "../../test-server.js"; @@ -84,6 +86,8 @@ describe("Video endpoints", () => { expect(res.status).toBe(200); expect(res.headers["content-type"]).toMatch("video/webm"); expect(res.headers["accept-ranges"]).toBe("bytes"); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.headers["content-length"]).toBe("17"); expect(mockGetBlockBlobClient).toHaveBeenCalledWith("req-1/video-0.webm"); }); @@ -137,7 +141,43 @@ describe("Video endpoints", () => { expect(res.status).toBe(206); expect(res.headers["content-range"]).toBe("bytes 0-499/1000"); expect(res.headers["content-length"]).toBe("500"); - expect(mockDownload).toHaveBeenCalledWith(0, 500); + expect(mockDownload).toHaveBeenCalledWith(0, 500, { abortSignal: expect.any(AbortSignal) }); + }); + + it.each([ + ["bytes=5-", 5, 16], + ["bytes=-5", 12, 16], + ["bytes=10-9999", 10, 16], + ["bytes=-9999", 0, 16], + ])("honors the bounded range %s", async (header, start, end) => { + variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] }); + mockDownload.mockResolvedValue({ readableStreamBody: readableFrom("a".repeat(end - start + 1)) }); + const res = await supertest(testServer()).get(vidUrl("req-1")).set("Range", header).buffer(true).parse(rawParser); + expect(res.status).toBe(206); + expect(res.headers["content-range"]).toBe(`bytes ${start}-${end}/17`); + expect(res.headers["content-length"]).toBe(String(end - start + 1)); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(mockDownload).toHaveBeenCalledWith(start, end - start + 1, { abortSignal: expect.any(AbortSignal) }); + }); + + it.each(["bytes=17-", "bytes=9-2", "bytes=-0", "bytes=-", "bytes=0-1,3-4", "junk bytes=0-1", "bytes=9007199254740992-"])( + "rejects an invalid or unsatisfiable range %s without downloading", async header => { + variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] }); + const res = await supertest(testServer()).get(vidUrl("req-1")).set("Range", header); + expect(res.status).toBe(416); + expect(res.headers["content-range"]).toBe("bytes */17"); + expect(res.headers["accept-ranges"]).toBe("bytes"); + expect(mockDownload).not.toHaveBeenCalled(); + }, + ); + + it("returns 416 for a range on an empty recording", async () => { + variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] }); + mockGetProperties.mockResolvedValue({ contentLength: 0 }); + const res = await supertest(testServer()).get(vidUrl("req-1")).set("Range", "bytes=0-"); + expect(res.status).toBe(416); + expect(res.headers["content-range"]).toBe("bytes */0"); + expect(mockDownload).not.toHaveBeenCalled(); }); it("returns 400 for invalid iteration number", async () => { @@ -146,5 +186,45 @@ describe("Video endpoints", () => { expect(res.status).toBe(400); expect(res.body).toMatchObject({ error: "Invalid iteration number" }); }); + + it.each(["index=1junk", "index=0.5", "index=9007199254740992", "index=1&index=0", "iteration=1junk", "iteration=0", "phase=unknown"])( + "rejects an ambiguous video selector %s", async query => { + variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] }); + const res = await supertest(testServer()).get(vidUrl("req-1", `?${query}`)); + expect(res.status).toBe(400); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(mockDownload).not.toHaveBeenCalled(); + }, + ); + + it("aborts blob streaming when the native player disconnects during a range", async () => { + variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] }); + const body = new PassThrough(); + let upstreamSignal: AbortSignal | undefined; + mockDownload.mockImplementation(async (_start, _count, options: { abortSignal: AbortSignal }) => { + upstreamSignal = options.abortSignal; + return { readableStreamBody: body }; + }); + const address = testServer().address(); + if (!address || typeof address === "string") throw new Error("Test server has no TCP address."); + const request = get({ + hostname: "127.0.0.1", port: address.port, path: vidUrl("req-1"), headers: { Range: "bytes=0-16" }, + }); + const received = new Promise((resolve, reject) => { + request.once("error", reject); + request.once("response", response => response.once("data", () => resolve())); + }); + try { + await vi.waitFor(() => expect(upstreamSignal).toBeDefined()); + body.write("first"); + await received; + request.destroy(); + await vi.waitFor(() => expect(upstreamSignal?.aborted).toBe(true)); + await vi.waitFor(() => expect(body.destroyed).toBe(true)); + } finally { + request.destroy(); + body.destroy(); + } + }); }); }); diff --git a/apps/api/src/routes/requests/video.ts b/apps/api/src/routes/requests/video.ts index 4c31f621c..d604eae19 100644 --- a/apps/api/src/routes/requests/video.ts +++ b/apps/api/src/routes/requests/video.ts @@ -5,49 +5,70 @@ import { RestError } from "@azure/storage-blob"; import { z } from "zod"; import type { RunState } from "shared"; import type { Request, Response } from "express"; +import { pipeline } from "node:stream/promises"; import { apiRoute } from "../../openapi/api-route.js"; import type { RouteContext } from "../../route-context.js"; import { resolveRunForRequest } from "./resolve-run.js"; import { createBlobServiceClient, extractBlobName } from "./blob-helpers.js"; +export function parseVideoRange(header: string, size: number): { start: number; end: number } | undefined { + const match = /^bytes=(\d*)-(\d*)$/.exec(header); + if (!match || !size || (!match[1] && !match[2])) return; + const first = match[1] ? Number(match[1]) : undefined; + const last = match[2] ? Number(match[2]) : undefined; + if ((first !== undefined && !Number.isSafeInteger(first)) || (last !== undefined && !Number.isSafeInteger(last))) return; + if (first === undefined) { + if (!last) return; + return { start: Math.max(0, size - last), end: size - 1 }; + } + if (first >= size || (last !== undefined && last < first)) return; + return { start: first, end: Math.min(last ?? size - 1, size - 1) }; +} + async function handleVideo( ctx: RouteContext, req: Request, res: Response, - targetRun: RunState, + targetRun: RunState | undefined, id: string, ): Promise { - const iterationParam = req.query.iteration as string | undefined; - const phaseParam = req.query.phase as string | undefined; - const indexParam = req.query.index as string | undefined; - const videoIndex = indexParam ? parseInt(indexParam, 10) : 0; - - if (isNaN(videoIndex) || videoIndex < 0) { + res.setHeader("Cache-Control", "no-store"); + const iterationParam = req.query.iteration; + const phaseParam = req.query.phase; + const indexParam = req.query.index; + const videoIndex = indexParam === undefined ? 0 + : typeof indexParam === "string" && /^\d+$/.test(indexParam) ? Number(indexParam) : NaN; + if (!Number.isSafeInteger(videoIndex) || videoIndex < 0) { res.status(400).json({ error: "Invalid video index" }); return; } + if (phaseParam !== undefined && phaseParam !== "setup") { + res.status(400).json({ error: "Invalid video phase" }); + return; + } + if (iterationParam !== undefined && (typeof iterationParam !== "string" || + !/^[1-9]\d*$/.test(iterationParam) || !Number.isSafeInteger(Number(iterationParam)))) { + res.status(400).json({ error: "Invalid iteration number" }); + return; + } // Determine the videoUrls array — from setup, a specific turn, or from the top-level document let videoUrls: string[] | undefined; let label: string; if (phaseParam === "setup") { - videoUrls = targetRun.setupVideoUrls; + videoUrls = targetRun?.setupVideoUrls; label = `${id}-setup-video-${videoIndex}`; } else if (iterationParam) { - const iterNum = parseInt(iterationParam, 10); - if (isNaN(iterNum) || iterNum < 1) { - res.status(400).json({ error: "Invalid iteration number" }); - return; - } - const turns = targetRun.turns; + const iterNum = Number(iterationParam); + const turns = targetRun?.turns; const turn = turns?.find((t: { iteration: number }) => t.iteration === iterNum); videoUrls = turn?.videoUrls; label = `${id}-iteration-${iterNum}-video-${videoIndex}`; } else { // One-shot: videoUrls on run; multi-turn fallback: last turn - const turns = targetRun.turns; - videoUrls = targetRun.videoUrls ?? turns?.[turns.length - 1]?.videoUrls; + const turns = targetRun?.turns; + videoUrls = targetRun?.videoUrls ?? turns?.[turns.length - 1]?.videoUrls; label = `${id}-video-${videoIndex}`; } @@ -73,49 +94,38 @@ async function handleVideo( const containerClient = blobServiceClient.getContainerClient("snapshots"); const blobClient = containerClient.getBlockBlobClient(blobName); - // Get blob properties for content length - const properties = await blobClient.getProperties(); - const totalSize = properties.contentLength ?? 0; - - // Support HTTP Range requests for video seeking - const rangeHeader = req.headers.range; - if (rangeHeader && totalSize > 0) { - const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); - if (match) { - const start = parseInt(match[1], 10); - const end = match[2] ? parseInt(match[2], 10) : totalSize - 1; - const chunkSize = end - start + 1; - - const downloadResponse = await blobClient.download(start, chunkSize); - if (!downloadResponse.readableStreamBody) { - res.status(500).json({ error: "Failed to download video file" }); - return; - } - - res.status(206); - res.setHeader("Content-Type", "video/webm"); - res.setHeader("Content-Range", `bytes ${start}-${end}/${totalSize}`); - res.setHeader("Accept-Ranges", "bytes"); - res.setHeader("Content-Length", chunkSize); - downloadResponse.readableStreamBody.pipe(res); + const controller = new AbortController(); + const disconnected = () => { if (!res.writableFinished) controller.abort(); }; + res.once("close", disconnected); + if (req.aborted || res.destroyed) controller.abort(); + try { + const properties = await blobClient.getProperties({ abortSignal: controller.signal }); + const totalSize = properties.contentLength ?? 0; + const rangeHeader = req.headers.range; + const range = rangeHeader === undefined ? undefined : parseVideoRange(rangeHeader, totalSize); + res.setHeader("Accept-Ranges", "bytes"); + if (rangeHeader !== undefined && !range) { + res.setHeader("Content-Range", `bytes */${totalSize}`); + res.status(416).json({ error: "Requested video range is not satisfiable" }); return; } + const count = range ? range.end - range.start + 1 : undefined; + const downloadResponse = await blobClient.download(range?.start ?? 0, count, { abortSignal: controller.signal }); + if (!downloadResponse.readableStreamBody) { + res.status(500).json({ error: "Failed to download video file" }); + return; + } + res.status(range ? 206 : 200); + res.setHeader("Content-Type", "video/webm"); + res.setHeader("Content-Disposition", `inline; filename="${label}.webm"`); + res.setHeader("Content-Length", count ?? totalSize); + if (range) res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${totalSize}`); + await pipeline(downloadResponse.readableStreamBody, res, { signal: controller.signal }); + } catch (error) { + if (!controller.signal.aborted) throw error; + } finally { + res.removeListener("close", disconnected); } - - const downloadResponse = await blobClient.download(); - if (!downloadResponse.readableStreamBody) { - res.status(500).json({ error: "Failed to download video file" }); - return; - } - - res.setHeader("Content-Type", "video/webm"); - res.setHeader("Accept-Ranges", "bytes"); - res.setHeader("Content-Disposition", `inline; filename="${label}.webm"`); - if (totalSize > 0) { - res.setHeader("Content-Length", totalSize); - } - - downloadResponse.readableStreamBody.pipe(res); } export function registerRequestsVideoRoutes(ctx: RouteContext): void { @@ -141,7 +151,7 @@ apiRoute(ctx.app, ctx.registry, { return; } - await handleVideo(ctx, req, res, resource.run!, id); + await handleVideo(ctx, req, res, resource.run, id); } catch (error) { if (error instanceof RestError && (error.statusCode === 404 || error.code === "ContainerNotFound" || error.code === "BlobNotFound")) { res.status(404).json({ error: "Video file not found — the blob may have been deleted or is no longer available" }); diff --git a/apps/portal/nginx.conf b/apps/portal/nginx.conf index 59967c456..8ac69230e 100644 --- a/apps/portal/nginx.conf +++ b/apps/portal/nginx.conf @@ -4,6 +4,19 @@ server { root /usr/share/nginx/html; index index.html; + location = /scope-media-worker.js { + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + try_files $uri =404; + } + + # Uncontrolled/expired media URLs must never fall through to the SPA. + location ^~ /__scope_media__/ { + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options "nosniff" always; + return 403; + } + # API reverse proxy — same origin location /api/ { proxy_pass http://api:80; diff --git a/apps/portal/package.json b/apps/portal/package.json index 1037a9728..36e053ac6 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -32,6 +32,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dagre": "^0.8.5", + "eventsource-parser": "3.0.6", "jsqr": "^1.4.0", "ky": "^2.0.2", "lucide-react": "^0.469.0", diff --git a/apps/portal/public/scope-media-worker.js b/apps/portal/public/scope-media-worker.js new file mode 100644 index 000000000..bccd96cf4 --- /dev/null +++ b/apps/portal/public/scope-media-worker.js @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* This worker holds only client-bound ports, never bearer tokens or cached bytes. */ +const sessions = new Map(); +const bridgePath = new URL("__scope_media__/", self.registration.scope).pathname; +const idleMs = 5 * 60 * 1000; +const responseHeaders = new Set(["content-type", "content-range", "content-length", "accept-ranges", "content-disposition"]); +const validId = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; + +function closeSession(id, message) { + const session = sessions.get(id); + if (!session) return; + sessions.delete(id); + clearTimeout(session.timer); + for (const cancel of session.pending) cancel(message); + session.port.postMessage({ type: "closed", message }); + session.port.close(); +} + +function touch(id, session) { + clearTimeout(session.timer); + session.timer = setTimeout(() => closeSession(id, "Video session expired. Reopen the recording."), idleMs); +} + +self.addEventListener("install", event => event.waitUntil(self.skipWaiting())); +self.addEventListener("activate", event => event.waitUntil(self.clients.claim())); +self.addEventListener("message", event => { + const data = event.data; + if (!data || data.type !== "scope-media-open" || typeof data.id !== "string" || !validId.test(data.id) || + !event.source || event.source.type !== "window" || !event.ports[0]) return; + const source = event.source; + const sourceUrl = new URL(source.url); + if (sourceUrl.origin !== self.location.origin || !sourceUrl.href.startsWith(self.registration.scope)) return; + if (sessions.has(data.id)) return; + const session = { clientId: source.id, port: event.ports[0], pending: new Set(), timer: null }; + sessions.set(data.id, session); + session.port.onmessage = message => { + if (message.data?.type === "close") closeSession(data.id, "Video session closed."); + }; + session.port.onmessageerror = () => closeSession(data.id, "Video connection failed."); + touch(data.id, session); + session.port.postMessage({ type: "ready" }); +}); + +function failure(status, message, forwarded = []) { + const headers = new Headers({ "Cache-Control": "no-store", "Content-Type": "text/plain" }); + for (const [key, value] of forwarded) { + if (["content-range", "accept-ranges"].includes(key.toLowerCase())) headers.set(key, value); + } + return new Response(message, { status, headers }); +} + +function validHeaders(headers) { + return Array.isArray(headers) && headers.every(pair => + Array.isArray(pair) && pair.length === 2 && pair.every(value => typeof value === "string") && + responseHeaders.has(pair[0].toLowerCase())); +} + +async function mediaResponse(event, id) { + const session = sessions.get(id); + if (!session || !event.clientId || event.clientId !== session.clientId) { + return failure(403, "This recording belongs to a different or expired browser client."); + } + if (event.request.method !== "GET") return failure(405, "Only video GET requests are allowed."); + const client = await self.clients.get(session.clientId); + if (!client || new URL(client.url).origin !== self.location.origin) { + closeSession(id, "The originating page is no longer available."); + return failure(410, "Video session closed."); + } + if (sessions.get(id) !== session) return failure(410, "Video session closed."); + if (session.pending.size >= 4) return failure(429, "Too many concurrent video ranges."); + touch(id, session); + return new Promise(resolve => { + const channel = new MessageChannel(); + let stream; + let headersReceived = false; + let finished = false; + let awaitingChunk = false; + let completePull; + let remaining = 0; + let timer; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + session.pending.delete(cancel); + event.request.signal.removeEventListener("abort", abort); + channel.port1.close(); + settlePull(); + }; + const settlePull = () => { + const complete = completePull; + completePull = undefined; + complete?.(); + }; + const cancel = (message, report = true) => { + if (finished) return; + channel.port1.postMessage({ type: "cancel" }); + if (headersReceived) stream?.error(new Error(message)); + else resolve(failure(503, message)); + finish(); + if (report) session.port.postMessage({ type: "failed", message }); + }; + const abort = () => cancel("Video range canceled.", false); + const armTimeout = () => { + clearTimeout(timer); + timer = setTimeout(() => cancel("Video range timed out."), 30_000); + }; + session.pending.add(cancel); + event.request.signal.addEventListener("abort", abort, { once: true }); + channel.port1.onmessageerror = () => cancel("Invalid video stream message."); + channel.port1.onmessage = message => { + if (finished) return; + const data = message.data; + if (data?.type === "headers" && !headersReceived) { + if (![200, 206].includes(data.status) || !validHeaders(data.headers)) { + cancel("Invalid video response."); + return; + } + let headers; + try { headers = new Headers(data.headers); } + catch { cancel("Invalid video response headers."); return; } + const length = headers.get("content-length"); + remaining = length !== null && /^\d+$/.test(length) ? Number(length) : NaN; + if (!Number.isSafeInteger(remaining) || remaining < 0) { + cancel("Invalid video response length."); + return; + } + clearTimeout(timer); + touch(id, session); + headers.set("Cache-Control", "no-store"); + headersReceived = true; + const body = new ReadableStream({ + start(controller) { stream = controller; }, + pull() { + // Keep the Streams pull pending until its MessagePort round trip completes. + return new Promise(resolvePull => { + completePull = resolvePull; + awaitingChunk = true; + armTimeout(); + channel.port1.postMessage({ type: "pull" }); + }); + }, + cancel() { channel.port1.postMessage({ type: "cancel" }); finish(); }, + }, { highWaterMark: 0 }); + resolve(new Response(body, { status: data.status, headers })); + } else if (data?.type === "chunk" && headersReceived && awaitingChunk && + data.bytes instanceof Uint8Array && data.bytes.byteLength > 0 && data.bytes.byteLength <= remaining) { + clearTimeout(timer); + touch(id, session); + awaitingChunk = false; + remaining -= data.bytes.byteLength; + stream.enqueue(data.bytes); + settlePull(); + } else if (data?.type === "end" && headersReceived && awaitingChunk && remaining === 0) { + stream.close(); + finish(); + } else if (data?.type === "error") { + const status = Number.isInteger(data.status) && data.status >= 400 && data.status <= 599 ? data.status : 503; + const text = typeof data.message === "string" ? data.message : "Video delivery failed."; + if (!validHeaders(data.headers ?? [])) { cancel("Invalid video error headers."); return; } + let response; + try { response = failure(status, text, data.headers); } + catch { cancel("Invalid video error headers."); return; } + if (headersReceived) stream.error(new Error(text)); + else resolve(response); + finish(); + session.port.postMessage({ type: "failed", message: text }); + } else cancel("Invalid video stream message."); + }; + armTimeout(); + if (event.request.signal.aborted) abort(); + else session.port.postMessage({ type: "fetch", range: event.request.headers.get("range") }, [channel.port2]); + }); +} + +self.addEventListener("fetch", event => { + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || !url.pathname.startsWith(bridgePath)) return; + const id = url.pathname.slice(bridgePath.length); + event.respondWith(url.search || !validId.test(id) + ? failure(404, "Invalid video bridge route.") + : mediaResponse(event, id)); +}); diff --git a/apps/portal/src/components/ArtifactDownloadButton.stories.tsx b/apps/portal/src/components/ArtifactDownloadButton.stories.tsx new file mode 100644 index 000000000..7e35267aa --- /dev/null +++ b/apps/portal/src/components/ArtifactDownloadButton.stories.tsx @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { delay, http, HttpResponse } from "msw"; +import { expect, userEvent, type within } from "storybook/test"; +import { Toaster } from "sonner"; +import { useProjectContext } from "@/contexts/ProjectContext"; +import { ArtifactDownloadButton } from "./ArtifactDownloadButton"; + +function ProjectSelection({ children }: { children: React.ReactNode }) { + const { setSelectedProjectId } = useProjectContext(); + useEffect(() => setSelectedProjectId("demo-project"), [setSelectedProjectId]); + return <>{children}; +} + +const meta = { + component: ArtifactDownloadButton, + tags: ["ai-generated", "needs-work"], + decorators: [(Story) => ], + args: { url: "/api/v1/requests/demo/runs/attempt-1/archive", children: "Download archive" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Ready: Story = { + parameters: { msw: { handlers: [ + http.get("*/api/v1/requests/demo/runs/attempt-1/archive", () => new HttpResponse( + new Uint8Array([0x1f, 0x8b, 0x08, 0]), + { headers: { "content-type": "application/gzip", "content-disposition": 'attachment; filename="demo.tar.gz"' } }, + )), + ] } }, +}; + +export const PendingAndCancel = { + parameters: { msw: { handlers: [ + http.get("*/api/v1/requests/demo/runs/attempt-1/archive", async () => { + await delay(500); + return new HttpResponse(new Uint8Array([1]), { headers: { "content-type": "application/gzip" } }); + }), + ] } }, + play: async ({ canvas }: { canvas: ReturnType }) => { + const action = await canvas.findByRole("button", { name: "Download archive" }); + await expect(action).toBeEnabled(); + await userEvent.click(action); + await expect(action).toBeDisabled(); + await userEvent.click(await canvas.findByRole("button", { name: "Cancel download" })); + await expect(action).toBeEnabled(); + }, +} satisfies Story; + +export const AccessDenied = { + parameters: { msw: { handlers: [ + http.get("*/api/v1/requests/demo/runs/attempt-1/archive", () => HttpResponse.json( + { error: "Project access was removed." }, { status: 403 }, + )), + ] } }, + play: async ({ canvas }: { canvas: ReturnType }) => { + const action = await canvas.findByRole("button", { name: "Download archive" }); + await userEvent.click(action); + await expect(await canvas.findByText(/HTTP 403/)).toBeVisible(); + }, +} satisfies Story; diff --git a/apps/portal/src/components/ArtifactDownloadButton.test.tsx b/apps/portal/src/components/ArtifactDownloadButton.test.tsx new file mode 100644 index 000000000..aed3434a2 --- /dev/null +++ b/apps/portal/src/components/ArtifactDownloadButton.test.tsx @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ArtifactDownloadButton } from "./ArtifactDownloadButton"; +import { downloadArtifact, downloadBatchArtifacts } from "@/lib/artifact-delivery"; +import { setApiSessionSignal, resetApiClient } from "@/lib/api-client"; +import { toast } from "sonner"; + +const state = vi.hoisted(() => ({ + project: "p1" as string | undefined, + auth: { isReady: true, user: { id: "u1" }, account: null }, +})); +vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => state.auth })); +vi.mock("@/contexts/ProjectContext", () => ({ useSelectedProjectId: () => state.project })); +vi.mock("@/lib/artifact-delivery", () => ({ downloadArtifact: vi.fn(), downloadBatchArtifacts: vi.fn() })); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); + +const firstUrl = "/api/v1/requests/r/runs/a/archive"; +beforeEach(() => { + state.project = "p1"; + state.auth.isReady = true; + vi.clearAllMocks(); +}); +afterEach(() => { cleanup(); resetApiClient(); }); + +describe("artifact download action", () => { + it("guards repeated clicks, exposes pending/cancel, and reports completion", async () => { + let finish!: () => void; + vi.mocked(downloadArtifact).mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const pending = vi.fn(); + const parent = vi.fn(); + render(
Archive
); + const button = screen.getByRole("button", { name: "Archive" }); + act(() => { button.click(); button.click(); }); + expect(downloadArtifact).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + expect(button.getAttribute("aria-busy")).toBe("true"); + expect(button.hasAttribute("disabled")).toBe(true); + expect(screen.getByRole("button", { name: "Cancel download" })).toBeDefined(); + await act(async () => finish()); + expect(pending.mock.calls).toEqual([[true], [false]]); + expect(button.hasAttribute("disabled")).toBe(false); + }); + + it("cancels deliberately without displaying a failure", async () => { + vi.mocked(downloadArtifact).mockImplementation((_url, _project, signal) => new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + })); + render(Archive); + fireEvent.click(screen.getByText("Archive")); + fireEvent.click(screen.getByRole("button", { name: "Cancel download" })); + await waitFor(() => expect(screen.queryByRole("button", { name: "Cancel download" })).toBeNull()); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("surfaces permanent failures instead of pretending to finish a file", async () => { + vi.mocked(downloadArtifact).mockRejectedValue(new Error("HTTP 403: access denied")); + render(Archive); + fireEvent.click(screen.getByText("Archive")); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("HTTP 403: access denied")); + expect(screen.getByText("Archive").closest("button")?.disabled).toBe(false); + }); + + it.each(["project", "attempt", "account", "unmount"] as const)("cancels stale operations on %s without stale error UI", async change => { + let fail!: (reason: Error) => void; + let signal!: AbortSignal; + const account = new AbortController(); + setApiSessionSignal(account.signal); + vi.mocked(downloadArtifact).mockImplementation((_url, _project, scoped) => { + signal = scoped; + return new Promise((_, reject) => { fail = reject; }); + }); + const { rerender, unmount } = render(Archive); + fireEvent.click(screen.getByText("Archive")); + if (change === "project") state.project = "p2"; + if (change === "account") account.abort(); + if (change === "unmount") unmount(); + else rerender(Archive); + expect(signal.aborted).toBe(true); + await act(async () => fail(new Error("Late old-account error"))); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("binds a batch to its selected IDs and cancels when that selection changes", () => { + vi.mocked(downloadBatchArtifacts).mockImplementation(() => new Promise(() => {})); + const { rerender } = render(Batch); + fireEvent.click(screen.getByText("Batch")); + expect(downloadBatchArtifacts).toHaveBeenCalledWith(["a", "b"], "p1", expect.any(AbortSignal)); + const signal = vi.mocked(downloadBatchArtifacts).mock.calls[0][2]; + rerender(Batch); + expect(signal.aborted).toBe(true); + }); + + it("does not allow a download before auth/project readiness", () => { + state.auth.isReady = false; + state.project = undefined; + render(Archive); + fireEvent.click(screen.getByText("Archive")); + expect(downloadArtifact).not.toHaveBeenCalled(); + expect(screen.getByRole("button").hasAttribute("disabled")).toBe(true); + }); +}); diff --git a/apps/portal/src/components/ArtifactDownloadButton.tsx b/apps/portal/src/components/ArtifactDownloadButton.tsx new file mode 100644 index 000000000..37a2e0e59 --- /dev/null +++ b/apps/portal/src/components/ArtifactDownloadButton.tsx @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useRef, useState } from "react"; +import { Loader2, X } from "lucide-react"; +import { toast } from "sonner"; +import { Button, type ButtonProps } from "@/components/ui/button"; +import { useArtifactSession } from "@/hooks/use-artifact-session"; +import { downloadArtifact, downloadBatchArtifacts } from "@/lib/artifact-delivery"; + +interface Props extends Omit { + url: string; + filename?: string; + batchIds?: readonly string[]; + onPendingChange?: (pending: boolean) => void; +} + +export function ArtifactDownloadButton({ url, filename, batchIds, onPendingChange, disabled, children, ...props }: Props) { + const session = useArtifactSession(batchIds ? `${url}#${JSON.stringify(batchIds)}` : url); + const operation = useRef<{ session: typeof session; controller: AbortController } | null>(null); + const [pending, setPending] = useState(null); + const busy = pending === session; + return ( + <> + + {busy && } + + ); +} diff --git a/apps/portal/src/components/ConversationView.stories.tsx b/apps/portal/src/components/ConversationView.stories.tsx new file mode 100644 index 000000000..3434e4459 --- /dev/null +++ b/apps/portal/src/components/ConversationView.stories.tsx @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { expect, type within } from "storybook/test"; +import { useProjectContext } from "@/contexts/ProjectContext"; +import { ConversationView } from "./ConversationView"; + +const meta = { + component: ConversationView, + tags: ["ai-generated", "needs-work"], + decorators: [function SelectedProject(Story) { + const { setSelectedProjectId } = useProjectContext(); + useEffect(() => setSelectedProjectId("demo-project"), [setSelectedProjectId]); + return ; + }], + args: { + runId: "demo", attemptRunId: "attempt-1", + turns: [{ + iteration: 1, timestamp: "2026-01-01T12:00:00Z", passed: true, + codingAgentResponse: "The stored response remains available.", judgeFeedback: "Passed.", + snapshotUrl: "", harUrl: "stored-har", + }], + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const HarAccessDenied = { + parameters: { msw: { handlers: [ + http.get("*/api/v1/requests/demo/runs/attempt-1/har", () => HttpResponse.json( + { error: "Project access was removed." }, { status: 403 }, + )), + ] } }, + play: async ({ canvas }: { canvas: ReturnType }) => { + await expect(await canvas.findByRole("alert")).toHaveTextContent("HTTP 403"); + await expect(canvas.getByText("The stored response remains available.")).toBeVisible(); + }, +} satisfies Story; diff --git a/apps/portal/src/components/ConversationView.tsx b/apps/portal/src/components/ConversationView.tsx index 44f20cd1d..204928e81 100644 --- a/apps/portal/src/components/ConversationView.tsx +++ b/apps/portal/src/components/ConversationView.tsx @@ -183,7 +183,7 @@ function ToolCallInline({ tc }: { tc: ToolCall }) { function TurnMessages({ turn, scopedIteration, runId, attemptRunId }: { turn: ConversationTurn; scopedIteration: number; runId: string; attemptRunId?: string }) { const hasHar = !!turn.harUrl; - const { data: harData, isLoading: harLoading } = useHarExtraction(runId, turn.iteration, hasHar, attemptRunId); + const { data: harData, isLoading: harLoading, error: harError } = useHarExtraction(runId, turn.iteration, hasHar, attemptRunId); const segments = harData?.segments ?? []; const hasContentSegment = segments.some((s) => s.type === "content"); @@ -214,6 +214,9 @@ function TurnMessages({ turn, scopedIteration, runId, attemptRunId }: { turn: Co )} {/* Chronological segments from HAR */} + {harError &&

+ Unable to load HAR data: {harError.message} +

} {segments.map((segment, idx) => ( ))} diff --git a/apps/portal/src/components/HarNetworkViewer.stories.tsx b/apps/portal/src/components/HarNetworkViewer.stories.tsx index b280e342e..4ca3ab00e 100644 --- a/apps/portal/src/components/HarNetworkViewer.stories.tsx +++ b/apps/portal/src/components/HarNetworkViewer.stories.tsx @@ -3,8 +3,17 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { http, HttpResponse } from "msw"; +import { useEffect } from "react"; +import { expect, type within } from "storybook/test"; +import { useProjectContext } from "@/contexts/ProjectContext"; import { HarNetworkViewer } from "./HarNetworkViewer"; +function ProjectSelection({ children }: { children: React.ReactNode }) { + const { setSelectedProjectId } = useProjectContext(); + useEffect(() => setSelectedProjectId("demo-project"), [setSelectedProjectId]); + return <>{children}; +} + const base = new Date("2026-06-21T12:00:00Z").getTime(); const methods = ["GET", "POST", "GET", "PUT", "GET", "DELETE"]; @@ -53,14 +62,14 @@ const meta = { // detail panel's sticky offset renders the way it does in the portal. decorators: [ (Story) => ( - <> +
App header
- +
), ], } satisfies Meta; @@ -72,3 +81,22 @@ type Story = StoryObj; export const LongList: Story = { args: { runId: "demo-run" }, }; + +export const HistoricalAttempt: Story = { + args: { runId: "demo-run", attemptRunId: "attempt-1", iteration: 2 }, + parameters: { msw: { handlers: [ + http.get("*/api/v1/requests/demo-run/runs/attempt-1/har", () => HttpResponse.json(sampleHar)), + ] } }, +}; + +export const AccessDenied = { + args: { runId: "denied-run" }, + parameters: { msw: { handlers: [ + http.get("*/api/v1/requests/denied-run/har", () => HttpResponse.json( + { error: "Project access was removed." }, { status: 403 }, + )), + ] } }, + play: async ({ canvas }: { canvas: ReturnType }) => { + await expect(await canvas.findByRole("alert")).toHaveTextContent("HTTP 403"); + }, +} satisfies Story; diff --git a/apps/portal/src/components/HarNetworkViewer.tsx b/apps/portal/src/components/HarNetworkViewer.tsx index 315d41fe5..224dd2d78 100644 --- a/apps/portal/src/components/HarNetworkViewer.tsx +++ b/apps/portal/src/components/HarNetworkViewer.tsx @@ -8,7 +8,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { Download, Search, X, ChevronDown, ChevronRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { ArtifactDownloadButton } from "@/components/ArtifactDownloadButton"; // --------------------------------------------------------------------------- // Types (inline HAR 1.2 subset — no shared dep needed for the portal) @@ -193,7 +193,7 @@ export function HarNetworkViewer({ runId, iteration, attemptRunId }: HarNetworkV if (error) { return ( -
+
Failed to load HAR data: {error instanceof Error ? error.message : String(error)}
); @@ -217,15 +217,16 @@ export function HarNetworkViewer({ runId, iteration, attemptRunId }: HarNetworkV {formatBytes(totalSize)} transferred {formatMs(totalTime)} total
- +
{/* Filter bar */} diff --git a/apps/portal/src/components/LogViewer.scroll.test.tsx b/apps/portal/src/components/LogViewer.scroll.test.tsx index cb2950c4d..7692c1f8a 100644 --- a/apps/portal/src/components/LogViewer.scroll.test.tsx +++ b/apps/portal/src/components/LogViewer.scroll.test.tsx @@ -3,10 +3,24 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render as renderComponent, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { AuthContext } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; +import { ProjectProvider } from "@/contexts/ProjectContext"; import { LogViewer } from "./LogViewer"; import type { LogEvent } from "@/types"; +function render(ui: ReactNode) { + return renderComponent(ui, { + wrapper: ({ children }) => ( + + {children} + + ), + }); +} + class ResizeObserverMock { observe = vi.fn(); unobserve = vi.fn(); diff --git a/apps/portal/src/components/TurnTimeline.stories.tsx b/apps/portal/src/components/TurnTimeline.stories.tsx new file mode 100644 index 000000000..e8e963685 --- /dev/null +++ b/apps/portal/src/components/TurnTimeline.stories.tsx @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { useProjectContext } from "@/contexts/ProjectContext"; +import { TurnTimeline } from "./TurnTimeline"; + +const meta = { + component: TurnTimeline, + tags: ["ai-generated", "needs-work"], + decorators: [function SelectedProject(Story) { + const { setSelectedProjectId } = useProjectContext(); + useEffect(() => setSelectedProjectId("demo-project"), [setSelectedProjectId]); + return ; + }], + args: { + runId: "demo", + attemptRunId: "attempt-1", + turns: [{ + iteration: 2, timestamp: "2026-01-01T12:00:00Z", passed: true, + codingAgentResponse: "Implemented the requested behavior.", judgeFeedback: "All checks passed.", + snapshotUrl: "stored-snapshot", harUrl: "stored-har", atifUrl: "stored-atif", + }], + }, + parameters: { msw: { handlers: [ + http.get("*/api/v1/requests/demo/runs/attempt-1/*", () => HttpResponse.json( + { error: "Artifact is no longer available." }, { status: 404 }, + )), + ] } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const HistoricalDownloads: Story = {}; +export const LegacyLatestDownloads: Story = { args: { attemptRunId: undefined } }; diff --git a/apps/portal/src/components/TurnTimeline.tsx b/apps/portal/src/components/TurnTimeline.tsx index da52d8994..496049127 100644 --- a/apps/portal/src/components/TurnTimeline.tsx +++ b/apps/portal/src/components/TurnTimeline.tsx @@ -12,15 +12,19 @@ import { cn } from "@/lib/utils"; import { formatDuration } from "@/lib/utils"; import type { ConversationTurn } from "@/types"; import { useState } from "react"; +import { ArtifactDownloadButton } from "@/components/ArtifactDownloadButton"; +import { VideoPlayer } from "@/components/VideoPlayer"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; interface TurnTimelineProps { turns: ConversationTurn[]; runId: string; - /** If provided, uses per-run URLs for a specific historical attempt */ + /** Pins artifacts to this attempt, including the currently selected attempt. */ attemptRunId?: string; } export function TurnTimeline({ turns, runId, attemptRunId }: TurnTimelineProps) { + const [videoIteration, setVideoIteration] = useState(); const [expandedTurns, setExpandedTurns] = useState>( // Expand last turn by default new Set(turns.length > 0 ? [turns[turns.length - 1].iteration] : []) @@ -89,46 +93,40 @@ export function TurnTimeline({ turns, runId, attemptRunId }: TurnTimelineProps) )} {turn.snapshotUrl && ( - + )} {turn.harUrl && ( - + )} {turn.atifUrl && ( - + )} {turn.videoUrls && turn.videoUrls.length > 0 && (
); } diff --git a/apps/portal/src/components/VideoPlayer.play.test.tsx b/apps/portal/src/components/VideoPlayer.play.test.tsx new file mode 100644 index 000000000..74ea7d358 --- /dev/null +++ b/apps/portal/src/components/VideoPlayer.play.test.tsx @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { composeStories } from "@storybook/react-vite"; +import { cleanup, render, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import * as stories from "./VideoPlayer.stories"; +import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; + +beforeEach(() => { + vi.stubGlobal("navigator", { serviceWorker: { + getRegistrations: async () => [{ scope: window.location.origin + "/", active: { scriptURL: window.location.origin + "/mockServiceWorker.js" } }], + addEventListener() {}, removeEventListener() {}, + } }); + Object.defineProperty(window, "isSecureContext", { value: true, configurable: true }); +}); +afterEach(() => { cleanup(); localStorage.removeItem(PROJECT_STORAGE_KEY); vi.unstubAllGlobals(); }); + +describe("VideoPlayer stories", () => { + for (const [name, Story] of Object.entries(composeStories(stories))) { + it(name, async () => { + const { container } = render(); + await Story.play?.({ canvas: within(container), canvasElement: container }); + }); + } +}); diff --git a/apps/portal/src/components/VideoPlayer.stories.tsx b/apps/portal/src/components/VideoPlayer.stories.tsx new file mode 100644 index 000000000..97a2184f1 --- /dev/null +++ b/apps/portal/src/components/VideoPlayer.stories.tsx @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useLayoutEffect, useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent } from "storybook/test"; +import { AuthContext } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; +import { ProjectProvider, useProjectContext } from "@/contexts/ProjectContext"; +import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; +import { VideoPlayer } from "./VideoPlayer"; + +const meta = { + component: VideoPlayer, + tags: ["ai-generated", "needs-work"], + args: { src: "/api/v1/requests/example/runs/attempt-1/video", label: "Attempt 1 recording" }, + parameters: { + docs: { description: { component: + "Protected playback uses the app's dedicated service worker. Storybook already owns a root MSW worker, " + + "so the real component must show a conflict rather than replace MSW or use an eager whole-file blob. " + + "Progressive playback and seeking require the application browser acceptance described in authenticated-media.md.", + } }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function SelectedVideo({ project = true }: { project?: boolean }) { + const { setSelectedProjectId } = useProjectContext(); + const previous = useRef(localStorage.getItem(PROJECT_STORAGE_KEY)); + useLayoutEffect(() => { + setSelectedProjectId(project ? "demo-project" : undefined); + return () => { + if (previous.current === null) localStorage.removeItem(PROJECT_STORAGE_KEY); + else localStorage.setItem(PROJECT_STORAGE_KEY, previous.current); + }; + }, [project, setSelectedProjectId]); + return ; +} + +function Scenario({ project }: { project?: boolean }) { + return + + ; +} + +export const ServiceWorkerConflict: Story = { + render: () => , + play: async ({ canvas }) => { + const alert = await canvas.findByRole("alert"); + await expect(alert).toHaveTextContent("Another service worker controls this app."); + await expect(canvas.getByRole("button", { name: "Retry recording" })).toBeVisible(); + }, +}; + +export const RetryConflict: Story = { + render: () => , + play: async ({ canvas }) => { + await canvas.findByRole("alert"); + await userEvent.click(canvas.getByRole("button", { name: "Retry recording" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Another service worker controls this app."); + }, +}; + +export const AwaitingProject: Story = { + render: () => , + play: async ({ canvas }) => { + const video = canvas.getByLabelText("Attempt 1 recording"); + await expect(video).not.toHaveAttribute("src"); + await expect(video).toHaveAttribute("preload", "metadata"); + }, +}; diff --git a/apps/portal/src/components/VideoPlayer.test.tsx b/apps/portal/src/components/VideoPlayer.test.tsx new file mode 100644 index 000000000..35ca577da --- /dev/null +++ b/apps/portal/src/components/VideoPlayer.test.tsx @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { StrictMode } from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { AuthContext, type AuthContextValue } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; +import { ProjectProvider, useProjectContext } from "@/contexts/ProjectContext"; +import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; +import { resetApiClient, setApiSessionSignal } from "@/lib/api-client"; +import { openAuthenticatedMedia, type MediaBridge } from "@/lib/authenticated-media"; +import { VideoPlayer } from "./VideoPlayer"; + +vi.mock("@/lib/authenticated-media", () => ({ openAuthenticatedMedia: vi.fn() })); +const open = vi.mocked(openAuthenticatedMedia); +const path = "/api/v1/requests/one/runs/attempt-1/video"; +const secondPath = "/api/v1/requests/one/runs/attempt-2/video"; +const bridge = () => ({ url: "https://scope.test/__scope_media__/example", close: vi.fn() }); + +function ProjectSwitch() { + const { setSelectedProjectId } = useProjectContext(); + return ; +} +function tree(src = path, auth: AuthContextValue = signedInAuth) { + return + + ; +} + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.setItem(PROJECT_STORAGE_KEY, "demo-project"); + open.mockImplementation(async () => bridge()); +}); +afterEach(() => { cleanup(); localStorage.removeItem(PROJECT_STORAGE_KEY); resetApiClient(); }); + +it("uses only the bridge URL with metadata preload, never a direct protected src", async () => { + render(tree()); + await waitFor(() => expect(screen.getByLabelText("Recording").getAttribute("src")).toContain("/__scope_media__/")); + expect(open).toHaveBeenCalledWith(path, "demo-project", expect.any(AbortSignal), expect.any(Function)); + expect(screen.getByLabelText("Recording").getAttribute("preload")).toBe("metadata"); +}); + +it("defers bridge creation until project and auth are ready", () => { + render(tree(path, { ...signedInAuth, isReady: false })); + expect(open).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Recording").getAttribute("src")).toBeNull(); +}); + +it("shows explicit failure and retries with a fresh bridge", async () => { + open.mockRejectedValueOnce(new Error("Another service worker controls this app.")); + render(tree()); + expect((await screen.findByRole("alert")).textContent).toContain("Another service worker"); + fireEvent.click(screen.getByRole("button", { name: "Retry recording" })); + await waitFor(() => expect(screen.getByLabelText("Recording").getAttribute("src")).toContain("/__scope_media__/")); + expect(open).toHaveBeenCalledTimes(2); +}); + +it("closes bridge and clears the native source on unmount", async () => { + const delivery = bridge(); + open.mockResolvedValue(delivery); + const view = render(tree()); + const video = screen.getByLabelText("Recording"); + await waitFor(() => expect(video.getAttribute("src")).toBe(delivery.url)); + const signal = open.mock.calls[0][2]; + view.unmount(); + expect(signal.aborted).toBe(true); + expect(delivery.close).toHaveBeenCalled(); + expect(video.getAttribute("src")).toBeNull(); +}); + +it.each(["attempt", "project", "account"])("hides stale playback and cancels the old %s identity", async change => { + const first = bridge(); + let resolve!: (value: MediaBridge) => void; + open.mockResolvedValueOnce(first).mockImplementationOnce(() => new Promise(done => { resolve = done; })); + const view = render(tree()); + await waitFor(() => expect(screen.getByLabelText("Recording").getAttribute("src")).toBe(first.url)); + const oldSignal = open.mock.calls[0][2]; + if (change === "attempt") view.rerender(tree(secondPath)); + else if (change === "project") fireEvent.click(screen.getByRole("button", { name: "Switch project" })); + else view.rerender(tree(path, { ...signedInAuth, account: { ...signedInAuth.account!, homeAccountId: "new-account" } })); + await waitFor(() => expect(open).toHaveBeenCalledTimes(2)); + expect(oldSignal.aborted).toBe(true); + expect(first.close).toHaveBeenCalled(); + expect(screen.getByLabelText("Recording").getAttribute("src")).toBeNull(); + await act(async () => resolve(bridge())); +}); + +it("does not publish a late bridge after logout and closes it instead", async () => { + let resolve!: (value: MediaBridge) => void; + open.mockImplementationOnce(() => new Promise(done => { resolve = done; })); + const account = new AbortController(); + setApiSessionSignal(account.signal); + render(tree()); + const delivery = bridge(); + account.abort(); + await act(async () => resolve(delivery)); + expect(delivery.close).toHaveBeenCalled(); + expect(screen.getByLabelText("Recording").getAttribute("src")).toBeNull(); +}); + +it("clears already-playing native media synchronously when the account signal aborts", async () => { + const account = new AbortController(); + setApiSessionSignal(account.signal); + render(tree()); + const video = screen.getByLabelText("Recording"); + await waitFor(() => expect(video.getAttribute("src")).toContain("/__scope_media__/")); + account.abort(); + expect(video.getAttribute("src")).toBeNull(); + expect(open.mock.calls[0][2].aborted).toBe(true); +}); + +it("survives StrictMode setup/cleanup without retaining an aborted bridge", async () => { + render({tree()}); + await waitFor(() => expect(screen.getByLabelText("Recording").getAttribute("src")).toContain("/__scope_media__/")); + expect(open.mock.calls[0][2].aborted).toBe(true); + expect(open.mock.calls.at(-1)![2].aborted).toBe(false); +}); + +it("closes delivery after a native playback/worker-restart error", async () => { + const delivery = bridge(); + open.mockResolvedValue(delivery); + render(tree()); + const video = screen.getByLabelText("Recording"); + await waitFor(() => expect(video.getAttribute("src")).toBe(delivery.url)); + fireEvent.error(video); + expect(delivery.close).toHaveBeenCalled(); + expect(screen.getByRole("alert").textContent).toContain("Unable to play the recording"); +}); diff --git a/apps/portal/src/components/VideoPlayer.tsx b/apps/portal/src/components/VideoPlayer.tsx index df7af07d7..8277af469 100644 --- a/apps/portal/src/components/VideoPlayer.tsx +++ b/apps/portal/src/components/VideoPlayer.tsx @@ -2,26 +2,80 @@ // Licensed under the MIT License. import { Card, CardContent } from "@/components/ui/card"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useArtifactSession } from "@/hooks/use-artifact-session"; +import { openAuthenticatedMedia, type MediaBridge } from "@/lib/authenticated-media"; interface VideoPlayerProps { src: string; label?: string; } +function stopVideo(element: HTMLVideoElement | null) { + if (element) { element.pause(); element.removeAttribute("src"); element.load(); } +} + export function VideoPlayer({ src, label }: VideoPlayerProps) { + const session = useArtifactSession(src); + const video = useRef(null); + const activeBridge = useRef(undefined); + const [retry, setRetry] = useState(0); + const [state, setState] = useState<{ session: typeof session; url?: string; error?: string }>(); + const current = state?.session === session ? state : undefined; + useEffect(() => { + if (!session.ready) return; + if (typeof AbortSignal.any !== "function") { + setState({ session, error: "Authenticated video requires a browser with abortable streaming support." }); + return; + } + const controller = new AbortController(); + const signal = AbortSignal.any([session.controller.signal, controller.signal]); + let bridge: MediaBridge | undefined; + const element = video.current; + const stop = () => stopVideo(element); + signal.addEventListener("abort", stop, { once: true }); + void openAuthenticatedMedia(src, session.projectId!, signal, error => { + if (!signal.aborted) { stop(); setState({ session, error }); } + }).then(result => { + if (signal.aborted) result.close(); + else { bridge = result; activeBridge.current = result; setState({ session, url: result.url }); } + }).catch((cause: unknown) => { + if (!signal.aborted) setState({ session, error: cause instanceof Error ? cause.message : "Unable to open recording." }); + }); + return () => { + controller.abort(); + signal.removeEventListener("abort", stop); + bridge?.close(); + if (activeBridge.current === bridge) activeBridge.current = undefined; + stop(); + }; + }, [session, src, retry]); return ( {label && (

{label}

)} - }
); diff --git a/apps/portal/src/components/artifact-consumers.test.tsx b/apps/portal/src/components/artifact-consumers.test.tsx new file mode 100644 index 000000000..3674d0b78 --- /dev/null +++ b/apps/portal/src/components/artifact-consumers.test.tsx @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TurnTimeline } from "./TurnTimeline"; +import { CodebaseDetail } from "@/pages/CodebaseDetail"; +import { api } from "@/lib/api"; +import { downloadArtifact } from "@/lib/artifact-delivery"; +import type { CodebaseDocument, CodebaseRevisionDocument, ConversationTurn } from "@/types"; + +vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ + isReady: true, user: { id: "u1" }, account: null, +}) })); +vi.mock("@/contexts/ProjectContext", () => ({ useSelectedProjectId: () => "project-1" })); +vi.mock("@/lib/artifact-delivery", () => ({ downloadArtifact: vi.fn(), downloadBatchArtifacts: vi.fn() })); + +const turn: ConversationTurn = { + iteration: 2, passed: true, timestamp: "2026-01-01T12:00:00Z", + codingAgentResponse: "Stored response", judgeFeedback: "Passed", + snapshotUrl: "https://storage.test/private-snapshot", harUrl: "https://storage.test/private-har", + atifUrl: "https://storage.test/private-atif", +}; +beforeEach(() => { vi.mocked(downloadArtifact).mockResolvedValue(); }); +afterEach(() => { cleanup(); vi.clearAllMocks(); vi.restoreAllMocks(); }); + +describe("protected artifact consumers", () => { + it.each(["current-attempt", "historical-attempt", undefined])("pins snapshot/HAR/ATIF to %s", async attempt => { + render(); + for (const name of ["Snapshot", "HAR", "ATIF"]) { + fireEvent.click(screen.getByRole("button", { name })); + } + await waitFor(() => expect(downloadArtifact).toHaveBeenCalledTimes(3)); + const base = `/api/v1/requests/request-1${attempt ? `/runs/${attempt}` : ""}`; + expect(vi.mocked(downloadArtifact).mock.calls.map(call => call[0])).toEqual([ + `${base}/snapshots/2`, `${base}/har?iteration=2`, `${base}/atif?iteration=2`, + ]); + expect(vi.mocked(downloadArtifact).mock.calls.every(call => call[1] === "project-1")).toBe(true); + expect(screen.getByText("Stored response")).toBeDefined(); + expect(document.querySelector("a[href*='storage.test']")).toBeNull(); + }); + + it("routes BOTH codebase archive controls through authenticated delivery rather than stored origins", async () => { + const codebase: CodebaseDocument = { + _id: "codebase-1", slug: "example", name: "Example", sourceType: "archive", + revisionCounter: 1, createdAt: "2026-01-01T00:00:00Z", + }; + const revision: CodebaseRevisionDocument = { + _id: "revision-1", codebaseId: codebase._id, slug: "example", revisionNumber: 1, + ref: "example@1", sourceType: "archive", archiveUrl: "https://foreign.test/stored-archive", + createdAt: codebase.createdAt, resolvedAt: codebase.createdAt, + }; + vi.spyOn(api, "getCodebase").mockResolvedValue(codebase); + vi.spyOn(api, "listCodebaseRevisions").mockResolvedValue([revision]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + } /> + + ); + fireEvent.click(await screen.findByRole("button", { name: "Download" })); + fireEvent.click(screen.getByRole("button", { name: /\/api\/v1\/codebase-revisions\/revision-1\/archive/ })); + await waitFor(() => expect(downloadArtifact).toHaveBeenCalledTimes(2)); + for (const call of vi.mocked(downloadArtifact).mock.calls) { + expect(call[0]).toBe("/api/v1/codebase-revisions/revision-1/archive"); + expect(call[1]).toBe("project-1"); + expect(call[3]).toBe("example@1.tar.gz"); + } + expect(document.querySelector("a[href*='foreign.test']")).toBeNull(); + client.clear(); + }); +}); diff --git a/apps/portal/src/components/artifact-stories.play.test.tsx b/apps/portal/src/components/artifact-stories.play.test.tsx new file mode 100644 index 000000000..e174b172e --- /dev/null +++ b/apps/portal/src/components/artifact-stories.play.test.tsx @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { afterAll, afterEach, beforeAll, describe, it } from "vitest"; +import { cleanup, render, within } from "@testing-library/react"; +import { composeStories } from "@storybook/react-vite"; +import { setupServer } from "msw/node"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { AuthContext } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; +import { ProjectProvider } from "@/contexts/ProjectContext"; +import * as downloads from "./ArtifactDownloadButton.stories"; +import * as network from "./HarNetworkViewer.stories"; +import * as conversation from "./ConversationView.stories"; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); +afterEach(() => { cleanup(); toast.dismiss(); server.resetHandlers(); }); + +describe("artifact interaction stories", () => { + const downloadStories = composeStories(downloads); + it("shows pending state and permits cancellation", async () => { + server.use(...downloads.PendingAndCancel.parameters.msw.handlers); + const client = new QueryClient(); + const { container } = render( + + + + ); + await downloads.PendingAndCancel.play({ canvas: within(container) }); + client.clear(); + }); + + it("shows a download authorization failure", async () => { + server.use(...downloads.AccessDenied.parameters.msw.handlers); + const { container } = render( + + ); + await downloads.AccessDenied.play({ canvas: within(container) }); + }); + + it("shows a HAR authorization failure", async () => { + server.use(...network.AccessDenied.parameters.msw.handlers); + const stories = composeStories(network); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { container } = render( + + + + ); + await network.AccessDenied.play({ canvas: within(container) }); + client.clear(); + }); + + it("shows a conversation HAR failure without hiding the stored response", async () => { + server.use(...conversation.HarAccessDenied.parameters.msw.handlers); + const stories = composeStories(conversation); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { container } = render( + + + + ); + await conversation.HarAccessDenied.play({ canvas: within(container) }); + client.clear(); + }); +}); diff --git a/apps/portal/src/hooks/use-artifact-session.test.tsx b/apps/portal/src/hooks/use-artifact-session.test.tsx new file mode 100644 index 000000000..601ba6b83 --- /dev/null +++ b/apps/portal/src/hooks/use-artifact-session.test.tsx @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { StrictMode } from "react"; +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setApiSessionSignal, resetApiClient } from "@/lib/api-client"; +import { useArtifactSession } from "./use-artifact-session"; + +const state = vi.hoisted(() => ({ + project: "p1" as string | undefined, + auth: { isReady: true, user: { id: "u1" }, account: { + homeAccountId: "home", localAccountId: "local", tenantId: "tenant", environment: "idp", + } }, +})); +vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => state.auth })); +vi.mock("@/contexts/ProjectContext", () => ({ useSelectedProjectId: () => state.project })); + +beforeEach(() => { + state.project = "p1"; + state.auth.isReady = true; + state.auth.user.id = "u1"; + state.auth.account.homeAccountId = "home"; +}); +afterEach(() => { cleanup(); resetApiClient(); }); + +describe("artifact session identity", () => { + it("is stable for unrelated rerenders and replaces identity for each attempt", () => { + const { result, rerender } = renderHook(({ resource }) => useArtifactSession(resource), { + initialProps: { resource: "request/attempt-1" }, + }); + const first = result.current; + rerender({ resource: "request/attempt-1" }); + expect(result.current).toBe(first); + rerender({ resource: "request/attempt-2" }); + expect(first.controller.signal.aborted).toBe(true); + expect(result.current.controller.signal.aborted).toBe(false); + expect(result.current.key).not.toEqual(first.key); + }); + + it.each(["project", "user", "account", "logout"] as const)("aborts and isolates on %s change", change => { + const { result, rerender } = renderHook(() => useArtifactSession("request/attempt")); + const first = result.current; + if (change === "project") state.project = "p2"; + if (change === "user") state.auth.user.id = "u2"; + if (change === "account") state.auth.account.homeAccountId = "home2"; + if (change === "logout") state.auth.isReady = false; + rerender(); + expect(first.controller.signal.aborted).toBe(true); + expect(result.current).not.toBe(first); + expect(result.current.ready).toBe(change !== "logout"); + }); + + it("cancels immediately on global account abort, before any rerender", () => { + const account = new AbortController(); + setApiSessionSignal(account.signal); + const { result } = renderHook(() => useArtifactSession("r/a")); + expect(result.current.accountSignal).toBe(account.signal); + account.abort(); + expect(result.current.controller.signal.aborted).toBe(true); + }); + + it("survives StrictMode replay and aborts on unmount", () => { + const { result, unmount } = renderHook(() => useArtifactSession("r/a"), { wrapper: StrictMode }); + const signal = result.current.controller.signal; + expect(signal.aborted).toBe(false); + unmount(); + expect(signal.aborted).toBe(true); + }); + + it("isolates a new login epoch even when the same account signs in again", () => { + setApiSessionSignal(new AbortController().signal); + const { result, rerender } = renderHook(() => useArtifactSession("r/a")); + const first = result.current; + setApiSessionSignal(new AbortController().signal); + rerender(); + expect(first.controller.signal.aborted).toBe(true); + expect(result.current.key).not.toEqual(first.key); + }); + + it("does not enable requests without a selected project or with a cancelled account", () => { + state.project = undefined; + const account = new AbortController(); + account.abort(); + setApiSessionSignal(account.signal); + const { result } = renderHook(() => useArtifactSession("r/a")); + expect(result.current.ready).toBe(false); + expect(result.current.controller.signal.aborted).toBe(true); + }); +}); diff --git a/apps/portal/src/hooks/use-artifact-session.ts b/apps/portal/src/hooks/use-artifact-session.ts new file mode 100644 index 000000000..09362ee60 --- /dev/null +++ b/apps/portal/src/hooks/use-artifact-session.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useLayoutEffect, useMemo } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { useSelectedProjectId } from "@/contexts/ProjectContext"; +import { getApiSessionSignal } from "@/lib/api-client"; + +const accountKeys = new WeakMap(); + +export function useArtifactSession(resource: string) { + const { account, user, isReady } = useAuth(); + const projectId = useSelectedProjectId(); + const accountSignal = getApiSessionSignal(); + if (accountSignal && !accountKeys.has(accountSignal)) accountKeys.set(accountSignal, crypto.randomUUID()); + const session = useMemo(() => ({ + resource, projectId, ready: isReady && !!projectId, controller: new AbortController(), + accountSignal, + key: [resource, projectId, user?.id, account?.homeAccountId, account?.localAccountId, + account?.tenantId, account?.environment, accountSignal ? accountKeys.get(accountSignal) : undefined], + }), [ + resource, projectId, isReady, user?.id, account?.homeAccountId, + account?.localAccountId, account?.tenantId, account?.environment, accountSignal, + ]); + useLayoutEffect(() => { + const controller = new AbortController(); + session.controller = controller; + const cancel = () => controller.abort(); + if (session.accountSignal?.aborted) cancel(); + session.accountSignal?.addEventListener("abort", cancel, { once: true }); + return () => { + session.accountSignal?.removeEventListener("abort", cancel); + controller.abort(); + }; + }, [session]); + return session; +} diff --git a/apps/portal/src/hooks/use-log-stream.test.ts b/apps/portal/src/hooks/use-log-stream.test.ts index 71ce904d1..f49711e5a 100644 --- a/apps/portal/src/hooks/use-log-stream.test.ts +++ b/apps/portal/src/hooks/use-log-stream.test.ts @@ -1,178 +1,212 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -type CleanupFn = (() => void) | void; - -class HookRuntime { - stateSlots: unknown[] = []; - refSlots: Array<{ current: unknown }> = []; - effectSlots: Array<{ deps?: unknown[]; cleanup?: CleanupFn }> = []; - stateIndex = 0; - refIndex = 0; - effectIndex = 0; - - beginRender() { - this.stateIndex = 0; - this.refIndex = 0; - this.effectIndex = 0; - } - - unmount() { - for (const effect of this.effectSlots) effect.cleanup?.(); - this.effectSlots = []; - } -} - -const runtime = new HookRuntime(); - -vi.mock("react", () => ({ - useState(initialValue: T | (() => T)) { - const index = runtime.stateIndex++; - if (runtime.stateSlots[index] === undefined) { - runtime.stateSlots[index] = - typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; - } - const setState = (value: T | ((prev: T) => T)) => { - const prev = runtime.stateSlots[index] as T; - runtime.stateSlots[index] = typeof value === "function" ? (value as (prev: T) => T)(prev) : value; - }; - return [runtime.stateSlots[index] as T, setState] as const; - }, - useRef(initialValue: T) { - const index = runtime.refIndex++; - if (!runtime.refSlots[index]) runtime.refSlots[index] = { current: initialValue }; - return runtime.refSlots[index] as { current: T }; - }, - useEffect(effect: () => CleanupFn, deps?: unknown[]) { - const index = runtime.effectIndex++; - const current = runtime.effectSlots[index]; - const changed = !current?.deps - || !deps - || current.deps.length !== deps.length - || deps.some((dep, i) => dep !== current.deps?.[i]); - if (!changed) return; - - current?.cleanup?.(); - runtime.effectSlots[index] = { - deps, - cleanup: effect(), - }; - }, - useCallback unknown>(fn: T) { - return fn; +import { resetApiClient, setApiTokenProvider, setApiSessionSignal } from "@/lib/api-client"; +import { useLogStream } from "./use-log-stream"; + +const scope = vi.hoisted(() => ({ + projectId: "project-a", + auth: { + isReady: true, + user: { id: "alice" }, + account: { homeAccountId: "alice", localAccountId: "alice", tenantId: "tenant", environment: "local" }, }, })); - +vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => scope.auth })); +vi.mock("@/contexts/ProjectContext", () => ({ useSelectedProjectId: () => scope.projectId })); vi.mock("@/lib/api", () => ({ - api: { - logsUrl: (_id: string, _fromStart: boolean) => "/api/v1/runs/run-1/logs/stream", - }, + api: { logsUrl: (id: string, fromStart: boolean) => `https://scope.test/api/v1/requests/${id}/logs?fromStart=${fromStart}` }, })); -class MockEventSource { - static instances: MockEventSource[] = []; - static CLOSED = 2; - - readonly close = vi.fn(() => { - this.readyState = MockEventSource.CLOSED; - }); - onopen: ((event: Event) => void) | null = null; - onmessage: ((event: MessageEvent) => void) | null = null; - onerror: ((event: Event) => void) | null = null; - readyState = 1; - private listeners = new Map void>>(); - - constructor(_url: string) { - MockEventSource.instances.push(this); - } - - addEventListener(type: string, listener: (event: MessageEvent) => void) { - const entries = this.listeners.get(type) ?? new Set<(event: MessageEvent) => void>(); - entries.add(listener); - this.listeners.set(type, entries); - } - - emit(type: string, event: MessageEvent) { - for (const listener of this.listeners.get(type) ?? []) listener(event); - } -} - -async function renderHook(attemptNumber: number) { - const { useLogStream } = await import("./use-log-stream.js"); - const urlBuilder = stableUrlBuilder; - runtime.beginRender(); - useLogStream({ - id: "run-1", - enabled: true, - fromStart: true, - attemptNumber, - urlBuilder, - }); - runtime.beginRender(); - return useLogStream({ - id: "run-1", - enabled: true, - fromStart: true, - attemptNumber, - urlBuilder, - }); +const log = { timestamp: "2026-05-01T00:00:00.000Z", level: "info", message: "first attempt" }; +const encoder = new TextEncoder(); +let fetchMock: ReturnType; + +function liveStream() { + let source!: ReadableStreamDefaultController; + const cancel = vi.fn(); + const response = new Response(new ReadableStream({ + start(controller) { source = controller; }, + cancel, + }), { headers: { "Content-Type": "text/event-stream" } }); + return { + response, cancel, + send: (data: string) => source.enqueue(encoder.encode(data)), + close: () => source.close(), + fail: () => source.error(new TypeError("connection reset")), + }; } -const stableUrlBuilder = () => "/api/v1/runs/run-1/logs/stream"; +beforeEach(() => { + resetApiClient(); + scope.projectId = "project-a"; + scope.auth.isReady = true; + scope.auth.user.id = "alice"; + scope.auth.account.homeAccountId = "alice"; + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + setApiTokenProvider(() => "test-token"); +}); +afterEach(() => { + cleanup(); + resetApiClient(); + vi.unstubAllGlobals(); +}); -describe("useLogStream", () => { - beforeEach(() => { - runtime.stateSlots = []; - runtime.refSlots = []; - runtime.effectSlots = []; - MockEventSource.instances = []; - vi.stubGlobal("EventSource", MockEventSource); - vi.resetModules(); +describe("useLogStream authenticated fetch integration", () => { + it("uses bearer transport, preserves fromStart and handles live logs, clear and done", async () => { + const stream = liveStream(); + fetchMock.mockResolvedValue(stream.response); + const { result } = renderHook(() => useLogStream({ id: "request", fromStart: false })); + await waitFor(() => expect(result.current.isConnected).toBe(true)); + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.headers.get("Authorization")).toBe("Bearer test-token"); + expect(request.url).toBe("https://scope.test/api/v1/requests/request/logs?fromStart=false&projectId=project-a"); + expect(request.url).not.toContain("test-token"); + await act(async () => stream.send(`data: ${JSON.stringify(log)}\n\n`)); + await waitFor(() => expect(result.current.logs).toEqual([log])); + act(() => result.current.clear()); + expect(result.current.logs).toEqual([]); + await act(async () => stream.send("event: done\ndata: {}\n\n")); + await waitFor(() => expect(result.current.isDone).toBe(true)); + expect(result.current.isConnected).toBe(false); + expect(result.current.error).toBeNull(); + await waitFor(() => expect(stream.cancel).toHaveBeenCalled()); }); - afterEach(() => { - runtime.unmount(); - vi.unstubAllGlobals(); + it.each(["attempt", "id", "project", "account", "scope-user", "logout", "disabled"] as const)( + "clears and cancels the replaced %s stream, rejecting late queued logs", + async (change) => { + const first = liveStream(); + const second = liveStream(); + fetchMock.mockResolvedValueOnce(first.response).mockResolvedValueOnce(second.response); + const { result, rerender } = renderHook( + (props) => useLogStream(props), + { initialProps: { id: "request", attemptNumber: 1, enabled: true } }, + ); + await waitFor(() => expect(result.current.isConnected).toBe(true)); + await act(async () => first.send(`data: ${JSON.stringify(log)}\n\n`)); + await waitFor(() => expect(result.current.logs).toHaveLength(1)); + const props = { id: "request", attemptNumber: 1, enabled: true }; + if (change === "attempt") props.attemptNumber = 2; + if (change === "id") props.id = "other"; + if (change === "project") scope.projectId = "project-b"; + if (change === "account") scope.auth.account.homeAccountId = "bob"; + if (change === "scope-user") scope.auth.user.id = "bob"; + if (change === "logout") scope.auth.isReady = false; + if (change === "disabled") props.enabled = false; + act(() => { + first.send(`data: ${JSON.stringify({ ...log, message: "late" })}\n\n`); + rerender(props); + }); + expect(result.current.logs).toEqual([]); + expect(result.current.isDone).toBe(false); + await waitFor(() => expect(first.cancel).toHaveBeenCalled()); + if (change === "logout" || change === "disabled") { + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.current.isConnected).toBe(false); + } else { + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(result.current.error).toBeNull(); + } + }, + ); + + it("aborts a pending fetch on unmount", async () => { + fetchMock.mockImplementation((request: Request) => new Promise((_resolve, reject) => { + request.signal.addEventListener("abort", () => reject(request.signal.reason)); + })); + const { unmount } = renderHook(() => useLogStream({ id: "request" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + const request = fetchMock.mock.calls[0][0] as Request; + unmount(); + expect(request.signal.aborted).toBe(true); }); - it("creates a new EventSource when attemptNumber changes", async () => { - const initial = await renderHook(1); - expect(MockEventSource.instances).toHaveLength(1); - expect(initial.logs).toEqual([]); + it("does not open a stream before the Scope-user handshake is ready", () => { + scope.auth.isReady = false; + const { result } = renderHook(() => useLogStream({ id: "request" })); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.logs).toEqual([]); + }); - const first = MockEventSource.instances[0]; - const updated = await renderHook(2); + it("does not fetch logs without a selected project", () => { + scope.projectId = ""; + const { result } = renderHook(() => useLogStream({ id: "request" })); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.logs).toEqual([]); + }); - expect(first.close).toHaveBeenCalledTimes(1); - expect(MockEventSource.instances).toHaveLength(2); - expect(updated.logs).toEqual([]); + it("binds a custom historical/report URL to the selected project without changing replay options", async () => { + const stream = liveStream(); + fetchMock.mockResolvedValue(stream.response); + const urlBuilder = vi.fn((id: string, fromStart: boolean) => + `https://scope.test/api/v1/reports/${id}/logs?fromStart=${fromStart}`); + const { result } = renderHook(() => useLogStream({ id: "report", fromStart: true, urlBuilder })); + await waitFor(() => expect(result.current.isConnected).toBe(true)); + expect(urlBuilder).toHaveBeenCalledWith("report", true); + expect((fetchMock.mock.calls[0][0] as Request).url) + .toBe("https://scope.test/api/v1/reports/report/logs?fromStart=true&projectId=project-a"); }); - it("resets logs, done state, and error on reconnect", async () => { - await renderHook(1); - const first = MockEventSource.instances[0]; - expect(first).toBeDefined(); + it.each(["timeout", "error"] as const)("surfaces a named server %s without marking success or reconnecting", async (type) => { + const stream = liveStream(); + fetchMock.mockResolvedValue(stream.response); + const { result } = renderHook(() => useLogStream({ id: "request" })); + await waitFor(() => expect(result.current.isConnected).toBe(true)); + await act(async () => stream.send(`event: ${type}\ndata: {"message":"blob storage unavailable"}\n\n`)); + await waitFor(() => expect(result.current.error).toBe(type === "timeout" ? "Stream timed out" : "blob storage unavailable")); + expect(result.current.isDone).toBe(false); + expect(result.current.isConnected).toBe(false); + expect(fetchMock).toHaveBeenCalledOnce(); + }); - first.onmessage?.(new MessageEvent("message", { - data: JSON.stringify({ - timestamp: "2026-05-01T00:00:00.000Z", - level: "info", - message: "first attempt", - }), - })); - first.emit("done", new MessageEvent("done")); - first.emit("error", new MessageEvent("error", { data: JSON.stringify({ message: "boom" }) })); + it("does not reconnect a closed authorization lease or duplicate received logs", async () => { + const stream = liveStream(); + fetchMock.mockResolvedValue(stream.response); + const { result } = renderHook(() => useLogStream({ id: "request" })); + await waitFor(() => expect(result.current.isConnected).toBe(true)); + await act(async () => stream.send(`data: ${JSON.stringify(log)}\n\n`)); + await waitFor(() => expect(result.current.logs).toHaveLength(1)); + await act(async () => stream.close()); + await waitFor(() => expect(result.current.error).toMatch(/disconnected.*Reload/)); + expect(result.current.isConnected).toBe(false); + expect(result.current.isDone).toBe(false); + expect(result.current.logs).toEqual([log]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); - const beforeReconnect = await renderHook(1); - expect(beforeReconnect.logs).toHaveLength(1); - expect(beforeReconnect.isDone).toBe(true); - expect(beforeReconnect.error).toBe("boom"); + it("surfaces network failure after connection without a replay loop", async () => { + const stream = liveStream(); + fetchMock.mockResolvedValue(stream.response); + const { result } = renderHook(() => useLogStream({ id: "request" })); + await waitFor(() => expect(result.current.isConnected).toBe(true)); + await act(async () => stream.fail()); + await waitFor(() => expect(result.current.error).toContain("connection reset")); + expect(fetchMock).toHaveBeenCalledOnce(); + }); - const afterReconnect = await renderHook(2); - expect(afterReconnect.logs).toEqual([]); - expect(afterReconnect.isDone).toBe(false); - expect(afterReconnect.error).toBeNull(); + it("session cancellation closes an established fetch body", async () => { + const session = new AbortController(); + setApiSessionSignal(session.signal); + const stream = liveStream(); + fetchMock.mockImplementation((request: Request) => { + // A real fetch cancels its body when its request signal aborts. + request.signal.addEventListener("abort", () => stream.close()); + return stream.response; + }); + const { result, rerender } = renderHook(() => useLogStream({ id: "request" })); + await waitFor(() => expect(result.current.isConnected).toBe(true)); + act(() => { + session.abort(); + scope.auth.isReady = false; + rerender(); + }); + expect(result.current.logs).toEqual([]); + expect(result.current.isConnected).toBe(false); + expect(fetchMock).toHaveBeenCalledOnce(); }); }); diff --git a/apps/portal/src/hooks/use-log-stream.ts b/apps/portal/src/hooks/use-log-stream.ts index 09f294cd0..ae5c7de65 100644 --- a/apps/portal/src/hooks/use-log-stream.ts +++ b/apps/portal/src/hooks/use-log-stream.ts @@ -1,8 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { useSelectedProjectId } from "@/contexts/ProjectContext"; import { api } from "@/lib/api"; +import { openLogStream, readLogStream } from "@/lib/log-stream"; import type { LogEvent } from "@/types"; interface UseLogStreamOptions { @@ -18,14 +21,19 @@ interface UseLogStreamOptions { urlBuilder?: (id: string, fromStart: boolean) => string; } -interface UseLogStreamReturn { +interface StreamState { logs: LogEvent[]; isConnected: boolean; isDone: boolean; error: string | null; +} + +interface UseLogStreamReturn extends StreamState { clear: () => void; } +const EMPTY: StreamState = { logs: [], isConnected: false, isDone: false, error: null }; + export function useLogStream({ id, enabled = true, @@ -33,92 +41,57 @@ export function useLogStream({ attemptNumber, urlBuilder = api.logsUrl, }: UseLogStreamOptions): UseLogStreamReturn { - const [logs, setLogs] = useState([]); - const [isConnected, setIsConnected] = useState(false); - const [isDone, setIsDone] = useState(false); - const [error, setError] = useState(null); - const eventSourceRef = useRef(null); - - const clear = useCallback(() => setLogs([]), []); + const { account, user, isReady } = useAuth(); + const projectId = useSelectedProjectId(); + const identity = useMemo(() => ({ + id, enabled, fromStart, attemptNumber, urlBuilder, isReady, projectId, + }), [ + id, enabled, fromStart, attemptNumber, urlBuilder, isReady, projectId, + user?.id, account?.homeAccountId, account?.localAccountId, account?.tenantId, account?.environment, + ]); + const [snapshot, setSnapshot] = useState<(StreamState & { identity: typeof identity }) | null>(null); + + const clear = useCallback(() => { + setSnapshot((previous) => previous?.identity === identity ? { ...previous, logs: [] } : previous); + }, [identity]); useEffect(() => { - if (!enabled || !id) return; - - // Close any existing connection when dependencies change (especially attemptNumber) - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - - // Reset state for fresh connection - setLogs([]); - setIsDone(false); - setError(null); - - const url = urlBuilder(id, fromStart); - const es = new EventSource(url); - eventSourceRef.current = es; - - es.onopen = () => { - setIsConnected(true); - setError(null); - }; - - es.onmessage = (event) => { - try { - const parsed = JSON.parse(event.data); - // The SSE stream multiplexes log events with summary payloads - // (e.g. { type: "turns_summary", ... }). Only treat well-formed - // log events with a timestamp as logs — otherwise the LogViewer - // would render "Invalid Date" for the summary row. - if (!parsed || typeof parsed.timestamp !== "string" || typeof parsed.message !== "string") { - return; - } - setLogs((prev) => [...prev, parsed as LogEvent]); - } catch { - // Non-JSON messages (heartbeats, etc.) - } + const projectId = identity.projectId; + if (!identity.enabled || !identity.id || !identity.isReady || !projectId) return; + const controller = new AbortController(); + setSnapshot({ identity, ...EMPTY }); + const update = (change: (state: StreamState) => Partial) => { + if (controller.signal.aborted) return; + setSnapshot((previous) => previous?.identity === identity + ? { ...previous, ...change(previous) } + : previous); }; - - es.addEventListener("done", () => { - setIsDone(true); - setIsConnected(false); - es.close(); - }); - - es.addEventListener("timeout", () => { - setError("Stream timed out"); - setIsConnected(false); - es.close(); - }); - - es.addEventListener("error", (event: MessageEvent) => { + void (async () => { try { - const { message } = JSON.parse(event.data) as { message: string }; - setError(message); - } catch { - setError("Cannot connect to log storage"); + const url = new URL(identity.urlBuilder(identity.id, identity.fromStart), window.location.href); + url.searchParams.set("projectId", projectId); + const response = await openLogStream(url.toString(), controller.signal); + update(() => ({ isConnected: true, error: null })); + await readLogStream(response, controller.signal, (event) => { + if (event.type === "log") update((state) => ({ logs: [...state.logs, event.log] })); + else if (event.type === "done") update(() => ({ isDone: true, isConnected: false })); + else update(() => ({ + error: event.type === "timeout" ? "Stream timed out" : event.message, + isConnected: false, + })); + }); + } catch (cause) { + if (controller.signal.aborted) return; + update(() => ({ + isConnected: false, + error: cause instanceof Error ? cause.message : "Unable to connect to log stream.", + })); } - setIsConnected(false); - es.close(); - }); - - es.onerror = () => { - setIsConnected(false); - // EventSource auto-reconnects; only set error if CLOSED. - // Use functional update to avoid overwriting a more specific error - // already set by the named "error" event listener. - if (es.readyState === EventSource.CLOSED) { - setError((prev) => prev ?? "Connection lost"); - } - }; - - return () => { - es.close(); - eventSourceRef.current = null; - setIsConnected(false); - }; - }, [id, enabled, fromStart, attemptNumber, urlBuilder]); + })(); + return () => controller.abort(); + }, [identity]); - return { logs, isConnected, isDone, error, clear }; + // Hide the previous identity's logs during render, before effect cleanup can run. + const state = snapshot?.identity === identity ? snapshot : EMPTY; + return { logs: state.logs, isConnected: state.isConnected, isDone: state.isDone, error: state.error, clear }; } diff --git a/apps/portal/src/hooks/useHarExtraction.test.tsx b/apps/portal/src/hooks/useHarExtraction.test.tsx new file mode 100644 index 000000000..f0839bac2 --- /dev/null +++ b/apps/portal/src/hooks/useHarExtraction.test.tsx @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAllTurnsToolCalls, useHarData, useHarExtraction } from "./useHarExtraction"; +import { fetchArtifact } from "@/lib/artifact-delivery"; +import { setApiSessionSignal, resetApiClient } from "@/lib/api-client"; +import type { ConversationTurn } from "@/types"; + +const state = vi.hoisted(() => ({ + project: "p1", + auth: { isReady: true, user: { id: "u1" }, account: null }, +})); +vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => state.auth })); +vi.mock("@/contexts/ProjectContext", () => ({ useSelectedProjectId: () => state.project })); +vi.mock("@/lib/artifact-delivery", () => ({ fetchArtifact: vi.fn() })); +const har = { log: { entries: [] } }; +const clients: QueryClient[] = []; +function wrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + clients.push(client); + return ({ children }: { children: ReactNode }) => {children}; +} +const turn = (fields: Partial): ConversationTurn => ({ + iteration: 1, passed: false, codingAgentResponse: "", judgeFeedback: "", + snapshotUrl: "", timestamp: "2026-01-01T00:00:00Z", ...fields, +}); + +beforeEach(() => { + state.project = "p1"; + state.auth.isReady = true; + vi.mocked(fetchArtifact).mockReset(); + vi.mocked(fetchArtifact).mockImplementation(async () => Response.json(har)); +}); +afterEach(() => { + cleanup(); + for (const client of clients.splice(0)) client.clear(); + resetApiClient(); +}); + +describe("project/account/attempt-bound HAR display", () => { + it("shares one immutable HAR query between display and extraction", async () => { + const { result } = renderHook(() => ({ + raw: useHarData("r", 2, true, "attempt"), + extracted: useHarExtraction("r", 2, true, "attempt"), + }), { wrapper: wrapper() }); + await waitFor(() => expect(result.current.raw.isSuccess).toBe(true)); + expect(fetchArtifact).toHaveBeenCalledTimes(1); + expect(fetchArtifact).toHaveBeenCalledWith("/api/v1/requests/r/runs/attempt/har?iteration=2", "p1", expect.any(AbortSignal)); + expect(result.current.extracted.data?.toolCalls).toEqual([]); + }); + + it("does not fetch before authentication is ready", () => { + state.auth.isReady = false; + renderHook(() => useHarData("r", 1), { wrapper: wrapper() }); + expect(fetchArtifact).not.toHaveBeenCalled(); + }); + + it.each(["project", "attempt", "account"] as const)("isolates cached bytes on %s change", async change => { + setApiSessionSignal(new AbortController().signal); + const { result, rerender } = renderHook(({ attempt }) => useHarData("r", 1, true, attempt), { + wrapper: wrapper(), initialProps: { attempt: "a1" }, + }); + await waitFor(() => expect(result.current.data).toEqual(har)); + vi.mocked(fetchArtifact).mockImplementation(() => new Promise(() => {})); + if (change === "project") state.project = "p2"; + if (change === "account") setApiSessionSignal(new AbortController().signal); + rerender({ attempt: change === "attempt" ? "a2" : "a1" }); + expect(result.current.data).toBeUndefined(); + await waitFor(() => expect(fetchArtifact).toHaveBeenCalledTimes(2)); + expect(vi.mocked(fetchArtifact).mock.calls[0][2].aborted).toBe(true); + }); + + it("rejects late parsed bytes after account cancellation", async () => { + const account = new AbortController(); + setApiSessionSignal(account.signal); + let deliver!: (value: unknown) => void; + vi.mocked(fetchArtifact).mockImplementation(async () => { + const response = Response.json(har); + vi.spyOn(response, "json").mockImplementation(() => new Promise(resolve => { deliver = resolve; })); + return response; + }); + const { result } = renderHook(() => useHarData("r", 1), { wrapper: wrapper() }); + await waitFor(() => expect(deliver).toBeDefined()); + await act(async () => { account.abort(); deliver(har); }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); + }); + + it("hides already cached data as soon as readiness is lost", async () => { + const { result, rerender } = renderHook(() => useHarData("r", 1), { wrapper: wrapper() }); + await waitFor(() => expect(result.current.data).toEqual(har)); + state.auth.isReady = false; + rerender(); + expect(result.current.data).toBeUndefined(); + }); + + it("surfaces HTTP failures and malformed HAR instead of an empty success", async () => { + vi.mocked(fetchArtifact).mockRejectedValue(new Error("HTTP 403")); + const first = renderHook(() => useHarExtraction("r", 1, true), { wrapper: wrapper() }); + await waitFor(() => expect(first.result.current.error?.message).toBe("HTTP 403")); + expect(fetchArtifact).toHaveBeenCalledTimes(1); + first.unmount(); + vi.mocked(fetchArtifact).mockResolvedValue(Response.json({ error: "not HAR" })); + const second = renderHook(() => useHarExtraction("r2", 1, true), { wrapper: wrapper() }); + await waitFor(() => expect(second.result.current.error?.message).toContain("invalid HAR")); + }); + + it("uses attempt-specific JSONL and exposes invalid records", async () => { + const tool = { id: "call", name: "read_file", arguments: { path: "file" }, timestamp: "2026-01-01" }; + vi.mocked(fetchArtifact).mockImplementation(async () => new Response(`${JSON.stringify(tool)}\n`)); + const first = renderHook(() => useAllTurnsToolCalls("r", [turn({ toolCallsUrl: "stored-blob-url" })], undefined, "attempt"), { wrapper: wrapper() }); + await waitFor(() => expect(first.result.current.allToolCalls).toHaveLength(1)); + expect(fetchArtifact).toHaveBeenCalledWith("/api/v1/requests/r/runs/attempt/tool-calls?iteration=1", "p1", expect.any(AbortSignal)); + expect(first.result.current.allToolCalls[0]).toMatchObject({ ...tool, _iteration: 1 }); + first.unmount(); + vi.mocked(fetchArtifact).mockImplementation(async () => new Response('{"name":42}\n')); + const second = renderHook(() => useAllTurnsToolCalls("r2", [turn({ toolCallsUrl: "blob" })]), { wrapper: wrapper() }); + await waitFor(() => expect(second.result.current.error?.message).toContain("invalid tool-call")); + expect(second.result.current.allToolCalls).toEqual([]); + }); +}); diff --git a/apps/portal/src/hooks/useHarExtraction.ts b/apps/portal/src/hooks/useHarExtraction.ts index 6830411e3..6196e3e39 100644 --- a/apps/portal/src/hooks/useHarExtraction.ts +++ b/apps/portal/src/hooks/useHarExtraction.ts @@ -4,7 +4,8 @@ import { useQuery, useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; import { api } from "@/lib/api"; -import { apiClient } from "@/lib/api-client"; +import { fetchArtifact } from "@/lib/artifact-delivery"; +import { useArtifactSession } from "./use-artifact-session"; import type { ConversationTurn } from "@/types"; import { extractChronologicalSegments, @@ -17,23 +18,52 @@ import { export type { ConversationSegment, HarExtractedData, ToolCall }; +async function readHar(url: string, projectId: string, signal: AbortSignal): Promise { + const response = await fetchArtifact(url, projectId, signal); + const data: T = await response.json(); + signal.throwIfAborted(); + if (!data || typeof data !== "object" || !("log" in data) || !data.log || + typeof data.log !== "object" || !("entries" in data.log) || !Array.isArray(data.log.entries)) { + throw new Error("The server returned an invalid HAR file."); + } + return data; +} + +function parseToolCall(line: string): ToolCall { + const value: unknown = JSON.parse(line); + if (!value || typeof value !== "object" || !("id" in value) || typeof value.id !== "string" || + !("name" in value) || typeof value.name !== "string" || + !("arguments" in value) || !value.arguments || typeof value.arguments !== "object" || + Array.isArray(value.arguments) || + ("timestamp" in value && typeof value.timestamp !== "string") || + ("response" in value && typeof value.response !== "string")) { + throw new Error("The server returned an invalid tool-call record."); + } + return { + id: value.id, name: value.name, arguments: value.arguments as Record, + timestamp: "timestamp" in value ? value.timestamp as string : "", + ...("response" in value ? { response: value.response as string } : {}), + }; +} + // --------------------------------------------------------------------------- // Shared HAR data hook — single source of truth for fetching HAR JSON. // All tabs (Conversation, Network, Tool Calls) share this cache. // Generic so each consumer can use their own HAR type definition. // --------------------------------------------------------------------------- export function useHarData(runId: string, iteration?: number, enabled = true, attemptRunId?: string) { - return useQuery({ - queryKey: ["har", runId, iteration, attemptRunId], - queryFn: async () => { + const session = useArtifactSession(`${runId}/${attemptRunId ?? "latest"}`); + const result = useQuery({ + queryKey: ["har", runId, iteration, attemptRunId, session.key], + queryFn: async ({ signal }) => { const url = attemptRunId ? api.runHarUrl(runId, attemptRunId, iteration) : api.harUrl(runId, iteration); - const res = await apiClient(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); + return readHar(url, session.projectId!, AbortSignal.any([signal, session.controller.signal])); }, - enabled, + enabled: enabled && session.ready, + retry: false, staleTime: Infinity, // HAR data is immutable once created }); + return { ...result, data: session.ready ? result.data : undefined }; } // --------------------------------------------------------------------------- @@ -42,15 +72,16 @@ export function useHarData(runId: string, iteration?: number, enabl export function useHarExtraction(runId: string, iteration?: number, hasHar?: boolean, attemptRunId?: string): { data: HarExtractedData | undefined; isLoading: boolean; + error: Error | null; } { - const { data: har, isLoading } = useHarData(runId, iteration, !!hasHar, attemptRunId); + const { data: har, isLoading, error } = useHarData(runId, iteration, !!hasHar, attemptRunId); const data = useMemo(() => { if (!har) return undefined; return extractFromHar(har); }, [har]); - return { data, isLoading }; + return { data, isLoading, error }; } // --------------------------------------------------------------------------- @@ -65,7 +96,8 @@ export function useAllTurnsToolCalls( turns?: ConversationTurn[], topLevelHarUrl?: string, attemptRunId?: string, -): { allToolCalls: AggregatedToolCall[]; isLoading: boolean } { +): { allToolCalls: AggregatedToolCall[]; isLoading: boolean; error: Error | null } { + const session = useArtifactSession(`${runId}/${attemptRunId ?? "latest"}`); // Turns that already have pre-computed `toolCalls` (legacy inline shape) // don't need any blob fetch. const preComputed = useMemo(() => { @@ -100,17 +132,20 @@ export function useAllTurnsToolCalls( const jsonlResults = useQueries({ queries: jsonlQueries.map((q) => ({ - queryKey: ["tool-calls", runId, q.iteration, attemptRunId], - queryFn: async () => { + queryKey: ["tool-calls", runId, q.iteration, attemptRunId, session.key], + queryFn: async ({ signal }: { signal: AbortSignal }) => { const url = attemptRunId ? api.runToolCallsUrl(runId, attemptRunId, q.iteration) : api.toolCallsUrl(runId, q.iteration); - const res = await apiClient(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + const scopedSignal = AbortSignal.any([signal, session.controller.signal]); + const res = await fetchArtifact(url, session.projectId!, scopedSignal); const text = await res.text(); + scopedSignal.throwIfAborted(); return text .split("\n") .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line) as ToolCall); + .map(parseToolCall); }, + enabled: session.ready, + retry: false, staleTime: Infinity, })), }); @@ -142,14 +177,13 @@ export function useAllTurnsToolCalls( const results = useQueries({ queries: queries.map((q) => ({ - queryKey: ["har", runId, q.iteration, attemptRunId], - queryFn: async () => { + queryKey: ["har", runId, q.iteration, attemptRunId, session.key], + queryFn: async ({ signal }: { signal: AbortSignal }) => { const url = attemptRunId ? api.runHarUrl(runId, attemptRunId, q.iteration) : api.harUrl(runId, q.iteration); - const res = await apiClient(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json() as Promise; + return readHar(url, session.projectId!, AbortSignal.any([signal, session.controller.signal])); }, - enabled: q.enabled, + enabled: q.enabled && session.ready, + retry: false, staleTime: Infinity, })), }); @@ -183,5 +217,6 @@ export function useAllTurnsToolCalls( const isLoading = results.some((r) => r.isLoading) || jsonlResults.some((r) => r.isLoading); - return { allToolCalls, isLoading }; + const error = [...jsonlResults, ...results].find(result => result.error)?.error ?? null; + return { allToolCalls: session.ready ? allToolCalls : [], isLoading, error }; } diff --git a/apps/portal/src/lib/api-client.ts b/apps/portal/src/lib/api-client.ts index ad4b6e1c1..f702f7d15 100644 --- a/apps/portal/src/lib/api-client.ts +++ b/apps/portal/src/lib/api-client.ts @@ -72,6 +72,10 @@ export function setApiSessionSignal(signal: AbortSignal | undefined): void { sessionSignal = signal; } +export function getApiSessionSignal(): AbortSignal | undefined { + return sessionSignal; +} + /** Override how bearer tokens are resolved (wired by Portal auth). */ export function setApiTokenProvider(provider: TokenProvider): void { tokenProvider = provider; diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts index 96cd39566..eda367ba9 100644 --- a/apps/portal/src/lib/api.ts +++ b/apps/portal/src/lib/api.ts @@ -7,6 +7,7 @@ import type { Project, CreateProjectRequest, UpdateProjectRequest } from "@/type import { qs } from "./url"; import { recordServerDate } from "./serverClock"; import { apiClient } from "./api-client"; +import { downloadBatchArtifacts } from "./artifact-delivery"; import { getSelectedProjectId, ProjectRequiredError } from "./project-scope"; import { MAX_ARCHIVE_UPLOAD_LABEL } from "./codebaseUpload"; @@ -356,27 +357,8 @@ export const api = { }, /** Download a batch archive of multiple runs as a single .tar.gz */ - batchArchive: async (ids: string[]): Promise => { - const resp = await apiClient(`${BASE}/requests/archive`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ids }), - }); - recordServerDate(resp.headers.get("Date")); - if (!resp.ok) { - const err = await resp.json().catch(() => ({ error: resp.statusText })) as { error?: string }; - throw new Error(err.error ?? "Failed to download batch archive"); - } - const blob = await resp.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - a.download = `batch-${timestamp}.tar.gz`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + batchArchive: async (ids: string[], context: { projectId: string; signal: AbortSignal }): Promise => { + await downloadBatchArtifacts(ids, context.projectId, context.signal); }, /** Get video stream URL for a request (optionally per-iteration, per-index, or setup phase) */ @@ -407,6 +389,10 @@ export const api = { return `${BASE}/requests/${requestId}/runs/${runId}/har${qs}`; }, + runAtifUrl: (requestId: string, runId: string, iteration: number): string => { + return `${BASE}/requests/${requestId}/runs/${runId}/atif?iteration=${iteration}`; + }, + /** Video stream URL for a specific attempt */ runVideoUrl: (requestId: string, runId: string, iteration?: number, index = 0, phase?: string): string => { const params = new URLSearchParams(); diff --git a/apps/portal/src/lib/artifact-delivery.test.ts b/apps/portal/src/lib/artifact-delivery.test.ts new file mode 100644 index 000000000..56df97cad --- /dev/null +++ b/apps/portal/src/lib/artifact-delivery.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { gzipSync, gunzipSync } from "node:zlib"; +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import { artifactFilename, artifactUrl, ArtifactError, downloadArtifact, downloadBatchArtifacts, fetchArtifact } from "./artifact-delivery"; +import { resetApiClient, setApiTokenProvider, setReauthHandler } from "./api-client"; + +const path = "/api/v1/requests/request-1/runs/attempt-1/archive"; +const controller = () => new AbortController(); +const archive = (body: BodyInit = new Uint8Array([31, 139, 0, 255])) => + new Response(body, { headers: { "content-type": "application/gzip", "content-disposition": "attachment; filename=run.tar.gz" } }); + +describe("authenticated artifact delivery", () => { + const fetchMock = vi.fn(); + const anchor = { href: "", download: "", click: vi.fn(), remove: vi.fn() }; + let create: MockInstance; + let revoke: MockInstance; + + beforeEach(() => { + resetApiClient(); + vi.stubGlobal("window", Object.assign(new EventTarget(), { + location: { href: "https://scope.test/runs/request-1", origin: "https://scope.test" }, + })); + vi.stubGlobal("document", { createElement: () => anchor, body: { appendChild: vi.fn() } }); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + anchor.click.mockReset(); + anchor.remove.mockReset(); + create = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:scope-download"); + revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + }); + + afterEach(() => { + window.dispatchEvent(new Event("pagehide")); + resetApiClient(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it.each([ + "/api/v1/requests/r/archive", + "/api/v1/requests/r/runs/a/har?iteration=2", + "/api/v1/requests/r/runs/a/atif?iteration=2", + "/api/v1/requests/r/snapshots/1", + "/api/v1/requests/r/tool-calls?iteration=1", + "/api/v1/codebase-revisions/rev-1/archive", + "/api/v1/requests/r/video?phase=setup&index=1", + ])("binds canonical route %s to the explicit project", (url) => { + expect(artifactUrl(url, "project-1").searchParams.get("projectId")).toBe("project-1"); + }); + + it.each([ + "https://foreign.test/api/v1/requests/r/archive", + "//foreign.test/api/v1/requests/r/archive", + "https://user:password@scope.test/api/v1/requests/r/archive", + "/api/v1/requests/r/archive?token=secret", + "/api/v1/requests/r/archive?projectId=other", + "/api/v1/requests/r/archive?projectId=p&projectId=p", + "/api/v1/requests/r/archive#fragment", + "/api/v1/requests/r/har?iteration=0", + "/api/v1/requests/r/har?iteration=1x", + "/api/v1/requests/r/video?index=-1", + "/api/v1/requests/r/video?phase=unknown", + "/api/v1/users/me", + "blob:https://scope.test/file", + ])("rejects unsafe route %s before credentials are acquired", async (url) => { + const provider = vi.fn(); + setApiTokenProvider(provider); + await expect(fetchArtifact(url, "p", controller().signal)).rejects.toThrow(); + expect(provider).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("requires a project", () => { + expect(() => artifactUrl(path, "")).toThrow("project-bound"); + }); + + it.each([ + ['attachment; filename="plain.tar.gz"', "plain.tar.gz"], + ["attachment; filename=plain.har", "plain.har"], + ["attachment; filename=old; filename*=UTF-8''caf%C3%A9.har", "café.har"], + ["attachment; filename*=UTF-8'en'%E2%82%AC.har", "€.har"], + ['attachment; filename="../../secret\\r.har"', "_.._secretr.har"], + ["attachment; filename=...", "fallback.tar.gz"], + [null, "fallback.tar.gz"], + ])("uses a safe disposition filename for %s", (disposition, expected) => { + expect(artifactFilename(disposition, "fallback.tar.gz")).toBe(expected); + }); + + it("sanitizes fallbacks and rejects broken encoded filenames", () => { + expect(artifactFilename(null, "../bad/\u0000")).toBe("_bad__"); + expect(artifactFilename(null, "...")).toBe("artifact"); + expect(() => artifactFilename("attachment; filename*=UTF-8''%ZZ", "x")).toThrow("invalid encoded filename"); + expect(artifactFilename(null, "a".repeat(250))).toHaveLength(200); + }); + + it("preserves exact gzip bytes, type and filename, and never uses the API URL as a browser href", async () => { + const original = gzipSync(Buffer.from([0, 1, 2, 255, 128, 0])); + fetchMock.mockResolvedValue(archive(original)); + setApiTokenProvider(() => "test-token"); + const owner = controller(); + await downloadArtifact(path, "p", owner.signal); + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.headers.has("authorization")).toBe(true); + expect(request.redirect).toBe("error"); + expect(request.credentials).toBe("omit"); + expect(request.cache).toBe("no-store"); + expect(new URL(request.url).searchParams.get("projectId")).toBe("p"); + const blob = create.mock.calls[0][0] as Blob; + expect(Buffer.from(await blob.arrayBuffer())).toEqual(original); + expect(gunzipSync(Buffer.from(await blob.arrayBuffer()))).toEqual(Buffer.from([0, 1, 2, 255, 128, 0])); + expect(blob.type).toBe("application/gzip"); + expect(anchor.href).toBe("blob:scope-download"); + expect(anchor.download).toBe("run.tar.gz"); + expect(anchor.click).toHaveBeenCalledTimes(1); + owner.abort(); + expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:scope-download"); + }); + + it.each([ + ["/api/v1/requests/r/har", "application/json"], + ["/api/v1/requests/r/atif?iteration=1", "application/json"], + ["/api/v1/requests/r/tool-calls?iteration=1", "application/x-ndjson"], + ["/api/v1/codebase-revisions/rev/archive", "application/gzip"], + ])("downloads %s without transforming bytes", async (url, type) => { + const bytes = new Uint8Array([0, 255, 13, 10, 128]); + fetchMock.mockResolvedValue(new Response(bytes, { headers: { "content-type": type } })); + await downloadArtifact(url, "p", controller().signal, "fallback"); + expect(new Uint8Array(await (create.mock.calls[0][0] as Blob).arrayBuffer())).toEqual(bytes); + }); + + it("delegates exactly one 401 refresh to apiClient", async () => { + const provider = vi.fn(() => "test-token"); + setApiTokenProvider(provider); + fetchMock.mockResolvedValueOnce(new Response("", { status: 401 })).mockResolvedValueOnce(archive()); + await downloadArtifact(path, "p", controller().signal); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(provider).toHaveBeenCalledWith({ forceRefresh: true }); + expect(create).toHaveBeenCalledTimes(1); + }); + + it.each([401, 403, 404, 416])("never saves an HTTP %s error as an artifact", async status => { + const reauth = vi.fn(); + setReauthHandler(reauth); + fetchMock.mockImplementation(async () => new Response('{"error":"Denied"}', { + status, headers: { "content-type": "application/json", "content-range": "bytes */10" }, + })); + await expect(downloadArtifact(path, "p", controller().signal)).rejects.toMatchObject({ + status, headers: expect.any(Headers), + }); + expect(fetchMock).toHaveBeenCalledTimes(status === 401 ? 2 : 1); + expect(reauth).toHaveBeenCalledTimes(status === 401 ? 1 : 0); + expect(create).not.toHaveBeenCalled(); + }); + + it("retains range headers and status even for malformed error JSON", async () => { + fetchMock.mockResolvedValue(new Response("invalid", { + status: 416, headers: { "content-type": "application/json", "content-range": "bytes */10" }, + })); + try { + await fetchArtifact("/api/v1/requests/r/video", "p", controller().signal, "bytes=99-"); + expect.fail("Expected a 416"); + } catch (error) { + expect(error).toBeInstanceOf(ArtifactError); + expect((error as ArtifactError).headers?.get("content-range")).toBe("bytes */10"); + expect((error as ArtifactError).status).toBe(416); + } + }); + + it("caps transient GET retries and surfaces exhausted 503 without saving", async () => { + vi.useFakeTimers(); + fetchMock.mockImplementation(async () => new Response('{"error":"Unavailable"}', { + status: 503, headers: { "content-type": "application/json" }, + })); + const result = downloadArtifact(path, "p", controller().signal); + const assertion = expect(result).rejects.toMatchObject({ status: 503 }); + await vi.runAllTimersAsync(); + await assertion; + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(create).not.toHaveBeenCalled(); + }); + + it("preserves Range and accepts a 206 only when requested", async () => { + fetchMock.mockImplementation(async () => new Response(new Uint8Array([1]), { + status: 206, headers: { "content-type": "video/webm", "content-range": "bytes 1-1/2" }, + })); + const response = await fetchArtifact("/api/v1/requests/r/video", "p", controller().signal, "bytes=1-1"); + expect(response.status).toBe(206); + expect((fetchMock.mock.calls[0][0] as Request).headers.get("range")).toBe("bytes=1-1"); + await expect(fetchArtifact("/api/v1/requests/r/video", "p", controller().signal)).rejects.toThrow("incomplete"); + }); + + it.each(["text/html", "application/json"])("rejects a successful archive with type %s", async type => { + fetchMock.mockResolvedValue(new Response("not an archive", { headers: { "content-type": type } })); + await expect(downloadArtifact(path, "p", controller().signal)).rejects.toThrow("content type"); + expect(create).not.toHaveBeenCalled(); + }); + + it("rejects redirects even if a transport incorrectly returns one", async () => { + vi.spyOn(Response.prototype, "redirected", "get").mockReturnValue(true); + fetchMock.mockResolvedValue(archive()); + await expect(downloadArtifact(path, "p", controller().signal)).rejects.toThrow("redirects"); + expect(create).not.toHaveBeenCalled(); + }); + + it("aborts stalled body reads and cancels the stream without saving late bytes", async () => { + const cancel = vi.fn(); + fetchMock.mockResolvedValue(archive(new ReadableStream({ start(c) { c.enqueue(new Uint8Array([1])); }, cancel }))); + const owner = controller(); + const result = downloadArtifact(path, "p", owner.signal); + const assertion = expect(result).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await new Promise(resolve => setTimeout(resolve, 0)); + owner.abort(); + await assertion; + expect(cancel).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); + + it("does not fetch when already cancelled", async () => { + const owner = controller(); + owner.abort(); + await expect(downloadArtifact(path, "p", owner.signal)).rejects.toMatchObject({ name: "AbortError" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("cleans up after deferred browser dispatch, page teardown and dispatch failures", async () => { + vi.useFakeTimers(); + fetchMock.mockImplementation(async () => archive()); + await downloadArtifact(path, "p", controller().signal); + expect(revoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(60_000); + expect(revoke).toHaveBeenCalledTimes(1); + await downloadArtifact(path, "p", controller().signal); + window.dispatchEvent(new Event("pagehide")); + expect(revoke).toHaveBeenCalledTimes(2); + anchor.click.mockImplementationOnce(() => { throw new Error("Browser refused download"); }); + await expect(downloadArtifact(path, "p", controller().signal)).rejects.toThrow("Browser refused"); + expect(revoke).toHaveBeenCalledTimes(3); + }); + + it.each([403, 503])("never retries a batch POST on %s", async status => { + fetchMock.mockResolvedValue(new Response("{}", { status, headers: { "content-type": "application/json" } })); + await expect(downloadBatchArtifacts(["r1", "r2"], "p", controller().signal)).rejects.toMatchObject({ status }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); + + it("never retries a batch POST after an uncertain network failure", async () => { + fetchMock.mockRejectedValue(new TypeError("Network unavailable")); + await expect(downloadBatchArtifacts(["r1"], "p", controller().signal)).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("retries a transient GET establishment, but never restarts a failed accepted body", async () => { + vi.useFakeTimers(); + fetchMock.mockRejectedValueOnce(new TypeError("Network unavailable")).mockResolvedValueOnce(archive()); + const result = downloadArtifact(path, "p", controller().signal); + await vi.runAllTimersAsync(); + await result; + expect(fetchMock).toHaveBeenCalledTimes(2); + fetchMock.mockReset(); + create.mockClear(); + fetchMock.mockResolvedValue(archive(new ReadableStream({ + start(c) { c.enqueue(new Uint8Array([1])); c.error(new Error("Transfer interrupted")); }, + }))); + await expect(downloadArtifact(path, "p", controller().signal)).rejects.toThrow("Transfer interrupted"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); + + it("posts the selected batch once with explicit project and binary response", async () => { + let body: unknown; + fetchMock.mockImplementation(async input => { + body = await (input as Request).clone().json(); + return archive(); + }); + await downloadBatchArtifacts(["r1", "r2"], "p", controller().signal); + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.method).toBe("POST"); + expect(body).toEqual({ ids: ["r1", "r2"] }); + expect(new URL(request.url).searchParams.get("projectId")).toBe("p"); + expect(anchor.click).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/portal/src/lib/artifact-delivery.ts b/apps/portal/src/lib/artifact-delivery.ts new file mode 100644 index 000000000..6870e51ff --- /dev/null +++ b/apps/portal/src/lib/artifact-delivery.ts @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { apiClient } from "./api-client"; + +const requestArtifact = /^\/api\/v1\/requests\/[a-zA-Z0-9_-]+(?:\/runs\/[a-zA-Z0-9_-]+)?\/(?:archive|har|atif|tool-calls|video|snapshots\/[1-9]\d*)$/; +const codebaseArchive = /^\/api\/v1\/codebase-revisions\/[a-zA-Z0-9_-]+\/archive$/; +const queryKeys = new Set(["projectId", "iteration", "index", "phase"]); + +/** Never attach the Portal's bearer to a stored blob URL or a redirect. */ +export function artifactUrl(path: string, projectId: string): URL { + const url = new URL(path, window.location.href); + if (!projectId || url.origin !== window.location.origin || url.username || url.password || + url.hash || (!requestArtifact.test(url.pathname) && !codebaseArchive.test(url.pathname)) || + [...url.searchParams.keys()].some(key => !queryKeys.has(key)) || + (url.searchParams.has("projectId") && url.searchParams.get("projectId") !== projectId)) { + throw new Error("Only project-bound, same-origin Scope artifact routes are allowed."); + } + for (const key of queryKeys) { + if (url.searchParams.getAll(key).length > 1) throw new Error("Duplicate artifact query parameter."); + } + for (const key of ["iteration", "index"]) { + const value = url.searchParams.get(key); + if (value !== null && (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || + Number(value) < (key === "index" ? 0 : 1))) { + throw new Error(`Invalid artifact ${key}.`); + } + } + if (url.searchParams.has("phase") && url.searchParams.get("phase") !== "setup") { + throw new Error("Invalid artifact phase."); + } + url.searchParams.set("projectId", projectId); + return url; +} + +export class ArtifactError extends Error { + constructor(message: string, public readonly status: number, public readonly headers?: Headers) { + super(message); + this.name = "ArtifactError"; + } +} + +async function responseError(response: Response): Promise { + let message = `Artifact request failed (HTTP ${response.status}).`; + if (response.headers.get("content-type")?.includes("application/json")) { + try { + const body: unknown = await response.json(); + if (body && typeof body === "object" && "error" in body && typeof body.error === "string") { + message = `${message} ${body.error.slice(0, 300)}`; + } + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + message += " The server returned an invalid error response."; + } + } else { + await response.body?.cancel(); + } + return new ArtifactError(message, response.status, new Headers(response.headers)); +} + +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + signal.throwIfAborted(); + const cancel = () => { clearTimeout(timer); reject(signal.reason); }; + const timer = setTimeout(() => { signal.removeEventListener("abort", cancel); resolve(); }, ms); + signal.addEventListener("abort", cancel, { once: true }); + }); +} + +/** Retry only establishment; an accepted body's failures must not restart bytes. */ +export async function fetchArtifact( + path: string, projectId: string, signal: AbortSignal, range?: string, +): Promise { + const url = artifactUrl(path, projectId); + for (let attempt = 0; ; attempt++) { + signal.throwIfAborted(); + let response: Response; + try { + response = await apiClient(url, { + signal, redirect: "error", credentials: "omit", cache: "no-store", + headers: range ? { Range: range } : undefined, + }); + } catch (error) { + if (signal.aborted || attempt >= 2 || !(error instanceof TypeError)) throw error; + await delay(250 * 2 ** attempt, signal); + continue; + } + if ([429, 500, 502, 503, 504].includes(response.status) && attempt < 2) { + const retryAfter = response.headers.get("retry-after"); + const wait = retryAfter === null ? 250 * 2 ** attempt + : /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 + : Math.max(0, Date.parse(retryAfter) - Date.now()); + if (Number.isFinite(wait) && wait <= 5000) { + await response.body?.cancel(); + await delay(wait, signal); + continue; + } + } + signal.throwIfAborted(); + if (!response.ok) throw await responseError(response); + if (response.redirected || (response.url && new URL(response.url).origin !== url.origin)) { + await response.body?.cancel(); + throw new Error("Artifact redirects are not allowed."); + } + if (response.status !== 200 && !(range && response.status === 206)) { + await response.body?.cancel(); + throw new ArtifactError("The server returned an incomplete artifact response.", response.status); + } + const type = response.headers.get("content-type")?.split(";")[0].trim().toLowerCase(); + const isVideo = url.pathname.endsWith("/video"); + const isArchive = /\/(?:archive|snapshots\/\d+)$/.test(url.pathname); + if (!type || (isVideo ? !["video/webm", "video/mp4"].includes(type) + : isArchive ? !["application/gzip", "application/x-gzip", "application/x-tar", "application/zip", "application/octet-stream"].includes(type) + : !["application/json", "application/gzip", "application/octet-stream", "application/x-ndjson"].includes(type))) { + await response.body?.cancel(); + throw new Error("The server did not return the expected artifact content type."); + } + return response; + } +} + +export function artifactFilename(disposition: string | null, fallback: string): string { + const sanitize = (value: string) => value + .replace(/[\\/:*?"<>|\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "_") + .replace(/^\.+/, "").trim().slice(0, 200).replace(/[. ]+$/, ""); + const safeFallback = sanitize(fallback) || "artifact"; + let name = disposition?.match(/filename="((?:\\.|[^"])*)"/i)?.[1]?.replace(/\\(.)/g, "$1") + ?? disposition?.match(/filename=([^;]+)/i)?.[1]?.trim() ?? safeFallback; + const encoded = disposition?.match(/filename\*\s*=\s*UTF-8'[^']*'([^;]+)/i)?.[1]?.trim(); + if (encoded) { + try { name = decodeURIComponent(encoded); } + catch { throw new Error("The artifact has an invalid encoded filename."); } + } + return sanitize(name) || safeFallback; +} + +/** Object URLs live only in this page, never in storage or the service worker. */ +export async function downloadArtifact( + path: string, projectId: string, signal: AbortSignal, fallback = "artifact", +): Promise { + const response = await fetchArtifact(path, projectId, signal); + await saveArtifact(response, signal, fallback); +} + +export async function downloadBatchArtifacts(ids: readonly string[], projectId: string, signal: AbortSignal): Promise { + if (!projectId || !ids.length || ids.some(id => !/^[a-zA-Z0-9_-]+$/.test(id))) { + throw new Error("Select project-bound requests to download."); + } + signal.throwIfAborted(); + const url = new URL("/api/v1/requests/archive", window.location.origin); + url.searchParams.set("projectId", projectId); + // A batch POST is never replayed on a network/5xx failure. + const response = await apiClient(url, { + method: "POST", signal, redirect: "error", credentials: "omit", cache: "no-store", + json: { ids }, + }); + signal.throwIfAborted(); + if (!response.ok) throw await responseError(response); + if (response.status !== 200 || response.redirected || + (response.url && new URL(response.url).href !== url.href) || + !["application/gzip", "application/x-gzip", "application/octet-stream"].includes( + response.headers.get("content-type")?.split(";")[0].trim().toLowerCase() ?? "", + )) { + await response.body?.cancel(); + throw new Error("The server did not return a batch archive."); + } + await saveArtifact(response, signal, `batch-${new Date().toISOString().replace(/[:.]/g, "-")}.tar.gz`); +} + +async function saveArtifact(response: Response, signal: AbortSignal, fallback: string): Promise { + let filename: string; + try { + filename = artifactFilename(response.headers.get("content-disposition"), fallback); + } catch (error) { + await response.body?.cancel(); + throw error; + } + signal.throwIfAborted(); + const chunks: BlobPart[] = []; + const reader = response.body?.getReader(); + if (!reader) throw new Error("The artifact response has no body."); + let onAbort!: () => void; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + while (true) { + signal.throwIfAborted(); + const { value, done } = await Promise.race([reader.read(), aborted]); + signal.throwIfAborted(); + if (done) break; + chunks.push(new Uint8Array(value)); + } + } finally { + signal.removeEventListener("abort", onAbort); + try { + if (signal.aborted) await reader.cancel(signal.reason); + } finally { + reader.releaseLock(); + } + } + const blob = new Blob(chunks, { type: response.headers.get("content-type") ?? "application/octet-stream" }); + signal.throwIfAborted(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + let timer: ReturnType | undefined; + let revoked = false; + const revoke = () => { + if (revoked) return; + revoked = true; + clearTimeout(timer); + URL.revokeObjectURL(url); + signal.removeEventListener("abort", revoke); + window.removeEventListener("pagehide", revoke); + anchor.remove(); + }; + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + signal.addEventListener("abort", revoke, { once: true }); + window.addEventListener("pagehide", revoke, { once: true }); + timer = setTimeout(revoke, 60_000); + try { + anchor.click(); + } catch (error) { + revoke(); + throw error; + } finally { + anchor.remove(); + } +} diff --git a/apps/portal/src/lib/authenticated-media.test.ts b/apps/portal/src/lib/authenticated-media.test.ts new file mode 100644 index 000000000..dc6cc4ccc --- /dev/null +++ b/apps/portal/src/lib/authenticated-media.test.ts @@ -0,0 +1,508 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { MessageChannel as NativeMessageChannel, type MessagePort as NativePort } from "node:worker_threads"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { openAuthenticatedMedia } from "./authenticated-media"; +import { resetApiClient, setApiSessionSignal, setApiTokenProvider, setReauthHandler } from "./api-client"; + +const script = readFileSync(new URL("../../public/scope-media-worker.js", import.meta.url), "utf8"); +const origin = "https://scope.test"; +const path = "/api/v1/requests/request-1/runs/attempt-1/video?iteration=2&index=1"; +const channels: NativeMessageChannel[] = []; +const timers = new Set>(); +const sessions: AbortController[] = []; +class TestChannel extends NativeMessageChannel { + constructor() { super(); channels.push(this); } +} + +function onMessage(port: NativePort, listener: (event: MessageEvent<{ type: string }>) => void) { + port.addEventListener("message", event => listener(event as MessageEvent<{ type: string }>)); + port.start(); +} + +function worker(scope = `${origin}/`) { + const handlers = new Map) => void>(); + const clients = new Map([["tab-1", { id: "tab-1", url: `${scope}runs/1`, type: "window" }]]); + const context = { + location: new URL(scope), + registration: { scope }, + clients: { get: vi.fn(async (id: string) => clients.get(id)), claim: vi.fn() }, + skipWaiting: vi.fn(), + addEventListener(type: string, listener: (event: Record) => void) { handlers.set(type, listener); }, + }; + runInNewContext(script, { + self: context, MessageChannel: TestChannel, URL, Headers, Response, ReadableStream, Uint8Array, + setTimeout(callback: () => void, ms: number) { + const timer = setTimeout(callback, ms); timers.add(timer); return timer; + }, + clearTimeout, + }); + return { + context, + clients, + open(id: string, port: NativePort, clientId = "tab-1", url = `${scope}runs/1`) { + handlers.get("message")!({ + data: { type: "scope-media-open", id }, ports: [port], + source: { id: clientId, url, type: "window" }, + }); + }, + fetch(url: string, range: string | null = "bytes=0-3", clientId = "tab-1", signal?: AbortSignal, method = "GET") { + let response: Promise | undefined; + handlers.get("fetch")!({ + request: new Request(url, { method, headers: range ? { Range: range } : {}, signal }), + clientId, + respondWith(value: Response | Promise) { response = Promise.resolve(value); }, + }); + if (!response) throw new Error("Request was not intercepted."); + return response; + }, + }; +} + +function environment(scope = `${origin}/`) { + const sw = worker(scope); + const container = Object.assign(new EventTarget(), { + controller: { + scriptURL: `${scope}scope-media-worker.js`, + postMessage: vi.fn((data: { id: string }, ports: NativePort[]) => sw.open(data.id, ports[0])), + }, + getRegistrations: vi.fn(async () => [] as Array<{ + scope: string; active: { scriptURL: string }; waiting?: { scriptURL: string }; + }>), + register: vi.fn(async () => ({})), + }); + const page = Object.assign(new EventTarget(), { isSecureContext: true, location: new URL(scope) }); + vi.stubGlobal("window", page); + vi.stubGlobal("navigator", { serviceWorker: container }); + vi.stubGlobal("MessageChannel", TestChannel); + return { sw, container, page }; +} + +function session() { + const controller = new AbortController(); + sessions.push(controller); + return controller; +} + +function recording(body: BodyInit | ReadableStream = new Uint8Array([1, 2, 3, 4]), range = "bytes 0-3/10") { + return new Response(body, { status: 206, headers: { + "Content-Type": "video/webm", "Content-Length": "4", "Content-Range": range, "Accept-Ranges": "bytes", + } }); +} + +beforeEach(() => resetApiClient()); +afterEach(() => { + for (const controller of sessions.splice(0)) controller.abort(); + for (const timer of timers) clearTimeout(timer); + timers.clear(); + for (const channel of channels.splice(0)) { channel.port1.close(); channel.port2.close(); } + resetApiClient(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); + +describe("production page + native worker protocol + shared API transport", () => { + it("derives script, scope and bridge routing from the configured base path", async () => { + vi.stubEnv("BASE_URL", "/scope/"); + const { sw, container } = environment(`${origin}/scope/`); + vi.stubGlobal("fetch", vi.fn(async () => recording())); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + expect(container.register).toHaveBeenCalledWith(`${origin}/scope/scope-media-worker.js`, { + scope: "/scope/", updateViaCache: "none", + }); + expect(bridge.url).toContain(`${origin}/scope/__scope_media__/`); + expect((await sw.fetch(bridge.url)).status).toBe(206); + bridge.close(); + }); + + it("streams an initial non-range 200 without changing its bytes or length", async () => { + const { sw } = environment(); + vi.stubGlobal("fetch", vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { + headers: { "Content-Type": "video/webm", "Content-Length": "3", "Accept-Ranges": "bytes" }, + }))); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + const response = await sw.fetch(bridge.url, null); + expect(response.status).toBe(200); + expect(response.headers.get("content-range")).toBeNull(); + expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([1, 2, 3]); + bridge.close(); + }); + + it.each(["bytes=6-", "bytes=-4", "bytes=6-999"])("preserves the bounded response for %s", async range => { + const { sw } = environment(); + vi.stubGlobal("fetch", vi.fn(async () => recording(new Uint8Array([6, 7, 8, 9]), "bytes 6-9/10"))); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + const response = await sw.fetch(bridge.url, range); + expect(response.status).toBe(206); + expect(response.headers.get("content-range")).toBe("bytes 6-9/10"); + expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([6, 7, 8, 9]); + bridge.close(); + }); + + it("authenticates initial and subsequent ranges with exact 206 bytes and no URL credentials or cache", async () => { + const { sw } = environment(); + setApiTokenProvider(() => "test-only-idp"); + const fetcher = vi.fn(async () => recording()); + vi.stubGlobal("fetch", fetcher); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + for (const range of ["bytes=0-3", "bytes=4-7"]) { + fetcher.mockImplementation(async () => recording(new Uint8Array([1, 2, 3, 4]), + range === "bytes=0-3" ? "bytes 0-3/10" : "bytes 4-7/10")); + const response = await sw.fetch(bridge.url, range); + expect(response.status).toBe(206); + expect(response.headers.get("content-range")).toBe(range === "bytes=0-3" ? "bytes 0-3/10" : "bytes 4-7/10"); + expect(response.headers.get("content-length")).toBe("4"); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([1, 2, 3, 4]); + } + const calls = fetcher.mock.calls as unknown as [Request][]; + expect(calls).toHaveLength(2); + expect(calls.map(([request]) => request.headers.get("range"))).toEqual(["bytes=0-3", "bytes=4-7"]); + for (const [request] of calls) { + expect(request.headers.get("authorization")).toBe("Bearer test-only-idp"); + expect(request.url).toBe(`${origin}${path}&projectId=project-1`); + expect(request.credentials).toBe("omit"); + expect(request.cache).toBe("no-store"); + expect(request.redirect).toBe("error"); + } + expect(bridge.url).toMatch(/^https:\/\/scope.test\/__scope_media__\/[a-f0-9-]{36}$/); + expect(script).not.toMatch(/caches\.|indexedDB|localStorage|authorization|Bearer/); + bridge.close(); + }); + + it("does not drain the recording before native consumption and cancels old seek readers without closing the session", async () => { + const { sw } = environment(); + const pull = vi.fn((controller: ReadableStreamDefaultController) => controller.enqueue(new Uint8Array([7]))); + const cancel = vi.fn(); + const source = new ReadableStream({ pull, cancel }, { highWaterMark: 0 }); + vi.stubGlobal("fetch", vi.fn(async () => recording(source))); + const error = vi.fn(); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, error); + const response = await sw.fetch(bridge.url, "bytes=0-3"); + // ky's single response hook tees the response, allowing a bounded transport prefetch. + expect(pull.mock.calls.length).toBeLessThanOrEqual(2); + const reader = response.body!.getReader(); + expect((await reader.read()).value).toEqual(new Uint8Array([7])); + expect(pull.mock.calls.length).toBeLessThan(4); + await reader.cancel(); + await vi.waitFor(() => expect(cancel).toHaveBeenCalledTimes(1)); + expect(error).not.toHaveBeenCalled(); + vi.stubGlobal("fetch", vi.fn(async () => recording())); + expect((await sw.fetch(bridge.url, "bytes=0-3")).status).toBe(206); + bridge.close(); + }); + + it("serializes concurrent native reads while the upstream chunk is still pending", async () => { + const { sw } = environment(); + let upstream!: ReadableStreamDefaultController; + let canceled = false; + const source = new ReadableStream({ + start(controller) { upstream = controller; }, + cancel() { canceled = true; }, + }, { highWaterMark: 0 }); + vi.stubGlobal("fetch", vi.fn(async () => recording(source))); + const error = vi.fn(); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, error); + const response = await sw.fetch(bridge.url); + const reader = response.body!.getReader(); + const reads = [reader.read(), reader.read(), reader.read(), reader.read(), reader.read()]; + const completion = Promise.allSettled(reads); + for (const value of [1, 2, 3, 4]) { + await new Promise(resolve => setTimeout(resolve, 10)); + if (canceled) break; + upstream.enqueue(new Uint8Array([value])); + } + if (!canceled) upstream.close(); + expect(await completion).toEqual([ + ...[1, 2, 3, 4].map(value => ({ + status: "fulfilled", value: { value: new Uint8Array([value]), done: false }, + })), + { status: "fulfilled", value: { value: undefined, done: true } }, + ]); + expect(error).not.toHaveBeenCalled(); + bridge.close(); + }); + + it("uses the shared single 401 refresh for a range", async () => { + const { sw } = environment(); + const token = vi.fn().mockResolvedValueOnce("expired").mockResolvedValueOnce("renewed"); + const reauth = vi.fn(); + setApiTokenProvider(token); + setReauthHandler(reauth); + const fetcher = vi.fn().mockResolvedValueOnce(new Response("", { status: 401 })).mockResolvedValueOnce(recording()); + vi.stubGlobal("fetch", fetcher); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + expect((await sw.fetch(bridge.url, "bytes=0-3")).status).toBe(206); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(token).toHaveBeenNthCalledWith(2, { forceRefresh: true }); + expect(reauth).not.toHaveBeenCalled(); + bridge.close(); + }); + + it("stops after one refresh on permanent 401 and reports the failure", async () => { + const { sw } = environment(); + const token = vi.fn(() => "test-only-token"); + const reauth = vi.fn(); + setApiTokenProvider(token); + setReauthHandler(reauth); + const fetcher = vi.fn(async () => new Response("", { status: 401 })); + vi.stubGlobal("fetch", fetcher); + const error = vi.fn(); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, error); + expect((await sw.fetch(bridge.url)).status).toBe(401); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(token).toHaveBeenCalledTimes(2); + expect(reauth).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(error).toHaveBeenCalledWith(expect.stringContaining("401"))); + }); + + it.each([403, 404, 416])("preserves HTTP %s rather than racing it with session teardown", async status => { + const { sw } = environment(); + vi.stubGlobal("fetch", vi.fn(async () => new Response('{"error":"Recording denied"}', { + status, headers: { "Content-Type": "application/json", "Content-Range": "bytes */10", "Accept-Ranges": "bytes" }, + }))); + const error = vi.fn(); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, error); + const response = await sw.fetch(bridge.url); + expect(response.status).toBe(status); + expect(await response.text()).toContain("Recording denied"); + if (status === 416) expect(response.headers.get("content-range")).toBe("bytes */10"); + await vi.waitFor(() => expect(error).toHaveBeenCalledWith(expect.stringContaining("Recording denied"))); + }); + + it("reauthorizes after membership loss; the bridge id is not an authorization cache", async () => { + const { sw } = environment(); + const fetcher = vi.fn().mockResolvedValueOnce(recording()).mockResolvedValueOnce( + new Response('{"error":"Membership removed"}', { status: 403, headers: { "Content-Type": "application/json" } }), + ); + vi.stubGlobal("fetch", fetcher); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + expect((await sw.fetch(bridge.url)).status).toBe(206); + expect((await sw.fetch(bridge.url, "bytes=4-7")).status).toBe(403); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it.each(["account/logout/project/attempt signal", "pagehide", "worker replacement"])("cleans pending body reads on %s", async reason => { + const { sw, page, container } = environment(); + const cancel = vi.fn(); + const source = new ReadableStream({ cancel }, { highWaterMark: 0 }); + vi.stubGlobal("fetch", vi.fn(async () => recording(source))); + const controller = session(); + setApiSessionSignal(controller.signal); + const bridge = await openAuthenticatedMedia(path, "project-1", controller.signal, vi.fn()); + const response = await sw.fetch(bridge.url); + const read = response.body!.getReader().read(); + const rejection = expect(read).rejects.toThrow(); + if (reason === "pagehide") page.dispatchEvent(new Event("pagehide")); + else if (reason === "worker replacement") container.dispatchEvent(new Event("controllerchange")); + else controller.abort(); + await rejection; + await vi.waitFor(() => expect(cancel).toHaveBeenCalled()); + expect((await sw.fetch(bridge.url)).status).toBe(403); + }); + + it("rejects other clients and loses routing on a worker restart without fetching protected data", async () => { + const { sw } = environment(); + const fetcher = vi.fn(async () => recording()); + vi.stubGlobal("fetch", fetcher); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + expect((await sw.fetch(bridge.url, null, "other-tab")).status).toBe(403); + expect((await sw.fetch(bridge.url, null, "")).status).toBe(403); + expect((await worker().fetch(bridge.url)).status).toBe(403); + expect((await sw.fetch(`${bridge.url}?token=not-allowed`)).status).toBe(404); + expect((await sw.fetch(bridge.url, null, "tab-1", undefined, "POST")).status).toBe(405); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("fails clearly on insecure/unsupported browsers and does not overwrite incompatible workers", async () => { + const { page, container } = environment(); + page.isSecureContext = false; + await expect(openAuthenticatedMedia(path, "project-1", session().signal, vi.fn())).rejects.toThrow("secure browser"); + page.isSecureContext = true; + container.getRegistrations.mockResolvedValue([{ + scope: `${origin}/`, active: { scriptURL: `${origin}/scope-media-worker.js` }, + waiting: { scriptURL: `${origin}/mockServiceWorker.js` }, + }]); + await expect(openAuthenticatedMedia(path, "project-1", session().signal, vi.fn())).rejects.toThrow("cannot replace"); + expect(container.register).not.toHaveBeenCalled(); + vi.stubGlobal("navigator", {}); + await expect(openAuthenticatedMedia(path, "project-1", session().signal, vi.fn())).rejects.toThrow("secure browser"); + }); + + it("settles and closes a bridge canceled before the worker acknowledges", async () => { + const { container } = environment(); + container.controller.postMessage.mockImplementation(() => {}); + const controller = session(); + const opening = openAuthenticatedMedia(path, "project-1", controller.signal, vi.fn()); + const rejected = expect(opening).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(container.controller.postMessage).toHaveBeenCalled()); + controller.abort(); + await rejected; + }); + + it("settles a malformed opening response immediately and leaves no routing", async () => { + const { container, sw } = environment(); + container.controller.postMessage.mockImplementation((_data, ports) => ports[0].postMessage(null)); + await expect(openAuthenticatedMedia(path, "project-1", session().signal, vi.fn())).rejects.toThrow("Invalid video bridge"); + expect((await sw.fetch(`${origin}/__scope_media__/12345678-1234-1234-1234-123456789abc`)).status).toBe(403); + }); + + it("times out acquisition without registering after a late registration inventory", async () => { + vi.useFakeTimers(); + const { container } = environment(); + let complete!: (value: []) => void; + container.getRegistrations.mockImplementation(() => new Promise(resolve => { complete = resolve; })); + const opening = openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + const rejection = expect(opening).rejects.toThrow("could not acquire"); + await vi.advanceTimersByTimeAsync(10_001); + await rejection; + complete([]); + await Promise.resolve(); + expect(container.register).not.toHaveBeenCalled(); + }); + + it("fails when the worker does not acknowledge instead of hanging", async () => { + vi.useFakeTimers(); + const { container } = environment(); + container.controller.postMessage.mockImplementation(() => {}); + const opening = openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + const rejected = expect(opening).rejects.toThrow("did not respond"); + await vi.advanceTimersByTimeAsync(10_001); + await rejected; + }); + + it("rejects incorrect progressive headers and cancels the returned body", async () => { + const { sw } = environment(); + const cancel = vi.fn(); + vi.stubGlobal("fetch", vi.fn(async () => new Response(new ReadableStream({ cancel }), { + status: 206, headers: { "Content-Type": "video/webm", "Content-Length": "4", "Content-Range": "bytes 0-8/10", "Accept-Ranges": "bytes" }, + }))); + const error = vi.fn(); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, error); + expect((await sw.fetch(bridge.url)).status).toBe(503); + await vi.waitFor(() => expect(cancel).toHaveBeenCalled()); + }); + + it("rejects a well-formed but different response range", async () => { + const { sw } = environment(); + vi.stubGlobal("fetch", vi.fn(async () => recording())); + const error = vi.fn(); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, error); + expect((await sw.fetch(bridge.url, "bytes=4-7")).status).toBe(503); + await vi.waitFor(() => expect(error).toHaveBeenCalledWith(expect.stringContaining("different video range"))); + }); + + it("aborts establishment when the worker range timeout cancels the page request", async () => { + vi.useFakeTimers(); + const { sw } = environment(); + let upstream: Request | undefined; + vi.stubGlobal("fetch", vi.fn((request: Request) => new Promise((_resolve, reject) => { + upstream = request; + request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true }); + }))); + const bridge = await openAuthenticatedMedia(path, "project-1", session().signal, vi.fn()); + const response = sw.fetch(bridge.url); + await vi.waitFor(() => expect(upstream).toBeDefined()); + await vi.advanceTimersByTimeAsync(30_001); + expect((await response).status).toBe(503); + await vi.waitFor(() => expect(upstream?.signal.aborted).toBe(true)); + }); +}); + +describe("executable service-worker protocol defenses", () => { + const id = "12345678-1234-1234-1234-123456789abc"; + async function opened() { + const sw = worker(); + const channel = new TestChannel(); + const ready = new Promise(resolve => { onMessage(channel.port1, resolve); }); + sw.open(id, channel.port2); + await ready; + return { sw, port: channel.port1, url: `${origin}/__scope_media__/${id}` }; + } + + it.each([ + { type: "headers", status: 206, headers: [["Set-Cookie", "bad"]] }, + { type: "headers", status: 206, headers: [["content-length", "\r\ninvalid"]] }, + { type: "headers", status: 206, headers: [["content-length", "not-a-number"]] }, + { type: "error", status: 416, headers: [["content-range", "bytes */10\r\ninvalid"]] }, + { type: "chunk", bytes: new Uint8Array([1]) }, + { type: "nonsense" }, + ])("rejects malformed/unordered messages: $type", async message => { + const { sw, port, url } = await opened(); + onMessage(port, event => { + if (event.data.type === "fetch") event.ports[0].postMessage(message); + }); + expect((await sw.fetch(url)).status).toBe(503); + }); + + it("limits simultaneous ranges and times out unresponsive page fetches", async () => { + vi.useFakeTimers(); + const { sw, url } = await opened(); + const pending = Array.from({ length: 4 }, () => sw.fetch(url)); + expect((await sw.fetch(url)).status).toBe(429); + await vi.advanceTimersByTimeAsync(30_001); + expect((await Promise.all(pending)).map(response => response.status)).toEqual([503, 503, 503, 503]); + }); + + it("never requests bytes until the native reader pulls, then rejects truncated bodies", async () => { + const { sw, port, url } = await opened(); + const pulls = vi.fn(); + onMessage(port, event => { + if (event.data.type !== "fetch") return; + const transfer = event.ports[0]; + transfer.onmessage = () => { pulls(); transfer.postMessage({ type: "end" }); }; + transfer.postMessage({ type: "headers", status: 206, headers: [ + ["content-length", "4"], ["content-range", "bytes 0-3/10"], ["content-type", "video/webm"], + ] }); + }); + const response = await sw.fetch(url); + expect(pulls).not.toHaveBeenCalled(); + await expect(response.body!.getReader().read()).rejects.toThrow("Invalid video stream"); + expect(pulls).toHaveBeenCalledTimes(1); + }); + + it("rejects an oversized chunk rather than forwarding extra bytes", async () => { + const { sw, port, url } = await opened(); + onMessage(port, event => { + if (event.data.type !== "fetch") return; + const transfer = event.ports[0]; + transfer.onmessage = () => transfer.postMessage({ type: "chunk", bytes: new Uint8Array(5) }); + transfer.postMessage({ type: "headers", status: 206, headers: [["content-length", "4"]] }); + }); + const response = await sw.fetch(url); + await expect(response.body!.getReader().read()).rejects.toThrow("Invalid video stream"); + }); + + it("expires idle sessions without persistent routing", async () => { + vi.useFakeTimers(); + const { sw, url } = await opened(); + await vi.advanceTimersByTimeAsync(5 * 60_000 + 1); + expect((await sw.fetch(url)).status).toBe(403); + }); + + it("rejects cross-origin or out-of-scope pages and clients gone during lookup", async () => { + const sw = worker(`${origin}/scope/`); + const channel = new TestChannel(); + sw.open(id, channel.port2, "tab-1", "https://foreign.test/scope/"); + expect((await sw.fetch(`${origin}/scope/__scope_media__/${id}`)).status).toBe(403); + sw.open(id, channel.port2, "tab-1", `${origin}/outside/`); + expect((await sw.fetch(`${origin}/scope/__scope_media__/${id}`)).status).toBe(403); + const ready = new Promise(resolve => { onMessage(channel.port1, resolve); }); + sw.open(id, channel.port2, "tab-1", `${origin}/scope/`); + await ready; + sw.clients.clear(); + expect((await sw.fetch(`${origin}/scope/__scope_media__/${id}`)).status).toBe(410); + }); + + it("uses the registration base path and bypasses unrelated origin/routes", async () => { + const sw = worker(`${origin}/scope/`); + expect(() => sw.fetch(`${origin}/api/v1/requests/1/video`)).toThrow("not intercepted"); + expect(() => sw.fetch(`https://foreign.test/scope/__scope_media__/${id}`)).toThrow("not intercepted"); + expect((await sw.fetch(`${origin}/scope/__scope_media__/invalid`)).status).toBe(404); + }); +}); diff --git a/apps/portal/src/lib/authenticated-media.ts b/apps/portal/src/lib/authenticated-media.ts new file mode 100644 index 000000000..d47b7369e --- /dev/null +++ b/apps/portal/src/lib/authenticated-media.ts @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ArtifactError, artifactUrl, fetchArtifact } from "./artifact-delivery"; + +const responseHeaders = ["content-type", "content-range", "content-length", "accept-ranges", "content-disposition"]; + +async function mediaWorker(signal: AbortSignal): Promise { + if (!window.isSecureContext || !("serviceWorker" in navigator) || + typeof MessageChannel !== "function" || typeof ReadableStream !== "function" || + typeof AbortSignal.any !== "function" || typeof crypto.randomUUID !== "function") { + throw new Error("Authenticated video requires a secure browser with service-worker support."); + } + const base = new URL(import.meta.env.BASE_URL, window.location.origin); + const script = new URL("scope-media-worker.js", base).href; + return new Promise((resolve, reject) => { + let settled = false; + let registered = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener("abort", aborted); + navigator.serviceWorker.removeEventListener("controllerchange", changed); + if (error) reject(error); + else resolve(navigator.serviceWorker.controller!); + }; + const aborted = () => finish(new DOMException("Video session canceled.", "AbortError")); + const changed = () => { + if (!registered) return; + const controller = navigator.serviceWorker.controller; + if (controller?.scriptURL === script) finish(); + else if (controller) finish(new Error("An incompatible service worker controls this page.")); + }; + const timer = setTimeout(() => finish(new Error("Video delivery could not acquire this page. Reload and reopen the recording.")), 10_000); + signal.addEventListener("abort", aborted, { once: true }); + navigator.serviceWorker.addEventListener("controllerchange", changed); + if (signal.aborted) aborted(); + else void (async () => { + const registrations = await navigator.serviceWorker.getRegistrations(); + if (settled) return; + if ((navigator.serviceWorker.controller && navigator.serviceWorker.controller.scriptURL !== script) || + registrations.some(registration => + base.href.startsWith(registration.scope) && + [registration.active, registration.waiting, registration.installing] + .some(worker => worker && worker.scriptURL !== script), + )) throw new Error("Another service worker controls this app. Authenticated video cannot replace it."); + await navigator.serviceWorker.register(script, { scope: base.pathname, updateViaCache: "none" }); + registered = true; + if (!settled) changed(); + })().catch((cause: unknown) => finish(cause instanceof Error ? cause : new Error("Video worker registration failed."))); + }); +} + +function videoHeaders(response: Response, range: unknown): string[][] { + const length = response.headers.get("content-length"); + const size = length !== null && /^\d+$/.test(length) ? Number(length) : NaN; + const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get("content-range") ?? ""); + if (![200, 206].includes(response.status) || !Number.isSafeInteger(size) || size < 0 || + response.headers.get("accept-ranges") !== "bytes" || + (range !== null && response.status !== 206) || + (response.status === 206 && (range === null || !match || !match.slice(1).every(value => Number.isSafeInteger(Number(value))) || + Number(match[1]) > Number(match[2]) || Number(match[2]) >= Number(match[3]) || + Number(match[2]) - Number(match[1]) + 1 !== size))) { + throw new Error("The server returned invalid progressive video headers."); + } + if (typeof range === "string" && match) { + const requested = /^bytes=(\d*)-(\d*)$/.exec(range)!; + const total = Number(match[3]); + const start = requested[1] ? Number(requested[1]) : Math.max(0, total - Number(requested[2])); + const end = requested[1] ? Math.min(requested[2] ? Number(requested[2]) : total - 1, total - 1) : total - 1; + if (Number(match[1]) !== start || Number(match[2]) !== end) { + throw new Error("The server returned a different video range than requested."); + } + } + return responseHeaders.flatMap(key => { + const value = response.headers.get(key); + return value === null ? [] : [[key, value]]; + }); +} + +export interface MediaBridge { + url: string; + close(): void; +} + +export async function openAuthenticatedMedia( + path: string, projectId: string, signal: AbortSignal, onError: (message: string) => void, +): Promise { + const canonical = artifactUrl(path, projectId); + if (!canonical.pathname.endsWith("/video")) throw new Error("The media bridge only serves Scope video routes."); + const worker = await mediaWorker(signal); + signal.throwIfAborted(); + const id = crypto.randomUUID(); + const channel = new MessageChannel(); + const requests = new Set(); + let closed = false; + let rejectOpen: ((reason: Error) => void) | undefined; + let openTimer: ReturnType | undefined; + const close = () => { + if (closed) return; + closed = true; + clearTimeout(openTimer); + rejectOpen?.(new DOMException("Video session canceled.", "AbortError")); + rejectOpen = undefined; + for (const controller of requests) controller.abort(); + channel.port1.postMessage({ type: "close" }); + channel.port1.close(); + signal.removeEventListener("abort", close); + window.removeEventListener("pagehide", close); + navigator.serviceWorker.removeEventListener("controllerchange", replaced); + }; + const fail = (message: string) => { + rejectOpen?.(new Error(message)); + rejectOpen = undefined; + try { if (!signal.aborted && !closed) onError(message); } + finally { close(); } + }; + const replaced = () => fail("Video delivery restarted. Reopen the recording."); + signal.addEventListener("abort", close, { once: true }); + window.addEventListener("pagehide", close); + navigator.serviceWorker.addEventListener("controllerchange", replaced); + + async function serve(port: MessagePort, range: unknown) { + const controller = new AbortController(); + const requestSignal = AbortSignal.any([signal, controller.signal]); + requests.add(controller); + let reader: ReadableStreamDefaultReader | undefined; + let reading = false; + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + controller.abort(); + requests.delete(controller); + port.close(); + }; + const canceled = () => { + // Cancel pending reads as well as fetch; no stale reader may outlive its session. + void reader?.cancel().catch(() => { /* The fetch may already have aborted. */ }); + finish(); + }; + requestSignal.addEventListener("abort", canceled, { once: true }); + const error = (cause: unknown) => { + if (!closed && !requestSignal.aborted) { + const headers = cause instanceof ArtifactError ? cause.headers : undefined; + port.postMessage({ + type: "error", status: cause instanceof ArtifactError ? cause.status : 503, + message: cause instanceof Error ? cause.message : "Video stream failed.", + headers: ["content-range", "accept-ranges"].flatMap(key => { + const value = headers?.get(key); + return value == null ? [] : [[key, value]]; + }), + }); + } + finish(); + }; + port.onmessageerror = () => error(new Error("Video stream connection failed.")); + port.onmessage = async event => { + const data: unknown = event.data; + if (!data || typeof data !== "object" || !("type" in data)) { + error(new Error("Invalid video stream request.")); + return; + } + if (data.type === "cancel") { finish(); return; } + if (data.type !== "pull" || reading || !reader) { + error(new Error("Invalid video stream request.")); + return; + } + if (requestSignal.aborted) return; + reading = true; + try { + const chunk = await reader.read(); + requestSignal.throwIfAborted(); + if (chunk.done) { port.postMessage({ type: "end" }); finish(); } + else port.postMessage({ type: "chunk", bytes: chunk.value }); + } catch (cause) { + error(cause); + } finally { reading = false; } + }; + try { + if (closed || requestSignal.aborted) throw new DOMException("Video closed.", "AbortError"); + if (range !== null && (typeof range !== "string" || !/^bytes=(?:\d+-\d*|-\d+)$/.test(range))) { + throw new ArtifactError("Unsupported video range.", 416); + } + const response = await fetchArtifact(canonical.href, projectId, requestSignal, range ?? undefined); + requestSignal.throwIfAborted(); + if (!response.body) throw new Error("No video stream was returned."); + reader = response.body.getReader(); + const headers = videoHeaders(response, range); + port.postMessage({ + type: "headers", status: response.status, headers, + }); + } catch (cause) { + error(cause); + } + } + + await new Promise((resolve, reject) => { + if (closed || signal.aborted) { close(); reject(new DOMException("Video canceled.", "AbortError")); return; } + rejectOpen = reject; + openTimer = setTimeout(() => { reject(new Error("Video bridge did not respond.")); close(); }, 10_000); + channel.port1.onmessageerror = () => fail("Video bridge connection failed."); + channel.port1.onmessage = event => { + const data: unknown = event.data; + if (!data || typeof data !== "object" || !("type" in data)) { fail("Invalid video bridge message."); return; } + if (data.type === "ready") { + clearTimeout(openTimer); + rejectOpen = undefined; + resolve(); + } else if (data.type === "fetch" && "range" in data && event.ports[0]) { + if (closed || requests.size >= 4) { + event.ports[0].postMessage({ type: "error", status: 429, message: "Too many concurrent video ranges." }); + event.ports[0].close(); + } else void serve(event.ports[0], data.range); + } else if (data.type === "failed" && "message" in data && typeof data.message === "string") { + fail(data.message); + } else if (data.type === "closed" && "message" in data && typeof data.message === "string") { + rejectOpen?.(new Error(data.message)); + fail(data.message); + } else fail("Invalid video bridge message."); + }; + try { worker.postMessage({ type: "scope-media-open", id }, [channel.port2]); } + catch (cause) { close(); reject(cause); } + }); + signal.throwIfAborted(); + return { url: new URL(`__scope_media__/${id}`, new URL(import.meta.env.BASE_URL, window.location.origin)).href, close }; +} diff --git a/apps/portal/src/lib/log-stream.test.ts b/apps/portal/src/lib/log-stream.test.ts new file mode 100644 index 000000000..5e78f663b --- /dev/null +++ b/apps/portal/src/lib/log-stream.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { openLogStream, readLogStream, type LogStreamEvent } from "./log-stream"; +import { resetApiClient, setApiTokenProvider, setReauthHandler } from "./api-client"; + +const url = "https://scope.test/api/v1/requests/run/logs?fromStart=true"; +const log = { timestamp: "2026-05-01T00:00:00.000Z", level: "info", message: "hello 🦊 世界" }; +const encoder = new TextEncoder(); +let fetchMock: ReturnType Promise>>; +function response(text: string) { + return new Response(text, { headers: { "Content-Type": "text/event-stream; charset=utf-8" } }); +} +async function parse(text: string, byteChunks = false) { + const events: LogStreamEvent[] = []; + const bytes = encoder.encode(text); + const chunks = byteChunks ? Array.from(bytes, (byte) => new Uint8Array([byte])) : [bytes]; + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); + await readLogStream(new Response(stream), new AbortController().signal, (event) => events.push(event)); + return events; +} + +beforeEach(() => { + resetApiClient(); + fetchMock = vi.fn<(request: Request) => Promise>(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + resetApiClient(); +}); + +describe("log SSE parser", () => { + it.each([false, true])("parses UTF-8, CRLF, multiline data and heartbeats (byte chunks: %s)", async (chunks) => { + const json = JSON.stringify(log).replace(',"message"', ',\r\ndata: "message"'); + const events = await parse(`: heartbeat\r\n\r\nevent: message\r\ndata: ${json}\r\n\r\ndata: {"type":"turns_summary","turns":2}\r\n\r\nevent: done\r\ndata: {}\r\n\r\n`, chunks); + expect(events).toEqual([{ type: "log", log }, { type: "done" }]); + }); + it("supports CR-only frames, BOM and irrelevant named events", async () => { + expect(await parse(`\uFEFFevent: other\rdata: not-json\r\revent: done\rdata: {}\r\r`)) + .toEqual([{ type: "done" }]); + }); + it.each(["timeout", "error", "done"])("stops at a named %s event even with trailing chunks", async (type) => { + const events = await parse(`event: ${type}\ndata: {"message":"storage unavailable"}\n\ndata: ${JSON.stringify(log)}\n\n`); + expect(events).toEqual([type === "error" + ? { type: "error", message: "storage unavailable" } + : { type }]); + }); + it.each([ + ["not-json", "Invalid JSON"], + [JSON.stringify({ ...log, level: "arbitrary" }), "Invalid log entry"], + [JSON.stringify({ ...log, timestamp: "invalid" }), "Invalid log entry"], + [JSON.stringify({ ...log, data: [] }), "Invalid log entry"], + [JSON.stringify({ ...log, source: 12 }), "Invalid log entry"], + ])("surfaces malformed log data %s", async (data, message) => { + await expect(parse(`data: ${data}\n\n`)).rejects.toThrow(message); + }); + it("does not turn an unterminated event at EOF into a completed replay", async () => { + await expect(parse(`data: ${JSON.stringify(log)}\n`)).rejects.toThrow(/disconnected/); + }); + it("rejects malformed named errors instead of disguising them as a connection error", async () => { + await expect(parse("event: error\ndata: {}\n\n")).rejects.toThrow("Invalid log stream error event"); + }); +}); + +describe("authenticated stream establishment", () => { + it("refreshes one initial 401 through the shared transport and returns a readable stream", async () => { + const provider = vi.fn().mockResolvedValueOnce("old").mockResolvedValueOnce("fresh"); + const reauth = vi.fn(); + setApiTokenProvider(provider); + setReauthHandler(reauth); + fetchMock.mockResolvedValueOnce(new Response("", { status: 401 })) + .mockResolvedValueOnce(response("event: done\ndata: {}\n\n")); + const opened = await openLogStream(url, new AbortController().signal); + await expect(readLogStream(opened, new AbortController().signal, vi.fn())).resolves.toBeUndefined(); + expect(provider).toHaveBeenNthCalledWith(2, { forceRefresh: true }); + expect(fetchMock.mock.calls.map(([request]: [Request]) => request.headers.get("Authorization"))) + .toEqual(["Bearer old", "Bearer fresh"]); + expect(reauth).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls.every(([request]: [Request]) => request.url === url)).toBe(true); + }); + it.each([403, 404, 409])("terminates HTTP %s without token refresh, retries or interactive login", async (status) => { + const provider = vi.fn(() => "token"); + const reauth = vi.fn(); + setApiTokenProvider(provider); + setReauthHandler(reauth); + fetchMock.mockResolvedValue(new Response("{}", { status })); + await expect(openLogStream(url, new AbortController().signal)).rejects.toThrow(`HTTP ${status}`); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(provider).toHaveBeenCalledOnce(); + expect(reauth).not.toHaveBeenCalled(); + }); + it("caps repeated 401 at the existing single refresh and single reauth callback", async () => { + const reauth = vi.fn(); + setApiTokenProvider(() => "token"); + setReauthHandler(reauth); + fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 401 }))); + await expect(openLogStream(url, new AbortController().signal)).rejects.toThrow("HTTP 401"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(reauth).toHaveBeenCalledOnce(); + }); + it.each([429, 500, 502, 503, 504])("bounds transient HTTP %s retries and honors Retry-After", async (status) => { + vi.useFakeTimers(); + const provider = vi.fn(() => "reacquired-token"); + setApiTokenProvider(provider); + fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status, headers: { "Retry-After": "1" } }))); + const opened = openLogStream(url, new AbortController().signal); + const rejected = expect(opened).rejects.toThrow(`HTTP ${status}`); + await vi.advanceTimersByTimeAsync(999); + expect(fetchMock).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1_000); + await rejected; + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(provider).toHaveBeenCalledTimes(3); + }); + it("honors an HTTP-date Retry-After", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + fetchMock.mockResolvedValueOnce(new Response("", { status: 429, headers: { "Retry-After": "Fri, 01 May 2026 00:00:02 GMT" } })) + .mockResolvedValueOnce(response("event: done\ndata: {}\n\n")); + const opened = openLogStream(url, new AbortController().signal); + await vi.advanceTimersByTimeAsync(1_999); + expect(fetchMock).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + await (await opened).body?.cancel(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("does not retry early when Retry-After exceeds the bounded wait", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 429, headers: { "Retry-After": "3600" } })); + await expect(openLogStream(url, new AbortController().signal)).rejects.toThrow("HTTP 429"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it("reacquires the bearer after a network failure before any response", async () => { + vi.useFakeTimers(); + const provider = vi.fn().mockReturnValueOnce("first").mockReturnValueOnce("second"); + setApiTokenProvider(provider); + fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(response("event: done\ndata: {}\n\n")); + const opened = openLogStream(url, new AbortController().signal); + await vi.runAllTimersAsync(); + await (await opened).body?.cancel(); + expect(fetchMock.mock.calls.map(([request]: [Request]) => request.headers.get("Authorization"))) + .toEqual(["Bearer first", "Bearer second"]); + }); + it("aborts during backoff without sending another request", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + fetchMock.mockResolvedValueOnce(new Response("", { status: 503 })); + const opened = openLogStream(url, controller.signal); + const rejected = expect(opened).rejects.toMatchObject({ name: "AbortError" }); + await vi.advanceTimersByTimeAsync(1); + controller.abort(); + await rejected; + await vi.runAllTimersAsync(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it("rejects non-SSE successful responses instead of marking a login HTML page connected", async () => { + fetchMock.mockResolvedValue(new Response("login", { headers: { "Content-Type": "text/html" } })); + await expect(openLogStream(url, new AbortController().signal)).rejects.toThrow("Expected a text/event-stream"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/portal/src/lib/log-stream.ts b/apps/portal/src/lib/log-stream.ts new file mode 100644 index 000000000..bcf93276a --- /dev/null +++ b/apps/portal/src/lib/log-stream.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createParser } from "eventsource-parser"; +import { isNetworkError } from "ky"; +import { apiClient } from "./api-client"; +import type { LogEvent } from "@/types"; + +export type LogStreamEvent = + | { type: "log"; log: LogEvent } + | { type: "done" } + | { type: "timeout" } + | { type: "error"; message: string }; + +const DISCONNECTED = "Log stream disconnected. Reload to replay stored logs."; +const MAX_RETRIES = 2; +const MAX_RETRY_DELAY_MS = 30_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isLogEvent(value: unknown): value is LogEvent { + return isRecord(value) + && typeof value.timestamp === "string" + && Number.isFinite(Date.parse(value.timestamp)) + && typeof value.message === "string" + && typeof value.level === "string" + && ["info", "warn", "error", "debug"].includes(value.level) + && (value.source === undefined || typeof value.source === "string") + && (value.data === undefined || isRecord(value.data)); +} + +function retryDelay(response: Response | undefined, attempt: number): number | undefined { + const header = response?.headers.get("Retry-After"); + if (header) { + const seconds = Number(header); + const delay = Number.isFinite(seconds) && seconds >= 0 + ? seconds * 1_000 + : Date.parse(header) - Date.now(); + if (Number.isFinite(delay)) { + // Do not retry earlier than requested when the server exceeds our wait budget. + return delay > MAX_RETRY_DELAY_MS ? undefined : Math.max(0, delay); + } + } + return 500 * 2 ** attempt + Math.random() * 250; +} + +function waitForRetry(delay: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(); + }, delay); + signal.addEventListener("abort", abort, { once: true }); + }); +} + +/** Retry only before accepting a stream: the server has no replay/resume cursor. */ +export async function openLogStream(url: string, signal: AbortSignal): Promise { + for (let attempt = 0; ; attempt++) { + signal.throwIfAborted(); + let response: Response; + try { + response = await apiClient.get(url, { + signal, + headers: { Accept: "text/event-stream" }, + cache: "no-store", + }); + } catch (error) { + signal.throwIfAborted(); + if (!isNetworkError(error) || attempt >= MAX_RETRIES) throw error; + const delay = retryDelay(undefined, attempt); + if (delay === undefined) throw error; + await waitForRetry(delay, signal); + continue; + } + signal.throwIfAborted(); + if (!response.ok) { + const error = new Error(`Log stream request failed (HTTP ${response.status}).`); + const transient = response.status === 429 || [500, 502, 503, 504].includes(response.status); + const delay = retryDelay(response, attempt); + await response.body?.cancel(); + if (!transient || attempt >= MAX_RETRIES || delay === undefined) throw error; + await waitForRetry(delay, signal); + continue; + } + if (response.headers.get("Content-Type")?.split(";")[0]?.trim().toLowerCase() !== "text/event-stream") { + await response.body?.cancel(); + throw new Error("Expected a text/event-stream response for logs."); + } + if (!response.body) throw new Error("Log stream response has no body."); + return response; + } +} + +export async function readLogStream( + response: Response, + signal: AbortSignal, + onEvent: (event: LogStreamEvent) => void, +): Promise { + if (!response.body) throw new Error("Log stream response has no body."); + signal.throwIfAborted(); + const reader = response.body.getReader(); + const cancel = () => { + void reader.cancel().catch(() => { + console.warn("Unable to cancel closed log stream reader."); + }); + }; + signal.addEventListener("abort", cancel, { once: true }); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let terminal = false; + let trailingCR = false; + const parser = createParser({ + onEvent(event) { + if (terminal || signal.aborted) return; + const type = event.event || "message"; + if (type === "done" || type === "timeout") { + terminal = true; + onEvent({ type }); + return; + } + if (type !== "message" && type !== "error") return; + let value: unknown; + try { + value = JSON.parse(event.data); + } catch { + throw new Error("Invalid JSON in log stream event."); + } + if (type === "error") { + if (!isRecord(value) || typeof value.message !== "string" || !value.message) { + throw new Error("Invalid log stream error event."); + } + terminal = true; + onEvent({ type: "error", message: value.message }); + } else if (isLogEvent(value)) { + onEvent({ type: "log", log: value }); + } else if (!isRecord(value) || value.type !== "turns_summary") { + throw new Error("Invalid log entry received from server."); + } + }, + }); + try { + while (!terminal) { + signal.throwIfAborted(); + const { done, value } = await reader.read(); + signal.throwIfAborted(); + if (done) { + decoder.decode(); + // Resolve the parser's deferred trailing CR, without inventing an EOF event. + if (trailingCR) parser.feed("\n"); + if (!terminal) throw new Error(DISCONNECTED); + break; + } + const text = decoder.decode(value, { stream: true }); + if (text) trailingCR = text.endsWith("\r"); + parser.feed(text); + } + } finally { + signal.removeEventListener("abort", cancel); + try { + await reader.cancel(); + } finally { + reader.releaseLock(); + } + } +} diff --git a/apps/portal/src/pages/CodebaseDetail.tsx b/apps/portal/src/pages/CodebaseDetail.tsx index 43ed10f2c..8c2c8c364 100644 --- a/apps/portal/src/pages/CodebaseDetail.tsx +++ b/apps/portal/src/pages/CodebaseDetail.tsx @@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; import type { CodebaseRevisionDocument } from "@/types"; import { Button } from "@/components/ui/button"; +import { ArtifactDownloadButton } from "@/components/ArtifactDownloadButton"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; @@ -210,11 +211,11 @@ export function CodebaseDetail() { Immutable snapshot seeded into a run workspace. {selectedRevision && ( - + )} @@ -247,9 +248,11 @@ export function CodebaseDetail() { {selectedRevision.creator && {selectedRevision.creator}} {selectedRevision.contentSha256 && {selectedRevision.contentSha256}} - + {`${window.location.origin}/api/v1/codebase-revisions/${selectedRevision._id}/archive`} - + {selectedRevision._id} diff --git a/apps/portal/src/pages/RunDetail.artifacts.test.tsx b/apps/portal/src/pages/RunDetail.artifacts.test.tsx new file mode 100644 index 000000000..5fec6dc26 --- /dev/null +++ b/apps/portal/src/pages/RunDetail.artifacts.test.tsx @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RunDetail } from "./RunDetail"; +import { api } from "@/lib/api"; +import { downloadArtifact } from "@/lib/artifact-delivery"; +import { useLogStream } from "@/hooks/use-log-stream"; +import type { Run, RunState } from "@/types"; + +vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ isReady: true, user: { id: "u1" }, account: null }) })); +vi.mock("@/contexts/ProjectContext", () => ({ useSelectedProjectId: () => "p1" })); +vi.mock("@/hooks/use-log-stream", () => ({ useLogStream: vi.fn(() => ({ + logs: [], isConnected: false, isDone: true, error: null, +})) })); +vi.mock("@/components/CriteriaGraphView", () => ({ CriteriaGraphView: () => null })); +vi.mock("@/components/LogViewer", () => ({ LogViewer: () => null })); +vi.mock("@/lib/artifact-delivery", () => ({ downloadArtifact: vi.fn(), downloadBatchArtifacts: vi.fn() })); + +const current: RunState = { + _id: "current", attemptNumber: 1, status: "done", outcome: "succeeded", + turns: [{ iteration: 1, passed: true, snapshotUrl: "stored-current", judgeFeedback: "", + timestamp: "2026-01-01T00:00:00Z" }], +}; +const run: Run = { _id: "request-1", id: "example", workerType: "copilot", + createdAt: "2026-01-01T00:00:00Z", run: current }; +const clients: QueryClient[] = []; +const unexpectedFetch = vi.fn(() => { throw new Error("Unexpected API request in isolated artifact test"); }); +function mount() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + return render( + + } /> + + ); +} +beforeEach(() => { + vi.spyOn(api, "getRun").mockResolvedValue(run); + vi.spyOn(api, "getRunReports").mockResolvedValue([]); + vi.spyOn(api, "listReportTemplates").mockResolvedValue([]); + vi.spyOn(api, "listAgents").mockResolvedValue([]); + vi.stubGlobal("fetch", unexpectedFetch); + vi.mocked(downloadArtifact).mockResolvedValue(); +}); +afterEach(() => { + cleanup(); + clients.splice(0).forEach(client => client.clear()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + expect(unexpectedFetch).not.toHaveBeenCalled(); + vi.clearAllMocks(); +}); + +describe("selected attempt artifact pinning", () => { + it("does not expose the latest archive or logs while historical data is loading", async () => { + vi.spyOn(api, "listRunAttempts").mockImplementation(() => new Promise(() => {})); + mount(); + await screen.findByRole("status"); + expect(api.listRunAttempts).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: "Download Archive" })).toBeNull(); + expect(vi.mocked(useLogStream).mock.calls.every(([options]) => !options.enabled)).toBe(true); + }); + + it.each(["missing", "failed"] as const)("does not substitute the latest archive for %s history", async state => { + const attempts = vi.spyOn(api, "listRunAttempts"); + if (state === "missing") attempts.mockResolvedValue([current]); + else attempts.mockRejectedValue(new Error("HTTP 403")); + mount(); + expect((await screen.findByRole("alert")).textContent).toContain( + state === "missing" ? "Selected run attempt not found" : "HTTP 403", + ); + expect(screen.queryByRole("button", { name: "Download Archive" })).toBeNull(); + expect(downloadArtifact).not.toHaveBeenCalled(); + }); + + it("uses the actual resolved historical attempt for the archive", async () => { + vi.spyOn(api, "listRunAttempts").mockResolvedValue([{ ...current, _id: "historical" }, current]); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Download Archive" })); + await waitFor(() => expect(downloadArtifact).toHaveBeenCalledWith( + "/api/v1/requests/request-1/runs/historical/archive", "p1", expect.any(AbortSignal), "request-1.tar.gz", + )); + }); +}); diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index 77ac6b0fa..af814ad0d 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -6,6 +6,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; import { RetryConfirmDialog } from "@/components/RetryConfirmDialog"; import { Button } from "@/components/ui/button"; +import { ArtifactDownloadButton } from "@/components/ArtifactDownloadButton"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Skeleton } from "@/components/ui/skeleton"; @@ -285,10 +286,10 @@ export function RunDetail() { // Fetch all attempts when this request has been retried const hasMultipleAttempts = (run?.run?.attemptNumber ?? 1) > 1; - const { data: rawAttempts } = useQuery({ + const { data: rawAttempts, isPending: loadingAttempts, error: attemptsError } = useQuery({ queryKey: ["run-attempts", id], queryFn: () => api.listRunAttempts(id!), - enabled: !!id && hasMultipleAttempts, + enabled: !!id && !!run && (hasMultipleAttempts || (!!selectedRunId && selectedRunId !== run.run?._id)), }); // Merge the live run.run into the attempts list so the latest attempt @@ -302,10 +303,10 @@ export function RunDetail() { // When viewing a historical attempt via ?runId=xxx, use that RunState // instead of the current one. The selected attempt may come from the - // attempts list or fall back to the current run. + // attempts list. Never substitute the latest run for missing historical data. const activeRun: RunState | undefined = useMemo(() => { if (!selectedRunId || selectedRunId === run?.run?._id) return run?.run; - return attempts?.find((a) => a._id === selectedRunId) ?? run?.run; + return attempts?.find((a) => a._id === selectedRunId); }, [selectedRunId, run?.run, attempts]); const isViewingHistorical = !!selectedRunId && selectedRunId !== run?.run?._id; @@ -328,7 +329,7 @@ export function RunDetail() { const logStream = useLogStream({ id: run?._id ?? "", - enabled: !!run, + enabled: !!run && !!activeRun, fromStart: true, attemptNumber: activeRun?.attemptNumber, urlBuilder: logStreamUrlBuilder, @@ -522,6 +523,20 @@ export function RunDetail() { ); } + if (isViewingHistorical && !activeRun) { + return ( +
+ + {loadingAttempts ?

Loading selected run attempt...

: +

+ {attemptsError ? `Unable to load selected run attempt: ${attemptsError.message}` : "Selected run attempt not found."} +

} +
+ ); + } + const hasHarData = !!(activeRun?.harUrl || activeRun?.turns?.some(t => t.harUrl)); const hasVideoData = !!(activeRun?.videoUrls?.length || activeRun?.setupVideoUrls?.length || activeRun?.turns?.some(t => t.videoUrls?.length)); const videoCount = (activeRun?.setupVideoUrls?.length ?? 0) @@ -832,15 +847,16 @@ export function RunDetail() { )} {activeRun?.turns && activeRun?.turns.some(t => t.snapshotUrl) && ( - + )} {canShowRetry && ( + )} @@ -1874,7 +1891,10 @@ function ToolCallsTab({ runId, turns, harUrl, attemptRunId }: { runId: string; t )} - {!isLoading && allToolCalls.length === 0 && ( + {error &&

+ Unable to load tool calls: {error.message} +

} + {!isLoading && !error && allToolCalls.length === 0 && (

No tool calls captured. HAR file may still be available for download.

@@ -1979,16 +1999,17 @@ function ToolCallsTab({ runId, turns, harUrl, attemptRunId }: { runId: string; t

Per-Turn HAR Files

{turns.filter(t => t.harUrl).map(t => ( - + ))}
diff --git a/apps/portal/src/pages/RunsList.tsx b/apps/portal/src/pages/RunsList.tsx index 27b685df5..54d1be072 100644 --- a/apps/portal/src/pages/RunsList.tsx +++ b/apps/portal/src/pages/RunsList.tsx @@ -9,6 +9,7 @@ import { FaLinux } from "react-icons/fa"; import { toast } from "sonner"; import { api } from "@/lib/api"; import { Button } from "@/components/ui/button"; +import { ArtifactDownloadButton } from "@/components/ArtifactDownloadButton"; import { Badge } from "@/components/ui/badge"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, @@ -976,13 +977,7 @@ export function RunsList() { onError: (err: Error) => toast.error(`Failed to generate reports: ${err.message}`), }); - const batchDownloadMutation = useMutation({ - mutationFn: (ids: string[]) => api.batchArchive(ids), - onSuccess: (_, ids) => { - toast.success(`Downloading ${ids.length} run${ids.length !== 1 ? "s" : ""} as archive`); - }, - onError: (err: Error) => toast.error(`Failed to download archive: ${err.message}`), - }); + const [batchDownloading, setBatchDownloading] = useState(false); // ---- Selection helpers ---- const toggleRowSelection = useCallback((id: Key) => { @@ -1217,7 +1212,7 @@ export function RunsList() { bulkResumeMutation.isPending || bulkSetPriorityMutation.isPending || bulkReportMutation.isPending || - batchDownloadMutation.isPending; + batchDownloading; const handleBulkRetry = useCallback(() => { if (selectedIds.size === 0) return; @@ -1263,11 +1258,6 @@ export function RunsList() { setPriorityDialogOpen(true); }, [selectedRunsList, selectionCaps.prioritizable]); - const handleBulkDownload = useCallback(() => { - if (selectedIds.size === 0) return; - batchDownloadMutation.mutate([...selectedIds]); - }, [batchDownloadMutation, selectedIds]); - // Filter-rail options come from the server facets endpoint (issue #1138), so // every selectable value appears with an accurate full-dataset count — not just // the values present on the loaded page. Enum dimensions show all known values @@ -1835,20 +1825,18 @@ export function RunsList() { View - + - + + Download +