Keep published links and fix onboarding review findings

This commit is contained in:
webadderall committed 2026-11-11 23:27:00 +11:00
1 parent b2a7a3c029
commit f148ddcf34
10 files changed
+147 -11

No files matched your search

+82
View File
@@ -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();
});
@@ -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);
});
@@ -27,7 +27,7 @@ export function readRecentSharedVideos(
fresh = false,
): Promise<SharedVideo[]> {
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 };
@@ -21,6 +21,7 @@ import { PublishLinkIcon, PublishSaveIcon } from "./PublishDestinationIcons";
type Props = {
projectRevision?: string;
projectPath?: string | null;
projectSourcePath?: string | null;
t: ReturnType<typeof useI18n>["t"];
videoPlaybackRef?: RefObject<VideoPlaybackRef | null>;
exportSettings: ReturnType<typeof useExportSettings>;
@@ -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<HTMLDivElement | null>(null);
const [thumbnail, setThumbnail] = useState<string>();
@@ -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) {
</motion.div>
</PopoverContent>
</Popover>
{destination === "link" && (publishMenuOpen || shareActivity || shareBusy) && (
{(hasStartedShare || (destination === "link" && publishMenuOpen)) && (
<CloudShareButton
key={props.projectPath ?? props.projectSourcePath ?? undefined}
inline
projectPath={props.projectPath}
projectRevision={props.projectRevision}
@@ -350,8 +354,9 @@ export function EditorExportMenu(props: Props) {
props.onRequestShareSignIn();
}}
floatingActivity={{
formContainer: publishMenuOpen ? shareContainer : null,
visible: shareActivity,
formContainer:
publishMenuOpen && destination === "link" ? shareContainer : null,
visible: destination === "link" && shareActivity,
thumbnail,
onDismiss: dismissShareActivity,
onRetry: () => {
@@ -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}
@@ -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),
),
@@ -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);
});
@@ -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`;
+2 -1
View File
@@ -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;
}
+16 -1
View File
@@ -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 }) => {