j/n and k/p step through the items, Shift-J and Shift-K through the feed list, and g with a letter goes to a place: All Subscriptions, Directory, Popular, Currently Listening, Settings. o plays the selected item, m marks it read or unread and s keeps it, each by pressing the toolbar's own button; v opens the original, Shift-A marks all read, r refreshes, [ hides the feed list, and ? lists them all. None fire while typing, with a modifier held, or with a dialog open. Closes #8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
953 lines
47 KiB
JavaScript
953 lines
47 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.
|
|
// 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.locator('.feed', { hasText: '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('the theme button steps through dark, light and classic, and remembers', async ({ page }) => {
|
|
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 Auto/);
|
|
|
|
await page.reload();
|
|
await expect.poll(theme).toBe('classic');
|
|
// The 2004 Mac app set its type in Lucida Grande.
|
|
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();
|
|
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.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.
|
|
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.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');
|
|
|
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
|
// The enclosure goes to the Files pane beside the list, as the original's did.
|
|
await expect(page.locator('#files')).toBeVisible();
|
|
await expect(page.locator('#files .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', { has: page.locator('.kind.here') }).first();
|
|
await downloaded.click();
|
|
await expect(page.locator('#files [data-a="play"]')).toBeVisible();
|
|
await expect(page.locator('#files .encbox [title="Save to this computer"]')).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('#files [data-a="play"]')).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('#files [data-a="play"]')).toHaveCount(0);
|
|
// What it is and that it is here: one icon, green, with the words in its tooltip.
|
|
await expect(page.locator('#files .encbox .kind.here')).toHaveAttribute('title', 'image, downloaded');
|
|
// Still offered as a file, just not as an episode: viewable and keepable.
|
|
await expect(page.locator('#files [title="Save to this computer"]')).toBeVisible();
|
|
const view = page.locator('#files a[title="View in a new tab"]');
|
|
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 Files pane lists every one: the audio and the image.
|
|
await expect(page.locator('#files .encbox')).toHaveCount(2);
|
|
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
|
|
});
|
|
|
|
test('Currently Listening, its own place 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 }) }));
|
|
|
|
// Popular lists feeds and nothing else; the episodes have a place of their own under it.
|
|
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
|
await expect(page.locator('#popular')).toBeVisible();
|
|
await expect(page.locator('#listening')).toHaveCount(0);
|
|
const places = await page.locator('#feedlist .place b').allTextContents();
|
|
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Popular') + 1);
|
|
|
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).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: 'Currently Listening' }).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.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');
|
|
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
|
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
|
|
|
|
await page.locator('.tabs button', { hasText: 'Kept' }).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.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();
|
|
|
|
// 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 }),
|
|
}));
|
|
|
|
// Only a folder has a triangle, and it is a button that says whether the folder is open.
|
|
const chev = page.locator('.feed.group .chev');
|
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
|
await expect(page.locator('.feed:not(.group) .chev')).toHaveCount(0);
|
|
await expect(chev).toHaveAttribute('aria-expanded', 'false');
|
|
|
|
// 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[title="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 [data-a="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.locator('.feed', { hasText: '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[title="Mark unread"]').click();
|
|
await expect(row()).not.toHaveClass(/read/);
|
|
await expect(page.locator('#detail button[title="Mark read"]')).toBeVisible();
|
|
|
|
// Opening it is reading it.
|
|
await row().click();
|
|
await expect(row()).toHaveClass(/read/);
|
|
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test('the toolbar acts on the selected item', async ({ page }) => {
|
|
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.
|
|
await expect(page.locator('#tbRead')).toBeDisabled();
|
|
|
|
await row().click(); // opening it reads it
|
|
await expect(row()).toHaveClass(/read/);
|
|
await page.locator('#tbRead').click();
|
|
await expect(row()).not.toHaveClass(/read/);
|
|
|
|
await page.locator('#tbFlag').click();
|
|
await expect(row().locator('.fl')).toHaveClass(/on/);
|
|
await page.locator('#tbFlag').click(); // and back, so later tests see it unkept
|
|
await expect(row().locator('.fl')).not.toHaveClass(/on/);
|
|
|
|
// Second Episode is the one the daemon downloaded, so it plays from the toolbar.
|
|
await expect(page.locator('#tbPlay')).toBeEnabled();
|
|
await page.locator('#tbPlay').click();
|
|
await expect(page.locator('#player')).toBeVisible();
|
|
await page.locator('#pclose').click();
|
|
});
|
|
|
|
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');
|
|
// The sign-in page shows the icon, so it has to load before anyone has signed in.
|
|
const icon = await page.request.get('/icon.png');
|
|
expect(icon.status()).toBe(200);
|
|
expect(icon.headers()['content-type']).toBe('image/png');
|
|
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.');
|
|
// Settings stays: Sam has their own subscriptions to export and import, and the
|
|
// schedule and quota are worth seeing even without a say in them. Only the log and the
|
|
// users screen -- and the server -- are an admin's alone.
|
|
await expect(page.locator('#prefs')).toBeVisible();
|
|
await page.locator('#prefs').click();
|
|
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
|
await expect(page.locator('#gsave')).toBeHidden();
|
|
await expect(page.locator('#gusers')).toBeHidden();
|
|
await page.locator('#modalCard .cardacts .btn').first().click();
|
|
// 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.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 });
|
|
await row.click();
|
|
|
|
// An icon now; what it does, and to whom, is in its tooltip.
|
|
const del = page.locator('#files button[data-a="del"]');
|
|
await expect(del).toHaveAttribute('title', /^Delete for everyone \(shared with 1 other person/);
|
|
|
|
// 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.locator('.feed', { hasText: '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();
|
|
// When each account was added and last signed in; the admin signed in with the token link.
|
|
await expect(row).toContainText(/Added .* never signed in/);
|
|
await expect(userRow(page, 'admin')).toContainText(/signed in \d+m ago/);
|
|
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);
|
|
});
|
|
|
|
test('Settings exports your OPML and imports a pasted one', async ({ page }) => {
|
|
await page.locator('#prefs').click();
|
|
const [dl] = await Promise.all([
|
|
page.waitForEvent('download'),
|
|
page.locator('#modalCard a[title="Export OPML"]').click(),
|
|
]);
|
|
expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml');
|
|
const out = require('fs').readFileSync(await dl.path(), 'utf8');
|
|
for (const f of ['show.xml', 'pics.xml', 'multi.xml', 'subs.opml']) expect(out).toContain(f);
|
|
// A feed from an OPML subscription comes back with the OPML itself, not on its own.
|
|
expect(out).not.toContain('other.xml');
|
|
|
|
// One feed new to everyone, one the admin already has.
|
|
await page.locator('#gopml').click();
|
|
await page.locator('#opmlText').fill('<opml version="2.0"><head><title>t</title></head><body>' +
|
|
'<outline text="Imported Show" xmlUrl="http://127.0.0.1:8792/imported.xml"/>' +
|
|
'<outline text="Test Show" xmlUrl="http://127.0.0.1:8792/show.xml"/></body></opml>');
|
|
await page.locator('#oimp').click();
|
|
await expect(page.locator('.toast', { hasText: 'Subscribed to' }))
|
|
.toHaveText('Subscribed to 1 feed(s), 1 you already had');
|
|
// Named from the OPML's id until the first scan reads the feed's own title.
|
|
await expect(page.locator('#feedlist .feed', { hasText: /Imported Show|imported-show/ }))
|
|
.toBeVisible({ timeout: 20_000 });
|
|
});
|
|
|
|
test('an uploaded OPML file imports, and a file that is not OPML is refused', async ({ page }) => {
|
|
await page.locator('#prefs').click();
|
|
await page.locator('#gopml').click();
|
|
const pick = page.locator('#opmlFile');
|
|
|
|
// An RSS feed is XML but not OPML: refused in the page, and the picker lets go of it.
|
|
await pick.setInputFiles({
|
|
name: 'feed.xml', mimeType: 'application/xml',
|
|
buffer: require('fs').readFileSync(require('path').join(__dirname, 'fixtures', 'show.xml')),
|
|
});
|
|
await page.locator('#oimp').click();
|
|
await expect(page.locator('.toast.bad', { hasText: 'feed.xml is not an OPML file' })).toBeVisible();
|
|
expect(await pick.evaluate(i => i.files.length)).toBe(0);
|
|
|
|
// Something that gets past the page's quick look is still refused by the server, untouched.
|
|
const sneaky = await page.request.post('/api/opml', {
|
|
data: { xml: '<rss version="2.0"><channel><title><opml></title></channel></rss>' },
|
|
});
|
|
expect(sneaky.status()).toBe(400);
|
|
expect(await sneaky.text()).toContain('not an OPML file');
|
|
|
|
// A real one. Multi Show is already the admin's, so it counts as already had.
|
|
await pick.setInputFiles({
|
|
name: 'subs.opml', mimeType: 'text/x-opml',
|
|
buffer: Buffer.from('<opml version="2.0"><head><title>t</title></head><body>' +
|
|
'<outline text="Multi Show" xmlUrl="http://127.0.0.1:8792/multi.xml"/></body></opml>'),
|
|
});
|
|
await page.locator('#oimp').click();
|
|
await expect(page.locator('.toast', { hasText: 'Subscribed to' }))
|
|
.toHaveText('Subscribed to 0 feed(s), 1 you already had');
|
|
await expect(page.locator('#modal.on')).toBeHidden();
|
|
});
|
|
|
|
test('an export from one account imports into another', async ({ page, browser }) => {
|
|
// Regression: import only added URLs the catalogue lacked and subscribed nobody, so importing
|
|
// the admin's export into a second account did nothing at all.
|
|
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', 'opal'], { input: 'opalpassword', env });
|
|
} catch (e) {
|
|
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
|
|
}
|
|
const ctx = await browser.newContext();
|
|
const opal = await ctx.newPage();
|
|
await opal.goto('/login');
|
|
await opal.locator('#name').fill('opal');
|
|
await opal.locator('#pw').fill('opalpassword');
|
|
await opal.locator('button[type=submit]').click();
|
|
await expect(opal.locator('#feedlist')).toContainText('No feeds.');
|
|
|
|
// Export used to hand anyone the whole catalogue. Opal has nothing yet, so gets nothing.
|
|
const empty = await opal.request.get('/api/opml');
|
|
expect(empty.status()).toBe(200);
|
|
expect(await empty.text()).not.toContain('xmlUrl');
|
|
|
|
const urlsIn = xml => [...xml.matchAll(/xmlUrl="([^"]+)"/g)].map(m => m[1]).sort();
|
|
const exported = await (await page.request.get('/api/opml')).text(); // the admin's
|
|
const urls = urlsIn(exported);
|
|
expect(urls.length).toBeGreaterThan(2);
|
|
expect(await (await opal.request.post('/api/opml', { data: { xml: exported } })).json())
|
|
.toEqual({ added: urls.length, already: 0 });
|
|
expect(await (await opal.request.post('/api/opml', { data: { xml: exported } })).json())
|
|
.toEqual({ added: 0, already: urls.length });
|
|
|
|
await opal.reload();
|
|
await expect(opal.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
|
// The round trip closes: opal's own export now lists what the admin's did.
|
|
expect(urlsIn(await (await opal.request.get('/api/opml')).text())).toEqual(urls);
|
|
await ctx.close();
|
|
});
|
|
|
|
test('ipx import subscribes the admin, and ipx export writes the feeds out', async () => {
|
|
// Its own config and database. The CLI works in-process, and the suite's running daemon
|
|
// reads config.toml once at start, so it would not see what the CLI added anyway.
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
const setup = require('./global-setup');
|
|
const dir = path.join(setup.root, 'cli');
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'config.toml'),
|
|
`[general]\ndownload_dir = "${dir}/downloads"\nsocket = "${dir}/ipx.sock"\n`);
|
|
const env = { ...process.env, IPX_CONFIG: path.join(dir, 'config.toml'), IPX_DATA_DIR: path.join(dir, 'data') };
|
|
const ipx = (args, input) => execFileSync('./target/debug/ipx', args, { env, input, encoding: 'utf8' });
|
|
|
|
ipx(['user', 'add', 'boss'], 'bosspassword'); // the first account is the admin
|
|
const opml = path.join(dir, 'in.opml');
|
|
fs.writeFileSync(opml, '<opml version="2.0"><head><title>t</title></head><body>' +
|
|
'<outline text="One" xmlUrl="http://127.0.0.1:8792/one.xml"/>' +
|
|
'<outline text="Two" xmlUrl="http://127.0.0.1:8792/two.xml"/></body></opml>');
|
|
expect(ipx(['import', opml])).toContain('subscribed boss to 2 feed(s); 0 already there');
|
|
expect(ipx(['import', opml])).toContain('subscribed boss to 0 feed(s); 2 already there');
|
|
|
|
const out = path.join(dir, 'out.opml');
|
|
ipx(['export', out]);
|
|
const xml = fs.readFileSync(out, 'utf8');
|
|
expect(xml).toContain('http://127.0.0.1:8792/one.xml');
|
|
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
|
|
});
|
|
|
|
test('Popular lists what everyone here reads, but never a private feed', 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', 'piper'], { input: 'piperpassword', env });
|
|
} catch (e) {
|
|
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
|
|
}
|
|
const ctx = await browser.newContext();
|
|
const piper = await ctx.newPage();
|
|
await piper.goto('/login');
|
|
await piper.locator('#name').fill('piper');
|
|
await piper.locator('#pw').fill('piperpassword');
|
|
await piper.locator('button[type=submit]').click();
|
|
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
|
|
|
|
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
|
const offered = piper.locator('#popular .childrow');
|
|
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
|
// An OPML is listed as the feeds inside it, and a key in a URL marks someone's paid feed.
|
|
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
|
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
|
|
|
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
|
|
const listed = await (await piper.request.get('/api/popular')).text();
|
|
expect(listed).not.toContain('secret123');
|
|
expect(listed).not.toContain('.xml');
|
|
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
|
|
|
|
// Popular is the top ten of the directory, and the directory is every listed feed A to Z,
|
|
// with an OPML's feeds in place of the OPML in both.
|
|
const dir = await (await piper.request.get('/api/directory')).json();
|
|
const top = await (await piper.request.get('/api/popular')).json();
|
|
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
|
expect(names).toEqual([...names].sort());
|
|
const ids = dir.map(p => p.id);
|
|
expect(ids).not.toContain('test-subscriptions');
|
|
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
|
|
expect(top.length).toBe(Math.min(10, dir.length));
|
|
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
|
expect(ids).not.toContain('paid-show');
|
|
|
|
// Subscribe from the directory this time: the same feeds, as a grid of cover art.
|
|
const tiles = piper.locator('#popular .tile');
|
|
const pick = (row, name) => piper.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
|
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
|
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
|
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
|
|
await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
|
|
await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
|
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
|
await expect(tiles).toHaveCount(dir.length);
|
|
|
|
// Two filters that combine: what a feed is, and what it is about.
|
|
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
|
|
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
|
|
await pick('tabs', 'Blogs').click();
|
|
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
|
|
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
|
|
// No empty chips: no blog here names Technology, so Blogs does not offer it.
|
|
await expect(pick('chips', 'Technology')).toHaveCount(0);
|
|
await pick('tabs', 'Podcasts').click();
|
|
await pick('chips', 'Technology').click();
|
|
await expect(pick('chips', 'Technology')).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length);
|
|
// A second press lifts the chip and leaves the kind as it was.
|
|
await pick('chips', 'Technology').click();
|
|
await expect(tiles).toHaveCount(dir.filter(p => p.podcast).length);
|
|
await pick('tabs', 'All').click();
|
|
await expect(tiles).toHaveCount(dir.length);
|
|
|
|
// Add a feed opened over Directory fills its own list, not the pane behind it.
|
|
await piper.locator('#addFeed').click();
|
|
await expect(piper.locator('#modalCard .childrow', { hasText: 'Test Show' })).toBeVisible();
|
|
await expect(tiles).toHaveCount(dir.length);
|
|
await piper.locator('#modalCard button[title="Cancel"]').click();
|
|
|
|
const row = async () =>
|
|
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
|
|
const before = await row();
|
|
expect(before.subscribed).toBe(false);
|
|
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
|
|
|
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
|
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
|
|
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
|
await expect(offered.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
|
|
await expect(offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0);
|
|
|
|
// All Subscriptions is every item from piper's feeds and only those: the admin's Picture
|
|
// Blog is not among them.
|
|
await piper.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
|
const first = piper.locator('.ep', { hasText: 'First Episode' });
|
|
await expect(first).toBeVisible({ timeout: 20_000 });
|
|
await expect(first.locator('.fd')).toHaveText('Test Show');
|
|
await expect(piper.locator('.ep', { hasText: 'An Article' })).toHaveCount(0);
|
|
await expect(piper.locator('#count')).toContainText('All Subscriptions:');
|
|
await ctx.close();
|
|
});
|
|
|
|
test('adding a feed scans it straight away', async ({ page }) => {
|
|
await page.locator('#addFeed').click();
|
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/fresh.xml');
|
|
await page.locator('#nsave').click();
|
|
// Nobody pressed Scan. The scheduler's tick is a minute, so this is the add scanning it.
|
|
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
|
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
|
|
page.on('dialog', d => d.accept());
|
|
await page.locator('.feed', { hasText: 'Picture Blog' }).click();
|
|
const row = page.locator('.ep', { hasText: 'An Article' });
|
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
|
await row.click();
|
|
await page.locator('#files button[data-a="del"]').click();
|
|
|
|
// No "reaped", no chip at all: just the way to get it again.
|
|
await expect(row.locator('.kind.here')).toHaveCount(0);
|
|
await expect(row).not.toContainText(/reaped/i);
|
|
await row.click();
|
|
await expect(page.locator('#files')).not.toContainText(/reaped/i);
|
|
await expect(page.locator('#files button[title="Download to the server"]')).toBeVisible();
|
|
});
|
|
|
|
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
|
|
const icon = loc => loc.locator('svg path').first().getAttribute('d');
|
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
|
|
|
// Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things.
|
|
expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove')));
|
|
|
|
// The toolbar's read and keep show the selected item's state, as its own buttons do, and follow
|
|
// a change made from the toolbar.
|
|
await page.locator('.ep').first().click();
|
|
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
|
|
await icon(page.locator(`#detail [data-a="${a}"]`))];
|
|
for (const a of ['read', 'flag']) { const [tb, own] = await pair(a); expect(tb).toBe(own); }
|
|
const [kept] = await pair('flag');
|
|
await page.locator('#tbFlag').click();
|
|
await expect.poll(async () => { const [tb, own] = await pair('flag'); return tb === own && tb !== kept; }).toBe(true);
|
|
await page.locator('#tbFlag').click(); // leave it as it was
|
|
await expect.poll(async () => (await pair('flag'))[0]).toBe(kept);
|
|
|
|
// Every button in every dialog is an icon with its words in the tooltip.
|
|
const dialogs = [
|
|
() => page.locator('#addFeed').click(),
|
|
() => page.locator('#prefs').click(),
|
|
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
|
|
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
|
|
() => page.locator('#logs').click(),
|
|
() => page.locator('#content .acts [data-a="settings"]').click(),
|
|
() => page.locator('#content .acts [data-a="dl"]').click(),
|
|
() => page.locator('#content .acts [data-a="rm"]').click(),
|
|
];
|
|
for (const open of dialogs) {
|
|
await open();
|
|
const btns = page.locator('#modalCard .btn');
|
|
await expect(btns.first()).toBeVisible();
|
|
for (const b of await btns.all()) {
|
|
await expect(b.locator('svg')).toHaveCount(1);
|
|
await expect(b).toHaveAttribute('title', /\S/);
|
|
}
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.locator('#modal.on')).toBeHidden();
|
|
}
|
|
});
|
|
|
|
test('All Subscriptions marks everything read, across every feed', async ({ page }) => {
|
|
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
|
|
await all.click();
|
|
// Earlier tests read things; make sure something is unread. Opening an item reads it, and
|
|
// its own button makes it unread again.
|
|
await page.locator('.ep').first().click();
|
|
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
|
|
await expect(all.locator('.badge')).not.toHaveText('0');
|
|
|
|
page.once('dialog', d => d.accept());
|
|
await page.locator('#content .acts [data-a="readall"]').click();
|
|
await expect(all.locator('.badge')).toHaveText('0');
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
|
await expect(page.locator('.ep')).toHaveCount(0);
|
|
});
|
|
|
|
test('the item table sorts by any column, both ways, and remembers', async ({ page }) => {
|
|
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
|
|
await all.click();
|
|
const head = k => page.locator(`#list .ephead [data-sort="${k}"]`);
|
|
const titles = () => page.locator('#eps .ep .t').allTextContents();
|
|
// Byte order on lower case, which is what SQLite gives for lower(...).
|
|
const cmp = (a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
|
|
const sorted = (t, dir) => JSON.stringify(t) === JSON.stringify([...t].sort((a, b) => cmp(a, b) * dir));
|
|
await expect(page.locator('#eps .ep').nth(2)).toBeVisible({ timeout: 20_000 });
|
|
expect(new Set(await titles()).size).toBeGreaterThan(2); // or both orders would prove nothing
|
|
|
|
await expect(head('title')).toHaveText('Title');
|
|
await head('title').click();
|
|
await expect.poll(async () => sorted(await titles(), 1)).toBe(true);
|
|
await head('title').click();
|
|
await expect.poll(async () => sorted(await titles(), -1)).toBe(true);
|
|
|
|
// Kept across a reload.
|
|
await page.reload();
|
|
await all.click();
|
|
await expect(head('title').locator('.arr.desc')).toBeVisible();
|
|
await expect.poll(async () => sorted(await titles(), -1)).toBe(true);
|
|
|
|
// Size has its own column; the file column is just what the file is.
|
|
await expect(page.locator('#eps .ep .size', { hasText: /\d/ }).first()).toBeVisible();
|
|
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.
|
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
|
await page.locator('.ep', { has: page.locator('.kind.here') }).first().click();
|
|
await page.locator('#files [data-a="play"]').click();
|
|
await expect(page.locator('#player')).toBeVisible();
|
|
// The player bar's element doubles as a <video> so a video file has somewhere to show its
|
|
// picture (see #audio's own comment), but there is still exactly one of it, and nothing else.
|
|
await expect(page.locator('#audio')).toHaveCount(1);
|
|
await page.locator('#pclose').click();
|
|
});
|
|
|
|
test('someone the proxy signs in never sees the password page, and signs out through the proxy', async ({ page, browser }) => {
|
|
// Signed in with the token, not by the proxy: Sign out stays ipx's own.
|
|
expect((await (await page.request.get('/api/me')).json()).sign_out).toBeNull();
|
|
|
|
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': 'proxied@example.com' } });
|
|
const proxied = await ctx.newPage();
|
|
// Regression: after Sign out, the password form showed to someone the proxy still vouched for.
|
|
await proxied.goto('/login');
|
|
await expect(proxied).toHaveURL(/:8791\/$/);
|
|
await expect(proxied.locator('#who')).toContainText('proxied@example.com');
|
|
expect(await (await proxied.request.get('/api/me')).json())
|
|
.toMatchObject({ name: 'proxied@example.com', sign_out: '/signed-out-by-the-proxy' });
|
|
await proxied.locator('#signout').click();
|
|
await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
|
|
await ctx.close();
|
|
});
|
|
|
|
test('keys move through items and places, after Feedly', async ({ page }) => {
|
|
// Last in the file: selecting an item marks it read, which would change what later tests see.
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
const rows = page.locator('#eps .ep');
|
|
await expect(rows.nth(1)).toBeVisible({ timeout: 20_000 });
|
|
const sel = page.locator('#eps .ep.sel');
|
|
const guid = i => rows.nth(i).getAttribute('data-guid');
|
|
await page.keyboard.press('j');
|
|
await expect(sel).toHaveAttribute('data-guid', await guid(0));
|
|
await page.keyboard.press('j');
|
|
await expect(sel).toHaveAttribute('data-guid', await guid(1));
|
|
await page.keyboard.press('k');
|
|
await expect(sel).toHaveAttribute('data-guid', await guid(0));
|
|
|
|
// g and a letter go somewhere; typed into a box, the same letters are only text.
|
|
await page.keyboard.press('g');
|
|
await page.keyboard.press('d');
|
|
await expect(page.locator('#count')).toContainText('Directory');
|
|
await page.locator('#epSearch').focus();
|
|
await page.keyboard.type('ga');
|
|
await expect(page.locator('#count')).toContainText('Directory');
|
|
await page.locator('#epSearch').fill('');
|
|
await page.locator('#epSearch').blur();
|
|
|
|
await page.keyboard.press('?');
|
|
await expect(page.locator('#modalCard')).toContainText('Keyboard shortcuts');
|
|
await page.keyboard.press('Escape');
|
|
await page.keyboard.press('g');
|
|
await page.keyboard.press('a');
|
|
await expect(page.locator('#count')).toContainText('All Subscriptions');
|
|
});
|