Log tabs with a daemon I/O view, and Playwright UI tests
The log view splits into All / Daemon I/O / Scans / HTTP. Daemon I/O is the control protocol itself, logged where every command funnels through so it covers socket clients, the CLI and the web UI alike. stderr and the in-app buffer now have separate filters, so the UI can keep debug detail the terminal should not carry. Playwright drives a real browser against a daemon on fixture feeds. Eight tests, each mapping to a bug that reached a user -- the Rust tests and the stub-DOM smoke test cannot see a wrong selector or a dead handler. It immediately found one: OPML folders rendered expanded by default, because the code stored closed groups, so any folder never toggled counted as open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
/target
|
||||
/node_modules
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
33
PROGRESS.md
33
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
|
||||
|
||||
27
README.md
27
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
58
package-lock.json
generated
Normal file
58
package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
package.json
Normal file
13
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
43
playwright.config.js
Normal file
43
playwright.config.js
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
25
src/main.rs
25
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;
|
||||
}
|
||||
|
||||
127
tests/ui/app.spec.js
Normal file
127
tests/ui/app.spec.js
Normal file
@@ -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);
|
||||
});
|
||||
BIN
tests/ui/fixtures/ep1.mp3
Normal file
BIN
tests/ui/fixtures/ep1.mp3
Normal file
Binary file not shown.
5
tests/ui/fixtures/other.xml
Normal file
5
tests/ui/fixtures/other.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>Grouped Show</title><link>http://127.0.0.1:8792/</link>
|
||||
<description>Inside the OPML.</description>
|
||||
<item><title>Grouped Ep</title><guid>g-1</guid><description>x</description></item>
|
||||
</channel></rss>
|
||||
19
tests/ui/fixtures/serve.js
Normal file
19
tests/ui/fixtures/serve.js
Normal file
@@ -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}`));
|
||||
15
tests/ui/fixtures/show.xml
Normal file
15
tests/ui/fixtures/show.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel><title>Test Show</title><link>http://127.0.0.1:8792/</link><description>A fixture feed.</description>
|
||||
<itunes:image href="http://127.0.0.1:8792/art.png"/>
|
||||
<item><title>First Episode</title><guid>ui-1</guid>
|
||||
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||
<description><p>Show notes for the first one.</p></description>
|
||||
<itunes:duration>1830</itunes:duration><itunes:season>1</itunes:season><itunes:episode>1</itunes:episode>
|
||||
<enclosure url="http://127.0.0.1:8792/ep1.mp3" length="40000" type="audio/mpeg"/></item>
|
||||
<item><title>Second Episode</title><guid>ui-2</guid>
|
||||
<pubDate>Mon, 08 Sep 2026 10:00:00 +0000</pubDate>
|
||||
<description>Notes for the second.</description>
|
||||
<itunes:duration>900</itunes:duration>
|
||||
<enclosure url="http://127.0.0.1:8792/ep1.mp3?2" length="40000" type="audio/mpeg"/></item>
|
||||
</channel></rss>
|
||||
4
tests/ui/fixtures/subs.opml
Normal file
4
tests/ui/fixtures/subs.opml
Normal file
@@ -0,0 +1,4 @@
|
||||
<opml version="2.0"><head><title>Test Subscriptions</title></head>
|
||||
<body><outline text="Folder">
|
||||
<outline type="rss" text="Grouped Show" xmlUrl="http://127.0.0.1:8792/other.xml"/>
|
||||
</outline></body></opml>
|
||||
43
tests/ui/global-setup.js
Normal file
43
tests/ui/global-setup.js
Normal file
@@ -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 };
|
||||
@@ -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?`<span class="chev${open?' open':''}" title="Show or hide the feeds inside">▶</span>`:'')+
|
||||
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(`<h3>Log</h3>
|
||||
<div class="logbar">
|
||||
<div class="tabs" id="logtabs">
|
||||
${Object.keys(LOG_TABS).map(t=>
|
||||
`<button data-t="${t}" class="${logTab===t?'on':''}">${
|
||||
{all:'All',daemon:'Daemon I/O',scan:'Scans',web:'HTTP'}[t]}</button>`).join('')}
|
||||
</div>
|
||||
<select id="loglevel" style="width:auto">
|
||||
<option value="">All levels</option>
|
||||
<option value="INFO">Info and above</option>
|
||||
@@ -789,9 +803,16 @@ function logsModal(){
|
||||
<button class="btn" onclick="closeModal()">Close</button>
|
||||
</div>
|
||||
<div id="logbox"><p class="empty">Loading…</p></div>
|
||||
<span class="hint">Live from the running daemon: feed scans, downloads, torrents and every
|
||||
HTTP request. Set <b>IPX_LOG=ipx=debug</b> for more detail.</span>`, true);
|
||||
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
|
||||
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
|
||||
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
||||
what it captures.</span>`, true);
|
||||
|
||||
$$('#logtabs button').forEach(b=>b.onclick=()=>{
|
||||
logTab=b.dataset.t;
|
||||
$$('#logtabs button').forEach(x=>x.classList.toggle('on',x.dataset.t===logTab));
|
||||
drawLog();
|
||||
});
|
||||
$('#loglevel').onchange=e=>{ logLevel=e.target.value; drawLog(); };
|
||||
$('#logq').oninput=e=>{ logFilter=e.target.value.toLowerCase(); drawLog(); };
|
||||
$('#logcopy').onclick=()=>copyText(visibleLog().map(l=>
|
||||
@@ -816,7 +837,9 @@ async function pollLog(){
|
||||
}
|
||||
function visibleLog(){
|
||||
const min=logLevel?LEVELS[logLevel]:-1;
|
||||
const tab=LOG_TABS[logTab];
|
||||
return logLines.filter(l=>
|
||||
(!tab || tab(l.target)) &&
|
||||
(LEVELS[l.level]??1)>=min &&
|
||||
(!logFilter || (l.msg+' '+l.target).toLowerCase().includes(logFilter)));
|
||||
}
|
||||
@@ -826,6 +849,12 @@ function drawLog(){
|
||||
const rows=visibleLog();
|
||||
box.innerHTML = rows.length ? rows.map(l=>{
|
||||
const t=new Date(l.ts*1000).toLocaleTimeString();
|
||||
if(logTab==='daemon'){
|
||||
const out=l.msg.startsWith('<-');
|
||||
return `<div class="l"><time>${t}</time>`+
|
||||
`<span class="lv" style="color:${out?'var(--good)':'var(--accent)'}">${out?'out':'in'}</span>`+
|
||||
`<span>${esc(l.msg.replace(/^[<-]+\s*/,''))}</span></div>`;
|
||||
}
|
||||
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>`+
|
||||
`<span class="tg">${esc(l.target.replace(/^ipx::?/,''))}</span><span>${esc(l.msg)}</span></div>`;
|
||||
}).join('') : '<p class="empty">Nothing matches.</p>';
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user