Coverage for the Rust and the page, and tests for the offline gaps (#390, #391)

Nothing measured what the tests reach. scripts/coverage.sh runs the
cargo tests under cargo-llvm-cov in the ipx-coverage image
(Dockerfile.coverage, built by build-image.sh coverage), in a container
held to the image builder's 6 GB and six threads, one crate at a time,
with the same room and load checks: an instrumented build rebuilds every
dependency, the kind of build that took Tower down on 2026-11-04. The
first run stayed under load 8 with 11 GB free; 69% of lines. web.rs and
daemon.rs read low because the browser suite drives them against a
daemon that is not instrumented.

npm run coverage:page runs the browser suite on a build of the page
(IPX_COVERAGE=1) whose script keeps a source map to each web/src file:
web/concat-map.mts maps the joined files line by line and swc takes it
as its input map. Each test's page records V8's coverage
(tests/ui/coverage.ts, which the specs now take test and expect from),
and monocart-coverage-reports writes it file by file: 73% of lines.
build.mjs requires the map helper only for that build, since
Dockerfile.web's Node does not run TypeScript, and not with a top-level
await, which stopped the CommonJS tests requiring build.mjs.

The offline tests move to offline.spec.ts, in TypeScript, and stand
alone (they add their feed and set the read state they need), with what
#391 found untested: another account signing in clears what was kept, a
position saved offline waits and is sent, and pictures past the budget
go oldest first (keepPictures takes the budget, and trims whether or
not anything new came). The fixture JPEGs were a few bytes no browser
could draw; they are real now, and the artwork test checks one draws.
tsconfig.tools.json type-checks the tests' and build's TypeScript, in
npm run typecheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 01:55:19 +00:00
parent af69b699a8
commit b509ea4b35
19 changed files with 964 additions and 71 deletions

View File

@@ -188,13 +188,15 @@ 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 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
npx tsc -p . # type-checks web/src
npm run -s typecheck # web/src, the service worker, and the tests' and build's TypeScript
node tests/page-smoke.js
node --test tests/page-unit.js # the page's own functions, called directly, in a second
node tests/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA
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
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)
```
**Builds share Tower with everything else, production included.** On 2026-10-16 a build of the
@@ -272,6 +274,18 @@ seeking through files, a flood of wrong passwords. Things about them:
* The Rust tests run on Postgres with the same URL: `. /src/.envrc && cargo test`. Each test
makes a schema `ipxt_<pid>_<n>`, and the next run drops those an earlier one left.
Coverage (#390) says what the tests leave untested; read it before deciding a change is covered.
`scripts/coverage.sh` runs the Rust tests instrumented in the `ipx-coverage` image
(`sh scripts/build-image.sh coverage` makes it), in a container held to the builder's 6 GB and six
threads, one crate at a time, and writes `target/coverage/summary.txt`, `lcov.info` and
`html/`. Its first run rebuilds every dependency instrumented, in a target of its own; later
ones only what changed. It measures the cargo tests alone: the API handlers (`web.rs`) and the daemon run
under the browser suite, whose daemon is not instrumented, so their low numbers there say little.
`npm run coverage:page` is the browser suite on a build of the page that keeps a source map to
each `web/src` file (`IPX_COVERAGE=1`, `web/concat-map.mts`), and writes `target/coverage/page`.
The specs take `test` and `expect` from `tests/ui/coverage.ts`, not from `@playwright/test`, so
every test is counted. The service worker, the unit tests and the native-bridge check are not.
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
`matches_keywords`, `parse_interval`) are the easiest place to put it.

18
Dockerfile.coverage Normal file
View File

@@ -0,0 +1,18 @@
# What scripts/coverage.sh runs the Rust tests in, to see which lines they reach (#390): the
# toolchain, LLVM's coverage tools and cargo-llvm-cov. No source: coverage.sh mounts the tree.
# Built and pushed like the services, by scripts/build-image.sh coverage, under its caps.
#
# The same Rust as the tests are built with elsewhere: a newer one would rebuild everything with
# its own flags for nothing.
FROM rust:1.98.1-slim-trixie
# cc for rusqlite's bundled SQLite and librqbit, as Dockerfile's builder; ffmpeg for DeadAir's
# tests, as the image ipx runs in.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ffmpeg \
&& rm -rf /var/lib/apt/lists/* \
&& rustup component add llvm-tools-preview
# cargo-llvm-cov's own build, as its README gives it, rather than compiling it here.
ADD https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz /tmp/llvm-cov.tar.gz
RUN tar -xzf /tmp/llvm-cov.tar.gz -C /usr/local/cargo/bin && rm /tmp/llvm-cov.tar.gz \
&& cargo llvm-cov --version
WORKDIR /work

552
package-lock.json generated
View File

@@ -15,6 +15,8 @@
},
"devDependencies": {
"@playwright/test": "^1.56.0",
"@types/node": "24.19.2",
"monocart-coverage-reports": "3.2.0",
"typescript": "^7.0.2"
}
},
@@ -27,6 +29,13 @@
"node": ">=6"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
@@ -43,6 +52,26 @@
"node": ">=20"
}
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@swc/core": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz",
@@ -544,6 +573,16 @@
"@swc/counter": "^0.1.3"
}
},
"node_modules/@types/node": {
"version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
@@ -884,12 +923,376 @@
"node": ">=16.20.0"
}
},
"node_modules/acorn": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-loose": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz",
"integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/commander": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.1.0.tgz",
"integrity": "sha512-cgnQ88TRrWcC7pVq46RzJVRMktu3W2RcbnoidnfBsl1bmDCBwJS/udcIZpKoCrbAYYaCqsz1yZCVIgbIaU3ddQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/console-grid": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/console-grid/-/console-grid-2.2.4.tgz",
"integrity": "sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/eight-colors": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/eight-colors/-/eight-colors-1.3.3.tgz",
"integrity": "sha512-4B54S2Qi4pJjeHmCbDIsveQZWQ/TSSQng4ixYJ9/SYHHpeS5nYK0pzcHvWzWUfRsvJQjwoIENhAwqg59thQceg==",
"dev": true,
"license": "MIT"
},
"node_modules/execa": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-10.2.1.tgz",
"integrity": "sha512-/Bofl3ZBh2Q4/PG0W63qFzP5vDAypi8Zc90UWTBzaDNIYiX7apPOd0N6L8hG8CojUdq4V51adu8BObP+iVsSTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"figures": "^6.1.0",
"get-stream": "^9.0.1",
"human-signals": "^8.0.1",
"is-plain-obj": "^4.1.0",
"is-stream": "^4.0.1",
"npm-run-path": "^6.0.0",
"pretty-ms": "^9.3.1",
"signal-exit": "^4.1.0",
"strip-final-newline": "^4.0.0",
"which-command": "^0.1.0",
"yoctocolors": "^2.2.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
"integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-stream": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
"integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sec-ant/readable-stream": "^0.4.1",
"is-stream": "^4.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hls.js": {
"version": "1.6.19",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.19.tgz",
"integrity": "sha512-A//aZeaAgM5rgUCbTURD4gkfyw5Uag2/4Jky7CfDOukL/DN+P5DjbgK2BYteyP3/KnCkPfkGVkbLxQG2T0AbUQ==",
"license": "Apache-2.0"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/human-signals": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
"integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lz-utils": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/lz-utils/-/lz-utils-2.1.1.tgz",
"integrity": "sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/monocart-coverage-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/monocart-coverage-reports/-/monocart-coverage-reports-3.2.0.tgz",
"integrity": "sha512-hF8GxLozQGJ9P2FDjTrGfo/IAvHSlPdmYiBE3qkfbWUc9Zc+I+mrShta9+cJxmZfC5d6FiaEyrhjzCNrpAflog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0",
"acorn": "^8.19.0",
"acorn-loose": "^8.5.2",
"acorn-walk": "^8.3.5",
"commander": "^15.0.0",
"console-grid": "^2.2.4",
"convert-source-map": "^2.0.0",
"eight-colors": "^1.3.3",
"execa": "^10.2.1",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"lz-utils": "^2.1.1",
"monocart-locator": "^1.0.6",
"node-stream-zip": "^1.16.0",
"ws": "^8.22.0",
"yazl": "^3.3.1"
},
"bin": {
"mcr": "lib/cli.js"
},
"engines": {
"node": ">=22.15.0"
}
},
"node_modules/monocart-locator": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/monocart-locator/-/monocart-locator-1.0.6.tgz",
"integrity": "sha512-P4DCDxeOhy59KexF3yx/kD7y64HVxHO0lskxjxAML9kTOvQnD2jTpZWFQFEFyyABx32at3t77mWLKftFfk1wHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/node-stream-zip": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.16.0.tgz",
"integrity": "sha512-ObaRrRoR8T68wF6suxHd7R4XQNamij6ZQHrwG7Dx1D2zeHcDNLsIOBcWrIwtDm7AsCXBguaPHgXhcjxDa2szrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/antelle"
}
},
"node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
"integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
@@ -919,6 +1322,74 @@
"node": ">=20"
}
},
"node_modules/pretty-ms": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz",
"integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"parse-ms": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/semver": {
"version": "7.8.6",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
"integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
"integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@@ -953,6 +1424,87 @@
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/which-command": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz",
"integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==",
"dev": true,
"license": "MIT",
"bin": {
"which-command": "cli.js"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sindresorhus/which-command?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.22.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yazl": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz",
"integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "^1.0.0"
}
},
"node_modules/yoctocolors": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz",
"integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View File

@@ -5,7 +5,8 @@
"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.",
"scripts": {
"build": "node web/build.mjs",
"typecheck": "tsc -p . && tsc -p tsconfig.sw.json",
"typecheck": "tsc -p . && tsc -p tsconfig.sw.json && tsc -p tsconfig.tools.json",
"coverage:page": "IPX_COVERAGE=1 playwright test",
"smoke": "node tests/page-smoke.js",
"test": "playwright test",
"test:headed": "playwright test --headed",
@@ -13,6 +14,8 @@
},
"devDependencies": {
"@playwright/test": "^1.56.0",
"@types/node": "24.19.2",
"monocart-coverage-reports": "3.2.0",
"typescript": "^7.0.2"
},
"dependencies": {

View File

@@ -13,6 +13,9 @@ module.exports = defineConfig({
expect: { timeout: 10_000 },
fullyParallel: false, // one daemon, one database
workers: 1,
// The page's coverage, with IPX_COVERAGE=1 (#390): cleared first, written last.
globalSetup: './tests/ui/coverage-setup.ts',
globalTeardown: './tests/ui/coverage-teardown.ts',
reporter: process.env.CI ? 'line' : [['list']],
use: {
baseURL: 'http://127.0.0.1:8791',
@@ -46,7 +49,7 @@ module.exports = defineConfig({
reuseExistingServer: false,
timeout: 60_000,
stdout: 'ignore',
env: { WEB_BIND: '127.0.0.1:8791', WEB_ROOT: `${setup.root}/web`, IPX_UPSTREAM: '127.0.0.1:8796', HOME: setup.root, XDG_DATA_HOME: setup.root, XDG_CONFIG_HOME: setup.root },
env: { IPX_COVERAGE: process.env.IPX_COVERAGE ?? '', WEB_BIND: '127.0.0.1:8791', WEB_ROOT: `${setup.root}/web`, IPX_UPSTREAM: '127.0.0.1:8796', HOME: setup.root, XDG_DATA_HOME: setup.root, XDG_CONFIG_HOME: setup.root },
},
{
// Build first so the tests always run against current source.

View File

@@ -1,7 +1,7 @@
#!/bin/sh
# Build and push one service's image, inside limits Tower can afford.
#
# sh scripts/build-image.sh ipx|directory|web
# sh scripts/build-image.sh ipx|directory|web|coverage
#
# On 2026-11-04 a plain `docker buildx build` of ipx (its dependencies' cache layer invalidated
# by a Cargo.toml change) rebuilt everything in release on all twelve threads with 5 GB free and
@@ -20,7 +20,9 @@ case "${1:-}" in
ipx) FILE=Dockerfile; IMAGE=ipx ;;
directory) FILE=Dockerfile.directory; IMAGE=ipx-directory ;;
web) FILE=Dockerfile.web; IMAGE=ipx-web ;;
*) echo "usage: $0 ipx|directory|web" >&2; exit 2 ;;
# Not a service: what scripts/coverage.sh runs the Rust tests in (#390).
coverage) FILE=Dockerfile.coverage; IMAGE=ipx-coverage ;;
*) echo "usage: $0 ipx|directory|web|coverage" >&2; exit 2 ;;
esac
cd "$(dirname "$0")/.."

75
scripts/coverage.sh Normal file
View File

@@ -0,0 +1,75 @@
#!/bin/sh
# Which lines of the Rust the tests reach (#390), inside the limits builds are held to.
#
# sh scripts/coverage.sh all three crates, then the report
# sh scripts/coverage.sh ipx-feed one crate's tests, then the report of what has run
#
# A coverage build instruments every crate, dependencies included, in a target of its own: a whole
# rebuild, the kind that took Tower down on 2026-11-04. So it runs in a container under the same
# cap as scripts/build-image.sh's builder (6 GB, six threads), past which the kernel kills it and
# production is left alone; and, as CLAUDE.md asks of tests, one crate at a time. If the cap kills
# it, say so and leave the cap be.
#
# The report: target/coverage/summary.txt (a line a file), target/coverage/lcov.info, and
# target/coverage/html/index.html.
set -eu
MEM=6g
CPUS=0-5
NEED_KB=$((9 * 1024 * 1024))
MAX_LOAD=12
IMAGE=192.168.1.130:5000/ipx-coverage:latest
# The Docker daemon is Tower's, so its paths are Tower's: this tree is /mnt/user/vscode/ipx there,
# and target/ is a link to target-real on /mnt/fast (builds through /mnt/user's FUSE took the load
# to 248, CLAUDE.md).
SRC=/mnt/user/vscode/ipx
TARGET=/mnt/fast/vscode/ipx/target-real
REGISTRY=$TARGET/coverage-registry
cd "$(dirname "$0")/.."
crates=${1:-ipx-feed ipx-directory ipx}
room() {
avail=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
load=$(cut -d' ' -f1 /proc/loadavg)
echo "MemAvailable $((avail / 1024)) MB, load $load"
if [ "$avail" -lt "$NEED_KB" ]; then
echo "refusing: under $((NEED_KB / 1024 / 1024)) GB available. Wait, or find what is using it." >&2
exit 1
fi
if awk -v l="$load" -v m="$MAX_LOAD" 'BEGIN { exit !(l > m) }'; then
echo "refusing: load over $MAX_LOAD. Wait for it to come down." >&2
exit 1
fi
}
run() {
# The source read only: nothing in the tree is the coverage run's to change. The registry is
# kept between runs, so the crates are downloaded once.
docker run --rm --memory "$MEM" --memory-swap "$MEM" --cpuset-cpus "$CPUS" \
-v "$SRC:/work:ro" -v "$TARGET:/target" -v "$REGISTRY:/usr/local/cargo/registry" \
-e CARGO_TARGET_DIR=/target/llvm-cov-target -e CARGO_LLVM_COV_TARGET_DIR=/target/llvm-cov-target \
"$IMAGE" "$@" || {
code=$?
[ "$code" -eq 137 ] && echo "killed at the ${MEM} memory cap: tell the user, do not raise it" >&2
exit "$code"
}
}
docker pull -q "$IMAGE" >/dev/null
mkdir -p "$REGISTRY"
# A whole run starts from nothing; one crate adds to what has run.
[ $# -eq 0 ] && run cargo llvm-cov clean --workspace
for c in $crates; do
room
echo "== $c"
run cargo llvm-cov --no-report --locked -p "$c"
done
room
mkdir -p "$TARGET/coverage"
run sh -c 'cargo llvm-cov report --lcov --output-path /target/coverage/lcov.info \
&& cargo llvm-cov report --html --output-dir /target/coverage \
&& cargo llvm-cov report --summary-only > /target/coverage/summary.txt'
# The paths inside the container are /work/...; the tree's own, from here.
sed -i 's#/work/##' "$TARGET/coverage/summary.txt" "$TARGET/coverage/lcov.info"
tail -1 "$TARGET/coverage/summary.txt"

View File

@@ -1,4 +1,5 @@
const { test, expect } = require('@playwright/test');
// With the page's coverage recorded when asked for (#390).
const { test, expect } = require('./coverage.ts');
const { TOKEN } = require('./global-setup');
// Signals' tests run only when the Signals code has changed since each last passed: they spend the
@@ -1285,6 +1286,8 @@ test('artwork comes from iPX, kept, and only as an image', async ({ page }) => {
expect(r.headers()['content-type']).toMatch(/^image\//);
expect(r.headers()['content-security-policy']).toContain('sandbox');
}
// And it draws: the fixture's JPEGs were once a few bytes that only said they were one (#391).
expect(await page.evaluate((src) => new Promise(r => { const i = new Image(); i.onload = () => r(i.naturalWidth); i.onerror = () => r(0); i.src = src; }), src)).toBe(96);
// An address no feed names is not fetched.
expect((await page.request.get('/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/show.xml'))).status()).toBe(404);
});
@@ -2485,61 +2488,3 @@ test('an error toast is announced and stays until it is dismissed', async ({ pag
await t.getByRole('button', { name: 'Dismiss' }).click();
await expect(t).toHaveCount(0);
});
// Last: it adds a feed, which the feed counts of earlier tests do not expect.
test.describe('offline (#389)', () => {
test.use({ serviceWorkers: 'allow' });
const outbox = page => page.evaluate(async () => (await (await caches.open('ipx-outbox')).keys()).length);
test('iPX opens offline with what it read and its pictures, and sends a read made offline once back', async ({ page, context }) => {
const api = (p, init) => page.evaluate(([p, init]) => fetch(p, { headers: { 'Content-Type': 'application/json' }, ...init })
.then((r) => r.json().catch(() => null)), [p, init]);
const added = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/offline.xml' }) });
const id = added.id || added.feed?.id || 'offline-blog';
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload();
await expect.poll(() => page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
await page.locator('#feedlist .feed', { hasText: 'Offline Blog' }).click({ timeout: 20_000 });
await page.locator('#content .tabs button[data-f="unread"]').click();
await expect(page.locator('.ep', { hasText: 'Read on a Plane' })).toBeVisible();
// The unread item's picture, kept through ipx: no feed names it as artwork, only its text.
await expect.poll(() => page.evaluate(async () => (await (await caches.open('ipx-pics')).keys()).length)).toBe(1);
await context.setOffline(true);
await page.reload();
// The page, the feed list and the list kept, with the feed and tab open before.
await page.locator('.ep', { hasText: 'Read on a Plane' }).click({ timeout: 20_000 });
await expect(page.locator('#detail .dbody')).toContainText('Read on a plane, with no connection.');
await expect.poll(() => page.locator('#detail .dbody img').evaluate(i => i.complete ? i.naturalWidth : 0)).toBeGreaterThan(0);
// Opening it read it, which waits to be sent.
await expect.poll(() => outbox(page)).toBe(1);
await context.setOffline(false);
await expect.poll(() => outbox(page), { timeout: 15_000 }).toBe(0);
const list = await api(`/api/feeds/${encodeURIComponent(id)}/entries?offset=0&limit=50&filter=all`);
expect(list.entries.find(e => e.guid === 'offline-1').read).toBe(true);
});
test('online, a picture in an item\'s text still comes from its publisher, through the worker', async ({ page }) => {
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload();
await page.locator('#feedlist .feed', { hasText: 'Offline Blog' }).click({ timeout: 20_000 });
await page.locator('#content .tabs button[data-f="all"]').click();
await page.locator('.ep', { hasText: 'Read on a Plane' }).click();
const img = page.locator('#detail .dbody img');
await expect.poll(() => img.evaluate(i => i.complete ? i.naturalWidth : 0)).toBeGreaterThan(0);
// Past the worker, not round it: what it passes on online is what it would keep.
expect(await page.evaluate(() => performance.getEntriesByName('http://127.0.0.1:8792/pic.png')[0]?.workerStart > 0)).toBe(true);
});
test('signing out leaves nothing kept for offline on the device', async ({ page }) => {
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload();
await expect.poll(() => page.evaluate(async () => (await caches.keys()).filter(k => k.startsWith('ipx-')).length)).toBeGreaterThan(0);
await page.locator('#signout').evaluate(b => b.click());
await page.waitForURL(u => !u.pathname.endsWith('/') || u.pathname === '/login' || u.pathname.includes('signed-out'));
// What was read, its pictures and unsent writes. The sign-in page's own script and styles are
// kept again as it loads: the same files for everyone, nothing of the account's.
expect(await page.evaluate(async () => (await caches.keys()).filter(k => k.startsWith('ipx-') && k !== 'ipx-page'))).toEqual([]);
});
});

View File

@@ -0,0 +1,7 @@
// A coverage run starts from nothing: what an earlier run added is not this one's (#390).
import fs from 'node:fs';
import { coverageOptions } from './coverage.ts';
export default function setup() {
if (process.env.IPX_COVERAGE) fs.rmSync(coverageOptions.outputDir!, { recursive: true, force: true });
}

View File

@@ -0,0 +1,7 @@
// The page's coverage report, once every test has added its page's (coverage.ts, #390).
import { CoverageReport } from 'monocart-coverage-reports';
import { coverageOptions } from './coverage.ts';
export default async function teardown() {
if (process.env.IPX_COVERAGE) await new CoverageReport(coverageOptions).generate();
}

34
tests/ui/coverage.ts Normal file
View File

@@ -0,0 +1,34 @@
// What the browser tests run of the page's script (#390). With IPX_COVERAGE=1, web/build.mjs
// builds the script unminified with a source map to web/src, every test's page records V8's
// coverage of it, and the teardown (coverage-teardown.ts) writes the report, file by file, to
// target/coverage/page. Without it, `test` is Playwright's own.
//
// The specs take `test` and `expect` from here, not from @playwright/test, so no test is left out.
import { test as base, expect } from '@playwright/test';
import { CoverageReport, type CoverageReportOptions } from 'monocart-coverage-reports';
export const coverageOptions: CoverageReportOptions = {
name: 'iPX page',
outputDir: 'target/coverage/page',
reports: ['v8', 'lcovonly', 'console-summary', 'markdown-details'],
// The pages' scripts; the service worker is a worker of its own, which a page's coverage does
// not see.
entryFilter: (e) => /\/(app|admin|login)\.js(\?|$)/.test(e.url),
sourceFilter: (p) => p.startsWith('web/src/'),
};
export const test = base.extend<{ coverage: void }>({
coverage: [
async ({ page }, use) => {
if (!process.env.IPX_COVERAGE) return use();
// Kept across a reload or a sign-in, which several tests make.
await page.coverage.startJSCoverage({ resetOnNavigation: false });
await use();
await new CoverageReport(coverageOptions).add(await page.coverage.stopJSCoverage());
},
{ auto: true },
],
});
export { expect };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 247 B

View File

@@ -1,7 +1,10 @@
<?xml version="1.0"?>
<rss version="2.0">
<channel><title>Offline Blog</title><link>http://127.0.0.1:8792/</link><description>A post with a picture in its text, to read offline (#389).</description>
<channel><title>Offline Blog</title><link>http://127.0.0.1:8792/</link><description>Posts with pictures in their text, to read offline (#389).</description>
<item><title>Read on a Plane</title><guid>offline-1</guid>
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
<description>&lt;p&gt;Read on a plane, with no connection.&lt;/p&gt;&lt;img src="http://127.0.0.1:8792/pic.png" alt="a picture"&gt;</description></item>
<item><title>Three Pictures</title><guid>offline-2</guid>
<pubDate>Sun, 31 Aug 2026 10:00:00 +0000</pubDate>
<description>&lt;p&gt;Three of them.&lt;/p&gt;&lt;img src="http://127.0.0.1:8792/pic.png?n=1"&gt;&lt;img src="http://127.0.0.1:8792/pic.png?n=2"&gt;&lt;img src="http://127.0.0.1:8792/pic.png?n=3"&gt;</description></item>
</channel></rss>

154
tests/ui/offline.spec.ts Normal file
View File

@@ -0,0 +1,154 @@
// iPX without a connection (#389): the service worker (web/sw/sw.ts) keeps the page, what it read
// and the pictures in unread items, and holds writes made offline until the connection is back.
// After app.spec.js, file order being run order: it adds a feed, which that file's counts of
// feeds do not expect.
import type { BrowserContext, Page, Worker } from '@playwright/test';
import { TOKEN } from './global-setup.js';
import { expect, test } from './coverage.ts';
// The suite blocks the worker, which answers before page.route can; these tests are about it.
test.use({ serviceWorkers: 'allow' });
const call = (page: Page, p: string, init?: RequestInit) =>
page.evaluate(([p, init]) => fetch(p!, { headers: { 'Content-Type': 'application/json' }, ...(init as RequestInit) })
.then((r) => r.json().catch(() => null)), [p, init] as const);
const cacheSize = (page: Page, name: string) =>
page.evaluate(async (name) => (await (await caches.open(name)).keys()).length, name);
const outbox = (page: Page) => cacheSize(page, 'ipx-outbox');
/// The page, under the worker's control.
async function controlled(page: Page) {
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload();
await expect.poll(() => page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
}
async function worker(context: BrowserContext): Promise<Worker> {
return context.serviceWorkers()[0] ?? context.waitForEvent('serviceworker');
}
/// The feed these tests read, added once; the tests that follow find it there.
async function offlineBlog(page: Page): Promise<string> {
const feeds: { id: string, url: string }[] = await call(page, '/api/feeds');
const had = feeds.find((f) => f.url.endsWith('/offline.xml'));
if (had) return had.id;
const added = await call(page, '/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/offline.xml' }) });
return added?.id || added?.feed?.id || 'offline-blog';
}
test.beforeEach(async ({ page }) => {
await page.goto(`/?token=${TOKEN}`);
await expect(page.locator('#feedlist')).toBeVisible();
await offlineBlog(page);
});
test('iPX opens offline with what it read and its pictures, and sends a read made offline once back', async ({ page, context }) => {
const id = await offlineBlog(page);
// A run before this one read them: both unread again, so the Unread list holds both.
await expect.poll(async () => (await call(page, `/api/feeds/${encodeURIComponent(id)}/entries?offset=0&limit=50&filter=all`))?.entries?.length ?? 0,
{ timeout: 20_000 }).toBe(2);
for (const guid of ['offline-1', 'offline-2'])
await call(page, `/api/entries/${encodeURIComponent(id)}/${guid}/flags`, { method: 'POST', body: JSON.stringify({ read: false }) });
await controlled(page);
await page.locator('#feedlist .feed', { hasText: 'Offline Blog' }).click({ timeout: 20_000 });
await page.locator('#content .tabs button[data-f="unread"]').click();
await expect(page.locator('.ep', { hasText: 'Read on a Plane' })).toBeVisible();
// The unread items' four pictures, kept through ipx: no feed names them as artwork, only text.
await expect.poll(() => cacheSize(page, 'ipx-pics')).toBe(4);
await context.setOffline(true);
await page.reload();
// The page, the feed list and the list kept, with the feed and tab open before.
await page.locator('.ep', { hasText: 'Read on a Plane' }).click({ timeout: 20_000 });
await expect(page.locator('#detail .dbody')).toContainText('Read on a plane, with no connection.');
await expect.poll(() => page.locator('#detail .dbody img').evaluate((i: HTMLImageElement) => i.complete ? i.naturalWidth : 0)).toBeGreaterThan(0);
// Opening it read it, which waits to be sent.
await expect.poll(() => outbox(page)).toBe(1);
// Opened again offline, the Unread list kept no longer has it: read, as the outbox has it.
await page.reload();
await expect(page.locator('.ep', { hasText: 'Three Pictures' })).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.ep', { hasText: 'Read on a Plane' })).toHaveCount(0);
await context.setOffline(false);
await expect.poll(() => outbox(page), { timeout: 15_000 }).toBe(0);
const list = await call(page, `/api/feeds/${encodeURIComponent(id)}/entries?offset=0&limit=50&filter=all`);
expect(list.entries.find((e: { guid: string }) => e.guid === 'offline-1').read).toBe(true);
});
test('a place in an episode saved offline waits, and is saved once back', async ({ page, context }) => {
await controlled(page);
const at = '/api/entries/offline-blog/offline-2/position';
await context.setOffline(true);
const answer = await page.evaluate((at) => fetch(at, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ secs: 321, duration: 600 }),
}).then((r) => `${r.status} ${r.headers.get('X-Ipx-Queued')}`), at);
// Answered for the server, so the player carries on, and kept.
expect(answer).toBe('204 1');
expect(await outbox(page)).toBe(1);
await context.setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await expect.poll(() => outbox(page), { timeout: 15_000 }).toBe(0);
expect((await call(page, '/api/entries/offline-blog/offline-2')).position).toBe(321);
});
test('online, a picture in an item\'s text still comes from its publisher, through the worker', async ({ page }) => {
await controlled(page);
await page.locator('#feedlist .feed', { hasText: 'Offline Blog' }).click({ timeout: 20_000 });
await page.locator('#content .tabs button[data-f="all"]').click();
await page.locator('.ep', { hasText: 'Read on a Plane' }).click();
const img = page.locator('#detail .dbody img');
await expect.poll(() => img.evaluate((i: HTMLImageElement) => i.complete ? i.naturalWidth : 0)).toBeGreaterThan(0);
// Past the worker, not round it: what it passes on online is what it would keep.
expect(await page.evaluate(() =>
((performance.getEntriesByName('http://127.0.0.1:8792/pic.png')[0] as PerformanceResourceTiming | undefined)?.workerStart ?? 0) > 0)).toBe(true);
});
test('kept pictures past the budget go oldest first', async ({ page, context }) => {
const pic = (n: number) => `http://127.0.0.1:8792/pic.png?n=${n}`;
// ipx serves only pictures it has sent in item text: the list names them, once scanned.
await expect.poll(async () => (await call(page, '/api/feeds/offline-blog/entries?offset=0&limit=50&filter=all'))?.entries?.length ?? 0,
{ timeout: 20_000 }).toBe(2);
// Read, so the page asks the worker to keep none of them itself while this test does.
for (const guid of ['offline-1', 'offline-2'])
await call(page, `/api/entries/offline-blog/${guid}/flags`, { method: 'POST', body: JSON.stringify({ read: true }) });
await controlled(page);
const sw = await worker(context);
const kept = await sw.evaluate(async ([a, b, c]) => {
await caches.delete('ipx-pics');
// One at a time, so which is oldest is known; the third goes over two pictures' budget.
await keepPictures([a!]);
await keepPictures([b!]);
const one = Number((await (await caches.open('ipx-pics')).match(`/api/art?u=${encodeURIComponent(a!)}`))?.headers.get('X-Size'));
await keepPictures([c!], one * 2.5);
return (await (await caches.open('ipx-pics')).keys()).map((k) => decodeURIComponent(new URL(k.url).searchParams.get('u')!));
}, [pic(1), pic(2), pic(3)]);
expect(kept).toEqual([pic(2), pic(3)]);
});
test('another account signing in on the device clears what the last one kept', async ({ page }) => {
await controlled(page);
// What an account before this one left, and the page last opened as that account.
await page.evaluate(async () => {
await (await caches.open('ipx-api')).put('/api/left-by-someone-else', new Response('{}'));
localStorage.setItem('ipx.owner', 'someone else');
});
await page.reload();
await expect.poll(() => page.evaluate(async () =>
!!(await (await caches.open('ipx-api')).match('/api/left-by-someone-else')))).toBe(false);
// This account is the owner now, and what it keeps from here stays.
expect(await page.evaluate(() => localStorage.getItem('ipx.owner'))).not.toBe('someone else');
});
test('signing out leaves nothing kept for offline on the device', async ({ page }) => {
await controlled(page);
await expect.poll(() => page.evaluate(async () => (await caches.keys()).filter((k) => k.startsWith('ipx-')).length)).toBeGreaterThan(0);
await page.locator('#signout').evaluate((b: HTMLButtonElement) => b.click());
await page.waitForURL((u) => u.pathname !== '/');
// What was read, its pictures and unsent writes. The sign-in page's own script and styles are
// kept again as it loads: the same files for everyone, nothing of the account's.
expect(await page.evaluate(async () => (await caches.keys()).filter((k) => k.startsWith('ipx-') && k !== 'ipx-page'))).toEqual([]);
});
// The worker's own functions, called in it by the test above; declared for the type check.
declare function keepPictures(srcs: string[], budget?: number): Promise<void>;

