From 206fb6e02282a8e9480de2bb676ac1a44ed41f22 Mon Sep 17 00:00:00 2001 From: Luis Pabon Date: Wed, 17 Jun 2026 15:32:21 +0100 Subject: [PATCH 01/10] chore: add planning artifacts for code quality batch Covers #60 (signal handling), #61 (ESLint), #62 (test reorg), #55 (tag versioning). Co-Authored-By: Claude Opus 4.6 --- .../2026-06-17_code-quality-batch/overview.md | 71 ++++++++++ .../2026-06-17_code-quality-batch/plan.yaml | 130 ++++++++++++++++++ .../2026-06-17_code-quality-batch/research.md | 34 +++++ 3 files changed, 235 insertions(+) create mode 100644 .project_planning/2026-06-17_code-quality-batch/overview.md create mode 100644 .project_planning/2026-06-17_code-quality-batch/plan.yaml create mode 100644 .project_planning/2026-06-17_code-quality-batch/research.md diff --git a/.project_planning/2026-06-17_code-quality-batch/overview.md b/.project_planning/2026-06-17_code-quality-batch/overview.md new file mode 100644 index 0000000..15a4f9f --- /dev/null +++ b/.project_planning/2026-06-17_code-quality-batch/overview.md @@ -0,0 +1,71 @@ +## Request + +Fix issues #60, #61, #62, #55 in one pass — signal handling, linting, test reorganization, and tag-based versioning. + +## Overview + +Four improvements batched as a code quality pass: + +1. **#60 — Signal handling**: Add `SIGINT`/`SIGTERM` handlers to `server.js` for graceful shutdown. Add `tini` as PID 1 in the Dockerfile so signals propagate correctly to the Node process. +2. **#61 — ESLint setup**: Add ESLint with flat config (`eslint.config.cjs`), `@eslint/js` recommended rules, `eslint-plugin-n` for Node-specific checks, `eslint-config-prettier` for formatter coexistence. Update `pnpm lint` to run both ESLint and Prettier. Fix any violations. +3. **#62 — Test reorganization**: Break `test/app.test.js` (982 lines) into focused test files by theme. Analyse coverage gaps and add tests for edge cases. Add integration tests with real HTML fixtures. +4. **#55 — Tag-based versioning**: Replace `release` file with `package.json` version field. CI triggers on `v*` tags, derives semver tags (major, major.minor, full), creates GitHub Releases with auto-generated notes. Remove `release` file from Dockerfile and `server.js`. + +## Key Decisions + +- **Tini for PID 1**: Using `tini` (Alpine package) instead of `dumb-init` — already available in Alpine repos, no extra download. Handles signal forwarding and zombie reaping. +- **ESLint flat config**: Using `eslint.config.cjs` (CommonJS) with the modern flat config format. No legacy `.eslintrc`. +- **Separate lint commands**: `pnpm lint` runs both `eslint` and `prettier --check`. Separate tools, separate concerns. +- **Version source**: `package.json` `version` field becomes the single source of truth. `server.js` reads it via `require('../package.json').version`. The `release` file is deleted. +- **Tag format**: `v2.0.0` style tags. CI extracts version, pushes Docker tags for `2.0.0`, `2.0`, `2`, and `latest`. +- **Test split strategy**: One file per theme (e.g., `test/validation.test.js`, `test/parsing.test.js`, `test/markdown.test.js`, `test/errors.test.js`). Shared fixtures in `test/fixtures/`. +- **GitHub Release**: Auto-created on tag push with `--generate-notes` (same pattern as steiner repo). + +## Tradeoffs + +- **Tini vs Node signal handling alone**: Tini adds a small binary but is the Docker best practice for signal handling. Pure-Node handlers work but don't handle zombie reaping. Chose tini. +- **ESLint + Prettier separate vs combined**: Could use `eslint-plugin-prettier` to run both via ESLint, but this is discouraged in 2026 — causes conflicts. Separate tools is cleaner. +- **Test fixtures as HTML files vs inline strings**: HTML fixture files are more maintainable and realistic for integration tests, but add file count. Worth it for readability and cross-section testing. +- **Removing `release` file entirely vs keeping for backwards compat**: Clean break. The file served one purpose and that purpose moves to tags + `package.json`. + +## Scope Boundaries + +**In scope:** +- Signal handling fix in `server.js` + tini in Dockerfile +- ESLint setup, config, and fixing all violations +- Test file reorganization + coverage gap analysis + new tests +- Tag-based CI workflow replacing release-file workflow +- GitHub Release creation on tag +- Updating Makefile, package.json scripts as needed +- Updating AGENTS.md and README.md if commands change + +**Out of scope:** +- Helm chart (#53) +- Memory consumption (#35) +- Dependency upgrades beyond ESLint tooling +- TypeScript migration +- New API features or response shape changes + +## Verification Strategy + +| Tool | Command | Cost | Notes | +|------|---------|------|-------| +| Prettier | `pnpm lint` (currently prettier-only) | Cheap | Already configured | +| ESLint | `pnpm lint` (after adding eslint) | Cheap | New — will be added | +| Tests | `pnpm test` | Cheap | Node built-in test runner | +| Docker build | `docker build -t readability-js .` | Medium | Verify Dockerfile changes | +| Docker signal test | `docker run` + ctrl+c | Manual | Verify #60 fix | +| CI workflow | Validate YAML syntax | Cheap | Can lint locally | + +Primary verification: `pnpm lint && pnpm test` after every step. Docker build after Dockerfile changes. + +## Decision Log + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | Batch #60, #61, #62, #55 together | All code quality / DX improvements with no feature risk | +| 2 | Defer #53 (helm) | Separate deliverable, different domain | +| 3 | Use tini for signal handling | Alpine-native, Docker best practice, handles zombies | +| 4 | ESLint flat config with eslint-plugin-n | 2026 standard for Node.js projects | +| 5 | Version from package.json, not release file | Single source of truth, standard Node convention | +| 6 | Tag-triggered CI following steiner pattern | Proven pattern from user's own project | diff --git a/.project_planning/2026-06-17_code-quality-batch/plan.yaml b/.project_planning/2026-06-17_code-quality-batch/plan.yaml new file mode 100644 index 0000000..1fb0f32 --- /dev/null +++ b/.project_planning/2026-06-17_code-quality-batch/plan.yaml @@ -0,0 +1,130 @@ +steps: + - id: step-1 + title: "Signal handling and graceful shutdown (#60)" + scope: | + Add SIGINT/SIGTERM handlers to server.js for graceful shutdown (close HTTP server, + then exit). Add tini as PID 1 in Dockerfile via ENTRYPOINT. Verify ctrl+c works + in container. + files: + - src/server.js + - Dockerfile + constraints: + - Must handle both SIGINT and SIGTERM + - Must close the HTTP server before exiting (drain active connections) + - Tini must be installed from Alpine repos (apk add --no-cache tini) + - Use ENTRYPOINT ["/sbin/tini", "--"] with CMD ["node", "src/server.js"] + - Log shutdown to stdout using the existing logger + acceptance: + - Container responds to single ctrl+c and exits cleanly + - Process exits with code 0 on graceful shutdown + - Active requests complete before shutdown (within a timeout) + verification: + - pnpm test + - docker build -t readability-js . + - "docker run --rm -p3000:3000 readability-js (then ctrl+c — must exit cleanly)" + + - id: step-2 + title: "ESLint setup and enforcement (#61)" + scope: | + Install ESLint with flat config. Create eslint.config.cjs with @eslint/js recommended, + eslint-plugin-n for Node rules, eslint-config-prettier to avoid conflicts. Update + pnpm lint to run both eslint and prettier. Fix all violations. + files: + - package.json + - eslint.config.cjs (new) + - src/app.js + - src/config.js + - src/logger.js + - src/markdown.js + - src/response.js + - src/server.js + - test/app.test.js + - scripts/memory-soak.js + constraints: + - Use flat config format (eslint.config.cjs for CommonJS) + - eslint-config-prettier must be last in config array + - Do not use eslint-plugin-prettier (run tools separately) + - "pnpm lint" must run both eslint and prettier check + - "pnpm lint:fix" must run both eslint --fix and prettier --write + - Do not change code behavior — lint fixes only + - Add eslint to CI (already covered by pnpm lint) + acceptance: + - pnpm lint passes with zero violations + - ESLint config covers src/, test/, scripts/ + - Node-specific rules active via eslint-plugin-n + verification: + - pnpm lint + - pnpm test + + - id: step-3 + title: "Reorganise and break down tests (#62)" + scope: | + Split test/app.test.js into focused test files by theme. Create shared test + helpers (createTestApp, createFixtureServer, etc.) in a test helper file. + Add HTML fixture files for integration tests. Analyse coverage gaps and add + new tests for uncovered edge cases. + files: + - test/app.test.js (split into multiple files) + - test/helpers.js (new — shared test utilities) + - test/validation.test.js (new — input validation, URL schemes, malformed URLs) + - test/fetch-errors.test.js (new — timeouts, network errors, HTTP errors, oversized responses, non-HTML) + - test/security.test.js (new — SSRF/private network blocking, sanitization, XSS) + - test/redirects.test.js (new — redirect following, redirect loops, redirect-to-private) + - test/parsing.test.js (new — successful article parsing, response fields, content format) + - test/markdown.test.js (new — markdown conversion, iframes, video, tables, GFM) + - test/config.test.js (new — config loading, validation, defaults, env vars) + - test/concurrency.test.js (new — concurrency gate, 429 responses) + - test/fixtures/ (new directory — HTML fixture files) + constraints: + - All existing tests must pass after reorganization (no test logic changes initially) + - "node --test" must discover all test files (test/*.test.js glob) + - Shared helpers must not export test cases, only utilities + - New tests must not change the HTTP contract or app behavior + - HTML fixtures should exercise realistic cross-section of article types + - Do not duplicate test coverage — move tests, don't copy + acceptance: + - Zero test files over 200 lines + - All original test scenarios preserved + - New edge case tests added (coverage gaps identified and plugged) + - pnpm test discovers and runs all test files + verification: + - pnpm test + - pnpm lint + + - id: step-4 + title: "Tag-based versioning and CI (#55)" + scope: | + Remove release file. Read version from package.json in server.js and Dockerfile. + Create new tag-triggered workflow for Docker build+push and GitHub Release creation. + Update existing CI workflow to remove release-file logic. Update Makefile and docs. + files: + - src/server.js + - Dockerfile + - package.json + - .github/workflows/build-publish.yaml + - .github/workflows/release.yaml (new — tag-triggered) + - release (delete) + - Makefile + - README.md + - AGENTS.md + constraints: + - package.json version field is single source of truth + - Tag format is v* (e.g. v2.0.0) + - Release workflow triggers on push of v* tags only + - Docker tags derived from semver — full (2.0.0), minor (2.0), major (2), latest + - GitHub Release created with --generate-notes (same as steiner) + - Existing build-publish.yaml keeps running lint+test on PR/push to master + - build-publish.yaml no longer builds/pushes Docker images (moved to release workflow) + - Do not break existing CI for PRs + acceptance: + - release file removed from repo + - server.js reads version from package.json + - Dockerfile no longer copies release file + - Tag push triggers Docker build+push with correct tags + - Tag push creates GitHub Release with auto-generated notes + - PR/push CI still runs lint + test + verification: + - pnpm test + - pnpm lint + - docker build -t readability-js . + - "Manually verify workflow YAML syntax" diff --git a/.project_planning/2026-06-17_code-quality-batch/research.md b/.project_planning/2026-06-17_code-quality-batch/research.md new file mode 100644 index 0000000..87c761a --- /dev/null +++ b/.project_planning/2026-06-17_code-quality-batch/research.md @@ -0,0 +1,34 @@ +## Question + +What are the current (2026) ESLint best practices for a Node.js CommonJS project with Prettier? + +## Findings + +- **Flat config** (`eslint.config.js`) is the standard format. CommonJS projects use `eslint.config.cjs` or `require()` syntax in `.config.js` +- **Base config**: `@eslint/js` with `js.configs.recommended` +- **Node-specific**: `eslint-plugin-n` — official successor to `eslint-plugin-node`, covers process/path/require rules +- **Prettier integration**: `eslint-config-prettier` (goes last in config array) disables formatting rules that conflict with Prettier +- **`eslint-plugin-prettier` is discouraged** — causes conflicts with certain rules. Run tools separately instead +- **Packages needed**: `eslint`, `@eslint/js`, `eslint-plugin-n`, `eslint-config-prettier` + +## Implications + +- Use flat config format (`eslint.config.cjs` since project is CommonJS) +- Prettier config must be last in the array to override formatting rules +- ESLint handles logic/quality, Prettier handles formatting — separate concerns, separate commands +- `eslint-plugin-n` adds Node-specific checks (unsupported features, deprecated APIs, etc.) + +## Risks and Uncertainties + +- Existing code may have lint violations requiring initial fix pass +- `eslint-plugin-n` version compatibility with ESLint 10+ should be verified at install time + +## Sources + +- ESLint documentation (eslint.org) +- eslint-config-prettier documentation +- eslint-plugin-n documentation + +## Open Questions + +None — straightforward setup. From 4f547e9d418fb33e3f57dd172e27b30305ed9364 Mon Sep 17 00:00:00 2001 From: Luis Pabon Date: Wed, 17 Jun 2026 15:40:34 +0100 Subject: [PATCH 02/10] Add graceful server shutdown --- Dockerfile | 3 +++ src/server.js | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f1d053c..e9f4c62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,8 @@ WORKDIR /application ARG RUNTIME_USER=readability +RUN apk add --no-cache tini + RUN adduser -D ${RUNTIME_USER} \ && mkdir -p /home/${RUNTIME_USER} /application \ && chown -R ${RUNTIME_USER}:${RUNTIME_USER} /home/${RUNTIME_USER} /application @@ -29,4 +31,5 @@ COPY release . USER ${RUNTIME_USER} +ENTRYPOINT ["/sbin/tini", "--"] CMD ["node", "src/server.js"] diff --git a/src/server.js b/src/server.js index 0c95a7e..2972c87 100644 --- a/src/server.js +++ b/src/server.js @@ -12,8 +12,60 @@ const version = fs .toString() .split(" ")[0]; -app.listen(config.port, () => { +const shutdownTimeoutMs = 10_000; +const server = app.listen(config.port, () => { logger.info( `Readability.js server v${version} listening on port ${config.port}!`, ); }); + +let isShuttingDown = false; + +function closeServer(signal) { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + process.exitCode = 0; + + logger.info( + `Received ${signal}, starting graceful shutdown with a ${shutdownTimeoutMs}ms timeout...`, + ); + + if (typeof server.closeIdleConnections === "function") { + server.closeIdleConnections(); + } + + const forceCloseTimer = setTimeout(() => { + logger.info( + `Graceful shutdown timed out after ${shutdownTimeoutMs}ms, closing remaining connections...`, + ); + + if (typeof server.closeAllConnections === "function") { + server.closeAllConnections(); + } + }, shutdownTimeoutMs); + + forceCloseTimer.unref(); + + server.close((error) => { + clearTimeout(forceCloseTimer); + + if (error) { + logger.error("HTTP server shutdown failed", error); + return; + } + + logger.info("HTTP server closed cleanly, exiting."); + process.exitCode = 0; + }); +} + +process.on("SIGINT", () => { + closeServer("SIGINT"); +}); + +process.on("SIGTERM", () => { + closeServer("SIGTERM"); +}); From 7ad0e8a7c0cdc7a440bd36ccafc3ae4c656684f3 Mon Sep 17 00:00:00 2001 From: Luis Pabon Date: Wed, 17 Jun 2026 15:50:39 +0100 Subject: [PATCH 03/10] chore: add flat eslint config and lint checks --- eslint.config.cjs | 41 ++++ package.json | 8 +- pnpm-lock.yaml | 603 ++++++++++++++++++++++++++++++++++++++++++++++ src/markdown.js | 4 +- src/server.js | 4 +- 5 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 eslint.config.cjs diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000..11d0780 --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,41 @@ +const js = require("@eslint/js"); +const n = require("eslint-plugin-n").default; +const eslintConfigPrettier = require("eslint-config-prettier/flat"); + +const nodeRecommendedScript = n.configs["flat/recommended-script"]; + +module.exports = [ + { + ignores: ["**/node_modules/**", "**/coverage/**", "**/dist/**"], + }, + js.configs.recommended, + { + files: ["src/**/*.js", "test/**/*.js", "scripts/**/*.js"], + ...nodeRecommendedScript, + settings: { + ...(nodeRecommendedScript.settings || {}), + node: { + version: ">=24.0.0", + }, + }, + rules: { + ...nodeRecommendedScript.rules, + "no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, + }, + { + files: ["test/**/*.js"], + rules: { + "n/no-unpublished-import": "off", + "n/no-unpublished-require": "off", + }, + }, + eslintConfigPrettier, +]; diff --git a/package.json b/package.json index d9eb72f..228cba0 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,15 @@ "scripts": { "start": "nodemon src/server.js", "test": "node --test", - "lint": "prettier -c src/ test/ scripts/", - "lint:fix": "prettier -w src/ test/ scripts/", + "lint": "eslint src/ test/ scripts/ && prettier -c src/ test/ scripts/", + "lint:fix": "eslint --fix src/ test/ scripts/ && prettier --write src/ test/ scripts/", "memory:soak": "node scripts/memory-soak.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.5.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-n": "^18.1.0", "nodemon": "^3.1.14", "prettier": "^3.8.4", "supertest": "^7.1.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56feb26..fd7e05a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,18 @@ importers: specifier: ^1.0.2 version: 1.0.2 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.5.0) + eslint: + specifier: ^10.5.0 + version: 10.5.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.5.0) + eslint-plugin-n: + specifier: ^18.1.0 + version: 18.1.0(eslint@10.5.0) nodemon: specifier: ^3.1.14 version: 3.1.14 @@ -94,6 +106,45 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -103,6 +154,26 @@ packages: '@noble/hashes': optional: true + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -117,6 +188,15 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -124,6 +204,19 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -203,6 +296,10 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -223,6 +320,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -248,6 +348,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -271,6 +375,83 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-n@18.1.0: + resolution: {integrity: sha512-hkUm9EtnFV2h2fE16jNVUfCVUqvPzI7fGLsFdun5lFt/pbmf2kCgDx6ymi9rx+NCUSggBmurJCZOfG20JBs/kg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=8.57.1' + ts-declaration-location: ^1.0.6 + typescript: '>=5.0.0' + peerDependenciesMeta: + ts-declaration-location: + optional: true + typescript: + optional: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -279,9 +460,22 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -290,6 +484,17 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -322,14 +527,31 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -361,6 +583,14 @@ packages: ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -390,6 +620,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -399,6 +632,26 @@ packages: canvas: optional: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -450,6 +703,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -474,6 +730,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -481,6 +749,14 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -488,6 +764,10 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@3.8.4: resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} engines: {node: '>=14'} @@ -524,6 +804,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -551,6 +834,14 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -594,6 +885,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tldts-core@7.4.3: resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} @@ -628,6 +923,10 @@ packages: resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} engines: {node: '>=18', npm: '>=9'} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -643,6 +942,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -663,6 +965,15 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -673,6 +984,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + snapshots: '@asamuzakjp/css-color@5.1.11': @@ -723,10 +1038,60 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)': + dependencies: + eslint: 10.5.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.5.0)': + optionalDependencies: + eslint: 10.5.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': optionalDependencies: '@noble/hashes': 1.8.0 + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@mixmark-io/domino@2.2.0': {} '@mozilla/readability@0.6.0': {} @@ -737,6 +1102,12 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + '@types/trusted-types@2.0.7': optional: true @@ -745,6 +1116,19 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -826,6 +1210,12 @@ snapshots: cookiejar@2.1.4: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -846,6 +1236,8 @@ snapshots: decimal.js@10.6.0: {} + deep-is@0.1.4: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -869,6 +1261,11 @@ snapshots: encodeurl@2.0.0: {} + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@8.0.0: {} es-define-property@1.0.1: {} @@ -888,6 +1285,100 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + + eslint-compat-utils@0.5.1(eslint@10.5.0): + dependencies: + eslint: 10.5.0 + semver: 7.8.4 + + eslint-config-prettier@10.1.8(eslint@10.5.0): + dependencies: + eslint: 10.5.0 + + eslint-plugin-es-x@7.8.0(eslint@10.5.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.5.0 + eslint-compat-utils: 0.5.1(eslint@10.5.0) + + eslint-plugin-n@18.1.0(eslint@10.5.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + enhanced-resolve: 5.24.0 + eslint: 10.5.0 + eslint-plugin-es-x: 7.8.0(eslint@10.5.0) + get-tsconfig: 4.14.0 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.4 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + etag@1.8.1: {} express@5.2.1(supports-color@5.5.0): @@ -923,8 +1414,18 @@ snapshots: transitivePeerDependencies: - supports-color + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -940,6 +1441,18 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -981,12 +1494,26 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@15.15.0: {} + + globrex@0.1.2: {} + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-flag@3.0.0: {} has-symbols@1.1.0: {} @@ -1019,6 +1546,10 @@ snapshots: ignore-by-default@1.0.1: {} + ignore@5.3.2: {} + + imurmurhash@0.1.4: {} + inherits@2.0.4: {} ipaddr.js@1.9.1: {} @@ -1039,6 +1570,8 @@ snapshots: is-promise@4.0.0: {} + isexe@2.0.0: {} + jsdom@29.1.1(@noble/hashes@1.8.0): dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -1065,6 +1598,25 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lru-cache@11.5.1: {} math-intrinsics@1.1.0: {} @@ -1097,6 +1649,8 @@ snapshots: ms@2.1.3: {} + natural-compare@1.4.0: {} + negotiator@1.0.0: {} nodemon@3.1.14: @@ -1124,16 +1678,39 @@ snapshots: dependencies: wrappy: 1.0.2 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + parse5@8.0.1: dependencies: entities: 8.0.0 parseurl@1.3.3: {} + path-exists@4.0.0: {} + + path-key@3.1.1: {} + path-to-regexp@8.4.2: {} picomatch@2.3.2: {} + prelude-ls@1.2.1: {} + prettier@3.8.4: {} proxy-addr@2.0.7: @@ -1164,6 +1741,8 @@ snapshots: require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} + router@2.2.0(supports-color@5.5.0): dependencies: debug: 4.4.3(supports-color@5.5.0) @@ -1209,6 +1788,12 @@ snapshots: setprototypeof@1.2.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -1273,6 +1858,8 @@ snapshots: symbol-tree@3.2.4: {} + tapable@2.3.3: {} + tldts-core@7.4.3: {} tldts@7.4.3: @@ -1301,6 +1888,10 @@ snapshots: dependencies: '@mixmark-io/domino': 2.2.0 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -1313,6 +1904,10 @@ snapshots: unpipe@1.0.0: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + vary@1.1.2: {} w3c-xmlserializer@5.0.0: @@ -1331,8 +1926,16 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + wrappy@1.0.2: {} xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + + yocto-queue@0.1.0: {} diff --git a/src/markdown.js b/src/markdown.js index 0eea134..0be1e03 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -14,7 +14,7 @@ turndownService.use(gfm); turndownService.addRule("iframe", { filter: "iframe", - replacement(content, node) { + replacement(_content, node) { const src = node.getAttribute("src"); if (!src) { return ""; @@ -31,7 +31,7 @@ turndownService.addRule("iframe", { turndownService.addRule("video", { filter: "video", - replacement(content, node) { + replacement(_content, node) { const src = node.getAttribute("src"); if (!src) { return ""; diff --git a/src/server.js b/src/server.js index 2972c87..2479736 100644 --- a/src/server.js +++ b/src/server.js @@ -1,5 +1,5 @@ -const fs = require("fs"); -const path = require("path"); +const fs = require("node:fs"); +const path = require("node:path"); const { loadConfig } = require("./config"); const { createLogger } = require("./logger"); From f6458f624d784fa7b0a00928b3c573ef1b933e0c Mon Sep 17 00:00:00 2001 From: Luis Pabon Date: Wed, 17 Jun 2026 15:58:14 +0100 Subject: [PATCH 04/10] test: split readability app coverage --- test/app.test.js | 982 ------------------- test/concurrency.test.js | 80 ++ test/config.test.js | 61 ++ test/fetch-errors.test.js | 125 +++ test/fixtures/article-basic.html | 16 + test/fixtures/article-media.html | 18 + test/fixtures/article-sanitize-markdown.html | 16 + test/fixtures/article-table.html | 30 + test/fixtures/article-unsafe.html | 22 + test/fixtures/article-vimeo.html | 16 + test/helpers.js | 127 +++ test/markdown.test.js | 151 +++ test/parsing.test.js | 67 ++ test/redirects.test.js | 125 +++ test/security.test.js | 76 ++ test/validation.test.js | 102 ++ 16 files changed, 1032 insertions(+), 982 deletions(-) delete mode 100644 test/app.test.js create mode 100644 test/concurrency.test.js create mode 100644 test/config.test.js create mode 100644 test/fetch-errors.test.js create mode 100644 test/fixtures/article-basic.html create mode 100644 test/fixtures/article-media.html create mode 100644 test/fixtures/article-sanitize-markdown.html create mode 100644 test/fixtures/article-table.html create mode 100644 test/fixtures/article-unsafe.html create mode 100644 test/fixtures/article-vimeo.html create mode 100644 test/helpers.js create mode 100644 test/markdown.test.js create mode 100644 test/parsing.test.js create mode 100644 test/redirects.test.js create mode 100644 test/security.test.js create mode 100644 test/validation.test.js diff --git a/test/app.test.js b/test/app.test.js deleted file mode 100644 index ddd44d2..0000000 --- a/test/app.test.js +++ /dev/null @@ -1,982 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const http = require("node:http"); -const dns = require("node:dns/promises"); -const supertest = require("supertest"); - -const { DEFAULTS, loadConfig } = require("../src/config"); -const { RESPONSE_FIELDS } = require("../src/response"); -const { createApp, createReadabilityOptions, messages } = require("../src/app"); -const { toMarkdown } = require("../src/markdown"); - -function createTestApp(configOverrides) { - return createApp( - { - ...loadConfig({}), - ...configOverrides, - }, - { - info() {}, - error() {}, - }, - ); -} - -function createFixtureServer(routes) { - const server = http.createServer((req, res) => { - const handler = routes[req.url] || routes.default; - - if (!handler) { - res.statusCode = 404; - res.end("not found"); - return; - } - - handler(req, res); - }); - - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, () => { - const address = server.address(); - const baseUrl = `http://127.0.0.1:${address.port}`; - - resolve({ - baseUrl, - close: () => - new Promise((closeResolve, closeReject) => { - server.close((error) => { - if (error) { - closeReject(error); - return; - } - - closeResolve(); - }); - }), - }); - }); - }); -} - -function postJson(server, payload) { - return new Promise((resolve, reject) => { - const address = server.address(); - const body = JSON.stringify(payload); - const request = http.request( - { - method: "POST", - host: "127.0.0.1", - port: address.port, - path: "/", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - }, - }, - (response) => { - const chunks = []; - - response.on("data", (chunk) => { - chunks.push(chunk); - }); - response.on("end", () => { - resolve({ - statusCode: response.statusCode, - body: JSON.parse(Buffer.concat(chunks).toString("utf8")), - }); - }); - }, - ); - - request.on("error", reject); - request.write(body); - request.end(); - }); -} - -function assertFetchFailureShape( - response, - expectedCode, - expectedError = "Some weird error fetching the content", -) { - assert.equal(response.body.error, expectedError); - assert.equal(typeof response.body.details, "object"); - assert.ok(response.body.details); - assert.equal(response.body.details.code, expectedCode); - assert.equal(typeof response.body.details.message, "string"); -} - -test("GET / returns guidance for POST JSON", async () => { - const response = await supertest(createTestApp()).get("/").expect(400); - - assert.deepEqual(response.body, { - error: messages.INVALID_GET_MESSAGE, - }); -}); - -test("POST / rejects missing and empty url values", async () => { - const app = createTestApp(); - - await supertest(app) - .post("/") - .send({}) - .expect(400) - .expect(({ body }) => { - assert.deepEqual(body, { - error: messages.INVALID_REQUEST_MESSAGE, - }); - }); - - await supertest(app) - .post("/") - .send({ url: "" }) - .expect(400) - .expect(({ body }) => { - assert.deepEqual(body, { - error: messages.INVALID_REQUEST_MESSAGE, - }); - }); -}); - -test("configuration defaults are loaded and validated", () => { - assert.deepEqual(loadConfig({}), { - port: DEFAULTS.PORT, - requestBodyLimit: DEFAULTS.REQUEST_BODY_LIMIT, - fetchTimeoutMs: DEFAULTS.FETCH_TIMEOUT_MS, - fetchMaxBytes: DEFAULTS.FETCH_MAX_BYTES, - fetchMaxRedirects: DEFAULTS.FETCH_MAX_REDIRECTS, - blockPrivateNetworks: DEFAULTS.BLOCK_PRIVATE_NETWORKS, - readabilityMaxElems: undefined, - maxConcurrentRequests: DEFAULTS.MAX_CONCURRENT_REQUESTS, - contentFormat: DEFAULTS.CONTENT_FORMAT, - }); - - assert.throws( - () => - loadConfig({ - PORT: "70000", - }), - /PORT must be <= 65535/, - ); -}); - -test("readability options are only applied when configured", () => { - assert.equal( - createReadabilityOptions({ readabilityMaxElems: undefined }), - undefined, - ); - assert.deepEqual(createReadabilityOptions({ readabilityMaxElems: 2500 }), { - maxElemsToParse: 2500, - }); -}); - -test("POST / blocks private-network targets by default", async (t) => { - const fixture = await createFixtureServer({ - "/article": (_req, res) => { - res.setHeader("content-type", "text/html; charset=utf-8"); - res.end(` - - - Readable headline - - - -
-
-

