diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index 0447e617..5bc1acf8 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -441,32 +441,69 @@ describe("FeedRepository", () => { 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" } }); + test.each([false, true])( + "a private feed whose URL is not on this device is unavailable with online %s", + async (online) => { + 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({ online }); + + expect(t.load).not.toHaveBeenCalled(); + expect(t.feed("Private")).toMatchObject({ status: "error", error: "unavailable" }); + }, + ); - await t.repo.ensureAll({ online: true }); + test.each([false, true])( + "a fresh cached private feed does not ask for its URL with online %s", + async (online) => { + const privateFeed = { + ...alpha, + url: privateFeedPlaceholder("Private"), + urlSecretId: "id", + }; + const seeded = setup({ resolveUrl: () => "https://private.test/feed.xml?auth=s3cret" }); + await loaded(seeded, { Private: privateFeed }, doc("Private", [item(1)])); - expect(t.load).not.toHaveBeenCalled(); - expect(t.feed("Private")).toMatchObject({ status: "error", error: "unavailable" }); - }); + const resolveUrl = vi.fn(() => null); + const t = setup({ storage: seeded.storage, resolveUrl }); + t.repo.setSubscriptions({ Private: privateFeed }); + await t.repo.ensureAll({ online }); + + expect(resolveUrl).not.toHaveBeenCalled(); + expect(t.titles("Private")).toEqual(["Episode 1"]); + }, + ); - test("a fresh cached private feed does not ask for its URL", async () => { + test("offline, an expired private cache shows its episodes and missing address", async () => { const privateFeed = { ...alpha, url: privateFeedPlaceholder("Private"), urlSecretId: "id" }; const seeded = setup({ resolveUrl: () => "https://private.test/feed.xml?auth=s3cret" }); await loaded(seeded, { Private: privateFeed }, doc("Private", [item(1)])); - - const resolveUrl = vi.fn(() => null); - const t = setup({ storage: seeded.storage, resolveUrl }); + const t = setup({ storage: seeded.storage, resolveUrl: () => null }); + t.advance(7 * HOUR); t.repo.setSubscriptions({ Private: privateFeed }); - await t.repo.ensureAll({ online: true }); - expect(resolveUrl).not.toHaveBeenCalled(); + await t.repo.ensureAll({ online: false }); + + expect(t.load).not.toHaveBeenCalled(); + expect(t.feed("Private")).toMatchObject({ status: "stale", error: "unavailable" }); expect(t.titles("Private")).toEqual(["Episode 1"]); }); + test("offline, a private feed with its address on this device waits without an error or request", async () => { + const t = setup({ resolveUrl: () => "https://private.test/feed.xml?auth=s3cret" }); + t.repo.setSubscriptions({ + Private: { ...alpha, url: privateFeedPlaceholder("Private"), urlSecretId: "id" }, + }); + + await t.repo.ensureAll({ online: false }); + + expect(t.load).not.toHaveBeenCalled(); + expect(t.feed("Private")).toMatchObject({ status: "idle", error: undefined, episodes: [] }); + }); + test("a private feed's URL reaches neither the cache nor its episodes", async () => { const secretUrl = "https://private.test/feed.xml?auth=s3cret"; const placeholder = privateFeedPlaceholder("Private"); diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index e7852101..9af6dcd3 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -138,8 +138,9 @@ export class FeedRepository implements Readable> ensureAll({ online }: { online: boolean }): Promise { this.hydrate(); - if (!online) return Promise.resolve(true); - return this.load(this.subscriptions.keys(), "cached"); + if (online) return this.load(this.subscriptions.keys(), "cached"); + for (const feed of this.subscriptions.values()) void this.request(feed, "cached", false); + return Promise.resolve(true); } /** @@ -193,7 +194,7 @@ export class FeedRepository implements Readable> } /** Joins the feed's request in flight, or starts one when `mode` asks for it. */ - private request(feed: FeedAddress, mode: LoadMode): Promise | undefined { + private request(feed: FeedAddress, mode: LoadMode, online = true): Promise | undefined { const source = this.source(feed.url); if (source.inflight || !this.needsFetch(source.doc, mode)) return source.inflight; const url = this.deps.resolveUrl(feed); @@ -202,7 +203,7 @@ export class FeedRepository implements Readable> this.emit(); return undefined; } - return this.fetch(feed.url, url); + return online ? this.fetch(feed.url, url) : undefined; } private source(url: string): Source { diff --git a/src/ui/PodcastView/EpisodesPage.svelte b/src/ui/PodcastView/EpisodesPage.svelte index c38547cc..bb058c48 100644 --- a/src/ui/PodcastView/EpisodesPage.svelte +++ b/src/ui/PodcastView/EpisodesPage.svelte @@ -197,9 +197,7 @@ color: var(--text-muted); font-size: var(--font-ui-smaller); font-family: var(--font-monospace); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + overflow-wrap: anywhere; } } diff --git a/tests/e2e/private-feeds.test.ts b/tests/e2e/private-feeds.test.ts index b38dc015..862d036f 100644 --- a/tests/e2e/private-feeds.test.ts +++ b/tests/e2e/private-feeds.test.ts @@ -1,11 +1,27 @@ +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; import type { ObsidianClient } from "obsidian-e2e"; import { expect, onTestFinished, test } from "vitest"; import { SUPERCAST_FIXTURE_HOST } from "../../scripts/e2e-instance.mjs"; import { createPodNotesE2EHarness } from "./harness"; -import { runCommand, showAddPodcast, showLibrary, showPodNotes } from "./journey/actions"; +import { + closePodNotes, + runCommand, + setOffline, + showAddPodcast, + showLibrary, + showPodNotes, +} from "./journey/actions"; import { click, typeInto } from "./journey/input"; import { ui } from "./journey/targets"; -import { isShown, notices, waitUntilShown } from "./journey/wait"; +import { + eventually, + isDisabled, + isShown, + notices, + shownText, + waitUntilShown, +} from "./journey/wait"; import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; import { feedCacheHolds, @@ -25,6 +41,65 @@ const closeFeedAddressDialog = (obsidian: ObsidianClient) => 'document.querySelectorAll(".modal-container:not(:has(.mod-settings)) .modal-close-button").forEach(el => el.click()), true', ); +test("a private feed first opened offline explains that its address is not on this device", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + await seedLibrary(context, null); + const id = "podnotes-feed-url-47600000-0000-4000-8000-000000000000"; + await obsidian.dev.evalJson(`app.secretStorage.setSecret(${jsLiteral(id)}, ""), true`); + onTestFinished(async () => { + await closePodNotes(obsidian); + await setOffline(obsidian, false); + }); + await setOffline(obsidian, true); + await plugin.updateDataAndReload>((data) => { + data.savedFeeds = { + Private: { + title: "Private", + artworkUrl: "", + url: "podnotes-private-feed:Private", + urlSecretId: id, + }, + }; + }, RELOAD_OPTIONS); + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard("Private")); + await waitUntilShown(obsidian, ui.offlineStrip); + const actionShown = await eventually( + obsidian, + () => isShown(obsidian, ui.feedErrorAction("Set feed address")), + true, + ); + const text = await shownText(obsidian); + console.log("FIX476 offline", JSON.stringify({ text, actionShown })); + const evidence = resolve(".obsidian-e2e-artifacts/fix476"); + await mkdir(evidence, { recursive: true }); + await obsidian.dev.screenshot(resolve(evidence, "missing-address-offline.png")); + expect.soft(text).toContain("Address not on this device"); + expect.soft(actionShown).toBe(true); + expect.soft(await isDisabled(obsidian, ui.feedErrorAction("Set feed address"))).toBe(true); + expect + .soft( + await obsidian.dev.evalJson(`(() => { + const description = document.querySelector(".podcast-view .pn-banner .d"); + return !!description && description.scrollWidth <= description.clientWidth; + })()`), + ) + .toBe(true); + + await setOffline(obsidian, false); + await waitUntilShown(obsidian, ui.feedErrorAction("Set feed address")); + expect(await isDisabled(obsidian, ui.feedErrorAction("Set feed address"))).toBe(false); + expect(await shownText(obsidian)).toContain("Address not on this device"); + expect((await readLibrary(plugin)).subscriptions).toEqual([ + { title: "Private", url: "podnotes-private-feed:Private" }, + ]); + expect( + await obsidian.dev.evalJson(`app.secretStorage.getSecret(${jsLiteral(id)})`), + ).toBeFalsy(); +}); + test("protects a Supercast subscription's address in data, cache keys and note links, then accepts it under the same reference", async () => { const context = getContext(); const { obsidian, plugin, sandbox } = context;