diff --git a/AGENTS.md b/AGENTS.md index 6ff36ba8..4fba70ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,10 +55,13 @@ npm run docs:build ``` ## Testing -Vitest runs in jsdom and aliases `obsidian` to `tests/mocks/obsidian.ts`. -Prefer unit tests for pure utility, parser, store, API, and component behavior. -Use Testing Library for Svelte component behavior instead of asserting on -implementation details. +Vitest runs in Node and aliases `obsidian` to `tests/mocks/obsidian.ts`. A test +file that needs a DOM or loads Svelte code starts with +`// @vitest-environment jsdom`; leave it off otherwise, because jsdom setup is +the suite's largest cost. A Node test that loads Svelte code or reads `window` +or `document` fails with that hint. Prefer unit tests for pure utility, parser, +store, API, and component behavior. Use Testing Library for Svelte component +behavior instead of asserting on implementation details. When a bug depends on real Obsidian runtime behavior, reproduce it in Obsidian before changing code and verify it there after the fix. Timestamp links, URI diff --git a/README.md b/README.md index fc1f5f4b..0cc57962 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Other installation options can be found in the [documentation](https://chhoumann ## Development -- `npm run test` runs the accessibility checks and the jsdom/unit test suite. +- `npm run test` runs the accessibility checks and the unit test suite. - `npm run build` type-checks and bundles the plugin. - `npm run test:e2e` builds the plugin, then runs the local Obsidian-backed E2E suite. diff --git a/package-lock.json b/package-lock.json index 67fed913..48e38adb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "svelte-check": "^4.7.6", "typescript": "6.0.3", "vite": "^8.3.2", - "vitest": "^4.1.11", + "vitest": "^5.0.3", "wrangler": "4.149.0" }, "engines": { @@ -2994,7 +2994,8 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.13", @@ -3432,34 +3433,17 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3477,74 +3461,26 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@voidzero-dev/vite-plus-darwin-arm64": { "version": "0.1.24", "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.1.24.tgz", @@ -4223,9 +4159,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -4265,13 +4201,6 @@ "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/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -7467,15 +7396,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "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/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/openai": { "version": "7.25.0", @@ -8418,13 +8350,6 @@ "url": "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/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -8451,17 +8376,10 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -8753,12 +8671,13 @@ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -8811,16 +8730,6 @@ "node": "^20.0.0 || >=22.0.0" } }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tldts": { "version": "7.4.10", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", @@ -10537,38 +10446,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -10576,16 +10478,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -10627,12 +10529,32 @@ } }, "node_modules/vitest/node_modules/es-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", - "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/vitest/node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -10795,20 +10717,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { diff --git a/package.json b/package.json index 78635b3a..3ae4fe5c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "check:a11y": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings && svelte-check --tsconfig ./tsconfig.test.json --fail-on-warnings", "docs:build": "mkdocs build -f docs/mkdocs.yml -d site", "docs:deploy": "npm run docs:build && wrangler pages deploy docs/site --project-name podnotes --branch master", - "format": "oxfmt src tests scripts .github package.json manifest.json versions.json tsconfig.base.json tsconfig.json tsconfig.test.json .oxlintrc.json .oxfmtrc.json vite.config.ts vitest.config.ts vitest.e2e.config.ts vitest.setup.ts obsidian-e2e.config.mjs wrangler.jsonc docs/mkdocs.yml" + "format": "oxfmt src tests scripts .github package.json manifest.json versions.json tsconfig.base.json tsconfig.json tsconfig.test.json .oxlintrc.json .oxfmtrc.json vite.config.ts vitest.config.ts vitest.e2e.config.ts vitest.setup.ts vitest.setup.dom.ts obsidian-e2e.config.mjs wrangler.jsonc docs/mkdocs.yml" }, "dependencies": { "fuse.js": "^7.5.0", @@ -46,7 +46,7 @@ "svelte-check": "^4.7.6", "typescript": "6.0.3", "vite": "^8.3.2", - "vitest": "^4.1.11", + "vitest": "^5.0.3", "wrangler": "4.149.0" }, "overrides": { diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index 122887e2..2691df4e 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DownloadPathTemplateEngine, diff --git a/src/download/streaming.test.ts b/src/download/streaming.test.ts index a7f966fb..19c790ba 100644 --- a/src/download/streaming.test.ts +++ b/src/download/streaming.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { requestUrl } from "obsidian"; import { plugin } from "../store"; diff --git a/src/downloadEpisode.test.ts b/src/downloadEpisode.test.ts index cffb19f3..1b1adfa8 100644 --- a/src/downloadEpisode.test.ts +++ b/src/downloadEpisode.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { get } from "svelte/store"; import { Notice, requestUrl, TFile } from "obsidian"; diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index 863e311e..6d6b10d0 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import PodNotes from "./main"; import { VIEW_TYPE } from "./constants"; diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 36952ea0..da9af010 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import type { SecretStorage } from "obsidian"; import { DEFAULT_SETTINGS } from "./constants"; diff --git a/src/opml.test.ts b/src/opml.test.ts index 03c8fc00..e52fed0f 100644 --- a/src/opml.test.ts +++ b/src/opml.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { get, writable } from "svelte/store"; import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; import type { PodcastFeed } from "./types/PodcastFeed"; diff --git a/src/parser/feedDocumentParser.test.ts b/src/parser/feedDocumentParser.test.ts index 8ba43f86..e4f250a8 100644 --- a/src/parser/feedDocumentParser.test.ts +++ b/src/parser/feedDocumentParser.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { describe, expect, it } from "vitest"; import FeedDocumentParser from "./feedDocumentParser"; diff --git a/src/parser/feedParser.test.ts b/src/parser/feedParser.test.ts index 12bcb087..1091faa1 100644 --- a/src/parser/feedParser.test.ts +++ b/src/parser/feedParser.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { describe, expect, test, vi, beforeEach } from "vitest"; import FeedParser from "./feedParser"; import type { FeedDocumentSource } from "./feedDocumentSource"; diff --git a/src/services/FeedCacheService.test.ts b/src/services/FeedCacheService.test.ts index 52275ed9..8bc5369a 100644 --- a/src/services/FeedCacheService.test.ts +++ b/src/services/FeedCacheService.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { beforeEach, describe, expect, test, vi } from "vitest"; import type { Episode } from "src/types/Episode"; diff --git a/src/services/TranscriptionService.test.ts b/src/services/TranscriptionService.test.ts index 63a673af..200c9258 100644 --- a/src/services/TranscriptionService.test.ts +++ b/src/services/TranscriptionService.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { describe, expect, test, vi, beforeEach } from "vitest"; import { Notice } from "obsidian"; import { TranscriptionService } from "./TranscriptionService"; diff --git a/src/services/audioChunker.test.ts b/src/services/audioChunker.test.ts index 865d0694..b0c527d2 100644 --- a/src/services/audioChunker.test.ts +++ b/src/services/audioChunker.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, describe, expect, test, vi } from "vitest"; import { CHUNK_SIZE_BYTES, diff --git a/src/services/diarization/deepgramProvider.test.ts b/src/services/diarization/deepgramProvider.test.ts index 54e572e9..5dfc07ee 100644 --- a/src/services/diarization/deepgramProvider.test.ts +++ b/src/services/diarization/deepgramProvider.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RequestUrlResponse } from "obsidian"; diff --git a/src/ui/PodcastView/ChapterList.test.ts b/src/ui/PodcastView/ChapterList.test.ts index ad813ea0..1bb6c747 100644 --- a/src/ui/PodcastView/ChapterList.test.ts +++ b/src/ui/PodcastView/ChapterList.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render } from "@testing-library/svelte"; import { describe, expect, test, vi } from "vitest"; diff --git a/src/ui/PodcastView/EpisodePlayer.test.ts b/src/ui/PodcastView/EpisodePlayer.test.ts index 7de6e3d8..01734fa6 100644 --- a/src/ui/PodcastView/EpisodePlayer.test.ts +++ b/src/ui/PodcastView/EpisodePlayer.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, waitFor } from "@testing-library/svelte"; import { TFile } from "obsidian"; import { get } from "svelte/store"; diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 9297a57e..85efc26a 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import { get } from "svelte/store"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; diff --git a/src/ui/PodcastView/TopBar.test.ts b/src/ui/PodcastView/TopBar.test.ts index 73008753..2e06c22d 100644 --- a/src/ui/PodcastView/TopBar.test.ts +++ b/src/ui/PodcastView/TopBar.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render } from "@testing-library/svelte"; import { describe, expect, test } from "vitest"; diff --git a/src/ui/PodcastView/index.test.ts b/src/ui/PodcastView/index.test.ts index cab022ca..97bfa018 100644 --- a/src/ui/PodcastView/index.test.ts +++ b/src/ui/PodcastView/index.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import type { WorkspaceLeaf } from "obsidian"; import type { IPodNotes } from "../../types/IPodNotes"; diff --git a/src/ui/PodcastView/spawnEpisodeContextMenu.test.ts b/src/ui/PodcastView/spawnEpisodeContextMenu.test.ts index 6adb9ea2..5f6671f1 100644 --- a/src/ui/PodcastView/spawnEpisodeContextMenu.test.ts +++ b/src/ui/PodcastView/spawnEpisodeContextMenu.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { Menu } from "obsidian"; import { beforeEach, describe, expect, test, vi } from "vitest"; diff --git a/src/ui/QueueReorderModal.test.ts b/src/ui/QueueReorderModal.test.ts index e0b8045e..0de85fcf 100644 --- a/src/ui/QueueReorderModal.test.ts +++ b/src/ui/QueueReorderModal.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { App } from "obsidian"; import { beforeEach, describe, expect, test } from "vitest"; diff --git a/src/ui/ReorderQueue.test.ts b/src/ui/ReorderQueue.test.ts index 3e3f0ca1..2ee2c442 100644 --- a/src/ui/ReorderQueue.test.ts +++ b/src/ui/ReorderQueue.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render } from "@testing-library/svelte"; import { get } from "svelte/store"; import { beforeEach, describe, expect, test, vi } from "vitest"; diff --git a/src/ui/TimerNotice.test.ts b/src/ui/TimerNotice.test.ts index 7ecf41a9..706a9535 100644 --- a/src/ui/TimerNotice.test.ts +++ b/src/ui/TimerNotice.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { Notice } from "obsidian"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TimerNotice } from "./TimerNotice"; diff --git a/src/ui/common/Image.test.ts b/src/ui/common/Image.test.ts index e8085c19..1681e148 100644 --- a/src/ui/common/Image.test.ts +++ b/src/ui/common/Image.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/svelte"; import { tick } from "svelte"; import { describe, expect, test, vi } from "vitest"; diff --git a/src/ui/common/Progressbar.test.ts b/src/ui/common/Progressbar.test.ts index 4f718410..7555f4f2 100644 --- a/src/ui/common/Progressbar.test.ts +++ b/src/ui/common/Progressbar.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render } from "@testing-library/svelte"; import { describe, expect, test, vi } from "vitest"; diff --git a/src/ui/settings/PlaylistManager.test.ts b/src/ui/settings/PlaylistManager.test.ts index fc088387..248c82b9 100644 --- a/src/ui/settings/PlaylistManager.test.ts +++ b/src/ui/settings/PlaylistManager.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render } from "@testing-library/svelte"; import { get } from "svelte/store"; import * as obsidian from "obsidian"; diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index b4650a33..f654aed5 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { get } from "svelte/store"; import { describe, expect, it, vi } from "vitest"; import type { App } from "obsidian"; diff --git a/src/ui/settings/PodcastResultCard.test.ts b/src/ui/settings/PodcastResultCard.test.ts index 535e4e55..aea13144 100644 --- a/src/ui/settings/PodcastResultCard.test.ts +++ b/src/ui/settings/PodcastResultCard.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render } from "@testing-library/svelte"; import { describe, expect, test } from "vitest"; diff --git a/src/utility/networkRequest.test.ts b/src/utility/networkRequest.test.ts index 2db30e29..47858143 100644 --- a/src/utility/networkRequest.test.ts +++ b/src/utility/networkRequest.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RequestUrlResponse } from "obsidian"; diff --git a/tsconfig.test.json b/tsconfig.test.json index 1c6a7b70..66dde1e8 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -5,7 +5,13 @@ "allowJs": true, "checkJs": true, "lib": ["DOM", "ES2021"], - "types": ["svelte", "node", "obsidian", "vitest/globals", "@testing-library/jest-dom"] + "types": [ + "svelte", + "node", + "obsidian", + "vitest/globals", + "@testing-library/jest-dom/vitest" + ] }, "include": [ "src/**/*.ts", diff --git a/vitest.config.ts b/vitest.config.ts index 1f39107d..2cba7ed6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,24 @@ import * as path from "node:path"; import { svelte } from "@sveltejs/vite-plugin-svelte"; +import type { Plugin } from "vite"; import { defineConfig } from "vitest/config"; +// Node tests compile Svelte for the server, where $state is a plain object, not +// a proxy. Fail loudly instead of letting a test check server semantics. +const svelteNeedsJsdom: Plugin = { + name: "svelte-needs-jsdom", + enforce: "pre", + transform(_code, id) { + if (this.environment.name !== "client" && /\.svelte(\.[jt]s)?$/.test(id.split("?")[0])) { + this.error( + `${id} is Svelte code; add \`// @vitest-environment jsdom\` to the test file that loads it.`, + ); + } + }, +}; + export default defineConfig({ - plugins: [svelte()], + plugins: [svelteNeedsJsdom, svelte()], resolve: { alias: { src: path.resolve("./src"), @@ -17,7 +32,9 @@ export default defineConfig({ "scripts/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", ], globals: true, - environment: "jsdom", + // Files that need a DOM or load Svelte code opt in with + // `// @vitest-environment jsdom`. Creating a jsdom environment per file is + // the suite's largest cost. environmentOptions: { jsdom: { url: "https://podnotes.test/", diff --git a/vitest.setup.dom.ts b/vitest.setup.dom.ts new file mode 100644 index 00000000..81cc2233 --- /dev/null +++ b/vitest.setup.dom.ts @@ -0,0 +1,220 @@ +import "@testing-library/jest-dom/vitest"; + +function createMemoryStorage(): Storage { + const items = new Map(); + + return { + get length() { + return items.size; + }, + clear: () => { + items.clear(); + }, + getItem: (key: string) => items.get(key) ?? null, + key: (index: number) => Array.from(items.keys())[index] ?? null, + removeItem: (key: string) => { + items.delete(key); + }, + setItem: (key: string, value: string) => { + items.set(key, value); + }, + }; +} + +function ensureLocalStorage(): void { + let storage: Storage; + + try { + storage = window.localStorage; + } catch { + storage = createMemoryStorage(); + } + + if (!storage) { + storage = createMemoryStorage(); + } + + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }); + + Object.defineProperty(window, "localStorage", { + configurable: true, + value: storage, + }); +} + +ensureLocalStorage(); + +// Obsidian exposes an `activeDocument` global that resolves to the currently +// focused (possibly popped-out) document. In jsdom there is a single document, +// so point it at the test global for code that prefers it over the bare +// `document` for popout-window compatibility. +if (typeof (globalThis as { activeDocument?: unknown }).activeDocument === "undefined") { + Object.defineProperty(globalThis, "activeDocument", { + configurable: true, + get: () => document, + }); +} + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + +if (!(Element.prototype as unknown as { instanceOf?: unknown }).instanceOf) { + ( + Element.prototype as unknown as { + instanceOf: (constructor: typeof Element) => boolean; + } + ).instanceOf = function (this: Element, constructor: typeof Element): boolean { + const localConstructor = this.ownerDocument.defaultView?.[ + constructor.name as keyof Window + ] as unknown; + const elementConstructor = + typeof localConstructor === "function" ? localConstructor : constructor; + return this instanceof elementConstructor; + }; +} + +if ( + !(HTMLElement.prototype as unknown as { setAttr?: (name: string, value: string) => void }) + .setAttr +) { + ( + HTMLElement.prototype as unknown as { setAttr: (name: string, value: string) => void } + ).setAttr = function (this: HTMLElement, name: string, value: string) { + this.setAttribute(name, value); + }; +} + +if (!(HTMLElement.prototype as unknown as { setText?: (text: string) => void }).setText) { + (HTMLElement.prototype as unknown as { setText: (text: string) => void }).setText = function ( + this: HTMLElement, + text: string, + ) { + this.textContent = text; + }; +} + +type ObsidianDomContainer = HTMLElement | DocumentFragment; +type CreateElOptions = { text?: string; cls?: string }; + +function installCreateEl(proto: object): void { + const helpers = proto as { + createEl?: (tag: keyof HTMLElementTagNameMap, options?: CreateElOptions) => HTMLElement; + createDiv?: (options?: CreateElOptions) => HTMLDivElement; + createSpan?: (options?: CreateElOptions) => HTMLSpanElement; + }; + + if (!helpers.createEl) { + helpers.createEl = function ( + this: ObsidianDomContainer, + tag: keyof HTMLElementTagNameMap, + options: CreateElOptions = {}, + ) { + const el = document.createElement(tag); + if (options.text !== undefined) el.textContent = options.text; + if (options.cls) el.className = options.cls; + this.appendChild(el); + return el; + }; + } + + if (!helpers.createDiv) { + helpers.createDiv = function (this: ObsidianDomContainer, options: CreateElOptions = {}) { + const createEl = ( + this as ObsidianDomContainer & { + createEl: ( + tag: keyof HTMLElementTagNameMap, + options?: CreateElOptions, + ) => HTMLElement; + } + ).createEl; + return createEl.call(this, "div", options) as HTMLDivElement; + }; + } + + if (!helpers.createSpan) { + helpers.createSpan = function (this: ObsidianDomContainer, options: CreateElOptions = {}) { + const createEl = ( + this as ObsidianDomContainer & { + createEl: ( + tag: keyof HTMLElementTagNameMap, + options?: CreateElOptions, + ) => HTMLElement; + } + ).createEl; + return createEl.call(this, "span", options) as HTMLSpanElement; + }; + } +} + +installCreateEl(HTMLElement.prototype); +installCreateEl(DocumentFragment.prototype); + +if (!(HTMLElement.prototype as unknown as { empty?: () => void }).empty) { + (HTMLElement.prototype as unknown as { empty: () => void }).empty = function ( + this: HTMLElement, + ) { + while (this.firstChild) { + this.removeChild(this.firstChild); + } + }; +} + +// Obsidian augments HTMLElement with setCssStyles (assigns a batch of inline +// styles, the sanctioned alternative to direct `el.style.x = y` writes). jsdom +// has no such method, so mirror Obsidian's behaviour for component/DOM tests. +if (!(HTMLElement.prototype as unknown as { setCssStyles?: unknown }).setCssStyles) { + ( + HTMLElement.prototype as unknown as { + setCssStyles: (styles: Partial) => void; + } + ).setCssStyles = function (this: HTMLElement, styles: Partial) { + Object.assign(this.style, styles); + }; +} + +// jsdom does not implement the Web Animations API, which Svelte 5 transitions +// (e.g. transition:fade) rely on. Provide a minimal mock so components that use +// transitions can be rendered and asserted on in component tests. +// +// Known fidelity gaps (acceptable for the current suite, which only renders +// CSS fade transitions): `onfinish` fires immediately on a microtask rather +// than after the real duration, `playState` is always "finished", and +// `finished` is pre-resolved and ignores `cancel()`. If a future test needs to +// assert mid-transition or outro-timing behaviour, replace this with a fuller +// fake (e.g. a timer-driven animation) instead of relying on these defaults. +if (!Element.prototype.animate) { + (Element.prototype as unknown as { animate: () => Animation }).animate = function () { + let onfinish: (() => void) | null = null; + const animation = { + cancel() {}, + finish() {}, + play() {}, + pause() {}, + reverse() {}, + currentTime: 0, + startTime: 0, + playbackRate: 1, + playState: "finished", + finished: Promise.resolve(), + effect: null, + addEventListener() {}, + removeEventListener() {}, + get onfinish() { + return onfinish; + }, + set onfinish(fn: (() => void) | null) { + onfinish = fn; + if (fn) { + queueMicrotask(() => fn()); + } + }, + oncancel: null, + }; + + return animation as unknown as Animation; + }; +} diff --git a/vitest.setup.ts b/vitest.setup.ts index b27ca55c..af8c2ef8 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,220 +1,47 @@ -import "@testing-library/jest-dom"; - -function createMemoryStorage(): Storage { - const items = new Map(); - - return { - get length() { - return items.size; - }, - clear: () => { - items.clear(); - }, - getItem: (key: string) => items.get(key) ?? null, - key: (index: number) => Array.from(items.keys())[index] ?? null, - removeItem: (key: string) => { - items.delete(key); - }, - setItem: (key: string, value: string) => { - items.set(key, value); - }, - }; -} - -function ensureLocalStorage(): void { - let storage: Storage; - - try { - storage = window.localStorage; - } catch { - storage = createMemoryStorage(); - } - - if (!storage) { - storage = createMemoryStorage(); - } - - Object.defineProperty(globalThis, "localStorage", { - configurable: true, - value: storage, - }); - - Object.defineProperty(window, "localStorage", { - configurable: true, - value: storage, - }); -} - -ensureLocalStorage(); - -// Obsidian exposes an `activeDocument` global that resolves to the currently -// focused (possibly popped-out) document. In jsdom there is a single document, -// so point it at the test global for code that prefers it over the bare -// `document` for popout-window compatibility. -if (typeof (globalThis as { activeDocument?: unknown }).activeDocument === "undefined") { - Object.defineProperty(globalThis, "activeDocument", { - configurable: true, - get: () => document, - }); -} - -if (!Element.prototype.scrollIntoView) { - Element.prototype.scrollIntoView = () => {}; -} - -if (!(Element.prototype as unknown as { instanceOf?: unknown }).instanceOf) { - ( - Element.prototype as unknown as { - instanceOf: (constructor: typeof Element) => boolean; - } - ).instanceOf = function (this: Element, constructor: typeof Element): boolean { - const localConstructor = this.ownerDocument.defaultView?.[ - constructor.name as keyof Window - ] as unknown; - const elementConstructor = - typeof localConstructor === "function" ? localConstructor : constructor; - return this instanceof elementConstructor; - }; -} - -if ( - !(HTMLElement.prototype as unknown as { setAttr?: (name: string, value: string) => void }) - .setAttr -) { - ( - HTMLElement.prototype as unknown as { setAttr: (name: string, value: string) => void } - ).setAttr = function (this: HTMLElement, name: string, value: string) { - this.setAttribute(name, value); - }; -} - -if (!(HTMLElement.prototype as unknown as { setText?: (text: string) => void }).setText) { - (HTMLElement.prototype as unknown as { setText: (text: string) => void }).setText = function ( - this: HTMLElement, - text: string, - ) { - this.textContent = text; - }; -} - -type ObsidianDomContainer = HTMLElement | DocumentFragment; -type CreateElOptions = { text?: string; cls?: string }; - -function installCreateEl(proto: object): void { - const helpers = proto as { - createEl?: (tag: keyof HTMLElementTagNameMap, options?: CreateElOptions) => HTMLElement; - createDiv?: (options?: CreateElOptions) => HTMLDivElement; - createSpan?: (options?: CreateElOptions) => HTMLSpanElement; - }; - - if (!helpers.createEl) { - helpers.createEl = function ( - this: ObsidianDomContainer, - tag: keyof HTMLElementTagNameMap, - options: CreateElOptions = {}, - ) { - const el = document.createElement(tag); - if (options.text !== undefined) el.textContent = options.text; - if (options.cls) el.className = options.cls; - this.appendChild(el); - return el; - }; - } - - if (!helpers.createDiv) { - helpers.createDiv = function (this: ObsidianDomContainer, options: CreateElOptions = {}) { - const createEl = ( - this as ObsidianDomContainer & { - createEl: ( - tag: keyof HTMLElementTagNameMap, - options?: CreateElOptions, - ) => HTMLElement; - } - ).createEl; - return createEl.call(this, "div", options) as HTMLDivElement; - }; - } - - if (!helpers.createSpan) { - helpers.createSpan = function (this: ObsidianDomContainer, options: CreateElOptions = {}) { - const createEl = ( - this as ObsidianDomContainer & { - createEl: ( - tag: keyof HTMLElementTagNameMap, - options?: CreateElOptions, - ) => HTMLElement; - } - ).createEl; - return createEl.call(this, "span", options) as HTMLSpanElement; - }; - } -} - -installCreateEl(HTMLElement.prototype); -installCreateEl(DocumentFragment.prototype); - -if (!(HTMLElement.prototype as unknown as { empty?: () => void }).empty) { - (HTMLElement.prototype as unknown as { empty: () => void }).empty = function ( - this: HTMLElement, - ) { - while (this.firstChild) { - this.removeChild(this.firstChild); - } - }; -} - -// Obsidian augments HTMLElement with setCssStyles (assigns a batch of inline -// styles, the sanctioned alternative to direct `el.style.x = y` writes). jsdom -// has no such method, so mirror Obsidian's behaviour for component/DOM tests. -if (!(HTMLElement.prototype as unknown as { setCssStyles?: unknown }).setCssStyles) { - ( - HTMLElement.prototype as unknown as { - setCssStyles: (styles: Partial) => void; - } - ).setCssStyles = function (this: HTMLElement, styles: Partial) { - Object.assign(this.style, styles); - }; -} - -// jsdom does not implement the Web Animations API, which Svelte 5 transitions -// (e.g. transition:fade) rely on. Provide a minimal mock so components that use -// transitions can be rendered and asserted on in component tests. -// -// Known fidelity gaps (acceptable for the current suite, which only renders -// CSS fade transitions): `onfinish` fires immediately on a microtask rather -// than after the real duration, `playState` is always "finished", and -// `finished` is pre-resolved and ignores `cancel()`. If a future test needs to -// assert mid-transition or outro-timing behaviour, replace this with a fuller -// fake (e.g. a timer-driven animation) instead of relying on these defaults. -if (!Element.prototype.animate) { - (Element.prototype as unknown as { animate: () => Animation }).animate = function () { - let onfinish: (() => void) | null = null; - const animation = { - cancel() {}, - finish() {}, - play() {}, - pause() {}, - reverse() {}, - currentTime: 0, - startTime: 0, - playbackRate: 1, - playState: "finished", - finished: Promise.resolve(), - effect: null, - addEventListener() {}, - removeEventListener() {}, - get onfinish() { - return onfinish; - }, - set onfinish(fn: (() => void) | null) { - onfinish = fn; - if (fn) { - queueMicrotask(() => fn()); +import { afterAll, beforeEach } from "vitest"; + +if (typeof window !== "undefined") { + await import("./vitest.setup.dom"); +} else { + // Svelte's store runtime reads `document` once when it loads. Load it before + // the trap below, so that read never counts. + await import("svelte/store"); + + // Code that guards its DOM path with `typeof window` or `typeof document` + // would skip that path under Node, so a test could pass without running it. + // Fail it instead. Reads by Vitest itself, such as its object formatter + // probing `window`, do not count. + let firstRead: Error | undefined; + for (const name of ["window", "document"]) { + Object.defineProperty(globalThis, name, { + configurable: true, + get: function read() { + if (firstRead) return undefined; + // The formatter reads `window` once per printed object, so find the + // reader from a one-frame stack and build the full error only for ours. + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 1; + const reader: { stack?: string } = {}; + Error.captureStackTrace(reader, read); + Error.stackTraceLimit = stackTraceLimit; + if (/[\\/]node_modules[\\/]@?vitest[\\/]/.test(reader.stack ?? "")) { + return undefined; } + firstRead = new Error( + `This test reads \`${name}\`; add \`// @vitest-environment jsdom\` to its file.`, + ); + Error.captureStackTrace(firstRead, read); + return undefined; }, - oncancel: null, - }; - - return animation as unknown as Animation; - }; + }); + } + const failOnRead = () => { + const read = firstRead; + firstRead = undefined; + if (read) throw read; + }; + // Finish hooks run after the test's own afterEach and onTestFinished + // callbacks, so a read there fails the test that made it. + beforeEach(({ onTestFinished }) => onTestFinished(failOnRead)); + afterAll(failOnRead); }