- The import screen has a file picker beside the paste box. The page reads the file, checks it looks like OPML before sending, and clears the picker when it is refused and after it is imported. The file is sent as text and never written to disk on the server. - The server parses the OPML before touching anything and answers 400 "that is not an OPML file" (was a 500). subscribe_opml takes a parsed document, so ipx import also refuses a non-OPML file by name. - Tests: Settings' Export OPML download and paste import; uploading an RSS file (refused) and a real OPML; the server's 400; the admin's export round-tripped into a second account; ipx import/export in a scratch config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
547 lines
25 KiB
JavaScript
547 lines
25 KiB
JavaScript
const { test, expect } = require('@playwright/test');
|
|
const { TOKEN } = require('./global-setup');
|
|
|
|
// The token sets a cookie, so every test starts by presenting it once.
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto(`/?token=${TOKEN}`);
|
|
await expect(page.locator('#feedlist')).toBeVisible();
|
|
});
|
|
|
|
test('the page loads and lists the configured feeds', async ({ page }) => {
|
|
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
|
// empty, with every handler below the error dead. Server-side checks all passed.
|
|
// Three top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
|
await expect(page.locator('.feed')).toHaveCount(4, { timeout: 15_000 });
|
|
await expect(page.getByText('Test Show')).toBeVisible();
|
|
const errors = [];
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
await page.reload();
|
|
await expect(page.locator('.feed').first()).toBeVisible();
|
|
expect(errors, 'the page script must not throw at load').toEqual([]);
|
|
});
|
|
|
|
test('the theme toggle actually changes the theme', async ({ page }) => {
|
|
// Regression: this button was wired after a line that threw, so it did nothing.
|
|
const before = await page.evaluate(() => document.documentElement.dataset.theme || 'system');
|
|
await page.locator('#theme').click();
|
|
await expect
|
|
.poll(() => page.evaluate(() => document.documentElement.dataset.theme))
|
|
.not.toBe(before);
|
|
});
|
|
|
|
test('settings opens and saves the global schedule', async ({ page }) => {
|
|
await page.locator('#prefs').click();
|
|
await expect(page.locator('#modal.on')).toBeVisible();
|
|
await expect(page.locator('#gnum')).toBeVisible();
|
|
await page.locator('#gnum').fill('4');
|
|
await page.locator('#gunit').selectOption('h');
|
|
await page.locator('#gsave').click();
|
|
await expect(page.locator('#modal.on')).toBeHidden();
|
|
|
|
// It must survive a reload, i.e. actually reach the config.
|
|
await page.locator('#prefs').click();
|
|
await expect(page.locator('#gnum')).toHaveValue('4');
|
|
await expect(page.locator('#gunit')).toHaveValue('h');
|
|
});
|
|
|
|
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
|
|
await page.getByText('Test Show').click();
|
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
|
await expect(page.getByText('First Episode')).toBeVisible();
|
|
// Newest first, so target the episode by name rather than by position.
|
|
const first = page.locator('.ep', { hasText: 'First Episode' });
|
|
await expect(first).toContainText('S1E1');
|
|
await expect(first).toContainText('30:30'); // itunes:duration 1830
|
|
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toContainText('15:00');
|
|
|
|
// Selecting an item shows its text in the pane below, not inline in the row.
|
|
await first.click();
|
|
await expect(first).toHaveClass(/sel/);
|
|
await expect(page.locator('#detail')).toContainText('Show notes for the first one');
|
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
|
});
|
|
|
|
test('the three panes are there and the item text lands in the bottom one', async ({ page }) => {
|
|
await page.getByText('Test Show').click();
|
|
await expect(page.locator('#list')).toBeVisible();
|
|
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
|
|
await expect(page.locator('#detail')).toContainText('Pick an item');
|
|
|
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
|
// The enclosure travels with the item, into the same pane.
|
|
await expect(page.locator('#detail .encbox')).toHaveCount(1);
|
|
|
|
// Only the downloaded one gets a player, and max_new_per_check is 1, so find it by
|
|
// its chip rather than assuming which episode the daemon happened to fetch.
|
|
const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first();
|
|
await downloaded.click();
|
|
await expect(page.locator('#detail audio')).toBeVisible();
|
|
await expect(page.locator('#detail .encbox .btn', { hasText: 'Save' })).toBeVisible();
|
|
|
|
// Selecting another item replaces the pane rather than stacking.
|
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
|
await expect(page.locator('#detail audio')).toHaveCount(0);
|
|
});
|
|
|
|
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
|
// Regression: anything with a file got an <audio> element and a play button, so a blog's
|
|
// header image rendered as a broken player.
|
|
await page.locator('.feed', { hasText: 'Picture Blog' }).click();
|
|
const row = page.locator('.ep', { hasText: 'An Article' });
|
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
|
|
|
await expect(row.locator('[data-a="play"]')).toHaveCount(0);
|
|
await row.click();
|
|
|
|
await expect(page.locator('#detail .dt')).toHaveText('An Article');
|
|
await expect(page.locator('#detail audio')).toHaveCount(0);
|
|
await expect(page.locator('#detail .encbox')).toContainText('image');
|
|
await expect(page.locator('#detail .encbox')).toContainText('downloaded');
|
|
// Still offered as a file, just not as an episode: viewable and keepable.
|
|
await expect(page.locator('#detail .btn', { hasText: 'Save' })).toBeVisible();
|
|
const view = page.locator('#detail a', { hasText: 'View' });
|
|
await expect(view).toHaveAttribute('target', '_blank');
|
|
await expect(view).toHaveAttribute('rel', /noopener/);
|
|
await expect(view).toHaveAttribute('href', /\/media\/\d+/);
|
|
});
|
|
|
|
test('an item with several enclosures lists them all', async ({ page }) => {
|
|
await page.locator('.feed', { hasText: 'Multi Show' }).click();
|
|
const row = page.locator('.ep', { hasText: 'Two Files' });
|
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
|
// The row says there is more than one without listing them.
|
|
await expect(row).toContainText('+1 more file');
|
|
|
|
await row.click();
|
|
// The pane below lists every one: the audio and the image.
|
|
await expect(page.locator('#detail .encbox')).toHaveCount(2);
|
|
await expect(page.locator('#detail .encbox').nth(1)).toContainText('image');
|
|
});
|
|
|
|
test('the filter tabs change what is listed', async ({ page }) => {
|
|
await page.getByText('Test Show').click();
|
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
|
const 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: 'Flagged' }).first().click();
|
|
await expect(page.locator('#count')).toContainText('0 items');
|
|
});
|
|
|
|
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
|
await page.getByText('Test Show').click();
|
|
await page.locator('.btn', { hasText: 'Settings' }).first().click();
|
|
await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
|
|
await expect(page.locator('#scopy')).toBeVisible();
|
|
|
|
// navigator.clipboard is absent over plain http, so the button must not throw.
|
|
const errors = [];
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
await page.locator('#scopy').click();
|
|
await expect(page.locator('#scopy')).toHaveText(/Copied|Failed/);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
|
|
await page.locator('#logs').click();
|
|
await expect(page.locator('#logbox')).toBeVisible();
|
|
await expect(page.locator('#logtabs button')).toHaveCount(4);
|
|
|
|
// Generate traffic, then check the Daemon I/O tab shows both directions.
|
|
await page.locator('#logtabs button', { hasText: 'Daemon I/O' }).click();
|
|
await page.evaluate(() =>
|
|
fetch('/api/fetch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ force: true }),
|
|
}));
|
|
await expect(page.locator('#logbox .l').first()).toBeVisible({ timeout: 15_000 });
|
|
await expect(page.locator('#logbox')).toContainText('"cmd":"fetch"', { timeout: 15_000 });
|
|
await expect(page.locator('#logbox')).toContainText('"ev":', { timeout: 15_000 });
|
|
});
|
|
|
|
test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
|
// Read the subscription so its feeds exist.
|
|
await page.evaluate(() =>
|
|
fetch('/api/fetch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
|
}));
|
|
|
|
// Every row reserves the chevron slot for alignment; only a folder's is clickable.
|
|
const chev = page.locator('.feed.group .chev');
|
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
|
|
|
// Closed by default: the children are not listed until the folder is opened.
|
|
const before = await page.locator('.feed').count();
|
|
await chev.click();
|
|
await expect(page.locator('.feed')).toHaveCount(before + 2);
|
|
// Scoped to the sidebar: the name also appears as the page heading once selected.
|
|
await expect(page.locator('#feedlist').getByText('Grouped Show')).toBeVisible();
|
|
|
|
// The subscription's own page lists what is inside it.
|
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
|
await expect(page.locator('.childrow')).toHaveCount(2);
|
|
});
|
|
|
|
test('inside an OPML, feeds with unread items are listed first', async ({ page }) => {
|
|
await page.evaluate(() =>
|
|
fetch('/api/fetch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
|
}));
|
|
const chev = page.locator('.feed.group .chev');
|
|
await expect(chev).toBeVisible({ timeout: 20_000 });
|
|
await chev.click();
|
|
const side = page.locator('#feedlist');
|
|
await expect(side.getByText('Aardvark Radio')).toBeVisible({ timeout: 20_000 });
|
|
|
|
// Other tests change read state, so set it here: Aardvark Radio read, Grouped Show not.
|
|
// Opening an item reads it; the toggle in the pane below flips it back.
|
|
await side.getByText('Aardvark Radio').click();
|
|
const aa = page.locator('.ep', { hasText: 'Aardvark Ep' });
|
|
await aa.click();
|
|
await expect(aa).toHaveClass(/read/);
|
|
await side.getByText('Grouped Show').click();
|
|
const gs = page.locator('.ep', { hasText: 'Grouped Ep' });
|
|
await gs.click();
|
|
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
|
await expect(gs).not.toHaveClass(/read/);
|
|
|
|
// Aardvark comes first alphabetically and in the OPML, so only the unread sort puts
|
|
// Grouped Show above it. The folder stays open across the reload (localStorage).
|
|
await page.reload();
|
|
const want = ['Grouped Show', 'Aardvark Radio'];
|
|
await expect(page.locator('#feedlist .feed.child b')).toHaveText(want, { timeout: 20_000 });
|
|
await page.locator('.feed', { hasText: 'Test Subscriptions' }).first().click();
|
|
await expect(page.locator('.childrow b')).toHaveText(want);
|
|
});
|
|
|
|
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
|
await page.evaluate(() =>
|
|
fetch('/api/fetch', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ feed: 'test-subscriptions', force: true }),
|
|
}));
|
|
|
|
// The folder's own row has no entries, so anything it marks read came from its child.
|
|
const folder = page.locator('.feed', { hasText: 'Test Subscriptions' }).first();
|
|
await expect(folder).toBeVisible({ timeout: 20_000 });
|
|
await expect(folder.locator('.badge')).not.toHaveText('0');
|
|
await folder.click();
|
|
|
|
await page.locator('#content .acts button', { hasText: 'Mark all read' }).click();
|
|
await expect(folder.locator('.badge')).toHaveText('0');
|
|
});
|
|
|
|
test.describe('on a phone', () => {
|
|
test.use({ viewport: { width: 390, height: 844 } });
|
|
|
|
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
|
|
// The burger used to live in the player bar, which is hidden until something plays --
|
|
// leaving no way to reach the feeds at all.
|
|
await expect(page.locator('#burger')).toBeVisible();
|
|
await expect(page.locator('#player')).not.toBeVisible();
|
|
|
|
await page.locator('#burger').click();
|
|
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
|
|
await expect(page.locator('#sidebar')).not.toHaveClass(/open/);
|
|
|
|
// Nothing may push the page sideways at this width.
|
|
const over = await page.evaluate(() =>
|
|
document.documentElement.scrollWidth - window.innerWidth);
|
|
expect(over).toBeLessThanOrEqual(0);
|
|
|
|
// One pane at a time: the list, then the item over it, then back.
|
|
await expect(page.locator('#detail')).not.toBeVisible();
|
|
await page.locator('.ep').first().click();
|
|
await expect(page.locator('#detail')).toBeVisible();
|
|
await page.locator('#dback').click();
|
|
await expect(page.locator('#detail')).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
test('opening an item marks it read, and the toggle flips it back', async ({ page }) => {
|
|
const errors = [];
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
|
|
await page.getByText('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', { hasText: 'Mark unread' }).click();
|
|
await expect(row()).not.toHaveClass(/read/);
|
|
await expect(page.locator('#detail button', { hasText: 'Mark read' })).toBeVisible();
|
|
|
|
// Opening it is reading it.
|
|
await row().click();
|
|
await expect(row()).toHaveClass(/read/);
|
|
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
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');
|
|
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.');
|
|
await expect(page.locator('#prefs')).toBeHidden(); // not an admin
|
|
// Hiding the button is not the guard; the server is.
|
|
expect((await page.request.get('/api/users')).status()).toBe(403);
|
|
await expect(page.locator('#logs')).toBeHidden();
|
|
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
|
|
|
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
|
// files, but Sam's own read state.
|
|
await page.locator('#addFeed').click();
|
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/show.xml');
|
|
await page.locator('#nsave').click();
|
|
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
|
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
|
// The admin read these earlier in this file; for Sam they are all still unread.
|
|
const rows = await page.locator('.ep').count();
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
|
await expect(page.locator('.ep')).toHaveCount(rows);
|
|
|
|
// Two people now share this feed, so the page says so and Delete is honest about it.
|
|
await expect(page.locator('#content .sub').first()).toContainText('shared with 1 other person');
|
|
|
|
await ctx.close();
|
|
});
|
|
|
|
test('deleting a shared file warns that it is everyone\'s copy', async ({ page }) => {
|
|
// Admin and Sam both subscribe to Test Show by now, and the daemon downloaded a file.
|
|
await page.getByText('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();
|
|
|
|
const del = page.locator('#detail button', { hasText: 'Delete' });
|
|
await expect(del).toHaveText('Delete for everyone');
|
|
|
|
// 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.getByText('Test Show').click();
|
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
|
});
|
|
|
|
// Every row says "Admin" on its checkbox, so match the name exactly.
|
|
const userRow = (page, name) =>
|
|
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
|
|
|
async function openUsers(page) {
|
|
await page.locator('#prefs').click();
|
|
await page.locator('#gusers').click();
|
|
await expect(userRow(page, 'admin')).toBeVisible();
|
|
}
|
|
|
|
test('an admin adds someone, makes them an admin, and removes them', async ({ page }) => {
|
|
await openUsers(page);
|
|
await page.locator('#uname').fill('pat');
|
|
await page.locator('#upass').fill('patpassword');
|
|
await page.locator('#uadd').click();
|
|
const row = userRow(page, 'pat');
|
|
await expect(row).toBeVisible();
|
|
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', { hasText: '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');
|
|
});
|