19
tsconfig.tools.json Normal file
View File

@@ -0,0 +1,19 @@
{
// Type-checks the TypeScript that runs in Node rather than in a page or worker: the browser
// tests' own files and the build's helpers (#390). Node runs them by stripping the types, so the
// imports name their .ts files, and nothing is emitted.
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "dom"],
"module": "nodenext",
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
// global-setup.js and the other CommonJS files are read for their exports, not checked.
"allowJs": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["tests/ui/*.ts", "web/*.mts"]
}

View File

@@ -14,10 +14,16 @@ import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import swc from '@swc/core';
import html from '@swc/html';
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
// 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.
// Required, not imported with a top-level await, which would stop the tests require()ing this file.
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__.
const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
@@ -85,9 +91,10 @@ export function buildWorker({ minify = true } = {}) {
}
/// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) {
export function buildPage(name, { minify = !coverageMap } = {}) {
const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
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')
.replaceAll('__HLS_JS__', HLS_URL)
.replaceAll('__ICONS__', ICONS_URL)
.replaceAll('__WEB_VERSION__', WEB_VERSION);
@@ -102,6 +109,7 @@ export function buildPage(name, { minify = true } = {}) {
},
isModule: false,
minify,
...(coverageMap ? { sourceMaps: 'inline', inputSourceMap: JSON.stringify(coverageMap(parts)) } : {}),
}).code;
const page = fs.readFileSync(path.join(here, name), 'utf8');
const marker = /<script data-src="[^"]*"><\/script>/;

