From 690e45d7cf998e4caa7d8b0100584d30fa5fa795 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Wed, 28 Oct 2026 03:29:28 +0000 Subject: [PATCH] fix(extension): keep upload progress tied to its recording --- apps/chrome-extension/package.json | 2 + .../src/uploading/main.test.ts | 191 ++++++++++++++++++ apps/chrome-extension/src/uploading/main.ts | 3 + bun.lock | 2 + 4 files changed, 198 insertions(+) create mode 100644 apps/chrome-extension/src/uploading/main.test.ts diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json index 51e4d378813..039c6da3039 100644 --- a/apps/chrome-extension/package.json +++ b/apps/chrome-extension/package.json @@ -29,10 +29,12 @@ "@cap/ui": "workspace:*", "@playwright/test": "^1.51.1", "@types/chrome": "^0.0.323", + "@types/jsdom": "^21.1.7", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "4.4.1", "autoprefixer": "^10.4.16", + "jsdom": "^26.1.0", "postcss": "^8.4.31", "tailwindcss": "^3.4.0", "typescript": "^5.8.3", diff --git a/apps/chrome-extension/src/uploading/main.test.ts b/apps/chrome-extension/src/uploading/main.test.ts new file mode 100644 index 00000000000..f02d277fe0e --- /dev/null +++ b/apps/chrome-extension/src/uploading/main.test.ts @@ -0,0 +1,191 @@ +import { readFileSync } from "node:fs"; +import type { VideoId } from "@cap/recorder-core"; +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { FailedRecording } from "../shared/storage"; +import type { RecordingStatus } from "../shared/types"; + +type MessageListener = Parameters< + typeof chrome.runtime.onMessage.addListener +>[0]; +type StorageListener = Parameters< + typeof chrome.storage.onChanged.addListener +>[0]; +let listener: MessageListener; +let storageListener: StorageListener; +let dom: JSDOM; +let polledStatus: RecordingStatus; +const videoA = "video-a" as VideoId; +const videoB = "video-b" as VideoId; +const failed = { + phase: "error", + videoId: videoA, + message: "Upload failed for A", + recoverable: true, +} satisfies RecordingStatus; +const entry: FailedRecording = { + sessionId: "session-a", + videoId: videoA, + mimeType: "video/webm", + totalBytes: 100, + createdAt: 1, + shareUrl: null, + subpath: "raw-upload.webm", + durationMs: 1000, + width: 320, + height: 180, + fps: 30, + message: failed.message, +}; + +beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers(); + polledStatus = failed; + dom = new JSDOM( + readFileSync(new URL("../../uploading.html", import.meta.url), "utf8"), + { url: "https://extension.test/uploading.html?videoId=video-a" }, + ); + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + dom.window.requestAnimationFrame = vi.fn(() => 1); + vi.stubGlobal("chrome", { + runtime: { + onMessage: { + addListener: (fn: MessageListener) => { + listener = fn; + }, + }, + sendMessage: (_message: unknown, callback: (response: unknown) => void) => + callback({ ok: true, status: polledStatus }), + }, + storage: { + onChanged: { + addListener: (fn: StorageListener) => { + storageListener = fn; + }, + }, + local: { + get: (_keys: unknown, callback: (items: unknown) => void) => + callback({ "cap-extension-failed-recordings": [entry] }), + }, + session: { + get: (_keys: unknown, callback: (items: unknown) => void) => + callback({ + "cap-extension-recording-state": { + status: polledStatus, + plan: null, + updatedAt: 1, + }, + }), + }, + }, + }); + await import("./main"); + await Promise.resolve(); + await Promise.resolve(); +}); +afterEach(() => { + dom.window.close(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +const broadcast = (status: RecordingStatus) => + listener( + { target: "recording-status", type: "recording-status-changed", status }, + {}, + () => undefined, + ); + +it.each(["broadcast", "storage"])( + "preserves A's retry and download through B's lifecycle via %s", + async (source) => { + expect(document.getElementById("error-actions")?.hidden).toBe(false); + const statuses: RecordingStatus[] = [ + { phase: "creating" }, + { + phase: "recording", + videoId: videoB, + startedAt: 0, + durationMs: 0, + updatedAt: 0, + }, + { + phase: "error", + videoId: videoB, + message: "B upload failed", + recoverable: true, + }, + { + phase: "completed", + videoId: videoB, + shareUrl: "https://cap.test/s/video-b", + }, + ]; + for (const status of statuses) { + polledStatus = status; + if (source === "broadcast") broadcast(status); + if (source === "storage") { + storageListener( + { "cap-extension-recording-state": { newValue: { status } } }, + "session", + ); + await vi.advanceTimersByTimeAsync(0); + } + expect(document.getElementById("upload-detail")?.textContent).toBe( + "Upload failed for A", + ); + expect(document.getElementById("error-actions")?.hidden).toBe(false); + expect( + document.getElementById("share-link")?.getAttribute("href"), + ).not.toBe("https://cap.test/s/video-b"); + } + }, +); + +it("still updates and completes the matching recording", () => { + broadcast({ + phase: "uploading", + videoId: videoA, + startedAt: 0, + durationMs: 1000, + updatedAt: 0, + }); + expect(document.getElementById("upload-title")?.textContent).toBe( + "Uploading your Cap", + ); + broadcast({ + phase: "completed", + videoId: videoA, + shareUrl: "https://cap.test/s/video-a", + }); + expect(document.getElementById("upload-title")?.textContent).toBe( + "Your Cap is ready", + ); + expect(document.getElementById("share-link")?.getAttribute("href")).toBe( + "https://cap.test/s/video-a", + ); +}); + +it("ignores a poll for B while the page is tracking A", async () => { + broadcast({ + phase: "uploading", + videoId: videoA, + startedAt: 0, + durationMs: 1000, + updatedAt: 0, + }); + polledStatus = { + phase: "recording", + videoId: videoB, + startedAt: 1, + durationMs: 0, + updatedAt: 1, + }; + await vi.advanceTimersByTimeAsync(2000); + expect(document.getElementById("upload-title")?.textContent).toBe( + "Uploading your Cap", + ); +}); diff --git a/apps/chrome-extension/src/uploading/main.ts b/apps/chrome-extension/src/uploading/main.ts index 2e2fe9704ec..8d9e5a0580c 100644 --- a/apps/chrome-extension/src/uploading/main.ts +++ b/apps/chrome-extension/src/uploading/main.ts @@ -319,6 +319,9 @@ const renderRetryFailure = (message: string) => { }; const renderStatus = (status: RecordingStatus) => { + if (urlVideoId && (!("videoId" in status) || status.videoId !== urlVideoId)) { + return; + } if (retryPendingFreshStatus) { // The stale pre-retry error keeps echoing from session storage and the // status polls until the retry publishes its first status; anything diff --git a/bun.lock b/bun.lock index ae3266e557d..75a4461f0de 100644 --- a/bun.lock +++ b/bun.lock @@ -39,10 +39,12 @@ "@cap/ui": "workspace:*", "@playwright/test": "^1.51.1", "@types/chrome": "^0.0.323", + "@types/jsdom": "^21.1.7", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "4.4.1", "autoprefixer": "^10.4.16", + "jsdom": "^26.1.0", "postcss": "^8.4.31", "tailwindcss": "^3.4.0", "typescript": "^5.8.3",