|
|
|
|
@@ -1,16 +1,27 @@
|
|
|
|
|
// With the page's coverage recorded when asked for (#390).
|
|
|
|
|
const { test, expect } = require('./coverage.ts');
|
|
|
|
|
const { TOKEN } = require('./global-setup');
|
|
|
|
|
import { test, expect } from './coverage.ts';
|
|
|
|
|
import * as setup from './global-setup.ts';
|
|
|
|
|
import { TOKEN } from './global-setup.ts';
|
|
|
|
|
import crypto from 'node:crypto';
|
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
|
import type { Locator, Page, Request } from '@playwright/test';
|
|
|
|
|
|
|
|
|
|
// What tests leave on the page's window to read back: the live updates a page saw, what it shared.
|
|
|
|
|
declare global {
|
|
|
|
|
interface Window { started: string[]; done: boolean; shared: ShareData[] }
|
|
|
|
|
}
|
|
|
|
|
/// What a failed `ipx` command said, from execFileSync's error.
|
|
|
|
|
const said = (e: unknown) => { const x = e as { stderr?: unknown, stdout?: unknown }; return String(x.stderr || x.stdout); };
|
|
|
|
|
|
|
|
|
|
// 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, '../..');
|
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
const ROOT = path.join(import.meta.dirname, '../..');
|
|
|
|
|
const SIGNALS_CODE = (() => {
|
|
|
|
|
const h = require('node:crypto').createHash('sha256');
|
|
|
|
|
const h = 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');
|
|
|
|
|
@@ -19,10 +30,10 @@ const SIGNALS_CODE = (() => {
|
|
|
|
|
}
|
|
|
|
|
return h.digest('hex');
|
|
|
|
|
})();
|
|
|
|
|
function signalsTest(name, fn) {
|
|
|
|
|
function signalsTest(name: string, fn: (args: { page: Page }) => Promise<void>) {
|
|
|
|
|
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');
|
|
|
|
|
crypto.createHash('sha256').update(name).digest('hex').slice(0, 16));
|
|
|
|
|
const want = 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 = '';
|
|
|
|
|
@@ -47,7 +58,7 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
|
|
|
|
// 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 = [];
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
|
|
|
await page.reload();
|
|
|
|
|
await expect(page.locator('.feed').first()).toBeVisible();
|
|
|
|
|
@@ -59,7 +70,7 @@ test('the script is its own file, cached until a deploy changes it', async ({ pa
|
|
|
|
|
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');
|
|
|
|
|
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');
|
|
|
|
|
@@ -74,7 +85,7 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
|
|
|
|
|
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 expect(page.locator('#stheme')).toHaveValue((await root())[0]!);
|
|
|
|
|
|
|
|
|
|
await page.locator('#stheme').selectOption('modern');
|
|
|
|
|
await page.locator('#smode').selectOption('auto');
|
|
|
|
|
@@ -173,7 +184,7 @@ test('a font chosen in Settings is kept in this browser, over any theme\'s own',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the reading font is an item\'s title and text, and the interface keeps its own', async ({ page }) => {
|
|
|
|
|
const fam = (sel) => page.locator(sel).first().evaluate(el => getComputedStyle(el).fontFamily);
|
|
|
|
|
const fam = (sel: string) => page.locator(sel).first().evaluate(el => getComputedStyle(el).fontFamily);
|
|
|
|
|
await page.locator('.feed', { hasText: 'Picture Blog' }).click();
|
|
|
|
|
await page.locator('.ep', { hasText: 'An Article' }).click();
|
|
|
|
|
await expect(page.locator('#detail .dt')).toHaveText('An Article');
|
|
|
|
|
@@ -192,7 +203,7 @@ test('the reading font is an item\'s title and text, and the interface keeps its
|
|
|
|
|
expect(lh).toBeCloseTo(1.75, 2);
|
|
|
|
|
// Four fifths of the pane, whatever the font (#360).
|
|
|
|
|
const share = await page.locator('#detail .dbody').evaluate(el => {
|
|
|
|
|
const p = el.parentElement, c = getComputedStyle(p);
|
|
|
|
|
const p = el.parentElement!, c = getComputedStyle(p);
|
|
|
|
|
return el.getBoundingClientRect().width / (p.clientWidth - parseFloat(c.paddingLeft) - parseFloat(c.paddingRight)); });
|
|
|
|
|
expect(share).toBeCloseTo(0.8, 2);
|
|
|
|
|
|
|
|
|
|
@@ -462,7 +473,7 @@ test('a feed URL is editable and has a copy button', async ({ page }) => {
|
|
|
|
|
await expect(page.locator('#scopy')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// navigator.clipboard is absent over plain http, so the button must not throw.
|
|
|
|
|
const errors = [];
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
|
|
|
await page.locator('#scopy').click();
|
|
|
|
|
await expect(page.locator('#scopy')).toHaveText(/Copied|Failed/);
|
|
|
|
|
@@ -574,7 +585,7 @@ test('a show inside an OPML can be kept, and only it', async ({ page }) => {
|
|
|
|
|
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);
|
|
|
|
|
expect((kept.feeds || kept).find((f: Feed) => f.title === 'Aardvark Radio')?.kept).toBe(true);
|
|
|
|
|
await keep.click();
|
|
|
|
|
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
|
|
|
|
|
|
|
|
|
@@ -585,8 +596,8 @@ test('a show inside an OPML can be kept, and only it', async ({ page }) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 api = (p: string, init?: { method?: string, body?: string }) => page.evaluate(([p, init]) => fetch(p, { headers: { 'Content-Type': 'application/json' }, ...init })
|
|
|
|
|
.then((r) => r.json().catch(() => null)), [p, init] as const);
|
|
|
|
|
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();
|
|
|
|
|
@@ -632,8 +643,8 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa
|
|
|
|
|
|
|
|
|
|
// A deploy of ipx-web: the page asks for itself again after the live stream comes back (#388).
|
|
|
|
|
// The new version is faked by answering / once with another app.js named in it.
|
|
|
|
|
async function newVersionOnce(page) {
|
|
|
|
|
await page.route(u => new URL(u).pathname === '/', async route => {
|
|
|
|
|
async function newVersionOnce(page: Page) {
|
|
|
|
|
await page.route(u => u.pathname === '/', async route => {
|
|
|
|
|
const r = await route.fetch();
|
|
|
|
|
await route.fulfill({ response: r, body: (await r.text()).replace(/\/app\.js\?v=[0-9a-f]+/, '/app.js?v=0000000000ff') });
|
|
|
|
|
}, { times: 1 });
|
|
|
|
|
@@ -650,7 +661,7 @@ test('a new version reloads the page and opens the item that was open, or offers
|
|
|
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
|
|
|
|
}
|
|
|
|
|
await expect(first).toBeVisible({ timeout: 20_000 });
|
|
|
|
|
const title = await first.locator('.t').textContent();
|
|
|
|
|
const title = (await first.locator('.t').textContent()) ?? '';
|
|
|
|
|
await first.click();
|
|
|
|
|
await expect(page.locator('#detail .dt')).toHaveText(title);
|
|
|
|
|
|
|
|
|
|
@@ -710,7 +721,7 @@ test.describe('on a phone', () => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('opening an item marks it read, and the toggle flips it back', async ({ page }) => {
|
|
|
|
|
const errors = [];
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
|
|
|
|
|
|
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
|
|
|
@@ -756,8 +767,6 @@ test('the toolbar acts on the selected item', async ({ page }) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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`,
|
|
|
|
|
@@ -766,7 +775,7 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|
|
|
|
try {
|
|
|
|
|
execFileSync('./target/debug/ipx', ['user', 'add', 'sam'], { input: 'sampassword', env });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
|
|
|
|
|
if (!said(e).includes('already exists')) throw e;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A fresh context, so none of the admin's cookies come along.
|
|
|
|
|
@@ -841,7 +850,7 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
|
|
|
|
|
|
|
|
|
// 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 = [];
|
|
|
|
|
const seen: string[] = [];
|
|
|
|
|
page.on('dialog', d => {
|
|
|
|
|
seen.push(d.message());
|
|
|
|
|
if (seen.length === 1) d.accept();
|
|
|
|
|
@@ -859,10 +868,10 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Every row says "Admin" on its checkbox, so match the name exactly.
|
|
|
|
|
const userRow = (page, name) =>
|
|
|
|
|
const userRow = (page: Page, name: string) =>
|
|
|
|
|
page.locator('#accounts [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
|
|
|
|
|
|
|
|
|
async function openUsers(page) {
|
|
|
|
|
async function openUsers(page: Page) {
|
|
|
|
|
await page.goto('/admin');
|
|
|
|
|
await page.locator('#atabs a', { hasText: 'Accounts' }).click();
|
|
|
|
|
await expect(page).toHaveURL(/\/admin#accounts$/);
|
|
|
|
|
@@ -884,7 +893,7 @@ test('an admin adds someone, makes them an admin, and removes them', async ({ pa
|
|
|
|
|
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
|
|
|
|
|
(await (await page.request.get('/api/users')).json()).find((u: Account) => u.name === 'pat')?.admin
|
|
|
|
|
).toBe(true);
|
|
|
|
|
|
|
|
|
|
page.once('dialog', d => d.accept());
|
|
|
|
|
@@ -899,7 +908,7 @@ test('the only admin cannot be demoted or removed', async ({ page }) => {
|
|
|
|
|
// 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');
|
|
|
|
|
const me = (await (await page.request.get('/api/users')).json()).find((u: Account) => u.name === 'admin');
|
|
|
|
|
expect((await page.request.delete(`/api/users/${me.id}`)).status()).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@@ -910,7 +919,7 @@ test('Settings exports your OPML and imports a pasted one', async ({ page }) =>
|
|
|
|
|
page.locator('#modalCard a[title="Export OPML"]').click(),
|
|
|
|
|
]);
|
|
|
|
|
expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml');
|
|
|
|
|
const out = require('fs').readFileSync(await dl.path(), 'utf8');
|
|
|
|
|
const out = 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');
|
|
|
|
|
@@ -936,11 +945,11 @@ test('an uploaded OPML file imports, and a file that is not OPML is refused', as
|
|
|
|
|
// 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')),
|
|
|
|
|
buffer: fs.readFileSync(path.join(import.meta.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);
|
|
|
|
|
expect(await pick.evaluate((i: HTMLInputElement) => 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', {
|
|
|
|
|
@@ -964,8 +973,6 @@ test('an uploaded OPML file imports, and a file that is not OPML is refused', as
|
|
|
|
|
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`,
|
|
|
|
|
@@ -974,7 +981,7 @@ test('an export from one account imports into another', async ({ page, browser }
|
|
|
|
|
try {
|
|
|
|
|
execFileSync('./target/debug/ipx', ['user', 'add', 'opal'], { input: 'opalpassword', env });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
|
|
|
|
|
if (!said(e).includes('already exists')) throw e;
|
|
|
|
|
}
|
|
|
|
|
const ctx = await browser.newContext();
|
|
|
|
|
const opal = await ctx.newPage();
|
|
|
|
|
@@ -989,7 +996,7 @@ test('an export from one account imports into another', async ({ page, browser }
|
|
|
|
|
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 urlsIn = (xml: string) => [...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);
|
|
|
|
|
@@ -1008,17 +1015,13 @@ test('an export from one account imports into another', async ({ page, browser }
|
|
|
|
|
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' });
|
|
|
|
|
const ipx = (args: string[], input?: string) => 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');
|
|
|
|
|
@@ -1036,8 +1039,6 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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`,
|
|
|
|
|
@@ -1046,7 +1047,7 @@ test('the Directory lists what servers share, the most subscribed first, but nev
|
|
|
|
|
try {
|
|
|
|
|
execFileSync('./target/debug/ipx', ['user', 'add', 'piper'], { input: 'piperpassword', env });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!String(e.stderr || e.stdout).includes('already exists')) throw e;
|
|
|
|
|
if (!said(e).includes('already exists')) throw e;
|
|
|
|
|
}
|
|
|
|
|
const ctx = await browser.newContext();
|
|
|
|
|
const piper = await ctx.newPage();
|
|
|
|
|
@@ -1073,14 +1074,14 @@ test('the Directory lists what servers share, the most subscribed first, but nev
|
|
|
|
|
|
|
|
|
|
// 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 dir: DirFeed[] = await (await piper.request.get('/api/directory')).json();
|
|
|
|
|
const top: DirFeed[] = 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);
|
|
|
|
|
const byTitle = (t: string) => 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);
|
|
|
|
|
@@ -1091,7 +1092,7 @@ test('the Directory lists what servers share, the most subscribed first, but nev
|
|
|
|
|
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 cat = (name: string) => 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 });
|
|
|
|
|
@@ -1104,7 +1105,7 @@ test('the Directory lists what servers share, the most subscribed first, but nev
|
|
|
|
|
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}$`) });
|
|
|
|
|
const kind = (name: string) => 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);
|
|
|
|
|
@@ -1117,7 +1118,7 @@ test('the Directory lists what servers share, the most subscribed first, but nev
|
|
|
|
|
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 }));
|
|
|
|
|
const byName = dir.map(p => p.title || p.id).sort((a: string, b: string) => 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]);
|
|
|
|
|
@@ -1145,7 +1146,7 @@ test('the Directory lists what servers share, the most subscribed first, but nev
|
|
|
|
|
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();
|
|
|
|
|
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');
|
|
|
|
|
@@ -1153,8 +1154,9 @@ test('the Directory lists what servers share, the most subscribed first, but nev
|
|
|
|
|
// 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 pics: ListPage = await page.evaluate(() => api('/api/feeds/picture-blog/entries'));
|
|
|
|
|
const file = pics.entries.flatMap(e => e.enclosures).find(x => x.path);
|
|
|
|
|
if (!file) throw new Error('Picture Blog has no downloaded file');
|
|
|
|
|
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);
|
|
|
|
|
@@ -1171,7 +1173,7 @@ test('the Directory lists what servers share, the most subscribed first, but nev
|
|
|
|
|
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');
|
|
|
|
|
(await (await piper.request.get('/api/directory')).json()).find((p: DirFeed) => 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();
|
|
|
|
|
@@ -1201,7 +1203,7 @@ test('the feed list keeps the icons it has drawn when a feed\'s new row comes in
|
|
|
|
|
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' };
|
|
|
|
|
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')];
|
|
|
|
|
@@ -1215,18 +1217,16 @@ test('the feed list keeps the icons it has drawn when a feed\'s new row comes in
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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' });
|
|
|
|
|
const ipx = (...args: string[]) => 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);
|
|
|
|
|
const listed = async () => (await page.evaluate((): Promise<DirFeed[]> => 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 =>
|
|
|
|
|
const daemonWrites = (category: string | null) => 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];
|
|
|
|
|
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');
|
|
|
|
|
@@ -1267,7 +1267,7 @@ test('reading an item updates its feed\'s count without reloading the list', asy
|
|
|
|
|
await unread.click();
|
|
|
|
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
|
|
|
|
await expect(page.locator('.ep').first()).toBeVisible();
|
|
|
|
|
const lists = [];
|
|
|
|
|
const lists: string[] = [];
|
|
|
|
|
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));
|
|
|
|
|
@@ -1297,7 +1297,7 @@ test('an item without a title is named from its text, then its file, then its sh
|
|
|
|
|
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({ enclosures: [{ url: 'https://k.example/media/KARTAS691.mp3?x=1' }] } as Partial<Entry> as Entry),
|
|
|
|
|
entryName({ feed_id: 'nobody', published: 0 }),
|
|
|
|
|
]);
|
|
|
|
|
expect(names[0]).toEqual({ text: 'A Title', derived: false });
|
|
|
|
|
@@ -1326,7 +1326,7 @@ test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
const icon = (loc: Locator) => 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.
|
|
|
|
|
@@ -1335,7 +1335,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
|
|
|
|
|
// 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')),
|
|
|
|
|
const pair = async (a: string) => [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');
|
|
|
|
|
@@ -1390,11 +1390,11 @@ test('All Subscriptions marks everything read, across every feed', async ({ page
|
|
|
|
|
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 head = (k: string) => 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));
|
|
|
|
|
const cmp = (a: string, b: string) => (a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
|
|
|
|
|
const sorted = (t: string[], dir: number) => 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
|
|
|
|
|
|
|
|
|
|
@@ -1467,7 +1467,7 @@ test('keys move through items and places, after Feedly', async ({ page }) => {
|
|
|
|
|
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');
|
|
|
|
|
const guid = async (i: number) => (await 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');
|
|
|
|
|
@@ -1503,16 +1503,16 @@ test('an API token acts as its owner until it is revoked', async ({ page, reques
|
|
|
|
|
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 as = (t: string) => ({ 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(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();
|
|
|
|
|
expect(listed.find((t: { name: string, last_used: number | null }) => t.name === 'test agent').last_used).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
// Revoked, it opens nothing.
|
|
|
|
|
await page.locator('.tokrow', { hasText: 'test agent' }).locator('[data-tok]').click();
|
|
|
|
|
@@ -1521,18 +1521,18 @@ test('an API token acts as its owner until it is revoked', async ({ page, reques
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an admin can give a blog its Directory category', async ({ page }) => {
|
|
|
|
|
const patch = (id, category) => page.evaluate(([id, category]) =>
|
|
|
|
|
const patch = (id: string, category: string | null) => 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);
|
|
|
|
|
const listed = async (title: string) => (await page.evaluate((): Promise<DirFeed[]> => 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`) });
|
|
|
|
|
const pill = (name: string) => 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();
|
|
|
|
|
@@ -1541,14 +1541,14 @@ test('an admin can give a blog its Directory category', async ({ page }) => {
|
|
|
|
|
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');
|
|
|
|
|
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();
|
|
|
|
|
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);
|
|
|
|
|
const listed = async () => (await page.evaluate((): Promise<DirFeed[]> => api('/api/directory'))).map(p => p.title);
|
|
|
|
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
|
|
|
|
|
|
|
|
|
await page.goto('/admin#directory');
|
|
|
|
|
@@ -1566,7 +1566,7 @@ test('the admin page sets up the directory, and a feed marked private leaves it
|
|
|
|
|
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('#dtoken').fill(setup.DIRECTORY_TOKEN);
|
|
|
|
|
await page.locator('#dsave').click();
|
|
|
|
|
await expect(page.locator('#dstatus')).toContainText('Connected as test');
|
|
|
|
|
|
|
|
|
|
@@ -1580,29 +1580,27 @@ test('the admin page sets up the directory, and a feed marked private leaves it
|
|
|
|
|
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'));
|
|
|
|
|
const row = await page.evaluate((): Promise<Feed[]> => 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'));
|
|
|
|
|
const paid = await page.evaluate((): Promise<Feed[]> => 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 all = () => page.evaluate((): Promise<DirFeed[]> => 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;
|
|
|
|
|
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;
|
|
|
|
|
if (!said(e).includes('already exists')) throw e;
|
|
|
|
|
}
|
|
|
|
|
const ctx = await browser.newContext();
|
|
|
|
|
const piper = await ctx.newPage();
|
|
|
|
|
@@ -1620,8 +1618,8 @@ test('an admin removes a feed from the directory, and unblocks it (#206)', async
|
|
|
|
|
// 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]);
|
|
|
|
|
const pid = users.find((u: Account) => u.name === 'piper').id;
|
|
|
|
|
const setAdmin = (admin: boolean) => page.evaluate(([id, admin]) => api(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify({ admin }) }), [pid, admin]);
|
|
|
|
|
await setAdmin(true);
|
|
|
|
|
try {
|
|
|
|
|
await piper.goto('/');
|
|
|
|
|
@@ -1662,8 +1660,8 @@ test('the pinned heading sits over its pins, and the page is set in Inter', asyn
|
|
|
|
|
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);
|
|
|
|
|
const mid = (b: { x: number, width: number }) => 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');
|
|
|
|
|
@@ -1682,7 +1680,7 @@ test.describe('on a phone', () => {
|
|
|
|
|
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)))
|
|
|
|
|
!!(d.querySelector('.encbox')!.compareDocumentPosition(d.querySelector('.dbody')!) & Node.DOCUMENT_POSITION_FOLLOWING)))
|
|
|
|
|
.toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
@@ -1747,10 +1745,10 @@ test('a failing feed is marked on its artwork and says why', async ({ page }) =>
|
|
|
|
|
// 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));
|
|
|
|
|
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.' };
|
|
|
|
|
f.failing = { reason: 'The publisher took this feed down, or moved it.', new_url: null };
|
|
|
|
|
renderFeeds();
|
|
|
|
|
return f.id;
|
|
|
|
|
});
|
|
|
|
|
@@ -1769,9 +1767,9 @@ test.describe('touch gestures on a phone', () => {
|
|
|
|
|
// 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) {
|
|
|
|
|
async function drag(page: Page, from: { x: number, y: number }, to: { x: number, y: number }, lift = true) {
|
|
|
|
|
const cdp = await page.context().newCDPSession(page);
|
|
|
|
|
const steps = 8, touch = (type, touchPoints) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints });
|
|
|
|
|
const steps = 8, touch = (type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel', touchPoints: { x: number, y: number }[]) => 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 }]));
|
|
|
|
|
@@ -1782,7 +1780,7 @@ test.describe('touch gestures on a phone', () => {
|
|
|
|
|
|
|
|
|
|
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
|
|
|
|
// Each swipe says what came of it, and ipx takes it (#384); a scroll is not one.
|
|
|
|
|
const swipes = [];
|
|
|
|
|
const swipes: Request[] = [];
|
|
|
|
|
page.on('request', r => { if (r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'swipe') swipes.push(r); });
|
|
|
|
|
await page.locator('#burger').click();
|
|
|
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
|
|
|
@@ -1851,7 +1849,7 @@ test.describe('touch gestures on a phone', () => {
|
|
|
|
|
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 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 });
|
|
|
|
|
@@ -1892,7 +1890,7 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
|
|
|
|
|
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();
|
|
|
|
|
const name = ((await child.locator('.txt b').textContent()) ?? '').trim();
|
|
|
|
|
await child.click();
|
|
|
|
|
await page.locator('#content .acts [data-a="pin"]').click();
|
|
|
|
|
|
|
|
|
|
@@ -1921,7 +1919,7 @@ test('a feed being checked shows a spinner on its row, and no toast', async ({ p
|
|
|
|
|
// 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');
|
|
|
|
|
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/);
|
|
|
|
|
@@ -1989,7 +1987,7 @@ test('words in Settings hide the items that mention them', async ({ page }) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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.evaluate(() => { window.shared = []; navigator.share = async (d?: ShareData) => { 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();
|
|
|
|
|
@@ -2003,7 +2001,7 @@ test('Share hands the feed, the item and the file to the share sheet', async ({
|
|
|
|
|
|
|
|
|
|
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 spin = (sel: string) => 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');
|
|
|
|
|
@@ -2030,9 +2028,9 @@ test("an open feed's page follows its row: an error a later check clears goes",
|
|
|
|
|
// The header was drawn once, on opening, and kept a cleared error until reopened (#316).
|
|
|
|
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
|
|
|
|
const head = page.locator('.fhead .meta');
|
|
|
|
|
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show'), last_error: 'connecting: timed out', failing: null }));
|
|
|
|
|
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show')!, last_error: 'connecting: timed out', failing: null }));
|
|
|
|
|
await expect(head.locator('.fail')).toHaveText('connecting: timed out');
|
|
|
|
|
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show'), last_error: null, entries: 99 }));
|
|
|
|
|
await page.evaluate(() => patchFeed({ ...S.feeds.find(f => f.id === 'test-show')!, last_error: null, entries: 99 }));
|
|
|
|
|
await expect(head.locator('.fail')).toHaveCount(0);
|
|
|
|
|
await expect(head.locator('.stat')).toContainText('99 items');
|
|
|
|
|
});
|
|
|
|
|
@@ -2068,11 +2066,11 @@ test.describe('on a narrow phone', () => {
|
|
|
|
|
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]);
|
|
|
|
|
expect(await page.evaluate(() => document.querySelector<HTMLMetaElement>('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);
|
|
|
|
|
return [getComputedStyle(document.querySelector('#topbar')!).backgroundColor, document.querySelector<HTMLMetaElement>('meta[name=theme-color]')?.content]; }, mode);
|
|
|
|
|
expect(c).toMatch(/^rgb\(/);
|
|
|
|
|
expect(tc).toBe(c);
|
|
|
|
|
}
|
|
|
|
|
@@ -2085,7 +2083,7 @@ test('a page brought back into view catches up with what was read elsewhere', as
|
|
|
|
|
// 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 } });
|
|
|
|
|
const flag = (read: boolean) => 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());
|
|
|
|
|
@@ -2202,12 +2200,10 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y
|
|
|
|
|
// 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();
|
|
|
|
|
catch (e) { if (!said(e).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');
|
|
|
|
|
@@ -2217,10 +2213,10 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y
|
|
|
|
|
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`);
|
|
|
|
|
const fetchList = async (p: Page) => { 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);
|
|
|
|
|
const length = (l: { body: string }) => (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);
|
|
|
|
|
@@ -2254,14 +2250,14 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y
|
|
|
|
|
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];
|
|
|
|
|
const traceOf = (h: Record<string, string>) => 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.poll(() => page.evaluate(() => document.querySelector<HTMLAudioElement>('#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+$/);
|
|
|
|
|
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();
|
|
|
|
|
@@ -2272,7 +2268,7 @@ test('a downloaded file\'s buttons sit on one row in the files pane', async ({ p
|
|
|
|
|
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))));
|
|
|
|
|
await page.evaluate(() => showDetail(S.entries.find(e => e.enclosures.some(x => x.path)) ?? null));
|
|
|
|
|
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)));
|
|
|
|
|
@@ -2355,8 +2351,8 @@ signalsTest('a Signal holds the items Jev says fit its keywords, read where they
|
|
|
|
|
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);
|
|
|
|
|
const sg = (await signals()).find((s: Signal) => s.name === 'F1');
|
|
|
|
|
const shown = async () => (await (await page.request.get(`/api/signals/${sg.id}/entries`)).json() as ListPage).entries.map(e => e.title);
|
|
|
|
|
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Monaco qualifying']);
|
|
|
|
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
@@ -2364,7 +2360,7 @@ signalsTest('a Signal holds the items Jev says fit its keywords, read where they
|
|
|
|
|
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);
|
|
|
|
|
expect((await signals()).find((s: Signal) => 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');
|
|
|
|
|
|
|
|
|
|
@@ -2379,7 +2375,7 @@ signalsTest('a Signal with no key to judge it links to where one is got and wher
|
|
|
|
|
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 page.evaluate((id: number) => 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();
|
|
|
|
|
@@ -2430,7 +2426,7 @@ signalsTest('the server pays for Signals only when it has a key to pay with', as
|
|
|
|
|
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);
|
|
|
|
|
const shown = async () => (await (await page.request.get(`/api/signals/${id}/entries`)).json() as ListPage).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)}`);
|
|
|
|
|
@@ -2450,12 +2446,12 @@ test('a dialog keeps the keyboard inside it and gives it back on closing', async
|
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
|
|
|
await page.keyboard.press('Tab');
|
|
|
|
|
// Past the last control Tab goes to the browser's own toolbar (the body, from here), never the page behind.
|
|
|
|
|
expect(await page.evaluate(() => { const a = document.activeElement; return a === document.body || document.querySelector('#modalCard').contains(a); }), `Tab ${i + 1} stays out of the page behind`).toBe(true);
|
|
|
|
|
expect(await page.evaluate(() => { const a = document.activeElement; return a === document.body || !!document.querySelector('#modalCard')?.contains(a); }), `Tab ${i + 1} stays out of the page behind`).toBe(true);
|
|
|
|
|
}
|
|
|
|
|
await page.keyboard.press('Escape');
|
|
|
|
|
await expect(page.locator('#modal.on')).toBeHidden();
|
|
|
|
|
await expect(page.locator('#addFeed')).toBeFocused();
|
|
|
|
|
expect(await page.locator('#shell').evaluate(e => e.inert)).toBe(false);
|
|
|
|
|
expect(await page.locator('#shell').evaluate((e: HTMLElement) => e.inert)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an item opens from the keyboard, and its row actions show while focused', async ({ page }) => {
|