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:
@@ -1,6 +1,6 @@
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { BASE, FEEDS, ITEMS, as, me, pick, feedId, p95 } from './lib.js';
|
||||
import { BASE, FEEDS, ITEMS, as, me, pick, feedId, p95, body, type Feed, type ListPage } from './lib.ts';
|
||||
|
||||
// An evening's browsing: twenty-five people at once opening their feed list, All Subscriptions,
|
||||
// a feed, a search, the Directory and a feed's page in it. Each answer's budget is well above what
|
||||
@@ -29,18 +29,18 @@ export const options = {
|
||||
export default function () {
|
||||
const u = me();
|
||||
const feeds = http.get(`${BASE}/api/feeds`, as(u, 'feeds'));
|
||||
check(feeds, { 'your thirty feeds': r => r.status === 200 && r.json().length === 30 });
|
||||
check(feeds, { 'your thirty feeds': r => r.status === 200 && body<Feed[]>(r).length === 30 });
|
||||
|
||||
const all = http.get(`${BASE}/api/entries?limit=50&filter=all&sort=published&dir=desc`, as(u, 'all-entries'));
|
||||
check(all, { 'All Subscriptions, a page of it': r => r.status === 200 && r.json().entries.length === 50 });
|
||||
check(all, { 'All Subscriptions, a page of it': r => r.status === 200 && body<ListPage>(r).entries.length === 50 });
|
||||
|
||||
const f = pick(feeds.json());
|
||||
const f = pick(body<Feed[]>(feeds));
|
||||
const one = http.get(`${BASE}/api/feeds/${f.id}/entries?limit=50&sort=title&dir=asc`, as(u, 'feed-entries'));
|
||||
check(one, { 'a feed of yours, every item': r => r.status === 200 && r.json().entries.length === ITEMS });
|
||||
check(one, { 'a feed of yours, every item': r => r.status === 200 && body<ListPage>(r).entries.length === ITEMS });
|
||||
|
||||
// Every generated item mentions the weather, in its text, not its title.
|
||||
const found = http.get(`${BASE}/api/entries?q=weather&limit=50`, as(u, 'search'));
|
||||
check(found, { 'a search of everything you read': r => r.status === 200 && r.json().total === 30 * ITEMS });
|
||||
check(found, { 'a search of everything you read': r => r.status === 200 && body<ListPage>(r).total === 30 * ITEMS });
|
||||
|
||||
// Not the Directory: since #204 it is ipx-directory's, which this harness does not start (#245).
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// What the load tests share. Each virtual user is a listener of its own, signed in by name with
|
||||
// the header the scratch daemon trusts from 127.0.0.1, as production trusts Cloudflare Access's.
|
||||
export const BASE = __ENV.BASE;
|
||||
export const USERS = Number(__ENV.USERS || 100);
|
||||
export const FEEDS = Number(__ENV.FEEDS || 1500);
|
||||
// Each generated feed has this many items; see run.js.
|
||||
export const ITEMS = 20;
|
||||
|
||||
export const as = (name, tag) => ({
|
||||
headers: { 'X-Load-User': name, 'Content-Type': 'application/json' },
|
||||
tags: { name: tag },
|
||||
});
|
||||
/// The listener this virtual user is, one of the hundred run.js seeded, thirty feeds each.
|
||||
export const me = () => `load-${(__VU - 1) % USERS}`;
|
||||
export const pick = a => a[Math.floor(Math.random() * a.length)];
|
||||
/// A p95 budget, in ms, for whichever database the daemon is on (run.js --postgres): SQLite reads
|
||||
/// a file beside the daemon, Postgres answers over the network, and each is quicker at something.
|
||||
export const p95 = (sqlite, postgres) => [`p(95)<${__ENV.DB === 'postgres' ? postgres : sqlite}`];
|
||||
export const feedId = n => `gen-${String(n).padStart(4, '0')}`;
|
||||
29
tests/load/lib.ts
Normal file
29
tests/load/lib.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// What the load tests share. Each virtual user is a listener of its own, signed in by name with
|
||||
// the header the scratch daemon trusts from 127.0.0.1, as production trusts Cloudflare Access's.
|
||||
export const BASE = __ENV.BASE;
|
||||
export const USERS = Number(__ENV.USERS || 100);
|
||||
export const FEEDS = Number(__ENV.FEEDS || 1500);
|
||||
// Each generated feed has this many items; see run.ts.
|
||||
export const ITEMS = 20;
|
||||
|
||||
import type { RefinedResponse, ResponseType } from 'k6/http';
|
||||
|
||||
/// The parts of ipx's answers the scripts read (web/src/types.ts has them whole).
|
||||
export type Enclosure = { id: number, path: string | null };
|
||||
export type Entry = { feed_id: string, guid: string, duration: number | null, position: number, read: boolean, enclosures: Enclosure[] };
|
||||
export type ListPage = { total: number, entries: Entry[] };
|
||||
export type Feed = { id: string };
|
||||
/// An answer's JSON as the API sends it: k6 types it as any JSON at all.
|
||||
export const body = <T>(r: RefinedResponse<ResponseType | undefined>) => r.json() as unknown as T;
|
||||
|
||||
export const as = (name: string, tag: string) => ({
|
||||
headers: { 'X-Load-User': name, 'Content-Type': 'application/json' },
|
||||
tags: { name: tag },
|
||||
});
|
||||
/// The listener this virtual user is, one of the hundred run.ts seeded, thirty feeds each.
|
||||
export const me = () => `load-${(__VU - 1) % USERS}`;
|
||||
export const pick = <T>(a: T[]): T => a[Math.floor(Math.random() * a.length)];
|
||||
/// A p95 budget, in ms, for whichever database the daemon is on (run.ts --postgres): SQLite reads
|
||||
/// a file beside the daemon, Postgres answers over the network, and each is quicker at something.
|
||||
export const p95 = (sqlite: number, postgres: number) => [`p(95)<${__ENV.DB === 'postgres' ? postgres : sqlite}`];
|
||||
export const feedId = (n: number) => `gen-${String(n).padStart(4, '0')}`;
|
||||
@@ -1,6 +1,6 @@
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { BASE, as, me, pick, p95 } from './lib.js';
|
||||
import { BASE, as, me, pick, p95, body, type Entry, type ListPage } from './lib.ts';
|
||||
|
||||
// Players saving where people are while scans write. A hundred listeners each save a position
|
||||
// every second -- the player saves every ten, so this is a thousand people listening -- mark an
|
||||
@@ -24,14 +24,16 @@ export const options = {
|
||||
};
|
||||
|
||||
// Each virtual user's own episode, and how far into it it is.
|
||||
let ep = null, secs = 0, n = 0;
|
||||
let ep: Entry | undefined, secs = 0, n = 0;
|
||||
|
||||
export function listen() {
|
||||
const u = me();
|
||||
if (!ep) {
|
||||
const r = http.get(`${BASE}/api/entries?limit=50`, as(u, 'pick'));
|
||||
ep = pick(r.json().entries.filter(e => e.duration));
|
||||
ep = pick(body<ListPage>(r).entries.filter(e => e.duration));
|
||||
}
|
||||
if (!ep) throw new Error('no episode with a duration to listen to');
|
||||
const at = ep;
|
||||
secs += 1;
|
||||
const saved = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/position`,
|
||||
JSON.stringify({ secs, duration: ep.duration }), as(u, 'position'));
|
||||
@@ -41,10 +43,10 @@ export function listen() {
|
||||
JSON.stringify({ read: n % 20 === 0 }), as(u, 'flags'));
|
||||
check(flags, { 'marked read or unread': r => r.status === 200 });
|
||||
const back = http.get(`${BASE}/api/feeds/${encodeURIComponent(ep.feed_id)}/entries?limit=50`, as(u, 'read-back'));
|
||||
const mine = back.status === 200 && back.json().entries.find(e => e.guid === ep.guid);
|
||||
const mine = back.status === 200 ? body<ListPage>(back).entries.find(e => e.guid === at.guid) : undefined;
|
||||
check(mine, {
|
||||
'your position, as you left it': e => e && e.position === secs,
|
||||
'read as you marked it': e => e && e.read === (n % 20 === 0),
|
||||
'your position, as you left it': e => e?.position === secs,
|
||||
'read as you marked it': e => e?.read === (n % 20 === 0),
|
||||
});
|
||||
}
|
||||
sleep(1);
|
||||
@@ -1,6 +1,6 @@
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
import { BASE, as, pick } from './lib.js';
|
||||
import { BASE, as, pick, body, type ListPage } from './lib.ts';
|
||||
|
||||
// Listeners seeking: fifty at once asking for ranges of the same few episodes, as a player does
|
||||
// on every seek and every few seconds of playback. Every range is checked against what the feed
|
||||
@@ -15,18 +15,18 @@ export const options = {
|
||||
'http_req_duration{name:range}': ['p(95)<200'],
|
||||
},
|
||||
};
|
||||
// The generated file: ID3's ten bytes, then a byte its offset gives (run.js, mediaByte).
|
||||
// The generated file: ID3's ten bytes, then a byte its offset gives (run.ts, mediaByte).
|
||||
const SIZE = 2 * 1024 * 1024;
|
||||
const byte = k => (k * 31 + 7) & 255;
|
||||
const byte = (k: number) => (k * 31 + 7) & 255;
|
||||
|
||||
export function setup() {
|
||||
const r = http.get(`${BASE}/api/entries?filter=downloaded&limit=50`, as('listener', 'setup'));
|
||||
const files = r.json().entries.flatMap(e => e.enclosures).filter(x => x.path).map(x => x.id);
|
||||
const files = body<ListPage>(r).entries.flatMap(e => e.enclosures).filter(x => x.path).map(x => x.id);
|
||||
if (!files.length) throw new Error('the listener has no downloaded files to seek through');
|
||||
return { files };
|
||||
}
|
||||
|
||||
export default function ({ files }) {
|
||||
export default function ({ files }: { files: number[] }) {
|
||||
const start = 10 + Math.floor(Math.random() * (SIZE - 11));
|
||||
const end = Math.min(SIZE - 1, start + Math.floor(Math.random() * 65536));
|
||||
const r = http.get(`${BASE}/media/${pick(files)}`, {
|
||||
@@ -3,7 +3,7 @@
|
||||
// database and to the healthcheck. The browser suite drives one person against a handful of
|
||||
// feeds, one request at a time.
|
||||
//
|
||||
// node tests/load/run.js [--postgres] [browse|listening|media|signin ...] default: all four
|
||||
// node tests/load/run.ts [--postgres] [browse|listening|media|signin ...] default: all four
|
||||
//
|
||||
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
|
||||
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own
|
||||
@@ -16,12 +16,12 @@
|
||||
// are what production would see.
|
||||
// IPX_LOAD_FEEDS sets the catalogue's size (1500, near production's), IPX_LOAD_USERS the
|
||||
// listeners (100), IPX_LOAD_LOG=1 shows the daemon's log.
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync, execFile, execFileSync } = require('child_process');
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync, execFile, execFileSync, type ChildProcess } from 'node:child_process';
|
||||
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const repo = path.resolve(import.meta.dirname, '../..');
|
||||
const root = '/tmp/ipx-load';
|
||||
const WEB = 8793, GEN = 8794;
|
||||
const FEEDS = Number(process.env.IPX_LOAD_FEEDS || 1500);
|
||||
@@ -57,11 +57,11 @@ const CATS = [['Technology'], ['News', 'Tech News'], ['Comedy'], ['Leisure', 'Vi
|
||||
['Society & Culture', 'Documentary'], ['Sports', 'Soccer'], ['Arts', 'Books'],
|
||||
['Science', 'Astronomy'], ['History'], ['True Crime'], ['Business', 'Investing'],
|
||||
['Education', 'Self-Improvement'], ['Health & Fitness', 'Mental Health'], ['Music']];
|
||||
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
const now = Date.now();
|
||||
// Every third feed is a blog: no files, as most of production's are.
|
||||
const isBlog = n => n % 3 === 2 && n >= MEDIA;
|
||||
function feedXml(n) {
|
||||
const isBlog = (n: number) => n % 3 === 2 && n >= MEDIA;
|
||||
function feedXml(n: number) {
|
||||
const [cat, sub] = CATS[n % CATS.length];
|
||||
const category = sub
|
||||
? `<itunes:category text="${esc(cat)}"><itunes:category text="${esc(sub)}"/></itunes:category>`
|
||||
@@ -81,22 +81,22 @@ function feedXml(n) {
|
||||
// A file whose every byte is known from its offset, so the media test can check that a range
|
||||
// it asked for is the range it got, not merely that it got something that long. ID3 first, so
|
||||
// ipx takes it for audio.
|
||||
const mediaByte = k => (k * 31 + 7) & 255;
|
||||
const mediaByte = (k: number) => (k * 31 + 7) & 255;
|
||||
const MEDIA_BYTES = Buffer.from(Uint8Array.from({ length: 2 * 1024 * 1024 }, (_, k) => mediaByte(k)));
|
||||
Buffer.from('ID3\x03\x00\x00\x00\x00\x00\x00', 'latin1').copy(MEDIA_BYTES);
|
||||
const art = fs.readFileSync(path.join(repo, 'tests/ui/fixtures/art.jpg'));
|
||||
|
||||
function serveFeeds() {
|
||||
return http.createServer((req, res) => {
|
||||
const m = req.url.match(/^\/(feed|media|art)\/(\d+)/);
|
||||
const m = (req.url ?? '').match(/^\/(feed|media|art)\/(\d+)/);
|
||||
if (m?.[1] === 'feed') { res.writeHead(200, { 'content-type': 'application/rss+xml' }); return res.end(feedXml(Number(m[2]))); }
|
||||
if (m?.[1] === 'media') { res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': MEDIA_BYTES.length }); return res.end(MEDIA_BYTES); }
|
||||
if (m?.[1] === 'art') { res.writeHead(200, { 'content-type': 'image/jpeg' }); return res.end(art); }
|
||||
res.writeHead(404).end();
|
||||
}).listen(GEN, '127.0.0.1');
|
||||
}
|
||||
const feedId = n => `gen-${String(n).padStart(4, '0')}`;
|
||||
const feedUrl = n => `http://127.0.0.1:${GEN}/feed/${n}.xml`;
|
||||
const feedId = (n: number) => `gen-${String(n).padStart(4, '0')}`;
|
||||
const feedUrl = (n: number) => `http://127.0.0.1:${GEN}/feed/${n}.xml`;
|
||||
|
||||
// ---- the daemon -----------------------------------------------------------------------------
|
||||
|
||||
@@ -113,7 +113,7 @@ function freshPostgres() {
|
||||
|
||||
/// Statements run in the test database, given by its URL in psql's environment, not its
|
||||
/// arguments, where every process on Tower could read the password.
|
||||
function psql(...sql) {
|
||||
function psql(...sql: string[]) {
|
||||
const u = new URL(PG_URL);
|
||||
const env = { ...process.env, PGHOST: u.hostname, PGPORT: u.port || '5432', PGUSER: decodeURIComponent(u.username),
|
||||
PGPASSWORD: decodeURIComponent(u.password), PGDATABASE: u.pathname.slice(1), PGOPTIONS: `-c search_path=${PG_SCHEMA}` };
|
||||
@@ -155,30 +155,33 @@ ${feeds}`);
|
||||
|
||||
/// Resolves with the first log line matching `test`, read from the daemon's JSON log, which it
|
||||
/// writes to stderr.
|
||||
function waitForLog(daemon, test, ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
/// A line of the daemon's JSON log: its event and the event's fields.
|
||||
type LogLine = { ev?: string, feeds?: number, [field: string]: unknown };
|
||||
function waitForLog(daemon: ChildProcess, test: (ev: LogLine) => boolean, ms: number) {
|
||||
return new Promise<LogLine>((resolve, reject) => {
|
||||
let buf = '';
|
||||
const timer = setTimeout(() => { daemon.stderr.off('data', on); reject(new Error(`no log line in ${ms / 1000}s for ${test}`)); }, ms);
|
||||
const on = chunk => {
|
||||
const stderr = daemon.stderr!;
|
||||
const timer = setTimeout(() => { stderr.off('data', on); reject(new Error(`no log line in ${ms / 1000}s for ${test}`)); }, ms);
|
||||
const on = (chunk: Buffer) => {
|
||||
buf += chunk;
|
||||
let i;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, i); buf = buf.slice(i + 1);
|
||||
let ev; try { ev = JSON.parse(line); } catch { continue; }
|
||||
if (test(ev)) { clearTimeout(timer); daemon.stderr.off('data', on); return resolve(ev); }
|
||||
let ev: LogLine; try { ev = JSON.parse(line); } catch { continue; }
|
||||
if (test(ev)) { clearTimeout(timer); stderr.off('data', on); return resolve(ev); }
|
||||
}
|
||||
};
|
||||
daemon.stderr.on('data', on);
|
||||
stderr.on('data', on);
|
||||
});
|
||||
}
|
||||
|
||||
const as = name => ({ 'X-Load-User': name, 'Content-Type': 'application/json' });
|
||||
async function api(name, url, opts = {}) {
|
||||
const as = (name: string) => ({ 'X-Load-User': name, 'Content-Type': 'application/json' });
|
||||
async function api(name: string, url: string, opts: RequestInit & { headers?: Record<string, string> } = {}) {
|
||||
const r = await fetch(BASE + url, { ...opts, headers: { ...as(name), ...opts.headers } });
|
||||
if (!r.ok) throw new Error(`${opts.method || 'GET'} ${url} as ${name}: ${r.status} ${await r.text()}`);
|
||||
return r.status === 204 ? null : r.json().catch(() => null);
|
||||
}
|
||||
const opml = ns => `<?xml version="1.0"?><opml version="2.0"><head><title>load</title></head><body>${
|
||||
const opml = (ns: number[]) => `<?xml version="1.0"?><opml version="2.0"><head><title>load</title></head><body>${
|
||||
ns.map(n => `<outline type="rss" text="${feedId(n)}" xmlUrl="${feedUrl(n)}"/>`).join('')}</body></opml>`;
|
||||
|
||||
async function seed() {
|
||||
@@ -195,7 +198,8 @@ async function seed() {
|
||||
await api('listener', '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(Array.from({ length: MEDIA }, (_, n) => n)) }) });
|
||||
for (let t = Date.now(); ; await new Promise(r => setTimeout(r, 500))) {
|
||||
const got = await api('listener', '/api/entries?filter=downloaded&limit=50');
|
||||
if (got.entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break;
|
||||
const entries: { enclosures: { path: string | null }[] }[] = got.entries;
|
||||
if (entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break;
|
||||
if (Date.now() - t > 120_000) throw new Error(`the listener's ${MEDIA} files did not download in 120s`);
|
||||
}
|
||||
// A password, for the sign-in test; the CLI hashes it as `ipx user add` always does.
|
||||
@@ -222,11 +226,11 @@ function watchHealth() {
|
||||
return () => { on = false; return seen; };
|
||||
}
|
||||
|
||||
function k6(script) {
|
||||
return new Promise(resolve => {
|
||||
function k6(script: string) {
|
||||
return new Promise<number | null>(resolve => {
|
||||
const run = spawn('k6', ['run', '--quiet', '-e', `BASE=${BASE}`, '-e', `USERS=${USERS}`, '-e', `FEEDS=${FEEDS}`,
|
||||
'-e', `DB=${POSTGRES ? 'postgres' : 'sqlite'}`,
|
||||
path.join(__dirname, `${script}.js`)], { stdio: 'inherit' });
|
||||
path.join(import.meta.dirname, `${script}.ts`)], { stdio: 'inherit' });
|
||||
run.on('exit', code => resolve(code));
|
||||
});
|
||||
}
|
||||
@@ -251,7 +255,7 @@ function k6(script) {
|
||||
let failed = 0;
|
||||
try {
|
||||
const t = Date.now();
|
||||
const first = await waitForLog(daemon, ev => ev.ev === 'scan_done' && ev.feeds > 0, 600_000);
|
||||
const first = await waitForLog(daemon, ev => ev.ev === 'scan_done' && (ev.feeds ?? 0) > 0, 600_000);
|
||||
console.log(`first scan: ${first.feeds} feeds in ${((Date.now() - t) / 1000).toFixed(1)}s`);
|
||||
await seed();
|
||||
// The planner's statistics, as production's long-standing tables have them. A schema filled
|
||||
@@ -270,7 +274,7 @@ function k6(script) {
|
||||
if (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
console.error((e as Error).message);
|
||||
failed++;
|
||||
} finally {
|
||||
// Its own PID, never a pkill: Tower sees production's ipx too (#38).
|
||||
@@ -1,7 +1,7 @@
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
import { Trend } from 'k6/metrics';
|
||||
import { BASE, as, me } from './lib.js';
|
||||
import { BASE, as, me } from './lib.ts';
|
||||
|
||||
// A flood of sign-in attempts with wrong passwords while everyone else goes on using the site.
|
||||
// Two things only many requests at once show: whether checking passwords, tens of milliseconds
|
||||
Reference in New Issue
Block a user