Readable headline

-

This is the lead paragraph.

-

This is the second paragraph.

-
-
- - `); - }, - }); - - t.after(async () => { - await fixture.close(); - }); - - const response = await supertest(createTestApp()) - .post("/") - .send({ url: `${fixture.baseUrl}/article` }) - .expect(500); - - assertFetchFailureShape(response, "FETCH_PRIVATE_NETWORK_BLOCKED"); - assert.equal(response.body.details.address, "127.0.0.1"); -}); - -test("POST / returns only the explicit response fields for a readable article", async (t) => { - const fixture = await createFixtureServer({ - "/article": (_req, res) => { - res.setHeader("content-type", "text/html; charset=utf-8"); - res.end(` - - - Readable headline - - - -
-
-

Readable headline

-

This is the lead paragraph.

-

This is the second paragraph.

-
-
- - `); - }, - }); - - t.after(async () => { - await fixture.close(); - }); - - const response = await supertest( - createTestApp({ - blockPrivateNetworks: false, - }), - ) - .post("/") - .send({ url: `${fixture.baseUrl}/article` }) - .expect(200); - - assert.deepEqual(Object.keys(response.body), RESPONSE_FIELDS); - assert.equal(response.body.url, `${fixture.baseUrl}/article`); - assert.equal(response.body.title, "Readable headline"); - assert.equal(response.body.byline, null); - assert.equal(response.body.dir, null); - assert.equal(typeof response.body.content, "string"); - assert.ok(response.body.content.includes("This is the lead paragraph.")); - assert.equal(typeof response.body.length, "number"); - assert.ok(response.body.length > 0); - assert.equal(response.body.excerpt, "This is the lead paragraph."); - assert.equal(response.body.siteName, null); - assert.equal(typeof response.body.textContent, "string"); - assert.ok( - response.body.textContent.includes("This is the second paragraph."), - ); - assert.equal(response.body.lang, "en"); - assert.equal(response.body.publishedTime, "2024-01-02T03:04:05Z"); -}); - -test("POST / sanitizes returned article content", async (t) => { - const fixture = await createFixtureServer({ - "/article": (_req, res) => { - res.setHeader("content-type", "text/html; charset=utf-8"); - res.end(` - - - Unsafe headline - - -
-

