The row in the player gets the amber EQ bars, as the item list's does, and its progress rail and time left move as it plays. Rows say how much is left, and their buttons are quiet so that row stands out. savePos no longer saves before the file has loaded: currentTime is 0 then, and a failed load or an early pause wiped the saved position. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
998 lines
50 KiB
JavaScript
998 lines
50 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 or forgets it', async ({ page }) => {
|
|
// Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it,
|
|
// and opening marks it read; it is listed all the same, because read is not finished. This
|
|
// test used to set it unread first, which hid exactly the bug in issue #14.
|
|
await page.evaluate(() =>
|
|
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
|
|
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('14:18 left');
|
|
|
|
// Removing it forgets where you got to, so it is still gone on the next visit.
|
|
await row.locator('[data-a=remove]').click();
|
|
await expect(row).toHaveCount(0);
|
|
await expect(page.locator('#player')).not.toBeVisible();
|
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
|
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
|
|
|
|
// Started again, it is back, and clicking the row resumes it. Finishing it (90%) is
|
|
// covered in the Rust tests; here the player's own save on close would race it.
|
|
await page.evaluate(() =>
|
|
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
|
await row.click();
|
|
await expect(page.locator('#player')).toBeVisible();
|
|
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
|
|
// The row in the player carries the EQ bars, as the feed view's does, until the player closes.
|
|
await expect(row).toHaveClass(/\bnow\b/);
|
|
await expect(row.locator('.eq')).toBeVisible();
|
|
await page.locator('#pclose').click();
|
|
await expect(row).not.toHaveClass(/\bnow\b/);
|
|
await expect(row.locator('.eq')).toBeHidden();
|
|
});
|
|
|
|
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: 'Pinned' }).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 }) => {
|
|
// The whole glyph, not just its path: pinned and not pinned share one outline and differ in fill.
|
|
const icon = loc => loc.locator('svg').first().innerHTML();
|
|
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 pin 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');
|
|
});
|
|
|
|
test('an admin can give a blog its Directory category', async ({ page }) => {
|
|
const patch = (id, category) => page.evaluate(([id, category]) =>
|
|
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
|
|
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
|
|
|
|
await patch('picture-blog', 'Visual Arts');
|
|
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Visual Arts' });
|
|
// A feed's own iTunes category wins over one given here.
|
|
await patch('test-show', 'Comedy');
|
|
expect((await listed('test-show')).category).toBe('Technology');
|
|
|
|
for (const id of ['picture-blog', 'test-show']) await patch(id, null);
|
|
expect((await listed('picture-blog')).category).toBeNull();
|
|
});
|
|
|
|
test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => {
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 20_000 });
|
|
const boxes = {
|
|
headCell: await page.locator('.ephead [data-sort="kept"]').boundingBox(),
|
|
headIcon: await page.locator('.ephead [data-sort="kept"] svg').first().boundingBox(),
|
|
rowCell: await page.locator('#eps .ep .fl').first().boundingBox(),
|
|
rowIcon: await page.locator('#eps .ep .fl svg').first().boundingBox(),
|
|
};
|
|
const mid = b => b.x + b.width / 2;
|
|
expect(Math.abs(mid(boxes.headIcon) - mid(boxes.rowIcon)), JSON.stringify(boxes)).toBeLessThan(1);
|
|
|
|
// From ipx itself, not a font service.
|
|
expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2');
|
|
expect(await page.evaluate(() => document.fonts.ready.then(() => document.fonts.check('14px Inter')))).toBe(true);
|
|
});
|