/** * discover-episode-preview.test.tsx — Discover: `l`/right/enter on a podcast * result must OPEN the show's episode list, NOT subscribe. * * Regression: `open` on a Discover podcast result (bound to `l`/right via * `swipe-next`, and to enter) used to toggle subscription — pressing `l` on a * show you wanted to browse subscribed it instead. `l`/right/enter now drill * into a fetched-on-demand episode list (depth 2, no subscription), and `a` * (the app-wide `subscribe` action) is the dedicated subscribe key. * * Mounts the real app (sandboxed, silent audio, mocked discover store) and * drives the Discover tab with the test renderer's mock keys: drill category * → podcast, `l` opens the episode list WITHOUT subscribing (feed store * untouched, subscribe not called); `h` pops back; `a` subscribes the * focused show; `l` then re-opens the episodes. * * App modules are loaded dynamically (never statically) because the sandbox * config/data dirs must be set BEFORE they evaluate — their module-level init * reads those env vars at import time. */ import { test, expect, afterAll, beforeAll, mock } from "bun:test"; import { testRender } from "@opentui/solid"; import { createSignal } from "solid-js"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import type { AudioControls } from "../src/hooks/useAudio"; import type { Episode } from "../src/types/episode"; import type { Podcast } from "../src/types/podcast"; import type { DepthFrame, NavigationState } from "../src/context/navigation-store"; // Recording audio stub: `play` pushes what was streamed. Registered FIRST so // a leaked partial useAudio mock from another file in this worker can't break // the app mount (see tests/search-focus.test.tsx for the same hazard). const played: Episode[] = []; const stubAudio: AudioControls = { isPlaying: () => false, position: () => 0, duration: () => 0, volume: () => 1, speed: () => 1, backendName: () => "none", error: () => null, currentEpisode: () => null, availablePlayers: () => [], play: async (episode: Episode) => { played.push(episode); }, load: async () => {}, pause: async () => {}, resume: async () => {}, togglePlayback: async () => {}, stop: async () => {}, seek: async () => {}, seekRelative: async () => {}, setVolume: async () => {}, setSpeed: async () => {}, switchBackend: async () => {}, prev: async () => {}, next: async () => {}, }; mock.module("../src/hooks/useAudio", () => ({ useAudio: () => stubAudio, })); // Deterministic discover store: `openEpisodes` seeds the episode list // synchronously (no network), `subscribe`/`unsubscribe` flip the show's flag // and are recorded so the test can assert l/enter never subscribed. const [selectedCategory, setSelectedCategory] = createSignal("all"); const [isLoading, setIsLoading] = createSignal(false); const [podcasts, setPodcasts] = createSignal([]); const [preview, setPreview] = createSignal>({}); const [previewLoading, setPreviewLoading] = createSignal>( new Set(), ); const [previewErrors, setPreviewErrors] = createSignal>( {}, ); const subscribeCalls: string[] = []; const openCalls: string[] = []; const flip = (id: string, subscribed: boolean) => setPodcasts((prev) => prev.map((p) => (p.id === id ? { ...p, isSubscribed: subscribed } : p)), ); const mockDiscoverStore = { selectedCategory, isLoading, podcasts, categories: [ { id: "all", name: "All", icon: "" }, { id: "technology", name: "Technology", icon: "" }, ], filteredPodcasts: () => { const cat = selectedCategory(); if (cat === "all") return podcasts(); return podcasts().filter((p) => (p.categories ?? []).some((c) => c.toLowerCase().includes(cat.toLowerCase()), ), ); }, setSelectedCategory, subscribe: (id: string) => { subscribeCalls.push(id); flip(id, true); }, unsubscribe: (id: string) => { flip(id, false); }, refresh: async () => {}, episodesForPodcast: (id: string) => preview()[id] ?? [], isLoadingEpisodesFor: (id: string) => previewLoading().has(id), previewError: (id: string) => previewErrors()[id], openEpisodes: async (pod: Podcast) => { openCalls.push(pod.id); setPreview((prev) => ({ ...prev, [pod.id]: [makeEpisode(1), makeEpisode(2)], })); }, refreshEpisodes: async () => {}, }; mock.module("../src/stores/discover", () => ({ DISCOVER_CATEGORIES: mockDiscoverStore.categories, useDiscoverStore: () => mockDiscoverStore, })); // Sandbox BEFORE any app module evaluates — config-dir/persistence read these // env vars at import time, so the app modules are loaded dynamically. const SANDBOX = join(process.cwd(), ".harness", "test-discover-preview"); mkdirSync(join(SANDBOX, "config-home"), { recursive: true }); mkdirSync(join(SANDBOX, "data-home"), { recursive: true }); process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home"); process.env.XDG_DATA_HOME = join(SANDBOX, "data-home"); process.env.PODTUI_AUDIO_BACKEND = "none"; const { App } = await import("../src/App"); const { ThemeProvider } = await import("../src/context/ThemeContext"); const toast = await import("../src/ui/toast"); const { KeybindProvider, useKeybinds } = await import( "../src/context/KeybindContext" ); const { NavigationProvider, useNavigation } = await import( "../src/context/NavigationContext" ); const { DialogProvider } = await import("../src/ui/dialog"); const { CommandProvider } = await import("../src/ui/command"); const { TABS } = await import("../src/utils/navigation"); const { useFeedStore } = await import("../src/stores/feed"); function makePodcast(): Podcast { return { id: "featured-show", title: "Featured Show", description: "A featured show.", feedUrl: "https://example.test/featured.xml", author: "tester", categories: ["Technology"], lastUpdated: new Date(), isSubscribed: false, }; } function makeEpisode(n: number): Episode { return { id: `featured-ep-${n}`, podcastId: "featured-show", title: `Featured Episode ${n}`, description: "", audioUrl: "https://example.test/ep.mp3", duration: 0, pubDate: new Date(`2026-08-0${n}T00:00:00Z`), }; } type MockInput = { pressKey: (key: string) => void; pressEnter: () => void }; type Mounted = { renderer: { destroy: () => void }; renderOnce: () => Promise; mockInput: MockInput; nav: () => NavigationState; keybindsReady: () => boolean; }; async function mountApp(): Promise { let navRef: NavigationState | null = null; let keybindsRef: { ready: boolean } | null = null; const StateProbe = () => { navRef = useNavigation(); keybindsRef = useKeybinds(); return null; }; const HarnessRoot = () => ( ); const setup = await testRender(() => , { width: 100, height: 30, useThread: false, }); // The test renderer intercepts stdout; the app is a TUI that writes frames // asynchronously, so silence that interception (same as search-focus). ( setup.renderer as unknown as { disableStdoutInterception?: () => void; } ).disableStdoutInterception?.(); await setup.renderOnce(); await sleep(60); return { renderer: setup.renderer, renderOnce: setup.renderOnce, mockInput: setup.mockInput, nav: () => navRef!, keybindsReady: () => keybindsRef?.ready ?? false, }; } function sleep(ms: number): Promise { const { promise, resolve } = Promise.withResolvers(); setTimeout(resolve, ms); return promise; } async function settleReady(m: Mounted): Promise { for (let i = 0; i < 80; i++) { await m.renderOnce(); await sleep(60); if (m.keybindsReady()) return; } throw new Error("keybinds never became ready"); } async function waitFor( m: Mounted, cond: () => boolean, what: string, timeoutMs = 5000, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (cond()) return; await m.renderOnce(); await sleep(25); } throw new Error(`timed out waiting for: ${what}`); } beforeAll(() => { setPodcasts([makePodcast()]); }); afterAll(() => { rmSync(SANDBOX, { recursive: true, force: true }); }); test("l on a podcast opens its episode list without subscribing; a subscribes", async () => { const m = await mountApp(); try { await settleReady(m); // Open the Discover tab (digit press retried until the router attaches). for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.DISCOVER; i++) { m.mockInput.pressKey("3"); await m.renderOnce(); await sleep(40); } expect(m.nav().activeTab()).toBe(TABS.DISCOVER); m.mockInput.pressEnter(); // open the tab's content (category depth) await waitFor( m, () => m.nav().currentDepth() === 0 && !m.nav().atRootTab(), "discover content mounted", ); // l on the focused category drills to the podcast results (depth 1). m.mockInput.pressKey("l"); await waitFor(m, () => m.nav().currentDepth() === 1, "results depth"); expect(m.nav().topFrame()?.kind).toBe("results"); // l on the focused podcast opens its episode list (depth 2) — the // show must NOT be subscribed, the feed store untouched. m.mockInput.pressKey("l"); await waitFor(m, () => m.nav().currentDepth() === 2, "episodes depth"); expect(m.nav().topFrame()?.kind).toBe("episodes"); expect(m.nav().topFrame()?.ctx).toBe("featured-show"); expect(openCalls).toEqual(["featured-show"]); expect(subscribeCalls).toHaveLength(0); expect(played).toHaveLength(0); expect( useFeedStore() .feeds() .some((f) => f.podcast.id === "featured-show"), ).toBe(false); // The seeded episode list is what the page renders at depth 2. expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength( 2, ); // h pops back to the results (depth 1). m.mockInput.pressKey("h"); await waitFor(m, () => m.nav().currentDepth() === 1, "back to results"); // a subscribes the focused show (the dedicated subscribe key). m.mockInput.pressKey("a"); await waitFor( m, () => mockDiscoverStore.podcasts()[0]?.isSubscribed === true, "a subscribes the show", ); expect(subscribeCalls).toEqual(["featured-show"]); // l still opens the episode list for a subscribed show (no toggle). m.mockInput.pressKey("l"); await waitFor(m, () => m.nav().currentDepth() === 2, "episodes re-opened"); expect(subscribeCalls).toEqual(["featured-show"]); expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength( 2, ); } finally { m.renderer.destroy(); } });