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 @@ + +Grouped Showhttp://127.0.0.1:8792/ +Inside the OPML. +Grouped Epg-1x + \ No newline at end of file diff --git a/tests/ui/fixtures/serve.js b/tests/ui/fixtures/serve.js new file mode 100644 index 0000000..a435b2a --- /dev/null +++ b/tests/ui/fixtures/serve.js @@ -0,0 +1,19 @@ +// Serves the fixture feeds so the daemon under test has something real to scan. +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const dir = __dirname; +const port = Number(process.env.FIXTURE_PORT || 8792); + +http.createServer((req, res) => { + const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index'; + const file = path.join(dir, path.basename(name)); + fs.readFile(file, (err, body) => { + if (err) { res.writeHead(404).end('no'); return; } + const type = file.endsWith('.mp3') ? 'audio/mpeg' + : file.endsWith('.opml') ? 'text/x-opml' : 'application/xml'; + res.writeHead(200, { 'content-type': type, 'content-length': body.length }); + res.end(body); + }); +}).listen(port, '127.0.0.1', () => console.log(`fixtures on ${port}`)); diff --git a/tests/ui/fixtures/show.xml b/tests/ui/fixtures/show.xml new file mode 100644 index 0000000..fc28031 --- /dev/null +++ b/tests/ui/fixtures/show.xml @@ -0,0 +1,15 @@ + + +Test Showhttp://127.0.0.1:8792/A fixture feed. + +First Episodeui-1 + Mon, 01 Sep 2026 10:00:00 +0000 + <p>Show notes for the first one.</p> + 183011 + +Second Episodeui-2 + Mon, 08 Sep 2026 10:00:00 +0000 + Notes for the second. + 900 + + \ No newline at end of file diff --git a/tests/ui/fixtures/subs.opml b/tests/ui/fixtures/subs.opml new file mode 100644 index 0000000..298a518 --- /dev/null +++ b/tests/ui/fixtures/subs.opml @@ -0,0 +1,4 @@ +Test Subscriptions + + + \ No newline at end of file diff --git a/tests/ui/global-setup.js b/tests/ui/global-setup.js new file mode 100644 index 0000000..fce51bc --- /dev/null +++ b/tests/ui/global-setup.js @@ -0,0 +1,43 @@ +// Builds a scratch config and data dir so the browser tests drive a real daemon with +// known feeds, rather than whatever happens to be on the machine. +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const root = path.join(os.tmpdir(), 'ipx-ui-test'); +const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log + +// Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts +// webServer *before* globalSetup, so a config written there does not exist yet when the +// daemon launches -- it would fall back to the real config and fight the live daemon. +function prepare() { + fs.rmSync(root, { recursive: true, force: true }); + for (const d of ['config', 'data', 'downloads']) { + fs.mkdirSync(path.join(root, d), { recursive: true }); + } + fs.writeFileSync(path.join(root, 'config', 'config.toml'), ` +[general] +download_dir = "${path.join(root, 'downloads')}" +socket = "${path.join(root, 'ipx.sock')}" +schedule = "every 60m" +max_new_per_check = 1 + +[torrent] +enabled = false + +[web] +enabled = true +bind = "127.0.0.1:8791" +token = "${TOKEN}" + +[feeds.test-show] +url = "http://127.0.0.1:8792/show.xml" +auto_download = true + +[feeds.test-subscriptions] +url = "http://127.0.0.1:8792/subs.opml" +auto_download = false +`); +} + +module.exports = { prepare, root, TOKEN }; diff --git a/web/index.html b/web/index.html index b0586b7..0463908 100644 --- a/web/index.html +++ b/web/index.html @@ -432,14 +432,15 @@ function renderFeeds(){ for(const f of shown){ if(f.group && byId[f.group]) continue; // drawn under its parent instead order.push([f,0]); - // A subscription can hold dozens of feeds, so the folder starts closed. - if(!collapsed.has(f.id) || q) for(const c of shown) if(c.group===f.id) order.push([c,1]); + // A subscription can hold dozens of feeds, so a folder starts closed. Searching + // opens them all, or matches inside a closed folder would be invisible. + if(expanded.has(f.id) || q) for(const c of shown) if(c.group===f.id) order.push([c,1]); } for(const [f,depth] of order){ const kids=shown.filter(c=>c.group===f.id).length; const el=document.createElement('div'); el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':''); - const open = kids && (!collapsed.has(f.id) || q); + const open = kids && (expanded.has(f.id) || q); el.innerHTML = (kids?``:'')+ artHTML(f.image,f.title||f.id)+ @@ -770,13 +771,26 @@ function closeModal(){ } /* ---------------- log view ---------------- */ -let logTimer=null, logSeq=0, logLines=[], logFilter='', logLevel=''; +let logTimer=null, logSeq=0, logLines=[], logFilter='', logLevel='', logTab='all'; +// Which sources belong to each tab. "daemon" is the control protocol itself: every +// command in and every event out, whatever sent it. +const LOG_TABS={ + all: null, + daemon: t=>t==='ipx::io', + scan: t=>t==='ipx::scan', + web: t=>t==='ipx::http', +}; const LEVELS={ERROR:3,WARN:2,INFO:1,DEBUG:0,TRACE:0}; function logsModal(){ logSeq=0; logLines=[]; openModal(`

Log

+
+ ${Object.keys(LOG_TABS).map(t=> + ``).join('')} +