From 0a66cbdebf0b898fdf98192832906cfc745b618b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:18:51 +0200 Subject: [PATCH 01/22] chore: open branch for spec #10 Co-Authored-By: Claude Opus 5 (1M context) From 83088a84ef84a04b708aa756f8cbfb1d01bdd5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:28:06 +0200 Subject: [PATCH 02/22] test: split the suite into a pure tier and a simulated-DOM tier The runner was configured with no DOM on purpose, so that the code-block pipeline could not quietly start depending on one. That has held, and it also meant four modules could not be tested at all, because the only way to reach any of them is through a document. Keep the constraint and make it local instead of global. Two named projects. `node` keeps the existing tests and keeps having no document, and it claims any test file dropped straight into `tests/` that no other tier has taken, so the strict tier is what an unfiled test gets rather than something to remember. `dom` runs jsdom over `tests/dom/`, which is where a test that legitimately needs a document goes. `pnpm test` runs both; `pnpm test:node` runs the pure tier alone for an edit loop. The boundary is the directory rather than a per-file environment pragma: a pragma would satisfy the letter of the config while losing the property it exists for, since the test would still pass and the line that handed the pipeline a document would be one line in a header nobody reads twice. `tests/thunderbird/` is already declined by the node tier so that the real-Thunderbird tier can arrive as a project of its own without reopening this. Coverage becomes available and is never gated. No threshold is configured and none is meant to be: this project leaves whole modules uncovered on purpose, so a number here would be one somebody tunes down until it agrees with whatever the last commit did. The vendored highlight.js is left out, since the pipeline's tests import it directly and it would otherwise bury this project's own numbers. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + package.json | 4 + pnpm-lock.yaml | 410 +++++++++++++++++++++++++- scripts/package.sh | 5 +- tests/dom/tier.test.js | 45 +++ tests/{ => node}/code-block.test.js | 4 +- tests/{ => node}/manifest.test.js | 2 +- tests/{ => node}/settings.test.js | 4 +- tests/{ => node}/snippet-size.test.js | 2 +- tests/{ => node}/styles.test.js | 2 +- tests/node/tier.test.js | 25 ++ tests/{ => node}/updates.test.js | 4 +- tests/{ => node}/version.test.js | 2 +- vitest.config.js | 81 ++++- 14 files changed, 572 insertions(+), 22 deletions(-) create mode 100644 tests/dom/tier.test.js rename tests/{ => node}/code-block.test.js (99%) rename tests/{ => node}/manifest.test.js (99%) rename tests/{ => node}/settings.test.js (98%) rename tests/{ => node}/snippet-size.test.js (98%) rename tests/{ => node}/styles.test.js (99%) create mode 100644 tests/node/tier.test.js rename tests/{ => node}/updates.test.js (98%) rename tests/{ => node}/version.test.js (96%) diff --git a/.gitignore b/.gitignore index db50608..514f888 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ node_modules/ *.xpi dist/ +# Coverage reports. Written by `pnpm coverage`, read once, never committed - +# nothing is gated on them, so there is nothing here worth keeping. +coverage/ + .DS_Store diff --git a/package.json b/package.json index fb886d5..3f1a42c 100644 --- a/package.json +++ b/package.json @@ -7,15 +7,19 @@ "packageManager": "pnpm@12.3.4", "scripts": { "test": "vitest run", + "test:node": "vitest run --project node", "test:watch": "vitest", + "coverage": "vitest run --coverage", "package": "bash scripts/package.sh", "lint": "bash scripts/lint.sh", "changelog": "git-cliff --unreleased --strip all" }, "devDependencies": { + "@vitest/coverage-v8": "^5.0.0", "addons-linter": "^10.10.0", "git-cliff": "^2.13.1", "highlight.js": "11.12.0", + "jsdom": "^30.0.1", "vitest": "^5.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3f2afc..afebf9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: .: devDependencies: + '@vitest/coverage-v8': + specifier: ^5.0.0 + version: 5.0.0(vitest@5.0.0) addons-linter: specifier: ^10.10.0 version: 10.10.0(supports-color@7.2.0) @@ -118,12 +121,84 @@ importers: highlight.js: specifier: 11.12.0 version: 11.12.0 + jsdom: + specifier: ^30.0.1 + version: 30.0.1 vitest: specifier: ^5.0.0 - version: 5.0.0(vite@8.2.2) + version: 5.0.0(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2) packages: + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -162,6 +237,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fluent/syntax@0.19.0': resolution: {integrity: sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==} engines: {node: '>=14.0.0', npm: '>=7.0.0'} @@ -327,6 +411,23 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@vitest/coverage-v8@5.0.0': + resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + peerDependencies: + '@vitest/browser': 5.0.0 + vitest: 5.0.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.1': + resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.1': + resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} + engines: {node: '>=22'} + '@vitest/mocker@5.0.0': resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} peerDependencies: @@ -391,6 +492,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.6: + resolution: {integrity: sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -398,6 +502,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -470,6 +577,10 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -479,6 +590,9 @@ packages: supports-color: optional: true + 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==} @@ -527,6 +641,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -721,6 +839,10 @@ packages: resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} engines: {node: '>=12.0.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -765,6 +887,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -779,10 +904,22 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-yaml@4.3.2: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -917,9 +1054,16 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + magic-string@1.2.3: resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -981,6 +1125,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1069,6 +1216,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1141,6 +1292,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -1157,6 +1311,25 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1165,6 +1338,10 @@ packages: resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -1264,9 +1441,17 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -1276,6 +1461,18 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1294,6 +1491,13 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1320,6 +1524,64 @@ packages: snapshots: + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4(supports-color@7.2.0))': dependencies: eslint: 9.39.4(supports-color@7.2.0) @@ -1366,6 +1628,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@fluent/syntax@0.19.0': {} '@fregante/relaxed-json@2.0.0': {} @@ -1463,6 +1727,24 @@ snapshots: '@types/json-schema@7.0.15': {} + '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/istanbul-lib-report': 1.0.1 + ast-v8-to-istanbul: 1.0.6 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2) + + '@vitest/istanbul-lib-coverage@1.0.1': {} + + '@vitest/istanbul-lib-report@1.0.1': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/mocker@5.0.0(vite@8.2.2)': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -1548,10 +1830,20 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + atomic-sleep@1.0.0: {} balanced-match@1.0.2: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + boolbase@1.0.0: {} brace-expansion@1.1.18: @@ -1639,12 +1931,21 @@ snapshots: css-what@6.2.2: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 7.2.0 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -1690,6 +1991,8 @@ snapshots: entities@7.0.1: {} + entities@8.1.0: {} + es-module-lexer@2.3.2: {} escalade@3.2.0: {} @@ -1882,6 +2185,12 @@ snapshots: highlight.js@11.12.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -1916,6 +2225,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-stream@4.0.1: {} is-unicode-supported@2.1.0: {} @@ -1924,10 +2235,38 @@ snapshots: isexe@2.0.0: {} + js-tokens@10.0.0: {} + js-yaml@4.3.2: dependencies: argparse: 2.0.1 + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-buffer@3.0.1: {} json-merge-patch@1.0.2: @@ -2042,10 +2381,18 @@ snapshots: lodash.once@4.1.1: {} + lru-cache@11.5.2: {} + magic-string@1.2.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + mdn-data@2.27.1: {} minimatch@3.1.5: @@ -2107,6 +2454,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2194,6 +2545,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.5: {} shebang-command@2.0.0: @@ -2252,6 +2607,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -2265,12 +2622,30 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyrainbow@3.1.1: {} + + tldts-core@7.4.12: {} + + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 undici@7.29.1: {} + undici@8.10.2: {} + unicorn-magic@0.3.0: {} upath@3.0.7: {} @@ -2291,7 +2666,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - vitest@5.0.0(vite@8.2.2): + vitest@5.0.0(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(vite@8.2.2) @@ -2307,19 +2682,46 @@ snapshots: tinyglobby: 0.2.17 vite: 8.2.2 why-is-node-running: 2.3.0 + optionalDependencies: + '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) + jsdom: 30.0.1 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 + webidl-conversions@8.0.1: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2337,6 +2739,10 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yargs-parser@21.1.1: {} diff --git a/scripts/package.sh b/scripts/package.sh index e337d19..18145e2 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -39,9 +39,12 @@ exclusions=( 'node_modules/*' 'pnpm-lock.yaml' 'package.json' - # The test suite and its runner config are dev-only. + # The test suite, its runner config and any coverage report it left behind + # are dev-only. The report matters here because it is written into the + # working tree, which is what this script zips. 'tests/*' 'vitest.config.js' + 'coverage/*' # This script and anything else that builds rather than ships. 'scripts/*' # Its own output, and any archive left at the root by an earlier convention. diff --git a/tests/dom/tier.test.js b/tests/dom/tier.test.js new file mode 100644 index 0000000..cdff380 --- /dev/null +++ b/tests/dom/tier.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +/** + * The other side of tests/node/tier.test.js: this directory is the one place a + * document is allowed, and this file is what says so. + * + * It is also where the two capabilities the later tiers were chosen for get + * pinned, and where the one gap in this tier is recorded as deliberate rather + * than discovered. Range and Selection exist here, which is what makes caret + * insertion drivable without Thunderbird. The editor command does not exist + * here and no simulated DOM implements it, which is why the insertion + * function's preferred path can only be exercised against a real Thunderbird. + * + * See docs/adr/0001-three-test-tiers.md. + */ +describe("the dom tier", () => { + it("can build a document and query it back", () => { + document.body.innerHTML = ""; + const pre = document.createElement("pre"); + pre.className = "thundercode-block"; + pre.textContent = "const answer = 42;"; + document.body.append(pre); + + const found = document.querySelector("pre.thundercode-block"); + expect(found).toBe(pre); + expect(found.textContent).toBe("const answer = 42;"); + }); + + it("has Range and Selection, which is what caret insertion needs", () => { + document.body.innerHTML = "

before

"; + const range = document.createRange(); + range.selectNodeContents(document.querySelector("p")); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + expect(selection.rangeCount).toBe(1); + expect(selection.getRangeAt(0).toString()).toBe("before"); + }); + + it("has no editor command, which is what the third tier is for", () => { + expect(document.execCommand).toBeUndefined(); + }); +}); diff --git a/tests/code-block.test.js b/tests/node/code-block.test.js similarity index 99% rename from tests/code-block.test.js rename to tests/node/code-block.test.js index f97895b..4eb1f60 100644 --- a/tests/code-block.test.js +++ b/tests/node/code-block.test.js @@ -3,12 +3,12 @@ import { describe, expect, it } from "vitest"; import { CONTAINER_CLASS, buildCodeBlockHtml, -} from "../src/code-block/build-code-block-html.js"; +} from "../../src/code-block/build-code-block-html.js"; // The one import here that is not the seam, and only ever read from: the // bundle's own language list is what "detection can only return a language // present in the bundle" is a claim about, and it is also what the popup fills // its dropdown from. Asserting against it keeps that one list one list. -import hljs from "../vendor/highlight.js/common.js"; +import hljs from "../../vendor/highlight.js/common.js"; /** * Read the block back without pinning its markup shape. Asserting on the diff --git a/tests/manifest.test.js b/tests/node/manifest.test.js similarity index 99% rename from tests/manifest.test.js rename to tests/node/manifest.test.js index bdb4673..119f9f0 100644 --- a/tests/manifest.test.js +++ b/tests/node/manifest.test.js @@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const manifest = JSON.parse( readFileSync(resolve(repoRoot, "manifest.json"), "utf8"), ); diff --git a/tests/settings.test.js b/tests/node/settings.test.js similarity index 98% rename from tests/settings.test.js rename to tests/node/settings.test.js index e5d7f0b..ae1b536 100644 --- a/tests/settings.test.js +++ b/tests/node/settings.test.js @@ -1,12 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CODE_BLOCK_DEFAULTS } from "../src/code-block/build-code-block-html.js"; +import { CODE_BLOCK_DEFAULTS } from "../../src/code-block/build-code-block-html.js"; import { SETTING_FIELDS, coerceSettings, readSettings, writeSettings, -} from "../src/settings/settings.js"; +} from "../../src/settings/settings.js"; /** * The options page is verified by hand, like the popup - the runner has no DOM diff --git a/tests/snippet-size.test.js b/tests/node/snippet-size.test.js similarity index 98% rename from tests/snippet-size.test.js rename to tests/node/snippet-size.test.js index e5f8661..53f8185 100644 --- a/tests/snippet-size.test.js +++ b/tests/node/snippet-size.test.js @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { LARGE_SNIPPET_LINES, measureSnippet, -} from "../src/popup/snippet-size.js"; +} from "../../src/popup/snippet-size.js"; const lines = (count) => Array.from({ length: count }, (_, i) => `${i}`); diff --git a/tests/styles.test.js b/tests/node/styles.test.js similarity index 99% rename from tests/styles.test.js rename to tests/node/styles.test.js index 6662e07..749598e 100644 --- a/tests/styles.test.js +++ b/tests/node/styles.test.js @@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); /** * The extension's two visible surfaces both rendered light inside a dark diff --git a/tests/node/tier.test.js b/tests/node/tier.test.js new file mode 100644 index 0000000..e727e7f --- /dev/null +++ b/tests/node/tier.test.js @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +/** + * This tier's defining property, asserted rather than left to the config. + * + * The pipeline is pure because nothing it depends on has ever been allowed to + * be a document, and what has kept it that way is that reaching for one here + * throws. That is a claim about the runner, not about any module, so no other + * test in this directory would notice it breaking: switch the whole suite to + * jsdom and everything below still passes while the property is gone. Hence a + * test whose only subject is the tier. + * + * See docs/adr/0001-three-test-tiers.md for why the boundary is this + * directory rather than a pragma at the top of each file. + */ +describe("the node tier", () => { + it("has no document, so a test that reaches for one fails", () => { + expect(globalThis.document).toBeUndefined(); + expect(() => document.createElement("pre")).toThrow(ReferenceError); + }); + + it("has no window either", () => { + expect(globalThis.window).toBeUndefined(); + }); +}); diff --git a/tests/updates.test.js b/tests/node/updates.test.js similarity index 98% rename from tests/updates.test.js rename to tests/node/updates.test.js index ead5059..6041b14 100644 --- a/tests/updates.test.js +++ b/tests/node/updates.test.js @@ -3,9 +3,9 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { buildUpdatesManifest } from "../scripts/build-updates-json.mjs"; +import { buildUpdatesManifest } from "../../scripts/build-updates-json.mjs"; -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const manifest = JSON.parse( readFileSync(resolve(repoRoot, "manifest.json"), "utf8"), ); diff --git a/tests/version.test.js b/tests/node/version.test.js similarity index 96% rename from tests/version.test.js rename to tests/node/version.test.js index 86e8757..17e1159 100644 --- a/tests/version.test.js +++ b/tests/node/version.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { nextVersion } from "../scripts/bump-version.mjs"; +import { nextVersion } from "../../scripts/bump-version.mjs"; /** * The release workflow calls this and pushes the result to `main`, unreviewed. diff --git a/vitest.config.js b/vitest.config.js index d57309a..fd118ca 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,16 +1,79 @@ import { defineConfig } from "vitest/config"; +// Why the tiers are directories rather than per-file environment pragmas, and +// why coverage is never gated: docs/adr/0001-three-test-tiers.md. export default defineConfig({ test: { - // Stated rather than left to the default. The seam must stay pure, and a - // runner with no DOM is what makes reaching for one fail loudly instead of - // quietly working in tests and nowhere else. - environment: "node", + projects: [ + { + test: { + name: "node", - // Tests live in `tests/`, and saying so is the whole reason this line - // exists: vitest's default glob is the entire tree, which in a checkout - // that has agent worktrees under `.claude/` means running dozens of stale - // copies of this suite and reporting their failures as this one's. - include: ["tests/**/*.test.js"], + // Stated rather than left to the default. The seam must stay pure, + // and a runner with no DOM is what makes reaching for one fail + // loudly instead of quietly working in tests and nowhere else. + environment: "node", + + // This tier is the default one, so it claims every test that has not + // been filed under a tier of its own. A file dropped straight into + // `tests/` therefore runs without a document rather than silently + // not running at all, and the only way to get a DOM is to ask for + // one by putting the test where the DOM lives. + // + // Naming `tests/` at all is the other half of this line: vitest's + // default glob is the entire tree, which in a checkout that has + // agent worktrees under `.claude/` means running dozens of stale + // copies of this suite and reporting their failures as this one's. + include: ["tests/**/*.test.js"], + exclude: ["tests/dom/**", "tests/thunderbird/**"], + }, + }, + { + test: { + name: "dom", + + // For the modules that need a document to do anything but do not + // need Thunderbird. jsdom rather than the alternatives on ecosystem + // grounds, not capability ones: they are equivalent on the thing + // that matters here, since both implement Range and Selection well + // enough to drive caret insertion and neither implements the editor + // command at all. + environment: "jsdom", + include: ["tests/dom/**/*.test.js"], + }, + }, + // The third tier - a real Thunderbird, driven headless - arrives as a + // project of its own over `tests/thunderbird/`, run by its own command. + // It is absent here on purpose so that `pnpm test` stays green on a + // machine with no Thunderbird installed. The node tier above already + // declines to claim that directory, so adding the project is the whole + // of the change. + ], + + coverage: { + // Reported, never gated. There is no `thresholds` key here and there is + // not meant to be one: this project leaves whole modules uncovered on + // purpose, so a number set here would be one somebody tunes down until + // it means nothing. What the report is for is narrower - seeing whether + // logic pulled out of a large module came with it or was copied. + // `skipFull` is spelled out because vitest turns it on by itself when it + // thinks an agent is reading the output, and a fully covered file is + // exactly the row this report exists to show: a module that took the + // logic reads 100%, and hiding it leaves nothing to look at. + reporter: [["text", { skipFull: false }], "html"], + + // This project's own code, the release scripts included: the node tier + // covers those, and they are the two files whose output every installed + // copy compares itself against. + include: ["src/**/*.js", "scripts/**/*.mjs"], + + // The vendored highlight.js is a third party's code at a pinned + // revision, and the seam's tests import it directly, so it would + // otherwise turn up in the report and bury this project's own numbers + // under several hundred files nobody here is going to write a test for. + // The include above already leaves it out; this says so out loud, so + // that widening that line one day does not quietly drag it back in. + exclude: ["vendor/**"], + }, }, }); From fa8f76aedc12459f4b7362c6756592774dbe8927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:28:15 +0200 Subject: [PATCH 03/22] docs: record the three test tiers as a decision record The tiers are a decision with a rejected alternative that will otherwise be suggested again, and one of them is unsupported by the platform it drives, so both belong somewhere durable rather than in a config comment. ADR-0001 records the three tiers, why the boundary lives in the directory layout rather than in per-file environment pragmas, why no coverage threshold will ever be set, and that the real-Thunderbird tier is undocumented and unsupported by Thunderbird with its breakage a cost this project owns. The README's developing section now names the tiers and the commands and points at it. This is the first ADR, so it also opens `docs/adr/`. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 39 ++++++++++- docs/adr/0001-three-test-tiers.md | 105 ++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0001-three-test-tiers.md diff --git a/README.md b/README.md index 7bdf00f..c0b6cc5 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,42 @@ popup. Compose scripts are injected per compose window, so changes under `src/compose/` need the compose window reopened as well - reloading the add-on does not reach one that is already open. -Run the tests with `pnpm test`. They cover the manifest, the update manifest, -the version arithmetic and the HTML builder; everything that needs a running -compose window is checked by hand against `docs/release-checklist.md`. +### Running the tests + +```sh +pnpm test # both automated tiers +pnpm test:node # the pure tier alone, for a fast edit loop +pnpm coverage # a report; nothing is gated on it +``` + +The suite is split into tiers, and which one a test belongs in is decided by +where it can be written rather than by what it is about: + +| Tier | Directory | Environment | +| --- | --- | --- | +| `node` | `tests/node/` | no DOM at all | +| `dom` | `tests/dom/` | a simulated document, via jsdom | +| `thunderbird` | `tests/thunderbird/` | a real Thunderbird, driven headless | + +`pnpm test` runs the first two. The third needs a Thunderbird to drive, so it +is a local command run while working the checklist, not part of the default run +and not part of CI. + +The `node` tier has no document on purpose: a test that reaches for one there +fails rather than passing, which is what has kept the code-block pipeline from +quietly growing a dependency on a DOM. Wanting a document means moving the file +into `tests/dom/`, which is a change someone can see. Anything dropped straight +into `tests/` without picking a tier runs in `node`, so the strict tier is the +default rather than something to remember. + +Coverage is reported and never gated - there is no threshold and there will not +be one. The reasoning behind all of this, including the alternatives that were +turned down, is in +[docs/adr/0001-three-test-tiers.md](docs/adr/0001-three-test-tiers.md). + +What is still checked by hand is anything that is a claim about Thunderbird +rather than about this project's own logic; that list is +`docs/release-checklist.md`. ## Commit messages diff --git a/docs/adr/0001-three-test-tiers.md b/docs/adr/0001-three-test-tiers.md new file mode 100644 index 0000000..67b86d3 --- /dev/null +++ b/docs/adr/0001-three-test-tiers.md @@ -0,0 +1,105 @@ +# Three test tiers, split by directory, with coverage never gated + +## Status + +Accepted. + +## Context + +The suite began as one run with no DOM, and that was a decision rather than a +default: the code-block pipeline must stay pure, so a test that reaches for a +document should fail loudly instead of quietly working in tests and nowhere +else. The constraint held. It also meant that four modules - the background, +the compose-sandbox insertion function, the popup and the options page - could +not be tested at all, because the only way to reach any of them is through a +document. Three of those four are where the add-on's observable behaviour +actually lives, and the release checklist was covering all of it by hand. + +So the constraint was global when it only needed to be local. It protected one +module at the cost of four. + +## Decision + +Three tiers, declared as named runner projects in `vitest.config.js`. + +**`node`** - no DOM. The pure pipeline, the settings coercion, the snippet +measurement, and the static files: the manifest, the update manifest, the +stylesheets, the version arithmetic. Lives in `tests/node/`, and also claims +any test file dropped straight into `tests/` that no other tier has taken, so +the strict tier is what an unfiled test gets. + +**`dom`** - a simulated document, for modules that need one to do anything but +do not need Thunderbird. Lives in `tests/dom/`. jsdom, chosen on ecosystem +grounds rather than capability: the candidates are equivalent on the thing that +matters here, since both implement Range and Selection well enough to drive +caret insertion and neither implements the editor command at all. + +**`thunderbird`** - a real Thunderbird, driven headless over WebDriver, in +`tests/thunderbird/`. It is the only tier that can exercise the editor command +path that actually runs in production, and the only one that can retire a +checklist item honestly. It is kept out of the default test command so the +suite stays green on a machine with no Thunderbird installed, and it is run +locally while working the checklist rather than in CI. + +`pnpm test` runs `node` and `dom`. `pnpm test:node` runs the strict tier alone, +because the cost of running tests while editing should never be the reason not +to run them. + +Which tier a test belongs in is answered by where it can be written: no +document, a simulated one, or a real Thunderbird. Nothing else. + +## Considered options + +**One DOM environment for everything.** Rejected. It costs exactly the property +that has kept the pipeline pure. Nothing would ever have reported that the +pipeline had started reading a document, because in the suite there would +always have been one. + +**Per-file environment pragmas.** Rejected, and this is the closer call, so it +is the one worth recording. The original constraint was never "no DOM +anywhere"; it was "reaching for a DOM should fail loudly". A pragma at the top +of a pipeline test satisfies the letter of the configuration while losing +precisely that: the test still passes, the file still looks ordinary, and the +line that gave the pipeline a document is one line in a header nobody reads +twice. Putting the boundary in the directory layout means asking for a document +is moving a file, which is visible in a diff and visible in a listing, and +means the tier a test runs in is a fact about the repository rather than a fact +about that file's first line. + +## Coverage + +Reported, never gated. There is no threshold configured and there is not meant +to be one. + +The report's value here is narrow and real: after logic is pulled out of a +large module, it is the cheapest way to see whether the new module took that +logic or only holds a copy of it. What it is not is a summary of how well this +project is tested, because this project leaves whole modules uncovered on +purpose - the theme reduction because a simulated CSS object model is least +faithful exactly where that module's claim lives, and everything the third tier +covers because CI does not run it. A threshold over a codebase like that is a +number somebody tunes down until it agrees with whatever the last commit did, +and a number that always agrees is not a check. + +The vendored highlight.js is excluded. It is a third party's code at a pinned +revision, and the pipeline's tests import it directly, so it would otherwise +bury this project's own numbers under several hundred files nobody here is +going to write a test for. + +## Consequences + +The third tier is undocumented and unsupported by Thunderbird. WebDriver +accepting the Thunderbird binary, switching into a privileged context and +temp-installing an unsigned checkout is all behaviour nobody has promised to +keep working, and a Thunderbird update may break it with no warning and no +recourse. That breakage is a cost this project owns and accepts: it is one +afternoon when it happens, against a harness that can block nothing because it +runs in no pipeline. It was verified end to end before being committed to, and +it is worth the exposure because it is the only place the production insertion +path can be exercised at all. + +The checklist keeps every item that is a claim about Thunderbird rather than +about this project's own logic - the button on a dark appearance, the icon +rather than a puzzle piece, where cloud attachment links land, the absence of +spell-check underlines, installing the built archive into a clean profile. None +of those should ever be retired because a fake agreed with them. From d9e05b614e52975d65a365c4ce2a51fb3cc0e1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:40:24 +0200 Subject: [PATCH 04/22] test: bring a strict browser fake shared by the tiers Generalises the stub the settings tests wrote by hand. Every namespace a test lets the code under test touch is stubbed per test, and anything reached that was not stubbed throws and names the property that was asked for, so a new browser API call becomes a failing test rather than an undefined that something further down interprets. The ruled-out storage area keeps its old shape - present and throwing, naming the rule - and is now enforced for every test rather than for the settings ones alone, including against a test that tries to stub it. Built here rather than taken from a package: the maintained WebExtension mocks know Chrome and Firefox and none of them knows Thunderbird's namespaces. It lives under tests/helpers/ because the pure tier and the simulated-DOM tier are separate runner projects and both need it. Co-Authored-By: Claude Opus 5 (1M context) --- tests/helpers/browser-fake.js | 271 ++++++++++++++++++++++++++++++++ tests/node/browser-fake.test.js | 165 +++++++++++++++++++ tests/node/settings.test.js | 45 ++---- 3 files changed, 452 insertions(+), 29 deletions(-) create mode 100644 tests/helpers/browser-fake.js create mode 100644 tests/node/browser-fake.test.js diff --git a/tests/helpers/browser-fake.js b/tests/helpers/browser-fake.js new file mode 100644 index 0000000..e60feb4 --- /dev/null +++ b/tests/helpers/browser-fake.js @@ -0,0 +1,271 @@ +/** + * The strict browser fake, shared by every tier that needs one. + * + * `browser` is a global rather than a document, so all of this add-on except + * the pipeline is reachable from a test that puts one there. What this file + * adds to that is strictness: a namespace or a member the test did not stub + * throws when it is reached and names what was asked for, so widening the + * extension's API surface shows up as a failing test rather than as an + * `undefined` that something further down the call quietly interprets. + * + * Built here rather than taken from a package. The maintained WebExtension + * mocks know Chrome and Firefox, and none of them knows Thunderbird's + * namespaces - `compose`, `composeAction`, `menus` - which is the same reason + * the Firefox-oriented linter is being replaced rather than kept alongside. + * + * The stubs a test writes stay deliberately thin, for the reason + * tests/node/settings.test.js already gave for its own: there is no store + * behind `storage.local` here, and asserting on one would only be asserting + * that this file works. What the fake records is which member was called, with + * what, and what the caller did with the answer, and each of those is a + * decision a module actually makes. + * + * It lives under tests/helpers/ rather than in either tier, because the pure + * tier and the simulated-DOM tier are separate runner projects and both need + * it. Only `*.test.js` is collected as a suite, so a helper directory here is + * not a tier of its own. + * + * let fake; + * + * beforeEach(() => { + * fake = installBrowserFake({ + * menus: { create: () => {}, onClicked: event() }, + * runtime: { lastError: undefined, onMessage: event() }, + * }); + * }); + * + * afterEach(() => { + * vi.unstubAllGlobals(); + * }); + */ + +import { vi } from "vitest"; + +const EVENT = Symbol("browser fake event"); + +/** + * Declares an event object - `addListener`, `removeListener`, `hasListener` - + * where a stub would otherwise name a function. + * + * The listeners a module registers are the seam for anything that does its + * work at module scope, the background above all: there is nothing to call, + * so a test drives it by firing what the import registered. `fire` below is + * the other half. + */ +export const event = () => ({ [EVENT]: true }); + +/** + * Members that are present and throw rather than absent, with the rule they + * break as the message. + * + * `storage.sync` is the one the spec rules out, and it was already stubbed + * this way by hand in the settings tests before this file existed: left in + * rather than omitted so that "settings follow the profile around" cannot be + * introduced quietly by someone who thinks it is an improvement. Absent, it + * would read as an API this project has not got round to using. Present and + * throwing, every test that reaches it fails at once. + */ +const RULED_OUT = { + "storage.sync": + "storage.sync is ruled out by the spec: settings must not follow the profile around", +}; + +const splitPath = (path) => { + const at = path.lastIndexOf("."); + return at === -1 ? ["", path] : [path.slice(0, at), path.slice(at + 1)]; +}; + +const join = (path, name) => (path === "" ? name : `${path}.${name}`); + +/** + * Every object in a stub tree is a namespace, so `storage: { local: { … } }` + * is strict at both levels. The rule is deliberately that blunt rather than + * guessing from a stub's contents which objects are namespaces and which are + * data. + * + * A member that carries data rather than API - `runtime.lastError` is the only + * one in this add-on - is therefore declared as `undefined` and given its + * value inside the test that wants one. That assignment is not turned into a + * namespace, so the object a caller reads properties off stays an ordinary + * object. + */ +const isNamespace = (value) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + value[EVENT] !== true; + +const ruledOut = (path, reason) => + new Proxy( + {}, + { + get: (_target, property) => { + if (typeof property === "symbol") { + return undefined; + } + throw new Error( + `browser.${path}.${String(property)} was reached, and ${reason}.`, + ); + }, + }, + ); + +/** + * Installs a fake `browser` built from `stubs` and returns the handle a test + * asserts through. The stubs are the whole declaration of what this test lets + * the code under test touch. + */ +export const installBrowserFake = (stubs = {}) => { + const calls = new Map(); + const reads = new Map(); + const listeners = new Map(); + + const record = (path, args) => { + const recorded = calls.get(path) ?? []; + recorded.push(args); + calls.set(path, recorded); + }; + + const recording = + (path, implementation) => + (...args) => { + record(path, args); + return implementation(...args); + }; + + const captured = (path) => { + listeners.set(path, []); + return namespace(path, { + addListener: (listener) => { + listeners.get(path).push(listener); + }, + removeListener: (listener) => { + const registered = listeners.get(path); + const at = registered.indexOf(listener); + if (at !== -1) { + registered.splice(at, 1); + } + }, + hasListener: (listener) => listeners.get(path).includes(listener), + }); + }; + + const namespace = (path, declared) => { + const members = {}; + + for (const [name, value] of Object.entries(declared)) { + const memberPath = join(path, name); + const reason = RULED_OUT[memberPath]; + if (reason) { + throw new Error(`browser.${memberPath} cannot be stubbed: ${reason}.`); + } + members[name] = build(memberPath, value); + } + + for (const [forbiddenPath, reason] of Object.entries(RULED_OUT)) { + const [parent, name] = splitPath(forbiddenPath); + if (parent === path) { + members[name] = ruledOut(forbiddenPath, reason); + } + } + + return new Proxy(members, { + get: (target, property) => { + if (typeof property === "symbol") { + return target[property]; + } + const memberPath = join(path, property); + reads.set(memberPath, (reads.get(memberPath) ?? 0) + 1); + if (!Object.hasOwn(target, property)) { + throw new Error( + `browser.${memberPath} was reached, but this test did not stub it.`, + ); + } + return target[property]; + }, + + // Overriding one member for one assertion, rather than re-installing a + // whole fake, is the idiom the settings tests already use. The override + // is recorded like any other stub, so what a test reassigns is still + // observable through `calls`. + set: (target, property, value) => { + const memberPath = join(path, property); + if (!Object.hasOwn(target, property)) { + throw new Error( + `browser.${memberPath} was assigned, but this test did not stub it.`, + ); + } + target[property] = + typeof value === "function" ? recording(memberPath, value) : value; + return true; + }, + }); + }; + + const build = (path, value) => { + if (typeof value === "function") { + return recording(path, value); + } + if (value?.[EVENT] === true) { + return captured(path); + } + if (isNamespace(value)) { + return namespace(path, value); + } + return value; + }; + + vi.stubGlobal("browser", namespace("", stubs)); + + return { + /** + * Every call to `browser.`, oldest first, as the argument lists they + * were made with: `[[{ windowId: 7 }]]` is one call with one argument. + */ + calls: (path) => [...(calls.get(path) ?? [])], + + /** + * How many times `browser.` has been read. + * + * For the members whose whole purpose is that somebody looks at them. + * `runtime.lastError` is the one: reading it is what tells the platform an + * error was handled, so a test that wants to know an error was swallowed + * rather than ignored has to be able to see the read. + */ + reads: (path) => reads.get(path) ?? 0, + + /** + * Delivers an event to the listeners the code under test registered, and + * answers with what each of them returned - so a listener that declines by + * returning `undefined` is distinguishable from one that answers with a + * promise. + * + * Firing an event nobody is listening to throws rather than passing + * quietly, since a test that fires into the void asserts nothing. + */ + fire: (path, ...args) => { + const registered = listeners.get(path); + if (!registered) { + throw new Error(`browser.${path} was not stubbed as an event.`); + } + if (registered.length === 0) { + throw new Error(`browser.${path} has no listener to fire.`); + } + return registered.map((listener) => listener(...args)); + }, + + /** + * Drops every registered listener, keeping the recorded calls. + * + * What it models is an event page being suspended: its scope goes, and the + * listeners it registered go with it. Without this, a test that wakes the + * background twice dispatches to two live copies of it and reads the older + * one's answer. + */ + forgetListeners: () => { + for (const registered of listeners.values()) { + registered.length = 0; + } + }, + }; +}; diff --git a/tests/node/browser-fake.test.js b/tests/node/browser-fake.test.js new file mode 100644 index 0000000..6733a6b --- /dev/null +++ b/tests/node/browser-fake.test.js @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { event, installBrowserFake } from "../helpers/browser-fake.js"; + +/** + * The one file in the suite whose subject is a test helper, and it is here for + * the same reason tests/node/tier.test.js is: the property this fake exists + * for is one no other test would notice breaking. + * + * Every test that uses the fake asserts what a module does. If the fake + * quietly stopped being strict - answering `undefined` for an unstubbed + * namespace instead of throwing - all of those would still pass, and the + * guarantee that the extension's API surface cannot widen without somebody + * being told would be gone. So the strictness gets a test of its own. + */ +describe("the strict browser fake", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("answers the members a test stubbed", async () => { + installBrowserFake({ + storage: { local: { get: async () => ({ tabWidth: 8 }) } }, + }); + + expect(await browser.storage.local.get(["tabWidth"])).toEqual({ + tabWidth: 8, + }); + }); + + /** + * The whole point of the file. The message has to name the property that was + * reached, because the failure it reports is "this code calls something new" + * and the useful half of that is what. + */ + it("throws and names the namespace when code reaches one nobody stubbed", () => { + installBrowserFake({ runtime: { onMessage: event() } }); + + expect(() => browser.notifications.create({})).toThrow( + /browser\.notifications/, + ); + }); + + it("names the whole path when the namespace is stubbed and the member is not", () => { + installBrowserFake({ tabs: { query: async () => [] } }); + + expect(() => browser.tabs.create({})).toThrow(/browser\.tabs\.create/); + }); + + /** + * Reaching for something is not the only way to widen the surface: a test + * that invents a member by assigning to it would be writing its own API. + */ + it("throws for an assignment to a member nobody stubbed", () => { + installBrowserFake({ runtime: { lastError: undefined } }); + + expect(() => { + browser.runtime.id = "thundercode@sitepark.com"; + }).toThrow(/browser\.runtime\.id/); + }); + + /** + * The area the spec rules out, and the case this fake generalised from. + * Present rather than absent, so that reaching it is a failure that names + * the rule rather than a member somebody assumes has not been needed yet. + */ + it("keeps the ruled-out storage area present, and throwing", () => { + installBrowserFake({ storage: { local: { get: async () => ({}) } } }); + + expect(() => browser.storage.sync.get(["tabWidth"])).toThrow( + /browser\.storage\.sync\.get was reached, and storage\.sync is ruled out/, + ); + }); + + it("refuses to let a test stub the ruled-out area either", () => { + expect(() => + installBrowserFake({ storage: { sync: { get: async () => ({}) } } }), + ).toThrow(/browser\.storage\.sync cannot be stubbed/); + }); + + it("records each call as the arguments it was made with", async () => { + const fake = installBrowserFake({ + compose: { setComposeDetails: async () => {} }, + }); + + await browser.compose.setComposeDetails(7, { deliveryFormat: "both" }); + + expect(fake.calls("compose.setComposeDetails")).toEqual([ + [7, { deliveryFormat: "both" }], + ]); + }); + + /** + * Overriding one member inside one test, against the object the fake + * installed, is the idiom the settings tests use rather than installing a + * second fake. The override has to stay recorded, or an assertion about what + * the module asked for would silently start passing vacuously. + */ + it("records calls to a member a test overrode by assignment", async () => { + const fake = installBrowserFake({ tabs: { query: async () => [] } }); + browser.tabs.query = async () => [{ id: 3, type: "messageCompose" }]; + + expect(await browser.tabs.query({ active: true })).toEqual([ + { id: 3, type: "messageCompose" }, + ]); + expect(fake.calls("tabs.query")).toEqual([[{ active: true }]]); + }); + + it("counts the reads of a member whose purpose is being looked at", () => { + const fake = installBrowserFake({ runtime: { lastError: undefined } }); + + expect(fake.reads("runtime.lastError")).toBe(0); + void browser.runtime.lastError; + void browser.runtime.lastError; + expect(fake.reads("runtime.lastError")).toBe(2); + }); + + /** + * The seam for every module that does its work at module scope: there is + * nothing to call, so the fake captures what the import registered and the + * test fires it. + */ + it("captures listeners and answers with what each of them returned", () => { + const fake = installBrowserFake({ runtime: { onMessage: event() } }); + browser.runtime.onMessage.addListener((message) => + message.type === "mine" ? "answered" : undefined, + ); + + expect(fake.fire("runtime.onMessage", { type: "mine" })).toEqual([ + "answered", + ]); + expect(fake.fire("runtime.onMessage", { type: "someone else's" })).toEqual([ + undefined, + ]); + }); + + it("throws rather than pass quietly when an event has no listener", () => { + const fake = installBrowserFake({ runtime: { onStartup: event() } }); + + expect(() => fake.fire("runtime.onStartup")).toThrow( + /browser\.runtime\.onStartup has no listener/, + ); + expect(() => fake.fire("runtime.onInstalled")).toThrow( + /browser\.runtime\.onInstalled was not stubbed as an event/, + ); + }); + + /** + * What a suspended event page looks like from outside: the scope is gone and + * so are its listeners, while everything it did before that is still on the + * record. + */ + it("forgets listeners without forgetting the calls that were made", () => { + const fake = installBrowserFake({ + menus: { create: () => {}, onClicked: event() }, + }); + browser.menus.onClicked.addListener(() => "still here"); + browser.menus.create({ id: "an-id" }); + + fake.forgetListeners(); + + expect(() => fake.fire("menus.onClicked", {})).toThrow(/no listener/); + expect(fake.calls("menus.create")).toEqual([[{ id: "an-id" }]]); + }); +}); diff --git a/tests/node/settings.test.js b/tests/node/settings.test.js index ae1b536..9757b77 100644 --- a/tests/node/settings.test.js +++ b/tests/node/settings.test.js @@ -7,9 +7,10 @@ import { readSettings, writeSettings, } from "../../src/settings/settings.js"; +import { installBrowserFake } from "../helpers/browser-fake.js"; /** - * The options page is verified by hand, like the popup - the runner has no DOM + * The options page is verified by hand, like the popup - this tier has no DOM * and is meant not to. What is testable, and is the whole of the ticket's * "invalid or empty values fall back to the defaults rather than producing a * broken block", is the coercion between storage and the seam. It is a pure @@ -175,39 +176,23 @@ describe("coerceSettings", () => { * this file's stub works. What it records is which area was called, with what, * and what the caller did with the answer, and each of those is a decision the * module actually makes. + * + * The stub these tests used to write by hand is now + * tests/helpers/browser-fake.js, which generalises it: `storage.sync` is still + * present and still throws, and so does everything else this module has not + * been given, so a settings module that started calling a second API would + * fail here rather than quietly work. */ describe("the settings store", () => { - /** - * The area the spec rules out. Left in the stub rather than omitted, and - * throwing rather than recording, so that "settings follow the profile - * around" cannot be introduced quietly by someone who thinks it is an - * improvement: every test in here fails at once instead. - */ - const sync = { - get: () => { - throw new Error("storage.sync is ruled out by the spec"); - }, - set: () => { - throw new Error("storage.sync is ruled out by the spec"); - }, - }; - - let calls; + let fake; beforeEach(() => { - calls = { get: [], set: [] }; - vi.stubGlobal("browser", { + fake = installBrowserFake({ storage: { local: { - get: async (names) => { - calls.get.push(names); - return { tabWidth: 8, fontSize: 11 }; - }, - set: async (values) => { - calls.set.push(values); - }, + get: async () => ({ tabWidth: 8, fontSize: 11 }), + set: async () => {}, }, - sync, }, }); }); @@ -218,7 +203,9 @@ describe("the settings store", () => { it("reads both settings out of storage.local", async () => { expect(await readSettings()).toEqual({ tabWidth: 8, fontSize: 11 }); - expect(calls.get).toEqual([["tabWidth", "fontSize"]]); + expect(fake.calls("storage.local.get")).toEqual([ + [["tabWidth", "fontSize"]], + ]); }); /** @@ -258,7 +245,7 @@ describe("the settings store", () => { tabWidth: 2, fontSize: SETTING_FIELDS.fontSize.max, }); - expect(calls.set).toEqual([stored]); + expect(fake.calls("storage.local.set")).toEqual([[stored]]); }); /** From 6f40bbb665bd0c2eee0cb93de112b51028e7955c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:40:34 +0200 Subject: [PATCH 05/22] test: pin the background's selection handover The one property the release checklist cannot see: with two compose windows open, a snippet parked by a right-click in one never surfaces in the other. Covered in the pure tier through the fake browser, since the background registers listeners at module scope and exports nothing to call. The rules pinned are parking against the originating tab, clearing a stale park when the next right-click carries no selection, handing the selection over once, dropping it when the popup fails to open, keeping nothing across a wake of the event page, offering the menu in the compose body and nowhere else, swallowing the duplicate-id error a second creation produces by reading the last error, and declining a message the extension does not own rather than answering it. Each test wakes a fresh instance of the module scope, because a cached one carries the previous test's parked selections and every take-once assertion would then depend on the order the file runs in. Co-Authored-By: Claude Opus 5 (1M context) --- tests/node/background.test.js | 342 ++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 tests/node/background.test.js diff --git a/tests/node/background.test.js b/tests/node/background.test.js new file mode 100644 index 0000000..6e8c944 --- /dev/null +++ b/tests/node/background.test.js @@ -0,0 +1,342 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { event, installBrowserFake } from "../helpers/browser-fake.js"; + +/** + * The background exports nothing and takes no arguments: it registers + * listeners at module scope and that is all it is. So the fake browser is the + * seam - import the module with one in place, then fire what the import + * registered - and no handler is extracted to be called directly, because + * that would be adding an interface in order to test what the fake already + * exposes. + * + * What is asserted below is the handover rules rather than the presence of + * listeners. They are the whole of this file's correctness and they are the + * one property the release checklist cannot see: a person with two compose + * windows open can check that a snippet arrived in the right one, but not that + * it could never arrive in the wrong one. + */ + +/** + * The message the popup claims its prefill with. A literal in both modules and + * exported by neither, which makes this the third copy: the background gets no + * new interface for the sake of a test, and the popup is not this ticket's to + * change. A test that made up its own name here would pass while the popup + * asked for something else, so the two literals staying in step is on whoever + * changes one of them. + */ +const TAKE_PENDING_SELECTION = "thundercode:take-pending-selection"; + +const composeTab = (id, windowId) => ({ id, windowId, type: "messageCompose" }); + +describe("the background", () => { + let fake; + + /** + * Errors the platform would have reported as unhandled. `menus.create` + * answers through a callback and `runtime.lastError` rather than by + * throwing, and an error no callback reads is what Gecko surfaces; the stub + * below reproduces that, since otherwise "the error is swallowed" would be + * indistinguishable from "there was never an error". + */ + let unhandled; + + /** The ids the menu already holds, which outlive any one wake of the page. */ + let created; + + beforeEach(() => { + unhandled = []; + created = new Set(); + + fake = installBrowserFake({ + menus: { + create: (properties, callback) => { + const duplicate = created.has(properties.id); + created.add(properties.id); + + browser.runtime.lastError = duplicate + ? { message: `ID already exists: ${properties.id}` } + : undefined; + const readBefore = fake.reads("runtime.lastError"); + callback(); + if (duplicate && fake.reads("runtime.lastError") === readBefore) { + unhandled.push(`menus.create: ID already exists: ${properties.id}`); + } + browser.runtime.lastError = undefined; + }, + onClicked: event(), + }, + runtime: { + lastError: undefined, + onStartup: event(), + onMessage: event(), + }, + composeAction: { + openPopup: async () => true, + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** + * A genuinely fresh instance of the background scope, which every test needs + * because the module does its work at module scope: a cached one would carry + * the previous test's parked selections and every take-once assertion below + * would depend on the order the file happens to run in. + * + * Calling it twice in one test is the honest way to write "the event page + * was suspended and woken again", which is a thing that happens between any + * two user gestures. The listeners of the instance that went away are + * dropped with it, so a dispatch reaches one background rather than two. + */ + const wake = async () => { + fake.forgetListeners(); + vi.resetModules(); + await import("../../src/background/background.js"); + }; + + /** + * The menu id is read off the creation the fake recorded rather than written + * out here. It is not exported, and a test that hardcoded it would keep + * passing after a rename while every real click stopped matching. + */ + const menuProperties = () => fake.calls("menus.create").at(-1)[0]; + + const rightClick = async (tab, selectionText) => { + const [handled] = fake.fire( + "menus.onClicked", + { menuItemId: menuProperties().id, selectionText }, + tab, + ); + await handled; + }; + + /** + * What the popup does when it opens: asks for the selection parked for the + * tab it resolved for itself. The sender is empty because the background + * deliberately does not read it - a popup document has no tab of its own, so + * `sender.tab` could not answer this. + */ + const claim = async (tabId) => { + const [answer] = fake.fire( + "runtime.onMessage", + { type: TAKE_PENDING_SELECTION, tabId }, + {}, + ); + return await answer; + }; + + describe("the menu it registers", () => { + /** + * `compose_body` matches a right-click anywhere in the message body, with + * or without a selection, so the empty popup and the prefilled one are one + * code path. `selection` is the context that must stay off the list: it + * would also match a selection in the message reader and put the item in + * menus with no composer to insert into. + */ + it("offers itself in the compose body and nowhere else", async () => { + await wake(); + + expect(menuProperties().contexts).toEqual(["compose_body"]); + }); + + /** + * An event page creates the item every time it is woken, so the second + * creation onwards always fails with a duplicate id. That error is + * expected and is the only one this module swallows; reading `lastError` + * inside the callback is what stops the platform reporting it as + * unhandled. + */ + it("swallows the duplicate-id error a second creation produces", async () => { + await wake(); + await wake(); + fake.fire("runtime.onStartup"); + + expect(fake.calls("menus.create")).toHaveLength(3); + expect(unhandled).toEqual([]); + }); + + /** + * The listener exists so that the page runs at startup at all, and + * re-creating the item is the work it does there: the item is then present + * before the user opens their first composer. + */ + it("puts the item back at startup", async () => { + await wake(); + fake.fire("runtime.onStartup"); + + const [first, second] = fake.calls("menus.create"); + expect(second[0]).toEqual(first[0]); + }); + }); + + describe("the selection handover", () => { + /** + * The property no checklist can see. Two composers, a right-click in each, + * and each popup claims its own: a snippet parked in one window is not + * something the other window can be handed, whichever order they ask in. + */ + it("parks a selection against the tab it came from", async () => { + await wake(); + await rightClick(composeTab(1, 11), "SELECT 1;"); + await rightClick(composeTab(2, 22), "print('two')"); + + expect(await claim(2)).toBe("print('two')"); + expect(await claim(1)).toBe("SELECT 1;"); + }); + + /** + * Anchored to the window that was clicked rather than to the current one, + * so the popup opens over the composer the user right-clicked in. + */ + it("opens the popup over the window that was clicked", async () => { + await wake(); + await rightClick(composeTab(2, 22), "print('two')"); + + expect(fake.calls("composeAction.openPopup")).toEqual([ + [{ windowId: 22 }], + ]); + }); + + /** + * A right-click that carries nothing is a request for an empty popup, not + * a request for whatever was parked last time. Clearing here rather than + * leaving the entry is what stops an old snippet being replayed into a + * later popup that the user opened to type something else. + */ + it("clears a stale park when the next right-click carries nothing", async () => { + for (const nothing of [undefined, ""]) { + await wake(); + await rightClick(composeTab(1, 11), "SELECT 1;"); + await rightClick(composeTab(1, 11), nothing); + + expect(await claim(1), String(nothing)).toBe(""); + } + }); + + /** + * Take-once: the entry is removed as it is handed over. The popup asks on + * every open, including the toolbar and shortcut opens that parked + * nothing, so an entry that survived being claimed would surface in the + * next popup the user opened by any other route. + */ + it("hands a selection over once and then has nothing", async () => { + await wake(); + await rightClick(composeTab(1, 11), "SELECT 1;"); + + expect(await claim(1)).toBe("SELECT 1;"); + expect(await claim(1)).toBe(""); + }); + + /** + * There is no popup to claim it, so holding it would mean handing it to + * whatever opened this tab's popup next. + */ + it("drops the park when the popup does not open", async () => { + await wake(); + browser.composeAction.openPopup = async () => false; + await rightClick(composeTab(1, 11), "SELECT 1;"); + + expect(await claim(1)).toBe(""); + }); + + /** + * A tab that parked nothing is answered rather than left waiting: the + * popup opened from the toolbar or the keyboard shortcut asks the same + * question and needs the same kind of answer. + */ + it("answers a tab that parked nothing with an empty selection", async () => { + await wake(); + + expect(await claim(1)).toBe(""); + }); + + /** + * Module scope does not survive the page being suspended, and the parked + * selection is written and read inside one user gesture precisely because + * that is the only lifetime it can rely on. A test that found a selection + * still parked after a wake would be pinning a lifetime the platform does + * not offer. + */ + it("keeps nothing across a wake of the event page", async () => { + await wake(); + await rightClick(composeTab(1, 11), "SELECT 1;"); + + await wake(); + + expect(await claim(1)).toBe(""); + }); + }); + + describe("the clicks and messages it does not own", () => { + it("ignores a click on a menu item it did not create", async () => { + await wake(); + const [handled] = fake.fire( + "menus.onClicked", + { menuItemId: "some-other-extension-item", selectionText: "SELECT 1;" }, + composeTab(1, 11), + ); + await handled; + + expect(fake.calls("composeAction.openPopup")).toEqual([]); + expect(await claim(1)).toBe(""); + }); + + /** + * `menus.onClicked` carries no tab for contexts outside a tab, and there + * is nothing to insert into without one. + */ + it("ignores a click that arrives without a tab", async () => { + await wake(); + const [handled] = fake.fire( + "menus.onClicked", + { menuItemId: menuProperties().id, selectionText: "SELECT 1;" }, + undefined, + ); + await handled; + + expect(fake.calls("composeAction.openPopup")).toEqual([]); + }); + + /** + * Undefined, not false and not a promise: this listener declines a message + * it does not own rather than answering it, which is what leaves any other + * listener in the extension free to take it. Answering would make the + * background the one that handled it. + */ + it("declines a message it does not own rather than answering it", async () => { + await wake(); + + for (const message of [ + { type: "someone-else:do-a-thing" }, + { tabId: 1 }, + "not an object at all", + undefined, + ]) { + const [answer] = fake.fire("runtime.onMessage", message, {}); + expect(answer, JSON.stringify(message) ?? "undefined").toBeUndefined(); + } + }); + + /** + * The other half of declining: a message the background did not answer has + * not consumed anything either, so the popup's own claim still finds its + * selection. + */ + it("leaves a parked selection alone when it declines a message", async () => { + await wake(); + await rightClick(composeTab(1, 11), "SELECT 1;"); + + fake.fire( + "runtime.onMessage", + { type: "someone-else:take", tabId: 1 }, + {}, + ); + + expect(await claim(1)).toBe("SELECT 1;"); + }); + }); +}); From 8f6fd90ec32da8c86fd1972be9a40ad0374f1f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:45:28 +0200 Subject: [PATCH 06/22] ci(release): keep refactors out of the generated release notes The release notes are the only changelog this project has, and a `refactor` produced a Changed entry in them. A refactor by definition changes nothing anyone using the add-on can observe, so that entry told a reader waiting to hear what the add-on now does about a file move instead. It joins the internal-only types; `perf` keeps Changed to itself, because a faster add-on is something a user experiences. A cycle whose every commit is internal now generates empty notes far more often than before, so the prose that claimed the release workflow refuses to publish on them is corrected rather than the behaviour: commit 3057179 removed that hard failure on purpose, and the README and the workflow comment had not caught up. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 12 +++++++----- README.md | 30 +++++++++++++++++++----------- cliff.toml | 5 ++++- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c6e54d..132f9ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,11 +79,13 @@ jobs: # The release body is the whole changelog: there is no CHANGELOG.md, so # this is the only place a release is described. # - # Empty output means every commit this cycle was an internal type. That - # is worth stopping for rather than publishing a blank body: an update - # reaches every installed copy, and one that says nothing about what - # changed is worse than not releasing. It runs before the tests because - # it is the cheapest check here. + # Empty output means every commit this cycle was an internal type, which + # a cycle spent on tests, docs or a refactor legitimately is. It does not + # stop the release: the step says why the body is blank and publishing + # continues. That call belongs to whoever dispatched the workflow, who + # read `pnpm changelog` first, rather than to a job already told to + # publish - and the warnings are what make an empty body legible in the + # log afterwards. - name: Build the release notes run: | pnpm exec git-cliff --config cliff.toml \ diff --git a/README.md b/README.md index c0b6cc5..7029727 100644 --- a/README.md +++ b/README.md @@ -130,11 +130,16 @@ Two types reach the notes: - `feat` - an **Added** entry. - `fix` - a **Fixed** entry. -`refactor` and `perf` become **Changed**, `revert` becomes **Removed**, and +`perf` becomes **Changed** and `revert` becomes **Removed**. `refactor`, `docs`, `test`, `chore`, `ci`, `build` and `style` are required on the commit but deliberately absent from the notes: someone reading them wants to know what the add-on now does, not how the repo is maintained. +`refactor` is on that list rather than beside `perf` for the same reason. A +refactor changes nothing anyone using the add-on can observe, so an entry for +one tells a reader waiting to hear what the add-on now does about a file move +instead. `perf` stays because a faster add-on is something a user experiences. + Scopes in use: `compose`, `code-block`, `popup`, `options`, `ui`, `release`. A commit with no type is dropped entirely rather than guessed at. That is @@ -142,9 +147,11 @@ meant to be caught in review - silently listing it under the wrong heading would be worse. Merge commits are skipped for the same reason and keep their default subjects. -Run `pnpm changelog` at any point to see what the next release will say. If a -cycle produces nothing, the release is refused rather than published with an -empty body; see below. +Run `pnpm changelog` at any point to see what the next release will say. A +cycle whose every commit was an internal type produces nothing at all, which +is an ordinary outcome rather than a rare one - a cycle spent on tests and an +extraction committed as `refactor` is exactly that. What happens next is +under Releasing. Because the notes are written at publish time from the commits themselves, there is nothing to prepare and nothing that can go stale. Fixing a bad @@ -162,17 +169,18 @@ chooses it. Releasing is running an action, not pushing a tag. 3. **Actions ▸ Release ▸ Run workflow**, on `main`. Leave the bump at `minor` unless the next cycle is a patch or a major. -The workflow refuses to start unless it is on `main`, the version is not -already tagged, and the generated notes are not empty. It then runs the tests, -builds the archive, generates `updates.json` from the manifest and the +The workflow refuses to start unless it is on `main` and the version is not +already tagged. It then runs the tests, builds the archive, generates `updates.json` from the manifest and the archive's digest, publishes both under a tag it creates itself with the notes as the release body, and finally raises `manifest.json` to the next version and pushes that to `main`. -Empty notes mean every commit in the cycle was an internal type, so the -release is refused. An update reaches every installed copy, and one that says -nothing about what changed is worse than not releasing at all. If something -user-facing did land, it was committed under the wrong type. +Empty notes do not stop it. They mean every commit in the cycle was an +internal type, and the workflow prints why the body is blank and publishes +anyway. That call is step 1's, not the job's: read `pnpm changelog` and decide +there, because a release with nothing to say about it is usually one worth +skipping, and if something user-facing did land it was committed under the +wrong type. So `main` always sits on an unreleased version, and every tag names a commit where the manifest agreed with it. The bump comes last on purpose: if anything diff --git a/cliff.toml b/cliff.toml index b978ea0..4ea7097 100644 --- a/cliff.toml +++ b/cliff.toml @@ -44,12 +44,15 @@ commit_parsers = [ { message = "^Merge", skip = true }, { message = "^feat", group = "Added" }, { message = "^fix", group = "Fixed" }, - { message = "^refactor", group = "Changed" }, + # `perf` alone owns Changed. A faster add-on is something someone using it + # experiences, so it belongs in the notes; a refactor by definition changes + # nothing observable, so it is listed with the internal-only types below. { message = "^perf", group = "Changed" }, { message = "^revert", group = "Removed" }, # Internal-only types. They are required on the commit and absent from the # release notes: someone reading them wants to know what the add-on now # does, not how the repo is maintained. + { message = "^refactor", skip = true }, { message = "^docs", skip = true }, { message = "^test", skip = true }, { message = "^chore", skip = true }, From e83c427ff88ef45640a8031a259b038728db3e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:46:06 +0200 Subject: [PATCH 07/22] ci: run on Node 24 and declare it as the supported floor Both workflows pinned Node 22 while everyone working on this is on 24, so green locally said nothing about green in CI. The package declared no floor at all, which meant the only way to find out was a failure. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- package.json | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 132f9ca..515552d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df2c14e..5425b48 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile diff --git a/package.json b/package.json index 3f1a42c..a3b8852 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "description": "Thunderbird MailExtension for inserting syntax-highlighted code blocks into HTML mail", "type": "module", "packageManager": "pnpm@12.3.4", + "engines": { + "node": ">=24" + }, "scripts": { "test": "vitest run", "test:node": "vitest run --project node", From 8af7beef8c4a9cca82045f0808fa7c2587c26c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:51:43 +0200 Subject: [PATCH 08/22] build: lint against Thunderbird's schemas instead of Firefox's addons-linter is Mozilla's and knows Firefox, so the `compose` permission and every compose, composeAction, menus and scripting call read to it as an unsupported API. Its warning list was this add-on's whole reason for existing, which is why the warnings could never be made fatal and the README told you to skim them. Thunderbird's own webext-linter matches those calls against Thunderbird's annotated schemas and passes every one of them, so the exit code is worth something and CI now fails the build on it. It publishes no tags and is not on npm yet, so scripts/lint.sh pins the commit whose package.json reads 1.9.0 and fetches it into an ignored directory. It bootstraps with its own npm in there; pnpm stays this repo's package manager. CI caches the schema zips on a weekly-rotating key, because they come from branch heads and a permanent cache would freeze the schema train. Two checks are skipped and no more: update-url, which is the replacement for addons-linter's --self-hosted, and unused-files, which reads the extension of `vendor/highlight.js/LICENSE` as `.js/license` and so reports a licence that has to ship as dead weight. Before it was skipped, that check found cliff.toml riding along in the archive, which is now excluded from the package. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 28 + .gitignore | 7 + README.md | 51 +- docs/release-checklist.md | 5 - package.json | 1 - pnpm-lock.yaml | 1347 ------------------------------------ scripts/lint.sh | 113 ++- scripts/package.sh | 9 +- 8 files changed, 178 insertions(+), 1383 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5425b48..110a16b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,6 +27,24 @@ jobs: - run: pnpm install --frozen-lockfile + # Caches the schema zips the linter downloads, which is the slow part of + # `pnpm run lint`. They come from branch heads rather than tags, so a + # cache that never expired would freeze the schema train at whenever it + # was first written; the key therefore carries the ISO week. Inside a week + # the fetch is free, and the first run of each week picks up whatever the + # annotated-schemas branches say now. Deliberately no restore-keys - a + # prefix match would hand back last week's entry and undo the rotation. + # The hash of lint.sh is in the key because the pinned linter commit and + # the cache layout are both decided in there. + - name: This week's cache key + id: linter-cache + run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v6 + with: + path: .webext-linter-cache + key: webext-linter-${{ hashFiles('scripts/lint.sh') }}-${{ steps.linter-cache.outputs.week }} + - run: pnpm test # The release job reads the archive path off this script's stdout and @@ -40,3 +58,13 @@ jobs: unzip -Z1 "$xpi" | grep -qx 'manifest.json' json="$(node scripts/build-updates-json.mjs "$xpi")" test -f "$json" + + # Fails the build on an error-severity finding - that is the whole point + # of the linter knowing Thunderbird rather than Firefox, and it is just + # the exit code, with nothing to parse. It runs after the archive check + # because it builds the archive too, so a packaging break should be + # reported as one. The release workflow does not repeat it: this job runs + # on every push to main, so no commit reaches a tag unlinted, and a + # release should not be able to fail on a tarball download. + - name: Lint the archive against Thunderbird's schemas + run: pnpm run lint diff --git a/.gitignore b/.gitignore index 514f888..952a3f8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,13 @@ node_modules/ *.xpi dist/ +# Thunderbird's linter, fetched by scripts/lint.sh, and the schemas and library +# hashes it downloads. The tool is pinned to a commit in that script and is not +# on npm yet, so it is not a dependency this repo can declare; the cache is +# separate so that bumping the pin does not discard it. +.webext-linter/ +.webext-linter-cache/ + # Coverage reports. Written by `pnpm coverage`, read once, never committed - # nothing is gated on them, so there is nothing here worth keeping. coverage/ diff --git a/README.md b/README.md index 7029727..694cc5a 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,47 @@ zipped, minus tests, docs and tooling. pnpm run lint ``` -Builds the archive and runs [addons-linter](https://github.com/mozilla/addons-linter) -over it - the engine behind `web-ext lint`, and the nearest thing to a review -Thunderbird add-ons have. It lints the built `.xpi` rather than the checkout, so -what it reads is what ships. - -Zero errors is the bar. Warnings are not, and cannot be: the linter knows -Firefox, so the MailExtension APIs this add-on exists to call - the `compose` -permission, `compose.{get,set}ComposeDetails`, `composeAction.openPopup` - all -read to it as unsupported. Skim the list rather than trusting the exit code; it -is short enough to know by heart, and a new entry is worth a look. +Builds the archive and runs Thunderbird's own +[webext-linter](https://github.com/thunderbird/webext-linter) over it. It +matches every `browser.*` call against Thunderbird's annotated API schemas and +applies the addons.thunderbird.net review policies, so it knows the surface +this add-on is built on: the `compose` permission and every `compose`, +`composeAction`, `menus` and `scripting` call pass. It lints the built `.xpi` +rather than the checkout, so what it reads is what ships. + +The exit code is the bar, and CI fails the build on it. Nothing is skimmed: +`0` means no error-severity finding, and the info-severity findings that are +printed alongside are few and all real. This replaced addons-linter, which +knows Firefox and reported this add-on's entire reason for existing as an +unsupported API, which is why its warnings could never be made to fail +anything. + +The script fetches the linter into `.webext-linter/` the first time it runs, +pinned to a commit in `scripts/lint.sh` and bootstrapped with its own `npm`, +because the tool publishes no tags and is not on npm yet. It and its schema +cache are both ignored and never packaged; nothing else here uses npm. + +Three things about the output that will look wrong the first time: + +- **It is written as a reviewer's reply to a submission.** This add-on is + submitted nowhere, so the manual-review sections at the end are addressed to + a reviewer who does not exist. The Issues section is the part to read. +- **It lints against the current release, not the floor.** The channel comes + from `strict_max_version`, and the manifest deliberately names none so that + updates keep reaching newer Thunderbirds, so every run says + `schema release-mv3`. The `128.0` floor is checked separately and better, by + the `strict-min-version-api` check: a call newer than the declared minimum + is an error. Do not add a `strict_max_version` to move the channel. +- **Two checks are skipped, and only two.** `update-url`, because serving its + own updates is why this add-on is unlisted, and `unused-files`, because an + upstream path-parsing bug makes it report the vendored highlight.js licence + as dead weight. The reasons are written out in `scripts/lint.sh`. + +One info finding is standing rather than new: both `src/compose/insert-into-body.js` +and the vendored highlight.js insert markup through `.innerHTML`, which +Thunderbird stops permitting after ESR 153. The supported replacement, +`Element.setHTML()`, needs Thunderbird 148, which is above this add-on's floor +of 128 - so this waits on the floor moving rather than on someone noticing it. ## Developing diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 2975981..2640ee0 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -72,11 +72,6 @@ the release is being prepared on a branch. insertion - this is the path users take, and it is not the path `about:debugging` exercises. - [ ] The Add-ons Manager shows the ThunderCode icon, not a puzzle piece. -- [ ] Run Thunderbird's reviewer linter once against the archive: - clone and - `node verify.js dist/thundercode-.xpi`. Warnings about unknown - `messenger.*` APIs and mail permissions are expected noise - the linter - does not know Thunderbird's own surface. Anything else is worth reading. ## Updates diff --git a/package.json b/package.json index a3b8852..f0c4396 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ }, "devDependencies": { "@vitest/coverage-v8": "^5.0.0", - "addons-linter": "^10.10.0", "git-cliff": "^2.13.1", "highlight.js": "11.12.0", "jsdom": "^30.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afebf9e..7efd656 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,9 +112,6 @@ importers: '@vitest/coverage-v8': specifier: ^5.0.0 version: 5.0.0(vitest@5.0.0) - addons-linter: - specifier: ^10.10.0 - version: 10.10.0(supports-color@7.2.0) git-cliff: specifier: ^2.13.1 version: 2.13.1 @@ -199,44 +196,6 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@eslint-community/eslint-utils@4.10.1': - resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} - 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.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.7': - resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -246,34 +205,6 @@ packages: '@noble/hashes': optional: true - '@fluent/syntax@0.19.0': - resolution: {integrity: sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==} - engines: {node: '>=14.0.0', npm: '>=7.0.0'} - - '@fregante/relaxed-json@2.0.0': - resolution: {integrity: sha512-PyUXQWB42s4jBli435TDiYuVsadwRHnMc27YaLouINktvTWsL3FcKrRMGawTayFk46X+n5bE23RjUTWQwrukWw==} - engines: {node: '>= 0.10.0'} - - '@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'} - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -284,15 +215,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mdn/browser-compat-data@8.0.8': - resolution: {integrity: sha512-Rutrrc3FYOc+um4/QC4WFETNk2fV8NKWoqQl3tQfcVTQ1WFaoJRbsJBasSF34ltitmnYYD6ZsKW1cOQtSj1jbQ==} - '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@rolldown/binding-android-arm-eabi@1.2.7': resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -408,9 +333,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@vitest/coverage-v8@5.0.0': resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} peerDependencies: @@ -442,52 +364,6 @@ packages: '@vitest/spy@5.0.0': resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.18.0: - resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} - engines: {node: '>=0.4.0'} - hasBin: true - - addons-linter@10.10.0: - resolution: {integrity: sha512-1n5Xvn4DyHMsulchNDl4ucWnXXQhHwLif6eK80KjieI9KIK3xD+3OYXk5ZcA6a2J8y+Nrlvq9/vq6w7KOqT2MQ==} - engines: {node: '>=20.0.0'} - hasBin: true - - addons-moz-compare@1.3.0: - resolution: {integrity: sha512-/rXpQeaY0nOKhNx00pmZXdk5Mu+KhVlL3/pSBuAYwrxRrNiTvI/9xfQI8Lmm7DMMl+PDhtfAHY/0ibTpdeoQQQ==} - - addons-scanner-utils@15.4.0: - resolution: {integrity: sha512-i3PnJx9YLZi96o4t4JWArP6JimC01949YVAcMJKHfyQ5qmUBaH21qNS8f12/RsloejW8IcCEljhh3Fq3gxFNsg==} - peerDependencies: - express: 5.2.1 - safe-compare: 1.1.4 - peerDependenciesMeta: - express: - optional: true - safe-compare: - optional: true - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -495,152 +371,32 @@ packages: ast-v8-to-istanbul@1.0.6: resolution: {integrity: sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==} - atomic-sleep@1.0.0: - resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} - engines: {node: '>=8.0.0'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - bidi-js@1.1.0: resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - - brace-expansion@1.1.18: - resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - cheerio-select@2.1.0: - resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - - cheerio@1.2.0: - resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} - engines: {node: '>=20.18.1'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - columnify@1.6.0: - resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==} - engines: {node: '>=8.0.0'} - - common-tags@1.8.2: - resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} - engines: {node: '>=4.0.0'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} - css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} - data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - 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==} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - encoding-sniffer@0.2.1: - resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} - - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - entities@8.1.0: resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} engines: {node: '>=20.19.0'} @@ -648,78 +404,9 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-plugin-no-unsanitized@4.1.5: - resolution: {integrity: sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg==} - peerDependencies: - eslint: ^9 || ^10 - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - 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@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - 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'} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} @@ -728,21 +415,6 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-patch@3.1.1: - resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} - - 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-uri@3.1.7: - resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -756,34 +428,11 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - first-chunk-stream@3.0.0: - resolution: {integrity: sha512-LNRvR4hr/S8cXXkIY5pTgVP7L3tq6LlYWcg9nWBuW7o1NMxKZo6oOVa/6GIekMGI0Iw7uC+HWimMe9u/VAeKqw==} - engines: {node: '>=8'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.4: - resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} @@ -823,18 +472,6 @@ packages: engines: {node: ^18.19 || >=20.6} hasBin: true - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - highlight.js@11.12.0: resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} engines: {node: '>=12.0.0'} @@ -843,46 +480,10 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - image-size@2.0.2: - resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} - engines: {node: '>=16.x'} - hasBin: true - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -898,19 +499,12 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} - is-utf8@0.2.1: - resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-yaml@4.3.2: - resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} - hasBin: true - jsdom@30.0.1: resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -920,38 +514,6 @@ packages: canvas: optional: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-merge-patch@1.0.2: - resolution: {integrity: sha512-M6Vp2GN9L7cfuMXiWOmHj9bEFbeC250iVtcKQbqVgEsDVYnIsrNsbU+h/Y/PkbBQCtEa4Bez+Ebv0zfbC8ObLg==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - jsonwebtoken@9.0.3: - resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} - engines: {node: '>=12', npm: '>=6'} - - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - - 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'} - lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -1026,34 +588,6 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -1067,71 +601,26 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - - 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'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} - parse5-htmlparser2-tree-adapter@7.1.0: - resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} - - parse5-parser-stream@7.1.2: - resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} - - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} - 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'} @@ -1140,9 +629,6 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1150,81 +636,31 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} - hasBin: true - postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} - process-warning@5.1.0: - resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - - real-require@1.0.0: - resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - rolldown@1.2.7: resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1240,65 +676,23 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - sonic-boom@4.2.1: - resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom-buf@2.0.0: - resolution: {integrity: sha512-gLFNHucd6gzb8jMsl5QmZ3QgnUJmp7qn4uUSHNwEXumAp7YizoGYw19ZUVfuq4aBOQUtyn2k8X/CwzWB73W2lQ==} - engines: {node: '>=8'} - - strip-bom-stream@4.0.0: - resolution: {integrity: sha512-0ApK3iAkHv6WbgLICw/J4nhwHeDZsBxIIsOD+gHgZICL6SeJ0S9f/WZqemka9cjkTyMN5geId6e8U5WGFAn3cQ==} - engines: {node: '>=8'} - strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - thread-stream@4.2.0: - resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} - engines: {node: '>=20'} - tinybench@6.1.4: resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} engines: {node: '>=20.0.0'} @@ -1330,14 +724,6 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - undici@7.29.1: - resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} - engines: {node: '>=20.18.1'} - undici@8.10.2: resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} @@ -1346,17 +732,6 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} - upath@3.0.7: - resolution: {integrity: sha512-VjBBquch25nUGMuVBpOb2Cj3gc8Kb7lJBqbsXR/0anZ/5uJsL14Kpth9JKfnBsckxCfgIp6hPvcvvmZ97R9X7g==} - engines: {node: '>=20'} - - upath@3.0.8: - resolution: {integrity: sha512-YAsrLMIlhfSCm9rga5TZsJ1mXgahs7N0qOTokzU8mFz35hUrYMvVkaEPefI3rEb/vkAWAOj1Km/qdije9RC1kQ==} - engines: {node: '>=20'} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vite@8.2.2: resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1445,22 +820,10 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -1483,14 +846,6 @@ packages: engines: {node: '>=8'} hasBin: true - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -1498,26 +853,6 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yauzl@3.4.0: - resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} - engines: {node: '>=12'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - yoctocolors@2.2.0: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} @@ -1582,74 +917,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4(supports-color@7.2.0))': - dependencies: - eslint: 9.39.4(supports-color@7.2.0) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.2(supports-color@7.2.0)': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@7.2.0) - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.7(supports-color@7.2.0)': - dependencies: - ajv: 6.15.0 - debug: 4.4.3(supports-color@7.2.0) - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.3.2 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - '@exodus/bytes@1.15.1': {} - '@fluent/syntax@0.19.0': {} - - '@fregante/relaxed-json@2.0.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': {} - '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -1659,12 +928,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 - '@mdn/browser-compat-data@8.0.8': {} - '@oxc-project/types@0.148.0': {} - '@pinojs/redact@0.4.0': {} - '@rolldown/binding-android-arm-eabi@1.2.7': optional: true @@ -1725,8 +990,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/json-schema@7.0.15': {} - '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -1756,78 +1019,6 @@ snapshots: '@vitest/spy@5.0.0': {} - acorn-jsx@5.3.2(acorn@8.18.0): - dependencies: - acorn: 8.18.0 - - acorn@8.18.0: {} - - addons-linter@10.10.0(supports-color@7.2.0): - dependencies: - '@fluent/syntax': 0.19.0 - '@fregante/relaxed-json': 2.0.0 - '@mdn/browser-compat-data': 8.0.8 - addons-moz-compare: 1.3.0 - addons-scanner-utils: 15.4.0 - ajv: 8.20.0 - cheerio: 1.2.0 - columnify: 1.6.0 - common-tags: 1.8.2 - css-tree: 3.2.1 - deepmerge: 4.3.1 - eslint: 9.39.4(supports-color@7.2.0) - eslint-plugin-no-unsanitized: 4.1.5(eslint@9.39.4(supports-color@7.2.0)) - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esprima: 4.0.1 - fast-json-patch: 3.1.1 - image-size: 2.0.2 - json-merge-patch: 1.0.2 - pino: 10.3.1 - semver: 7.8.5 - source-map-support: 0.5.21 - upath: 3.0.8 - yargs: 17.7.2 - yauzl: 3.4.0 - transitivePeerDependencies: - - express - - jiti - - safe-compare - - supports-color - - addons-moz-compare@1.3.0: {} - - addons-scanner-utils@15.4.0: - dependencies: - common-tags: 1.8.2 - first-chunk-stream: 3.0.0 - jsonwebtoken: 9.0.3 - strip-bom-stream: 4.0.0 - upath: 3.0.7 - yauzl: 3.4.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 - - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.7 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.6: @@ -1836,101 +1027,23 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - atomic-sleep@1.0.0: {} - - balanced-match@1.0.2: {} - bidi-js@1.1.0: dependencies: require-from-string: 2.0.2 - boolbase@1.0.0: {} - - brace-expansion@1.1.18: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - buffer-equal-constant-time@1.0.1: {} - - buffer-from@1.1.2: {} - - callsites@3.1.0: {} - chai@6.2.2: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - cheerio-select@2.1.0: - dependencies: - boolbase: 1.0.0 - css-select: 5.2.2 - css-what: 6.2.2 - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - - cheerio@1.2.0: - dependencies: - cheerio-select: 2.1.0 - dom-serializer: 2.0.0 - domhandler: 5.0.3 - domutils: 3.2.2 - encoding-sniffer: 0.2.1 - htmlparser2: 10.1.0 - parse5: 7.3.0 - parse5-htmlparser2-tree-adapter: 7.1.0 - parse5-parser-stream: 7.1.2 - undici: 7.29.1 - whatwg-mimetype: 4.0.0 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clone@1.0.4: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - columnify@1.6.0: - dependencies: - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - - common-tags@1.8.2: {} - - concat-map@0.0.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - css-select@5.2.2: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 - css-tree@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 - css-what@6.2.2: {} - data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -1938,151 +1051,18 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - debug@4.4.3(supports-color@7.2.0): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 7.2.0 - decimal.js@10.6.0: {} - deep-is@0.1.4: {} - - deepmerge@4.3.1: {} - - defaults@1.0.4: - dependencies: - clone: 1.0.4 - detect-libc@2.1.2: {} - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - - domelementtype@2.3.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - - emoji-regex@8.0.0: {} - - encoding-sniffer@0.2.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-encoding: 3.1.1 - - entities@4.5.0: {} - - entities@6.0.1: {} - - entities@7.0.1: {} - entities@8.1.0: {} es-module-lexer@2.3.2: {} - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-plugin-no-unsanitized@4.1.5(eslint@9.39.4(supports-color@7.2.0)): - dependencies: - eslint: 9.39.4(supports-color@7.2.0) - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@9.39.4(supports-color@7.2.0): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4(supports-color@7.2.0)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2(supports-color@7.2.0) - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.7(supports-color@7.2.0) - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@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 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@7.2.0) - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.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 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) - eslint-visitor-keys: 4.2.1 - - espree@11.2.0: - dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) - eslint-visitor-keys: 5.0.1 - - esprima@4.0.1: {} - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 - esutils@2.0.3: {} - execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -2100,16 +1080,6 @@ snapshots: expect-type@1.4.0: {} - fast-deep-equal@3.1.3: {} - - fast-json-patch@3.1.1: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-uri@3.1.7: {} - fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -2118,29 +1088,9 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - first-chunk-stream@3.0.0: {} - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.4 - keyv: 4.5.4 - - flatted@3.4.4: {} - fsevents@2.3.3: optional: true - get-caller-file@2.0.5: {} - get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 @@ -2175,14 +1125,6 @@ snapshots: git-cliff-windows-arm64: 2.13.1 git-cliff-windows-x64: 2.13.1 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@14.0.0: {} - - has-flag@4.0.0: {} - highlight.js@11.12.0: {} html-encoding-sniffer@6.0.0: @@ -2191,38 +1133,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - htmlparser2@10.1.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 7.0.1 - human-signals@8.0.1: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - ignore@5.3.2: {} - - image-size@2.0.2: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} @@ -2231,16 +1143,10 @@ snapshots: is-unicode-supported@2.1.0: {} - is-utf8@0.2.1: {} - isexe@2.0.0: {} js-tokens@10.0.0: {} - js-yaml@4.3.2: - dependencies: - argparse: 2.0.1 - jsdom@30.0.1: dependencies: '@asamuzakjp/css-color': 6.0.7 @@ -2267,51 +1173,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - json-buffer@3.0.1: {} - - json-merge-patch@1.0.2: - dependencies: - fast-deep-equal: 3.1.3 - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - jsonwebtoken@9.0.3: - dependencies: - jws: 4.0.1 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.8.5 - - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.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 - lightningcss-android-arm64@1.33.0: optional: true @@ -2361,26 +1222,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.includes@4.3.0: {} - - lodash.isboolean@3.0.3: {} - - lodash.isinteger@4.0.4: {} - - lodash.isnumber@3.0.3: {} - - lodash.isplainobject@4.0.6: {} - - lodash.isstring@4.0.1: {} - - lodash.merge@4.6.2: {} - - lodash.once@4.1.1: {} - lru-cache@11.5.2: {} magic-string@1.2.3: @@ -2395,129 +1236,43 @@ snapshots: mdn-data@2.27.1: {} - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.18 - - ms@2.1.3: {} - nanoid@3.3.18: {} - natural-compare@1.4.0: {} - npm-run-path@6.0.0: dependencies: path-key: 4.0.0 unicorn-magic: 0.3.0 - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - obug@2.1.4: {} - on-exit-leak-free@2.1.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 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - parse-ms@4.0.0: {} - parse5-htmlparser2-tree-adapter@7.1.0: - dependencies: - domhandler: 5.0.3 - parse5: 7.3.0 - - parse5-parser-stream@7.1.2: - dependencies: - parse5: 7.3.0 - - parse5@7.3.0: - dependencies: - entities: 6.0.1 - parse5@8.0.1: dependencies: entities: 8.1.0 - path-exists@4.0.0: {} - path-key@3.1.1: {} path-key@4.0.0: {} - pend@1.2.0: {} - picocolors@1.1.1: {} picomatch@4.0.7: {} - pino-abstract-transport@3.0.0: - dependencies: - split2: 4.2.0 - - pino-std-serializers@7.1.0: {} - - pino@10.3.1: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.1.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.1 - thread-stream: 4.2.0 - postcss@8.5.28: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 - prelude-ls@1.2.1: {} - pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 - process-warning@5.1.0: {} - punycode@2.3.1: {} - quick-format-unescaped@4.0.4: {} - - real-require@0.2.0: {} - - real-require@1.0.0: {} - - require-directory@2.1.1: {} - require-from-string@2.0.2: {} - resolve-from@4.0.0: {} - rolldown@1.2.7: dependencies: '@oxc-project/types': 0.148.0 @@ -2539,18 +1294,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.7 '@rolldown/binding-win32-x64-msvc': 1.2.7 - safe-buffer@5.2.1: {} - - safe-stable-stringify@2.5.0: {} - - safer-buffer@2.1.2: {} - saxes@6.0.0: dependencies: xmlchars: 2.2.0 - semver@7.8.5: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2561,58 +1308,16 @@ snapshots: signal-exit@4.1.0: {} - sonic-boom@4.2.1: - dependencies: - atomic-sleep: 1.0.0 - source-map-js@1.2.1: {} - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - split2@4.2.0: {} - stackback@0.0.2: {} std-env@4.2.0: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom-buf@2.0.0: - dependencies: - is-utf8: 0.2.1 - - strip-bom-stream@4.0.0: - dependencies: - first-chunk-stream: 3.0.0 - strip-bom-buf: 2.0.0 - strip-final-newline@4.0.0: {} - strip-json-comments@3.1.1: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - symbol-tree@3.2.4: {} - thread-stream@4.2.0: - dependencies: - real-require: 1.0.0 - tinybench@6.1.4: {} tinyexec@1.3.0: {} @@ -2638,24 +1343,10 @@ snapshots: dependencies: punycode: 2.3.1 - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - undici@7.29.1: {} - undici@8.10.2: {} unicorn-magic@0.3.0: {} - upath@3.0.7: {} - - upath@3.0.8: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - vite@8.2.2: dependencies: lightningcss: 1.33.0 @@ -2692,18 +1383,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - webidl-conversions@8.0.1: {} - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - - whatwg-mimetype@4.0.0: {} - whatwg-mimetype@5.0.0: {} whatwg-url@16.0.1: @@ -2731,36 +1412,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - word-wrap@1.2.5: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} - y18n@5.0.8: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yauzl@3.4.0: - dependencies: - pend: 1.2.0 - - yocto-queue@0.1.0: {} - yoctocolors@2.2.0: {} diff --git a/scripts/lint.sh b/scripts/lint.sh index 1729948..8fd74aa 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -2,31 +2,106 @@ # # Builds the archive and lints it: pnpm run lint # -# The linter is addons-linter, the engine behind `web-ext lint` and the same -# one Mozilla runs on submissions. Thunderbird has no linter of its own, so -# this is as close as an add-on here can get to a machine-checked review. +# The linter is Thunderbird's own webext-linter. It matches every `browser.*` +# call against Thunderbird's annotated API schemas and applies the +# addons.thunderbird.net review policies. # -# It lints the built .xpi rather than the working tree. web-ext's --source-dir -# mode would need its own ignore list, which is scripts/package.sh's exclusion -# list written a second time and drifting from it; running the archive instead -# checks the bytes that actually ship, tests and docs already absent. +# It replaced addons-linter, which is Mozilla's and knows Firefox. To that one +# the `compose` permission and every compose, composeAction, menus and +# scripting call read as an unsupported API, so its warning list was this +# add-on's entire reason for existing and could never be made fatal. This one +# recognises all of it, which is what makes an exit code worth failing a build +# on. +# +# Exit codes are the linter's own: 0 = no error-severity findings, 1 = one or +# more, 2 = the tool itself failed. Info-severity findings are printed and do +# not fail. Read them anyway; there are few and they are all real. +# +# It lints the built .xpi rather than the working tree, unchanged from before: +# a source-folder run would need its own ignore list, which is +# scripts/package.sh's exclusion list written a second time and drifting from +# it, while the archive is the bytes that actually ship. set -euo pipefail cd "$(dirname "$0")/.." +# Pinned to a commit, not a tag, because upstream has none: `git tag` and the +# releases list on the repository are both empty, and the project versions by +# commit message and package.json instead. This is the commit whose +# package.json reads 1.9.0. It is not on npm either - the @thunderbirdops +# scope exists but this package is not in it yet - which is why the tool is +# fetched here at all. The day it publishes, everything below collapses into +# an ordinary devDependency and a version range. +linter_repo="thunderbird/webext-linter" +linter_commit="fb6bc3f387d99d693a9c15dd618b29de3dd2289d" + +# Both are gitignored and excluded from the archive. The caches sit outside the +# tool directory so that bumping the pin above does not throw the fetched +# schemas away with it, and so CI can cache the expensive one without the one +# that is immutable anyway. +linter_dir=".webext-linter" +cache_dir=".webext-linter-cache" + +# The stamp is written last on purpose: an interrupted fetch then refetches +# rather than leaving a half-installed tool that looks present. +stamp="$linter_dir/.pinned-commit" +if [ "$(cat "$stamp" 2>/dev/null || true)" != "$linter_commit" ]; then + echo "Fetching $linter_repo@${linter_commit:0:12}" >&2 + rm -rf "$linter_dir" + mkdir -p "$linter_dir" + + # A codeload tarball rather than a clone: `git clone --depth 1` cannot be + # given a commit, and a full clone to reach one is the entire history for a + # single tree. + curl --fail --silent --show-error --location \ + "https://codeload.github.com/$linter_repo/tar.gz/$linter_commit" | + tar --extract --gzip --strip-components=1 --directory "$linter_dir" + + # pnpm is this repo's package manager and this is not this repo's dependency + # tree. The linter has sixteen runtime dependencies and ships its own + # package-lock.json, so it bootstraps with its own npm inside this ignored + # directory; nothing here reaches pnpm-lock.yaml or node_modules/. --omit=dev + # skips its prettier, which only its own contributors need. + (cd "$linter_dir" && npm ci --omit=dev --no-audit --no-fund >&2) + + echo "$linter_commit" >"$stamp" +fi + xpi="$(bash scripts/package.sh)" echo "Linting $xpi" >&2 -# --self-hosted turns off the checks that only apply to add-ons distributed -# through addons.mozilla.org. Without it the manifest's `update_url` is a hard -# error ("not allowed for Mozilla-hosted add-ons") - but self-serving updates -# is precisely why this add-on is not listed. See "Installing" in README.md. -# -# Warnings are not failures, and cannot be: addons-linter knows Firefox, so -# every MailExtension point this add-on exists to use - the `compose` -# permission, `compose.{get,set}ComposeDetails`, `composeAction.openPopup` - -# reads to it as an unsupported API. Making warnings fatal would mean silencing -# them one by one and losing the ones worth reading. Read the list; it should -# stay short. -exec pnpm exec addons-linter --self-hosted "$xpi" +# --checks-skip carries the two findings this add-on answers for deliberately, +# and nothing else is suppressed. The list is meant to stay this short. +# +# update-url is the direct replacement for addons-linter's --self-hosted. The +# check is right that an add-on serving its own updates cannot be listed on +# ATN, and staying off ATN is precisely why this one serves its own updates. +# See "Installing" in README.md. +# +# unused-files is an upstream bug rather than a finding. The check exempts +# licence and readme files, but it decides whether a file is documentation from +# the last dot in the whole path instead of in the file name, so for +# `vendor/highlight.js/LICENSE` it reads the extension as `.js/license`, misses +# the exemption and reports the vendored BSD-3-Clause notice as dead weight. +# That notice has to ship and the directory is named after the library, so +# there is nothing here to fix. The cost is real: this check is what found +# cliff.toml sitting unreferenced in the archive, so scripts/package.sh's +# exclusion list is once again the only thing keeping the archive clean. +# +# --cdn-lib-lookup false turns off identifying an unrecognised bundled library +# by content-hash lookup against jsDelivr and friends. The only bundled library +# here is the vendored highlight.js, which is hand-modified (see +# vendor/highlight.js/PROVENANCE.md), so a content hash cannot match it by +# construction. Checked both ways: with the lookup on, four third-party hosts +# are asked and nothing is found. Off, the run learns the same thing without +# depending on them being up. +# +# The cache directories are named rather than left to default because the +# default is relative to the working directory, and CI caches a fixed path. +exec node "$linter_dir/verify.js" "$xpi" \ + --cache-schema-dir "$cache_dir/schema" \ + --cache-hash-db-dir "$cache_dir/lib-hash-db" \ + --cache-experiments-dir "$cache_dir/experiments" \ + --cdn-lib-lookup false \ + --checks-skip update-url,unused-files diff --git a/scripts/package.sh b/scripts/package.sh index 18145e2..cee17c5 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -45,8 +45,15 @@ exclusions=( 'tests/*' 'vitest.config.js' 'coverage/*' - # This script and anything else that builds rather than ships. + # Release tooling. git-cliff renders the release notes at publish time and + # nothing at runtime reads its config; it shipped in the archive until + # Thunderbird's linter noticed it sitting there unreferenced. + 'cliff.toml' + # This script and anything else that builds rather than ships, including the + # linter scripts/lint.sh fetches and the caches it fills. 'scripts/*' + '.webext-linter/*' + '.webext-linter-cache/*' # Its own output, and any archive left at the root by an earlier convention. 'dist/*' '*.xpi' From 303a398dd0905e8ecbfa4101ced45eaad9b1a778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:52:54 +0200 Subject: [PATCH 09/22] test(compose): pin the insertion function's two fallback paths The function handed to the compose sandbox already reports which of its three paths ran, precisely so the mechanism is observable from outside the sandbox, and nothing read that report until now. It is unchanged: self-contained by requirement, taking only its argument object, so the simulated-DOM tier can call it with no seam to build. With the editor command reporting failure, a caret inside the body splices the block in and leaves the caret after it - asserted by typing at the caret afterwards rather than by pinning a node and an offset - a body with no caret anywhere appends rather than appearing to do nothing, and content that turns out to produce an empty fragment does not throw while the caret is repositioned. A plain-text composer's source arrives as text on both DOM paths, with no markup parsed out of it and its indentation intact. One test asserts the only thing this tier can say about the preferred path: a command that answers yes is the end of it, and neither fallback touches the document. The gap is deliberate rather than incidental. No simulated DOM implements the editor command, so every test says out loud what the command answered instead of relying on its absence, and the claim that a real editor action joins the undo stack stays with the real-Thunderbird tier. Co-Authored-By: Claude Opus 5 (1M context) --- tests/dom/insert-into-body.test.js | 343 +++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 tests/dom/insert-into-body.test.js diff --git a/tests/dom/insert-into-body.test.js b/tests/dom/insert-into-body.test.js new file mode 100644 index 0000000..2574a02 --- /dev/null +++ b/tests/dom/insert-into-body.test.js @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildCodeBlockHtml } from "../../src/code-block/build-code-block-html.js"; +import { insertIntoBody } from "../../src/compose/insert-into-body.js"; + +/** + * The insertion function's two DOM paths, driven against a simulated compose + * body. + * + * The function is handed to `scripting.executeScript({ func })` and re-evaluated + * inside the compose editor's sandbox, so it is self-contained by requirement + * and takes only its argument object. That is what makes it callable from here + * with no seam to build: a document and a selection are the whole of its + * environment. What it reports back - which of its three paths ran - is the + * interface these tests assert through, and until this file existed nothing + * read it. + * + * **The gap here is deliberate.** Its preferred path is an editor command, and + * no simulated DOM implements one: `tests/dom/tier.test.js` pins that + * `document.execCommand` is undefined in this tier, and the ADR records that + * neither jsdom nor its alternative has it. So every test below says out loud + * what the command answered, and the two fallbacks are covered with it + * reporting failure. The claim the preferred path is chosen for - that a real + * editor action joins the undo stack and marks the message modified - is a + * claim about Gecko's editor and cannot be made here at all. It belongs to the + * real-Thunderbird tier, and the one thing this tier can say about that path is + * the one asserted below: when the command answers yes, neither fallback + * touches the document. + * + * Nothing in here writes out markup. The content is what the popup would hand + * over - `buildCodeBlockHtml`'s `html` for an HTML composer and its `text` for + * a plain-text one - so retuning the block cannot fail these tests, and the + * document is read back as visible text rather than as a shape. + */ + +/** + * The source a user pasted. Indented, and carrying angle brackets that look + * like markup, because both are what the plain-text assertions are about: the + * indentation is what this whole feature exists to protect, and a `` that + * arrives as an element rather than as two characters is the bug a plain-text + * composer is a different editor in order to avoid. + */ +const snippet = 'function shout(word) {\n\treturn "" + word + "";\n}\n'; + +/** + * A real block from the pipeline rather than a hand-written string, because + * this is exactly what the popup passes: `html` for an HTML composer, and + * `text` - the normalised source, tabs expanded - for a plain-text one. The + * language is named so nothing here depends on what detection makes of the + * fixture. + */ +const block = buildCodeBlockHtml({ source: snippet, language: "plaintext" }); + +/** + * The editor command this tier does not have, taught to the document for the + * length of one test, answering what the test tells it to and recording what + * it was asked for. + * + * Stated per test rather than left to jsdom's absence. An absent command + * throws and the function falls through to the DOM path, so the fallbacks + * would be reached either way - but then the test would be silent about which + * of "the command said no" and "there was no command" it was covering, and the + * day a simulated DOM grows an `execCommand` that answers `true` the whole + * file would go green while asserting nothing. + */ +const editorCommand = (answer) => { + const asked = []; + document.execCommand = (command, showUi, value) => { + asked.push({ command, showUi, value }); + return answer; + }; + return asked; +}; + +/** + * Drops a caret into a text node, the way clicking into a message does. + */ +const caretAt = (node, offset) => { + const range = document.createRange(); + range.setStart(node, offset); + range.collapse(true); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +}; + +/** + * What the user types next, put in wherever the caret now is. + * + * This is how "the caret is left after the block" is asserted: the property + * that matters is that carrying on typing continues after the block rather + * than inside or before it, and reading it back this way says that without + * pinning which node and offset the implementation chose to express it as. + */ +const typeAtCaret = (text) => { + document + .getSelection() + .getRangeAt(0) + .insertNode(document.createTextNode(text)); +}; + +/** + * Whether these read in this order in the body's visible text. + * + * The body is read as text and not as markup on purpose: the block wraps + * itself and the composer's own paragraphs are the composer's business, so + * asserting on either would fail the day something gains an attribute without + * changing what anyone reads. Every assertion below passes the text itself as + * the label, so a failure reports what the body actually said. + */ +const readsInOrder = (...parts) => { + const text = document.body.textContent; + let from = 0; + return parts.every((part) => { + const at = text.indexOf(part, from); + from = at + part.length; + return at !== -1; + }); +}; + +/** Elements in the body, for asserting that content did or did not parse. */ +const elementCount = () => document.body.querySelectorAll("*").length; + +describe("insertIntoBody", () => { + beforeEach(() => { + document.body.innerHTML = ""; + document.getSelection().removeAllRanges(); + }); + + afterEach(() => { + // Put back the way this tier found it - absent - so that no test can pass + // because an earlier one taught the document a command of its own. + delete document.execCommand; + }); + + describe("with the editor command reporting failure", () => { + /** + * The caret is where the user put it, so the block goes in there and not + * at either end, and what was on both sides of it stays on both sides of + * it. Splicing into the middle of a paragraph is the case that would + * quietly lose the tail of it. + */ + it("splices the block in at the caret and leaves the caret after it", () => { + document.body.innerHTML = "

Here it is: and that is all.

"; + const paragraph = document.querySelector("p").firstChild; + caretAt(paragraph, "Here it is: ".length); + editorCommand(false); + + expect( + insertIntoBody({ content: block.html, isPlainText: false }), + ).toEqual({ mechanism: "range" }); + + typeAtCaret("Thanks!"); + expect( + readsInOrder("Here it is: ", block.text, "Thanks!", "and that is all."), + document.body.textContent, + ).toBe(true); + }); + + /** + * The commonest way to reach this: a composer whose body has never been + * clicked into has no selection at all. Appending rather than failing is + * what keeps the button from appearing to do nothing, and the reported + * mechanism is how the popup - which closes on insert and takes its + * console with it - could ever tell the two apart. + */ + it("appends when the body has no caret in it, and reports that it did", () => { + document.body.innerHTML = "

Morning,

"; + editorCommand(false); + + expect( + insertIntoBody({ content: block.html, isPlainText: false }), + ).toEqual({ mechanism: "append" }); + + expect( + readsInOrder("Morning,", block.text), + document.body.textContent, + ).toBe(true); + }); + + /** + * A caret that is somewhere other than the message body is not a caret + * this function may insert at, so it appends as if there were none. The + * failure this rules out is a block spliced into whatever else on the page + * happened to hold the selection. + */ + it("appends when the caret is outside the message body", () => { + document.body.innerHTML = "

Morning,

"; + const elsewhere = document.createElement("title"); + elsewhere.textContent = "not the message"; + document.head.append(elsewhere); + caretAt(elsewhere.firstChild, 0); + editorCommand(false); + + const inserted = insertIntoBody({ + content: block.html, + isPlainText: false, + }); + elsewhere.remove(); + + expect(inserted).toEqual({ mechanism: "append" }); + expect( + readsInOrder("Morning,", block.text), + document.body.textContent, + ).toBe(true); + }); + + /** + * A plain-text composer is a different editor rather than the same one + * with the styling switched off, so what goes in is the source itself: + * text, with the angle brackets in it staying two characters rather than + * becoming an element, and with the indentation the block exists to + * preserve arriving as it left. + * + * The same claim is made against a real plain-text composer by the + * real-Thunderbird tier, where the editor doing the accepting is Gecko's + * plaintext editor. What is asserted here is the fallback path: the source + * reaching the body as a text node and nothing being parsed out of it. + */ + it("puts a plain-text composer's source in as text, indentation and all", () => { + document.body.innerHTML = "
Morning,\n
"; + const body = document.querySelector("pre").firstChild; + caretAt(body, body.length); + editorCommand(false); + + const before = elementCount(); + expect( + insertIntoBody({ content: block.text, isPlainText: true }), + ).toEqual({ mechanism: "range" }); + + // The fixture has to be indented for this test to mean anything, so it + // says so rather than trusting itself. + expect(block.text).toMatch(/\n +return/); + expect(document.body.textContent).toContain(block.text); + expect(document.body.textContent).toContain('""'); + expect(elementCount()).toBe(before); + }); + + /** + * The two properties are independent - which path ran, and what that path + * puts in the document - so the plain-text case is covered on both. A + * composer never clicked into is where the append path is commonest, and + * it is the one where markup arriving instead of text would be least + * likely to be noticed before the message went out. + */ + it("appends a plain-text composer's source as text as well", () => { + document.body.innerHTML = "
Morning,\n
"; + editorCommand(false); + + const before = elementCount(); + expect( + insertIntoBody({ content: block.text, isPlainText: true }), + ).toEqual({ mechanism: "append" }); + + expect(document.body.textContent).toContain(block.text); + expect(elementCount()).toBe(before); + }); + + /** + * Content that parses to nothing is inserted, and then there is nothing to + * put the caret after: a fragment that turned out empty was never inserted + * and so has no parent, and repositioning relative to it would throw. The + * user-visible cost of that throw would be an insert reported as failed + * for a document that is exactly as they left it. + */ + it("does not throw when the content turns out to produce an empty fragment", () => { + document.body.innerHTML = "

Morning,

"; + caretAt(document.querySelector("p").firstChild, "Morning".length); + editorCommand(false); + + let inserted; + expect(() => { + inserted = insertIntoBody({ content: "", isPlainText: false }); + }).not.toThrow(); + + expect(inserted).toEqual({ mechanism: "range" }); + expect(document.body.textContent).toBe("Morning,"); + + // And the caret is still somewhere usable rather than dropped, which is + // the other half of "the document is as the user left it". + typeAtCaret("!"); + expect(document.body.textContent).toBe("Morning!,"); + }); + }); + + describe("when the editor command succeeds", () => { + /** + * The one thing this tier can say about the path it cannot run: that a + * command which answers yes is the end of it. Neither fallback may touch + * the document afterwards, or an insert that the editor already made would + * be made a second time by hand - two blocks, and the second one outside + * the undo step the first one created. + * + * The command here inserts nothing, so a document that is unchanged is the + * whole assertion. What it actually does to a real body, and whether that + * lands in the undo stack, is the real-Thunderbird tier's to say. + */ + it("reports the editor command and leaves the document to it", () => { + document.body.innerHTML = "

Here it is: and that is all.

"; + caretAt(document.querySelector("p").firstChild, "Here it is: ".length); + const asked = editorCommand(true); + const untouched = document.body.innerHTML; + const caret = document.getSelection().getRangeAt(0); + + expect( + insertIntoBody({ content: block.html, isPlainText: false }), + ).toEqual({ mechanism: "execCommand" }); + + expect(document.body.innerHTML).toBe(untouched); + expect(asked).toEqual([ + { command: "insertHTML", showUi: false, value: block.html }, + ]); + + // The caret is the editor's to move on this path, so the function leaves + // it exactly where it found it rather than repositioning a block it did + // not place. + const after = document.getSelection().getRangeAt(0); + expect(after.startContainer).toBe(caret.startContainer); + expect(after.startOffset).toBe(caret.startOffset); + }); + + /** + * Which command is asked for is the whole of the difference between the + * two composers on this path: the plaintext editor rejects an HTML insert, + * and its own insert is what maps the source's newlines onto whatever that + * editor represents a line break with instead of this function guessing. + */ + it("asks the plain-text editor for its own insert, not an HTML one", () => { + document.body.innerHTML = "
Morning,\n
"; + const body = document.querySelector("pre").firstChild; + caretAt(body, body.length); + const asked = editorCommand(true); + + expect( + insertIntoBody({ content: block.text, isPlainText: true }), + ).toEqual({ mechanism: "execCommand" }); + + expect(asked).toEqual([ + { command: "insertText", showUi: false, value: block.text }, + ]); + }); + }); +}); From bf519316fbf5ee5a7b6c07f69890bb52c1769310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:53:03 +0200 Subject: [PATCH 10/22] test(options): pin the correction notice on the options page The page gets no entry point: the shipped options.html is loaded into the simulated-DOM tier and the module imported over it, which is how it runs when Thunderbird opens it, and loading the real file is what makes a renamed field fail here rather than in someone's settings page. What is pinned is only the correction notice, which is the part worth pinning: an out-of-range value is stored clamped, the field shows the clamped value, and the page says it adjusted rather than reporting a plain save; an emptied field is reported as a correction, and it is also the only shape a nonsense value can arrive in, since a number field hands over an empty string for one; and a leading zero typed over the same number is not reported, because that is the field's own formatting and not a correction anyone needs telling about. The bounds are read from the settings module and the confirmation wording off the page itself, so retuning either stays a one-line change. The settings tests said the options page was verified by hand because the runner had no DOM. The DOM was never the whole reason and it is no longer the situation, so that comment now says which tier decides the numbers and which one shows them. Co-Authored-By: Claude Opus 5 (1M context) --- tests/dom/options.test.js | 182 ++++++++++++++++++++++++++++++++++++ tests/node/settings.test.js | 17 +++- 2 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 tests/dom/options.test.js diff --git a/tests/dom/options.test.js b/tests/dom/options.test.js new file mode 100644 index 0000000..bce9a7a --- /dev/null +++ b/tests/dom/options.test.js @@ -0,0 +1,182 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SETTING_FIELDS } from "../../src/settings/settings.js"; +import { installBrowserFake } from "../helpers/browser-fake.js"; + +/** + * The options page's correction notice. + * + * The page has no exported entry point and is not getting one: four + * assertions do not justify a second initialiser, and what it does at module + * scope is what it does when Thunderbird opens it. So the shipped HTML is + * loaded into this tier's document and the module is imported on top of it, + * which is as close to how it runs as anything short of a real Thunderbird. + * Loading the real file rather than a fixture is also what makes a renamed + * field fail here instead of in someone's settings page. + * + * What is worth pinning is only the correction notice. A value the block + * cannot use is not saved as typed, and the page says so out loud rather than + * letting the field change quietly under a "Saved." - a correction nobody is + * told about is one they find out about in an email they have already sent. + * The rest of the page is attribute plumbing, which the hand-run checklist + * covers better than a fake will. + * + * The numbers come from `SETTING_FIELDS`, and the wording is read back off the + * page itself rather than written out here: the bounds and the two sentences + * both belong to modules that own them, and retuning either should stay a + * one-line change rather than turning this file red. + */ + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const optionsPage = readFileSync( + resolve(repoRoot, "src/options/options.html"), + "utf8", +); + +let fake; + +/** + * Puts the shipped page in the document and runs its script over it, with + * `stored` as what `storage.local` already holds. + * + * The module does its work at module scope and ends in a top-level `await` on + * the settings read, so the import has to happen with the fake already + * installed, and the registry has to be reset per test or the second test gets + * the first test's page. The ` + + diff --git a/src/popup/popup.js b/src/popup/popup.js index e986c86..8911912 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -6,199 +6,17 @@ import { createLanguageLatch } from "./language-latch.js"; import { measureSnippet } from "./snippet-size.js"; import { loadThemeMap } from "./theme-map.js"; -const sourceField = document.getElementById("source"); -const languageField = document.getElementById("language"); -const insertButton = document.getElementById("insert"); -const errorLine = document.getElementById("error"); -const warningLine = document.getElementById("warning"); -const previewPane = document.getElementById("preview"); - -/** - * Tab width and font size, read once as the popup opens. - * - * Kept as the promise rather than awaited into a variable: the read starts - * immediately, so it is long finished by the time anyone has pasted anything, - * and awaiting it inside the insert removes the window where a fast Insert - * would find it not yet loaded. Re-reading per insert would buy freshness - * nobody can use - the popup is closed while the options page is open. - */ -const settings = readSettings(); - -/** - * Started at load, awaited at insert. Reading the theme is asynchronous - the - * stylesheet has to have finished parsing - but it does not depend on anything - * the user does, so kicking it off now means the wait has almost always - * already elapsed by the time Insert is pressed. - * - * Held as the promise rather than resolved into a variable so there is no - * moment where the map is "not ready yet" and something has to decide what to - * do about it. - */ -const themeMap = loadThemeMap(document.getElementById("theme")); - -/** - * The dropdown is the bundle's own language list, read back from it rather - * than written out here. A hand-kept list would drift from what is actually - * registered the first time the vendored bundle is bumped, and the failure - * would be an entry that throws or a language quietly missing from the menu. - * - * Labels come from the same place. `getLanguage(id).name` is the display name - * upstream ships for each language, so "cpp" reads as "C++" without this file - * owning a translation table. - */ -function fillLanguageDropdown() { - const options = hljs - .listLanguages() - .map((id) => ({ id, label: hljs.getLanguage(id).name ?? id })) - .sort((a, b) => a.label.localeCompare(b.label)); - - for (const { id, label } of options) { - languageField.add(new Option(label, id)); - } -} - -fillLanguageDropdown(); - -/** - * The override rule - whether the language has been taken over, and whether a - * fresh guess is owed - lives in ./language-latch.js, where it can be driven - * without a document. One latch per popup, built here rather than imported as - * state, because the document is built fresh every time the button is clicked - * and the rule resets with it. - * - * What is left in this file is the two halves the latch deliberately does not - * know about: which edits count as wholesale, which needs the event, and what - * the dropdown currently shows, which is passed in on every question. - */ -const latch = createLanguageLatch(); - -languageField.addEventListener("change", () => { - latch.takeOver(); - // `change` is what a dropdown fires, and re-rendering on it is what makes a - // corrected language confirmable by eye without touching the source again. - schedulePreview(); -}); - -/** - * The compose window this popup was opened from. - * - * A popup anchored in a compose window resolves `currentWindow` to that - * window, so the active tab is the composer the button was clicked in - which - * is what keeps a snippet out of the wrong email when several composers are - * open. If the resolved tab is not a composer, we refuse rather than guess at - * another one. - */ -async function findComposeTab() { - const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); - if (!tab || tab.type !== "messageCompose") { - throw new Error("No compose window found for this popup."); - } - return tab; -} - -async function insert() { - const tab = await findComposeTab(); - // Which of the two blocks this composer can take is a property of the - // window, not a choice: the compose format of an open window cannot be - // changed, and `setComposeDetails` ignores `isPlainText`. So we ask and - // adapt rather than offering to switch, and the button works either way. - const { isPlainText } = await browser.compose.getComposeDetails(tab.id); - - // Both settings arrive resolved - `readSettings` falls back to the seam's - // defaults for anything unset or unusable - so there is nothing to check - // here, and no branch for "settings never configured". - const { tabWidth, fontSize } = await settings; - - // The theme is passed in as data, always, even for the plain-text composer - // that will not use it. Branching on `isPlainText` here would put a second - // reason to know about the composer's format into the one call that should - // not care: the seam already renders both and the caller picks. - const { html, text } = buildCodeBlockHtml({ - source: sourceField.value, - // Not the dropdown directly: an insert can outrun the debounced render - // that would have filled it in, and this asks for detection in that window - // rather than shipping a block under a language nobody chose. Asking - // without honouring leaves the request standing, so the render that was - // already due still detects. - language: latch.requestedLanguage(languageField.value), - themeMap: await themeMap, - tabWidth, - fontSize, - }); - - if (!isPlainText) { - // Set before the block goes in, never after. The default `"auto"` sends an - // HTML message as plain text when it sees no formatting, which would drop - // the block entirely; `"both"` also guarantees the plain-text alternative - // part. Doing it first means a failure here costs an insert rather than - // leaving an already-inserted block on a message that will downgrade it. - // - // Which is only safe because a body-less call leaves the document alone, - // and that is worth citing rather than assuming: the spec's blanket "every - // call replaces the whole document, moves the caret to the top and - // destroys the undo history" is true only of a call that carries a body. - // `ext-compose.js` hands the details to `SetComposeDetails` in - // `MsgComposeCommands.js`, where the `innerHTML` assignment, - // `editor.beginningOfDocument()` and `editor.clearUndoRedo()` all sit - // inside `if (typeof newValues.body == "string")`. `deliveryFormat` is - // handled separately, and only sets `compFields.deliveryFormat` and - // refreshes the send-format menu. So passing `deliveryFormat` alone cannot - // touch the caret this insert is about to read - the only marks it leaves - // are `gContentChanged = true`, on a message we are about to change - // anyway, and a `focus()` back onto whatever was focused. - await browser.compose.setComposeDetails(tab.id, { deliveryFormat: "both" }); - } - // A plain-text message is skipped deliberately: `deliveryFormat` describes - // how an HTML message is put on the wire, and there is no HTML part here to - // downgrade. Ticket 02 predicted this call would be rejected on a plain-text - // composer, which the popup would then surface as an error while inserting - // nothing - the button looking broken in exactly the window this ticket is - // about. Not making the call is both the fix and the honest description. - - const [injection] = await browser.scripting.executeScript({ - target: { tabId: tab.id }, - func: insertIntoBody, - args: [{ content: isPlainText ? text : html, isPlainText }], - }); - if (injection.error) { - throw injection.error; - } -} - -/** - * Advisory, and structurally so: this function writes to the warning line and - * to nothing else. It never touches `insertButton.disabled`, and neither does - * the insert path read the warning - emailing three thousand lines of code is a - * mistake worth mentioning and not one worth preventing. Ticket 02 removed the - * last thing that gated Insert on the textarea's contents; this is not quietly - * putting one back, and there is no size at which it starts to. - * - * Recomputed from scratch on every source change, which covers paste, typing, - * cut and undo alike. That is also what clears the warning again when the - * content drops back under the threshold: there is no separate hide path to - * forget to call. - */ -function refreshSizeWarning() { - const { lineCount, isLarge } = measureSnippet(sourceField.value); - warningLine.textContent = isLarge - ? `${lineCount} lines. The block carries all its formatting inline, so ` + - `the inserted HTML will be several times the size of the source. ` + - `This is a heads-up, not a limit.` - : ""; - warningLine.hidden = !isLarge; -} - /** * How long the popup waits for typing to stop before re-rendering, in * milliseconds. * * The seam is not free: highlighting is a scan over the whole snippet, and * detection scores it against all 36 grammars - around 100ms for a 500-line - * paste and half a second for the 3000-line one ticket 11's warning exists - * for. The snippet may be hundreds of lines, and rendering on every keystroke - * would do all of that once per character and throw all but the last result - * away. This is the same debounce the detection rides on, which is the point: - * one source change, one pass. + * paste and half a second for the 3000-line one ticket 11's warning exists for. + * The snippet may be hundreds of lines, and rendering on every keystroke would + * do all of that once per character and throw all but the last result away. + * This is the same debounce the detection rides on, which is the point: one + * source change, one pass. * * A trailing debounce is the simplest thing that fixes it, and the only thing * tried. `requestIdleCallback` would schedule better and would also mean a @@ -211,277 +29,509 @@ function refreshSizeWarning() { */ const PREVIEW_DEBOUNCE_MS = 150; -let previewTimer; - /** - * Guards against an older render finishing after a newer one. Incremented by - * every call to `renderFromSource` and compared across its awaits. - */ -let previewGeneration = 0; - -/** - * Renders the preview, and settles the language, from one call to the seam. - * - * One call rather than two is not an optimisation bolted on afterwards: it is - * what makes the dropdown and the preview incapable of disagreeing. Ticket 04 - * detected on its own pass and ticket 06 rendered on another, and neither - * could see the other; a snippet that detects as `x` cannot now be previewed - * as `y`, because there is one `detectedLanguage` and one `html` and they came - * out of the same call over the same source. + * Wires up a popup document and starts the work that opening it implies: the + * language dropdown, the settings and theme reads, the load-time render, the + * right-click prefill, and the four listeners below. * - * The popup asks for detection the way any caller does - by naming no - * language - and reads back `detectedLanguage`, which is the language that was - * applied and not the one that was requested. Detection itself lives behind - * the seam, and this file neither knows nor can tell that `hljs.highlightAuto` - * is involved. + * All of it used to be this module's side effects, which made an import the + * only way in - so it could not be run twice, and it took the clock from + * whatever global happened to be around it. A test of this file needs both, and + * neither was a property worth keeping: the document and the debounce's two + * timer functions are arguments now, each defaulting to the page's own, and the + * order of everything below is otherwise untouched. * - * The rest is the preview: `html` here is not a rendering *like* the - * one that gets inserted, it is the string that will be. The seam is pure, so - * the same source, language, theme map and settings cannot produce two - * different blocks - which is why the preview can be trusted, and why there is - * deliberately no preview stylesheet and no simplified preview markup anywhere - * in this popup. A second rendering path would be a second thing to keep - * correct, and its drift would show up as a preview that was accurate right up - * until the day it mattered. + * Deliberately one function rather than a handful of exported handlers. The + * class of bug this file has actually had is a wiring bug - three `input` + * listeners registered by work that could not see itself, and a prefill that + * replayed each of them by hand - and handlers exported one at a time would let + * a test assert every one of them while the wiring between them, which is the + * part that broke, stayed unasserted. * - * The obvious tension is that this puts a built HTML string into a live - * document, which is the shape of an injection bug. Three things make it not - * one, and it is worth saying which of them is the real defence: - * - * - The string is not user HTML. It is the seam's output, and the seam escapes - * every `&`, `<` and `>` in the source before it becomes markup - a test - * pins that - so pasted markup arrives as text. This is the guarantee that - * matters, and it is the same one the message body already relies on. - * - It is parsed inertly, by `DOMParser` into a detached document, and only - * the resulting block element is adopted. A parse is not an execution: no script - * runs, no `src` is fetched, no handler attribute is honoured, and that holds - * whatever the string turns out to contain. `innerHTML` on the live document - * would be one line shorter and would also fetch an `` if the seam - * ever emitted one. - * - The popup is an extension page under the default MV3 CSP, so inline script - * could not run here even if something managed to write it in. + * @param {object} [host] + * @param {Document} [host.document] The document to wire up. + * @param {typeof globalThis.setTimeout} [host.setTimeout] The debounce's clock, + * taken as an argument so that a test can hold it still rather than wait out + * a real 150ms per render. + * @param {typeof globalThis.clearTimeout} [host.clearTimeout] */ -async function renderFromSource() { - // Both promises were started at load and are long resolved by the time - // anyone has pasted anything. They are awaited here rather than kept in a - // variable for the same reason the insert awaits them: there is then no - // state where this has to decide what a not-yet-loaded theme means. - const generation = ++previewGeneration; - const { tabWidth, fontSize } = await settings; - const resolvedThemeMap = await themeMap; - // A newer render was scheduled while this one waited. Dropping the stale one - // keeps an older render from being the one left on screen: with a debounce in - // front this is close to unreachable, but "close to" is not a property worth - // relying on for the element whose whole job is to be accurate. - if (generation !== previewGeneration) { - return; +export function startPopup({ + document = globalThis.document, + setTimeout = globalThis.setTimeout, + clearTimeout = globalThis.clearTimeout, +} = {}) { + // The window the popup is closed through, read off the document rather than + // taken from the global for the same reason as everything else here. + const view = document.defaultView; + + const sourceField = document.getElementById("source"); + const languageField = document.getElementById("language"); + const insertButton = document.getElementById("insert"); + const errorLine = document.getElementById("error"); + const warningLine = document.getElementById("warning"); + const previewPane = document.getElementById("preview"); + + /** + * Tab width and font size, read once as the popup opens. + * + * Kept as the promise rather than awaited into a variable: the read starts + * immediately, so it is long finished by the time anyone has pasted anything, + * and awaiting it inside the insert removes the window where a fast Insert + * would find it not yet loaded. Re-reading per insert would buy freshness + * nobody can use - the popup is closed while the options page is open. + */ + const settings = readSettings(); + + /** + * Started at load, awaited at insert. Reading the theme is asynchronous - the + * stylesheet has to have finished parsing - but it does not depend on + * anything the user does, so kicking it off now means the wait has almost + * always already elapsed by the time Insert is pressed. + * + * Held as the promise rather than resolved into a variable so there is no + * moment where the map is "not ready yet" and something has to decide what to + * do about it. + */ + const themeMap = loadThemeMap(document.getElementById("theme")); + + /** + * The dropdown is the bundle's own language list, read back from it rather + * than written out here. A hand-kept list would drift from what is actually + * registered the first time the vendored bundle is bumped, and the failure + * would be an entry that throws or a language quietly missing from the menu. + * + * Labels come from the same place. `getLanguage(id).name` is the display name + * upstream ships for each language, so "cpp" reads as "C++" without this file + * owning a translation table. + */ + function fillLanguageDropdown() { + const options = hljs + .listLanguages() + .map((id) => ({ id, label: hljs.getLanguage(id).name ?? id })) + .sort((a, b) => a.label.localeCompare(b.label)); + + for (const { id, label } of options) { + // Built through the supplied document rather than the `Option` + // constructor, which would always take the page's own. + const option = document.createElement("option"); + option.value = id; + option.textContent = label; + languageField.add(option); + } } - const source = sourceField.value; - // Asked after the staleness check, because asking this way spends the - // request: a render that turns out to be stale must not swallow a detection - // the newer one still owes. - const language = latch.honourRequest(languageField.value); - - const { html, detectedLanguage } = buildCodeBlockHtml({ - source, - language, - themeMap: resolvedThemeMap, - tabWidth, - fontSize, + fillLanguageDropdown(); + + /** + * The override rule - whether the language has been taken over, and whether a + * fresh guess is owed - lives in ./language-latch.js, where it can be driven + * without a document. One latch per popup, built here rather than imported as + * state, because the document is built fresh every time the button is clicked + * and the rule resets with it. + * + * What is left in this file is the two halves the latch deliberately does not + * know about: which edits count as wholesale, which needs the event, and what + * the dropdown currently shows, which is passed in on every question. + */ + const latch = createLanguageLatch(); + + languageField.addEventListener("change", () => { + latch.takeOver(); + // `change` is what a dropdown fires, and re-rendering on it is what makes a + // corrected language confirmable by eye without touching the source again. + schedulePreview(); }); - // Assigning `value` is safe for any result: detection can only return a name - // `hljs.listLanguages()` carries, and that is the same list the dropdown was - // filled from. It fires no `change`, so writing it here cannot be mistaken - // for the user taking the language over. - if (language === undefined) { - languageField.value = detectedLanguage; + /** + * The compose window this popup was opened from. + * + * A popup anchored in a compose window resolves `currentWindow` to that + * window, so the active tab is the composer the button was clicked in - which + * is what keeps a snippet out of the wrong email when several composers are + * open. If the resolved tab is not a composer, we refuse rather than guess at + * another one. + */ + async function findComposeTab() { + const [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + if (!tab || tab.type !== "messageCompose") { + throw new Error("No compose window found for this popup."); + } + return tab; } - // An empty textarea shows nothing - not the bordered empty box the seam - // returns for empty source, and not an error either. There is nothing to - // preview before anything has been pasted, and a box appearing the moment - // the popup opens would read as the block already existing. The call above - // still happened, and cost nothing: it is what puts the dropdown on Plain - // text for an empty document. - // - // Literally empty, not whitespace-only. Source that is all spaces *does* - // insert an empty bordered box, and a preview that hid it would be lying - // about the one thing this element exists to tell the truth about. - if (source === "") { - hidePreview(); - return; + async function insert() { + const tab = await findComposeTab(); + // Which of the two blocks this composer can take is a property of the + // window, not a choice: the compose format of an open window cannot be + // changed, and `setComposeDetails` ignores `isPlainText`. So we ask and + // adapt rather than offering to switch, and the button works either way. + const { isPlainText } = await browser.compose.getComposeDetails(tab.id); + + // Both settings arrive resolved - `readSettings` falls back to the seam's + // defaults for anything unset or unusable - so there is nothing to check + // here, and no branch for "settings never configured". + const { tabWidth, fontSize } = await settings; + + // The theme is passed in as data, always, even for the plain-text composer + // that will not use it. Branching on `isPlainText` here would put a second + // reason to know about the composer's format into the one call that should + // not care: the seam already renders both and the caller picks. + const { html, text } = buildCodeBlockHtml({ + source: sourceField.value, + // Not the dropdown directly: an insert can outrun the debounced render + // that would have filled it in, and this asks for detection in that + // window rather than shipping a block under a language nobody chose. + // Asking without honouring leaves the request standing, so the render + // that was already due still detects. + language: latch.requestedLanguage(languageField.value), + themeMap: await themeMap, + tabWidth, + fontSize, + }); + + if (!isPlainText) { + // Set before the block goes in, never after. The default `"auto"` sends + // an HTML message as plain text when it sees no formatting, which would + // drop the block entirely; `"both"` also guarantees the plain-text + // alternative part. Doing it first means a failure here costs an insert + // rather than leaving an already-inserted block on a message that will + // downgrade it. + // + // Which is only safe because a body-less call leaves the document alone, + // and that is worth citing rather than assuming: the spec's blanket + // "every call replaces the whole document, moves the caret to the top and + // destroys the undo history" is true only of a call that carries a body. + // `ext-compose.js` hands the details to `SetComposeDetails` in + // `MsgComposeCommands.js`, where the `innerHTML` assignment, + // `editor.beginningOfDocument()` and `editor.clearUndoRedo()` all sit + // inside `if (typeof newValues.body == "string")`. `deliveryFormat` is + // handled separately, and only sets `compFields.deliveryFormat` and + // refreshes the send-format menu. So passing `deliveryFormat` alone + // cannot touch the caret this insert is about to read - the only marks it + // leaves are `gContentChanged = true`, on a message we are about to + // change anyway, and a `focus()` back onto whatever was focused. + await browser.compose.setComposeDetails(tab.id, { + deliveryFormat: "both", + }); + } + // A plain-text message is skipped deliberately: `deliveryFormat` describes + // how an HTML message is put on the wire, and there is no HTML part here to + // downgrade. Ticket 02 predicted this call would be rejected on a + // plain-text composer, which the popup would then surface as an error while + // inserting nothing - the button looking broken in exactly the window this + // ticket is about. Not making the call is both the fix and the honest + // description. + + const [injection] = await browser.scripting.executeScript({ + target: { tabId: tab.id }, + func: insertIntoBody, + args: [{ content: isPlainText ? text : html, isPlainText }], + }); + if (injection.error) { + throw injection.error; + } } - const parsed = new DOMParser().parseFromString(html, "text/html"); - previewPane.replaceChildren( - document.importNode(parsed.body.firstElementChild, true), - ); - previewPane.hidden = false; -} - -function hidePreview() { - previewPane.replaceChildren(); - previewPane.hidden = true; -} - -function schedulePreview() { - clearTimeout(previewTimer); - previewTimer = setTimeout(renderNow, PREVIEW_DEBOUNCE_MS); -} + /** + * Advisory, and structurally so: this function writes to the warning line and + * to nothing else. It never touches `insertButton.disabled`, and neither does + * the insert path read the warning - emailing three thousand lines of code is + * a mistake worth mentioning and not one worth preventing. Ticket 02 removed + * the last thing that gated Insert on the textarea's contents; this is not + * quietly putting one back, and there is no size at which it starts to. + * + * Recomputed from scratch on every source change, which covers paste, typing, + * cut and undo alike. That is also what clears the warning again when the + * content drops back under the threshold: there is no separate hide path to + * forget to call. + */ + function refreshSizeWarning() { + const { lineCount, isLarge } = measureSnippet(sourceField.value); + warningLine.textContent = isLarge + ? `${lineCount} lines. The block carries all its formatting inline, so ` + + `the inserted HTML will be several times the size of the source. ` + + `This is a heads-up, not a limit.` + : ""; + warningLine.hidden = !isLarge; + } -/** - * Renders without waiting, and cancels any render that was waiting. - * - * Used for the two ways content arrives that are not typing - the popup - * opening, and the right-click prefill - where a debounce would only mean the - * dropdown visibly correcting itself a moment after the popup appeared. - */ -function renderNow() { - clearTimeout(previewTimer); - // A render that fails clears the preview rather than leaving the last good - // one up. Stale is the one failure mode this element must not have: a - // preview showing the previous language beside a dropdown showing the new - // one is worse than no preview at all. The error itself is not surfaced - // here - the insert makes the identical call and reports it properly on the - // error line, and a preview failure is not an insert failure until someone - // presses Insert. - renderFromSource().catch(hidePreview); -} + let previewTimer; + + /** + * Guards against an older render finishing after a newer one. Incremented by + * every call to `renderFromSource` and compared across its awaits. + */ + let previewGeneration = 0; + + /** + * Renders the preview, and settles the language, from one call to the seam. + * + * One call rather than two is not an optimisation bolted on afterwards: it is + * what makes the dropdown and the preview incapable of disagreeing. Ticket 04 + * detected on its own pass and ticket 06 rendered on another, and neither + * could see the other; a snippet that detects as `x` cannot now be previewed + * as `y`, because there is one `detectedLanguage` and one `html` and they + * came out of the same call over the same source. + * + * The popup asks for detection the way any caller does - by naming no + * language - and reads back `detectedLanguage`, which is the language that + * was applied and not the one that was requested. Detection itself lives + * behind the seam, and this file neither knows nor can tell that + * `hljs.highlightAuto` is involved. + * + * The rest is the preview: `html` here is not a rendering *like* the one that + * gets inserted, it is the string that will be. The seam is pure, so the same + * source, language, theme map and settings cannot produce two different + * blocks - which is why the preview can be trusted, and why there is + * deliberately no preview stylesheet and no simplified preview markup + * anywhere in this popup. A second rendering path would be a second thing to + * keep correct, and its drift would show up as a preview that was accurate + * right up until the day it mattered. + * + * The obvious tension is that this puts a built HTML string into a live + * document, which is the shape of an injection bug. Three things make it not + * one, and it is worth saying which of them is the real defence: + * + * - The string is not user HTML. It is the seam's output, and the seam + * escapes every `&`, `<` and `>` in the source before it becomes markup - a + * test pins that - so pasted markup arrives as text. This is the guarantee + * that matters, and it is the same one the message body already relies on. + * - It is parsed inertly, by `DOMParser` into a detached document, and only + * the resulting block element is adopted. A parse is not an execution: no + * script runs, no `src` is fetched, no handler attribute is honoured, and + * that holds whatever the string turns out to contain. `innerHTML` on the + * live document would be one line shorter and would also fetch an + * `` if the seam ever emitted one. + * - The popup is an extension page under the default MV3 CSP, so inline + * script could not run here even if something managed to write it in. + */ + async function renderFromSource() { + // Both promises were started at load and are long resolved by the time + // anyone has pasted anything. They are awaited here rather than kept in a + // variable for the same reason the insert awaits them: there is then no + // state where this has to decide what a not-yet-loaded theme means. + const generation = ++previewGeneration; + const { tabWidth, fontSize } = await settings; + const resolvedThemeMap = await themeMap; + // A newer render was scheduled while this one waited. Dropping the stale + // one keeps an older render from being the one left on screen: with a + // debounce in front this is close to unreachable, but "close to" is not a + // property worth relying on for the element whose whole job is to be + // accurate. + if (generation !== previewGeneration) { + return; + } + + const source = sourceField.value; + // Asked after the staleness check, because asking this way spends the + // request: a render that turns out to be stale must not swallow a detection + // the newer one still owes. + const language = latch.honourRequest(languageField.value); + + const { html, detectedLanguage } = buildCodeBlockHtml({ + source, + language, + themeMap: resolvedThemeMap, + tabWidth, + fontSize, + }); + + // Assigning `value` is safe for any result: detection can only return a + // name `hljs.listLanguages()` carries, and that is the same list the + // dropdown was filled from. It fires no `change`, so writing it here cannot + // be mistaken for the user taking the language over. + if (language === undefined) { + languageField.value = detectedLanguage; + } + + // An empty textarea shows nothing - not the bordered empty box the seam + // returns for empty source, and not an error either. There is nothing to + // preview before anything has been pasted, and a box appearing the moment + // the popup opens would read as the block already existing. The call above + // still happened, and cost nothing: it is what puts the dropdown on Plain + // text for an empty document. + // + // Literally empty, not whitespace-only. Source that is all spaces *does* + // insert an empty bordered box, and a preview that hid it would be lying + // about the one thing this element exists to tell the truth about. + if (source === "") { + hidePreview(); + return; + } + + const parsed = new DOMParser().parseFromString(html, "text/html"); + previewPane.replaceChildren( + document.importNode(parsed.body.firstElementChild, true), + ); + previewPane.hidden = false; + } -/** - * Everything that happens when the source changes, in one place and in one - * order. - * - * There were three `input` listeners here - detection, the size warning, the - * preview - registered by three tickets that could not see each other, and the - * prefill below had to replay each of them by hand. One entry point means the - * prefill announces a change instead of re-enacting one, and means the - * difference between the paths is stated as data rather than as which - * listeners a caller remembered to call. - * - * @param {object} change - * @param {boolean} change.wholesale Whether the content was replaced rather - * than edited, which is the only thing detection keys on. - * @param {boolean} [change.immediate] Render now rather than after the - * debounce. Typing is the debounced case and everything else is not: content - * that arrives all at once has no burst to collapse. - */ -function handleSourceChanged({ wholesale, immediate = false }) { - // Not debounced, and cheap enough not to be: counting lines is a scan, not a - // highlight, and a warning that appeared a fifth of a second after the paste - // would read as a reaction to whatever the user did next. - refreshSizeWarning(); - latch.sourceChanged({ wholesale }); - - if (immediate) { - renderNow(); - return; + function hidePreview() { + previewPane.replaceChildren(); + previewPane.hidden = true; } - schedulePreview(); -} -sourceField.addEventListener("input", (event) => { - handleSourceChanged({ wholesale: isWholesaleChange(event) }); -}); + function schedulePreview() { + clearTimeout(previewTimer); + previewTimer = setTimeout(renderNow, PREVIEW_DEBOUNCE_MS); + } -/** - * Whether this edit replaced the content wholesale - a paste, a drop, a - * middle-click yank - rather than moving it along by a character. - * - * Detection is not cheap, and it is not wanted per keystroke even if it were: - * the trigger is the arrival of new content and not every edit of it. That is - * also the honest reading of the story - the language is detected when code is - * pasted, and a snippet being tweaked afterwards has already got one - and it - * is what keeps the dropdown from re-guessing under someone's fingers while - * they fix a typo. - * - * An event with no `inputType` at all counts as wholesale. A browser that will - * not say what happened should cost a redundant detection, not a dropdown that - * silently never updates again. - */ -function isWholesaleChange(event) { - return !event.inputType || event.inputType.startsWith("insertFrom"); -} + /** + * Renders without waiting, and cancels any render that was waiting. + * + * Used for the two ways content arrives that are not typing - the popup + * opening, and the right-click prefill - where a debounce would only mean the + * dropdown visibly correcting itself a moment after the popup appeared. + */ + function renderNow() { + clearTimeout(previewTimer); + // A render that fails clears the preview rather than leaving the last good + // one up. Stale is the one failure mode this element must not have: a + // preview showing the previous language beside a dropdown showing the new + // one is worse than no preview at all. The error itself is not surfaced + // here - the insert makes the identical call and reports it properly on the + // error line, and a preview failure is not an insert failure until someone + // presses Insert. + renderFromSource().catch(hidePreview); + } -// Once at load, so the warning line and the dropdown start in a state this -// file owns rather than one the markup guessed at. It comes out at no warning -// and Plain text, which is what an empty document should say. -handleSourceChanged({ wholesale: true, immediate: true }); + /** + * Everything that happens when the source changes, in one place and in one + * order. + * + * There were three `input` listeners here - detection, the size warning, the + * preview - registered by three tickets that could not see each other, and + * the prefill below had to replay each of them by hand. One entry point means + * the prefill announces a change instead of re-enacting one, and means the + * difference between the paths is stated as data rather than as which + * listeners a caller remembered to call. + * + * @param {object} change + * @param {boolean} change.wholesale Whether the content was replaced rather + * than edited, which is the only thing detection keys on. + * @param {boolean} [change.immediate] Render now rather than after the + * debounce. Typing is the debounced case and everything else is not: + * content that arrives all at once has no burst to collapse. + */ + function handleSourceChanged({ wholesale, immediate = false }) { + // Not debounced, and cheap enough not to be: counting lines is a scan, not + // a highlight, and a warning that appeared a fifth of a second after the + // paste would read as a reaction to whatever the user did next. + refreshSizeWarning(); + latch.sourceChanged({ wholesale }); + + if (immediate) { + renderNow(); + return; + } + schedulePreview(); + } -/** - * Right-click path: the menu handler parks the selected text against the - * compose tab and opens this popup, which claims it here. The background drops - * the text as it hands it over, so a toolbar or shortcut open - which parks - * nothing - gets an empty string and the popup opens empty. - * - * The prefill is the convenient path, not the reliable one. `selectionText` is - * plain text extracted from HTML, so whatever indentation it arrives with is - * whatever survived that extraction. Pasting over it is still the path that - * gives the block its indentation back. - */ -async function claimSelectionPrefill() { - const tab = await findComposeTab(); - const selectionText = await browser.runtime.sendMessage({ - type: "thundercode:take-pending-selection", - tabId: tab.id, + sourceField.addEventListener("input", (event) => { + handleSourceChanged({ wholesale: isWholesaleChange(event) }); }); - if (typeof selectionText !== "string" || selectionText === "") { - return; + + /** + * Whether this edit replaced the content wholesale - a paste, a drop, a + * middle-click yank - rather than moving it along by a character. + * + * Detection is not cheap, and it is not wanted per keystroke even if it were: + * the trigger is the arrival of new content and not every edit of it. That is + * also the honest reading of the story - the language is detected when code + * is pasted, and a snippet being tweaked afterwards has already got one - and + * it is what keeps the dropdown from re-guessing under someone's fingers + * while they fix a typo. + * + * An event with no `inputType` at all counts as wholesale. A browser that + * will not say what happened should cost a redundant detection, not a + * dropdown that silently never updates again. + */ + function isWholesaleChange(event) { + return !event.inputType || event.inputType.startsWith("insertFrom"); } - sourceField.value = selectionText; - // Assigning `value` from script fires no `input` event, so the change has to - // be announced by hand - once, to the one thing that watches the textarea. - // Content that arrived from outside is as wholesale as a paste, and it is - // already here rather than being typed, so there is no burst to wait out. - handleSourceChanged({ wholesale: true, immediate: true }); -} -// Deliberately silent on failure. A prefill that does not arrive leaves an -// empty textarea, which is exactly what the toolbar button opens anyway; an -// error line here would report a broken convenience as a broken popup. -claimSelectionPrefill().catch(() => {}); + // Once at load, so the warning line and the dropdown start in a state this + // file owns rather than one the markup guessed at. It comes out at no warning + // and Plain text, which is what an empty document should say. + handleSourceChanged({ wholesale: true, immediate: true }); -/** - * The one path from "confirm" to a closed popup, shared by the button and the - * keyboard. Both entry points have to behave identically, including the error - * branch - a shortcut that silently does nothing is worse than one that does - * not exist. - */ -async function confirmInsert() { - if (insertButton.disabled) { - return; // An insert is already in flight; a second Ctrl+Enter is a no-op. + /** + * Right-click path: the menu handler parks the selected text against the + * compose tab and opens this popup, which claims it here. The background + * drops the text as it hands it over, so a toolbar or shortcut open - which + * parks nothing - gets an empty string and the popup opens empty. + * + * The prefill is the convenient path, not the reliable one. `selectionText` + * is plain text extracted from HTML, so whatever indentation it arrives with + * is whatever survived that extraction. Pasting over it is still the path + * that gives the block its indentation back. + */ + async function claimSelectionPrefill() { + const tab = await findComposeTab(); + const selectionText = await browser.runtime.sendMessage({ + type: "thundercode:take-pending-selection", + tabId: tab.id, + }); + if (typeof selectionText !== "string" || selectionText === "") { + return; + } + sourceField.value = selectionText; + // Assigning `value` from script fires no `input` event, so the change has + // to be announced by hand - once, to the one thing that watches the + // textarea. Content that arrived from outside is as wholesale as a paste, + // and it is already here rather than being typed, so there is no burst to + // wait out. + handleSourceChanged({ wholesale: true, immediate: true }); } - insertButton.disabled = true; - errorLine.hidden = true; - try { - await insert(); - window.close(); - } catch (error) { - errorLine.textContent = String(error?.message ?? error); - errorLine.hidden = false; - insertButton.disabled = false; - } -} -insertButton.addEventListener("click", confirmInsert); - -// Ctrl+Enter confirms, so paste-and-insert never needs the mouse. Bound on the -// document rather than the textarea so it also works once focus has moved to -// the button. -// -// `preventDefault` is load-bearing, not tidiness: the compose window binds -// Ctrl+Enter to Send, and a chrome `` still fires for a key press that -// started inside an extension popup unless the popup consumes the event. Miss -// this and the shortcut sends the message. -// -// `metaKey` is accepted alongside `ctrlKey` because on macOS the same gesture -// is Cmd+Enter - the manifest's `Ctrl` is likewise read as Command there. -document.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || !(event.ctrlKey || event.metaKey)) { - return; + // Deliberately silent on failure. A prefill that does not arrive leaves an + // empty textarea, which is exactly what the toolbar button opens anyway; an + // error line here would report a broken convenience as a broken popup. + claimSelectionPrefill().catch(() => {}); + + /** + * The one path from "confirm" to a closed popup, shared by the button and the + * keyboard. Both entry points have to behave identically, including the error + * branch - a shortcut that silently does nothing is worse than one that does + * not exist. + */ + async function confirmInsert() { + if (insertButton.disabled) { + return; // An insert is already in flight; a second Ctrl+Enter is a no-op. + } + insertButton.disabled = true; + errorLine.hidden = true; + try { + await insert(); + view.close(); + } catch (error) { + errorLine.textContent = String(error?.message ?? error); + errorLine.hidden = false; + insertButton.disabled = false; + } } - event.preventDefault(); - void confirmInsert(); -}); + + insertButton.addEventListener("click", confirmInsert); + + // Ctrl+Enter confirms, so paste-and-insert never needs the mouse. Bound on + // the document rather than the textarea so it also works once focus has moved + // to the button. + // + // `preventDefault` is load-bearing, not tidiness: the compose window binds + // Ctrl+Enter to Send, and a chrome `` still fires for a key press that + // started inside an extension popup unless the popup consumes the event. Miss + // this and the shortcut sends the message. + // + // `metaKey` is accepted alongside `ctrlKey` because on macOS the same gesture + // is Cmd+Enter - the manifest's `Ctrl` is likewise read as Command there. + document.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || !(event.ctrlKey || event.metaKey)) { + return; + } + event.preventDefault(); + void confirmInsert(); + }); +} From bdaa53aa2efa136d76330ebc6fe31464eaa776a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 10:52:19 +0200 Subject: [PATCH 13/22] refactor: name the selection handover message in one place The type string the popup asks with and the background answers was a literal at both ends, and the background's own test had restated it as a third copy with a comment saying so. Both ends now import it, and so does that test. Co-Authored-By: Claude Opus 5 (1M context) --- src/background/background.js | 4 ++-- src/messaging/take-pending-selection.js | 14 ++++++++++++++ src/popup/popup.js | 3 ++- tests/node/background.test.js | 11 +---------- 4 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 src/messaging/take-pending-selection.js diff --git a/src/background/background.js b/src/background/background.js index 6964160..d222a15 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -1,3 +1,5 @@ +import { TAKE_PENDING_SELECTION } from "../messaging/take-pending-selection.js"; + /** * The extension's background, and the first one it has had: ticket 01 left it * out deliberately because a `compose_action` with a `default_popup` opens @@ -22,8 +24,6 @@ */ const MENU_ID = "thundercode-insert-code-block"; -const TAKE_PENDING_SELECTION = "thundercode:take-pending-selection"; - /** * Text a right-click parked for the popup that is about to open, keyed by the * compose tab it came from. diff --git a/src/messaging/take-pending-selection.js b/src/messaging/take-pending-selection.js new file mode 100644 index 0000000..9e30c98 --- /dev/null +++ b/src/messaging/take-pending-selection.js @@ -0,0 +1,14 @@ +/** + * The one message this add-on sends: the popup asking the background for the + * text a right-click parked against its compose tab. + * + * A module of its own because both ends need the same string and neither end + * owns it - the background answers the message and the popup asks it, and a + * literal at each end is two things to keep in step. It was two, and a test + * that restated it made three. + * + * Namespaced with the add-on's own prefix because `runtime.onMessage` is a bus: + * every listener in this extension hears every message sent to it, so the type + * has to be recognisable rather than merely descriptive. + */ +export const TAKE_PENDING_SELECTION = "thundercode:take-pending-selection"; diff --git a/src/popup/popup.js b/src/popup/popup.js index 8911912..01c664f 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -1,6 +1,7 @@ import hljs from "../../vendor/highlight.js/common.js"; import { buildCodeBlockHtml } from "../code-block/build-code-block-html.js"; import { insertIntoBody } from "../compose/insert-into-body.js"; +import { TAKE_PENDING_SELECTION } from "../messaging/take-pending-selection.js"; import { readSettings } from "../settings/settings.js"; import { createLanguageLatch } from "./language-latch.js"; import { measureSnippet } from "./snippet-size.js"; @@ -472,7 +473,7 @@ export function startPopup({ async function claimSelectionPrefill() { const tab = await findComposeTab(); const selectionText = await browser.runtime.sendMessage({ - type: "thundercode:take-pending-selection", + type: TAKE_PENDING_SELECTION, tabId: tab.id, }); if (typeof selectionText !== "string" || selectionText === "") { diff --git a/tests/node/background.test.js b/tests/node/background.test.js index 6e8c944..f1dde86 100644 --- a/tests/node/background.test.js +++ b/tests/node/background.test.js @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TAKE_PENDING_SELECTION } from "../../src/messaging/take-pending-selection.js"; import { event, installBrowserFake } from "../helpers/browser-fake.js"; /** @@ -17,16 +18,6 @@ import { event, installBrowserFake } from "../helpers/browser-fake.js"; * it could never arrive in the wrong one. */ -/** - * The message the popup claims its prefill with. A literal in both modules and - * exported by neither, which makes this the third copy: the background gets no - * new interface for the sake of a test, and the popup is not this ticket's to - * change. A test that made up its own name here would pass while the popup - * asked for something else, so the two literals staying in step is on whoever - * changes one of them. - */ -const TAKE_PENDING_SELECTION = "thundercode:take-pending-selection"; - const composeTab = (id, windowId) => ({ id, windowId, type: "messageCompose" }); describe("the background", () => { From 676ae18fef8b29d133e5a214e11787e372c102e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 11:04:20 +0200 Subject: [PATCH 14/22] test(popup): drive the popup's wiring and the language latch The latch's three rules go in the pure tier, one assertion each, where they need no document: an override outliving ten later pastes, one guess owed for a burst of them, and a caller that only asks leaving the guess standing. The wiring goes in the simulated tier, through `startPopup` against the shipped popup.html, the strict browser fake and a clock this file holds still. What it covers is the paths that actually broke: paste and confirm inside the debounce window inserting under the detected language rather than the last one displayed, the prefill announcing a change through the one entry point instead of replaying listeners, two renders overlapping where only the newest writes to the screen, a failed insert leaving confirmation usable with the error visible, and the size warning appearing and clearing from the same entry point. Each popup gets a document of its own in a frame, because the shortcut is bound to the document: sharing one would leave every earlier popup in the file listening, and confirming once would confirm as many times as there had been tests. One fixture was replaced during the work: a shell snippet detects as the language the dropdown happens to open on, so the overlap assertion passed for a source it had never rendered. The guard that would have caught that is now in the test. Co-Authored-By: Claude Opus 5 (1M context) --- tests/dom/popup.test.js | 778 ++++++++++++++++++++++++++++++ tests/node/language-latch.test.js | 133 +++++ 2 files changed, 911 insertions(+) create mode 100644 tests/dom/popup.test.js create mode 100644 tests/node/language-latch.test.js diff --git a/tests/dom/popup.test.js b/tests/dom/popup.test.js new file mode 100644 index 0000000..1d0b9b1 --- /dev/null +++ b/tests/dom/popup.test.js @@ -0,0 +1,778 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildCodeBlockHtml } from "../../src/code-block/build-code-block-html.js"; +import { TAKE_PENDING_SELECTION } from "../../src/messaging/take-pending-selection.js"; +import { startPopup } from "../../src/popup/popup.js"; +import { LARGE_SNIPPET_LINES } from "../../src/popup/snippet-size.js"; +import { installBrowserFake } from "../helpers/browser-fake.js"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +/** + * The shipped page, read from the file rather than hand-built here. The popup + * finds its elements by id, so a fixture written in this file would let the two + * drift and still pass: the ids are the contract between popup.js and + * popup.html, and this is how the tests below sit on the real one. + */ +const popupPage = readFileSync( + resolve(repoRoot, "src/popup/popup.html"), + "utf8", +); + +/** + * The popup's clock, held still. + * + * `startPopup` takes the debounce's two timer functions, so nothing below waits + * out a real 150ms, and the two things the debounce is actually about become + * observable: how many renders were scheduled, and how many ran. Vitest's own + * fake timers would have replaced the clock this file also uses to let promises + * settle, which is the one clock these tests need to keep running. + */ +const createClock = () => { + let now = 0; + let nextId = 1; + const pending = new Map(); + + return { + /** Renders the debounce has actually run. */ + fired: 0, + + setTimeout: (callback, delay) => { + nextId += 1; + pending.set(nextId, { callback, due: now + delay }); + return nextId; + }, + + clearTimeout: (id) => void pending.delete(id), + + /** Renders currently waiting, which a burst of typing collapses to one. */ + get scheduled() { + return pending.size; + }, + + tick(ms) { + now += ms; + const due = [...pending].sort(([, a], [, b]) => a.due - b.due); + for (const [id, timer] of due) { + if (timer.due <= now) { + pending.delete(id); + this.fired += 1; + timer.callback(); + } + } + }, + }; +}; + +/** + * Lets everything the popup has already started run to completion. + * + * One turn of the real clock drains every promise that is already resolved, + * which is all of them: the settings and the theme are stubbed rather than + * fetched, and the popup's own waiting goes through the clock above. + */ +const settle = () => new Promise((done) => setTimeout(done, 0)); + +/** A promise this file resolves by hand, for making two renders overlap. */ +const deferred = () => { + let resolve; + const promise = new Promise((keep) => { + resolve = keep; + }); + return { promise, resolve }; +}; + +const lines = (count) => + Array.from({ length: count }, (_, at) => `line ${at}`).join("\n"); + +/** + * Two sources the pipeline detects differently. Which languages those are is + * not this file's business, and every assertion reads them back out of the + * pipeline rather than naming them; that they differ is what makes "under the + * detected language rather than the last one displayed" a question with two + * possible answers. + * + * Neither detects as the entry the dropdown opens on, and an earlier draft had + * one that did: the assertion it was the subject of then passed for a source it + * had never rendered. The guard in `overlap` below is there because of it. + */ +const python = "def greet(name):\n print(f'hello {name}')\n"; +const markup = "\n you\n hello\n\n"; + +describe("the popup", () => { + /** + * What the stubbed storage hands back. Not the defaults, so that a render + * built with the popup's settings is distinguishable from one built without + * them. + */ + const stored = { tabWidth: 8, fontSize: 11 }; + + const composeTab = { id: 7, windowId: 3, type: "messageCompose" }; + + let frame; + let clock; + let fake; + let closed; + + /** + * The popup's own document, in a frame of its own per test. + * + * A frame rather than this tier's own document, because the popup binds its + * keyboard shortcut to the document: a shared one would keep every earlier + * popup in this file listening, and confirming once would confirm as many + * times as there had been tests. A document per popup is also what the add-on + * has - the page is built fresh every time the button is clicked and dies + * when it closes - so the isolation is the faithful arrangement rather than a + * concession to the runner. + */ + let popup; + + /** + * The block the pipeline makes of a source, which is what the popup shows and + * what it inserts: both are the same string from the same call, so the + * expectations below are read from the pipeline rather than written out. + * + * The theme map is empty because jsdom fetches no stylesheet. That is the + * degradation the pipeline already has a name for - a block that comes out in + * its own unthemed colours - and the colours themselves are pinned in the + * pure tier, so nothing here depends on the theme having loaded. + */ + const rendering = (source, language) => + buildCodeBlockHtml({ source, language, themeMap: {}, ...stored }); + + /** + * The pipeline's own output, parsed and serialised by this document, so that + * comparing it against the preview compares two blocks rather than two + * spellings of one. + */ + const asRendered = (html) => + new DOMParser().parseFromString(html, "text/html").body.firstElementChild + .outerHTML; + + const element = (id) => popup.getElementById(id); + + beforeEach(() => { + clock = createClock(); + closed = 0; + + fake = installBrowserFake({ + storage: { local: { get: async () => stored } }, + tabs: { query: async () => [composeTab] }, + compose: { + getComposeDetails: async () => ({ isPlainText: false }), + setComposeDetails: async () => {}, + }, + scripting: { + executeScript: async () => [{ result: { mechanism: "execCommand" } }], + }, + // Nothing parked, which is what a toolbar or shortcut open gets. + runtime: { sendMessage: async () => "" }, + }); + + frame = document.createElement("iframe"); + document.body.append(frame); + popup = frame.contentDocument; + popup.open(); + popup.write(popupPage); + popup.close(); + + // Closing is what a successful insert ends with, and it is worth asserting + // rather than performing: jsdom's own `close` would take the document the + // assertions still have to read. + frame.contentWindow.close = () => { + closed += 1; + }; + }); + + afterEach(() => { + frame.remove(); + vi.unstubAllGlobals(); + }); + + /** + * Opens the popup the way the page does, against this document and the clock + * above. + * + * The theme link is answered by hand because jsdom fetches no stylesheet, so + * it would otherwise report neither success nor failure and the theme map + * would never resolve. An `error` is the honest event for a stylesheet that + * was never loaded, and it is the one the module is built to survive. + */ + const open = () => { + startPopup({ + document: popup, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + }); + element("theme").dispatchEvent(new Event("error")); + }; + + /** Content arriving all at once: a paste, a drop, a middle-click yank. */ + const paste = (source) => { + element("source").value = source; + element("source").dispatchEvent( + new InputEvent("input", { inputType: "insertFromPaste" }), + ); + }; + + /** Content being edited where it already is, which asks for no new guess. */ + const type = (source) => { + element("source").value = source; + element("source").dispatchEvent( + new InputEvent("input", { inputType: "insertText" }), + ); + }; + + /** The dropdown being used, which is the gesture that takes the language. */ + const choose = (language) => { + element("language").value = language; + element("language").dispatchEvent(new Event("change")); + }; + + /** + * Waits the debounce out, generously. The popup keeps its own constant to + * itself, and a number copied in here would be pinning a threshold nothing + * measured; what the tests below assert about the wait is how many renders it + * cost, which does not depend on how long it is. + */ + const debounce = async () => { + clock.tick(1000); + await settle(); + }; + + describe("the preview", () => { + /** + * The popup opens on an empty textarea, and an empty textarea shows + * nothing: not the bordered empty box the pipeline returns for empty + * source, and not an error either. A box appearing the moment the popup + * opens would read as the block already existing. + */ + it("shows nothing at all before anything has been pasted", async () => { + open(); + await settle(); + + expect(element("preview").hidden).toBe(true); + expect(element("preview").children).toHaveLength(0); + expect(element("warning").hidden).toBe(true); + }); + + /** + * The preview is not a rendering like the one that gets inserted, it is the + * one that will be: one pipeline call produces both, which is what makes + * the dropdown and the preview incapable of disagreeing. + */ + it("shows the block the pipeline would insert", async () => { + open(); + await settle(); + + paste(python); + await debounce(); + + expect(element("preview").hidden).toBe(false); + expect(element("preview").firstElementChild.outerHTML).toBe( + asRendered(rendering(python).html), + ); + }); + + /** + * Literally empty, not whitespace-only: source that is all spaces does + * insert an empty bordered box, and a preview that hid it would be lying + * about the one thing this element exists to tell the truth about. + */ + it("hides the preview again when the source is emptied", async () => { + open(); + await settle(); + paste(python); + await debounce(); + + type(""); + await debounce(); + + expect(element("preview").hidden).toBe(true); + expect(element("preview").children).toHaveLength(0); + }); + + /** + * A burst of typing costs one render rather than one per keystroke, and the + * render it costs is the last one: highlighting is a scan over the whole + * snippet and detection scores it against every grammar the bundle carries, + * so rendering per character would do all of that once per character and + * throw all but the last result away. + */ + it("collapses a burst of changes into a single render", async () => { + open(); + await settle(); + + paste("print(1)"); + paste("print(2)"); + paste(python); + expect(clock.scheduled).toBe(1); + + await debounce(); + + expect(clock.fired).toBe(1); + expect(element("preview").firstElementChild.outerHTML).toBe( + asRendered(rendering(python).html), + ); + }); + }); + + describe("the language", () => { + it("moves the dropdown to the language it detected", async () => { + open(); + await settle(); + + paste(python); + await debounce(); + + expect(element("language").value).toBe( + rendering(python).detectedLanguage, + ); + }); + + /** + * An override is an instruction, and a dropdown that re-guesses over the + * top of a deliberate choice is worse than one that never guessed. Two + * pastes afterwards rather than one, and the second is the source whose + * detection would disagree loudest. + */ + it("never guesses again once the language has been chosen", async () => { + open(); + await settle(); + paste(python); + await debounce(); + + choose("ruby"); + await debounce(); + paste(markup); + await debounce(); + paste(python); + await debounce(); + + expect(element("language").value).toBe("ruby"); + expect(element("preview").firstElementChild.outerHTML).toBe( + asRendered(rendering(python, "ruby").html), + ); + }); + + /** + * Re-rendering on `change` is what makes a corrected language confirmable + * by eye without touching the source again. + */ + it("renders again when the language is corrected", async () => { + open(); + await settle(); + paste(python); + await debounce(); + + choose("ruby"); + await debounce(); + + expect(element("preview").firstElementChild.outerHTML).toBe( + asRendered(rendering(python, "ruby").html), + ); + }); + + /** + * Two wholesale changes inside one debounce window, which is one render: + * the guess is owed by the change and spent by the render that honours it, + * so the one render that happens is the one that detects, and it detects + * the content that is actually there. + */ + it("detects once for two pastes inside one window", async () => { + open(); + await settle(); + paste("print(1)"); + await debounce(); + + paste(markup); + paste(python); + await debounce(); + + expect(clock.fired).toBe(2); + expect(element("language").value).toBe( + rendering(python).detectedLanguage, + ); + }); + + /** + * Editing content that is already there does not ask for a fresh guess: the + * trigger is the arrival of new content and not every edit of it, which is + * what keeps the dropdown from re-guessing under someone's fingers while + * they fix a typo. + */ + it("leaves the language alone while the snippet is edited", async () => { + open(); + await settle(); + paste(python); + await debounce(); + const detected = element("language").value; + + type(`${python}${markup}`); + await debounce(); + + expect(element("language").value).toBe(detected); + }); + }); + + /** + * Two renders in flight at once, which is the only way the guard against an + * older render can be asserted at all. The settings read is left pending + * until three renders are waiting on it - the load-time one and two pastes - + * so that two of them are already stale by the time any of them can continue. + */ + describe("when two renders overlap", () => { + let writes; + + const overlap = async () => { + const settings = deferred(); + browser.storage.local.get = () => settings.promise; + open(); + + writes = []; + new MutationObserver((records) => writes.push(...records)).observe( + element("preview"), + { childList: true }, + ); + + paste(python); + await debounce(); + paste(markup); + await debounce(); + + // Nothing has rendered yet, so the dropdown is still on the entry the + // alphabetical list opened on. That this is not the answer the + // assertions expect is what lets them fail. + expect(element("language").value).not.toBe( + rendering(markup).detectedLanguage, + ); + + settings.resolve(stored); + await settle(); + }; + + /** + * Stale is the one failure mode the preview must not have: a block showing + * the previous source beside a dropdown showing the new one is worse than + * no preview at all. The older renders do not merely lose the race, they + * never write - so there is no moment at which the wrong block is on + * screen. + */ + it("leaves the newest render on screen, and only that one", async () => { + await overlap(); + + expect(element("preview").firstElementChild.outerHTML).toBe( + asRendered(rendering(markup).html), + ); + expect(writes).toHaveLength(1); + }); + + /** + * The guess is spent by the render that honours it, and a render that turns + * out to be stale honours nothing. If a stale one spent it, the newest + * render would find the request already gone and would take the dropdown's + * value - which is whatever the alphabetical list opened on, since nothing + * has detected yet. + */ + it("does not let a stale render swallow the newer guess", async () => { + await overlap(); + + expect(element("language").value).toBe( + rendering(markup).detectedLanguage, + ); + }); + }); + + describe("inserting", () => { + const confirm = async () => { + element("insert").dispatchEvent(new MouseEvent("click")); + await settle(); + }; + + const inserted = () => { + const calls = fake.calls("scripting.executeScript"); + expect(calls).toHaveLength(1); + return calls[0][0].args[0]; + }; + + /** + * Paste and confirm inside the debounce window, which is close to the + * fastest way to use this popup, and the path that was getting the language + * wrong: reading the dropdown here would insert the block under whatever + * language was last shown. Both the render and the insert ask the latch, so + * they cannot arrive at different answers. + */ + it("inserts under the detected language, not the one shown", async () => { + open(); + await settle(); + paste(python); + await debounce(); + const displayed = element("language").value; + expect(displayed).not.toBe(rendering(markup).detectedLanguage); + + paste(markup); + await confirm(); + + expect(inserted().content).toBe(rendering(markup).html); + expect(inserted().content).not.toBe(rendering(markup, displayed).html); + expect(closed).toBe(1); + }); + + /** + * A failure has to leave the popup usable: the error where it can be read, + * and the button able to try again. The insert is the only thing that ever + * disables it, and re-enabling it here is what stops one rejected call + * turning into a popup that can only be closed. + */ + it("re-enables confirmation and shows the error on failure", async () => { + open(); + await settle(); + paste(python); + await debounce(); + browser.scripting.executeScript = async () => [ + { error: new Error("the tab went away") }, + ]; + + await confirm(); + + expect(element("error").hidden).toBe(false); + expect(element("error").textContent).toBe("the tab went away"); + expect(element("insert").disabled).toBe(false); + expect(closed).toBe(0); + }); + + /** + * A shortcut that silently does nothing is worse than one that does not + * exist, so it is the same path as the button and not a second copy of it. + * `preventDefault` is load-bearing rather than tidiness: the compose window + * binds Ctrl+Enter to Send, and a chrome key still fires for a press that + * started inside an extension popup unless the popup consumes the event. + */ + it("confirms from the keyboard, and consumes the key", async () => { + open(); + await settle(); + paste(python); + await debounce(); + + const press = new KeyboardEvent("keydown", { + key: "Enter", + ctrlKey: true, + cancelable: true, + }); + popup.dispatchEvent(press); + await settle(); + + expect(press.defaultPrevented).toBe(true); + expect(inserted().content).toBe(rendering(python).html); + expect(closed).toBe(1); + }); + + /** + * A popup anchored in a compose window resolves the current window to that + * window, so the active tab is the composer the button was clicked in - + * which is what keeps a snippet out of the wrong email when several + * composers are open. A tab that is not a composer is refused rather than + * swapped for a guess at another one. + */ + it("refuses to insert when the active tab is not a composer", async () => { + open(); + await settle(); + paste(python); + await debounce(); + browser.tabs.query = async () => [{ id: 9, windowId: 3, type: "mail" }]; + + await confirm(); + + expect(fake.calls("scripting.executeScript")).toEqual([]); + expect(element("error").hidden).toBe(false); + expect(element("error").textContent).not.toBe(""); + expect(element("insert").disabled).toBe(false); + expect(closed).toBe(0); + }); + + /** + * A second Ctrl+Enter while the first insert is still in flight is a no-op, + * not a second block: the button is disabled for the length of the call and + * that is what both entry points read. + */ + it("ignores a second confirmation while one is in flight", async () => { + const injection = deferred(); + open(); + await settle(); + paste(python); + await debounce(); + browser.scripting.executeScript = () => injection.promise; + + await confirm(); + expect(element("insert").disabled).toBe(true); + await confirm(); + injection.resolve([{ result: { mechanism: "execCommand" } }]); + await settle(); + + expect(fake.calls("scripting.executeScript")).toHaveLength(1); + expect(closed).toBe(1); + }); + + /** + * Enter on its own belongs to the textarea, where it types a newline. Only + * the modified press confirms, and only that press is consumed. + */ + it("leaves an unmodified Enter to the textarea", async () => { + open(); + await settle(); + paste(python); + await debounce(); + + const press = new KeyboardEvent("keydown", { + key: "Enter", + cancelable: true, + }); + popup.dispatchEvent(press); + await settle(); + + expect(press.defaultPrevented).toBe(false); + expect(fake.calls("scripting.executeScript")).toEqual([]); + }); + + /** + * A plain-text composer is a different editor rather than the same one with + * the styling switched off, and `deliveryFormat` describes how an HTML + * message is put on the wire. Making that call here was predicted to be + * rejected and surfaced as an error while inserting nothing, so not making + * it is both the fix and the honest description. + */ + it("skips the delivery format for a plain-text composer", async () => { + browser.compose.getComposeDetails = async () => ({ isPlainText: true }); + open(); + await settle(); + paste(python); + await debounce(); + + await confirm(); + + expect(inserted()).toEqual({ + content: rendering(python).text, + isPlainText: true, + }); + expect(fake.calls("compose.setComposeDetails")).toEqual([]); + }); + }); + + describe("the size warning", () => { + /** + * Recomputed from scratch on every source change, which is also what clears + * it again when the content drops back under the threshold: there is no + * separate hide path to forget to call. It is not debounced either - + * nothing below waits out the clock - because counting lines is a scan + * rather than a highlight, and a warning appearing a fifth of a second + * after the paste would read as a reaction to whatever the user did next. + * + * The threshold is read from the module that owns it, because moving it is + * a tuning decision and not a behaviour change. + */ + it("appears past the threshold and clears again below it", async () => { + open(); + await settle(); + + paste(lines(LARGE_SNIPPET_LINES + 1)); + + expect(element("warning").hidden).toBe(false); + expect(element("warning").textContent).toContain( + String(LARGE_SNIPPET_LINES + 1), + ); + + type(lines(LARGE_SNIPPET_LINES)); + + expect(element("warning").hidden).toBe(true); + expect(element("warning").textContent).toBe(""); + }); + + /** + * Advisory, and structurally so. Ticket 02 removed the last thing that + * gated Insert on the textarea's contents, and there is no size at which + * one comes back: emailing three thousand lines of code is a mistake worth + * mentioning and not one worth preventing. + */ + it("never disables the button it warns next to", async () => { + open(); + await settle(); + + paste(lines(LARGE_SNIPPET_LINES * 2)); + + expect(element("warning").hidden).toBe(false); + expect(element("insert").disabled).toBe(false); + }); + }); + + describe("the right-click prefill", () => { + const parked = lines(LARGE_SNIPPET_LINES + 1); + + /** + * The shape of the bug this ticket exists for. There were three `input` + * listeners on the textarea, registered by three tickets that could not see + * each other, and the prefill had to replay each of them by hand: the + * warning, the detection and the preview are asserted together here because + * forgetting one of them is exactly how that broke, and one of the three + * passing on its own proves nothing. + * + * It also renders without waiting out the debounce - the clock never moves + * below - because content that is already here has no burst to collapse, + * and a debounce would only mean the dropdown visibly correcting itself a + * moment after the popup appeared. + */ + it("announces the parked selection like any other change", async () => { + browser.runtime.sendMessage = async () => parked; + + open(); + await settle(); + + expect(element("source").value).toBe(parked); + expect(element("warning").hidden).toBe(false); + expect(element("language").value).toBe( + rendering(parked).detectedLanguage, + ); + expect(element("preview").hidden).toBe(false); + expect(element("preview").firstElementChild.outerHTML).toBe( + asRendered(rendering(parked).html), + ); + expect(clock.fired).toBe(0); + }); + + /** + * Claimed for the tab this popup resolved for itself, which is what keeps a + * snippet out of the wrong email when several composers are open. The + * message type is imported rather than written out, because a name made up + * here would pass while the background answered something else. + */ + it("claims the selection of the tab it resolved for itself", async () => { + open(); + await settle(); + + expect(fake.calls("runtime.sendMessage")).toEqual([ + [{ type: TAKE_PENDING_SELECTION, tabId: composeTab.id }], + ]); + }); + + /** + * A prefill that does not arrive leaves an empty textarea, which is exactly + * what the toolbar button opens anyway. An error line here would report a + * broken convenience as a broken popup. + */ + it("opens empty and quiet when the claim fails", async () => { + browser.runtime.sendMessage = async () => { + throw new Error("no background"); + }; + + open(); + await settle(); + + expect(element("source").value).toBe(""); + expect(element("preview").hidden).toBe(true); + expect(element("error").hidden).toBe(true); + }); + }); +}); diff --git a/tests/node/language-latch.test.js b/tests/node/language-latch.test.js new file mode 100644 index 0000000..677cc34 --- /dev/null +++ b/tests/node/language-latch.test.js @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import { createLanguageLatch } from "../../src/popup/language-latch.js"; + +/** + * The override rule, driven without a document at all. It is the half of the + * popup that is a decision rather than a wiring - two pieces of state and three + * rules - and the reason it is a module is that the rules are worth asserting + * one at a time, which is not something the popup's tests can do while a render + * and a debounce are in the way. + * + * The dropdown's value is this file's fixture rather than its subject: the + * language names below could be any strings, and the one thing that must never + * be asserted here is what the pipeline detects for a given source. That + * belongs to the pipeline's own tests, and a latch that only passes the shown + * value through cannot get it wrong. + */ +describe("createLanguageLatch", () => { + const chosen = "ruby"; + const shown = "python"; + + /** + * The opening state, and the reason it is `true` rather than `false`: the + * popup's load-time render derives the dropdown's opening value from the + * empty textarea like every other value it takes, rather than leaving it on + * the first entry of an alphabetical list. + */ + it("asks for a guess before anything has been rendered", () => { + expect(createLanguageLatch().requestedLanguage(shown)).toBeUndefined(); + }); + + it("answers with the dropdown once a render has taken the guess", () => { + const latch = createLanguageLatch(); + latch.honourRequest("plaintext"); + + expect(latch.requestedLanguage(shown)).toBe(shown); + }); + + it("asks for a fresh guess when content arrives wholesale", () => { + const latch = createLanguageLatch(); + latch.honourRequest("plaintext"); + latch.sourceChanged({ wholesale: true }); + + expect(latch.requestedLanguage(shown)).toBeUndefined(); + }); + + /** + * The other half of the same rule. A snippet being tweaked has already got a + * language, and re-guessing under someone's fingers while they fix a typo is + * the failure this half prevents. + */ + it("leaves the language alone when content already there is edited", () => { + const latch = createLanguageLatch(); + latch.honourRequest("plaintext"); + latch.sourceChanged({ wholesale: false }); + + expect(latch.requestedLanguage(shown)).toBe(shown); + }); + + /** + * Two pastes in quick succession, which the popup's debounce collapses into + * one render. The guess is owed once and spent once: a second render with + * nothing new to look at takes the dropdown, so the pipeline is not asked to + * score all thirty-six grammars again for a source it just scored. + */ + it("owes one guess for any number of wholesale changes in a row", () => { + const latch = createLanguageLatch(); + latch.sourceChanged({ wholesale: true }); + latch.sourceChanged({ wholesale: true }); + latch.sourceChanged({ wholesale: true }); + + expect(latch.honourRequest(shown)).toBeUndefined(); + expect(latch.honourRequest(shown)).toBe(shown); + }); + + /** + * Paste and Ctrl+Enter inside the debounce window, which is close to the + * fastest way to use the popup. The insert asks the same question the render + * would have asked and gets the same answer, and because it only asks, the + * render that has not run yet still owes its guess. + * + * This is also what keeps a render that turns out to be stale from swallowing + * a detection the newer one still owes: a caller that asks changes nothing, + * and only the render that acts on the answer spends it. + */ + it("keeps owing the guess to a caller that only asks", () => { + const latch = createLanguageLatch(); + latch.sourceChanged({ wholesale: true }); + + expect(latch.requestedLanguage(shown)).toBeUndefined(); + expect(latch.requestedLanguage(shown)).toBeUndefined(); + expect(latch.honourRequest(shown)).toBeUndefined(); + }); + + /** + * An override is an instruction, so it outlives every later paste rather than + * the next one. Ten pastes here rather than one, because "permanent" is the + * claim and a rule that survived exactly one change would pass a weaker test. + */ + it("keeps an override through any number of later wholesale changes", () => { + const latch = createLanguageLatch(); + latch.takeOver(); + + for (let paste = 0; paste < 10; paste += 1) { + latch.sourceChanged({ wholesale: true }); + expect(latch.requestedLanguage(chosen), `paste ${paste}`).toBe(chosen); + expect(latch.honourRequest(chosen), `render ${paste}`).toBe(chosen); + } + }); + + it("takes an override over a guess that is already owed", () => { + const latch = createLanguageLatch(); + latch.sourceChanged({ wholesale: true }); + latch.takeOver(); + + expect(latch.honourRequest(chosen)).toBe(chosen); + }); + + /** + * One latch per popup is what keeps "permanent" from meaning "for ever". The + * popup document is built fresh every time the button is clicked, so a latch + * built with it starts guessing again - which is the behaviour, and it is why + * nothing here is module state and nothing is written to storage. + */ + it("gives every popup a latch of its own", () => { + const overridden = createLanguageLatch(); + overridden.takeOver(); + const opened = createLanguageLatch(); + + expect(overridden.requestedLanguage(chosen)).toBe(chosen); + expect(opened.requestedLanguage(chosen)).toBeUndefined(); + }); +}); From bfd2d6700bad6adac06db7e8e743de0a2095677c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 11:07:51 +0200 Subject: [PATCH 15/22] docs(popup): give the untested module its real reason The theme reduction said it was untested because the runner has no DOM by design, which stopped being true when the simulated tier landed. The honest reason is narrower: the claim the module is built around is that the CSS parser expands shorthands into longhands while parsing, and that is precisely where a simulated object model is least faithful, so a passing test there would pin the fake's behaviour and call it the platform's. Two neighbouring comments said the popup is verified by hand for the same reason. It is not any more. Co-Authored-By: Claude Opus 5 (1M context) --- src/popup/snippet-size.js | 10 +++++----- src/popup/theme-map.js | 19 ++++++++++++++----- tests/node/snippet-size.test.js | 11 ++++++----- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/popup/snippet-size.js b/src/popup/snippet-size.js index 025daf5..9b5661f 100644 --- a/src/popup/snippet-size.js +++ b/src/popup/snippet-size.js @@ -18,11 +18,11 @@ export const LARGE_SNIPPET_LINES = 500; * How many lines the pasted source has, and whether that is enough to warn * about. * - * Pure and DOM-free so the threshold decision can be driven from a Node test - - * the popup around it cannot be, since the runner has no DOM. The wording of - * the warning is deliberately *not* here: pinning a sentence in a test makes - * rephrasing it a test failure, and the sentence is the part of this most - * likely to be reworded. + * Pure and DOM-free so the threshold decision can be driven without a document + * at all, one line count at a time, rather than through the popup that shows + * it. The wording of the warning is deliberately *not* here: pinning a sentence + * in a test makes rephrasing it a test failure, and the sentence is the part of + * this most likely to be reworded. * * @param {string} source * @returns {{ lineCount: number, isLarge: boolean }} diff --git a/src/popup/theme-map.js b/src/popup/theme-map.js index ec4e931..ef0b7b3 100644 --- a/src/popup/theme-map.js +++ b/src/popup/theme-map.js @@ -10,11 +10,20 @@ import { CONTAINER_CLASS } from "../code-block/build-code-block-html.js"; * the browser's own CSS parser: this module only walks the already-parsed * CSSOM. No regex over the file, no colour table. * - * This is popup-side code and is deliberately not unit tested. It needs a - * browser to do anything at all, the test runner has no DOM by design, and its - * failure mode is visible the instant a block comes out monochrome. That - * division is the point of the seam: the pipeline is pure and tested, and the - * one thing that cannot be is this file. + * Deliberately not unit tested, and for a narrower reason than this comment + * used to give. There is a simulated document in the suite now, so "the runner + * has no DOM" no longer says anything about this file. What is still true is + * that the claim the module is built around - that the CSS parser expands a + * shorthand into its longhands while parsing into the object model, so the + * accessors below see a value the theme's author never wrote out - is exactly + * where a simulated object model is least faithful. Reading one property back + * would carry across; the expansion would not, and a test that passed on it + * would be pinning the fake's behaviour and calling it the platform's. + * + * The gap is affordable rather than merely admitted. The pipeline that consumes + * this map is pure and pinned, a stylesheet that yields nothing costs the + * colour and nothing else, and the failure mode is visible the instant a block + * comes out monochrome. */ /** diff --git a/tests/node/snippet-size.test.js b/tests/node/snippet-size.test.js index 53f8185..8928a0c 100644 --- a/tests/node/snippet-size.test.js +++ b/tests/node/snippet-size.test.js @@ -8,11 +8,12 @@ import { const lines = (count) => Array.from({ length: count }, (_, i) => `${i}`); /** - * The popup itself is verified by hand - the runner has no DOM, deliberately. - * What is worth pinning is the one decision inside it that is arithmetic rather - * than presentation: how many lines were pasted, and whether that is over the - * line. The off-by-one around a trailing newline is the reason this is a - * separate function at all. + * The popup that shows the warning is driven in the simulated tier, where the + * assertion is that the line appears past the threshold and clears again below + * it. What is worth pinning here is the decision underneath that, which is + * arithmetic rather than presentation: how many lines were pasted, and whether + * that is over the line. The off-by-one around a trailing newline is the reason + * this is a separate function at all. * * These tests avoid the literal 500 wherever they can, because the threshold is * explicitly approximate. Moving it is a tuning decision, not a behaviour From 9cb2f44d877d3eff200838a8bfb46e57060cd435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 11:13:33 +0200 Subject: [PATCH 16/22] test: drive a real Thunderbird headless as a third tier One command provisions the Thunderbird the manifest's floor promises, starts it headless on a profile that cannot update itself, temp-installs this checkout unsigned, opens a compose window and finds the add-on's button in the format toolbar. It needs nothing installed: the build and a matching geckodriver are fetched into an ignored directory and verified against published checksums. `pnpm test` now names its projects, so the default run stays offline and fast while the tier has a command of its own. Two things the research had wrong, both found by running it. geckodriver is pinned to 0.36.0 rather than anything newer because from 0.37.0 the driver hands the add-on to the application as base64, which Marionette only learned to read long after 128, so a temporary install against the pinned build answers with a bare InvalidArgumentError. And a compose window does need an identity, so the profile carries Mozilla's own prefs-only dummy account rather than creating one from chrome context: an account that exists before the first paint is also what keeps the account setup tab shut. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 6 + package.json | 8 +- pnpm-lock.yaml | 124 ++++++ scripts/package.sh | 5 + tests/thunderbird/compose-window.test.js | 132 ++++++ tests/thunderbird/harness/index.js | 42 ++ tests/thunderbird/harness/pins.js | 91 ++++ tests/thunderbird/harness/profile.js | 178 ++++++++ tests/thunderbird/harness/provision.js | 209 +++++++++ tests/thunderbird/harness/session.js | 545 +++++++++++++++++++++++ tests/thunderbird/pins.test.js | 125 ++++++ vitest.config.js | 38 +- 12 files changed, 1494 insertions(+), 9 deletions(-) create mode 100644 tests/thunderbird/compose-window.test.js create mode 100644 tests/thunderbird/harness/index.js create mode 100644 tests/thunderbird/harness/pins.js create mode 100644 tests/thunderbird/harness/profile.js create mode 100644 tests/thunderbird/harness/provision.js create mode 100644 tests/thunderbird/harness/session.js create mode 100644 tests/thunderbird/pins.test.js diff --git a/.gitignore b/.gitignore index 952a3f8..ad9d60b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,12 @@ dist/ .webext-linter/ .webext-linter-cache/ +# What the Thunderbird tier downloads and generates: the pinned build, the +# driver, and one profile per run. Nothing here is reproducible from the repo +# on purpose - it is reproducible from `pnpm test:thunderbird`, which fetches +# it, and every byte of it is pinned in tests/thunderbird/harness/pins.js. +.thunderbird/ + # Coverage reports. Written by `pnpm coverage`, read once, never committed - # nothing is gated on them, so there is nothing here worth keeping. coverage/ diff --git a/package.json b/package.json index f0c4396..342b93a 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ "node": ">=24" }, "scripts": { - "test": "vitest run", + "test": "vitest run --project node --project dom", "test:node": "vitest run --project node", - "test:watch": "vitest", - "coverage": "vitest run --coverage", + "test:thunderbird": "vitest run --project thunderbird", + "test:watch": "vitest --project node --project dom", + "coverage": "vitest run --project node --project dom --coverage", "package": "bash scripts/package.sh", "lint": "bash scripts/lint.sh", "changelog": "git-cliff --unreleased --strip all" @@ -22,6 +23,7 @@ "git-cliff": "^2.13.1", "highlight.js": "11.12.0", "jsdom": "^30.0.1", + "selenium-webdriver": "^4.48.0", "vitest": "^5.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7efd656..544b30a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: jsdom: specifier: ^30.0.1 version: 30.0.1 + selenium-webdriver: + specifier: ^4.48.0 + version: 4.48.0 vitest: specifier: ^5.0.0 version: 5.0.0(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2) @@ -152,6 +155,9 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bazel/runfiles@6.5.0': + resolution: {integrity: sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -378,6 +384,9 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -484,6 +493,12 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -499,6 +514,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -514,6 +532,12 @@ packages: canvas: optional: true + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -614,6 +638,9 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -644,10 +671,16 @@ packages: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -657,10 +690,20 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + selenium-webdriver@4.48.0: + resolution: {integrity: sha512-rKM9uXFRWcF9aThrZQDNQH2/9Et/WvMZbg3/x1rnSYWoXiwJuShYeH0IAli8Cuw+c3lEV0UWPfUz88H+fvW9Hg==} + engines: {node: '>= 22.0.0'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -686,6 +729,9 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} @@ -716,6 +762,10 @@ packages: resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} hasBin: true + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + tough-cookie@6.0.2: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} @@ -732,6 +782,9 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite@8.2.2: resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -846,6 +899,18 @@ packages: engines: {node: '>=8'} hasBin: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -887,6 +952,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bazel/runfiles@6.5.0': {} + '@bcoe/v8-coverage@1.0.2': {} '@bramus/specificity@2.4.2': @@ -1033,6 +1100,8 @@ snapshots: chai@6.2.2: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1135,6 +1204,10 @@ snapshots: human-signals@8.0.1: {} + immediate@3.0.6: {} + + inherits@2.0.4: {} + is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} @@ -1143,6 +1216,8 @@ snapshots: is-unicode-supported@2.1.0: {} + isarray@1.0.0: {} + isexe@2.0.0: {} js-tokens@10.0.0: {} @@ -1173,6 +1248,17 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.33.0: optional: true @@ -1245,6 +1331,8 @@ snapshots: obug@2.1.4: {} + pako@1.0.11: {} + parse-ms@4.0.0: {} parse5@8.0.1: @@ -1269,8 +1357,20 @@ snapshots: dependencies: parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} + punycode@2.3.1: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + require-from-string@2.0.2: {} rolldown@1.2.7: @@ -1294,10 +1394,24 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.7 '@rolldown/binding-win32-x64-msvc': 1.2.7 + safe-buffer@5.1.2: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 + selenium-webdriver@4.48.0: + dependencies: + '@bazel/runfiles': 6.5.0 + jszip: 3.10.1 + tmp: 0.2.7 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -1314,6 +1428,10 @@ snapshots: std-env@4.2.0: {} + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-final-newline@4.0.0: {} symbol-tree@3.2.4: {} @@ -1335,6 +1453,8 @@ snapshots: dependencies: tldts-core: 7.4.12 + tmp@0.2.7: {} + tough-cookie@6.0.2: dependencies: tldts: 7.4.12 @@ -1347,6 +1467,8 @@ snapshots: unicorn-magic@0.3.0: {} + util-deprecate@1.0.2: {} + vite@8.2.2: dependencies: lightningcss: 1.33.0 @@ -1412,6 +1534,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.21.3: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/scripts/package.sh b/scripts/package.sh index cee17c5..6331ccb 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -57,6 +57,11 @@ exclusions=( # Its own output, and any archive left at the root by an earlier convention. 'dist/*' '*.xpi' + # The Thunderbird tier's cache: an extracted Thunderbird, a geckodriver and a + # profile per run. Excluded for the obvious reason and one less obvious one - + # this script is what the tier installs, so an unexcluded build would zip the + # 84 MiB Thunderbird it is about to be installed into. + '.thunderbird/*' # Issue tracker, specs and repo documentation. `.git` is matched both as a # directory (main checkout) and as a plain file (git worktrees). '.scratch/*' diff --git a/tests/thunderbird/compose-window.test.js b/tests/thunderbird/compose-window.test.js new file mode 100644 index 0000000..a0c93f0 --- /dev/null +++ b/tests/thunderbird/compose-window.test.js @@ -0,0 +1,132 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { ACTION_BUTTON_ID, ACTION_TOOLBAR_ID } from "./harness/session.js"; +import { PROFILE_PREFS, UPDATE_PREF_NAMES } from "./harness/profile.js"; +import { THUNDERBIRD_VERSION, repoRoot } from "./harness/pins.js"; +import { resolveThunderbirdBinary } from "./harness/provision.js"; +import { startThunderbird } from "./harness/session.js"; + +/** + * The third tier, and the only one that can see the add-on as a user does. + * + * What it proves is the mechanism: a pinned Thunderbird, fetched and started + * headless on a profile that cannot update itself, with this checkout + * temp-installed into it, opening a compose window and finding the add-on's + * button in the format toolbar. Everything the add-on *does* with that button + * is asserted in the tests that build on this - the mechanism is the subject + * here, because a suite built on an unproven mechanism reports the mechanism's + * failures as the add-on's. + * + * None of this is supported by Thunderbird. See the Consequences section of + * docs/adr/0001-three-test-tiers.md for what that costs and who pays it, and + * tests/thunderbird/harness/index.js for the harness's own interface. + */ + +const manifest = JSON.parse( + readFileSync(path.join(repoRoot, "manifest.json"), "utf8"), +); + +let session; + +beforeAll(async () => { + // The first run downloads about 90 MiB, which is why the hook timeout in + // vitest.config.js is minutes rather than seconds. + session = await startThunderbird({ log: console.log }); +}, 600_000); + +afterAll(async () => { + await session?.stop(); +}); + +describe("the driven Thunderbird", () => { + it("is Thunderbird, and the version the manifest's floor promises", async () => { + const info = await session.appInfo(); + expect(info.name).toBe("Thunderbird"); + + // Only when the harness chose the build. Under the environment override + // the version is whatever the maintainer installed, which is the point of + // the override; asserting the pin there would make the override unusable. + if (resolveThunderbirdBinary().source === "pinned") { + expect(info.version).toBe(THUNDERBIRD_VERSION.replace("esr", "")); + } + }); + + it("runs on the profile this run created", async () => { + const info = await session.appInfo(); + // Resolved on both sides: the profile lives under a temporary directory, + // and macOS hands back a symlinked path for those. + expect(path.resolve(info.profileDir)).toBe(path.resolve(session.profileDir)); + }); + + it("cannot update itself", async () => { + const guards = await session.updateGuards(); + + // The policy is the one that does not care whether anything is + // automating: it is read straight from `Services.policies`, so it holds + // for a build a person launches by hand as well. + expect(guards.policyAllowsAppUpdate).toBe(false); + + // And the prefs, compared against the harness's own list rather than + // written out again, so adding one there is not a second edit here. + expect(Object.keys(guards.prefs).sort()).toEqual([...UPDATE_PREF_NAMES].sort()); + for (const name of UPDATE_PREF_NAMES) { + expect(guards.prefs[name], name).toBe(PROFILE_PREFS[name]); + } + }); +}); + +describe("the installed add-on", () => { + it("is this checkout, installed temporarily and unsigned", async () => { + const info = await session.addonInfo(); + + expect(info.id).toBe(manifest.browser_specific_settings.gecko.id); + expect(info.version).toBe(manifest.version); + expect(info.isActive).toBe(true); + + // Temporary is the whole mechanism: a permanent install of an unsigned + // archive is a different code path, and it is the one the release + // checklist still walks by hand. + expect(info.temporarilyInstalled).toBe(true); + + // Unsigned, and accepted anyway. Thunderbird does not sign add-ons - + // comm-central builds with signing off - so this is a property of the + // application rather than of the install: nothing here had to be relaxed + // for an unsigned add-on to load. + expect(info.signedState).toBeLessThanOrEqual(0); + expect((await session.updateGuards()).signaturesRequired).toBe(false); + }); +}); + +describe("a compose window", () => { + it("opens, and carries the add-on's button in the format toolbar", async () => { + const compose = await session.openCompose(); + try { + // In the toolbar, not merely somewhere in the window: an element found + // by id says nothing about where it ended up, and where it ends up is + // what `default_area` in the manifest asks for. + expect(await compose.toolbarButtonIds()).toContain(ACTION_BUTTON_ID); + expect(ACTION_TOOLBAR_ID).toBe("FormatToolbar"); + + // And it is the add-on's button rather than an element that happens to + // share the id, read back through the manifest that named it. + const button = await compose.actionButton(); + expect(await button.getAttribute("tooltiptext")).toBe( + manifest.compose_action.default_title, + ); + expect(await button.getTagName()).toBe("toolbarbutton"); + } finally { + await compose.close(); + } + }); + + it("comes up empty, so an insertion test starts from nothing", async () => { + const compose = await session.openCompose(); + try { + expect(await compose.bodyText()).toBe(""); + } finally { + await compose.close(); + } + }); +}); diff --git a/tests/thunderbird/harness/index.js b/tests/thunderbird/harness/index.js new file mode 100644 index 0000000..98a7507 --- /dev/null +++ b/tests/thunderbird/harness/index.js @@ -0,0 +1,42 @@ +/** + * The Thunderbird tier's harness, in one import. + * + * ```js + * import { startThunderbird } from "./harness/index.js"; + * + * const session = await startThunderbird(); // ~15s, or ~2min the + * try { // first time (downloads) + * const compose = await session.openCompose(); // { format: "plaintext" } + * await compose.focusBody(); // caret in the body + * await compose.sendKeys("text"); // real key events + * await compose.pressChord(Key.CONTROL, "b"); // never Key.chord + * const button = await compose.actionButton(); // a clickable element + * await compose.toolbarButtonIds(); // where it sits + * await compose.openActionPopup(); // click, wait, URL + * await compose.bodyHtml(); // and bodyText() + * await compose.chrome("return 1 + 1;"); // privileged, this window + * await compose.close(); + * } finally { + * await session.stop(); // takes the profile too + * } + * ``` + * + * `session` also has `chrome()` on the main window, `appInfo()`, `addonInfo()`, + * `updateGuards()`, `composeWindows()` and `openCompose()`. Everything runs in + * Marionette's chrome context, so a script sees `Services`, `ChromeUtils`, `Cc` + * and `Ci`, and `window` is the window it was called on. + * + * Two limits worth knowing before writing a test against this, both found the + * hard way and both explained where they bite - in session.js: + * `openActionPopup()` cannot see inside the popup, and a letter-key extension + * shortcut cannot be fired by synthesised input on Thunderbird 128. + */ +export { + ACTION_BUTTON_ID, + ACTION_TOOLBAR_ID, + ADDON_ID, + startThunderbird, +} from "./session.js"; +export { PROFILE_PREFS, UPDATE_PREF_NAMES } from "./profile.js"; +export { resolveThunderbirdBinary } from "./provision.js"; +export { THUNDERBIRD_VERSION } from "./pins.js"; diff --git a/tests/thunderbird/harness/pins.js b/tests/thunderbird/harness/pins.js new file mode 100644 index 0000000..0055c56 --- /dev/null +++ b/tests/thunderbird/harness/pins.js @@ -0,0 +1,91 @@ +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +/** + * Everything this tier pins, in one file, because every one of these values is + * a claim about the outside world that can rot without any code here changing. + * + * See docs/adr/0001-three-test-tiers.md for why this tier exists at all. + */ + +/** + * The floor `manifest.json` promises is `128.0`, and this is the highest build + * on that train: `128.15.0esr` does not exist, so 128 receives no further + * updates. Automating the floor therefore means automating a frozen build that + * is past end of life - which is the trade the manifest's promise implies, and + * the reason THUNDERBIRD_BINARY exists rather than a reason to move the floor. + * + * The tests read the major from here and compare it against the manifest, so + * raising the floor fails the tier rather than silently testing the wrong + * thing. + */ +export const THUNDERBIRD_VERSION = "128.14.0esr"; + +/** + * `.tar.bz2`, not `.tar.xz`: the extension differs by train, and `.tar.xz` is + * a 404 for the whole 128 series. Current ESRs ship `.tar.xz`, so a future + * bump to this pin has to revisit the suffix and not just the number. + */ +export const THUNDERBIRD_ARCHIVE = `thunderbird-${THUNDERBIRD_VERSION}.tar.bz2`; + +const THUNDERBIRD_RELEASE_URL = `https://archive.mozilla.org/pub/thunderbird/releases/${THUNDERBIRD_VERSION}`; + +export const THUNDERBIRD_URL = `${THUNDERBIRD_RELEASE_URL}/linux-x86_64/en-US/${THUNDERBIRD_ARCHIVE}`; + +/** + * The checksums sit one directory above the locale directory and cover every + * platform in the release, so the entry to look for is keyed by the archive's + * path relative to that directory rather than by its bare name. + */ +export const THUNDERBIRD_SHA256SUMS_URL = `${THUNDERBIRD_RELEASE_URL}/SHA256SUMS`; +export const THUNDERBIRD_SHA256SUMS_ENTRY = `linux-x86_64/en-US/${THUNDERBIRD_ARCHIVE}`; + +/** + * 0.36.0, and the version is load-bearing in both directions. + * + * Above it: 0.37.0 changed the add-on install command to forward the archive + * to the application as base64, and Marionette only learned to accept that + * form well after 128. Against the pinned build, 0.37.x answers + * `installAddon` with a bare `InvalidArgumentError` from + * `driver.sys.mjs`, because 128 reads only a `path` parameter and finds + * none. 0.36.0 writes the archive to a temporary file and sends that path, + * which both 128 and current builds understand - the newer Marionette still + * accepts `path` - so the older driver is the one that drives both. + * + * Below it: switching Marionette into the privileged context needs the + * application started with system access from Firefox 138 on, and 0.36.0 is + * the release that introduced `--allow-system-access`. 128 does not ask for + * it, so the flag is there for the environment override; a 0.35.x driver would + * reject the flag outright. + * + * Its support matrix starts at 115 ESR with no upper bound, so one driver + * covers the floor and the current release. + * + * Unlike Thunderbird, geckodriver publishes no checksum file, so the digest + * below is pinned here instead: it was taken from the release asset once and + * is checked on every download. A mismatch means the asset changed under a + * published tag, which is the case worth failing on. + */ +export const GECKODRIVER_VERSION = "0.36.0"; +export const GECKODRIVER_URL = `https://github.com/mozilla/geckodriver/releases/download/v${GECKODRIVER_VERSION}/geckodriver-v${GECKODRIVER_VERSION}-linux64.tar.gz`; +export const GECKODRIVER_SHA256 = + "0bde38707eb0a686a20c6bd50f4adcc7d60d4f73c60eb83ee9e0db8f65823e04"; + +export const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); + +/** + * One ignored directory holds everything this tier downloads or generates: the + * extracted build, the driver, the staged copy that gets installed, and one + * profile per run. It is named in `.gitignore` and in `scripts/package.sh`'s + * exclusion list, the second of which matters more than it looks - the archive + * is the working tree zipped, so an unexcluded 84 MiB build would ship. + */ +export const cacheDir = path.join(repoRoot, ".thunderbird"); +export const downloadDir = path.join(cacheDir, "downloads"); +export const buildDir = path.join(cacheDir, `thunderbird-${THUNDERBIRD_VERSION}`); +export const geckodriverDir = path.join(cacheDir, `geckodriver-${GECKODRIVER_VERSION}`); +export const profilesDir = path.join(cacheDir, "profiles"); +export const stagingDir = path.join(cacheDir, "addon"); diff --git a/tests/thunderbird/harness/profile.js b/tests/thunderbird/harness/profile.js new file mode 100644 index 0000000..9f72b37 --- /dev/null +++ b/tests/thunderbird/harness/profile.js @@ -0,0 +1,178 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { profilesDir } from "./pins.js"; + +/** + * A fresh profile per run, and one that cannot update itself. + * + * The update prefs are not a precaution. A pinned build left alone upgrades + * itself mid-run, which is how this was found: the published driver stack + * ships app-update prefs from the Rust side but nothing Thunderbird-aware, and + * mozbase's Thunderbird profile turns off *add-on* updates and the mail + * provider wizard and says nothing at all about the application. So every + * pref below is written here explicitly, and the enterprise policy beside the + * binary (see provision.js) is the belt to their braces. + * + * `user.js` rather than `prefs.js`: Thunderbird re-applies it over `prefs.js` + * at every startup, so nothing the application decides to write during a run + * can take a pref back. + */ + +/** + * Two facts about the prefs geckodriver itself writes, since they decide what + * has to be here. It writes its own defaults into a custom profile only for + * keys the profile does not already set, so anything named here wins. And its + * strongest one, `app.update.disabledForTesting`, is gated on Marionette + * running - true under the driver, false for a hand-launched build, which is + * why the policy file carries the real weight. + */ +const UPDATE_PREFS = { + // Bites because Marionette is running. `app.update.enabled` is deliberately + // absent: it was removed from Gecko in Firefox 63 and setting it does + // nothing at all, which makes it worse than useless here - it reads like + // cover that is not there. + "app.update.disabledForTesting": true, + "app.update.auto": false, + "app.update.background.enabled": false, + "app.update.checkInstallTime": false, + "app.update.staging.enabled": false, + "app.update.langpack.enabled": false, + "extensions.update.enabled": false, + "extensions.update.autoUpdateDefault": false, + "extensions.installDistroAddons": false, +}; + +/** + * First-run noise. A brand-new profile otherwise opens the account setup tab, + * the mail provider dialog and the rights notification, any of which can be + * the modal that a driven run waits behind forever. + */ +const FIRST_RUN_PREFS = { + "mail.provider.enabled": false, + "mail.provider.suppress_dialog_on_startup": true, + "mailnews.start_page.enabled": false, + "mailnews.start_page.url": "about:blank", + "mailnews.start_page.override_url": "about:blank", + "mailnews.start_page_override.mstone": "ignore", + "mail.rights.version": 9999, + "mail.shell.checkDefaultClient": false, + "datareporting.policy.dataSubmissionEnabled": false, + "mailnews.database.global.indexer.enabled": false, + "mail.spotlight.firstRunDone": true, + "mail.winsearch.firstRunDone": true, + "mail.spellcheck.inline": false, + "browser.warnOnQuit": false, + "browser.sessionstore.resume_from_crash": false, +}; + +/** + * Temporary installs never require a signature, and Thunderbird does not sign + * add-ons at all - comm-central builds with signing off, so the pref is + * honoured rather than locked. Set anyway, because the cost is one line and + * the failure it would otherwise produce reads like a code problem. + */ +const ADDON_PREFS = { + "xpinstall.signatures.required": false, + "extensions.logging.enabled": true, + // A driven run stops for no slow-script dialog. Both of these are gone from + // the profile a person uses, so nothing here hides a hang from the harness: + // the test's own timeout reports it instead of a modal nobody can see. + "dom.max_script_run_time": 0, + "dom.max_chrome_script_run_time": 0, +}; + +/** + * A compose window needs an identity. Every entry point into the compose + * service carries an `nsIMsgIdentity`, and with no accounts configured the + * value handed over is null rather than an error, so the window either never + * opens or opens without the one thing it is being opened for. + * + * This is Mozilla's own recipe, from comm-central's mozmill runner by way of + * the add-on SDK, copied verbatim in shape: `account1` is Local Folders and + * carries no identity, and the identity hangs off `account2`, a pop3 account + * pointed at a hostname that does not resolve. It is preferred over creating + * the account through `MailServices.accounts` from chrome context - which the + * harness could do, having chrome context anyway - because an account that + * exists before the first paint is what keeps the account setup tab from + * opening in the first place. `ensureIdentity()` in session.js is the API + * route, kept as the repair path for the day these pref names drift. + */ +const ACCOUNT_PREFS = { + "mail.account.account1.server": "server1", + "mail.account.account2.identities": "id1", + "mail.account.account2.server": "server2", + "mail.accountmanager.accounts": "account1,account2", + "mail.accountmanager.defaultaccount": "account2", + "mail.accountmanager.localfoldersserver": "server1", + "mail.identity.id1.fullName": "ThunderCode Harness", + "mail.identity.id1.smtpServer": "smtp1", + "mail.identity.id1.useremail": "harness@invalid.test", + "mail.identity.id1.valid": true, + // The add-on is about HTML mail, so the default identity composes HTML. The + // plain-text composer is asked for per window rather than by flipping this. + "mail.identity.id1.compose_html": true, + "mail.root.none-rel": "[ProfD]Mail", + "mail.root.pop3-rel": "[ProfD]Mail", + "mail.server.server1.directory-rel": "[ProfD]Mail/Local Folders", + "mail.server.server1.hostname": "Local Folders", + "mail.server.server1.name": "Local Folders", + "mail.server.server1.type": "none", + "mail.server.server1.userName": "nobody", + "mail.server.server2.type": "pop3", + "mail.server.server2.hostname": "harness.invalid.test", + "mail.server.server2.userName": "harness", + "mail.server.server2.name": "harness@invalid.test", + // The account is a prop, not a mailbox. Without these three it tries to + // reach a host that does not resolve, on startup and then every ten + // minutes, and the run pays for the DNS timeout. + "mail.server.server2.login_at_startup": false, + "mail.server.server2.check_new_mail": false, + "mail.server.server2.download_on_biff": false, + "mail.smtp.defaultserver": "smtp1", + "mail.smtpserver.smtp1.hostname": "harness.invalid.test", + "mail.smtpserver.smtp1.username": "harness", + "mail.smtpservers": "smtp1", +}; + +export const PROFILE_PREFS = { + ...UPDATE_PREFS, + ...FIRST_RUN_PREFS, + ...ADDON_PREFS, + ...ACCOUNT_PREFS, +}; + +/** The prefs a test can assert are in force, without repeating their values. */ +export const UPDATE_PREF_NAMES = Object.keys(UPDATE_PREFS); + +/** + * A session can add prefs of its own - `startThunderbird({ prefs })` merges + * them over the set above - which is the only way to reach a pref that has to + * be set before startup. + * + * One that looks tempting and is not: `extensions.webextensions.remote = + * false`, to bring extension pages into the parent process so that a popup's + * document could be read from chrome. It was tried. The popup panel's browser + * then stays on `about:blank` and the popup never loads at all, so it buys + * nothing and costs the process model the add-on actually ships in. + */ + +function userJs(prefs) { + const lines = Object.entries(prefs).map( + ([name, value]) => `user_pref(${JSON.stringify(name)}, ${JSON.stringify(value)});`, + ); + return `// Written by tests/thunderbird/harness/profile.js. Regenerated per run.\n${lines.join("\n")}\n`; +} + +/** + * Fresh means new: a directory that did not exist a moment ago, named after + * the run, rather than a reused one with the last run's state cleared out of + * it. Nothing survives a run except the download cache, so a test can never + * pass because of something an earlier test left behind. + */ +export async function createProfile({ prefs = {} } = {}) { + await fs.mkdir(profilesDir, { recursive: true }); + const dir = await fs.mkdtemp(path.join(profilesDir, "run-")); + await fs.writeFile(path.join(dir, "user.js"), userJs({ ...PROFILE_PREFS, ...prefs })); + return dir; +} diff --git a/tests/thunderbird/harness/provision.js b/tests/thunderbird/harness/provision.js new file mode 100644 index 0000000..49ea695 --- /dev/null +++ b/tests/thunderbird/harness/provision.js @@ -0,0 +1,209 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { + GECKODRIVER_SHA256, + GECKODRIVER_URL, + GECKODRIVER_VERSION, + THUNDERBIRD_ARCHIVE, + THUNDERBIRD_SHA256SUMS_ENTRY, + THUNDERBIRD_SHA256SUMS_URL, + THUNDERBIRD_URL, + THUNDERBIRD_VERSION, + buildDir, + downloadDir, + geckodriverDir, +} from "./pins.js"; + +const run = promisify(execFile); + +/** + * Fetching the two binaries this tier drives, and doing it idempotently: the + * first run downloads about 90 MiB, every run after it finds the extracted + * tree and does nothing. + * + * There is no dependency for this on purpose. A downloader package would be a + * runtime dependency of the test suite for two well-known URLs, and the whole + * shape of this repo is that a contributor installs one lockfile and starts. + */ + +const THUNDERBIRD_ENV = "THUNDERBIRD_BINARY"; + +async function exists(target) { + try { + await fs.stat(target); + return true; + } catch { + return false; + } +} + +async function digest(file) { + const hash = createHash("sha256"); + hash.update(await fs.readFile(file)); + return hash.digest("hex"); +} + +async function download(url, target) { + await fs.mkdir(path.dirname(target), { recursive: true }); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`GET ${url} answered ${response.status} ${response.statusText}`); + } + // Written under a partial name and moved into place, so an interrupted run + // cannot leave a truncated archive that the next run treats as cached and + // then fails to verify in a confusing way. + const partial = `${target}.partial`; + await fs.writeFile(partial, Buffer.from(await response.arrayBuffer())); + await fs.rename(partial, target); +} + +/** + * The checksum file covers every platform in the release; the line for one + * archive is keyed by its path relative to the release directory. + */ +async function publishedThunderbirdDigest() { + const response = await fetch(THUNDERBIRD_SHA256SUMS_URL); + if (!response.ok) { + throw new Error( + `GET ${THUNDERBIRD_SHA256SUMS_URL} answered ${response.status} ${response.statusText}`, + ); + } + const sums = await response.text(); + for (const line of sums.split("\n")) { + const [sum, name] = line.trim().split(/\s+/); + if (name === THUNDERBIRD_SHA256SUMS_ENTRY) return sum; + } + throw new Error( + `${THUNDERBIRD_SHA256SUMS_URL} has no entry for ${THUNDERBIRD_SHA256SUMS_ENTRY}`, + ); +} + +async function fetchVerified(url, target, expected, describe) { + if (!(await exists(target))) { + describe(`downloading ${url}`); + await download(url, target); + } + const actual = await digest(target); + if (actual !== expected) { + // Delete it: leaving a file that failed verification in the cache means + // the next run either re-reports the same failure against a file nobody + // will look at, or worse, someone deletes the check instead of the file. + await fs.rm(target, { force: true }); + throw new Error( + `${path.basename(target)} has sha256 ${actual}, expected ${expected}`, + ); + } + return target; +} + +/** + * `DisableAppUpdate` is the belt to the profile's braces, and it is the + * stronger of the two. The pref that actually stops an update, + * `app.update.disabledForTesting`, only bites while Marionette is running, and + * `app.update.enabled` was removed from Gecko years ago; the policy is read by + * `Services.policies.isAllowed("appUpdate")` regardless of automation mode. + * + * This matters because the build is a tarball extracted into a directory the + * user can write, which is exactly the condition under which Thunderbird + * decides it is allowed to update itself in place. + */ +async function writePolicies(appDir) { + const policies = path.join(appDir, "distribution", "policies.json"); + await fs.mkdir(path.dirname(policies), { recursive: true }); + await fs.writeFile( + policies, + `${JSON.stringify( + { policies: { DisableAppUpdate: true, ExtensionUpdate: false } }, + null, + 2, + )}\n`, + ); + return policies; +} + +async function provisionThunderbird(describe) { + const binary = path.join(buildDir, "thunderbird", "thunderbird"); + if (!(await exists(binary))) { + const archive = await fetchVerified( + THUNDERBIRD_URL, + path.join(downloadDir, THUNDERBIRD_ARCHIVE), + await publishedThunderbirdDigest(), + describe, + ); + describe(`extracting ${THUNDERBIRD_ARCHIVE}`); + // Extracted next to the final directory and moved, for the same reason the + // download is: a half-extracted tree must never look like a cached one. + const partial = `${buildDir}.partial`; + await fs.rm(partial, { recursive: true, force: true }); + await fs.mkdir(partial, { recursive: true }); + await run("tar", ["-xjf", archive, "-C", partial]); + await fs.rename(partial, buildDir); + } + await writePolicies(path.join(buildDir, "thunderbird")); + return binary; +} + +async function provisionGeckodriver(describe) { + const driver = path.join(geckodriverDir, "geckodriver"); + if (await exists(driver)) return driver; + + const archive = await fetchVerified( + GECKODRIVER_URL, + path.join(downloadDir, `geckodriver-v${GECKODRIVER_VERSION}-linux64.tar.gz`), + GECKODRIVER_SHA256, + describe, + ); + describe("extracting geckodriver"); + await fs.mkdir(geckodriverDir, { recursive: true }); + await run("tar", ["-xzf", archive, "-C", geckodriverDir]); + await fs.chmod(driver, 0o755); + return driver; +} + +/** + * Where the binary under test comes from, and why. Both fields are reported + * out so a test can assert the pin and a failure can say which build it was + * looking at. + */ +export function resolveThunderbirdBinary() { + const override = process.env[THUNDERBIRD_ENV]; + if (override) { + return { binary: override, source: THUNDERBIRD_ENV, version: null }; + } + return { + binary: path.join(buildDir, "thunderbird", "thunderbird"), + source: "pinned", + version: THUNDERBIRD_VERSION, + }; +} + +/** + * Idempotent, and skipped entirely for the Thunderbird half when the + * environment names an existing install - the point of the override is to + * drive the build a maintainer already has, so downloading 84 MiB to ignore it + * would defeat it. The driver is still fetched either way: the only + * geckodriver on a typical Linux box is the Firefox snap's, which is confined + * and cannot launch a binary outside its sandbox. + */ +export async function provision({ log = () => {} } = {}) { + const describe = (message) => log(`[thunderbird tier] ${message}`); + const resolved = resolveThunderbirdBinary(); + + if (resolved.source === THUNDERBIRD_ENV) { + if (!(await exists(resolved.binary))) { + throw new Error( + `${THUNDERBIRD_ENV} is set to ${resolved.binary}, which does not exist`, + ); + } + describe(`using ${THUNDERBIRD_ENV}=${resolved.binary}, download skipped`); + } else { + await provisionThunderbird(describe); + } + + const geckodriver = await provisionGeckodriver(describe); + return { thunderbird: resolved, geckodriver }; +} diff --git a/tests/thunderbird/harness/session.js b/tests/thunderbird/harness/session.js new file mode 100644 index 0000000..04540b3 --- /dev/null +++ b/tests/thunderbird/harness/session.js @@ -0,0 +1,545 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { Builder, By, Key } from "selenium-webdriver"; +import firefox, { Context } from "selenium-webdriver/firefox.js"; + +import { repoRoot } from "./pins.js"; +import { UPDATE_PREF_NAMES, createProfile } from "./profile.js"; +import { provision } from "./provision.js"; + +const run = promisify(execFile); +const require = createRequire(import.meta.url); + +/** + * The harness. One call starts a Thunderbird, temp-installs this checkout into + * it and hands back something that can open compose windows; one call stops + * it and takes the profile with it. + * + * None of this is supported by Thunderbird - see the Consequences section of + * docs/adr/0001-three-test-tiers.md. What makes it work is that geckodriver + * cares only that the binary is Gecko, and that Marionette's chrome context is + * the whole application rather than a web page. + */ + +const manifest = require(path.join(repoRoot, "manifest.json")); + +/** + * The button's id is derived rather than written down, from the same two + * manifest keys Thunderbird derives it from: the extension id, lowercased with + * everything outside `[a-z0-9_-]` replaced by an underscore, and the module + * name of the action. Written out as a literal it would be a copy of a value + * this repo already owns, and changing the id in the manifest would leave a + * test looking for a button that no longer exists while claiming the button is + * missing. + */ +export const ADDON_ID = manifest.browser_specific_settings.gecko.id; +const widgetId = ADDON_ID.toLowerCase().replace(/[^a-z0-9_-]/g, "_"); +export const ACTION_BUTTON_ID = `${widgetId}-composeAction-toolbarbutton`; + +/** + * Where Thunderbird puts a `compose_action` button, by its own rule: the + * format toolbar when the manifest asks for `formattoolbar`, and the compose + * toolbar otherwise. Both ids come from Thunderbird's `ext-composeAction.js`. + */ +export const ACTION_TOOLBAR_ID = + manifest.compose_action.default_area === "formattoolbar" + ? "FormatToolbar" + : "composeToolbar2"; + +const COMPOSE_WINDOW_URL = + "chrome://messenger/content/messengercompose/messengercompose.xhtml"; + +const DEFAULT_TIMEOUT = 30_000; + +async function waitFor(describe, predicate, timeout = DEFAULT_TIMEOUT) { + const deadline = Date.now() + timeout; + let last; + for (;;) { + try { + const value = await predicate(); + if (value) return value; + last = undefined; + } catch (error) { + last = error; + } + if (Date.now() > deadline) { + throw new Error( + `timed out after ${timeout}ms waiting for ${describe}${last ? `: ${last.message}` : ""}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +/** + * The archive the release ships, built by the same script the release uses. + * + * `installAddon` takes a directory too and zips it for us, but it zips *all* + * of it - `node_modules/`, `.git/` and the 84 MiB build this tier just + * downloaded included. Reusing `scripts/package.sh` avoids both that and a + * second copy of its exclusion list, which would be the more expensive + * mistake: a list that drifts installs an add-on nobody ships. + * + * This is still a temporary install of an unsigned archive, which is a + * different code path from the Add-ons Manager install the release checklist + * keeps by hand, so that item stays where it is. + */ +async function buildArchive() { + const { stdout } = await run("bash", [path.join(repoRoot, "scripts/package.sh")], { + cwd: repoRoot, + }); + const xpi = stdout.trim(); + return path.isAbsolute(xpi) ? xpi : path.join(repoRoot, xpi); +} + +/** + * A compose window, addressed by its WebDriver window handle. + * + * Every method here switches to that handle first, so two open composers can + * be driven in any order - which is the case the add-on's own rule about a + * snippet never crossing windows needs, and the reason this is an object per + * window rather than methods on the harness. + */ +class ComposeWindow { + constructor(session, handle, format) { + this.session = session; + this.handle = handle; + this.format = format; + } + + get driver() { + return this.session.driver; + } + + async focus() { + await this.driver.switchTo().window(this.handle); + return this; + } + + /** + * Run privileged code with this window as `window`. The script's return + * value comes back over the wire, so it has to be JSON-shaped or a DOM + * element - the same rule as any `executeScript`. + */ + async chrome(script, ...args) { + await this.focus(); + return this.driver.executeScript(script, ...args); + } + + /** The add-on's button, as an element that can be clicked. */ + async actionButton() { + await this.focus(); + return waitFor(`the ${ACTION_BUTTON_ID} button`, () => + this.driver.findElement(By.id(ACTION_BUTTON_ID)), + ); + } + + /** + * The ids of everything in the toolbar the manifest asks for, in order. + * A test asserting the button is *in the format toolbar* wants this rather + * than `actionButton()`: an element found by id says nothing about where it + * ended up, and "somewhere in the compose window" is not the claim. + */ + async toolbarButtonIds() { + return this.chrome( + `const toolbar = document.getElementById(arguments[0]); + return toolbar ? Array.from(toolbar.children).map((child) => child.id) : null;`, + ACTION_TOOLBAR_ID, + ); + } + + /** The editor's body, both ways round: markup for HTML, text for either. */ + async bodyHtml() { + return this.chrome("return GetCurrentEditor().rootElement.innerHTML;"); + } + + async bodyText() { + return this.chrome("return GetCurrentEditor().rootElement.textContent;"); + } + + /** Puts the caret in the message body, which is where an insert lands. */ + async focusBody() { + await this.chrome(` + const editor = GetCurrentEditor(); + editor.selection.collapse(editor.rootElement, 0); + document.getElementById("messageEditor").focus(); + `); + return this; + } + + /** + * Types into the focused element of this window. Real key events: text typed + * this way lands in the message body, which is what makes an insert + * assertable against something a person could have typed. + */ + async sendKeys(...keys) { + await this.focus(); + await this.driver.actions({ async: false }).sendKeys(...keys).perform(); + return this; + } + + /** + * A modified key press - `pressChord(Key.CONTROL, "b")`. + * + * Written out as held modifiers rather than with `Key.chord`, which is the + * obvious call and the wrong one: through this driver `Key.chord` loses the + * modifiers silently, so `Key.chord(Key.CONTROL, "b")` types a literal `b` + * and the test that was checking for bold text reports that the feature is + * broken. Held down explicitly, the same press bolds. + * + * What it cannot do, on 128 at least, is fire the add-on's own + * `Ctrl+Shift+C`. Thunderbird registers an extension shortcut as a XUL `key` + * element, and for a letter key that element matches on **keypress** - it + * only gets `event="keydown"` for keycode shortcuts like the function keys. + * A synthesised `Ctrl+Shift+C` here produces keydown and keyup and no + * keypress at all, while `Ctrl+Shift+Q` produces all three; a `key` element + * added by hand with the same modifiers and a different letter does fire, + * and dispatching a command event at the extension's own `key` element does + * open the popup. So the add-on's wiring is intact and it is the key event + * that never arrives, which means a test of that shortcut needs a route + * other than this one. Untried for lack of the tools here: a real X server + * under `xvfb-run` with native key events pushed into it. + */ + async pressChord(...keys) { + const modifiers = keys.slice(0, -1); + const key = keys.at(-1); + await this.focus(); + let actions = this.driver.actions({ async: false }); + for (const modifier of modifiers) actions = actions.keyDown(modifier); + actions = actions.sendKeys(key); + for (const modifier of modifiers.toReversed()) actions = actions.keyUp(modifier); + await actions.perform(); + return this; + } + + /** + * The pages of any open action popups, as URLs. + * + * A popup is a `browser` inside a panel rather than a window, so it has no + * WebDriver window handle and nothing addresses it directly. Its URL is + * enough to assert that the add-on's popup is the thing that opened - which + * is as far as this goes: see `openActionPopup()`. + */ + async actionPopupUrls() { + return this.chrome(` + return Array.from( + document.querySelectorAll('browser[webextension-view-type="popup"]') + ).map((browser) => browser.currentURI?.spec ?? browser.getAttribute("src")); + `); + } + + /** + * Clicks the add-on's button and waits for its popup to load, returning the + * popup's URL. + * + * What this cannot do is reach inside the popup. Its browser is remote, so + * chrome script sees no `contentDocument`; Marionette's frame switching + * refuses the element ("Unable to locate frame for element") because the + * popup's browsing context is top-level rather than a child of this window; + * and in content context there are no window handles at all here, not even + * for the message body. Bringing extension pages in-process to get around it + * stops the popup loading entirely (see profile.js). So a test about what is + * *in* the popup needs a different route, and this is the honest limit of + * what the button click can assert. + */ + async openActionPopup() { + const before = (await this.actionPopupUrls()).length; + const button = await this.actionButton(); + await button.click(); + const urls = await waitFor("the action popup to load", async () => { + const open = await this.actionPopupUrls(); + return open.length > before && open.at(-1)?.startsWith("moz-extension://") + ? open + : null; + }); + return urls.at(-1); + } + + /** Dismisses an open popup, which is what a person's Escape key does. */ + async closeActionPopup() { + await this.sendKeys(Key.ESCAPE); + await waitFor( + "the action popup to close", + async () => (await this.actionPopupUrls()).length === 0, + ); + return this; + } + + /** True while the window is still open. */ + async isOpen() { + const handles = await this.driver.getAllWindowHandles(); + return handles.includes(this.handle); + } + + async close() { + if (!(await this.isOpen())) return; + await this.focus(); + // The window is asked to close from inside rather than through + // `driver.close()`, because a composer with a dirty body puts up a + // save-changes prompt that a driven run has no way past. Resetting the + // change state first is the difference between a clean teardown and a + // modal that outlives the test. + await this.driver.executeScript(` + gMsgCompose?.compFields && (gMsgCompose.bodyModified = false); + GetCurrentEditor()?.resetModificationCount(); + window.close(); + `); + await waitFor("the compose window to close", async () => !(await this.isOpen())); + await this.session.focusMainWindow(); + } +} + +class Session { + constructor(driver, { thunderbird, geckodriver, profileDir, addonId, mainWindow }) { + this.driver = driver; + this.thunderbird = thunderbird; + this.geckodriver = geckodriver; + this.profileDir = profileDir; + this.addonId = addonId; + this.mainWindow = mainWindow; + this.actionButtonId = ACTION_BUTTON_ID; + this.actionToolbarId = ACTION_TOOLBAR_ID; + } + + /** Privileged code in the main mail window. */ + async chrome(script, ...args) { + await this.focusMainWindow(); + return this.driver.executeScript(script, ...args); + } + + async focusMainWindow() { + await this.driver.switchTo().window(this.mainWindow); + } + + /** + * Opens a composer through `nsIMsgComposeService` rather than through the UI. + * + * `MsgNewMessage` depends on the 3-pane window's tabmail and on a folder + * being selected; the service takes the identity and the format directly, + * which is also the only way to ask for a plain-text composer without going + * through a menu - and a plain-text composer is a different editor rather + * than the same one with the styling switched off. + */ + async openCompose({ format = "html" } = {}) { + const before = await this.driver.getAllWindowHandles(); + await this.chrome( + `const [format] = arguments; + const { MailServices } = ChromeUtils.importESModule( + "resource:///modules/MailServices.sys.mjs" + ); + const identity = MailServices.accounts.defaultAccount?.defaultIdentity; + if (!identity) { + throw new Error("no default identity: the profile has no usable account"); + } + const params = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance( + Ci.nsIMsgComposeParams + ); + params.composeFields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + params.identity = identity; + params.type = Ci.nsIMsgCompType.New; + params.format = + format === "plaintext" + ? Ci.nsIMsgCompFormat.PlainText + : Ci.nsIMsgCompFormat.HTML; + MailServices.compose.OpenComposeWindowWithParams(null, params);`, + format, + ); + + const handle = await waitFor("a new compose window handle", async () => { + const after = await this.driver.getAllWindowHandles(); + const fresh = after.filter((candidate) => !before.includes(candidate)); + for (const candidate of fresh) { + await this.driver.switchTo().window(candidate); + const url = await this.driver.executeScript("return window.location.href;"); + if (url === COMPOSE_WINDOW_URL) return candidate; + } + return null; + }); + + const composeWindow = new ComposeWindow(this, handle, format); + // The editor is built asynchronously after the window loads, and every + // useful thing a test does with a composer goes through it, so waiting for + // it here is the difference between one wait and one in every test. + await waitFor("the compose editor", () => + composeWindow.chrome( + `return !!(typeof GetCurrentEditor === "function" && GetCurrentEditor());`, + ), + ); + return composeWindow; + } + + /** Every open compose window, oldest first. */ + async composeWindows() { + const handles = await this.driver.getAllWindowHandles(); + const found = []; + for (const handle of handles) { + await this.driver.switchTo().window(handle); + const url = await this.driver.executeScript("return window.location.href;"); + if (url === COMPOSE_WINDOW_URL) found.push(new ComposeWindow(this, handle, null)); + } + return found; + } + + /** + * What Thunderbird thinks it is running: the version, and the name, since + * "firefox" appears in enough of this harness to be worth disproving once. + */ + async appInfo() { + return this.chrome(` + return { + name: Services.appinfo.name, + version: Services.appinfo.version, + profileDir: Services.dirsvc.get("ProfD", Ci.nsIFile).path, + }; + `); + } + + /** + * How the installed add-on got there, read back from the add-on manager + * rather than assumed from the call that installed it. `temporarilyInstalled` + * and `signedState` are the two claims worth checking: a permanent install or + * a signature requirement would both be a different mechanism than the one + * this tier is built on. + */ + async addonInfo() { + await this.focusMainWindow(); + return this.driver.executeAsyncScript( + `const [id, done] = arguments; + const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" + ); + AddonManager.getAddonByID(id).then( + (addon) => + done( + addon + ? { + id: addon.id, + version: addon.version, + type: addon.type, + isActive: addon.isActive, + temporarilyInstalled: addon.temporarilyInstalled, + signedState: addon.signedState, + } + : null + ), + (error) => done({ error: String(error) }) + );`, + this.addonId, + ); + } + + /** + * Both halves of "cannot update itself", read from the running application: + * the policy, which applies whether or not anything is automating, and the + * prefs, which are what a run without the policy would be relying on. + */ + async updateGuards() { + return this.chrome( + `const [names] = arguments; + const prefs = {}; + for (const name of names) { + prefs[name] = Services.prefs.getBoolPref(name, null); + } + return { + policyAllowsAppUpdate: Services.policies.isAllowed("appUpdate"), + signaturesRequired: Services.prefs.getBoolPref( + "xpinstall.signatures.required", + null + ), + prefs, + };`, + UPDATE_PREF_NAMES, + ); + } + + async stop() { + try { + await this.driver.quit(); + } finally { + // The profile is the run, so it goes with it. Keeping it would make the + // next run's "fresh profile" claim depend on nobody having pointed a + // second run at the same directory. + await fs.rm(this.profileDir, { recursive: true, force: true }); + } + } +} + +/** + * The one entry point. Provisions if it has to, starts Thunderbird on a new + * profile, waits for the main window, installs this checkout and returns the + * session. + */ +export async function startThunderbird({ log = () => {}, prefs = {} } = {}) { + const { thunderbird, geckodriver } = await provision({ log }); + const profileDir = await createProfile({ prefs }); + + const options = new firefox.Options() + .setBinary(thunderbird.binary) + .addArguments("-profile", profileDir); + + // Headless by default; `THUNDERBIRD_HEADLESS=0` gives it a display, which is + // the documented escape hatch for the things headless Thunderbird has been + // known to get wrong - run the command under `xvfb-run` and the run is still + // unattended. `-headless` is handled in toolkit rather than in Firefox's own + // code, which is why it reaches Thunderbird at all. + if (process.env.THUNDERBIRD_HEADLESS !== "0") { + options.addArguments("-headless"); + } + + const service = new firefox.ServiceBuilder(geckodriver) + // Switching Marionette into the privileged context needs the application + // started with system access from Firefox 138 on. 128 does not ask for it, + // so this is for the environment override rather than for the pin - and it + // has to go on the service rather than through `moz:firefoxOptions.args`, + // which geckodriver dropped as a route for it in 0.37.1. + .addArguments("--allow-system-access"); + if (process.env.THUNDERBIRD_TIER_DEBUG) { + service.addArguments("--log", "trace").setStdio("inherit"); + } + + log(`[thunderbird tier] launching ${thunderbird.binary}`); + const driver = await new Builder() + // Not a typo and not aspirational: geckodriver matches on this name and + // nothing else. What it launches is whatever `binary` points at. + .forBrowser("firefox") + .setFirefoxOptions(options) + .setFirefoxService(service) + .build(); + + try { + await driver.setContext(Context.CHROME); + const mainWindow = await waitFor("the main mail window", async () => { + for (const handle of await driver.getAllWindowHandles()) { + await driver.switchTo().window(handle); + const type = await driver.executeScript( + "return document.documentElement.getAttribute('windowtype');", + ); + if (type === "mail:3pane") return handle; + } + return null; + }); + await driver.switchTo().window(mainWindow); + + const xpi = await buildArchive(); + log(`[thunderbird tier] installing ${path.relative(repoRoot, xpi)}`); + const addonId = await driver.installAddon(xpi, true); + + return new Session(driver, { + thunderbird, + geckodriver, + profileDir, + addonId, + mainWindow, + }); + } catch (error) { + await driver.quit().catch(() => {}); + await fs.rm(profileDir, { recursive: true, force: true }); + throw error; + } +} diff --git a/tests/thunderbird/pins.test.js b/tests/thunderbird/pins.test.js new file mode 100644 index 0000000..11bb5f9 --- /dev/null +++ b/tests/thunderbird/pins.test.js @@ -0,0 +1,125 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { THUNDERBIRD_VERSION, repoRoot } from "./harness/pins.js"; +import { createProfile } from "./harness/profile.js"; +import { provision, resolveThunderbirdBinary } from "./harness/provision.js"; + +/** + * The half of this tier that needs no Thunderbird running: what it pins, and + * how the environment override is read. + * + * These are in the Thunderbird tier rather than in the node tier even though + * they would pass there, because they are claims about the harness rather than + * about the add-on, and splitting them would mean someone changing the pin + * gets a failure from a directory they were not working in. + */ + +const THUNDERBIRD_ENV = "THUNDERBIRD_BINARY"; + +// The manifest, read rather than imported, following the node tier's habit of +// taking the expected value from the file that owns it. +const manifest = JSON.parse( + readFileSync(path.join(repoRoot, "manifest.json"), "utf8"), +); + +describe("the pinned Thunderbird", () => { + it("is the floor the manifest promises", () => { + const floor = manifest.browser_specific_settings.gecko.strict_min_version; + const [floorMajor] = floor.split("."); + const [pinMajor] = THUNDERBIRD_VERSION.split("."); + + // The same train, and at or above the exact version promised. Raising the + // manifest's floor without repinning the harness lands here rather than in + // a run that quietly tests a version the add-on no longer supports. + expect(pinMajor).toBe(floorMajor); + expect(Number.parseInt(THUNDERBIRD_VERSION, 10)).toBeGreaterThanOrEqual( + Number.parseInt(floor, 10), + ); + }); + + it("is an esr build, because the floor is one", () => { + expect(THUNDERBIRD_VERSION.endsWith("esr")).toBe(true); + }); +}); + +describe("the profile", () => { + it("is a new directory every time, not a cleaned-out one", async () => { + const first = await createProfile(); + const second = await createProfile(); + try { + expect(first).not.toBe(second); + expect(existsSync(first)).toBe(true); + expect(existsSync(second)).toBe(true); + } finally { + const { rm } = await import("node:fs/promises"); + await rm(first, { recursive: true, force: true }); + await rm(second, { recursive: true, force: true }); + } + }); +}); + +describe(`the ${THUNDERBIRD_ENV} override`, () => { + /** + * The override's plumbing, not its outcome. Whether a given install can be + * driven is a property of that install - this machine's is 115, below the + * manifest floor, so it cannot load a Manifest V3 MailExtension at all and + * pointing the harness at it fails for a real reason. What is asserted here + * is the part that is this repo's to get right: the variable is read, it + * wins over the pin, and the download is not attempted when it is set. + */ + const withOverride = async (value, body) => { + const before = process.env[THUNDERBIRD_ENV]; + if (value === undefined) delete process.env[THUNDERBIRD_ENV]; + else process.env[THUNDERBIRD_ENV] = value; + try { + return await body(); + } finally { + if (before === undefined) delete process.env[THUNDERBIRD_ENV]; + else process.env[THUNDERBIRD_ENV] = before; + } + }; + + it("takes the binary from the environment when it is set", async () => { + await withOverride("/opt/thunderbird/thunderbird", () => { + const resolved = resolveThunderbirdBinary(); + expect(resolved.binary).toBe("/opt/thunderbird/thunderbird"); + expect(resolved.source).toBe(THUNDERBIRD_ENV); + // No version is claimed for an install the harness did not fetch. + expect(resolved.version).toBeNull(); + }); + }); + + it("falls back to the pinned build when it is not", async () => { + await withOverride(undefined, () => { + const resolved = resolveThunderbirdBinary(); + expect(resolved.source).toBe("pinned"); + expect(resolved.version).toBe(THUNDERBIRD_VERSION); + expect(resolved.binary).toContain(THUNDERBIRD_VERSION); + }); + }); + + it("skips the download and provisions only the driver", async () => { + // `process.execPath` stands in for an installed Thunderbird: the point is + // that provisioning returns the path it was given rather than fetching 84 + // MiB to ignore it, and any existing file proves that. + await withOverride(process.execPath, async () => { + const { thunderbird, geckodriver } = await provision(); + expect(thunderbird.binary).toBe(process.execPath); + expect(thunderbird.source).toBe(THUNDERBIRD_ENV); + // The driver is still fetched: the only geckodriver on a typical Linux + // box is the Firefox snap's, which cannot launch anything outside its + // sandbox. + expect(existsSync(geckodriver)).toBe(true); + }); + }); + + it("says which variable and which path when the path is wrong", async () => { + await withOverride("/nonexistent/thunderbird", async () => { + await expect(provision()).rejects.toThrow( + /THUNDERBIRD_BINARY.*\/nonexistent\/thunderbird/, + ); + }); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index fd118ca..9744eeb 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -42,12 +42,38 @@ export default defineConfig({ include: ["tests/dom/**/*.test.js"], }, }, - // The third tier - a real Thunderbird, driven headless - arrives as a - // project of its own over `tests/thunderbird/`, run by its own command. - // It is absent here on purpose so that `pnpm test` stays green on a - // machine with no Thunderbird installed. The node tier above already - // declines to claim that directory, so adding the project is the whole - // of the change. + { + test: { + name: "thunderbird", + + // A real Thunderbird, fetched and driven headless. No DOM from the + // runner: the document this tier works with is the one inside the + // application, reached over WebDriver, and a jsdom sitting in the + // test process would only be something to confuse it with. + environment: "node", + include: ["tests/thunderbird/**/*.test.js"], + + // Kept out of `pnpm test`, which names its projects. Not because it + // would fail on a machine with no Thunderbird - it fetches its own, + // so it passes from a clean checkout - but because the default run + // must not need the network, 90 MiB of disk or two minutes, and + // because an unsupported harness should never be the reason a + // change cannot be tested. `pnpm test:thunderbird` runs it. + + // One Thunderbird at a time. Two files starting one each would race + // over the download on a cold cache and then compete for the same + // driver port, and the failure would look like the harness rather + // than like the arrangement. + fileParallelism: false, + + // Minutes, not seconds, and the two differ for a reason: the hook is + // where a cold cache downloads Thunderbird and geckodriver, while a + // test only drives an application that is already up. A test that + // takes a minute is a hung window, not a slow one. + hookTimeout: 600_000, + testTimeout: 120_000, + }, + }, ], coverage: { From 33b9e84c02f63b523fd99ef8c95e7c038fcc0060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 11:13:41 +0200 Subject: [PATCH 17/22] docs: write down how to run the unsupported tier The README gains what the tier needs, what the three environment variables do and the fact that Thunderbird supports none of it, so that whoever finds it broken after an update knows that was always the deal. The decision record's reason for keeping the tier out of the default command was wrong once the harness fetched its own build: the suite is green on a machine with no Thunderbird either way, and the real reasons are that the default run must not need the network or two minutes, and that an unsupported harness should never be why a change cannot be tested. It also now records the pile of external pins the tier turned out to own, the dummy account among them. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 53 +++++++++++++++++++++++++++---- docs/adr/0001-three-test-tiers.md | 17 ++++++++-- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 694cc5a..82ba80a 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,10 @@ does not reach one that is already open. ### Running the tests ```sh -pnpm test # both automated tiers -pnpm test:node # the pure tier alone, for a fast edit loop -pnpm coverage # a report; nothing is gated on it +pnpm test # both automated tiers +pnpm test:node # the pure tier alone, for a fast edit loop +pnpm test:thunderbird # the real-Thunderbird tier; see below +pnpm coverage # a report; nothing is gated on it ``` The suite is split into tiers, and which one a test belongs in is decided by @@ -127,9 +128,8 @@ where it can be written rather than by what it is about: | `dom` | `tests/dom/` | a simulated document, via jsdom | | `thunderbird` | `tests/thunderbird/` | a real Thunderbird, driven headless | -`pnpm test` runs the first two. The third needs a Thunderbird to drive, so it -is a local command run while working the checklist, not part of the default run -and not part of CI. +`pnpm test` runs the first two. The third is a local command run while working +the checklist, not part of the default run and not part of CI. The `node` tier has no document on purpose: a test that reaches for one there fails rather than passing, which is what has kept the code-block pipeline from @@ -143,6 +143,47 @@ be one. The reasoning behind all of this, including the alternatives that were turned down, is in [docs/adr/0001-three-test-tiers.md](docs/adr/0001-three-test-tiers.md). +### The real-Thunderbird tier + +```sh +pnpm test:thunderbird +``` + +**Thunderbird does not support this and does not document it.** Driving the +application over WebDriver, switching into its privileged context and +temp-installing an unsigned build are all things that happen to work rather +than things anyone has promised to keep working, and a Thunderbird update can +break the tier with no warning. When that happens it is this project's cost to +absorb, which is affordable exactly because the tier runs in no pipeline and +can block nothing. It is the only tier that can exercise the editor command +path that runs in production. + +Nothing needs to be installed first. The command fetches the pinned Thunderbird +and a matching geckodriver into `.thunderbird/`, verifies both against +published checksums, and starts the application headless on a profile it +creates for the run and deletes afterwards. That is about 90 MiB and a couple +of minutes the first time and nothing on every run after it; the directory is +ignored and disposable, so deleting it starts over. Linux x86_64 only as it +stands - the archive names and the driver asset are picked for that platform. + +The pinned version is the floor `strict_min_version` promises, which means the +tier drives a build that is frozen and past end of life. That is the trade the +promise implies rather than a reason to move the floor, and it is why the +override below exists. + +| Variable | Effect | +| --- | --- | +| `THUNDERBIRD_BINARY` | Drive an installed Thunderbird instead of the pin, and skip the download. Needs 128 or newer: a Manifest V3 MailExtension will not load at all below that, so pointing this at an older build fails for a real reason. | +| `THUNDERBIRD_HEADLESS=0` | Give the application a display. Run the command under `xvfb-run` and it stays unattended; this is the fallback for the things headless Thunderbird has been known to get wrong. | +| `THUNDERBIRD_TIER_DEBUG=1` | geckodriver's trace log, on the terminal. | + +The harness itself is `tests/thunderbird/harness/`, and its interface is +documented in `tests/thunderbird/harness/index.js` - including two limits found +while building it, which are worth reading before writing a test that runs into +them: the add-on's popup cannot be read from outside once it is open, and a +letter-key shortcut cannot be delivered to Thunderbird 128 by synthesised +input. + What is still checked by hand is anything that is a claim about Thunderbird rather than about this project's own logic; that list is `docs/release-checklist.md`. diff --git a/docs/adr/0001-three-test-tiers.md b/docs/adr/0001-three-test-tiers.md index 67b86d3..d7f2954 100644 --- a/docs/adr/0001-three-test-tiers.md +++ b/docs/adr/0001-three-test-tiers.md @@ -37,9 +37,12 @@ caret insertion and neither implements the editor command at all. **`thunderbird`** - a real Thunderbird, driven headless over WebDriver, in `tests/thunderbird/`. It is the only tier that can exercise the editor command path that actually runs in production, and the only one that can retire a -checklist item honestly. It is kept out of the default test command so the -suite stays green on a machine with no Thunderbird installed, and it is run -locally while working the checklist rather than in CI. +checklist item honestly. It provisions the Thunderbird it drives, so it is +green on a machine that has none; it is kept out of the default test command +anyway, because that run must not need the network, ninety megabytes of disk or +a couple of minutes, and because an unsupported harness should never be the +reason a change cannot be tested. It is run locally while working the checklist +rather than in CI. `pnpm test` runs `node` and `dom`. `pnpm test:node` runs the strict tier alone, because the cost of running tests while editing should never be the reason not @@ -98,6 +101,14 @@ runs in no pipeline. It was verified end to end before being committed to, and it is worth the exposure because it is the only place the production insertion path can be exercised at all. +What the tier turned out to own, beyond the code: a Thunderbird version, a +geckodriver version, the archive's compression suffix, a list of preferences, +an enterprise policy file and a dummy local account, because a compose window +with no identity does not open. Every one of those is a claim about the outside +world that can rot while nothing here changes, which is the same exposure as +the paragraph above and the reason they are pinned in one file with their +reasons written next to them rather than spread through the harness. + The checklist keeps every item that is a claim about Thunderbird rather than about this project's own logic - the button on a dark appearance, the icon rather than a puzzle piece, where cloud attachment links land, the absence of From 86f6054164636467335b4a34ca5153c3bdb0eb13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 11:53:13 +0200 Subject: [PATCH 18/22] test(compose): assert the insert a compose window actually receives The tier could find the add-on's button; now it uses it. A snippet is typed into the popup and a block comes out in the message body - through the toolbar button, through the shortcut's key element, through a right-click carrying a selection, and into a plain-text composer - with the insertion function's own report of which path it took read back off the console, so "through the editor command rather than a fallback" is asserted rather than assumed. One Ctrl+Z takes the block out again, which is the editor's side of the same claim. The popup's document is still unreachable. What turned out to be reachable is the popup itself: focusing its browser element hands it the keyboard, so it can be typed into and confirmed from outside while the message body says what happened. A focus failure would otherwise read as a working insert, so every test reads the body once before confirming and expects it untouched - broken on purpose once to watch all eight fail with that message. Two things these tests cannot have, both said where they bite: the key press itself, which synthesised input cannot deliver to a letter-key shortcut, and the popup's textarea, which nothing can read. So the shortcut is driven at the key element Thunderbird built from the manifest, and the prefill is observed through what it inserts. And one finding: the popup cannot be opened in a plain-text composer at all, because Thunderbird hides the toolbar the button sits in and the popup is anchored to that button. That is issue #12. The insert itself is correct, which is what the test asserts, with the toolbar unhidden for the length of it. Co-Authored-By: Claude Opus 5 (1M context) --- tests/thunderbird/compose-window.test.js | 12 + tests/thunderbird/harness/index.js | 43 ++- tests/thunderbird/harness/session.js | 384 ++++++++++++++++++- tests/thunderbird/insertion.test.js | 457 +++++++++++++++++++++++ 4 files changed, 890 insertions(+), 6 deletions(-) create mode 100644 tests/thunderbird/insertion.test.js diff --git a/tests/thunderbird/compose-window.test.js b/tests/thunderbird/compose-window.test.js index a0c93f0..cd7a380 100644 --- a/tests/thunderbird/compose-window.test.js +++ b/tests/thunderbird/compose-window.test.js @@ -97,6 +97,18 @@ describe("the installed add-on", () => { expect(info.signedState).toBeLessThanOrEqual(0); expect((await session.updateGuards()).signaturesRequired).toBe(false); }); + + it("came from the archive the release ships, named for the manifest", async () => { + // The harness installs `scripts/package.sh`'s output rather than zipping + // the checkout itself, so every run of this tier is a run of the release + // script. That makes the archive's name something this tier asserts rather + // than something to check by hand before a tag. What it does not cover is + // installing that archive through the Add-ons Manager, which is a + // different code path and stays on the checklist. + expect(path.basename(session.archive)).toBe( + `thundercode-${manifest.version}.xpi`, + ); + }); }); describe("a compose window", () => { diff --git a/tests/thunderbird/harness/index.js b/tests/thunderbird/harness/index.js index 98a7507..43c00b7 100644 --- a/tests/thunderbird/harness/index.js +++ b/tests/thunderbird/harness/index.js @@ -21,20 +21,53 @@ * } * ``` * + * Driving the add-on rather than only finding it, which is what the insertion + * tests are made of: + * + * ```js + * await compose.pressActionShortcut(); // the manifest's shortcut, at + * // the key element it became + * await compose.typeIntoActionPopup(source); // into the popup, not the body + * await compose.confirmActionPopup(); // Ctrl+Enter, then wait for the + * // popup to close + * await compose.selectInBody("text"); // something to right-click + * const items = await compose.openBodyContextMenu(); // the add-on's items + * await compose.activateMenuItem(items[0].id); + * await compose.editorState(); // { canUndo, modificationCount } + * await compose.undo(); + * await session.consoleMessages(); // which path the insert took + * ``` + * * `session` also has `chrome()` on the main window, `appInfo()`, `addonInfo()`, - * `updateGuards()`, `composeWindows()` and `openCompose()`. Everything runs in + * `updateGuards()`, `composeWindows()`, `openCompose()` and `archive` - the + * path of the `.xpi` this run built and installed. Everything runs in * Marionette's chrome context, so a script sees `Services`, `ChromeUtils`, `Cc` * and `Ci`, and `window` is the window it was called on. * - * Two limits worth knowing before writing a test against this, both found the - * hard way and both explained where they bite - in session.js: - * `openActionPopup()` cannot see inside the popup, and a letter-key extension - * shortcut cannot be fired by synthesised input on Thunderbird 128. + * Four limits worth knowing before writing a test against this. Each was found + * the hard way and each is explained where it bites, in session.js: + * + * - `openActionPopup()` cannot see *inside* the popup. What it can do is hand + * the popup the keyboard and read the result out of the message body, which + * is how every insertion test here works. + * - Opening the popup does not move the focus, so keys reach the message body + * unless `focusActionPopup()` has run. A test that forgets can pass while + * asserting nothing: type a snippet, press `Ctrl+Enter`, and a plain-text + * composer ends up holding exactly what an insert would have put there. + * - A letter-key extension shortcut cannot be fired by synthesised input on + * Thunderbird 128. `pressActionShortcut()` drives the key element instead, + * and says what that does and does not cover. + * - The popup cannot be opened in a plain-text composer at all, because + * Thunderbird hides the toolbar this add-on's button sits in and the popup + * is anchored to that button. That is a defect in the add-on rather than a + * limit of the harness - issue #12, with the details in insertion.test.js. */ export { ACTION_BUTTON_ID, ACTION_TOOLBAR_ID, ADDON_ID, + MENU_ITEM_ID_PREFIX, + SHORTCUT_KEYSET_ID, startThunderbird, } from "./session.js"; export { PROFILE_PREFS, UPDATE_PREF_NAMES } from "./profile.js"; diff --git a/tests/thunderbird/harness/session.js b/tests/thunderbird/harness/session.js index 04540b3..b8462d5 100644 --- a/tests/thunderbird/harness/session.js +++ b/tests/thunderbird/harness/session.js @@ -50,6 +50,27 @@ export const ACTION_TOOLBAR_ID = ? "FormatToolbar" : "composeToolbar2"; +/** + * The two other places Thunderbird files this add-on under, derived from the + * same widget id as the button and for the same reason. + * + * `SHORTCUT_KEYSET_ID` is the `keyset` the extension framework appends to + * every window it registers the manifest's `commands` in, holding one `key` + * element per shortcut. `MENU_ITEM_ID_PREFIX` is what an item created through + * the `menus` API is given, followed by an underscore and the id the extension + * chose - so the prefix finds this add-on's items in a menu without this file + * knowing that id, which lives in the background and is not exported. + */ +export const SHORTCUT_KEYSET_ID = `ext-keyset-id-${widgetId}`; +export const MENU_ITEM_ID_PREFIX = `${widgetId}-menuitem-`; + +/** + * Thunderbird's own context menu for the message body, by its id in + * `messengercompose.xhtml`. The `menus` API's `compose_body` context is this + * menu, so an item registered for that context is an item in here. + */ +const COMPOSE_CONTEXT_MENU_ID = "msgComposeContext"; + const COMPOSE_WINDOW_URL = "chrome://messenger/content/messengercompose/messengercompose.xhtml"; @@ -161,6 +182,28 @@ class ComposeWindow { return this.chrome("return GetCurrentEditor().rootElement.textContent;"); } + /** + * The body as the message would carry it, through the editor's own + * serialiser. + * + * This exists for the plain-text composer, where `bodyText()` is not enough: + * that editor represents a line break as a `br` element, which + * `textContent` drops silently, so a snippet's indentation survives the + * insert and then disappears on the way into the assertion. `OutputRaw` + * keeps the serialiser from re-wrapping the result to the composer's line + * width, which is a thing it does by default and which would rewrite the + * very lines this is being read to check. + */ + async bodyPlainText() { + return this.chrome(` + const encoder = Ci.nsIDocumentEncoder; + return GetCurrentEditor().outputToString( + "text/plain", + encoder.OutputLFLineBreak | encoder.OutputRaw + ); + `); + } + /** Puts the caret in the message body, which is where an insert lands. */ async focusBody() { await this.chrome(` @@ -171,6 +214,17 @@ class ComposeWindow { return this; } + /** + * Types into the message body, which is how a test gets text for a caret to + * sit in the middle of. Real keys rather than an assignment to `innerHTML`, + * so what an insert then has to leave intact is text the editor itself put + * there, wrapped in whatever the editor decided to wrap it in. + */ + async typeIntoBody(...keys) { + await this.focusBody(); + return this.sendKeys(...keys); + } + /** * Types into the focused element of this window. Real key events: text typed * this way lands in the message body, which is what makes an insert @@ -250,15 +304,303 @@ class ComposeWindow { const before = (await this.actionPopupUrls()).length; const button = await this.actionButton(); await button.click(); + return this.waitForActionPopup(before); + } + + /** + * Waits for one more action popup than there was, and for the panel around + * it to finish opening. Shared by the two ways the popup is opened here, the + * button and the shortcut, because "the popup appeared" is the same wait + * either way and the panel's state is the readiness signal both need: a + * popup focused while its panel is still `showing` is dismissed rather than + * focused. + */ + async waitForActionPopup(before = 0) { const urls = await waitFor("the action popup to load", async () => { const open = await this.actionPopupUrls(); return open.length > before && open.at(-1)?.startsWith("moz-extension://") ? open : null; }); + await waitFor("the action popup's panel to finish opening", () => + this.chrome(` + const browser = document.querySelector('browser[webextension-view-type="popup"]'); + if (!browser) throw new Error("the action popup closed again"); + return browser.closest("panel")?.state === "open"; + `), + ); return urls.at(-1); } + /** + * Gives the open popup the keyboard, which is the difference between a test + * that drives the add-on and a test that types into the message body. + * + * Opening the popup does not move the chrome window's focus, so keys + * synthesised into this window still go to the message editor. That failure + * is silent and it looks like a passing test: the snippet turns up in the + * body as typed text, `Ctrl+Enter` reaches the compose window's own Send + * binding rather than the popup's confirm, and in a plain-text composer the + * result is indistinguishable from a successful insert. Every test here + * therefore reads the body once before confirming and expects it empty. + * + * Focusing the `browser` element is what moves the focus. + * `Services.focus.setFocus(browser, FLAG_BYKEY)` does the same thing; + * `panel.focus()` and `browsingContext.focus()` were both tried and neither + * moves it at all. + * + * None of this reads the popup's document, which is still out of reach - see + * `openActionPopup()`. It puts the keyboard where a person's click already + * put it. + */ + async focusActionPopup() { + await waitFor("the action popup to take the keyboard", () => + this.chrome(` + const browser = document.querySelector('browser[webextension-view-type="popup"]'); + if (!browser) throw new Error("the action popup closed"); + browser.focus(); + return document.activeElement === browser; + `), + ); + return this; + } + + /** Types into the open popup rather than into the message body. */ + async typeIntoActionPopup(...keys) { + await this.focusActionPopup(); + return this.sendKeys(...keys); + } + + /** + * Confirms the popup from the keyboard and waits for it to close. + * + * `Ctrl+Enter` rather than the Insert button because the button is inside + * the popup's document and unreachable, and because the popup deliberately + * runs both through one function: a keyboard confirm that behaves + * differently from the button is the bug that function exists to prevent. + * + * The popup closing is the signal that the insert succeeded - it closes + * itself on success and stays open with an error line on failure, and that + * line cannot be read from out here. So the timeout says which of the two + * happened, because "no block in the body" on its own does not. + */ + async confirmActionPopup() { + await this.pressChord(Key.CONTROL, Key.ENTER); + await waitFor( + "the popup to close, which is how a successful insert ends - a failed " + + "one leaves it open showing an error line this harness cannot read", + async () => (await this.actionPopupUrls()).length === 0, + ); + return this; + } + + /** + * The `key` elements Thunderbird derived from the manifest's `commands`, as + * their attributes. + * + * Read out of the keyset the extension framework appended to this window, so + * what comes back is Thunderbird's own translation of the manifest rather + * than this repo's restatement of it: `Ctrl+Shift+C` arrives as + * `modifiers="accel,shift"` with `key="C"`, and `accel` is the part that + * makes the same manifest entry read as Command on macOS. + */ + async actionShortcutKeys() { + return this.chrome( + `const [keysetId] = arguments; + const keyset = document.getElementById(keysetId); + return Array.from(keyset?.children ?? []).map((key) => ({ + key: key.getAttribute("key"), + keycode: key.getAttribute("keycode"), + modifiers: key.getAttribute("modifiers"), + }));`, + SHORTCUT_KEYSET_ID, + ); + } + + /** + * Fires the add-on's shortcut the way a key press does, minus the key press + * itself, and waits for the popup. + * + * This is the honest half of that shortcut, and the half that is this + * add-on's. A synthesised `Ctrl+Shift+C` never reaches the key element at + * all: for a letter key the element matches on keypress, and synthesised + * input produces keydown and keyup and no keypress - `Ctrl+Shift+Q`, which + * Thunderbird binds to nothing, produces all three. What is left on this + * side of that event is the manifest's `commands` entry having become a key + * element with the right modifiers, and that element's command opening this + * add-on's popup over this window, and that is what this drives. Delivering + * the key press is Thunderbird's side of the bargain and stays on the + * release checklist. + */ + async pressActionShortcut() { + const before = (await this.actionPopupUrls()).length; + await this.chrome( + `const [keysetId] = arguments; + const [key] = document.getElementById(keysetId)?.children ?? []; + if (!key) { + throw new Error(keysetId + " holds no key element for this add-on"); + } + key.dispatchEvent(new window.Event("command", { bubbles: true, cancelable: true }));`, + SHORTCUT_KEYSET_ID, + ); + return this.waitForActionPopup(before); + } + + /** + * Finds the first occurrence of some text in the message body and puts the + * selection over it, or the caret after it. + * + * One walk with two endings, because the two callers want the same search: + * a right-click needs a selection to carry, and an insert mid-paragraph + * needs a caret with text on both sides of it. Written as a walk over text + * nodes rather than a `Range` search because there is no such search, and + * because the body a test seeds is one paragraph deep anyway. + */ + async findInBody(text, { collapseAfter = false } = {}) { + const selected = await this.chrome( + `const [needle, collapseAfter] = arguments; + const editor = GetCurrentEditor(); + const bodyDocument = editor.document; + const walker = bodyDocument.createTreeWalker( + editor.rootElement, + bodyDocument.defaultView.NodeFilter.SHOW_TEXT + ); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const at = node.data.indexOf(needle); + if (at === -1) continue; + const range = bodyDocument.createRange(); + range.setStart(node, collapseAfter ? at + needle.length : at); + range.setEnd(node, at + needle.length); + if (collapseAfter) range.collapse(true); + editor.selection.removeAllRanges(); + editor.selection.addRange(range); + return collapseAfter ? "" : editor.selection.toString(); + } + return null;`, + text, + collapseAfter, + ); + const wanted = collapseAfter ? "" : text; + if (selected !== wanted) { + throw new Error( + `wanted ${JSON.stringify(text)} in the message body, found ${JSON.stringify(selected)}`, + ); + } + return this; + } + + /** Selects some text in the body - something for a right-click to carry. */ + async selectInBody(text) { + return this.findInBody(text); + } + + /** Puts the caret straight after some text in the body, selecting nothing. */ + async placeCaretAfter(text) { + return this.findInBody(text, { collapseAfter: true }); + } + + /** + * Right-clicks the current selection in the message body, waits for + * Thunderbird's compose context menu, and answers with this add-on's items + * in it. + * + * A real widget-level event, synthesised into the editor's own window at the + * selection's coordinates. Not a `dispatchEvent`: the menu is built from + * `nsContextMenu.contentData`, which the context-menu actor fills in from a + * trusted event, so an untrusted one opens no menu at all. And on the + * selection rather than at the middle of the editor, because Gecko collapses + * a selection that a right-click misses, and the selection is the whole + * subject here. + */ + async openBodyContextMenu() { + await this.chrome(` + const editor = GetCurrentEditor(); + const view = editor.document.defaultView; + const rect = editor.selection.getRangeAt(0).getBoundingClientRect(); + view.windowUtils.sendMouseEvent( + "contextmenu", + rect.left + rect.width / 2, + rect.top + rect.height / 2, + 2, + 1, + 0 + ); + `); + await waitFor(`the ${COMPOSE_CONTEXT_MENU_ID} menu to open`, () => + this.chrome( + `const [menuId] = arguments; + return document.getElementById(menuId)?.state === "open";`, + COMPOSE_CONTEXT_MENU_ID, + ), + ); + return this.chrome( + `const [menuId, prefix] = arguments; + return Array.from(document.getElementById(menuId).querySelectorAll("menuitem")) + .filter((item) => item.id.startsWith(prefix)) + .map((item) => ({ id: item.id, label: item.getAttribute("label") }));`, + COMPOSE_CONTEXT_MENU_ID, + MENU_ITEM_ID_PREFIX, + ); + } + + /** + * Activates a context menu item by id, and closes the menu as a click would. + * + * `doCommand()` rather than a synthesised click: a menu popup is its own + * widget, and aiming a click at a platform menu is a different problem from + * the one this is about. Hiding the menu afterwards is the other half of + * what the click does - the extension framework's own handler for a + * modified click does exactly this pair - and it matters here because a + * context menu left open is a popup that the action popup would have to open + * behind. + */ + async activateMenuItem(id) { + await this.chrome( + `const [menuId, itemId] = arguments; + const item = document.getElementById(itemId); + if (!item) throw new Error("no " + itemId + " in " + menuId); + item.doCommand(); + document.getElementById(menuId).hidePopup();`, + COMPOSE_CONTEXT_MENU_ID, + id, + ); + await waitFor(`the ${COMPOSE_CONTEXT_MENU_ID} menu to close`, () => + this.chrome( + `const [menuId] = arguments; + return document.getElementById(menuId)?.state !== "open";`, + COMPOSE_CONTEXT_MENU_ID, + ), + ); + return this; + } + + /** + * What the editor thinks was done to it. This is how an editor action is + * told apart from a DOM mutation from the platform's side: only a real + * editor command leaves a transaction on the undo stack, so a block that can + * be taken out again with one undo did not arrive by having its nodes + * appended. + * + * `canUndo` is a property here and not a method, which is worth writing down + * because it was a method for years and still reads like one. + */ + async editorState() { + return this.chrome(` + const editor = GetCurrentEditor(); + return { + canUndo: editor.canUndo, + canRedo: editor.canRedo, + modificationCount: editor.getModificationCount(), + }; + `); + } + + /** Undo, as `Ctrl+Z` would - one step unless asked for more. */ + async undo(steps = 1) { + await this.chrome(`GetCurrentEditor().undo(arguments[0]);`, steps); + return this; + } + /** Dismisses an open popup, which is what a person's Escape key does. */ async closeActionPopup() { await this.sendKeys(Key.ESCAPE); @@ -294,12 +636,18 @@ class ComposeWindow { } class Session { - constructor(driver, { thunderbird, geckodriver, profileDir, addonId, mainWindow }) { + constructor( + driver, + { thunderbird, geckodriver, profileDir, addonId, archive, mainWindow }, + ) { this.driver = driver; this.thunderbird = thunderbird; this.geckodriver = geckodriver; this.profileDir = profileDir; this.addonId = addonId; + // The archive this run installed, so a test can assert what the release + // script produced rather than running it a second time to look. + this.archive = archive; this.mainWindow = mainWindow; this.actionButtonId = ACTION_BUTTON_ID; this.actionToolbarId = ACTION_TOOLBAR_ID; @@ -311,6 +659,39 @@ class Session { return this.driver.executeScript(script, ...args); } + /** + * Every `console` call this process has cached, oldest first, as a level and + * the arguments joined into one line. + * + * This is how the insertion function's report of which path it took becomes + * observable from outside its sandbox, which is the one thing that report + * exists for: the popup throws the return value away as it closes, and the + * function logs the mechanism precisely because of that. The compose editor + * runs in the parent process, so the sandbox injected into it logs here + * rather than in a content process. + * + * `nsIConsoleAPIStorage` rather than an observer, and that is not a + * preference: the `console-api-log-event` topic these events used to be + * notified on was replaced by an explicit listener list, so a registered + * observer is never called and reads as the add-on having logged nothing. + * Reading the cache after the fact needs no registration at all. + * + * The cache is per inner window and is cleared when that window is + * destroyed, so a test reads it before closing the compose window it is + * asking about - which also means one test cannot see another's reports. + */ + async consoleMessages() { + return this.chrome(` + const storage = Cc["@mozilla.org/consoleAPI-storage;1"].getService( + Ci.nsIConsoleAPIStorage + ); + return storage.getEvents().map((event) => ({ + level: event.level, + text: Array.from(event.arguments ?? []).map(String).join(" "), + })); + `); + } + async focusMainWindow() { await this.driver.switchTo().window(this.mainWindow); } @@ -535,6 +916,7 @@ export async function startThunderbird({ log = () => {}, prefs = {} } = {}) { geckodriver, profileDir, addonId, + archive: xpi, mainWindow, }); } catch (error) { diff --git a/tests/thunderbird/insertion.test.js b/tests/thunderbird/insertion.test.js new file mode 100644 index 0000000..d00613b --- /dev/null +++ b/tests/thunderbird/insertion.test.js @@ -0,0 +1,457 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { buildCodeBlockHtml } from "../../src/code-block/build-code-block-html.js"; +import { repoRoot } from "./harness/pins.js"; +import { ACTION_TOOLBAR_ID, startThunderbird } from "./harness/session.js"; + +/** + * What the add-on does, asserted instead of performed. + * + * compose-window.test.js proves the mechanism - a pinned Thunderbird with this + * checkout installed, opening a compose window and carrying the add-on's + * button. This file uses it: a snippet goes into the popup and a block comes + * out in the message body, through the toolbar button, through the shortcut, + * through a right-click, and into a plain-text composer. Every item these + * assertions cover has come off docs/release-checklist.md, so a failure here + * is a claim that used to be checked by hand and now is not checked at all. + * + * The popup is driven from outside, because its document cannot be read from + * anywhere (see `openActionPopup()` in harness/session.js). Two consequences + * shape every test below: + * + * 1. What is asserted is the message body, which is the right thing to assert + * anyway - it is what a recipient gets and the only thing a user asked for. + * 2. A focus failure would look exactly like a passing test, because the + * snippet would be typed into the body instead. So `insertThroughPopup` + * reads the body once before confirming and expects it unchanged, and + * every test goes through it. + */ + +const manifest = JSON.parse( + readFileSync(path.join(repoRoot, "manifest.json"), "utf8"), +); + +/** + * A snippet with all four characters that would turn into markup or entities + * if anything on the way handed the source to an HTML parser. The block's text + * coming back equal to this is the assertion the checklist used to make by eye. + */ +const CODE = 'if (a < b && c > "d") { alert(\'hi\'); }'; + +/** + * The class on the wrapper the block puts itself in, by the name Thunderbird + * knows it by. + * + * Written out rather than read from the seam, which keeps it private, and the + * same way tests/node/code-block.test.js writes it out in the one assertion + * there that is about the wrapper. These tests are about the wrapper: it is + * what keeps the spell checker out of the block, so "a block inside the + * wrapper" is the claim rather than "a block somewhere in the body". + */ +const WRAPPER_CLASS = "moz-forward-container"; + +/** Prose, for the two claims that are about content nobody would highlight. */ +const PROSE = "just a sentence, no code in it at all"; + +/** + * The line the insertion function logs to say which of its three paths ran. + * It is a literal in src/compose/insert-into-body.js rather than an export, + * because the only thing that reads it is a person watching the console - the + * popup closes before it could read the return value. This tier is the second + * reader. + */ +const MECHANISM_REPORT = "ThunderCode: inserted via"; +const EDITOR_COMMAND = `${MECHANISM_REPORT} execCommand`; + +let session; + +beforeAll(async () => { + session = await startThunderbird({ log: console.log }); +}, 600_000); + +afterAll(async () => { + await session?.stop(); +}); + +/** + * The blocks in the message body, read back by the wrapper they sit in rather + * than by matching markup. + * + * Asserting on the literal HTML would fail the day the seam adds an attribute, + * and here it would fail sooner than that: the editor normalises what it takes + * in, so `#24292e` comes back as `rgb(36, 41, 46)` and a string comparison + * against the seam's own output fails while nothing at all is wrong. + */ +const blocksIn = (compose) => + compose.chrome( + `const [wrapperClass] = arguments; + return Array.from( + GetCurrentEditor().rootElement.getElementsByClassName(wrapperClass) + ).map((wrapper) => ({ + children: Array.from(wrapper.children).map((child) => child.localName), + text: wrapper.textContent, + }));`, + WRAPPER_CLASS, + ); + +/** + * The body's top-level nodes, as names and text, with the block marked. + * Enough to say what ended up on either side of an insert without saying + * anything about how the editor chose to divide it up. + * + * `childNodes` rather than `children`, and that is the whole reason this is a + * helper: text typed into an empty composer is a bare text node, so an element + * walk reports the words on either side of the block as absent and the test + * passes for the wrong reason. + */ +const outlineOf = (compose) => + compose.chrome( + `const [wrapperClass] = arguments; + return Array.from(GetCurrentEditor().rootElement.childNodes).map((child) => ({ + name: child.nodeName.toLowerCase(), + isBlock: child.classList?.contains(wrapperClass) ?? false, + text: child.textContent, + }));`, + WRAPPER_CLASS, + ); + +/** Every mechanism report the insertion function has made, oldest first. */ +const mechanismReports = async () => + (await session.consoleMessages()) + .map(({ text }) => text) + .filter((text) => text.startsWith(MECHANISM_REPORT)); + +/** + * The path a person takes: open the popup the way the caller says, type a + * snippet into it, confirm. + * + * The assertion in the middle is not a spare one. Opening the popup does not + * move the keyboard focus, so if `typeIntoActionPopup` ever stopped moving it, + * the snippet would be typed into the message body and `Ctrl+Enter` would + * reach the compose window's Send binding - and in a plain-text composer the + * body would then hold exactly what a successful insert puts there. This is + * what stops that from reading as a pass. + */ +const insertThroughPopup = async ( + compose, + source, + open = (composer) => composer.openActionPopup(), +) => { + const untouched = await compose.bodyText(); + await open(compose); + await compose.typeIntoActionPopup(source); + expect( + await compose.bodyText(), + "the snippet was typed into the message body instead of the popup", + ).toBe(untouched); + await compose.confirmActionPopup(); +}; + +describe("inserting through the toolbar button", () => { + it("lands one block, inside the wrapper, in the message body", async () => { + const compose = await session.openCompose(); + try { + await compose.focusBody(); + await insertThroughPopup(compose, CODE); + + // One wrapper, holding one `pre`, holding the source as it was typed. + // The wrapper is the whole reason the block is not spell-checked, and + // the `pre` inside it is what carries the indentation, so "a block + // inside the wrapper" is two claims and both are here. + expect(await blocksIn(compose)).toEqual([ + { children: ["pre"], text: CODE }, + ]); + + // And the four characters arrived as characters. The block's text above + // already says so - a `` read as markup would not be in the text at + // all - and this says the other half: they are escaped in the markup + // rather than sitting in it raw. + const html = await compose.bodyHtml(); + expect(html).toContain("a < b && c >"); + } finally { + await compose.close(); + } + }); + + it("goes in through the editor command rather than a fallback", async () => { + const compose = await session.openCompose(); + try { + await compose.focusBody(); + await insertThroughPopup(compose, CODE); + + // The insertion function's own report, which is the only place the + // mechanism is stated: the popup discards the return value as it closes. + // `execCommand` is the preferred path and the one no simulated DOM + // implements, which is what this whole tier exists for. + expect((await mechanismReports()).at(-1)).toBe(EDITOR_COMMAND); + + // And the same claim from the editor's side, which does not take the + // add-on's word for it: an editor command leaves a transaction behind + // and counts as a modification, while the fallbacks move nodes about + // without the editor knowing either happened. The modification is what + // makes closing the composer prompt to save, which is the half of that + // checklist item without a dialog in it. + const state = await compose.editorState(); + expect(state.canUndo).toBe(true); + expect(state.modificationCount).toBeGreaterThan(0); + } finally { + await compose.close(); + } + }); + + it("comes out again in one undo", async () => { + const compose = await session.openCompose(); + try { + await compose.focusBody(); + await insertThroughPopup(compose, CODE); + expect(await blocksIn(compose)).toHaveLength(1); + + await compose.undo(); + + expect(await blocksIn(compose)).toEqual([]); + expect(await compose.bodyText()).toBe(""); + } finally { + await compose.close(); + } + }); + + it("leaves the text on either side of the caret alone", async () => { + const compose = await session.openCompose(); + try { + // Prose rather than code, for the second claim this makes: the popup + // renders and inserts whatever is pasted into it, and source with + // nothing to highlight is the case that used to be checked by hand for + // not throwing. A throw would leave the popup open with an error line, + // and `confirmActionPopup` would time out saying so. + await compose.typeIntoBody("one two"); + await compose.placeCaretAfter("one "); + await insertThroughPopup(compose, PROSE); + + const outline = await outlineOf(compose); + const at = outline.findIndex((node) => node.isBlock); + expect(at, "no block in the body").toBeGreaterThan(-1); + expect(outline.filter((node) => node.isBlock)).toHaveLength(1); + expect( + outline + .slice(0, at) + .map((node) => node.text) + .join("") + .trim(), + ).toBe("one"); + expect( + outline + .slice(at + 1) + .map((node) => node.text) + .join("") + .trim(), + ).toBe("two"); + } finally { + await compose.close(); + } + }); + + it("closes the popup, so a second snippet starts from an empty one", async () => { + const compose = await session.openCompose(); + try { + await compose.focusBody(); + await insertThroughPopup(compose, CODE); + + // `insertThroughPopup` already waits for this - a popup that stayed open + // is a failed insert - so what this test adds is the claim stated as + // itself rather than as a precondition of everything else. + expect(await compose.actionPopupUrls()).toEqual([]); + } finally { + await compose.close(); + } + }); +}); + +describe("the keyboard shortcut", () => { + /** + * Thunderbird's translation of a manifest shortcut into a `key` element's + * `modifiers` attribute, which is the form the assertion below has to be in. + * `Ctrl` becomes `accel`, and that is the interesting one: `accel` is + * Control on Linux and Windows and Command on macOS, which is how one + * manifest entry is the right shortcut on all three. + */ + const MODIFIER_ATTRIBUTES = { + Ctrl: "accel", + Command: "accel", + MacCtrl: "control", + Alt: "alt", + Shift: "shift", + }; + + const suggested = manifest.commands._execute_compose_action.suggested_key.default; + const parts = suggested.split("+"); + const shortcut = { + key: parts.at(-1), + keycode: null, + modifiers: parts + .slice(0, -1) + .map((part) => MODIFIER_ATTRIBUTES[part]) + .join(","), + }; + + it("is registered as the shortcut the manifest asks for", async () => { + const compose = await session.openCompose(); + try { + // One key element, and the one the manifest describes. Read from the + // keyset Thunderbird built rather than from the add-on, so a manifest + // entry that Thunderbird silently declined - a taken shortcut, a + // spelling it does not accept - fails here rather than being reported as + // a shortcut that does nothing. + expect(await compose.actionShortcutKeys()).toEqual([shortcut]); + } finally { + await compose.close(); + } + }); + + it("inserts exactly what the button inserts", async () => { + const byButton = await session.openCompose(); + const byShortcut = await session.openCompose(); + try { + for (const [compose, open] of [ + [byButton, (composer) => composer.openActionPopup()], + [byShortcut, (composer) => composer.pressActionShortcut()], + ]) { + await compose.focusBody(); + await insertThroughPopup(compose, CODE, open); + } + + // Identical, and compared against the other path rather than against a + // description of it: a shortcut that inserts something subtly different + // from the button is the failure this is about, and no expected value + // written out here would catch it. + expect(await blocksIn(byShortcut)).toEqual(await blocksIn(byButton)); + expect(await byShortcut.bodyText()).toBe(await byButton.bodyText()); + expect(await byShortcut.editorState()).toMatchObject({ canUndo: true }); + } finally { + await byShortcut.close(); + await byButton.close(); + } + }); +}); + +describe("a right-click carrying a selection", () => { + it("opens the popup with the selected text already in it", async () => { + const compose = await session.openCompose(); + try { + await compose.typeIntoBody("before SELECTED after"); + await compose.selectInBody("SELECTED"); + + // The add-on's item, in Thunderbird's own context menu for the message + // body, found by the prefix the extension framework gives it. Its id is + // the background's and is not exported, so the prefix is what there is; + // one item is what this add-on creates. + const [item, ...rest] = await compose.openBodyContextMenu(); + expect(rest).toEqual([]); + expect(item.label).toBeTruthy(); + + await compose.activateMenuItem(item.id); + await compose.waitForActionPopup(); + + // Nothing is typed here. The popup is confirmed as it was opened, so the + // only way the block below can carry the selected text is that the + // right-click parked it and the popup claimed it - which is the prefill, + // observed through the one thing the prefill is for. + // + // What this does not see is the textarea itself, because nothing can: + // that the text is *visible* in the popup rather than merely held by it + // is the part a person still confirms, and it is the same claim as the + // popup being legible at all. + await compose.focusActionPopup(); + await compose.confirmActionPopup(); + + expect(await blocksIn(compose)).toEqual([ + { children: ["pre"], text: "SELECTED" }, + ]); + + // Replaced, not duplicated: the word appears once, and it appears inside + // the block. + expect((await compose.bodyText()).match(/SELECTED/g)).toHaveLength(1); + const outline = await outlineOf(compose); + expect(outline.filter((node) => node.isBlock)).toHaveLength(1); + expect( + outline + .filter((node) => !node.isBlock) + .map((node) => node.text) + .join("") + .replace(/\s+/g, " ") + .trim(), + ).toBe("before after"); + } finally { + await compose.close(); + } + }); +}); + +describe("a plain-text composer", () => { + /** + * A plain-text composer cannot open this add-on's popup at all, and that is + * a defect in the add-on rather than a limit of this harness. It is filed as + * issue #12. + * + * `compose_action.default_area` is `formattoolbar`, and Thunderbird hides + * the format toolbar in a plain-text composer - there is no formatting to + * offer. The popup is anchored to that button (`triggerAction` in + * `ExtensionToolbarButtons.sys.mjs` calls + * `openPopup(button, "bottomleft topleft")`), so with the button in a hidden + * toolbar the panel opens and rolls straight back up. Every route in goes + * through that same call, so the button, `Ctrl+Shift+C` and the right-click + * item all fail the same way. Confirmed on a real X server as well as + * headless, so it is not a headless artefact. + * + * What is broken is reaching the popup, and what this test is about is what + * happens after that - a different editor receiving text rather than markup. + * So the toolbar is unhidden for the length of the test, which changes + * nothing about the insert: the same button, the same popup, the same + * `scripting.executeScript` into the same composer. When the add-on is fixed + * this call comes out and nothing else here changes. + */ + const revealTheButton = (compose) => + compose.chrome( + `const [toolbarId] = arguments; + document.getElementById(toolbarId).hidden = false;`, + // The toolbar the manifest asks for, not the literal, so that moving the + // button to the compose toolbar - which is one of the ways issue #12 + // could be fixed - makes this a harmless no-op instead of a lie. + ACTION_TOOLBAR_ID, + ); + + const source = "def greet(name):\n return f'hi <{name}>'"; + + it("receives the source as text, with no markup in the body", async () => { + const compose = await session.openCompose({ format: "plaintext" }); + try { + await compose.focusBody(); + await revealTheButton(compose); + await insertThroughPopup(compose, source); + + // What the message would carry, compared against what the seam says a + // plain-text composer gets. Read from the seam rather than written out + // so that a change to how the source is normalised stays one change. + const { text } = buildCodeBlockHtml({ source }); + expect(await compose.bodyPlainText()).toContain(text); + + // No markup, said three ways, because "no markup" is the whole claim: + // no block wrapper, nothing the highlighter would have wrapped a token + // in, and the source's own angle brackets sitting in the document as + // escaped text rather than as an element. + expect(await blocksIn(compose)).toEqual([]); + const html = await compose.bodyHtml(); + expect(html).not.toContain(" Date: Wed, 9 Sep 2026 11:53:13 +0200 Subject: [PATCH 19/22] docs(release): split the checklist into what the tests cover and what needs eyes Thirty-one items done by hand become nineteen. The file now opens with three commands and a list of what running them stands in for, so a failure in the tier is recognisable as a checklist item failing rather than as a test being fussy; what follows is what no driver can claim - the button on a dark appearance, the icon rather than a puzzle piece, where cloud attachment links land, the absence of spell-check underlines, the archive installed into a clean profile, and the key press behind Ctrl+Shift+C. Nothing was deleted that nothing verifies. The two items superseded by unit coverage rather than by the tier, the large-snippet threshold and the live preview, are listed with the tests that own them; the shortcut stays, because what is unverified there is exactly the delivery. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 22 ++++++-- docs/release-checklist.md | 112 +++++++++++++++++++++++++------------- 2 files changed, 89 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 82ba80a..33c7724 100644 --- a/README.md +++ b/README.md @@ -177,16 +177,26 @@ override below exists. | `THUNDERBIRD_HEADLESS=0` | Give the application a display. Run the command under `xvfb-run` and it stays unattended; this is the fallback for the things headless Thunderbird has been known to get wrong. | | `THUNDERBIRD_TIER_DEBUG=1` | geckodriver's trace log, on the terminal. | +What it asserts is what the add-on does: a snippet typed into the popup and a +block coming out in the message body, through the toolbar button, through the +shortcut, through a right-click and into a plain-text composer, with the +insertion function's own report of which path it took read back off the +console. That is `tests/thunderbird/insertion.test.js`, and every assertion in +it used to be a line on the release checklist. + The harness itself is `tests/thunderbird/harness/`, and its interface is -documented in `tests/thunderbird/harness/index.js` - including two limits found -while building it, which are worth reading before writing a test that runs into -them: the add-on's popup cannot be read from outside once it is open, and a -letter-key shortcut cannot be delivered to Thunderbird 128 by synthesised -input. +documented in `tests/thunderbird/harness/index.js` - including four limits +found while building it, which are worth reading before writing a test that +runs into them. The popup's document cannot be read from outside; the popup has +to be handed the keyboard before it hears anything, and a test that forgets can +pass while asserting nothing; a letter-key shortcut cannot be delivered to +Thunderbird 128 by synthesised input; and the popup cannot be opened in a +plain-text composer at all, which is a defect in the add-on rather than a limit +of the harness. What is still checked by hand is anything that is a claim about Thunderbird rather than about this project's own logic; that list is -`docs/release-checklist.md`. +`docs/release-checklist.md`, which now starts by running the two commands above. ## Commit messages diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 2640ee0..4624815 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,8 +1,11 @@ # Release checklist -`pnpm test` covers the manifest, the update manifest, the HTML builder and the -settings. It cannot open a compose window, so everything the add-on actually -*does* is unverified until someone does it. This file is that someone's list. +The suite can open a compose window now. What it cannot do is look at one, so +this file is what is left: claims about Thunderbird, and claims about what +something looks like. Everything that was a claim about this add-on's own logic +has moved into the tests, and the first section lists what that took with it - +not as items to work through, but so that a failure there is recognisable as a +checklist item failing rather than as a test being fussy. Run it before every tag, on **both** supported Thunderbird versions: @@ -14,14 +17,57 @@ Run it before every tag, on **both** supported Thunderbird versions: Record the result in the GitHub release description, or in the pull request if the release is being prepared on a branch. +## What the tests cover + +- [ ] `pnpm test` passes. +- [ ] `pnpm test:thunderbird` passes. It drives the pinned 128 ESR, which it + fetches itself, so this is the floor version of the two runs above. +- [ ] `THUNDERBIRD_BINARY=/path/to/thunderbird pnpm test:thunderbird` passes + against the current release. Same suite, the maintainer's own install; + the README says what the variable does. + +Three commands, and they stand in for the following, each of which was an item +on this list and is now an assertion in `tests/thunderbird/insertion.test.js` +unless another file is named: + +- A block landing at the caret in an empty HTML composer, through the toolbar + button. +- The same insert with the caret mid-paragraph, leaving the text on both sides + of it intact - and source with nothing to highlight not throwing on the way. +- A selection right-clicked, arriving in the popup, and replaced rather than + duplicated. +- The insert going through the editor command rather than a DOM fallback, which + is the path that runs in production, and one `Ctrl+Z` taking it out again. +- `&`, `<`, `>` and `"` in the source reaching the message as those characters. +- The popup closing when the insert lands. +- A plain-text composer receiving the source as text with no markup in it. + The test unhides the format toolbar to get there, because as things stand the + popup cannot be opened in a plain-text composer at all - issue #12. What is + covered is the insert; what is broken is reaching it. +- The shortcut inserting exactly what the button inserts, and the manifest's + `Ctrl+Shift+C` having become the key element Thunderbird derives from it. + **Delivering that key press is not covered** - see the first item under + Insertion. +- `pnpm run package` producing `dist/thundercode-.xpi` with the + manifest's version in its name, in `compose-window.test.js`: the tier + installs that archive, so every run builds it. +- The live preview updating as the source changes, and the large-snippet + warning appearing past the threshold and not below it, in + `tests/dom/popup.test.js` and `tests/node/snippet-size.test.js`. Both are + claims about this add-on's own arithmetic rather than about Thunderbird, + which is what made them safe to stop looking at. +- Correcting the detected language and the preview following it, in + `tests/dom/popup.test.js`. + ## Insertion -- [ ] Insert a block at the caret in an empty HTML compose window. -- [ ] Insert a block with the caret mid-paragraph; surrounding text is intact. -- [ ] Select existing text in the compose window, right-click, insert as a code - block; the selection is replaced, not duplicated. -- [ ] `Ctrl+Shift+C` opens the popup. -- [ ] Undo (`Ctrl+Z`) reverses the insert in one step. +- [ ] `Ctrl+Shift+C` opens the popup, pressed on a real keyboard in an HTML + composer. The tier drives the `key` element Thunderbird built from the + manifest and asserts that opening the popup that way inserts identically + to the button, but it cannot press the key: a letter-key shortcut is + matched on keypress, and synthesised input produces none. So what is left + here is exactly the delivery, which is Thunderbird's half of that + shortcut. - [ ] No red spell-check underlines anywhere in an inserted block, and prose typed above and below it is still checked. The block relies on the `moz-forward-container` wrapper for this, which is Thunderbird's own @@ -30,47 +76,35 @@ the release is being prepared on a branch. - [ ] Attaching a file with Filelink while a block sits above a forwarded message still puts the cloud links in a sensible place. This is the known cost of that wrapper; it is a nuisance, not a failure. -- [ ] The message is marked modified after an insert (closing prompts to save). - -## Highlighting - -- [ ] Paste source in a language with a distinctive shape (Python, SQL); the - detected language is right and the block is coloured. -- [ ] Override the detected language in the popup; the preview follows. -- [ ] Paste plain prose; the plaintext fallback does not throw. -- [ ] Source containing `&`, `<`, `>` and `"` renders as those characters - rather than as entities or markup. - -## Popup - -- [ ] The live preview updates as the source changes. -- [ ] The large-snippet warning appears above the threshold and not below it. -- [ ] Inserting closes the popup. - -## Options - -- [ ] Open the options pane from the Add-ons Manager; it is embedded, not a tab. -- [ ] Change the theme; a newly inserted block uses it. -- [ ] Settings survive a Thunderbird restart. +- [ ] The message is marked modified after an insert, so closing the composer + prompts to save. The tier reads the editor's modification count; that + Thunderbird then puts up the prompt is the part with a dialog in it. ## Appearance - [ ] The toolbar button is visible in the format toolbar on a light theme. - [ ] The toolbar button is visible on a dark theme (not dark ink on dark). - [ ] An inserted block reads correctly in both themes. +- [ ] Paste source in a language with a distinctive shape (Python, SQL), then + paste plain prose: the code is coloured in the composer and the prose is + not. That the right language is detected, and that the colours are in the + markup at all, is `tests/node/code-block.test.js`. That they survive into + a message body and read as code is this. -## Plain-text composers +## Options -- [ ] Open a plain-text compose window; the add-on degrades as intended rather - than inserting broken markup. +- [ ] Open the options pane from the Add-ons Manager; it is embedded, not a tab. +- [ ] Change the theme; a newly inserted block uses it. The theme is read out of + the stylesheet through Thunderbird's own CSS parser, which is the one + thing a simulated document is least faithful about - see the comment at + the top of `src/popup/theme-map.js`. +- [ ] Settings survive a Thunderbird restart. ## Packaging -- [ ] `pnpm run package` succeeds and `dist/thundercode-.xpi` has the - version from `manifest.json` in its name. -- [ ] Install that archive from file into a clean profile and repeat one - insertion - this is the path users take, and it is not the path - `about:debugging` exercises. +- [ ] Install `dist/thundercode-.xpi` from file into a clean profile + and repeat one insertion - this is the path users take, and it is not the + path a temporary install exercises. - [ ] The Add-ons Manager shows the ThunderCode icon, not a puzzle piece. ## Updates From 8e5ad03bac96ffb1c35835a42f0046309bebf236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 12:11:12 +0200 Subject: [PATCH 20/22] test: file the harness's own assertions by where they can be written `tests/thunderbird/pins.test.js` said out loud that its assertions "would pass" in the node tier and kept them in the Thunderbird tier by topic. That is the one thing ADR-0001 rules out: which tier a test belongs in is answered by where it can be written, and nothing else. The cost was not theoretical. `pnpm test` and CI run the node and dom tiers only, so the guard that fails when `manifest.json`'s floor outgrows the pinned build, and the whole of the `THUNDERBIRD_BINARY` resolution, ran in no pipeline at all - raising the floor would have broken the harness with everything green. Six of the seven assertions need no Thunderbird and move to `tests/node/thunderbird-harness.test.js`. The seventh needs the download, so it stays in the tier as `tests/thunderbird/provision.test.js` and says why. The floor guard also grew a real comparison: it claimed "at or above the exact version promised" while comparing majors, so a floor of `128.15` - a build that does not exist - would have passed it. While in there, two things the tier's tests were getting from the wrong place. `harness/index.js` is documented as the harness's interface and was imported by nobody; every test file in the tier now goes through it, and the names no test asked for have come out of it. And `THUNDERBIRD_ENV` is exported from `provision.js`, which owns it, rather than re-declared in the test. The two literals that cannot be read from the module that owns them are the insertion function's mechanism strings, because that module is handed to the compose sandbox and has to stay self-contained. Those stay written out, with the reason next to them rather than left to look like an oversight. Co-Authored-By: Claude Opus 5 (1M context) --- tests/dom/popup.test.js | 18 ++- tests/node/thunderbird-harness.test.js | 142 +++++++++++++++++++++++ tests/thunderbird/compose-window.test.js | 15 ++- tests/thunderbird/harness/index.js | 27 ++--- tests/thunderbird/harness/provision.js | 8 +- tests/thunderbird/insertion.test.js | 20 +++- tests/thunderbird/pins.test.js | 125 -------------------- tests/thunderbird/provision.test.js | 40 +++++++ 8 files changed, 243 insertions(+), 152 deletions(-) create mode 100644 tests/node/thunderbird-harness.test.js delete mode 100644 tests/thunderbird/pins.test.js create mode 100644 tests/thunderbird/provision.test.js diff --git a/tests/dom/popup.test.js b/tests/dom/popup.test.js index 1d0b9b1..00b4256 100644 --- a/tests/dom/popup.test.js +++ b/tests/dom/popup.test.js @@ -23,6 +23,20 @@ const popupPage = readFileSync( "utf8", ); +/** + * What a successful injection answers with, as the insertion function reports + * it. + * + * Written out rather than imported, and this is the one place in these tests + * where that is deliberate: src/compose/insert-into-body.js is handed to the + * compose sandbox as a self-contained function, so it exports nothing and must + * keep exporting nothing. The popup only checks that a result came back, so + * the value here is a stand-in for a real report rather than something asserted + * against - tests/dom/insert-into-body.test.js is where the mechanism names + * are the subject, against the function itself. + */ +const INJECTION_RESULT = [{ result: { mechanism: "execCommand" } }]; + /** * The popup's clock, held still. * @@ -167,7 +181,7 @@ describe("the popup", () => { setComposeDetails: async () => {}, }, scripting: { - executeScript: async () => [{ result: { mechanism: "execCommand" } }], + executeScript: async () => INJECTION_RESULT, }, // Nothing parked, which is what a toolbar or shortcut open gets. runtime: { sendMessage: async () => "" }, @@ -610,7 +624,7 @@ describe("the popup", () => { await confirm(); expect(element("insert").disabled).toBe(true); await confirm(); - injection.resolve([{ result: { mechanism: "execCommand" } }]); + injection.resolve(INJECTION_RESULT); await settle(); expect(fake.calls("scripting.executeScript")).toHaveLength(1); diff --git a/tests/node/thunderbird-harness.test.js b/tests/node/thunderbird-harness.test.js new file mode 100644 index 0000000..a51eaed --- /dev/null +++ b/tests/node/thunderbird-harness.test.js @@ -0,0 +1,142 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { THUNDERBIRD_VERSION, repoRoot } from "../thunderbird/harness/pins.js"; +import { createProfile } from "../thunderbird/harness/profile.js"; +import { + THUNDERBIRD_ENV, + provision, + resolveThunderbirdBinary, +} from "../thunderbird/harness/provision.js"; + +/** + * The Thunderbird tier's harness, in the tier its own assertions can be + * written in. + * + * Which tier a test belongs in is answered by where it can be written, and + * nothing else - docs/adr/0001-three-test-tiers.md. None of this needs a + * document, let alone a running Thunderbird: what the harness pins, how the + * environment override is resolved, and that a profile is a new directory + * every time are all claims about this repo's own code. Filing them next to + * the tier they describe would have been filing them by topic, and the cost + * was concrete - `pnpm test` and CI run this tier and not that one, so the + * guard below that fails when `manifest.json`'s floor outgrows the pin ran in + * no pipeline at all. + * + * The three harness modules are imported directly rather than through + * `harness/index.js`, which is the tier's own interface: the barrel re-exports + * the WebDriver session, and pulling selenium into the tier that must not need + * it would be paying for a Thunderbird this file never starts. + * + * One assertion about the harness does not belong here and stays where it is: + * that the override skips the download needs the download, which is a run with + * the network and ninety megabytes in it. That is + * tests/thunderbird/provision.test.js. + */ + +// The manifest, read rather than imported, following this tier's habit of +// taking the expected value from the file that owns it. +const manifest = JSON.parse( + readFileSync(path.join(repoRoot, "manifest.json"), "utf8"), +); + +/** + * `128.0` and `128.14.0esr` as one comparable number, so that "at or above" + * below means all three components rather than the major alone. A floor of + * `128.15` is above this pin and no such build exists, which is exactly the + * kind of raise the assertion has to catch. + */ +const ordinal = (version) => { + const [major, minor = 0, patch = 0] = version + .replace("esr", "") + .split(".") + .map(Number); + return major * 1_000_000 + minor * 1_000 + patch; +}; + +describe("the pinned Thunderbird", () => { + it("is the floor the manifest promises", () => { + const floor = manifest.browser_specific_settings.gecko.strict_min_version; + const [floorMajor] = floor.split("."); + const [pinMajor] = THUNDERBIRD_VERSION.split("."); + + // The same train, and at or above the exact version promised. Raising the + // manifest's floor without repinning the harness lands here rather than in + // a run that quietly tests a version the add-on no longer supports. The + // major is asserted on its own first because it is the raise that happens, + // and a mismatch there reads better than a mismatch of two large numbers. + expect(pinMajor).toBe(floorMajor); + expect(ordinal(THUNDERBIRD_VERSION)).toBeGreaterThanOrEqual(ordinal(floor)); + }); + + it("is an esr build, because the floor is one", () => { + expect(THUNDERBIRD_VERSION.endsWith("esr")).toBe(true); + }); +}); + +describe("the profile", () => { + it("is a new directory every time, not a cleaned-out one", async () => { + const first = await createProfile(); + const second = await createProfile(); + try { + expect(first).not.toBe(second); + expect(existsSync(first)).toBe(true); + expect(existsSync(second)).toBe(true); + } finally { + rmSync(first, { recursive: true, force: true }); + rmSync(second, { recursive: true, force: true }); + } + }); +}); + +describe(`the ${THUNDERBIRD_ENV} override`, () => { + /** + * The override's plumbing, not its outcome. Whether a given install can be + * driven is a property of that install, and finding out costs a Thunderbird. + * What is asserted here is the part that is this repo's to get right: the + * variable is read, it wins over the pin, and a path that is not there is + * reported as that rather than as a launch failure. + */ + const withOverride = async (value, body) => { + const before = process.env[THUNDERBIRD_ENV]; + if (value === undefined) delete process.env[THUNDERBIRD_ENV]; + else process.env[THUNDERBIRD_ENV] = value; + try { + return await body(); + } finally { + if (before === undefined) delete process.env[THUNDERBIRD_ENV]; + else process.env[THUNDERBIRD_ENV] = before; + } + }; + + it("takes the binary from the environment when it is set", async () => { + await withOverride("/opt/thunderbird/thunderbird", () => { + const resolved = resolveThunderbirdBinary(); + expect(resolved.binary).toBe("/opt/thunderbird/thunderbird"); + expect(resolved.source).toBe(THUNDERBIRD_ENV); + // No version is claimed for an install the harness did not fetch. + expect(resolved.version).toBeNull(); + }); + }); + + it("falls back to the pinned build when it is not", async () => { + await withOverride(undefined, () => { + const resolved = resolveThunderbirdBinary(); + expect(resolved.source).toBe("pinned"); + expect(resolved.version).toBe(THUNDERBIRD_VERSION); + expect(resolved.binary).toContain(THUNDERBIRD_VERSION); + }); + }); + + it("says which variable and which path when the path is wrong", async () => { + // Nothing is fetched on the way to this failure: the override's path is + // checked before either binary is provisioned, which is what lets this + // assertion live in a tier that must not reach the network. + await withOverride("/nonexistent/thunderbird", async () => { + await expect(provision()).rejects.toThrow( + new RegExp(`${THUNDERBIRD_ENV}.*/nonexistent/thunderbird`), + ); + }); + }); +}); diff --git a/tests/thunderbird/compose-window.test.js b/tests/thunderbird/compose-window.test.js index cd7a380..71077e4 100644 --- a/tests/thunderbird/compose-window.test.js +++ b/tests/thunderbird/compose-window.test.js @@ -2,11 +2,16 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { ACTION_BUTTON_ID, ACTION_TOOLBAR_ID } from "./harness/session.js"; -import { PROFILE_PREFS, UPDATE_PREF_NAMES } from "./harness/profile.js"; -import { THUNDERBIRD_VERSION, repoRoot } from "./harness/pins.js"; -import { resolveThunderbirdBinary } from "./harness/provision.js"; -import { startThunderbird } from "./harness/session.js"; +import { + ACTION_BUTTON_ID, + ACTION_TOOLBAR_ID, + PROFILE_PREFS, + THUNDERBIRD_VERSION, + UPDATE_PREF_NAMES, + repoRoot, + resolveThunderbirdBinary, + startThunderbird, +} from "./harness/index.js"; /** * The third tier, and the only one that can see the add-on as a user does. diff --git a/tests/thunderbird/harness/index.js b/tests/thunderbird/harness/index.js index 43c00b7..0806366 100644 --- a/tests/thunderbird/harness/index.js +++ b/tests/thunderbird/harness/index.js @@ -6,7 +6,7 @@ * * const session = await startThunderbird(); // ~15s, or ~2min the * try { // first time (downloads) - * const compose = await session.openCompose(); // { format: "plaintext" } + * const compose = await session.openCompose(); // or { format: "plaintext" } * await compose.focusBody(); // caret in the body * await compose.sendKeys("text"); // real key events * await compose.pressChord(Key.CONTROL, "b"); // never Key.chord @@ -39,8 +39,8 @@ * ``` * * `session` also has `chrome()` on the main window, `appInfo()`, `addonInfo()`, - * `updateGuards()`, `composeWindows()`, `openCompose()` and `archive` - the - * path of the `.xpi` this run built and installed. Everything runs in + * `updateGuards()`, `openCompose()`, `profileDir` and `archive` - the path of + * the `.xpi` this run built and installed. Everything runs in * Marionette's chrome context, so a script sees `Services`, `ChromeUtils`, `Cc` * and `Ci`, and `window` is the window it was called on. * @@ -61,15 +61,16 @@ * Thunderbird hides the toolbar this add-on's button sits in and the popup * is anchored to that button. That is a defect in the add-on rather than a * limit of the harness - issue #12, with the details in insertion.test.js. + * + * Every test file in this tier imports from here and not from the files + * behind it, so this list is what the tier actually uses: a name that stops + * appearing in a test comes out of here rather than staying as documentation + * of something nobody asks for. The one test that reaches past it is + * tests/node/thunderbird-harness.test.js, which covers the pins from the node + * tier and says there why it cannot come through a barrel that loads + * selenium. */ -export { - ACTION_BUTTON_ID, - ACTION_TOOLBAR_ID, - ADDON_ID, - MENU_ITEM_ID_PREFIX, - SHORTCUT_KEYSET_ID, - startThunderbird, -} from "./session.js"; +export { ACTION_BUTTON_ID, ACTION_TOOLBAR_ID, startThunderbird } from "./session.js"; export { PROFILE_PREFS, UPDATE_PREF_NAMES } from "./profile.js"; -export { resolveThunderbirdBinary } from "./provision.js"; -export { THUNDERBIRD_VERSION } from "./pins.js"; +export { THUNDERBIRD_ENV, provision, resolveThunderbirdBinary } from "./provision.js"; +export { THUNDERBIRD_VERSION, repoRoot } from "./pins.js"; diff --git a/tests/thunderbird/harness/provision.js b/tests/thunderbird/harness/provision.js index 49ea695..58463a1 100644 --- a/tests/thunderbird/harness/provision.js +++ b/tests/thunderbird/harness/provision.js @@ -30,7 +30,13 @@ const run = promisify(execFile); * shape of this repo is that a contributor installs one lockfile and starts. */ -const THUNDERBIRD_ENV = "THUNDERBIRD_BINARY"; +/** + * The variable that points the harness at an installed Thunderbird instead of + * the pin. Exported because the tests that cover the override assert which + * variable was read, and reading that from here is what keeps renaming it a + * one-line change rather than a red suite. + */ +export const THUNDERBIRD_ENV = "THUNDERBIRD_BINARY"; async function exists(target) { try { diff --git a/tests/thunderbird/insertion.test.js b/tests/thunderbird/insertion.test.js index d00613b..2acc34f 100644 --- a/tests/thunderbird/insertion.test.js +++ b/tests/thunderbird/insertion.test.js @@ -3,8 +3,11 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildCodeBlockHtml } from "../../src/code-block/build-code-block-html.js"; -import { repoRoot } from "./harness/pins.js"; -import { ACTION_TOOLBAR_ID, startThunderbird } from "./harness/session.js"; +import { + ACTION_TOOLBAR_ID, + repoRoot, + startThunderbird, +} from "./harness/index.js"; /** * What the add-on does, asserted instead of performed. @@ -57,10 +60,15 @@ const PROSE = "just a sentence, no code in it at all"; /** * The line the insertion function logs to say which of its three paths ran. - * It is a literal in src/compose/insert-into-body.js rather than an export, - * because the only thing that reads it is a person watching the console - the - * popup closes before it could read the return value. This tier is the second - * reader. + * + * Restated here rather than imported, against this suite's habit of reading + * expectations from the module that owns them, and the exception is the + * module: src/compose/insert-into-body.js is handed to the compose sandbox as + * a self-contained function, so it exports nothing and adding an export would + * cost it the property that makes it injectable. The other reason the line is + * a literal there is that its only reader was a person watching the console - + * the popup closes before it could read the return value. This tier is the + * second reader. */ const MECHANISM_REPORT = "ThunderCode: inserted via"; const EDITOR_COMMAND = `${MECHANISM_REPORT} execCommand`; diff --git a/tests/thunderbird/pins.test.js b/tests/thunderbird/pins.test.js deleted file mode 100644 index 11bb5f9..0000000 --- a/tests/thunderbird/pins.test.js +++ /dev/null @@ -1,125 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; - -import { THUNDERBIRD_VERSION, repoRoot } from "./harness/pins.js"; -import { createProfile } from "./harness/profile.js"; -import { provision, resolveThunderbirdBinary } from "./harness/provision.js"; - -/** - * The half of this tier that needs no Thunderbird running: what it pins, and - * how the environment override is read. - * - * These are in the Thunderbird tier rather than in the node tier even though - * they would pass there, because they are claims about the harness rather than - * about the add-on, and splitting them would mean someone changing the pin - * gets a failure from a directory they were not working in. - */ - -const THUNDERBIRD_ENV = "THUNDERBIRD_BINARY"; - -// The manifest, read rather than imported, following the node tier's habit of -// taking the expected value from the file that owns it. -const manifest = JSON.parse( - readFileSync(path.join(repoRoot, "manifest.json"), "utf8"), -); - -describe("the pinned Thunderbird", () => { - it("is the floor the manifest promises", () => { - const floor = manifest.browser_specific_settings.gecko.strict_min_version; - const [floorMajor] = floor.split("."); - const [pinMajor] = THUNDERBIRD_VERSION.split("."); - - // The same train, and at or above the exact version promised. Raising the - // manifest's floor without repinning the harness lands here rather than in - // a run that quietly tests a version the add-on no longer supports. - expect(pinMajor).toBe(floorMajor); - expect(Number.parseInt(THUNDERBIRD_VERSION, 10)).toBeGreaterThanOrEqual( - Number.parseInt(floor, 10), - ); - }); - - it("is an esr build, because the floor is one", () => { - expect(THUNDERBIRD_VERSION.endsWith("esr")).toBe(true); - }); -}); - -describe("the profile", () => { - it("is a new directory every time, not a cleaned-out one", async () => { - const first = await createProfile(); - const second = await createProfile(); - try { - expect(first).not.toBe(second); - expect(existsSync(first)).toBe(true); - expect(existsSync(second)).toBe(true); - } finally { - const { rm } = await import("node:fs/promises"); - await rm(first, { recursive: true, force: true }); - await rm(second, { recursive: true, force: true }); - } - }); -}); - -describe(`the ${THUNDERBIRD_ENV} override`, () => { - /** - * The override's plumbing, not its outcome. Whether a given install can be - * driven is a property of that install - this machine's is 115, below the - * manifest floor, so it cannot load a Manifest V3 MailExtension at all and - * pointing the harness at it fails for a real reason. What is asserted here - * is the part that is this repo's to get right: the variable is read, it - * wins over the pin, and the download is not attempted when it is set. - */ - const withOverride = async (value, body) => { - const before = process.env[THUNDERBIRD_ENV]; - if (value === undefined) delete process.env[THUNDERBIRD_ENV]; - else process.env[THUNDERBIRD_ENV] = value; - try { - return await body(); - } finally { - if (before === undefined) delete process.env[THUNDERBIRD_ENV]; - else process.env[THUNDERBIRD_ENV] = before; - } - }; - - it("takes the binary from the environment when it is set", async () => { - await withOverride("/opt/thunderbird/thunderbird", () => { - const resolved = resolveThunderbirdBinary(); - expect(resolved.binary).toBe("/opt/thunderbird/thunderbird"); - expect(resolved.source).toBe(THUNDERBIRD_ENV); - // No version is claimed for an install the harness did not fetch. - expect(resolved.version).toBeNull(); - }); - }); - - it("falls back to the pinned build when it is not", async () => { - await withOverride(undefined, () => { - const resolved = resolveThunderbirdBinary(); - expect(resolved.source).toBe("pinned"); - expect(resolved.version).toBe(THUNDERBIRD_VERSION); - expect(resolved.binary).toContain(THUNDERBIRD_VERSION); - }); - }); - - it("skips the download and provisions only the driver", async () => { - // `process.execPath` stands in for an installed Thunderbird: the point is - // that provisioning returns the path it was given rather than fetching 84 - // MiB to ignore it, and any existing file proves that. - await withOverride(process.execPath, async () => { - const { thunderbird, geckodriver } = await provision(); - expect(thunderbird.binary).toBe(process.execPath); - expect(thunderbird.source).toBe(THUNDERBIRD_ENV); - // The driver is still fetched: the only geckodriver on a typical Linux - // box is the Firefox snap's, which cannot launch anything outside its - // sandbox. - expect(existsSync(geckodriver)).toBe(true); - }); - }); - - it("says which variable and which path when the path is wrong", async () => { - await withOverride("/nonexistent/thunderbird", async () => { - await expect(provision()).rejects.toThrow( - /THUNDERBIRD_BINARY.*\/nonexistent\/thunderbird/, - ); - }); - }); -}); diff --git a/tests/thunderbird/provision.test.js b/tests/thunderbird/provision.test.js new file mode 100644 index 0000000..de351e4 --- /dev/null +++ b/tests/thunderbird/provision.test.js @@ -0,0 +1,40 @@ +import { existsSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { THUNDERBIRD_ENV, provision } from "./harness/index.js"; + +/** + * The one claim about the harness that cannot be made without the download, + * which is what keeps it in this tier: everything else the harness pins and + * resolves is asserted in tests/node/thunderbird-harness.test.js, where + * `pnpm test` and CI can see it. + * + * See docs/adr/0001-three-test-tiers.md for the rule that split them. + */ +describe(`the ${THUNDERBIRD_ENV} override`, () => { + const withOverride = async (value, body) => { + const before = process.env[THUNDERBIRD_ENV]; + process.env[THUNDERBIRD_ENV] = value; + try { + return await body(); + } finally { + if (before === undefined) delete process.env[THUNDERBIRD_ENV]; + else process.env[THUNDERBIRD_ENV] = before; + } + }; + + it("skips the download and provisions only the driver", async () => { + // `process.execPath` stands in for an installed Thunderbird: the point is + // that provisioning returns the path it was given rather than fetching 84 + // MiB to ignore it, and any existing file proves that. + await withOverride(process.execPath, async () => { + const { thunderbird, geckodriver } = await provision(); + expect(thunderbird.binary).toBe(process.execPath); + expect(thunderbird.source).toBe(THUNDERBIRD_ENV); + // The driver is still fetched: the only geckodriver on a typical Linux + // box is the Firefox snap's, which cannot launch anything outside its + // sandbox. + expect(existsSync(geckodriver)).toBe(true); + }); + }); +}); From c95a7207cb17c9c2ca996bfcd7566b1d5898a1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 12:11:30 +0200 Subject: [PATCH 21/22] refactor: cut the harness down to what its tests use Everything removed here was read by nothing. `ComposeWindow.format` was written and never looked at, and the only caller that had to invent a value for it was `composeWindows()`, which no test ever called. `Session` copied the button and toolbar ids onto itself alongside the resolved binary and the driver path, and all four were write-only: the tests that want the ids import them. `closeActionPopup()` had no callers either - a test that wants the popup gone closes the window, which is what every one of them does. The two provisioning functions were the same sequence twice, differing in the compression flag: fetch once, verify against a published digest, extract into a directory that is only moved into place when the extraction finished. That sequence now lives in one place, which also gives geckodriver the extract-and-rename the Thunderbird half already had. The digest arrives as a function rather than a value because resolving Thunderbird's costs a request, and a warm cache must ask nothing of the network. The geckodriver archive's name moves to `pins.js`, which claims to hold everything this tier pins: built inline where it was downloaded, the platform and the compression suffix were two claims about the outside world sitting where nobody would look for them. `provision.js` also called its logging function `describe`, which is what `session.js` calls the thing it is waiting for. One name for two ideas across two files of the same harness; the logger is now `report`. Co-Authored-By: Claude Opus 5 (1M context) --- tests/thunderbird/harness/pins.js | 11 ++- tests/thunderbird/harness/provision.js | 112 ++++++++++++++++--------- tests/thunderbird/harness/session.js | 44 ++-------- 3 files changed, 89 insertions(+), 78 deletions(-) diff --git a/tests/thunderbird/harness/pins.js b/tests/thunderbird/harness/pins.js index 0055c56..f851fb5 100644 --- a/tests/thunderbird/harness/pins.js +++ b/tests/thunderbird/harness/pins.js @@ -67,7 +67,16 @@ export const THUNDERBIRD_SHA256SUMS_ENTRY = `linux-x86_64/en-US/${THUNDERBIRD_AR * published tag, which is the case worth failing on. */ export const GECKODRIVER_VERSION = "0.36.0"; -export const GECKODRIVER_URL = `https://github.com/mozilla/geckodriver/releases/download/v${GECKODRIVER_VERSION}/geckodriver-v${GECKODRIVER_VERSION}-linux64.tar.gz`; + +/** + * `.tar.gz` and `linux64`, both of them the release asset's own spelling + * rather than a name this file chose. It lives here beside the Thunderbird + * archive because this file is the one that holds what the tier pins: built + * inline where it is downloaded, the platform and the compression would be two + * claims about the outside world sitting somewhere nobody looks for them. + */ +export const GECKODRIVER_ARCHIVE = `geckodriver-v${GECKODRIVER_VERSION}-linux64.tar.gz`; +export const GECKODRIVER_URL = `https://github.com/mozilla/geckodriver/releases/download/v${GECKODRIVER_VERSION}/${GECKODRIVER_ARCHIVE}`; export const GECKODRIVER_SHA256 = "0bde38707eb0a686a20c6bd50f4adcc7d60d4f73c60eb83ee9e0db8f65823e04"; diff --git a/tests/thunderbird/harness/provision.js b/tests/thunderbird/harness/provision.js index 58463a1..0e3431b 100644 --- a/tests/thunderbird/harness/provision.js +++ b/tests/thunderbird/harness/provision.js @@ -5,9 +5,9 @@ import path from "node:path"; import { promisify } from "node:util"; import { + GECKODRIVER_ARCHIVE, GECKODRIVER_SHA256, GECKODRIVER_URL, - GECKODRIVER_VERSION, THUNDERBIRD_ARCHIVE, THUNDERBIRD_SHA256SUMS_ENTRY, THUNDERBIRD_SHA256SUMS_URL, @@ -88,9 +88,9 @@ async function publishedThunderbirdDigest() { ); } -async function fetchVerified(url, target, expected, describe) { +async function fetchVerified(url, target, expected, report) { if (!(await exists(target))) { - describe(`downloading ${url}`); + report(`downloading ${url}`); await download(url, target); } const actual = await digest(target); @@ -131,41 +131,75 @@ async function writePolicies(appDir) { return policies; } -async function provisionThunderbird(describe) { - const binary = path.join(buildDir, "thunderbird", "thunderbird"); - if (!(await exists(binary))) { - const archive = await fetchVerified( - THUNDERBIRD_URL, - path.join(downloadDir, THUNDERBIRD_ARCHIVE), - await publishedThunderbirdDigest(), - describe, - ); - describe(`extracting ${THUNDERBIRD_ARCHIVE}`); - // Extracted next to the final directory and moved, for the same reason the - // download is: a half-extracted tree must never look like a cached one. - const partial = `${buildDir}.partial`; - await fs.rm(partial, { recursive: true, force: true }); - await fs.mkdir(partial, { recursive: true }); - await run("tar", ["-xjf", archive, "-C", partial]); - await fs.rename(partial, buildDir); - } - await writePolicies(path.join(buildDir, "thunderbird")); - return binary; +/** + * Both binaries arrive the same way: an archive fetched once, verified against + * a published digest, and extracted into a directory that is only moved into + * place when the extraction finished. The two differ in the compression flag + * and in nothing else, so this is where that sequence lives and each caller + * below is left holding only what is true of its own binary. + * + * `expectedDigest` is a function rather than a value because resolving + * Thunderbird's costs a request: on a warm cache `probe` is already there and + * nothing should be asked of the network at all. + */ +async function fetchArchiveInto({ + probe, + dir, + url, + archive, + expectedDigest, + tarFlag, + report, +}) { + if (await exists(probe)) return probe; + + const downloaded = await fetchVerified( + url, + path.join(downloadDir, archive), + await expectedDigest(), + report, + ); + report(`extracting ${archive}`); + // Extracted next to the final directory and moved, for the same reason the + // download is: a half-extracted tree must never look like a cached one. + const partial = `${dir}.partial`; + await fs.rm(partial, { recursive: true, force: true }); + await fs.mkdir(partial, { recursive: true }); + await run("tar", [tarFlag, downloaded, "-C", partial]); + await fs.rename(partial, dir); + return probe; } -async function provisionGeckodriver(describe) { - const driver = path.join(geckodriverDir, "geckodriver"); - if (await exists(driver)) return driver; +async function provisionThunderbird(report) { + const appDir = path.join(buildDir, "thunderbird"); + const binary = await fetchArchiveInto({ + probe: path.join(appDir, "thunderbird"), + dir: buildDir, + url: THUNDERBIRD_URL, + archive: THUNDERBIRD_ARCHIVE, + expectedDigest: publishedThunderbirdDigest, + tarFlag: "-xjf", + report, + }); + // Written on every run rather than only after an extraction: the build + // survives between runs and the policy is the only thing stopping it + // updating itself, so a cache that lost it has to get it back. + await writePolicies(appDir); + return binary; +} - const archive = await fetchVerified( - GECKODRIVER_URL, - path.join(downloadDir, `geckodriver-v${GECKODRIVER_VERSION}-linux64.tar.gz`), - GECKODRIVER_SHA256, - describe, - ); - describe("extracting geckodriver"); - await fs.mkdir(geckodriverDir, { recursive: true }); - await run("tar", ["-xzf", archive, "-C", geckodriverDir]); +async function provisionGeckodriver(report) { + const driver = await fetchArchiveInto({ + probe: path.join(geckodriverDir, "geckodriver"), + dir: geckodriverDir, + url: GECKODRIVER_URL, + archive: GECKODRIVER_ARCHIVE, + expectedDigest: async () => GECKODRIVER_SHA256, + tarFlag: "-xzf", + report, + }); + // Same reason as the policy file above: idempotent, and the repair path for + // a cache that was restored without its permission bits. await fs.chmod(driver, 0o755); return driver; } @@ -196,7 +230,7 @@ export function resolveThunderbirdBinary() { * and cannot launch a binary outside its sandbox. */ export async function provision({ log = () => {} } = {}) { - const describe = (message) => log(`[thunderbird tier] ${message}`); + const report = (message) => log(`[thunderbird tier] ${message}`); const resolved = resolveThunderbirdBinary(); if (resolved.source === THUNDERBIRD_ENV) { @@ -205,11 +239,11 @@ export async function provision({ log = () => {} } = {}) { `${THUNDERBIRD_ENV} is set to ${resolved.binary}, which does not exist`, ); } - describe(`using ${THUNDERBIRD_ENV}=${resolved.binary}, download skipped`); + report(`using ${THUNDERBIRD_ENV}=${resolved.binary}, download skipped`); } else { - await provisionThunderbird(describe); + await provisionThunderbird(report); } - const geckodriver = await provisionGeckodriver(describe); + const geckodriver = await provisionGeckodriver(report); return { thunderbird: resolved, geckodriver }; } diff --git a/tests/thunderbird/harness/session.js b/tests/thunderbird/harness/session.js index b8462d5..c9d2678 100644 --- a/tests/thunderbird/harness/session.js +++ b/tests/thunderbird/harness/session.js @@ -36,7 +36,7 @@ const manifest = require(path.join(repoRoot, "manifest.json")); * test looking for a button that no longer exists while claiming the button is * missing. */ -export const ADDON_ID = manifest.browser_specific_settings.gecko.id; +const ADDON_ID = manifest.browser_specific_settings.gecko.id; const widgetId = ADDON_ID.toLowerCase().replace(/[^a-z0-9_-]/g, "_"); export const ACTION_BUTTON_ID = `${widgetId}-composeAction-toolbarbutton`; @@ -61,8 +61,8 @@ export const ACTION_TOOLBAR_ID = * chose - so the prefix finds this add-on's items in a menu without this file * knowing that id, which lives in the background and is not exported. */ -export const SHORTCUT_KEYSET_ID = `ext-keyset-id-${widgetId}`; -export const MENU_ITEM_ID_PREFIX = `${widgetId}-menuitem-`; +const SHORTCUT_KEYSET_ID = `ext-keyset-id-${widgetId}`; +const MENU_ITEM_ID_PREFIX = `${widgetId}-menuitem-`; /** * Thunderbird's own context menu for the message body, by its id in @@ -126,10 +126,9 @@ async function buildArchive() { * window rather than methods on the harness. */ class ComposeWindow { - constructor(session, handle, format) { + constructor(session, handle) { this.session = session; this.handle = handle; - this.format = format; } get driver() { @@ -601,16 +600,6 @@ class ComposeWindow { return this; } - /** Dismisses an open popup, which is what a person's Escape key does. */ - async closeActionPopup() { - await this.sendKeys(Key.ESCAPE); - await waitFor( - "the action popup to close", - async () => (await this.actionPopupUrls()).length === 0, - ); - return this; - } - /** True while the window is still open. */ async isOpen() { const handles = await this.driver.getAllWindowHandles(); @@ -636,21 +625,14 @@ class ComposeWindow { } class Session { - constructor( - driver, - { thunderbird, geckodriver, profileDir, addonId, archive, mainWindow }, - ) { + constructor(driver, { profileDir, addonId, archive, mainWindow }) { this.driver = driver; - this.thunderbird = thunderbird; - this.geckodriver = geckodriver; this.profileDir = profileDir; this.addonId = addonId; // The archive this run installed, so a test can assert what the release // script produced rather than running it a second time to look. this.archive = archive; this.mainWindow = mainWindow; - this.actionButtonId = ACTION_BUTTON_ID; - this.actionToolbarId = ACTION_TOOLBAR_ID; } /** Privileged code in the main mail window. */ @@ -743,7 +725,7 @@ class Session { return null; }); - const composeWindow = new ComposeWindow(this, handle, format); + const composeWindow = new ComposeWindow(this, handle); // The editor is built asynchronously after the window loads, and every // useful thing a test does with a composer goes through it, so waiting for // it here is the difference between one wait and one in every test. @@ -755,18 +737,6 @@ class Session { return composeWindow; } - /** Every open compose window, oldest first. */ - async composeWindows() { - const handles = await this.driver.getAllWindowHandles(); - const found = []; - for (const handle of handles) { - await this.driver.switchTo().window(handle); - const url = await this.driver.executeScript("return window.location.href;"); - if (url === COMPOSE_WINDOW_URL) found.push(new ComposeWindow(this, handle, null)); - } - return found; - } - /** * What Thunderbird thinks it is running: the version, and the name, since * "firefox" appears in enough of this harness to be worth disproving once. @@ -912,8 +882,6 @@ export async function startThunderbird({ log = () => {}, prefs = {} } = {}) { const addonId = await driver.installAddon(xpi, true); return new Session(driver, { - thunderbird, - geckodriver, profileDir, addonId, archive: xpi, From 0d495015ec8ca21982d6d48a44966bd4a3de983f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schleusner?= Date: Wed, 9 Sep 2026 12:12:56 +0200 Subject: [PATCH 22/22] docs: make the counts and the cross-references true again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four claims that had drifted, and one section that was filed under the wrong heading. The release checklist's Updates section asserted the rule the split was supposed to satisfy - every item left is a claim about Thunderbird - while holding three claims about GitHub and this repo's own release workflow. It is now "After publishing", which says what those items are and why they survive: not because a test could not make them, but because there is nothing to make them against until the workflow has published something. The items about a real Thunderbird checking for and installing an update move out into a section of their own, where they are what the rest of the file is. One item is retired rather than reframed. The "Update manifest did not contain an entry for …" line in the Error Console is the sole symptom of a mismatch between the update manifest's key and the id this add-on declares, and `tests/node/updates.test.js` pins exactly that; the item above it already fails if a check does not upgrade. What is left of the `updates.json` item is the part nothing verifies - whether the published release actually carries the asset - because the file's contents are pinned and CI checks that it builds, but neither can see a release. The README said two checks are skipped when the lint script also turns off the CDN library lookup, said the checklist starts with two commands when it lists three, and left `test:watch` out of the command list. And `.gitignore` pointed at "issue 12" for the packaged artifacts, which predates all of this work and now names an unrelated bug. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 ++- README.md | 18 +++++++++++----- docs/release-checklist.md | 43 ++++++++++++++++++++++++++++----------- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index ad9d60b..f95bc5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ -# Packaged extension artifacts (see issue 12) +# Packaged extension artifacts. Built by scripts/package.sh, which prints the +# path it wrote; nothing here is committed. *.xpi dist/ diff --git a/README.md b/README.md index 33c7724..5a143be 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,15 @@ Three things about the output that will look wrong the first time: `schema release-mv3`. The `128.0` floor is checked separately and better, by the `strict-min-version-api` check: a call newer than the declared minimum is an error. Do not add a `strict_max_version` to move the channel. -- **Two checks are skipped, and only two.** `update-url`, because serving its - own updates is why this add-on is unlisted, and `unused-files`, because an - upstream path-parsing bug makes it report the vendored highlight.js licence - as dead weight. The reasons are written out in `scripts/lint.sh`. +- **Two checks are skipped and one lookup is off, and that is all.** + `update-url`, because serving its own updates is why this add-on is + unlisted, and `unused-files`, because an upstream path-parsing bug makes it + report the vendored highlight.js licence as dead weight. The lookup is + `--cdn-lib-lookup`, which identifies a bundled library by asking third-party + CDNs for its content hash: the only bundled library here is a hand-modified + highlight.js, so no hash can match it by construction and leaving it on only + makes the run depend on four hosts being up. All three reasons are written + out in `scripts/lint.sh`. One info finding is standing rather than new: both `src/compose/insert-into-body.js` and the vendored highlight.js insert markup through `.innerHTML`, which @@ -115,6 +120,7 @@ does not reach one that is already open. ```sh pnpm test # both automated tiers pnpm test:node # the pure tier alone, for a fast edit loop +pnpm test:watch # both automated tiers, rerunning as files change pnpm test:thunderbird # the real-Thunderbird tier; see below pnpm coverage # a report; nothing is gated on it ``` @@ -196,7 +202,9 @@ of the harness. What is still checked by hand is anything that is a claim about Thunderbird rather than about this project's own logic; that list is -`docs/release-checklist.md`, which now starts by running the two commands above. +`docs/release-checklist.md`, which now opens with three commands - `pnpm test`, +this one, and this one again with `THUNDERBIRD_BINARY` pointed at an installed +Thunderbird - and only then reaches the items a person has to look at. ## Commit messages diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 4624815..cf565fa 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,11 +1,13 @@ # Release checklist The suite can open a compose window now. What it cannot do is look at one, so -this file is what is left: claims about Thunderbird, and claims about what -something looks like. Everything that was a claim about this add-on's own logic -has moved into the tests, and the first section lists what that took with it - -not as items to work through, but so that a failure there is recognisable as a -checklist item failing rather than as a test being fussy. +this file is what is left: claims about Thunderbird, claims about what +something looks like, and one section of claims about the release itself that +only exist after it has been published. Everything that was a claim about this +add-on's own logic has moved into the tests, and the first section lists what +that took with it - not as items to work through, but so that a failure there +is recognisable as a checklist item failing rather than as a test being +fussy. Run it before every tag, on **both** supported Thunderbird versions: @@ -58,6 +60,11 @@ unless another file is named: which is what made them safe to stop looking at. - Correcting the detected language and the preview following it, in `tests/dom/popup.test.js`. +- The update manifest being keyed by the id this add-on's manifest declares, + in `tests/node/updates.test.js`. That was the "Update manifest did not + contain an entry for …" line to look for in the Error Console after an + update check, which is the only symptom a mismatch has - and it is a claim + about a file this repo generates rather than about Thunderbird reading it. ## Insertion @@ -107,7 +114,14 @@ unless another file is named: path a temporary install exercises. - [ ] The Add-ons Manager shows the ThunderCode icon, not a puzzle piece. -## Updates +## After publishing + +The one section here that is not about Thunderbird, said out loud rather than +filed as though it were. These are claims about GitHub and about this repo's +own release workflow, and the reason they survive the split is not that a test +could not make them - it is that there is nothing to make them against until +the workflow has run and published something. A person looking at the release +that just went out is the only thing that can see them. - [ ] The published release is **not** a draft and **not** a prerelease. The workflow sets both false, so this is a check that nobody edited the @@ -117,15 +131,20 @@ unless another file is named: - [ ] `main` now holds the *next* version, pushed by the workflow's bump commit. If it still holds the released one, the bump step failed and the next release will refuse to start. -- [ ] `updates.json` is attached to the release alongside the `.xpi`, and - - returns it. +- [ ] + returns the new version. What that URL *says* is pinned by + `tests/node/updates.test.js`, and that the file builds at all is checked + on every push by the test workflow; what neither can see is whether the + release carries it. That URL is baked into every installed copy, so a + release published without the asset leaves all of them polling a 404 and + never hearing about the update. + +## Updating an installed copy -The rest is only meaningful once a previous release exists. +Thunderbird's half of an update - the daily check, the download and the +install - against a real one. Only meaningful once a previous release exists. - [ ] Set `extensions.logging.enabled` to `true` in the config editor first; update failures are otherwise completely silent. - [ ] With the previous version installed, force a check from the Add-ons Manager gear menu and confirm it upgrades to the new one. -- [ ] The Error Console shows no "Update manifest did not contain an entry for - …" line.