From 1dd650044b33d38335b87fe548a305ee84531411 Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Mon, 14 Sep 2026 15:56:45 -0400 Subject: [PATCH 1/2] bin/pnpm add --save-dev --save-exact @testing-library/react@16.3.3 @testing-library/dom@10 @testing-library/user-event@14.6.7 @testing-library/jest-dom@7.0.1 jsdom@30.0.1 Co-authored-by: Amp Signed-off-by: Charlie Croom Amp-Thread-ID: https://ampcode.com/threads/T-01a0a149-b278-7550-acc4-356168b0c4b5 --- package.json | 5 + pnpm-lock.yaml | 538 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 541 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index fd0d047e..91eff3fb 100644 --- a/package.json +++ b/package.json @@ -66,10 +66,15 @@ "@playwright/test": "1.60.0", "@tailwindcss/postcss": "4.3.3", "@tauri-apps/cli": "2.11.4", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.7", "@types/node": "24.13.3", "@types/react": "19.2.18", "@types/react-dom": "19.2.7", "@vitejs/plugin-react": "6.1.1", + "jsdom": "30.0.1", "postcss": "8.5.28", "tailwindcss": "4.3.3", "typescript": "7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a857eee7..db0096c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,18 @@ importers: '@tauri-apps/cli': specifier: 2.11.4 version: 2.11.4 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: 7.0.1 + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.0.1))(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3))) + '@testing-library/react': + specifier: 16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: 14.6.7 + version: 14.6.7(@testing-library/dom@10.4.1) '@types/node': specifier: 24.13.3 version: 24.13.3 @@ -108,6 +120,9 @@ importers: '@vitejs/plugin-react': specifier: 6.1.1 version: 6.1.1(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)) + jsdom: + specifier: 30.0.1 + version: 30.0.1(@noble/hashes@2.0.1) postcss: specifier: 8.5.28 version: 8.5.28 @@ -125,14 +140,33 @@ importers: version: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)) + version: 4.1.11(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.0.1))(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.3.0': resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} engines: {node: '>=10'} + '@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/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + 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/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -220,6 +254,46 @@ packages: cpu: [x64] os: [win32] + '@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.13': + resolution: {integrity: sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==} + 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'} + '@deepseek-ai/cordis@4.0.2': resolution: {integrity: sha512-asOnXP1TzFSFQlHb1iegDZp0z/8WD1c7YNrwJR/Tx2bzNuMXfcekE/I67Iv6SQXeLB4csxqCngzQKANP7gdw0g==} hasBin: true @@ -238,6 +312,15 @@ packages: '@emoji-mart/data@1.2.1': resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} + '@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 + '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -609,6 +692,44 @@ packages: engines: {node: '>= 10'} hasBin: true + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -826,6 +947,21 @@ packages: '@xterm/xterm@5.5.0': resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -833,6 +969,9 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + blurhash@2.0.5: resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==} @@ -864,9 +1003,20 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + 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.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + 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'} @@ -876,6 +1026,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -902,6 +1055,12 @@ packages: dockview@8.2.0: resolution: {integrity: sha512-Jj+fnFWSrdEhF+1DmpGNYjEvyqf7J0FHkDqsD98wL4WOYB2CSKavDCL49B3LHIGYvWNN7Q1O1j4p7Pts38CM5A==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + emoji-mart@5.6.0: resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} @@ -909,6 +1068,10 @@ packages: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -964,9 +1127,17 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -986,6 +1157,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==} + isbot@5.2.2: resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} engines: {node: '>=18'} @@ -994,6 +1168,18 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + 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 + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1145,11 +1331,19 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lucide-react@1.34.0: resolution: {integrity: sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1204,6 +1398,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -1288,6 +1485,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1314,6 +1515,9 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1338,14 +1542,25 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: react: ^19.2.8 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -1356,6 +1571,10 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + remark-breaks@4.0.0: resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} @@ -1371,6 +1590,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.3.0: resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} @@ -1379,6 +1602,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1411,12 +1638,19 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1439,6 +1673,21 @@ packages: 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'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -1457,6 +1706,10 @@ packages: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -1596,11 +1849,38 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + 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.1: + resolution: {integrity: sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==} + engines: {node: ^22.14.0 || >=24.0.0} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + 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==} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -1611,8 +1891,33 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.3.0': {} + '@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/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/runtime@7.29.7': {} '@base-ui/react@1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': @@ -1673,6 +1978,34 @@ snapshots: '@biomejs/cli-win32-x64@2.5.12': optional: true + '@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.13(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@deepseek-ai/cordis@4.0.2': dependencies: '@deepseek-ai/cosmokit': 1.8.3 @@ -1682,6 +2015,10 @@ snapshots: '@emoji-mart/data@1.2.1': {} + '@exodus/bytes@1.15.1(@noble/hashes@2.0.1)': + optionalDependencies: + '@noble/hashes': 2.0.1 + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -1950,6 +2287,45 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.0.1))(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 4.1.11(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.0.1))(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2107,10 +2483,24 @@ snapshots: '@xterm/xterm@5.5.0': {} + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} bail@2.0.2: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + blurhash@2.0.5: {} ccount@2.0.1: {} @@ -2131,12 +2521,28 @@ snapshots: cookie-es@3.1.1: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} + data-urls@7.0.0(@noble/hashes@2.0.1): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -2161,6 +2567,10 @@ snapshots: dependencies: dockview-core: 8.2.0 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + emoji-mart@5.6.0: {} enhanced-resolve@5.24.5: @@ -2168,6 +2578,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.1.0: {} + es-module-lexer@2.3.2: {} escape-string-regexp@5.0.0: {} @@ -2223,8 +2635,16 @@ snapshots: dependencies: '@types/hast': 3.0.5 + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} + indent-string@4.0.0: {} + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -2240,10 +2660,40 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + isbot@5.2.2: {} jiti@2.7.0: {} + js-tokens@4.0.0: {} + + jsdom@30.0.1(@noble/hashes@2.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.13(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.0.1) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.0.1) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) + 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.1(@noble/hashes@2.0.1) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + lightningcss-android-arm64@1.32.0: optional: true @@ -2344,10 +2794,14 @@ snapshots: longest-streak@3.1.0: {} + lru-cache@11.5.2: {} + lucide-react@1.34.0(react@19.2.8): dependencies: react: 19.2.8 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -2512,6 +2966,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -2703,6 +3159,8 @@ snapshots: transitivePeerDependencies: - supports-color + min-indent@1.0.1: {} + ms@2.1.3: {} nanoid@3.3.18: {} @@ -2733,6 +3191,10 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -2753,13 +3215,23 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + property-information@7.2.0: {} + punycode@2.3.1: {} + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 scheduler: 0.27.0 + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8): dependencies: '@types/hast': 3.0.5 @@ -2780,6 +3252,11 @@ snapshots: react@19.2.8: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + remark-breaks@4.0.0: dependencies: '@types/mdast': 4.0.4 @@ -2820,6 +3297,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + reselect@5.3.0: {} rolldown@1.2.7: @@ -2843,6 +3322,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.7 '@rolldown/binding-win32-x64-msvc': 1.2.7 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} seroval-plugins@1.6.7(seroval@1.6.7): @@ -2866,6 +3349,10 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -2874,6 +3361,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + symbol-tree@3.2.4: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -2889,6 +3378,20 @@ snapshots: 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 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -2920,6 +3423,8 @@ snapshots: undici@7.16.0: {} + undici@8.10.2: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -2985,7 +3490,7 @@ snapshots: jiti: 2.7.0 yaml: 2.8.3 - vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)): + vitest@4.1.11(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.0.1))(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.8.3)) @@ -3009,14 +3514,43 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + jsdom: 30.0.1(@noble/hashes@2.0.1) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.0.1) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.1(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.0.1) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.8.3: {} zwitch@2.0.4: {} From 1b3923de9b1269e2caf3bbb58af37b5b94e88558 Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Mon, 14 Sep 2026 15:56:47 -0400 Subject: [PATCH 2/2] Reduce browser fixture overhead and establish behavior-based test layers Co-authored-by: Amp Signed-off-by: Charlie Croom Amp-Thread-ID: https://ampcode.com/threads/T-01a0a149-b278-7550-acc4-356168b0c4b5 --- AGENTS.md | 25 + docs/browser-testing.md | 24 +- docs/contributing.md | 52 ++ .../messages/MessageComposer.test.tsx | 661 +++++++++--------- src/features/messages/emoji-size.test.ts | 27 + tests/browser/fixture.mjs | 23 +- tests/browser/mention-edit.spec.mjs | 171 ++--- tests/browser/navigation-authority.spec.mjs | 2 +- tests/browser/profiles.spec.mjs | 459 ++++++------ tests/browser/settings.spec.mjs | 2 + tests/browser/source-fixture.mjs | 31 + tests/browser/typeahead.spec.mjs | 60 +- 12 files changed, 787 insertions(+), 750 deletions(-) create mode 100644 src/features/messages/emoji-size.test.ts create mode 100644 tests/browser/source-fixture.mjs diff --git a/AGENTS.md b/AGENTS.md index 42fab9da..db41af19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,31 @@ Keep reviews convergent: consolidate actionable findings and clear exit criteria Block on concrete correctness, security, or agreed-contract defects; unrelated hardening is follow-up. Reopen scope only when new evidence warrants it. +## Choose tests by behavior + +Default to colocated Vitest tests. Mount React with React Testing Library for +component behavior; do not mock React hooks or implement a substitute lifecycle. +Use Node for logic/services and opt into jsdom only when a DOM is needed. + +Before adding a browser journey, name the browser behavior or integration boundary +it proves that lower-layer tests cannot. Keep scenario matrices in the lowest +layer that preserves that contract; retain representative app wiring coverage. +Do not infer layout, native editing or cross-window correctness from a DOM emulator. +When moving coverage, map removed assertions to replacements and demonstrate that +the replacement catches the regression before deleting the browser case. + +Generate only the fixture data the test needs. Share immutable builds and stateless +servers, never mutable test state, identities or browser contexts. Preserve large +datasets and isolated runners when scale or performance is the behavior under test. +Record browser cases added/removed, their browser-only justification, replacement +coverage and fail-then-pass evidence in the PR description. For test infrastructure +changes, report before/after setup and execution timings with the command, engine, +environment and checked snapshots; distinguish local measurements from hosted CI. +List deferred checks. Do not meet time budgets by skipping engines, dropping +failure paths or weakening assertions. Enforce these rules during agent review; +do not rely on contributors filling in a PR template. Follow the +[test-layer review rules](docs/contributing.md#choosing-a-test-layer). + ## Deterministic tests Tests must control the ordering they assert, not depend on runner speed. diff --git a/docs/browser-testing.md b/docs/browser-testing.md index 0ad69e29..0c1b9670 100644 --- a/docs/browser-testing.md +++ b/docs/browser-testing.md @@ -62,11 +62,25 @@ isolation and one invocation to preserve both engines' evidence. Compiled frontend assets are worker-scoped, split by `developmentReact` and `pluginFixtures`, and removed when that worker ends. They are never reused across -invocations. Every test still gets a fresh preview server/port, ephemeral signing -keys, signed histories, relay state and browser context/storage. Evidence records -the worker and its build time; worker restarts rebuild rather than reuse stale assets. - -Results go to ignored `test-results/browser/`: each test writes `evidence.json` +invocations. Every built-app test still gets a fresh preview server/port, ephemeral +signing keys, signed histories, relay state and browser context/storage. Evidence records +the worker and its build time, plus history counts and signing time; worker +restarts rebuild rather than reuse stale assets. + +Declare `historyCounts` with `test.use` for built-app tests that do not need large +histories, for example `{ alpha: 1, beta: 0 }`. Counts apply per community. Keep +pagination, anchor and measurement datasets unchanged unless their behavior is +revalidated at the new size. The legacy large default remains for unaudited cases; +new tests should explicitly choose their data rather than inherit it accidentally. + +Source-only diagnostic pages can import `test` and `expect` from +`source-fixture.mjs` and navigate to `/tests/fixtures/example.html`. That fixture +shares a stateless Vite server and its isolated optimizer cache per worker, with +fresh browser contexts/storage for every test. Do not use it for custom mutable +server middleware or a different Vite configuration. The existing `vite-server.mjs` +helper keeps independently configured servers' caches isolated. + +Results go to ignored `test-results/browser/`: each built-app test writes `evidence.json` with runtime versions, HEAD/dirty status, request ledger, runtime errors and measurements. Failure screenshots and traces are retained too. The next invocation replaces that output; copy artifacts before a rerun if you need to compare them. diff --git a/docs/contributing.md b/docs/contributing.md index b4ebdf2b..28c99079 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -253,6 +253,58 @@ then Playwright. Updating a test's location must also update discovery, imports, fixture URLs and root-path calculations; moving a file must not silently drop it from the gate. +### Choosing a test layer + +Choose the cheapest layer that can observe the failure, not the tool used by the +last test in the feature. Regression coverage is about behavior, not test counts +or a coverage percentage. These rules apply to human and AI contributions alike. + +| Contract | Default layer | +| --- | --- | +| Parsing, policy, state machines, protocol handling, service coordination | Vitest in Node; use real collaborating services where the boundary matters | +| Component state, effects, subscriptions, forms, semantic DOM and stale async results | React Testing Library in Vitest with jsdom | +| Layout, virtualization, scrolling, native editing/focus interactions, real browser storage coordination | Playwright in both engines | +| App composition across routing, plugins, transport and persistence | Representative Playwright journeys, with permutations in lower layers | + +Run JS tests with `bin/pnpm exec vitest run`, optionally followed by a test path. +For mounted component tests, add `// @vitest-environment jsdom` at the top of the +colocated test and import `@testing-library/jest-dom/vitest` for DOM assertions. +Use real React (including StrictMode), role/label queries and `userEvent` for +interactions. Use `fireEvent` for deliberately low-level events or bulk input +whose keystrokes are not the contract. Unmount with RTL `cleanup` in `afterEach`; +clear owned storage and restore spies. Fake external services, not React hooks. +Keep snapshots stable until a service actually changes, and assert cleanup and +late-result rejection through real mounting, rerendering and unmounting. +See the [composer tests](../src/features/messages/MessageComposer.test.tsx). + +jsdom is the default DOM emulator, not a second browser gate. Its +[standards-oriented implementation](https://github.com/jsdom/jsdom#readme) and +compatibility with Testing Library favor behavioral fidelity over emulator-only +speed claims. [Vitest supports Happy DOM too](https://vitest.dev/guide/environment), +but introducing another emulator requires a demonstrated benefit on our actual +component tests without per-environment workarounds. Neither proves rendering, +native IME behavior or browser performance. Keep layout shims local and explicit; +do not treat synthetic dimensions as acceptance evidence. + +Before accepting test changes, reviewers should verify: + +- Each added browser case identifies a browser-specific behavior or integration + boundary that a lower layer cannot establish. Keep failure/recovery coverage, + but avoid repeating the same state matrix through full app startup. +- A moved assertion has a named replacement and evidence that a plausible defect + makes it fail. Similar test titles do not establish equivalent coverage. +- Fixture data matches the test's needs. Share stateless servers/compiled assets, + not browser contexts or mutable state; keep scale tests representative. +- Timing claims distinguish setup, execution, runner/engine and the checked + snapshot. Report added/removed cases and deferred checks. Do not impose a + flaky wall-clock threshold on ordinary correctness tests. + +Follow `AGENTS.md` to record those decisions in the PR description and enforce +them during agent review. Request a lower-layer test when the browser justification +is missing, rather than accept unbounded journey growth. Existing broad fixtures and hook-mocked tests are +migration work, not patterns for new tests; convert them by owner without +bundling unrelated product changes. + ### Manual browser fixtures With `just web` running **without a `BUZZ_DEV_VIEWER` pin**, these separate diagnostic pages diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 85acc855..299506ab 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -1,400 +1,384 @@ -import { assert, afterEach, beforeEach, expect, it, vi } from "vitest"; -import { isValidElement, type ReactNode, type ReactElement } from "react"; -import { ComposerTools } from "../conversation/ComposerTools"; -import type { ConversationExtensions } from "../conversation/contracts"; -import { MessageComposer } from "./MessageComposer"; +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { - isEmojiOnly, - isUnicodeEmojiOnly, - singleCustomEmoji, - usesLargeEmojiPresentation, -} from "./emoji-size"; + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useLayoutEffect } from "react"; +import type { Contribution } from "../../plugins/contributions"; +import type { + ComposerTool, + ComposerToolProps, +} from "../conversation/contracts"; +import { MessageComposer, type MessageComposerProps } from "./MessageComposer"; import type { RelaySession } from "../relay/session"; import type { CustomEmoji } from "../relay/emoji"; -// Production handlers with a shallow hook harness; not DOM focus/layout evidence. -const hooks = vi.hoisted(() => ({ - states: [] as unknown[], - refs: [] as unknown[], - refIndex: 0, - index: 0, - id: 0, - effects: [] as (() => void)[], -})); -vi.mock("react", async (original) => ({ - ...(await original()), - useEffect: () => {}, - useCallback: (callback: unknown) => callback, - useLayoutEffect: (effect: () => void) => hooks.effects.push(effect), - useId: () => `composer-${++hooks.id}`, - useRef: (initial: unknown) => { - const i = hooks.refIndex++; - if (!(i in hooks.refs)) - hooks.refs[i] = { - current: - initial === null - ? { - focus: vi.fn(), - style: {}, - children: [], - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - ownerDocument: { - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - activeElement: null, - }, - isConnected: true, - selectionStart: 0, - selectionEnd: 0, - setSelectionRange(start: number, end: number) { - this.selectionStart = start; - this.selectionEnd = end; - }, - } - : initial, - }; - return hooks.refs[i]; - }, - useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => - snapshot(), - useState(initial: unknown) { - const i = hooks.index++; - if (!(i in hooks.states)) - hooks.states[i] = typeof initial === "function" ? initial() : initial; - return [ - hooks.states[i], - (value: unknown) => { - hooks.states[i] = value; - }, - ]; - }, -})); -function elements(node: ReactNode): ReactElement>[] { - if (Array.isArray(node)) return node.flatMap(elements); - if (!isValidElement>(node)) return []; - return [node, ...elements(node.props.children as ReactNode)]; -} -const storage = new Map(); +const first = { pubkey: "a".repeat(64), name: "Honey" }; +const second = { pubkey: "b".repeat(64), name: "Honey" }; + +let style: HTMLStyleElement; beforeEach(() => { - hooks.states = []; - hooks.effects = []; - hooks.refs = []; - hooks.refIndex = 0; - hooks.index = hooks.id = 0; - storage.clear(); - vi.stubGlobal("requestAnimationFrame", () => 0); - vi.stubGlobal("localStorage", { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => storage.set(key, value), - }); + localStorage.clear(); + // jsdom does not lay out the emoji mirror. Give its bookkeeping a finite line + // height; actual wrapping, dimensions and caret placement stay in Playwright. + style = document.createElement("style"); + style.textContent = "textarea { line-height: 48px; }"; + document.head.append(style); +}); +afterEach(() => { + cleanup(); + style.remove(); }); -afterEach(() => vi.unstubAllGlobals()); -function mount( - threadRootId?: string, - scope = "scope", - writable = true, - emojiEntries: readonly CustomEmoji[] = [], -) { - hooks.states = []; - hooks.effects = []; - hooks.refs = []; - hooks.refIndex = 0; + +function mount(options: Partial = {}) { + let commands: ComposerToolProps; + function Tool(props: ComposerToolProps) { + useLayoutEffect(() => { + commands = props; + }); + return ( + <> + + + + ); + } + const tools: readonly Contribution[] = [ + { + id: "fixture", + key: "test/fixture", + pluginId: "test", + revision: "1", + title: "Fixture tools", + component: Tool, + }, + ]; + const emojiListeners = new Set<() => void>(); + let emoji = { + status: "ready" as const, + entries: [] as readonly CustomEmoji[], + }; const messages = { - send: vi.fn(() => "channel-id"), - reply: vi.fn(() => "reply-id"), + send: vi.fn(() => "channel-id"), + reply: vi.fn(() => "reply-id"), }; - const onSend = vi.fn(); const session = { messages, emoji: { - snapshot: () => ({ status: "ready", entries: emojiEntries }), - subscribe: () => () => {}, - ensure: () => Promise.resolve(), - refresh: () => Promise.resolve(), + snapshot: () => emoji, + subscribe(listener: () => void) { + emojiListeners.add(listener); + return () => { + emojiListeners.delete(listener); + }; + }, + ensure: vi.fn(() => Promise.resolve()), + refresh: vi.fn(() => Promise.resolve()), }, media: (url: string) => url, - outbox: { supports: () => writable }, + outbox: { supports: () => true }, } as unknown as RelaySession; - const render = () => { - hooks.index = hooks.refIndex = 0; - const scoped = MessageComposer({ - session, - extensions: {} as ConversationExtensions, - scope, - channelId: "channel", - channelName: "General", - onSend, - ...(threadRootId ? { threadRootId } : {}), - }); - const tree = ( - scoped.type as ( - props: typeof scoped.props, - ) => ReactElement> - )(scoped.props); - const field = elements(tree).find((element) => element.type === "textarea"); - if (field) { - const ref = field.props.ref as { current: HTMLTextAreaElement }; - const length = (field.props.value as string).length; - ref.current.setSelectionRange( - Math.min(ref.current.selectionStart, length), - Math.min(ref.current.selectionEnd, length), - ); - } - for (const effect of hooks.effects.splice(0)) effect(); - return tree; - }; - const input = () => { - const field = elements(render()).find((e) => e.type === "textarea"); - if (!field) throw new Error("No composer input"); - return field; + const onSend = vi.fn(); + let props: MessageComposerProps = { + session, + onSend, + scope: "scope", + channelId: "channel", + channelName: "General", + extensions: { + tools: { snapshot: () => tools, subscribe: () => () => {} }, + inline: { snapshot: () => [], subscribe: () => () => {} }, + }, + ...options, }; + const view = render(, { + reactStrictMode: true, + }); + const input = () => + within(view.container).getByRole("textbox"); return { - render, + ...view, input, messages, onSend, - type(text: string) { - const field = input(); - (field.props.onInput as (e: unknown) => void)({ - currentTarget: { value: text }, - }); - const ref = field.props.ref as { current: HTMLTextAreaElement }; - ref.current.setSelectionRange(text.length, text.length); + session, + emojiListeners, + user: userEvent.setup(), + commands: () => commands, + retarget(next: Partial) { + props = { ...props, ...next }; + view.rerender(); }, - submit() { - (render().props.onSubmit as (e: unknown) => void)({ - preventDefault() {}, + setEmoji(entries: readonly CustomEmoji[]) { + act(() => { + emoji = { status: "ready", entries }; + for (const listener of emojiListeners) listener(); }); }, - key(shiftKey = false, isComposing = false) { - const preventDefault = vi.fn(); - (input().props.onKeyDown as (e: unknown) => void)({ - key: "Enter", - shiftKey, - nativeEvent: { isComposing }, - preventDefault, - }); - return preventDefault; + fill(text: string) { + fireEvent.input(input(), { target: { value: text } }); + }, + submit() { + fireEvent.submit(within(view.container).getByRole("form")); }, }; } -it("reuses the editor for channel sends and thread replies, preserving keyboard behavior", () => { - const channel = mount(); - channel.type("channel draft"); - channel.submit(); - expect(channel.messages.send).toHaveBeenCalledExactlyOnceWith( + +it("sends channel messages and thread replies through real form and keyboard events", async () => { + const h = mount(); + await h.user.type(h.input(), "channel draft"); + await h.user.click(screen.getByRole("button", { name: "Send message" })); + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( "channel", "channel draft", [], ); - expect(channel.messages.reply).not.toHaveBeenCalled(); - const thread = mount("root"); - thread.type("thread draft"); - expect(thread.key(true)).not.toHaveBeenCalled(); - expect(thread.key(false, true)).not.toHaveBeenCalled(); - expect(thread.messages.reply).not.toHaveBeenCalled(); - expect(thread.key()).toHaveBeenCalledTimes(1); - expect(thread.messages.reply).toHaveBeenCalledExactlyOnceWith( + expect(h.messages.reply).not.toHaveBeenCalled(); + h.retarget({ threadRootId: "root" }); + await h.user.type(h.input(), "thread draft"); + await h.user.keyboard("{Shift>}{Enter}{/Shift}"); + expect(h.input()).toHaveValue("thread draft\n"); + // This checks the composition guard, not native IME behavior. + fireEvent.keyDown(h.input(), { key: "Enter", isComposing: true }); + expect(h.messages.reply).not.toHaveBeenCalled(); + await h.user.keyboard("{Enter}"); + expect(h.messages.reply).toHaveBeenCalledExactlyOnceWith( "channel", "root", - "thread draft", + "thread draft\n", [], ); - expect(thread.messages.send).not.toHaveBeenCalled(); - expect(thread.onSend).toHaveBeenCalledExactlyOnceWith("reply-id"); - expect(thread.input().props.value).toBe(""); + expect(h.messages.send).toHaveBeenCalledTimes(1); + expect(h.onSend.mock.calls).toEqual([["channel-id"], ["reply-id"]]); + expect(h.input()).toHaveValue(""); }); -it("keeps channel, separate thread and identity drafts isolated across remounts", () => { - mount().type("channel draft"); - mount("one").type("first thread"); - mount("two").type("second thread"); - mount("one", "other identity").type("other identity"); - expect(mount().input().props.value).toBe("channel draft"); - expect(mount("one").input().props.value).toBe("first thread"); - expect(mount("two").input().props.value).toBe("second thread"); - expect(mount("one", "other identity").input().props.value).toBe( - "other identity", - ); + +it("isolates channel, thread and identity drafts through retargeting and remounting", () => { + const h = mount(); + h.fill("channel draft"); + h.retarget({ threadRootId: "one" }); + expect(h.input()).toHaveValue(""); + h.fill("first thread"); + h.retarget({ threadRootId: "two" }); + h.fill("second thread"); + h.retarget({ threadRootId: "one", scope: "other identity" }); + expect(h.input()).toHaveValue(""); + h.fill("other identity"); + h.retarget({ threadRootId: "one", scope: "scope" }); + expect(h.input()).toHaveValue("first thread"); + h.retarget({ threadRootId: "two" }); + expect(h.input()).toHaveValue("second thread"); + h.unmount(); + const restored = mount(); + expect(restored.input()).toHaveValue("channel draft"); + restored.retarget({ threadRootId: "one", scope: "other identity" }); + expect(restored.input()).toHaveValue("other identity"); }); -it("keeps the draft on synchronous rejection and clears only after the outbox accepts intent", () => { - const h = mount("root"); - h.type("retry me"); + +it("retains rejected intent and clears the draft only after the outbox accepts it", async () => { + const h = mount({ threadRootId: "root" }); + h.fill("retry me"); h.messages.reply.mockImplementationOnce(() => { throw new Error("outbox full"); }); - h.submit(); - expect(h.input().props.value).toBe("retry me"); + await h.user.click(screen.getByRole("button", { name: "Send message" })); + expect(h.input()).toHaveValue("retry me"); expect(h.onSend).not.toHaveBeenCalled(); - expect( - elements(h.render()).some( - (e) => e.props.role === "alert" && e.props.children === "outbox full", - ), - ).toBe(true); - h.submit(); - expect(h.input().props.value).toBe(""); + expect(screen.getByRole("alert")).toHaveTextContent("outbox full"); + await h.user.click(screen.getByRole("button", { name: "Send message" })); + expect(h.messages.reply).toHaveBeenCalledTimes(2); + expect(h.input()).toHaveValue(""); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); -it("gives the channel and thread separate input/label identities, and gates unsupported writes", () => { - const channel = mount().render(), - thread = mount("root").render(); - const channelInput = elements(channel).find((e) => e.type === "textarea"); - const threadInput = elements(thread).find((e) => e.type === "textarea"); - expect(threadInput?.props.id).not.toBe(channelInput?.props.id); - expect(threadInput?.props.placeholder).toBe("Reply to thread"); - expect(elements(thread).find((e) => e.type === "label")?.props.htmlFor).toBe( - threadInput?.props.id, + +it("labels simultaneous composers independently and prevents disabled or unsupported writes", async () => { + const channel = mount(); + const thread = mount({ threadRootId: "root" }); + expect(screen.getByLabelText("Message #General")).toBe(channel.input()); + expect( + screen.getByLabelText("Reply to thread", { selector: "textarea" }), + ).toBe(thread.input()); + expect(thread.input().id).not.toBe(channel.input().id); + thread.fill("retain me"); + thread.retarget({ disabled: true }); + expect(thread.input()).toBeDisabled(); + await thread.user.click( + within(thread.container).getByRole("button", { name: "Send message" }), ); + expect(thread.messages.reply).not.toHaveBeenCalled(); + expect(thread.input()).toHaveValue("retain me"); + thread.retarget({ + session: { + ...thread.session, + outbox: { supports: () => false }, + } as unknown as RelaySession, + }); expect( - elements(mount("root", "scope", false).render()).some( - (e) => e.type === "textarea", + within(thread.container).queryByRole("textbox"), + ).not.toBeInTheDocument(); + expect( + within(thread.container).getByText( + "This relay connection supports reading only.", ), - ).toBe(false); + ).toBeVisible(); }); -it("enlarges Unicode-only drafts and restores normal text presentation", () => { - for (const emoji of ["๐Ÿ˜€", " ๐Ÿ‘‹๐Ÿฝ ", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", "๐Ÿ‡ฌ๐Ÿ‡ง", "1๏ธโƒฃ"]) - expect(isUnicodeEmojiOnly(emoji)).toBe(true); - for (const other of ["", "1", ":party:", "๐Ÿ˜€a", "๐Ÿ˜€ hello"]) - expect(isUnicodeEmojiOnly(other)).toBe(false); - +it("subscribes to emoji changes and releases the subscription when unmounted", () => { const h = mount(); - h.type("๐Ÿ˜€"); - expect(h.input().props["data-single-emoji"]).toBe(true); - h.type("๐Ÿ˜€ hello"); - expect(h.input().props["data-single-emoji"]).toBeUndefined(); - h.type("๐Ÿ˜€ ๐Ÿ™ ๐Ÿ‘"); - expect(h.input().props["data-single-emoji"]).toBe(true); - h.type("๐Ÿ˜€ ๐Ÿ™ ๐Ÿ‘ ๐Ÿ˜„"); - expect(h.input().props["data-single-emoji"]).toBe(true); - h.type("๐Ÿ˜€ ๐Ÿ™ hello"); - expect(h.input().props["data-single-emoji"]).toBeUndefined(); + h.fill(":party:"); + expect(h.input()).not.toHaveAttribute("data-custom-emoji-only"); + expect(h.emojiListeners.size).toBe(1); + expect(h.session.emoji.ensure).toHaveBeenCalled(); + h.setEmoji([{ shortcode: "party", url: "https://emoji.test/party.png" }]); + expect(h.input()).toHaveAttribute("data-custom-emoji-only", "true"); + h.setEmoji([]); + expect(h.input()).not.toHaveAttribute("data-custom-emoji-only"); + h.unmount(); + expect(h.emojiListeners.size).toBe(0); }); -it("recognizes an exact custom emoji draft without treating shortcode prose as emoji", () => { - const party = { shortcode: "party", url: "https://example.test/party.png" }; - expect(singleCustomEmoji(":PARTY: ", [party])).toBe(party); - expect(singleCustomEmoji("hello :party:", [party])).toBeUndefined(); - expect(singleCustomEmoji(":missing:", [party])).toBeUndefined(); - expect(isEmojiOnly(":party: ๐Ÿ˜€ :PARTY:", [party])).toBe(true); - expect(isEmojiOnly(":party: hello", [party])).toBe(false); - expect(usesLargeEmojiPresentation(":party: ๐Ÿ˜€ :PARTY:", [party])).toBe(true); - expect( - usesLargeEmojiPresentation(":party: ๐Ÿ˜€ :PARTY: :party:", [party]), - ).toBe(true); +it("enlarges Unicode-only drafts and restores normal text presentation", () => { + const h = mount(); + for (const draft of ["๐Ÿ˜€", "๐Ÿ˜€ ๐Ÿ™ ๐Ÿ‘", "๐Ÿ˜€ ๐Ÿ™ ๐Ÿ‘ ๐Ÿ˜„"]) { + h.fill(draft); + expect(h.input()).toHaveAttribute("data-single-emoji", "true"); + h.fill(`${draft} hello`); + expect(h.input()).not.toHaveAttribute("data-single-emoji"); + } }); it.each([undefined, "root"])( - "carries exact namesake selection into %s, restores it, and never resolves typed names", - (root) => { - const first = { pubkey: "a".repeat(64), name: "Honey" }; - const second = { pubkey: "b".repeat(64), name: "Honey" }; - let h = mount(root); - h.type("@Honey prose only"); + "persists exact namesake recipients for %s without resolving typed prose", + async (root) => { + const options = root ? { threadRootId: root } : {}; + let h = mount(options); + h.fill("@Honey prose only"); h.submit(); expect( (root ? h.messages.reply : h.messages.send).mock.calls[0]?.at(-1), ).toEqual([]); - h.type("Please help "); - for (const recipient of [first, second]) { - const picker = elements(h.render()).find((e) => e.type === ComposerTools); - assert.exists(picker); - (picker.props.insertMention as (value: unknown) => void)(recipient); - } - h = mount(root); + h.fill("Please help "); + await h.user.click(screen.getByRole("button", { name: "First Honey" })); + await h.user.click(screen.getByRole("button", { name: "Second Honey" })); + h.unmount(); + h = mount(options); expect( - elements(h.render()).filter((e) => e.props.title === second.pubkey), - ).toHaveLength(1); + screen.getByRole("button", { + name: `Remove mention Honey ${second.pubkey}`, + }), + ).toBeVisible(); h.submit(); expect( (root ? h.messages.reply : h.messages.send).mock.calls[0]?.at(-1), ).toEqual([first.pubkey, second.pubkey]); - expect(h.input().props.value).toBe(""); - h = mount(root); - h.type("@Honey typed after send"); + expect(h.input()).toHaveValue(""); + h.unmount(); + h = mount(options); + h.fill("@Honey typed after send"); h.submit(); expect( (root ? h.messages.reply : h.messages.send).mock.calls[0]?.at(-1), ).toEqual([]); }, ); -it("deleting a mention or removing its chip removes notification intent", () => { + +it("deleting a mention or removing its chip removes notification intent", async () => { const h = mount(); - const choose = () => { - const picker = elements(h.render()).find((e) => e.type === ComposerTools); - assert.exists(picker); - (picker.props.insertMention as (v: unknown) => void)({ - pubkey: "a".repeat(64), - name: "Honey", - }); - }; - choose(); - h.type("no recipient now"); + await h.user.click(screen.getByRole("button", { name: "First Honey" })); + h.fill("no recipient now"); h.submit(); expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([]); - choose(); - const remove = elements(h.render()).find( - (e) => e.props.title === "a".repeat(64), + await h.user.click(screen.getByRole("button", { name: "First Honey" })); + await h.user.click( + screen.getByRole("button", { + name: `Remove mention Honey ${first.pubkey}`, + }), ); - assert.exists(remove); - (remove.props.onClick as () => void)(); h.submit(); expect(h.messages.send.mock.calls[1]?.at(-1)).toEqual([]); }); -it("ambiguous namesake deletion cannot notify the wrong remaining identity", () => { +it("ambiguous namesake replacement cannot notify the wrong remaining identity", async () => { const h = mount(); - for (const key of ["a", "b"]) { - const picker = elements(h.render()).find((e) => e.type === ComposerTools); - assert.exists(picker); - (picker.props.insertMention as (v: unknown) => void)({ - pubkey: key.repeat(64), - name: "Honey", - }); - } - h.type("@Honey help"); + await h.user.click(screen.getByRole("button", { name: "First Honey" })); + await h.user.click(screen.getByRole("button", { name: "Second Honey" })); + h.fill("@Honey help"); h.submit(); expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([]); }); -it("serializes text and mention commands in one turn and rejects malformed recipients", () => { +it("serializes tool commands in one React batch and rejects malformed recipients", () => { const h = mount(); - h.type("Hi "); - const tools = elements(h.render()).find((e) => e.type === ComposerTools); - assert.exists(tools); - const insertText = tools.props.insertText as (text: string) => boolean; - const insertMention = tools.props.insertMention as ( - recipient: unknown, - ) => boolean; - expect(insertText("there ")).toBe(true); - expect(insertMention({ pubkey: "a".repeat(64), name: "Honey" })).toBe(true); - expect(insertText("and ")).toBe(true); - expect(insertMention({ pubkey: "b".repeat(64), name: "Honey" })).toBe(true); - expect(insertMention({ pubkey: "wrong", name: "Honey" })).toBe(false); - expect(insertMention({ pubkey: "a".repeat(64), name: " " })).toBe(false); - expect(insertMention(null)).toBe(false); + h.fill("Hi "); + act(() => { + const { insertText, insertMention } = h.commands(); + expect(insertText("there ")).toBe(true); + expect(insertMention(first)).toBe(true); + expect(insertText("and ")).toBe(true); + expect(insertMention(second)).toBe(true); + expect(insertMention({ pubkey: "wrong", name: "Honey" })).toBe(false); + expect(insertMention({ ...first, name: " " })).toBe(false); + expect(insertMention(null as unknown as typeof first)).toBe(false); + }); h.submit(); expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( "channel", "Hi there @Honey and @Honey ", - ["a".repeat(64), "b".repeat(64)], + [first.pubkey, second.pubkey], ); }); -it("keeps custom emoji shortcode text readable in the draft and sends it unchanged", () => { - const h = mount(undefined, "scope", true, [ - { shortcode: "party", url: "https://emoji.test/party.png" }, - ]); - const tools = elements(h.render()).find((e) => e.type === ComposerTools); - assert.exists(tools); - const insertText = tools.props.insertText as (text: string) => boolean; - expect(insertText(":party:")).toBe(true); - expect(h.input().props.value).toBe(":party:"); - expect(h.input().props["data-custom-emoji-only"]).toBe(true); - expect(insertText(":party:")).toBe(true); - expect(h.input().props.value).toBe(":party::party:"); - expect( - elements(h.render()).filter((element) => element.type === "img"), - ).toHaveLength(2); +it("revokes captured tool commands after retargeting, disabling and unmounting", () => { + const h = mount(); + h.fill("channel draft"); + const channel = h.commands(); + h.retarget({ threadRootId: "root" }); + act(() => { + expect(channel.insertText("stale")).toBe(false); + expect(channel.insertMention(first)).toBe(false); + }); + expect(h.input()).toHaveValue(""); + const thread = h.commands(); + h.retarget({ disabled: true }); + act(() => { + expect(thread.insertText("disabled")).toBe(false); + }); + h.retarget({ disabled: false }); + act(() => { + expect(h.commands().insertText("current")).toBe(true); + }); + expect(h.input()).toHaveValue("current"); + const current = h.commands(); + h.unmount(); + act(() => { + expect(current.insertText("unmounted")).toBe(false); + }); +}); + +it("keeps custom emoji text readable and sends repeated shortcodes unchanged", () => { + const h = mount(); + h.setEmoji([{ shortcode: "party", url: "https://emoji.test/party.png" }]); + act(() => { + expect(h.commands().insertText(":party:")).toBe(true); + }); + expect(h.input()).toHaveValue(":party:"); + expect(h.input()).toHaveAttribute("data-custom-emoji-only", "true"); + act(() => { + expect(h.commands().insertText(":party:")).toBe(true); + }); + expect(h.input()).toHaveValue(":party::party:"); + expect(h.container.querySelectorAll("img")).toHaveLength(2); h.submit(); expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( "channel", @@ -403,16 +387,13 @@ it("keeps custom emoji shortcode text readable in the draft and sends it unchang ); }); -it("renders a leading custom emoji inline when text follows it", () => { - const h = mount(undefined, "scope", true, [ - { shortcode: "bufo", url: "https://emoji.test/bufo.png" }, - ]); - h.type(":bufo:lakjsdlkjflakjsdf"); - expect(h.input().props["data-custom-emoji-only"]).toBeUndefined(); - expect(h.input().props["data-leading-custom-emoji"]).toBe(true); - expect( - elements(h.render()).filter((element) => element.type === "img"), - ).toHaveLength(1); +it("renders a leading custom emoji inline without changing trailing text", () => { + const h = mount(); + h.setEmoji([{ shortcode: "bufo", url: "https://emoji.test/bufo.png" }]); + h.fill(":bufo:lakjsdlkjflakjsdf"); + expect(h.input()).not.toHaveAttribute("data-custom-emoji-only"); + expect(h.input()).toHaveAttribute("data-leading-custom-emoji", "true"); + expect(h.container.querySelectorAll("img")).toHaveLength(1); h.submit(); expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( "channel", @@ -421,27 +402,23 @@ it("renders a leading custom emoji inline when text follows it", () => { ); }); -it("rejects overlong or over-limit edits without changing the draft", () => { +it("rejects overlong and over-limit tool edits without changing accepted intent", () => { const h = mount(); - h.type("x".repeat(15999)); - const tools = () => { - const tool = elements(h.render()).find((e) => e.type === ComposerTools); - assert.exists(tool); - return tool; - }; - const recipient = { pubkey: "a".repeat(64), name: "Honey" }; - expect( - (tools().props.insertMention as (r: unknown) => boolean)(recipient), - ).toBe(false); - expect(h.input().props.value).toBe("x".repeat(15999)); - h.type(""); - for (let i = 0; i < 32; i++) - expect( - (tools().props.insertMention as (r: unknown) => boolean)(recipient), - ).toBe(true); - const before = h.input().props.value; - expect( - (tools().props.insertMention as (r: unknown) => boolean)(recipient), - ).toBe(false); - expect(h.input().props.value).toBe(before); + h.fill("x".repeat(15999)); + act(() => { + expect(h.commands().insertMention(first)).toBe(false); + }); + expect(h.input()).toHaveValue("x".repeat(15999)); + expect(screen.getByRole("alert")).toHaveTextContent("too long"); + h.fill(""); + act(() => { + for (let i = 0; i < 32; i++) + expect(h.commands().insertMention(first)).toBe(true); + }); + const before = h.input().value; + act(() => { + expect(h.commands().insertMention(first)).toBe(false); + }); + expect(h.input()).toHaveValue(before); + expect(screen.getByRole("alert")).toHaveTextContent("at most 32 recipients"); }); diff --git a/src/features/messages/emoji-size.test.ts b/src/features/messages/emoji-size.test.ts new file mode 100644 index 00000000..e59ff35d --- /dev/null +++ b/src/features/messages/emoji-size.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from "vitest"; +import { + isEmojiOnly, + isUnicodeEmojiOnly, + singleCustomEmoji, + usesLargeEmojiPresentation, +} from "./emoji-size"; + +it("recognizes Unicode emoji sequences without enlarging ordinary prose", () => { + for (const emoji of ["๐Ÿ˜€", " ๐Ÿ‘‹๐Ÿฝ ", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", "๐Ÿ‡ฌ๐Ÿ‡ง", "1๏ธโƒฃ"]) + expect(isUnicodeEmojiOnly(emoji)).toBe(true); + for (const other of ["", "1", ":party:", "๐Ÿ˜€a", "๐Ÿ˜€ hello"]) + expect(isUnicodeEmojiOnly(other)).toBe(false); +}); + +it("recognizes custom emoji without treating shortcode prose as emoji", () => { + const party = { shortcode: "party", url: "https://example.test/party.png" }; + expect(singleCustomEmoji(":PARTY: ", [party])).toBe(party); + expect(singleCustomEmoji("hello :party:", [party])).toBeUndefined(); + expect(singleCustomEmoji(":missing:", [party])).toBeUndefined(); + expect(isEmojiOnly(":party: ๐Ÿ˜€ :PARTY:", [party])).toBe(true); + expect(isEmojiOnly(":party: hello", [party])).toBe(false); + expect(usesLargeEmojiPresentation(":party: ๐Ÿ˜€ :PARTY:", [party])).toBe(true); + expect( + usesLargeEmojiPresentation(":party: ๐Ÿ˜€ :PARTY: :party:", [party]), + ).toBe(true); +}); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index b4ecb916..44487b08 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -35,6 +35,7 @@ export const test = base.extend({ dmLabels: [false, { option: true }], tallMessages: [false, { option: true }], membershipActivity: [false, { option: true }], + historyCounts: [{ alpha: historySize, beta: 80 }, { option: true }], developmentReact: [false, { option: true, scope: "worker" }], pluginFixtures: [false, { option: true, scope: "worker" }], compiledApp: [buildApp, { scope: "worker" }], @@ -55,6 +56,7 @@ export const test = base.extend({ dmLabels, tallMessages, membershipActivity, + historyCounts, pluginFixtures, developmentReact, compiledApp, @@ -156,22 +158,22 @@ export const test = base.extend({ const hiddenChannels = new Set(); const streams = new Map(); const histories = new Map(); + const historyStarted = performance.now(); for (const community of ["primary", "secondary"]) for (const channel of channels) histories.set( `${community}/${channel}`, - Array.from( - { length: channel === "alpha" ? historySize : 80 }, - (_, i) => - sign( - 9, - [["h", channel]], - `${community} ${channel} message ${i}\n${"Mixed height message content. ".repeat((1 + (i % 7) * 3) * (tallMessages ? 3 : 1))}`, - readState ? peerKey : userKey, - 1700000100 + i, - ), + Array.from({ length: historyCounts[channel] }, (_, i) => + sign( + 9, + [["h", channel]], + `${community} ${channel} message ${i}\n${"Mixed height message content. ".repeat((1 + (i % 7) * 3) * (tallMessages ? 3 : 1))}`, + readState ? peerKey : userKey, + 1700000100 + i, + ), ), ); + const historyDurationMs = performance.now() - historyStarted; for (const community of ["primary", "secondary"]) for (const id of dmIds) histories.set(`${community}/${id}`, []); const targetEvents = []; @@ -337,6 +339,7 @@ export const test = base.extend({ worker: testInfo.workerIndex, durationMs: compiledApp.durationMs, }, + signedHistory: { counts: historyCounts, durationMs: historyDurationMs }, largeSidebar, readState, sidebarUnread, diff --git a/tests/browser/mention-edit.spec.mjs b/tests/browser/mention-edit.spec.mjs index c42fee27..8f62a0f4 100644 --- a/tests/browser/mention-edit.spec.mjs +++ b/tests/browser/mention-edit.spec.mjs @@ -1,100 +1,83 @@ -import { test, expect } from "@playwright/test"; -import { createServer } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; +import { test, expect } from "./source-fixture.mjs"; + test("editing selected name plus pasting same name cannot transfer notification to pasted prose", async ({ page, }) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0 }, - }); - try { - await server.listen(); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html`, + await page.goto("/tests/fixtures/mentions.html"); + const first = await page.evaluate(() => window.mentionFixture.first); + const input = page.getByRole("textbox", { name: "Message #General" }); + const cases = [ + { + start: 6, + end: 6, + inserted: "bee @Honey", + expected: "@Honeybee @Honey", + notify: false, + }, + { + start: 0, + end: 6, + inserted: "@Honey", + expected: "@Honey", + notify: false, + }, + { + start: 0, + end: 6, + inserted: "bee @Honey", + expected: "bee @Honey", + notify: false, + }, + { + start: 0, + end: 0, + inserted: "Hi ", + expected: "Hi @Honey", + notify: true, + }, + { + start: 7, + end: 7, + inserted: "help", + expected: "@Honey help", + notify: true, + }, + ]; + for (const [index, scenario] of cases.entries()) { + await page + .getByRole("button", { name: "Mention a member", exact: true }) + .click(); + await page + .getByRole("button", { name: `Honey ${first}`, exact: true }) + .click(); + await expect(input).toHaveValue("@Honey "); + // The picker restores focus/caret on the next animation frame. Let that + // finish before establishing the selection this edit is meant to replace. + await page.evaluate(() => new Promise(requestAnimationFrame)); + await expect(input).toBeFocused(); + await input.evaluate( + (e, { start, end }) => e.setSelectionRange(start, end), + scenario, + ); + await page.keyboard.insertText(scenario.inserted); + await expect(input).toHaveValue( + scenario.expected + (scenario.expected.endsWith("help") ? "" : " "), + ); + await page + .getByRole("button", { name: "Send message", exact: true }) + .click(); + await expect + .poll(() => + page.evaluate(() => window.mentionFixture.publications.length), + ) + .toBe(index + 1); + const event = await page.evaluate( + (index) => window.mentionFixture.publications[index], + index, + ); + expect(event.content).toBe(scenario.expected); + expect(event.tags.filter(([tag]) => tag === "p")).toEqual( + scenario.notify ? [["p", first]] : [], ); - const first = await page.evaluate(() => window.mentionFixture.first); - const input = page.getByRole("textbox", { name: "Message #General" }); - const cases = [ - { - start: 6, - end: 6, - inserted: "bee @Honey", - expected: "@Honeybee @Honey", - notify: false, - }, - { - start: 0, - end: 6, - inserted: "@Honey", - expected: "@Honey", - notify: false, - }, - { - start: 0, - end: 6, - inserted: "bee @Honey", - expected: "bee @Honey", - notify: false, - }, - { - start: 0, - end: 0, - inserted: "Hi ", - expected: "Hi @Honey", - notify: true, - }, - { - start: 7, - end: 7, - inserted: "help", - expected: "@Honey help", - notify: true, - }, - ]; - for (const [index, scenario] of cases.entries()) { - await page - .getByRole("button", { name: "Mention a member", exact: true }) - .click(); - await page - .getByRole("button", { name: `Honey ${first}`, exact: true }) - .click(); - await expect(input).toHaveValue("@Honey "); - // The picker restores focus/caret on the next animation frame. Let that - // finish before establishing the selection this edit is meant to replace. - await page.evaluate(() => new Promise(requestAnimationFrame)); - await expect(input).toBeFocused(); - await input.evaluate( - (e, { start, end }) => e.setSelectionRange(start, end), - scenario, - ); - await page.keyboard.insertText(scenario.inserted); - await expect(input).toHaveValue( - scenario.expected + (scenario.expected.endsWith("help") ? "" : " "), - ); - await page - .getByRole("button", { name: "Send message", exact: true }) - .click(); - await expect - .poll(() => - page.evaluate(() => window.mentionFixture.publications.length), - ) - .toBe(index + 1); - const event = await page.evaluate( - (index) => window.mentionFixture.publications[index], - index, - ); - expect(event.content).toBe(scenario.expected); - expect(event.tags.filter(([tag]) => tag === "p")).toEqual( - scenario.notify ? [["p", first]] : [], - ); - } - } finally { - await server.close(); } }); diff --git a/tests/browser/navigation-authority.spec.mjs b/tests/browser/navigation-authority.spec.mjs index eb602768..cf1f87fc 100644 --- a/tests/browser/navigation-authority.spec.mjs +++ b/tests/browser/navigation-authority.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "./fixture.mjs"; -test.use({ pluginFixtures: true }); +test.use({ pluginFixtures: true, historyCounts: { alpha: 0, beta: 0 } }); for (const operation of ["complete", "resolve"]) { test(`removed provider cannot ${operation} its old pending request`, async ({ page, diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index a0992d66..35df65c2 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -1,269 +1,230 @@ -import { test, expect } from "@playwright/test"; -import { createServer } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; +import { test, expect } from "./source-fixture.mjs"; test("profile plumbing: exact avatar/mention targets, thread enrichment, lifecycle and recovery", async ({ page, }, testInfo) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0, strictPort: false }, - }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - try { - await server.listen(); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/profiles.html`, - ); - const panel = page.getByRole("complementary", { - name: "Profile", - exact: true, - }); - await page.waitForFunction(() => !!window.profilesFixture); - const npubs = await page.evaluate(() => window.profilesFixture.npubs); - const key = page - .getByRole("complementary", { name: "Profile", exact: true }) - .locator("code"); - const avatar = page.getByRole("button", { - name: "View Viewer profile", - exact: true, - }); - await expect(avatar).toHaveCount(1); - await avatar.focus(); - await avatar.press("Enter"); - await expect(key).toHaveText(npubs.viewer); - const portrait = panel.getByRole("img", { name: "Viewer avatar" }); - await expect(portrait).toBeVisible(); - const name = panel.getByRole("heading", { name: "Viewer", exact: true }); - const portraitWidth = await portrait.evaluate( - (element) => element.getBoundingClientRect().width, + await page.goto("/tests/fixtures/profiles.html"); + const panel = page.getByRole("complementary", { + name: "Profile", + exact: true, + }); + await page.waitForFunction(() => !!window.profilesFixture); + const npubs = await page.evaluate(() => window.profilesFixture.npubs); + const key = page + .getByRole("complementary", { name: "Profile", exact: true }) + .locator("code"); + const avatar = page.getByRole("button", { + name: "View Viewer profile", + exact: true, + }); + await expect(avatar).toHaveCount(1); + await avatar.focus(); + await avatar.press("Enter"); + await expect(key).toHaveText(npubs.viewer); + const portrait = panel.getByRole("img", { name: "Viewer avatar" }); + await expect(portrait).toBeVisible(); + const name = panel.getByRole("heading", { name: "Viewer", exact: true }); + const portraitWidth = await portrait.evaluate( + (element) => element.getBoundingClientRect().width, + ); + const contentWidth = await portrait.evaluate((element) => { + const region = element.closest('[aria-label="Profile details"]'); + return ( + region.clientWidth - + parseFloat(getComputedStyle(region).paddingLeft) - + parseFloat(getComputedStyle(region).paddingRight) ); - const contentWidth = await portrait.evaluate((element) => { - const region = element.closest('[aria-label="Profile details"]'); - return ( - region.clientWidth - - parseFloat(getComputedStyle(region).paddingLeft) - - parseFloat(getComputedStyle(region).paddingRight) - ); + }); + const maxPortraitWidth = await page.evaluate(() => + Math.min(256, innerHeight * 0.35), + ); + expect(portraitWidth).toBeCloseTo( + Math.min(contentWidth, maxPortraitWidth), + 0, + ); + expect( + await portrait.evaluate( + (element) => element.getBoundingClientRect().height, + ), + ).toBeCloseTo(portraitWidth, 0); + expect( + await name.evaluate( + (element, portrait) => + element.getBoundingClientRect().top >= + portrait.getBoundingClientRect().bottom, + await portrait.elementHandle(), + ), + ).toBe(true); + expect( + await page.evaluate(() => window.profilesFixture.report.media), + ).toContainEqual(["https://images.test/avatar.png", undefined]); + await expect(panel.getByText("Human profile", { exact: true })).toBeVisible(); + // Stub clipboard at the browser boundary: test the UI's exact key and + // recovery, without relying on OS permission/pasteboard behavior in CI. + await page.evaluate(() => { + window.profileCopies = []; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: async (value) => window.profileCopies.push(value) }, }); - const maxPortraitWidth = await page.evaluate(() => - Math.min(256, innerHeight * 0.35), - ); - expect(portraitWidth).toBeCloseTo( - Math.min(contentWidth, maxPortraitWidth), - 0, - ); - expect( - await portrait.evaluate( - (element) => element.getBoundingClientRect().height, - ), - ).toBeCloseTo(portraitWidth, 0); - expect( - await name.evaluate( - (element, portrait) => - element.getBoundingClientRect().top >= - portrait.getBoundingClientRect().bottom, - await portrait.elementHandle(), - ), - ).toBe(true); - expect( - await page.evaluate(() => window.profilesFixture.report.media), - ).toContainEqual(["https://images.test/avatar.png", undefined]); - await expect( - panel.getByText("Human profile", { exact: true }), - ).toBeVisible(); - // Stub clipboard at the browser boundary: test the UI's exact key and - // recovery, without relying on OS permission/pasteboard behavior in CI. - await page.evaluate(() => { - window.profileCopies = []; - Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { writeText: async (value) => window.profileCopies.push(value) }, + }); + await panel.getByRole("button", { name: "Copy npub" }).click(); + await expect(panel.getByRole("status")).toHaveText("Public key copied."); + expect(await page.evaluate(() => window.profileCopies)).toEqual([ + npubs.viewer, + ]); + await page.evaluate(() => { + navigator.clipboard.writeText = async () => { + throw new Error("denied"); + }; + }); + await panel.getByRole("button", { name: "Copy npub" }).click(); + await expect(panel.getByRole("status")).toHaveText( + "Could not copy. Select the public key above to copy it.", + ); + await expect(key).toHaveText(npubs.viewer); + await page.getByRole("region", { name: "Profile details" }).press("Escape"); + await expect(panel).toHaveCount(0); + await expect(avatar).toBeFocused(); + const mention = page.getByRole("button", { + name: "View Mic profile", + exact: true, + }); + await mention.click(); + await expect(key).toHaveText(npubs.mic); + await panel.getByRole("button", { name: "Close channel panel" }).click(); + await expect(mention).toBeFocused(); + await mention.press("Space"); + await expect(key).toHaveText(npubs.mic); + await page.evaluate(() => + window.profilesFixture.change("disable", "buzz.profiles"), + ); + await expect(panel).toHaveCount(0); + await expect(mention).toHaveCount(0); + await expect( + page + .getByRole("region", { name: "Channel message history" }) + .getByText("Hello @Mic", { exact: true }), + ).toBeVisible(); + await page.evaluate(() => + window.profilesFixture.change("enable", "buzz.profiles"), + ); + await expect(mention).toHaveCount(1); + await expect(panel).toHaveCount(0); + await page + .getByRole("button", { name: "View thread: 1 reply", exact: true }) + .click(); + const threadMention = page.getByRole("button", { + name: "View Pinky profile", + exact: true, + }); + await expect(threadMention).toBeVisible(); + await threadMention.click(); + await expect(key).toHaveText(npubs.pinky); + await expect(panel.getByText("Agent profile", { exact: true })).toBeVisible(); + await panel.getByRole("button", { name: "Close channel panel" }).click(); + await expect( + page.getByRole("button", { name: "View thread: 1 reply", exact: true }), + ).toBeFocused(); + const missingKey = await page.evaluate( + () => window.profilesFixture.keys.missing, + ); + await page + .getByRole("button", { name: `View ${missingKey.slice(0, 10)} profile` }) + .click(); + await expect(key).toHaveText(npubs.missing); + await expect(panel.getByText("Could not load this profile.")).toBeVisible(); + await page.evaluate(() => window.profilesFixture.recover()); + await panel.getByRole("button", { name: "Retry profile" }).click(); + await expect(panel.getByText("Recovered biography")).toBeVisible(); + for (const mode of ["light", "dark"]) { + if (mode === "dark") + await page.getByRole("button", { name: "Toggle appearance" }).click(); + for (const width of [1280, 900, 390]) { + await page.setViewportSize({ width, height: 800 }); + await expect(key).toBeVisible(); + const layout = await panel.evaluate((element) => { + const region = element.querySelector('[aria-label="Profile details"]'); + const key = element.querySelector("code"); + return { + padding: parseFloat(getComputedStyle(region).paddingLeft), + overflow: element.scrollWidth > element.clientWidth, + keyOverflow: key.scrollWidth > key.clientWidth, + }; + }); + expect(layout.padding).toBeGreaterThanOrEqual(16); + expect(layout.overflow).toBe(false); + expect(layout.keyOverflow).toBe(false); + await panel.screenshot({ + path: testInfo.outputPath(`profile-${mode}-${width}.png`), }); - }); - await panel.getByRole("button", { name: "Copy npub" }).click(); - await expect(panel.getByRole("status")).toHaveText("Public key copied."); - expect(await page.evaluate(() => window.profileCopies)).toEqual([ - npubs.viewer, - ]); - await page.evaluate(() => { - navigator.clipboard.writeText = async () => { - throw new Error("denied"); - }; - }); - await panel.getByRole("button", { name: "Copy npub" }).click(); - await expect(panel.getByRole("status")).toHaveText( - "Could not copy. Select the public key above to copy it.", - ); - await expect(key).toHaveText(npubs.viewer); - await page.getByRole("region", { name: "Profile details" }).press("Escape"); - await expect(panel).toHaveCount(0); - await expect(avatar).toBeFocused(); - const mention = page.getByRole("button", { - name: "View Mic profile", - exact: true, - }); - await mention.click(); - await expect(key).toHaveText(npubs.mic); - await panel.getByRole("button", { name: "Close channel panel" }).click(); - await expect(mention).toBeFocused(); - await mention.press("Space"); - await expect(key).toHaveText(npubs.mic); - await page.evaluate(() => - window.profilesFixture.change("disable", "buzz.profiles"), - ); - await expect(panel).toHaveCount(0); - await expect(mention).toHaveCount(0); - await expect( - page - .getByRole("region", { name: "Channel message history" }) - .getByText("Hello @Mic", { exact: true }), - ).toBeVisible(); - await page.evaluate(() => - window.profilesFixture.change("enable", "buzz.profiles"), - ); - await expect(mention).toHaveCount(1); - await expect(panel).toHaveCount(0); - await page - .getByRole("button", { name: "View thread: 1 reply", exact: true }) - .click(); - const threadMention = page.getByRole("button", { - name: "View Pinky profile", - exact: true, - }); - await expect(threadMention).toBeVisible(); - await threadMention.click(); - await expect(key).toHaveText(npubs.pinky); - await expect( - panel.getByText("Agent profile", { exact: true }), - ).toBeVisible(); - await panel.getByRole("button", { name: "Close channel panel" }).click(); - await expect( - page.getByRole("button", { name: "View thread: 1 reply", exact: true }), - ).toBeFocused(); - const missingKey = await page.evaluate( - () => window.profilesFixture.keys.missing, - ); - await page - .getByRole("button", { name: `View ${missingKey.slice(0, 10)} profile` }) - .click(); - await expect(key).toHaveText(npubs.missing); - await expect(panel.getByText("Could not load this profile.")).toBeVisible(); - await page.evaluate(() => window.profilesFixture.recover()); - await panel.getByRole("button", { name: "Retry profile" }).click(); - await expect(panel.getByText("Recovered biography")).toBeVisible(); - for (const mode of ["light", "dark"]) { - if (mode === "dark") - await page.getByRole("button", { name: "Toggle appearance" }).click(); - for (const width of [1280, 900, 390]) { - await page.setViewportSize({ width, height: 800 }); - await expect(key).toBeVisible(); - const layout = await panel.evaluate((element) => { - const region = element.querySelector( - '[aria-label="Profile details"]', - ); - const key = element.querySelector("code"); - return { - padding: parseFloat(getComputedStyle(region).paddingLeft), - overflow: element.scrollWidth > element.clientWidth, - keyOverflow: key.scrollWidth > key.clientWidth, - }; - }); - expect(layout.padding).toBeGreaterThanOrEqual(16); - expect(layout.overflow).toBe(false); - expect(layout.keyOverflow).toBe(false); - await panel.screenshot({ - path: testInfo.outputPath(`profile-${mode}-${width}.png`), - }); - } } - await page.evaluate(() => window.profilesFixture.replace()); - await expect(panel).toHaveCount(0); - const reads = await page.evaluate(() => ({ - reads: window.profilesFixture.report.profileReads, - key: window.profilesFixture.keys.pinky, - })); - expect(reads.reads.some((batch) => batch.includes(reads.key))).toBe(true); - expect(errors).toEqual([]); - } finally { - await server.close(); } + await page.evaluate(() => window.profilesFixture.replace()); + await expect(panel).toHaveCount(0); + const reads = await page.evaluate(() => ({ + reads: window.profilesFixture.report.profileReads, + key: window.profilesFixture.keys.pinky, + })); + expect(reads.reads.some((batch) => batch.includes(reads.key))).toBe(true); + expect(errors).toEqual([]); }); test("contextual panel callbacks retire with opening, channel, contribution and session", async ({ page, }) => { - const server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0, strictPort: false }, + await page.goto("/tests/fixtures/profiles.html?context-probe"); + const avatar = page.getByRole("button", { + name: "View Viewer profile", + exact: true, }); - try { - await server.listen(); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/profiles.html?context-probe`, - ); - const avatar = page.getByRole("button", { - name: "View Viewer profile", - exact: true, - }); - const panel = page.getByRole("complementary", { - name: "Context probe", - exact: true, + const panel = page.getByRole("complementary", { + name: "Context probe", + exact: true, + }); + const capture = async () => { + await avatar.click(); + await expect(panel).toBeVisible(); + await page.evaluate(() => { + window.oldPanelContext = window.profilesFixture.contexts.at(-1); }); - const capture = async () => { - await avatar.click(); - await expect(panel).toBeVisible(); - await page.evaluate(() => { - window.oldPanelContext = window.profilesFixture.contexts.at(-1); - }); - }; - const invoke = () => - page.evaluate(() => - window.oldPanelContext.open(window.profilesFixture.targets.mic), - ); - await capture(); - expect(await page.evaluate(() => window.oldPanelContext.channelId)).toBe( - "one", - ); - expect(await invoke()).toBe(true); - // A second synchronous use of the old opening must not replace its successor. - expect(await invoke()).toBe(false); - await panel.getByRole("button", { name: "Close channel panel" }).click(); - await capture(); - await page.locator('[data-channel-id="two"]').click(); - await expect(panel).toHaveCount(0); - expect(await invoke()).toBe(false); - await page.locator('[data-channel-id="one"]').click(); - await capture(); - await page.evaluate(() => - window.profilesFixture.change("disable", "context.probe"), + }; + const invoke = () => + page.evaluate(() => + window.oldPanelContext.open(window.profilesFixture.targets.mic), ); - expect(await invoke()).toBe(false); - await expect(panel).toHaveCount(0); - await page.evaluate(() => - window.profilesFixture.change("enable", "context.probe"), - ); - await expect(avatar).toBeVisible(); - expect(await invoke()).toBe(false); - await capture(); - await page.evaluate(() => window.profilesFixture.replace()); - expect(await invoke()).toBe(false); - await expect(panel).toHaveCount(0); - await capture(); - await page.evaluate(() => window.profilesFixture.disconnect()); - expect(await invoke()).toBe(false); - await expect(panel).toHaveCount(0); - } finally { - await server.close(); - } + await capture(); + expect(await page.evaluate(() => window.oldPanelContext.channelId)).toBe( + "one", + ); + expect(await invoke()).toBe(true); + // A second synchronous use of the old opening must not replace its successor. + expect(await invoke()).toBe(false); + await panel.getByRole("button", { name: "Close channel panel" }).click(); + await capture(); + await page.locator('[data-channel-id="two"]').click(); + await expect(panel).toHaveCount(0); + expect(await invoke()).toBe(false); + await page.locator('[data-channel-id="one"]').click(); + await capture(); + await page.evaluate(() => + window.profilesFixture.change("disable", "context.probe"), + ); + expect(await invoke()).toBe(false); + await expect(panel).toHaveCount(0); + await page.evaluate(() => + window.profilesFixture.change("enable", "context.probe"), + ); + await expect(avatar).toBeVisible(); + expect(await invoke()).toBe(false); + await capture(); + await page.evaluate(() => window.profilesFixture.replace()); + expect(await invoke()).toBe(false); + await expect(panel).toHaveCount(0); + await capture(); + await page.evaluate(() => window.profilesFixture.disconnect()); + expect(await invoke()).toBe(false); + await expect(panel).toHaveCount(0); }); diff --git a/tests/browser/settings.spec.mjs b/tests/browser/settings.spec.mjs index 34eec25e..7c7c4ca0 100644 --- a/tests/browser/settings.spec.mjs +++ b/tests/browser/settings.spec.mjs @@ -1,5 +1,7 @@ import { test, expect } from "./fixture.mjs"; +test.use({ historyCounts: { alpha: 1, beta: 0 } }); + const button = (page, name) => page.getByRole("button", { name, exact: true }); test("avatar Settings access dismisses cleanly and exposes Profile and Plugins", async ({ diff --git a/tests/browser/source-fixture.mjs b/tests/browser/source-fixture.mjs new file mode 100644 index 00000000..52919728 --- /dev/null +++ b/tests/browser/source-fixture.mjs @@ -0,0 +1,31 @@ +import { test as base, expect } from "@playwright/test"; +import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; +import { createServer } from "./vite-server.mjs"; + +// Only stateless source serving is shared. Playwright still owns a fresh context +// per test; fixtures with mutable server middleware must keep their own server. +export const test = base.extend({ + sourceOrigin: [ + // biome-ignore lint/correctness/noEmptyPattern: Playwright requires a destructured fixture argument. + async ({}, use) => { + const server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envFile: false, + plugins: [react()], + logLevel: "error", + server: { host: "127.0.0.1", port: 0, strictPort: true }, + }); + try { + await server.listen(); + await use(`http://127.0.0.1:${server.httpServer.address().port}`); + } finally { + await server.close(); + } + }, + { scope: "worker" }, + ], + baseURL: async ({ sourceOrigin }, use) => use(sourceOrigin), +}); +export { expect }; diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs index 9776da3d..4bf5f634 100644 --- a/tests/browser/typeahead.spec.mjs +++ b/tests/browser/typeahead.spec.mjs @@ -1,27 +1,7 @@ -import { test, expect } from "@playwright/test"; -import { createServer } from "./vite-server.mjs"; -import react from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; +import { test, expect } from "./source-fixture.mjs"; -let server; -test.beforeAll(async () => { - server = await createServer({ - root: fileURLToPath(new URL("../../", import.meta.url)), - configFile: false, - envFile: false, - plugins: [react()], - logLevel: "error", - server: { host: "127.0.0.1", port: 0 }, - }); - await server.listen(); -}); -test.afterAll(async () => { - await server?.close(); -}); const open = async (page) => { - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html`, - ); + await page.goto("/tests/fixtures/mentions.html"); return page.getByRole("textbox", { name: "Message #General" }); }; test("typeahead replaces only the query and publishes selected namesake identity, including replies", async ({ @@ -163,9 +143,7 @@ test("completion resumes after selection collapses to the original caret", async test("selection recovery requires fresh results and preserves Escape dismissal", async ({ page, }) => { - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/typeahead.html`, - ); + await page.goto("/tests/fixtures/typeahead.html"); const input = page.getByRole("textbox", { name: "Message #Test" }); const requests = () => page.evaluate(() => window.completionFixture.queries().length); @@ -249,9 +227,7 @@ test("textarea exposes its listbox popup relationship only while suggestions are test("late publications cannot cross edits, ABA, Escape, blur, plugin replacement or destinations", async ({ page, }) => { - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/typeahead.html`, - ); + await page.goto("/tests/fixtures/typeahead.html"); const input = page.getByRole("textbox", { name: "Message #Test" }); const latest = async () => { await expect @@ -304,9 +280,7 @@ test("late publications cannot cross edits, ABA, Escape, blur, plugin replacemen test("selection follows IDs through reordering and rejected replacement never falls through to send", async ({ page, }) => { - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/typeahead.html`, - ); + await page.goto("/tests/fixtures/typeahead.html"); const input = page.getByRole("textbox", { name: "Message #Test" }); await input.fill("!order"); await expect @@ -365,9 +339,7 @@ test("current custom catalog drives typeahead and signed tags across community r body: '', }), ); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/emoji.html`, - ); + await page.goto("/tests/fixtures/emoji.html"); const input = page.getByRole("textbox", { name: "Message #general" }); await input.fill(":very-long"); const longOption = page.getByRole("option", { @@ -720,9 +692,7 @@ test("mention choices survive unrelated list updates but revoke removed membersh test("cold multi-word mention query wakes when profiles arrive without another keystroke", async ({ page, }) => { - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html?delayed-profiles`, - ); + await page.goto("/tests/fixtures/mentions.html?delayed-profiles"); const input = page.getByRole("textbox", { name: "Message #General" }); await input.fill("@Mary J"); await expect(page.getByRole("option", { name: /^Mary Jane / })).toHaveCount( @@ -739,9 +709,7 @@ test("cold multi-word mention query wakes when profiles arrive without another k test("recovery is a keyboard-selectable action without transferring editor focus", async ({ page, }) => { - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/typeahead.html`, - ); + await page.goto("/tests/fixtures/typeahead.html"); const input = page.getByRole("textbox", { name: "Message #Test" }); for (const withChoice of [false, true]) { await input.fill(`!retry-${withChoice}`); @@ -776,9 +744,7 @@ test("channel and actual ThreadPanel composers keep separate completion and draf page, }) => { await page.setViewportSize({ width: 1000, height: 520 }); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/messages.html`, - ); + await page.goto("/tests/fixtures/messages.html"); const main = page.getByRole("textbox", { name: "Message #one" }); const thread = page.getByRole("textbox", { name: "Reply to thread" }); await expect(thread).toBeVisible(); @@ -804,9 +770,7 @@ test("channel and actual ThreadPanel composers keep separate completion and draf test("disabled and read-only DOM state reject late publications and displayed choices", async ({ page, }) => { - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/typeahead.html`, - ); + await page.goto("/tests/fixtures/typeahead.html"); const input = page.getByRole("textbox", { name: "Message #Test" }); await input.fill("!disabled"); const old = await page.evaluate( @@ -879,9 +843,7 @@ test("portal bounds hold when the focused composer moves outside the viewport", page, }) => { await page.setViewportSize({ width: 800, height: 300 }); - await page.goto( - `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/typeahead.html`, - ); + await page.goto("/tests/fixtures/typeahead.html"); const input = page.getByRole("textbox", { name: "Message #Test" }); await input.fill("!geometry"); const index = await page.evaluate(