Reload the episode that was loaded in the player when the previous run ended (persisted on play/load and synchronously at exit) into the Player tab paused at its saved position — never autostarted. Episodes at or above 98% completion are skipped, as are empty-player and unsubscribed episodes. - useAudio gains load(episode) (sets currentEpisode/position/Now Playing without starting the backend) and restoreLastSession(), triggered once at boot and serialized through a chain so a late-finishing boot restore can't clobber later state. - togglePlayback branches on a startedPlayback flag: a restored episode starts the backend from the saved position; a paused one resumes. - stop() clears the marker; the exit teardown writes it synchronously (process.exit bypasses async writes). - feed/progress stores expose whenReady() so restore waits for the async boot loads; feed store gains findEpisode(). - app-persistence serializes last-player marker writes and exposes waitForLastPlayerWrite() for deterministic tests. - tests/restore-session.test.ts: real modules + local RSS feed server; the real useAudio is imported via a ?restore-test query suffix to bypass the suite's mock.module leak across shared bun workers.
196 lines
7.1 KiB
TypeScript
196 lines
7.1 KiB
TypeScript
/**
|
|
* restore-session.test.ts — "load the last player session at boot" feature.
|
|
*
|
|
* useAudio persists which episode is loaded in the player (on play/load/stop
|
|
* and synchronously at exit), and `restoreLastSession()` reloads it at boot
|
|
* PAUSED at its saved position — never autostarted — skipping episodes at or
|
|
* above 98% completion. The first play action must START the backend (a
|
|
* restored episode was never handed to it), not unpause a dead player.
|
|
*
|
|
* Strategy — the suite shares bun test workers across files, so OTHER test
|
|
* files' `mock.module("../src/hooks/useAudio")` leaks into this file's module
|
|
* registry, and store singletons may already exist. Hence:
|
|
* - the real useAudio is imported via a `?restore-test` query suffix, which
|
|
* bun treats as a distinct module identity and loads from disk, bypassing
|
|
* the suite's useAudio mock (verified: query-suffixed imports are not
|
|
* intercepted by mock.module);
|
|
* - feeds are injected through the REAL feed store's public addFeed() API
|
|
* against a local RSS server (no config seeding — works on whatever
|
|
* singleton state this worker holds), and progress through update().
|
|
* Audio is the no-op backend via PODTUI_AUDIO_BACKEND=none.
|
|
*/
|
|
import { test, expect, afterAll } from "bun:test";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
|
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-restore-"));
|
|
const DATA = mkdtempSync(join(tmpdir(), "podtui-restore-data-"));
|
|
process.env.XDG_CONFIG_HOME = CONFIG;
|
|
process.env.XDG_DATA_HOME = DATA;
|
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
|
|
|
// ── Local RSS feed server (episode ids = feedUrl#index) ────────────────────
|
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
function feedXml(origin: string): string {
|
|
const items = ["Episode One", "Episode Two"]
|
|
.map(
|
|
(title) => `<item>
|
|
<title>${title}</title>
|
|
<pubDate>2026-08-10T00:00:00Z</pubDate>
|
|
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
|
|
</item>`,
|
|
)
|
|
.join("\n");
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0"><channel>
|
|
<title>Restore Test Show</title>
|
|
<description>restore-session test feed</description>
|
|
${items}
|
|
</channel></rss>`;
|
|
}
|
|
|
|
server = Bun.serve({
|
|
port: 0,
|
|
fetch(req) {
|
|
const url = new URL(req.url);
|
|
if (url.pathname.endsWith(".xml")) {
|
|
return new Response(feedXml(url.origin), {
|
|
headers: { "Content-Type": "application/rss+xml" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
},
|
|
});
|
|
|
|
// ── Real modules (loaded after env + server are up) ───────────────────────
|
|
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
|
// loads the real file instead of a leaked mock.module from another test file.
|
|
const { useAudio, restoreLastSession } = await import("../src/hooks/useAudio?restore-test");
|
|
const { useFeedStore } = await import("../src/stores/feed");
|
|
const { useProgressStore } = await import("../src/stores/progress");
|
|
const { saveLastPlayerToFile, waitForLastPlayerWrite } = await import(
|
|
"../src/utils/app-persistence"
|
|
);
|
|
|
|
const feedStore = useFeedStore();
|
|
const progressStore = useProgressStore();
|
|
|
|
// Subscribe to the local feed through the real store API.
|
|
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
|
|
const feed = await feedStore.addFeed(
|
|
{
|
|
id: feedUrl,
|
|
title: "Restore Test Show",
|
|
description: "restore-session test feed",
|
|
feedUrl,
|
|
lastUpdated: new Date(),
|
|
isSubscribed: true,
|
|
},
|
|
"test-source",
|
|
);
|
|
if (!feed || feed.episodes.length < 2) {
|
|
throw new Error("test feed did not load two episodes");
|
|
}
|
|
const ep1 = feed.episodes[0];
|
|
const ep2 = feed.episodes[1];
|
|
|
|
// Simulate a previous session: ep1 loaded, 20% through, marker persisted.
|
|
progressStore.update(ep1.id, 120, 600);
|
|
saveLastPlayerToFile({ episodeId: ep1.id, timestamp: new Date() });
|
|
await waitForLastPlayerWrite();
|
|
|
|
/** Rewrite the marker as if a previous session had ended this way. */
|
|
async function writeMarker(episodeId: string | null): Promise<void> {
|
|
saveLastPlayerToFile({ episodeId, timestamp: new Date() });
|
|
await waitForLastPlayerWrite();
|
|
}
|
|
|
|
/** Read the current last-player marker from disk. */
|
|
async function readMarker(): Promise<{ episodeId: string | null } | null> {
|
|
const file = Bun.file(join(CONFIG, "podtui", "last-player.json"));
|
|
if (!(await file.exists())) return null;
|
|
return (await file.json()) as { episodeId: string | null };
|
|
}
|
|
|
|
afterAll(() => {
|
|
server?.stop(true);
|
|
rmSync(CONFIG, { recursive: true, force: true });
|
|
rmSync(DATA, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
|
|
test("restore loads the last player episode paused, without autostart", async () => {
|
|
const audio = useAudio(); // boot trigger also fires restoreLastSession()
|
|
await restoreLastSession();
|
|
|
|
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
|
expect(audio.isPlaying()).toBe(false);
|
|
// Position reflects where playback will resume — the player tab shows it.
|
|
expect(audio.position()).toBe(120);
|
|
});
|
|
|
|
test("first play on a restored episode starts playback from saved progress", async () => {
|
|
const audio = useAudio();
|
|
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
|
|
|
await audio.togglePlayback();
|
|
|
|
expect(audio.isPlaying()).toBe(true);
|
|
expect(audio.position()).toBe(120);
|
|
|
|
// A second toggle pauses (normal pause path) — no backend restart.
|
|
await audio.togglePlayback();
|
|
expect(audio.isPlaying()).toBe(false);
|
|
});
|
|
|
|
test("restore skips episodes at or above 98% completion", async () => {
|
|
const audio = useAudio();
|
|
await audio.stop(); // clear the previously restored episode
|
|
await waitForLastPlayerWrite(); // stop()'s null marker has landed
|
|
await writeMarker(ep1.id); // stop() cleared the marker; restore needs it
|
|
|
|
// Exactly 98% — boundary: NOT restored.
|
|
progressStore.update(ep1.id, 588, 600);
|
|
await restoreLastSession();
|
|
expect(audio.currentEpisode()).toBeNull();
|
|
|
|
// Just under 98% — restored.
|
|
progressStore.update(ep1.id, 587, 600);
|
|
await restoreLastSession();
|
|
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
|
expect(audio.position()).toBe(587);
|
|
|
|
await audio.stop();
|
|
});
|
|
|
|
test("restore no-ops when the player was empty at last quit", async () => {
|
|
const audio = useAudio();
|
|
await writeMarker(null);
|
|
|
|
await restoreLastSession();
|
|
expect(audio.currentEpisode()).toBeNull();
|
|
});
|
|
|
|
test("restore no-ops when the episode is no longer in any feed", async () => {
|
|
const audio = useAudio();
|
|
await writeMarker("gone-ep");
|
|
|
|
await restoreLastSession();
|
|
expect(audio.currentEpisode()).toBeNull();
|
|
});
|
|
|
|
test("play persists the marker; stop clears it", async () => {
|
|
const audio = useAudio();
|
|
|
|
await audio.play(ep2);
|
|
await waitForLastPlayerWrite();
|
|
expect((await readMarker())?.episodeId).toBe(ep2.id);
|
|
|
|
await audio.stop();
|
|
await waitForLastPlayerWrite();
|
|
expect((await readMarker())?.episodeId).toBeNull();
|
|
});
|