- Settings > Manage users: add an account (password, or none for proxy
sign-in), toggle admin, remove. Backed by GET/POST /api/users and
PATCH/DELETE /api/users/{id}, 403 for non-admins. The only admin
cannot be demoted or removed.
- GET /api/logs is admin-only and the Log button is hidden for others;
the log names every account, feed and failed sign-in.
- Feeds inside an OPML list those with unread items first, in the
sidebar folder and on the subscription's page.
- Deploying is now buildx --push to 192.168.1.130:5000 and recreating
the ipodderx service of the Arcane project content; CLAUDE.md and the
README's Docker section say so.
- Tests: Playwright for user admin, the last-admin guard, 403s for a
non-admin and the unread ordering (new Aardvark Radio fixture); a unit
test for last_admin; the smoke test drives usersModal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
415 lines
19 KiB
JavaScript
415 lines
19 KiB
JavaScript
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.
|
|
// Three top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
|
await expect(page.locator('.feed')).toHaveCount(4, { 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 the text opens below', 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');
|
|
|
|
// Selecting an item shows its text in the pane below, not inline in the row.
|
|
await first.click();
|
|
await expect(first).toHaveClass(/sel/);
|
|
await expect(page.locator('#detail')).toContainText('Show notes for the first one');
|
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
|
});
|
|
|
|
test('the three panes are there and the item text lands in the bottom one', async ({ page }) => {
|
|
await page.getByText('Test Show').click();
|
|
await expect(page.locator('#list')).toBeVisible();
|
|
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
|
|
await expect(page.locator('#detail')).toContainText('Pick an item');
|
|
|
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
|
// The enclosure travels with the item, into the same pane.
|
|
await expect(page.locator('#detail .encbox')).toHaveCount(1);
|
|
|
|
// Only the downloaded one gets a player, and max_new_per_check is 1, so find it by
|
|
// its chip rather than assuming which episode the daemon happened to fetch.
|
|
const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first();
|
|
await downloaded.click();
|
|
await expect(page.locator('#detail audio')).toBeVisible();
|
|
await expect(page.locator('#detail .encbox .btn', { hasText: 'Save' })).toBeVisible();
|
|
|
|
// Selecting another item replaces the pane rather than stacking.
|
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
|
await expect(page.locator('#detail audio')).toHaveCount(0);
|
|
});
|
|
|
|
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
|
// Regression: anything with a file got an <audio> element and a play button, so a blog's
|
|
// header image rendered as a broken player.
|
|
await page.locator('.feed', { hasText: 'Picture Blog' }).click();
|
|
const row = page.locator('.ep', { hasText: 'An Article' });
|
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
|
|
|
await expect(row.locator('[data-a="play"]')).toHaveCount(0);
|
|
await row.click();
|
|
|
|
await expect(page.locator('#detail .dt')).toHaveText('An Article');
|
|
await expect(page.locator('#detail audio')).toHaveCount(0);
|
|
await expect(page.locator('#detail .encbox')).toContainText('image');
|
|
await expect(page.locator('#detail .encbox')).toContainText('downloaded');
|
|
// Still offered as a file, just not as an episode: viewable and keepable.
|
|
await expect(page.locator('#detail .btn', { hasText: 'Save' })).toBeVisible();
|
|
const view = page.locator('#detail a', { hasText: 'View' });
|
|
await expect(view).toHaveAttribute('target', '_blank');
|
|
await expect(view).toHaveAttribute('rel', /noopener/);
|
|
await expect(view).toHaveAttribute('href', /\/media\/\d+/);
|
|
});
|
|
|
|
test('an item with several enclosures lists them all', async ({ page }) => {
|
|
await page.locator('.feed', { hasText: 'Multi Show' }).click();
|
|
const row = page.locator('.ep', { hasText: 'Two Files' });
|
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
|
// The row says there is more than one without listing them.
|
|
await expect(row).toContainText('+1 more file');
|
|
|
|
await row.click();
|
|
// The pane below lists every one: the audio and the image.
|
|
await expect(page.locator('#detail .encbox')).toHaveCount(2);
|
|
await expect(page.locator('#detail .encbox').nth(1)).toContainText('image');
|
|
});
|
|
|
|
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 all = await page.locator('.ep').count(); // All is the default tab
|
|
await expect(page.locator('#count')).toContainText('item');
|
|
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
|
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
|
|
|
|
await page.locator('.tabs button', { hasText: 'Flagged' }).first().click();
|
|
await expect(page.locator('#count')).toContainText('0 items');
|
|
});
|
|
|
|
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 }),
|
|
}));
|
|
|
|
// Every row reserves the chevron slot for alignment; only a folder's is clickable.
|
|
const chev = page.locator('.feed.group .chev');
|
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
|
|
|
// Closed by default: the children are not listed until the folder is opened.
|
|
const before = await page.locator('.feed').count();
|
|
await chev.click();
|
|
await expect(page.locator('.feed')).toHaveCount(before + 2);
|
|
// 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(2);
|
|
});
|
|
|
|
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
|
|
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.group .chev');
|
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
|
await chev.click();
|
|
const side = page.locator('#feedlist');
|
|
await expect(side.getByText('Aardvark Radio')).toBeVisible({ timeout: 20_000 });
|
|
|
|
// Other tests change read state, so set it here: Aardvark Radio read, Grouped Show not.
|
|
// Opening an item reads it; the toggle in the pane below flips it back.
|
|
await side.getByText('Aardvark Radio').click();
|
|
const aa = page.locator('.ep', { hasText: 'Aardvark Ep' });
|
|
await aa.click();
|
|
await expect(aa).toHaveClass(/read/);
|
|
await side.getByText('Grouped Show').click();
|
|
const gs = page.locator('.ep', { hasText: 'Grouped Ep' });
|
|
await gs.click();
|
|
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
|
await expect(gs).not.toHaveClass(/read/);
|
|
|
|
// Aardvark comes first alphabetically and in the OPML, so only the unread sort puts
|
|
// Grouped Show above it. The folder stays open across the reload (localStorage).
|
|
await page.reload();
|
|
const want = ['Grouped Show', 'Aardvark Radio'];
|
|
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
|
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
|
await expect(page.locator('.childrow b')).toHaveText(want);
|
|
});
|
|
|
|
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
|
await page.evaluate(() =>
|
|
fetch('/api/fetch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
|
}));
|
|
|
|
// The folder's own row has no entries, so anything it marks read came from its child.
|
|
const folder = page.locator('.feed', { hasText: 'Test Subscriptions' }).first();
|
|
await expect(folder).toBeVisible({ timeout: 20_000 });
|
|
await expect(folder.locator('.badge')).not.toHaveText('0');
|
|
await folder.click();
|
|
|
|
await page.locator('#content .acts button', { hasText: 'Mark all read' }).click();
|
|
await expect(folder.locator('.badge')).toHaveText('0');
|
|
});
|
|
|
|
test.describe('on a phone', () => {
|
|
test.use({ viewport: { width: 390, height: 844 } });
|
|
|
|
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
|
|
// The burger used to live in the player bar, which is hidden until something plays --
|
|
// leaving no way to reach the feeds at all.
|
|
await expect(page.locator('#burger')).toBeVisible();
|
|
await expect(page.locator('#player')).not.toBeVisible();
|
|
|
|
await page.locator('#burger').click();
|
|
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
|
|
await expect(page.locator('#sidebar')).not.toHaveClass(/open/);
|
|
|
|
// Nothing may push the page sideways at this width.
|
|
const over = await page.evaluate(() =>
|
|
document.documentElement.scrollWidth - window.innerWidth);
|
|
expect(over).toBeLessThanOrEqual(0);
|
|
|
|
// One pane at a time: the list, then the item over it, then back.
|
|
await expect(page.locator('#detail')).not.toBeVisible();
|
|
await page.locator('.ep').first().click();
|
|
await expect(page.locator('#detail')).toBeVisible();
|
|
await page.locator('#dback').click();
|
|
await expect(page.locator('#detail')).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
test('opening an item marks it read, and the toggle flips it back', async ({ page }) => {
|
|
const errors = [];
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
|
|
await page.getByText('Test Show').click();
|
|
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
|
|
await expect(row()).toBeVisible({ timeout: 20_000 });
|
|
|
|
// Another test may have opened this item already, so start from a known state: the
|
|
// toggle in the text below flips it back -- which used to recurse until the stack blew.
|
|
await row().click();
|
|
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
|
await expect(row()).not.toHaveClass(/read/);
|
|
await expect(page.locator('#detail button', { hasText: 'Mark read' })).toBeVisible();
|
|
|
|
// Opening it is reading it.
|
|
await row().click();
|
|
await expect(row()).toHaveClass(/read/);
|
|
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test('a second person has their own feeds and their own read state', async ({ browser }) => {
|
|
const { execFileSync } = require('child_process');
|
|
const setup = require('./global-setup');
|
|
const env = {
|
|
...process.env,
|
|
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
|
IPX_DATA_DIR: `${setup.root}/data`,
|
|
};
|
|
try {
|
|
execFileSync('./target/debug/ipx', ['user', 'add', 'sam'], { input: 'sampassword', env });
|
|
} catch (e) {
|
|
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
|
|
}
|
|
|
|
// A fresh context, so none of the admin's cookies come along.
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
await page.goto('/login');
|
|
await page.locator('#name').fill('sam');
|
|
await page.locator('#pw').fill('sampassword');
|
|
await page.locator('button[type=submit]').click();
|
|
await expect(page.locator('#feedlist')).toBeVisible();
|
|
|
|
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
|
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
|
await expect(page.locator('#prefs')).toBeHidden(); // not an admin
|
|
// Hiding the button is not the guard; the server is.
|
|
expect((await page.request.get('/api/users')).status()).toBe(403);
|
|
await expect(page.locator('#logs')).toBeHidden();
|
|
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
|
|
|
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
|
// files, but Sam's own read state.
|
|
await page.locator('#addFeed').click();
|
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/show.xml');
|
|
await page.locator('#nsave').click();
|
|
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
|
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
|
// The admin read these earlier in this file; for Sam they are all still unread.
|
|
const rows = await page.locator('.ep').count();
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
|
await expect(page.locator('.ep')).toHaveCount(rows);
|
|
|
|
// Two people now share this feed, so the page says so and Delete is honest about it.
|
|
await expect(page.locator('#content .sub').first()).toContainText('shared with 1 other person');
|
|
|
|
await ctx.close();
|
|
});
|
|
|
|
test('deleting a shared file warns that it is everyone\'s copy', async ({ page }) => {
|
|
// Admin and Sam both subscribe to Test Show by now, and the daemon downloaded a file.
|
|
await page.getByText('Test Show').click();
|
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
|
const row = page.locator('.ep').first();
|
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
|
await row.click();
|
|
|
|
const del = page.locator('#detail button', { hasText: 'Delete' });
|
|
await expect(del).toHaveText('Delete for everyone');
|
|
|
|
// Two prompts: the page's own, then the server's, because someone else has not played
|
|
// it. Accept the first, decline the second, and the file must survive.
|
|
const seen = [];
|
|
page.on('dialog', d => {
|
|
seen.push(d.message());
|
|
if (seen.length === 1) d.accept();
|
|
else d.dismiss();
|
|
});
|
|
await del.click();
|
|
await expect.poll(() => seen.length, { timeout: 10_000 }).toBe(2);
|
|
expect(seen[0]).toContain('shared with 1 other person');
|
|
expect(seen[1]).toContain('one copy of this file');
|
|
|
|
await page.reload();
|
|
await page.getByText('Test Show').click();
|
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
|
});
|
|
|
|
// Every row says "Admin" on its checkbox, so match the name exactly.
|
|
const userRow = (page, name) =>
|
|
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
|
|
|
async function openUsers(page) {
|
|
await page.locator('#prefs').click();
|
|
await page.locator('#gusers').click();
|
|
await expect(userRow(page, 'admin')).toBeVisible();
|
|
}
|
|
|
|
test('an admin adds someone, makes them an admin, and removes them', async ({ page }) => {
|
|
await openUsers(page);
|
|
await page.locator('#uname').fill('pat');
|
|
await page.locator('#upass').fill('patpassword');
|
|
await page.locator('#uadd').click();
|
|
const row = userRow(page, 'pat');
|
|
await expect(row).toBeVisible();
|
|
await expect(row.locator('[data-a="admin"]')).not.toBeChecked();
|
|
|
|
await row.locator('[data-a="admin"]').check();
|
|
// Not just the box: it has to have reached the database.
|
|
await expect.poll(async () =>
|
|
(await (await page.request.get('/api/users')).json()).find(u => u.name === 'pat')?.admin
|
|
).toBe(true);
|
|
|
|
page.once('dialog', d => d.accept());
|
|
await row.locator('[data-a="rm"]').click();
|
|
await expect(row).toHaveCount(0);
|
|
});
|
|
|
|
test('the only admin cannot be demoted or removed', async ({ page }) => {
|
|
await openUsers(page);
|
|
await userRow(page, 'admin').locator('[data-a="admin"]').click();
|
|
await expect(page.locator('.toast.bad')).toContainText('only admin');
|
|
// Redrawn from the server, so the box is back.
|
|
await expect(userRow(page, 'admin').locator('[data-a="admin"]')).toBeChecked();
|
|
|
|
const me = (await (await page.request.get('/api/users')).json()).find(u => u.name === 'admin');
|
|
expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400);
|
|
});
|