From fedef9d2b902e657135f0c5bf48105273df8ff5e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:00:06 +0200 Subject: [PATCH 1/2] test(feeds): reproduce offline requests and reconnect joining --- src/ui/PodcastView/PodcastView.shell.test.ts | 17 +-- tests/e2e/offline-feed-requests.test.ts | 139 +++++++++++++++++++ 2 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/offline-feed-requests.test.ts diff --git a/src/ui/PodcastView/PodcastView.shell.test.ts b/src/ui/PodcastView/PodcastView.shell.test.ts index e21802ec..99efd248 100644 --- a/src/ui/PodcastView/PodcastView.shell.test.ts +++ b/src/ui/PodcastView/PodcastView.shell.test.ts @@ -366,12 +366,12 @@ describe("offline", () => { expect(button("Refresh all feeds")).toBeEnabled(); }); - test("the feeds that could not load offline load when the connection returns", async () => { - vi.spyOn(console, "error").mockImplementation(() => {}); - const load = vi - .fn<(url: string) => Promise>() - .mockRejectedValueOnce(new Error("offline")) - .mockResolvedValue(feedDocument(feed.title, [episode])); + test("automatic loading follows the online store and reconnect starts a fresh request", async () => { + const load = vi.fn<(url: string) => Promise>(() => + get(services.online) + ? Promise.resolve(feedDocument(feed.title, [episode])) + : new Promise(() => {}), + ); dispose(); ({ services, doc, dispose } = createTestServices({ feeds: new FeedRepository({ @@ -385,14 +385,15 @@ describe("offline", () => { services.online.set(false); services.navigation.replace({ kind: "latest" }); renderView(320); - await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(1)); await tick(); + expect(navigator.onLine).toBe(true); + expect(load).not.toHaveBeenCalled(); expect(screen.getByText("Nothing here yet")).toBeInTheDocument(); services.online.set(true); expect(await screen.findByText(episode.title)).toBeInTheDocument(); - expect(load.mock.calls).toEqual([[feed.url], [feed.url]]); + expect(load.mock.calls).toEqual([[feed.url]]); }); test("First run has no strip", async () => { diff --git a/tests/e2e/offline-feed-requests.test.ts b/tests/e2e/offline-feed-requests.test.ts new file mode 100644 index 00000000..f4df6402 --- /dev/null +++ b/tests/e2e/offline-feed-requests.test.ts @@ -0,0 +1,139 @@ +import http from "node:http"; +import { resolve } from "node:path"; +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness } from "./harness"; +import { closePodNotes, setOffline, showList, showPodNotes } from "./journey/actions"; +import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; +import { click } from "./journey/input"; +import { ageFeedCache, seedLibrary } from "./journey/library"; +import { ui } from "./journey/targets"; +import { eventually, holdsFor, isDisabled, listedEpisodes, waitUntilShown } from "./journey/wait"; +import { jsLiteral, RELOAD_OPTIONS } from "./runtime"; + +const getContext = createPodNotesE2EHarness("offline-feed-requests"); + +describe("offline feed requests", () => { + test.each([false, true])("offline reopen and reconnect with held requests %s", async (hold) => { + const context = getContext(); + const { obsidian, plugin } = context; + const shows: FixtureFeed[] = ["Alpha", "Beta"].map((title, index) => ({ + title, + slug: `feed2-${title.toLowerCase()}`, + artwork: false, + episodes: [{ title: `${title} cached`, seconds: 6, pubDate: pubDate(index) }], + })); + const server = await startFixtureServer(shows); + const urls = shows.map((show) => server.feedUrl(show)); + const port = Number(new URL(urls[0]).port); + const bodies = new Map(); + for (const url of urls) { + const local = new URL(url); + local.hostname = "127.0.0.1"; + bodies.set( + local.pathname, + (await (await fetch(local)).text()).replaceAll("cached", "updated"), + ); + } + const recovered = http.createServer((request, response) => { + const body = bodies.get(request.url ?? ""); + response.writeHead(body ? 200 : 404, { "Content-Type": "application/rss+xml" }); + response.end(body); + }); + onTestFinished(async () => { + await closePodNotes(obsidian); + await obsidian.dev.evalJson(`(() => { + const probe = window.__feed2Requests; + if (probe) { + probe.ipc.send = probe.original; + for (const reply of probe.held) probe.ipc.emit(reply, {}, { status: 503, headers: {}, body: new ArrayBuffer(0) }); + } + delete window.__feed2Requests; + return true; + })()`); + await setOffline(obsidian, false); + recovered.closeAllConnections(); + await new Promise((done) => recovered.close(() => done())); + await server.stop(); + }); + await seedLibrary(context, server, { subscriptions: shows }); + await showPodNotes(obsidian); + await showList(obsidian, "Latest episodes"); + expect( + await eventually(obsidian, () => listedEpisodes(obsidian), [ + "Alpha cached", + "Beta cached", + ]), + ).toEqual(["Alpha cached", "Beta cached"]); + await closePodNotes(obsidian); + await ageFeedCache(obsidian, 7 * 60 * 60 * 1000); + await server.stop(); + await setOffline(obsidian, true); + await obsidian.dev.evalJson(`(() => { + const ipc = window.electron.ipcRenderer; + const original = ipc.send; + const urls = ${jsLiteral(urls)}; + const probe = { ipc, original, attempts: [], held: [] }; + ipc.send = function(channel, reply, request, ...rest) { + if (channel === "request-url" && urls.includes(request?.url)) { + probe.attempts.push({ url: request.url, online: navigator.onLine }); + if (${jsLiteral(hold)} && !navigator.onLine) { + probe.held.push(reply); + return; + } + } + return original.call(ipc, channel, reply, request, ...rest); + }; + window.__feed2Requests = probe; + return true; + })()`); + const attempts = () => + obsidian.dev.evalJson>( + "window.__feed2Requests.attempts", + ); + await plugin.reload(RELOAD_OPTIONS); + expect(await holdsFor(obsidian, attempts, [], 500)).toEqual([]); + if (hold) await click(obsidian, ui.ribbonAction("Show PodNotes")); + else await showPodNotes(obsidian); + await waitUntilShown(obsidian, ui.offlineStrip); + await showList(obsidian, "Latest episodes"); + expect(await listedEpisodes(obsidian)).toEqual(["Alpha cached", "Beta cached"]); + expect(await isDisabled(obsidian, ui.headAction("Refresh all feeds"))).toBe(true); + const offline = await holdsFor(obsidian, attempts, []); + console.log( + "FEED2 offline", + JSON.stringify({ + hold, + attempts: offline.length, + cachedRows: await listedEpisodes(obsidian), + }), + ); + expect.soft(offline).toEqual([]); + await obsidian.dev.screenshot( + resolve(".obsidian-e2e-artifacts/feed2", `offline-${hold}.png`), + ); + await new Promise((done) => recovered.listen(port, "127.0.0.1", done)); + await setOffline(obsidian, false); + const refreshed = () => + attempts().then((values) => + values + .filter(({ online }) => online) + .map(({ url }) => url) + .sort(), + ); + const online = await eventually(obsidian, refreshed, [...urls].sort()); + console.log("FEED2 reconnect", JSON.stringify({ hold, attempts: online.length })); + expect.soft(online).toEqual([...urls].sort()); + expect + .soft( + await eventually(obsidian, () => listedEpisodes(obsidian), [ + "Alpha updated", + "Beta updated", + ]), + ) + .toEqual(["Alpha updated", "Beta updated"]); + expect(await holdsFor(obsidian, refreshed, [...urls].sort())).toEqual([...urls].sort()); + await obsidian.dev.screenshot( + resolve(".obsidian-e2e-artifacts/feed2", `reconnected-${hold}.png`), + ); + }); +}); From 873e6889b98d0f1f5b2240b2098acc97830d8c8e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:06:40 +0200 Subject: [PATCH 2/2] fix(feeds): skip automatic feed requests while offline --- src/URIHandler.feeds.test.ts | 10 +- src/services/FeedRepository.test.ts | 95 ++++++++++++++----- src/services/FeedRepository.ts | 4 +- src/ui/PodcastView/EpisodesPage.test.ts | 2 +- src/ui/PodcastView/LibraryPage.test.ts | 2 +- .../PodcastView.integration.test.ts | 5 +- src/ui/PodcastView/PodcastView.shell.test.ts | 75 +++++++++++++-- src/ui/PodcastView/PodcastView.svelte | 6 +- tests/e2e/offline-feed-requests.test.ts | 11 +-- 9 files changed, 159 insertions(+), 51 deletions(-) diff --git a/src/URIHandler.feeds.test.ts b/src/URIHandler.feeds.test.ts index 11df8aa0..b0bdd74a 100644 --- a/src/URIHandler.feeds.test.ts +++ b/src/URIHandler.feeds.test.ts @@ -151,7 +151,7 @@ describe("podNotesURIHandler with saved feeds", () => { test("a link to the playing private episode by its secret URL seeks it without loading the feed", async () => { const { feeds } = follow(); load.mockResolvedValue(feedDocument("Patron Show", [first])); - await feeds.ensureAll(); + await feeds.ensureAll({ online: true }); const [subscribed] = get(feeds).get("Patron Show")?.episodes ?? []; player.play(subscribed); load.mockClear(); @@ -257,7 +257,7 @@ describe("podNotesURIHandler with saved feeds", () => { async function lostPrivateUrl() { const { feeds, feedUrls, patron } = follow(); load.mockResolvedValue(feedDocument("Patron Show", [first])); - await feeds.ensureAll(); + await feeds.ensureAll({ online: true }); feedUrls.delete(patron.urlSecretId ?? ""); load.mockClear(); } @@ -288,7 +288,7 @@ describe("podNotesURIHandler with saved feeds", () => { vi.spyOn(console, "error").mockImplementation(() => {}); const { feeds } = follow(); load.mockResolvedValue(feedDocument("Patron Show", [first])); - await feeds.ensureAll(); + await feeds.ensureAll({ online: true }); load.mockRejectedValue(new Error("offline")); await open(privateLink, second.title); @@ -365,7 +365,7 @@ describe("podNotesURIHandler with saved feeds", () => { async (_, url, title) => { const { feeds } = follow(); load.mockResolvedValue(feedDocument(title, [first])); - await feeds.ensureAll(); + await feeds.ensureAll({ online: true }); load.mockResolvedValue(feedDocument(title, [second, first])); await open(url, second.title); @@ -383,7 +383,7 @@ describe("podNotesURIHandler with saved feeds", () => { vi.spyOn(console, "error").mockImplementation(() => {}); const { feeds } = follow(); load.mockResolvedValue(feedDocument(title, [first])); - await feeds.ensureAll(); + await feeds.ensureAll({ online: true }); load.mockRejectedValue(new Error("offline")); await open(url, first.title); diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index 4d6c114d..0447e617 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -76,7 +76,7 @@ async function loaded( response: ParsedFeedEpisodes, ) { t.repo.setSubscriptions(feeds); - const loading = t.repo.ensureAll(); + const loading = t.repo.ensureAll({ online: true }); for (const request of t.requests) request.respond.resolve(response); await loading; } @@ -99,7 +99,7 @@ describe("FeedRepository", () => { "Alpha Alias": { ...alpha, title: "Alpha Alias" }, }); - const loading = t.repo.ensureAll(); + const loading = t.repo.ensureAll({ online: true }); expect(t.load).toHaveBeenCalledTimes(1); t.requests[0].respond.resolve(doc("Alpha Channel", [item(1)])); await loading; @@ -118,7 +118,7 @@ describe("FeedRepository", () => { const t = setup(); t.repo.setSubscriptions({ Alpha: alpha }); - const first = t.repo.ensureAll(); + const first = t.repo.ensureAll({ online: true }); const refresh = t.repo.load(["Alpha"], "network"); t.requests[0].respond.resolve(doc("Alpha", [item(1)])); await expect(Promise.all([first, refresh])).resolves.toEqual([true, true]); @@ -130,7 +130,7 @@ describe("FeedRepository", () => { test("refreshes keep the shown episodes until they answer, in whatever order they answer", async () => { const t = setup(); t.repo.setSubscriptions({ Alpha: alpha, Beta: beta }); - const first = t.repo.ensureAll(); + const first = t.repo.ensureAll({ online: true }); t.requests[0].respond.resolve(doc("Alpha", [item(1)])); t.requests[1].respond.resolve(doc("Beta", [item(2)])); await first; @@ -152,7 +152,7 @@ describe("FeedRepository", () => { await loaded(t, { Alpha: alpha }, doc("Alpha", [item(1)])); t.advance(7 * HOUR); - const refresh = t.repo.ensureAll(); + const refresh = t.repo.ensureAll({ online: true }); expect(t.load).toHaveBeenCalledTimes(2); expect(t.titles("Alpha")).toEqual(["Episode 1"]); t.requests[1].respond.reject(new Error("offline")); @@ -166,7 +166,7 @@ describe("FeedRepository", () => { test("a feed that never loaded reports the failure", async () => { const t = setup(); t.repo.setSubscriptions({ Alpha: alpha }); - const loading = t.repo.ensureAll(); + const loading = t.repo.ensureAll({ online: true }); t.requests[0].respond.reject(new Error("offline")); await expect(loading).resolves.toBe(false); @@ -176,7 +176,7 @@ describe("FeedRepository", () => { test("a response for a podcast removed while it loads is dropped and not cached", async () => { const t = setup(); t.repo.setSubscriptions({ Alpha: alpha, Beta: beta }); - const loading = t.repo.ensureAll(); + const loading = t.repo.ensureAll({ online: true }); t.requests[0].respond.resolve(doc("Alpha", [item(1)])); t.repo.setSubscriptions({ Alpha: alpha }); @@ -188,7 +188,7 @@ describe("FeedRepository", () => { t.repo.setSubscriptions({ Alpha: alpha, Beta: beta }); expect(t.titles("Beta")).toEqual([]); - void t.repo.ensureAll(); + void t.repo.ensureAll({ online: true }); expect(t.load).toHaveBeenCalledTimes(3); }); @@ -196,10 +196,10 @@ describe("FeedRepository", () => { const t = setup(); const moved = { ...alpha, url: "https://alpha.test/moved.xml" }; t.repo.setSubscriptions({ Alpha: alpha }); - const before = t.repo.ensureAll(); + const before = t.repo.ensureAll({ online: true }); t.repo.setSubscriptions({ Alpha: moved }); - const after = t.repo.ensureAll(); + const after = t.repo.ensureAll({ online: true }); t.requests[1].respond.resolve(doc("Alpha", [item(2)])); t.requests[0].respond.resolve(doc("Alpha", [item(1)])); await Promise.all([before, after]); @@ -218,7 +218,7 @@ describe("FeedRepository", () => { const t = setup({ storage: first.storage }); t.repo.setSubscriptions({ Alpha: alpha }); - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); expect(t.load).not.toHaveBeenCalled(); expect(t.titles("Alpha")).toHaveLength(75); @@ -269,7 +269,7 @@ describe("FeedRepository", () => { const t = setup({ storage: first.storage }); t.repo.setSubscriptions({ Alpha: alpha }); - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); expect(t.feed("Alpha")?.episodes).toEqual(first.feed("Alpha")?.episodes); expect(t.feed("Alpha")?.episodes[0].episodeDate).toEqual(episode.episodeDate); @@ -291,7 +291,7 @@ describe("FeedRepository", () => { const t = setup({ storage: first.storage }); t.repo.setSubscriptions({ Alpha: alpha }); - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); expect(t.load).not.toHaveBeenCalled(); expect(Object.keys(t.cached())).toEqual([alpha.url]); @@ -386,23 +386,68 @@ describe("FeedRepository", () => { const t = setup({ storage: seeded.storage }); t.advance(6 * HOUR); t.repo.setSubscriptions({ Alpha: alpha }); - void t.repo.ensureAll(); + void t.repo.ensureAll({ online: true }); expect(t.load).not.toHaveBeenCalled(); expect(t.feed("Alpha")).toMatchObject({ status: "ready", episodes: [expect.anything()] }); t.advance(1); - void t.repo.ensureAll(); + void t.repo.ensureAll({ online: true }); expect(t.load).toHaveBeenCalledTimes(1); expect(t.feed("Alpha")).toMatchObject({ status: "loading", episodes: [expect.anything()] }); }); + test("offline, an expired cache shows without a request", async () => { + const seeded = setup(); + await loaded(seeded, { Alpha: alpha }, doc("Alpha", [item(1)])); + + const t = setup({ storage: seeded.storage }); + t.advance(7 * HOUR); + t.repo.setSubscriptions({ Alpha: alpha }); + const loading = t.repo.ensureAll({ online: false }); + + expect(t.load).not.toHaveBeenCalled(); + await expect(loading).resolves.toBe(true); + expect(t.feed("Alpha")).toMatchObject({ status: "stale", error: undefined }); + expect(t.titles("Alpha")).toEqual(["Episode 1"]); + }); + + test("offline, a feed with no document waits idle without a request", async () => { + const t = setup(); + t.repo.setSubscriptions({ Alpha: alpha }); + const loading = t.repo.ensureAll({ online: false }); + + expect(t.load).not.toHaveBeenCalled(); + await expect(loading).resolves.toBe(true); + expect(t.feed("Alpha")).toMatchObject({ status: "idle", error: undefined, episodes: [] }); + }); + + test("reconnecting after an offline start fetches each URL once", async () => { + const t = setup(); + t.repo.setSubscriptions({ + Alpha: alpha, + "Alpha Alias": { ...alpha, title: "Alpha Alias" }, + Beta: beta, + }); + void t.repo.ensureAll({ online: false }); + expect(t.load).not.toHaveBeenCalled(); + + const reconnect = t.repo.ensureAll({ online: true }); + expect(t.requests.map(({ url }) => url)).toEqual([alpha.url, beta.url]); + t.requests[0].respond.resolve(doc("Alpha", [item(1)])); + t.requests[1].respond.resolve(doc("Beta", [item(2)])); + await expect(reconnect).resolves.toBe(true); + + expect(t.titles("Alpha Alias")).toEqual(["Episode 1"]); + expect(t.titles("Beta")).toEqual(["Episode 2"]); + }); + test("a private feed whose URL is not on this device is never fetched", async () => { const resolveUrl = vi.fn(() => null); const t = setup({ resolveUrl }); const placeholder = privateFeedPlaceholder("Private"); t.repo.setSubscriptions({ Private: { ...alpha, url: placeholder, urlSecretId: "id" } }); - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); expect(t.load).not.toHaveBeenCalled(); expect(t.feed("Private")).toMatchObject({ status: "error", error: "unavailable" }); @@ -416,7 +461,7 @@ describe("FeedRepository", () => { const resolveUrl = vi.fn(() => null); const t = setup({ storage: seeded.storage, resolveUrl }); t.repo.setSubscriptions({ Private: privateFeed }); - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); expect(resolveUrl).not.toHaveBeenCalled(); expect(t.titles("Private")).toEqual(["Episode 1"]); @@ -492,7 +537,7 @@ describe("FeedRepository", () => { const t = setup({ storage: first.storage }); t.repo.setSubscriptions({ Alpha: alpha }); - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); expect(t.load).not.toHaveBeenCalled(); const guids = new Map(t.feed("Alpha")!.episodes.map(({ title, guid }) => [title, guid])); @@ -550,7 +595,7 @@ describe("FeedRepository", () => { await loaded(t, { Alpha: alpha, Beta: beta }, doc("Show", [item(1)])); const episodes = t.feed("Alpha")?.episodes; - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); expect(t.feed("Alpha")?.episodes).toBe(episodes); t.repo.setSubscriptions({ Alpha: { ...alpha, title: "Alpha Renamed" }, Beta: beta }); @@ -572,7 +617,7 @@ describe("FeedRepository", () => { storage.saveLocalStorage(CACHE_KEY, stored); const t = setup({ storage }); t.repo.setSubscriptions({ Alpha: alpha }); - void t.repo.ensureAll(); + void t.repo.ensureAll({ online: true }); expect(t.load).toHaveBeenCalledTimes(1); } @@ -585,7 +630,7 @@ describe("FeedRepository", () => { ); const t = setup({ storage }); t.repo.setSubscriptions({ Alpha: alpha }); - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); expect(t.load).not.toHaveBeenCalled(); expect(t.feed("Alpha")?.episodes).toEqual([ @@ -626,7 +671,7 @@ describe("FeedRepository", () => { ); const t = setup({ storage }); t.repo.setSubscriptions({ Alpha: alpha }); - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); const [first, second] = t.feed("Alpha")!.episodes; expect(t.load).not.toHaveBeenCalled(); @@ -644,7 +689,7 @@ describe("FeedRepository", () => { const t = setup({ storage }); t.repo.setSubscriptions({}); - await t.repo.ensureAll(); + await t.repo.ensureAll({ online: true }); expect(storage.values.has("podnotes:feed-cache:v5")).toBe(false); expect([...legacyStorage.keys()]).toEqual([]); @@ -695,7 +740,7 @@ describe("FeedRepository", () => { const t = setup({ storage }); t.repo.setSubscriptions({ Alpha: alpha }); - void t.repo.ensureAll(); + void t.repo.ensureAll({ online: true }); expect(t.load).not.toHaveBeenCalled(); expect(t.titles("Alpha")).toEqual(["Episode 1"]); @@ -732,7 +777,7 @@ describe("FeedRepository", () => { test("a feed link for a subscribed feed shares its request", async () => { const t = setup(); t.repo.setSubscriptions({ Alpha: alpha }); - const loading = t.repo.ensureAll(); + const loading = t.repo.ensureAll({ online: true }); const episodes = t.repo.episodesAt(alpha.url); t.requests[0].respond.resolve(doc("Alpha Channel", [item(1)])); diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index 057f3ffe..e7852101 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -136,7 +136,9 @@ export class FeedRepository implements Readable> ); } - ensureAll(): Promise { + ensureAll({ online }: { online: boolean }): Promise { + this.hydrate(); + if (!online) return Promise.resolve(true); return this.load(this.subscriptions.keys(), "cached"); } diff --git a/src/ui/PodcastView/EpisodesPage.test.ts b/src/ui/PodcastView/EpisodesPage.test.ts index c392c29b..0bced951 100644 --- a/src/ui/PodcastView/EpisodesPage.test.ts +++ b/src/ui/PodcastView/EpisodesPage.test.ts @@ -256,7 +256,7 @@ describe("a podcast's page", () => { const answer = deferred(); load.mockReturnValueOnce(answer.promise); services.online.set(true); - void services.feeds.ensureAll(); + void services.feeds.ensureAll({ online: true }); await tick(); expect(screen.queryByRole("alert")).toBeNull(); diff --git a/src/ui/PodcastView/LibraryPage.test.ts b/src/ui/PodcastView/LibraryPage.test.ts index 3eac0869..18227196 100644 --- a/src/ui/PodcastView/LibraryPage.test.ts +++ b/src/ui/PodcastView/LibraryPage.test.ts @@ -142,7 +142,7 @@ describe("the Library's podcast grid", () => { await repository.load(["Art Show"], "network"); services.online.set(true); - void repository.ensureAll(); + void repository.ensureAll({ online: true }); await tick(); expect(card("Art Show").querySelector(".pn-badge")).toBeNull(); diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index a57f5f66..a454f66b 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; import * as obsidian from "obsidian"; import { get, readable } from "svelte/store"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -159,7 +159,7 @@ async function cacheFeeds(...entries: [PodcastFeed, Episode[]][]) { now: Date.now, }); earlier.setSubscriptions(Object.fromEntries(entries.map(([feed]) => [feed.title, feed]))); - await earlier.ensureAll(); + await earlier.ensureAll({ online: true }); } function respondByUrl(responses: Record) { @@ -200,6 +200,7 @@ beforeEach(() => { }); afterEach(() => { + cleanup(); resetStores(); mockLoad.mockReset(); delete (globalThis as Record).app; diff --git a/src/ui/PodcastView/PodcastView.shell.test.ts b/src/ui/PodcastView/PodcastView.shell.test.ts index 99efd248..fd27a4fb 100644 --- a/src/ui/PodcastView/PodcastView.shell.test.ts +++ b/src/ui/PodcastView/PodcastView.shell.test.ts @@ -366,21 +366,36 @@ describe("offline", () => { expect(button("Refresh all feeds")).toBeEnabled(); }); - test("automatic loading follows the online store and reconnect starts a fresh request", async () => { - const load = vi.fn<(url: string) => Promise>(() => - get(services.online) - ? Promise.resolve(feedDocument(feed.title, [episode])) - : new Promise(() => {}), + const second = { ...feed, title: "Second Podcast", url: "https://second.example.com/feed.xml" }; + const secondEpisode = { + ...episode, + title: "Second 1", + streamUrl: "https://second.example.com/1.mp3", + podcastName: second.title, + }; + + function viewWithOfflineRequestsHeld(storage = new MemoryStorage()) { + const documents: Record = { + [feed.url]: feedDocument(feed.title, [episode]), + [second.url]: feedDocument(second.title, [secondEpisode]), + }; + const load = vi.fn<(url: string) => Promise>((url) => + get(services.online) ? Promise.resolve(documents[url]) : new Promise(() => {}), ); dispose(); ({ services, doc, dispose } = createTestServices({ feeds: new FeedRepository({ load, resolveUrl: (saved) => saved.url, - storage: new MemoryStorage(), + storage, now: Date.now, }), })); + return load; + } + + test("automatic loading follows the online store and reconnect starts a fresh request", async () => { + const load = viewWithOfflineRequestsHeld(); services.library.savedFeeds.set({ [feed.title]: feed }); services.online.set(false); services.navigation.replace({ kind: "latest" }); @@ -396,6 +411,54 @@ describe("offline", () => { expect(load.mock.calls).toEqual([[feed.url]]); }); + test("subscription changes load only when online", async () => { + const load = viewWithOfflineRequestsHeld(); + services.library.savedFeeds.set({}); + services.online.set(false); + services.navigation.replace({ kind: "latest" }); + renderView(320); + await tick(); + + services.library.savedFeeds.set({ [feed.title]: feed }); + await tick(); + expect(load).not.toHaveBeenCalled(); + + services.online.set(true); + expect(await screen.findByText(episode.title)).toBeInTheDocument(); + services.library.savedFeeds.set({ [feed.title]: feed, [second.title]: second }); + + expect(await screen.findByText(secondEpisode.title)).toBeInTheDocument(); + expect(load.mock.calls).toEqual([[feed.url], [second.url]]); + }); + + test("offline, an expired cache shows its episodes without a request, and Refresh stays off", async () => { + const storage = new MemoryStorage(); + const earlier = new FeedRepository({ + load: async () => feedDocument(feed.title, [episode]), + resolveUrl: (saved) => saved.url, + storage, + now: () => Date.now() - 7 * 3_600_000, + }); + earlier.setSubscriptions({ [feed.title]: feed }); + await earlier.ensureAll({ online: true }); + const load = viewWithOfflineRequestsHeld(storage); + services.library.savedFeeds.set({ [feed.title]: feed }); + services.online.set(false); + services.navigation.replace({ kind: "latest" }); + renderView(320); + await tick(); + + expect(screen.getByText(episode.title)).toBeInTheDocument(); + expect(button("Refresh all feeds")).toBeDisabled(); + + services.navigation.replace({ kind: "feed", key: feed.title }); + await tick(); + expect(screen.getByText(episode.title)).toBeInTheDocument(); + expect(button("Refresh episodes")).toBeDisabled(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(load).not.toHaveBeenCalled(); + }); + test("First run has no strip", async () => { services.library.savedFeeds.set({}); services.online.set(false); diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index 7181655e..95c2ebf4 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -1,6 +1,5 @@