Unsafe headline

-

Lead paragraph.

- - - -

Tail paragraph.

-
- - `); - }, - }); - - t.after(async () => { - await fixture.close(); - }); - - const response = await supertest( - createTestApp({ - blockPrivateNetworks: false, - }), - ) - .post("/") - .send({ url: `${fixture.baseUrl}/article` }) - .expect(200); - - assert.doesNotMatch(response.body.content, / -

Clean tail paragraph.

- - - `); - }, - }); - - t.after(async () => { - await fixture.close(); - }); - - const response = await supertest( - createTestApp({ - blockPrivateNetworks: false, - }), - ) - .post("/") - .send({ url: `${fixture.baseUrl}/article` }) - .expect(200); - - // Markdown output — no script tags, no event handlers - assert.doesNotMatch(response.body.content, / +

Clean tail paragraph.

+ + + diff --git a/test/fixtures/article-table.html b/test/fixtures/article-table.html new file mode 100644 index 0000000..e10916f --- /dev/null +++ b/test/fixtures/article-table.html @@ -0,0 +1,30 @@ + + + + Table article + + +
+

Table article

+

See the data below.

+ + + + + + + + + + + + + + + + + +
NameScore
Alice95
Bob87
+
+ + diff --git a/test/fixtures/article-unsafe.html b/test/fixtures/article-unsafe.html new file mode 100644 index 0000000..612977d --- /dev/null +++ b/test/fixtures/article-unsafe.html @@ -0,0 +1,22 @@ + + + + Unsafe headline + + +
+