49
web/concat-map.mts Normal file
View File

@@ -0,0 +1,49 @@
// A source map for the page's script as web/build.mjs 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
// 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.
export interface SourceMap {
version: 3;
sources: string[];
sourcesContent: string[];
names: string[];
mappings: string;
}
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
/// One number in a source map's base64 VLQ: sign in the lowest bit, five bits a digit.
export function vlq(n: number): string {
let v = n < 0 ? (-n << 1) | 1 : n << 1;
let out = '';
do {
let digit = v & 31;
v >>>= 5;
if (v) digit |= 32;
out += B64[digit];
} while (v);
return out;
}
/// The map of `files` joined with '\n' between them, as build.mjs joins them.
export function concatMap(files: { name: string; text: string }[]): SourceMap {
const lines: string[] = [];
// Each segment's fields are relative to the one before: source, line in it, column.
let lastSource = 0, lastLine = 0;
files.forEach((f, source) => {
const count = f.text.split('\n').length;
for (let line = 0; line < count; line++) {
lines.push(vlq(0) + vlq(source - lastSource) + vlq(line - lastLine) + vlq(0));
lastSource = source;
lastLine = line;
}
});
return {
version: 3,
sources: files.map((f) => f.name),
sourcesContent: files.map((f) => f.text),
names: [],
mappings: lines.join(';'),
};
}

