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:
41
CLAUDE.md
41
CLAUDE.md
@@ -128,8 +128,8 @@ running the command and kills the session (exit 144), which has happened more th
|
|||||||
|
|
||||||
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
||||||
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
||||||
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
|
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mts` lists which files
|
||||||
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mjs` uses
|
make up each page's script. The page is its own image, **`ipx-web`** (#203): `web/build.mts` uses
|
||||||
swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the
|
swc to strip the types and minify each script (`app.js`, `admin.js`, `login.js`), minifies the
|
||||||
pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves
|
pages, and puts them with the icons and typeface in `web/dist`, which Caddy serves
|
||||||
(`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx
|
(`web/Caddyfile`, `Dockerfile.web`), passing `/api` and `/media` to ipx on the same origin. ipx
|
||||||
@@ -143,23 +143,35 @@ not signed in to `/login` and anyone but an admin away from `/admin` (and refuse
|
|||||||
`/admin.js`); `/api/login-check` sends someone Cloudflare Access vouches for past the form; a page
|
`/admin.js`); `/api/login-check` sends someone Cloudflare Access vouches for past the form; a page
|
||||||
asked for with `?token=` goes to `/api/signin`, which sets the cookie. The page is the same file
|
asked for with `?token=` goes to `/api/signin`, which sets the cookie. The page is the same file
|
||||||
for everyone, so what ipx used to write into it per person is the page's own job now:
|
for everyone, so what ipx used to write into it per person is the page's own job now:
|
||||||
`web/boot.js`, in `<head>`, puts the theme from its cookie and an admin's link from the last visit
|
`web/boot.ts`, in `<head>`, puts the theme from its cookie and an admin's link from the last visit
|
||||||
on the page before the first frame, and `/api/me` brings the rest (version, the account's old
|
on the page before the first frame, and `/api/me` brings the rest (version, the account's old
|
||||||
theme).
|
theme).
|
||||||
|
|
||||||
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
||||||
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
`web/build.mts` lists them, sharing one top-level scope as the single inline script did; a new
|
||||||
file goes into that list. Top-level names are kept as they are, because markup calls some by
|
file goes into that list. Top-level names are kept as they are, because markup calls some by
|
||||||
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
|
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
|
||||||
|
|
||||||
After any edit to it:
|
After any edit to it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npx tsc -p . && npx tsc -p tsconfig.sw.json
|
npm run -s typecheck
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.ts
|
||||||
node --test tests/page-unit.js
|
node --test tests/page-unit.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**All the code here is TypeScript** (#392): the page, the service worker, the tests, the build, the
|
||||||
|
home page's generator and the load tests. Node runs a `.ts` file as it is, by stripping the types,
|
||||||
|
so it takes only TypeScript that erases (`erasableSyntaxOnly`: no enums, namespaces or constructor
|
||||||
|
parameter properties) and imports name their `.ts` files; k6 strips its own. What a browser loads
|
||||||
|
on its own is compiled by `web/build.mts` with swc (`boot.ts`, the worker). The directory's
|
||||||
|
`site.js` is compiled from `crates/directory/src/site.ts` and committed, since its build has no
|
||||||
|
Node: after editing `site.ts`, `node web/build.mts --directory-script`, which
|
||||||
|
`node tests/directory-script.ts` checks. `npm run -s typecheck` checks five programs, each with its
|
||||||
|
own globals: the page (`tsconfig.json`, which also writes the page's declarations to
|
||||||
|
`target/page-types` for the browser tests' `page.evaluate` calls), the worker, Node's
|
||||||
|
(`tsconfig.tools.json`), and k6's (`tsconfig.k6.json`).
|
||||||
|
|
||||||
The service worker that keeps iPX readable offline (#389) is TypeScript too, `web/sw/sw.ts`,
|
The service worker that keeps iPX readable offline (#389) is TypeScript too, `web/sw/sw.ts`,
|
||||||
a script of its own built to `/sw.js`, and checked by `tsconfig.sw.json` against a worker's
|
a script of its own built to `/sw.js`, and checked by `tsconfig.sw.json` against a worker's
|
||||||
globals, which cannot share a program with the page's. It goes to the network first for
|
globals, which cannot share a program with the page's. It goes to the network first for
|
||||||
@@ -188,13 +200,14 @@ cargo clippy --workspace --all-targets -- -D warnings # no warnings, ever
|
|||||||
cargo deny check # known vulnerabilities, licences, sources (deny.toml); reads Cargo.lock, builds nothing
|
cargo deny check # known vulnerabilities, licences, sources (deny.toml); reads Cargo.lock, builds nothing
|
||||||
cargo machete # dependencies a crate lists and does not use; now and then, it can be wrong
|
cargo machete # dependencies a crate lists and does not use; now and then, it can be wrong
|
||||||
cargo test # parsing, filters, retention, schedules, SQL, per-user state
|
cargo test # parsing, filters, retention, schedules, SQL, per-user state
|
||||||
npm run -s typecheck # web/src, the service worker, and the tests' and build's TypeScript
|
npm run -s typecheck # the page, the service worker, the tests', build's and load tests' TypeScript
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.ts
|
||||||
node --test tests/page-unit.js # the page's own functions, called directly, in a second
|
node tests/directory-script.ts # the directory's site.js is its site.ts as it stands
|
||||||
node tests/native-bridge.js # the page hands playback to a native shell
|
node --test tests/page-unit.ts # the page's own functions, called directly, in a second
|
||||||
node tests/contrast.js # every theme's palette against WCAG AA
|
node tests/native-bridge.ts # the page hands playback to a native shell
|
||||||
|
node tests/contrast.ts # every theme's palette against WCAG AA
|
||||||
npx playwright test # the browser tests, against a real daemon on fixture feeds
|
npx playwright test # the browser tests, against a real daemon on fixture feeds
|
||||||
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
|
node tests/load/run.ts # k6: many people at once against a scratch daemon with 1,500 feeds
|
||||||
sh scripts/coverage.sh # which Rust lines the cargo tests reach, capped like a build (#390)
|
sh scripts/coverage.sh # which Rust lines the cargo tests reach, capped like a build (#390)
|
||||||
npm run coverage:page # which lines of web/src the browser tests reach (#390)
|
npm run coverage:page # which lines of web/src the browser tests reach (#390)
|
||||||
```
|
```
|
||||||
@@ -264,7 +277,7 @@ seeking through files, a flood of wrong passwords. Things about them:
|
|||||||
* Each threshold is a budget well above what the request takes now: it is there to catch a query
|
* Each threshold is a budget well above what the request takes now: it is there to catch a query
|
||||||
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
|
that has started asking once per feed, or writers queueing on a lock, not a slow minute.
|
||||||
* `ipx status` runs every second throughout, and a run fails if one takes 5s.
|
* `ipx status` runs every second throughout, and a run fails if one takes 5s.
|
||||||
* One at a time: `node tests/load/run.js signin`. All four take about six minutes.
|
* One at a time: `node tests/load/run.ts signin`. All four take about six minutes.
|
||||||
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
|
* The daemon is on **SQLite** unless `--postgres`, which puts it in `IPX_TEST_DATABASE_URL`, in
|
||||||
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
|
a schema of its own (`ipx_load`) dropped and made again each run. That URL, in `/src/.envrc`, is
|
||||||
`ipodderx_test` on the `databases` project's server, production's, as `ipodderx`, taken from
|
`ipodderx_test` on the `databases` project's server, production's, as `ipodderx`, taken from
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ipx-directory (#204). Built from the repo's root: docker buildx build -f Dockerfile.directory .
|
# ipx-directory (#204). Built from the repo's root: docker buildx build -f Dockerfile.directory .
|
||||||
# No node here: only ipx's own build runs web/build.mjs.
|
# No node here: only ipx's own build runs web/build.mts.
|
||||||
FROM rust:1-slim-trixie AS build
|
FROM rust:1-slim-trixie AS build
|
||||||
# SQLite is compiled from source for sqlx, which needs a C toolchain.
|
# SQLite is compiled from source for sqlx, which needs a C toolchain.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
|
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# ipx-web (#203): the frontend, built from web/ and served by Caddy, which passes the API to ipx.
|
# ipx-web (#203): the frontend, built from web/ and served by Caddy, which passes the API to ipx.
|
||||||
# Built from the repo's root: docker buildx build -f Dockerfile.web .
|
# Built from the repo's root: docker buildx build -f Dockerfile.web .
|
||||||
FROM node:22-slim AS build
|
FROM node:24-slim AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
RUN node web/build.mjs /dist
|
RUN node web/build.mts /dist
|
||||||
|
|
||||||
FROM caddy:2
|
FROM caddy:2
|
||||||
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
|
# caddy:2's Alpine ships curl, which nothing here runs (no healthcheck since #218): out, so a
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
node tests/page-smoke.js # the page script loads without throwing
|
node tests/page-smoke.ts # the page script loads without throwing
|
||||||
node tests/native-bridge.js # the page hands playback to a native shell
|
node tests/native-bridge.ts # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
// Made from site.ts by `node web/build.mts --directory-script`: edit that, not this.
|
||||||
// The one thing the directory's pages script (#215): Copy puts a feed's address on the clipboard.
|
// The one thing the directory's pages script (#215): Copy puts a feed's address on the clipboard.
|
||||||
// Without it, the address is in a field of its own to select by hand.
|
// Without it, the address is in a field of its own to select by hand.
|
||||||
document.addEventListener('click', ev => {
|
//
|
||||||
const b = ev.target.closest('[data-copy]');
|
// The source: the directory serves site.js, made from this by `node web/build.mts --directory-script`
|
||||||
if (!b || !navigator.clipboard) return;
|
// (#392), since its build has no Node to make it; tests/directory-script.ts checks the two agree.
|
||||||
navigator.clipboard.writeText(b.dataset.copy).then(() => {
|
document.addEventListener('click', (ev)=>{
|
||||||
b.textContent = 'Copied';
|
const b = ev.target?.closest('[data-copy]');
|
||||||
setTimeout(() => { b.textContent = 'Copy'; }, 1500);
|
// Only in a secure context; plain HTTP has no clipboard.
|
||||||
});
|
if (!b || !navigator.clipboard) return;
|
||||||
|
void navigator.clipboard.writeText(b.dataset.copy ?? '').then(()=>{
|
||||||
|
b.textContent = 'Copied';
|
||||||
|
setTimeout(()=>{
|
||||||
|
b.textContent = 'Copy';
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
14
crates/directory/src/site.ts
Normal file
14
crates/directory/src/site.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// The one thing the directory's pages script (#215): Copy puts a feed's address on the clipboard.
|
||||||
|
// Without it, the address is in a field of its own to select by hand.
|
||||||
|
//
|
||||||
|
// The source: the directory serves site.js, made from this by `node web/build.mts --directory-script`
|
||||||
|
// (#392), since its build has no Node to make it; tests/directory-script.ts checks the two agree.
|
||||||
|
document.addEventListener('click', (ev) => {
|
||||||
|
const b = (ev.target as Element | null)?.closest<HTMLElement>('[data-copy]');
|
||||||
|
// Only in a secure context; plain HTTP has no clipboard.
|
||||||
|
if (!b || !navigator.clipboard) return;
|
||||||
|
void navigator.clipboard.writeText(b.dataset.copy ?? '').then(() => {
|
||||||
|
b.textContent = 'Copied';
|
||||||
|
setTimeout(() => { b.textContent = 'Copy'; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,8 +72,8 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
|
|||||||
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
||||||
| `web/app.css` | The stylesheet both pages share | — |
|
| `web/app.css` | The stylesheet both pages share | — |
|
||||||
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
||||||
| `web/build.mjs` | swc: strips the types into `app.js`/`admin.js`/`login.js`, named in the page by a hash of their contents, minifies, and writes the static site to `web/dist` | — |
|
| `web/build.mts` | swc: strips the types into `app.js`/`admin.js`/`login.js`, named in the page by a hash of their contents, minifies, and writes the static site to `web/dist` | — |
|
||||||
| `web/boot.js` | In `<head>`: the theme from its cookie, and an admin's link, before the first frame | — |
|
| `web/boot.ts` | In `<head>`: the theme from its cookie, and an admin's link, before the first frame | — |
|
||||||
| `web/Caddyfile` | `ipx-web`: serves `web/dist`, asks ipx before serving a page, passes `/api` and `/media` to ipx | — |
|
| `web/Caddyfile` | `ipx-web`: serves `web/dist`, asks ipx before serving a page, passes `/api` and `/media` to ipx | — |
|
||||||
|
|
||||||
The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
|
The page is a separate image, `ipx-web` (`Dockerfile.web`, #203), so **editing `web/` needs
|
||||||
@@ -213,10 +213,10 @@ before they reach the page.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
||||||
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
node tests/page-smoke.ts # the page script loads and every selector it wires at load exists
|
||||||
node tests/native-bridge.js # the page hands playback to a native shell
|
node tests/native-bridge.ts # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
node tests/load/run.js # k6: many people at once against a daemon with 1,500 generated feeds
|
node tests/load/run.ts # k6: many people at once against a daemon with 1,500 generated feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that
|
The Rust tests cannot see a wrong selector, a handler that runs and does nothing, or a page that
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ erDiagram
|
|||||||
- `signals` and `signal_verdicts` are created from their entities like every table
|
- `signals` and `signal_verdicts` are created from their entities like every table
|
||||||
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
|
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
|
||||||
were added to existing tables, so `create_missing` adds each column when it is missing.
|
were added to existing tables, so `create_missing` adds each column when it is missing.
|
||||||
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
|
- The icons are files of ipx-web's, not the database's: `web/build.mts` copies Font Awesome Free's
|
||||||
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
|
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
|
||||||
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
|
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
|
||||||
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
|
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
|
||||||
@@ -406,7 +406,7 @@ erDiagram
|
|||||||
- `cargo test -p ipx`: the request's shape and the threshold (`signals::tests`), and the queue,
|
- `cargo test -p ipx`: the request's shape and the threshold (`signals::tests`), and the queue,
|
||||||
verdicts, ownership, unread counts and new keywords (`db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner`),
|
verdicts, ownership, unread counts and new keywords (`db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner`),
|
||||||
on SQLite and Postgres both.
|
on SQLite and Postgres both.
|
||||||
- `node --test tests/page-unit.js`: a Signal's place id and its list's address.
|
- `node --test tests/page-unit.ts`: a Signal's place id and its list's address.
|
||||||
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
|
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
|
||||||
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
|
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
|
||||||
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
|
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0",
|
"@playwright/test": "^1.56.0",
|
||||||
|
"@types/k6": "2.3.1",
|
||||||
"@types/node": "24.19.2",
|
"@types/node": "24.19.2",
|
||||||
"monocart-coverage-reports": "3.2.0",
|
"monocart-coverage-reports": "3.2.0",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
@@ -573,6 +574,13 @@
|
|||||||
"@swc/counter": "^0.1.3"
|
"@swc/counter": "^0.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/k6": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/k6/-/k6-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-3zbIAfQXvSDsA3ZaKC7+eczsvx1hGtL8o68iYEssOmTEmg0MuoCwZ45aAI3jDm0JtlhJZaQAszrie+M1xaqK7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.19.2",
|
"version": "24.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -2,18 +2,20 @@
|
|||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
"version": "1.3.1-dev",
|
"version": "1.3.1-dev",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
|
"type": "module",
|
||||||
|
"description": "Builds the ipx web pages from web/src (web/build.mts, run by build.rs) and tests them. The Rust tests cover the server.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node web/build.mjs",
|
"build": "node web/build.mts",
|
||||||
"typecheck": "tsc -p . && tsc -p tsconfig.sw.json && tsc -p tsconfig.tools.json",
|
"typecheck": "tsc -p . --noEmit false --declaration --emitDeclarationOnly --outDir target/page-types && tsc -p tsconfig.sw.json && tsc -p tsconfig.tools.json && tsc -p tsconfig.k6.json",
|
||||||
"coverage:page": "IPX_COVERAGE=1 playwright test",
|
"coverage:page": "IPX_COVERAGE=1 playwright test",
|
||||||
"smoke": "node tests/page-smoke.js",
|
"smoke": "node tests/page-smoke.ts",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"test:headed": "playwright test --headed",
|
"test:headed": "playwright test --headed",
|
||||||
"load": "node tests/load/run.js"
|
"load": "node tests/load/run.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0",
|
"@playwright/test": "^1.56.0",
|
||||||
|
"@types/k6": "2.3.1",
|
||||||
"@types/node": "24.19.2",
|
"@types/node": "24.19.2",
|
||||||
"monocart-coverage-reports": "3.2.0",
|
"monocart-coverage-reports": "3.2.0",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { defineConfig } = require('@playwright/test');
|
import { defineConfig } from '@playwright/test';
|
||||||
const setup = require('./tests/ui/global-setup');
|
import * as setup from './tests/ui/global-setup.ts';
|
||||||
|
|
||||||
// Before anything else, including the servers below.
|
// Before anything else, including the servers below.
|
||||||
setup.prepare();
|
setup.prepare();
|
||||||
@@ -7,7 +7,7 @@ setup.prepare();
|
|||||||
// Real browser against a real daemon. The stub-DOM smoke test catches a script that
|
// Real browser against a real daemon. The stub-DOM smoke test catches a script that
|
||||||
// fails to load; it cannot catch a wrong selector, a handler that runs but does nothing,
|
// fails to load; it cannot catch a wrong selector, a handler that runs but does nothing,
|
||||||
// or a page that renders empty -- which is exactly what has slipped through before.
|
// or a page that renders empty -- which is exactly what has slipped through before.
|
||||||
module.exports = defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/ui',
|
testDir: './tests/ui',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: { timeout: 10_000 },
|
expect: { timeout: 10_000 },
|
||||||
@@ -27,7 +27,7 @@ module.exports = defineConfig({
|
|||||||
},
|
},
|
||||||
webServer: [
|
webServer: [
|
||||||
{
|
{
|
||||||
command: 'node tests/ui/fixtures/serve.js',
|
command: 'node tests/ui/fixtures/serve.ts',
|
||||||
port: 8792,
|
port: 8792,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
stdout: 'ignore',
|
stdout: 'ignore',
|
||||||
@@ -44,7 +44,7 @@ module.exports = defineConfig({
|
|||||||
{
|
{
|
||||||
// The frontend as production serves it (#203): built from web/, served by Caddy on the
|
// The frontend as production serves it (#203): built from web/, served by Caddy on the
|
||||||
// suite's port, the API passed to the daemon behind it.
|
// suite's port, the API passed to the daemon behind it.
|
||||||
command: `node web/build.mjs ${setup.root}/web && exec caddy run --config web/Caddyfile --adapter caddyfile`,
|
command: `node web/build.mts ${setup.root}/web && exec caddy run --config web/Caddyfile --adapter caddyfile`,
|
||||||
url: 'http://127.0.0.1:8791/favicon.png',
|
url: 'http://127.0.0.1:8791/favicon.png',
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
@@ -1,21 +1,36 @@
|
|||||||
// The API reference's content: each service's endpoints, grouped, and the types they take and
|
// The API reference's content: each service's endpoints, grouped, and the types they take and
|
||||||
// give, as the handlers in crates/ipx/src/web.rs and crates/directory/src/lib.rs have them. A
|
// give, as the handlers in crates/ipx/src/web.rs and crates/directory/src/lib.rs have them. A
|
||||||
// change to a route or a field there wants its line here; gen.mjs turns this into pages.
|
// change to a route or a field there wants its line here; gen.mts turns this into pages.
|
||||||
// Types are written as Rust writes them, the way docs.rs shows a signature; a name in TYPES links
|
// Types are written as Rust writes them, the way docs.rs shows a signature; a name in TYPES links
|
||||||
// to its page.
|
// to its page.
|
||||||
|
|
||||||
|
export type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||||
|
/// A field, a path parameter or an enum's variant: its name, its Rust type, and what it is.
|
||||||
|
export type Field = [name: string, type: string, doc: string];
|
||||||
|
/// An endpoint. `body`, `query` and `returns` are Rust types, or words for an answer with no body.
|
||||||
|
export type Item = {
|
||||||
|
method: Method, path: string, slug: string, summary: string,
|
||||||
|
access?: string, doc?: string, body?: string, query?: string, returns: string,
|
||||||
|
pathParams?: Field[], errors?: [status: string, why: string][], example?: string,
|
||||||
|
};
|
||||||
|
/// A module of endpoints, as docs.rs shows a crate's modules.
|
||||||
|
export type Group = { id: string, name: string, title: string, summary: string, items: Item[] };
|
||||||
|
export type ApiType = { name: string, doc?: string, fields: Field[] };
|
||||||
|
/// One service's reference.
|
||||||
|
export type Api = { name: string, label?: string, root: string, changelog: string, intro: string, groups: Group[], types: ApiType[] };
|
||||||
|
|
||||||
const U = 'http://<your server>:8099';
|
const U = 'http://<your server>:8099';
|
||||||
const D = 'https://directory.ipxreader.app';
|
const D = 'https://directory.ipxreader.app';
|
||||||
const ipxCurl = (m, p, body) =>
|
const ipxCurl = (m: Method, p: string, body?: string) =>
|
||||||
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer ipx_…"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${U}${p}"`;
|
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer ipx_…"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${U}${p}"`;
|
||||||
const dirCurl = (m, p, body) =>
|
const dirCurl = (m: Method, p: string, body?: string) =>
|
||||||
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer <token>"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${D}${p}"`;
|
`curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer <token>"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${D}${p}"`;
|
||||||
|
|
||||||
const NOT_SUBSCRIBED = ['404 Not Found', 'You do not subscribe to the feed, or it does not exist. A feed someone else reads is not yours to see.'];
|
const NOT_SUBSCRIBED: [string, string] = ['404 Not Found', 'You do not subscribe to the feed, or it does not exist. A feed someone else reads is not yours to see.'];
|
||||||
const NOT_ADMIN = ['403 Forbidden', 'You are not an admin.'];
|
const NOT_ADMIN: [string, string] = ['403 Forbidden', 'You are not an admin.'];
|
||||||
const NO_DIRECTORY = ['503 Service Unavailable', 'No directory is set up on this server.'];
|
const NO_DIRECTORY: [string, string] = ['503 Service Unavailable', 'No directory is set up on this server.'];
|
||||||
|
|
||||||
export const ipx = {
|
export const ipx: Api = {
|
||||||
name: 'ipx',
|
name: 'ipx',
|
||||||
root: '/docs/api/ipx',
|
root: '/docs/api/ipx',
|
||||||
changelog: 'crates/ipx/CHANGELOG.md',
|
changelog: 'crates/ipx/CHANGELOG.md',
|
||||||
@@ -607,7 +622,7 @@ export const ipx = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const directory = {
|
export const directory: Api = {
|
||||||
name: 'ipx_directory',
|
name: 'ipx_directory',
|
||||||
label: 'ipx-directory',
|
label: 'ipx-directory',
|
||||||
root: '/docs/api/directory',
|
root: '/docs/api/directory',
|
||||||
@@ -11,4 +11,4 @@ cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2"
|
|||||||
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/"
|
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/"
|
||||||
cp -r "$here/shots" "$here/dist/"
|
cp -r "$here/shots" "$here/dist/"
|
||||||
# The docs: the guides and the API references, generated (#223).
|
# The docs: the guides and the API references, generated (#223).
|
||||||
node "$here/gen.mjs"
|
node "$here/gen.mts"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const read = name => fs.readFileSync(path.join(here, 'guides', name), 'utf8');
|
const read = (name: string) => fs.readFileSync(path.join(here, 'guides', name), 'utf8');
|
||||||
|
|
||||||
const front = `<h1>Docs</h1>
|
const front = `<h1>Docs</h1>
|
||||||
<p class="lede">How to run iPX on your own server, and how to talk to it and to the directory from a program of your own.</p>
|
<p class="lede">How to run iPX on your own server, and how to talk to it and to the directory from a program of your own.</p>
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
// The docs pages, into site/dist/docs: the guides in docs.mjs, and an API reference for each
|
// The docs pages, into site/dist/docs: the guides in docs.mts, and an API reference for each
|
||||||
// service from api.mjs, laid out as docs.rs lays out a crate (#223): a root page listing its
|
// service from api.mts, laid out as docs.rs lays out a crate (#223): a root page listing its
|
||||||
// modules and types, a page a module listing its endpoints, a page an endpoint and a page a type.
|
// modules and types, a page a module listing its endpoints, a page an endpoint and a page a type.
|
||||||
// No script: the site's Content-Security-Policy allows none of its own.
|
// No script: the site's Content-Security-Policy allows none of its own.
|
||||||
//
|
//
|
||||||
// node site/gen.mjs (site/build.sh runs it)
|
// node site/gen.mts (site/build.sh runs it)
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { ipx, directory } from './api.mjs';
|
import { ipx, directory, type Api, type ApiType, type Field, type Group, type Item, type Method } from './api.mts';
|
||||||
import { guides } from './docs.mjs';
|
import { guides } from './docs.mts';
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const dist = path.join(here, 'dist');
|
const dist = path.join(here, 'dist');
|
||||||
const SRC = 'https://git.sdf1.net/rays/ipx/src/branch/main';
|
const SRC = 'https://git.sdf1.net/rays/ipx/src/branch/main';
|
||||||
|
|
||||||
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: unknown) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
/// The version a reference documents: its service's last release, from its changelog, not the
|
/// The version a reference documents: its service's last release, from its changelog, not the
|
||||||
/// -dev version between releases.
|
/// -dev version between releases.
|
||||||
function released(changelog) {
|
function released(changelog: string) {
|
||||||
const m = fs.readFileSync(path.join(here, '..', changelog), 'utf8').match(/^## \[(\d+\.\d+\.\d+)\]/m);
|
const m = fs.readFileSync(path.join(here, '..', changelog), 'utf8').match(/^## \[(\d+\.\d+\.\d+)\]/m);
|
||||||
return m ? m[1] : '';
|
return m?.[1] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(rel, html) {
|
function write(rel: string, html: string) {
|
||||||
const file = path.join(dist, rel);
|
const file = path.join(dist, rel);
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
fs.writeFileSync(file, html);
|
fs.writeFileSync(file, html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shell({ title, description, sidebar, body, current }) {
|
function shell({ title, description, sidebar, body }: { title: string, description: string, sidebar: string, body: string, current: string }) {
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -65,10 +65,10 @@ function shell({ title, description, sidebar, body, current }) {
|
|||||||
|
|
||||||
/* ---------------- the guides ---------------- */
|
/* ---------------- the guides ---------------- */
|
||||||
|
|
||||||
const REFS = [[ipx, 'ipx'], [directory, 'ipx-directory']];
|
const REFS: [Api, string][] = [[ipx, 'ipx'], [directory, 'ipx-directory']];
|
||||||
|
|
||||||
function guideSidebar(cur) {
|
function guideSidebar(cur: string) {
|
||||||
const li = (href, name, on) => `<li><a href="${href}"${on ? ' aria-current="page"' : ''}>${name}</a></li>`;
|
const li = (href: string, name: string, on: boolean) => `<li><a href="${href}"${on ? ' aria-current="page"' : ''}>${name}</a></li>`;
|
||||||
return `<nav aria-label="Docs">
|
return `<nav aria-label="Docs">
|
||||||
<h2 class="side-h">Guides</h2>
|
<h2 class="side-h">Guides</h2>
|
||||||
<ul>${guides.map(g => li(g.href, g.name, g.href === cur)).join('')}</ul>
|
<ul>${guides.map(g => li(g.href, g.name, g.href === cur)).join('')}</ul>
|
||||||
@@ -83,43 +83,46 @@ for (const g of guides) {
|
|||||||
|
|
||||||
/* ---------------- the references ---------------- */
|
/* ---------------- the references ---------------- */
|
||||||
|
|
||||||
const METHOD_CLASS = { GET: 'm-get', POST: 'm-post', PATCH: 'm-patch', PUT: 'm-patch', DELETE: 'm-delete' };
|
const METHOD_CLASS: Record<Method, string> = { GET: 'm-get', POST: 'm-post', PATCH: 'm-patch', PUT: 'm-patch', DELETE: 'm-delete' };
|
||||||
|
|
||||||
function reference(api) {
|
function reference(api: Api) {
|
||||||
const label = api.label || api.name;
|
const label = api.label || api.name;
|
||||||
const version = released(api.changelog);
|
const version = released(api.changelog);
|
||||||
const typeNames = new Set(api.types.map(t => t.name));
|
const typeNames = new Set(api.types.map(t => t.name));
|
||||||
const typeHref = n => `${api.root}/types/${n}`;
|
const typeHref = (n: string) => `${api.root}/types/${n}`;
|
||||||
const itemHref = (g, it) => `${api.root}/${g.id}/${it.slug}`;
|
const itemHref = (g: Group, it: Item) => `${api.root}/${g.id}/${it.slug}`;
|
||||||
const fnName = it => it.slug.replace(/-/g, '_');
|
const fnName = (it: Item) => it.slug.replace(/-/g, '_');
|
||||||
// A Rust type, its names that are types of this API linked.
|
// A Rust type, its names that are types of this API linked.
|
||||||
const ty = s => esc(s).replace(/\b[A-Z][A-Za-z]+\b/g, n => typeNames.has(n) ? `<a class="t" href="${typeHref(n)}">${n}</a>` : n);
|
const ty = (s: string) => esc(s).replace(/\b[A-Z][A-Za-z]+\b/g, n => typeNames.has(n) ? `<a class="t" href="${typeHref(n)}">${n}</a>` : n);
|
||||||
const isType = s => /^[A-Z]/.test(s) && /[A-Za-z>]$/.test(s) && !/\s/.test(s.replace(/, /g, ','));
|
const isType = (s: string) => /^[A-Z]/.test(s) && /[A-Za-z>]$/.test(s) && !/\s/.test(s.replace(/, /g, ','));
|
||||||
const access = it => it.access || 'signed in';
|
const access = (it: Item) => it.access || 'signed in';
|
||||||
|
|
||||||
// Which endpoints mention each type, for its "Used by", as docs.rs lists where a type turns up.
|
// Which endpoints mention each type, for its "Used by", as docs.rs lists where a type turns up.
|
||||||
const usedBy = new Map();
|
// An endpoint, with its module, or a type that has it as a field (its module null).
|
||||||
|
const usedBy = new Map<string, ([Group, Item] | [null, ApiType])[]>();
|
||||||
for (const g of api.groups) for (const it of g.items) {
|
for (const g of api.groups) for (const it of g.items) {
|
||||||
for (const s of [it.body, it.query, it.returns].filter(Boolean)) {
|
for (const s of [it.body, it.query, it.returns].filter((s): s is string => !!s)) {
|
||||||
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n)) {
|
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n)) {
|
||||||
if (!usedBy.has(n)) usedBy.set(n, []);
|
const uses = usedBy.get(n) ?? [];
|
||||||
if (!usedBy.get(n).some(([, x]) => x === it)) usedBy.get(n).push([g, it]);
|
usedBy.set(n, uses);
|
||||||
|
if (!uses.some(([, x]) => x === it)) uses.push([g, it]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Types inside types: a type used only as a field is reached from the type that holds it.
|
// Types inside types: a type used only as a field is reached from the type that holds it.
|
||||||
for (const t of api.types) for (const [, ft] of t.fields) {
|
for (const t of api.types) for (const [, ft] of t.fields) {
|
||||||
for (const n of ft.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n) && n !== t.name) {
|
for (const n of ft.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n) && n !== t.name) {
|
||||||
if (!usedBy.has(n)) usedBy.set(n, []);
|
const uses = usedBy.get(n) ?? [];
|
||||||
if (!usedBy.get(n).some(([, x]) => x === t)) usedBy.get(n).push([null, t]);
|
usedBy.set(n, uses);
|
||||||
|
if (!uses.some(([, x]) => x === t)) uses.push([null, t]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const crumbs = (...parts) => `<p class="path"><a href="${api.root}/">${esc(api.name)}</a>${parts.map(([h, n]) => `::<a href="${h}">${n}</a>`).join('')}</p>`;
|
const crumbs = (...parts: [href: string, name: string][]) => `<p class="path"><a href="${api.root}/">${esc(api.name)}</a>${parts.map(([h, n]) => `::<a href="${h}">${n}</a>`).join('')}</p>`;
|
||||||
const top = `<a class="crate" href="${api.root}/"><img src="/logo.svg" alt="" width="40" height="40"><span><b>${esc(label)}</b>${version ? `<span class="ver">${version}</span>` : ''}</span></a>`;
|
const top = `<a class="crate" href="${api.root}/"><img src="/logo.svg" alt="" width="40" height="40"><span><b>${esc(label)}</b>${version ? `<span class="ver">${version}</span>` : ''}</span></a>`;
|
||||||
const back = `<p class="side-back"><a href="/docs/">All docs</a></p>`;
|
const back = `<p class="side-back"><a href="/docs/">All docs</a></p>`;
|
||||||
|
|
||||||
function sidebar({ group, item, type } = {}) {
|
function sidebar({ group, item, type }: { group?: Group, item?: Item, type?: ApiType } = {}) {
|
||||||
let s = top;
|
let s = top;
|
||||||
if (group) {
|
if (group) {
|
||||||
s += `<h2 class="side-h">Module <a href="${api.root}/${group.id}/">${group.name}</a></h2>
|
s += `<h2 class="side-h">Module <a href="${api.root}/${group.id}/">${group.name}</a></h2>
|
||||||
@@ -130,7 +133,7 @@ function reference(api) {
|
|||||||
return `<nav aria-label="${esc(label)} reference">${s}</nav>${back}`;
|
return `<nav aria-label="${esc(label)} reference">${s}</nav>${back}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = (rel, title, description, side, body) =>
|
const page = (rel: string, title: string, description: string, side: string, body: string) =>
|
||||||
write(rel, shell({ title, description, sidebar: side, body, current: 'docs' }));
|
write(rel, shell({ title, description, sidebar: side, body, current: 'docs' }));
|
||||||
|
|
||||||
// The root: what the crate page shows on docs.rs, its modules and types.
|
// The root: what the crate page shows on docs.rs, its modules and types.
|
||||||
@@ -156,8 +159,8 @@ ${api.intro}
|
|||||||
it.body && ` body ${ty(it.body)}`,
|
it.body && ` body ${ty(it.body)}`,
|
||||||
` -> ${isType(it.returns) ? ty(it.returns) : esc(it.returns)}`,
|
` -> ${isType(it.returns) ? ty(it.returns) : esc(it.returns)}`,
|
||||||
].filter(Boolean).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
const fieldsOf = n => api.types.find(t => t.name === n)?.fields || [];
|
const fieldsOf = (n: string) => api.types.find(t => t.name === n)?.fields || [];
|
||||||
const rows = list => `<dl class="fields">${list.map(([n, t, d]) => `<dt id="${it.slug}-${n}"><code>${esc(n)}: ${ty(t)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
|
const rows = (list: Field[]) => `<dl class="fields">${list.map(([n, t, d]) => `<dt id="${it.slug}-${n}"><code>${esc(n)}: ${ty(t)}</code></dt><dd>${d || ''}</dd>`).join('')}</dl>`;
|
||||||
let body = `${crumbs([`${api.root}/${g.id}/`, g.name])}
|
let body = `${crumbs([`${api.root}/${g.id}/`, g.name])}
|
||||||
<h1 class="item"><span class="kind">Endpoint</span> ${esc(api.name)}::${g.name}::<span class="name fn">${fnName(it)}</span><a class="srclink" href="${SRC}/${api.name === 'ipx' ? 'crates/ipx/src/web.rs' : 'crates/directory/src/lib.rs'}">Source</a></h1>
|
<h1 class="item"><span class="kind">Endpoint</span> ${esc(api.name)}::${g.name}::<span class="name fn">${fnName(it)}</span><a class="srclink" href="${SRC}/${api.name === 'ipx' ? 'crates/ipx/src/web.rs' : 'crates/directory/src/lib.rs'}">Source</a></h1>
|
||||||
<pre class="decl"><code>${decl}</code></pre>
|
<pre class="decl"><code>${decl}</code></pre>
|
||||||
@@ -201,10 +204,10 @@ ${t.doc ? `<p class="summary">${t.doc}</p>` : ''}`;
|
|||||||
page(`${api.root.slice(1)}/types/${t.name}.html`, `${t.name} - ${label}`, (t.doc || `${t.name} in ${label}'s API`).replace(/<[^>]+>/g, ''), sidebar({ type: t }), body);
|
page(`${api.root.slice(1)}/types/${t.name}.html`, `${t.name} - ${label}`, (t.doc || `${t.name} in ${label}'s API`).replace(/<[^>]+>/g, ''), sidebar({ type: t }), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every link to a type must name one there is: a misspelt type in api.mjs is otherwise a
|
// Every link to a type must name one there is: a misspelt type in api.mts is otherwise a
|
||||||
// name in plain text, which looks right and leads nowhere.
|
// name in plain text, which looks right and leads nowhere.
|
||||||
for (const g of api.groups) for (const it of g.items) for (const s of [it.body, it.query].filter(Boolean)) {
|
for (const g of api.groups) for (const it of g.items) for (const s of [it.body, it.query].filter((s): s is string => !!s)) {
|
||||||
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g)) if (!typeNames.has(n) && !['Vec', 'Option'].includes(n)) throw new Error(`${it.path}: no type ${n}`);
|
for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) ?? []) if (!typeNames.has(n) && !['Vec', 'Option'].includes(n)) throw new Error(`${it.path}: no type ${n}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,28 +2,30 @@
|
|||||||
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
|
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
|
||||||
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
|
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
|
||||||
//
|
//
|
||||||
// node tests/contrast.js
|
// node tests/contrast.ts
|
||||||
const fs = require('fs');
|
import fs from 'node:fs';
|
||||||
const path = require('path');
|
import path from 'node:path';
|
||||||
|
|
||||||
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
|
const css = fs.readFileSync(path.join(import.meta.dirname, '../web/app.css'), 'utf8');
|
||||||
const blocks = {};
|
/// A rule's custom properties: each colour as #rrggbb, and --fieldmix as a fraction, in text too.
|
||||||
|
type Palette = Record<string, string>;
|
||||||
|
const blocks: Record<string, Palette> = {};
|
||||||
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
|
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
|
||||||
const vars = {};
|
const vars: Palette = {};
|
||||||
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
|
for (const v of m[2]!.matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]!] = v[2]!.toLowerCase();
|
||||||
const mix = m[2].match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = +mix[1] / 100;
|
const mix = m[2]!.match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = String(+mix[1]! / 100);
|
||||||
if (Object.keys(vars).length) blocks[m[1]] = vars;
|
if (Object.keys(vars).length) blocks[m[1]!] = vars;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lum = h => {
|
const lum = (h: string) => {
|
||||||
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
|
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||||
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||||
return .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
|
return .2126 * c[0]! + .7152 * c[1]! + .0722 * c[2]!;
|
||||||
};
|
};
|
||||||
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
const ratio = (a: string, b: string) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
||||||
|
|
||||||
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
|
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
|
||||||
const RULES = [
|
const RULES: [string, string[], number][] = [
|
||||||
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
|
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
|
||||||
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
|
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
|
||||||
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
|
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
|
||||||
@@ -36,8 +38,8 @@ const RULES = [
|
|||||||
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
|
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
|
||||||
];
|
];
|
||||||
|
|
||||||
const base = blocks[':root'];
|
const base = blocks[':root']!;
|
||||||
const themes = {};
|
const themes: Record<string, Palette> = {};
|
||||||
for (const sel of Object.keys(blocks)) {
|
for (const sel of Object.keys(blocks)) {
|
||||||
const t = sel.match(/data-theme="(\w+)"/);
|
const t = sel.match(/data-theme="(\w+)"/);
|
||||||
if (!t) continue;
|
if (!t) continue;
|
||||||
@@ -51,24 +53,28 @@ for (const sel of Object.keys(blocks)) {
|
|||||||
themes['modern'] = base;
|
themes['modern'] = base;
|
||||||
|
|
||||||
// --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does.
|
// --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does.
|
||||||
const oklab = h => {
|
type Lab = [number, number, number];
|
||||||
|
const oklab = (h: string): Lab => {
|
||||||
const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||||
const [l, m, s] = [.4122214708 * r + .5363325363 * g + .0514459929 * b, .2119034982 * r + .6806995451 * g + .1073969566 * b, .0883024619 * r + .2817188376 * g + .6299787005 * b].map(Math.cbrt);
|
const [l, m, s] = [.4122214708 * r + .5363325363 * g + .0514459929 * b, .2119034982 * r + .6806995451 * g + .1073969566 * b, .0883024619 * r + .2817188376 * g + .6299787005 * b].map(Math.cbrt);
|
||||||
return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s];
|
return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s];
|
||||||
};
|
};
|
||||||
const fromOklab = ([L, a, b]) => {
|
const fromOklab = ([L, a, b]: Lab) => {
|
||||||
const [l, m, s] = [L + .3963377774 * a + .2158037573 * b, L - .1055613458 * a - .0638541728 * b, L - .0894841775 * a - 1.291485548 * b].map(x => x ** 3);
|
const [l, m, s] = [L + .3963377774 * a + .2158037573 * b, L - .1055613458 * a - .0638541728 * b, L - .0894841775 * a - 1.291485548 * b].map(x => x ** 3);
|
||||||
return '#' + [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s]
|
return '#' + [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s]
|
||||||
.map(c => Math.min(1, Math.max(0, c)) <= .0031308 ? 12.92 * Math.max(0, c) : 1.055 * Math.min(1, c) ** (1 / 2.4) - .055)
|
.map(c => Math.min(1, Math.max(0, c)) <= .0031308 ? 12.92 * Math.max(0, c) : 1.055 * Math.min(1, c) ** (1 / 2.4) - .055)
|
||||||
.map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
|
.map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
|
||||||
};
|
};
|
||||||
for (const p of Object.values(themes)) { const f = oklab(p.fg), g = oklab(p.bg), k = p.fieldmix; p.field = fromOklab(f.map((v, i) => v * k + g[i] * (1 - k))); }
|
for (const p of Object.values(themes)) {
|
||||||
|
const f = oklab(p.fg!), g = oklab(p.bg!), k = Number(p.fieldmix);
|
||||||
|
p.field = fromOklab(f.map((v, i) => v * k + g[i]! * (1 - k)) as Lab);
|
||||||
|
}
|
||||||
RULES.push(['field', ['bg', 'panel', 'panel2', 'raise'], 3]); // a text box's or a switch's edge, wherever one sits
|
RULES.push(['field', ['bg', 'panel', 'panel2', 'raise'], 3]); // a text box's or a switch's edge, wherever one sits
|
||||||
|
|
||||||
let bad = 0;
|
let bad = 0;
|
||||||
for (const [name, p] of Object.entries(themes)) {
|
for (const [name, p] of Object.entries(themes)) {
|
||||||
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
||||||
const r = ratio(p[fg], p[g]);
|
const r = ratio(p[fg]!, p[g]!);
|
||||||
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
|
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
|
||||||
}
|
}
|
||||||
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
|
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
|
||||||
@@ -79,33 +85,37 @@ for (const [name, p] of Object.entries(themes)) {
|
|||||||
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
|
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
|
||||||
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
|
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
|
||||||
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
|
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
|
||||||
const rgba = (sel, n) => {
|
/// A colour as red, green, blue, 0 to 255, and one drawn over another with its alpha.
|
||||||
|
type RGB = number[];
|
||||||
|
const rgba = (sel: string, n: string): [RGB, number] => {
|
||||||
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
|
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
|
||||||
return [[+m[1], +m[2], +m[3]], +m[4]];
|
if (!m) throw new Error(`no --wash${n} in ${sel}`);
|
||||||
|
return [[+m[1]!, +m[2]!, +m[3]!], +m[4]!];
|
||||||
};
|
};
|
||||||
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
const rgb = (h: string): RGB => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||||
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
const hex = (c: RGB) => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||||
const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
|
const over = (c: RGB, a: number, g: RGB): RGB => g.map((x, i) => c[i]! * a + x * (1 - a));
|
||||||
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
|
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']] as const) {
|
||||||
const p = themes[name];
|
const p = themes[name]!;
|
||||||
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
|
const tint = washes.map(w => [w, rgba(sel, w.n)] as const).reverse(); // the first listed is drawn on top
|
||||||
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
|
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
|
||||||
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
|
if (!sm) throw new Error(`no --glass-sheen in ${sel}`);
|
||||||
const worst = {};
|
const sheen: [RGB, number] = [[+sm[1]!, +sm[2]!, +sm[3]!], +sm[4]!];
|
||||||
|
const worst: Record<string, number> = {};
|
||||||
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
|
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
|
||||||
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
|
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
|
||||||
let g = rgb(p.bg);
|
let g = rgb(p.bg!);
|
||||||
for (const [w, [c, a]] of tint) {
|
for (const [w, [c, a]] of tint) {
|
||||||
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
|
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
|
||||||
g = over(c, a * Math.max(0, 1 - d), g);
|
g = over(c, a * Math.max(0, 1 - d), g);
|
||||||
}
|
}
|
||||||
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it,
|
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it,
|
||||||
// and a panel's top-left corner under its sheen at full strength.
|
// and a panel's top-left corner under its sheen at full strength.
|
||||||
const panel = over(rgb(p.panel), .7, g);
|
const panel = over(rgb(p.panel!), .7, g);
|
||||||
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]])
|
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]] as const)
|
||||||
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
|
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
|
||||||
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
|
const r = ratio(p[fg]!, hex(gc)), k = `--${fg} on ${gn}`;
|
||||||
if (!(k in worst) || r < worst[k]) worst[k] = r;
|
if (!(k in worst) || r < worst[k]!) worst[k] = r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
|
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
|
||||||
12
tests/directory-script.ts
Normal file
12
tests/directory-script.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// The directory serves crates/directory/src/site.js, made from site.ts beside it (#392): its build
|
||||||
|
// has no Node to make it. A change to site.ts not made again into site.js fails here.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { buildDirectoryScript } from '../web/build.mts';
|
||||||
|
|
||||||
|
const file = path.join(import.meta.dirname, '..', 'crates', 'directory', 'src', 'site.js');
|
||||||
|
if (fs.readFileSync(file, 'utf8') !== buildDirectoryScript()) {
|
||||||
|
console.error('FAIL: crates/directory/src/site.js is not site.ts as it stands: run `node web/build.mts --directory-script`');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('OK: directory-script: site.js is site.ts made JavaScript');
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
// The stub DOM the page's script is loaded against, shared by page-smoke.js and
|
|
||||||
// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
|
|
||||||
// to find what it reaches for, and no more.
|
|
||||||
//
|
|
||||||
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
|
|
||||||
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
|
|
||||||
// native.ts replaces that surface on the element and a proxy that answers everything would prove
|
|
||||||
// nothing about whether it worked.
|
|
||||||
const vm = require('vm');
|
|
||||||
|
|
||||||
function makeContext(html, script, { media = false } = {}) {
|
|
||||||
// Ids in the page, and in the markup the script builds for its dialogs.
|
|
||||||
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
|
||||||
const missing = [];
|
|
||||||
|
|
||||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
|
||||||
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
|
||||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
|
||||||
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){}, contains(){ return false; }, select(){}, setSelectionRange(){}, focus(){},
|
|
||||||
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
|
||||||
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
|
||||||
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
|
||||||
|
|
||||||
const body = el('body');
|
|
||||||
const audio = media ? makeMediaElement() : null;
|
|
||||||
if (media) {
|
|
||||||
// native.ts reads has-video off the body to decide whether the host takes the file, so this
|
|
||||||
// one has to be a real set rather than something that always says no.
|
|
||||||
const classes = new Set();
|
|
||||||
body.classList = {
|
|
||||||
add: c => classes.add(c), remove: c => classes.delete(c),
|
|
||||||
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
|
|
||||||
contains: c => classes.has(c),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const document = {
|
|
||||||
querySelector(sel) {
|
|
||||||
if (sel === '#audio' && audio) return audio;
|
|
||||||
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
|
||||||
return el(sel);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
createElement: () => el('created'),
|
|
||||||
addEventListener(){}, body,
|
|
||||||
documentElement: { dataset: {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
document, console, crypto: require("crypto").webcrypto, performance,
|
|
||||||
window: { isSecureContext: false, addEventListener(){} },
|
|
||||||
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
|
||||||
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
|
||||||
fetch: (url) => Promise.resolve({
|
|
||||||
ok: true, status: 200, text: () => Promise.resolve(''),
|
|
||||||
json: () => Promise.resolve(
|
|
||||||
String(url).includes('/api/settings')
|
|
||||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
|
||||||
: String(url).includes('/api/users')
|
|
||||||
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
|
||||||
: /\/api\/(popular|directory)/.test(String(url))
|
|
||||||
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
|
||||||
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
|
||||||
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
|
||||||
}),
|
|
||||||
EventSource: function () { this.close = () => {}; },
|
|
||||||
MediaMetadata: function () {},
|
|
||||||
Blob: function () {},
|
|
||||||
getComputedStyle: () => ({ backgroundColor: '' }),
|
|
||||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
||||||
confirm: () => false, prompt: () => null, alert(){},
|
|
||||||
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
|
||||||
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
|
||||||
};
|
|
||||||
if (media) {
|
|
||||||
ctx.HTMLMediaElement = MediaElement;
|
|
||||||
ctx.Event = Event;
|
|
||||||
ctx.isFinite = isFinite;
|
|
||||||
ctx.CSS = { escape: s => String(s) };
|
|
||||||
}
|
|
||||||
ctx.globalThis = ctx;
|
|
||||||
ctx.window.location = { href: '', hash: '' };
|
|
||||||
ctx.location = ctx.window.location;
|
|
||||||
|
|
||||||
return { ctx, missing, audio, body, ids };
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
|
|
||||||
|
|
||||||
function Event(type) { this.type = type; }
|
|
||||||
|
|
||||||
function MediaElement() {
|
|
||||||
this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
|
|
||||||
this._ready = 0; this._vol = 1; this._rate = 1;
|
|
||||||
this._listeners = {};
|
|
||||||
this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
|
|
||||||
// Every call that reached the real element, so a test can say the host took over rather than
|
|
||||||
// the element quietly playing as well.
|
|
||||||
this.calls = [];
|
|
||||||
}
|
|
||||||
MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
|
|
||||||
MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
|
|
||||||
MediaElement.prototype.load = function(){ this.calls.push('load'); };
|
|
||||||
MediaElement.prototype.addEventListener = function(name, fn, opts){
|
|
||||||
(this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
|
|
||||||
};
|
|
||||||
MediaElement.prototype.dispatchEvent = function(ev){
|
|
||||||
for (const l of (this._listeners[ev.type] || []).slice()) {
|
|
||||||
if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
|
|
||||||
l.fn(ev);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
|
|
||||||
MediaElement.prototype.setAttribute = function(){};
|
|
||||||
MediaElement.prototype.getAttribute = function(){ return null; };
|
|
||||||
const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
|
|
||||||
configurable: true,
|
|
||||||
get(){ return this[field]; },
|
|
||||||
set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
|
|
||||||
});
|
|
||||||
accessor('src', '_src', true);
|
|
||||||
accessor('currentTime', '_t', true);
|
|
||||||
accessor('volume', '_vol');
|
|
||||||
accessor('playbackRate', '_rate');
|
|
||||||
Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
|
|
||||||
Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
|
|
||||||
Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
|
|
||||||
|
|
||||||
function makeMediaElement(){ return new MediaElement(); }
|
|
||||||
|
|
||||||
module.exports = { makeContext, vm };
|
|
||||||
145
tests/dom-stub.ts
Normal file
145
tests/dom-stub.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// The stub DOM the page's script is loaded against, shared by page-smoke.ts, page-unit.ts and
|
||||||
|
// native-bridge.ts. It is deliberately thin: enough for every handler the script wires at load
|
||||||
|
// to find what it reaches for, and no more.
|
||||||
|
//
|
||||||
|
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
|
||||||
|
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
|
||||||
|
// native.ts replaces that surface on the element and a proxy that answers everything would prove
|
||||||
|
// nothing about whether it worked.
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { webcrypto } from 'node:crypto';
|
||||||
|
|
||||||
|
// The VM's side is the page's script, whose types are its own (web/src): a test reaches into it by
|
||||||
|
// name, ctx.selectFeed(...), and gets back what the script made. So both are `any` at this edge.
|
||||||
|
/// A stub element: whatever the script sets on it, and empty answers for what it reads.
|
||||||
|
type Stub = Record<string | symbol, any>;
|
||||||
|
/// The page's globals, as the script in the VM sees them, its own functions among them after it runs.
|
||||||
|
type Globals = Record<string, any>;
|
||||||
|
|
||||||
|
function makeContext(html: string, script: string, { media = false } = {}) {
|
||||||
|
// Ids in the page, and in the markup the script builds for its dialogs.
|
||||||
|
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => (m[1] || m[2])!));
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
const el = (_name: string): Stub => new Proxy<Stub>({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||||
|
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||||
|
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||||
|
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){}, contains(){ return false; }, select(){}, setSelectionRange(){}, focus(){},
|
||||||
|
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
||||||
|
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
||||||
|
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
||||||
|
|
||||||
|
const body = el('body');
|
||||||
|
const audio = media ? new MediaElement() : null;
|
||||||
|
if (media) {
|
||||||
|
// native.ts reads has-video off the body to decide whether the host takes the file, so this
|
||||||
|
// one has to be a real set rather than something that always says no.
|
||||||
|
const classes = new Set<string>();
|
||||||
|
body.classList = {
|
||||||
|
add: (c: string) => { classes.add(c); }, remove: (c: string) => { classes.delete(c); },
|
||||||
|
toggle: (c: string, on?: boolean) => {
|
||||||
|
if (on ?? !classes.has(c)) classes.add(c); else classes.delete(c);
|
||||||
|
},
|
||||||
|
contains: (c: string) => classes.has(c),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
querySelector(sel: string) {
|
||||||
|
if (sel === '#audio' && audio) return audio;
|
||||||
|
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
||||||
|
return el(sel);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => el('created'),
|
||||||
|
addEventListener(){}, body,
|
||||||
|
documentElement: { dataset: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const location = { href: '', hash: '' };
|
||||||
|
const ctx: Globals = {
|
||||||
|
document, console, crypto: webcrypto, performance,
|
||||||
|
window: { isSecureContext: false, addEventListener(){}, location },
|
||||||
|
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
||||||
|
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
||||||
|
fetch: (url: unknown) => Promise.resolve({
|
||||||
|
ok: true, status: 200, text: () => Promise.resolve(''),
|
||||||
|
json: () => Promise.resolve(
|
||||||
|
String(url).includes('/api/settings')
|
||||||
|
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||||
|
: String(url).includes('/api/users')
|
||||||
|
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||||
|
: /\/api\/(popular|directory)/.test(String(url))
|
||||||
|
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
||||||
|
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
||||||
|
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
||||||
|
}),
|
||||||
|
EventSource: class { close() {} },
|
||||||
|
MediaMetadata: class {},
|
||||||
|
Blob: class {},
|
||||||
|
getComputedStyle: () => ({ backgroundColor: '' }),
|
||||||
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||||
|
confirm: () => false, prompt: () => null, alert(){},
|
||||||
|
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: class {},
|
||||||
|
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
||||||
|
location,
|
||||||
|
};
|
||||||
|
if (media) {
|
||||||
|
ctx.HTMLMediaElement = MediaElement;
|
||||||
|
ctx.Event = StubEvent;
|
||||||
|
ctx.isFinite = isFinite;
|
||||||
|
ctx.CSS = { escape: (s: unknown) => String(s) };
|
||||||
|
}
|
||||||
|
ctx.globalThis = ctx;
|
||||||
|
|
||||||
|
return { ctx, missing, audio, body, ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
|
||||||
|
|
||||||
|
class StubEvent { type: string; constructor(type: string) { this.type = type; } }
|
||||||
|
|
||||||
|
type Listener = { fn: (ev: StubEvent) => void, once: boolean };
|
||||||
|
|
||||||
|
class MediaElement {
|
||||||
|
_src = ''; _t = 0; _dur = NaN; _paused = true;
|
||||||
|
_ready = 0; _vol = 1; _rate = 1;
|
||||||
|
_listeners: Record<string, Listener[]> = {};
|
||||||
|
dataset = {};
|
||||||
|
classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
|
||||||
|
// Every call that reached the real element, so a test can say the host took over rather than
|
||||||
|
// the element quietly playing as well.
|
||||||
|
calls: string[] = [];
|
||||||
|
|
||||||
|
play() { this.calls.push('play'); this._paused = false; return Promise.resolve(); }
|
||||||
|
pause() { this.calls.push('pause'); this._paused = true; }
|
||||||
|
load() { this.calls.push('load'); }
|
||||||
|
addEventListener(name: string, fn: Listener['fn'], opts?: { once?: boolean }) {
|
||||||
|
(this._listeners[name] ||= []).push({ fn, once: !!opts?.once });
|
||||||
|
}
|
||||||
|
dispatchEvent(ev: StubEvent) {
|
||||||
|
for (const l of (this._listeners[ev.type] || []).slice()) {
|
||||||
|
if (l.once) this._listeners[ev.type] = this._listeners[ev.type]!.filter(x => x !== l);
|
||||||
|
l.fn(ev);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
removeAttribute(name: string) { this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; }
|
||||||
|
setAttribute() {}
|
||||||
|
getAttribute() { return null; }
|
||||||
|
|
||||||
|
// Accessors on the prototype, configurable, as a real element's are: native.ts takes them over.
|
||||||
|
get src() { return this._src; }
|
||||||
|
set src(v) { this.calls.push('src:' + v); this._src = v; }
|
||||||
|
get currentTime() { return this._t; }
|
||||||
|
set currentTime(v) { this.calls.push('currentTime:' + v); this._t = v; }
|
||||||
|
get volume() { return this._vol; }
|
||||||
|
set volume(v) { this._vol = v; }
|
||||||
|
get playbackRate() { return this._rate; }
|
||||||
|
set playbackRate(v) { this._rate = v; }
|
||||||
|
get duration() { return this._dur; }
|
||||||
|
get paused() { return this._paused; }
|
||||||
|
get readyState() { return this._ready; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { makeContext, vm, MediaElement };
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import http from 'k6/http';
|
import http from 'k6/http';
|
||||||
import { check, sleep } from 'k6';
|
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,
|
// 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
|
// 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 () {
|
export default function () {
|
||||||
const u = me();
|
const u = me();
|
||||||
const feeds = http.get(`${BASE}/api/feeds`, as(u, 'feeds'));
|
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'));
|
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'));
|
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.
|
// 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'));
|
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).
|
// 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 http from 'k6/http';
|
||||||
import { check, sleep } from 'k6';
|
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
|
// 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
|
// 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.
|
// 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() {
|
export function listen() {
|
||||||
const u = me();
|
const u = me();
|
||||||
if (!ep) {
|
if (!ep) {
|
||||||
const r = http.get(`${BASE}/api/entries?limit=50`, as(u, 'pick'));
|
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;
|
secs += 1;
|
||||||
const saved = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/position`,
|
const saved = http.post(`${BASE}/api/entries/${encodeURIComponent(ep.feed_id)}/${encodeURIComponent(ep.guid)}/position`,
|
||||||
JSON.stringify({ secs, duration: ep.duration }), as(u, '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'));
|
JSON.stringify({ read: n % 20 === 0 }), as(u, 'flags'));
|
||||||
check(flags, { 'marked read or unread': r => r.status === 200 });
|
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 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, {
|
check(mine, {
|
||||||
'your position, as you left it': e => e && e.position === secs,
|
'your position, as you left it': e => e?.position === secs,
|
||||||
'read as you marked it': e => e && e.read === (n % 20 === 0),
|
'read as you marked it': e => e?.read === (n % 20 === 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sleep(1);
|
sleep(1);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import http from 'k6/http';
|
import http from 'k6/http';
|
||||||
import { check } from 'k6';
|
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
|
// 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
|
// 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'],
|
'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 SIZE = 2 * 1024 * 1024;
|
||||||
const byte = k => (k * 31 + 7) & 255;
|
const byte = (k: number) => (k * 31 + 7) & 255;
|
||||||
|
|
||||||
export function setup() {
|
export function setup() {
|
||||||
const r = http.get(`${BASE}/api/entries?filter=downloaded&limit=50`, as('listener', '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');
|
if (!files.length) throw new Error('the listener has no downloaded files to seek through');
|
||||||
return { files };
|
return { files };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ({ files }) {
|
export default function ({ files }: { files: number[] }) {
|
||||||
const start = 10 + Math.floor(Math.random() * (SIZE - 11));
|
const start = 10 + Math.floor(Math.random() * (SIZE - 11));
|
||||||
const end = Math.min(SIZE - 1, start + Math.floor(Math.random() * 65536));
|
const end = Math.min(SIZE - 1, start + Math.floor(Math.random() * 65536));
|
||||||
const r = http.get(`${BASE}/media/${pick(files)}`, {
|
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
|
// database and to the healthcheck. The browser suite drives one person against a handful of
|
||||||
// feeds, one request at a time.
|
// 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
|
// 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
|
// 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.
|
// are what production would see.
|
||||||
// IPX_LOAD_FEEDS sets the catalogue's size (1500, near production's), IPX_LOAD_USERS the
|
// 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.
|
// listeners (100), IPX_LOAD_LOG=1 shows the daemon's log.
|
||||||
const http = require('http');
|
import http from 'node:http';
|
||||||
const fs = require('fs');
|
import fs from 'node:fs';
|
||||||
const path = require('path');
|
import path from 'node:path';
|
||||||
const { spawn, spawnSync, execFile, execFileSync } = require('child_process');
|
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 root = '/tmp/ipx-load';
|
||||||
const WEB = 8793, GEN = 8794;
|
const WEB = 8793, GEN = 8794;
|
||||||
const FEEDS = Number(process.env.IPX_LOAD_FEEDS || 1500);
|
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'],
|
['Society & Culture', 'Documentary'], ['Sports', 'Soccer'], ['Arts', 'Books'],
|
||||||
['Science', 'Astronomy'], ['History'], ['True Crime'], ['Business', 'Investing'],
|
['Science', 'Astronomy'], ['History'], ['True Crime'], ['Business', 'Investing'],
|
||||||
['Education', 'Self-Improvement'], ['Health & Fitness', 'Mental Health'], ['Music']];
|
['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();
|
const now = Date.now();
|
||||||
// Every third feed is a blog: no files, as most of production's are.
|
// Every third feed is a blog: no files, as most of production's are.
|
||||||
const isBlog = n => n % 3 === 2 && n >= MEDIA;
|
const isBlog = (n: number) => n % 3 === 2 && n >= MEDIA;
|
||||||
function feedXml(n) {
|
function feedXml(n: number) {
|
||||||
const [cat, sub] = CATS[n % CATS.length];
|
const [cat, sub] = CATS[n % CATS.length];
|
||||||
const category = sub
|
const category = sub
|
||||||
? `<itunes:category text="${esc(cat)}"><itunes:category text="${esc(sub)}"/></itunes:category>`
|
? `<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
|
// 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
|
// it asked for is the range it got, not merely that it got something that long. ID3 first, so
|
||||||
// ipx takes it for audio.
|
// 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)));
|
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);
|
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'));
|
const art = fs.readFileSync(path.join(repo, 'tests/ui/fixtures/art.jpg'));
|
||||||
|
|
||||||
function serveFeeds() {
|
function serveFeeds() {
|
||||||
return http.createServer((req, res) => {
|
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] === '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] === '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); }
|
if (m?.[1] === 'art') { res.writeHead(200, { 'content-type': 'image/jpeg' }); return res.end(art); }
|
||||||
res.writeHead(404).end();
|
res.writeHead(404).end();
|
||||||
}).listen(GEN, '127.0.0.1');
|
}).listen(GEN, '127.0.0.1');
|
||||||
}
|
}
|
||||||
const feedId = n => `gen-${String(n).padStart(4, '0')}`;
|
const feedId = (n: number) => `gen-${String(n).padStart(4, '0')}`;
|
||||||
const feedUrl = n => `http://127.0.0.1:${GEN}/feed/${n}.xml`;
|
const feedUrl = (n: number) => `http://127.0.0.1:${GEN}/feed/${n}.xml`;
|
||||||
|
|
||||||
// ---- the daemon -----------------------------------------------------------------------------
|
// ---- the daemon -----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ function freshPostgres() {
|
|||||||
|
|
||||||
/// Statements run in the test database, given by its URL in psql's environment, not its
|
/// 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.
|
/// arguments, where every process on Tower could read the password.
|
||||||
function psql(...sql) {
|
function psql(...sql: string[]) {
|
||||||
const u = new URL(PG_URL);
|
const u = new URL(PG_URL);
|
||||||
const env = { ...process.env, PGHOST: u.hostname, PGPORT: u.port || '5432', PGUSER: decodeURIComponent(u.username),
|
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}` };
|
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
|
/// Resolves with the first log line matching `test`, read from the daemon's JSON log, which it
|
||||||
/// writes to stderr.
|
/// writes to stderr.
|
||||||
function waitForLog(daemon, test, ms) {
|
/// A line of the daemon's JSON log: its event and the event's fields.
|
||||||
return new Promise((resolve, reject) => {
|
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 = '';
|
let buf = '';
|
||||||
const timer = setTimeout(() => { daemon.stderr.off('data', on); reject(new Error(`no log line in ${ms / 1000}s for ${test}`)); }, ms);
|
const stderr = daemon.stderr!;
|
||||||
const on = chunk => {
|
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;
|
buf += chunk;
|
||||||
let i;
|
let i;
|
||||||
while ((i = buf.indexOf('\n')) >= 0) {
|
while ((i = buf.indexOf('\n')) >= 0) {
|
||||||
const line = buf.slice(0, i); buf = buf.slice(i + 1);
|
const line = buf.slice(0, i); buf = buf.slice(i + 1);
|
||||||
let ev; try { ev = JSON.parse(line); } catch { continue; }
|
let ev: LogLine; try { ev = JSON.parse(line); } catch { continue; }
|
||||||
if (test(ev)) { clearTimeout(timer); daemon.stderr.off('data', on); return resolve(ev); }
|
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' });
|
const as = (name: string) => ({ 'X-Load-User': name, 'Content-Type': 'application/json' });
|
||||||
async function api(name, url, opts = {}) {
|
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 } });
|
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()}`);
|
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);
|
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>`;
|
ns.map(n => `<outline type="rss" text="${feedId(n)}" xmlUrl="${feedUrl(n)}"/>`).join('')}</body></opml>`;
|
||||||
|
|
||||||
async function seed() {
|
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)) }) });
|
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))) {
|
for (let t = Date.now(); ; await new Promise(r => setTimeout(r, 500))) {
|
||||||
const got = await api('listener', '/api/entries?filter=downloaded&limit=50');
|
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`);
|
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.
|
// 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; };
|
return () => { on = false; return seen; };
|
||||||
}
|
}
|
||||||
|
|
||||||
function k6(script) {
|
function k6(script: string) {
|
||||||
return new Promise(resolve => {
|
return new Promise<number | null>(resolve => {
|
||||||
const run = spawn('k6', ['run', '--quiet', '-e', `BASE=${BASE}`, '-e', `USERS=${USERS}`, '-e', `FEEDS=${FEEDS}`,
|
const run = spawn('k6', ['run', '--quiet', '-e', `BASE=${BASE}`, '-e', `USERS=${USERS}`, '-e', `FEEDS=${FEEDS}`,
|
||||||
'-e', `DB=${POSTGRES ? 'postgres' : 'sqlite'}`,
|
'-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));
|
run.on('exit', code => resolve(code));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -251,7 +255,7 @@ function k6(script) {
|
|||||||
let failed = 0;
|
let failed = 0;
|
||||||
try {
|
try {
|
||||||
const t = Date.now();
|
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`);
|
console.log(`first scan: ${first.feeds} feeds in ${((Date.now() - t) / 1000).toFixed(1)}s`);
|
||||||
await seed();
|
await seed();
|
||||||
// The planner's statistics, as production's long-standing tables have them. A schema filled
|
// 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`); }
|
if (health.failed || health.slowest >= 5000) { failed++; console.log(`${script}: the healthcheck would have failed`); }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e.message);
|
console.error((e as Error).message);
|
||||||
failed++;
|
failed++;
|
||||||
} finally {
|
} finally {
|
||||||
// Its own PID, never a pkill: Tower sees production's ipx too (#38).
|
// Its own PID, never a pkill: Tower sees production's ipx too (#38).
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import http from 'k6/http';
|
import http from 'k6/http';
|
||||||
import { check, sleep } from 'k6';
|
import { check, sleep } from 'k6';
|
||||||
import { Trend } from 'k6/metrics';
|
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.
|
// 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
|
// Two things only many requests at once show: whether checking passwords, tens of milliseconds
|
||||||
@@ -6,14 +6,14 @@
|
|||||||
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
||||||
// road, with nothing to look at.
|
// road, with nothing to look at.
|
||||||
//
|
//
|
||||||
// node tests/native-bridge.js
|
// node tests/native-bridge.ts
|
||||||
const { makeContext, vm } = require('./dom-stub.js');
|
import { makeContext, vm } from './dom-stub.ts';
|
||||||
const { buildPage } = require('../web/build.mjs');
|
import { buildPage } from '../web/build.mts';
|
||||||
|
|
||||||
const { html, js: script } = buildPage('index.html');
|
const { html, js: script } = buildPage('index.html');
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
const ok = (cond: unknown, what: string) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
||||||
|
|
||||||
/* ---- a browser: nothing installs ---- */
|
/* ---- a browser: nothing installs ---- */
|
||||||
{
|
{
|
||||||
@@ -27,13 +27,17 @@ const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- inside the shell ---- */
|
/* ---- inside the shell ---- */
|
||||||
const posted = [];
|
/// What the page sent the shell: a kind, `t`, and its fields.
|
||||||
const { ctx, audio, body } = makeContext(html, script, { media: true });
|
type Posted = { t: string, [field: string]: unknown };
|
||||||
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: m => posted.push(m) } } };
|
const posted: Posted[] = [];
|
||||||
|
const { ctx, audio: media, body } = makeContext(html, script, { media: true });
|
||||||
|
if (!media) throw new Error('makeContext gave no media element for { media: true }');
|
||||||
|
const audio = media;
|
||||||
|
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: (m: Posted) => posted.push(m) } } };
|
||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
|
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
|
||||||
|
|
||||||
const last = t => [...posted].reverse().find(m => m.t === t);
|
const last = (t: string) => [...posted].reverse().find(m => m.t === t);
|
||||||
const since = () => posted.splice(0, posted.length);
|
const since = () => posted.splice(0, posted.length);
|
||||||
|
|
||||||
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
||||||
@@ -64,7 +68,7 @@ ok(audio.calls.includes('load'), 'the element was not made to let go of what it
|
|||||||
// player.ts sets currentTime=0 straight after the src.
|
// player.ts sets currentTime=0 straight after the src.
|
||||||
since();
|
since();
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
ok(last('seek') && last('seek').to === 0, 'a seek did not reach the host');
|
ok(last('seek')?.to === 0, 'a seek did not reach the host');
|
||||||
|
|
||||||
since();
|
since();
|
||||||
audio.play();
|
audio.play();
|
||||||
@@ -90,13 +94,13 @@ ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
|
|||||||
// The 15-second key: a read and a write through the shim.
|
// The 15-second key: a read and a write through the shim.
|
||||||
since();
|
since();
|
||||||
audio.currentTime -= 15;
|
audio.currentTime -= 15;
|
||||||
ok(last('seek') && last('seek').to === 15, 'back 15 seconds did not land at 15');
|
ok(last('seek')?.to === 15, 'back 15 seconds did not land at 15');
|
||||||
|
|
||||||
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
||||||
since();
|
since();
|
||||||
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
||||||
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
||||||
ok(last('position') && /\/position$/.test(last('position').url), 'the position write did not become a request to the host');
|
ok(/\/position$/.test(String(last('position')?.url ?? '')), 'the position write did not become a request to the host');
|
||||||
|
|
||||||
// Closing the player has to stop the host, not just blank the element.
|
// Closing the player has to stop the host, not just blank the element.
|
||||||
since();
|
since();
|
||||||
@@ -6,11 +6,12 @@
|
|||||||
// never defined. `node --check` passes that happily -- it is a parse, not a run --
|
// never defined. `node --check` passes that happily -- it is a parse, not a run --
|
||||||
// and every server-side test passed too, because the server was fine.
|
// and every server-side test passed too, because the server was fine.
|
||||||
//
|
//
|
||||||
// node tests/page-smoke.js
|
// node tests/page-smoke.ts
|
||||||
const { makeContext, vm } = require('./dom-stub.js');
|
import { makeContext, vm } from './dom-stub.ts';
|
||||||
const PAGE = process.argv[2] || 'index.html';
|
const PAGE = process.argv[2] || 'index.html';
|
||||||
|
|
||||||
const { buildPage } = require('../web/build.mjs');
|
import { buildPage } from '../web/build.mts';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
// What ships: minified, so an id may have lost its quotes.
|
// What ships: minified, so an id may have lost its quotes.
|
||||||
const { html, js: script, script: file } = buildPage(PAGE);
|
const { html, js: script, script: file } = buildPage(PAGE);
|
||||||
if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && PAGE !== 'login.html') {
|
if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && PAGE !== 'login.html') {
|
||||||
@@ -25,7 +26,7 @@ try {
|
|||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
vm.runInContext(script, ctx, { filename: `${PAGE}<script>`, timeout: 5000 });
|
vm.runInContext(script, ctx, { filename: `${PAGE}<script>`, timeout: 5000 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
|
console.error('FAIL: the page script threw while loading\n ' + String((e as Error).stack ?? e).split('\n').slice(0, 3).join('\n '));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ const feed = {
|
|||||||
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
|
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
|
||||||
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
|
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
|
||||||
};
|
};
|
||||||
const drive = PAGE === 'admin.html' ? [
|
const drive: [string, () => any][] = PAGE === 'admin.html' ? [
|
||||||
['drawServer', () => ctx.drawServer()],
|
['drawServer', () => ctx.drawServer()],
|
||||||
['drawAccounts', () => ctx.drawAccounts()],
|
['drawAccounts', () => ctx.drawAccounts()],
|
||||||
['drawLogView', () => ctx.drawLogView()],
|
['drawLogView', () => ctx.drawLogView()],
|
||||||
@@ -59,11 +60,11 @@ const drive = PAGE === 'admin.html' ? [
|
|||||||
for (const [name, fn] of drive) {
|
for (const [name, fn] of drive) {
|
||||||
try {
|
try {
|
||||||
const r = fn();
|
const r = fn();
|
||||||
if (r && typeof r.catch === 'function') r.catch(e => {
|
if (r && typeof r.catch === 'function') r.catch((e: Error) => {
|
||||||
console.error(`FAIL: ${name} rejected: ${e.message}`); process.exit(1);
|
console.error(`FAIL: ${name} rejected: ${e.message}`); process.exit(1);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`FAIL: ${name} threw: ${e.message}`);
|
console.error(`FAIL: ${name} threw: ${(e as Error).message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,7 @@ if (missing.length) {
|
|||||||
console.log(`OK: ${PAGE}: its script loads clean, every selector it wires at load exists`);
|
console.log(`OK: ${PAGE}: its script loads clean, every selector it wires at load exists`);
|
||||||
// The admin page's log arms a poll timer; without an exit the pending interval keeps node alive.
|
// The admin page's log arms a poll timer; without an exit the pending interval keeps node alive.
|
||||||
if (PAGE === 'index.html') {
|
if (PAGE === 'index.html') {
|
||||||
const r = require('child_process').spawnSync(process.execPath, [__filename, 'admin.html'], { stdio: 'inherit' });
|
const r = spawnSync(process.execPath, [import.meta.filename, 'admin.html'], { stdio: 'inherit' });
|
||||||
process.exit(r.status);
|
process.exit(r.status);
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
|
// Unit tests for the page's own functions (#234): the script as shipped, loaded into the stub DOM
|
||||||
// page-smoke.js uses, and its helpers called directly. The browser suite tests what people do;
|
// page-smoke.ts uses, and its helpers called directly. The browser suite tests what people do;
|
||||||
// this tests the small pieces it is made of, in a second, with nothing running.
|
// this tests the small pieces it is made of, in a second, with nothing running.
|
||||||
//
|
//
|
||||||
// node --test tests/page-unit.js
|
// node --test tests/page-unit.ts
|
||||||
//
|
//
|
||||||
// The files in web/src are one script, not modules. Its top-level `function`s are properties of
|
// The files in web/src are one script, not modules. Its top-level `function`s are properties of
|
||||||
// the context; its top-level `const`s are not, but a script run in the same context sees them, as
|
// the context; its top-level `const`s are not, but a script run in the same context sees them, as
|
||||||
// a second <script> on a page would, so `run` evaluates an expression there.
|
// a second <script> on a page would, so `run` evaluates an expression there.
|
||||||
const { test } = require('node:test');
|
import { test } from 'node:test';
|
||||||
const assert = require('node:assert/strict');
|
import assert from 'node:assert/strict';
|
||||||
const { makeContext, vm } = require('./dom-stub.js');
|
import { makeContext, vm } from './dom-stub.ts';
|
||||||
const { buildPage } = require('../web/build.mjs');
|
import { buildPage } from '../web/build.mts';
|
||||||
|
|
||||||
const { html, js } = buildPage('index.html');
|
const { html, js } = buildPage('index.html');
|
||||||
const { ctx } = makeContext(html, js);
|
const { ctx } = makeContext(html, js);
|
||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
|
vm.runInContext(js, ctx, { filename: 'index.html<script>', timeout: 5000 });
|
||||||
const run = expr => vm.runInContext(expr, ctx);
|
// What the page's script answers, from inside the VM: its types are the script's, not this file's.
|
||||||
|
const run = (expr: string): any => vm.runInContext(expr, ctx);
|
||||||
// Time as the page sees it, for the helpers that count from now.
|
// Time as the page sees it, for the helpers that count from now.
|
||||||
const NOW = 1_800_000_000;
|
const NOW = 1_800_000_000;
|
||||||
run(`Date.now = () => ${NOW * 1000}`);
|
run(`Date.now = () => ${NOW * 1000}`);
|
||||||
@@ -89,7 +90,7 @@ test('initials and plural', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("kindOf names a file by its type, or else its address's extension", () => {
|
test("kindOf names a file by its type, or else its address's extension", () => {
|
||||||
const kind = enc => run(`kindOf(${JSON.stringify(enc)})`);
|
const kind = (enc: object) => run(`kindOf(${JSON.stringify(enc)})`);
|
||||||
assert.equal(kind({ mime: 'image/png' }), 'image');
|
assert.equal(kind({ mime: 'image/png' }), 'image');
|
||||||
assert.equal(kind({ mime: 'Video/MP4' }), 'video');
|
assert.equal(kind({ mime: 'Video/MP4' }), 'video');
|
||||||
assert.equal(kind({ mime: 'audio/mpeg' }), 'audio');
|
assert.equal(kind({ mime: 'audio/mpeg' }), 'audio');
|
||||||
@@ -102,7 +103,7 @@ test("kindOf names a file by its type, or else its address's extension", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('an item without a title is named by its file', () => {
|
test('an item without a title is named by its file', () => {
|
||||||
const name = e => run(`entryName(${JSON.stringify(e)})`);
|
const name = (e: object) => run(`entryName(${JSON.stringify(e)})`);
|
||||||
assert.deepEqual({ ...name({ title: 'Episode 1' }) }, { text: 'Episode 1', derived: false });
|
assert.deepEqual({ ...name({ title: 'Episode 1' }) }, { text: 'Episode 1', derived: false });
|
||||||
assert.deepEqual({ ...name({ title: ' ', enclosures: [{ url: 'https://x/My%20Show%20101.mp3?x=1' }] }) },
|
assert.deepEqual({ ...name({ title: ' ', enclosures: [{ url: 'https://x/My%20Show%20101.mp3?x=1' }] }) },
|
||||||
{ text: 'My Show 101', derived: true });
|
{ text: 'My Show 101', derived: true });
|
||||||
@@ -111,7 +112,7 @@ test('an item without a title is named by its file', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the directory sorts by name as people read, and by subscribers', () => {
|
test('the directory sorts by name as people read, and by subscribers', () => {
|
||||||
const sorted = (list, by) => run(`${JSON.stringify(list)}.sort(${by}).map(p => p.title)`);
|
const sorted = (list: object[], by: string) => run(`${JSON.stringify(list)}.sort(${by}).map(p => p.title)`);
|
||||||
assert.deepEqual([...sorted([{ title: 'ep 10' }, { title: 'Ep 2' }, { title: 'ep 1' }], 'byName')], ['ep 1', 'Ep 2', 'ep 10']);
|
assert.deepEqual([...sorted([{ title: 'ep 10' }, { title: 'Ep 2' }, { title: 'ep 1' }], 'byName')], ['ep 1', 'Ep 2', 'ep 10']);
|
||||||
assert.deepEqual([...sorted([{ title: 'B', subscribers: 1 }, { title: 'C', subscribers: 5 }, { title: 'A', subscribers: 1 }], 'bySubs')],
|
assert.deepEqual([...sorted([{ title: 'B', subscribers: 1 }, { title: 'C', subscribers: 5 }, { title: 'A', subscribers: 1 }], 'bySubs')],
|
||||||
['C', 'A', 'B']);
|
['C', 'A', 'B']);
|
||||||
@@ -181,7 +182,7 @@ test('the icon picker finds icons by the start of their words, the one named it
|
|||||||
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
|
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
|
||||||
['brands/apple', 'Apple', 'fruit ios mac'],
|
['brands/apple', 'Apple', 'fruit ios mac'],
|
||||||
]);
|
]);
|
||||||
const ids = q => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
|
const ids = (q: string) => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
|
||||||
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
|
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
|
||||||
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
|
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
|
||||||
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
|
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
|
||||||
@@ -214,14 +215,14 @@ test('Jev is offered the icons found for each word of a Signal', () => {
|
|||||||
['brands/mandalorian', 'Mandalorian', 'disney plus helmet star wars'], ['brands/apple', 'Apple', 'fruit ios mac'],
|
['brands/mandalorian', 'Mandalorian', 'disney plus helmet star wars'], ['brands/apple', 'Apple', 'fruit ios mac'],
|
||||||
['solid/fort-awesome', 'Fort', 'castle'], ['solid/apple-whole', 'Apple Whole', 'fruit'],
|
['solid/fort-awesome', 'Fort', 'castle'], ['solid/apple-whole', 'Apple Whole', 'fruit'],
|
||||||
]);
|
]);
|
||||||
const offered = q => JSON.parse(run(`JSON.stringify(iconCandidates(${brands}, ${JSON.stringify(q)}, [], null, 40).map(c => c[0]))`));
|
const offered = (q: string) => JSON.parse(run(`JSON.stringify(iconCandidates(${brands}, ${JSON.stringify(q)}, [], null, 40).map(c => c[0]))`));
|
||||||
assert.deepEqual(offered('Disney castle'), ['solid/fort-awesome'], 'a brand only by its own name, never its search terms (#264)');
|
assert.deepEqual(offered('Disney castle'), ['solid/fort-awesome'], 'a brand only by its own name, never its search terms (#264)');
|
||||||
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
|
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
|
||||||
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
|
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Signals are everyone\'s on the server\'s key, and yours on your own once a key is saved', () => {
|
test('Signals are everyone\'s on the server\'s key, and yours on your own once a key is saved', () => {
|
||||||
const on = me => { run(`S.me = ${JSON.stringify(me)}`); return run('signalsOn()'); };
|
const on = (me: object | null) => { run(`S.me = ${JSON.stringify(me)}`); return run('signalsOn()'); };
|
||||||
assert.equal(on({ signals: 'server', typesafe_key_set: false }), true);
|
assert.equal(on({ signals: 'server', typesafe_key_set: false }), true);
|
||||||
assert.equal(on({ signals: 'own', typesafe_key_set: true }), true);
|
assert.equal(on({ signals: 'own', typesafe_key_set: true }), true);
|
||||||
assert.equal(on({ signals: 'own', typesafe_key_set: false }), false);
|
assert.equal(on({ signals: 'own', typesafe_key_set: false }), false);
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
// With the page's coverage recorded when asked for (#390).
|
// With the page's coverage recorded when asked for (#390).
|
||||||
const { test, expect } = require('./coverage.ts');
|
import { test, expect } from './coverage.ts';
|
||||||
const { TOKEN } = require('./global-setup');
|
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
|
// 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
|
// 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,
|
// 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.
|
// hashed, and kept under target/ once the test passes. IPX_SIGNALS_TESTS=1 runs them anyway.
|
||||||
const fs = require('node:fs');
|
import fs from 'node:fs';
|
||||||
const path = require('node:path');
|
import path from 'node:path';
|
||||||
const ROOT = path.join(__dirname, '../..');
|
const ROOT = path.join(import.meta.dirname, '../..');
|
||||||
const SIGNALS_CODE = (() => {
|
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 dir of ['crates/ipx/src', 'web/src']) {
|
||||||
for (const f of fs.readdirSync(path.join(ROOT, dir)).sort()) {
|
for (const f of fs.readdirSync(path.join(ROOT, dir)).sort()) {
|
||||||
const text = fs.readFileSync(path.join(ROOT, dir, f), 'utf8');
|
const text = fs.readFileSync(path.join(ROOT, dir, f), 'utf8');
|
||||||
@@ -19,10 +30,10 @@ const SIGNALS_CODE = (() => {
|
|||||||
}
|
}
|
||||||
return h.digest('hex');
|
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',
|
const stamp = path.join(ROOT, 'target/signals-tests',
|
||||||
require('node:crypto').createHash('sha256').update(name).digest('hex').slice(0, 16));
|
crypto.createHash('sha256').update(name).digest('hex').slice(0, 16));
|
||||||
const want = require('node:crypto').createHash('sha256').update(SIGNALS_CODE + fn.toString()).digest('hex');
|
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.
|
// Playwright reads the fixtures a test asks for from its parameters; these ask for the page alone.
|
||||||
test(name, async ({ page }) => {
|
test(name, async ({ page }) => {
|
||||||
let had = '';
|
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.
|
// 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:not(.sigs)')).toHaveCount(5, { timeout: 15_000 });
|
||||||
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
|
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
|
||||||
const errors = [];
|
const errors: string[] = [];
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator('.feed').first()).toBeVisible();
|
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 doc = await page.reload();
|
||||||
const r = await js;
|
const r = await js;
|
||||||
// The page is checked every visit, so it always names the current script...
|
// 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.
|
// ...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(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/);
|
||||||
expect(r.headers()['cache-control']).toContain('immutable');
|
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 root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
|
||||||
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||||
await page.locator('#prefs').click();
|
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('#stheme').selectOption('modern');
|
||||||
await page.locator('#smode').selectOption('auto');
|
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 }) => {
|
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('.feed', { hasText: 'Picture Blog' }).click();
|
||||||
await page.locator('.ep', { hasText: 'An Article' }).click();
|
await page.locator('.ep', { hasText: 'An Article' }).click();
|
||||||
await expect(page.locator('#detail .dt')).toHaveText('An Article');
|
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);
|
expect(lh).toBeCloseTo(1.75, 2);
|
||||||
// Four fifths of the pane, whatever the font (#360).
|
// Four fifths of the pane, whatever the font (#360).
|
||||||
const share = await page.locator('#detail .dbody').evaluate(el => {
|
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)); });
|
return el.getBoundingClientRect().width / (p.clientWidth - parseFloat(c.paddingLeft) - parseFloat(c.paddingRight)); });
|
||||||
expect(share).toBeCloseTo(0.8, 2);
|
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();
|
await expect(page.locator('#scopy')).toBeVisible();
|
||||||
|
|
||||||
// navigator.clipboard is absent over plain http, so the button must not throw.
|
// 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));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
await page.locator('#scopy').click();
|
await page.locator('#scopy').click();
|
||||||
await expect(page.locator('#scopy')).toHaveText(/Copied|Failed/);
|
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 keep.click();
|
||||||
await expect(keep).toHaveAttribute('aria-pressed', 'true');
|
await expect(keep).toHaveAttribute('aria-pressed', 'true');
|
||||||
const kept = await page.evaluate(() => fetch('/api/feeds').then((r) => r.json()));
|
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 keep.click();
|
||||||
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
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 }) => {
|
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 })
|
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]);
|
.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 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';
|
const id = added.id || added.feed?.id || 'markdown-blog';
|
||||||
await page.reload();
|
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).
|
// 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.
|
// The new version is faked by answering / once with another app.js named in it.
|
||||||
async function newVersionOnce(page) {
|
async function newVersionOnce(page: Page) {
|
||||||
await page.route(u => new URL(u).pathname === '/', async route => {
|
await page.route(u => u.pathname === '/', async route => {
|
||||||
const r = await route.fetch();
|
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') });
|
await route.fulfill({ response: r, body: (await r.text()).replace(/\/app\.js\?v=[0-9a-f]+/, '/app.js?v=0000000000ff') });
|
||||||
}, { times: 1 });
|
}, { 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 page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
}
|
}
|
||||||
await expect(first).toBeVisible({ timeout: 20_000 });
|
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 first.click();
|
||||||
await expect(page.locator('#detail .dt')).toHaveText(title);
|
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 }) => {
|
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));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
|
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
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 }) => {
|
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 = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
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 {
|
try {
|
||||||
execFileSync('./target/debug/ipx', ['user', 'add', 'sam'], { input: 'sampassword', env });
|
execFileSync('./target/debug/ipx', ['user', 'add', 'sam'], { input: 'sampassword', env });
|
||||||
} catch (e) {
|
} 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.
|
// 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
|
// 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.
|
// it. Accept the first, decline the second, and the file must survive.
|
||||||
const seen = [];
|
const seen: string[] = [];
|
||||||
page.on('dialog', d => {
|
page.on('dialog', d => {
|
||||||
seen.push(d.message());
|
seen.push(d.message());
|
||||||
if (seen.length === 1) d.accept();
|
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.
|
// 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}$`) }) });
|
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.goto('/admin');
|
||||||
await page.locator('#atabs a', { hasText: 'Accounts' }).click();
|
await page.locator('#atabs a', { hasText: 'Accounts' }).click();
|
||||||
await expect(page).toHaveURL(/\/admin#accounts$/);
|
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();
|
await row.locator('[data-a="admin"]').check();
|
||||||
// Not just the box: it has to have reached the database.
|
// Not just the box: it has to have reached the database.
|
||||||
await expect.poll(async () =>
|
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);
|
).toBe(true);
|
||||||
|
|
||||||
page.once('dialog', d => d.accept());
|
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.
|
// Redrawn from the server, so the box is back.
|
||||||
await expect(userRow(page, 'admin').locator('[data-a="admin"]')).toBeChecked();
|
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);
|
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(),
|
page.locator('#modalCard a[title="Export OPML"]').click(),
|
||||||
]);
|
]);
|
||||||
expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml');
|
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);
|
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.
|
// A feed from an OPML subscription comes back with the OPML itself, not on its own.
|
||||||
expect(out).not.toContain('other.xml');
|
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.
|
// An RSS feed is XML but not OPML: refused in the page, and the picker lets go of it.
|
||||||
await pick.setInputFiles({
|
await pick.setInputFiles({
|
||||||
name: 'feed.xml', mimeType: 'application/xml',
|
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 page.locator('#oimp').click();
|
||||||
await expect(page.locator('.toast.bad', { hasText: 'feed.xml is not an OPML file' })).toBeVisible();
|
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.
|
// Something that gets past the page's quick look is still refused by the server, untouched.
|
||||||
const sneaky = await page.request.post('/api/opml', {
|
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 }) => {
|
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
|
// 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.
|
// the admin's export into a second account did nothing at all.
|
||||||
const { execFileSync } = require('child_process');
|
|
||||||
const setup = require('./global-setup');
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
||||||
@@ -974,7 +981,7 @@ test('an export from one account imports into another', async ({ page, browser }
|
|||||||
try {
|
try {
|
||||||
execFileSync('./target/debug/ipx', ['user', 'add', 'opal'], { input: 'opalpassword', env });
|
execFileSync('./target/debug/ipx', ['user', 'add', 'opal'], { input: 'opalpassword', env });
|
||||||
} catch (e) {
|
} 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 ctx = await browser.newContext();
|
||||||
const opal = await ctx.newPage();
|
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(empty.status()).toBe(200);
|
||||||
expect(await empty.text()).not.toContain('xmlUrl');
|
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 exported = await (await page.request.get('/api/opml')).text(); // the admin's
|
||||||
const urls = urlsIn(exported);
|
const urls = urlsIn(exported);
|
||||||
expect(urls.length).toBeGreaterThan(2);
|
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 () => {
|
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
|
// 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.
|
// 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');
|
const dir = path.join(setup.root, 'cli');
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
|
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(dir, 'config.toml'),
|
fs.writeFileSync(path.join(dir, 'config.toml'),
|
||||||
`[general]\ndownload_dir = "${dir}/downloads"\nsocket = "${dir}/ipx.sock"\n`);
|
`[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 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
|
ipx(['user', 'add', 'boss'], 'bosspassword'); // the first account is the admin
|
||||||
const opml = path.join(dir, 'in.opml');
|
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 }) => {
|
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 = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
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 {
|
try {
|
||||||
execFileSync('./target/debug/ipx', ['user', 'add', 'piper'], { input: 'piperpassword', env });
|
execFileSync('./target/debug/ipx', ['user', 'add', 'piper'], { input: 'piperpassword', env });
|
||||||
} catch (e) {
|
} 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 ctx = await browser.newContext();
|
||||||
const piper = await ctx.newPage();
|
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
|
// 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.
|
// OPML's feeds in place of the OPML in both.
|
||||||
const dir = await (await piper.request.get('/api/directory')).json();
|
const dir: DirFeed[] = await (await piper.request.get('/api/directory')).json();
|
||||||
const top = await (await piper.request.get('/api/popular')).json();
|
const top: DirFeed[] = await (await piper.request.get('/api/popular')).json();
|
||||||
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
||||||
expect(names).toEqual([...names].sort());
|
expect(names).toEqual([...names].sort());
|
||||||
// The directory's ids, not this server's: it names a feed by its address (#205).
|
// The directory's ids, not this server's: it names a feed by its address (#205).
|
||||||
const ids = dir.map(p => p.id);
|
const ids = dir.map(p => p.id);
|
||||||
const titles = dir.map(p => p.title);
|
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).not.toContain('Test Subscriptions');
|
||||||
expect(titles).toEqual(expect.arrayContaining(['Grouped Show', 'Aardvark Radio']));
|
expect(titles).toEqual(expect.arrayContaining(['Grouped Show', 'Aardvark Radio']));
|
||||||
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
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`);
|
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||||
|
|
||||||
// Then the categories, as tiles, each a page of its own.
|
// 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');
|
const tiles = piper.locator('#dtiles .tile');
|
||||||
expect(byTitle('Test Show')).toMatchObject({ podcast: true, category: 'Technology' });
|
expect(byTitle('Test Show')).toMatchObject({ podcast: true, category: 'Technology' });
|
||||||
expect(byTitle('Picture Blog')).toMatchObject({ podcast: false });
|
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();
|
await expect(cat('Technology')).toBeFocused();
|
||||||
|
|
||||||
// What a feed is holds on every page. No blog here names Technology, so Blogs has no such tile.
|
// 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 kind('Blogs').click();
|
||||||
await expect(kind('Blogs')).toHaveAttribute('aria-pressed', 'true');
|
await expect(kind('Blogs')).toHaveAttribute('aria-pressed', 'true');
|
||||||
await expect(cat('Technology')).toHaveCount(0);
|
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: /Test Subscriptions/ })).toHaveCount(0);
|
||||||
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).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).
|
// 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 expect(tiles.first()).toContainText(byName[0]);
|
||||||
await piper.locator('#dirsort').selectOption('za');
|
await piper.locator('#dirsort').selectOption('za');
|
||||||
await expect(tiles.first()).toContainText(byName[byName.length - 1]);
|
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.filter({ hasText: 'Test Show' })).toBeFocused();
|
||||||
await expect(tiles).toHaveCount(dir.length);
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
// Only a listed feed has one, and it carries no file or its address.
|
// 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(preview).toContain('First Episode');
|
||||||
expect(JSON.parse(preview).description).toBe('A fixture feed.');
|
expect(JSON.parse(preview).description).toBe('A fixture feed.');
|
||||||
expect(preview).not.toContain('.mp3');
|
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).
|
// 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/test-show/entries')).status()).toBe(404);
|
||||||
expect((await piper.request.get('/api/feeds/paid-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);
|
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(file).toBeTruthy();
|
||||||
expect((await page.request.get(`/media/${file.id}`)).status()).toBe(200);
|
expect((await page.request.get(`/media/${file.id}`)).status()).toBe(200);
|
||||||
expect((await piper.request.get(`/media/${file.id}`)).status()).toBe(404);
|
expect((await piper.request.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();
|
await piper.locator('#modalCard button[title="Cancel"]').click();
|
||||||
|
|
||||||
const row = async () =>
|
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();
|
const before = await row();
|
||||||
expect(before).toMatchObject({ subscribed: false, feed: null });
|
expect(before).toMatchObject({ subscribed: false, feed: null });
|
||||||
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
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 kept = await page.evaluate(async () => {
|
||||||
const frames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
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.
|
// 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();
|
patchFeed(row); await frames();
|
||||||
const rows = [...document.querySelectorAll('#feedlist .feed')];
|
const rows = [...document.querySelectorAll('#feedlist .feed')];
|
||||||
const before = [...document.querySelectorAll('#feedlist .feed img')];
|
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 }) => {
|
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 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).
|
// 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
|
// 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.
|
// 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);
|
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 expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
||||||
await daemonWrites('Comedy');
|
await daemonWrites('Comedy');
|
||||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Command Line Show');
|
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 unread.click();
|
||||||
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
||||||
await expect(page.locator('.ep').first()).toBeVisible();
|
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()); });
|
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 page.locator('.ep').first().click(); // opening an item reads it
|
||||||
await expect(badge).toHaveText(String(before - 1));
|
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: 'A Title' }),
|
||||||
entryName({ title: ' ', description: '<p>I like the way <b>AI</b> is evolving.</p><img src="x.png">' }),
|
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({ 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 }),
|
entryName({ feed_id: 'nobody', published: 0 }),
|
||||||
]);
|
]);
|
||||||
expect(names[0]).toEqual({ text: 'A Title', derived: false });
|
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 }) => {
|
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.
|
// 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();
|
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.
|
// 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
|
// 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.
|
// a change made from the toolbar.
|
||||||
await page.locator('.ep').first().click();
|
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}"]`))];
|
await icon(page.locator(`#detail [data-a="${a}"]`))];
|
||||||
for (const a of ['read', 'flag']) { const [tb, own] = await pair(a); expect(tb).toBe(own); }
|
for (const a of ['read', 'flag']) { const [tb, own] = await pair(a); expect(tb).toBe(own); }
|
||||||
const [kept] = await pair('flag');
|
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 }) => {
|
test('the item table sorts by any column, both ways, and remembers', async ({ page }) => {
|
||||||
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
|
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
|
||||||
await all.click();
|
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();
|
const titles = () => page.locator('#eps .ep .t').allTextContents();
|
||||||
// Byte order on lower case, which is what SQLite gives for lower(...).
|
// 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 cmp = (a: string, b: string) => (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 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 });
|
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
|
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');
|
const rows = page.locator('#eps .ep');
|
||||||
await expect(rows.nth(1)).toBeVisible({ timeout: 20_000 });
|
await expect(rows.nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
const sel = page.locator('#eps .ep.sel');
|
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 page.keyboard.press('j');
|
||||||
await expect(sel).toHaveAttribute('data-guid', await guid(0));
|
await expect(sel).toHaveAttribute('data-guid', await guid(0));
|
||||||
await page.keyboard.press('j');
|
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');
|
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.
|
// 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();
|
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);
|
expect((await request.get('/api/feeds', as(token))).status()).toBe(200);
|
||||||
// A wrong one is turned away, not let through as anyone.
|
// 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);
|
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.
|
// Only the hash is kept: the list never carries the token.
|
||||||
const listed = await page.evaluate(() => api('/api/tokens'));
|
const listed = await page.evaluate(() => api('/api/tokens'));
|
||||||
expect(JSON.stringify(listed)).not.toContain(token);
|
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.
|
// Revoked, it opens nothing.
|
||||||
await page.locator('.tokrow', { hasText: 'test agent' }).locator('[data-tok]').click();
|
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 }) => {
|
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]);
|
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
|
// 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.
|
// 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');
|
await patch('picture-blog', 'Visual Arts');
|
||||||
// Apple's subcategory, under its category, as the Directory browses them (#118).
|
// Apple's subcategory, under its category, as the Directory browses them (#118).
|
||||||
await expect.poll(() => listed('Picture Blog'), { timeout: 20_000 })
|
await expect.poll(() => listed('Picture Blog'), { timeout: 20_000 })
|
||||||
.toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
|
.toMatchObject({ podcast: false, category: 'Arts', subcategory: 'Visual Arts' });
|
||||||
// A category's subcategories are on its own page, as Apple's directory has them.
|
// 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 page.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
await expect(page.locator('#directory .pills')).toHaveCount(0);
|
await expect(page.locator('#directory .pills')).toHaveCount(0);
|
||||||
await page.locator('#directory .cat', { hasText: /^Arts\d/ }).click();
|
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);
|
await expect(page.locator('#dtiles .tile')).toHaveCount(1);
|
||||||
// A feed's own iTunes category wins over one given here.
|
// A feed's own iTunes category wins over one given here.
|
||||||
await patch('test-show', 'Comedy');
|
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);
|
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 }) => {
|
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 expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
||||||
|
|
||||||
await page.goto('/admin#directory');
|
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('#dtoken').fill('not-the-token');
|
||||||
await page.locator('#dsave').click();
|
await page.locator('#dsave').click();
|
||||||
await expect(page.locator('#dstatus')).toContainText('does not accept this server');
|
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 page.locator('#dsave').click();
|
||||||
await expect(page.locator('#dstatus')).toContainText('Connected as test');
|
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 page.locator('#ssave').click();
|
||||||
await expect.poll(listed, { timeout: 20_000 }).not.toContain('Multi Show');
|
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.
|
// 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 });
|
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 page.evaluate(() => api('/api/feeds/multi-show', { method: 'PATCH', body: JSON.stringify({ private: false }) }));
|
||||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
await expect.poll(listed, { timeout: 20_000 }).toContain('Multi Show');
|
||||||
// A feed whose address carries a key is private whatever it is marked.
|
// 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);
|
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 }) => {
|
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);
|
const listed = async () => (await all()).map(p => p.title);
|
||||||
await expect.poll(listed, { timeout: 20_000 }).toContain('Aardvark Radio');
|
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.
|
// 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` };
|
const env = { ...process.env, IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_DATA_DIR: `${setup.root}/data` };
|
||||||
try {
|
try {
|
||||||
execFileSync('./target/debug/ipx', ['user', 'add', 'piper'], { input: 'piperpassword', env });
|
execFileSync('./target/debug/ipx', ['user', 'add', 'piper'], { input: 'piperpassword', env });
|
||||||
} catch (e) {
|
} 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 ctx = await browser.newContext();
|
||||||
const piper = await ctx.newPage();
|
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
|
// 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.
|
// admin here subscribes to every fixture, so their tiles open their own feeds instead.
|
||||||
const users = await page.evaluate(() => api('/api/users'));
|
const users = await page.evaluate(() => api('/api/users'));
|
||||||
const pid = users.find(u => u.name === 'piper').id;
|
const pid = users.find((u: Account) => u.name === 'piper').id;
|
||||||
const setAdmin = admin => page.evaluate(([id, admin]) => api(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify({ admin }) }), [pid, admin]);
|
const setAdmin = (admin: boolean) => page.evaluate(([id, admin]) => api(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify({ admin }) }), [pid, admin]);
|
||||||
await setAdmin(true);
|
await setAdmin(true);
|
||||||
try {
|
try {
|
||||||
await piper.goto('/');
|
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(),
|
rowCell: await page.locator('#eps .ep .fl').first().boundingBox(),
|
||||||
rowIcon: await page.locator('#eps .ep .fl svg').first().boundingBox(),
|
rowIcon: await page.locator('#eps .ep .fl svg').first().boundingBox(),
|
||||||
};
|
};
|
||||||
const mid = b => b.x + b.width / 2;
|
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);
|
expect(Math.abs(mid(boxes.headIcon!) - mid(boxes.rowIcon!)), JSON.stringify(boxes)).toBeLessThan(1);
|
||||||
|
|
||||||
// From ipx itself, not a font service.
|
// From ipx itself, not a font service.
|
||||||
expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2');
|
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();
|
await expect(del).toBeInViewport();
|
||||||
// Below a long set of notes it was screens down and looked missing (issue #21).
|
// Below a long set of notes it was screens down and looked missing (issue #21).
|
||||||
expect(await page.locator('#detail').evaluate(d =>
|
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);
|
.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
|
// 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.
|
// inside a folder that failed its last check.
|
||||||
const solo = await page.evaluate(() => {
|
const solo = await page.evaluate(() => {
|
||||||
S.feeds.find(f => f.group).last_error = 'HTTP 404';
|
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));
|
const f = S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id))!;
|
||||||
f.last_error = 'HTTP 404 Not Found';
|
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();
|
renderFeeds();
|
||||||
return f.id;
|
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
|
// 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
|
// 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).
|
// 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 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])];
|
const sent = [touch('touchStart', [from])];
|
||||||
for (let i = 1; i <= steps; i++)
|
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 }]));
|
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 }) => {
|
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.
|
// 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); });
|
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('#burger').click();
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).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('#burger').click();
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
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');
|
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 });
|
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 });
|
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();
|
const group = page.locator('.feed.group').first();
|
||||||
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
||||||
const child = page.locator('.feed.child').first();
|
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 child.click();
|
||||||
await page.locator('#content .acts [data-a="pin"]').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).
|
// went by; the spinner comes once the check has taken a moment (#232).
|
||||||
const atOnce = await page.evaluate(() => {
|
const atOnce = await page.evaluate(() => {
|
||||||
setScanning('test-show', true);
|
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);
|
expect(atOnce, 'no spinner the moment a check starts').toBe(false);
|
||||||
await expect(row).toHaveClass(/\bscanning\b/);
|
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 }) => {
|
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('.feed', { hasText: 'Test Show' }).click();
|
||||||
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
await page.locator('#content .acts [data-a="share"]').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 }) => {
|
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();
|
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';
|
const btn = '.fhead [data-a=scan] .i';
|
||||||
await expect(page.locator(btn)).toBeVisible();
|
await expect(page.locator(btn)).toBeVisible();
|
||||||
expect(await spin(btn)).toBe('none');
|
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).
|
// The header was drawn once, on opening, and kept a cleared error until reopened (#316).
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
const head = page.locator('.fhead .meta');
|
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 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('.fail')).toHaveCount(0);
|
||||||
await expect(head.locator('.stat')).toContainText('99 items');
|
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]);
|
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.slice(0, 2)).toEqual(['sticky', 0]);
|
||||||
expect(bar[2]).toBe(await page.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
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.
|
// 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']) {
|
for (const mode of ['dark', 'light']) {
|
||||||
const [c, tc] = await page.evaluate(m => { setTheme('glass', m);
|
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(c).toMatch(/^rgb\(/);
|
||||||
expect(tc).toBe(c);
|
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).
|
// background: All Subscriptions went on counting an item read elsewhere as unread (#168).
|
||||||
const badge = page.locator('.place[data-id=":all"] .badge');
|
const badge = page.locator('.place[data-id=":all"] .badge');
|
||||||
const [one] = (await (await page.request.get('/api/entries?limit=1')).json()).entries;
|
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 flag(false);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
const before = Number(await badge.textContent());
|
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
|
// 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.
|
// 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` };
|
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 }); }
|
try { execFileSync('./target/debug/ipx', ['user', 'add', 'quinn'], { input: 'quinnpassword', env }); }
|
||||||
catch (e) { if (!String(e.stderr || e.stdout).includes('already exists')) throw e; }
|
catch (e) { if (!said(e).includes('already exists')) throw e; }
|
||||||
const ctx = await page.context().browser().newContext();
|
const ctx = await page.context().browser()!.newContext();
|
||||||
const quinn = await ctx.newPage();
|
const quinn = await ctx.newPage();
|
||||||
await quinn.goto('/login');
|
await quinn.goto('/login');
|
||||||
await quinn.locator('#name').fill('quinn');
|
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.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } });
|
||||||
await quinn.request.patch(`/api/feeds/${encodeURIComponent(id)}`, { data: { deadair: true } });
|
await quinn.request.patch(`/api/feeds/${encodeURIComponent(id)}`, { data: { deadair: true } });
|
||||||
const enc = (await entry()).enclosures[0].id;
|
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() }; };
|
return { status: r.status(), cache: r.headers()['cache-control'], body: await r.text() }; };
|
||||||
const [mine, theirs] = await Promise.all([fetchList(page), fetchList(quinn)]);
|
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.status, theirs.status]).toEqual([200, 200]);
|
||||||
expect([mine.cache, theirs.cache]).toEqual(['no-store', 'no-store']);
|
expect([mine.cache, theirs.cache]).toEqual(['no-store', 'no-store']);
|
||||||
expect(mine.body).not.toBe(theirs.body);
|
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.postDataJSON().ms).toBeGreaterThan(0);
|
||||||
expect(report.headers().traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
|
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).
|
// 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()));
|
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');
|
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
|
||||||
// Off from the player: the original, from the same place.
|
// Off from the player: the original, from the same place.
|
||||||
await page.locator('#pdead').click();
|
await page.locator('#pdead').click();
|
||||||
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'false');
|
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();
|
expect((await entry()).enclosures[0].deadair).toBeUndefined();
|
||||||
|
|
||||||
await page.locator('#pclose').click();
|
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.setViewportSize({ width: 1280, height: 800 });
|
||||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||||
await expect(page.locator('.ep').first()).toBeVisible();
|
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();
|
const box = page.locator('#files .encbox').first();
|
||||||
await expect(box.locator('.btn')).toHaveCount(4);
|
await expect(box.locator('.btn')).toHaveCount(4);
|
||||||
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
|
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);
|
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 signals = async () => (await (await page.request.get('/api/signals')).json());
|
||||||
const sg = (await signals()).find(s => s.name === 'F1');
|
const sg = (await signals()).find((s: Signal) => s.name === 'F1');
|
||||||
const shown = async () => (await (await page.request.get(`/api/signals/${sg.id}/entries`)).json()).entries.map(e => e.title);
|
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 expect.poll(shown, { timeout: 15_000 }).toEqual(['Monaco qualifying']);
|
||||||
|
|
||||||
await page.reload();
|
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 page.locator('.ep', { hasText: 'Monaco qualifying' }).click();
|
||||||
await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read,
|
await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read,
|
||||||
{ timeout: 5_000 }).toBe(true);
|
{ 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).
|
// And the sidebar says so without a reload: it kept saying 1 (#258).
|
||||||
await expect(row.locator('.badge')).toHaveText('0');
|
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 } });
|
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();
|
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json();
|
||||||
await page.reload();
|
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 expect(page.locator('#content a[href="https://typesafe.ai"]')).toBeVisible();
|
||||||
await page.locator('#content [data-keylink]').click();
|
await page.locator('#content [data-keylink]').click();
|
||||||
await expect(page.locator('#stskey')).toBeFocused();
|
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');
|
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: 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 { 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 expect.poll(shown, { timeout: 15_000 }).toEqual(['Baking bread']);
|
||||||
await page.request.delete(`/api/signals/${id}`);
|
await page.request.delete(`/api/signals/${id}`);
|
||||||
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
|
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++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
await page.keyboard.press('Tab');
|
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.
|
// 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 page.keyboard.press('Escape');
|
||||||
await expect(page.locator('#modal.on')).toBeHidden();
|
await expect(page.locator('#modal.on')).toBeHidden();
|
||||||
await expect(page.locator('#addFeed')).toBeFocused();
|
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 }) => {
|
test('an item opens from the keyboard, and its row actions show while focused', async ({ page }) => {
|
||||||
@@ -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
|
// 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
|
// 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.
|
// target/coverage/page. Without it, `test` is Playwright's own.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// Serves the fixture feeds so the daemon under test has something real to scan.
|
// Serves the fixture feeds so the daemon under test has something real to scan.
|
||||||
const http = require('http');
|
import http from 'node:http';
|
||||||
const fs = require('fs');
|
import fs from 'node:fs';
|
||||||
const path = require('path');
|
import path from 'node:path';
|
||||||
|
|
||||||
const dir = __dirname;
|
const dir = import.meta.dirname;
|
||||||
const port = Number(process.env.FIXTURE_PORT || 8792);
|
const port = Number(process.env.FIXTURE_PORT || 8792);
|
||||||
|
|
||||||
http.createServer((req, res) => {
|
http.createServer((req, res) => {
|
||||||
@@ -18,13 +18,14 @@ http.createServer((req, res) => {
|
|||||||
res.writeHead(401, { 'content-type': 'application/json' }).end('{"detail":"invalid api key"}');
|
res.writeHead(401, { 'content-type': 'application/json' }).end('{"detail":"invalid api key"}');
|
||||||
return;
|
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')) {
|
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
||||||
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
|
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const title = String(state).toLowerCase();
|
const title = String(state).toLowerCase();
|
||||||
const answers = {};
|
const answers: Record<string, { noul: number }> = {};
|
||||||
for (const [id, q] of Object.entries(questions)) {
|
for (const [id, q] of Object.entries(questions)) {
|
||||||
// A key's check asks about no Signal's words.
|
// A key's check asks about no Signal's words.
|
||||||
const words = /described by these words: ([^.]*)\./.exec(q.instructions)?.[1].split(', ') ?? [];
|
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.
|
// Dated today, since a Signal looks at the last 30 days only.
|
||||||
if (req.url === '/signal.xml') {
|
if (req.url === '/signal.xml') {
|
||||||
const now = new Date().toUTCString();
|
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>
|
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>`);
|
<title>Signal Show</title>${item('sig-1', 'Monaco qualifying')}${item('sig-2', 'Baking bread')}</channel></rss>`);
|
||||||
return;
|
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));
|
const file = path.join(dir, path.basename(name));
|
||||||
fs.readFile(file, (err, body) => {
|
fs.readFile(file, (err, body) => {
|
||||||
if (err) { res.writeHead(404).end('no'); return; }
|
if (err) { res.writeHead(404).end('no'); return; }
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// Builds a scratch config and data dir so the browser tests drive a real daemon with
|
// 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.
|
// known feeds, rather than whatever happens to be on the machine.
|
||||||
const fs = require('fs');
|
import fs from 'node:fs';
|
||||||
const path = require('path');
|
import path from 'node:path';
|
||||||
const os = require('os');
|
import os from 'node:os';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
const root = path.join(os.tmpdir(), 'ipx-ui-test');
|
const root = path.join(os.tmpdir(), 'ipx-ui-test');
|
||||||
const TOKEN = 'testtokentesttokentesttoken12345'; // fixed, so tests need not scrape a log
|
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 = 'http://127.0.0.1:8795';
|
||||||
const DIRECTORY_TOKEN = 'ipxd_testdirectorytoken';
|
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
|
// 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.
|
// 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
|
// Playwright imports this config again in every worker process, so prepare() runs more
|
||||||
@@ -83,8 +84,8 @@ fetch_internal = ["127.0.0.1"]
|
|||||||
[[tokens]]
|
[[tokens]]
|
||||||
name = "test"
|
name = "test"
|
||||||
access = "rwa"
|
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 };
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// iPX without a connection (#389): the service worker (web/sw/sw.ts) keeps the page, what it read
|
// 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.
|
// 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.
|
// feeds do not expect.
|
||||||
|
|
||||||
import type { BrowserContext, Page, Worker } from '@playwright/test';
|
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';
|
import { expect, test } from './coverage.ts';
|
||||||
|
|
||||||
// The suite blocks the worker, which answers before page.route can; these tests are about it.
|
// The suite blocks the worker, which answers before page.route can; these tests are about it.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
// Type-checks web/src (npx tsc). Nothing is emitted: web/build.mjs does that with swc.
|
// Type-checks web/src (npx tsc). Nothing is emitted: web/build.mts does that with swc.
|
||||||
// The files are one script in one scope, not modules, which is why there are no imports.
|
// The files are one script in one scope, not modules, which is why there are no imports.
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
@@ -10,5 +10,5 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["web/src/*.ts"]
|
"include": ["web/src/*.ts", "web/boot.ts", "crates/directory/src/site.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
19
tsconfig.k6.json
Normal file
19
tsconfig.k6.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
// Type-checks the load tests' k6 scripts (#392), which run in k6's own JavaScript runtime, not
|
||||||
|
// Node's: its modules and globals (__ENV, __VU) come from @types/k6, and k6 strips the types
|
||||||
|
// itself. tests/load/run.ts, the harness, is Node's and in tsconfig.tools.json.
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"lib": ["es2022"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["k6"]
|
||||||
|
},
|
||||||
|
"include": ["tests/load/*.ts"],
|
||||||
|
"exclude": ["tests/load/run.ts"]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
// Type-checks the service worker (web/sw), apart from the page: a worker's globals are not a
|
// Type-checks the service worker (web/sw), apart from the page: a worker's globals are not a
|
||||||
// page's, and the two libraries cannot be in one program (#389). Nothing is emitted: web/build.mjs
|
// page's, and the two libraries cannot be in one program (#389). Nothing is emitted: web/build.mts
|
||||||
// does that with swc, as for the page.
|
// does that with swc, as for the page.
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
// Type-checks the TypeScript that runs in Node rather than in a page or worker: the browser
|
// Type-checks the TypeScript that runs in Node rather than in a page, a worker or k6: the tests,
|
||||||
// tests' own files and the build's helpers (#390). Node runs them by stripping the types, so the
|
// the build and the home page's generator (#390, #392). Node runs them by stripping the types, so the
|
||||||
// imports name their .ts files, and nothing is emitted.
|
// imports name their .ts files, and nothing is emitted.
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
@@ -8,12 +8,12 @@
|
|||||||
"module": "nodenext",
|
"module": "nodenext",
|
||||||
"moduleResolution": "nodenext",
|
"moduleResolution": "nodenext",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
// global-setup.js and the other CommonJS files are read for their exports, not checked.
|
|
||||||
"allowJs": true,
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
// Only what Node can strip: no enums, namespaces or constructor parameter properties.
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["tests/ui/*.ts", "web/*.mts"]
|
"include": ["target/page-types/web/src/*.d.ts", "tests/*.ts", "tests/ui/**/*.ts", "tests/load/run.ts", "web/*.mts", "site/*.mts", "playwright.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ http://{$WEB_BIND::8080} {
|
|||||||
@compress not path /api/events /media/*
|
@compress not path /api/events /media/*
|
||||||
encode @compress zstd gzip
|
encode @compress zstd gzip
|
||||||
|
|
||||||
# The copies web/build.mjs compressed are served in place of their file, never asked for by
|
# The copies web/build.mts compressed are served in place of their file, never asked for by
|
||||||
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
|
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
|
||||||
# admins (#253).
|
# admins (#253).
|
||||||
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
|
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
|
||||||
@@ -137,7 +137,7 @@ http://{$WEB_BIND::8080} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
|
# Scripts and the stylesheet are named by a hash of their contents (web/build.mts), so a browser
|
||||||
# keeps them a year; the icons a day, under names that do not change.
|
# keeps them a year; the icons a day, under names that do not change.
|
||||||
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
|
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
|
||||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
/* The edge of a text box or a switch: the theme's own text mixed into its ground, as far as it
|
/* The edge of a text box or a switch: the theme's own text mixed into its ground, as far as it
|
||||||
takes to clear 3:1 on every surface one sits on (#305, #308). --line is for rules between
|
takes to clear 3:1 on every surface one sits on (#305, #308). --line is for rules between
|
||||||
things, which need not, and a box drawn in it all but vanished. A theme whose grounds sit
|
things, which need not, and a box drawn in it all but vanished. A theme whose grounds sit
|
||||||
further apart takes more; tests/contrast.js holds every palette to it. */
|
further apart takes more; tests/contrast.ts holds every palette to it. */
|
||||||
--fieldmix:60%;
|
--fieldmix:60%;
|
||||||
--field:color-mix(in oklab,var(--fg) var(--fieldmix),var(--bg));
|
--field:color-mix(in oklab,var(--fg) var(--fieldmix),var(--bg));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// Runs in <head>, before the first frame (#203): this browser's theme and font (#359), from their cookies, and
|
|
||||||
// whether the last visit here was an admin's, so the link to Admin is there from the start. The
|
|
||||||
// server wrote both into the page while it served it; the page is a static file now.
|
|
||||||
(()=>{const r=document.documentElement,c=/(?:^|; )ipx_theme=([a-z]{1,20})\.(light|dark|auto)(?:;|$)/.exec(document.cookie);
|
|
||||||
if(c){r.dataset.theme=c[1];r.dataset.choice=c[2];if(c[2]!=='auto')r.dataset.mode=c[2];}
|
|
||||||
const f=/(?:^|; )ipx_font=([a-z]{1,10})(?:;|$)/.exec(document.cookie);if(f)r.dataset.font=f[1];
|
|
||||||
const g=/(?:^|; )ipx_rfont=([a-z]{1,10})(?:;|$)/.exec(document.cookie);if(g)r.dataset.rfont=g[1];
|
|
||||||
try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})();
|
|
||||||
19
web/boot.ts
Normal file
19
web/boot.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Runs in <head>, before the first frame (#203): this browser's theme and font (#359), from their
|
||||||
|
// cookies, and whether the last visit here was an admin's, so the link to Admin is there from the
|
||||||
|
// start. The server wrote both into the page while it served it; the page is a static file now.
|
||||||
|
// A script of its own, not part of app.js, which loads after the markup (web/build.mts).
|
||||||
|
(() => {
|
||||||
|
const r = document.documentElement;
|
||||||
|
const cookie = (re: RegExp) => re.exec(document.cookie);
|
||||||
|
const theme = cookie(/(?:^|; )ipx_theme=([a-z]{1,20})\.(light|dark|auto)(?:;|$)/);
|
||||||
|
if (theme) {
|
||||||
|
r.dataset.theme = theme[1];
|
||||||
|
r.dataset.choice = theme[2];
|
||||||
|
if (theme[2] !== 'auto') r.dataset.mode = theme[2];
|
||||||
|
}
|
||||||
|
const font = cookie(/(?:^|; )ipx_font=([a-z]{1,10})(?:;|$)/);
|
||||||
|
if (font) r.dataset.font = font[1];
|
||||||
|
const rfont = cookie(/(?:^|; )ipx_rfont=([a-z]{1,10})(?:;|$)/);
|
||||||
|
if (rfont) r.dataset.rfont = rfont[1];
|
||||||
|
try { if (localStorage.getItem('ipx.admin') === '1') r.dataset.admin = ''; } catch { /* storage refused */ }
|
||||||
|
})();
|
||||||
@@ -8,28 +8,27 @@
|
|||||||
// URL, and the page, which the browser checks on every visit, is what carries it. Caddy's file
|
// URL, and the page, which the browser checks on every visit, is what carries it. Caddy's file
|
||||||
// server ignores the query.
|
// server ignores the query.
|
||||||
//
|
//
|
||||||
// node web/build.mjs [out-dir] default out-dir: web/dist
|
// node web/build.mts [out-dir] default out-dir: web/dist
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import zlib from 'node:zlib';
|
import zlib from 'node:zlib';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import swc from '@swc/core';
|
import swc from '@swc/core';
|
||||||
import html from '@swc/html';
|
import html from '@swc/html';
|
||||||
|
import { concatMap } from './concat-map.mts';
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
// A build for measuring what the browser tests run (#390), IPX_COVERAGE=1: the script not minified
|
// A build for measuring what the browser tests run (#390), IPX_COVERAGE=1: the script not minified
|
||||||
// and with a source map back to each file of web/src, which concat-map.mts makes. Loaded only then:
|
// and with a source map back to each file of web/src, which concat-map.mts makes. Loaded only then:
|
||||||
// it is TypeScript, which the Node of Dockerfile.web does not run.
|
// it is TypeScript, which the Node of Dockerfile.web does not run.
|
||||||
// Required, not imported with a top-level await, which would stop the tests require()ing this file.
|
const coverageMap = process.env.IPX_COVERAGE ? concatMap : null;
|
||||||
const coverageMap = process.env.IPX_COVERAGE ? createRequire(import.meta.url)('./concat-map.mts').concatMap : null;
|
|
||||||
// The page's own version (package.json), given to its script in place of __WEB_VERSION__.
|
// The page's own version (package.json), given to its script in place of __WEB_VERSION__.
|
||||||
const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
|
const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
|
||||||
|
|
||||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||||
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||||
const PAGES = {
|
const PAGES: Record<string, { script: string, src: string[] }> = {
|
||||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'offline', 'events', 'native'] },
|
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'offline', 'events', 'native'] },
|
||||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||||
'login.html': { script: 'login.js', src: ['login'] },
|
'login.html': { script: 'login.js', src: ['login'] },
|
||||||
@@ -37,7 +36,7 @@ const PAGES = {
|
|||||||
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
|
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
|
||||||
const STYLE = 'app.css';
|
const STYLE = 'app.css';
|
||||||
|
|
||||||
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
const hash = (s: string | Buffer) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
||||||
|
|
||||||
// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181). Served as it comes,
|
// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181). Served as it comes,
|
||||||
// by a name with its hash in it, which the page's script is given in place of __HLS_JS__.
|
// by a name with its hash in it, which the page's script is given in place of __HLS_JS__.
|
||||||
@@ -79,20 +78,32 @@ export function buildStyle({ minify = true } = {}) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The offline service worker (#389), web/sw/sw.ts made JavaScript, at the top of the site.
|
/// A script served on its own, not one of the pages' (#392): TypeScript made JavaScript.
|
||||||
export function buildWorker({ minify = true } = {}) {
|
function compile(file: string, { minify = true } = {}): string {
|
||||||
const src = fs.readFileSync(path.join(here, 'sw', 'sw.ts'), 'utf8');
|
const src = fs.readFileSync(path.resolve(here, file), 'utf8');
|
||||||
return swc.transformSync(src, {
|
return swc.transformSync(src, {
|
||||||
filename: 'sw.ts',
|
filename: path.basename(file),
|
||||||
jsc: { parser: { syntax: 'typescript' }, target: 'es2022', minify: minify ? { compress: true, mangle: true } : undefined },
|
jsc: { parser: { syntax: 'typescript' }, target: 'es2022', minify: minify ? { compress: true, mangle: true } : undefined },
|
||||||
isModule: false,
|
isModule: false,
|
||||||
minify,
|
minify,
|
||||||
}).code;
|
}).code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The offline service worker (#389), at the top of the site.
|
||||||
|
export const buildWorker = (o?: { minify?: boolean }) => compile('sw/sw.ts', o);
|
||||||
|
/// The directory's one script (#392), as crates/directory/src/site.js holds it: readable, since it
|
||||||
|
/// is committed, and saying where it comes from.
|
||||||
|
export const buildDirectoryScript = () =>
|
||||||
|
'// Made from site.ts by `node web/build.mts --directory-script`: edit that, not this.\n' +
|
||||||
|
compile('../crates/directory/src/site.ts', { minify: false });
|
||||||
|
/// What runs in <head> before the first frame (boot.ts), named by a hash of what is served.
|
||||||
|
const BOOT = compile('boot.ts');
|
||||||
|
|
||||||
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
||||||
export function buildPage(name, { minify = !coverageMap } = {}) {
|
export function buildPage(name: string, { minify = !coverageMap } = {}) {
|
||||||
const { script, src: files } = PAGES[name];
|
const spec = PAGES[name];
|
||||||
|
if (!spec) throw new Error(`${name} is not one of the pages`);
|
||||||
|
const { script, src: files } = spec;
|
||||||
const parts = files.map(f => ({ name: `web/src/${f}.ts`, text: fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8') }));
|
const parts = files.map(f => ({ name: `web/src/${f}.ts`, text: fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8') }));
|
||||||
const src = parts.map(p => p.text).join('\n')
|
const src = parts.map(p => p.text).join('\n')
|
||||||
.replaceAll('__HLS_JS__', HLS_URL)
|
.replaceAll('__HLS_JS__', HLS_URL)
|
||||||
@@ -123,7 +134,7 @@ export function buildPage(name, { minify = !coverageMap } = {}) {
|
|||||||
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
||||||
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
||||||
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg|boot\.js)"/g,
|
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg|boot\.js)"/g,
|
||||||
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
(_, attr, file) => `${attr}="/${file}?v=${hash(file === 'boot.js' ? BOOT : fs.readFileSync(path.join(here, file)))}"`);
|
||||||
if (!minify) return { html: out, js, script };
|
if (!minify) return { html: out, js, script };
|
||||||
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||||
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
||||||
@@ -131,11 +142,14 @@ export function buildPage(name, { minify = !coverageMap } = {}) {
|
|||||||
return { html: r.code, js, script };
|
return { html: r.code, js, script };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === '--directory-script') {
|
||||||
|
fs.writeFileSync(path.join(here, '..', 'crates', 'directory', 'src', 'site.js'), buildDirectoryScript());
|
||||||
|
} else if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
const out = process.argv[2] || path.join(here, 'dist');
|
const out = process.argv[2] || path.join(here, 'dist');
|
||||||
fs.mkdirSync(out, { recursive: true });
|
fs.mkdirSync(out, { recursive: true });
|
||||||
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
||||||
fs.writeFileSync(path.join(out, 'sw.js'), buildWorker());
|
fs.writeFileSync(path.join(out, 'sw.js'), buildWorker());
|
||||||
|
fs.writeFileSync(path.join(out, 'boot.js'), BOOT);
|
||||||
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
|
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
|
||||||
// Served as they are, under the names the pages and the stylesheet use. /favicon.ico is the
|
// Served as they are, under the names the pages and the stylesheet use. /favicon.ico is the
|
||||||
// PNG, for a browser that asks for that on its own; iOS asks for the precomposed touch icon
|
// PNG, for a browser that asks for that on its own; iOS asks for the precomposed touch icon
|
||||||
@@ -146,7 +160,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|||||||
...['signals', 'deadair-on', 'deadair-off'].flatMap(n => [`${n}.svg`, `${n}-dark.svg`]).map(f => [f, f]),
|
...['signals', 'deadair-on', 'deadair-off'].flatMap(n => [`${n}.svg`, `${n}-dark.svg`]).map(f => [f, f]),
|
||||||
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
|
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
|
||||||
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
|
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
|
||||||
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
|
['ipodderx-icon.png', 'icon.png'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
|
||||||
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
||||||
// The fonts Settings offers besides Inter (#359), with their licences.
|
// The fonts Settings offers besides Inter (#359), with their licences.
|
||||||
fs.cpSync(path.join(here, 'fonts'), path.join(out, 'fonts'), { recursive: true });
|
fs.cpSync(path.join(here, 'fonts'), path.join(out, 'fonts'), { recursive: true });
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// A source map for the page's script as web/build.mjs makes it, files joined one after another
|
// A source map for the page's script as web/build.mts makes it, files joined one after another
|
||||||
// (#390): each line of the joined script maps to its line in the file it came from. Given to swc
|
// (#390): each line of the joined script maps to its line in the file it came from. Given to swc
|
||||||
// as the input map, the built script then maps to web/src/*.ts file by file, which is what the
|
// as the input map, the built script then maps to web/src/*.ts file by file, which is what the
|
||||||
// coverage report needs. Only a coverage build uses it.
|
// coverage report needs. Only a coverage build uses it.
|
||||||
@@ -26,7 +26,7 @@ export function vlq(n: number): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The map of `files` joined with '\n' between them, as build.mjs joins them.
|
/// The map of `files` joined with '\n' between them, as build.mts joins them.
|
||||||
export function concatMap(files: { name: string; text: string }[]): SourceMap {
|
export function concatMap(files: { name: string; text: string }[]): SourceMap {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
// Each segment's fields are relative to the one before: source, line in it, column.
|
// Each segment's fields are relative to the one before: source, line in it, column.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// What ipx's API sends the page, as types for the type-check (#235), each named after the Rust
|
// What ipx's API sends the page, as types for the type-check (#235), each named after the Rust
|
||||||
// struct it mirrors in crates/ipx/src. Types only: swc strips this file to nothing, so it is not
|
// struct it mirrors in crates/ipx/src. Types only: swc strips this file to nothing, so it is not
|
||||||
// in web/build.mjs's list; tsconfig.json's glob reads it. A field added there goes here too.
|
// in web/build.mts's list; tsconfig.json's glob reads it. A field added there goes here too.
|
||||||
// An Option is `T | null`, as serde writes None.
|
// An Option is `T | null`, as serde writes None.
|
||||||
|
|
||||||
/// A feed in your list, `/api/feeds` and the live `feed_row` (web.rs FeedRow).
|
/// A feed in your list, `/api/feeds` and the live `feed_row` (web.rs FeedRow).
|
||||||
|
|||||||
Reference in New Issue
Block a user