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:
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 };
|
||||
Reference in New Issue
Block a user