From f148ddcf34f32d59bc2276734f3cb1dd80476b0a Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:27:00 +1100 Subject: [PATCH] Keep published links and fix onboarding review findings --- electron/onboardingIntro.test.ts | 82 +++++++++++++++++++ .../dashboard/recentSharedVideoCache.test.ts | 22 +++++ .../dashboard/recentSharedVideoCache.ts | 2 +- .../video-editor/layout/EditorExportMenu.tsx | 11 ++- .../video-editor/layout/EditorHeader.tsx | 1 + .../project/useProjectLifecycle.ts | 10 ++- .../videoPlayback/webcamSync.test.ts | 4 + .../video-editor/videoPlayback/webcamSync.ts | 6 +- src/index.css | 3 +- tests/ui/publish-cloud.spec.ts | 17 +++- 10 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 electron/onboardingIntro.test.ts diff --git a/electron/onboardingIntro.test.ts b/electron/onboardingIntro.test.ts new file mode 100644 index 00000000..7673ba2e --- /dev/null +++ b/electron/onboardingIntro.test.ts @@ -0,0 +1,82 @@ +import { EventEmitter } from "node:events"; +import type { BrowserWindow } from "electron"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +class TestWindow extends EventEmitter { + webContents = Object.assign(new EventEmitter(), { + ipc: new EventEmitter(), + mainFrame: {}, + }); + private destroyed = false; + private visible = false; + setIgnoreMouseEvents = vi.fn(); + setBounds = vi.fn(); + setOpacity = vi.fn(); + moveTop = vi.fn(); + showInactive() { + this.visible = true; + } + isVisible() { + return this.visible; + } + isDestroyed() { + return this.destroyed; + } + destroy() { + this.destroyed = true; + this.emit("closed"); + } +} + +vi.mock("electron", () => ({ BrowserWindow: TestWindow })); + +const { startOnboardingIntro } = await import("./onboardingIntro"); + +beforeEach(() => + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "performance"], + }), +); +afterEach(() => vi.useRealTimers()); + +function start() { + const popup = new TestWindow(); + const show = vi.fn(); + const backdrop = startOnboardingIntro( + { x: 0, y: 0, width: 1440, height: 900 }, + "/test/preload.js", + vi.fn(), + { popup: popup as unknown as BrowserWindow, show }, + ) as unknown as TestWindow; + const stage = (name: string) => + backdrop.webContents.ipc.emit( + "onboarding-intro", + { senderFrame: backdrop.webContents.mainFrame }, + name, + ); + return { popup, show, backdrop, stage }; +} + +it("waits for both the intro and popup, then fades the backdrop out", () => { + const { popup, show, backdrop, stage } = start(); + stage("ready"); + stage("finished"); + expect(show).not.toHaveBeenCalled(); + popup.emit("ready-to-show"); + expect(show).toHaveBeenCalledOnce(); + expect(popup.setOpacity).toHaveBeenCalledWith(0); + vi.advanceTimersByTime(660); + expect(popup.setOpacity).toHaveBeenLastCalledWith(1); + expect(backdrop.isDestroyed()).toBe(true); +}); + +it.each(["skip", "timeout"])("shows a ready popup after intro %s", (mode) => { + const { popup, show, backdrop, stage } = start(); + popup.emit("ready-to-show"); + expect(show).not.toHaveBeenCalled(); + if (mode === "skip") stage("skipped"); + else vi.advanceTimersByTime(6000); + expect(show).toHaveBeenCalledOnce(); + expect(backdrop.isDestroyed()).toBe(true); + expect(popup.setOpacity).not.toHaveBeenCalled(); +}); diff --git a/src/components/video-editor/dashboard/recentSharedVideoCache.test.ts b/src/components/video-editor/dashboard/recentSharedVideoCache.test.ts index a6ca0fc3..3a2f82c5 100644 --- a/src/components/video-editor/dashboard/recentSharedVideoCache.test.ts +++ b/src/components/video-editor/dashboard/recentSharedVideoCache.test.ts @@ -93,3 +93,25 @@ it("keeps a known nonempty display snapshot during an expired background reload" await pending; expect(cache.peekRecentSharedVideos("account")).toEqual([]); }); + +it("uses the newer result when a forced refresh overtakes an in-flight list", async () => { + const old = { code: "old", title: "Old", url: "https://share.test/s/old", ready: true }; + let release!: (result: { success: boolean; videos: (typeof old)[] }) => void; + list.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ).mockResolvedValueOnce({ success: true, videos: [] }); + const read = (fresh = false) => + cache.readRecentSharedVideos("account", "https://share.test", "token", fresh); + const earlier = read(); + await Promise.resolve(); + const refreshed = read(true); + expect(read()).toBe(refreshed); + await expect(refreshed).resolves.toEqual([]); + release({ success: true, videos: [old] }); + await earlier; + expect(cache.peekRecentSharedVideos("account")).toEqual([]); + expect(list).toHaveBeenCalledTimes(2); +}); diff --git a/src/components/video-editor/dashboard/recentSharedVideoCache.ts b/src/components/video-editor/dashboard/recentSharedVideoCache.ts index 5752afc0..ba8e0bda 100644 --- a/src/components/video-editor/dashboard/recentSharedVideoCache.ts +++ b/src/components/video-editor/dashboard/recentSharedVideoCache.ts @@ -27,7 +27,7 @@ export function readRecentSharedVideos( fresh = false, ): Promise { const existing = snapshots.get(key); - if (existing?.pending) return existing.pending; + if (existing?.pending && !fresh) return existing.pending; if (!fresh && existing?.videos && existing.expiresAt > Date.now()) return Promise.resolve(existing.videos); const snapshot: Snapshot = { videos: existing?.videos, expiresAt: existing?.expiresAt ?? 0 }; diff --git a/src/components/video-editor/layout/EditorExportMenu.tsx b/src/components/video-editor/layout/EditorExportMenu.tsx index 88769950..76972dfc 100644 --- a/src/components/video-editor/layout/EditorExportMenu.tsx +++ b/src/components/video-editor/layout/EditorExportMenu.tsx @@ -21,6 +21,7 @@ import { PublishLinkIcon, PublishSaveIcon } from "./PublishDestinationIcons"; type Props = { projectRevision?: string; projectPath?: string | null; + projectSourcePath?: string | null; t: ReturnType["t"]; videoPlaybackRef?: RefObject; exportSettings: ReturnType; @@ -52,6 +53,7 @@ export function EditorExportMenu(props: Props) { const { publishDestination: destination, setPublishDestination: setDestination } = props.exportSettings; const [shareBusy, setShareBusy] = useState(false); + const [hasStartedShare, setHasStartedShare] = useState(false); const [shareActivity, setShareActivity] = useState(false); const [shareContainer, setShareContainer] = useState(null); const [thumbnail, setThumbnail] = useState(); @@ -90,6 +92,7 @@ export function EditorExportMenu(props: Props) { (busy: boolean) => { setShareBusy(busy); if (busy) { + setHasStartedShare(true); captureThumbnail(); setShareActivity(true); setShowExportDropdown(false); @@ -333,8 +336,9 @@ export function EditorExportMenu(props: Props) { - {destination === "link" && (publishMenuOpen || shareActivity || shareBusy) && ( + {(hasStartedShare || (destination === "link" && publishMenuOpen)) && ( { diff --git a/src/components/video-editor/layout/EditorHeader.tsx b/src/components/video-editor/layout/EditorHeader.tsx index bedaf880..36f5c24f 100644 --- a/src/components/video-editor/layout/EditorHeader.tsx +++ b/src/components/video-editor/layout/EditorHeader.tsx @@ -278,6 +278,7 @@ export function EditorHeader(props: Props) { handleStartExportFromDropdown={handleStartExportFromDropdown} revealExportedFile={revealExportedFile} projectPath={project.currentProjectPath} + projectSourcePath={project.videoSourcePath} projectRevision={props.projectRevision} projectTitle={projectDisplayName} prepareExportForShare={props.prepareExportForShare} diff --git a/src/components/video-editor/project/useProjectLifecycle.ts b/src/components/video-editor/project/useProjectLifecycle.ts index 7be0d098..24479e67 100644 --- a/src/components/video-editor/project/useProjectLifecycle.ts +++ b/src/components/video-editor/project/useProjectLifecycle.ts @@ -194,7 +194,7 @@ export function useProjectLifecycle(input: Input) { appearance.setBorderRadius(editor.borderRadius); appearance.setPadding(editor.padding); appearance.setCropRegion(editor.cropRegion); - appearance.setWebcam({ + const webcam = { ...editor.webcam, loop: editor.webcam.loop ?? @@ -204,7 +204,8 @@ export function useProjectLifecycle(input: Input) { editor.webcam.sourcePath ? fromFileUrl(editor.webcam.sourcePath) : null, editor.webcam.timeOffsetMs, )), - }); + }; + appearance.setWebcam(webcam); timeline.setZoomRegions(editor.zoomRegions); timeline.setTrimRegions(editor.trimRegions); timeline.setClipRegions(editor.clipRegions); @@ -245,13 +246,14 @@ export function useProjectLifecycle(input: Input) { refs.nextAnnotationZIndexRef.current = editor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; - const restoredHistory = restoreEditorHistory(loadedProject.history, editor); + const appliedEditor = { ...editor, webcam }; + const restoredHistory = restoreEditorHistory(loadedProject.history, appliedEditor); current.restoreHistory(restoredHistory); project.setLastSavedSnapshot( cloneStructured( createProjectData( sourcePath, - current.buildPersistedEditorState(editor), + current.buildPersistedEditorState(appliedEditor), loadedProject.projectId ?? null, persistEditorHistory(restoredHistory), ), diff --git a/src/components/video-editor/videoPlayback/webcamSync.test.ts b/src/components/video-editor/videoPlayback/webcamSync.test.ts index e945d2d9..eeb97157 100644 --- a/src/components/video-editor/videoPlayback/webcamSync.test.ts +++ b/src/components/video-editor/videoPlayback/webcamSync.test.ts @@ -72,6 +72,10 @@ describe("getWebcamMediaTargetTimeSeconds", () => { it("repeats older uploads while preserving timing for paired webcam recordings", () => { expect(shouldLoopWebcamSource("/clips/demo.mp4", "/downloads/camera.mp4")).toBe(true); expect(shouldLoopWebcamSource("/clips/demo.mp4", "/clips/demo-webcam.webm")).toBe(false); + expect(shouldLoopWebcamSource("/clips/demo.mp4", "/relocated/demo-webcam.webm")).toBe(false); + expect(shouldLoopWebcamSource("C:\\clips\\demo.mp4", "D:\\relocated\\demo-webcam.webm")).toBe( + false, + ); expect(shouldLoopWebcamSource("/clips/demo.mp4", "/camera.mp4", 150)).toBe(false); }); diff --git a/src/components/video-editor/videoPlayback/webcamSync.ts b/src/components/video-editor/videoPlayback/webcamSync.ts index 0c535539..0a50dad7 100644 --- a/src/components/video-editor/videoPlayback/webcamSync.ts +++ b/src/components/video-editor/videoPlayback/webcamSync.ts @@ -34,7 +34,11 @@ export function shouldLoopWebcamSource( timeOffsetMs = 0, ): boolean { if (!videoPath || !webcamPath || timeOffsetMs !== 0) return false; - const stem = (path: string) => path.replace(/\\/g, "/").replace(/\.[^./]+$/, ""); + const stem = (path: string) => + path + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, ""); const videoStem = stem(videoPath); const webcamStem = stem(webcamPath); return webcamStem !== videoStem && webcamStem !== `${videoStem}-webcam`; diff --git a/src/index.css b/src/index.css index 7b36a30e..76df3b40 100644 --- a/src/index.css +++ b/src/index.css @@ -262,7 +262,8 @@ @media (forced-colors: active) { :is(.input, .textarea):focus, .input-group:has(.input-group__input:focus), - .share-link-field:has(> input:focus) { + .share-link-field:has(> input:focus), + [data-error-report] > button:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } diff --git a/tests/ui/publish-cloud.spec.ts b/tests/ui/publish-cloud.spec.ts index a1486983..96eaa8a7 100644 --- a/tests/ui/publish-cloud.spec.ts +++ b/tests/ui/publish-cloud.spec.ts @@ -40,6 +40,7 @@ for (const full of [false, true]) { test("publish preserves local destination and sharing continues in the background", async ({ page, }) => { + test.setTimeout(60_000); await page.goto("/tests/ui/publish.html?switch"); await page.getByRole("button", { name: "Publish", exact: true }).click(); await page.getByRole("row", { name: "Save to device", exact: true }).click(); @@ -68,10 +69,24 @@ test("publish preserves local destination and sharing continues in the backgroun .getByRole("button", { name: "Fixture refresh token", exact: true }) .evaluate((button: HTMLButtonElement) => button.click()); await expect(page.getByRole("button", { name: "Copy link", exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "Create link" })).toHaveCount(0, { + timeout: 15_000, + }); + await page.getByRole("button", { name: "Publish", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Share link" })).toHaveValue( + "https://example.test/s/preview", + ); + await page.getByRole("row", { name: "Save to device", exact: true }).click(); + await page.getByRole("row", { name: "Create share link", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Share link" })).toHaveValue( + "https://example.test/s/preview", + ); + await page.keyboard.press("Escape"); await page .getByRole("button", { name: "Fixture switch account", exact: true }) .evaluate((button: HTMLButtonElement) => button.click()); - await expect(page.getByRole("button", { name: "Copy link", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Publish", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Share link" })).toHaveCount(0); }); test("a full cloud quota prevents starting preparation", async ({ page }) => {