Release 0.5.4: remembered view, Auto theme, Currently Listening
- Remember the feed/place and tab across a reload or new visit; an unknown or unsubscribed one lands on All Subscriptions instead of the first feed alphabetically. - Add an Auto theme that follows the system's light/dark setting, and move Dark/Light/Classic/Auto into Settings as a dropdown alongside the header button's toggle. - Add Currently Listening below Popular: episodes started and not finished, across every subscribed feed, one tap to resume. Reuses the existing entries/filter machinery (Filter::InProgress) rather than a new endpoint. - Likely fix for the iOS bug where the topbar stopped responding to taps until a hard refresh (100vh -> 100dvh); unverified on a real device. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
This commit is contained in:
@@ -12,7 +12,7 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
||||
// empty, with every handler below the error dead. Server-side checks all passed.
|
||||
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
||||
await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
|
||||
await expect(page.getByText('Test Show')).toBeVisible();
|
||||
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.reload();
|
||||
@@ -33,7 +33,7 @@ test('the theme button steps through dark, light and classic, and remembers', as
|
||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
||||
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
|
||||
expect(await theme()).toBe('classic');
|
||||
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Dark/);
|
||||
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/);
|
||||
|
||||
await page.reload();
|
||||
await expect.poll(theme).toBe('classic');
|
||||
@@ -41,6 +41,28 @@ test('the theme button steps through dark, light and classic, and remembers', as
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||
});
|
||||
|
||||
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => {
|
||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
||||
await page.locator('#prefs').click();
|
||||
await expect(page.locator('#stheme')).toHaveValue(await theme());
|
||||
|
||||
await page.locator('#stheme').selectOption('auto');
|
||||
await expect.poll(theme).toBe('auto');
|
||||
// Auto follows the system; emulating a light system must show the light palette live,
|
||||
// no reload needed, since it is a media query rather than something JS picks per click.
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
||||
.toBe('rgb(242, 244, 247)'); // --bg in the light palette
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
||||
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
|
||||
|
||||
// The header button and the dropdown are the same one setting, not two.
|
||||
await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal
|
||||
await page.locator('#theme').click();
|
||||
expect(await theme()).toBe('dark');
|
||||
});
|
||||
|
||||
test('settings opens and saves the global schedule', async ({ page }) => {
|
||||
await page.locator('#prefs').click();
|
||||
await expect(page.locator('#modal.on')).toBeVisible();
|
||||
@@ -57,7 +79,7 @@ test('settings opens and saves the global schedule', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: '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.
|
||||
@@ -74,7 +96,7 @@ test('episodes show with their metadata, and the text opens below', async ({ pag
|
||||
});
|
||||
|
||||
test('the three panes are there and the item text lands in the bottom one', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: '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');
|
||||
@@ -133,8 +155,34 @@ test('an item with several enclosures lists them all', async ({ page }) => {
|
||||
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Currently Listening, below Popular, resumes an episode you started', async ({ page }) => {
|
||||
// Second Episode (900s) is 42 seconds in and unfinished. Which of Test Show's two episodes
|
||||
// the daemon auto-downloaded is not fixed (see the three-panes test above), so an earlier
|
||||
// test may have opened -- and so read -- this one already; reset it before relying on it.
|
||||
await page.evaluate(() =>
|
||||
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: false }) }));
|
||||
await page.evaluate(() =>
|
||||
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
||||
|
||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
await expect(row).toContainText('0:42 of 15:00');
|
||||
|
||||
await row.click();
|
||||
await expect(page.locator('#player')).toBeVisible();
|
||||
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
|
||||
await page.locator('#pclose').click();
|
||||
|
||||
// Finished (read) drops it from the list, however far it got.
|
||||
await page.evaluate(() =>
|
||||
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
|
||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
|
||||
});
|
||||
|
||||
test('the filter tabs change what is listed', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: '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');
|
||||
@@ -147,7 +195,7 @@ test('the filter tabs change what is listed', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await page.locator('#content .acts [data-a="settings"]').click();
|
||||
await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
|
||||
await expect(page.locator('#scopy')).toBeVisible();
|
||||
@@ -288,7 +336,7 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
|
||||
await expect(row()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
@@ -307,7 +355,7 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
|
||||
});
|
||||
|
||||
test('the toolbar acts on the selected item', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
|
||||
await expect(row()).toBeVisible({ timeout: 20_000 });
|
||||
// Nothing selected, nothing to act on.
|
||||
@@ -395,7 +443,7 @@ test('a second person has their own feeds and their own read state', async ({ br
|
||||
|
||||
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('.feed', { hasText: 'Test Show' }).click();
|
||||
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
||||
const row = page.locator('.ep').first();
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
@@ -419,7 +467,7 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
||||
expect(seen[1]).toContain('one copy of this file');
|
||||
|
||||
await page.reload();
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
@@ -791,6 +839,22 @@ test('the item table sorts by any column, both ways, and remembers', async ({ pa
|
||||
await expect(page.locator('#eps .ep .file', { hasText: /\d/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the selected feed and tab are remembered across a reload', async ({ page }) => {
|
||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
||||
await expect(page.locator('.tabs button.on')).toHaveText('Unread');
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('#content h2')).toHaveText('Test Show');
|
||||
await expect(page.locator('.tabs button.on')).toHaveText('Unread');
|
||||
|
||||
// A feed that is gone -- unsubscribed, or never visited on this browser -- lands on All
|
||||
// Subscriptions, not the first feed alphabetically.
|
||||
await page.evaluate(() => localStorage.setItem('ipx.feed', 'no-such-feed'));
|
||||
await page.reload();
|
||||
await expect(page.locator('#feedlist .place.sel')).toContainText('All Subscriptions');
|
||||
});
|
||||
|
||||
test('play in the Files pane plays once, in the player bar', async ({ page }) => {
|
||||
// Regression: the pane had an <audio> of its own, and playing it started the player bar too,
|
||||
// so the same file played twice at once.
|
||||
|
||||
Reference in New Issue
Block a user