diff --git a/.gitignore b/.gitignore
index ea8c4bf..1a0a581 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,4 @@
/target
+/node_modules
+/test-results
+/playwright-report
diff --git a/PROGRESS.md b/PROGRESS.md
index b5c9504..66305fe 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -56,6 +56,39 @@ and until now nothing set them.
---
+## 2026-09-10 — Daemon I/O log tab, and Playwright
+
+**Log tabs.** All / Daemon I/O / Scans / HTTP. "Daemon I/O" is the control protocol itself: every
+command arriving (`-> {"cmd":...}`) and every event leaving (`<- {"ev":...}`), logged under
+`ipx::io` at the one point every command funnels through, so it catches socket clients, the CLI
+proxying and the web UI alike. Rendered in that tab as in/out rather than level+target.
+
+The two logging destinations now have **separate filters**: stderr follows `IPX_LOG` (default
+`ipx=info`), the in-app buffer follows `IPX_UI_LOG` (default `ipx=debug`) and holds 5000 lines. The
+UI can therefore show protocol traffic and routine skips that would be noise on a terminal.
+
+**Playwright.** `npm install` plus `npx playwright install --with-deps chromium`, added to
+`/src/install.sh`. `playwright.config.js` starts a fixture feed server and a daemon on a scratch
+config, and eight tests drive a real browser. Each maps to a bug that actually reached Ray.
+
+Two things the harness taught, both worth keeping:
+
+- **Playwright starts `webServer` before `globalSetup`.** Writing the scratch config in globalSetup
+ meant the daemon launched with no config, fell back to defaults, and tried to seize the live
+ daemon's socket. The config is written at config-load time instead.
+- Ports 8097/8771 looked free but `ss` showed 8097 held by a process outside this container (shared
+ host network). Moved to 8791/8792.
+
+**The suite found a real bug on its first green-ish run:** OPML folders rendered *expanded* by
+default. The code stored the set of closed groups, so a folder never toggled — every folder in a
+fresh browser — counted as open, the exact opposite of the comment above it. It stores the open ones
+now. Two other failures were my tests' fault, not the code's: asserting `S1E1` on `.ep.first()` when
+newest-first put a different episode there, and an unscoped `getByText` matching both the sidebar
+entry and the page heading. The `SE` in that first failure was the artwork placeholder's initials,
+which I nearly misread as a broken chip.
+
+---
+
## 2026-09-10 — Regression: derived feeds looked "unsubscribed" to half the code
Reported as `error: enclosure 235 belongs to unsubscribed feed "abort-retry-fail"`. Moving OPML
diff --git a/README.md b/README.md
index bf2254e..004baae 100644
--- a/README.md
+++ b/README.md
@@ -163,11 +163,32 @@ Put it behind a reverse proxy with TLS if that matters to you.
## Log view
-The **Log** button in the sidebar shows the running daemon's output live: feed scans, downloads,
+The **Log** button in the sidebar shows the running daemon's output live, in four tabs:
+**Daemon I/O** is the control protocol itself — every command in and every event out, as JSON;
+**Scans** is feed and download activity; **HTTP** is web requests; **All** is everything: feed scans, downloads,
torrent activity and every HTTP request, with level and text filters and a copy button. It reads a
2000-line ring buffer held inside the process (`/api/logs`), not a file — so it works the same under
-Docker, where logs go to stdout and there is no file to tail. `IPX_LOG=ipx=debug` adds detail;
-`IPX_LOG=ipx=info,librqbit=info` shows what torrents are doing.
+Docker, where logs go to stdout and there is no file to tail. The buffer keeps `debug` even when the terminal does not, so protocol traffic and routine
+skips are there without making stderr unreadable — `IPX_UI_LOG` changes what it captures and
+`IPX_LOG` what reaches stderr.
+
+## Tests
+
+```sh
+cargo test # the server: parsing, filters, retention, schedules, SQL
+node tests/page-smoke.js # the page script loads without throwing
+npx playwright test # a real browser against a real daemon
+```
+
+The Rust tests cover the server and the smoke test catches a script that fails to load, but neither
+can see a wrong selector, a handler that runs and does nothing, or a page that renders empty — which
+is what has actually slipped through. The Playwright suite drives a headless browser against a
+daemon started on fixture feeds, and each test maps to a bug that reached a user: the page rendering
+empty, a dead theme toggle, settings not persisting, episode metadata, filter tabs, the feed URL
+field and its copy button, the log tabs, and OPML folders.
+
+`npm install` gets the test runner; the browser itself comes from
+`npx playwright install --with-deps chromium` (in `install.sh`).
## Docker
diff --git a/docker-compose.yml b/docker-compose.yml
index a93f5a1..c3a2fcb 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,8 +1,8 @@
services:
- ipx:
+ iPodderX:
build: .
# image: ipx:latest # swap build: for image: once you have published one
- container_name: ipx
+ container_name: iPodderX
restart: unless-stopped
environment:
# Unraid shares expect these; downloads land owned by nobody:users.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..28544fb
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,58 @@
+{
+ "name": "ipx-ui-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ipx-ui-tests",
+ "devDependencies": {
+ "@playwright/test": "^1.56.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2483bb1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "ipx-ui-tests",
+ "private": true,
+ "description": "Browser tests for the ipx web UI. The Rust tests cover the server; these cover the page.",
+ "scripts": {
+ "test": "playwright test",
+ "test:headed": "playwright test --headed",
+ "smoke": "node tests/page-smoke.js"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.56.0"
+ }
+}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..6e2dab3
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,43 @@
+const { defineConfig } = require('@playwright/test');
+const setup = require('./tests/ui/global-setup');
+
+// Before anything else, including the servers below.
+setup.prepare();
+
+// Real browser against a real daemon. The stub-DOM smoke test catches a script that
+// fails to load; it cannot catch a wrong selector, a handler that runs but does nothing,
+// or a page that renders empty -- which is exactly what has slipped through before.
+module.exports = defineConfig({
+ testDir: './tests/ui',
+ timeout: 30_000,
+ expect: { timeout: 10_000 },
+ fullyParallel: false, // one daemon, one database
+ workers: 1,
+ reporter: process.env.CI ? 'line' : [['list']],
+ use: {
+ baseURL: 'http://127.0.0.1:8791',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ },
+ webServer: [
+ {
+ command: 'node tests/ui/fixtures/serve.js',
+ port: 8792,
+ reuseExistingServer: false,
+ stdout: 'ignore',
+ },
+ {
+ // Build first so the tests always run against current source.
+ command: 'cargo build -q && exec ./target/debug/ipx daemon',
+ port: 8791,
+ reuseExistingServer: false,
+ timeout: 180_000,
+ stdout: 'pipe',
+ env: {
+ IPX_CONFIG: `${setup.root}/config/config.toml`,
+ IPX_DATA_DIR: `${setup.root}/data`,
+ IPX_LOG: 'ipx=info',
+ },
+ },
+ ],
+});
diff --git a/src/ipc.rs b/src/ipc.rs
index f1337e6..1adef43 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -149,6 +149,15 @@ impl Emitter {
}
if let Some(tx) = &self.tx {
+ // The outbound half of the protocol, as it goes on the wire. Progress is the
+ // high-volume one, so it sits at debug.
+ if let Ok(json) = serde_json::to_string(&e) {
+ if matches!(e, Event::Progress { .. }) {
+ tracing::debug!(target: "ipx::io", "<- {json}");
+ } else {
+ tracing::info!(target: "ipx::io", "<- {json}");
+ }
+ }
// An error here only means nobody is listening yet.
let _ = tx.send(e.clone());
}
diff --git a/src/logbuf.rs b/src/logbuf.rs
index 4eb7bc0..443ec3f 100644
--- a/src/logbuf.rs
+++ b/src/logbuf.rs
@@ -10,7 +10,7 @@ use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
/// Kept small enough to be cheap to hold and to serialise in one response.
-const CAPACITY: usize = 2000;
+const CAPACITY: usize = 5000;
#[derive(Clone, Debug, serde::Serialize)]
pub struct LogLine {
diff --git a/src/main.rs b/src/main.rs
index 97ea866..77fe69c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -126,13 +126,21 @@ async fn main() -> Result<()> {
{
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
+ use tracing_subscriber::Layer;
+ // Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
+ // keeps debug as well, so the log view can show protocol traffic and routine
+ // skips that would be noise on a terminal. IPX_UI_LOG overrides it.
+ let stderr_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_LOG")
+ .unwrap_or_else(|_| "ipx=info".into());
+ let ui_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG")
+ .unwrap_or_else(|_| "ipx=debug".into());
tracing_subscriber::registry()
.with(
- tracing_subscriber::EnvFilter::try_from_env("IPX_LOG")
- .unwrap_or_else(|_| "ipx=info".into()),
+ tracing_subscriber::fmt::layer()
+ .with_writer(std::io::stderr)
+ .with_filter(stderr_filter),
)
- .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
- .with(logbuf::RingLayer)
+ .with(logbuf::RingLayer.with_filter(ui_filter))
.init();
}
@@ -282,7 +290,14 @@ async fn daemon(
biased;
_ = stop.changed() => break,
Some(cmd) = rx_cmd.recv() => {
- tracing::info!(?cmd, "command from a client");
+ // Both halves of the protocol are logged under one target so the UI can
+ // show the conversation on its own: this is everything arriving, whatever
+ // the source -- a socket client, the CLI proxying, or the web UI.
+ tracing::info!(
+ target: "ipx::io",
+ "-> {}",
+ serde_json::to_string(&cmd).unwrap_or_else(|_| format!("{cmd:?}"))
+ );
if !until_stopped(&ctx, &rx_stop, run(&ctx, cmd)).await {
break;
}
diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js
new file mode 100644
index 0000000..52c1c7d
--- /dev/null
+++ b/tests/ui/app.spec.js
@@ -0,0 +1,127 @@
+const { test, expect } = require('@playwright/test');
+const { TOKEN } = require('./global-setup');
+
+// The token sets a cookie, so every test starts by presenting it once.
+test.beforeEach(async ({ page }) => {
+ await page.goto(`/?token=${TOKEN}`);
+ await expect(page.locator('#feedlist')).toBeVisible();
+});
+
+test('the page loads and lists the configured feeds', async ({ page }) => {
+ // Regression: a ReferenceError in the script left the shell rendered and the sidebar
+ // empty, with every handler below the error dead. Server-side checks all passed.
+ await expect(page.locator('.feed')).toHaveCount(2, { timeout: 15_000 });
+ await expect(page.getByText('Test Show')).toBeVisible();
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ await page.reload();
+ await expect(page.locator('.feed').first()).toBeVisible();
+ expect(errors, 'the page script must not throw at load').toEqual([]);
+});
+
+test('the theme toggle actually changes the theme', async ({ page }) => {
+ // Regression: this button was wired after a line that threw, so it did nothing.
+ const before = await page.evaluate(() => document.documentElement.dataset.theme || 'system');
+ await page.locator('#theme').click();
+ await expect
+ .poll(() => page.evaluate(() => document.documentElement.dataset.theme))
+ .not.toBe(before);
+});
+
+test('settings opens and saves the global schedule', async ({ page }) => {
+ await page.locator('#prefs').click();
+ await expect(page.locator('#modal.on')).toBeVisible();
+ await expect(page.locator('#gnum')).toBeVisible();
+ await page.locator('#gnum').fill('4');
+ await page.locator('#gunit').selectOption('h');
+ await page.locator('#gsave').click();
+ await expect(page.locator('#modal.on')).toBeHidden();
+
+ // It must survive a reload, i.e. actually reach the config.
+ await page.locator('#prefs').click();
+ await expect(page.locator('#gnum')).toHaveValue('4');
+ await expect(page.locator('#gunit')).toHaveValue('h');
+});
+
+test('episodes show with their metadata, and notes expand', async ({ page }) => {
+ await page.getByText('Test Show').click();
+ await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
+ await expect(page.getByText('First Episode')).toBeVisible();
+ // Newest first, so target the episode by name rather than by position.
+ const first = page.locator('.ep', { hasText: 'First Episode' });
+ await expect(first).toContainText('S1E1');
+ await expect(first).toContainText('30:30'); // itunes:duration 1830
+ await expect(page.locator('.ep', { hasText: 'Second Episode' })).toContainText('15:00');
+
+ await first.locator('.t').click();
+ await expect(page.locator('.notes').first()).toContainText('Show notes for the first one');
+});
+
+test('the filter tabs change what is listed', async ({ page }) => {
+ await page.getByText('Test Show').click();
+ await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
+ const unread = await page.locator('.ep').count();
+
+ await page.locator('.tabs button', { hasText: 'All' }).first().click();
+ await expect(page.locator('#count')).toContainText('episode');
+ expect(await page.locator('.ep').count()).toBeGreaterThanOrEqual(unread);
+
+ await page.locator('.tabs button', { hasText: 'Flagged' }).first().click();
+ await expect(page.locator('#count')).toContainText('0 episodes');
+});
+
+test('a feed URL is editable and has a copy button', async ({ page }) => {
+ await page.getByText('Test Show').click();
+ await page.locator('.btn', { hasText: 'Settings' }).first().click();
+ await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
+ await expect(page.locator('#scopy')).toBeVisible();
+
+ // navigator.clipboard is absent over plain http, so the button must not throw.
+ const errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ await page.locator('#scopy').click();
+ await expect(page.locator('#scopy')).toHaveText(/Copied|Failed/);
+ expect(errors).toEqual([]);
+});
+
+test('the log view has tabs and shows daemon traffic', async ({ page }) => {
+ await page.locator('#logs').click();
+ await expect(page.locator('#logbox')).toBeVisible();
+ await expect(page.locator('#logtabs button')).toHaveCount(4);
+
+ // Generate traffic, then check the Daemon I/O tab shows both directions.
+ await page.locator('#logtabs button', { hasText: 'Daemon I/O' }).click();
+ await page.evaluate(() =>
+ fetch('/api/fetch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ force: true }),
+ }));
+ await expect(page.locator('#logbox .l').first()).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('#logbox')).toContainText('"cmd":"fetch"', { timeout: 15_000 });
+ await expect(page.locator('#logbox')).toContainText('"ev":', { timeout: 15_000 });
+});
+
+test('an OPML subscription is a collapsible folder', async ({ page }) => {
+ // Read the subscription so its feeds exist.
+ await page.evaluate(() =>
+ fetch('/api/fetch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
+ }));
+
+ const chev = page.locator('.feed .chev');
+ await expect(chev).toBeVisible({ timeout: 20_000 });
+
+ // Closed by default: the child is not listed until the folder is opened.
+ const before = await page.locator('.feed').count();
+ await chev.click();
+ await expect(page.locator('.feed')).toHaveCount(before + 1);
+ // Scoped to the sidebar: the name also appears as the page heading once selected.
+ await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
+
+ // The subscription's own page lists what is inside it.
+ await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
+ await expect(page.locator('.childrow')).toHaveCount(1);
+});
diff --git a/tests/ui/fixtures/ep1.mp3 b/tests/ui/fixtures/ep1.mp3
new file mode 100644
index 0000000..239bc1b
Binary files /dev/null and b/tests/ui/fixtures/ep1.mp3 differ
diff --git a/tests/ui/fixtures/other.xml b/tests/ui/fixtures/other.xml
new file mode 100644
index 0000000..13977bb
--- /dev/null
+++ b/tests/ui/fixtures/other.xml
@@ -0,0 +1,5 @@
+
+
Loading…
Nothing matches.
'; @@ -855,10 +884,10 @@ $('#addFeed').onclick=()=>{ }; }; -let collapsed = new Set(JSON.parse(localStorage.getItem('ipx.collapsed')||'[]')); +let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); function toggleGroup(id){ - collapsed.has(id) ? collapsed.delete(id) : collapsed.add(id); - try{ localStorage.setItem('ipx.collapsed', JSON.stringify([...collapsed])); }catch{} + expanded.has(id) ? expanded.delete(id) : expanded.add(id); + try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{} renderFeeds(); } let globalEvery = 60, globalMax = 3;