diff --git a/CLAUDE.md b/CLAUDE.md index da7915b..f8d575a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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__`, 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. diff --git a/Dockerfile.coverage b/Dockerfile.coverage new file mode 100644 index 0000000..c4c1fb4 --- /dev/null +++ b/Dockerfile.coverage @@ -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 diff --git a/package-lock.json b/package-lock.json index d3ebcb1..e0412bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index c580514..2ce914b 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/playwright.config.js b/playwright.config.js index fe339da..4791d4f 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -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. diff --git a/scripts/build-image.sh b/scripts/build-image.sh index 0ef1e58..a8ac2ef 100644 --- a/scripts/build-image.sh +++ b/scripts/build-image.sh @@ -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")/.." diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100644 index 0000000..14c6dd7 --- /dev/null +++ b/scripts/coverage.sh @@ -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" diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 6e1667f..fb78c5b 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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([]); - }); -}); diff --git a/tests/ui/coverage-setup.ts b/tests/ui/coverage-setup.ts new file mode 100644 index 0000000..c70cd9b --- /dev/null +++ b/tests/ui/coverage-setup.ts @@ -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 }); +} diff --git a/tests/ui/coverage-teardown.ts b/tests/ui/coverage-teardown.ts new file mode 100644 index 0000000..76cb897 --- /dev/null +++ b/tests/ui/coverage-teardown.ts @@ -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(); +} diff --git a/tests/ui/coverage.ts b/tests/ui/coverage.ts new file mode 100644 index 0000000..f9464e2 --- /dev/null +++ b/tests/ui/coverage.ts @@ -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 }; diff --git a/tests/ui/fixtures/art.jpg b/tests/ui/fixtures/art.jpg index f9011df..4cecc6f 100644 Binary files a/tests/ui/fixtures/art.jpg and b/tests/ui/fixtures/art.jpg differ diff --git a/tests/ui/fixtures/art2.jpg b/tests/ui/fixtures/art2.jpg index ad3c9ef..41775a6 100644 Binary files a/tests/ui/fixtures/art2.jpg and b/tests/ui/fixtures/art2.jpg differ diff --git a/tests/ui/fixtures/offline.xml b/tests/ui/fixtures/offline.xml index eeed292..7f79faa 100644 --- a/tests/ui/fixtures/offline.xml +++ b/tests/ui/fixtures/offline.xml @@ -1,7 +1,10 @@ -Offline Bloghttp://127.0.0.1:8792/A post with a picture in its text, to read offline (#389). +Offline Bloghttp://127.0.0.1:8792/Posts with pictures in their text, to read offline (#389). Read on a Planeoffline-1 Mon, 01 Sep 2026 10:00:00 +0000 <p>Read on a plane, with no connection.</p><img src="http://127.0.0.1:8792/pic.png" alt="a picture"> +Three Picturesoffline-2 + Sun, 31 Aug 2026 10:00:00 +0000 + <p>Three of them.</p><img src="http://127.0.0.1:8792/pic.png?n=1"><img src="http://127.0.0.1:8792/pic.png?n=2"><img src="http://127.0.0.1:8792/pic.png?n=3"> diff --git a/tests/ui/offline.spec.ts b/tests/ui/offline.spec.ts new file mode 100644 index 0000000..f1df1db --- /dev/null +++ b/tests/ui/offline.spec.ts @@ -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 { + 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 { + 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; diff --git a/tsconfig.tools.json b/tsconfig.tools.json new file mode 100644 index 0000000..1a1f37e --- /dev/null +++ b/tsconfig.tools.json @@ -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"] +} diff --git a/web/build.mjs b/web/build.mjs index c57f25e..3b85185 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -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 = /