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:
bowenliang123 committed 2026-08-22 02:33:28 +08:00
1 parent 9905006ef8
commit 49f1439dae
20 files changed
+3052 -2271

No files matched your search

+3 -3
View File
@@ -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"
}
+459
View File
@@ -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: {}
+56
View File
@@ -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)')
})
+294
View File
@@ -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)')
})
+504
View File
@@ -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
})
+178
View File
@@ -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
+106
View File
@@ -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)')
})
+315
View File
@@ -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,
})
}
+96
View File
@@ -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')
})
+498
View File
@@ -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')
})
-470
View File
@@ -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)')
+68
View File
@@ -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)')
})
+62
View File
@@ -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)')
})
+67
View File
@@ -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)')
})
+160
View File
@@ -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 */ }
}
})
-161
View File
@@ -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 */ }
}
+89
View File
@@ -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)')
})
+87
View File
@@ -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)')
})
+10
View File
@@ -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'],
},
})