Files
ipodderx-rs/tests/ui/app.spec.js
rays 7473d5b7fb Mark all read on an OPML subscription
Marking the subscription read did nothing: its own row holds no entries.
read-all now resolves the feeds grouped under the id -- via
subscriptions(), so a child promoted to config is included -- and marks
those. Button sits before Unsubscribe, where every other feed keeps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:45:46 +00:00

210 lines
9.8 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 child is 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 episode');
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 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 }),
}));
// 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 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);
});
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');
});