From 49f1439dae6f1df72d1a0bf4f30267f80bdc532a Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Sat, 22 Aug 2026 02:33:28 +0800 Subject: [PATCH] test: migrate to vitest, split the monolithic client spec into modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vitest is the runner (matches the official harness); the two bare-node scripts and the manual repro become 13 spec files under tests/ - the 1637-line client.test.mjs splits into focused modules (chart / browser / linkage / composition-link / tool-chip-bridge / browser-autoload / images / modal / resilience), each booting a fresh view bed from tests/helpers/viewBed.mjs — the stateful fake-React harness, fake ctx, fixtures, and tree walkers now live in one place - tests/hmr-safety.client.spec.mjs (jsdom lane, @vitest-environment): fiber dispose removes every registration — locale dicts, slot rows, trigger source, provide contribution, and the plugin-owned style tag - host spec gains the same dispose-removes-registrations pin for the two session-projection units - the real-React jsdom mount (was npm run repro, broken since the tsdown switch by its own string surgery) now runs the bundle verbatim through __ModuleLoader__ and is part of the suite; fixed two latent harness bugs along the way: the slot-register capture was overwritten by the later overlay registration, and the granularity query needed scoping to the trend row (the events card reuses the pill-button classes) - bundle evaluation in all specs runs the artifact verbatim via the boot handoff — no string surgery, decoupled from bundler layout - scripts.test is now typecheck + vitest run; scripts.repro is gone --- package.json | 6 +- pnpm-lock.yaml | 459 +++++++++ tests/browser-autoload.spec.mjs | 56 + tests/browser.spec.mjs | 294 ++++++ tests/chart.spec.mjs | 504 +++++++++ tests/client.spec.mjs | 178 ++++ tests/client.test.mjs | 1637 ------------------------------ tests/composition-link.spec.mjs | 106 ++ tests/helpers/viewBed.mjs | 315 ++++++ tests/hmr-safety.client.spec.mjs | 96 ++ tests/host.spec.mjs | 498 +++++++++ tests/host.test.mjs | 470 --------- tests/images.spec.mjs | 68 ++ tests/linkage.spec.mjs | 62 ++ tests/modal.spec.mjs | 67 ++ tests/real-react.client.spec.mjs | 160 +++ tests/repro-real-react.mjs | 161 --- tests/resilience.spec.mjs | 89 ++ tests/tool-chip-bridge.spec.mjs | 87 ++ vitest.config.mjs | 10 + 20 files changed, 3052 insertions(+), 2271 deletions(-) create mode 100644 tests/browser-autoload.spec.mjs create mode 100644 tests/browser.spec.mjs create mode 100644 tests/chart.spec.mjs create mode 100644 tests/client.spec.mjs delete mode 100644 tests/client.test.mjs create mode 100644 tests/composition-link.spec.mjs create mode 100644 tests/helpers/viewBed.mjs create mode 100644 tests/hmr-safety.client.spec.mjs create mode 100644 tests/host.spec.mjs delete mode 100644 tests/host.test.mjs create mode 100644 tests/images.spec.mjs create mode 100644 tests/linkage.spec.mjs create mode 100644 tests/modal.spec.mjs create mode 100644 tests/real-react.client.spec.mjs delete mode 100644 tests/repro-real-react.mjs create mode 100644 tests/resilience.spec.mjs create mode 100644 tests/tool-chip-bridge.spec.mjs create mode 100644 vitest.config.mjs diff --git a/package.json b/package.json index be85529..ae00ed8 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,9 @@ "watch": "tsdown --watch", "register": "bash scripts/register.sh", "typecheck": "tsc --noEmit", - "test": "npm run typecheck && node tests/host.test.mjs && node tests/client.test.mjs", + "test": "npm run typecheck && vitest run", "release": "bash scripts/publish.sh", "hooks": "bash scripts/install-hooks.sh", - "repro": "node tests/repro-real-react.mjs", "social-preview": "node docs/social-preview/generate.mjs" }, "dsh": { @@ -92,7 +91,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tsdown": "^0.22.14", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vitest": "^4.1.11" }, "packageManager": "pnpm@11.9.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc19ec4..40e2ce2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: typescript: specifier: ^7.0.2 version: 7.0.2 + vitest: + specifier: ^4.1.11 + version: 4.1.11(jsdom@30.0.1)(vite@8.2.2) packages: @@ -191,6 +194,9 @@ packages: '@noble/hashes': optional: true + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@oxc-project/types@0.146.0': resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} @@ -330,9 +336,18 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -477,6 +492,35 @@ packages: '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@yuku-codegen/binding-android-arm64@0.8.7': resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} cpu: [arm64] @@ -619,6 +663,10 @@ packages: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -629,6 +677,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -649,6 +701,9 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -682,6 +737,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -702,10 +761,20 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -715,6 +784,11 @@ packages: picomatch: optional: true + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + get-tsconfig@5.0.0-beta.5: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} @@ -758,6 +832,80 @@ packages: resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} hasBin: true + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -769,6 +917,9 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -904,6 +1055,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -917,6 +1073,12 @@ packages: parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -926,6 +1088,10 @@ packages: engines: {node: '>=20'} hasBin: true + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -996,6 +1162,9 @@ packages: resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1003,12 +1172,21 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.3.0: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} @@ -1017,6 +1195,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tldts-core@7.4.10: resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} @@ -1113,6 +1295,90 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -1133,6 +1399,11 @@ packages: resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} 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'} @@ -1297,6 +1568,8 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@oxc-project/types@0.146.0': {} '@quansync/fs@1.0.0': @@ -1392,10 +1665,19 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -1479,6 +1761,47 @@ snapshots: '@ungap/structured-clone@1.3.3': {} + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2)': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2 + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@yuku-codegen/binding-android-arm64@0.8.7': optional: true @@ -1557,6 +1880,8 @@ snapshots: ansis@4.3.1: {} + assertion-error@2.0.1: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -1565,6 +1890,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -1577,6 +1904,8 @@ snapshots: commander@8.3.0: {} + convert-source-map@2.0.0: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -1605,6 +1934,8 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -1615,12 +1946,23 @@ snapshots: entities@8.0.0: {} + es-module-lexer@2.3.2: {} + escape-string-regexp@5.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + fsevents@2.3.3: + optional: true + get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 @@ -1689,6 +2031,55 @@ snapshots: dependencies: commander: 8.3.0 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -1697,6 +2088,10 @@ snapshots: lru-cache@11.5.2: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} mdast-util-find-and-replace@3.0.2: @@ -2030,6 +2425,8 @@ snapshots: ms@2.1.3: {} + nanoid@3.3.18: {} + obug@2.1.4: {} oniguruma-parser@0.12.2: {} @@ -2044,10 +2441,20 @@ snapshots: dependencies: entities: 8.0.0 + pathe@2.0.3: {} + + picocolors@1.1.1: {} + picomatch@4.0.5: {} playwright-core@1.62.1: {} + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + property-information@7.2.0: {} punycode@2.3.1: {} @@ -2132,10 +2539,16 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -2143,6 +2556,8 @@ snapshots: symbol-tree@3.2.4: {} + tinybench@2.9.0: {} + tinyexec@1.3.0: {} tinyglobby@0.2.17: @@ -2150,6 +2565,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.1: {} + tldts-core@7.4.10: {} tldts@7.4.10: @@ -2263,6 +2680,43 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@8.2.2: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@4.1.11(jsdom@30.0.1)(vite@8.2.2): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -2287,6 +2741,11 @@ snapshots: 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: {} diff --git a/tests/browser-autoload.spec.mjs b/tests/browser-autoload.spec.mjs new file mode 100644 index 0000000..1bdb563 --- /dev/null +++ b/tests/browser-autoload.spec.mjs @@ -0,0 +1,56 @@ +/** + * Context-browser auto-load: expanding an element whose seq is outside the + * loaded conversation window pages older history in (via the plugin's own + * sessions.provide contribution) until the join hits. + */ +import assert from 'node:assert/strict' +import { test } from 'vitest' +import { bootViewBed, byClass, textOf } from './helpers/viewBed.mjs' + +const bed = await bootViewBed() +const { renderView, snapshot } = bed +let tr + +test('context browser auto-load: loading note, one page pulled, joined content, no over-paging', async () => { + // ---- Context browser auto-load: expanding an element whose seq is outside + // the loaded conversation window pages older history in (via the plugin's + // own `sessions.provide` contribution) until the join hits. ---- + let sessionSnap = { nodes: [], hasMore: true, loadingOlder: false } + let loadCalls = 0 + bed.useSessionHolder = (sel) => sel(sessionSnap) + bed.loadOlderHolder = async () => { + loadCalls += 1 + // The page lands: seq 1's full content enters the window, history ends. + sessionSnap = { + nodes: [{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'FULL-MESSAGE-TEXT' }] }], + hasMore: false, + loadingOlder: false, + } + } + bed.dataValue = snapshot + // Mount the ContextBrowser fiber before addressing its hook slots. + tr = renderView() + const brSlots = bed.brSlots() + brSlots[1][1]('user') // reopen the user category + brSlots[2][1]('n1') // expand the out-of-window element + tr = renderView() + assert.match(textOf(byClass(tr, 'lc-br-content')[0]), /loading older history/, 'loading note while the page is being pulled') + // ContextBrowser hook slots: 0 sel, 1 openCat, 2 openElem, 3 exhausted, + // 4 pagesRef, 5 reset effect, 6 auto-load effect, 7 history-end effect, + // 8 pin-linkage effect. + // React runs effects in order post-render; drive them the same way. + brSlots[5].effect() + brSlots[6].effect() + assert.equal(loadCalls, 1, 'expanding pulls one older page') + tr = renderView() + const joined = textOf(byClass(tr, 'lc-br-content')[0]) + assert.match(joined, /FULL-MESSAGE-TEXT/, 'joined content replaces the preview once the page lands') + assert.ok(!/outside the loaded window/.test(joined), 'window note gone after the join hits') + // No further pages once the seq joined. + brSlots[6].effect() + assert.equal(loadCalls, 1, 'no extra pages after the join hits') + bed.useSessionHolder = undefined + bed.loadOlderHolder = undefined + + console.log('✔ context browser auto-load test passed (loading note, one page pulled, joined content, no over-paging)') +}) diff --git a/tests/browser.spec.mjs b/tests/browser.spec.mjs new file mode 100644 index 0000000..e06dcf9 --- /dev/null +++ b/tests/browser.spec.mjs @@ -0,0 +1,294 @@ +/** + * Context-browser spec: step picker, category accordion, per-step + * reconstruction with archived nodes, header content (system prompt + tool + * schemas), raw/markdown switches, parameter tables, graceful degradation, + * and previous-turn delta pills. + */ +import assert from 'node:assert/strict' +import { test } from 'vitest' +import { bootViewBed, byClass, catRowOf, textOf } from './helpers/viewBed.mjs' + +const bed = await bootViewBed() +const { hookStates, renderView, snapshot } = bed +let tr + +test('context browser: picker, accordion, reconstruction, header content, deltas, degradation', async () => { + // assistant) plus one archived (removed) node that was still alive at the + // early steps; one header epoch with full prompt/schema content. ---- + bed.dataValue = { + ...snapshot, + archive: [{ seq: 0, cat: 'user', tokens: 5, text: 'archived message', gone: 3, time: 500 }], + } + bed.headersValue = { + headers: [{ + seq: 1, time: 900, system: 'SYSTEM-PROMPT-TEXT', + // Listed in producer order (tiny BEFORE bash) — the tools section must + // re-rank them by token price, largest first. `bash` carries a real + // JSON-Schema parameter object so the parsed parameter table has rows + // to render; `tiny` ships an empty-parameter schema (table falls back + // to the "no parameters" line). + tools: [ + { name: 'tiny', tokens: 2, description: 'a tiny helper', schema: { name: 'tiny', parameters: { type: 'object' } } }, + { + name: 'bash', tokens: 5, description: 'run a command', + schema: { + name: 'bash', + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: 'shell command to run' }, + cwd: { type: 'string', description: 'working directory' }, + timeout: { type: 'number' }, + flags: { + type: 'array', + items: { type: 'string' }, + description: 'extra flags', + }, + }, + required: ['command'], + }, + }, + }, + ], + }], + } + tr = renderView() + const brKey = [...hookStates.keys()].find(k => k.includes('ContextBrowser')) + assert.ok(brKey, 'ContextBrowser fiber registered') + const brSlots = hookStates.get(brKey) // sel(0) openCat(1) openElem(2) + const ctxSlots = bed.ctxSlots() // selected(0) hovered(1) hoverTurn(2) tick(3) gran(4) hoverCat(5) + + // Live view (default): six category rows; message counts follow the live nodes. + assert.equal(byClass(tr, 'lc-br-cat-row').length, 6, 'six category sections (system/tools + four message cats)') + assert.equal(byClass(tr, 'lc-br-pick').length, 1, 'step picker present') + const pickOptions = byClass(tr, 'lc-br-pick')[0].args.slice(2).flat() + assert.equal(pickOptions.length, 5, 'picker lists live + one option per retained step') + assert.equal(byClass(tr, 'lc-br-body').length, 0, 'all categories collapsed by default (no flat dump)') + + // Open the user category -> one live element row; open the element -> content + // falls back to the preview + the window note (no useSession in this harness). + assert.ok(catRowOf(tr, 'User'), 'user category row present') + brSlots[1][1]('user') // openCat('user') + tr = renderView() + assert.equal(byClass(tr, 'lc-br-body').length, 1, 'one category body open') + assert.equal(byClass(tr, 'lc-br-elem-row').length, 1, 'live view lists the live user node') + brSlots[2][1]('n1') // openElem(seq 1) + tr = renderView() + assert.equal(byClass(tr, 'lc-br-content').length, 1, 'element content area open') + assert.match(textOf(byClass(tr, 'lc-br-content')[0]), /first message/, 'content falls back to the node preview') + assert.match(textOf(byClass(tr, 'lc-br-content')[0]), /outside the loaded window/, 'window note follows the fallback preview') + // The message detail card carries the raw/markdown switch too (a segmented + // pill like the trend chart's Step/Turn), defaulting to MARKDOWN: the + // preview renders through MarkdownText while the window note stays. + const msgSeg = byClass(byClass(tr, 'lc-br-content')[0], 'lc-rich-seg')[0] + assert.ok(msgSeg, 'message detail card carries the raw/markdown switch') + const msgSegBtns = byClass(msgSeg, 'lc-rich-seg-btn') + assert.deepEqual(msgSegBtns.map(b => textOf(b)), ['Raw', 'Markdown'], 'one segment per view') + assert.match(msgSegBtns[1].args[1].className, /lc-rich-seg-on/, 'markdown segment is active by default') + assert.equal(msgSegBtns[0].args[1].title, 'View Raw Text', 'raw segment tooltip') + assert.equal(msgSegBtns[1].args[1].title, 'View as Markdown', 'markdown segment tooltip') + assert.equal(textOf(byClass(tr, 'lc-md-stub')[0]), 'first message', 'markdown view carries the message text') + assert.match(textOf(byClass(tr, 'lc-br-content')[0]), /outside the loaded window/, 'window note stays in markdown mode') + // The Raw segment restores the raw
; picking Markdown again flips back.
+  msgSegBtns[0].args[1].onClick()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-md-stub').length, 0, 'raw mode drops the markdown renderer')
+  assert.match(textOf(byClass(tr, 'lc-br-pre')[0]), /first message/, 'raw view carries the message text')
+  assert.match(byClass(tr, 'lc-rich-seg-btn')[0].args[1].className, /lc-rich-seg-on/, 'raw segment lights up')
+  byClass(tr, 'lc-rich-seg-btn')[1].args[1].onClick()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-md-stub').length, 1, 'switching back restores the markdown view')
+
+  // Pick step seq 2 (Turn 1 · Step 1): the reconstruction includes the archived
+  // node (gone 3 > 2) — the accordion resets on picking, so reopen the category.
+  byClass(tr, 'lc-br-pick')[0].args[1].onChange({ target: { value: '2' } })
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-br-body').length, 0, 'picking a step collapses the accordion')
+  brSlots[1][1]('user')
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-br-elem-row').length, 2, 'a past step reconstructs archived + live nodes')
+  assert.match(textOf(byClass(tr, 'lc-br-body')[0]), /archived message/, 'the archived node appears in its step')
+
+  // The live view must NOT show the archived node.
+  byClass(tr, 'lc-br-pick')[0].args[1].onChange({ target: { value: 'live' } })
+  tr = renderView()
+  brSlots[1][1]('user')
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-br-elem-row').length, 1, 'live view excludes removed nodes')
+
+  // Header content sections: the system prompt and tool schemas ride the
+  // contextHeaders projection (full content, not just prices).
+  brSlots[1][1]('system')
+  tr = renderView()
+  brSlots[2][1]('sys')
+  tr = renderView()
+  assert.match(textOf(byClass(tr, 'lc-br-body')[0]), /SYSTEM-PROMPT-TEXT/, 'system section shows the full prompt')
+
+  // Raw/markdown view switch on the system-prompt detail card, defaulting to
+  // MARKDOWN; the Raw segment restores the raw 
 and Markdown flips back.
