diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 8a9ca094..80bdcaff 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -3,9 +3,16 @@ import { fileURLToPath } from "node:url"; import { type ObsidianClient, type PluginReloadOptions, + createObsidianClient, resolveObsidianEnvOptions, } from "obsidian-e2e"; -import { createPluginHarness } from "obsidian-e2e/vitest"; +import { + type VaultRunLock, + acquireVaultRunLock, + clearVaultRunLockMarker, + createPluginHarness, +} from "obsidian-e2e/vitest"; +import { afterAll, afterEach, beforeAll, beforeEach } from "vitest"; export const PLUGIN_ID = "podnotes"; export const VIEW_TYPE = "podcast_player_view"; @@ -37,9 +44,112 @@ export const RELOAD_OPTIONS: PluginReloadOptions = { * Canonical `OBSIDIAN_E2E_*` env is emitted by the shared runner; the legacy * `PODNOTES_E2E_*` aliases remain a fallback during the migration. */ -export const createPodNotesE2EHarness = createPluginHarness({ +export function createPodNotesE2EHarness(testName: string) { + const suiteClient = createObsidianClient( + resolveObsidianEnvOptions({ legacyPrefix: "PODNOTES" }), + ); + let lock: VaultRunLock | undefined; + let wasEnabled = false; + let runtimeFailure: unknown; + // Own the lock before shared setup reloads the plugin and can open a player leaf. + beforeAll(async () => { + lock = await acquireVaultRunLock({ + vaultName: suiteClient.vaultName, + vaultPath: await suiteClient.vaultPath(), + timeoutMs: 60_000, + }); + await lock.publishMarker(suiteClient); + wasEnabled = await suiteClient.plugin(PLUGIN_ID).isEnabled(); + await (await import("./journey/library")).snapshotJourneyState(suiteClient); + await (await import("./journey/library")).installJourneyErrorRecorder(suiteClient); + }, 90_000); + afterAll(async () => { + try { + const library = await import("./journey/library"); + if (library.hasJourneySnapshot(suiteClient)) { + await library.restoreJourneyCache(suiteClient); + await suiteClient.plugin(PLUGIN_ID).enable(); + await waitForPodNotesReady(suiteClient); + await suiteClient.dev.evalJsonAsync( + "(async () => { await app.plugins.saveConfig(); return true; })()", + ); + await library.restoreJourneyWorkspace(suiteClient); + if (!wasEnabled) { + await suiteClient.dev.evalJsonAsync( + `(async () => { await app.plugins.disablePluginAndSave(${JSON.stringify(PLUGIN_ID)}); await app.plugins.saveConfig(); return true; })()`, + ); + } + } + await (await import("./journey/actions")).assertNoRuntimeErrors(suiteClient); + } finally { + if (lock) { + try { + await ( + await import("./journey/library") + ).removeJourneyErrorRecorder(suiteClient); + } finally { + try { + await clearVaultRunLockMarker(suiteClient); + } finally { + await lock.release(); + } + } + } + } + }, 60_000); + afterEach(async () => { + try { + if ((await import("./journey/library")).hasJourneySnapshot(getContext().obsidian)) { + await getContext().obsidian.dev.evalJsonAsync( + "(async () => { await app.plugins.saveConfig(); return true; })()", + ); + } + await ( + await import("./journey/library") + ).restoreJourneyWorkspace(getContext().obsidian); + await (await import("./journey/actions")).assertNoRuntimeErrors(getContext().obsidian); + } catch (error) { + if (runtimeFailure) + throw new AggregateError( + [runtimeFailure, error], + "Runtime and workspace restoration failed.", + ); + throw error; + } + if (runtimeFailure) throw runtimeFailure; + }); + const getContext = createSharedHarness(testName); + beforeAll(async () => + (await import("./journey/actions")).assertNoRuntimeErrors(getContext().obsidian), + ); + afterEach(async () => { + const { obsidian } = getContext(); + if ((await import("./journey/library")).hasJourneySnapshot(obsidian)) { + try { + await (await import("./journey/actions")).assertNoRuntimeErrors(obsidian); + } catch (error) { + runtimeFailure = error; + } + } + }); + // The shared harness rolls back only files a test wrote through the client, so + // data.json is snapshotted up front for tests that change settings through the UI. + beforeEach(async () => { + await (await import("./journey/actions")).assertNoRuntimeErrors(getContext().obsidian); + runtimeFailure = undefined; + await getContext().obsidian.dev.evalJson("window.__podnotesJourneyEarlyErrors = [], true"); + await getContext().obsidian.exec("dev:errors", { clear: true }); + await getContext() + .plugin.data() + .patch(() => undefined); + }); + return getContext; +} + +const createSharedHarness = createPluginHarness({ ...resolveObsidianEnvOptions({ legacyPrefix: "PODNOTES" }), pluginId: PLUGIN_ID, + sharedVaultLock: false, reload: { readyCommandId: RELOAD_OPTIONS.readyOptions?.commandId, timeoutMs: 30_000, @@ -54,6 +164,11 @@ export const createPodNotesE2EHarness = createPluginHarness({ beforeDataRestore: async (obsidian) => { await detachPodNotesViews(obsidian); await flushPodNotesSaves(obsidian); + const library = await import("./journey/library"); + if (library.hasJourneySnapshot(obsidian)) { + // Reload while disabled so old feed requests cannot write over the restored cache. + await library.restoreJourneyCache(obsidian); + } }, symlinkArtifacts: ["main.js", "manifest.json"], // Module-relative, not process.cwd(): the factory otherwise compares dev-vault @@ -126,6 +241,25 @@ async function flushPodNotesSaves(obsidian: ObsidianClient): Promise { ); } +export function expectKnownFailure( + issue: number, + captured: T, + known: { correct: (value: T) => void; reproduces: (value: T) => boolean }, +): void { + let failure: unknown; + try { + known.correct(captured); + } catch (error) { + failure = error; + } + if (failure === undefined) { + throw new Error( + `#${issue} no longer reproduces: remove its known-failure marker. Captured ${JSON.stringify(captured)}`, + ); + } + if (!known.reproduces(captured)) throw failure; +} + export async function openPodNotesView(obsidian: ObsidianClient): Promise { const result = await evalJsonAsync<{ activeViewType: string | null; diff --git a/tests/e2e/journey-feeds.test.ts b/tests/e2e/journey-feeds.test.ts new file mode 100644 index 00000000..7b66d935 --- /dev/null +++ b/tests/e2e/journey-feeds.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness, expectKnownFailure, RELOAD_OPTIONS } from "./harness"; +import { + chooseFile, + click, + closeSettings, + isShown, + listedEpisodes, + notices, + openPodNotesSettings, + shownText, + showPodNotes, + typeInto, + ui, + waitUntilShown, +} from "./journey/actions"; +import { + type FixtureFeed, + type FixtureServer, + installItunesStub, + itunesSearchTerms, + opmlDocument, + pubDate, + removeItunesStub, + startFixtureServer, +} from "./journey/fixtures"; +import { + feedCacheHolds, + observePlayback, + readLibrary, + seedLibrary, + waitForLibrary, + waitForPlayback, + writeVaultFile, +} from "./journey/library"; + +const getContext = createPodNotesE2EHarness("journey-feeds"); + +async function serve(...feeds: FixtureFeed[]): Promise { + const server = await startFixtureServer(feeds); + onTestFinished(() => server.stop()); + return server; +} + +function feed(title: string, slug: string, episodeTitles: string[], artwork = true): FixtureFeed { + return { + title, + slug, + artwork, + episodes: episodeTitles.map((episodeTitle, index) => ({ + title: episodeTitle, + seconds: 6, + pubDate: pubDate(index), + guid: `${slug}-${index}`, + description: `${episodeTitle} show notes.`, + })), + }; +} + +const byTitle = (a: { title: string }, b: { title: string }) => a.title.localeCompare(b.title); + +describe("feed journeys", () => { + test("J-FEED-01 searching Settings and clicking Add subscribes to the podcast for good", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const show = feed("Journey Search Show", "search", ["Search Show Pilot"]); + const server = await serve(show); + await seedLibrary(context, server); + await installItunesStub(obsidian, [ + { + collectionName: "Journey Search Show", + feedUrl: server.feedUrl(show), + artworkUrl100: server.artworkUrl(show), + collectionId: "4242", + }, + ]); + onTestFinished(() => removeItunesStub(obsidian)); + onTestFinished(() => closeSettings(obsidian)); + + await openPodNotesSettings(obsidian); + await typeInto(obsidian, ui.podcastSearch, "Journey Search"); + await click(obsidian, ui.addPodcast("Journey Search Show")); + + await waitForLibrary( + plugin, + (library) => library.subscriptions.length === 1, + "the added podcast saved", + ); + expect(await itunesSearchTerms(obsidian)).toEqual(["Journey Search"]); + await closeSettings(obsidian); + + await plugin.reload(RELOAD_OPTIONS); + expect((await readLibrary(plugin)).subscriptions).toEqual([ + { title: "Journey Search Show", url: server.feedUrl(show) }, + ]); + await showPodNotes(obsidian); + await click(obsidian, ui.podcastCard("Journey Search Show")); + await obsidian.waitFor(async () => (await listedEpisodes(obsidian)).length > 0, { + timeoutMs: 15_000, + message: "the subscribed podcast's episodes", + }); + expect(await listedEpisodes(obsidian)).toEqual(["Search Show Pilot"]); + }); + + test("J-FEED-06 opening a podcast lists every episode, beyond the Latest Episodes limit", async () => { + const context = getContext(); + const { obsidian } = context; + const titles = [ + "Archive Episode 12", + "Archive Episode 11", + "Archive Episode 10", + "Archive Episode 09", + "Archive Episode 08", + "Archive Episode 07", + "Archive Episode 06", + "Archive Episode 05", + "Archive Episode 04", + "Archive Episode 03", + "Archive Episode 02", + "Archive Episode 01", + ]; + const archive = feed("Journey Archive", "archive", titles); + await seedLibrary(context, await serve(archive), { subscriptions: [archive] }); + + await showPodNotes(obsidian); + await click(obsidian, ui.podcastCard("Journey Archive")); + await obsidian.waitFor(async () => (await listedEpisodes(obsidian)).length >= 12, { + timeoutMs: 15_000, + message: "the podcast's episode list", + }); + expect(await listedEpisodes(obsidian)).toEqual(titles); + + await click(obsidian, ui.backToLatestEpisodes); + await obsidian.waitFor(async () => (await listedEpisodes(obsidian)).length === 10, { + timeoutMs: 15_000, + message: "Latest Episodes capped at the default limit of 10", + }); + expect(await listedEpisodes(obsidian)).toEqual(titles.slice(0, 10)); + }); + + test("J-FEED-08 importing an OPML file through the file picker subscribes to its new podcasts", async () => { + const context = getContext(); + const { obsidian, plugin, sandbox } = context; + const existing = feed("Journey Existing", "existing", ["Existing Episode"]); + const first = feed("Journey Import One", "import-one", ["Import One Episode"]); + const second = feed("Journey Import Two", "import-two", ["Import Two Episode"]); + const server = await serve(existing, first, second); + await seedLibrary(context, server, { subscriptions: [existing] }); + const opmlPath = sandbox.path("subscriptions.opml"); + await writeVaultFile( + obsidian, + opmlPath, + opmlDocument( + [existing, first, second].map((entry) => ({ + title: entry.title, + url: server.feedUrl(entry), + })), + ), + ); + onTestFinished(() => closeSettings(obsidian)); + + await openPodNotesSettings(obsidian); + await chooseFile( + obsidian, + ui.settingControl("Import OPML", "button"), + `${await obsidian.vaultPath()}/${opmlPath}`, + ); + + const summary = await obsidian.waitFor( + async () => + (await notices(obsidian)).find((text) => text.includes("OPML import complete.")), + { timeoutMs: 15_000, intervalMs: 100, message: "the OPML import summary" }, + ); + expect(summary).toContain("Saved 2 new podcasts. Skipped 1 existing podcasts."); + const expected = [ + { title: "Journey Existing", url: server.feedUrl(existing) }, + { title: "Journey Import One", url: server.feedUrl(first) }, + { title: "Journey Import Two", url: server.feedUrl(second) }, + ]; + const saved = await waitForLibrary( + plugin, + (library) => library.subscriptions.length === 3, + "imports saved", + ); + expect([...saved.subscriptions].sort(byTitle)).toEqual(expected); + + await closeSettings(obsidian); + await plugin.reload(RELOAD_OPTIONS); + expect([...(await readLibrary(plugin)).subscriptions].sort(byTitle)).toEqual(expected); + }); + + test("J-EDGE-05 a feed missing optional fields still lists and plays its episodes, skipping one without audio", async () => { + const context = getContext(); + const { obsidian } = context; + const sparse: FixtureFeed = { + title: "Journey Sparse", + slug: "sparse", + artwork: true, + episodes: [ + { title: "Bare Episode", seconds: 6, pubDate: pubDate(0) }, + { title: "Announcement Without Audio", seconds: null, pubDate: pubDate(1) }, + { title: "Untitled Audio", seconds: 6, pubDate: pubDate(1), omitTitle: true }, + { title: "Second Bare Episode", seconds: 6, pubDate: pubDate(2) }, + ], + }; + const empty: FixtureFeed = { + title: "Journey Empty", + slug: "empty", + artwork: true, + episodes: [], + }; + const server = await serve(sparse, empty); + await seedLibrary(context, server, { subscriptions: [sparse, empty] }); + + await showPodNotes(obsidian); + await click(obsidian, ui.podcastCard("Journey Sparse")); + await obsidian.waitFor(async () => (await listedEpisodes(obsidian)).length >= 2, { + timeoutMs: 15_000, + message: "the sparse feed's episodes", + }); + expect(await listedEpisodes(obsidian)).toEqual(["Bare Episode", "Second Bare Episode"]); + + await click(obsidian, ui.episode("Bare Episode")); + const first = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === "Bare Episode" && + playback.playing && + playback.seconds > 0, + "the bare episode playing", + ); + await waitForPlayback( + obsidian, + (playback) => playback.seconds > first.seconds + 1, + "the bare episode's media advancing", + ); + expect((await observePlayback(obsidian)).media?.src).toBe( + server.mediaUrl(sparse, sparse.episodes[0]), + ); + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.podcastCard("Journey Empty")); + await obsidian.waitFor(async () => !(await isShown(obsidian, ui.episodeLoading)), { + timeoutMs: 15_000, + message: "the empty channel finished loading", + }); + expect(await listedEpisodes(obsidian)).toEqual([]); + expect(await shownText(obsidian)).toContain("No episodes found."); + expect(server.requests()).toContain("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/empty/feed.xml"); + expect(await notices(obsidian)).not.toContain( + "Could not load episodes for Journey Empty. Check your connection and try again.", + ); + }); + + test("#378 known failure J-EDGE-05: a feed episode without pubDate still appears in the episode list", async () => { + const context = getContext(); + const { obsidian } = context; + const show: FixtureFeed = { + title: "Journey Missing Date", + slug: "missing-date", + artwork: true, + episodes: [ + { title: "Dated Control", seconds: 6, pubDate: pubDate(0) }, + { title: "Undated Episode", seconds: 6 }, + ], + }; + await seedLibrary(context, await serve(show), { subscriptions: [show] }); + await showPodNotes(obsidian); + await click(obsidian, ui.podcastCard(show.title)); + await obsidian.waitFor(() => feedCacheHolds(obsidian, "Dated Control"), { + timeoutMs: 15_000, + message: "the missing-date feed loaded", + }); + await obsidian.waitFor(async () => !(await isShown(obsidian, ui.episodeLoading)), { + timeoutMs: 15_000, + message: "the missing-date feed finished loading", + }); + const captured = (await listedEpisodes(obsidian)).sort(); + expectKnownFailure(378, captured, { + correct: (value) => expect(value).toEqual(["Dated Control", "Undated Episode"]), + reproduces: (value) => value.length === 1 && value[0] === "Dated Control", + }); + }); + + test("#358 known failure: a podcast without artwork still gets a card in the grid", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const control = feed("Journey Artwork Control", "control", ["Control Episode"]); + const artless = feed("Journey No Artwork", "artless", ["Artless Episode"], false); + await seedLibrary(context, await serve(control, artless), { + subscriptions: [control, artless], + }); + + await showPodNotes(obsidian); + await waitUntilShown(obsidian, ui.podcastCard("Journey Artwork Control")); + await obsidian.sleep(1000); + const captured = { + subscribed: (await readLibrary(plugin)).subscriptions + .map((entry) => entry.title) + .sort(), + controlCard: await isShown(obsidian, ui.podcastCard("Journey Artwork Control")), + artlessCard: await isShown(obsidian, ui.podcastCard("Journey No Artwork")), + }; + + expectKnownFailure(358, captured, { + correct: (value) => expect(value.artlessCard).toBe(true), + reproduces: (value) => + value.controlCard && + !value.artlessCard && + value.subscribed.join() === "Journey Artwork Control,Journey No Artwork", + }); + }); + + test("#375 known failure: the Episode list tab shows episodes that loaded while the grid was open", async () => { + const context = getContext(); + const { obsidian } = context; + const show = feed("Journey Latest", "latest", ["Latest Two", "Latest One"]); + await seedLibrary(context, await serve(show), { subscriptions: [show] }); + + await showPodNotes(obsidian); + await waitUntilShown(obsidian, ui.podcastCard("Journey Latest")); + await obsidian.waitFor(() => feedCacheHolds(obsidian, "Latest One"), { + timeoutMs: 15_000, + message: "the feed loaded while the grid shows", + }); + await click(obsidian, ui.episodeListTab); + await obsidian.sleep(1000); + const captured = { + listed: await listedEpisodes(obsidian), + emptyState: (await shownText(obsidian)).includes("No episodes found."), + }; + + expectKnownFailure(375, captured, { + correct: (value) => expect(value.listed).toEqual(["Latest Two", "Latest One"]), + reproduces: (value) => value.listed.length === 0 && value.emptyState, + }); + }); +}); diff --git a/tests/e2e/journey-links-local.test.ts b/tests/e2e/journey-links-local.test.ts new file mode 100644 index 00000000..d0d997b1 --- /dev/null +++ b/tests/e2e/journey-links-local.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness, PLUGIN_ID, RELOAD_OPTIONS } from "./harness"; +import { + click, + closePodNotes, + listedEpisodes, + openInReadingView, + revealInFileExplorer, + runCommand, + showPodNotes, + ui, + waitUntilShown, +} from "./journey/actions"; +import { + type FixtureEpisode, + type FixtureFeed, + type FixtureServer, + pubDate, + startFixtureServer, + wav, +} from "./journey/fixtures"; +import { + episodeRef, + playbackOver, + playerLeafCount, + readLibrary, + readVaultText, + seedLibrary, + storedEpisode, + waitForLibrary, + waitForPlayback, + writeVaultFile, +} from "./journey/library"; + +const getContext = createPodNotesE2EHarness("journey-links-local"); + +async function serve(...feeds: FixtureFeed[]): Promise { + const server = await startFixtureServer(feeds); + onTestFinished(() => server.stop()); + return server; +} + +describe("link and local file journeys", () => { + test("J-NOTE-07 the default note's Resume in PodNotes link resumes the saved position", async () => { + const context = getContext(); + const { obsidian, plugin, sandbox } = context; + const noted: FixtureEpisode = { title: "Noted Episode", seconds: 60, pubDate: pubDate(0) }; + const other: FixtureEpisode = { title: "Other Episode", seconds: 60, pubDate: pubDate(1) }; + const done: FixtureEpisode = { + title: "Finished Episode", + seconds: 60, + pubDate: pubDate(2), + }; + const show: FixtureFeed = { + title: "Journey Notes", + slug: "notes", + artwork: true, + episodes: [noted, other, done], + }; + const server = await serve(show); + await seedLibrary(context, server, { + subscriptions: [show], + progress: [ + { + episode: storedEpisode(server, show, done), + seconds: 60, + duration: 60, + finished: true, + }, + ], + }); + const feedParam = encodeURIComponent(server.feedUrl(show)); + + await showPodNotes(obsidian); + await click(obsidian, ui.podcastCard("Journey Notes")); + await click(obsidian, ui.episode("Noted Episode")); + await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === "Noted Episode" && + playback.playing && + playback.seconds >= 3, + "Noted Episode played for 3 s", + ); + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (playback) => !playback.playing, "paused"); + const [, paused] = await playbackOver(obsidian, 500); + + await runCommand(obsidian, `${PLUGIN_ID}:create-podcast-note`); + const notePath = sandbox.path("PodNotes/Journey Notes/Noted Episode.md"); + await sandbox.waitForExists("PodNotes/Journey Notes/Noted Episode.md", { + timeoutMs: 10_000, + }); + const note = await readVaultText(obsidian, notePath); + expect(note).toMatch(/^---\ntype: podcastEpisode\n/); + expect(note).toContain("\n# Noted Episode\n"); + expect(note).toContain( + `\n[Resume in PodNotes](obsidian://podnotes?episodeName=Noted%20Episode&url=${feedParam})\n`, + ); + + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.podcastCard("Journey Notes")); + await click(obsidian, ui.episode("Other Episode")); + await waitForPlayback( + obsidian, + (playback) => playback.episode?.title === "Other Episode" && playback.playing, + "switched to Other Episode", + ); + await waitForLibrary( + plugin, + (library) => + Math.abs( + (library.progress(episodeRef(show, noted))?.seconds ?? -1) - paused.seconds, + ) < 0.25, + "Noted Episode's position saved on the switch", + ); + + await openInReadingView(obsidian, notePath); + await click(obsidian, ui.readingLink("Resume in PodNotes")); + const resumed = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === "Noted Episode" && + playback.media !== null && + playback.seconds > 0, + "the Resume link reopens Noted Episode", + ); + expect(resumed.seconds).toBeGreaterThanOrEqual(paused.seconds - 0.5); + expect(resumed.seconds).toBeLessThanOrEqual(paused.seconds + 3); + expect(resumed.playing).toBe(true); + + const finishedNote = sandbox.path("Finished episode.md"); + await writeVaultFile( + obsidian, + finishedNote, + `[Resume in PodNotes](obsidian://podnotes?episodeName=Finished+Episode&url=${feedParam})\n`, + ); + await openInReadingView(obsidian, finishedNote); + await click(obsidian, ui.readingLink("Resume in PodNotes")); + const restarted = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === "Finished Episode" && + playback.playing && + playback.media !== null, + "the Resume link reopens Finished Episode", + ); + expect(restarted.seconds).toBeLessThan(3); + }, 60_000); + + test("J-URI-03 a timestamp link to a podcast that was never loaded fetches it and plays from that time", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const linked: FixtureEpisode = { + title: "Linked Episode", + seconds: 60, + pubDate: pubDate(0), + }; + const current: FixtureEpisode = { + title: "Current Episode", + seconds: 60, + pubDate: pubDate(0), + }; + const linkedShow: FixtureFeed = { + title: "Journey Links", + slug: "links", + artwork: true, + episodes: [linked], + }; + const currentShow: FixtureFeed = { + title: "Journey Current", + slug: "current", + artwork: true, + episodes: [current], + }; + const server = await serve(linkedShow, currentShow); + await seedLibrary(context, server, { + subscriptions: [currentShow], + current: storedEpisode(server, currentShow, current), + }); + const notePath = sandbox.path("Timestamp link.md"); + await writeVaultFile( + obsidian, + notePath, + `- [00:00:37](obsidian://podnotes?episodeName=Linked%20Episode&url=${encodeURIComponent(server.feedUrl(linkedShow))}&time=37) the part worth hearing\n`, + ); + expect(server.requests()).not.toContain("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/links/feed.xml"); + + await openInReadingView(obsidian, notePath); + await click(obsidian, ui.readingLink("00:00:37")); + const playing = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === "Linked Episode" && + playback.media !== null && + playback.seconds > 0, + "the linked episode loads", + ); + expect(playing.episode).toEqual({ podcast: "Journey Links", title: "Linked Episode" }); + expect(playing.seconds).toBeGreaterThanOrEqual(37); + expect(playing.seconds).toBeLessThanOrEqual(40); + expect(playing.media?.src).toBe(server.mediaUrl(linkedShow, linked)); + expect(server.requests()).toContain("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/links/feed.xml"); + await waitForPlayback( + obsidian, + (playback) => playback.playing && playback.seconds > playing.seconds + 0.5, + "the linked episode plays on", + ); + }); + + test("J-LOCAL-01 Play with PodNotes on a vault audio file opens the player and keeps the file in Local Files", async () => { + const context = getContext(); + const { obsidian, plugin, sandbox } = context; + const explorerWasEnabled = await obsidian.dev.evalJson( + `app.internalPlugins.getPluginById("file-explorer")?.enabled === true`, + ); + if (!explorerWasEnabled) { + await obsidian.plugin("file-explorer").enable({ filter: "core" }); + onTestFinished(() => obsidian.plugin("file-explorer").disable({ filter: "core" })); + } + await seedLibrary(context, null); + const audioPath = sandbox.path("Local Journey.wav"); + await writeVaultFile(obsidian, audioPath, wav(30)); + await closePodNotes(obsidian); + expect(await playerLeafCount(obsidian)).toBe(0); + + await revealInFileExplorer(obsidian, audioPath); + await click(obsidian, ui.fileExplorerItem(audioPath), { button: "right" }); + await click(obsidian, ui.menuItem("Play with PodNotes")); + + await waitUntilShown(obsidian, ui.podcastViewport); + expect(await playerLeafCount(obsidian)).toBe(1); + const started = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === "Local Journey" && + playback.playing && + playback.seconds > 0, + "the local file playing", + ); + expect(started.episode).toEqual({ podcast: "local file", title: "Local Journey" }); + await waitForPlayback( + obsidian, + (playback) => playback.seconds > started.seconds + 1, + "the local file's media advancing", + ); + + const registered = { title: "Local Journey", filePath: audioPath }; + await waitForLibrary( + plugin, + (library) => library.localFiles.some((entry) => entry.filePath === audioPath), + "the local file registered", + ); + await plugin.reload(RELOAD_OPTIONS); + expect((await readLibrary(plugin)).localFiles).toEqual([registered]); + await showPodNotes(obsidian); + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.playlistCard("Local Files")); + await obsidian.waitFor(async () => (await listedEpisodes(obsidian)).length > 0, { + timeoutMs: 10_000, + message: "the Local Files list", + }); + expect(await listedEpisodes(obsidian)).toEqual(["Local Journey"]); + }); +}); diff --git a/tests/e2e/journey-mobile.test.ts b/tests/e2e/journey-mobile.test.ts new file mode 100644 index 00000000..55af5542 --- /dev/null +++ b/tests/e2e/journey-mobile.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; +import { createPodNotesE2EHarness } from "./harness"; +import { + click, + closePodNotes, + reloadObsidian, + setMobileEmulation, + shownText, + ui, + waitUntilShown, +} from "./journey/actions"; +import { playerLeafCount, seedLibrary } from "./journey/library"; + +const getContext = createPodNotesE2EHarness("journey-mobile"); + +describe("mobile journeys", () => { + // Obsidian's mobile emulation on desktop: the plugin runs its mobile code paths and + // the mobile layout, but this is not an iOS or Android device. + test("J-ONB-02 on a mobile first launch PodNotes stays closed until its ribbon icon opens it", async () => { + const context = getContext(); + const { obsidian } = context; + await seedLibrary(context, null); + + await setMobileEmulation(obsidian, true); + await closePodNotes(obsidian); + await obsidian.dev.evalJsonAsync( + "(async () => { await app.workspace.saveLayout(); return true; })()", + ); + await reloadObsidian(obsidian); + for (let check = 0; check < 5; check++) { + expect(await playerLeafCount(obsidian)).toBe(0); + await obsidian.sleep(300); + } + + await click(obsidian, ui.leftDrawerToggle); + await click(obsidian, ui.ribbonAction("Show PodNotes")); + await waitUntilShown(obsidian, ui.podcastViewport); + expect(await playerLeafCount(obsidian)).toBe(1); + + await click(obsidian, ui.playlistCard("Local Files")); + await obsidian.waitFor(async () => (await shownText(obsidian)).includes("Local Files"), { + timeoutMs: 10_000, + message: "the Local Files list", + }); + expect(await shownText(obsidian)).toContain("No episodes found."); + }, 120_000); +}); diff --git a/tests/e2e/journey-playback.test.ts b/tests/e2e/journey-playback.test.ts new file mode 100644 index 00000000..4e99b98c --- /dev/null +++ b/tests/e2e/journey-playback.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness, expectKnownFailure } from "./harness"; +import { click, restartObsidian, showPodNotes, ui } from "./journey/actions"; +import { + type FixtureEpisode, + type FixtureFeed, + type FixtureServer, + pubDate, + startFixtureServer, +} from "./journey/fixtures"; +import { + episodeRef, + observePlayback, + playbackOver, + readLibrary, + seedLibrary, + storedEpisode, + waitForLibrary, + waitForPlayback, +} from "./journey/library"; + +const LONG: FixtureEpisode = { title: "Long Listen", seconds: 60, pubDate: pubDate(0) }; +const OPENER: FixtureEpisode = { title: "Short Opener", seconds: 6, pubDate: pubDate(1) }; +const FOLLOW_UP: FixtureEpisode = { title: "Short Follow-up", seconds: 6, pubDate: pubDate(2) }; +const FEED: FixtureFeed = { + title: "Journey Playback", + slug: "playback", + artwork: true, + episodes: [LONG, OPENER, FOLLOW_UP], +}; +const isolatedInstance = Boolean(process.env.OBSIDIAN_E2E_OBSIDIAN_HOME); + +const getContext = createPodNotesE2EHarness("journey-playback"); + +async function serve(): Promise { + const server = await startFixtureServer([FEED]); + onTestFinished(() => server.stop()); + return server; +} + +async function playFromList(episode: FixtureEpisode): Promise { + const { obsidian } = getContext(); + await showPodNotes(obsidian); + await click(obsidian, ui.podcastCard(FEED.title)); + await click(obsidian, ui.episode(episode.title)); + await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === episode.title && playback.playing && playback.seconds > 0, + `${episode.title} playing`, + ); +} + +describe("playback journeys", () => { + test("J-LIST-08 clicking an episode in the list plays its real media", async () => { + const context = getContext(); + const server = await serve(); + await seedLibrary(context, server, { subscriptions: [FEED] }); + + await playFromList(LONG); + const [first, second] = await playbackOver(context.obsidian, 1500); + + expect(second.episode).toEqual({ podcast: "Journey Playback", title: "Long Listen" }); + expect(second.playing).toBe(true); + expect(second.media).toMatchObject({ src: server.mediaUrl(FEED, LONG), paused: false }); + expect(second.duration).toBeCloseTo(60, 1); + expect(second.seconds - first.seconds).toBeGreaterThan(1); + expect(server.requests()).toContain("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/playback/1.wav"); + }); + + test("J-PLAY-01 the on-screen button pauses, keeps the position, and resumes", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + await seedLibrary(context, await serve(), { subscriptions: [FEED] }); + await playFromList(LONG); + await waitForPlayback(obsidian, (playback) => playback.seconds >= 2, "two seconds played"); + + await click(obsidian, ui.playbackToggle); + await waitForPlayback( + obsidian, + (playback) => !playback.playing && playback.media?.paused === true, + "paused", + ); + await obsidian.sleep(500); + const [pausedAt, pausedLater] = await playbackOver(obsidian, 1500); + expect(pausedLater.seconds).toBe(pausedAt.seconds); + expect(pausedLater.media?.seconds).toBe(pausedAt.media?.seconds); + expect(pausedLater.seconds).toBeGreaterThanOrEqual(2); + + const saved = await waitForLibrary( + plugin, + (library) => + Math.abs( + (library.progress(episodeRef(FEED, LONG))?.seconds ?? -1) - pausedLater.seconds, + ) < 0.25, + "the paused position saved", + ); + expect(saved.progress(episodeRef(FEED, LONG))?.finished).toBe(false); + + await click(obsidian, ui.playbackToggle); + await waitForPlayback( + obsidian, + (playback) => playback.playing && playback.seconds > pausedLater.seconds + 0.5, + "playback resumes from the paused position", + ); + }); + + test("J-PLAY-09 an episode that ends is marked played and the queue plays the next one", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + queue: [storedEpisode(server, FEED, FOLLOW_UP)], + playlists: [ + { + name: "Journey Picks", + removeAfterPlay: true, + episodes: [storedEpisode(server, FEED, OPENER)], + }, + ], + }); + + await playFromList(OPENER); + const next = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === FOLLOW_UP.title && + playback.playing && + playback.seconds > 0.5, + "the queued episode playing after the first one ends", + 20_000, + ); + expect(next.media).toMatchObject({ src: server.mediaUrl(FEED, FOLLOW_UP), paused: false }); + + const library = await waitForLibrary( + plugin, + (saved) => saved.progress(episodeRef(FEED, OPENER))?.finished === true, + "the finished episode saved as played", + ); + expect(library.progress(episodeRef(FEED, OPENER))).toMatchObject({ finished: true }); + expect(library.queue).toEqual([]); + expect(library.playlists).toEqual({ "Journey Picks": [] }); + expect(library.currentEpisode).toEqual({ + podcast: "Journey Playback", + title: "Short Follow-up", + }); + }, 60_000); + + test.runIf(isolatedInstance)( + "#377 known failure J-PLAY-11: after restart the restored player keeps its saved position without autoplay", + async () => { + const context = getContext(); + const { obsidian, plugin } = context; + await seedLibrary(context, await serve(), { subscriptions: [FEED] }); + await playFromList(LONG); + + const checkpoint = ( + await waitForLibrary( + plugin, + (library) => (library.progress(episodeRef(FEED, LONG))?.seconds ?? 0) >= 5, + "a playback checkpoint of at least 5 s on disk", + 20_000, + ) + ).progress(episodeRef(FEED, LONG))?.seconds as number; + expect((await observePlayback(obsidian)).playing).toBe(true); + + const { before, after } = await restartObsidian(obsidian); + expect(after.mainPid).not.toBe(before.mainPid); + expect(after.rendererPid).not.toBe(before.rendererPid); + + const restored = await readLibrary(plugin); + const saved = restored.progress(episodeRef(FEED, LONG))?.seconds as number; + expect(restored.currentEpisode).toEqual({ + podcast: "Journey Playback", + title: "Long Listen", + }); + expect(saved).toBeGreaterThanOrEqual(checkpoint); + expect(saved).toBeLessThanOrEqual(checkpoint + 7); + + const relaunched = await observePlayback(obsidian); + expect(relaunched.episode).toEqual({ + podcast: "Journey Playback", + title: "Long Listen", + }); + expect(relaunched.playing).toBe(false); + + await showPodNotes(obsidian); + await click(obsidian, ui.playerTab); + const resumed = await waitForPlayback( + obsidian, + (playback) => playback.media !== null && playback.seconds > 0, + "the player loads the restored episode", + ); + expect(resumed.episode).toEqual({ podcast: "Journey Playback", title: "Long Listen" }); + expect(resumed.seconds).toBeGreaterThanOrEqual(saved - 0.5); + expect(resumed.seconds).toBeLessThanOrEqual(saved + 3); + const [first, later] = await playbackOver(obsidian, 1500); + const captured = { + playing: later.playing, + mediaPaused: later.media?.paused, + advance: later.seconds - first.seconds, + }; + expectKnownFailure(377, captured, { + correct: (value) => { + expect(value.playing).toBe(false); + expect(value.mediaPaused).toBe(true); + expect(value.advance).toBe(0); + }, + reproduces: (value) => + value.playing && value.mediaPaused === false && value.advance > 1, + }); + }, + 120_000, + ); +}); diff --git a/tests/e2e/journey-resilience.test.ts b/tests/e2e/journey-resilience.test.ts new file mode 100644 index 00000000..2bd5f1d3 --- /dev/null +++ b/tests/e2e/journey-resilience.test.ts @@ -0,0 +1,300 @@ +import fs from "node:fs/promises"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness, expectKnownFailure, PLUGIN_ID } from "./harness"; +import { + click, + closeSettings, + isShown, + listedEpisodes, + notices, + openPodNotesSettings, + restartObsidian, + runCommand, + shownText, + showPodNotes, + typeInto, + ui, +} from "./journey/actions"; +import { + type FixtureEpisode, + type FixtureFeed, + type FixtureServer, + installOpenAIStub, + pubDate, + removeOpenAIStub, + startFixtureServer, + transcriptionUploads, +} from "./journey/fixtures"; +import { + readVaultBytes, + readVaultText, + seedLibrary, + storedEpisode, + waitForLibrary, + waitForPlayback, +} from "./journey/library"; + +const getContext = createPodNotesE2EHarness("journey-resilience"); +const isolatedInstance = Boolean(process.env.OBSIDIAN_E2E_OBSIDIAN_HOME); + +async function serve(...feeds: FixtureFeed[]): Promise { + const server = await startFixtureServer(feeds); + onTestFinished(() => server.stop()); + return server; +} + +describe("resilience journeys", () => { + test("#357 known failure J-EDGE-12: a data.json replaced by sync keeps both the remote and the local change", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const local: FixtureFeed = { + title: "Journey Local Feed", + slug: "local", + artwork: true, + episodes: [], + }; + const remote: FixtureFeed = { + title: "Journey Remote Feed", + slug: "remote", + artwork: true, + episodes: [], + }; + const server = await serve(local, remote); + await seedLibrary(context, server, { subscriptions: [local] }); + await obsidian.dev.evalJson(`(() => { + window.__podnotesJourneyRaw = { seen: [], ref: app.vault.on("raw", (path) => window.__podnotesJourneyRaw.seen.push(path)) }; + return true; + })()`); + onTestFinished(() => + obsidian.dev.evalJson( + "window.__podnotesJourneyRaw && app.vault.offref(window.__podnotesJourneyRaw.ref), delete window.__podnotesJourneyRaw, true", + ), + ); + onTestFinished(() => closeSettings(obsidian)); + + const dataPath = await plugin.dataPath(); + const synced = JSON.parse(await fs.readFile(dataPath, "utf8")); + synced.savedFeeds["Journey Remote Feed"] = { + title: "Journey Remote Feed", + url: server.feedUrl(remote), + artworkUrl: server.artworkUrl(remote), + }; + const staged = path.join(path.dirname(dataPath), ".data.json.sync"); + await fs.writeFile(staged, JSON.stringify(synced, null, 2)); + await fs.rename(staged, dataPath); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `window.__podnotesJourneyRaw.seen.some((path) => path.endsWith("plugins/${PLUGIN_ID}/data.json"))`, + ), + { + timeoutMs: 10_000, + intervalMs: 100, + message: "Obsidian to notice the replaced data.json", + }, + ); + await obsidian.sleep(1000); + + await openPodNotesSettings(obsidian); + await typeInto(obsidian, ui.settingControl("Skip backward length (s)", "input"), "42", { + replace: true, + }); + const saved = await waitForLibrary( + plugin, + (library) => library.skipBackwardSeconds === 42, + "the local setting change saved", + ); + const captured = { + skipBackwardSeconds: saved.skipBackwardSeconds, + subscriptions: saved.subscriptions.map((entry) => entry.title).sort(), + }; + + expectKnownFailure(357, captured, { + correct: (value) => + expect(value).toEqual({ + skipBackwardSeconds: 42, + subscriptions: ["Journey Local Feed", "Journey Remote Feed"], + }), + reproduces: (value) => + value.skipBackwardSeconds === 42 && + value.subscriptions.join() === "Journey Local Feed", + }); + }); + + test.runIf(isolatedInstance)( + "J-EDGE-03 offline after a restart: cached episodes browse, the downloaded episode plays, an unloaded podcast fails cleanly", + async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const keeper: FixtureEpisode = { + title: "Offline Keeper", + seconds: 20, + pubDate: pubDate(0), + }; + const extra: FixtureEpisode = { + title: "Offline Extra", + seconds: 20, + pubDate: pubDate(1), + }; + const show: FixtureFeed = { + title: "Journey Offline", + slug: "offline", + artwork: true, + episodes: [keeper, extra], + }; + const server = await serve(show); + await seedLibrary(context, server, { subscriptions: [show] }); + + await showPodNotes(obsidian); + await click(obsidian, ui.podcastCard("Journey Offline")); + await obsidian.waitFor(async () => (await listedEpisodes(obsidian)).length === 2, { + timeoutMs: 15_000, + message: "the podcast's episodes while online", + }); + await click(obsidian, ui.episode("Offline Keeper"), { button: "right" }); + await click(obsidian, ui.menuItem("Download")); + const downloaded = ( + await waitForLibrary( + plugin, + (library) => + library.downloads.some((entry) => entry.title === "Offline Keeper"), + "the download registered", + ) + ).downloads.find((entry) => entry.title === "Offline Keeper"); + expect(downloaded?.podcast).toBe("Journey Offline"); + expect( + (await readVaultBytes(obsidian, downloaded?.filePath ?? "")).equals( + server.audio(show, keeper), + ), + ).toBe(true); + + await server.stop(); + await expect(fetch(server.feedUrl(show))).rejects.toThrow(); + const requestsWhileOnline = server.requests().length; + + await restartObsidian(obsidian, async () => { + await plugin.data>().patch((data) => { + data.savedFeeds["Journey Never Loaded"] = { + title: "Journey Never Loaded", + url: server.feedUrl({ ...show, slug: "never-loaded" }), + artworkUrl: server.artworkUrl(show), + }; + }); + }); + + await showPodNotes(obsidian); + await click(obsidian, ui.podcastCard("Journey Offline")); + await obsidian.waitFor( + async () => (await listedEpisodes(obsidian)).includes("Offline Keeper"), + { + timeoutMs: 15_000, + message: "the downloaded episode listed offline", + }, + ); + await click(obsidian, ui.backToLatestEpisodes); + await obsidian.waitFor(async () => (await listedEpisodes(obsidian)).length === 2, { + timeoutMs: 15_000, + message: "cached Latest Episodes offline", + }); + expect(await listedEpisodes(obsidian)).toEqual(["Offline Keeper", "Offline Extra"]); + + await click(obsidian, ui.episode("Offline Keeper")); + const playing = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === "Offline Keeper" && + playback.playing && + playback.seconds > 0, + "the downloaded episode playing offline", + ); + expect(playing.media?.src.startsWith("http")).toBe(false); + await waitForPlayback( + obsidian, + (playback) => playback.seconds > playing.seconds + 1, + "the downloaded episode's media advancing", + ); + + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.podcastCard("Journey Never Loaded")); + await obsidian.waitFor( + async () => + (await notices(obsidian)).includes( + "Could not load episodes for Journey Never Loaded. Check your connection and try again.", + ), + { timeoutMs: 15_000, message: "the offline failure notice" }, + ); + expect(await isShown(obsidian, ui.episodeLoading)).toBe(false); + expect(await shownText(obsidian)).toContain("No episodes found."); + expect(server.requests().length).toBe(requestsWhileOnline); + }, + 120_000, + ); + + test("J-TR-02 transcribing the current episode writes its transcript note from the real audio", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const spoken: FixtureEpisode = { title: "Spoken Episode", seconds: 4, pubDate: pubDate(0) }; + const show: FixtureFeed = { + title: "Journey Transcribe", + slug: "transcribe", + artwork: true, + episodes: [spoken], + }; + const server = await serve(show); + const secretId = "podnotes-openai-api-key-424242"; + const previousSecret = await obsidian.dev.evalJson( + `app.secretStorage.getSecret(${JSON.stringify(secretId)})`, + ); + onTestFinished(() => + obsidian.dev.evalJson( + previousSecret === null + ? `app.secretStorage.deleteSecret(${JSON.stringify(secretId)}), true` + : `app.secretStorage.setSecret(${JSON.stringify(secretId)}, ${JSON.stringify(previousSecret)}), true`, + ), + ); + await obsidian.dev.evalJson( + `app.secretStorage.setSecret(${JSON.stringify(secretId)}, "sk-journey-test"), true`, + ); + await seedLibrary(context, server, { + subscriptions: [show], + current: storedEpisode(server, show, spoken), + openAISecretId: secretId, + }); + await installOpenAIStub( + obsidian, + "Journey transcript opens here. It closes with a second sentence.", + ); + onTestFinished(() => removeOpenAIStub(obsidian)); + + await runCommand(obsidian, `${PLUGIN_ID}:podnotes-transcribe`); + await sandbox.waitForExists("Transcripts/Journey Transcribe/Spoken Episode.md", { + timeoutMs: 30_000, + }); + const transcript = await readVaultText( + obsidian, + sandbox.path("Transcripts/Journey Transcribe/Spoken Episode.md"), + ); + expect(transcript).toMatch( + /^# Spoken Episode\n\nPodcast: Journey Transcribe\nDate: .+\n\nJourney transcript opens here\.\n\nIt closes with a second sentence\.$/, + ); + + const uploads = await transcriptionUploads(obsidian); + expect(uploads).toHaveLength(1); + expect(uploads[0]).toMatchObject({ + method: "POST", + authorization: "Bearer sk-journey-test", + model: "whisper-1", + fileBytes: server.audio(show, spoken).length, + riffWave: true, + sha256: createHash("sha256").update(server.audio(show, spoken)).digest("hex"), + }); + expect(server.requests()).toContain("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/transcribe/1.wav"); + expect( + await obsidian.dev.evalJsonAsync( + `app.plugins.plugins.${PLUGIN_ID}.api.transcript`, + ), + ).toBe(transcript); + }, 60_000); +}); diff --git a/tests/e2e/journey/actions.ts b/tests/e2e/journey/actions.ts new file mode 100644 index 00000000..455d635d --- /dev/null +++ b/tests/e2e/journey/actions.ts @@ -0,0 +1,504 @@ +import { execFile, spawn } from "node:child_process"; +import fs from "node:fs"; +import { once } from "node:events"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import type { ObsidianClient } from "obsidian-e2e"; +import { + loadRunnerConfig, + resolveInstanceOptions, + resolveProvisionOptions, +} from "obsidian-e2e/runner"; +import { PLUGIN_ID, VIEW_TYPE, waitForPodNotesReady } from "../harness"; + +export async function assertNoRuntimeErrors(obsidian: ObsidianClient): Promise { + const early = await obsidian.dev.evalJson("window.__podnotesJourneyEarlyErrors"); + if (!Array.isArray(early)) throw new Error("The startup error recorder is missing."); + if (early.length) throw new Error(`Startup runtime errors: ${JSON.stringify(early)}`); + const errors = await obsidian.dev.runtimeErrors(); + if (errors.length) throw new Error(`Unrelated renderer errors: ${JSON.stringify(errors)}`); + const { stdout } = await obsidian.exec("dev:errors"); + if (stdout.trim() !== "No errors captured.") + throw new Error(`Obsidian runtime errors: ${stdout.trim()}`); +} + +export type UiTarget = Readonly<{ + name: string; + elements: string; +}>; + +const q = JSON.stringify; + +function byCss(name: string, selector: string, text?: string): UiTarget { + const filter = + text === undefined ? "" : `.filter((el) => el.textContent.trim() === ${q(text)})`; + return { name, elements: `[...document.querySelectorAll(${q(selector)})]${filter}` }; +} + +const PLAYER_VIEW = `.workspace-leaf-content[data-type="${VIEW_TYPE}"] .podcast-view`; + +export const ui = { + podcastGridTab: byCss("Podcast grid tab", `${PLAYER_VIEW} button[aria-label="Podcast grid"]`), + episodeListTab: byCss("Episode list tab", `${PLAYER_VIEW} button[aria-label="Episode list"]`), + playerTab: byCss("Player tab", `${PLAYER_VIEW} button[aria-label="Player"]`), + podcastCard: (title: string): UiTarget => ({ + name: `podcast card ${title}`, + elements: `[...document.querySelectorAll(${q(`${PLAYER_VIEW} .podcast-grid button`)})].filter((button) => (button.getAttribute("aria-label") ?? button.querySelector("img")?.alt ?? button.textContent.trim()) === ${q(title)})`, + }), + playlistCard: (name: string) => + byCss( + `playlist card ${name}`, + `${PLAYER_VIEW} button.playlist-card[aria-label=${q(name)}]`, + ), + episode: (title: string): UiTarget => ({ + name: `episode ${title}`, + elements: `[...document.querySelectorAll(${q(`${PLAYER_VIEW} .podcast-episode-item`)})].filter((el) => !el.closest(".episode-player") && el.querySelector(".episode-item-title")?.textContent.trim() === ${q(title)})`, + }), + backToLatestEpisodes: byCss( + "Latest Episodes back button", + `${PLAYER_VIEW} button.go-back`, + "Latest Episodes", + ), + episodeLoading: byCss("episode list loading", `${PLAYER_VIEW} .episode-list-loading`), + playbackToggle: byCss( + "on-screen playback toggle", + `${PLAYER_VIEW} .episode-player button[aria-label="Toggle playback"]`, + ), + menuItem: (title: string): UiTarget => ({ + name: `menu item ${title}`, + elements: `[...document.querySelectorAll(".menu .menu-item")].filter((el) => el.querySelector(".menu-item-title")?.textContent.trim() === ${q(title)})`, + }), + fileExplorerItem: (filePath: string) => + byCss(`file explorer item ${filePath}`, `.nav-file-title[data-path=${q(filePath)}]`), + leftDrawerToggle: byCss("left sidebar toggle", ".sidebar-toggle-button.mod-left"), + ribbonAction: (label: string) => + byCss(`ribbon action ${label}`, `.side-dock-ribbon-action[aria-label=${q(label)}]`), + readingLink: (text: string) => + byCss( + `Reading view link ${text}`, + ".workspace-leaf.mod-active .markdown-reading-view a", + text, + ), + settingsTab: (name: string) => + byCss(`settings tab ${name}`, ".modal.mod-settings .vertical-tab-nav-item", name), + settingControl: (settingName: string, control: string): UiTarget => ({ + name: `${control} of setting ${settingName}`, + elements: `[...document.querySelectorAll(".modal.mod-settings .setting-item")].filter((row) => row.querySelector(".setting-item-name")?.textContent.trim() === ${q(settingName)}).map((row) => row.querySelector(${q(control)})).filter(Boolean)`, + }), + podcastSearch: byCss( + "podcast search", + '.modal.mod-settings input[placeholder="Search or enter feed URL..."]', + ), + addPodcast: (title: string) => + byCss( + `Add ${title}`, + `.modal.mod-settings button[aria-label=${q(`Add ${title} podcast`)}]`, + ), + podcastViewport: byCss("PodNotes view", PLAYER_VIEW), +} as const; + +type Located = + | { state: "ready"; x: number; y: number } + | { state: "absent" | "hidden" | "ambiguous" | "covered"; detail: string }; + +async function locate(obsidian: ObsidianClient, target: UiTarget): Promise { + return await obsidian.dev.evalJson(`(() => { + const all = (${target.elements}); + const visible = all.filter((el) => el && el.getClientRects().length > 0 && getComputedStyle(el).visibility !== "hidden"); + if (visible.length === 0) return { state: all.length ? "hidden" : "absent", detail: String(all.length) }; + if (visible.length > 1) return { state: "ambiguous", detail: String(visible.length) }; + const el = visible[0]; + el.scrollIntoView({ block: "nearest", inline: "nearest" }); + const rect = el.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const hit = document.elementFromPoint(x, y); + if (!hit || !el.contains(hit)) return { state: "covered", detail: hit ? hit.outerHTML.slice(0, 120) : "nothing" }; + return { state: "ready", x, y }; + })()`); +} + +async function settledPoint( + obsidian: ObsidianClient, + target: UiTarget, + timeoutMs = 10_000, +): Promise<{ x: number; y: number }> { + let previous = ""; + let last = ""; + return await obsidian + .waitFor( + async () => { + const sample = await locate(obsidian, target); + const key = JSON.stringify(sample); + const settled = sample.state === "ready" && key === last; + previous = last; + last = key; + return settled ? sample : false; + }, + { timeoutMs, intervalMs: 100, message: `${target.name} visible and still` }, + ) + .catch((error: Error) => { + error.message += ` Last samples: ${previous}, ${last}.`; + throw error; + }); +} + +async function cdp(obsidian: ObsidianClient, method: string, params: object): Promise { + const { stdout } = await obsidian.exec("dev:cdp", { method, params: JSON.stringify(params) }); + const output = stdout.trim(); + if (output.startsWith("Error")) throw new Error(`${method}: ${output}`); + return output ? JSON.parse(output) : undefined; +} + +export async function click( + obsidian: ObsidianClient, + target: UiTarget, + options: { button?: "left" | "right"; timeoutMs?: number } = {}, +): Promise { + const { x, y } = await settledPoint(obsidian, target, options.timeoutMs); + const button = options.button ?? "left"; + await cdp(obsidian, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y }); + for (const type of ["mousePressed", "mouseReleased"]) { + await cdp(obsidian, "Input.dispatchMouseEvent", { type, x, y, button, clickCount: 1 }); + } +} + +export async function typeInto( + obsidian: ObsidianClient, + target: UiTarget, + text: string, + options: { replace?: boolean } = {}, +): Promise { + await click(obsidian, target); + if (options.replace) { + const selectAll = { + key: "a", + windowsVirtualKeyCode: 65, + modifiers: process.platform === "darwin" ? 4 : 2, + }; + await cdp(obsidian, "Input.dispatchKeyEvent", { + type: "keyDown", + ...selectAll, + ...(process.platform === "darwin" ? { commands: ["selectAll"] } : {}), + }); + await cdp(obsidian, "Input.dispatchKeyEvent", { type: "keyUp", ...selectAll }); + } + await cdp(obsidian, "Input.insertText", { text }); +} + +export async function isShown(obsidian: ObsidianClient, target: UiTarget): Promise { + return ( + (await obsidian.dev.evalJson( + `(${target.elements}).filter((el) => el && el.getClientRects().length > 0).length`, + )) > 0 + ); +} + +export async function waitUntilShown( + obsidian: ObsidianClient, + target: UiTarget, + timeoutMs = 15_000, +): Promise { + await obsidian.waitFor(() => isShown(obsidian, target), { + timeoutMs, + intervalMs: 200, + message: `${target.name} shown`, + }); +} + +export async function listedEpisodes(obsidian: ObsidianClient): Promise { + return await obsidian.dev.evalJson( + `[...document.querySelectorAll(${q(`${PLAYER_VIEW} .podcast-episode-item`)})].filter((el) => !el.closest(".episode-player") && el.getClientRects().length > 0).map((el) => el.querySelector(".episode-item-title")?.textContent.trim() ?? "")`, + ); +} + +export async function shownText(obsidian: ObsidianClient): Promise { + return await obsidian.dev.evalJson( + `document.querySelector(${q(PLAYER_VIEW)})?.innerText ?? ""`, + ); +} + +export async function notices(obsidian: ObsidianClient): Promise { + return await obsidian.dev.evalJson( + `[...document.querySelectorAll(".notice")].map((notice) => notice.textContent.trim())`, + ); +} + +export async function dismissNotices(obsidian: ObsidianClient): Promise { + await obsidian.dev.evalJson( + `document.querySelectorAll(".notice-container .notice").forEach((notice) => notice.remove()), true`, + ); +} + +export async function runCommand(obsidian: ObsidianClient, id: string): Promise { + const { stdout } = await obsidian.exec("command", { id }); + if (/^Error/m.test(stdout)) throw new Error(`command ${id}: ${stdout.trim()}`); +} + +export async function showPodNotes(obsidian: ObsidianClient): Promise { + await runCommand(obsidian, `${PLUGIN_ID}:podnotes-show-leaf`); + await waitUntilShown(obsidian, ui.podcastViewport); +} + +export async function closePodNotes(obsidian: ObsidianClient): Promise { + await obsidian.dev.evalJsonAsync(`(async () => { + await app.workspace.detachLeavesOfType(${q(VIEW_TYPE)}); + return true; + })()`); +} + +export async function openPodNotesSettings(obsidian: ObsidianClient): Promise { + await runCommand(obsidian, "app:open-settings"); + await click(obsidian, ui.settingsTab("PodNotes")); + await waitUntilShown(obsidian, ui.podcastSearch); +} + +export async function closeSettings(obsidian: ObsidianClient): Promise { + await obsidian.dev.evalJson("app.setting.close(), true"); +} + +/** + * Clicks `opener`, which must open a file chooser, and answers it with + * `filePath` from disk through the real ``. Electron + * ignores Chromium's chooser interception and opens a native dialog that + * stays modal and blocks obsidian:// links until the app quits, so only the + * dialog itself is held back while the test holds the chooser. + */ +export async function chooseFile( + obsidian: ObsidianClient, + opener: UiTarget, + filePath: string, +): Promise { + await obsidian.dev.evalJson(`(() => { + if (window.__podnotesJourneyChooser) throw new Error("A file chooser is already held."); + const prototype = HTMLInputElement.prototype; + const own = Object.getOwnPropertyDescriptor(prototype, "click"); + const click = prototype.click; + const held = []; + prototype.click = function () { + if (this.type === "file") return void held.push(this); + return click.call(this); + }; + window.__podnotesJourneyChooser = { own, held }; + return true; + })()`); + try { + await click(obsidian, opener); + const input = await obsidian.waitFor( + async () => { + const evaluated = (await cdp(obsidian, "Runtime.evaluate", { + expression: "window.__podnotesJourneyChooser.held.at(-1) ?? null", + })) as { result: { objectId?: string } }; + return evaluated.result.objectId ?? false; + }, + { timeoutMs: 5_000, intervalMs: 100, message: "the file chooser to open" }, + ); + await cdp(obsidian, "DOM.setFileInputFiles", { objectId: input, files: [filePath] }); + } finally { + await obsidian.dev.evalJson(`(() => { + const { own } = window.__podnotesJourneyChooser; + if (own) Object.defineProperty(HTMLInputElement.prototype, "click", own); + else delete HTMLInputElement.prototype.click; + delete window.__podnotesJourneyChooser; + return true; + })()`); + } +} + +export async function openInReadingView(obsidian: ObsidianClient, notePath: string): Promise { + await obsidian.dev.evalJsonAsync(`(async () => { + const file = app.vault.getAbstractFileByPath(${q(notePath)}); + if (!file) throw new Error(${q(`${notePath} does not exist.`)}); + const leaf = app.workspace.getLeaf("tab"); + await leaf.openFile(file, { active: true, state: { mode: "preview" } }); + app.workspace.setActiveLeaf(leaf, { focus: true }); + return true; + })()`); +} + +export async function revealInFileExplorer( + obsidian: ObsidianClient, + filePath: string, +): Promise { + await runCommand(obsidian, "file-explorer:open"); + await obsidian.dev.evalJsonAsync(`(async () => { + const file = app.vault.getAbstractFileByPath(${q(filePath)}); + const leaf = app.workspace.getLeavesOfType("file-explorer")[0]; + if (!file || !leaf) throw new Error("The file explorer or the file is missing."); + await app.workspace.revealLeaf(leaf); + leaf.view.revealInFolder(file); + return true; + })()`); + await waitUntilShown(obsidian, ui.fileExplorerItem(filePath)); +} + +export type AppProcess = Readonly<{ mainPid: number; rendererPid: number }>; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +async function isolatedInstance() { + const config = await loadRunnerConfig(REPO_ROOT); + return resolveInstanceOptions( + resolveProvisionOptions({}, config, REPO_ROOT), + {}, + config, + REPO_ROOT, + ); +} + +async function appProcess(obsidian: ObsidianClient): Promise { + const { userDataPath: dataDir } = await isolatedInstance(); + const { stdout } = await promisify(execFile)("ps", ["-eo", "pid=,args="]); + const mains = stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.includes(`--user-data-dir=${dataDir}`) && !line.includes("--type=")) + .map((line) => line.split(/\s+/)) + .filter(([, executable]) => /obsidian$/i.test(path.basename(executable ?? ""))); + if (mains.length !== 1) + throw new Error(`Expected one Obsidian main process, found ${mains.length}.`); + return { + mainPid: Number(mains[0][0]), + rendererPid: await obsidian.dev.evalJson("process.pid"), + }; +} + +/** + * Runner provisioning replaces the enabled-plugin list. Relaunch the same process + * directly so the startup recorder stays first. Linux needs a fresh private Xvfb + * display; macOS needs app.quit because closing its last window leaves it running. + */ +export async function restartObsidian( + obsidian: ObsidianClient, + whileClosed: () => Promise = async () => {}, +): Promise<{ before: AppProcess; after: AppProcess }> { + await assertNoRuntimeErrors(obsidian); + const before = await appProcess(obsidian); + const launch = await obsidian.dev.evalJson<{ exe: string; argv: string[] }>( + '({ exe: window.electron.remote.app.getPath("exe"), argv: window.electron.remote.process.argv })', + ); + const instance = await isolatedInstance(); + if (!launch.argv.includes(`--user-data-dir=${instance.userDataPath}`)) + throw new Error("Refusing to quit a different Obsidian profile."); + await obsidian.dev.evalJson( + process.platform === "darwin" + ? "setTimeout(() => window.electron.remote.app.quit(), 50), true" + : "setTimeout(() => electronWindow.close(), 50), true", + ); + await obsidian.waitFor(() => !processAlive(before.mainPid), { + timeoutMs: 30_000, + intervalMs: 200, + message: "Obsidian to quit", + }); + let failure: { error: unknown } | undefined; + try { + await whileClosed(); + } catch (error) { + failure = { error }; + } + try { + await relaunchIsolatedApp(launch); + await obsidian.waitFor( + () => + obsidian.dev + .evalJson( + "app.workspace.layoutReady && Boolean(app.plugins.plugins.podnotes?.api)", + ) + .catch(() => false), + { timeoutMs: 30_000, intervalMs: 200, message: "Obsidian to relaunch" }, + ); + await waitForPodNotesReady(obsidian); + await assertNoRuntimeErrors(obsidian); + await dismissNotices(obsidian); + } catch (error) { + if (failure) + throw new AggregateError( + [failure.error, error], + "Restart fixture and relaunch failed.", + ); + throw error; + } + if (failure) throw failure.error; + return { before, after: await appProcess(obsidian) }; +} + +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function relaunchIsolatedApp(launch: { exe: string; argv: string[] }): Promise { + const instance = await isolatedInstance(); + if (!launch.argv.includes(`--user-data-dir=${instance.userDataPath}`)) + throw new Error("Refusing to relaunch a different Obsidian profile."); + const env: NodeJS.ProcessEnv = { ...process.env, HOME: instance.obsidianHome }; + delete env.XDG_RUNTIME_DIR; + delete env.WAYLAND_DISPLAY; + delete env.WAYLAND_SOCKET; + if (process.platform === "linux") { + env.XDG_CONFIG_HOME = path.dirname(instance.userDataPath); + env.XDG_SESSION_TYPE = "x11"; + env.OBSIDIAN_E2E_CAPTURE_XVFB = "1"; + } + const log = fs.openSync( + path.join(instance.obsidianHome, "Library", "Logs", "obsidian.log"), + "a", + ); + try { + const child = + process.platform === "linux" + ? spawn( + "xvfb-run", + ["-a", "-s", "-screen 0 3840x3200x24", launch.exe, ...launch.argv], + { detached: true, stdio: ["ignore", log, log], env }, + ) + : spawn(launch.exe, launch.argv, { + detached: true, + stdio: ["ignore", log, log], + env, + }); + await once(child, "spawn"); + child.unref(); + } finally { + fs.closeSync(log); + } +} + +export async function reloadObsidian(obsidian: ObsidianClient): Promise { + await assertNoRuntimeErrors(obsidian); + await obsidian.dev.evalJson("window.__podnotesJourneyBeforeReload = true"); + await obsidian.exec("reload"); + await obsidian.waitFor( + async () => + await obsidian.dev + .evalJson( + "!window.__podnotesJourneyBeforeReload && app.workspace.layoutReady", + ) + .catch(() => false), + { timeoutMs: 30_000, intervalMs: 300, message: "Obsidian to reload" }, + ); + await waitForPodNotesReady(obsidian); + await assertNoRuntimeErrors(obsidian); + await dismissNotices(obsidian); +} + +/** Switches Obsidian's mobile emulation, which reloads the app, and waits for PodNotes. */ +export async function setMobileEmulation(obsidian: ObsidianClient, on: boolean): Promise { + await assertNoRuntimeErrors(obsidian); + await obsidian.exec("dev:mobile", on ? { on: true } : { off: true }); + await obsidian.waitFor( + async () => + await obsidian.dev + .evalJson(`app.isMobile === ${on} && app.workspace.layoutReady`) + .catch(() => false), + { timeoutMs: 30_000, intervalMs: 500, message: `mobile emulation ${on ? "on" : "off"}` }, + ); + await waitForPodNotesReady(obsidian); + await assertNoRuntimeErrors(obsidian); + await dismissNotices(obsidian); +} diff --git a/tests/e2e/journey/fixtures.ts b/tests/e2e/journey/fixtures.ts new file mode 100644 index 00000000..db72accb --- /dev/null +++ b/tests/e2e/journey/fixtures.ts @@ -0,0 +1,343 @@ +import dns from "node:dns/promises"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import type { ObsidianClient } from "obsidian-e2e"; + +export type FixtureEpisode = Readonly<{ + title: string; + seconds: number | null; + pubDate?: string; + omitTitle?: boolean; + description?: string; + guid?: string; +}>; + +export type FixtureFeed = Readonly<{ + title: string; + slug: string; + artwork: boolean; + episodes: readonly FixtureEpisode[]; +}>; + +export type FixtureServer = Readonly<{ + feedUrl(feed: FixtureFeed): string; + mediaUrl(feed: FixtureFeed, episode: FixtureEpisode): string; + artworkUrl(feed: FixtureFeed): string; + audio(feed: FixtureFeed, episode: FixtureEpisode): Buffer; + requests(): readonly string[]; + stop(): Promise; +}>; + +const HOST = "127.0.0.1.nip.io"; +const SAMPLE_RATE = 8000; + +export function pubDate(daysBefore: number): string { + return new Date(Date.UTC(2026, 9, 10, 9) - daysBefore * 86_400_000).toUTCString(); +} + +export async function startFixtureServer(feeds: readonly FixtureFeed[]): Promise { + await dns.lookup(HOST).catch((error: Error) => { + throw new Error( + `${HOST} must resolve to 127.0.0.1 for the fixture feeds: ${error.message}`, + ); + }); + + const requests: string[] = []; + const routes = new Map(); + const server = http.createServer((request, response) => { + const path = request.url ?? ""; + requests.push(path); + const route = routes.get(path); + if (!route) { + response.writeHead(404).end(); + return; + } + serveRange(request, response, route.type, route.body, route.cache === true); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const origin = `http://${HOST}:${(server.address() as AddressInfo).port}`; + + const feedUrl = (feed: FixtureFeed) => `${origin}/${feed.slug}/feed.xml`; + const artworkUrl = (feed: FixtureFeed) => `${origin}/${feed.slug}/artwork.svg`; + const mediaPath = (feed: FixtureFeed, episode: FixtureEpisode) => + `/${feed.slug}/${feed.episodes.indexOf(episode) + 1}.wav`; + const audio = (feed: FixtureFeed, episode: FixtureEpisode) => { + const route = routes.get(mediaPath(feed, episode)); + if (!route) throw new Error(`${feed.title} / ${episode.title} has no audio.`); + return route.body; + }; + + for (const feed of feeds) { + feed.episodes.forEach((episode, index) => { + if (episode.seconds !== null) { + const body = wav(episode.seconds, 300 + 40 * index + 7 * feed.slug.length); + routes.set(mediaPath(feed, episode), { type: "audio/wav", body }); + } + }); + if (feed.artwork) { + routes.set(`/${feed.slug}/artwork.svg`, { + type: "image/svg+xml", + body: Buffer.from(artworkSvg(feed.title)), + cache: true, + }); + } + routes.set(`/${feed.slug}/feed.xml`, { + type: "application/rss+xml", + body: Buffer.from( + rss(feed, feed.artwork ? artworkUrl(feed) : null, (episode) => + episode.seconds === null + ? null + : { + url: `${origin}${mediaPath(feed, episode)}`, + bytes: audio(feed, episode).length, + }, + ), + ), + }); + } + + return { + feedUrl, + artworkUrl, + mediaUrl: (feed, episode) => `${origin}${mediaPath(feed, episode)}`, + audio, + requests: () => [...requests], + stop: () => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), + }; +} + +export function opmlDocument(entries: ReadonlyArray<{ title: string; url: string }>): string { + const outlines = entries + .map( + ({ title, url }) => + `\t\t`, + ) + .join("\n"); + return ` + + Journey subscriptions + +${outlines} + + +`; +} + +export function wav(seconds: number, frequency = 440): Buffer { + const samples = SAMPLE_RATE * seconds; + const buffer = Buffer.alloc(44 + samples * 2); + buffer.write("RIFF", 0); + buffer.writeUInt32LE(36 + samples * 2, 4); + buffer.write("WAVEfmt ", 8); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(1, 22); + buffer.writeUInt32LE(SAMPLE_RATE, 24); + buffer.writeUInt32LE(SAMPLE_RATE * 2, 28); + buffer.writeUInt16LE(2, 32); + buffer.writeUInt16LE(16, 34); + buffer.write("data", 36); + buffer.writeUInt32LE(samples * 2, 40); + for (let i = 0; i < samples; i++) { + buffer.writeInt16LE( + Math.round(3000 * Math.sin((2 * Math.PI * frequency * i) / SAMPLE_RATE)), + 44 + i * 2, + ); + } + return buffer; +} + +function serveRange( + request: http.IncomingMessage, + response: http.ServerResponse, + type: string, + body: Buffer, + cache: boolean, +): void { + const headers = { + "Content-Type": type, + "Accept-Ranges": "bytes", + ...(cache ? { "Cache-Control": "public, max-age=86400" } : { "Cache-Control": "no-store" }), + }; + const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.range ?? ""); + if (!range) { + response.writeHead(200, { ...headers, "Content-Length": body.length }); + response.end(body); + return; + } + const start = range[1] ? Number(range[1]) : body.length - Number(range[2]); + const end = + range[1] && range[2] ? Math.min(Number(range[2]), body.length - 1) : body.length - 1; + response.writeHead(206, { + ...headers, + "Content-Length": end - start + 1, + "Content-Range": `bytes ${start}-${end}/${body.length}`, + }); + response.end(body.subarray(start, end + 1)); +} + +function rss( + feed: FixtureFeed, + artworkUrl: string | null, + enclosure: (episode: FixtureEpisode) => { url: string; bytes: number } | null, +): string { + const items = feed.episodes.map((episode) => { + const media = enclosure(episode); + return [ + "\t\t", + episode.omitTitle ? "" : `\t\t\t${escapeXml(episode.title)}`, + episode.pubDate ? `\t\t\t${episode.pubDate}` : "", + episode.guid ? `\t\t\t${escapeXml(episode.guid)}` : "", + episode.description + ? `\t\t\t${escapeXml(episode.description)}` + : "", + media + ? `\t\t\t` + : "", + "\t\t", + ] + .filter(Boolean) + .join("\n"); + }); + return ` + + + ${escapeXml(feed.title)} + Journey fixture feed ${escapeXml(feed.title)}. +${artworkUrl ? `\t\t\n` : ""}${items.join("\n")} + + +`; +} + +function artworkSvg(title: string): string { + return ` + + ${escapeXml(title)} +`; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export type ItunesResult = Readonly<{ + collectionName: string; + feedUrl: string; + artworkUrl100: string; + collectionId: string; +}>; + +/** + * Answer iTunes search requests, and only those, with `results`. Obsidian's + * requestUrl goes to the main process over the `request-url` IPC channel, so + * the stub sits there; every other request passes through untouched. + */ +export async function installItunesStub( + obsidian: ObsidianClient, + results: readonly ItunesResult[], +): Promise { + await obsidian.dev.evalJsonAsync(`(async () => { + if (window.__podnotesJourneyItunes) throw new Error("The iTunes stub is already installed."); + const ipc = window.electron.ipcRenderer; + const originalSend = ipc.send; + const terms = []; + ipc.send = function (channel, replyChannel, request, ...rest) { + if (channel === "request-url" && typeof request?.url === "string" && request.url.startsWith("https://itunes.apple.com/search?")) { + terms.push(new URL(request.url).searchParams.get("term")); + const body = new TextEncoder().encode(JSON.stringify({ resultCount: ${results.length}, results: ${JSON.stringify(results)} })).buffer; + queueMicrotask(() => ipc.emit(replyChannel, {}, { status: 200, headers: { "content-type": "application/json" }, body })); + return; + } + return originalSend.call(ipc, channel, replyChannel, request, ...rest); + }; + window.__podnotesJourneyItunes = { ipc, originalSend, terms }; + return true; + })()`); +} + +export async function itunesSearchTerms(obsidian: ObsidianClient): Promise { + return await obsidian.dev.evalJson("window.__podnotesJourneyItunes.terms"); +} + +export async function removeItunesStub(obsidian: ObsidianClient): Promise { + await obsidian.dev.evalJson(`(() => { + const stub = window.__podnotesJourneyItunes; + if (stub) stub.ipc.send = stub.originalSend; + delete window.__podnotesJourneyItunes; + return true; + })()`); +} + +export type TranscriptionUpload = Readonly<{ + method: string; + authorization: string | null; + model: string | null; + fileName: string | null; + fileBytes: number; + riffWave: boolean; + sha256: string; +}>; + +const OPENAI_TRANSCRIPTIONS_URL = "https://api.openai.com/v1/audio/transcriptions"; + +export async function installOpenAIStub(obsidian: ObsidianClient, text: string): Promise { + await obsidian.dev.evalJsonAsync(`(async () => { + if (window.__podnotesJourneyOpenAI) throw new Error("The OpenAI stub is already installed."); + const originalFetch = window.fetch; + const uploads = []; + window.fetch = async function (input, init) { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url !== ${JSON.stringify(OPENAI_TRANSCRIPTIONS_URL)}) return originalFetch.call(this, input, init); + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + if (method !== "POST") throw new TypeError("The transcription upload must use POST."); + const body = init?.body; + const file = body instanceof FormData ? body.get("file") : null; + const bytes = file instanceof Blob ? new Uint8Array(await file.arrayBuffer()) : new Uint8Array(); + const ascii = (from, to) => String.fromCharCode(...bytes.slice(from, to)); + uploads.push({ + method, + authorization: new Headers(init?.headers).get("authorization"), + model: body instanceof FormData ? body.get("model") : null, + fileName: file instanceof File ? file.name : null, + fileBytes: bytes.byteLength, + riffWave: ascii(0, 4) === "RIFF" && ascii(8, 12) === "WAVE", + sha256: require("node:crypto").createHash("sha256").update(bytes).digest("hex"), + }); + return new Response(JSON.stringify({ text: ${JSON.stringify(text)} }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + window.__podnotesJourneyOpenAI = { originalFetch, uploads }; + return true; + })()`); +} + +export async function transcriptionUploads( + obsidian: ObsidianClient, +): Promise { + return await obsidian.dev.evalJson( + "window.__podnotesJourneyOpenAI.uploads", + ); +} + +export async function removeOpenAIStub(obsidian: ObsidianClient): Promise { + await obsidian.dev.evalJson(`(() => { + const stub = window.__podnotesJourneyOpenAI; + if (stub) window.fetch = stub.originalFetch; + delete window.__podnotesJourneyOpenAI; + return true; + })()`); +} diff --git a/tests/e2e/journey/library.ts b/tests/e2e/journey/library.ts new file mode 100644 index 00000000..54b8fa46 --- /dev/null +++ b/tests/e2e/journey/library.ts @@ -0,0 +1,489 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ObsidianClient, PluginHandle, SandboxApi } from "obsidian-e2e"; +import e2eConfig from "../../../obsidian-e2e.config.mjs"; +import { PLUGIN_ID, RELOAD_OPTIONS, VIEW_TYPE } from "../harness"; +import { dismissNotices, setMobileEmulation } from "./actions"; +import type { FixtureEpisode, FixtureFeed, FixtureServer } from "./fixtures"; + +export type EpisodeRef = Readonly<{ podcast: string; title: string }>; + +export type PlaybackObservation = Readonly<{ + episode: EpisodeRef | null; + playing: boolean; + seconds: number; + duration: number | null; + media: Readonly<{ src: string; paused: boolean; seconds: number }> | null; +}>; + +export type SavedProgress = Readonly<{ seconds: number; duration: number; finished: boolean }>; + +export type SavedLibrary = Readonly<{ + subscriptions: ReadonlyArray>; + currentEpisode: EpisodeRef | null; + queue: readonly string[]; + playlists: Readonly>; + localFiles: ReadonlyArray>; + downloads: ReadonlyArray>; + skipBackwardSeconds: number; + progress(episode: EpisodeRef): SavedProgress | undefined; +}>; + +export type StoredEpisode = Readonly< + Record & { title: string; podcastName: string } +>; + +export type LibrarySeed = Readonly<{ + subscriptions?: readonly FixtureFeed[]; + current?: StoredEpisode; + queue?: readonly StoredEpisode[]; + playlists?: ReadonlyArray<{ + name: string; + removeAfterPlay: boolean; + episodes: readonly StoredEpisode[]; + }>; + progress?: ReadonlyArray<{ + episode: StoredEpisode; + seconds: number; + duration: number; + finished: boolean; + }>; + openAISecretId?: string; +}>; + +type JourneyContext = Readonly<{ + obsidian: ObsidianClient; + plugin: PluginHandle; + sandbox: SandboxApi; +}>; +type Json = Record; + +const FEED_CACHE_KEY = "podnotes:feed-cache:v5"; + +type JourneySnapshot = Readonly<{ + cache: unknown; + layout: Record; + recentFiles: readonly string[]; + mobile: boolean; + size: [number, number]; + workspaceFiles: ReadonlyArray<{ path: string; bytes: Buffer | null }>; +}>; + +const snapshots = new WeakMap(); + +type ErrorRecorder = Readonly<{ + id: string; + directory: string; + configPath: string; + configBytes: Buffer | null; + enabled: readonly string[]; +}>; + +const recorders = new WeakMap(); + +export async function installJourneyErrorRecorder(obsidian: ObsidianClient): Promise { + const vault = await obsidian.vaultPath(); + const id = `podnotes-e2e-errors-${process.pid}`; + const directory = path.join(vault, ".obsidian", "plugins", id); + const configPath = path.join(vault, ".obsidian", "community-plugins.json"); + const configBytes = await fs.readFile(configPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + const enabled = await obsidian.dev.evalJson("[...app.plugins.enabledPlugins]"); + await fs.mkdir(directory); + recorders.set(obsidian, { id, directory, configPath, configBytes, enabled }); + await fs.writeFile( + path.join(directory, "manifest.json"), + JSON.stringify({ + id, + name: "PodNotes E2E error recorder", + version: "1.0.0", + minAppVersion: "1.13.0", + description: "Capture uncaught errors before PodNotes starts.", + author: "PodNotes tests", + }), + ); + // Obsidian initializes plugins before its CLI error buffer. Load this recorder first. + await fs.writeFile( + path.join(directory, "main.js"), + ` + module.exports = class extends require("obsidian").Plugin { + onload() { + window.__podnotesJourneyEarlyErrors = []; + window.__podnotesJourneyRecorderBeforePodNotes = !this.app.plugins.plugins.podnotes?.api; + this.registerDomEvent(window, "error", (event) => window.__podnotesJourneyEarlyErrors.push({ source: "error", message: event.message })); + this.registerDomEvent(window, "unhandledrejection", (event) => window.__podnotesJourneyEarlyErrors.push({ source: "unhandledrejection", message: String(event.reason) })); + } + onunload() { + delete window.__podnotesJourneyEarlyErrors; + delete window.__podnotesJourneyRecorderBeforePodNotes; + } + }; + `, + ); + await obsidian.dev.evalJsonAsync(`(async () => { + await app.plugins.loadManifests(); + await app.plugins.enablePlugin(${JSON.stringify(id)}); + app.plugins.enabledPlugins = new Set(${JSON.stringify([id, ...enabled])}); + await app.plugins.saveConfig(); + return true; + })()`); +} + +export async function removeJourneyErrorRecorder(obsidian: ObsidianClient): Promise { + const recorder = recorders.get(obsidian); + if (!recorder) return; + await obsidian.dev.evalJsonAsync(`(async () => { + await app.plugins.disablePlugin(${JSON.stringify(recorder.id)}); + app.plugins.enabledPlugins = new Set(${JSON.stringify(recorder.enabled)}); + app.plugins.requestSaveConfig.cancel(); + await app.plugins.saveConfig(); + app.plugins.requestSaveConfig.cancel(); + await app.vault.adapter.promise; + return true; + })()`); + if (recorder.configBytes === null) await fs.rm(recorder.configPath, { force: true }); + else await fs.writeFile(recorder.configPath, recorder.configBytes); + await fs.rm(recorder.directory, { recursive: true }); + await obsidian.dev.evalJsonAsync( + "(async () => { await app.plugins.loadManifests(); return true; })()", + ); + recorders.delete(obsidian); +} +export async function snapshotJourneyState(obsidian: ObsidianClient): Promise { + if (snapshots.has(obsidian)) throw new Error("Journey state was not restored."); + const runtime = await obsidian.dev.evalJson>(`({ + cache: app.loadLocalStorage(${JSON.stringify(FEED_CACHE_KEY)}), + layout: app.workspace.getLayout(), + recentFiles: app.workspace.recentFileTracker.serialize(), + mobile: app.isMobile, + size: electronWindow.getSize(), + })`); + const vault = await obsidian.vaultPath(); + const workspaceFiles = await Promise.all( + ["workspace.json", "workspace-mobile.json"].map(async (name) => { + const filePath = path.join(vault, ".obsidian", name); + const bytes = await fs.readFile(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + return { path: filePath, bytes }; + }), + ); + snapshots.set(obsidian, { ...runtime, workspaceFiles }); +} + +export function hasJourneySnapshot(obsidian: ObsidianClient): boolean { + return snapshots.has(obsidian); +} + +export async function restoreJourneyCache(obsidian: ObsidianClient): Promise { + const snapshot = snapshots.get(obsidian); + if (!snapshot) return; + await obsidian.dev.evalJsonAsync(`(async () => { + await app.plugins.disablePluginAndSave(${JSON.stringify(PLUGIN_ID)}); + await app.plugins.saveConfig(); + return true; + })()`); + await obsidian.dev.evalJson("window.__podnotesJourneyCacheReload = true"); + await obsidian.exec("reload"); + await obsidian.waitFor( + () => + obsidian.dev + .evalJson( + "!window.__podnotesJourneyCacheReload && app.workspace.layoutReady && !app.plugins.plugins.podnotes", + ) + .catch(() => false), + { timeoutMs: 30_000, intervalMs: 200, message: "the disabled plugin's renderer to reload" }, + ); + await obsidian.dev.evalJson( + `app.saveLocalStorage(${JSON.stringify(FEED_CACHE_KEY)}, ${JSON.stringify(snapshot.cache)}), true`, + ); +} + +export async function restoreJourneyWorkspace(obsidian: ObsidianClient): Promise { + const snapshot = snapshots.get(obsidian); + if (!snapshot) return; + const failures: unknown[] = []; + try { + const mobile = await obsidian.dev.evalJson("app.isMobile"); + if (mobile !== snapshot.mobile) await setMobileEmulation(obsidian, snapshot.mobile); + await obsidian.dev.evalJsonAsync(`(async () => { + await app.workspace.changeLayout(${JSON.stringify(snapshot.layout)}); + app.workspace.recentFileTracker.load(${JSON.stringify(snapshot.recentFiles)}); + electronWindow.setSize(${snapshot.size[0]}, ${snapshot.size[1]}); + app.workspace.requestSaveLayout.cancel(); + await app.workspace.saveLayout(); + app.workspace.requestSaveLayout.cancel(); + await app.vault.adapter.promise; + return true; + })()`); + } catch (error) { + failures.push(error); + } + const restored = await Promise.allSettled( + snapshot.workspaceFiles.map(async (file) => { + if (file.bytes === null) await fs.rm(file.path, { force: true }); + else await fs.writeFile(file.path, file.bytes); + }), + ); + snapshots.delete(obsidian); + for (const result of restored) { + if (result.status === "rejected") failures.push(result.reason); + } + if (failures.length) throw new AggregateError(failures, "Workspace could not be restored."); +} + +export function storedEpisode( + server: FixtureServer, + feed: FixtureFeed, + episode: FixtureEpisode, +): StoredEpisode { + return { + title: episode.title, + podcastName: feed.title, + streamUrl: server.mediaUrl(feed, episode), + url: "", + feedUrl: server.feedUrl(feed), + description: episode.description ?? "", + content: "", + artworkUrl: feed.artwork ? server.artworkUrl(feed) : "", + episodeDate: episode.pubDate ? new Date(episode.pubDate).toISOString() : "", + mediaType: "audio", + }; +} + +export function episodeRef(feed: FixtureFeed, episode: FixtureEpisode): EpisodeRef { + return { podcast: feed.title, title: episode.title }; +} + +export async function seedLibrary( + { obsidian, plugin, sandbox }: JourneyContext, + server: FixtureServer | null, + seed: LibrarySeed = {}, +): Promise { + await snapshotJourneyState(obsidian); + await obsidian.dev.evalJson( + `app.saveLocalStorage(${JSON.stringify(FEED_CACHE_KEY)}, null), true`, + ); + await obsidian.dev.evalJsonAsync(`(async () => { + await app.workspace.detachLeavesOfType(${JSON.stringify(VIEW_TYPE)}); + return true; + })()`); + + await plugin.updateDataAndReload((data) => { + for (const key of Object.keys(data)) delete data[key]; + Object.assign(data, structuredClone(e2eConfig.defaultData)); + data.note.path = sandbox.path("PodNotes/{{podcast}}/{{title}}.md"); + data.feedNote.path = sandbox.path("PodNotes/Podcasts/{{podcast}}.md"); + data.download.path = sandbox.path("Downloads/{{podcast}}/{{title}}"); + data.transcript.path = sandbox.path("Transcripts/{{podcast}}/{{title}}.md"); + data.savedFeeds = Object.fromEntries( + (seed.subscriptions ?? []).map((feed) => { + if (!server) throw new Error("Subscriptions need a fixture server."); + return [ + feed.title, + { + title: feed.title, + url: server.feedUrl(feed), + artworkUrl: feed.artwork ? server.artworkUrl(feed) : "", + }, + ]; + }), + ); + if (seed.current) data.currentEpisode = seed.current; + data.queue.episodes = seed.queue ?? []; + data.playlists = Object.fromEntries( + (seed.playlists ?? []).map((playlist) => [ + playlist.name, + { + icon: "list-music", + name: playlist.name, + episodes: playlist.episodes, + shouldEpisodeRemoveAfterPlay: playlist.removeAfterPlay, + shouldRepeat: false, + }, + ]), + ); + data.playedEpisodes = Object.fromEntries( + (seed.progress ?? []).map(({ episode, seconds, duration, finished }) => [ + `${episode.podcastName}::${episode.title}`, + { + title: episode.title, + podcastName: episode.podcastName, + time: seconds, + duration, + finished, + }, + ]), + ); + if (seed.openAISecretId) data.openAISecretId = seed.openAISecretId; + }, RELOAD_OPTIONS); + await dismissNotices(obsidian); +} + +export async function feedCacheHolds( + obsidian: ObsidianClient, + episodeTitle: string, +): Promise { + const cached = await obsidian.dev.evalJson( + `app.loadLocalStorage(${JSON.stringify(FEED_CACHE_KEY)})`, + ); + return cached?.includes(JSON.stringify(episodeTitle)) ?? false; +} + +function decodeLibrary(data: Json): SavedLibrary { + if (data.schemaVersion !== 2) { + throw new Error( + `data.json schema ${String(data.schemaVersion)} is not the v2 these journeys read.`, + ); + } + const titles = (episodes: Json[] | undefined) => + (episodes ?? []).map((episode) => String(episode.title)); + const played = Object.values(data.playedEpisodes ?? {}); + return { + subscriptions: Object.values(data.savedFeeds ?? {}).map((feed) => ({ + title: String(feed.title), + url: String(feed.url), + })), + currentEpisode: data.currentEpisode + ? { + podcast: String(data.currentEpisode.podcastName), + title: String(data.currentEpisode.title), + } + : null, + queue: titles(data.queue?.episodes), + playlists: Object.fromEntries( + Object.values(data.playlists ?? {}).map((playlist) => [ + String(playlist.name), + titles(playlist.episodes), + ]), + ), + localFiles: (data.localFiles?.episodes ?? []).map((episode: Json) => ({ + title: String(episode.title), + filePath: String(episode.filePath), + })), + downloads: Object.entries(data.downloadedEpisodes ?? {}).flatMap( + ([podcast, episodes]) => + episodes.map((episode) => ({ + podcast, + title: String(episode.title), + filePath: String(episode.filePath), + })), + ), + skipBackwardSeconds: Number(data.skipBackwardLength), + progress: ({ podcast, title }) => { + const record = played.find( + (entry) => entry.podcastName === podcast && entry.title === title, + ); + return record + ? { + seconds: Number(record.time), + duration: Number(record.duration), + finished: record.finished === true, + } + : undefined; + }, + }; +} + +export async function readLibrary(plugin: PluginHandle): Promise { + return decodeLibrary(await plugin.data().read()); +} + +export async function waitForLibrary( + plugin: PluginHandle, + predicate: (library: SavedLibrary) => boolean, + message: string, + timeoutMs = 15_000, +): Promise { + let last: SavedLibrary | undefined; + try { + await plugin.waitForData((data) => predicate((last = decodeLibrary(data))), { + timeoutMs, + intervalMs: 200, + }); + } catch (error) { + throw new Error(`${message} (data.json never matched): ${String(error)}`); + } + return last as SavedLibrary; +} + +export async function observePlayback(obsidian: ObsidianClient): Promise { + return await obsidian.dev.evalJson(`(() => { + const api = app.plugins.plugins.${PLUGIN_ID}.api; + const media = document.querySelector('.workspace-leaf-content[data-type="${VIEW_TYPE}"] .episode-player :is(audio, video)'); + const episode = api.podcast; + return { + episode: episode ? { podcast: episode.podcastName, title: episode.title } : null, + playing: api.isPlaying, + seconds: api.currentTime, + duration: Number.isFinite(api.length) ? api.length : null, + media: media ? { src: media.currentSrc, paused: media.paused, seconds: media.currentTime } : null, + }; + })()`); +} + +export async function waitForPlayback( + obsidian: ObsidianClient, + predicate: (playback: PlaybackObservation) => boolean, + message: string, + timeoutMs = 15_000, +): Promise { + let last: PlaybackObservation | undefined; + try { + return await obsidian.waitFor( + async () => { + last = await observePlayback(obsidian); + return predicate(last) ? last : false; + }, + { timeoutMs, intervalMs: 100 }, + ); + } catch (error) { + const cause = (error as { causeError?: unknown }).causeError; + throw new Error( + `${message}; last playback ${JSON.stringify(last)}: ${String(error)}${cause ? ` (last read failed: ${String(cause)})` : ""}`, + ); + } +} + +export async function playbackOver( + obsidian: ObsidianClient, + gapMs: number, +): Promise<[PlaybackObservation, PlaybackObservation]> { + const first = await observePlayback(obsidian); + await obsidian.sleep(gapMs); + return [first, await observePlayback(obsidian)]; +} + +export async function readVaultBytes(obsidian: ObsidianClient, filePath: string): Promise { + return await fs.readFile(path.join(await obsidian.vaultPath(), filePath)); +} + +export async function readVaultText(obsidian: ObsidianClient, filePath: string): Promise { + return (await readVaultBytes(obsidian, filePath)).toString("utf8"); +} + +export async function writeVaultFile( + obsidian: ObsidianClient, + filePath: string, + contents: Buffer | string, +): Promise { + const absolute = path.join(await obsidian.vaultPath(), filePath); + await fs.mkdir(path.dirname(absolute), { recursive: true }); + await fs.writeFile(absolute, contents); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `Boolean(app.vault.getAbstractFileByPath(${JSON.stringify(filePath)}))`, + ), + { timeoutMs: 10_000, intervalMs: 100, message: `${filePath} indexed by Obsidian` }, + ); +} + +export async function playerLeafCount(obsidian: ObsidianClient): Promise { + return await obsidian.dev.evalJson( + `app.workspace.getLeavesOfType(${JSON.stringify(VIEW_TYPE)}).length`, + ); +}