Tests, tooling and the served scripts are TypeScript (#392)

The page and the service worker were strict TypeScript; the tests, the
build, the home page's generator, the load tests, boot.js and the
directory's site.js were plain JavaScript that nothing checked. All of
it is TypeScript now, in five programs, each with its own globals, all
checked by npm run typecheck: the page (tsconfig.json, with boot.ts and
the directory's site.ts), the worker, Node's (tsconfig.tools.json: the
Node tests, the browser suite, the build, the site generator, the load
harness), and k6's (tsconfig.k6.json, with @types/k6).

Node 24 runs the .ts files by stripping their types, so they hold only
TypeScript that erases (erasableSyntaxOnly) and import each other by
.ts name; package.json says "type": "module" for it, and Dockerfile.web
moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types
itself.

The browser suite's page.evaluate calls reach the page's globals; the
page's tsc run now also writes its declarations to target/page-types,
which the suite's program includes, so those calls are typed by the
page's own code rather than by hand-written copies.

boot.ts is compiled by build.mts with swc, as the worker is, and named
by the hash of what is served. The directory's build has no Node, so
site.js is compiled from site.ts and committed (node web/build.mts
--directory-script), and tests/directory-script.ts fails if they drift.

Checked: the site generator's 139 pages are byte for byte what the
JavaScript made; the page build's app.js is identical and only boot.js
(now minified) and its hash differ; contrast, page-smoke, page-unit,
native-bridge, the browser suite (96 passed, 2 skipped) and the
directory's and ipx's Rust tests pass; k6 parses each load script. The
load tests themselves did not run: they build a release binary outside
the capped builder. .agents/skills/security-audit is an installed
skill's, not ours, and is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 02:23:22 +00:00
parent b509ea4b35
commit f8408a3d26
45 changed files with 692 additions and 531 deletions

View File

@@ -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 }) => {

View File

@@ -1,4 +1,4 @@
// What the browser tests run of the page's script (#390). With IPX_COVERAGE=1, web/build.mjs
// What the browser tests run of the page's script (#390). With IPX_COVERAGE=1, web/build.mts
// builds the script unminified with a source map to web/src, every test's page records V8's
// coverage of it, and the teardown (coverage-teardown.ts) writes the report, file by file, to
// target/coverage/page. Without it, `test` is Playwright's own.

View File

@@ -1,9 +1,9 @@
// Serves the fixture feeds so the daemon under test has something real to scan.
const http = require('http');
const fs = require('fs');
const path = require('path');
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const dir = __dirname;
const dir = import.meta.dirname;
const port = Number(process.env.FIXTURE_PORT || 8792);
http.createServer((req, res) => {
@@ -18,13 +18,14 @@ http.createServer((req, res) => {
res.writeHead(401, { 'content-type': 'application/json' }).end('{"detail":"invalid api key"}');
return;
}
const { state, questions } = JSON.parse(body);
// As ipx asks TypeSafe's Jev: the item, and a question a Signal.
const { state, questions } = JSON.parse(body) as { state: unknown, questions: Record<string, { criteria: unknown, instructions: string }> };
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
return;
}
const title = String(state).toLowerCase();
const answers = {};
const answers: Record<string, { noul: number }> = {};
for (const [id, q] of Object.entries(questions)) {
// A key's check asks about no Signal's words.
const words = /described by these words: ([^.]*)\./.exec(q.instructions)?.[1].split(', ') ?? [];
@@ -37,12 +38,12 @@ http.createServer((req, res) => {
// Dated today, since a Signal looks at the last 30 days only.
if (req.url === '/signal.xml') {
const now = new Date().toUTCString();
const item = (guid, title) => `<item><guid>${guid}</guid><title>${title}</title><pubDate>${now}</pubDate></item>`;
const item = (guid: string, title: string) => `<item><guid>${guid}</guid><title>${title}</title><pubDate>${now}</pubDate></item>`;
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
<title>Signal Show</title>${item('sig-1', 'Monaco qualifying')}${item('sig-2', 'Baking bread')}</channel></rss>`);
return;
}
const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index';
const name = decodeURIComponent((req.url ?? '/').split('?')[0]!.replace(/^\//, '')) || 'index';
const file = path.join(dir, path.basename(name));
fs.readFile(file, (err, body) => {
if (err) { res.writeHead(404).end('no'); return; }

View File

@@ -1,8 +1,9 @@
// Builds a scratch config and data dir so the browser tests drive a real daemon with
// known feeds, rather than whatever happens to be on the machine.
const fs = require('fs');
const path = require('path');
const os = require('os');
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
const root = path.join(os.tmpdir(), 'ipx-ui-test');
const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log
@@ -10,7 +11,7 @@ const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not
const DIRECTORY = 'http://127.0.0.1:8795';
const DIRECTORY_TOKEN = 'ipxd_testdirectorytoken';
// Called from playwright.config.js at load time, NOT as globalSetup: Playwright starts
// Called from playwright.config.ts at load time, NOT as globalSetup: Playwright starts
// webServer *before* globalSetup, so a config written there does not exist yet when the
// daemon launches -- it would fall back to the real config and fight the live daemon.
// Playwright imports this config again in every worker process, so prepare() runs more
@@ -83,8 +84,8 @@ fetch_internal = ["127.0.0.1"]
[[tokens]]
name = "test"
access = "rwa"
token_sha256 = "${require('crypto').createHash('sha256').update(DIRECTORY_TOKEN).digest('hex')}"
token_sha256 = "${crypto.createHash('sha256').update(DIRECTORY_TOKEN).digest('hex')}"
`);
}
module.exports = { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN };
export { prepare, root, TOKEN, DIRECTORY, DIRECTORY_TOKEN };

View File

@@ -1,10 +1,10 @@
// iPX without a connection (#389): the service worker (web/sw/sw.ts) keeps the page, what it read
// and the pictures in unread items, and holds writes made offline until the connection is back.
// After app.spec.js, file order being run order: it adds a feed, which that file's counts of
// After app.spec.ts, file order being run order: it adds a feed, which that file's counts of
// feeds do not expect.
import type { BrowserContext, Page, Worker } from '@playwright/test';
import { TOKEN } from './global-setup.js';
import { TOKEN } from './global-setup.ts';
import { expect, test } from './coverage.ts';
// The suite blocks the worker, which answers before page.route can; these tests are about it.