+  assert.ok(byClass(tr, 'lc-rich-seg').length >= 1, 'system detail card carries the raw/markdown switch')
+  const sysSegBtns = () => byClass(byClass(tr, 'lc-rich-seg')[0], 'lc-rich-seg-btn')
+  assert.match(sysSegBtns()[1].args[1].className, /lc-rich-seg-on/, 'markdown segment is active by default')
+  const sysMd = byClass(tr, 'lc-md-stub')
+  assert.equal(sysMd.length, 1, 'markdown mode renders the prompt through MarkdownText')
+  assert.equal(textOf(sysMd[0]), 'SYSTEM-PROMPT-TEXT', 'markdown view carries the same source text')
+  assert.equal(byClass(tr, 'lc-br-body')[0] && byClass(byClass(tr, 'lc-br-body')[0], 'lc-br-pre').length, 0,
+    'markdown mode drops the raw 
')
+  sysSegBtns()[0].args[1].onClick()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-md-stub').length, 0, 'raw mode drops the markdown renderer')
+  assert.match(textOf(byClass(tr, 'lc-br-pre')[0]), /SYSTEM-PROMPT-TEXT/, 'raw view shows the prompt')
+  sysSegBtns()[1].args[1].onClick()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-md-stub').length, 1, 'switching back restores the markdown view')
+  brSlots[1][1]('tools')
+  tr = renderView()
+  assert.match(textOf(byClass(tr, 'lc-br-body')[0]), /bash/, 'tools section lists the schema rows')
+  const toolRowOrder = byClass(tr, 'lc-br-elem-row').map(r => textOf(r))
+  assert.equal(toolRowOrder.length, 2, 'both schemas listed')
+  assert.match(toolRowOrder[0], /bash/, 'tools ranked by tokens: largest first')
+  assert.match(toolRowOrder[1], /tiny/, 'tools ranked by tokens: smallest last')
+  brSlots[2][1]('tool:bash')
+  tr = renderView()
+  const toolContent = textOf(byClass(tr, 'lc-br-content')[0])
+  assert.match(toolContent, /run a command/, 'tool row expands to its description')
+  // The description sits inside its own titled card, with a "Description" head
+  // and a body carrying the prose — the same chrome the parameter table uses.
+  const descCards = byClass(tr, 'lc-ts-card').filter(c => {
+    const head = byClass(c, 'lc-ts-card-head')[0]
+    return head !== undefined && textOf(head).includes('Description')
+  })
+  assert.equal(descCards.length, 1, 'description is rendered inside a titled card')
+  // The description card head carries the same raw/markdown switch, also
+  // defaulting to MARKDOWN; the Raw segment restores the raw body (and back).
+  const descSeg = byClass(descCards[0], 'lc-rich-seg')[0]
+  assert.ok(descSeg, 'description card head carries the raw/markdown switch')
+  const descMd = byClass(descCards[0], 'lc-ts-desc-md')
+  assert.equal(descMd.length, 1, 'description defaults to the markdown view')
+  assert.equal(textOf(byClass(descMd[0], 'lc-md-stub')[0]), 'run a command', 'markdown view carries the description text')
+  byClass(descSeg, 'lc-rich-seg-btn')[0].args[1].onClick()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-ts-desc-md').length, 0, 'raw mode drops the markdown body')
+  assert.match(textOf(byClass(tr, 'lc-ts-desc-body')[0]), /run a command/, 'raw view carries the prose')
+  byClass(tr, 'lc-rich-seg-btn')[1].args[1].onClick()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-ts-desc-md').length, 1, 'switching back restores the markdown view')
+  // Parsed parameter table sits above the (still-collapsed) raw JSON: one row
+  // per declared property, type labels carry the JSON-Schema type, required
+  // ones marked with ✓, descriptions shown on a second line.
+  const paramRows = byClass(tr, 'lc-ts-param-row')
+  assert.equal(paramRows.length, 4, 'parameter table renders one row per property')
+  const bashRowText = paramRows.map(r => textOf(r))
+  assert.ok(bashRowText.some(s => /command/.test(s) && /string/.test(s) && /shell command to run/.test(s)),
+    'command row carries name + type + description')
+  assert.ok(bashRowText.some(s => /command/.test(s) && /✓/.test(s)),
+    'command is marked required')
+  assert.ok(bashRowText.some(s => /timeout/.test(s) && /number/.test(s) && !/✓/.test(s)),
+    'optional property shows type without the required mark')
+  assert.ok(bashRowText.some(s => /flags/.test(s) && /array/.test(s)),
+    'array parameters render their element type')
+  // Raw JSON is collapsed by default — the toggle is visible but the schema
+  // string does NOT appear in the rendered text yet.
+  assert.equal(byClass(tr, 'lc-br-pre').filter(n => /"parameters"/.test(textOf(n))).length, 0,
+    'raw JSON stays collapsed behind the toggle by default')
+  const toggle = byClass(tr, 'lc-ts-json-toggle')[0]
+  assert.ok(toggle, 'JSON toggle button is rendered')
+  assert.match(textOf(toggle), /View Raw JSON|查看原始 JSON/, 'toggle shows the open label')
+  // Expanding the toggle reveals the schema; clicking again collapses it.
+  toggle.args[1].onClick()
+  tr = renderView()
+  assert.match(textOf(byClass(tr, 'lc-br-pre')[0]), /"parameters"/, 'expanding reveals the raw JSON')
+  const collapseToggle = byClass(tr, 'lc-ts-json-toggle')[0]
+  assert.match(textOf(collapseToggle), /Collapse|收起/, 'toggle label flips to the hide label')
+  collapseToggle.args[1].onClick()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-br-pre').length, 0, 'collapsing the toggle removes the JSON block')
+
+  // Without the contextHeaders key (older host), those sections degrade to a note.
+  bed.headersValue = undefined
+  brSlots[1][1]('system')
+  brSlots[2][1](null)
+  tr = renderView()
+  assert.match(textOf(byClass(tr, 'lc-br-body')[0]), /older plugin build/, 'absent headers projection degrades gracefully')
+  brSlots[1][1](null)
+  bed.dataValue = snapshot
+  tr = renderView()
+
+  // ---- Δ pills: count + token swings vs the PREVIOUS TURN's last request
+  // (one baseline whatever step/turn granularity; live = last request).
+  // The header caption states the baseline; each category row carries a count
+  // pill after "n items" and a token pill hugging the left of "≈X", each
+  // hidden while its figure held.
+  const deltaHint = byClass(tr, 'lc-br-hint')[0]
+  assert.ok(deltaHint, 'browser header carries the δ baseline caption')
+  assert.match(textOf(deltaHint), /previous turn/, 'caption names the previous-turn baseline')
+  bed.headersValue = {
+    headers: [{
+      seq: 1, time: 900, system: 'SYSTEM-PROMPT-TEXT',
+      tools: [
+        { name: 'tiny', tokens: 2, description: 'a tiny helper', schema: { name: 'tiny', parameters: { type: 'object' } } },
+        { name: 'bash', tokens: 5, description: 'run a command', schema: { name: 'bash', parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } } },
+      ],
+    }],
+  }
+  bed.dataValue = { ...snapshot, archive: [{ seq: 0, cat: 'user', tokens: 5, text: 'archived message', gone: 3, time: 500 }] }
+  tr = renderView()
+  // Pick step seq 3 (turn 2): baseline = turn 1's last request, seq 2. That
+  // step's assemble (seq 1 user + seq 2 assistant) vs seq 2's (archived seq 0
+  // + seq 1 user) reads user 1→2 (-1) and assistant 1→0 (+1); tokens ride the
+  // request records (user 40 vs 25, assistant 12 vs 10).
+  byClass(tr, 'lc-br-pick')[0].args[1].onChange({ target: { value: '3' } })
+  tr = renderView()
+  const countPills = byClass(tr, 'lc-br-delta')
+  assert.equal(countPills.length, 2, 'count pills: user -1, assistant +1')
+  assert.equal(textOf(countPills[0]), '-1', 'user lost one element vs the previous turn')
+  assert.equal(textOf(countPills[1]), '+1', 'assistant gained one element vs the previous turn')
+  assert.match(countPills[0].args[1].className, /down/, 'a shrunken category is red-tinted')
+  const tokenPills = byClass(tr, 'lc-br-tdelta')
+  assert.equal(tokenPills.length, 2, 'token pills: user +15, assistant +2')
+  assert.equal(textOf(tokenPills[0]), '+15', 'user tokens grew by 15 (40 vs 25)')
+  assert.equal(textOf(tokenPills[1]), '+2', 'assistant tokens grew by 2 (12 vs 10)')
+  // every category row still carries its count/tokens
+  assert.equal(byClass(tr, 'lc-br-tokens-grp').length, 6, 'token figure never leaves a row')
+
+  // ---- the baseline is the previous turn's last step in EITHER dimension:
+  // custom fixture, turn 2 has two steps and one user node joins per step, so
+  // viewing turn 2's last step (seq 4) reads +2 vs turn 1's last (seq 2, 1
+  // node) — stay identical after flipping the trend granularity to turn.
+  bed.dataValue = {
+    ok: true, model: 'm', provider: 'p', contextWindow: 1000,
+    current: { system: 1, tools: 2, user: 99, inject: 0, assistant: 0, tool: 0, total: 102 },
+    toolList: [],
+    requests: [
+      { seq: 1, turn: 1, step: 0, time: 1, system: 1, tools: 2, user: 10, inject: 0, assistant: 0, tool: 0, total: 13 },
+      { seq: 2, turn: 1, step: 1, time: 2, system: 1, tools: 2, user: 20, inject: 0, assistant: 0, tool: 0, total: 23 },
+      { seq: 3, turn: 2, step: 0, time: 3, system: 1, tools: 2, user: 30, inject: 0, assistant: 0, tool: 0, total: 33 },
+      { seq: 4, turn: 2, step: 1, time: 4, system: 1, tools: 2, user: 40, inject: 0, assistant: 0, tool: 0, total: 43 },
+      { seq: 5, turn: 3, step: 0, time: 5, system: 1, tools: 2, user: 50, inject: 0, assistant: 0, tool: 0, total: 53 },
+    ],
+    events: [],
+    nodes: [
+      { seq: 1, cat: 'user', tokens: 1, time: 1 },
+      { seq: 2, cat: 'user', tokens: 2, time: 2 },
+      { seq: 3, cat: 'user', tokens: 3, time: 3 },
+      { seq: 4, cat: 'user', tokens: 4, time: 4 },
+      { seq: 5, cat: 'user', tokens: 5, time: 5 },
+    ],
+    droppedNodes: 0,
+    archive: [],
+  }
+  tr = renderView()
+  byClass(tr, 'lc-br-pick')[0].args[1].onChange({ target: { value: '4' } })
+  tr = renderView()
+  const readDeltas = () => ({ count: textOf(byClass(tr, 'lc-br-delta')[0]), token: textOf(byClass(tr, 'lc-br-tdelta')[0]) })
+  assert.deepEqual(readDeltas(), { count: '+2', token: '+20' }, 'step granularity: seq 4 vs turn 1 last (seq 2)')
+  ctxSlots[4][1]('turn') // context view -> turn granularity
+  tr = renderView()
+  assert.deepEqual(readDeltas(), { count: '+2', token: '+20' }, 'turn granularity keeps the SAME previous-turn baseline, not seq 3')
+  // Live view: baseline = last request (seq 5, turn 3) — user 5 vs 4 (+1).
+  byClass(tr, 'lc-br-pick')[0].args[1].onChange({ target: { value: 'live' } })
+  tr = renderView()
+  assert.deepEqual(readDeltas(), { count: '+1', token: '+49' }, 'live compares against the most recent request')
+
+  console.log('✔ context browser test passed (picker, category accordion, per-step reconstruction, archived nodes, header content, graceful degradation)')
+})
diff --git a/tests/chart.spec.mjs b/tests/chart.spec.mjs
new file mode 100644
index 0000000..7c9349d
--- /dev/null
+++ b/tests/chart.spec.mjs
@@ -0,0 +1,504 @@
+/**
+ * Context-view trend-chart spec: fixed-width bars, horizontal scroll, turn
+ * ranges, stats board, plugin info card, hover tooltips, legend chips,
+ * granularity toggle, edge fades, full history, right-edge anchoring,
+ * message times, event range labels, and the overview headline.
+ */
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { test } from 'vitest'
+import { bootViewBed, byClass, plainText, textOf } from './helpers/viewBed.mjs'
+
+const bed = await bootViewBed()
+const { hookStates, renderView, snapshot } = bed
+
+test('context view: trend chart, stats board, plugin info, hover linking, granularity, fades, anchoring, events, overview headline', async () => {
+  // The ContextView fiber: the boundary wrapper owns an EMPTY hook slot array,
+  // so the data-driven body's fiber is the one carrying the view hooks.
+  const ctxKey = [...hookStates.keys()].find(k => k.includes('ContextView') && hookStates.get(k).length > 0)
+  assert.ok(ctxKey, 'ContextView fiber registered')
+  bed.dataValue = snapshot
+  const tree = renderView()
+
+  const bars = byClass(tree, 'lc-bar')
+  assert.equal(bars.length, 4, 'one bar per request')
+  const turns = byClass(tree, 'lc-turn')
+  assert.equal(turns.length, 3, 'three turn ranges (T1 has two bars, T2/T3 one each)')
+  assert.deepEqual(turns.map(t => t.args[2]), ['T1', 'T2', 'T3'], 'turn labels in order')
+  const turnWidths = turns.map(t => t.args[1].style.width)
+  assert.equal(turnWidths[0], '30px', 'T1 tick spans two columns (2*16-2)')
+  assert.equal(turnWidths[1], '14px', 'T2 tick spans one column')
+  assert.ok(byClass(tree, 'lc-chart-scroll').length === 1, 'scroll container present')
+  assert.ok(byClass(tree, 'lc-turns').length === 1, 'turn tick row present')
+
+  // ---- context stats board: totals over the retained window ----
+  // fixture: 4 requests (turns 1,1,2,3), no events yet -> all event counters 0.
+  const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
+  const statVals = byClass(tree, 'lc-stat-value').map(n => n.args[2])
+  assert.equal(statVals.length, 7, 'seven stats cells (turns / steps / injections / compactions / prunes / cache hit / cost)')
+  assert.equal(statVals[0], '3', 'turns counted by distinct turn')
+  assert.equal(statVals[1], '4', 'steps = request count')
+  assert.equal(statVals[2], '0', 'no injections yet')
+  assert.equal(statVals[3], '0', 'no compactions yet')
+  assert.equal(statVals[4], '0', 'no prunes yet')
+  assert.equal(statVals[5], '—', 'no tokenUsage projection yet -> cache-hit cell shows a dash')
+  assert.equal(statVals[6], '—', 'no cost totals yet -> cost cell shows a dash')
+
+  // ---- plugin info card: two full-width rows; every row is itself a link ----
+  const piLabels = byClass(tree, 'lc-pi-label').map(n => n.args[2])
+  const piValues = byClass(tree, 'lc-pi-value').map(n => n.args[2])
+  const piGrid = byClass(tree, 'lc-pi-grid')
+  assert.equal(piGrid.length, 1, 'plugin info rendered as one grid')
+  assert.equal(piLabels.length, 2, 'plugin info: two rows (Plugin / GitHub)')
+  assert.equal(plainText(piValues[0]), 'dsh-context (v' + pkg.version + ')', 'Plugin row combines package id + version (update chip only after the npm check resolves)')
+  assert.equal(plainText(piValues[1]), 'bowenliang123/dsh-context', 'GitHub row shows the short owner/repo')
+
+  // Each row IS the link — Plugin goes to the repo's releases page, GitHub to
+  // the repo root.
+  const linkRows = byClass(tree, 'lc-pi-row')
+  assert.equal(linkRows.length, 2, 'every row is a whole-row link')
+  assert.equal(linkRows[0].args[1].href, 'https://github.com/bowenliang123/dsh-context/releases', 'Plugin → GitHub releases page')
+  assert.equal(linkRows[1].args[1].href, 'https://github.com/bowenliang123/dsh-context', 'GitHub → GitHub repo')
+  // Hover affordance is CSS-driven (row-level `:hover` underlines the value);
+  // no JS state needed, so no onMouseEnter/onMouseLeave handlers.
+
+  // ---- hover linking: hovering a trend bar updates the detail below ----
+  const ctxSlots = hookStates.get(ctxKey) // selected(0) hovered(1) hoverTurn(2) tick(3) gran(4) hoverCat(5)
+  const detailStep = (tr) => {
+    const head = byClass(tr, 'lc-detail-head')[0]
+    return head === undefined ? '' : textOf(head).trim()
+  }
+
+  let tr = renderView()
+  assert.match(detailStep(tr), /Turn 3/, 'detail defaults to the newest request (Turn 3)')
+  assert.equal(byClass(tr, 'lc-bar-hovered').length, 0, 'no hovered bar initially')
+
+  ctxSlots[1][1](3) // setHoveredSeq(seq 3, turn 2)
+  tr = renderView()
+  assert.match(detailStep(tr), /Turn 2/, 'hovering a bar links the detail below to it')
+  const hovered = byClass(tr, 'lc-bar-hovered')
+  assert.equal(hovered.length, 1, 'exactly one hovered bar')
+  assert.equal(hovered[0].args[1].key, 3, 'hovered bar is seq 3')
+  const bar3 = byClass(tr, 'lc-bar').find(b => b.args[1].key === 3)
+  assert.equal(typeof bar3.args[1].onMouseEnter, 'function', 'bars carry onMouseEnter')
+  assert.equal(typeof bar3.args[1].onClick, 'function', 'bars carry onClick')
+  // the instant custom tooltip replaces the delayed native title
+  const chartTip = byClass(tr, 'lc-chart-tip')
+  assert.equal(chartTip.length, 1, 'hovering shows the custom tooltip immediately')
+  assert.match(textOf(chartTip[0]), /Turn 2 · Step 0/, 'tooltip names the hovered request')
+  assert.match(textOf(chartTip[0]), /total ≈ 107/, 'tooltip carries the estimated total')
+  assert.equal(typeof chartTip[0].args[1].style.left, 'string', 'tooltip is positioned at the bar column')
+  // turn-aware dimming: the chart is in dim mode while a turn is focused
+  assert.equal(byClass(tr, 'lc-chart-dim').length, 1, 'bar hover activates the turn-aware dim')
+
+  ctxSlots[1][1](null) // leave the plot
+  tr = renderView()
+  assert.match(detailStep(tr), /Turn 3/, 'leaving the plot reverts the detail to the newest request')
+  assert.equal(byClass(tr, 'lc-chart-tip').length, 0, 'tooltip clears with the hover')
+  assert.equal(byClass(tr, 'lc-chart-dim').length, 0, 'dim clears with the hover')
+
+  // ---- overview stacked bar: themed hover tooltip per segment ----
+  const overviewStack = byClass(tr, 'lc-stacked').find(s => s.args[1].style.height === '16px')
+  assert.ok(overviewStack, 'overview stacked bar present')
+  const segment = overviewStack.args.slice(2).flat().find(s => s !== null)
+  assert.ok(segment, 'overview has segments')
+  assert.equal(segment.args[1].title, undefined, 'native title replaced by the custom tooltip')
+  assert.equal(typeof segment.args[1].onMouseEnter, 'function', 'segments carry onMouseEnter')
+  segment.args[1].onMouseEnter({ clientX: 120 }) // fake pointer; ref is null in tests -> centered fallback
+  tr = renderView()
+  const tip = byClass(tr, 'lc-bar-tip-on')
+  assert.equal(tip.length, 1, 'hovering a segment shows the tooltip')
+  assert.match(textOf(tip[0]), /\(10%\)/, 'tooltip shows the segment share of the total')
+  // The occupied-region reference frame appears on hover (there is a free
+  // track here, so the legend % refers to the boxed part, not the bar width).
+  assert.equal(byClass(tr, 'lc-occupied-box-on').length, 1, 'hovered segment frames the occupied region')
+  const occBox = byClass(tr, 'lc-occupied-box-on')[0]
+  assert.equal(typeof occBox.args[1].style.width, 'string', 'frame width follows the used share')
+  assert.equal(typeof tip[0].args[1].style.left, 'string', 'tooltip is positioned along the pointer')
+
+  // ---- composition bar hover highlights the matching legend chip (and back) ----
+  // the tooltip test above left the first segment hovered -> its chip is on
+  let chipsOn = byClass(tr, 'lc-chip-on')
+  assert.equal(chipsOn.length, 1, 'hovered segment highlights its legend chip')
+  const chip0 = byClass(tr, 'lc-chip')[0]
+  assert.equal(typeof chip0.args[1].onMouseEnter, 'function', 'legend chips carry onMouseEnter')
+  chip0.args[1].onMouseEnter()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-stacked-seg-on').length, 2, 'hovering a chip highlights its segment (overview + mirrored browser bar)')
+  assert.equal(byClass(tr, 'lc-bar-tip-on').length, 1, 'hovering a chip also shows the tooltip above its segment')
+  assert.match(textOf(byClass(tr, 'lc-bar-tip-on')[0]), /\(10%\)/, 'chip-driven tooltip carries the share')
+  chip0.args[1].onMouseLeave()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-stacked-seg-on').length, 0, 'leaving the chip clears the segment highlight')
+  assert.equal(byClass(tr, 'lc-chip-on').length, 0, 'leaving the chip clears the chip highlight')
+  assert.equal(byClass(tr, 'lc-bar-tip-on').length, 0, 'leaving the chip fades the tooltip out')
+  // segment -> chip, on a different category
+  const seg1 = byClass(tr, 'lc-stacked').find(s => s.args[1].style.height === '16px')
+    .args.slice(2).flat().filter(s => s !== null)[1]
+  seg1.args[1].onMouseEnter({ clientX: 80 })
+  tr = renderView()
+  const chipsOn2 = byClass(tr, 'lc-chip-on')
+  assert.equal(chipsOn2.length, 1, 'hovering another segment moves the chip highlight')
+  // the label is now a styled span; compare its text (the highlighted chip's
+  // category name must equal the matching chip's)
+  assert.equal(textOf(chipsOn2[0].args[3]), textOf(byClass(tr, 'lc-chip')[1].args[3]), 'the matching chip is highlighted')
+  assert.equal(byClass(tr, 'lc-stacked-seg-on').length, 2, 'the hovered segment is marked (overview + mirrored browser bar)')
+
+  // ---- the free window space (blank track) is hoverable too ----
+  // fixture: window 128000 vs anchored occupancy 83017 -> 44983 free (35%)
+  const freeSeg = byClass(tr, 'lc-stacked-free')[0]
+  assert.ok(freeSeg, 'free window segment present when contextWindow > usage')
+  assert.equal(typeof freeSeg.args[1].onMouseEnter, 'function', 'free segment carries onMouseEnter')
+  freeSeg.args[1].onMouseEnter()
+  tr = renderView()
+  const freeTip = byClass(tr, 'lc-bar-tip-on')
+  assert.equal(freeTip.length, 1, 'hovering the blank space shows the tooltip')
+  assert.equal(byClass(tr, 'lc-occupied-box-on').length, 1, 'hovering the free track still frames the occupied region')
+  assert.match(textOf(freeTip[0]), /Free window 45\.0k \(35%\)/, 'tooltip names the free window and its share')
+  assert.equal(byClass(tr, 'lc-stacked-free-on').length, 1, 'free segment highlights on hover')
+  assert.equal(byClass(tr, 'lc-chip-on').length, 0, 'no legend chip matches the free space')
+  const stackEl = byClass(tr, 'lc-stacked').find(s => s.args[1].style.height === '16px')
+  stackEl.args[1].onMouseLeave()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-bar-tip-on').length, 0, 'leaving the stack fades the free tooltip out')
+  assert.equal(byClass(tr, 'lc-occupied-box-on').length, 0, 'leaving the stack fades the occupied frame out')
+
+  // ---- auto-compaction reserve band: the rightmost (1−0.8) of the window is
+  // striped headroom; hovering it explains the area instead of the free track.
+  // fixture: max 128000 (window) > 83017 used -> scale = window -> 80%/20%.
+  const reserveEl = byClass(tr, 'lc-reserve')[0]
+  assert.ok(reserveEl, 'auto-compaction reserve band rendered when the window is known')
+  assert.equal(reserveEl.args[1].style.left, '80%', 'reserve starts at the 80% threshold')
+  assert.equal(reserveEl.args[1].style.width, '20%', 'reserve covers the rightmost 20% of the window')
+  assert.equal(typeof reserveEl.args[1].onMouseEnter, 'function', 'reserve band is hoverable')
+  reserveEl.args[1].onMouseEnter()
+  tr = renderView()
+  const reserveTip = byClass(tr, 'lc-bar-tip-on')
+  assert.equal(reserveTip.length, 1, 'hovering the reserve shows its explanation')
+  assert.match(textOf(reserveTip[0]), /compact reserve 80%/, 'reserve tooltip names the compaction threshold')
+  assert.equal(byClass(tr, 'lc-occupied-box-on').length, 0, 'reserve hover clears the segment reference frame')
+  reserveEl.args[1].onMouseLeave()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-bar-tip-on').length, 0, 'leaving the reserve hides its tooltip')
+
+  // ---- turn strip: one color block per turn, aligned with the bars above ----
+  let turnBlocks = byClass(tr, 'lc-turn')
+  assert.equal(turnBlocks.length, 3, 'one turn block per turn')
+  assert.equal(typeof turnBlocks[0].args[1].onMouseEnter, 'function', 'turn blocks carry onMouseEnter')
+  const blockColors = turnBlocks.map(b => b.args[1].style.background)
+  assert.ok(blockColors.every(c => typeof c === 'string' && c.length > 0), 'turn blocks carry a color')
+  assert.notEqual(blockColors[0], blockColors[1], 'consecutive turns get distinct colors')
+  assert.equal(turnBlocks[0].args[1].title, 'T1', 'turn blocks carry a full-label tooltip')
+  turnBlocks[0].args[1].onMouseEnter() // T1 (covers seq 1 and 2)
+  tr = renderView()
+  const inTurn = byClass(tr, 'lc-bar-in-turn')
+  assert.equal(inTurn.length, 2, 'hovering T1 highlights its two bars')
+  assert.deepEqual(inTurn.map(b => b.args[1].key), [1, 2], 'highlighted bars are seq 1 and 2')
+  const onBlocks = byClass(tr, 'lc-turn-on')
+  assert.equal(onBlocks.length, 1, 'exactly one turn block highlighted')
+  assert.equal(onBlocks[0].args[2], 'T1', 'highlighted block is T1')
+  assert.equal(byClass(tr, 'lc-chart-dim').length, 1, 'strip hover also dims bars outside the turn')
+
+  // leaving the strip clears the turn highlight
+  const strip = byClass(tr, 'lc-turns')[0]
+  assert.equal(typeof strip.args[1].onMouseLeave, 'function', 'strip carries onMouseLeave')
+  strip.args[1].onMouseLeave()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-bar-in-turn').length, 0, 'leaving the strip clears bar highlights')
+  assert.equal(byClass(tr, 'lc-chart-dim').length, 0, 'leaving the strip clears the dim')
+
+  // hovering a bar highlights its turn block (bidirectional)
+  ctxSlots[1][1](3) // hover seq 3 (turn 2)
+  tr = renderView()
+  const onBlocks2 = byClass(tr, 'lc-turn-on')
+  assert.equal(onBlocks2.length, 1, 'bar hover highlights exactly one turn block')
+  assert.equal(onBlocks2[0].args[2], 'T2', 'hovering a bar highlights its turn block')
+  ctxSlots[1][1](null)
+
+  // ---- granularity toggle: one bar per step vs one bar per turn ----
+  // the trend card scopes its own toggle row: the events card kind toggles
+  // reuse the pill-button classes, so address the row, not the buttons.
+  const granRow = () => byClass(tr, 'lc-gran')[0].args.slice(2)
+  const onBtns = (row) => row.filter(b => String(b.args[1].className || '').includes('lc-gran-on'))
+  let granBtns = granRow()
+  assert.equal(granBtns.length, 2, 'granularity toggle has two buttons')
+  assert.equal(granBtns[0].args[2], 'Step', 'first button is step granularity')
+  assert.equal(granBtns[1].args[2], 'Turn', 'second button is turn granularity')
+  assert.equal(onBtns(granRow()).length, 1, 'step is active by default')
+  assert.equal(byClass(tr, 'lc-bar').length, 4, 'step mode: one bar per step')
+
+  // switch to turn granularity: the 4 steps collapse into 3 turn bars
+  granBtns[1].args[1].onClick()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-bar').length, 3, 'turn mode: one bar per turn')
+  assert.equal(byClass(tr, 'lc-turn').length, 3, 'turn strip still has one block per turn')
+  const turnOn = onBtns(granRow())
+  assert.equal(turnOn[0].args[2], 'Turn', 'turn button is active after switching')
+
+  // turn bars keep the uniform column width and align with their strip blocks
+  const turnBars = byClass(tr, 'lc-bar')
+  const t1Bar = turnBars.find(b => b.args[1].key === 2)
+  assert.ok(t1Bar, 'T1 is aggregated into its last step (seq 2)')
+  for (const b of turnBars) assert.equal(b.args[1].style.width, '14px', 'every turn bar keeps the uniform column width')
+  const turnBlocks2 = byClass(tr, 'lc-turn')
+  for (const blk of turnBlocks2) assert.equal(blk.args[1].style.width, '14px', 'every turn block matches the bar width (aligned)')
+  assert.equal(turnBlocks2[0].args[1].style.width, t1Bar.args[1].style.width, 'T1 bar and block align 1:1')
+
+  // the turn detail is labeled with the step count and tagged as the last step
+  t1Bar.args[1].onMouseEnter()
+  tr = renderView()
+  assert.match(detailStep(tr), /Turn 1 · 2 steps/, 'turn detail shows the step count, not a bare step number')
+  assert.equal(byClass(tr, 'lc-detail-tag').length, 1, 'the last-step tag marks the shown breakdown')
+  assert.equal(byClass(tr, 'lc-detail-tag')[0].args[2], 'last step', 'tag text localized')
+  ctxSlots[1][1](null)
+
+  // back to step granularity
+  granBtns = granRow()
+  granBtns[0].args[1].onClick()
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-bar').length, 4, 'back to one bar per step')
+  assert.equal(onBtns(granRow())[0].args[2], 'Step', 'step button active again')
+
+  // ---- edge fades signal reachable history beyond the viewport ----
+  let scroller = byClass(tr, 'lc-chart-scroll')[0]
+  const fakeScroller = { scrollLeft: 200, clientWidth: 120, scrollWidth: 800 }
+  scroller.args[1].onScroll({ currentTarget: fakeScroller })
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-chart-fade-l').length, 1, 'left fade shown while scrolled into history')
+  assert.equal(byClass(tr, 'lc-chart-fade-r').length, 1, 'right fade shown while more bars follow')
+  fakeScroller.scrollLeft = 680
+  scroller.args[1].onScroll({ currentTarget: fakeScroller })
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-chart-fade-r').length, 0, 'right fade gone at the right end')
+  assert.equal(byClass(tr, 'lc-chart-fade-l').length, 1, 'left fade stays at the right end')
+  fakeScroller.scrollLeft = 0
+  scroller.args[1].onScroll({ currentTarget: fakeScroller })
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-chart-fade-l').length, 0, 'left fade gone at the start')
+
+  // ---- no 80-bar cap: every request the host sends is rendered ----
+  const bigRequests = []
+  for (let i = 0; i < 120; i++) {
+    bigRequests.push({
+      seq: 1000 + i, turn: 1 + Math.floor(i / 4), step: i % 4, time: 1000 * i,
+      system: 10, tools: 20, user: 30, inject: 5, assistant: 15, tool: 20, total: 100,
+    })
+  }
+  bed.dataValue = { ...snapshot, requests: bigRequests }
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-bar').length, 120, 'all requests render — earlier turns/steps stay reachable')
+  assert.equal(byClass(tr, 'lc-turn').length, 30, 'turn strip follows the full history')
+  bed.dataValue = snapshot
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-bar').length, 4, 'snapshot restored')
+
+  // ---- default anchor: the newest bars sit at the right edge ----
+  const scrollNode = byClass(tr, 'lc-chart-scroll')[0]
+  const scrollEl = { scrollLeft: 0, clientWidth: 120, scrollWidth: 800 }
+  scrollNode.args[1].ref.current = scrollEl // attach a fake layout element
+  const trendKey = [...hookStates.keys()].find(k => k.includes('TrendChart'))
+  assert.ok(trendKey, 'TrendChart fiber registered')
+  const layoutEffectSlot = hookStates.get(trendKey).find(s => s && typeof s.effect === 'function')
+  assert.ok(layoutEffectSlot, 'chart layout effect captured')
+  layoutEffectSlot.effect()
+  assert.equal(scrollEl.scrollLeft, 800, 'initial layout anchors at the right (newest bars)')
+  scrollEl.scrollLeft = 200
+  layoutEffectSlot.effect()
+  assert.equal(scrollEl.scrollLeft, 200, 'scrolling away from the end is respected')
+  scrollEl.scrollLeft = 780
+  layoutEffectSlot.effect()
+  assert.equal(scrollEl.scrollLeft, 800, 'stays anchored at the end while near it')
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-chart-fade-l').length, 1, 'left fade shown once anchored at the newest bars')
+  assert.equal(byClass(tr, 'lc-chart-fade-r').length, 0, 'no right fade at the end')
+
+  // ---- granularity switches re-anchor at the newest bars ----
+  // (the turn->step report: returning to step must show the right edge)
+  const latestEffect = () => hookStates.get(trendKey).find(s => s && typeof s.effect === 'function')
+  scrollEl.scrollLeft = 0 // stale left edge from the narrow turn chart
+  const granTurnBtn = granRow()[1]
+  granTurnBtn.args[1].onClick() // step -> turn
+  tr = renderView()
+  latestEffect().effect()
+  assert.equal(scrollEl.scrollLeft, 800, 'switching to turn re-anchors at the newest bars')
+  scrollEl.scrollLeft = 0
+  const granStepBtn = granRow()[0]
+  granStepBtn.args[1].onClick() // turn -> step
+  tr = renderView()
+  latestEffect().effect()
+  assert.equal(scrollEl.scrollLeft, 800, 'switching back to step re-anchors at the newest bars')
+  // a plain re-render (poll) without a switch must NOT yank a scrolled-away view
+  scrollEl.scrollLeft = 200
+  latestEffect().effect()
+  assert.equal(scrollEl.scrollLeft, 200, 'plain re-renders still respect the scroll position')
+
+  // ---- message list: newest first, with timestamps when available ----
+  const nodeRows = byClass(tr, 'lc-node')
+  assert.equal(nodeRows.length, 2, 'message rows rendered')
+  assert.equal(nodeRows[0].args[1].key, 2, 'newest message on top')
+  assert.equal(nodeRows[1].args[1].key, 1, 'older message below')
+  const nodeTimes = byClass(tr, 'lc-node-time')
+  assert.equal(nodeTimes.length, 2, 'timestamps shown for every node')
+  // fmtTime renders LOCAL time; mirror the same formatting for expectations.
+  const fmtTimeLocal = (t) => {
+    const d = new Date(t)
+    const p = (x) => (x < 10 ? '0' : '') + x
+    return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
+  }
+  assert.equal(nodeTimes[0].args[2], fmtTimeLocal(65000), 'formatted time (65000ms)')
+  assert.equal(nodeTimes[1].args[2], fmtTimeLocal(1000), 'formatted time (1000ms)')
+
+  // ---- context events show where they sit in the request timeline ----
+  // The host sends events oldest->newest; the list reverses them. Boundary
+  // events (compaction/prune) show the GAP between the request before and the
+  // request after (same-turn "Step a→b", cross-turn "Turn a·s → Turn b·s");
+  // inject/model switches keep the single point of the request they belong
+  // to; events with no following request (in flight) stay bare. The same
+  // attachment drives the ✂ marker and the detail header chip.
+  bed.dataValue = {
+    ...snapshot,
+    events: [
+      { seq: 3, kind: 'compaction', time: 1200, tokens: 900, count: 2, fromTurn: 1, fromStep: 0, turn: 2, step: 0 }, // attaches to request seq 3 (Turn 2 · Step 0)
+      { seq: 6, kind: 'prune', time: 1500, tokens: 60, fromTurn: 1, fromStep: 0, turn: 1, step: 1 },
+      { seq: 7, kind: 'prune', time: 2000, tokens: 100, fromTurn: 1, fromStep: 1, turn: 2, step: 0 },
+      { seq: 8, kind: 'model', time: 3000, from: 'a', to: 'b', turn: 1, step: 1 },
+      { seq: 9, kind: 'inject', time: 4000, tokens: 5, form: 'notice', turn: 2, step: 0 },
+      { seq: 10, kind: 'compaction', time: 5000, tokens: 5000, count: 4, fromTurn: 2, fromStep: 0 }, // no request after -> unlabeled
+    ],
+  }
+  tr = renderView()
+  const evRows = byClass(tr, 'lc-event')
+  assert.equal(evRows.length, 6, 'event rows rendered newest first')
+  const kindChips = byClass(tr, 'lc-kind')
+  assert.equal(kindChips.length, 6, 'every event row carries a kind chip (注入/压缩/剪枝/切换)')
+  const atLabels = byClass(tr, 'lc-event-at')
+  assert.equal(atLabels.length, 5, 'boundary + single-point events carry labels; in-flight stays bare')
+  assert.equal(atLabels[0].args[2], 'Turn 2 · Step 0', 'inject keeps the single point of the request it fed')
+  assert.equal(atLabels[1].args[2], 'Turn 1 · Step 1', 'model switch keeps the single point')
+  assert.equal(atLabels[2].args[2], 'Turn 1 · Step 1 → Turn 2 · Step 0', 'cross-turn boundary shows the gap')
+  assert.equal(atLabels[3].args[2], 'Turn 1 · Step 0→1', 'same-turn boundary compresses to a step range')
+  assert.equal(atLabels[4].args[2], 'Turn 1 · Step 0 → Turn 2 · Step 0', 'oldest boundary event shows its gap')
+
+  // the events card header carries the four kind buttons as a picker, all
+  // picked by default: clicking an unpicked kind adds it (A -> A+B -> ...),
+  // clicking a picked one removes it, clicking the last one resets to all
+  let kindsRow = byClass(tr, 'lc-kinds')
+  assert.equal(kindsRow.length, 1, 'events card shows the kind buttons inline')
+  const kindBtns = () => byClass(tr, 'lc-kinds')[0].args.slice(2)
+  assert.equal(kindBtns().length, 4, 'four kind buttons (注入/压缩/剪枝/切换)')
+  assert.equal(onBtns(kindBtns()).length, 4, 'all four kinds are picked by default')
+  assert.equal(byClass(tr, 'lc-event').length, 6, 'default shows every event')
+  const click = (i) => { kindBtns()[i].args[1].onClick(); tr = renderView() }
+  click(0) // pick-only 注入
+  assert.equal(byClass(tr, 'lc-event').length, 1, 'clicking 注入 among all shows only injections')
+  assert.ok(String(kindBtns()[0].args[1].className || '').includes('lc-gran-on'), 'picked button is highlighted')
+  assert.ok(String(kindBtns()[0].args[1].className || '').includes('lc-kind-inject'), 'highlight carries the kind color')
+  assert.equal(onBtns(kindBtns()).length, 1, 'the other three turned off')
+  click(1) // add 压缩
+  assert.equal(byClass(tr, 'lc-event').length, 3, 'adding 压缩 shows 注入 + 压缩')
+  click(2) // add 剪枝
+  assert.equal(byClass(tr, 'lc-event').length, 5, 'adding 剪枝 shows 注入 + 压缩 + 剪枝')
+  click(0) // remove 注入
+  assert.equal(byClass(tr, 'lc-event').length, 4, 'removing 注入 leaves 压缩 + 剪枝')
+  click(3) // add 切换
+  assert.equal(byClass(tr, 'lc-event').length, 5, 'adding 切换 shows 压缩 + 剪枝 + 切换')
+  click(1); click(2) // remove 压缩 and 剪枝
+  assert.equal(byClass(tr, 'lc-event').length, 1, 'only 切换 stays picked')
+  click(3) // remove the last one -> reset to all
+  assert.equal(byClass(tr, 'lc-event').length, 6, 'removing the last picked kind restores all')
+  assert.equal(onBtns(kindBtns()).length, 4, 'all four kinds picked again')
+
+  // the stats board picks up the event counters
+  const statVals2 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
+  assert.equal(statVals2[2], '1', 'one injection counted')
+  assert.equal(statVals2[3], '2', 'two compactions counted')
+  assert.equal(statVals2[4], '2', 'two prunes counted')
+  assert.equal(statVals2[5], '—', 'cache-hit cell still a dash before a tokenUsage projection lands')
+
+  // the cache-hit cell reuses the OFFICIAL token-meter `tokenUsage` projection —
+  // the exact same data the chat stats line below the input box reads, same
+  // formula (cache reads over billed input = uncached + reads + writes).
+  bed.usageValue = { uncachedInputTokens: 10, outputTokens: 40, cacheReadTokens: 80, cacheWriteTokens: 10 }
+  tr = renderView()
+  const statVals3 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
+  assert.equal(statVals3.length, 7, 'seven stats cells with the cache-hit and cost cells')
+  assert.equal(statVals3[5], '80.00%', 'cache hit = cacheRead / (uncached + cacheRead + cacheWrite), two decimals')
+  assert.equal(statVals3[6], '—', 'cost cell stays a dash without cost totals')
+  assert.equal(statVals3[0], '3', 'turn count unchanged by the usage projection')
+  assert.equal(statVals3[3], '2', 'compaction count unchanged by the usage projection')
+  // truncation proof: 8334 / 25000 = 33.336% -> cut to 33.33%, never rounded up
+  bed.usageValue = { uncachedInputTokens: 10000, outputTokens: 40, cacheReadTokens: 8334, cacheWriteTokens: 6666 }
+  tr = renderView()
+  const statVals4 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
+  assert.equal(statVals4[5], '33.33%', 'two decimals are TRUNCATED, not rounded (33.336 -> 33.33)')
+  bed.usageValue = undefined
+
+  // the cost cell prices the host-folded cumulative totals with the hardcoded
+  // DeepSeek V4 list prices (test host has no getLocale -> USD): 1M off-peak
+  // flash cache reads ($0.007) + 1M off-peak misses ($0.22) = $0.227 -> $0.23
+  const prevData = bed.dataValue
+  bed.dataValue = { ...prevData, cost: { flash: { off: { uncached: 1000000, cacheRead: 1000000, cacheWrite: 0, output: 0 } } } }
+  tr = renderView()
+  const statVals5 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
+  assert.equal(statVals5[6], '$0.23', 'cost = (cacheRead x hit + uncached x miss) at the off-peak flash rate')
+  const costCell = byClass(tr, 'lc-stat')[6]
+  assert.equal(byClass(tr, 'lc-stat-q').length, 1, 'cost cell label shows the "?" affordance')
+  // The explanation is a styled DOM bubble (the native `title` attribute is
+  // invisible in the harness GUI): intro sentence + the price list built from
+  // cost.ts (test dict falls back to keys; no locale -> USD rates).
+  const costTipEls = byClass(tr, 'lc-stat-tip')
+  assert.equal(costTipEls.length, 1, 'cost cell carries a styled explanation bubble')
+  const tipText = plainText(costTipEls[0])
+  assert.match(tipText, /stats\.costTip/, 'bubble explains the whole-session estimate')
+  assert.match(tipText, /stats\.costPriceHead/, 'bubble sections the price list under its own head')
+  assert.match(tipText, /\$0\.014|\$0\.44/, 'bubble lists the peak flash input rate ($0.014 hit / $0.44 miss per 1M)')
+  assert.match(tipText, /\$0\.007|\$0\.22/, 'bubble lists the off-peak flash input rate (half price)')
+  bed.dataValue = prevData
+  tr = renderView()
+
+  // the ✂ marker sits on the bar it attaches to and tooltips the event gap
+  const barMark = byClass(tr, 'lc-bar-marker')
+  assert.equal(barMark.length, 1, 'one ✂ marker on the attached bar')
+  assert.match(barMark[0].args[1].title, /Turn 1 · Step 0 → Turn 2 · Step 0/, '✂ tooltip carries the event gap')
+
+  // the detail header shows the same gap as a chip when that bar is active
+  ctxSlots[1][1](3) // hover the attached bar (seq 3, Turn 2 · Step 0)
+  tr = renderView()
+  const markerChip = byClass(tr, 'lc-detail-marker')
+  assert.equal(markerChip.length, 1, 'detail header shows the attached boundary event')
+  assert.equal(markerChip[0].args[2], '✂ Turn 1 · Step 0 → Turn 2 · Step 0', 'chip shows the event gap')
+  assert.equal(typeof markerChip[0].args[1].title, 'string', 'chip tooltips the event text')
+  ctxSlots[1][1](null) // leave the plot
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-detail-marker').length, 0, 'chip clears with the hover')
+  bed.dataValue = snapshot
+  tr = renderView()
+  assert.equal(byClass(tr, 'lc-event').length, 0, 'event list restored to the empty state')
+
+  // ---- overview headline is the provider-based occupancy (like the chat
+  // ring); the composition is anchored to it, proportions stay heuristic ----
+  // fixture (no `contextPressure` projection -> derived fallback): last request
+  // prompt 83000, last total 83, current total 100, window 128000
+  // -> occupancy = 83000 + (100 - 83) = 83017 (65%), raw heuristic = 100.
+  const overviewNum = byClass(tr, 'lc-overview-num')[0]
+  assert.ok(overviewNum, 'overview number row present')
+  assert.match(textOf(overviewNum), /83\.0k/, 'headline shows the provider-based occupancy')
+  assert.match(textOf(overviewNum), /\/ 128\.0k tokens/, 'window shown next to the occupancy')
+  assert.match(textOf(overviewNum), /65%/, 'occupancy percent is the emphasized figure of the line')
+  assert.ok(!/~65%/.test(textOf(overviewNum)), 'no conflicting heuristic percentage next to the headline')
+
+  // ---- the OFFICIAL token-meter `contextPressure` projection wins over the
+  // derived fallback (the chat ring's own value, read as a second projection) ----
+  bed.pressureValue = { pressureTokens: 90000, projectedTokens: 90010, contextWindow: 200000 }
+  tr = renderView()
+  const overviewNum2 = byClass(tr, 'lc-overview-num')[0]
+  assert.match(textOf(overviewNum2), /90\.0k/, 'official contextPressure projection is the headline when present')
+  assert.match(textOf(overviewNum2), /45%/, 'contextPressure window is the percent denominator')
+  bed.pressureValue = undefined
+  bed.dataValue = snapshot
+  tr = renderView()
+
+  console.log('✔ chart render test passed (context stats board, free window hover, fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times, event range labels, detail marker chip, overview actual)')
+
+  // ---- Context browser card: step picker + category accordion + element
+})
diff --git a/tests/client.spec.mjs b/tests/client.spec.mjs
new file mode 100644
index 0000000..45cd4a6
--- /dev/null
+++ b/tests/client.spec.mjs
@@ -0,0 +1,178 @@
+/**
+ * Registration-surface spec for the packaged client bundle: simulates the
+ * web boot handoff (window.__ModuleLoader__.load), the module-table require,
+ * and the client ctx (locale/slots/effect + a fake DOM), then asserts the
+ * plugin registers its dictionaries, styles, the conversation.view tab
+ * entry, and the /context command trigger source.
+ */
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { test } from 'vitest'
+import { bootViewBed, FAKE_PRIMITIVES, makeFakeDoc } from './helpers/viewBed.mjs'
+
+const bundle = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
+
+// ---- fake browser environment ----
+const { fakeDoc, registered } = makeFakeDoc()
+const fakeReact = {
+  createElement: (...args) => ({ kind: 'element', args }),
+  useState: (init) => [init, () => {}],
+  useEffect: () => {},
+  // The ErrorBoundary extends React.Component; a minimal base keeps the
+  // class construction working (props assigned, state merge via setState).
+  Component: class Component {
+    constructor(props) { this.props = props }
+    setState(partial) { this.state = { ...this.state, ...partial } }
+  },
+}
+
+let handoff = null
+globalThis.window = {
+  __ModuleLoader__: {
+    load(h) {
+      assert.ok(h.id === 'dsh-context', 'handoff id must be the package name')
+      assert.equal(typeof h.factory, 'function')
+      handoff = h
+    },
+  },
+}
+globalThis.document = fakeDoc
+
+const require = (spec) => {
+  assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives',
+    `bundle must only require platform modules (got "${spec}")`)
+  return spec === 'react' ? fakeReact : FAKE_PRIMITIVES
+}
+
+// ---- materialize the bundle the way the loader does ----
+// Run the bundle verbatim: it registers itself through the boot handoff
+// (window.__ModuleLoader__.load), and the loader materializes the plugin by
+// invoking the captured factory with the module-table require. The factory
+// body declares its own module/exports (the bundle intro), so no string
+// surgery on the artifact — the test stays decoupled from bundler layout.
+new Function(bundle)()
+assert.ok(handoff !== null, 'bundle must register through __ModuleLoader__.load')
+const pluginExports = handoff.factory(require)
+
+test('client bundle: handoff, dicts, styles, slot registration, /context command', async () => {
+  assert.equal(pluginExports.name, 'dsh-context')
+  assert.deepEqual(pluginExports.inject, ['slots', 'locale'])
+
+  // ---- apply the client plugin ----
+  const localeRegistrations = []
+  const slotInjections = []
+  const effects = []
+  const fakeCtx = {
+    get: () => undefined,
+    effect(fn) { effects.push(fn); fn(); return () => {} }, // Cordis runs the effect body immediately
+    locale: {
+      register: (ns, dicts) => { localeRegistrations.push([ns, dicts]); return () => {} },
+      bind: (ns) => (key, vars) => {
+        const dict = localeRegistrations[0][1].zh
+        let s = dict[key] !== undefined ? dict[key] : key
+        if (vars) for (const k in vars) s = s.replace('{' + k + '}', String(vars[k]))
+        return s
+      },
+    },
+    slots: {
+      inject: (name, fn) => { slotInjections.push([name, fn]) },
+      register: (opts, component) => {
+        assert.equal(typeof component, 'function')
+        return opts
+      },
+    },
+  }
+  pluginExports.apply(fakeCtx)
+
+  assert.equal(effects.length, 4, 'dictionaries + styles + loadOlderHistory prop + /context command effects')
+  assert.deepEqual(localeRegistrations[0][0], 'dsh-context')
+  assert.ok(localeRegistrations[0][1].zh && localeRegistrations[0][1].en, 'bilingual dicts')
+  const styleTag = registered.get('dsh-context')
+  assert.ok(styleTag, 'plugin-owned