Unsafe headline

+

Lead paragraph.

+ + + +

Tail paragraph.

+
+ + diff --git a/test/fixtures/article-vimeo.html b/test/fixtures/article-vimeo.html new file mode 100644 index 0000000..946259c --- /dev/null +++ b/test/fixtures/article-vimeo.html @@ -0,0 +1,16 @@ + + + + Vimeo embed + + +
+

Vimeo embed

+

Watch this video.

+ +
+ + diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..eb77a86 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,127 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); + +const { loadConfig } = require("../src/config"); +const { createApp } = require("../src/app"); + +function createTestApp(configOverrides) { + return createApp( + { + ...loadConfig({}), + ...configOverrides, + }, + { + info() {}, + error() {}, + }, + ); +} + +function createFixtureServer(routes) { + const server = http.createServer((req, res) => { + const handler = routes[req.url] || routes.default; + + if (!handler) { + res.statusCode = 404; + res.end("not found"); + return; + } + + handler(req, res); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, () => { + const address = server.address(); + + resolve({ + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((closeResolve, closeReject) => { + server.close((error) => { + if (error) { + closeReject(error); + return; + } + + closeResolve(); + }); + }), + }); + }); + }); +} + +function fixtureHtml(name) { + return fs.readFileSync( + path.join(__dirname, "fixtures", `${name}.html`), + "utf8", + ); +} + +function htmlRoute(fixtureName) { + return (_req, res) => { + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end(fixtureHtml(fixtureName)); + }; +} + +function postJson(server, payload) { + return new Promise((resolve, reject) => { + const address = server.address(); + const body = JSON.stringify(payload); + const request = http.request( + { + method: "POST", + host: "127.0.0.1", + port: address.port, + path: "/", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + }, + (response) => { + const chunks = []; + + response.on("data", (chunk) => { + chunks.push(chunk); + }); + response.on("end", () => { + resolve({ + statusCode: response.statusCode, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")), + }); + }); + }, + ); + + request.on("error", reject); + request.write(body); + request.end(); + }); +} + +function assertFetchFailureShape( + response, + expectedCode, + expectedError = "Some weird error fetching the content", +) { + assert.equal(response.body.error, expectedError); + assert.equal(typeof response.body.details, "object"); + assert.ok(response.body.details); + assert.equal(response.body.details.code, expectedCode); + assert.equal(typeof response.body.details.message, "string"); +} + +module.exports = { + assertFetchFailureShape, + createFixtureServer, + createTestApp, + fixtureHtml, + htmlRoute, + postJson, +}; diff --git a/test/markdown.test.js b/test/markdown.test.js new file mode 100644 index 0000000..bf224c2 --- /dev/null +++ b/test/markdown.test.js @@ -0,0 +1,151 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const supertest = require("supertest"); + +const { toMarkdown } = require("../src/markdown"); +const { createFixtureServer, createTestApp, htmlRoute } = require("./helpers"); + +test("POST / returns markdown content by default", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.doesNotMatch(response.body.content, /

/i); + assert.doesNotMatch(response.body.content, /

/i); + assert.match(response.body.content, /This is the lead paragraph/); +}); + +test("POST / returns HTML content when contentFormat=html is requested", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article`, contentFormat: "html" }) + .expect(200); + + assert.match(response.body.content, /

/i); + assert.match(response.body.content, /This is the lead paragraph/); +}); + +test("POST / returns markdown content when contentFormat=markdown is requested explicitly", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article`, contentFormat: "markdown" }) + .expect(200); + + assert.doesNotMatch(response.body.content, /

/i); + assert.doesNotMatch(response.body.content, /

/i); + assert.match(response.body.content, /This is the second paragraph/); +}); + +test("POST / converts Vimeo iframe to markdown embed link", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-vimeo"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.match( + response.body.content, + /\[Video: Vimeo\]\(https:\/\/player\.vimeo\.com\/video\/123456789\)/, + ); +}); + +test("POST / preserves allowed iframe and video tags in returned content", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-media"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.match( + response.body.content, + /\[Video: YouTube\]\(https:\/\/www\.youtube\.com\/embed\/abc123\)/, + ); + assert.match( + response.body.content, + /\[Video\]\(https:\/\/cdn\.example\/video\.mp4\)/, + ); +}); + +test("POST / converts HTML table to GFM markdown table", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-table"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.match(response.body.content, /\|/); + assert.match(response.body.content, /Name/); + assert.match(response.body.content, /Score/); + assert.match(response.body.content, /Alice/); + assert.doesNotMatch(response.body.content, / { + const result = toMarkdown( + '', + ); + + assert.match( + result, + /\[Embedded content\]\(https:\/\/embed\.unknown-provider\.com\/widget\/abc\)/, + ); +}); diff --git a/test/parsing.test.js b/test/parsing.test.js new file mode 100644 index 0000000..e02be94 --- /dev/null +++ b/test/parsing.test.js @@ -0,0 +1,67 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const supertest = require("supertest"); + +const { RESPONSE_FIELDS } = require("../src/response"); +const { createFixtureServer, createTestApp, htmlRoute } = require("./helpers"); + +test("POST / returns only the explicit response fields for a readable article", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.deepEqual(Object.keys(response.body), RESPONSE_FIELDS); + assert.equal(response.body.url, `${fixture.baseUrl}/article`); + assert.equal(response.body.title, "Readable headline"); + assert.equal(response.body.byline, null); + assert.equal(response.body.dir, null); + assert.equal(typeof response.body.content, "string"); + assert.ok(response.body.content.includes("This is the lead paragraph.")); + assert.equal(typeof response.body.length, "number"); + assert.ok(response.body.length > 0); + assert.equal(response.body.excerpt, "This is the lead paragraph."); + assert.equal(response.body.siteName, null); + assert.equal(typeof response.body.textContent, "string"); + assert.ok( + response.body.textContent.includes("This is the second paragraph."), + ); + assert.equal(response.body.lang, "en"); + assert.equal(response.body.publishedTime, "2024-01-02T03:04:05Z"); +}); + +test("POST / falls back to the document language in the response", async (t) => { + const fixture = await createFixtureServer({ + "/article": (_req, res) => { + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end(` + + Bonjour +

Bonjour

Texte lisible.

+ `); + }, + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article`, contentFormat: "html" }) + .expect(200); + + assert.equal(response.body.lang, "fr"); +}); diff --git a/test/redirects.test.js b/test/redirects.test.js new file mode 100644 index 0000000..7f52d58 --- /dev/null +++ b/test/redirects.test.js @@ -0,0 +1,125 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const dns = require("node:dns/promises"); +const supertest = require("supertest"); + +const { + assertFetchFailureShape, + createFixtureServer, + createTestApp, +} = require("./helpers"); + +test("POST / follows redirects when the targets remain allowed", async (t) => { + const fixture = await createFixtureServer({ + "/start": (_req, res) => { + res.statusCode = 302; + res.setHeader("location", "/article"); + res.end(); + }, + "/article": (_req, res) => { + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end( + "Redirected headline

Redirected headline

Redirect success body.

", + ); + }, + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/start` }) + .expect(200); + + assert.equal(response.body.title, "Redirected headline"); + assert.match(response.body.content, /Redirect success body/); +}); + +test("POST / rejects redirects to private networks when blocking is enabled", async (t) => { + t.after(() => { + t.mock.restoreAll(); + }); + + t.mock.method(dns, "lookup", async (hostname) => { + if (hostname === "public.example") { + return [{ address: "93.184.216.34", family: 4 }]; + } + + throw Object.assign(new Error(`unexpected hostname ${hostname}`), { + code: "ENOTFOUND", + }); + }); + + t.mock.method(global, "fetch", async () => { + return new Response(null, { + status: 302, + headers: { + location: "http://127.0.0.1/private-article", + }, + }); + }); + + const response = await supertest(createTestApp()) + .post("/") + .send({ url: "http://public.example/start" }) + .expect(500); + + assertFetchFailureShape(response, "FETCH_PRIVATE_NETWORK_BLOCKED"); + assert.equal(response.body.details.address, "127.0.0.1"); +}); + +test("POST / returns a stable redirect-limit error for redirect loops", async (t) => { + const fixture = await createFixtureServer({ + "/loop-a": (_req, res) => { + res.statusCode = 302; + res.setHeader("location", "/loop-b"); + res.end(); + }, + "/loop-b": (_req, res) => { + res.statusCode = 302; + res.setHeader("location", "/loop-a"); + res.end(); + }, + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false, fetchMaxRedirects: 1 }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/loop-a` }) + .expect(500); + + assertFetchFailureShape(response, "FETCH_REDIRECT_LIMIT_EXCEEDED"); + assert.equal(response.body.details.maxRedirects, 1); +}); + +test("POST / returns a stable error when a redirect omits the Location header", async (t) => { + const fixture = await createFixtureServer({ + "/start": (_req, res) => { + res.statusCode = 302; + res.end(); + }, + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/start` }) + .expect(500); + + assertFetchFailureShape(response, "FETCH_REDIRECT_WITHOUT_LOCATION"); + assert.equal(response.body.details.status, 302); +}); diff --git a/test/security.test.js b/test/security.test.js new file mode 100644 index 0000000..5d99f8f --- /dev/null +++ b/test/security.test.js @@ -0,0 +1,76 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const supertest = require("supertest"); + +const { + assertFetchFailureShape, + createFixtureServer, + createTestApp, + htmlRoute, +} = require("./helpers"); + +test("POST / blocks private-network targets by default", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest(createTestApp()) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(500); + + assertFetchFailureShape(response, "FETCH_PRIVATE_NETWORK_BLOCKED"); + assert.equal(response.body.details.address, "127.0.0.1"); +}); + +test("POST / sanitizes returned article content", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-unsafe"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.doesNotMatch(response.body.content, /