A feed that sends <source:markdown> beside its HTML (Scripting News) has its Markdown kept in entries.markdown, added to existing databases by an ALTER. A stored item gets it when a scan finds it told differently, the Markdown counting as a change, the way corrections arrive (#141). The API sends it as written and made HTML, cleaned by ammonia like the description. The item's page has a choice between its meta line and its buttons: HTML, Markdown or Markdown source, remembered in the browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2279 lines
125 KiB
JavaScript
2279 lines
125 KiB
JavaScript
const { test, expect } = require('@playwright/test');
|
|
const { TOKEN } = require('./global-setup');
|
|
|
|
// Signals' tests run only when the Signals code has changed since each last passed: they spend the
|
|
// TypeSafe key (#271). The Signals code is signals.rs and signals.ts whole, and the lines naming
|
|
// Signals, TypeSafe or Jev anywhere else in the daemon and the page; with the test's own source,
|
|
// hashed, and kept under target/ once the test passes. IPX_SIGNALS_TESTS=1 runs them anyway.
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const ROOT = path.join(__dirname, '../..');
|
|
const SIGNALS_CODE = (() => {
|
|
const h = require('node:crypto').createHash('sha256');
|
|
for (const dir of ['crates/ipx/src', 'web/src']) {
|
|
for (const f of fs.readdirSync(path.join(ROOT, dir)).sort()) {
|
|
const text = fs.readFileSync(path.join(ROOT, dir, f), 'utf8');
|
|
h.update(f + '\n' + (/^signals\./.test(f) ? text : text.split('\n').filter(l => /signal|typesafe|jev/i.test(l)).join('\n')));
|
|
}
|
|
}
|
|
return h.digest('hex');
|
|
})();
|
|
function signalsTest(name, fn) {
|
|
const stamp = path.join(ROOT, 'target/signals-tests',
|
|
require('node:crypto').createHash('sha256').update(name).digest('hex').slice(0, 16));
|
|
const want = require('node:crypto').createHash('sha256').update(SIGNALS_CODE + fn.toString()).digest('hex');
|
|
// Playwright reads the fixtures a test asks for from its parameters; these ask for the page alone.
|
|
test(name, async ({ page }) => {
|
|
let had = '';
|
|
try { had = fs.readFileSync(stamp, 'utf8'); } catch {}
|
|
test.skip(had === want && !process.env.IPX_SIGNALS_TESTS, 'the Signals code is as it was when this last passed');
|
|
await fn({ page });
|
|
fs.mkdirSync(path.dirname(stamp), { recursive: true });
|
|
fs.writeFileSync(stamp, want);
|
|
});
|
|
}
|
|
|
|
// 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.
|
|
// The Signals folder is above them, and no feed.
|
|
await expect(page.locator('.feed:not(.sigs)')).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 script is its own file, cached until a deploy changes it', async ({ page }) => {
|
|
const js = page.waitForResponse(r => new URL(r.url()).pathname === '/app.js');
|
|
const doc = await page.reload();
|
|
const r = await js;
|
|
// The page is checked every visit, so it always names the current script...
|
|
expect(doc.headers()['cache-control']).toBe('no-cache');
|
|
// ...by a hash of its contents, which is why the script itself can be kept for a year.
|
|
expect(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/);
|
|
expect(r.headers()['cache-control']).toContain('immutable');
|
|
expect(r.headers()['content-type']).toContain('javascript');
|
|
expect(await page.locator('script:not([src])').count(), 'no inline script').toBe(0);
|
|
// The sign-in page's script loads before signing in.
|
|
const login = await page.request.get('/login.js', { headers: { cookie: '' } });
|
|
expect(login.status()).toBe(200);
|
|
});
|
|
|
|
test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => {
|
|
const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
|
|
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
|
await page.locator('#prefs').click();
|
|
await expect(page.locator('#stheme')).toHaveValue((await root())[0]);
|
|
|
|
await page.locator('#stheme').selectOption('modern');
|
|
await page.locator('#smode').selectOption('auto');
|
|
// Auto follows the system, live, with no reload.
|
|
await page.emulateMedia({ colorScheme: 'light' });
|
|
await expect.poll(root).toEqual(['modern', 'light']);
|
|
await expect.poll(bg).toBe('rgb(238, 245, 251)'); // Modern's light --bg
|
|
await page.emulateMedia({ colorScheme: 'dark' });
|
|
await expect.poll(root).toEqual(['modern', 'dark']);
|
|
await expect.poll(bg).toBe('rgb(10, 23, 38)'); // Modern's dark --bg
|
|
|
|
// Dracula, then its light half, Alucard.
|
|
await page.locator('#stheme').selectOption('dracula');
|
|
await page.locator('#smode').selectOption('dark');
|
|
await expect.poll(bg).toBe('rgb(40, 42, 54)'); // #282A36
|
|
await page.locator('#smode').selectOption('light');
|
|
await expect.poll(bg).toBe('rgb(255, 251, 235)'); // #FFFBEB
|
|
|
|
// Classic and Paper come one way only, so there is nothing to choose.
|
|
await page.locator('#stheme').selectOption('paper');
|
|
await expect(page.locator('#smode')).toBeHidden();
|
|
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
|
|
await page.locator('#stheme').selectOption('classic');
|
|
await expect(page.locator('#smode')).toBeHidden();
|
|
await page.reload(); // kept in this browser's cookie
|
|
await expect.poll(root).toEqual(['classic', 'light']);
|
|
// The 2004 Mac app set its type in Lucida Grande.
|
|
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
|
expect(await page.locator('#theme').count(), 'the theme lives in Settings only').toBe(0);
|
|
|
|
// Back to Nordic, dark: the mode chosen before Classic is kept for the themes that have one.
|
|
await page.locator('#prefs').click();
|
|
await page.locator('#stheme').selectOption('nordic');
|
|
await expect(page.locator('#smode')).toHaveValue('light');
|
|
await page.locator('#smode').selectOption('dark');
|
|
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
|
|
});
|
|
|
|
test('each browser keeps its own theme, in a cookie, not on the account', async ({ page, browser }) => {
|
|
// Glass on a phone, Dracula on a desktop, signed in as the same person (issue #69).
|
|
let patched = false;
|
|
page.on('request', r => { if (r.url().endsWith('/api/me') && r.method() === 'PATCH') patched = true; });
|
|
await page.locator('#prefs').click();
|
|
await page.locator('#stheme').selectOption('flatremix');
|
|
await page.locator('#smode').selectOption('light');
|
|
const cookie = (await page.context().cookies()).find(c => c.name === 'ipx_theme');
|
|
expect(cookie?.value).toBe('flatremix.light');
|
|
expect(patched, 'nothing sent to the account').toBe(false);
|
|
|
|
// This browser: boot.js reads the cookie in <head> and draws the page in it from the first
|
|
// frame, before the app's script has run (#203).
|
|
const firstFrame = () => document.documentElement.dataset;
|
|
await page.reload({ waitUntil: 'commit' });
|
|
await page.waitForFunction(() => document.body);
|
|
expect(await page.evaluate(firstFrame)).toMatchObject({ theme: 'flatremix', choice: 'light', mode: 'light' });
|
|
|
|
// Another browser, the same account: not this one's theme.
|
|
const other = await browser.newContext();
|
|
const p2 = await other.newPage();
|
|
await p2.goto(`/?token=${TOKEN}`);
|
|
await expect(p2.locator('#feedlist')).toBeVisible();
|
|
expect((await p2.evaluate(firstFrame)).theme).not.toBe('flatremix');
|
|
await other.close();
|
|
});
|
|
|
|
test('a theme this browser kept in localStorage becomes its cookie once', async ({ browser }) => {
|
|
const who = `theme-${Date.now()}@example.com`;
|
|
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
|
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
|
|
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
|
|
const page = await ctx.newPage();
|
|
await page.goto('/');
|
|
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
|
.toEqual(['modern', 'light']);
|
|
expect((await ctx.cookies()).find(c => c.name === 'ipx_theme')?.value).toBe('modern.light');
|
|
await ctx.close();
|
|
});
|
|
|
|
test('the admin page saves the global schedule', async ({ page }) => {
|
|
// Reached from the header's Admin link, and on its own page, not in Settings (issue #19).
|
|
await page.locator('#admin').click();
|
|
await expect(page).toHaveURL(/\/admin$/);
|
|
await expect(page.locator('#atabs a.on')).toHaveText('Server');
|
|
await page.locator('#gnum').fill('4');
|
|
await page.locator('#gunit').selectOption('h');
|
|
await page.locator('#gsave').click();
|
|
await expect(page.locator('#toasts')).toContainText('Settings saved');
|
|
|
|
// It must survive a reload, i.e. actually reach the config.
|
|
await page.reload();
|
|
await expect(page.locator('#gnum')).toHaveValue('4');
|
|
await expect(page.locator('#gunit')).toHaveValue('h');
|
|
// And Settings in the app no longer has it.
|
|
await page.goto('/');
|
|
await page.locator('#prefs').click();
|
|
await expect(page.locator('#modalCard')).toContainText('Theme');
|
|
await expect(page.locator('#gnum')).toHaveCount(0);
|
|
});
|
|
|
|
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 .dt')).toHaveCount(0); // nothing picked, nothing said
|
|
|
|
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('while an episode plays, its play buttons all say pause, and pause it', async ({ page }) => {
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
|
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
|
|
await expect(downloaded).toBeVisible({ timeout: 20_000 });
|
|
await downloaded.click();
|
|
// What the buttons do, not whether this browser decodes the fixture: it does not, reliably,
|
|
// and a load error pauses the player, rightly turning every button back to play. So the
|
|
// player plays and pauses here as a real one does, events and all, with no file behind it.
|
|
await page.evaluate(() => {
|
|
let paused = true;
|
|
Object.defineProperty(audio, 'paused', { get: () => paused, configurable: true });
|
|
audio.play = async () => { paused = false; audio.dispatchEvent(new Event('play')); };
|
|
audio.pause = () => { paused = true; audio.dispatchEvent(new Event('pause')); };
|
|
});
|
|
const pane = page.locator('#files [data-a="play"]');
|
|
await pane.click();
|
|
await expect.poll(() => page.evaluate(() => !audio.paused)).toBe(true);
|
|
// The files pane, the row and the toolbar all follow the player bar, not only the bar.
|
|
await expect(pane).toHaveAttribute('title', 'Pause');
|
|
await expect(downloaded.locator('[data-a="play"]')).toHaveAttribute('title', 'Pause');
|
|
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Pause');
|
|
await pane.click(); // and pressing it pauses
|
|
await expect.poll(() => page.evaluate(() => audio.paused)).toBe(true);
|
|
await expect(pane).toHaveAttribute('title', 'Play');
|
|
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Play the selected item');
|
|
await page.locator('#pclose').click();
|
|
});
|
|
|
|
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 the Directory, 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 }) }));
|
|
|
|
// The Directory lists feeds and nothing else; the episodes have a place of their own under it.
|
|
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
|
await expect(page.locator('#directory')).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('Directory') + 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');
|
|
|
|
// The search box looks through it, as it does a feed's items (#127).
|
|
await page.locator('#epSearch').fill('second');
|
|
await expect(row).toBeVisible();
|
|
await page.locator('#epSearch').fill('nothing-is-called-this');
|
|
await expect(row).toHaveCount(0);
|
|
await expect(page.locator('#listening')).toContainText('Nothing you are listening to mentions');
|
|
await page.locator('#epSearch').fill('');
|
|
await expect(row).toBeVisible();
|
|
|
|
// 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('a player nobody has played since it last saved does not save again', async ({ page }) => {
|
|
// A tab left paused at 41:15 saved that as it reloaded, over the 32:48 another had reached,
|
|
// and the episode dropped out of Currently Listening. The fixture audio does not decode, so
|
|
// this stands in for a loaded file paused at 2 seconds and counts what savePos sends.
|
|
const sent = await page.evaluate(() => {
|
|
Object.defineProperty(audio, 'readyState', { get: () => 4 });
|
|
Object.defineProperty(audio, 'currentTime', { get: () => 2, set() {} });
|
|
let n = 0;
|
|
navigator.sendBeacon = () => (n++, true);
|
|
player.guid = 'ui-2'; player.feed = 'test-show'; player.entry = null; player.moved = false;
|
|
savePos(); // what a reload, a pause or the close button calls
|
|
const idle = n;
|
|
player.moved = true; // what playing sets
|
|
savePos();
|
|
savePos(); // and once saved, it is idle again
|
|
return [idle, n];
|
|
});
|
|
expect(sent).toEqual([0, 1]);
|
|
});
|
|
|
|
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('on the Unread tab an item stays while you read it and goes when you move on', async ({ page }) => {
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
|
// Earlier tests read things; make the first two unread with their own dots.
|
|
for (const i of [0, 1]) {
|
|
const row = page.locator('.ep').nth(i);
|
|
if (await row.evaluate(r => r.classList.contains('read'))) {
|
|
await row.locator('[data-a="read"]').click();
|
|
await expect(page.locator('.ep').nth(i)).not.toHaveClass(/\bread\b/);
|
|
}
|
|
}
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
|
const first = page.locator('.ep').first();
|
|
const guid = await first.getAttribute('data-guid');
|
|
await first.click();
|
|
const it = page.locator(`.ep[data-guid="${guid}"]`);
|
|
await expect(it).toHaveClass(/\bread\b/);
|
|
await page.waitForTimeout(1500); // past the SSE refresh debounce and loadFeeds
|
|
await expect(it).toBeVisible();
|
|
|
|
await page.locator('.ep').nth(1).click();
|
|
await expect(it).toHaveCount(0);
|
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
|
});
|
|
|
|
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.goto('/admin#log');
|
|
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 });
|
|
// The Signals folder is a folder too (#255).
|
|
await expect(page.locator('.feed:not(.group):not(.sigs) .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 the items of the shows inside it, together (#283).
|
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
|
await page.locator('#content .tabs button[data-f="all"]').click();
|
|
await expect(page.locator('.ep', { hasText: 'Aardvark Ep' })).toBeVisible();
|
|
await expect(page.locator('.ep', { hasText: 'Grouped Ep' })).toBeVisible();
|
|
await expect(page.locator('.childrow')).toHaveCount(0);
|
|
});
|
|
|
|
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 });
|
|
// The OPML's Unread tab has the unread show's item and not the read one's (#283).
|
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
|
await page.locator('#content .tabs button[data-f="unread"]').click();
|
|
await expect(page.locator('.ep', { hasText: 'Grouped Ep' })).toBeVisible();
|
|
await expect(page.locator('.ep', { hasText: 'Aardvark Ep' })).toHaveCount(0);
|
|
});
|
|
|
|
test('a show inside an OPML can be kept, and only it', 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 });
|
|
if (await chev.getAttribute('aria-expanded') === 'false') await chev.click();
|
|
await page.locator('#feedlist').getByText('Aardvark Radio').click();
|
|
|
|
// A plus on the show's page; pressed, a check (#280).
|
|
const keep = page.locator('#content .acts [data-a="keep"]');
|
|
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
|
await expect(keep).toHaveAttribute('title', /Test Subscriptions drops it/);
|
|
await keep.click();
|
|
await expect(keep).toHaveAttribute('aria-pressed', 'true');
|
|
const kept = await page.evaluate(() => fetch('/api/feeds').then((r) => r.json()));
|
|
expect((kept.feeds || kept).find((f) => f.title === 'Aardvark Radio')?.kept).toBe(true);
|
|
await keep.click();
|
|
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
|
|
|
// The OPML itself has no Keep.
|
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
|
await expect(page.locator('#content h2')).toHaveText('Test Subscriptions');
|
|
await expect(page.locator('#content [data-a="keep"]')).toHaveCount(0);
|
|
});
|
|
|
|
test('an item sent as HTML and Markdown switches between them', async ({ page }) => {
|
|
const api = (p, init) => page.evaluate(([p, init]) => fetch(p, { headers: { 'Content-Type': 'application/json' }, ...init })
|
|
.then((r) => r.json().catch(() => null)), [p, init]);
|
|
const added = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/markdown.xml' }) });
|
|
const id = added.id || added.feed?.id || 'markdown-blog';
|
|
await page.reload();
|
|
await page.locator('#feedlist .feed', { hasText: 'Markdown Blog' }).click({ timeout: 20_000 });
|
|
await page.locator('#content .tabs button[data-f="all"]').click();
|
|
await page.locator('.ep', { hasText: 'Both Ways' }).click();
|
|
|
|
// The publisher's HTML first; the choice is between the title and the item's buttons (#285).
|
|
const view = page.locator('#detail .dmeta .tview');
|
|
await expect(view).toHaveValue('html');
|
|
await expect(page.locator('#detail .dbody b')).toHaveText('HTML');
|
|
await view.selectOption('markdown');
|
|
await expect(page.locator('#detail .dbody strong')).toHaveText('Markdown');
|
|
await view.selectOption('source');
|
|
await expect(page.locator('#detail .dbody pre.mdsrc')).toHaveText('The **Markdown** copy.');
|
|
// Kept for the next item; set back so nothing after this sees it.
|
|
expect(await page.evaluate(() => localStorage.getItem('ipx.textview'))).toBe('source');
|
|
await view.selectOption('html');
|
|
|
|
// An item with no Markdown has no choice to make.
|
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
|
await page.locator('.ep').first().click();
|
|
await expect(page.locator('#detail .tview')).toHaveCount(0);
|
|
await api('/api/feeds/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
});
|
|
|
|
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 top bar leaves the logo out, for the search box', async ({ page }) => {
|
|
await expect(page.locator('#burger')).toBeVisible();
|
|
await expect(page.locator('#applogo')).toBeHidden();
|
|
});
|
|
|
|
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 logo, so it has to load before anyone has signed in.
|
|
for (const path of ['/logo.svg', '/logo-dark.svg']) {
|
|
const logo = await page.request.get(path);
|
|
expect(logo.status(), path).toBe(200);
|
|
expect(logo.headers()['content-type'], path).toBe('image/svg+xml');
|
|
}
|
|
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 theme and subscriptions. The admin page -- the server's
|
|
// settings, the accounts and the log -- is an admin's alone, and Sam is not even sent the
|
|
// link to it, never mind the page.
|
|
await expect(page.locator('#prefs')).toBeVisible();
|
|
await page.locator('#prefs').click();
|
|
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
|
await page.locator('#modalCard .cardx').click();
|
|
// The page is the same for everyone now (#203): the link is there, and hidden from anyone /api/me
|
|
// does not call an admin.
|
|
await expect(page.locator('#admin')).toBeHidden();
|
|
// Asking for it anyway goes back to the app, and its script is refused.
|
|
await page.goto('/admin');
|
|
await expect(page).toHaveURL(/:8791\/$/);
|
|
expect((await page.request.get('/admin.js')).status()).toBe(403);
|
|
// Hiding the way in is not the guard; the server is.
|
|
expect((await page.request.get('/api/users')).status()).toBe(403);
|
|
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('#accounts [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
|
|
|
async function openUsers(page) {
|
|
await page.goto('/admin');
|
|
await page.locator('#atabs a', { hasText: 'Accounts' }).click();
|
|
await expect(page).toHaveURL(/\/admin#accounts$/);
|
|
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('the Directory lists what servers share, the most subscribed first, but never a private feed', async ({ browser, page }) => {
|
|
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.');
|
|
|
|
// The most subscribed are the Directory's first section; Popular, their own place, is gone (#124).
|
|
await expect(piper.locator('#feedlist .place', { hasText: 'Popular' })).toHaveCount(0);
|
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
|
const chart = piper.locator('#dchart .rank');
|
|
await expect(chart.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(chart.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
|
await expect(chart.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/directory')).text();
|
|
expect(listed).not.toContain('secret123');
|
|
expect(listed).not.toContain('.xml');
|
|
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
|
|
|
|
// The chart is /api/popular's top ten, 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());
|
|
// The directory's ids, not this server's: it names a feed by its address (#205).
|
|
const ids = dir.map(p => p.id);
|
|
const titles = dir.map(p => p.title);
|
|
const byTitle = t => dir.find(p => p.title === t);
|
|
expect(titles).not.toContain('Test Subscriptions');
|
|
expect(titles).toEqual(expect.arrayContaining(['Grouped Show', 'Aardvark Radio']));
|
|
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
|
expect(titles).not.toContain('Paid Show');
|
|
expect(ids).not.toContain('test-show');
|
|
await expect(chart).toHaveCount(top.length);
|
|
await expect(chart.first().locator('.no')).toHaveText('1');
|
|
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
|
|
|
// Then the categories, as tiles, each a page of its own.
|
|
const cat = name => piper.locator('#directory .cat', { hasText: new RegExp(`^${name}\\d`) });
|
|
const tiles = piper.locator('#dtiles .tile');
|
|
expect(byTitle('Test Show')).toMatchObject({ podcast: true, category: 'Technology' });
|
|
expect(byTitle('Picture Blog')).toMatchObject({ podcast: false });
|
|
await cat('Technology').click();
|
|
await expect(piper.locator('#directory h2')).toHaveText('Technology');
|
|
await expect(tiles).toHaveCount(dir.filter(p => p.category === 'Technology').length);
|
|
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
|
|
// Its way back lands on the tile it went in by.
|
|
await piper.locator('#directory .dback').click();
|
|
await expect(cat('Technology')).toBeFocused();
|
|
|
|
// What a feed is holds on every page. No blog here names Technology, so Blogs has no such tile.
|
|
const kind = name => piper.locator('#directory .tabs button', { hasText: new RegExp(`^${name}$`) });
|
|
await kind('Blogs').click();
|
|
await expect(kind('Blogs')).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(cat('Technology')).toHaveCount(0);
|
|
await piper.locator('#directory .dall').click();
|
|
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
|
|
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
|
|
await kind('All').click();
|
|
await expect(tiles).toHaveCount(dir.length);
|
|
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);
|
|
// Sorted by name either way, A to Z as it opens, or by subscribers (#122).
|
|
const byName = dir.map(p => p.title || p.id).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }));
|
|
await expect(tiles.first()).toContainText(byName[0]);
|
|
await piper.locator('#dirsort').selectOption('za');
|
|
await expect(tiles.first()).toContainText(byName[byName.length - 1]);
|
|
await expect(tiles).toHaveCount(dir.length);
|
|
await piper.locator('#dirsort').selectOption('subscribers');
|
|
await expect(tiles).toHaveCount(dir.length);
|
|
await piper.locator('#dirsort').selectOption('az');
|
|
|
|
// The search box looks through the Directory by name (#126), and clearing it goes back.
|
|
await piper.locator('#epSearch').fill('test show');
|
|
await expect(piper.locator('#directory h2')).toHaveText('Results for “test show”');
|
|
await expect(tiles).toHaveCount(dir.filter(p => (p.title || p.id).toLowerCase().includes('test show')).length);
|
|
await piper.locator('#epSearch').fill('');
|
|
await expect(tiles).toHaveCount(dir.length);
|
|
|
|
// A feed you do not subscribe to opens its page here: what it is and its latest items (#128).
|
|
await tiles.filter({ hasText: 'Test Show' }).locator('.txt').click();
|
|
await expect(piper.locator('.show h2')).toHaveText('Test Show');
|
|
await expect(piper.locator('#dshow .lep', { hasText: 'First Episode' })).toBeVisible();
|
|
// What the feed says it is, from its own <description> (#130).
|
|
await expect(piper.locator('#dabout p')).toHaveText('A fixture feed.');
|
|
await expect(piper.locator('#dabout button')).toBeHidden();
|
|
// Back, onto the tile it was opened from, the page under it as it was.
|
|
await piper.locator('#directory .dback').click();
|
|
await expect(tiles.filter({ hasText: 'Test Show' })).toBeFocused();
|
|
await expect(tiles).toHaveCount(dir.length);
|
|
// Only a listed feed has one, and it carries no file or its address.
|
|
const preview = await (await piper.request.get(`/api/directory/${byTitle('Test Show').id}`)).text();
|
|
expect(preview).toContain('First Episode');
|
|
expect(JSON.parse(preview).description).toBe('A fixture feed.');
|
|
expect(preview).not.toContain('.mp3');
|
|
expect((await piper.request.get('/api/directory/paid-show')).status()).toBe(404);
|
|
// Nor does anything else give a feed's items or files to someone who does not subscribe (#129).
|
|
expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(404);
|
|
expect((await piper.request.get('/api/feeds/paid-show/entries')).status()).toBe(404);
|
|
const pics = await page.evaluate(() => api('/api/feeds/picture-blog/entries'));
|
|
const file = pics.entries.flatMap(e => e.enclosures).find(x => x.path);
|
|
expect(file).toBeTruthy();
|
|
expect((await page.request.get(`/media/${file.id}`)).status()).toBe(200);
|
|
expect((await piper.request.get(`/media/${file.id}`)).status()).toBe(404);
|
|
expect((await piper.request.post(`/api/enclosures/${file.id}/download`)).status()).toBe(404);
|
|
expect((await piper.request.delete(`/api/enclosures/${file.id}?force=true`)).status()).toBe(404);
|
|
expect((await piper.request.post('/api/feeds/picture-blog/download-latest', { data: { count: 1 } })).status()).toBe(404);
|
|
|
|
// Add a feed is for an address; the Directory is where you browse (issue #30).
|
|
await piper.locator('#addFeed').click();
|
|
await expect(piper.locator('#nurl')).toBeVisible();
|
|
await expect(piper.locator('#modalCard .tile, #modalCard .rank')).toHaveCount(0);
|
|
// Nor a folder: a feed's folder is its title (#201).
|
|
await expect(piper.locator('#nfolder')).toHaveCount(0);
|
|
await piper.locator('#modalCard button[title="Cancel"]').click();
|
|
|
|
const row = async () =>
|
|
(await (await piper.request.get('/api/directory')).json()).find(p => p.title === 'Test Show');
|
|
const before = await row();
|
|
expect(before).toMatchObject({ subscribed: false, feed: null });
|
|
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 by the id it has here, with
|
|
// one more subscriber once this server has shared again.
|
|
await expect.poll(row, { timeout: 20_000 }).toMatchObject({ subscribed: true, feed: 'test-show', subscribers: before.subscribers + 1 });
|
|
expect((await piper.request.get('/api/feeds/test-show/entries')).status()).toBe(200);
|
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
|
await expect(chart.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
|
|
await expect(chart.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('the feed list keeps the icons it has drawn when a feed\'s new row comes in (#134)', async ({ page }) => {
|
|
await expect(page.locator('#feedlist .feed').first()).toBeVisible({ timeout: 20_000 });
|
|
const kept = await page.evaluate(async () => {
|
|
const frames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
// Artwork that loads, on a feed of its own row: the fixtures' fails on purpose, for initials.
|
|
const row = { ...S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)), image: '/favicon.png' };
|
|
patchFeed(row); await frames();
|
|
const rows = [...document.querySelectorAll('#feedlist .feed')];
|
|
const before = [...document.querySelectorAll('#feedlist .feed img')];
|
|
// The same feed's new row again, as a check of every feed sends one for each feed it reads.
|
|
patchFeed({ ...row }); await frames();
|
|
const after = [...document.querySelectorAll('#feedlist .feed img')];
|
|
return { drawn: before.length > 0, redrawn: !rows[0].isConnected, same: after.length === before.length && after.every((img, i) => img === before[i]) };
|
|
});
|
|
// The rows are new, the images in them the ones already drawn.
|
|
expect(kept).toEqual({ drawn: true, redrawn: true, same: true });
|
|
});
|
|
|
|
test('ipx add and rm while the daemon runs reach it, and its next change keeps them (#120)', async ({ page }) => {
|
|
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` };
|
|
const ipx = (...args) => execFileSync('./target/debug/ipx', args, { env, encoding: 'utf8' });
|
|
// By title: the directory names a feed by its address, not by its id here (#205).
|
|
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
|
|
// A change of the daemon's own: it writes the whole catalogue from the copy it holds, which
|
|
// wrote a removed feed back and dropped an added one when that copy was old.
|
|
const daemonWrites = category => page.evaluate(c =>
|
|
api('/api/feeds/picture-blog', { method: 'PATCH', body: JSON.stringify({ category: c }) }), category);
|
|
|
|
const id = ipx('add', '--list', 'http://127.0.0.1:8792/cli.xml').match(/^added (\S+?),? /m)[1];
|
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
|
await daemonWrites('Comedy');
|
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
|
|
|
ipx('rm', id);
|
|
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Command Line Show');
|
|
await daemonWrites(null);
|
|
expect(await listed()).not.toContain(id);
|
|
});
|
|
|
|
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('adding a page adds the feed it links, and a page with no feed is refused', async ({ page }) => {
|
|
const feeds = await page.locator('.feed').count();
|
|
await page.locator('#addFeed').click();
|
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/nofeed.html');
|
|
await page.locator('#nsave').click();
|
|
await expect(page.locator('.toast')).toContainText('links no feed');
|
|
await expect(page.locator('#modal.on')).toBeVisible(); // left open to correct it
|
|
await expect(page.locator('.feed')).toHaveCount(feeds);
|
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/site.html');
|
|
await page.locator('#nsave').click();
|
|
await expect(page.locator('.feed', { hasText: 'Linked Site' })).toBeVisible({ timeout: 20_000 });
|
|
});
|
|
|
|
test('reading an item updates its feed\'s count without reloading the list', async ({ page }) => {
|
|
const unread = page.locator('.feed:not(.group)', { has: page.locator('.badge:not(.zero)') }).first();
|
|
await expect(unread).toBeVisible({ timeout: 20_000 });
|
|
const id = await unread.getAttribute('data-id');
|
|
const badge = page.locator(`.feed[data-id="${id}"] .badge`);
|
|
const before = Number(await badge.textContent());
|
|
await unread.click();
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
|
await expect(page.locator('.ep').first()).toBeVisible();
|
|
const lists = [];
|
|
page.on('request', r => { if (new URL(r.url()).pathname === '/api/feeds') lists.push(r.url()); });
|
|
await page.locator('.ep').first().click(); // opening an item reads it
|
|
await expect(badge).toHaveText(String(before - 1));
|
|
expect(lists, 'the row came back with the read, not by reloading the list').toEqual([]);
|
|
});
|
|
|
|
test('artwork comes from iPX, kept, and only as an image', async ({ page }) => {
|
|
// Every image a feed names is drawn from iPX's address.
|
|
expect(await page.evaluate(() => artHTML('https://example.com/a.jpg', 'A'))).toContain('src="/api/art?u=https%3A%2F%2Fexample.com%2Fa.jpg"');
|
|
// Multi Show's items name art.jpg.
|
|
await expect(page.locator('.feed', { hasText: 'Multi Show' })).toBeVisible({ timeout: 20_000 });
|
|
const src = '/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/art.jpg');
|
|
for (const _ of [1, 2]) { // fetched, then kept
|
|
const r = await page.request.get(src);
|
|
expect(r.status()).toBe(200);
|
|
expect(r.headers()['content-type']).toMatch(/^image\//);
|
|
expect(r.headers()['content-security-policy']).toContain('sandbox');
|
|
}
|
|
// An address no feed names is not fetched.
|
|
expect((await page.request.get('/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/show.xml'))).status()).toBe(404);
|
|
});
|
|
|
|
test('an item without a title is named from its text, then its file, then its show and date', async ({ page }) => {
|
|
const names = await page.evaluate(() => [
|
|
entryName({ title: 'A Title' }),
|
|
entryName({ title: ' ', description: '<p>I like the way <b>AI</b> is evolving.</p><img src="x.png">' }),
|
|
entryName({ description: '<p>' + 'word '.repeat(40) + '</p>' }).text.length,
|
|
entryName({ enclosures: [{ url: 'https://k.example/media/KARTAS691.mp3?x=1' }] }),
|
|
entryName({ feed_id: 'nobody', published: 0 }),
|
|
]);
|
|
expect(names[0]).toEqual({ text: 'A Title', derived: false });
|
|
expect(names[1]).toEqual({ text: 'I like the way AI is evolving.', derived: true });
|
|
expect(names[2]).toBeLessThanOrEqual(121); // cut at a word, with …
|
|
expect(names[3]).toEqual({ text: 'KARTAS691', derived: true });
|
|
expect(names[4]).toEqual({ text: 'nobody', derived: true });
|
|
});
|
|
|
|
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('#gopml').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(),
|
|
// The Signals folder is not there without a key (#271); the dialog is the same either way.
|
|
() => page.evaluate(() => signalModal()),
|
|
];
|
|
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();
|
|
// The button turns only once the server has it. The badge was no proof: it was seldom 0 to
|
|
// begin with, and a mark-unread still in flight could land after the read-all below.
|
|
await expect(page.locator('#detail [data-a="read"][title="Mark read"]')).toBeVisible();
|
|
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 API token acts as its owner until it is revoked', async ({ page, request }) => {
|
|
// Made in Settings, shown once.
|
|
await page.locator('#prefs').click();
|
|
await page.locator('#tokname').fill('test agent');
|
|
await page.locator('#tokadd').click();
|
|
const token = await page.locator('#tokval').inputValue();
|
|
expect(token).toMatch(/^ipx_[0-9a-f]{64}$/);
|
|
await expect(page.locator('.tokrow', { hasText: 'test agent' })).toContainText('last used never');
|
|
|
|
// Sent as a Bearer header, with no cookie, it is the person who made it.
|
|
const as = t => ({ headers: { Authorization: `Bearer ${t}` } });
|
|
const me = await (await request.get('/api/me', as(token))).json();
|
|
expect(me.name).toBe(await page.evaluate(() => S.me.name));
|
|
expect((await request.get('/api/feeds', as(token))).status()).toBe(200);
|
|
// A wrong one is turned away, not let through as anyone.
|
|
expect((await request.get('/api/me', as('ipx_' + '0'.repeat(64)))).status()).toBe(401);
|
|
// Only the hash is kept: the list never carries the token.
|
|
const listed = await page.evaluate(() => api('/api/tokens'));
|
|
expect(JSON.stringify(listed)).not.toContain(token);
|
|
expect(listed.find(t => t.name === 'test agent').last_used).toBeTruthy();
|
|
|
|
// Revoked, it opens nothing.
|
|
await page.locator('.tokrow', { hasText: 'test agent' }).locator('[data-tok]').click();
|
|
await expect(page.locator('.tokrow', { hasText: 'test agent' })).toHaveCount(0);
|
|
expect((await request.get('/api/me', as(token))).status()).toBe(401);
|
|
});
|
|
|
|
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]);
|
|
// By title: the directory names a feed by its address, not by its id here (#205). It hears of
|
|
// a category given here with this server's next snapshot, sent as soon as one is saved.
|
|
const listed = async title => (await page.evaluate(() => api('/api/directory'))).find(p => p.title === title);
|
|
|
|
await patch('picture-blog', 'Visual Arts');
|
|
// Apple's subcategory, under its category, as the Directory browses them (#118).
|
|
await expect.poll(() => listed('Picture Blog'), { timeout: 20_000 })
|
|
.toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
|
|
// A category's subcategories are on its own page, as Apple's directory has them.
|
|
const pill = name => page.locator('#directory .pills button', { hasText: new RegExp(`^${name}\\d`) });
|
|
await page.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
|
await expect(page.locator('#directory .pills')).toHaveCount(0);
|
|
await page.locator('#directory .cat', { hasText: /^Arts\d/ }).click();
|
|
await pill('Visual Arts').click();
|
|
await expect(pill('Visual Arts')).toHaveAttribute('aria-pressed', 'true');
|
|
await expect(page.locator('#dtiles .tile')).toHaveCount(1);
|
|
// 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);
|
|
await expect.poll(async () => (await listed('Picture Blog')).category, { timeout: 20_000 }).toBeNull();
|
|
});
|
|
|
|
test('the admin page sets up the directory, and a feed marked private leaves it (#205)', async ({ page }) => {
|
|
const listed = async () => (await page.evaluate(() => api('/api/directory'))).map(p => p.title);
|
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
|
|
|
await page.goto('/admin#directory');
|
|
await expect(page.locator('#durl')).toHaveValue('http://127.0.0.1:8795');
|
|
// The token is set and never shown: the field is empty, and says so.
|
|
await expect(page.locator('#dtoken')).toHaveValue('');
|
|
await expect(page.locator('#dtoken')).toHaveAttribute('placeholder', /^Set\./);
|
|
const settings = await page.evaluate(() => api('/api/settings'));
|
|
expect(settings.directory_token_set).toBe(true);
|
|
expect(JSON.stringify(settings)).not.toContain('ipxd_');
|
|
await expect(page.locator('#dstatus')).toContainText('Connected as test, which may browse, share and administer the directory.');
|
|
await expect(page.locator('#dstatus')).toContainText(/Shared \d+ feeds? at/);
|
|
|
|
// A wrong token says so, in words.
|
|
await page.locator('#dtoken').fill('not-the-token');
|
|
await page.locator('#dsave').click();
|
|
await expect(page.locator('#dstatus')).toContainText('does not accept this server');
|
|
await page.locator('#dtoken').fill(require('./global-setup').DIRECTORY_TOKEN);
|
|
await page.locator('#dsave').click();
|
|
await expect(page.locator('#dstatus')).toContainText('Connected as test');
|
|
|
|
// Marked private in its settings, a feed is out of the directory with the next snapshot.
|
|
await page.goto('/');
|
|
await page.locator('.feed', { hasText: 'Multi Show' }).click();
|
|
await page.locator('#content .acts [data-a="settings"]').click();
|
|
const priv = page.locator('#sprivate');
|
|
await expect(priv).not.toBeChecked();
|
|
await priv.check({ force: true });
|
|
await page.locator('#ssave').click();
|
|
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Multi Show');
|
|
// Its address shows nothing, so only the mark keeps it out; and an admin can take it off.
|
|
const row = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'multi-show'));
|
|
expect(row).toMatchObject({ private: true, private_by_address: false });
|
|
await page.evaluate(() => api('/api/feeds/multi-show', { method: 'PATCH', body: JSON.stringify({ private: false }) }));
|
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
|
// A feed whose address carries a key is private whatever it is marked.
|
|
const paid = await page.evaluate(() => api('/api/feeds')).then(fs => fs.find(f => f.id === 'paid-show'));
|
|
if (paid) expect(paid.private_by_address).toBe(true);
|
|
});
|
|
|
|
test('an admin removes a feed from the directory, and unblocks it (#206)', async ({ page, browser }) => {
|
|
const all = () => page.evaluate(() => api('/api/directory'));
|
|
const listed = async () => (await all()).map(p => p.title);
|
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Aardvark Radio');
|
|
const id = (await all()).find(p => p.title === 'Aardvark Radio').id;
|
|
|
|
// Someone who is not an admin is neither offered it nor allowed it.
|
|
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')).toBeVisible();
|
|
expect((await (await piper.request.get(`/api/directory/${id}`)).json()).can_admin).toBe(false);
|
|
expect((await piper.request.delete(`/api/directory/${id}`)).status()).toBe(403);
|
|
expect((await piper.request.post(`/api/directory/${id}/refresh`)).status()).toBe(403);
|
|
expect((await piper.request.get('/api/directory-admin')).status()).toBe(403);
|
|
expect((await piper.request.delete('/api/directory-instances/test')).status()).toBe(403);
|
|
|
|
// Made an admin, and subscribed to none of it, piper opens its page and is offered both. The
|
|
// admin here subscribes to every fixture, so their tiles open their own feeds instead.
|
|
const users = await page.evaluate(() => api('/api/users'));
|
|
const pid = users.find(u => u.name === 'piper').id;
|
|
const setAdmin = admin => page.evaluate(([id, admin]) => api(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify({ admin }) }), [pid, admin]);
|
|
await setAdmin(true);
|
|
try {
|
|
await piper.goto('/');
|
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
|
await piper.locator('#epSearch').fill('aardvark');
|
|
await piper.locator('#dtiles .tile', { hasText: 'Aardvark Radio' }).locator('.txt').click();
|
|
await expect(piper.locator('.show h2')).toHaveText('Aardvark Radio');
|
|
await piper.locator('.show [data-a="drefresh"]').click();
|
|
await expect(piper.locator('.toast', { hasText: 'Checked' })).toBeVisible();
|
|
await expect(piper.locator('.show [data-a="dremove"]')).toHaveCount(1);
|
|
piper.once('dialog', d => d.accept());
|
|
await piper.locator('.show [data-a="dremove"]').click();
|
|
await expect(piper.locator('.show h2')).toHaveCount(0);
|
|
expect(await listed()).not.toContain('Aardvark Radio');
|
|
} finally {
|
|
await setAdmin(false);
|
|
await ctx.close();
|
|
}
|
|
|
|
// Admin, Directory lists the servers sharing and what is blocked, by title, never its address.
|
|
await page.goto('/admin#directory');
|
|
await expect(page.locator('#dadmin .urow[data-name="test"]')).toContainText(/\d+ feeds? · \d+ subscribers?/);
|
|
const blocked = page.locator('#dadmin .urow[data-id]', { hasText: 'Aardvark Radio' });
|
|
await expect(blocked).toBeVisible();
|
|
expect(JSON.stringify(await page.evaluate(() => api('/api/directory-admin')))).not.toMatch(/https?:/);
|
|
// Unblocked, it is back with the snapshot ipx sends straight away, unchanged as it is.
|
|
await blocked.locator('[data-a="unblock"]').click();
|
|
await expect(blocked).toHaveCount(0);
|
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Aardvark Radio');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test.describe('on a phone', () => {
|
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
|
|
|
test('a downloaded file can be deleted, from above the show notes', async ({ page }) => {
|
|
await page.locator('#burger').click();
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).first().click();
|
|
await page.locator('.ep').first().click();
|
|
const del = page.locator('#detail [data-a="del"]');
|
|
await expect(del).toBeInViewport();
|
|
// Below a long set of notes it was screens down and looked missing (issue #21).
|
|
expect(await page.locator('#detail').evaluate(d =>
|
|
!!(d.querySelector('.encbox').compareDocumentPosition(d.querySelector('.dbody')) & Node.DOCUMENT_POSITION_FOLLOWING)))
|
|
.toBe(true);
|
|
});
|
|
});
|
|
|
|
test('a file not yet downloaded has its icon in line with the rest of its row', async ({ page }) => {
|
|
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
|
await expect(page.locator('.ep .dlbar').first()).toBeAttached({ timeout: 20_000 });
|
|
// The icon's middle against the date's, downloaded or not. The download bar used to take a
|
|
// line of its own and lift the icon of every pending file (issue #31).
|
|
const offsets = await page.$$eval('.ep', rows => rows.map(r => {
|
|
const k = r.querySelector('.file .kind'), d = r.querySelector('.date');
|
|
if (!k || !d) return null;
|
|
const a = k.getBoundingClientRect(), b = d.getBoundingClientRect();
|
|
return Math.round((a.top + a.height / 2) - (b.top + b.height / 2));
|
|
}).filter(x => x !== null));
|
|
expect(offsets.length).toBeGreaterThan(1);
|
|
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test('the logo is the dark one in dark mode and the light one in light mode', async ({ page }) => {
|
|
const shown = () => page.locator('#applogo img:visible').getAttribute('src');
|
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
|
expect(await shown()).toMatch(/^\/logo-dark\.svg\?v=/);
|
|
await page.evaluate(() => setTheme('modern', 'light'));
|
|
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
|
|
// Paper has only a light palette, so the light logo whatever the mode asked.
|
|
await page.evaluate(() => setTheme('paper', 'dark'));
|
|
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
|
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
|
});
|
|
|
|
test('/api/status gives a dashboard the counts, with the shared token as a cookie', async ({ page }) => {
|
|
// As Homepage's customapi widget asks: no session, only the token in a Cookie header.
|
|
const r = await page.request.get('/api/status', { headers: { cookie: `ipx_token=${TOKEN}` } });
|
|
expect(r.status()).toBe(200);
|
|
const s = await r.json();
|
|
for (const k of ['feeds', 'pending', 'downloaded']) expect(typeof s[k], k).toBe('number');
|
|
expect(s.version).toMatch(/^\d+\.\d+\.\d+/);
|
|
// The logo names ipx's version and the page's, each its own (#207).
|
|
await expect(page.locator('#applogo')).toHaveAttribute('title', new RegExp(`^iPX ${s.version}, page \\d+\\.\\d+\\.\\d+`));
|
|
const none = await page.request.get('/api/status', { headers: { cookie: '' } });
|
|
expect(none.status()).toBe(401);
|
|
});
|
|
|
|
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
|
await page.evaluate(() => setTheme('modern', 'light'));
|
|
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
|
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
|
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon-dark\.png\?v=[0-9a-f]{12}$/);
|
|
// A browser asks for /favicon.ico on its own, signed in or not.
|
|
for (const path of ['/favicon.ico', '/favicon.png', '/favicon-dark.png', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']) {
|
|
const r = await page.request.get(path, { headers: { cookie: '' } });
|
|
expect(r.status(), path).toBe(200);
|
|
expect(r.headers()['content-type'], path).toBe('image/png');
|
|
}
|
|
});
|
|
|
|
test('a failing feed is marked on its artwork and says why', async ({ page }) => {
|
|
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
|
|
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
|
|
await page.locator('.feed.group .chev').first().click();
|
|
// Faked in the page: no fixture feed fails. A feed on its own failing for a day, and one
|
|
// inside a folder that failed its last check.
|
|
const solo = await page.evaluate(() => {
|
|
S.feeds.find(f => f.group).last_error = 'HTTP 404';
|
|
const f = S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id));
|
|
f.last_error = 'HTTP 404 Not Found';
|
|
f.failing = { reason: 'The publisher took this feed down, or moved it.' };
|
|
renderFeeds();
|
|
return f.id;
|
|
});
|
|
await expect(page.locator('.fart .ferr svg')).toHaveCount(3); // both feeds and the folder
|
|
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
|
|
const row = page.locator(`.feed[data-id="${solo}"]`);
|
|
await expect(row).toHaveClass(/failing/);
|
|
await expect(row.locator('small')).toHaveText('The publisher took this feed down, or moved it.');
|
|
await expect(page.locator('.feed.group.err small').first()).toHaveText('1 feed not updating');
|
|
await page.reload(); // put the real list back
|
|
});
|
|
|
|
test.describe('touch gestures on a phone', () => {
|
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
|
|
|
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol. Sent all at
|
|
// once, in order: awaited one by one, a busy machine stretched a drag past the 800 ms a swipe
|
|
// has, and it was taken for a slow drag and went nowhere (#184).
|
|
async function drag(page, from, to, lift = true) {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const steps = 8, touch = (type, touchPoints) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints });
|
|
const sent = [touch('touchStart', [from])];
|
|
for (let i = 1; i <= steps; i++)
|
|
sent.push(touch('touchMove', [{ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }]));
|
|
if (lift) sent.push(touch('touchEnd', []));
|
|
await Promise.all(sent);
|
|
return cdp;
|
|
}
|
|
|
|
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
|
await page.locator('#burger').click();
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
|
const titles = await page.locator('.ep .t').allTextContents();
|
|
await page.locator('.ep').first().click();
|
|
const shown = page.locator('#detail .dt');
|
|
await expect(shown).toHaveText(titles[0]);
|
|
|
|
await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49)
|
|
await expect(shown).toHaveText(titles[0]);
|
|
await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back
|
|
// Held partway: the next item is beside the reader, not the list under it (#52), and
|
|
// let go short of a swipe, it goes again.
|
|
const held = await drag(page, { x: 300, y: 400 }, { x: 230, y: 405 }, false);
|
|
await expect(page.locator('#dpeek.next .dt')).toHaveText(titles[1]);
|
|
await expect(page.locator('#detail')).toHaveCSS('opacity', '1');
|
|
await held.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
|
await expect(page.locator('#dpeek')).toHaveCount(0);
|
|
await expect(shown).toHaveText(titles[0]);
|
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
|
await expect(shown).toHaveText(titles[1]);
|
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
|
|
await expect(shown).toHaveText(titles[1]);
|
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right: the one before
|
|
await expect(shown).toHaveText(titles[0]);
|
|
await drag(page, { x: 200, y: 300 }, { x: 210, y: 600 }); // down: a scroll, not a swipe
|
|
await expect(shown).toHaveText(titles[0]);
|
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list
|
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
|
});
|
|
|
|
test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => {
|
|
await page.locator('#burger').click();
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
|
// Earlier tests read these; both have to be unread to be on the Unread tab.
|
|
await page.evaluate(() => Promise.all(S.entries.map(e => setRead(e, false))));
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
|
const titles = await page.locator('.ep .t').allTextContents();
|
|
await page.locator('.ep').first().click();
|
|
const shown = page.locator('#detail .dt');
|
|
await expect(shown).toHaveText(titles[0]);
|
|
|
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
|
await expect(shown).toHaveText(titles[1]);
|
|
// The first is read now, and used to be gone from the list already, so this went back to
|
|
// the list instead.
|
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 });
|
|
await expect(shown).toHaveText(titles[0]);
|
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 });
|
|
await expect(shown).toHaveText(titles[1]);
|
|
// Out of the reader, the ones read on the way leave the Unread tab as before.
|
|
await page.locator('#dback').click();
|
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
|
await expect(page.locator('.ep .t', { hasText: titles[0] })).toHaveCount(0);
|
|
});
|
|
|
|
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
|
|
await page.locator('#burger').click();
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
|
const box = await page.locator('#list').boundingBox();
|
|
const fetch = page.waitForRequest(r => r.url().endsWith('/api/fetch') && r.method() === 'POST');
|
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
|
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
|
|
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
|
|
// and a spinner says the check started, for a couple of seconds.
|
|
await expect(page.locator('#pullspin')).toBeVisible();
|
|
// A second pull while it is up checks nothing more.
|
|
let again = 0;
|
|
page.on('request', r => { if (r.url().endsWith('/api/fetch')) again++; });
|
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
|
await expect(page.locator('#pullspin')).toHaveCount(1);
|
|
await expect(page.locator('#pullspin')).toHaveCount(0, { timeout: 5_000 });
|
|
expect(again).toBe(0);
|
|
});
|
|
});
|
|
|
|
test.describe('an item with no files, on a phone', () => {
|
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
|
|
|
test('shows no files box at all', async ({ page }) => {
|
|
await page.locator('#burger').click();
|
|
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
|
// An item with no enclosure, found from the list the page itself has.
|
|
const guid = await page.evaluate(() => S.entries.find(e => !e.enclosures.length)?.guid);
|
|
expect(guid, 'the fixtures have an item with no files').toBeTruthy();
|
|
await page.locator(`.ep[data-guid="${guid}"]`).click();
|
|
await expect(page.locator('#detail .dt')).toBeVisible();
|
|
await expect(page.locator('#detail .encbox')).toHaveCount(0);
|
|
await expect(page.locator('#detail')).not.toContainText('No files');
|
|
});
|
|
});
|
|
|
|
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
|
|
// The Signals folder is first, under All Subscriptions (#256), and no feed.
|
|
const rows = page.locator('#feedlist .feed:not(.sigs)');
|
|
const group = page.locator('.feed.group').first();
|
|
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
|
const child = page.locator('.feed.child').first();
|
|
const name = (await child.locator('.txt b').textContent()).trim();
|
|
await child.click();
|
|
await page.locator('#content .acts [data-a="pin"]').click();
|
|
|
|
// First in the list, out of its folder, marked, and with the rule under it.
|
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
|
await expect(rows.first()).toHaveClass(/\bpinned\b/);
|
|
await expect(rows.first()).not.toHaveClass(/\bchild\b/);
|
|
await expect(rows.first()).toHaveClass(/\blastpin\b/);
|
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(0);
|
|
await expect(page.locator('#content .acts [data-a="pin"]')).toHaveAttribute('aria-pressed', 'true');
|
|
// It is on the account: a reload keeps it.
|
|
await page.reload();
|
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
|
|
|
// Unpinned, it goes back into its folder.
|
|
await rows.first().click();
|
|
await page.locator('#content .acts [data-a="pin"]').click();
|
|
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
|
});
|
|
|
|
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
|
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
|
await expect(row).toBeVisible();
|
|
// A check of a few milliseconds leaves the count where it is, or every count blinked as a scan
|
|
// went by; the spinner comes once the check has taken a moment (#232).
|
|
const atOnce = await page.evaluate(() => {
|
|
setScanning('test-show', true);
|
|
return document.querySelector('#feedlist .feed[data-id="test-show"]').classList.contains('scanning');
|
|
});
|
|
expect(atOnce, 'no spinner the moment a check starts').toBe(false);
|
|
await expect(row).toHaveClass(/\bscanning\b/);
|
|
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
|
await page.evaluate(() => setScanning('test-show', false));
|
|
await expect(row).not.toHaveClass(/\bscanning\b/);
|
|
await expect(row.locator('.badge')).toBeVisible();
|
|
// The button turns from the press to the end of the scan it asked for (#233): a scheduled
|
|
// scan's end, or the end of a scan of one other feed, leaves it turning.
|
|
const spins = () => document.body.classList.contains('scan-any');
|
|
expect(await page.evaluate(spins => { spinStart(null); spinEnd({ only: null, forced: false });
|
|
const a = document.body.classList.contains('scan-any'); spinEnd({ only: 'test-show', forced: true });
|
|
return a && document.body.classList.contains('scan-any'); })).toBe(true);
|
|
await page.evaluate(() => spinEnd({ only: null, forced: true }));
|
|
expect(await page.evaluate(spins)).toBe(false);
|
|
// Checking every feed says so on the rows, not in a toast (issue #37).
|
|
expect(await page.evaluate(() => { scanAll(); return document.body.classList.contains('scan-any'); }),
|
|
'turning the moment it is pressed').toBe(true);
|
|
await expect(page.locator('body')).not.toHaveClass(/\bscan-any\b/, { timeout: 15_000 });
|
|
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
|
});
|
|
|
|
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
|
|
// piper, made in an earlier test, subscribes to Test Show alone.
|
|
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 .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
|
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
|
|
// Listen as the page does, then ask.
|
|
await piper.evaluate(() => {
|
|
window.started = []; window.done = false;
|
|
const es = new EventSource('/api/events');
|
|
es.onmessage = m => { const ev = JSON.parse(m.data);
|
|
if (ev.ev === 'feed_start') window.started.push(ev.feed);
|
|
if (ev.ev === 'scan_done') window.done = true; };
|
|
});
|
|
await piper.waitForTimeout(500);
|
|
await piper.locator('#scanAll').click();
|
|
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
|
|
const started = await piper.evaluate(() => window.started);
|
|
expect(started.length, 'something was checked').toBeGreaterThan(0);
|
|
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
|
|
await ctx.close();
|
|
});
|
|
|
|
test('words in Settings hide the items that mention them', async ({ page }) => {
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
|
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
|
|
|
|
await page.locator('#prefs').click();
|
|
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
|
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
|
await page.locator('#sblock').press('Tab');
|
|
expect((await saved).status()).toBe(204);
|
|
await page.locator('#modal .cardx').click();
|
|
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
|
|
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
|
|
|
|
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
|
|
});
|
|
|
|
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
|
|
await page.evaluate(() => { window.shared = []; navigator.share = async d => { window.shared.push(d); }; });
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
|
await page.locator('#content .acts [data-a="share"]').click();
|
|
await page.locator('.ep', { hasText: 'Second Episode' }).click();
|
|
await page.locator('.encbox [data-a="share"]').first().click();
|
|
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
|
|
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
|
|
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
|
|
]);
|
|
});
|
|
|
|
test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
|
|
const btn = '.fhead [data-a=scan] .i';
|
|
await expect(page.locator(btn)).toBeVisible();
|
|
expect(await spin(btn)).toBe('none');
|
|
// Pressed: this feed's button turns, the toolbar's does not (#78, #233).
|
|
await page.evaluate(() => spinStart('test-show'));
|
|
expect(await spin(btn)).toBe('ipxspin');
|
|
expect(await spin('#scanAll .i')).toBe('none');
|
|
// Feeds being checked one by one no longer start or stop it (#231).
|
|
await page.evaluate(() => { setScanning('test-show', true); setScanning('test-show', false); });
|
|
expect(await spin(btn)).toBe('ipxspin');
|
|
// Another feed's scan ending, or a scheduled one's, leaves it turning; its own stops it.
|
|
await page.evaluate(() => { spinEnd({ only: 'multi-show', forced: true }); spinEnd({ only: null, forced: false }); });
|
|
expect(await spin(btn)).toBe('ipxspin');
|
|
await page.evaluate(() => spinEnd({ only: 'test-show', forced: true }));
|
|
expect(await spin(btn)).toBe('none');
|
|
// The toolbar's turns for a scan of every feed, until that scan ends.
|
|
await page.evaluate(() => spinStart(null));
|
|
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
|
await page.evaluate(() => spinEnd({ only: null, forced: true }));
|
|
expect(await spin('#scanAll .i')).toBe('none');
|
|
});
|
|
|
|
test.describe('on a narrow phone', () => {
|
|
test.use({ viewport: { width: 360, height: 760 } });
|
|
|
|
test("a feed's buttons stay on one row, and the bar round the island is the page's colour", async ({ page }) => {
|
|
// Seven 44px buttons did not fit 360px, and Unsubscribe went onto a row of its own (#146).
|
|
await page.locator('#burger').click();
|
|
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
|
|
const tops = await page.locator('#content .acts .btn').evaluateAll(bs => bs.map(b => b.getBoundingClientRect().top));
|
|
expect(tops.length).toBe(7);
|
|
expect(new Set(tops).size).toBe(1);
|
|
// iOS 26 paints behind the status bar and the Dynamic Island in the colour of a sticky bar at
|
|
// the top edge, older ones in theme-color: both the top bar's, which is the page's (#147).
|
|
const bar = await page.locator('#topbar').evaluate(b => [getComputedStyle(b).position, b.getBoundingClientRect().top, getComputedStyle(b).backgroundColor]);
|
|
expect(bar.slice(0, 2)).toEqual(['sticky', 0]);
|
|
expect(bar[2]).toBe(await page.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
|
expect(await page.evaluate(() => document.querySelector('meta[name=theme-color]').content)).toBe(bar[2]);
|
|
// Glass's bar is see-through on a desktop, which no flat strip can match: solid on a phone.
|
|
for (const mode of ['dark', 'light']) {
|
|
const [c, tc] = await page.evaluate(m => { setTheme('glass', m);
|
|
return [getComputedStyle(document.querySelector('#topbar')).backgroundColor, document.querySelector('meta[name=theme-color]').content]; }, mode);
|
|
expect(c).toMatch(/^rgb\(/);
|
|
expect(tc).toBe(c);
|
|
}
|
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
|
});
|
|
});
|
|
|
|
test('a page brought back into view catches up with what was read elsewhere', async ({ page }) => {
|
|
// Reading an item tells no other page, and a phone's page misses everything while it is in the
|
|
// background: All Subscriptions went on counting an item read elsewhere as unread (#168).
|
|
const badge = page.locator('.place[data-id=":all"] .badge');
|
|
const [one] = (await (await page.request.get('/api/entries?limit=1')).json()).entries;
|
|
const flag = read => page.request.post(`/api/entries/${encodeURIComponent(one.feed_id)}/${encodeURIComponent(one.guid)}/flags`, { data: { read } });
|
|
await flag(false);
|
|
await page.reload();
|
|
const before = Number(await badge.textContent());
|
|
expect(before).toBeGreaterThan(0);
|
|
await flag(true); // read on another device
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|
});
|
|
await expect(badge).toHaveText(String(before - 1));
|
|
});
|
|
|
|
test('a feed\'s settings say what downloads, take words as chips, and keep what is saved', async ({ page }) => {
|
|
// The redesigned dialog (#167): the download choice, word chips, the fold, and a save that holds.
|
|
const open = async () => {
|
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
|
await page.locator('#content .acts [data-a="settings"]').click();
|
|
await expect(page.locator('#modalCard.fset')).toBeVisible();
|
|
};
|
|
await open();
|
|
// The strip is the feed's own items, and Newest 5 lights every one of its two.
|
|
await expect(page.locator('#sepwin i')).toHaveCount(2);
|
|
await page.locator('.seg label', { hasText: 'Newest' }).click();
|
|
await page.locator('#smax').fill('5');
|
|
await expect(page.locator('#sepwin i.on')).toHaveCount(2);
|
|
await expect(page.locator('#swcap')).toContainText('Downloads the newest');
|
|
// Words become chips with Enter or a comma, and come off with their x.
|
|
await page.locator('#skwin').fill('first, second');
|
|
await page.locator('#skwin').press('Enter');
|
|
await expect(page.locator('#skw .chip')).toHaveCount(2);
|
|
await page.locator('#skw .chip', { hasText: 'second' }).locator('.chipx').click();
|
|
await expect(page.locator('#skw .chip')).toHaveCount(1);
|
|
await page.locator('#sblockin').fill('spoiler');
|
|
await page.locator('#sblockin').press('Enter');
|
|
// What only automatic downloads use folds away with them.
|
|
await page.locator('.switch', { hasText: 'Download automatically' }).click();
|
|
await expect(page.locator('#sdeps')).toHaveClass(/\boff\b/);
|
|
await page.locator('.switch', { hasText: 'Download automatically' }).click();
|
|
await expect(page.locator('#sdeps')).not.toHaveClass(/\boff\b/);
|
|
await page.locator('#ssave').click();
|
|
await expect(page.locator('#modal.on')).toBeHidden();
|
|
|
|
// Saved: open again and it is all there.
|
|
await open();
|
|
await expect(page.locator('[name=swin][value=newest]')).toBeChecked();
|
|
await expect(page.locator('#smax')).toHaveValue('5');
|
|
await expect(page.locator('#skw .chip')).toHaveText(['first']);
|
|
await expect(page.locator('#sblock .chip')).toHaveText(['spoiler']);
|
|
|
|
// Back as it was, for the tests after this one.
|
|
await page.locator('.seg label', { hasText: 'Default' }).click();
|
|
await page.locator('#skw .chipx').click();
|
|
await page.locator('#sblock .chipx').click();
|
|
await page.locator('#ssave').click();
|
|
await expect(page.locator('#modal.on')).toBeHidden();
|
|
await open();
|
|
await expect(page.locator('[name=swin][value=default]')).toBeChecked();
|
|
await expect(page.locator('#skw .chip, #sblock .chip')).toHaveCount(0);
|
|
await page.keyboard.press('Escape');
|
|
});
|
|
|
|
test('DeadAir is off until turned on for a show, then plays it shortened, with your pause, and switches back', async ({ page }) => {
|
|
// #171/#172. A show of its own, with real audio, added here and left at the end.
|
|
const { id } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } })).json();
|
|
const entry = async () => {
|
|
const r = await page.request.get(`/api/feeds/${encodeURIComponent(id)}/entries`);
|
|
return (await r.json()).entries?.[0];
|
|
};
|
|
await expect.poll(async () => (await entry())?.guid, { timeout: 15_000 }).toBe('quiet-1');
|
|
await page.request.post(`/api/feeds/${encodeURIComponent(id)}/download-latest`, { data: { count: 1 } });
|
|
await expect.poll(async () => !!(await entry())?.enclosures[0]?.path, { timeout: 15_000 }).toBe(true);
|
|
// Off for a show until someone turns it on: nothing is cut.
|
|
expect((await entry()).enclosures[0].deadair).toBeUndefined();
|
|
await page.reload();
|
|
|
|
// On, in the show's settings.
|
|
await page.locator('#feedlist .feed', { hasText: 'Quiet Show' }).first().click();
|
|
await page.locator('#content .acts [data-a="settings"]').click();
|
|
const sw = page.locator('#sdead');
|
|
await expect(sw).not.toBeChecked();
|
|
await sw.locator('xpath=..').click();
|
|
await page.locator('#ssave').click();
|
|
// Found at once, not at the next pass, and the row says so when it is done without a reload:
|
|
// "Cutting dead air…" until then, then about 1.7 s off each of four 2 s silences.
|
|
const row = page.locator('.ep', { hasText: 'Pauses Between' });
|
|
await expect(row).toContainText('s shorter', { timeout: 20_000 });
|
|
expect((await entry()).enclosures[0].deadair).toBeGreaterThan(5);
|
|
await expect(row).not.toContainText('Cutting dead air');
|
|
|
|
// A longer pause of your own (#182) saves less.
|
|
const usual = (await entry()).enclosures[0].deadair;
|
|
await page.locator('#content .acts [data-a="settings"]').click();
|
|
await expect(page.locator('#sdpause')).toHaveValue('300');
|
|
await page.locator('#sdpause').fill('500');
|
|
await page.locator('#ssave').click();
|
|
await expect.poll(async () => (await entry()).enclosures[0].deadair).toBeLessThan(usual);
|
|
|
|
// Someone else on the same show at the usual pause, at the same address at the same time: one
|
|
// file on disk, a playlist each, and neither kept by a cache to be handed to the other.
|
|
{
|
|
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', 'quinn'], { input: 'quinnpassword', env }); }
|
|
catch (e) { if (!String(e.stderr || e.stdout).includes('already exists')) throw e; }
|
|
const ctx = await page.context().browser().newContext();
|
|
const quinn = await ctx.newPage();
|
|
await quinn.goto('/login');
|
|
await quinn.locator('#name').fill('quinn');
|
|
await quinn.locator('#pw').fill('quinnpassword');
|
|
await quinn.locator('button[type=submit]').click();
|
|
await expect(quinn.locator('#feedlist')).toBeVisible();
|
|
await quinn.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } });
|
|
await quinn.request.patch(`/api/feeds/${encodeURIComponent(id)}`, { data: { deadair: true } });
|
|
const enc = (await entry()).enclosures[0].id;
|
|
const fetchList = async p => { const r = await p.request.get(`/media/${enc}/deadair.m3u8`);
|
|
return { status: r.status(), cache: r.headers()['cache-control'], body: await r.text() }; };
|
|
const [mine, theirs] = await Promise.all([fetchList(page), fetchList(quinn)]);
|
|
const length = l => l.body.match(/#EXTINF:([\d.]+)/g).reduce((s, x) => s + Number(x.slice(8)), 0);
|
|
expect([mine.status, theirs.status]).toEqual([200, 200]);
|
|
expect([mine.cache, theirs.cache]).toEqual(['no-store', 'no-store']);
|
|
expect(mine.body).not.toBe(theirs.body);
|
|
expect(length(theirs), 'the usual pause cuts more than the longer one').toBeLessThan(length(mine));
|
|
await quinn.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
|
|
await ctx.close();
|
|
}
|
|
|
|
// Saved without its dead air too (#186): smaller than the original, named to say so.
|
|
const saved = await page.evaluate(async id => {
|
|
const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]);
|
|
return { status: d.status, name: d.headers.get('content-disposition'), size: (await d.arrayBuffer()).byteLength, full: (await o.arrayBuffer()).byteLength,
|
|
// Kept out of shared caches, or Cloudflare serves its own copy to anyone (#189).
|
|
caching: [d, o, p].map(r => r.headers.get('cache-control')), piece: p.status };
|
|
}, (await entry()).enclosures[0].id);
|
|
expect(saved.status).toBe(200);
|
|
expect(saved.name).toContain('DeadAir');
|
|
expect(saved.size).toBeLessThan(saved.full);
|
|
expect(saved.caching).toEqual(['private', 'private', 'private']);
|
|
expect(saved.piece).toBe(206);
|
|
|
|
// Played through DeadAir's playlist (#181), by hls.js in Chromium, with the switch beside the
|
|
// speed pressed. The element's own src is then hls.js's blob: address.
|
|
// How long it took to start goes to the server's log, under the trace of the tap (#190).
|
|
const started = page.waitForRequest(r => r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'play_start');
|
|
const piece = page.waitForRequest(r => r.url().endsWith('/deadair.mp3'));
|
|
await row.locator('[data-a=play]').click();
|
|
await expect.poll(() => page.evaluate(() => player.src)).toMatch(/\/deadair\.m3u8$/);
|
|
const report = await started;
|
|
expect(report.postDataJSON()).toMatchObject({ deadair: true });
|
|
expect(report.postDataJSON().ms).toBeGreaterThan(0);
|
|
expect(report.headers().traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
|
|
// hls.js's own requests carry the same trace as the tap that played them (#192).
|
|
const traceOf = h => h.traceparent?.split('-')[1];
|
|
expect(traceOf((await piece).headers())).toBe(traceOf(report.headers()));
|
|
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').currentTime), { timeout: 15_000 }).toBeGreaterThan(0.5);
|
|
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
|
|
// Off from the player: the original, from the same place.
|
|
await page.locator('#pdead').click();
|
|
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'false');
|
|
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/media\/\d+$/);
|
|
expect((await entry()).enclosures[0].deadair).toBeUndefined();
|
|
|
|
await page.locator('#pclose').click();
|
|
await page.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
|
|
});
|
|
|
|
test('a downloaded file\'s buttons sit on one row in the files pane', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
|
await expect(page.locator('.ep').first()).toBeVisible();
|
|
await page.evaluate(() => showDetail(S.entries.find(e => e.enclosures.some(x => x.path))));
|
|
const box = page.locator('#files .encbox').first();
|
|
await expect(box.locator('.btn')).toHaveCount(4);
|
|
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
|
|
expect(new Set(tops).size, 'play, save, share and delete wrapped onto two rows').toBe(1);
|
|
});
|
|
|
|
test('a list seen before shows at once, and one not yet seen shows a spinner while it loads', async ({ page }) => {
|
|
// #247. Every list's answer held back a second and a half, as a slow connection would.
|
|
await page.route(/\/api\/(entries|feeds\/[^/]+\/entries)\?/, async route => {
|
|
await new Promise(r => setTimeout(r, 1500));
|
|
await route.continue();
|
|
});
|
|
await page.evaluate(() => { lists.clear(); fetching.clear(); });
|
|
await page.locator('.place[data-id=":all"]').click();
|
|
await expect(page.locator('#eps .listload'), 'waited for, and said so').toBeVisible();
|
|
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
|
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
|
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
|
|
// Back to All Subscriptions: drawn from what was kept, before the slow answer could come.
|
|
await page.locator('.place[data-id=":all"]').click();
|
|
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 500 });
|
|
await expect(page.locator('#eps .listload')).toHaveCount(0);
|
|
await page.unroute(/\/api\/(entries|feeds\/[^/]+\/entries)\?/);
|
|
});
|
|
|
|
test('Currently Listening and the Directory show at once once seen, with a spinner the first time', async ({ page }) => {
|
|
// #248. Both answers held back a second and a half.
|
|
const slow = /\/api\/(directory$|entries\?filter=in_progress)/;
|
|
await page.route(slow, async route => { await new Promise(r => setTimeout(r, 1500)); await route.continue(); });
|
|
// What the page fetched ahead when it loaded is let finish and then forgotten, and no more is
|
|
// fetched ahead, or the first visit would find the Directory already here, as it is meant to.
|
|
await page.waitForFunction(() => pageFetching.size === 0 && fetching.size === 0);
|
|
await page.evaluate(() => { window.preloadLists = () => {}; pages.clear(); });
|
|
for (const [id, drawn] of [[':listening', '#listening .childrow, #listening .hint'], [':directory', '#directory > *:not(.listload)']]) {
|
|
await page.locator(`.place[data-id="${id}"]`).click();
|
|
await expect(page.locator('.listload'), `${id}: waited for, and said so`).toBeVisible();
|
|
await expect(page.locator(drawn).first()).toBeVisible({ timeout: 10_000 });
|
|
await page.locator('.place[data-id=":all"]').click();
|
|
await page.locator(`.place[data-id="${id}"]`).click();
|
|
await expect(page.locator(drawn).first(), `${id}: drawn from what was kept`).toBeVisible({ timeout: 500 });
|
|
await expect(page.locator('.listload')).toHaveCount(0);
|
|
}
|
|
await page.unroute(slow);
|
|
});
|
|
|
|
test('the page comes compressed, and the compressed copies cannot be asked for by name', async ({ page }) => {
|
|
// #253: brotli made at build, served in place of the file.
|
|
const js = await page.request.get('/app.js', { headers: { 'Accept-Encoding': 'br, gzip' } });
|
|
expect(js.status()).toBe(200);
|
|
expect(js.headers()['content-encoding']).toBe('br');
|
|
expect(js.headers()['content-type']).toContain('javascript');
|
|
const gz = await page.request.get('/app.css', { headers: { 'Accept-Encoding': 'gzip' } });
|
|
expect(gz.headers()['content-encoding']).toBe('gzip');
|
|
// The admin page's script, under its compressed name, would skip the check that keeps it to admins.
|
|
for (const p of ['/admin.js.br', '/admin.html.gz', '/app.js.br']) {
|
|
expect((await page.request.get(p)).status(), p).toBe(404);
|
|
}
|
|
});
|
|
|
|
signalsTest('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
|
|
// #254. Jev is the fixture server's stand-in; the show is added here and removed at the end.
|
|
expect((await page.request.patch('/api/me', { data: { typesafe_key: 'test-key' } })).status()).toBe(204);
|
|
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
|
|
await page.reload();
|
|
|
|
// The + on the folder's page (#265); making one opens the folder to show it.
|
|
await page.locator('#feedlist .sigs').click();
|
|
await page.locator('#content .acts [data-a="new"]').click();
|
|
await page.locator('#sgname').fill('F1');
|
|
await page.locator('#sgkwin').fill('monaco');
|
|
await page.keyboard.press('Enter');
|
|
// An icon of its own, found by searching Font Awesome Free (#261).
|
|
await page.locator('#sgicon').click();
|
|
await page.locator('#sgiconq').fill('flag checkered');
|
|
await page.locator('[data-ic="solid/flag-checkered"]').click();
|
|
await page.locator('#sgsave').click();
|
|
const row = page.locator('#feedlist .feed.child', { hasText: 'F1' });
|
|
await expect(row).toHaveClass(/sel/);
|
|
await expect(row.locator('.ficon')).toHaveAttribute('style', /\/icons\/solid\/flag-checkered\.svg/);
|
|
expect((await page.request.get('/icons/solid/flag-checkered.svg')).status()).toBe(200);
|
|
|
|
const signals = async () => (await (await page.request.get('/api/signals')).json());
|
|
const sg = (await signals()).find(s => s.name === 'F1');
|
|
const shown = async () => (await (await page.request.get(`/api/signals/${sg.id}/entries`)).json()).entries.map(e => e.title);
|
|
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Monaco qualifying']);
|
|
|
|
await page.reload();
|
|
await row.click();
|
|
await page.locator('.ep', { hasText: 'Monaco qualifying' }).click();
|
|
await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read,
|
|
{ timeout: 5_000 }).toBe(true);
|
|
expect((await signals()).find(s => s.id === sg.id).unread).toBe(0);
|
|
// And the sidebar says so without a reload: it kept saying 1 (#258).
|
|
await expect(row.locator('.badge')).toHaveText('0');
|
|
|
|
expect((await page.request.get(`/api/signals/${sg.id + 1000}/entries`)).status(), 'no such Signal').toBe(404);
|
|
|
|
await page.request.delete(`/api/signals/${sg.id}`);
|
|
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
|
|
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
|
});
|
|
|
|
signalsTest('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => {
|
|
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
|
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json();
|
|
await page.reload();
|
|
await page.evaluate(id => selectFeed(':signal:' + id), id);
|
|
await expect(page.locator('#content a[href="https://typesafe.ai"]')).toBeVisible();
|
|
await page.locator('#content [data-keylink]').click();
|
|
await expect(page.locator('#stskey')).toBeFocused();
|
|
await page.keyboard.press('Escape');
|
|
await page.request.delete(`/api/signals/${id}`);
|
|
});
|
|
|
|
signalsTest('Signals are turned on in Settings with a key TypeSafe takes, and off again by forgetting it', async ({ page }) => {
|
|
// #271. The test daemon has no key of its own, so each person brings theirs.
|
|
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
|
await page.reload();
|
|
await expect(page.locator('#feedlist .sigs')).toHaveCount(0);
|
|
await page.evaluate(() => prefsModal());
|
|
await expect(page.locator('#ssigkey')).toBeHidden();
|
|
await page.locator('#ssigon').check();
|
|
await page.locator('#stskey').fill('bad-key');
|
|
await page.locator('#stskey').press('Enter');
|
|
await page.locator('#stskey').blur();
|
|
await expect(page.locator('.toast', { hasText: 'TypeSafe did not accept that key' })).toBeVisible();
|
|
expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false);
|
|
await page.locator('#stskey').fill('test-key');
|
|
await page.locator('#stskey').blur();
|
|
await expect(page.locator('#feedlist .sigs')).toHaveCount(1);
|
|
page.once('dialog', d => d.accept());
|
|
await page.locator('#ssigon').uncheck();
|
|
await expect(page.locator('#feedlist .sigs')).toHaveCount(0);
|
|
expect((await (await page.request.get('/api/me')).json()).typesafe_key_set).toBe(false);
|
|
await page.keyboard.press('Escape');
|
|
});
|
|
|
|
signalsTest('the server pays for Signals only when it has a key to pay with', async ({ page }) => {
|
|
// #271. With the shell's TYPESAFE_KEY (/src/.envrc's development key) the server can pay;
|
|
// Jev itself is still the fixture server's stand-in.
|
|
const { typesafe_server_key: has } = await (await page.request.get('/api/settings')).json();
|
|
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
|
expect((await page.request.patch('/api/settings', { data: { signals_on_server: true } })).status()).toBe(204);
|
|
try {
|
|
expect((await (await page.request.get('/api/me')).json()).signals).toBe(has ? 'server' : 'own');
|
|
await page.goto('/admin#server');
|
|
if (!has) { await expect(page.locator('#gsig')).toBeDisabled(); return; }
|
|
await expect(page.locator('#gsig')).toBeChecked();
|
|
|
|
// No key of your own, and Signals are there, judged on the server's.
|
|
await page.goto('/');
|
|
await expect(page.locator('#feedlist .sigs')).toHaveCount(1);
|
|
await page.evaluate(() => prefsModal());
|
|
await expect(page.locator('#stskey')).toHaveCount(0);
|
|
await page.keyboard.press('Escape');
|
|
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
|
|
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Bread', keywords: ['baking'] } })).json();
|
|
const shown = async () => (await (await page.request.get(`/api/signals/${id}/entries`)).json()).entries.map(e => e.title);
|
|
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Baking bread']);
|
|
await page.request.delete(`/api/signals/${id}`);
|
|
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
|
|
} finally {
|
|
await page.request.patch('/api/settings', { data: { signals_on_server: false } });
|
|
}
|
|
});
|