View File

@@ -166,15 +166,15 @@ async function picture(req: Request): Promise<Response> {
}
}
/// Fetches through ipx, three at a time, the pictures not kept yet, then trims to the budget.
async function keepPictures(srcs: string[]) {
/// Fetches through ipx, three at a time, the pictures not kept yet, then trims to the budget,
/// oldest kept first out.
async function keepPictures(srcs: string[], budget = PICS_BUDGET) {
const c = await caches.open(PICS);
const todo: string[] = [];
// Kept under the address the browser will ask for, which it normalises (a space is %20); asked
// of ipx as the text names it, which is what ipx knows it by.
const key = (s: string) => { try { return artFor(new URL(s).href); } catch { return null; } };
for (const s of srcs) { const k = key(s); if (k && !(await c.match(k))) todo.push(s); }
if (!todo.length) return;
const one = async () => {
for (let s; (s = todo.shift()) !== undefined;) {
const k = key(s);
@@ -191,5 +191,5 @@ async function keepPictures(srcs: string[]) {
const keys = await c.keys();
const sizes = await Promise.all(keys.map(async k => Number((await c.match(k))?.headers.get('X-Size')) || 0));
let total = sizes.reduce((a, b) => a + b, 0);
for (let i = 0; i < keys.length && total > PICS_BUDGET; i++) { await c.delete(keys[i]!); total -= sizes[i]!; }
for (let i = 0; i < keys.length && total > budget; i++) { await c.delete(keys[i]!); total -= sizes[i]!; }
}