feat(feed): bound feed lifecycle to 30-day window with nonblocking refresh
Persisted feeds keep only episodes from the last 30 days (plus completed downloads); older episodes live in volatile memory and survive refreshes via union merge, with per-feed in-memory caches capped at 500. Refresh batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no Promise.all barrier), config.json writes are trailing-edge debounced (250ms, immediate flushPendingSave for unsubscribes), and cold fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store powers a global top-right indicator covering refresh, fetch-more, subscribe, search, and downloads. Also includes the in-flight incremental RSS parsing (chunked with event-loop yields) and refresh spinner work this tree already carried.
This commit is contained in:
323
tests/feed-nonblocking.test.ts
Normal file
323
tests/feed-nonblocking.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Non-blocking feed refresh tests — task 03 of the bounded-feed-lifecycle
|
||||
* feature.
|
||||
*
|
||||
* Pins the contracts that make a refresh batch feel non-blocking:
|
||||
*
|
||||
* 1. refreshAllFeeds never holds more than FETCH_CONCURRENCY (4) RSS
|
||||
* requests in flight — a worker pool bounds the batch instead of
|
||||
* Promise.all firing every feed at once.
|
||||
* 2. Each feed's refreshed episodes are applied AS ITS OWN FETCH LANDS —
|
||||
* the old Promise.all barrier is gone, so a slow feed no longer hides
|
||||
* the fast feeds' fresh episodes.
|
||||
* 3. config.json writes are trailing-edge debounced (rapid changes
|
||||
* collapse into one final write) and flushPendingSave() persists
|
||||
* immediately, without waiting out the debounce window.
|
||||
*
|
||||
* Polling note (why the polls below use setImmediate, not microtasks):
|
||||
* vi's fake timers trap setTimeout/setInterval/Date/Bun.sleep, so the
|
||||
* debounce is driven with vi.advanceTimersByTime. But a poll loop of pure
|
||||
* microtask turns (`await Promise.resolve()`) can NEVER observe an
|
||||
* in-flight refresh: it keeps the microtask queue non-empty, the event
|
||||
* loop's poll phase is never reached, and Bun.serve never even receives
|
||||
* the fetch (verified empirically). setImmediate is a real macrotask that
|
||||
* fake timers do NOT trap, and it lets the socket I/O progress — each
|
||||
* `tick()` below is one bounded event-loop turn. No real sleeps anywhere.
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||
// (their module-level init reads it).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-nonblocking-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
import { whenConfigIdle } from "../src/utils/config";
|
||||
|
||||
interface ServedEpisode {
|
||||
title: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let servedEpisodes: ServedEpisode[] = [];
|
||||
|
||||
/** Per-pathname request gates: while a path has an unresolved gate, the
|
||||
* server parks that request until the test resolves it. */
|
||||
let gates = new Map<string, { gate: Promise<void>; resolve: () => void }>();
|
||||
/** Requests currently inside the fetch handler (entered, not yet answered). */
|
||||
let inFlight = 0;
|
||||
/** High-water mark of `inFlight` — the concurrency-bound assertion source. */
|
||||
let maxConcurrent = 0;
|
||||
|
||||
// Bun runs test files in ONE process, so the store singleton is shared with
|
||||
// other test files. Track the feeds we add and remove them in afterAll so
|
||||
// whichever file runs next sees a pristine store.
|
||||
const addedFeedIds: string[] = [];
|
||||
/** Feed created by the debounce test, reused by the flushPendingSave test. */
|
||||
let debounceFeedId = "";
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
(ep, i) => `<item>
|
||||
<title>${ep.title}</title>
|
||||
<pubDate>${ep.date}</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Non-Blocking Test Show</title>
|
||||
<description>Non-blocking refresh test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
const makePodcast = (feedUrl: string): Podcast => ({
|
||||
id: feedUrl,
|
||||
title: "Non-Blocking Test Show",
|
||||
description: "Non-blocking refresh test feed",
|
||||
author: "tester",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
});
|
||||
|
||||
/** Park a request path behind an unresolved gate. */
|
||||
function setGate(path: string): void {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
gates.set(path, { gate: promise, resolve });
|
||||
}
|
||||
|
||||
/** Resolve every gate currently set. */
|
||||
function releaseAllGates(): void {
|
||||
for (const { resolve } of gates.values()) resolve();
|
||||
gates.clear();
|
||||
}
|
||||
|
||||
/** One real macrotask turn — see the polling note in the header. */
|
||||
const tick = (): Promise<void> => {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
setImmediate(resolve);
|
||||
return promise;
|
||||
};
|
||||
|
||||
/** Poll `cond` across up to `iterations` event-loop turns (one setImmediate
|
||||
* each). Returns whether the condition held by the deadline. */
|
||||
async function pollUntil(
|
||||
cond: () => boolean,
|
||||
iterations = 500,
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (cond()) return true;
|
||||
await tick();
|
||||
}
|
||||
return cond();
|
||||
}
|
||||
|
||||
/** Raw config.json text ("" when the file does not exist yet). */
|
||||
const readConfigRaw = (): Promise<string> =>
|
||||
Bun.file(join(process.env.XDG_CONFIG_HOME!, "podtui", "config.json"))
|
||||
.text()
|
||||
.catch(() => "");
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
inFlight++;
|
||||
if (inFlight > maxConcurrent) maxConcurrent = inFlight;
|
||||
try {
|
||||
const gate = gates.get(url.pathname);
|
||||
if (gate) await gate.gate;
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
} finally {
|
||||
inFlight--;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
gates.clear();
|
||||
inFlight = 0;
|
||||
maxConcurrent = 0;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
const store = useFeedStore();
|
||||
for (const id of addedFeedIds) store.removeFeed(id);
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("refreshAllFeeds never exceeds FETCH_CONCURRENCY in-flight requests", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes = [{ title: "Bound Ep 0", date: "2026-08-10T00:00:00Z" }];
|
||||
const urls = Array.from(
|
||||
{ length: 10 },
|
||||
(_, n) => `http://127.0.0.1:${server!.port}/bound-${n}.xml`,
|
||||
);
|
||||
const ids: string[] = [];
|
||||
for (const url of urls) {
|
||||
const feed = await store.addFeed(makePodcast(url), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
ids.push(feed!.id);
|
||||
addedFeedIds.push(feed!.id);
|
||||
}
|
||||
|
||||
// Gate every path so the batch's requests pile up at the server. addFeed
|
||||
// ran sequentially above (its own fetches never exceed 1 in flight), so
|
||||
// the counter below measures the batch alone.
|
||||
for (const url of urls) setGate(new URL(url).pathname);
|
||||
inFlight = 0;
|
||||
maxConcurrent = 0;
|
||||
|
||||
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
|
||||
const sawBound = await pollUntil(() => maxConcurrent >= 4);
|
||||
expect(sawBound).toBe(true);
|
||||
// The worker pool caps the batch at 4 — exactly 4 gated requests are
|
||||
// parked (nothing has been released, so nothing completed yet), and
|
||||
// nothing may exceed the bound, now or as the batch drains.
|
||||
expect(maxConcurrent).toBe(4);
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(4);
|
||||
|
||||
releaseAllGates();
|
||||
await refreshPromise;
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(4);
|
||||
for (const id of ids) {
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshAllFeeds applies each feed as its own fetch lands (no barrier)", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes = [{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" }];
|
||||
const aUrl = `http://127.0.0.1:${server!.port}/incr-a.xml`;
|
||||
const bUrl = `http://127.0.0.1:${server!.port}/incr-b.xml`;
|
||||
const a = await store.addFeed(makePodcast(aUrl), "test-source");
|
||||
const b = await store.addFeed(makePodcast(bUrl), "test-source");
|
||||
expect(a).not.toBeNull();
|
||||
expect(b).not.toBeNull();
|
||||
const aId = a!.id;
|
||||
const bId = b!.id;
|
||||
addedFeedIds.push(aId, bId);
|
||||
|
||||
// A new episode appears for both feeds; B's fetch is parked at the
|
||||
// server, A's is not.
|
||||
servedEpisodes = [
|
||||
{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||
{ title: "Incr Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||
];
|
||||
setGate(new URL(bUrl).pathname);
|
||||
|
||||
const beforeA = store.getFeed(aId)!.lastUpdated.getTime();
|
||||
const beforeB = store.getFeed(bId)!.lastUpdated.getTime();
|
||||
// Advance the (mocked) clock so the refresh's `new Date()` lastUpdated
|
||||
// bump is observably greater than beforeA (the fake clock otherwise
|
||||
// never moves — same pattern as feed-refresh.test.ts).
|
||||
vi.advanceTimersByTime(60_000);
|
||||
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
|
||||
|
||||
const applied = await pollUntil(
|
||||
() => store.getFeed(aId)!.lastUpdated.getTime() > beforeA,
|
||||
);
|
||||
expect(applied).toBe(true);
|
||||
// A's refreshed window is visible in feeds() while B is STILL gated —
|
||||
// the proof that per-feed results apply as they land.
|
||||
expect(store.getFeed(aId)!.episodes.length).toBe(2);
|
||||
expect(store.getFeed(bId)!.episodes.length).toBe(1);
|
||||
expect(store.getFeed(bId)!.lastUpdated.getTime()).toBe(beforeB);
|
||||
|
||||
releaseAllGates();
|
||||
await refreshPromise;
|
||||
expect(store.getFeed(aId)!.episodes.length).toBe(2);
|
||||
expect(store.getFeed(bId)!.episodes.length).toBe(2);
|
||||
});
|
||||
|
||||
test("config.json writes are trailing-edge debounced (two refreshes, one save)", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes = [{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" }];
|
||||
const url = `http://127.0.0.1:${server!.port}/debounce.xml`;
|
||||
const feed = await store.addFeed(makePodcast(url), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
debounceFeedId = feed!.id;
|
||||
addedFeedIds.push(debounceFeedId);
|
||||
|
||||
servedEpisodes = [
|
||||
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||
];
|
||||
await store.refreshFeed(debounceFeedId);
|
||||
|
||||
servedEpisodes = [
|
||||
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
|
||||
];
|
||||
await store.refreshFeed(debounceFeedId);
|
||||
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(3);
|
||||
|
||||
// No timer advanced: the debounced saves have NOT fired — the refreshed
|
||||
// episodes exist only in memory (await whenConfigIdle first so a
|
||||
// straggler write from an earlier test cannot race this read).
|
||||
await whenConfigIdle();
|
||||
const before = await readConfigRaw();
|
||||
expect(before).not.toContain("Deb Ep 1");
|
||||
expect(before).not.toContain("Deb Ep 2");
|
||||
|
||||
// SAVE_DEBOUNCE_MS = 250 (module-private in feed.ts — hardcoded here).
|
||||
vi.advanceTimersByTime(250);
|
||||
await whenConfigIdle();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await whenConfigIdle();
|
||||
|
||||
// One read, both refreshed episodes: the two refreshes collapsed into a
|
||||
// single trailing-edge write.
|
||||
const after = await readConfigRaw();
|
||||
expect(after).toContain("Deb Ep 1");
|
||||
expect(after).toContain("Deb Ep 2");
|
||||
});
|
||||
|
||||
test("flushPendingSave persists immediately, without waiting out the debounce", async () => {
|
||||
const store = useFeedStore();
|
||||
// Same feed as the debounce test (still in the singleton): serve a 4th
|
||||
// episode and refresh — the save is scheduled, then flushed by hand.
|
||||
servedEpisodes = [
|
||||
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
|
||||
{ title: "Deb Ep 3", date: "2026-08-07T00:00:00Z" },
|
||||
];
|
||||
await store.refreshFeed(debounceFeedId);
|
||||
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(4);
|
||||
|
||||
// No advanceTimersByTime: flushPendingSave must write right now.
|
||||
store.flushPendingSave();
|
||||
await whenConfigIdle();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await whenConfigIdle();
|
||||
|
||||
const raw = await readConfigRaw();
|
||||
expect(raw).toContain("Deb Ep 3");
|
||||
});
|
||||
161
tests/feed-refresh-spinner.test.tsx
Normal file
161
tests/feed-refresh-spinner.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* FeedPage refresh spinner — while feeds are being fetched (manual `r` and
|
||||
* the background refresh timer both route through refreshAllFeeds →
|
||||
* isLoadingFeeds), a braille spinner renders at the BOTTOM of the episode
|
||||
* list, horizontally centered in the current pane.
|
||||
*
|
||||
* The refresh is left in flight on purpose (the test server delays its
|
||||
* response) so the loading state is visible in the captured frame.
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import type { Server } from "bun";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||
// (their module-level init reads it) and silence the audio backend.
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-spinner-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||
import { NavigationProvider } from "../src/context/NavigationContext";
|
||||
import { FeedPage } from "../src/pages/Feed/FeedPage";
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
// The LoadingIndicator glyph cycle.
|
||||
const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
|
||||
|
||||
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
|
||||
const frameLines = (f: Frame): string[] =>
|
||||
f.lines.map((l) => l.spans.map((s) => s.text).join(""));
|
||||
|
||||
let server: Server<undefined> | null = null;
|
||||
/** Response delay (ms) for the next fetch — 0 during setup, >0 while the
|
||||
* refresh is in flight so the loading state is observable. */
|
||||
let delayMs = 0;
|
||||
let feedUrl = "";
|
||||
let feedId = "";
|
||||
|
||||
/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them. */
|
||||
function feedXml(origin: string): string {
|
||||
const items = Array.from({ length: 3 }, (_, i) => `<item>
|
||||
<title>Spin Ep ${3 - i}</title>
|
||||
<pubDate>${new Date(Date.UTC(2026, 0, 1 + i)).toISOString()}</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Spinner Show</title>
|
||||
<description>spinner test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (!url.pathname.endsWith(".xml")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
const { promise, resolve } = Promise.withResolvers<Response>();
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
new Response(feedXml(url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
}),
|
||||
),
|
||||
delayMs,
|
||||
);
|
||||
return promise;
|
||||
},
|
||||
});
|
||||
const podcast: Podcast = {
|
||||
id: "",
|
||||
title: "Spinner Show",
|
||||
description: "spinner test feed",
|
||||
author: "tester",
|
||||
feedUrl: "",
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
};
|
||||
feedUrl = `http://127.0.0.1:${server.port}/spinner.xml`;
|
||||
const store = useFeedStore();
|
||||
const feed = await store.addFeed(
|
||||
{ ...podcast, feedUrl },
|
||||
"test-source",
|
||||
);
|
||||
feedId = feed!.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const store = useFeedStore();
|
||||
store.removeFeed(feedId);
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("refresh spinner renders at the bottom of the list, centered in the current pane", async () => {
|
||||
const store = useFeedStore();
|
||||
const setup = await testRender(
|
||||
() => (
|
||||
<ThemeProvider mode="dark">
|
||||
<NavigationProvider>
|
||||
<FeedPage />
|
||||
</NavigationProvider>
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 100, height: 30, useThread: false },
|
||||
);
|
||||
|
||||
// Settle until the episode list is mounted.
|
||||
let lines: string[] | null = null;
|
||||
for (let i = 0; i < 40 && !lines; i++) {
|
||||
await setup.renderOnce();
|
||||
const ls = frameLines(setup.captureSpans() as unknown as Frame);
|
||||
if (ls.some((l) => l.includes("Spin Ep 3"))) lines = ls;
|
||||
else await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (!lines) throw new Error("FeedPage did not render episodes before timeout");
|
||||
|
||||
// Kick off a refresh and leave it in flight: isLoadingFeeds flips true
|
||||
// synchronously, so the very next frame shows the spinner.
|
||||
delayMs = 400;
|
||||
const refreshing = store.refreshAllFeeds();
|
||||
await setup.renderOnce();
|
||||
const loading = frameLines(setup.captureSpans() as unknown as Frame);
|
||||
|
||||
// Locate the spinner row and the current pane's borders ("│" columns;
|
||||
// only the current pane is bordered in PaneRow).
|
||||
const spinnerRow = loading.findIndex((l) => SPINNER_RE.test(l));
|
||||
expect(spinnerRow).toBeGreaterThan(-1);
|
||||
|
||||
const spinnerCol = loading[spinnerRow].search(SPINNER_RE);
|
||||
const borderCols = loading
|
||||
.map((l, i) => (i <= spinnerRow ? [...l].map((ch, x) => (ch === "│" ? x : -1)) : []))
|
||||
.flat()
|
||||
.filter((x) => x >= 0);
|
||||
const paneLeft = Math.min(...borderCols);
|
||||
const paneRight = Math.max(...borderCols);
|
||||
const paneCenter = (paneLeft + paneRight) / 2;
|
||||
expect(paneLeft).toBeGreaterThan(0); // borders actually found
|
||||
|
||||
// Bottom of the list: below the last episode row.
|
||||
const lastEpRow = loading.findLastIndex((l) => l.includes("Spin Ep"));
|
||||
expect(spinnerRow).toBeGreaterThan(lastEpRow);
|
||||
|
||||
// Horizontally centered in the current pane (not left-padded).
|
||||
expect(Math.abs(spinnerCol - paneCenter)).toBeLessThanOrEqual(8);
|
||||
|
||||
// Let the refresh finish so teardown is clean.
|
||||
delayMs = 0;
|
||||
await refreshing;
|
||||
setup.renderer.destroy();
|
||||
});
|
||||
@@ -137,6 +137,7 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
|
||||
|
||||
test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||
const store = useFeedStore();
|
||||
const savedEpisodes = servedEpisodes;
|
||||
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
@@ -158,6 +159,11 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||
|
||||
failPath = null;
|
||||
store.removeFeed(feedId);
|
||||
// Restore the shared served content: with union merge semantics (volatile
|
||||
// episodes survive refreshes) this feed keeps its larger in-memory window,
|
||||
// so later tests must serve the same episodes they added — a shrink here
|
||||
// would make the next test's "unchanged" refresh genuinely different.
|
||||
servedEpisodes = savedEpisodes;
|
||||
});
|
||||
|
||||
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||
@@ -183,3 +189,44 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () =>
|
||||
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh parses in bounded chunks, yielding to the event loop between them", async () => {
|
||||
const store = useFeedStore();
|
||||
// 60 episodes: a chunked parse (25/chunk) must yield between chunks; a
|
||||
// monolithic parse would complete without yielding at all.
|
||||
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
||||
title: `Ep ${60 - i}`,
|
||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/chunky.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
const feedId = feed!.id;
|
||||
expect(store.getFeed(feedId)!.episodes.length).toBe(20); // subscribe window
|
||||
|
||||
// Count event-loop yields during the refresh: each parse-chunk boundary
|
||||
// posts through a MessageChannel (the yield primitive in feed.ts — the
|
||||
// one macrotask turn bun's fake timers do not trap, which also pins that
|
||||
// the yield works under fake timers). This runs under fake timers like
|
||||
// the other tests; a setTimeout-based yield would deadlock here.
|
||||
const OriginalMessageChannel = globalThis.MessageChannel;
|
||||
let posts = 0;
|
||||
globalThis.MessageChannel = class extends OriginalMessageChannel {
|
||||
constructor() {
|
||||
super();
|
||||
posts++;
|
||||
}
|
||||
};
|
||||
try {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshFeed(feedId);
|
||||
} finally {
|
||||
globalThis.MessageChannel = OriginalMessageChannel;
|
||||
}
|
||||
|
||||
expect(posts).toBeGreaterThan(0);
|
||||
expect(store.getFeed(feedId)!.episodes.length).toBe(50); // refresh window
|
||||
|
||||
// Leave the shared singleton as we found it (see the addedFeedIds note
|
||||
// in feed-pagination.test.ts — bun runs test files in one process).
|
||||
store.removeFeed(feedId);
|
||||
});
|
||||
|
||||
286
tests/feed-retention.test.ts
Normal file
286
tests/feed-retention.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Bounded-feed-lifecycle persistence tests — task 01 (retention window).
|
||||
*
|
||||
* Pins the persistence contract:
|
||||
* 1. saveFeedsToFile never writes an episode older than PERSISTED_WINDOW_DAYS
|
||||
* unless its id is a completed download in downloads.json.
|
||||
* 2. loadFeedsFromFile prunes over-window episodes from legacy configs and
|
||||
* rewrites config.json when it pruned anything.
|
||||
* 3. Undatable episodes (missing/invalid pubDate) are always persisted.
|
||||
*
|
||||
* The async saveFeedsToFile IIFE reads downloads.json then enqueues an
|
||||
* updateConfig write on the serialized write chain, so assertions wait via
|
||||
* whenConfigIdle() + a short real-timer settle (no fake timers here — see
|
||||
* settleWrites).
|
||||
*/
|
||||
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing anything
|
||||
// under test (config-dir reads XDG_CONFIG_HOME lazily, but stay consistent
|
||||
// with the store test harness). Do NOT import the feed store — its module
|
||||
// boot IIFE would hit the network.
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-retention-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import {
|
||||
PERSISTED_WINDOW_DAYS,
|
||||
episodeIsPersistable,
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
} from "../src/utils/feeds-persistence";
|
||||
import { whenConfigIdle } from "../src/utils/config";
|
||||
import { FeedVisibility } from "../src/types/feed";
|
||||
import type { Feed } from "../src/types/feed";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
|
||||
const configJsonPath = join(configHome, "podtui", "config.json");
|
||||
const downloadsJsonPath = join(configHome, "podtui", "downloads.json");
|
||||
|
||||
/** Milliseconds in one day — mirrors the window math in feeds-persistence. */
|
||||
const DAY = 24 * 3600 * 1000;
|
||||
|
||||
function makeEpisode(partial: Partial<Episode> & { id: string }): Episode {
|
||||
return {
|
||||
podcastId: "feed-1",
|
||||
title: partial.id,
|
||||
description: "",
|
||||
audioUrl: `https://example.com/audio/${partial.id}.mp3`,
|
||||
duration: 600,
|
||||
pubDate: new Date(),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFeed(episodes: Episode[]): Feed {
|
||||
return {
|
||||
id: "feed-1",
|
||||
podcast: {
|
||||
id: "feed-1",
|
||||
title: "Retention Show",
|
||||
description: "Retention test feed",
|
||||
author: "tester",
|
||||
feedUrl: "https://example.com/feed.xml",
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes,
|
||||
visibility: FeedVisibility.PUBLIC,
|
||||
sourceId: "source-1",
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
};
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
setTimeout(resolve, ms);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the fire-and-forget save chain to drain. saveFeedsToFile's IIFE is
|
||||
* not awaitable: it reads downloads.json first and only THEN enqueues its
|
||||
* write on the serialized chain, so the first whenConfigIdle() may observe
|
||||
* the chain BEFORE the write is queued. A real-timer settle is the only way
|
||||
* to let the IIFE's async read land without fake timers (which would stall
|
||||
* the Bun.file I/O and the write chain itself); the poll fallback in the
|
||||
* assertions absorbs any residual scheduling skew on a loaded machine.
|
||||
*/
|
||||
async function settleWrites(): Promise<void> {
|
||||
await whenConfigIdle();
|
||||
await delay(20);
|
||||
await whenConfigIdle();
|
||||
}
|
||||
|
||||
/** Episode ids of the first feed in config.json, or null when absent. */
|
||||
async function readPersistedEpisodeIds(): Promise<string[] | null> {
|
||||
const raw = await Bun.file(configJsonPath).json().catch(() => null);
|
||||
if (!raw || typeof raw !== "object" || !("feeds" in raw)) return null;
|
||||
const feeds = raw.feeds;
|
||||
if (!Array.isArray(feeds) || feeds.length === 0) return null;
|
||||
const first = feeds[0];
|
||||
if (!first || typeof first !== "object" || !("episodes" in first)) return null;
|
||||
const episodes = first.episodes;
|
||||
if (!Array.isArray(episodes)) return null;
|
||||
return episodes.map((ep) => {
|
||||
if (ep && typeof ep === "object" && "id" in ep) return String(ep.id);
|
||||
return "";
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait (up to ~1s) until config.json's first feed has exactly these ids. */
|
||||
async function pollConfigFor(ids: string[]): Promise<void> {
|
||||
const expected = [...ids].sort().join(",");
|
||||
const deadline = Date.now() + 1000;
|
||||
for (;;) {
|
||||
const actual = (await readPersistedEpisodeIds())?.sort().join(",");
|
||||
if (actual === expected) return;
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`config.json never reached expected episode ids [${ids.join(", ")}]`,
|
||||
);
|
||||
}
|
||||
await delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Unit: episodeIsPersistable ──────────────────────────────────────────────
|
||||
|
||||
test("episodeIsPersistable drops a 40-day-old episode that is not downloaded", () => {
|
||||
const ep = makeEpisode({
|
||||
id: "old-plain-id",
|
||||
pubDate: new Date(Date.now() - 40 * DAY),
|
||||
});
|
||||
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(false);
|
||||
});
|
||||
|
||||
test("episodeIsPersistable keeps a 40-day-old episode whose id is a completed download", () => {
|
||||
const ep = makeEpisode({
|
||||
id: "old-downloaded-id",
|
||||
pubDate: new Date(Date.now() - 40 * DAY),
|
||||
});
|
||||
expect(
|
||||
episodeIsPersistable(ep, new Set(["old-downloaded-id"]), new Date()),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("episodeIsPersistable keeps a 5-day-old episode", () => {
|
||||
const ep = makeEpisode({
|
||||
id: "recent-id",
|
||||
pubDate: new Date(Date.now() - 5 * DAY),
|
||||
});
|
||||
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
|
||||
});
|
||||
|
||||
test("episodeIsPersistable keeps an episode with an invalid pubDate", () => {
|
||||
const ep = makeEpisode({ id: "undatable-id", pubDate: new Date(NaN) });
|
||||
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
|
||||
});
|
||||
|
||||
test("PERSISTED_WINDOW_DAYS is 30", () => {
|
||||
expect(PERSISTED_WINDOW_DAYS).toBe(30);
|
||||
});
|
||||
|
||||
// ── Save path: retention window applied with completed-download exemption ──
|
||||
|
||||
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {
|
||||
// Arrange: downloads.json lists one completed download.
|
||||
mkdirSync(join(configHome, "podtui"), { recursive: true });
|
||||
await Bun.write(
|
||||
downloadsJsonPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
episodeId: "old-downloaded-id",
|
||||
feedId: "feed-1",
|
||||
status: "completed",
|
||||
filePath: null,
|
||||
downloadedAt: null,
|
||||
fileSize: 0,
|
||||
error: null,
|
||||
audioUrl: "",
|
||||
episodeTitle: "",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// Act: recent episode, old plain episode, old downloaded episode.
|
||||
const feed = makeFeed([
|
||||
makeEpisode({
|
||||
id: "recent-id",
|
||||
pubDate: new Date(Date.now() - 5 * DAY),
|
||||
}),
|
||||
makeEpisode({
|
||||
id: "old-plain-id",
|
||||
pubDate: new Date(Date.now() - 40 * DAY),
|
||||
}),
|
||||
makeEpisode({
|
||||
id: "old-downloaded-id",
|
||||
pubDate: new Date(Date.now() - 40 * DAY),
|
||||
}),
|
||||
]);
|
||||
saveFeedsToFile([feed]);
|
||||
await settleWrites();
|
||||
// The IIFE's downloads.json read can land after the first settle; poll
|
||||
// briefly in case the write chain drained before that read resolved.
|
||||
await pollConfigFor(["old-downloaded-id", "recent-id"]);
|
||||
|
||||
// Assert: persisted episodes keep recent + downloaded, drop old-plain.
|
||||
const persisted = await readPersistedEpisodeIds();
|
||||
expect(persisted).toContain("recent-id");
|
||||
expect(persisted).toContain("old-downloaded-id");
|
||||
expect(persisted).not.toContain("old-plain-id");
|
||||
});
|
||||
|
||||
// ── Load path: legacy config cleanup rewrite ───────────────────────────────
|
||||
|
||||
test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", async () => {
|
||||
// Arrange: seed config.json directly with a feed whose episodes are ALL
|
||||
// older than the window; no downloads.json present.
|
||||
mkdirSync(join(configHome, "podtui"), { recursive: true });
|
||||
await Bun.write(
|
||||
configJsonPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
feeds: [
|
||||
{
|
||||
id: "feed-1",
|
||||
podcast: {
|
||||
id: "feed-1",
|
||||
title: "Legacy Show",
|
||||
description: "",
|
||||
author: "tester",
|
||||
feedUrl: "https://example.com/legacy.xml",
|
||||
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: [
|
||||
{
|
||||
id: "old-a",
|
||||
podcastId: "feed-1",
|
||||
title: "Old A",
|
||||
description: "",
|
||||
audioUrl: "https://example.com/audio/old-a.mp3",
|
||||
duration: 60,
|
||||
pubDate: new Date(Date.now() - 40 * DAY).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "old-b",
|
||||
podcastId: "feed-1",
|
||||
title: "Old B",
|
||||
description: "",
|
||||
audioUrl: "https://example.com/audio/old-b.mp3",
|
||||
duration: 60,
|
||||
pubDate: new Date(Date.now() - 40 * DAY).toISOString(),
|
||||
},
|
||||
],
|
||||
visibility: "public",
|
||||
sourceId: "source-1",
|
||||
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
|
||||
isPinned: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
// Act.
|
||||
const feeds = await loadFeedsFromFile();
|
||||
await settleWrites();
|
||||
|
||||
// Assert: returned feed has zero episodes AND config.json was rewritten
|
||||
// (the cleanup save is fire-and-forget — poll for the rewritten file).
|
||||
expect(feeds).toHaveLength(1);
|
||||
expect(feeds[0].episodes).toHaveLength(0);
|
||||
await pollConfigFor([]);
|
||||
expect(await readPersistedEpisodeIds()).toEqual([]);
|
||||
});
|
||||
252
tests/feed-volatile-merge.test.ts
Normal file
252
tests/feed-volatile-merge.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Volatile in-memory episode merge + bounded cache tests.
|
||||
*
|
||||
* Two behaviors from the bounded-feed-lifecycle work:
|
||||
* 1. mergeEpisodes unions refreshed episodes with what's already in memory
|
||||
* (the fetched copy wins on id collision), so a refresh never shrinks
|
||||
* the session's visible window; the union is capped per feed at
|
||||
* MAX_EPISODES_IN_MEMORY.
|
||||
* 2. The per-feed parse cache is capped at MAX_EPISODES_IN_MEMORY, so
|
||||
* loadMoreEpisodes can never surface more than the cap and
|
||||
* hasMoreEpisodes flips false there.
|
||||
*
|
||||
* Unchanged-refresh detection compares the fetched window against the
|
||||
* corresponding PREFIX of the merged list (sameRefreshWindow) — comparing
|
||||
* full lists would bump lastUpdated on every refresh because the merged list
|
||||
* legitimately holds episodes beyond the fetched window.
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||
// (their module-level init reads it).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import { MAX_EPISODES_IN_MEMORY, useFeedStore } from "../src/stores/feed";
|
||||
import { mergeEpisodes } from "../src/utils/episode-merge";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
interface ServedEpisode {
|
||||
title: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let servedEpisodes: ServedEpisode[] = [];
|
||||
// Bun runs test files in ONE process, so the store singleton is shared with
|
||||
// the other feed test files. Track the feeds we add and remove them in
|
||||
// afterAll so whichever file runs next sees a pristine store (execution
|
||||
// order between files is not guaranteed).
|
||||
const addedFeedIds: string[] = [];
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
(ep, i) => `<item>
|
||||
<title>${ep.title}</title>
|
||||
<pubDate>${ep.date}</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Volatile Show</title>
|
||||
<description>Volatile merge test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
const makePodcast = (feedUrl: string): Podcast => ({
|
||||
id: feedUrl,
|
||||
title: "Volatile Show",
|
||||
description: "Volatile merge test feed",
|
||||
author: "tester",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
});
|
||||
|
||||
const makeEpisode = (id: string, title: string, pubDate: Date): Episode => ({
|
||||
id,
|
||||
podcastId: "pod",
|
||||
title,
|
||||
description: "",
|
||||
audioUrl: `https://example.com/${id}.mp3`,
|
||||
duration: 100,
|
||||
pubDate,
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
// Leave the shared singleton as we found it (see addedFeedIds note).
|
||||
const store = useFeedStore();
|
||||
for (const id of addedFeedIds) store.removeFeed(id);
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── mergeEpisodes unit tests ─────────────────────────────────────────────
|
||||
|
||||
test("mergeEpisodes dedupes on id collision and keeps the fetched copy", () => {
|
||||
const existing = [
|
||||
makeEpisode("a", "Old Title", new Date("2026-08-01T00:00:00Z")),
|
||||
makeEpisode("b", "Ep B", new Date("2026-08-02T00:00:00Z")),
|
||||
];
|
||||
const fetched = [
|
||||
makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")),
|
||||
];
|
||||
|
||||
const merged = mergeEpisodes(existing, fetched, 10);
|
||||
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
||||
});
|
||||
|
||||
test("mergeEpisodes unions disjoint lists sorted newest-first", () => {
|
||||
const existing = [
|
||||
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
||||
];
|
||||
const fetched = [
|
||||
makeEpisode("newest", "Newest", new Date("2026-08-03T00:00:00Z")),
|
||||
makeEpisode("mid", "Mid", new Date("2026-08-02T00:00:00Z")),
|
||||
];
|
||||
|
||||
const merged = mergeEpisodes(existing, fetched, 10);
|
||||
|
||||
expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]);
|
||||
});
|
||||
|
||||
test("mergeEpisodes drops the oldest episodes past the cap", () => {
|
||||
const existing = [
|
||||
makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")),
|
||||
];
|
||||
const fetched = [
|
||||
makeEpisode("day3", "Day 3", new Date("2026-08-03T00:00:00Z")),
|
||||
makeEpisode("day2", "Day 2", new Date("2026-08-02T00:00:00Z")),
|
||||
];
|
||||
|
||||
const merged = mergeEpisodes(existing, fetched, 2);
|
||||
|
||||
expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]);
|
||||
});
|
||||
|
||||
test("mergeEpisodes never mutates its inputs", () => {
|
||||
const existing = [
|
||||
makeEpisode("a", "A", new Date("2026-08-01T00:00:00Z")),
|
||||
makeEpisode("b", "B", new Date("2026-08-02T00:00:00Z")),
|
||||
];
|
||||
const fetched = [
|
||||
makeEpisode("a", "A (fetched)", new Date("2026-08-01T00:00:00Z")),
|
||||
makeEpisode("c", "C", new Date("2026-08-03T00:00:00Z")),
|
||||
];
|
||||
const existingIds = existing.map((e) => e.id);
|
||||
const existingTitles = existing.map((e) => e.title);
|
||||
const fetchedIds = fetched.map((e) => e.id);
|
||||
const fetchedTitles = fetched.map((e) => e.title);
|
||||
|
||||
mergeEpisodes(existing, fetched, 10);
|
||||
|
||||
expect(existing.map((e) => e.id)).toEqual(existingIds);
|
||||
expect(existing.map((e) => e.title)).toEqual(existingTitles);
|
||||
expect(fetched.map((e) => e.id)).toEqual(fetchedIds);
|
||||
expect(fetched.map((e) => e.title)).toEqual(fetchedTitles);
|
||||
});
|
||||
|
||||
// ── store integration ────────────────────────────────────────────────────
|
||||
|
||||
test("refresh merges new episodes without removing the volatile window", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes = [
|
||||
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
||||
];
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/volatile.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const id = feed!.id;
|
||||
addedFeedIds.push(id);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(3);
|
||||
const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime();
|
||||
|
||||
// The feed now serves the same 3 episodes plus 2 newer ones (new ids at
|
||||
// item indices 3 and 4).
|
||||
servedEpisodes = [
|
||||
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
||||
{ title: "Ep 5", date: "2026-08-05T00:00:00Z" },
|
||||
{ title: "Ep 4", date: "2026-08-04T00:00:00Z" },
|
||||
];
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshFeed(id);
|
||||
|
||||
const afterFirst = store.getFeed(id)!;
|
||||
expect(afterFirst.episodes.length).toBe(5);
|
||||
expect(afterFirst.lastUpdated.getTime()).toBeGreaterThan(beforeUpdated);
|
||||
|
||||
// Identical second refresh: no lastUpdated bump, object identity kept.
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshFeed(id);
|
||||
|
||||
const afterSecond = store.getFeed(id)!;
|
||||
expect(afterSecond).toBe(afterFirst);
|
||||
expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime());
|
||||
});
|
||||
|
||||
test("cached episodes are capped at MAX_EPISODES_IN_MEMORY", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes = Array.from({ length: 600 }, (_, i) => ({
|
||||
title: `Ep ${600 - i}`,
|
||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/huge.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const id = feed!.id;
|
||||
addedFeedIds.push(id);
|
||||
|
||||
// Subscribe window (MAX_EPISODES_SUBSCRIBE = 20) with 480 more cached.
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
||||
|
||||
// Load in MAX_EPISODES_REFRESH chunks until the cache is exhausted.
|
||||
let maxLoaded = 0;
|
||||
let iterations = 0;
|
||||
while (store.hasMoreEpisodes(id) && iterations < 20) {
|
||||
await store.loadMoreEpisodes(id);
|
||||
maxLoaded = Math.max(maxLoaded, store.getFeed(id)!.episodes.length);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
expect(iterations).toBeLessThan(20);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(MAX_EPISODES_IN_MEMORY);
|
||||
expect(maxLoaded).toBeLessThanOrEqual(MAX_EPISODES_IN_MEMORY);
|
||||
});
|
||||
204
tests/global-activity-indicator.test.tsx
Normal file
204
tests/global-activity-indicator.test.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Global activity indicator — the shared leak-proof activity store
|
||||
* (begin/end counter + track helper) and the global top-right overlay that
|
||||
* surfaces feed refresh, fetch-more, subscribe fetch, search, and download
|
||||
* activity. The download transfer is left in flight on purpose (the test
|
||||
* server delays its response) so the "Downloading" state is observable in
|
||||
* the captured frame.
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config/data dirs at throwaway directories BEFORE importing the
|
||||
// stores (their module-level init reads them) and silence the audio backend.
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-activity-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
const dataHome = mkdtempSync(join(tmpdir(), "podtui-activity-data-"));
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||
import { GlobalActivityIndicator } from "../src/components/GlobalActivityIndicator";
|
||||
import { useActivityStore } from "../src/stores/activity";
|
||||
import { useDownloadStore } from "../src/stores/download";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
|
||||
// The LoadingIndicator glyph cycle.
|
||||
const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
|
||||
|
||||
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
|
||||
const frameLines = (f: Frame): string[] =>
|
||||
f.lines.map((l) => l.spans.map((s) => s.text).join(""));
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
setTimeout(resolve, ms);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Render the indicator in isolation under the dark theme. The ThemeProvider
|
||||
* gates children on async init (capabilities + palette detection, up to
|
||||
* ~1.5s under tmux), so settle frames until the indicator is mounted. */
|
||||
async function renderIndicator() {
|
||||
const setup = await testRender(
|
||||
() => (
|
||||
<ThemeProvider mode="dark">
|
||||
<GlobalActivityIndicator />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 60, height: 10, useThread: false },
|
||||
);
|
||||
await setup.renderOnce();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await setup.renderOnce();
|
||||
await sleep(50);
|
||||
}
|
||||
return setup;
|
||||
}
|
||||
|
||||
const frameText = (setup: { captureSpans: () => unknown }): string =>
|
||||
frameLines(setup.captureSpans() as unknown as Frame).join("\n");
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let audioUrl = "";
|
||||
/** Response delay (ms) for the next audio request — keeps the transfer in
|
||||
* flight while the "Downloading" state is asserted. */
|
||||
let audioDelayMs = 0;
|
||||
/** Episode ids this file started downloads for (shared singleton cleanup). */
|
||||
const downloadedEpisodeIds: string[] = [];
|
||||
|
||||
const makeEpisode = (id: string, title: string): Episode => ({
|
||||
id,
|
||||
podcastId: "pod",
|
||||
title,
|
||||
description: "",
|
||||
audioUrl,
|
||||
duration: 0,
|
||||
pubDate: new Date("2026-08-01T00:00:00Z"),
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
const { promise, resolve } = Promise.withResolvers<Response>();
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
new Response("audio bytes", {
|
||||
headers: { "Content-Type": "audio/mpeg" },
|
||||
}),
|
||||
),
|
||||
audioDelayMs,
|
||||
);
|
||||
return promise;
|
||||
},
|
||||
});
|
||||
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const dl = useDownloadStore();
|
||||
for (const id of downloadedEpisodeIds) {
|
||||
dl.cancelDownload(id);
|
||||
await dl.removeDownload(id);
|
||||
}
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
rmSync(dataHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("beginActivity/end pairs compose: ending one keeps the other active", () => {
|
||||
const activity = useActivityStore();
|
||||
expect(activity.isActive()).toBe(false);
|
||||
expect(activity.labels()).toEqual([]);
|
||||
|
||||
const endFirst = activity.beginActivity("Refreshing");
|
||||
const endSecond = activity.beginActivity("Refreshing");
|
||||
expect(activity.isActive()).toBe(true);
|
||||
expect(activity.labels()).toEqual(["Refreshing", "Refreshing"]);
|
||||
|
||||
endFirst();
|
||||
expect(activity.isActive()).toBe(true);
|
||||
expect(activity.labels()).toEqual(["Refreshing"]);
|
||||
|
||||
endSecond();
|
||||
expect(activity.isActive()).toBe(false);
|
||||
expect(activity.labels()).toEqual([]);
|
||||
});
|
||||
|
||||
test("track re-throws rejection and returns isActive() to its prior value", async () => {
|
||||
const activity = useActivityStore();
|
||||
const prior = activity.isActive();
|
||||
await expect(
|
||||
activity.track(Promise.reject(new Error("boom")), "Refreshing"),
|
||||
).rejects.toThrow("boom");
|
||||
expect(activity.isActive()).toBe(prior);
|
||||
expect(activity.labels()).toEqual([]);
|
||||
});
|
||||
|
||||
test("idle: renders nothing, no spinner, no label", async () => {
|
||||
const setup = await renderIndicator();
|
||||
const text = frameText(setup);
|
||||
expect(text).not.toMatch(SPINNER_RE);
|
||||
expect(text).not.toContain("…");
|
||||
expect(text).not.toContain("Downloading");
|
||||
expect(text.trim()).toBe("");
|
||||
setup.renderer.destroy();
|
||||
});
|
||||
|
||||
test("tracked activity: spinner + label appear while active, vanish on end", async () => {
|
||||
const activity = useActivityStore();
|
||||
const setup = await renderIndicator();
|
||||
expect(frameText(setup)).not.toMatch(SPINNER_RE);
|
||||
|
||||
const end = activity.beginActivity("Refreshing");
|
||||
await setup.renderOnce();
|
||||
const active = frameText(setup);
|
||||
expect(active).toMatch(SPINNER_RE);
|
||||
expect(active).toContain("Refreshing…");
|
||||
|
||||
end();
|
||||
await setup.renderOnce();
|
||||
const done = frameText(setup);
|
||||
expect(done).not.toMatch(SPINNER_RE);
|
||||
expect(done).not.toContain("Refreshing…");
|
||||
expect(done.trim()).toBe("");
|
||||
setup.renderer.destroy();
|
||||
});
|
||||
|
||||
test("active download: 'Downloading' label appears and disappears", async () => {
|
||||
const dl = useDownloadStore();
|
||||
const setup = await renderIndicator();
|
||||
|
||||
// Keep the transfer in flight while asserting; the response only lands
|
||||
// after audioDelayMs, so the download stays DOWNLOADING across renders.
|
||||
audioDelayMs = 400;
|
||||
const episode = makeEpisode("activity-dl-ep", "DL Ep");
|
||||
downloadedEpisodeIds.push(episode.id);
|
||||
dl.startDownload(episode, "activity-test-feed");
|
||||
|
||||
await setup.renderOnce();
|
||||
const during = frameText(setup);
|
||||
expect(during).toContain("Downloading 1");
|
||||
|
||||
// Cancel: the abort settles the fetch and activeCount returns to 0.
|
||||
dl.cancelDownload(episode.id);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (dl.getActiveCount() + dl.getQueue().length === 0) break;
|
||||
await sleep(25);
|
||||
}
|
||||
await dl.removeDownload(episode.id);
|
||||
await setup.renderOnce();
|
||||
const after = frameText(setup);
|
||||
expect(dl.getActiveCount() + dl.getQueue().length).toBe(0);
|
||||
expect(after).not.toMatch(SPINNER_RE);
|
||||
expect(after).not.toContain("Downloading");
|
||||
|
||||
audioDelayMs = 0;
|
||||
setup.renderer.destroy();
|
||||
});
|
||||
@@ -66,6 +66,7 @@ type TestPaneProps = {
|
||||
current?: (() => unknown) | unknown;
|
||||
preview?: unknown;
|
||||
focused?: unknown;
|
||||
currentBorder?: unknown;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
@@ -83,6 +84,7 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
|
||||
preview={props.preview as any}
|
||||
currentLabel="List"
|
||||
focused={props.focused as any}
|
||||
currentBorder={props.currentBorder as any}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
),
|
||||
@@ -232,4 +234,17 @@ describe("PaneRow current-pane borders", () => {
|
||||
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||
});
|
||||
|
||||
test("currentBorder=['left'] removes the right edge (left only)", async () => {
|
||||
const { spans, destroy } = await renderPaneRow({
|
||||
parent: null,
|
||||
current: () => <text>ITEM</text>,
|
||||
preview: null,
|
||||
currentBorder: ["left"],
|
||||
});
|
||||
cleanups.push(destroy);
|
||||
// Only the left border glyph at column 20 — no right edge at 69.
|
||||
expect(borderColumns(spans)).toEqual([20]);
|
||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user