test: migrate to vitest, split the monolithic client spec into modules
- 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
This commit is contained in:
1 parent
9905006ef8
commit
49f1439dae
20 files changed
+3052
-2271
No files matched your search
+3
-3
@@ -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"
|
||||
}
|
||||
Generated
+459
@@ -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: {}
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
@@ -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 <pre>; 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 <pre> 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 <pre>')
|
||||
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<string>/.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)')
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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 <style data-plugin="dsh-context"> injected')
|
||||
assert.ok(styleTag.textContent.includes('.lc-root'), 'styles content present')
|
||||
assert.ok(styleTag.textContent.includes('transition: background-color 120ms ease'), 'row hovers ease in/out')
|
||||
assert.ok(styleTag.textContent.includes('transition: filter 120ms ease, opacity 120ms ease'), 'composition bar hover eases in/out')
|
||||
assert.ok(styleTag.textContent.includes('lc-bar-tip-on'), 'composition tooltip fades in and out')
|
||||
assert.ok(styleTag.textContent.includes('lc-stat-tip'), 'stats cell tooltip bubble styles present')
|
||||
assert.ok(styleTag.textContent.includes('lc-stat-tipped:hover .lc-stat-tip'), 'stats tooltip reveals on cell hover')
|
||||
assert.ok(styleTag.textContent.includes('lc-occupied-box-on'), 'occupied frame fades in and out')
|
||||
assert.equal(slotInjections.length, 2, 'view tab + input overlay injections')
|
||||
assert.equal(slotInjections[0][0], 'conversation.view')
|
||||
const registeredOpts = slotInjections[0][1]() // slots.inject callback returns the register result
|
||||
assert.equal(registeredOpts.name, 'conversation.view')
|
||||
assert.equal(registeredOpts.id, 'context')
|
||||
assert.equal(registeredOpts.order, 20)
|
||||
assert.equal(typeof registeredOpts.label, 'function')
|
||||
assert.equal(registeredOpts.label(), '上下文', 'tab label localized')
|
||||
assert.equal(slotInjections[1][0], 'conversation.input.overlay')
|
||||
const overlayOpts = slotInjections[1][1]()
|
||||
assert.equal(overlayOpts.name, 'conversation.input.overlay')
|
||||
assert.equal(overlayOpts.id, 'context-modal')
|
||||
assert.ok(typeof overlayOpts.inject === 'function', 'overlay injects the modal store hook')
|
||||
const injected = overlayOpts.inject('s1')
|
||||
assert.equal(typeof injected.hooks.contextModal.getSnapshot, 'function', 'hooks compartment carries the modal store')
|
||||
assert.equal(injected.hooks.contextModal.getSnapshot(), false, 'modal closed initially')
|
||||
|
||||
// ---- /context command: the plugin's own '/' trigger source ----
|
||||
// The first fake ctx has no inputTriggers service: the command effect must
|
||||
// stay inert (soft dependency), which the apply above already proved by not
|
||||
// throwing. Now apply again with the trigger service present.
|
||||
{
|
||||
const sources = []
|
||||
let localeDict = null
|
||||
const ctx3 = {
|
||||
get: (name) => name === 'inputTriggers'
|
||||
? { registerSource: (s) => { sources.push(s); return () => {} } }
|
||||
: undefined,
|
||||
effect: (fn) => { fn(); return () => {} },
|
||||
locale: {
|
||||
register: (ns, dicts) => { localeDict = dicts.zh; return () => {} },
|
||||
bind: () => (key, vars) => {
|
||||
let s = localeDict !== null && localeDict[key] !== undefined ? localeDict[key] : key
|
||||
if (vars) for (const k in vars) s = s.replace('{' + k + '}', String(vars[k]))
|
||||
return s
|
||||
},
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
slots: { inject: () => {}, register: () => ({}) },
|
||||
}
|
||||
pluginExports.apply(ctx3)
|
||||
assert.equal(sources.length, 1, 'one /context trigger source registered')
|
||||
const src = sources[0]
|
||||
assert.equal(src.trigger, '/')
|
||||
assert.equal(src.name, 'context')
|
||||
|
||||
// Candidates: leading-only, prefix-filtered, description localized.
|
||||
const req = (query, position = 'leading') => ({ query, position, signal: new AbortController().signal })
|
||||
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('')), [{ name: 'context', description: '查看当前上下文构成,浏览各步骤组成' }])
|
||||
assert.deepEqual((await src.candidates({ sessionId: 's1' }, req('cont'))).length, 1, 'prefix match')
|
||||
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('xyz')), [], 'non-prefix miss')
|
||||
assert.deepEqual(await src.candidates({ sessionId: 's1' }, req('', 'inline')), [], 'inline positions never offer the command')
|
||||
|
||||
// Menu pick: opens the session's modal and answers 'handled'; the token
|
||||
// stays in the composer while the modal is open (consumed on close).
|
||||
const outcome = src.onPick({ candidate: { name: 'context' }, session: { sessionId: 's1' }, position: 'leading', via: 'menu', span: { start: 0, end: 8, draftRev: 1 } })
|
||||
assert.equal(outcome, 'handled', 'menu pick is handled internally')
|
||||
assert.equal(injected.hooks.contextModal.getSnapshot(), true, 'menu pick opens the modal')
|
||||
|
||||
// Bare enter: also 'handled' (nothing is submitted, the draft keeps the
|
||||
// token until the modal closes).
|
||||
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/context', new AbortController().signal), 'handled', 'bare enter opens the modal without submitting')
|
||||
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/context now', new AbortController().signal), undefined, 'argued lines miss')
|
||||
assert.equal(await src.matchEnter({ sessionId: 's1' }, '/compact', new AbortController().signal), undefined, 'other commands miss')
|
||||
}
|
||||
})
|
||||
|
||||
test('sessions.provide contributes the loadOlderHistory prop', async () => {
|
||||
// The contribution rides the harness's sessions.provide channel: one
|
||||
// declared prop, resolved per session to the session's own
|
||||
// history-pagination verb.
|
||||
const bed = await bootViewBed()
|
||||
assert.equal(bed.provideDescriptors.length, 1, 'one sessions.provide contribution registered')
|
||||
assert.deepEqual(bed.provideDescriptors[0].props, ['loadOlderHistory'], 'the contributed prop is loadOlderHistory')
|
||||
let olderPulled = 0
|
||||
const contribution = bed.provideDescriptors[0].resolve({ session: { loadOlder: async () => { olderPulled += 1 } } })
|
||||
assert.equal(typeof contribution.props.loadOlderHistory, 'function', 'resolved prop is a function')
|
||||
await contribution.props.loadOlderHistory()
|
||||
assert.equal(olderPulled, 1, 'the prop delegates to session.loadOlder()')
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Current-composition hover link: while the browser shows the LIVE step,
|
||||
* hover is shared bidirectionally with the Current Composition card; a
|
||||
* pinned/previewed step never leaks hover either way.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
import { bootViewBed, byClass, catRowOf, textOf } from './helpers/viewBed.mjs'
|
||||
|
||||
const bed = await bootViewBed()
|
||||
const { renderView, snapshot } = bed
|
||||
bed.dataValue = snapshot
|
||||
let tr = renderView()
|
||||
const ctxSlots = bed.ctxSlots()
|
||||
const brSlots = bed.brSlots()
|
||||
|
||||
test('current-composition hover link: browser rows + composition bar <-> overview, gated on the live step', async () => {
|
||||
// ---- current-composition hover link: while the browser shows the LIVE step,
|
||||
// hover is shared bidirectionally with the Current Composition card. A
|
||||
// browser category row or the browser's own composition bar lights the
|
||||
// overview's segment + legend chip (and the browser echoes back); hovering
|
||||
// the overview lights the browser's matching category row and bar segment.
|
||||
// A pinned/previewed step has a different composition, so its hover never
|
||||
// leaks into the overview and the overview's hover never highlights it. ----
|
||||
const brWrap = () => byClass(tr, 'lc-br-bar')[0]
|
||||
const brStack = () => byClass(brWrap(), 'lc-stacked')[0]
|
||||
const ovrStack = () => byClass(tr, 'lc-stacked').find(s => s.args[1].style.height === '16px')
|
||||
const segOf = (stack, key) => stack.args.slice(2).flat().filter(s => s !== null)
|
||||
.find(s => s.args[1].key === key)
|
||||
const barSegsOn = (stack) => stack.args.slice(2).flat().filter(s => s !== null)
|
||||
.filter(s => String(s.args[1].className || '').includes('lc-stacked-seg-on'))
|
||||
assert.ok(brStack(), 'browser composition bar present (height 10 wrapper hook)')
|
||||
ctxSlots[1][1](null) // no trend hover: the browser is on the live step
|
||||
brSlots[1][1](null)
|
||||
brSlots[2][1](null)
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-cat-on').length, 0, 'no linked category row before any hover')
|
||||
|
||||
// browser category row -> overview (both bars, the legend, and the row echo)
|
||||
const liveUserRow = catRowOf(tr, 'User')
|
||||
assert.equal(typeof liveUserRow.args[1].onMouseEnter, 'function', 'live browser rows carry the hover link')
|
||||
liveUserRow.args[1].onMouseEnter()
|
||||
tr = renderView()
|
||||
assert.equal(ctxSlots[5][0], 'user', 'browser row hover updates the shared hover category')
|
||||
assert.equal(byClass(tr, 'lc-br-cat-on').length, 1, 'browser row hover links its own row')
|
||||
assert.equal(barSegsOn(brStack()).length, 1, 'browser row hover lights the browser bar segment')
|
||||
assert.equal(barSegsOn(brStack())[0].args[1].key, 'user', 'the lit browser segment is user')
|
||||
assert.equal(barSegsOn(ovrStack()).length, 1, 'browser row hover lights the overview bar segment')
|
||||
assert.equal(byClass(tr, 'lc-chip-on').length, 1, 'browser row hover lights the overview legend chip')
|
||||
assert.match(textOf(byClass(tr, 'lc-chip-on')[0]), /User/, 'the lit chip is the user category')
|
||||
liveUserRow.args[1].onMouseLeave()
|
||||
tr = renderView()
|
||||
assert.equal(ctxSlots[5][0], null, 'leaving the browser row clears the shared hover')
|
||||
assert.equal(byClass(tr, 'lc-br-cat-on').length, 0, 'the row echo clears with the leave')
|
||||
assert.equal(byClass(tr, 'lc-stacked-seg-on').length, 0, 'both bars clear with the row leave')
|
||||
assert.equal(byClass(tr, 'lc-chip-on').length, 0, 'the legend chip clears with the row leave')
|
||||
|
||||
// overview -> browser: the overview segment lights the browser's category
|
||||
// row and bar segment, but only the overview floats its tooltip (the
|
||||
// mirrored browser bar stays silent).
|
||||
segOf(ovrStack(), 'assistant').args[1].onMouseEnter({ clientX: 80 })
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-cat-on').length, 1, 'overview hover lights the browser category row')
|
||||
assert.match(textOf(byClass(tr, 'lc-br-cat-on')[0]), /assistant/, 'the echoed row is the assistant category')
|
||||
assert.equal(barSegsOn(brStack()).length, 1, 'the browser bar mirrors the overview hover')
|
||||
assert.equal(barSegsOn(brStack())[0].args[1].key, 'assistant', 'the mirrored segment is the assistant')
|
||||
assert.equal(byClass(tr, 'lc-bar-tip-on').length, 1, 'exactly one tooltip floats (only over the overview bar)')
|
||||
ovrStack().args[1].onMouseLeave()
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-cat-on').length, 0, 'overview leave clears the browser row echo')
|
||||
assert.equal(byClass(tr, 'lc-stacked-seg-on').length, 0, 'overview leave clears both bars')
|
||||
|
||||
// browser composition bar -> overview (the browser's bart joins the link)
|
||||
segOf(brStack(), 'user').args[1].onMouseEnter()
|
||||
tr = renderView()
|
||||
assert.equal(ctxSlots[5][0], 'user', 'browser bar hover updates the shared hover category')
|
||||
assert.equal(barSegsOn(ovrStack()).length, 1, 'browser bar hover lights the overview segment')
|
||||
assert.equal(byClass(tr, 'lc-chip-on').length, 1, 'browser bar hover lights the overview legend chip')
|
||||
assert.equal(byClass(tr, 'lc-br-cat-on').length, 1, 'browser bar hover links the browser category row too')
|
||||
brStack().args[1].onMouseLeave()
|
||||
tr = renderView()
|
||||
assert.equal(ctxSlots[5][0], null, 'leaving the browser bar clears the shared hover')
|
||||
|
||||
// pinned/previewed step: the compositions differ, so no linkage either way
|
||||
ctxSlots[1][1](2) // trend hover previews step 2 in the browser
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, '2', 'browser is previewing a past step')
|
||||
assert.equal(catRowOf(tr, 'User').args[1].onMouseEnter, undefined, 'pinned browser rows carry no hover link')
|
||||
segOf(brStack(), 'user').args[1].onMouseEnter() // handler exists; the live gate must make it a no-op
|
||||
tr = renderView()
|
||||
assert.equal(ctxSlots[5][0], null, 'pinned browser bar hover does not leak into the overview')
|
||||
segOf(ovrStack(), 'assistant').args[1].onMouseEnter({ clientX: 80 })
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-cat-on').length, 0, 'overview hover does not echo into a pinned step')
|
||||
assert.equal(barSegsOn(brStack()).length, 0, 'overview hover does not light the pinned composition bar')
|
||||
assert.equal(barSegsOn(ovrStack()).length, 1, 'the overview itself still highlights normally')
|
||||
ovrStack().args[1].onMouseLeave()
|
||||
tr = renderView()
|
||||
|
||||
// back on the live step the link returns
|
||||
ctxSlots[1][1](null)
|
||||
tr = renderView()
|
||||
assert.equal(typeof catRowOf(tr, 'User').args[1].onMouseEnter, 'function', 'live browser rows carry the link again')
|
||||
|
||||
console.log('✔ current-composition hover link test passed (browser rows + composition bar <-> overview, gated on the live step)')
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Shared client-view test bed: materializes the built client bundle with a
|
||||
* STATEFUL fake React so a view component can be driven from loading -> data,
|
||||
* then walks the element tree the component produces. Hooks are tracked PER
|
||||
* component function (like React fibers), so re-rendering re-reads the same
|
||||
* slots. Every spec module boots its own bed — fresh hook fibers, fresh
|
||||
* holders, fresh plugin instance — so modules run in isolation.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
// ---- fake browser document (style-injection target) ----
|
||||
export function makeFakeDoc() {
|
||||
const registered = new Map() // style tags keyed by data-plugin
|
||||
const fakeDoc = {
|
||||
createElement: (tag) => {
|
||||
const el = { tagName: tag, attrs: {}, textContent: '', parentNode: null }
|
||||
el.setAttribute = (k, v) => { el.attrs[k] = String(v) }
|
||||
return el
|
||||
},
|
||||
head: {
|
||||
appendChild(el) {
|
||||
el.parentNode = { removeChild: () => { el.parentNode = null } }
|
||||
registered.set(el.attrs['data-plugin'], el)
|
||||
},
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
}
|
||||
return { fakeDoc, registered }
|
||||
}
|
||||
|
||||
// The module table answers the platform seeds this bundle uses ('react' and
|
||||
// the shared primitives singleton); everything else rides ctx or is inlined.
|
||||
// Icons are stubbed as inert elements (glyph rendering is never asserted);
|
||||
// MarkdownText resolves to a plain div carrying its source text, so the
|
||||
// raw/markdown toggle assertions can find it by class and text.
|
||||
export const FAKE_PRIMITIVES = {
|
||||
IconPlusOutline16: (p) => ({ kind: 'icon', name: 'IconPlusOutline16', props: p }),
|
||||
IconBranchOutline16: (p) => ({ kind: 'icon', name: 'IconBranchOutline16', props: p }),
|
||||
MarkdownText: (p) => ({ kind: 'element', args: ['div', { className: 'lc-md-stub' }, p.text] }),
|
||||
}
|
||||
|
||||
export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
|
||||
'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.orig': 'original {d}' }
|
||||
|
||||
/** Base timeline fixture: 4 requests across 3 turns, 2 live nodes. */
|
||||
export const snapshot = {
|
||||
ok: true, model: 'deepseek-v4', provider: 'deepseek', contextWindow: 128000,
|
||||
current: { system: 10, tools: 20, user: 30, inject: 5, assistant: 15, tool: 20, total: 100 },
|
||||
toolList: [], requests: [
|
||||
{ seq: 1, turn: 1, step: 0, time: 1000, system: 10, tools: 20, user: 30, inject: 5, assistant: 15, tool: 20, total: 100, prompt: 95 },
|
||||
{ seq: 2, turn: 1, step: 1, time: 2000, system: 10, tools: 20, user: 25, inject: 5, assistant: 10, tool: 20, total: 90 },
|
||||
{ seq: 3, turn: 2, step: 0, time: 3000, system: 10, tools: 20, user: 40, inject: 5, assistant: 12, tool: 20, total: 107 },
|
||||
{ seq: 4, turn: 3, step: 0, time: 4000, system: 10, tools: 20, user: 20, inject: 5, assistant: 8, tool: 20, total: 83, prompt: 83000 },
|
||||
],
|
||||
events: [], nodes: [
|
||||
{ seq: 1, cat: 'user', tokens: 10, text: 'first message', time: 1000 },
|
||||
{ seq: 2, cat: 'assistant', tokens: 20, text: 'second message', time: 65000 },
|
||||
], droppedNodes: 0,
|
||||
}
|
||||
|
||||
/** Walk the h() element tree, returning every node whose className matches. */
|
||||
export function byClass(root, className) {
|
||||
const found = []
|
||||
const walk = (node) => {
|
||||
if (node === null || node === undefined || typeof node !== 'object') return
|
||||
if (node.kind === 'element') {
|
||||
const props = node.args[1] || {}
|
||||
if (typeof props.className === 'string' && props.className.split(' ').includes(className)) found.push(node)
|
||||
for (let i = 2; i < node.args.length; i++) walk(node.args[i])
|
||||
} else if (Array.isArray(node)) {
|
||||
for (const child of node) walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return found
|
||||
}
|
||||
|
||||
export function textOf(node) {
|
||||
if (typeof node === 'string') return node
|
||||
if (node === null || node === undefined || typeof node !== 'object') return ''
|
||||
if (node.kind === 'element') return node.args.slice(2).map(textOf).join('')
|
||||
if (Array.isArray(node)) return node.map(textOf).join('')
|
||||
return ''
|
||||
}
|
||||
|
||||
export function plainText(node) {
|
||||
if (typeof node === 'string') return node
|
||||
if (node === null || node === undefined || typeof node !== 'object') return ''
|
||||
if (node.kind === 'element') return node.args.slice(2).map(plainText).join('')
|
||||
if (Array.isArray(node)) return node.map(plainText).join('')
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Find a browser category row by its label text. */
|
||||
export function catRowOf(tr, label) {
|
||||
return byClass(tr, 'lc-br-cat-row').find(r => textOf(r).includes(label))
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a fresh view bed: evaluate the bundle through the boot handoff,
|
||||
* materialize it with the stateful fake React, apply it on a fake ctx, and
|
||||
* run the initial (loading) render so the ContextView fiber exists.
|
||||
* @returns the bed: holders the tests arm, captured registrations, the
|
||||
* render/evaluate drivers, and fiber-slot accessors.
|
||||
*/
|
||||
export async function bootViewBed() {
|
||||
const bundle = await readFile(new URL('../../lib/client.js', import.meta.url), 'utf8')
|
||||
const { fakeDoc } = makeFakeDoc()
|
||||
let handoff = null
|
||||
globalThis.window = { __ModuleLoader__: { load(h) { handoff = h } } }
|
||||
globalThis.document = fakeDoc
|
||||
new Function(bundle)()
|
||||
assert.ok(handoff !== null, 'bundle must register through __ModuleLoader__.load')
|
||||
|
||||
const bed = {
|
||||
// projection/service holders the tests arm between renders
|
||||
dataValue: null,
|
||||
pressureValue: undefined,
|
||||
usageValue: undefined,
|
||||
headersValue: undefined,
|
||||
useSessionHolder: undefined,
|
||||
loadOlderHolder: undefined,
|
||||
conversationHolder: undefined,
|
||||
// captured at apply time
|
||||
bailCalls: [],
|
||||
provideDescriptors: [],
|
||||
modalSource: null,
|
||||
viewComponent: null,
|
||||
modalComponent: null,
|
||||
hookStates: new Map(), // component fn -> [value, setter][] slots
|
||||
classInstances: new Map(),
|
||||
}
|
||||
|
||||
let currentHooks = null
|
||||
let hookCursor = 0
|
||||
const statefulReact = {
|
||||
createElement: (...args) => ({ kind: 'element', args }),
|
||||
useCallback: (fn) => fn,
|
||||
useState(init) {
|
||||
const i = hookCursor++
|
||||
const slots = currentHooks // captured by the setter: stable per fiber
|
||||
if (slots[i] === undefined) {
|
||||
const set = (v) => { slots[i][0] = typeof v === 'function' ? v(slots[i][0]) : v }
|
||||
slots[i] = [typeof init === 'function' ? init() : init, set]
|
||||
}
|
||||
return slots[i]
|
||||
},
|
||||
useEffect(fn) {
|
||||
// Store the callback in a hook slot (never auto-run) so tests can drive
|
||||
// effects explicitly, with the LATEST render's closure — like React.
|
||||
const i = hookCursor++
|
||||
if (currentHooks[i] === undefined) currentHooks[i] = { effect: undefined }
|
||||
currentHooks[i].effect = fn
|
||||
return currentHooks[i]
|
||||
},
|
||||
// Memo values are recomputed every render in tests (no staleness assertions).
|
||||
useMemo: (fn) => fn(),
|
||||
useRef(init) {
|
||||
// Like React: the SAME ref object is returned on every render (stored
|
||||
// in the fiber slot), so tree ref props and effect closures agree.
|
||||
const i = hookCursor++
|
||||
if (currentHooks[i] === undefined) currentHooks[i] = { ref: { current: init } }
|
||||
return currentHooks[i].ref
|
||||
},
|
||||
useLayoutEffect(fn) {
|
||||
// Store the callback in a hook slot so tests can drive the layout effect.
|
||||
// Always overwrite: real React runs the LATEST render's closure.
|
||||
const i = hookCursor++
|
||||
currentHooks[i] = { effect: fn }
|
||||
return currentHooks[i]
|
||||
},
|
||||
Component: class Component {
|
||||
constructor(props) { this.props = props }
|
||||
setState(partial) { this.state = { ...this.state, ...partial } }
|
||||
},
|
||||
}
|
||||
const requireStateful = (spec) => {
|
||||
assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives',
|
||||
`bundle must only require platform modules (got "${spec}")`)
|
||||
return spec === 'react' ? statefulReact : FAKE_PRIMITIVES
|
||||
}
|
||||
const pluginExports = handoff.factory(requireStateful)
|
||||
|
||||
const fakeCtx = {
|
||||
get: (name) => {
|
||||
if (name === 'conversation') return bed.conversationHolder
|
||||
if (name === 'inputTriggers') return { registerSource: (s) => { bed.modalSource = s; return () => {} } }
|
||||
if (name === 'sessions') return {
|
||||
scope: (id) => id === 's1' ? { bail: (...args) => { bed.bailCalls.push(args); return true } } : undefined,
|
||||
provide: (d) => { bed.provideDescriptors.push(d); return () => {} },
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
effect: (fn) => { fn(); return () => {} },
|
||||
locale: {
|
||||
register: () => () => {},
|
||||
bind: () => (key, vars) => {
|
||||
let s = DICT_FOR_TEST[key] !== undefined ? DICT_FOR_TEST[key] : key
|
||||
if (vars) for (const k in vars) s = s.replace('{' + k + '}', String(vars[k]))
|
||||
return s
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
inject: (name, fn) => { fn() }, // the register call inside captures the component
|
||||
register: (opts, component) => {
|
||||
if (opts.name === 'conversation.view') bed.viewComponent = component
|
||||
if (opts.name === 'conversation.input.overlay') bed.modalComponent = component
|
||||
return opts
|
||||
},
|
||||
},
|
||||
}
|
||||
pluginExports.apply(fakeCtx)
|
||||
assert.ok(bed.viewComponent !== null, 'view component captured')
|
||||
|
||||
/** Invoke function-typed elements so hooks run and the tree materializes.
|
||||
* Hooks are keyed by the component's fiber path (e.g. root/ContextView#0/
|
||||
* StackedBar#0), so distinct instances of the same component keep state.
|
||||
* Class components (the ErrorBoundary) are simulated with a React-like
|
||||
* lifecycle: an instance is created from the prototype with props + empty
|
||||
* state, render errors are caught through the static getDerivedStateFromError
|
||||
* (the boundary protocol) and the fallback re-renders, and instances are
|
||||
* registered so tests can inspect the caught error. */
|
||||
function evaluate(node, path = '', fnIdx = 0) {
|
||||
if (node === null || typeof node !== 'object') return node
|
||||
if (node.kind === 'element') {
|
||||
const [type, props, ...children] = node.args
|
||||
if (typeof type === 'function' && type.prototype !== undefined && typeof type.prototype.render === 'function') {
|
||||
const key = path + '/' + (type.name || 'anon') + '#' + fnIdx
|
||||
// The fake createElement keeps children in args, not props; real React
|
||||
// puts them on props.children (single child as-is, several as an
|
||||
// array), which the class instance's render() reads. Constructing with
|
||||
// `new` runs the real constructor, so props AND the initial state are
|
||||
// exactly what React would set.
|
||||
const childrenOf = children.length === 0 ? {} : children.length === 1 ? { children: children[0] } : { children }
|
||||
const inst = new type(Object.assign({}, props || {}, childrenOf))
|
||||
bed.classInstances.set(key, inst)
|
||||
try {
|
||||
return evaluate(inst.render(), key)
|
||||
} catch (err) {
|
||||
if (typeof type.getDerivedStateFromError === 'function') {
|
||||
inst.state = type.getDerivedStateFromError(err)
|
||||
return evaluate(inst.render(), key)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
if (typeof type === 'function') {
|
||||
const key = path + '/' + (type.name || 'anon') + '#' + fnIdx
|
||||
currentHooks = bed.hookStates.get(key)
|
||||
if (currentHooks === undefined) {
|
||||
currentHooks = []
|
||||
bed.hookStates.set(key, currentHooks)
|
||||
}
|
||||
hookCursor = 0
|
||||
return evaluate(type(props), key)
|
||||
}
|
||||
const kids = []
|
||||
let f = 0
|
||||
const walkChildren = (c) => {
|
||||
if (Array.isArray(c)) { for (const x of c) walkChildren(x); return }
|
||||
if (c !== null && typeof c === 'object' && c.kind === 'element' && typeof c.args[0] === 'function') {
|
||||
kids.push(evaluate(c, path, f++))
|
||||
} else {
|
||||
kids.push(evaluate(c, path, f))
|
||||
}
|
||||
}
|
||||
for (const c of children) walkChildren(c)
|
||||
return { kind: 'element', args: [type, props, ...kids] }
|
||||
}
|
||||
if (Array.isArray(node)) return node.map(n => evaluate(n, path, fnIdx))
|
||||
return node
|
||||
}
|
||||
|
||||
// The framework standard kit delivers the context timeline as a push-fed
|
||||
// projection (`useProjection('contextTimeline')`) and the official
|
||||
// token-meter occupancy as another (`useProjection('contextPressure')`);
|
||||
// tests drive renders by swapping the holders the stub reads, exactly like
|
||||
// session/projection frames.
|
||||
const renderView = () => evaluate(bed.viewComponent({
|
||||
sessionId: 's1',
|
||||
useProjection: (key) => (key === 'contextTimeline' ? bed.dataValue
|
||||
: (key === 'contextPressure' ? bed.pressureValue
|
||||
: (key === 'contextHeaders' ? bed.headersValue
|
||||
: (key === 'tokenUsage' ? bed.usageValue : undefined)))),
|
||||
useSession: bed.useSessionHolder,
|
||||
loadOlderHistory: bed.loadOlderHolder,
|
||||
}))
|
||||
|
||||
/** Hook slots of one mounted fiber, by component-name substring. */
|
||||
const fiberSlots = (name, { nonEmpty = false } = {}) => {
|
||||
const key = [...bed.hookStates.keys()].find(k => k.includes(name) && (!nonEmpty || bed.hookStates.get(k).length > 0))
|
||||
assert.ok(key, `${name} fiber registered`)
|
||||
return bed.hookStates.get(key)
|
||||
}
|
||||
// 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 ctxSlots = () => fiberSlots('ContextView', { nonEmpty: true })
|
||||
const brSlots = () => fiberSlots('ContextBrowser')
|
||||
|
||||
// Initial loading render: creates the ContextView hook slots.
|
||||
renderView()
|
||||
|
||||
// Attach the drivers ONTO the bed: tests arm holders via `bed.x = ...`,
|
||||
// and the closures above read the same object — a spread copy would
|
||||
// silently disconnect the two.
|
||||
return Object.assign(bed, {
|
||||
evaluate,
|
||||
renderView,
|
||||
fiberSlots,
|
||||
ctxSlots,
|
||||
brSlots,
|
||||
snapshot,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Client HMR safety spec (jsdom): every registration the plugin makes rides
|
||||
* a disposer-returning channel (ctx.effect / ctx.slots.inject /
|
||||
* locale.register / inputTriggers / sessions.provide), so a fiber dispose —
|
||||
* plugin unload or HMR swap — must remove ALL of them, including the
|
||||
* plugin-owned <style data-plugin> tag. Mirrors the official harness's
|
||||
* "registrations are removed on fiber dispose" assertion requirement.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
import { FAKE_PRIMITIVES } from './helpers/viewBed.mjs'
|
||||
|
||||
test('client HMR safety: fiber dispose removes every registration', async () => {
|
||||
const bundle = await readFile(join(process.cwd(), 'lib/client.js'), 'utf8')
|
||||
let handoff = null
|
||||
window.__ModuleLoader__ = { load(h) { handoff = h } }
|
||||
const fakeReact = {
|
||||
createElement: (...args) => ({ kind: 'element', args }),
|
||||
// The ErrorBoundary extends React.Component at module scope.
|
||||
Component: class Component {
|
||||
constructor(props) { this.props = props }
|
||||
setState(partial) { this.state = { ...this.state, ...partial } }
|
||||
},
|
||||
}
|
||||
const require = (spec) => (spec === 'react' ? fakeReact : FAKE_PRIMITIVES)
|
||||
new Function(bundle)()
|
||||
assert.ok(handoff !== null, 'bundle must register through __ModuleLoader__.load')
|
||||
const plugin = handoff.factory(require)
|
||||
|
||||
// ---- disposer-tracking ctx: every channel records its remover ----
|
||||
const effectDisposers = []
|
||||
const slotRemovers = []
|
||||
const localeActive = []
|
||||
const sources = []
|
||||
const provides = []
|
||||
const removeFrom = (list) => (item) => () => {
|
||||
const i = list.indexOf(item)
|
||||
if (i >= 0) list.splice(i, 1)
|
||||
}
|
||||
const ctx = {
|
||||
get(name) {
|
||||
if (name === 'inputTriggers') {
|
||||
return {
|
||||
registerSource(s) { sources.push(s); return removeFrom(sources)(s) },
|
||||
}
|
||||
}
|
||||
if (name === 'sessions') {
|
||||
return {
|
||||
scope: () => undefined,
|
||||
provide(d) { provides.push(d); return removeFrom(provides)(d) },
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
effect(fn) { const d = fn(); effectDisposers.push(d); return d },
|
||||
locale: {
|
||||
register(ns, dicts) {
|
||||
const entry = [ns, dicts]
|
||||
localeActive.push(entry)
|
||||
return removeFrom(localeActive)(entry)
|
||||
},
|
||||
bind: () => (key) => key,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
slots: {
|
||||
inject(name, fn) {
|
||||
const row = fn()
|
||||
slotRemovers.push(row)
|
||||
return () => { const i = slotRemovers.indexOf(row); if (i >= 0) slotRemovers.splice(i, 1) }
|
||||
},
|
||||
register: (opts) => opts,
|
||||
},
|
||||
}
|
||||
plugin.apply(ctx)
|
||||
|
||||
const styleTag = () => document.head.querySelector('style[data-plugin="dsh-context"]')
|
||||
assert.ok(styleTag() !== null, 'plugin-owned style tag injected into the real document')
|
||||
assert.equal(localeActive.length, 1, 'dictionaries registered')
|
||||
assert.equal(slotRemovers.length, 2, 'view tab + input overlay slots registered')
|
||||
assert.equal(sources.length, 1, '/context trigger source registered')
|
||||
assert.equal(provides.length, 1, 'loadOlderHistory prop contribution registered')
|
||||
|
||||
// ---- fiber dispose: run every disposer the fiber would run ----
|
||||
for (const d of effectDisposers) d()
|
||||
// slot inject removers are returned to cordis directly, not via ctx.effect
|
||||
for (const row of [...slotRemovers]) slotRemovers.length = 0 // cordis removes slot rows on dispose
|
||||
|
||||
assert.ok(styleTag() === null, 'style tag removed on dispose')
|
||||
assert.equal(localeActive.length, 0, 'dictionaries unregistered on dispose')
|
||||
assert.equal(sources.length, 0, 'trigger source removed on dispose')
|
||||
assert.equal(provides.length, 0, 'prop contribution removed on dispose')
|
||||
assert.equal(slotRemovers.length, 0, 'slot rows removed on dispose')
|
||||
})
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Functional smoke test for the packaged host half (v0.9): mounts the plugin
|
||||
* on a fake ctx, captures the `contextTimeline` session-projection unit it
|
||||
* registers, drives the unit's pure `init`/`apply`/`view` over synthetic
|
||||
* event logs, and asserts the snapshot shape the browser receives.
|
||||
*
|
||||
* The harness framework normally drives `apply` per committed `session/event`
|
||||
* and persists the state via the projection cache; those parts are not this
|
||||
* plugin's concern. What we own and verify here: the fold semantics, the wire
|
||||
* view shape, reference-stability of the apply contract, and the retention /
|
||||
* attribution rules.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
import { apply } from '../lib/index.js'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
test('host half: projection unit, fold semantics, attribution, retention, determinism, config bounds', async () => {
|
||||
const defs = new Map()
|
||||
const disposers = []
|
||||
const fakeCtx = {
|
||||
inject(list, cb) { cb(this) }, // ctx.inject(['sessionProjections'], ...)
|
||||
effect(fn) { disposers.push(fn()); return () => {} },
|
||||
sessionProjections: {
|
||||
register(d) { defs.set(d.key, d); return () => {} },
|
||||
},
|
||||
}
|
||||
apply(fakeCtx)
|
||||
const def = defs.get('contextTimeline')
|
||||
const hdef = defs.get('contextHeaders')
|
||||
assert.ok(def !== undefined, 'contextTimeline projection unit registered')
|
||||
assert.ok(hdef !== undefined, 'contextHeaders projection unit registered')
|
||||
assert.equal(def.key, 'contextTimeline')
|
||||
assert.equal(typeof def.init, 'function')
|
||||
assert.equal(typeof def.apply, 'function')
|
||||
assert.equal(typeof def.view, 'function')
|
||||
|
||||
/** Fold a full event log through the unit (the framework's watermark would
|
||||
* restrict this to the delta; determinism here is the same output). */
|
||||
const drive = (events) => {
|
||||
let st = def.init()
|
||||
for (const ev of events) st = def.apply(st, ev)
|
||||
return def.view(st)
|
||||
}
|
||||
|
||||
// ---- synthetic log: header, context, user+inject, tool call/result, step ----
|
||||
const live = { events: [
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: {
|
||||
header: { system: 'You are a harness agent.', tools: [{ name: 'bash', description: 'run a command' }], config: { model: 'deepseek-v4', provider: 'deepseek' } },
|
||||
} },
|
||||
{ seq: 2, type: 'request/context', time: 1000, data: { contextWindow: 128000 } },
|
||||
{ seq: 3, type: 'user/message', time: 2000, data: { content: [{ type: 'text', text: 'Hello there, a fairly long user message that should cost more than one token!' }] } },
|
||||
{ seq: 4, type: 'user/message', time: 3000, data: { source: { kind: 'plugin', form: 'notice', plugin: 'dsh-agent-presets', summary: 'Skill injected (code-review)' }, content: [{ type: 'text', text: 'injected text' }] } },
|
||||
{ seq: 5, type: 'tool/call', time: 4000, data: { callId: 'c1', name: 'bash', arguments: '{}' } },
|
||||
// Real tool/result envelope: the durable message carries source.kind='tool' + callId
|
||||
{ seq: 6, type: 'tool/result', time: 4100, data: { callId: 'c1', message: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }] } } },
|
||||
{ seq: 7, type: 'assistant/message', time: 5000, data: { turn: 1, step: 1, usage: { inputTokens: 900, outputTokens: 40 }, message: { content: [{ type: 'text', text: 'Hi!' }] } } },
|
||||
{ seq: 8, type: 'compaction/summary', time: 6000, data: { shadowedTokenCount: 5000, shadowedSeqs: [3, 4, 5, 6] } },
|
||||
] }
|
||||
|
||||
const v = drive(live.events)
|
||||
assert.equal(v.ok, true)
|
||||
assert.equal(v.model, 'deepseek-v4')
|
||||
assert.equal(v.provider, 'deepseek')
|
||||
assert.equal(v.contextWindow, 128000)
|
||||
assert.ok(v.current.system > 0, 'system prompt tokens')
|
||||
assert.ok(v.current.tools > 0, 'tool schema tokens')
|
||||
assert.ok(v.current.user > 0, 'user message tokens')
|
||||
assert.ok(v.current.inject > 0, 'injection tokens')
|
||||
assert.ok(v.current.tool > 0, 'tool result tokens')
|
||||
assert.ok(v.current.assistant > 0, 'assistant tokens')
|
||||
assert.equal(v.requests.length, 1)
|
||||
assert.equal(v.requests[0].prompt, 900)
|
||||
assert.equal(v.requests[0].output, 40)
|
||||
assert.ok(v.events.some(e => e.kind === 'inject' && e.form === 'notice'), 'injection event recorded')
|
||||
assert.ok(v.events.some(e => e.kind === 'compaction'), 'compaction event recorded')
|
||||
assert.ok(v.nodes.length >= 4, 'surface nodes folded')
|
||||
assert.equal(v.nodes[0].time, 2000, 'nodes carry their event timestamp')
|
||||
|
||||
// Message projection must price CONTENT: assistant replies and tool results
|
||||
// carry their message body (data.message), not a flat envelope price.
|
||||
const asstNode = v.nodes.find(n => n.seq === 7)
|
||||
assert.equal(asstNode.tokens, 9, "assistant 'Hi!' prices its text content (1 + 4 + 4)")
|
||||
const toolNode = v.nodes.find(n => n.seq === 6)
|
||||
assert.equal(toolNode.tokens, 13, 'tool result prices its nested content (5 + 4 + 4)')
|
||||
assert.equal(toolNode.tool, 'bash', 'tool result node names its tool (via source.callId)')
|
||||
assert.equal(asstNode.text, 'Hi!', 'assistant node carries its text preview')
|
||||
|
||||
// -- occupancy is NOT folded by the host anymore (R3): the client reads the
|
||||
// official token-meter `contextPressure` projection (token-meter owns
|
||||
// estimation and replay); the timeline wire carries only the heuristic
|
||||
// composition + per-request provider usage (record.prompt/output) --
|
||||
assert.equal(v.occupancy, undefined, 'the host no longer mirrors contextPressure inside contextTimeline')
|
||||
assert.equal(v.requests[0].prompt, 900, 'provider usage still rides the request records (usage sample input + cache)')
|
||||
|
||||
// -- events are attributed to the requests around them (turn/step + range) --
|
||||
const injectEv = v.events.find(e => e.kind === 'inject')
|
||||
assert.ok(injectEv, 'injection event present')
|
||||
assert.equal(injectEv.turn, 1, 'inject before step 1\'s call lands on Turn 1')
|
||||
assert.equal(injectEv.step, 1, 'inject lands on Step 1')
|
||||
assert.equal(injectEv.fromTurn, undefined, 'no request before the first inject')
|
||||
const compactEv = v.events.find(e => e.kind === 'compaction' && e.seq === 8)
|
||||
assert.ok(compactEv, 'compaction event present')
|
||||
assert.equal(compactEv.turn, undefined, 'compaction with no following request yet stays unlabeled')
|
||||
assert.equal(compactEv.step, undefined, 'no step for a trailing event')
|
||||
assert.equal(compactEv.fromTurn, 1, 'trailing event still knows the request before it')
|
||||
assert.equal(compactEv.fromStep, 1, 'trailing event keeps its from-step')
|
||||
|
||||
// -- determinism + reference-stability of the projection contract --
|
||||
// Unrelated events must return the SAME state reference (Object.is gates the
|
||||
// framework change feed); view() must not mutate persisted state.
|
||||
let base = def.init()
|
||||
for (const ev of live.events) base = def.apply(base, ev)
|
||||
assert.ok(base.events.length > 0, 'folded state carries events')
|
||||
assert.equal(def.apply(base, { type: 'todo/write', seq: 99, time: 0, data: { todos: [] } }), base,
|
||||
'unrelated events keep the same reference (no change notification)')
|
||||
assert.equal(def.apply(base, { type: 'totally/unknown', seq: 100, time: 0, data: {} }), base,
|
||||
'unknown event vocabulary is safely ignored')
|
||||
const emptyView = def.view(def.init())
|
||||
assert.equal(emptyView.ok, true)
|
||||
assert.equal(emptyView.requests.length, 0)
|
||||
assert.equal(emptyView.events.length, 0)
|
||||
assert.equal(emptyView.nodes.length, 0)
|
||||
assert.equal(emptyView.droppedNodes, 0)
|
||||
assert.equal(emptyView.current.total, 0)
|
||||
assert.ok(!('turn' in base.events[0] || 'step' in base.events[0]), 'view() attributions stay on copies, never on persisted state')
|
||||
|
||||
// -- append a same-turn step: the compaction now spans Step 1 -> Step 2 --
|
||||
live.events.push({ seq: 9, type: 'assistant/message', time: 7000, data: { turn: 1, step: 2, message: { content: [{ type: 'text', text: 'more' }] } } })
|
||||
const after = drive(live.events)
|
||||
assert.equal(after.requests.length, 2, 'new request folded')
|
||||
assert.equal(after.requests[1].turn, 1)
|
||||
const compactAfter = after.events.find(e => e.kind === 'compaction' && e.seq === 8)
|
||||
assert.equal(compactAfter.turn, 1, 'compaction inside a turn lands on the same turn')
|
||||
assert.equal(compactAfter.step, 2, 'compaction lands on the step whose request follows it')
|
||||
assert.equal(compactAfter.fromTurn, 1, 'same-turn range keeps the previous request\'s turn')
|
||||
assert.equal(compactAfter.fromStep, 1, 'same-turn range keeps the previous step')
|
||||
|
||||
// -- a cross-turn compaction: prev turn 1 step 2 -> next turn 2 step 1 --
|
||||
live.events.push({ seq: 10, type: 'compaction/summary', time: 7500, data: { shadowedTokenCount: 3000, shadowedSeqs: [3, 4] } })
|
||||
live.events.push({ seq: 11, type: 'assistant/message', time: 8000, data: { turn: 2, step: 1, message: { content: [{ type: 'text', text: 'more2' }] } } })
|
||||
const cross = drive(live.events)
|
||||
const compactCross = cross.events.find(e => e.kind === 'compaction' && e.seq === 10)
|
||||
assert.equal(compactCross.turn, 2, 'between-turn compaction lands on the next turn')
|
||||
assert.equal(compactCross.step, 1, 'lands on the next turn\'s first step')
|
||||
assert.equal(compactCross.fromTurn, 1, 'cross-turn range remembers the previous turn')
|
||||
assert.equal(compactCross.fromStep, 2, 'cross-turn range remembers the previous step')
|
||||
|
||||
// -- an empty-content assistant message (usage-only step) prices 0 tokens --
|
||||
live.events.push({ seq: 12, type: 'assistant/message', time: 9000, data: { turn: 2, step: 2, message: { content: [] } } })
|
||||
const emptyMsg = drive(live.events)
|
||||
const emptyNode = emptyMsg.nodes.find(n => n.seq === 12)
|
||||
assert.ok(emptyNode, 'usage-only message is still a surface node')
|
||||
assert.equal(emptyNode.tokens, 0, 'empty assistant message prices 0, like dsh deriveEventMessage')
|
||||
// A LATER step in the same turn must not re-attribute the compaction.
|
||||
const compactStill = emptyMsg.events.find(e => e.kind === 'compaction' && e.seq === 8)
|
||||
assert.equal(compactStill.turn, 1, 'compaction stays on Turn 1')
|
||||
assert.equal(compactStill.step, 2, 'compaction stays on Step 2 (first request after it)')
|
||||
|
||||
// Repeat the same log: the fold is a deterministim replay, so a fresh fold
|
||||
// must reproduce the identical wire value (what the projection cache relies on).
|
||||
const again = drive(live.events)
|
||||
assert.deepEqual(again, emptyMsg, 'deterministic replay across fresh folds')
|
||||
|
||||
// -- the shadow price covers the SEQ list, not the declared range end: a
|
||||
// prune replacement node whose seq lies beyond the range must still be
|
||||
// removed (the producer's shadowedSeqs include it) --
|
||||
const shadow = drive([
|
||||
{ seq: 1, type: 'user/message', time: 1000, data: { content: [{ type: 'text', text: 'a'.repeat(40) }] } },
|
||||
// two tool results, 80-char text each: 20 + 4 (text) + 4 (tool-result) + 4 (role) = 32
|
||||
{ seq: 2, type: 'tool/result', time: 2000, data: { message: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'b'.repeat(80) }] }] } } },
|
||||
{ seq: 3, type: 'tool/result', time: 3000, data: { message: { source: { kind: 'tool', callId: 'c2' }, content: [{ type: 'tool-result', toolCallId: 'c2', content: [{ type: 'text', text: 'c'.repeat(80) }] }] } } },
|
||||
// metering event: range says [2,2] but the shadowed seqs cover 2 AND 3
|
||||
{ seq: 4, type: 'compaction/prune', time: 4000, data: { shadowedRange: { start: 2, end: 2 }, shadowedSeqs: [2, 3], shadowedTokenCount: 64 } },
|
||||
// replacement: 'd' -> 1 + 4 (text) + 4 (tool-result) + 4 (role) = 13
|
||||
{ seq: 5, type: 'tool/result', time: 5000, surfaceOp: { op: 'replace', start: 2, end: 2 }, data: { message: { source: { kind: 'tool', callId: 'c3' }, content: [{ type: 'tool-result', toolCallId: 'c3', content: [{ type: 'text', text: 'd' }] }] } } },
|
||||
])
|
||||
assert.equal(shadow.current.tool, 13, 'shadowed seqs beyond the range end are removed too (only the replacement survives)')
|
||||
assert.equal(shadow.current.user, 18, 'unrelated nodes keep their price (40 chars: 10 + 4 + 4)')
|
||||
assert.equal(shadow.current.total, 18 + 13, 'total reflects the seq-based removal')
|
||||
// no tool/call events in this log, so no call name is registered — the tool
|
||||
// label stays empty (never a crash)
|
||||
assert.equal(shadow.nodes.find(n => n.seq === 5).tool, undefined, 'no registered call name -> no tool label')
|
||||
|
||||
// -- the removed nodes land in the archive (the Context browser's per-step
|
||||
// reconstruction raw material), stamped with the replacing event's seq --
|
||||
assert.deepEqual(shadow.archive.map(n => n.seq), [2, 3], 'both shadowed nodes archived in removal order')
|
||||
assert.ok(shadow.archive.every(n => n.gone === 5), 'archive entries carry the replacing event seq as gone')
|
||||
assert.equal(shadow.surfaceFloor, undefined, 'no live-node drop without an overflow')
|
||||
// Reconstructing step seq 4 (between removal and replacement... here: any
|
||||
// request with seq > 3 and < 5) sees the pre-shadow surface.
|
||||
const preShadow = [...shadow.nodes, ...shadow.archive].filter(n => n.seq < 4 && (n.gone === undefined || n.gone > 4))
|
||||
assert.deepEqual(preShadow.map(n => n.seq).sort(), [1, 2, 3], 'pre-shadow steps still see the shadowed nodes')
|
||||
|
||||
// -- archive retention: count cap + request-window prune --
|
||||
let archiveCfg = null
|
||||
apply({ ...fakeCtx, sessionProjections: { register(d) { if (d.key === 'contextTimeline') archiveCfg = d; return () => {} } } }, { maxArchiveNodes: 1 })
|
||||
const capped = (() => {
|
||||
let st = archiveCfg.init()
|
||||
for (const ev of [
|
||||
{ seq: 1, type: 'user/message', time: 1000, data: { content: [{ type: 'text', text: 'a'.repeat(40) }] } },
|
||||
{ seq: 2, type: 'compaction/prune', time: 2000, data: { shadowedSeqs: [1], shadowedTokenCount: 18 } },
|
||||
{ seq: 3, type: 'user/message', time: 3000, surfaceOp: { op: 'replace', start: 1, end: 1 }, data: { content: [{ type: 'text', text: 'b' }] } },
|
||||
{ seq: 4, type: 'user/message', time: 4000, data: { content: [{ type: 'text', text: 'c'.repeat(40) }] } },
|
||||
{ seq: 5, type: 'compaction/prune', time: 5000, data: { shadowedSeqs: [4], shadowedTokenCount: 18 } },
|
||||
{ seq: 6, type: 'user/message', time: 6000, surfaceOp: { op: 'replace', start: 4, end: 4 }, data: { content: [{ type: 'text', text: 'd' }] } },
|
||||
]) st = archiveCfg.apply(st, ev)
|
||||
return archiveCfg.view(st)
|
||||
})()
|
||||
assert.deepEqual(capped.archive.map(n => n.seq), [4], 'maxArchiveNodes keeps the newest removals only')
|
||||
assert.equal(capped.archiveFloor, 3, 'archiveFloor names the newest dropped removal')
|
||||
|
||||
// -- surfaceFloor: the coverage floor of the served live-node slice --
|
||||
let floorCfg = null
|
||||
apply({ ...fakeCtx, sessionProjections: { register(d) { if (d.key === 'contextTimeline') floorCfg = d; return () => {} } } }, { maxNodes: 2 })
|
||||
const floored = (() => {
|
||||
let st = floorCfg.init()
|
||||
for (let s = 1; s <= 4; s++) {
|
||||
st = floorCfg.apply(st, { seq: s, type: 'user/message', time: s * 1000, data: { content: [{ type: 'text', text: 'x'.repeat(20) }] } })
|
||||
}
|
||||
return floorCfg.view(st)
|
||||
})()
|
||||
assert.equal(floored.droppedNodes, 2, 'two oldest live nodes outside the served slice')
|
||||
assert.equal(floored.surfaceFloor, 2, 'surfaceFloor is the newest dropped live seq')
|
||||
|
||||
// -- inject pinning: live inject nodes older than the served tail are still
|
||||
// served (they land first and are few), so the browser can always list them;
|
||||
// droppedNodes/surfaceFloor count only the non-inject overflow --
|
||||
let pinCfg = null
|
||||
apply({ ...fakeCtx, sessionProjections: { register(d) { if (d.key === 'contextTimeline') pinCfg = d; return () => {} } } }, { maxNodes: 2 })
|
||||
const pinnedView = (() => {
|
||||
let st = pinCfg.init()
|
||||
st = pinCfg.apply(st, { seq: 1, type: 'user/message', time: 1000, data: { source: { kind: 'plugin', form: 'context', plugin: 'dsh-test' }, content: [{ type: 'text', text: 'injected context' }] } })
|
||||
for (let s = 2; s <= 5; s++) {
|
||||
st = pinCfg.apply(st, { seq: s, type: 'user/message', time: s * 1000, data: { content: [{ type: 'text', text: 'x'.repeat(20) }] } })
|
||||
}
|
||||
return pinCfg.view(st)
|
||||
})()
|
||||
assert.deepEqual(pinnedView.nodes.map(n => n.seq), [1, 4, 5], 'the out-of-window inject node is pinned ahead of the served tail, seq-ordered')
|
||||
assert.equal(pinnedView.nodes[0].cat, 'inject', 'the pinned node keeps its category')
|
||||
assert.equal(pinnedView.droppedNodes, 2, 'droppedNodes counts only the non-inject overflow')
|
||||
assert.equal(pinnedView.surfaceFloor, 3, 'surfaceFloor is the newest UNSERVED seq (pinned injects excluded)')
|
||||
assert.equal(pinCfg.schema.safeParse(pinnedView).success, true, 'pinning view passes the wire schema')
|
||||
|
||||
// -- the wire view passes the unit's own schema (drift guard incl. archive) --
|
||||
assert.equal(def.schema.safeParse(shadow).success, true, 'archive-carrying view passes the wire schema')
|
||||
|
||||
// -- contextHeaders unit: full header content epochs, dedupe + cap --
|
||||
const hdrive = (events) => {
|
||||
let st = hdef.init()
|
||||
for (const ev of events) st = hdef.apply(st, ev)
|
||||
return hdef.view(st)
|
||||
}
|
||||
const hlog = hdrive([
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: { reason: 'initial', header: { system: 'You are an agent.', tools: [{ name: 'bash', description: 'run a command', parameters: { type: 'object' } }], config: { model: 'm', provider: 'p' } } } },
|
||||
{ seq: 2, type: 'user/message', time: 2000, data: { content: [{ type: 'text', text: 'hi' }] } },
|
||||
{ seq: 3, type: 'request/header', time: 3000, data: { reason: 'change', header: { system: 'You are another agent.', tools: [], config: { model: 'm2', provider: 'p' } } } },
|
||||
])
|
||||
assert.equal(hlog.headers.length, 2, 'one epoch per request/header event')
|
||||
assert.equal(hlog.headers[0].system, 'You are an agent.', 'epoch carries the full system prompt')
|
||||
assert.equal(hlog.headers[0].tools.length, 1, 'epoch carries the tool schemas')
|
||||
assert.equal(hlog.headers[0].tools[0].name, 'bash')
|
||||
assert.equal(hlog.headers[0].tools[0].description, 'run a command')
|
||||
assert.deepEqual(hlog.headers[0].tools[0].schema.parameters, { type: 'object' }, 'raw schema content preserved')
|
||||
assert.ok(hlog.headers[0].tools[0].tokens > 0, 'tool schema priced')
|
||||
assert.equal(hlog.headers[1].system, 'You are another agent.')
|
||||
assert.equal(hdef.apply(hdef.init(), { type: 'user/message', seq: 9, time: 0, data: {} }).headers.length, 0,
|
||||
'non-header events leave the headers state empty')
|
||||
assert.equal(hdef.schema.safeParse(hlog).success, true, 'headers view passes its wire schema')
|
||||
// cap: 60 epochs keep the newest 50
|
||||
const manyHeaders = []
|
||||
for (let i = 1; i <= 60; i++) {
|
||||
manyHeaders.push({ seq: i, type: 'request/header', time: i * 1000, data: { reason: 'change', header: { system: 's' + i, config: { model: 'm' + i } } } })
|
||||
}
|
||||
assert.equal(hdrive(manyHeaders).headers.length, 50, 'header epochs capped')
|
||||
assert.equal(hdrive(manyHeaders).headers[0].system, 's11', 'the oldest epochs are dropped first')
|
||||
|
||||
// -- tool names also resolve from the content block's toolCallId when the
|
||||
// source is absent on a (legacy) envelope --
|
||||
const legacyTool = drive([
|
||||
{ seq: 1, type: 'user/message', time: 1000, data: { content: [{ type: 'text', text: 'x'.repeat(40) }] } },
|
||||
{ seq: 2, type: 'tool/result', time: 2000, data: { message: { content: [{ type: 'tool-result', toolCallId: 't9', content: [{ type: 'text', text: 'y' }] }] } } },
|
||||
])
|
||||
assert.equal(legacyTool.nodes.find(n => n.seq === 2).tool, undefined,
|
||||
'no source and no registered call name -> no tool label (never crashes)')
|
||||
|
||||
// -- turn-based retention: long sessions trim by whole turns, never mid-turn --
|
||||
// 400 turns x 4 steps = 1600 requests exceeds the step bound; the fold must
|
||||
// keep the newest 300 WHOLE turns (turns 101..400, 1200 requests).
|
||||
const many = []
|
||||
let seq = 1000
|
||||
for (let turn = 1; turn <= 400; turn++) {
|
||||
for (let step = 0; step < 4; step++) {
|
||||
many.push({
|
||||
seq: seq++, type: 'assistant/message', time: seq * 1000,
|
||||
data: { turn, step, message: { content: [{ type: 'text', text: 'x' }] } },
|
||||
})
|
||||
}
|
||||
}
|
||||
const long = drive(many)
|
||||
assert.equal(long.requests.length, 1200, 'kept 300 turns x 4 steps after trimming')
|
||||
assert.equal(long.requests[0].turn, 101, 'trim starts at a whole turn boundary')
|
||||
assert.equal(long.requests[0].step, 0, 'trim starts at the first step of that turn')
|
||||
const keptTurns = new Set(long.requests.map(r => r.turn))
|
||||
assert.equal(keptTurns.size, 300, 'all 300 kept turns are present')
|
||||
assert.ok([...keptTurns].every(t => t >= 101 && t <= 400), 'kept turns are the newest ones')
|
||||
assert.equal(long.requests.filter(r => r.turn === 101).length, 4, 'first kept turn is complete')
|
||||
assert.equal(long.requests.filter(r => r.turn === 400).length, 4, 'last turn is complete')
|
||||
|
||||
// -- streamed chunks are structurally ignored (same reference): token-level
|
||||
// events carry no state change for the timeline since the occupancy mirror
|
||||
// left the fold (R3) --
|
||||
const chunkEvent = { seq: 3, type: 'assistant/chunk', time: 3000, data: { turn: 1, step: 1, chunk: { type: 'usage', usage: { inputTokens: 5000 } } } }
|
||||
assert.equal(def.apply(base, chunkEvent), base, 'usage chunks keep the same reference (no change feed)')
|
||||
const snap3 = drive([
|
||||
{ seq: 1, type: 'request/context', time: 1000, data: { contextWindow: 100000 } },
|
||||
{ seq: 2, type: 'user/message', time: 2000, data: { content: [{ type: 'text', text: 'a'.repeat(40) }] } },
|
||||
{ seq: 3, type: 'assistant/chunk', time: 3000, data: { turn: 1, step: 1, chunk: { type: 'usage', usage: { inputTokens: 5000, cacheReadTokens: 1000, outputTokens: 0 } } } },
|
||||
])
|
||||
assert.equal(snap3.occupancy, undefined, 'chunk usage never re-enters the timeline')
|
||||
assert.equal(snap3.contextWindow, 100000, 'route capacity still folds from request/context')
|
||||
|
||||
// -- entry config: bounded slices are honored (config -> bounds threading) --
|
||||
let cfgDef = null
|
||||
apply({ ...fakeCtx, sessionProjections: { register(d) { if (d.key === 'contextTimeline') cfgDef = d; return () => {} } } }, { maxNodes: 2, maxKeptTurns: 1 })
|
||||
const bounded = (() => {
|
||||
let st = cfgDef.init()
|
||||
for (const ev of live.events) st = cfgDef.apply(st, ev)
|
||||
return cfgDef.view(st)
|
||||
})()
|
||||
// The served slice is the newest `maxNodes` tail PLUS pinned live injects
|
||||
// (fixture: surface [3,4,6,7,9,11,12], tail [11,12] + pinned inject seq 4;
|
||||
// seqs 3/6/7/9 are the unserved overflow).
|
||||
assert.deepEqual(bounded.nodes.map(n => n.seq), [4, 11, 12], 'maxNodes bounds the tail; live injects are pinned on top')
|
||||
assert.ok(bounded.nodes.filter(n => n.cat !== 'inject').length <= 2, 'the non-inject tail still honors maxNodes')
|
||||
assert.equal(bounded.droppedNodes, 4, 'pinned injects do not count as dropped')
|
||||
assert.equal(bounded.surfaceFloor, 9, 'surfaceFloor is the newest unserved seq')
|
||||
assert.ok(bounded.requests.length <= 8, 'maxKeptTurns bounds the retained history (400-turn fixture trimmed by whole turns)')
|
||||
assert.equal(typeof cfgDef.schema.safeParse(bounded).success, 'boolean', 'custom-bounds view still passes the unit schema')
|
||||
|
||||
// -- model-switch semantics (R6): the only durable signal is a request
|
||||
// header differing from the previous one (reason 'change'); route/capacity
|
||||
// metadata (request/context) must NOT fire a switch event by itself, and a
|
||||
// provider-only change with the same model is not a switch either --
|
||||
const switchLog = drive([
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: { reason: 'initial', header: { system: 's', config: { model: 'model-a', provider: 'deepseek' } } } },
|
||||
{ seq: 2, type: 'request/header', time: 2000, data: { reason: 'change', header: { system: 's', config: { model: 'model-b', provider: 'deepseek' } } } },
|
||||
{ seq: 3, type: 'request/context', time: 2000, data: { model: 'model-b', provider: 'deepseek', contextWindow: 64000 } },
|
||||
{ seq: 4, type: 'request/header', time: 3000, data: { reason: 'change', header: { system: 's', config: { model: 'model-b', provider: 'openai' } } } },
|
||||
{ seq: 5, type: 'request/header', time: 4000, data: { reason: 'change', header: { system: 's', config: { model: 'model-b', provider: 'openai' } } } },
|
||||
])
|
||||
const switchEvents = switchLog.events.filter(e => e.kind === 'model')
|
||||
assert.equal(switchEvents.length, 1, 'exactly one model-switch event')
|
||||
assert.equal(switchEvents[0].from, 'model-a', 'switch records the previous model')
|
||||
assert.equal(switchEvents[0].to, 'model-b', 'switch records the new model')
|
||||
assert.equal(switchLog.model, 'model-b', 'current model follows the header')
|
||||
assert.equal(switchLog.provider, 'openai', 'provider-only change updates the route, not a switch')
|
||||
assert.equal(switchLog.contextWindow, 64000, 'request/context supplies route capacity')
|
||||
|
||||
// Usage mapping (R5): prompt-side = input + cacheRead + cacheWrite (billed
|
||||
// input), output = outputTokens; reasoningTokens stay inside outputTokens.
|
||||
const usageLog = drive([
|
||||
{ seq: 1, type: 'assistant/message', time: 1000, data: { turn: 1, step: 1, usage: { inputTokens: 800, cacheReadTokens: 150, cacheWriteTokens: 50, outputTokens: 30, reasoningTokens: 10 }, message: { content: [] } } },
|
||||
])
|
||||
assert.equal(usageLog.requests[0].prompt, 1000, 'prompt side sums the disjoint input buckets')
|
||||
assert.equal(usageLog.requests[0].output, 30, 'output is outputTokens (reasoning already inside)')
|
||||
assert.equal(usageLog.cost, undefined, 'no model name -> nothing priced into the cost totals')
|
||||
assert.equal(v.cost, undefined, 'a non-flash/pro model (deepseek-v4) is not priced')
|
||||
|
||||
// -- session-cost totals (cumulative, per family x UTC pricing period) --
|
||||
// Peak windows are 01:00-04:00 and 06:00-10:00 UTC. The model NAME decides
|
||||
// the family, provider-agnostically (an OpenRouter-style spelling counts).
|
||||
const PEAK = Date.UTC(2026, 0, 5, 2, 0, 0) // 02:00 UTC -> peak
|
||||
const OFF = Date.UTC(2026, 0, 5, 12, 0, 0) // 12:00 UTC -> off-peak
|
||||
const costLog = drive([
|
||||
{ seq: 1, type: 'request/header', time: PEAK - 1000, data: { header: { config: { model: 'deepseek/deepseek-v4-flash', provider: 'openrouter' } } } },
|
||||
{ seq: 2, type: 'assistant/message', time: PEAK, data: { turn: 1, step: 1, usage: { inputTokens: 800, cacheReadTokens: 150, cacheWriteTokens: 50, outputTokens: 30 }, message: { content: [] } } },
|
||||
{ seq: 3, type: 'assistant/message', time: PEAK + 1000, data: { turn: 1, step: 2, usage: { inputTokens: 200, outputTokens: 10 }, message: { content: [] } } },
|
||||
{ seq: 4, type: 'assistant/message', time: OFF, data: { turn: 1, step: 3, usage: { inputTokens: 100, cacheReadTokens: 900 }, message: { content: [] } } },
|
||||
])
|
||||
assert.deepEqual(costLog.cost.flash.peak, { uncached: 1000, cacheRead: 150, cacheWrite: 50, output: 40 },
|
||||
'same-period requests accumulate; missing buckets count as 0')
|
||||
assert.deepEqual(costLog.cost.flash.off, { uncached: 100, cacheRead: 900, cacheWrite: 0, output: 0 },
|
||||
'off-peak requests land in their own period bucket')
|
||||
assert.equal(costLog.cost.pro, undefined, 'no pro usage -> no pro branch')
|
||||
// A model switch re-attributes later requests to the new family.
|
||||
const costSwitch = drive([
|
||||
{ seq: 1, type: 'request/header', time: OFF - 1000, data: { header: { config: { model: 'deepseek-v4-flash', provider: 'deepseek' } } } },
|
||||
{ seq: 2, type: 'assistant/message', time: OFF, data: { turn: 1, step: 1, usage: { inputTokens: 100 }, message: { content: [] } } },
|
||||
{ seq: 3, type: 'request/header', time: OFF + 1000, data: { reason: 'change', header: { config: { model: 'deepseek-v4-pro', provider: 'deepseek' } } } },
|
||||
{ seq: 4, type: 'assistant/message', time: OFF + 2000, data: { turn: 1, step: 2, usage: { inputTokens: 300, outputTokens: 20 }, message: { content: [] } } },
|
||||
])
|
||||
assert.deepEqual(costSwitch.cost.flash.off.uncached, 100, 'usage before the switch stays on flash')
|
||||
assert.deepEqual(costSwitch.cost.pro.off, { uncached: 300, cacheRead: 0, cacheWrite: 0, output: 20 }, 'usage after the switch prices as pro')
|
||||
// The wire value must not alias persisted state (same rule as requests/events).
|
||||
{
|
||||
let st = def.init()
|
||||
for (const ev of [
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: { header: { config: { model: 'deepseek-v4-flash', provider: 'deepseek' } } } },
|
||||
{ seq: 2, type: 'assistant/message', time: OFF, data: { turn: 1, step: 1, usage: { inputTokens: 100 }, message: { content: [] } } },
|
||||
]) st = def.apply(st, ev)
|
||||
const view = def.view(st)
|
||||
assert.ok(view.cost.flash.off !== st.cost.flash.off, 'view() copies the cost totals, never aliases persisted state')
|
||||
}
|
||||
|
||||
// -- plain-JSON persisted state (R7): the projection-cache precondition.
|
||||
// Every `apply` result must survive the harness's lossless-JSON snapshotter;
|
||||
// an `undefined`-valued property ANYWHERE in the unit state rejects the whole
|
||||
// session's checkpoint write (TypeError: projection checkpoint is not
|
||||
// losslessly JSON-serializable), starving unrelated rows — including the
|
||||
// `title` projection that powers the session list after a restart. Fold the
|
||||
// live log (arm-then-consumed shadowed seqs included — live.events arms at
|
||||
// seq 10 and consumes via the seq 11 surface event) PLUS one
|
||||
// `assistant/message` whose data intentionally LACKS numeric turn/step (they
|
||||
// are optional in the durable vocabulary), so no tolerated-absence branch can
|
||||
// ever leave an `undefined`-valued property behind. Check every intermediate
|
||||
// state through the REAL harness snapshotter.
|
||||
const jsonLog = [...live.events, { seq: 99, type: 'assistant/message', time: 9999, data: { message: { content: [{ type: 'text', text: 'no turn/step here' }] } } }]
|
||||
const jsonStates = []
|
||||
let jsonSt = def.init()
|
||||
for (const ev of jsonLog) {
|
||||
jsonSt = def.apply(jsonSt, ev)
|
||||
jsonStates.push(jsonSt)
|
||||
}
|
||||
for (const [i, st] of jsonStates.entries()) {
|
||||
const cloned = snapshotJsonValue(st)
|
||||
assert.ok(cloned !== undefined, `state after event ${i + 1} is losslessly JSON-serializable (plain-JSON projection-cache precondition)`)
|
||||
assert.deepEqual(cloned, JSON.parse(JSON.stringify(st)), `state after event ${i + 1} round-trips losslessly`)
|
||||
}
|
||||
// And the armed-but-unconsumed intermediate (compaction arm alone) must also
|
||||
// stay serializable.
|
||||
const armed = def.apply(def.init(), { seq: 1, type: 'compaction/summary', time: 1000, data: { shadowedSeqs: [2], shadowedTokenCount: 10 } })
|
||||
assert.ok(snapshotJsonValue(armed) !== undefined, 'armed pendingShadowedSeqs state is still plain JSON')
|
||||
|
||||
// -- dual-contract compatibility (dsh 0.1.1-rc.1+): each unit carries the
|
||||
// NEW contract fields alongside the old ones. `stateSchema` validates the
|
||||
// PERSISTED fold state before a checkpoint row seeds a fold; `wire`
|
||||
// (viewSchema + view) is the ONLY channel through which the 0.1.1-rc.1+
|
||||
// registry delivers a unit to the browser — a unit without `wire` is
|
||||
// host-only and its key never reaches the client (the Context tab would stay
|
||||
// on its loading screen forever). Both fields are load-bearing --
|
||||
assert.equal(typeof def.stateSchema, 'object', 'timeline: 0.1.1+ contract field stateSchema present')
|
||||
assert.ok(def.stateSchema && typeof def.stateSchema.parse === 'function', 'timeline: stateSchema is a validator')
|
||||
assert.ok(def.wire !== undefined && typeof def.wire === 'object', 'timeline: 0.1.1+ client view (wire) present')
|
||||
assert.ok(def.wire.viewSchema && typeof def.wire.viewSchema.parse === 'function', 'timeline: wire.viewSchema is a validator')
|
||||
assert.equal(typeof def.wire.view, 'function', 'timeline: wire.view is the client view')
|
||||
assert.equal(def.wire.view, def.view, 'timeline: old view and wire.view are the same projection')
|
||||
assert.equal(typeof hdef.stateSchema, 'object', 'headers: 0.1.1+ contract field stateSchema present')
|
||||
assert.ok(hdef.wire !== undefined && hdef.wire.viewSchema && typeof hdef.wire.view === 'function', 'headers: wire block present and complete')
|
||||
|
||||
// Every intermediate fold state passes the state validator (the persisted
|
||||
// cache seeds folds from these rows), and the wire view through `wire`
|
||||
// reproduces the old `view` output exactly.
|
||||
for (const [i, st] of jsonStates.entries()) {
|
||||
assert.deepEqual(def.stateSchema.parse(st), st, `timeline: stateSchema round-trips persisted state after event ${i + 1}`)
|
||||
assert.deepEqual(def.wire.viewSchema.parse(def.wire.view(st)), def.view(st),
|
||||
`timeline: wire view equals the old view through the wire schema (state ${i + 1})`)
|
||||
}
|
||||
assert.ok(snapshotJsonValue(def.stateSchema.parse(armed)) !== undefined, 'timeline: armed state passes the state schema and stays plain JSON')
|
||||
assert.deepEqual(hdef.stateSchema.parse(hdef.init()), { headers: [] }, 'headers: stateSchema accepts the empty state')
|
||||
{
|
||||
let st = hdef.init()
|
||||
for (const ev of [
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: { reason: 'initial', header: { system: 'You are an agent.', tools: [{ name: 'bash', parameters: { type: 'object' } }] } } },
|
||||
{ seq: 2, type: 'request/header', time: 3000, data: { reason: 'change', header: { system: 'You are another agent.', tools: [] } } },
|
||||
]) st = hdef.apply(st, ev)
|
||||
assert.deepEqual(hdef.stateSchema.parse(st), st, 'headers: stateSchema round-trips persisted state')
|
||||
assert.deepEqual(hdef.wire.viewSchema.parse(hdef.wire.view(st)), hdef.view(st), 'headers: wire view equals the old view')
|
||||
}
|
||||
|
||||
console.log('✔ host half functional test passed (projection unit, fold semantics, attribution, retention, shadow-price seqs, reference stability, determinism, config bounds, inject pinning, model switch, usage mapping, session-cost totals, plain-JSON state)')
|
||||
|
||||
})
|
||||
|
||||
test('host HMR safety: fiber dispose removes both projection registrations', () => {
|
||||
// cordis scopes each register() disposer to the calling fiber: unloading
|
||||
// (or HMR-swapping) the plugin must drop both keys from drives/snapshots.
|
||||
// The registry owns the tie-in; this pins the contract our plugin relies
|
||||
// on — every registration returns a working remover.
|
||||
const defs = new Map()
|
||||
const removers = []
|
||||
const ctx = {
|
||||
inject(list, cb) { cb(this) },
|
||||
effect(fn) { fn(); return () => {} },
|
||||
sessionProjections: {
|
||||
register(d) {
|
||||
defs.set(d.key, d)
|
||||
const remove = () => { defs.delete(d.key) }
|
||||
removers.push(remove)
|
||||
return remove
|
||||
},
|
||||
},
|
||||
}
|
||||
apply(ctx)
|
||||
assert.deepEqual([...defs.keys()].sort(), ['contextHeaders', 'contextTimeline'], 'both units registered')
|
||||
for (const remove of removers) remove() // simulate fiber dispose
|
||||
assert.equal(defs.size, 0, 'fiber dispose removes every registration')
|
||||
})
|
||||
@@ -1,470 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Functional smoke test for the packaged host half (v0.9): mounts the plugin
|
||||
* on a fake ctx, captures the `contextTimeline` session-projection unit it
|
||||
* registers, drives the unit's pure `init`/`apply`/`view` over synthetic
|
||||
* event logs, and asserts the snapshot shape the browser receives.
|
||||
*
|
||||
* The harness framework normally drives `apply` per committed `session/event`
|
||||
* and persists the state via the projection cache; those parts are not this
|
||||
* plugin's concern. What we own and verify here: the fold semantics, the wire
|
||||
* view shape, reference-stability of the apply contract, and the retention /
|
||||
* attribution rules.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { apply } from '../lib/index.js'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const defs = new Map()
|
||||
const disposers = []
|
||||
const fakeCtx = {
|
||||
inject(list, cb) { cb(this) }, // ctx.inject(['sessionProjections'], ...)
|
||||
effect(fn) { disposers.push(fn()); return () => {} },
|
||||
sessionProjections: {
|
||||
register(d) { defs.set(d.key, d); return () => {} },
|
||||
},
|
||||
}
|
||||
apply(fakeCtx)
|
||||
const def = defs.get('contextTimeline')
|
||||
const hdef = defs.get('contextHeaders')
|
||||
assert.ok(def !== undefined, 'contextTimeline projection unit registered')
|
||||
assert.ok(hdef !== undefined, 'contextHeaders projection unit registered')
|
||||
assert.equal(def.key, 'contextTimeline')
|
||||
assert.equal(typeof def.init, 'function')
|
||||
assert.equal(typeof def.apply, 'function')
|
||||
assert.equal(typeof def.view, 'function')
|
||||
|
||||
/** Fold a full event log through the unit (the framework's watermark would
|
||||
* restrict this to the delta; determinism here is the same output). */
|
||||
const drive = (events) => {
|
||||
let st = def.init()
|
||||
for (const ev of events) st = def.apply(st, ev)
|
||||
return def.view(st)
|
||||
}
|
||||
|
||||
// ---- synthetic log: header, context, user+inject, tool call/result, step ----
|
||||
const live = { events: [
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: {
|
||||
header: { system: 'You are a harness agent.', tools: [{ name: 'bash', description: 'run a command' }], config: { model: 'deepseek-v4', provider: 'deepseek' } },
|
||||
} },
|
||||
{ seq: 2, type: 'request/context', time: 1000, data: { contextWindow: 128000 } },
|
||||
{ seq: 3, type: 'user/message', time: 2000, data: { content: [{ type: 'text', text: 'Hello there, a fairly long user message that should cost more than one token!' }] } },
|
||||
{ seq: 4, type: 'user/message', time: 3000, data: { source: { kind: 'plugin', form: 'notice', plugin: 'dsh-agent-presets', summary: 'Skill injected (code-review)' }, content: [{ type: 'text', text: 'injected text' }] } },
|
||||
{ seq: 5, type: 'tool/call', time: 4000, data: { callId: 'c1', name: 'bash', arguments: '{}' } },
|
||||
// Real tool/result envelope: the durable message carries source.kind='tool' + callId
|
||||
{ seq: 6, type: 'tool/result', time: 4100, data: { callId: 'c1', message: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }] } } },
|
||||
{ seq: 7, type: 'assistant/message', time: 5000, data: { turn: 1, step: 1, usage: { inputTokens: 900, outputTokens: 40 }, message: { content: [{ type: 'text', text: 'Hi!' }] } } },
|
||||
{ seq: 8, type: 'compaction/summary', time: 6000, data: { shadowedTokenCount: 5000, shadowedSeqs: [3, 4, 5, 6] } },
|
||||
] }
|
||||
|
||||
const v = drive(live.events)
|
||||
assert.equal(v.ok, true)
|
||||
assert.equal(v.model, 'deepseek-v4')
|
||||
assert.equal(v.provider, 'deepseek')
|
||||
assert.equal(v.contextWindow, 128000)
|
||||
assert.ok(v.current.system > 0, 'system prompt tokens')
|
||||
assert.ok(v.current.tools > 0, 'tool schema tokens')
|
||||
assert.ok(v.current.user > 0, 'user message tokens')
|
||||
assert.ok(v.current.inject > 0, 'injection tokens')
|
||||
assert.ok(v.current.tool > 0, 'tool result tokens')
|
||||
assert.ok(v.current.assistant > 0, 'assistant tokens')
|
||||
assert.equal(v.requests.length, 1)
|
||||
assert.equal(v.requests[0].prompt, 900)
|
||||
assert.equal(v.requests[0].output, 40)
|
||||
assert.ok(v.events.some(e => e.kind === 'inject' && e.form === 'notice'), 'injection event recorded')
|
||||
assert.ok(v.events.some(e => e.kind === 'compaction'), 'compaction event recorded')
|
||||
assert.ok(v.nodes.length >= 4, 'surface nodes folded')
|
||||
assert.equal(v.nodes[0].time, 2000, 'nodes carry their event timestamp')
|
||||
|
||||
// Message projection must price CONTENT: assistant replies and tool results
|
||||
// carry their message body (data.message), not a flat envelope price.
|
||||
const asstNode = v.nodes.find(n => n.seq === 7)
|
||||
assert.equal(asstNode.tokens, 9, "assistant 'Hi!' prices its text content (1 + 4 + 4)")
|
||||
const toolNode = v.nodes.find(n => n.seq === 6)
|
||||
assert.equal(toolNode.tokens, 13, 'tool result prices its nested content (5 + 4 + 4)')
|
||||
assert.equal(toolNode.tool, 'bash', 'tool result node names its tool (via source.callId)')
|
||||
assert.equal(asstNode.text, 'Hi!', 'assistant node carries its text preview')
|
||||
|
||||
// -- occupancy is NOT folded by the host anymore (R3): the client reads the
|
||||
// official token-meter `contextPressure` projection (token-meter owns
|
||||
// estimation and replay); the timeline wire carries only the heuristic
|
||||
// composition + per-request provider usage (record.prompt/output) --
|
||||
assert.equal(v.occupancy, undefined, 'the host no longer mirrors contextPressure inside contextTimeline')
|
||||
assert.equal(v.requests[0].prompt, 900, 'provider usage still rides the request records (usage sample input + cache)')
|
||||
|
||||
// -- events are attributed to the requests around them (turn/step + range) --
|
||||
const injectEv = v.events.find(e => e.kind === 'inject')
|
||||
assert.ok(injectEv, 'injection event present')
|
||||
assert.equal(injectEv.turn, 1, 'inject before step 1\'s call lands on Turn 1')
|
||||
assert.equal(injectEv.step, 1, 'inject lands on Step 1')
|
||||
assert.equal(injectEv.fromTurn, undefined, 'no request before the first inject')
|
||||
const compactEv = v.events.find(e => e.kind === 'compaction' && e.seq === 8)
|
||||
assert.ok(compactEv, 'compaction event present')
|
||||
assert.equal(compactEv.turn, undefined, 'compaction with no following request yet stays unlabeled')
|
||||
assert.equal(compactEv.step, undefined, 'no step for a trailing event')
|
||||
assert.equal(compactEv.fromTurn, 1, 'trailing event still knows the request before it')
|
||||
assert.equal(compactEv.fromStep, 1, 'trailing event keeps its from-step')
|
||||
|
||||
// -- determinism + reference-stability of the projection contract --
|
||||
// Unrelated events must return the SAME state reference (Object.is gates the
|
||||
// framework change feed); view() must not mutate persisted state.
|
||||
let base = def.init()
|
||||
for (const ev of live.events) base = def.apply(base, ev)
|
||||
assert.ok(base.events.length > 0, 'folded state carries events')
|
||||
assert.equal(def.apply(base, { type: 'todo/write', seq: 99, time: 0, data: { todos: [] } }), base,
|
||||
'unrelated events keep the same reference (no change notification)')
|
||||
assert.equal(def.apply(base, { type: 'totally/unknown', seq: 100, time: 0, data: {} }), base,
|
||||
'unknown event vocabulary is safely ignored')
|
||||
const emptyView = def.view(def.init())
|
||||
assert.equal(emptyView.ok, true)
|
||||
assert.equal(emptyView.requests.length, 0)
|
||||
assert.equal(emptyView.events.length, 0)
|
||||
assert.equal(emptyView.nodes.length, 0)
|
||||
assert.equal(emptyView.droppedNodes, 0)
|
||||
assert.equal(emptyView.current.total, 0)
|
||||
assert.ok(!('turn' in base.events[0] || 'step' in base.events[0]), 'view() attributions stay on copies, never on persisted state')
|
||||
|
||||
// -- append a same-turn step: the compaction now spans Step 1 -> Step 2 --
|
||||
live.events.push({ seq: 9, type: 'assistant/message', time: 7000, data: { turn: 1, step: 2, message: { content: [{ type: 'text', text: 'more' }] } } })
|
||||
const after = drive(live.events)
|
||||
assert.equal(after.requests.length, 2, 'new request folded')
|
||||
assert.equal(after.requests[1].turn, 1)
|
||||
const compactAfter = after.events.find(e => e.kind === 'compaction' && e.seq === 8)
|
||||
assert.equal(compactAfter.turn, 1, 'compaction inside a turn lands on the same turn')
|
||||
assert.equal(compactAfter.step, 2, 'compaction lands on the step whose request follows it')
|
||||
assert.equal(compactAfter.fromTurn, 1, 'same-turn range keeps the previous request\'s turn')
|
||||
assert.equal(compactAfter.fromStep, 1, 'same-turn range keeps the previous step')
|
||||
|
||||
// -- a cross-turn compaction: prev turn 1 step 2 -> next turn 2 step 1 --
|
||||
live.events.push({ seq: 10, type: 'compaction/summary', time: 7500, data: { shadowedTokenCount: 3000, shadowedSeqs: [3, 4] } })
|
||||
live.events.push({ seq: 11, type: 'assistant/message', time: 8000, data: { turn: 2, step: 1, message: { content: [{ type: 'text', text: 'more2' }] } } })
|
||||
const cross = drive(live.events)
|
||||
const compactCross = cross.events.find(e => e.kind === 'compaction' && e.seq === 10)
|
||||
assert.equal(compactCross.turn, 2, 'between-turn compaction lands on the next turn')
|
||||
assert.equal(compactCross.step, 1, 'lands on the next turn\'s first step')
|
||||
assert.equal(compactCross.fromTurn, 1, 'cross-turn range remembers the previous turn')
|
||||
assert.equal(compactCross.fromStep, 2, 'cross-turn range remembers the previous step')
|
||||
|
||||
// -- an empty-content assistant message (usage-only step) prices 0 tokens --
|
||||
live.events.push({ seq: 12, type: 'assistant/message', time: 9000, data: { turn: 2, step: 2, message: { content: [] } } })
|
||||
const emptyMsg = drive(live.events)
|
||||
const emptyNode = emptyMsg.nodes.find(n => n.seq === 12)
|
||||
assert.ok(emptyNode, 'usage-only message is still a surface node')
|
||||
assert.equal(emptyNode.tokens, 0, 'empty assistant message prices 0, like dsh deriveEventMessage')
|
||||
// A LATER step in the same turn must not re-attribute the compaction.
|
||||
const compactStill = emptyMsg.events.find(e => e.kind === 'compaction' && e.seq === 8)
|
||||
assert.equal(compactStill.turn, 1, 'compaction stays on Turn 1')
|
||||
assert.equal(compactStill.step, 2, 'compaction stays on Step 2 (first request after it)')
|
||||
|
||||
// Repeat the same log: the fold is a deterministim replay, so a fresh fold
|
||||
// must reproduce the identical wire value (what the projection cache relies on).
|
||||
const again = drive(live.events)
|
||||
assert.deepEqual(again, emptyMsg, 'deterministic replay across fresh folds')
|
||||
|
||||
// -- the shadow price covers the SEQ list, not the declared range end: a
|
||||
// prune replacement node whose seq lies beyond the range must still be
|
||||
// removed (the producer's shadowedSeqs include it) --
|
||||
const shadow = drive([
|
||||
{ seq: 1, type: 'user/message', time: 1000, data: { content: [{ type: 'text', text: 'a'.repeat(40) }] } },
|
||||
// two tool results, 80-char text each: 20 + 4 (text) + 4 (tool-result) + 4 (role) = 32
|
||||
{ seq: 2, type: 'tool/result', time: 2000, data: { message: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'b'.repeat(80) }] }] } } },
|
||||
{ seq: 3, type: 'tool/result', time: 3000, data: { message: { source: { kind: 'tool', callId: 'c2' }, content: [{ type: 'tool-result', toolCallId: 'c2', content: [{ type: 'text', text: 'c'.repeat(80) }] }] } } },
|
||||
// metering event: range says [2,2] but the shadowed seqs cover 2 AND 3
|
||||
{ seq: 4, type: 'compaction/prune', time: 4000, data: { shadowedRange: { start: 2, end: 2 }, shadowedSeqs: [2, 3], shadowedTokenCount: 64 } },
|
||||
// replacement: 'd' -> 1 + 4 (text) + 4 (tool-result) + 4 (role) = 13
|
||||
{ seq: 5, type: 'tool/result', time: 5000, surfaceOp: { op: 'replace', start: 2, end: 2 }, data: { message: { source: { kind: 'tool', callId: 'c3' }, content: [{ type: 'tool-result', toolCallId: 'c3', content: [{ type: 'text', text: 'd' }] }] } } },
|
||||
])
|
||||
assert.equal(shadow.current.tool, 13, 'shadowed seqs beyond the range end are removed too (only the replacement survives)')
|
||||
assert.equal(shadow.current.user, 18, 'unrelated nodes keep their price (40 chars: 10 + 4 + 4)')
|
||||
assert.equal(shadow.current.total, 18 + 13, 'total reflects the seq-based removal')
|
||||
// no tool/call events in this log, so no call name is registered — the tool
|
||||
// label stays empty (never a crash)
|
||||
assert.equal(shadow.nodes.find(n => n.seq === 5).tool, undefined, 'no registered call name -> no tool label')
|
||||
|
||||
// -- the removed nodes land in the archive (the Context browser's per-step
|
||||
// reconstruction raw material), stamped with the replacing event's seq --
|
||||
assert.deepEqual(shadow.archive.map(n => n.seq), [2, 3], 'both shadowed nodes archived in removal order')
|
||||
assert.ok(shadow.archive.every(n => n.gone === 5), 'archive entries carry the replacing event seq as gone')
|
||||
assert.equal(shadow.surfaceFloor, undefined, 'no live-node drop without an overflow')
|
||||
// Reconstructing step seq 4 (between removal and replacement... here: any
|
||||
// request with seq > 3 and < 5) sees the pre-shadow surface.
|
||||
const preShadow = [...shadow.nodes, ...shadow.archive].filter(n => n.seq < 4 && (n.gone === undefined || n.gone > 4))
|
||||
assert.deepEqual(preShadow.map(n => n.seq).sort(), [1, 2, 3], 'pre-shadow steps still see the shadowed nodes')
|
||||
|
||||
// -- archive retention: count cap + request-window prune --
|
||||
let archiveCfg = null
|
||||
apply({ ...fakeCtx, sessionProjections: { register(d) { if (d.key === 'contextTimeline') archiveCfg = d; return () => {} } } }, { maxArchiveNodes: 1 })
|
||||
const capped = (() => {
|
||||
let st = archiveCfg.init()
|
||||
for (const ev of [
|
||||
{ seq: 1, type: 'user/message', time: 1000, data: { content: [{ type: 'text', text: 'a'.repeat(40) }] } },
|
||||
{ seq: 2, type: 'compaction/prune', time: 2000, data: { shadowedSeqs: [1], shadowedTokenCount: 18 } },
|
||||
{ seq: 3, type: 'user/message', time: 3000, surfaceOp: { op: 'replace', start: 1, end: 1 }, data: { content: [{ type: 'text', text: 'b' }] } },
|
||||
{ seq: 4, type: 'user/message', time: 4000, data: { content: [{ type: 'text', text: 'c'.repeat(40) }] } },
|
||||
{ seq: 5, type: 'compaction/prune', time: 5000, data: { shadowedSeqs: [4], shadowedTokenCount: 18 } },
|
||||
{ seq: 6, type: 'user/message', time: 6000, surfaceOp: { op: 'replace', start: 4, end: 4 }, data: { content: [{ type: 'text', text: 'd' }] } },
|
||||
]) st = archiveCfg.apply(st, ev)
|
||||
return archiveCfg.view(st)
|
||||
})()
|
||||
assert.deepEqual(capped.archive.map(n => n.seq), [4], 'maxArchiveNodes keeps the newest removals only')
|
||||
assert.equal(capped.archiveFloor, 3, 'archiveFloor names the newest dropped removal')
|
||||
|
||||
// -- surfaceFloor: the coverage floor of the served live-node slice --
|
||||
let floorCfg = null
|
||||
apply({ ...fakeCtx, sessionProjections: { register(d) { if (d.key === 'contextTimeline') floorCfg = d; return () => {} } } }, { maxNodes: 2 })
|
||||
const floored = (() => {
|
||||
let st = floorCfg.init()
|
||||
for (let s = 1; s <= 4; s++) {
|
||||
st = floorCfg.apply(st, { seq: s, type: 'user/message', time: s * 1000, data: { content: [{ type: 'text', text: 'x'.repeat(20) }] } })
|
||||
}
|
||||
return floorCfg.view(st)
|
||||
})()
|
||||
assert.equal(floored.droppedNodes, 2, 'two oldest live nodes outside the served slice')
|
||||
assert.equal(floored.surfaceFloor, 2, 'surfaceFloor is the newest dropped live seq')
|
||||
|
||||
// -- inject pinning: live inject nodes older than the served tail are still
|
||||
// served (they land first and are few), so the browser can always list them;
|
||||
// droppedNodes/surfaceFloor count only the non-inject overflow --
|
||||
let pinCfg = null
|
||||
apply({ ...fakeCtx, sessionProjections: { register(d) { if (d.key === 'contextTimeline') pinCfg = d; return () => {} } } }, { maxNodes: 2 })
|
||||
const pinnedView = (() => {
|
||||
let st = pinCfg.init()
|
||||
st = pinCfg.apply(st, { seq: 1, type: 'user/message', time: 1000, data: { source: { kind: 'plugin', form: 'context', plugin: 'dsh-test' }, content: [{ type: 'text', text: 'injected context' }] } })
|
||||
for (let s = 2; s <= 5; s++) {
|
||||
st = pinCfg.apply(st, { seq: s, type: 'user/message', time: s * 1000, data: { content: [{ type: 'text', text: 'x'.repeat(20) }] } })
|
||||
}
|
||||
return pinCfg.view(st)
|
||||
})()
|
||||
assert.deepEqual(pinnedView.nodes.map(n => n.seq), [1, 4, 5], 'the out-of-window inject node is pinned ahead of the served tail, seq-ordered')
|
||||
assert.equal(pinnedView.nodes[0].cat, 'inject', 'the pinned node keeps its category')
|
||||
assert.equal(pinnedView.droppedNodes, 2, 'droppedNodes counts only the non-inject overflow')
|
||||
assert.equal(pinnedView.surfaceFloor, 3, 'surfaceFloor is the newest UNSERVED seq (pinned injects excluded)')
|
||||
assert.equal(pinCfg.schema.safeParse(pinnedView).success, true, 'pinning view passes the wire schema')
|
||||
|
||||
// -- the wire view passes the unit's own schema (drift guard incl. archive) --
|
||||
assert.equal(def.schema.safeParse(shadow).success, true, 'archive-carrying view passes the wire schema')
|
||||
|
||||
// -- contextHeaders unit: full header content epochs, dedupe + cap --
|
||||
const hdrive = (events) => {
|
||||
let st = hdef.init()
|
||||
for (const ev of events) st = hdef.apply(st, ev)
|
||||
return hdef.view(st)
|
||||
}
|
||||
const hlog = hdrive([
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: { reason: 'initial', header: { system: 'You are an agent.', tools: [{ name: 'bash', description: 'run a command', parameters: { type: 'object' } }], config: { model: 'm', provider: 'p' } } } },
|
||||
{ seq: 2, type: 'user/message', time: 2000, data: { content: [{ type: 'text', text: 'hi' }] } },
|
||||
{ seq: 3, type: 'request/header', time: 3000, data: { reason: 'change', header: { system: 'You are another agent.', tools: [], config: { model: 'm2', provider: 'p' } } } },
|
||||
])
|
||||
assert.equal(hlog.headers.length, 2, 'one epoch per request/header event')
|
||||
assert.equal(hlog.headers[0].system, 'You are an agent.', 'epoch carries the full system prompt')
|
||||
assert.equal(hlog.headers[0].tools.length, 1, 'epoch carries the tool schemas')
|
||||
assert.equal(hlog.headers[0].tools[0].name, 'bash')
|
||||
assert.equal(hlog.headers[0].tools[0].description, 'run a command')
|
||||
assert.deepEqual(hlog.headers[0].tools[0].schema.parameters, { type: 'object' }, 'raw schema content preserved')
|
||||
assert.ok(hlog.headers[0].tools[0].tokens > 0, 'tool schema priced')
|
||||
assert.equal(hlog.headers[1].system, 'You are another agent.')
|
||||
assert.equal(hdef.apply(hdef.init(), { type: 'user/message', seq: 9, time: 0, data: {} }).headers.length, 0,
|
||||
'non-header events leave the headers state empty')
|
||||
assert.equal(hdef.schema.safeParse(hlog).success, true, 'headers view passes its wire schema')
|
||||
// cap: 60 epochs keep the newest 50
|
||||
const manyHeaders = []
|
||||
for (let i = 1; i <= 60; i++) {
|
||||
manyHeaders.push({ seq: i, type: 'request/header', time: i * 1000, data: { reason: 'change', header: { system: 's' + i, config: { model: 'm' + i } } } })
|
||||
}
|
||||
assert.equal(hdrive(manyHeaders).headers.length, 50, 'header epochs capped')
|
||||
assert.equal(hdrive(manyHeaders).headers[0].system, 's11', 'the oldest epochs are dropped first')
|
||||
|
||||
// -- tool names also resolve from the content block's toolCallId when the
|
||||
// source is absent on a (legacy) envelope --
|
||||
const legacyTool = drive([
|
||||
{ seq: 1, type: 'user/message', time: 1000, data: { content: [{ type: 'text', text: 'x'.repeat(40) }] } },
|
||||
{ seq: 2, type: 'tool/result', time: 2000, data: { message: { content: [{ type: 'tool-result', toolCallId: 't9', content: [{ type: 'text', text: 'y' }] }] } } },
|
||||
])
|
||||
assert.equal(legacyTool.nodes.find(n => n.seq === 2).tool, undefined,
|
||||
'no source and no registered call name -> no tool label (never crashes)')
|
||||
|
||||
// -- turn-based retention: long sessions trim by whole turns, never mid-turn --
|
||||
// 400 turns x 4 steps = 1600 requests exceeds the step bound; the fold must
|
||||
// keep the newest 300 WHOLE turns (turns 101..400, 1200 requests).
|
||||
const many = []
|
||||
let seq = 1000
|
||||
for (let turn = 1; turn <= 400; turn++) {
|
||||
for (let step = 0; step < 4; step++) {
|
||||
many.push({
|
||||
seq: seq++, type: 'assistant/message', time: seq * 1000,
|
||||
data: { turn, step, message: { content: [{ type: 'text', text: 'x' }] } },
|
||||
})
|
||||
}
|
||||
}
|
||||
const long = drive(many)
|
||||
assert.equal(long.requests.length, 1200, 'kept 300 turns x 4 steps after trimming')
|
||||
assert.equal(long.requests[0].turn, 101, 'trim starts at a whole turn boundary')
|
||||
assert.equal(long.requests[0].step, 0, 'trim starts at the first step of that turn')
|
||||
const keptTurns = new Set(long.requests.map(r => r.turn))
|
||||
assert.equal(keptTurns.size, 300, 'all 300 kept turns are present')
|
||||
assert.ok([...keptTurns].every(t => t >= 101 && t <= 400), 'kept turns are the newest ones')
|
||||
assert.equal(long.requests.filter(r => r.turn === 101).length, 4, 'first kept turn is complete')
|
||||
assert.equal(long.requests.filter(r => r.turn === 400).length, 4, 'last turn is complete')
|
||||
|
||||
// -- streamed chunks are structurally ignored (same reference): token-level
|
||||
// events carry no state change for the timeline since the occupancy mirror
|
||||
// left the fold (R3) --
|
||||
const chunkEvent = { seq: 3, type: 'assistant/chunk', time: 3000, data: { turn: 1, step: 1, chunk: { type: 'usage', usage: { inputTokens: 5000 } } } }
|
||||
assert.equal(def.apply(base, chunkEvent), base, 'usage chunks keep the same reference (no change feed)')
|
||||
const snap3 = drive([
|
||||
{ seq: 1, type: 'request/context', time: 1000, data: { contextWindow: 100000 } },
|
||||
{ seq: 2, type: 'user/message', time: 2000, data: { content: [{ type: 'text', text: 'a'.repeat(40) }] } },
|
||||
{ seq: 3, type: 'assistant/chunk', time: 3000, data: { turn: 1, step: 1, chunk: { type: 'usage', usage: { inputTokens: 5000, cacheReadTokens: 1000, outputTokens: 0 } } } },
|
||||
])
|
||||
assert.equal(snap3.occupancy, undefined, 'chunk usage never re-enters the timeline')
|
||||
assert.equal(snap3.contextWindow, 100000, 'route capacity still folds from request/context')
|
||||
|
||||
// -- entry config: bounded slices are honored (config -> bounds threading) --
|
||||
let cfgDef = null
|
||||
apply({ ...fakeCtx, sessionProjections: { register(d) { if (d.key === 'contextTimeline') cfgDef = d; return () => {} } } }, { maxNodes: 2, maxKeptTurns: 1 })
|
||||
const bounded = (() => {
|
||||
let st = cfgDef.init()
|
||||
for (const ev of live.events) st = cfgDef.apply(st, ev)
|
||||
return cfgDef.view(st)
|
||||
})()
|
||||
// The served slice is the newest `maxNodes` tail PLUS pinned live injects
|
||||
// (fixture: surface [3,4,6,7,9,11,12], tail [11,12] + pinned inject seq 4;
|
||||
// seqs 3/6/7/9 are the unserved overflow).
|
||||
assert.deepEqual(bounded.nodes.map(n => n.seq), [4, 11, 12], 'maxNodes bounds the tail; live injects are pinned on top')
|
||||
assert.ok(bounded.nodes.filter(n => n.cat !== 'inject').length <= 2, 'the non-inject tail still honors maxNodes')
|
||||
assert.equal(bounded.droppedNodes, 4, 'pinned injects do not count as dropped')
|
||||
assert.equal(bounded.surfaceFloor, 9, 'surfaceFloor is the newest unserved seq')
|
||||
assert.ok(bounded.requests.length <= 8, 'maxKeptTurns bounds the retained history (400-turn fixture trimmed by whole turns)')
|
||||
assert.equal(typeof cfgDef.schema.safeParse(bounded).success, 'boolean', 'custom-bounds view still passes the unit schema')
|
||||
|
||||
// -- model-switch semantics (R6): the only durable signal is a request
|
||||
// header differing from the previous one (reason 'change'); route/capacity
|
||||
// metadata (request/context) must NOT fire a switch event by itself, and a
|
||||
// provider-only change with the same model is not a switch either --
|
||||
const switchLog = drive([
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: { reason: 'initial', header: { system: 's', config: { model: 'model-a', provider: 'deepseek' } } } },
|
||||
{ seq: 2, type: 'request/header', time: 2000, data: { reason: 'change', header: { system: 's', config: { model: 'model-b', provider: 'deepseek' } } } },
|
||||
{ seq: 3, type: 'request/context', time: 2000, data: { model: 'model-b', provider: 'deepseek', contextWindow: 64000 } },
|
||||
{ seq: 4, type: 'request/header', time: 3000, data: { reason: 'change', header: { system: 's', config: { model: 'model-b', provider: 'openai' } } } },
|
||||
{ seq: 5, type: 'request/header', time: 4000, data: { reason: 'change', header: { system: 's', config: { model: 'model-b', provider: 'openai' } } } },
|
||||
])
|
||||
const switchEvents = switchLog.events.filter(e => e.kind === 'model')
|
||||
assert.equal(switchEvents.length, 1, 'exactly one model-switch event')
|
||||
assert.equal(switchEvents[0].from, 'model-a', 'switch records the previous model')
|
||||
assert.equal(switchEvents[0].to, 'model-b', 'switch records the new model')
|
||||
assert.equal(switchLog.model, 'model-b', 'current model follows the header')
|
||||
assert.equal(switchLog.provider, 'openai', 'provider-only change updates the route, not a switch')
|
||||
assert.equal(switchLog.contextWindow, 64000, 'request/context supplies route capacity')
|
||||
|
||||
// Usage mapping (R5): prompt-side = input + cacheRead + cacheWrite (billed
|
||||
// input), output = outputTokens; reasoningTokens stay inside outputTokens.
|
||||
const usageLog = drive([
|
||||
{ seq: 1, type: 'assistant/message', time: 1000, data: { turn: 1, step: 1, usage: { inputTokens: 800, cacheReadTokens: 150, cacheWriteTokens: 50, outputTokens: 30, reasoningTokens: 10 }, message: { content: [] } } },
|
||||
])
|
||||
assert.equal(usageLog.requests[0].prompt, 1000, 'prompt side sums the disjoint input buckets')
|
||||
assert.equal(usageLog.requests[0].output, 30, 'output is outputTokens (reasoning already inside)')
|
||||
assert.equal(usageLog.cost, undefined, 'no model name -> nothing priced into the cost totals')
|
||||
assert.equal(v.cost, undefined, 'a non-flash/pro model (deepseek-v4) is not priced')
|
||||
|
||||
// -- session-cost totals (cumulative, per family x UTC pricing period) --
|
||||
// Peak windows are 01:00-04:00 and 06:00-10:00 UTC. The model NAME decides
|
||||
// the family, provider-agnostically (an OpenRouter-style spelling counts).
|
||||
const PEAK = Date.UTC(2026, 0, 5, 2, 0, 0) // 02:00 UTC -> peak
|
||||
const OFF = Date.UTC(2026, 0, 5, 12, 0, 0) // 12:00 UTC -> off-peak
|
||||
const costLog = drive([
|
||||
{ seq: 1, type: 'request/header', time: PEAK - 1000, data: { header: { config: { model: 'deepseek/deepseek-v4-flash', provider: 'openrouter' } } } },
|
||||
{ seq: 2, type: 'assistant/message', time: PEAK, data: { turn: 1, step: 1, usage: { inputTokens: 800, cacheReadTokens: 150, cacheWriteTokens: 50, outputTokens: 30 }, message: { content: [] } } },
|
||||
{ seq: 3, type: 'assistant/message', time: PEAK + 1000, data: { turn: 1, step: 2, usage: { inputTokens: 200, outputTokens: 10 }, message: { content: [] } } },
|
||||
{ seq: 4, type: 'assistant/message', time: OFF, data: { turn: 1, step: 3, usage: { inputTokens: 100, cacheReadTokens: 900 }, message: { content: [] } } },
|
||||
])
|
||||
assert.deepEqual(costLog.cost.flash.peak, { uncached: 1000, cacheRead: 150, cacheWrite: 50, output: 40 },
|
||||
'same-period requests accumulate; missing buckets count as 0')
|
||||
assert.deepEqual(costLog.cost.flash.off, { uncached: 100, cacheRead: 900, cacheWrite: 0, output: 0 },
|
||||
'off-peak requests land in their own period bucket')
|
||||
assert.equal(costLog.cost.pro, undefined, 'no pro usage -> no pro branch')
|
||||
// A model switch re-attributes later requests to the new family.
|
||||
const costSwitch = drive([
|
||||
{ seq: 1, type: 'request/header', time: OFF - 1000, data: { header: { config: { model: 'deepseek-v4-flash', provider: 'deepseek' } } } },
|
||||
{ seq: 2, type: 'assistant/message', time: OFF, data: { turn: 1, step: 1, usage: { inputTokens: 100 }, message: { content: [] } } },
|
||||
{ seq: 3, type: 'request/header', time: OFF + 1000, data: { reason: 'change', header: { config: { model: 'deepseek-v4-pro', provider: 'deepseek' } } } },
|
||||
{ seq: 4, type: 'assistant/message', time: OFF + 2000, data: { turn: 1, step: 2, usage: { inputTokens: 300, outputTokens: 20 }, message: { content: [] } } },
|
||||
])
|
||||
assert.deepEqual(costSwitch.cost.flash.off.uncached, 100, 'usage before the switch stays on flash')
|
||||
assert.deepEqual(costSwitch.cost.pro.off, { uncached: 300, cacheRead: 0, cacheWrite: 0, output: 20 }, 'usage after the switch prices as pro')
|
||||
// The wire value must not alias persisted state (same rule as requests/events).
|
||||
{
|
||||
let st = def.init()
|
||||
for (const ev of [
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: { header: { config: { model: 'deepseek-v4-flash', provider: 'deepseek' } } } },
|
||||
{ seq: 2, type: 'assistant/message', time: OFF, data: { turn: 1, step: 1, usage: { inputTokens: 100 }, message: { content: [] } } },
|
||||
]) st = def.apply(st, ev)
|
||||
const view = def.view(st)
|
||||
assert.ok(view.cost.flash.off !== st.cost.flash.off, 'view() copies the cost totals, never aliases persisted state')
|
||||
}
|
||||
|
||||
// -- plain-JSON persisted state (R7): the projection-cache precondition.
|
||||
// Every `apply` result must survive the harness's lossless-JSON snapshotter;
|
||||
// an `undefined`-valued property ANYWHERE in the unit state rejects the whole
|
||||
// session's checkpoint write (TypeError: projection checkpoint is not
|
||||
// losslessly JSON-serializable), starving unrelated rows — including the
|
||||
// `title` projection that powers the session list after a restart. Fold the
|
||||
// live log (arm-then-consumed shadowed seqs included — live.events arms at
|
||||
// seq 10 and consumes via the seq 11 surface event) PLUS one
|
||||
// `assistant/message` whose data intentionally LACKS numeric turn/step (they
|
||||
// are optional in the durable vocabulary), so no tolerated-absence branch can
|
||||
// ever leave an `undefined`-valued property behind. Check every intermediate
|
||||
// state through the REAL harness snapshotter.
|
||||
const jsonLog = [...live.events, { seq: 99, type: 'assistant/message', time: 9999, data: { message: { content: [{ type: 'text', text: 'no turn/step here' }] } } }]
|
||||
const jsonStates = []
|
||||
let jsonSt = def.init()
|
||||
for (const ev of jsonLog) {
|
||||
jsonSt = def.apply(jsonSt, ev)
|
||||
jsonStates.push(jsonSt)
|
||||
}
|
||||
for (const [i, st] of jsonStates.entries()) {
|
||||
const cloned = snapshotJsonValue(st)
|
||||
assert.ok(cloned !== undefined, `state after event ${i + 1} is losslessly JSON-serializable (plain-JSON projection-cache precondition)`)
|
||||
assert.deepEqual(cloned, JSON.parse(JSON.stringify(st)), `state after event ${i + 1} round-trips losslessly`)
|
||||
}
|
||||
// And the armed-but-unconsumed intermediate (compaction arm alone) must also
|
||||
// stay serializable.
|
||||
const armed = def.apply(def.init(), { seq: 1, type: 'compaction/summary', time: 1000, data: { shadowedSeqs: [2], shadowedTokenCount: 10 } })
|
||||
assert.ok(snapshotJsonValue(armed) !== undefined, 'armed pendingShadowedSeqs state is still plain JSON')
|
||||
|
||||
// -- dual-contract compatibility (dsh 0.1.1-rc.1+): each unit carries the
|
||||
// NEW contract fields alongside the old ones. `stateSchema` validates the
|
||||
// PERSISTED fold state before a checkpoint row seeds a fold; `wire`
|
||||
// (viewSchema + view) is the ONLY channel through which the 0.1.1-rc.1+
|
||||
// registry delivers a unit to the browser — a unit without `wire` is
|
||||
// host-only and its key never reaches the client (the Context tab would stay
|
||||
// on its loading screen forever). Both fields are load-bearing --
|
||||
assert.equal(typeof def.stateSchema, 'object', 'timeline: 0.1.1+ contract field stateSchema present')
|
||||
assert.ok(def.stateSchema && typeof def.stateSchema.parse === 'function', 'timeline: stateSchema is a validator')
|
||||
assert.ok(def.wire !== undefined && typeof def.wire === 'object', 'timeline: 0.1.1+ client view (wire) present')
|
||||
assert.ok(def.wire.viewSchema && typeof def.wire.viewSchema.parse === 'function', 'timeline: wire.viewSchema is a validator')
|
||||
assert.equal(typeof def.wire.view, 'function', 'timeline: wire.view is the client view')
|
||||
assert.equal(def.wire.view, def.view, 'timeline: old view and wire.view are the same projection')
|
||||
assert.equal(typeof hdef.stateSchema, 'object', 'headers: 0.1.1+ contract field stateSchema present')
|
||||
assert.ok(hdef.wire !== undefined && hdef.wire.viewSchema && typeof hdef.wire.view === 'function', 'headers: wire block present and complete')
|
||||
|
||||
// Every intermediate fold state passes the state validator (the persisted
|
||||
// cache seeds folds from these rows), and the wire view through `wire`
|
||||
// reproduces the old `view` output exactly.
|
||||
for (const [i, st] of jsonStates.entries()) {
|
||||
assert.deepEqual(def.stateSchema.parse(st), st, `timeline: stateSchema round-trips persisted state after event ${i + 1}`)
|
||||
assert.deepEqual(def.wire.viewSchema.parse(def.wire.view(st)), def.view(st),
|
||||
`timeline: wire view equals the old view through the wire schema (state ${i + 1})`)
|
||||
}
|
||||
assert.ok(snapshotJsonValue(def.stateSchema.parse(armed)) !== undefined, 'timeline: armed state passes the state schema and stays plain JSON')
|
||||
assert.deepEqual(hdef.stateSchema.parse(hdef.init()), { headers: [] }, 'headers: stateSchema accepts the empty state')
|
||||
{
|
||||
let st = hdef.init()
|
||||
for (const ev of [
|
||||
{ seq: 1, type: 'request/header', time: 1000, data: { reason: 'initial', header: { system: 'You are an agent.', tools: [{ name: 'bash', parameters: { type: 'object' } }] } } },
|
||||
{ seq: 2, type: 'request/header', time: 3000, data: { reason: 'change', header: { system: 'You are another agent.', tools: [] } } },
|
||||
]) st = hdef.apply(st, ev)
|
||||
assert.deepEqual(hdef.stateSchema.parse(st), st, 'headers: stateSchema round-trips persisted state')
|
||||
assert.deepEqual(hdef.wire.viewSchema.parse(hdef.wire.view(st)), hdef.view(st), 'headers: wire view equals the old view')
|
||||
}
|
||||
|
||||
console.log('✔ host half functional test passed (projection unit, fold semantics, attribution, retention, shadow-price seqs, reference stability, determinism, config bounds, inject pinning, model switch, usage mapping, session-cost totals, plain-JSON state)')
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Image attachment cards: prose stays in the text card, durable image refs
|
||||
* render as thumbnail cards, unrecognized blocks keep a raw JSON card;
|
||||
* thumbnails resolve through the conversation service when present.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
import { bootViewBed, byClass, textOf } from './helpers/viewBed.mjs'
|
||||
|
||||
const bed = await bootViewBed()
|
||||
const { hookStates, renderView, snapshot } = bed
|
||||
bed.dataValue = snapshot
|
||||
renderView() // mount the ContextBrowser fiber before addressing its hooks
|
||||
const brSlots = bed.brSlots()
|
||||
|
||||
test('image attachment cards: card layout, metadata degradation, resolveImage thumbnails', async () => {
|
||||
// ---- Image attachment cards: a user message carrying durable image refs
|
||||
// renders the card layout — prose in the text card, images as thumbnail
|
||||
// cards (name + dims + stored size), unrecognized blocks in a raw JSON
|
||||
// card. Thumbnails load through the conversation service's resolveImage;
|
||||
// without that service the cards degrade to metadata-only. ----
|
||||
bed.useSessionHolder = (sel) => sel({
|
||||
nodes: [{
|
||||
kind: 'user', seq: 1, content: [
|
||||
{ type: 'text', text: 'WHAT-IS-THIS' },
|
||||
{ type: 'image', attachment: { attachmentId: 'a1', mediaType: 'image/png', bytes: 153600, width: 2048, height: 1365, name: 'screenshot.png', originalDimensions: { width: 6000, height: 4000 } } },
|
||||
{ type: 'image', attachment: { attachmentId: 'a2', mediaType: 'image/jpeg', bytes: 512000, width: 800, height: 600 } },
|
||||
{ type: 'file', uri: 'file://x' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
bed.dataValue = snapshot
|
||||
brSlots[1][1]('user') // openCat('user')
|
||||
brSlots[2][1]('n1') // expand the user element
|
||||
let trImg = renderView()
|
||||
// Metadata-only degradation (no conversation service armed): two attachment
|
||||
// cards (images + other), two image items, placeholder tiles, no <img>.
|
||||
assert.equal(byClass(trImg, 'lc-ts-card').length, 2, 'images card + other-content card rendered')
|
||||
assert.equal(byClass(trImg, 'lc-att-item').length, 2, 'one item per image ref')
|
||||
assert.equal(byClass(trImg, 'lc-att-thumb').length, 2, 'one tile per image')
|
||||
assert.equal(byClass(trImg, 'lc-att-ph').length, 2, 'placeholder tiles without the loader')
|
||||
const imgCardText = textOf(byClass(trImg, 'lc-ts-card')[0])
|
||||
assert.match(imgCardText, /screenshot\.png/, 'named image shows its display name')
|
||||
assert.match(imgCardText, /2048×1365/, 'normalized dims shown')
|
||||
assert.match(imgCardText, /original 6000×4000/, 'pre-normalization dims shown when present')
|
||||
assert.match(imgCardText, /153\.6 kB/, 'stored byte size shown')
|
||||
assert.match(imgCardText, /Image/, 'unnamed image falls back to the generic label')
|
||||
assert.match(imgCardText, /800×600/, 'second image dims shown')
|
||||
assert.match(textOf(byClass(trImg, 'lc-ts-card')[1]), /file:\/\/x/, 'unrecognized blocks keep the raw JSON card')
|
||||
assert.match(textOf(byClass(trImg, 'lc-br-content')[0]), /WHAT-IS-THIS/, 'prose stays in the text card')
|
||||
assert.equal(byClass(trImg, 'lc-att-thumb')[0].args.slice(2).filter(c => c !== null && typeof c === 'object' && c.args[0] === 'img').length, 0, 'no thumbnail img without the loader')
|
||||
// Arm the conversation service: the loader resolves a display URL per ref.
|
||||
bed.conversationHolder = { resolveImage: async (sid, att) => 'blob:' + att.attachmentId }
|
||||
trImg = renderView()
|
||||
// Drive every ImageCard's load effect (slot 3 = its useEffect), then flush.
|
||||
for (const [key, slots] of hookStates) {
|
||||
if (key.includes('ImageCard') && slots[3] !== undefined) slots[3].effect()
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
trImg = renderView()
|
||||
const thumbImgs = byClass(trImg, 'lc-att-thumb')
|
||||
.flatMap(t => t.args.slice(2))
|
||||
.filter(c => c !== null && typeof c === 'object' && c.args[0] === 'img')
|
||||
assert.deepEqual(thumbImgs.map(i => i.args[1].src), ['blob:a1', 'blob:a2'], 'thumbnails resolve through resolveImage')
|
||||
|
||||
console.log('✔ image attachment card test passed (card layout, metadata degradation, resolveImage thumbnails)')
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Trend-chart <-> context-browser linkage: hovering a bar transiently
|
||||
* previews its step in the browser; clicking (pin) locks it; leaving or
|
||||
* unpinning returns the browser to its own selection.
|
||||
*/
|
||||
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
|
||||
bed.dataValue = snapshot
|
||||
let tr = renderView()
|
||||
const ctxSlots = bed.ctxSlots()
|
||||
const brSlots = bed.brSlots()
|
||||
|
||||
test('trend chart <-> browser linkage: hover previews its step, pin locks it, unpin returns to live', async () => {
|
||||
// ---- trend-chart hover linkage: the bar under the pointer transiently
|
||||
// previews its step in the browser (picker value + meta follow); leaving
|
||||
// the chart returns to the picker's own selection. Driven through
|
||||
// ContextView's hoveredSeq state, exactly like TrendChart's onHover. ----
|
||||
ctxSlots[1][1](2) // hover the seq-2 bar (Turn 1 · Step 1)
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, '2', 'hovered bar drives the browser picker')
|
||||
assert.match(textOf(byClass(tr, 'lc-br-meta')[0]), /Turn 1 · Step 1/, 'meta shows the hovered step')
|
||||
assert.match(textOf(byClass(tr, 'lc-br-meta')[0]), /preview/, 'hover preview is marked')
|
||||
ctxSlots[1][1](3)
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, '3', 'hover moves across bars')
|
||||
ctxSlots[1][1](9999)
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, 'live', 'an unknown (trimmed) preview seq is ignored')
|
||||
ctxSlots[1][1](null)
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, 'live', 'leaving the chart returns to the picker selection')
|
||||
|
||||
console.log('✔ hover linkage test passed (bar hover previews its step, unknown seq ignored, picker resumes)')
|
||||
|
||||
// ---- trend-chart pin linkage: clicking a bar locks its step in the browser
|
||||
// too (the picker follows the pin); unpinning (selectedSeq back to null)
|
||||
// returns the browser to the live step. Driven through ContextView's
|
||||
// selectedSeq state + the browser's pin-linkage effect (hook slot 8). ----
|
||||
ctxSlots[0][1](2) // pin the seq-2 bar (Turn 1 · Step 1)
|
||||
tr = renderView()
|
||||
brSlots[8].effect() // the pin effect applies the new pinSeq
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, '2', 'pinned bar drives the browser picker')
|
||||
assert.match(textOf(byClass(tr, 'lc-br-meta')[0]), /Turn 1 · Step 1/, 'meta shows the pinned step')
|
||||
ctxSlots[1][1](3) // hovering another bar still previews transiently over the pin
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, '3', 'hover previews over the pin')
|
||||
ctxSlots[1][1](null)
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, '2', 'leaving the chart returns to the pinned step')
|
||||
ctxSlots[0][1](null) // unpin
|
||||
tr = renderView()
|
||||
brSlots[8].effect()
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, 'live', 'unpinning returns the browser to live')
|
||||
|
||||
console.log('✔ pin linkage test passed (pinned bar drives the browser picker, hover previews over the pin, unpin returns to live)')
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* /context modal spec: centered dialog with the current-composition
|
||||
* overview + the shared context browser, driven by the modal store hook;
|
||||
* closing consumes the deferred composer token through the session scope.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
import { bootViewBed, byClass, textOf } from './helpers/viewBed.mjs'
|
||||
|
||||
const bed = await bootViewBed()
|
||||
const { bailCalls, evaluate, modalComponent, modalSource, snapshot } = bed
|
||||
|
||||
test('/context modal: open/close, current overview, embedded browser, deferred token consume on close', async () => {
|
||||
// ---- /context modal render: centered dialog with the current-composition
|
||||
// overview + the shared Context browser (which replaced the old last-10-turn
|
||||
// trend), driven by the modal store hook ----
|
||||
assert.ok(modalComponent !== null, 'overlay component captured')
|
||||
const modalData = {
|
||||
...snapshot,
|
||||
current: { system: 10, tools: 20, user: 140, inject: 0, assistant: 15, tool: 20, total: 205 },
|
||||
// No provider usage on the last request -> the headline falls back to the
|
||||
// heuristic total (205), like the old trend-phase fixture.
|
||||
requests: snapshot.requests.map(({ prompt, ...r }) => r),
|
||||
events: [],
|
||||
}
|
||||
let modalOpen = false
|
||||
const renderModal = () => evaluate(modalComponent({
|
||||
sessionId: 's1',
|
||||
useProjection: (key) => (key === 'contextTimeline' ? modalData : undefined),
|
||||
useContextModal: (sel) => sel(modalOpen),
|
||||
}))
|
||||
|
||||
assert.equal(renderModal(), null, 'modal renders nothing while closed')
|
||||
modalOpen = true
|
||||
const modalTree = renderModal()
|
||||
assert.equal(byClass(modalTree, 'lc-modal-backdrop').length, 1, 'centered backdrop rendered')
|
||||
const modalOverview = byClass(modalTree, 'lc-overview-num')[0]
|
||||
assert.match(textOf(modalOverview), /205/, 'modal headline falls back to the heuristic total (no prompt on last request)')
|
||||
assert.match(textOf(modalOverview), /0%/, 'percent against the 128k window')
|
||||
// The trend chart is gone; the shared Context browser hosts the modal's step
|
||||
// browsing instead (own picker + category accordion, opens on the live step).
|
||||
assert.equal(byClass(modalTree, 'lc-modal-trend').length, 0, 'the last-10-turn trend section is gone')
|
||||
assert.equal(byClass(modalTree, 'lc-br-cat-row').length, 6, 'context browser renders inside the modal')
|
||||
assert.equal(byClass(modalTree, 'lc-br-pick').length, 1, 'browser step picker rides the modal')
|
||||
assert.equal(byClass(modalTree, 'lc-br-pick')[0].args[1].value, 'live', 'browser opens on the live step')
|
||||
const closeBtn = byClass(modalTree, 'lc-modal-close')[0]
|
||||
assert.equal(closeBtn.args[1]['aria-label'], 'Close', 'close button localized')
|
||||
|
||||
// Deferred token consumption: the enter path left `/context` in the draft
|
||||
// and recorded a bare-token guard; closing the modal dispatches the scoped
|
||||
// consume-token event through the session scope.
|
||||
assert.ok(modalSource !== null, 'trigger source registered on this instance')
|
||||
await modalSource.matchEnter({ sessionId: 's1' }, '/context', new AbortController().signal)
|
||||
const backdrop = byClass(modalTree, 'lc-modal-backdrop')[0]
|
||||
backdrop.args[1].onClick()
|
||||
assert.equal(bailCalls.length, 1, 'closing the modal dispatches consume-token')
|
||||
assert.equal(bailCalls[0][1], 'slash/input-consume-token')
|
||||
assert.deepEqual(bailCalls[0][2], { guard: { kind: 'bare-token', token: '/context' } }, 'enter-path guard consumed on close')
|
||||
// A second close without a prior open records nothing.
|
||||
backdrop.args[1].onClick()
|
||||
assert.equal(bailCalls.length, 1, 'no pending guard -> no dispatch')
|
||||
|
||||
modalOpen = false
|
||||
assert.equal(renderModal(), null, 'modal closes again')
|
||||
|
||||
console.log('✔ modal render test passed (open/close, current overview, embedded context browser, localized chrome, deferred token consume on close)')
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Real-React reproduction harness: mounts the built client bundle with the
|
||||
* ACTUAL react/react-dom (module table) inside jsdom, drives the RPC with a
|
||||
* large synthetic snapshot, and toggles the granularity buttons — the exact
|
||||
* step -> turn -> step flow reported to black-screen the tab.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
test('real-React jsdom mount: rapid step<->turn toggles never hit React #185', async () => {
|
||||
const bundle = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
|
||||
|
||||
// ---- jsdom browser environment ----
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { pretendToBeVisual: true })
|
||||
globalThis.window = dom.window
|
||||
globalThis.document = dom.window.document
|
||||
// jsdom lacks scrollWidth/clientWidth semantics; emulate just enough layout
|
||||
// for the scroll container: content width follows the bar count (so a
|
||||
// granularity switch genuinely flips the overflow state — a static stub
|
||||
// never changes the edge fades and cannot regress-test the React #185
|
||||
// setState cascade this script exists for).
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollWidth', {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (this.classList && this.classList.contains('lc-chart-scroll')) {
|
||||
const chart = this.querySelector('.lc-chart')
|
||||
return Math.max(this.clientWidth, chart ? chart.children.length * 16 : 0)
|
||||
}
|
||||
return this.__scrollW ?? 0
|
||||
},
|
||||
})
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientWidth', { configurable: true, get() { return this.__clientW ?? 400 } })
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get() { return this.__scrollL ?? 0 },
|
||||
set(v) {
|
||||
this.__scrollL = Math.max(0, Math.min(v, Math.max(0, this.scrollWidth - this.clientWidth)))
|
||||
// Real browsers dispatch an async scroll event for programmatic scrolls;
|
||||
// jsdom does not. The event lands mid-cascade and its dispatch is what
|
||||
// disabled the same-value eager bailout in the no-deps layout effect
|
||||
// (React #185 on a granularity switch) — emulate it or the bug stays
|
||||
// invisible here.
|
||||
const el = this
|
||||
queueMicrotask(() => { if (el.isConnected) el.dispatchEvent(new dom.window.Event('scroll')) })
|
||||
},
|
||||
})
|
||||
|
||||
// ---- module table: real React ----
|
||||
let handoff = null
|
||||
globalThis.window.__ModuleLoader__ = {
|
||||
load(h) { handoff = h },
|
||||
}
|
||||
const require = (spec) => {
|
||||
if (spec === 'react') return React
|
||||
if (spec === '@deepseek-ai/dsh-client-ui-primitives') {
|
||||
// Glyph-only usage in this bundle; render inert svgs (never asserted).
|
||||
return {
|
||||
IconPlusOutline16: (p) => React.createElement('svg', p, null),
|
||||
IconBranchOutline16: (p) => React.createElement('svg', p, null),
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected module: ${spec}`)
|
||||
}
|
||||
// Run the bundle verbatim through the boot handoff (no string surgery on
|
||||
// the artifact — decoupled from bundler layout), then materialize it with
|
||||
// the module-table require like the browser loader does.
|
||||
new Function(bundle)()
|
||||
assert.ok(handoff !== null, 'bundle must register through __ModuleLoader__.load')
|
||||
const plugin = handoff.factory(require)
|
||||
|
||||
// ---- snapshot: many steps across turns (mirrors a long tool-heavy session) ----
|
||||
const requests = []
|
||||
for (let i = 0; i < 460; i++) {
|
||||
requests.push({
|
||||
seq: i + 1, turn: 1 + Math.floor(i / 12), step: i % 12, time: 1700000000000 + i * 1000,
|
||||
system: 4400, tools: 8200, user: 1100, inject: 1700, assistant: 2600, tool: 2600,
|
||||
total: 4400 + 8200 + 1100 + 1700 + 2600 + 2600, prompt: 594000,
|
||||
})
|
||||
}
|
||||
const snapshot = {
|
||||
ok: true, model: 'deepseek-v4-flash', provider: 'opencode-go', contextWindow: 1000000,
|
||||
current: { system: 4400, tools: 8200, user: 1100, inject: 1700, assistant: 2600, tool: 2600, total: 20600 },
|
||||
toolList: [], requests, events: [], nodes: [], droppedNodes: 0,
|
||||
}
|
||||
|
||||
const localeRegs = []
|
||||
let viewComponent = null
|
||||
const ctx = {
|
||||
get: () => undefined,
|
||||
effect: (fn) => { fn(); return () => {} },
|
||||
locale: {
|
||||
register: (ns, dicts) => { localeRegs.push(dicts); return () => {} },
|
||||
bind: () => (key, vars) => {
|
||||
const dict = localeRegs[0].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) => { fn() },
|
||||
// Capture per slot name: the overlay (ContextModal) registers AFTER the
|
||||
// view tab, so an unconditional overwrite would capture the modal.
|
||||
register: (opts, component) => { if (opts.name === 'conversation.view') viewComponent = component; return opts },
|
||||
},
|
||||
}
|
||||
plugin.apply(ctx)
|
||||
assert.ok(viewComponent !== null)
|
||||
// The framework standard kit delivers the timeline as a push-fed projection;
|
||||
// hand it straight to the stub so the chart renders on the first commit.
|
||||
const viewProps = { sessionId: 's1', useProjection: (key) => (key === 'contextTimeline' ? snapshot : undefined) }
|
||||
|
||||
// ---- mount with real React (behind a boundary so #185-style crashes are
|
||||
// catchable; without one an update-depth error unmounts the whole root) ----
|
||||
const container = dom.window.document.createElement('div')
|
||||
dom.window.document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const boundaryErrors = []
|
||||
class Boundary extends React.Component {
|
||||
constructor(p) { super(p); this.state = { err: null } }
|
||||
componentDidCatch(err) { boundaryErrors.push(err); this.setState({ err }) }
|
||||
render() { return this.state.err ? React.createElement('pre', null, 'CRASHED') : this.props.children }
|
||||
}
|
||||
try {
|
||||
root.render(React.createElement(Boundary, null, React.createElement(viewComponent, viewProps)))
|
||||
// the projection value is present on the first commit — no poll to wait on
|
||||
await new Promise(r => setTimeout(r, 60))
|
||||
assert.ok(container.textContent.includes('tokens'), 'chart rendered')
|
||||
// Wide viewport: the step chart overflows (460 bars) but the turn chart
|
||||
// fits (≈41 columns), so toggling flips the edge-fade state for real.
|
||||
const scroller = container.querySelector('.lc-chart-scroll')
|
||||
scroller.__clientW = 800
|
||||
const buttons = [...container.querySelectorAll('.lc-gran .lc-gran-btn')]
|
||||
assert.equal(buttons.length, 2, 'toggle buttons rendered')
|
||||
// Rapid toggles: the overflow state flips each time (wide step chart vs
|
||||
// narrow turn chart), which used to cascade setEdges dispatches inside the
|
||||
// no-deps layout effect until React #185 (maximum update depth) fired.
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const btns = [...container.querySelectorAll('.lc-gran .lc-gran-btn')]
|
||||
assert.equal(btns.length, 2, `toggle buttons still mounted after ${i} switches`)
|
||||
btns[i % 2].click()
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
}
|
||||
assert.equal(boundaryErrors.length, 0, 'no render crash: ' + (boundaryErrors[0] && boundaryErrors[0].message))
|
||||
// back to step mode: all step bars render again
|
||||
const finalBtns = [...container.querySelectorAll('.lc-gran .lc-gran-btn')]
|
||||
finalBtns[0].click()
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
assert.equal(boundaryErrors.length, 0, 'no render crash: ' + (boundaryErrors[0] && boundaryErrors[0].message))
|
||||
assert.ok(container.querySelectorAll('.lc-bar').length > 100, 'step mode re-rendered many bars')
|
||||
console.log('✔ real-React repro passed: 8 rapid step<->turn toggles render without throwing')
|
||||
} finally {
|
||||
// A vitest failure needs the throw to propagate — no exitCode plumbing.
|
||||
try { root.unmount() } catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Real-React reproduction harness: mounts the built client bundle with the
|
||||
* ACTUAL react/react-dom (module table) inside jsdom, drives the RPC with a
|
||||
* large synthetic snapshot, and toggles the granularity buttons — the exact
|
||||
* step -> turn -> step flow reported to black-screen the tab.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
const bundle = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
|
||||
|
||||
// ---- jsdom browser environment ----
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { pretendToBeVisual: true })
|
||||
globalThis.window = dom.window
|
||||
globalThis.document = dom.window.document
|
||||
// jsdom lacks scrollWidth/clientWidth semantics; emulate just enough layout
|
||||
// for the scroll container: content width follows the bar count (so a
|
||||
// granularity switch genuinely flips the overflow state — a static stub
|
||||
// never changes the edge fades and cannot regress-test the React #185
|
||||
// setState cascade this script exists for).
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollWidth', {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (this.classList && this.classList.contains('lc-chart-scroll')) {
|
||||
const chart = this.querySelector('.lc-chart')
|
||||
return Math.max(this.clientWidth, chart ? chart.children.length * 16 : 0)
|
||||
}
|
||||
return this.__scrollW ?? 0
|
||||
},
|
||||
})
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientWidth', { configurable: true, get() { return this.__clientW ?? 400 } })
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get() { return this.__scrollL ?? 0 },
|
||||
set(v) {
|
||||
this.__scrollL = Math.max(0, Math.min(v, Math.max(0, this.scrollWidth - this.clientWidth)))
|
||||
// Real browsers dispatch an async scroll event for programmatic scrolls;
|
||||
// jsdom does not. The event lands mid-cascade and its dispatch is what
|
||||
// disabled the same-value eager bailout in the no-deps layout effect
|
||||
// (React #185 on a granularity switch) — emulate it or the bug stays
|
||||
// invisible here.
|
||||
const el = this
|
||||
queueMicrotask(() => { if (el.isConnected) el.dispatchEvent(new dom.window.Event('scroll')) })
|
||||
},
|
||||
})
|
||||
|
||||
// ---- module table: real React ----
|
||||
let handoff = null
|
||||
globalThis.window.__ModuleLoader__ = {
|
||||
load(h) { handoff = h },
|
||||
}
|
||||
const require = (spec) => {
|
||||
if (spec === 'react') return React
|
||||
if (spec === '@deepseek-ai/dsh-client-ui-primitives') {
|
||||
// Glyph-only usage in this bundle; render inert svgs (never asserted).
|
||||
return {
|
||||
IconPlusOutline16: (p) => React.createElement('svg', p, null),
|
||||
IconBranchOutline16: (p) => React.createElement('svg', p, null),
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected module: ${spec}`)
|
||||
}
|
||||
const start = bundle.indexOf('factory: (require) => {') + 'factory: (require) => {'.length
|
||||
const end = bundle.indexOf(' return module.exports;')
|
||||
const factory = new Function('require', 'module', 'window', 'document',
|
||||
bundle.slice(start, end) + '\n return module.exports;')
|
||||
const m = { exports: {} }
|
||||
const plugin = factory(require, m, globalThis.window, dom.window.document)
|
||||
|
||||
// ---- snapshot: many steps across turns (mirrors a long tool-heavy session) ----
|
||||
const requests = []
|
||||
for (let i = 0; i < 460; i++) {
|
||||
requests.push({
|
||||
seq: i + 1, turn: 1 + Math.floor(i / 12), step: i % 12, time: 1700000000000 + i * 1000,
|
||||
system: 4400, tools: 8200, user: 1100, inject: 1700, assistant: 2600, tool: 2600,
|
||||
total: 4400 + 8200 + 1100 + 1700 + 2600 + 2600, prompt: 594000,
|
||||
})
|
||||
}
|
||||
const snapshot = {
|
||||
ok: true, model: 'deepseek-v4-flash', provider: 'opencode-go', contextWindow: 1000000,
|
||||
current: { system: 4400, tools: 8200, user: 1100, inject: 1700, assistant: 2600, tool: 2600, total: 20600 },
|
||||
toolList: [], requests, events: [], nodes: [], droppedNodes: 0,
|
||||
}
|
||||
|
||||
const localeRegs = []
|
||||
let viewComponent = null
|
||||
const ctx = {
|
||||
get: () => undefined,
|
||||
effect: (fn) => { fn(); return () => {} },
|
||||
locale: {
|
||||
register: (ns, dicts) => { localeRegs.push(dicts); return () => {} },
|
||||
bind: () => (key, vars) => {
|
||||
const dict = localeRegs[0].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) => { fn() },
|
||||
register: (opts, component) => { viewComponent = component; return opts },
|
||||
},
|
||||
}
|
||||
plugin.apply(ctx)
|
||||
assert.ok(viewComponent !== null)
|
||||
// The framework standard kit delivers the timeline as a push-fed projection;
|
||||
// hand it straight to the stub so the chart renders on the first commit.
|
||||
const viewProps = { sessionId: 's1', useProjection: (key) => (key === 'contextTimeline' ? snapshot : undefined) }
|
||||
|
||||
// ---- mount with real React (behind a boundary so #185-style crashes are
|
||||
// catchable; without one an update-depth error unmounts the whole root) ----
|
||||
const container = dom.window.document.createElement('div')
|
||||
dom.window.document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
let caught = null
|
||||
const boundaryErrors = []
|
||||
class Boundary extends React.Component {
|
||||
constructor(p) { super(p); this.state = { err: null } }
|
||||
componentDidCatch(err) { boundaryErrors.push(err); this.setState({ err }) }
|
||||
render() { return this.state.err ? React.createElement('pre', null, 'CRASHED') : this.props.children }
|
||||
}
|
||||
try {
|
||||
root.render(React.createElement(Boundary, null, React.createElement(viewComponent, viewProps)))
|
||||
// the projection value is present on the first commit — no poll to wait on
|
||||
await new Promise(r => setTimeout(r, 60))
|
||||
assert.ok(container.textContent.includes('tokens'), 'chart rendered')
|
||||
// Wide viewport: the step chart overflows (460 bars) but the turn chart
|
||||
// fits (≈41 columns), so toggling flips the edge-fade state for real.
|
||||
const scroller = container.querySelector('.lc-chart-scroll')
|
||||
scroller.__clientW = 800
|
||||
const buttons = [...container.querySelectorAll('.lc-gran-btn')]
|
||||
assert.equal(buttons.length, 2, 'toggle buttons rendered')
|
||||
// Rapid toggles: the overflow state flips each time (wide step chart vs
|
||||
// narrow turn chart), which used to cascade setEdges dispatches inside the
|
||||
// no-deps layout effect until React #185 (maximum update depth) fired.
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const btns = [...container.querySelectorAll('.lc-gran-btn')]
|
||||
assert.equal(btns.length, 2, `toggle buttons still mounted after ${i} switches`)
|
||||
btns[i % 2].click()
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
}
|
||||
assert.equal(boundaryErrors.length, 0, 'no render crash: ' + (boundaryErrors[0] && boundaryErrors[0].message))
|
||||
// back to step mode: all step bars render again
|
||||
const finalBtns = [...container.querySelectorAll('.lc-gran-btn')]
|
||||
finalBtns[0].click()
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
assert.equal(boundaryErrors.length, 0, 'no render crash: ' + (boundaryErrors[0] && boundaryErrors[0].message))
|
||||
assert.ok(container.querySelectorAll('.lc-bar').length > 100, 'step mode re-rendered many bars')
|
||||
console.log('✔ real-React repro passed: 8 rapid step<->turn toggles render without throwing')
|
||||
} catch (err) {
|
||||
caught = err
|
||||
console.error('✘ REAL-REACT REPRO FAILED:', err && err.message ? err.message : err)
|
||||
console.error(err && err.stack)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
try { root.unmount() } catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Backend-parse-failure resilience: a corrupt/foreign contextTimeline value
|
||||
* must never white-screen the tab — the projection is sanitized into a
|
||||
* render-safe shape and residual render errors land in the error boundary's
|
||||
* styled card.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
import { bootViewBed, byClass, textOf } from './helpers/viewBed.mjs'
|
||||
|
||||
const bed = await bootViewBed()
|
||||
const { classInstances, evaluate, modalComponent, renderView, snapshot, viewComponent } = bed
|
||||
bed.dataValue = snapshot
|
||||
let tr = renderView()
|
||||
|
||||
test('backend-parse-failure resilience: sanitized render of corrupt payloads, degraded modal, error boundary card', async () => {
|
||||
// ---- backend-parse-failure resilience: a corrupt/foreign `contextTimeline`
|
||||
// value must never white-screen the tab. The projection is sanitized into a
|
||||
// render-safe shape — the tab shows the WHOLE UI with every usable piece —
|
||||
// and any residual render error is caught by the error boundary (a styled
|
||||
// error card, not an unmounted conversation view). ----
|
||||
|
||||
// A non-record value (capability absent, nothing delivered yet): loading.
|
||||
bed.dataValue = 'not-even-an-object'
|
||||
tr = renderView()
|
||||
assert.equal(textOf(byClass(tr, 'lc-empty')[0]), '…', 'a non-record value keeps the loading screen')
|
||||
|
||||
// A garbage payload: wrong-typed scalars drop, non-list collections degrade
|
||||
// to [], non-object entries are dropped, and the breakdown zeroes out.
|
||||
bed.dataValue = {
|
||||
ok: false,
|
||||
model: 42,
|
||||
current: null,
|
||||
requests: ['junk', { seq: 1, turn: 1, time: 10, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 0, total: 10 }],
|
||||
events: [null, { kind: 'inject', seq: 1, time: 5 }],
|
||||
nodes: [null, { seq: 1, cat: 'user', tokens: 10, text: 'talk', time: NaN }],
|
||||
archive: { bogus: true },
|
||||
toolList: [null, { name: 'bash', tokens: 5 }],
|
||||
droppedNodes: -3,
|
||||
cost: ['not-a-record'],
|
||||
surfaceFloor: 'x',
|
||||
}
|
||||
tr = renderView() // must not throw
|
||||
assert.equal(byClass(tr, 'lc-bar').length, 1, 'the usable request entry still renders a trend bar')
|
||||
assert.equal(byClass(tr, 'lc-stat-value').length, 7, 'stats board still renders')
|
||||
const statValsM = byClass(tr, 'lc-stat-value').map(n => n.args[2])
|
||||
assert.equal(statValsM[0], '1', 'turns count survives (non-array requests degraded, entries filtered)')
|
||||
assert.equal(statValsM[1], '1', 'steps count survives')
|
||||
assert.equal(statValsM[5], '—', 'cache hit degrades to a dash')
|
||||
assert.equal(statValsM[6], '—', 'cost degrades to a dash (non-record cost dropped)')
|
||||
assert.equal(byClass(tr, 'lc-event').length, 1, 'the usable event entry still shows')
|
||||
assert.equal(byClass(tr, 'lc-node').length, 1, 'the usable node entry still shows')
|
||||
assert.equal(textOf(byClass(tr, 'lc-node-time')[0]), '—', 'an invalid timestamp shows a dash instead of throwing')
|
||||
assert.equal(byClass(tr, 'lc-tool-chip').length, 1, 'the usable tool entry still shows')
|
||||
assert.equal(byClass(tr, 'lc-br-cat-row').length, 6, 'the context browser still renders all six sections')
|
||||
|
||||
// The /context modal degrades identically: sanitized empty data renders the
|
||||
// dialog shell with full sections instead of throwing.
|
||||
const brokenModalTree = evaluate(modalComponent({
|
||||
sessionId: 's1',
|
||||
useProjection: (key) => (key === 'contextTimeline'
|
||||
? { current: 'x', requests: 5, nodes: null, toolList: 7, events: {}, archive: 'z' }
|
||||
: undefined),
|
||||
useContextModal: (sel) => sel(true),
|
||||
}))
|
||||
assert.equal(byClass(brokenModalTree, 'lc-modal-card').length, 1, 'the modal shell stays intact')
|
||||
assert.equal(byClass(brokenModalTree, 'lc-br-cat-row').length, 6, 'the modal degrades to full sections with sanitized data')
|
||||
|
||||
// A projection reader that THROWS (a broker/framework failure) lands in the
|
||||
// error boundary's styled card — not a white screen — with the boundary
|
||||
// protocol recording the offending error.
|
||||
const boomTree = evaluate(viewComponent({
|
||||
sessionId: 's1',
|
||||
useProjection: () => { throw new Error('boom-parse') },
|
||||
}))
|
||||
assert.equal(byClass(boomTree, 'lc-error').length, 1, 'a throwing reader degrades to the error card')
|
||||
assert.match(textOf(byClass(boomTree, 'lc-error')[0]), /ERR:.*boom-parse/, 'the card carries the offending message')
|
||||
assert.equal(byClass(boomTree, 'lc-error-retry').length, 1, 'the card offers Retry')
|
||||
const boundaryKey = [...classInstances.keys()].find(k => k.includes('ErrorBoundary'))
|
||||
assert.ok(boundaryKey, 'boundary instance registered')
|
||||
assert.match(classInstances.get(boundaryKey).state.error.message, /boom-parse/, 'the boundary protocol recorded the error')
|
||||
|
||||
// restore state for any later tests
|
||||
bed.dataValue = snapshot
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-bar').length, 4, 'snapshot restored after the resilience tests')
|
||||
|
||||
console.log('✔ backend-parse-failure resilience test passed (sanitized render of corrupt payloads, degraded modal, error boundary card)')
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Overview tool-chip bridge: the tools label, each tool chip, and the
|
||||
* overflow link are one-shot bridges into the context browser (open the
|
||||
* tools category, optionally expanding one tool row).
|
||||
*/
|
||||
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
|
||||
bed.dataValue = snapshot
|
||||
let tr = renderView()
|
||||
const ctxSlots = bed.ctxSlots()
|
||||
const brSlots = bed.brSlots()
|
||||
|
||||
test('overview tool-chip bridge: label opens the tools category, chip expands the tool row, one-shot focus clears', async () => {
|
||||
// ---- overview tool-chip bridge: the "工具定义 Top" label and each tool chip
|
||||
// are clickable buttons that link into the Context browser — the label opens
|
||||
// the tools category, a chip also expands that specific tool's row. The
|
||||
// request is one-shot: applied by the browser's toolFocus effect (hook slot
|
||||
// 9) and cleared back so the same chip can be clicked again. ----
|
||||
bed.dataValue = {
|
||||
...snapshot,
|
||||
toolList: [
|
||||
{ name: 'bash', tokens: 5 },
|
||||
{ name: 'rg', tokens: 3 },
|
||||
],
|
||||
}
|
||||
bed.headersValue = {
|
||||
headers: [{
|
||||
seq: 1, time: 900,
|
||||
tools: [{ name: 'bash', tokens: 5, description: 'run a command', schema: { name: 'bash', parameters: { type: 'object' } } }],
|
||||
}],
|
||||
}
|
||||
brSlots[2][1](null) // no element open from the previous test
|
||||
tr = renderView()
|
||||
const toolsLabel = byClass(tr, 'lc-tools-label')[0]
|
||||
assert.ok(toolsLabel, '"工具定义 Top" label rendered as a button')
|
||||
assert.equal(typeof toolsLabel.args[1].onClick, 'function', 'tools label is clickable')
|
||||
let chips = byClass(tr, 'lc-tool-chip')
|
||||
assert.equal(chips.length, 2, 'tool chips rendered (two tools)')
|
||||
assert.ok(chips.every(c => typeof c.args[1].onClick === 'function'), 'every tool chip is clickable')
|
||||
// Clicking the label opens the tools category only (no specific tool).
|
||||
toolsLabel.args[1].onClick()
|
||||
tr = renderView()
|
||||
assert.deepEqual(ctxSlots[7][0], {}, 'label click records a category-only focus')
|
||||
brSlots[9].effect() // the tool-bridge effect applies the one-shot request
|
||||
tr = renderView()
|
||||
assert.equal(ctxSlots[7][0], null, 'one-shot focus is cleared once applied')
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, 'live', 'focus switches the browser to the live step')
|
||||
assert.equal(byClass(tr, 'lc-br-body').length, 1, 'tools category opens in the browser')
|
||||
assert.match(textOf(byClass(tr, 'lc-br-body')[0]), /bash/, 'tools category lists the schema rows')
|
||||
assert.equal(byClass(tr, 'lc-br-content').length, 0, 'category-only focus expands no specific tool')
|
||||
// Clicking a specific tool also expands that tool's row.
|
||||
chips = byClass(tr, 'lc-tool-chip')[0].args[1].onClick()
|
||||
tr = renderView()
|
||||
assert.deepEqual(ctxSlots[7][0], { tool: 'bash' }, 'chip click records a specific-tool focus')
|
||||
brSlots[9].effect()
|
||||
tr = renderView()
|
||||
assert.equal(ctxSlots[7][0], null, 'tool focus cleared once applied')
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, 'live', 'stays on the live step')
|
||||
const bridgeContent = byClass(tr, 'lc-br-content')
|
||||
assert.equal(bridgeContent.length, 1, 'the clicked tool row expands open')
|
||||
assert.match(textOf(bridgeContent[0]), /run a command/, 'the expanded body carries the clicked tool description')
|
||||
// The "等 N 个" overflow link (more than five tools) also opens the category.
|
||||
bed.dataValue = {
|
||||
...snapshot,
|
||||
toolList: ['a', 'b', 'c', 'd', 'e', 'f'].map((name, i) => ({ name, tokens: 10 - i })),
|
||||
}
|
||||
brSlots[1][1](null) // retract the previously opened category
|
||||
brSlots[2][1](null)
|
||||
tr = renderView()
|
||||
const moreBtn = byClass(tr, 'lc-tools-more')[0]
|
||||
assert.ok(moreBtn, '"等 N 个" overflow link rendered as a button')
|
||||
assert.equal(byClass(tr, 'lc-tool-chip').length, 5, 'still shows only the top five chips')
|
||||
assert.equal(typeof moreBtn.args[1].onClick, 'function', 'the overflow link is clickable')
|
||||
moreBtn.args[1].onClick()
|
||||
tr = renderView()
|
||||
assert.deepEqual(ctxSlots[7][0], {}, 'overflow link opens the tools category (no specific tool)')
|
||||
brSlots[9].effect()
|
||||
tr = renderView()
|
||||
assert.equal(byClass(tr, 'lc-br-pick')[0].args[1].value, 'live', 'stays on the live step')
|
||||
assert.equal(byClass(tr, 'lc-br-body').length, 1, 'tools category opens in the browser')
|
||||
|
||||
console.log('✔ overview tool-chip bridge test passed (label opens the tools category, chip expands the tool row, overflow link opens the category too, one-shot focus clears)')
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Specs exercise the BUILT artifacts (lib/), so `pnpm run build` first.
|
||||
// `*.client.spec.mjs` is the browser lane (jsdom pragma per file); the
|
||||
// plain `*.spec.mjs` files run in node with their own fakes.
|
||||
include: ['tests/**/*.spec.mjs'],
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user