From 1a1dd08464bfa29a0dc8897a0b80c5185de4cf66 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 22:28:04 +0200 Subject: [PATCH] test: retain verifier regression guards --- src/commands.test.ts | 19 ++++ src/main.persistence.test.ts | 19 ++++ .../publishedTranscript/index.test.ts | 26 +++++ tests/e2e/journey-player.test.ts | 84 ++++++++++++++ tests/e2e/journey-transcript.test.ts | 55 +++++++++ tests/e2e/playback-media-reset.test.ts | 106 ++++++++++++++++++ tests/e2e/settings-tab.test.ts | 36 ++++++ tests/e2e/table-cell-capture.test.ts | 24 ++++ 8 files changed, 369 insertions(+) create mode 100644 tests/e2e/playback-media-reset.test.ts diff --git a/src/commands.test.ts b/src/commands.test.ts index 52b248c3..80447c0f 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -341,6 +341,25 @@ describe("capture", () => { expect(editor.getValue()).toBe("Notes\n"); }); + + it("falls back to the note editor when the cell's replaceSelection is not a function", async () => { + const { plugin, openNote, focusPodNotes } = await loadPlugin(playing); + const { editor } = openNote("Table.md", TABLE, TABLE.indexOf("anchor") + 6); + Object.assign(editor, { + editorComponent: { tableCell: { editor: { replaceSelection: "unavailable" } } }, + }); + focusPodNotes(); + + expect(await plugin.capture.timestamp()).toEqual({ + destination: "editor", + path: "Table.md", + text: "- 00:20:34", + created: false, + }); + expect(editor.getValue()).toBe( + "| Time | Note |\n| --- | --- |\n| Start | anchor- 00:20:34 |\nAfter", + ); + }); }); it("goes to the episode note once the last editor's tab is closed", async () => { diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index bff21997..f9ad0d15 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -6,6 +6,7 @@ import * as obsidian from "obsidian"; import { DEFAULT_SETTINGS } from "./constants"; import PodNotes from "./main"; import { encodePodNotesData } from "./persistence/podNotesData"; +import * as podNotesData from "./persistence/podNotesData"; import { PlayerController } from "./player/PlayerController"; import { downloadedEpisodes, localFiles, plugin as pluginStore } from "./store"; import { bindStoresToSettings } from "./store/persistence"; @@ -85,6 +86,24 @@ afterEach(() => { vi.restoreAllMocks(); }); +it("does not decode its own saved document again during a settings session", async () => { + const plugin = makePlugin(); + const decode = vi.spyOn(podNotesData, "decodePodNotesData"); + await plugin.loadSettings(); + expect(decode).toHaveBeenCalledTimes(1); + decode.mockClear(); + + for (const seconds of [20, 30, 45]) { + plugin.settings.skipBackwardLength = seconds; + await plugin.saveSettingsStrict(); + await plugin.onExternalSettingsChange(); + expect(await plugin.loadData()).toMatchObject({ skipBackwardLength: seconds }); + } + + expect(plugin.settings.skipBackwardLength).toBe(45); + expect(decode).toHaveBeenCalledTimes(0); +}); + describe("save refusal recovery", () => { function recovering() { vi.spyOn(console, "error").mockImplementation(() => undefined); diff --git a/src/services/publishedTranscript/index.test.ts b/src/services/publishedTranscript/index.test.ts index 6df7b0d3..e81ad07b 100644 --- a/src/services/publishedTranscript/index.test.ts +++ b/src/services/publishedTranscript/index.test.ts @@ -213,6 +213,32 @@ describe("findPublishedTranscript", () => { describe("fetchPublishedTranscript", () => { const signal = new AbortController().signal; + test("preserves the quoted feed and transcript URLs of a public source", async () => { + const { feeds } = repository([]); + fetchTextMock.mockResolvedValue( + "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nRead https://show.test/feed.xml and https://show.test/transcripts/1.vtt for more.\n", + ); + + expect( + await fetchPublishedTranscript( + feeds, + episodeOf(show), + { key: "Show", reference: "transcripts/1.vtt", format: "vtt" }, + signal, + ), + ).toEqual({ + kind: "timed", + cues: [ + { + start: 0, + end: 5, + speaker: "Host", + text: "Read https://show.test/feed.xml and https://show.test/transcripts/1.vtt for more.", + }, + ], + }); + }); + test("fetches the link resolved against the feed, once, within the transcript limits", async () => { const { feeds } = repository([]); fetchTextMock.mockResolvedValue(transcriptFixture("build-your-saas.vtt")); diff --git a/tests/e2e/journey-player.test.ts b/tests/e2e/journey-player.test.ts index 0e55670b..f00cad7f 100644 --- a/tests/e2e/journey-player.test.ts +++ b/tests/e2e/journey-player.test.ts @@ -133,6 +133,90 @@ const focused = (target: UiTarget) => ); describe("player journeys", () => { + test("the fullscreen button belongs to the video stage and hides while that stage is fullscreen", async () => { + const { obsidian } = getContext(); + await openPlayer([], () => ({ mediaType: "video" })); + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (state) => state.playing, "video loaded"); + const fullscreen: UiTarget = { + name: "video fullscreen", + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} .pn-video button.pn-fs`)})]`, + }; + expect(await isShown(obsidian, fullscreen)).toBe(true); + onTestFinished(async () => { + await obsidian.dev.evalJsonAsync( + "(async () => { if (document.fullscreenElement) await document.exitFullscreen(); return true; })()", + ); + }); + + await click(obsidian, fullscreen); + expect( + await eventually( + obsidian, + () => + obsidian.dev.evalJson( + `document.fullscreenElement === document.querySelector(${jsLiteral(`${PLAYER_VIEW} .pn-video`)})`, + ), + true, + ), + ).toBe(true); + expect( + await obsidian.dev.evalJson( + `getComputedStyle((${fullscreen.elements})[0]).display`, + ), + ).toBe("none"); + + await pressKey(obsidian, "Escape"); + expect( + await eventually( + obsidian, + () => obsidian.dev.evalJson("document.fullscreenElement === null"), + true, + ), + ).toBe(true); + }); + + test.each(["theme-light", "theme-dark"])( + "mini-player component styles survive the shell stylesheet loading last in %s", + async (theme) => { + const { obsidian } = getContext(); + await openPlayer([]); + await resizeView(320, "compact"); + await click(obsidian, ui.collapsePlayer); + await waitUntilShown(obsidian, ui.miniPlayer); + await cdp(obsidian, "Input.dispatchMouseEvent", { type: "mouseMoved", x: 0, y: 0 }); + const styles = await obsidian.dev.evalJson<{ + before: Record[]; + after: Record[]; + }>(`(() => { + const root = document.querySelector(${jsLiteral(PLAYER_VIEW)}); + const sheet = [...document.head.querySelectorAll("style")].find(sheet => root.classList.contains(sheet.id)); + if (!sheet) throw new Error("The shell stylesheet is missing"); + const next = sheet.nextSibling; + const originalTheme = document.body.className; + const read = () => [...root.querySelectorAll(".pn-mini button.pn-iconbtn")].map(button => { + const style = getComputedStyle(button); + return Object.fromEntries(["color", "opacity", "height", "width", "background-color", "border-radius"].map(key => [key, style.getPropertyValue(key)])); + }); + try { + document.body.classList.remove("theme-light", "theme-dark"); + document.body.classList.add(${jsLiteral(theme)}); + document.head.prepend(sheet); + const before = read(); + document.head.append(sheet); + return { before, after: read() }; + } finally { + document.head.insertBefore(sheet, next); + document.body.className = originalTheme; + } + })()`); + + expect(styles.before).toHaveLength(2); + expect(styles.before.map((style) => style.opacity)).toEqual(["1", "1"]); + expect(styles.after).toEqual(styles.before); + }, + ); + test("a video with a long queue keeps its 16:9 frame, compact and wide, and after a panel covered it", async () => { const { obsidian } = getContext(); await openPlayer(QUEUED, () => ({ mediaType: "video" })); diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 488a37e3..26d888a0 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -145,6 +145,61 @@ async function takeNote(): Promise { } describe("transcript journeys", () => { + test("switching episodes clears Find and shows every line of the incoming transcript", async () => { + const context = getContext(); + const { obsidian } = context; + const incoming: FixtureEpisode = { + title: "Second Transcript", + seconds: 60, + pubDate: pubDate(2), + transcripts: [{ path: "2.vtt", type: "text/vtt", body: VTT }], + }; + const feed = { ...FEED, episodes: [...FEED.episodes, incoming] }; + const server = await startFixtureServer([feed]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { + subscriptions: [feed], + current: storedEpisode(server, feed, CAPTIONED), + queue: [storedEpisode(server, feed, incoming)], + }); + await showPodNotes(obsidian); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await obsidian.dev.evalJson("app.workspace.rightSplit.setSize(820), true"); + await click(obsidian, ui.playerPanel("Transcript")); + expect( + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES), + ).toEqual(LINES); + await click(obsidian, panelButton("Find in transcript")); + await typeText(obsidian, "palace"); + expect( + await eventually(obsidian, async () => (await shownTranscript()).marks, [ + "palace", + "palace", + "Palace", + ]), + ).toEqual(["palace", "palace", "Palace"]); + + await obsidian.dev.evalJson( + `app.plugins.plugins.podnotes.player.play(${jsLiteral(storedEpisode(server, feed, incoming))}), true`, + ); + await waitForPlayback( + obsidian, + (state) => state.episode?.title === incoming.title && state.playing, + "incoming episode playing", + ); + expect( + await eventually(obsidian, shownTranscript, { + lines: LINES, + current: LINES[0], + hit: null, + marks: [], + }), + ).toEqual({ lines: LINES, current: LINES[0], hit: null, marks: [] }); + expect(await isShown(obsidian, findField)).toBe(false); + await click(obsidian, panelButton("Find in transcript")); + expect(await obsidian.dev.evalJson(`(${findField.elements})[0].value`)).toBe(""); + }); + test("the feed's transcript follows playback, seeks where tapped, finds a word, and saves the command's note", async () => { const { obsidian } = getContext(); await openTranscript(CAPTIONED); diff --git a/tests/e2e/playback-media-reset.test.ts b/tests/e2e/playback-media-reset.test.ts new file mode 100644 index 00000000..9d10b0de --- /dev/null +++ b/tests/e2e/playback-media-reset.test.ts @@ -0,0 +1,106 @@ +import { expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness, type PodNotesData } from "./harness"; +import { showLibrary, showPodNotes } from "./journey/actions"; +import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; +import { click } from "./journey/input"; +import { seedLibrary, storedEpisode, waitForPlayback } from "./journey/library"; +import { ui } from "./journey/targets"; +import { jsLiteral, WAIT_OPTS } from "./runtime"; + +const getContext = createPodNotesE2EHarness("playback-media-reset"); +const FEED: FixtureFeed = { + title: "Media reset", + slug: "media-reset", + artwork: true, + episodes: [ + { title: "Outgoing", seconds: 60, pubDate: pubDate(0) }, + { title: "Incoming", seconds: 60, pubDate: pubDate(1) }, + ], +}; + +async function startOutgoing() { + const context = getContext(); + const server = await startFixtureServer([FEED]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [FEED] }); + await showPodNotes(context.obsidian); + await showLibrary(context.obsidian); + await click(context.obsidian, ui.podcastCard(FEED.title)); + await click(context.obsidian, ui.episode("Outgoing")); + await waitForPlayback( + context.obsidian, + (state) => state.playing && state.seconds > 0, + "playing", + ); + return { ...context, incoming: storedEpisode(server, FEED, FEED.episodes[1]) }; +} + +for (const method of ["resume", "play"]) { + test(`${method} preserves 12 seconds before a reset element reports emptied`, async () => { + const { obsidian } = await startOutgoing(); + await obsidian.dev.evalJson( + "app.plugins.plugins.podnotes.api.stop(), app.plugins.plugins.podnotes.api.currentTime = 12, true", + ); + await waitForPlayback( + obsidian, + (state) => !state.playing && state.seconds === 12, + "paused at 12", + ); + const immediate = await obsidian.dev.evalJson<{ + before: number; + ready: number; + reset: number; + }>(`(() => { + const plugin = app.plugins.plugins.podnotes; + const media = document.querySelector(".podnotes-media"); + const before = media.currentTime; + media.load(); + const ready = media.readyState, reset = media.currentTime; + ${method === "resume" ? "plugin.player.resume()" : "plugin.player.play(plugin.api.podcast)"}; + return { before, ready, reset }; + })()`); + expect(immediate).toEqual({ before: 12, ready: 0, reset: 0 }); + const after = await waitForPlayback( + obsidian, + (state) => state.playing && (state.media?.seconds ?? 0) > 0, + "resumed after reset", + ); + expect(after.seconds).toBeGreaterThanOrEqual(12); + expect(after.seconds).toBeLessThan(15); + }); +} + +test("switching after a pending native seek saves and queues the ended outgoing episode", async () => { + const { obsidian, plugin, incoming } = await startOutgoing(); + await obsidian.dev.evalJson("app.plugins.plugins.podnotes.api.currentTime = 59, true"); + await plugin.waitForData( + (data) => data.playedEpisodes?.["Media reset::Outgoing"]?.finished === true, + WAIT_OPTS, + ); + await waitForPlayback(obsidian, (state) => !state.playing && state.seconds >= 59, "ended"); + const result = await obsidian.dev.evalJson<{ + before: number; + seek: number; + title: string; + }>(`(() => { + const plugin = app.plugins.plugins.podnotes; + const media = document.querySelector(".podnotes-media"); + const before = media.currentTime; + media.currentTime = 12; + const seek = media.currentTime; + plugin.player.play(${jsLiteral(incoming)}); + return { before, seek, title: plugin.api.podcast.title }; + })()`); + expect(result).toEqual({ before: 60, seek: 12, title: "Incoming" }); + await waitForPlayback(obsidian, (state) => state.playing, "incoming playing"); + await obsidian.dev.evalJsonAsync( + "(async () => { const p = app.plugins.plugins.podnotes; p.player.pause(); await p.flushSaves(); return true; })()", + ); + const data = await plugin.data().read(); + expect(data.playedEpisodes?.["Media reset::Outgoing"]).toMatchObject({ + time: 12, + duration: 60, + finished: false, + }); + expect(data.queue?.episodes.map((episode) => episode.title)).toContain("Outgoing"); +}); diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index b2d7283e..1019bdfc 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -168,6 +168,42 @@ describe("PodNotes settings tab", () => { ).toEqual(["Skip back (s)", saved]); }); + test("blur keeps an external setting received after the focused edit was saved", async () => { + const { obsidian, plugin } = getContext(); + const local = "Local/{{title}}.md"; + const remote = "Remote/{{title}}.md"; + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + const path = ui.settingControl("Episode note path", "input"); + await typeInto(obsidian, path, local, { replace: true }); + await plugin.waitForData((data) => data.note?.path === local, { + timeoutMs: 5_000, + intervalMs: 100, + }); + const synced = await plugin.data().read(); + if (!synced.note) throw new Error("The saved episode note settings are missing"); + synced.note.path = remote; + await plugin.data().write(synced); + await obsidian.dev.evalJsonAsync( + "app.plugins.plugins.podnotes.onExternalSettingsChange().then(() => true)", + ); + expect( + await obsidian.dev.evalJson(`({ + memory: app.plugins.plugins.podnotes.settings.note.path, + field: document.activeElement.value, + focused: (${path.elements}).includes(document.activeElement) + })`), + ).toEqual({ memory: remote, field: local, focused: true }); + + await pressKey(obsidian, "Tab"); + await obsidian.dev.evalJsonAsync( + "app.plugins.plugins.podnotes.flushSaves().then(() => true)", + ); + + expect(await settingValue(obsidian, "Episode note path")).toBe(remote); + expect((await plugin.data().read()).note?.path).toBe(remote); + }); + test("leaving a field saves a return to its original text before the debounce", async () => { const { obsidian, plugin } = getContext(); const initial = "Initial/{{title}}.md"; diff --git a/tests/e2e/table-cell-capture.test.ts b/tests/e2e/table-cell-capture.test.ts index 8b946be3..58377afa 100644 --- a/tests/e2e/table-cell-capture.test.ts +++ b/tests/e2e/table-cell-capture.test.ts @@ -129,6 +129,30 @@ async function expectCell(path: string, cell: string): Promise { } describe("Live Preview table captures", () => { + test("captures in the cell when the PodNotes leaf takes focus in the same evaluation", async () => { + const { obsidian } = getContext(); + const path = await setUp(); + const result = await obsidian.dev.evalJsonAsync(`(async () => { + const leaf = app.workspace.getLeavesOfType("podcast_player_view")[0]; + app.workspace.setActiveLeaf(leaf, { focus: true }); + const activeEditorNull = app.workspace.activeEditor === null; + const capture = await app.plugins.plugins.podnotes.capture.timestamp(); + return { activeEditorNull, capture }; + })()`); + + expect(result).toEqual({ + activeEditorNull: true, + capture: { + destination: "editor", + path, + text: "00:00:30 \\| cut ", + created: false, + }, + }); + await expectCell(path, "anchor00:00:30 \\| cut "); + expect(await cellText(path)).toBe("anchor00:00:30 | cut "); + }); + test("#445 three captures persist in one cell and Undo removes only the last", async () => { const { obsidian } = getContext(); const path = await setUp();