dsh-graft-plugin 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +27 -1
- package/package.json +13 -8
- package/scripts/test-autosync.mjs +66 -1
- package/scripts/test-guidance.mjs +160 -0
- package/scripts/test-harness.mjs +219 -1
- package/src/auto-sync.js +34 -2
- package/src/client.js +438 -59
- package/src/graft-tools.js +11 -0
- package/src/index.js +190 -36
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adithya N Raj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -48,11 +48,32 @@ cd <your repo>
|
|
|
48
48
|
graft build
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
### In the desktop app
|
|
52
|
+
|
|
53
|
+
The desktop dsh installs plugins from its own **Plugins** settings — no CLI, no profile directory:
|
|
54
|
+
|
|
55
|
+
1. Open **Plugins** (settings, left sidebar) and click **Add plugin**.
|
|
56
|
+
2. Paste the GitHub repository address into the field (the package name `dsh-graft-plugin` works too):
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
https://github.com/adithyanraj03/dsh-graft-plugin
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
3. Click **Install**.
|
|
63
|
+
|
|
64
|
+

|
|
65
|
+
|
|
66
|
+
The dialog switches to the **Installed** confirmation, and the plugin appears under **Installed** at the bottom of the settings page:
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
> The dialog notes that installed plugins **do not update automatically yet** — to upgrade, uninstall and install again. The graft CLI requirement (`npm install -g @nanonets/graft`) is the same.
|
|
71
|
+
|
|
51
72
|
### The sidebar tab
|
|
52
73
|
|
|
53
74
|
On dsh `0.1.5` and later the button opens the graph in **dsh's own right sidebar** — a tab titled Graft, also listed on the sidebar's guide page as "The graft graph for this workspace" — and a second click focuses the tab that is already open. No install needed for that path: the registration is soft, so an older dsh without the right sidebar falls through to `dsh-better-sidebar` when it is mounted, and to a browser tab when neither is there.
|
|
54
75
|
|
|
55
|
-
If your profile still runs `dsh-better-sidebar`, that path is tried **first** and behaves exactly as before.
|
|
76
|
+
If your profile still runs `dsh-better-sidebar`, that path is tried **first** and behaves exactly as before. From 0.3.0 it is an **optional peer dependency** — dsh no longer downloads it for you, so add it yourself when you want it (on dsh `0.1.5-rc.2` or later it cannot mount anyway, so most profiles should skip this):
|
|
56
77
|
|
|
57
78
|
```sh
|
|
58
79
|
dsh plugin --profile web add dsh-better-sidebar
|
|
@@ -133,8 +154,11 @@ All optional, all on the plugin's row in your profile's `cordis.patch.yml`:
|
|
|
133
154
|
|
|
134
155
|
| dsh | status |
|
|
135
156
|
|---|---|
|
|
157
|
+
| `0.2.1-rc.2` | test suites pass against the `0.2.1-rc.2` packages — chip, tools, `/graft`, auto-rebuild and the model-facing guidance; the dual-shape codecs load on both 0.1 and 0.2 hosts |
|
|
158
|
+
| `0.1.7-rc.2` | tested on a live profile (0.3.0) — chip, tools, `/graft`, auto-rebuild and the model-facing guidance all verified |
|
|
136
159
|
| `0.1.2-rc.1` – `0.1.0-rc.7` | tested, full feature set — the `graft viz` tab via `dsh-better-sidebar` |
|
|
137
160
|
| `0.1.5-rc.2` / `0.1.5` | tested — chip, tools, `/graft` and auto-rebuild all work; on `0.1.5` the `graft viz` button opens the graph in the built-in right sidebar |
|
|
161
|
+
| `0.2.0-rc.2` | needs plugin `0.2.1`+ — earlier releases fail to activate (see Troubleshooting) |
|
|
138
162
|
|
|
139
163
|
- Node 22+
|
|
140
164
|
- the `graft` CLI on PATH (`npm install -g @nanonets/graft`)
|
|
@@ -168,6 +192,8 @@ registering "dsh-graft-plugin" via __ModuleLoader__.load
|
|
|
168
192
|
|
|
169
193
|
**No chip appears and dsh started without errors.** The plugin is in the profile's `package.json` `dependencies` but not in `dsh.profile.bundles` — which happens under pnpm 10+ when the `add` ends non-zero on `ERR_PNPM_IGNORED_BUILDS`; dsh skips the bundle row in that case and starts up looking healthy. Fix: `pnpm approve-builds` in the profile directory (tick `dsh-graft-plugin`), then re-run `dsh plugin --profile web add dsh-graft-plugin`. If `pnpm approve-builds` reports nothing awaiting approval, check `pnpm-workspace.yaml` for an unanswered `dsh-graft-plugin` entry (pnpm's approval placeholder) and set its value to `true` first.
|
|
170
194
|
|
|
195
|
+
**On dsh 0.2 the plugin fails to load with `strict codec has no create() factory`, and dsh offers to remove it.** dsh 0.2 requires every strict remote codec to carry a `create()` schema factory; plugin releases before `0.2.1` only had the 0.1 `schema` key. Update to `0.2.1` or later, which carries both and so loads on 0.1 and 0.2.
|
|
196
|
+
|
|
171
197
|
**`/graft` says the graft CLI is not on PATH.** The postinstall was blocked or skipped (see Install): `npm install -g @nanonets/graft`.
|
|
172
198
|
|
|
173
199
|
## Licence
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-graft-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shows which graft index a dsh session is serving and whether it still matches the code, and gives the model graft_ask, graft_grep, graft_callers, graft_skeleton and graft_map as native tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -25,12 +25,13 @@
|
|
|
25
25
|
"scripts/test-tools.mjs",
|
|
26
26
|
"scripts/test-autosync.mjs",
|
|
27
27
|
"scripts/test-command.mjs",
|
|
28
|
+
"scripts/test-guidance.mjs",
|
|
28
29
|
"README.md",
|
|
29
30
|
"LICENSE",
|
|
30
31
|
"screenshots.json"
|
|
31
32
|
],
|
|
32
33
|
"scripts": {
|
|
33
|
-
"test": "node scripts/test-harness.mjs && node scripts/test-autosync.mjs && node scripts/test-command.mjs",
|
|
34
|
+
"test": "node scripts/test-harness.mjs && node scripts/test-autosync.mjs && node scripts/test-command.mjs && node scripts/test-guidance.mjs",
|
|
34
35
|
"test:live": "node scripts/test-tools.mjs",
|
|
35
36
|
"postinstall": "node scripts/postinstall.mjs"
|
|
36
37
|
},
|
|
@@ -45,15 +46,19 @@
|
|
|
45
46
|
]
|
|
46
47
|
}
|
|
47
48
|
},
|
|
48
|
-
"dependencies": {
|
|
49
|
-
"dsh-better-sidebar": "^0.18.0"
|
|
50
|
-
},
|
|
51
49
|
"peerDependencies": {
|
|
52
|
-
"@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.6"
|
|
50
|
+
"@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.6",
|
|
51
|
+
"dsh-better-sidebar": ">=0.18.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"dsh-better-sidebar": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
53
57
|
},
|
|
54
58
|
"devDependencies": {
|
|
55
|
-
"@deepseek-ai/
|
|
56
|
-
"@deepseek-ai/dsh-
|
|
59
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
60
|
+
"@deepseek-ai/dsh-tools": "0.2.1-rc.2",
|
|
61
|
+
"@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2"
|
|
57
62
|
},
|
|
58
63
|
"keywords": [
|
|
59
64
|
"dsh-plugin",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { resolveGraftCommand, savedTokensIn } from '../src/graft-tools.js'
|
|
2
3
|
import { createAutoSync } from '../src/auto-sync.js'
|
|
3
4
|
import { totalSaved } from '../src/index.js'
|
|
4
5
|
|
|
@@ -76,6 +77,17 @@ console.log('\n--- graft missing is not an error ---')
|
|
|
76
77
|
const none = createAutoSync({ graft: null, now: () => now, spawn: () => { throw new Error('should not spawn') } })
|
|
77
78
|
check('no graft, no rebuild, no throw', (await none.syncIfDirty('C:/x')) === 'graft-unavailable')
|
|
78
79
|
|
|
80
|
+
console.log('\n--- a manual Build forces a rebuild, like the popup button ---')
|
|
81
|
+
// The popup's Build must not go through syncIfDirty's gates: a repo whose
|
|
82
|
+
// stats already read clean (plain `graft build` never patches stats.json, so
|
|
83
|
+
// `dirty` is whatever the last sync-run left) would answer 'clean' and spawn
|
|
84
|
+
// nothing — the exact "button press changes nothing" shipped once.
|
|
85
|
+
state.lock = true
|
|
86
|
+
now += 60_000
|
|
87
|
+
check('a forced rebuild skips the clean gate', (await sync.rebuild('C:/y')) === 'started')
|
|
88
|
+
check('and it spawned graft\'s sync-run for that root', spawned.some((a) => a[1][1] === 'C:/y'), JSON.stringify(spawned.at(-1)))
|
|
89
|
+
check('it set syncing so the chip can show it', state.stats.syncing === true)
|
|
90
|
+
|
|
79
91
|
console.log('\n--- mid-turn rebuild: a long turn must not sit on a stale graph ---')
|
|
80
92
|
// The turn boundary alone left a graph dirty for 36 minutes of one agentic
|
|
81
93
|
// turn while the model kept querying it. A write now also arms a debounce.
|
|
@@ -106,6 +118,11 @@ check('a second write replaces the timer, not adds one', pendingTimers.size ===
|
|
|
106
118
|
const fire = [...pendingTimers.values()][0]
|
|
107
119
|
pendingTimers.clear()
|
|
108
120
|
await fire()
|
|
121
|
+
// The timer callback fires syncIfDirty without awaiting it (fire-and-forget
|
|
122
|
+
// by design — a throw inside a timer must never surface), so flush before
|
|
123
|
+
// asserting the spawn landed. This needed one more hop once syncIfDirty
|
|
124
|
+
// started delegating to rebuild().
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
109
126
|
check('the quiet period rebuilds without waiting for the turn', fired.length === 1, String(fired.length))
|
|
110
127
|
|
|
111
128
|
const off = createAutoSync({ graft: { state, syncRun: 'x' }, now: () => now, spawn: () => ({ on() {}, unref() {} }), debounceMs: 0, setTimeout: () => { throw new Error('should not arm') } })
|
|
@@ -113,5 +130,53 @@ let armed = true
|
|
|
113
130
|
try { await off.markDirty('C:/y') } catch { armed = false }
|
|
114
131
|
check('debounceMs 0 restores turn-end-only', armed === true)
|
|
115
132
|
|
|
133
|
+
console.log('\n--- a graft cli we cannot locate must never crash dsh ---')
|
|
134
|
+
// THE ORIGINAL FATALITY: graft installed as a bare shim (bun global) left
|
|
135
|
+
// `cliPath` undefined, `dirname(undefined)` in loadGraftState escaped the
|
|
136
|
+
// `void`-ed markDirty as an unhandled rejection, and dsh died on the FIRST
|
|
137
|
+
// successful edit in an indexed repo. These checks pin the guarantee: no
|
|
138
|
+
// input, no throw, no rejection, no spawn.
|
|
139
|
+
const noCli = createAutoSync({ now: () => now, spawn: (...a) => { spawned.push(a); return { on() {}, unref() {} } }, debounceMs: 0 })
|
|
140
|
+
let crashed = false
|
|
141
|
+
const spawnedBefore = spawned.length
|
|
142
|
+
try { await noCli.markDirty('C:/no-cli') } catch { crashed = true }
|
|
143
|
+
check('markDirty resolves without a cli', crashed === false)
|
|
144
|
+
check('and never spawns a rebuild', spawned.length === spawnedBefore, String(spawned.length - spawnedBefore))
|
|
145
|
+
check('syncIfDirty reports graft unavailable', (await noCli.syncIfDirty('C:/no-cli')) === 'graft-unavailable')
|
|
146
|
+
|
|
147
|
+
// Even a loader that REJECTS is swallowed — graft() awaits the injected stub
|
|
148
|
+
// inside its own try, so no input shape can turn into an unhandled rejection.
|
|
149
|
+
const throwing = createAutoSync({ graft: Promise.reject(new Error('boom')), now: () => now, spawn: () => ({ on() {}, unref() {} }), debounceMs: 0 })
|
|
150
|
+
crashed = false
|
|
151
|
+
try { await throwing.markDirty('C:/throwing') } catch { crashed = true }
|
|
152
|
+
check('a rejecting graft loader is swallowed', crashed === false)
|
|
153
|
+
check('and sync stays unavailable, not fatal', (await throwing.syncIfDirty('C:/throwing')) === 'graft-unavailable')
|
|
154
|
+
|
|
155
|
+
console.log('\n--- resolveGraftCommand finds graft however it was installed ---')
|
|
156
|
+
const fakeExists = (set) => (p) => set.has(p)
|
|
157
|
+
const bunCli = join('/home/x', '.bun', 'install', 'global', 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')
|
|
158
|
+
// A bun global install: only the shim is on PATH, the package lives under
|
|
159
|
+
// $BUN_INSTALL/install/global. The PATH-only scan returned prefix: [] here,
|
|
160
|
+
// which is what fed undefined into loadGraftState above.
|
|
161
|
+
const viaBunInstall = resolveGraftCommand(
|
|
162
|
+
{ PATH: '/home/x/.bun/bin', BUN_INSTALL: '/home/x/.bun', HOME: '/home/x' },
|
|
163
|
+
fakeExists(new Set([bunCli])),
|
|
164
|
+
)
|
|
165
|
+
check('a bun global install resolves to its dist cli', viaBunInstall.prefix[0] === bunCli && viaBunInstall.shell === false, viaBunInstall.prefix[0])
|
|
166
|
+
const viaHome = resolveGraftCommand(
|
|
167
|
+
{ PATH: '', HOME: '/home/x' },
|
|
168
|
+
fakeExists(new Set([bunCli])),
|
|
169
|
+
)
|
|
170
|
+
check('or via HOME/.bun when BUN_INSTALL is unset', viaHome.prefix[0] === bunCli, viaHome.prefix[0])
|
|
171
|
+
const viaUserprofile = resolveGraftCommand(
|
|
172
|
+
{ PATH: '', USERPROFILE: 'C:\\Users\\x' },
|
|
173
|
+
// The fixture path is built with the same join() the source uses, so this
|
|
174
|
+
// checks WHERE the lookup happens, not how a host renders separators.
|
|
175
|
+
fakeExists(new Set([join('C:\\Users\\x', '.bun', 'install', 'global', 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js')])),
|
|
176
|
+
)
|
|
177
|
+
check('or via USERPROFILE/.bun on Windows', viaUserprofile.prefix[0] === join('C:\\Users\\x', '.bun', 'install', 'global', 'node_modules', '@nanonets', 'graft', 'dist', 'cli.js'), viaUserprofile.prefix[0])
|
|
178
|
+
const bare = resolveGraftCommand({ PATH: '', HOME: '/home/x' }, fakeExists(new Set()))
|
|
179
|
+
check('nothing installed falls back to the bare command', bare.command === 'graft' && bare.prefix.length === 0)
|
|
180
|
+
|
|
116
181
|
console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
|
|
117
182
|
process.exit(failures === 0 ? 0 : 1)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standing instruction must reach the model on CURRENT dsh.
|
|
3
|
+
*
|
|
4
|
+
* dsh `0.1.7-rc.2` removed the `agent/session-start` event the guidance used to
|
|
5
|
+
* ride: the listener stayed registered and the suite stayed green, while no
|
|
6
|
+
* agent ever fired it, so `graft-status:usage` silently vanished from every
|
|
7
|
+
* prompt. This suite runs `apply()` against a REAL Cordis context and drives
|
|
8
|
+
* the event dsh actually emits now (`agent/created`, the same seam dsh's own
|
|
9
|
+
* file-reference plugin uses), so an upstream rename fails here instead of
|
|
10
|
+
* disappearing into a prompt nobody diffs.
|
|
11
|
+
*
|
|
12
|
+
* Cordis resolves injects asynchronously, so every check after a trigger
|
|
13
|
+
* waits a tick first — the assertions test what a live session would see,
|
|
14
|
+
* not what a synchronous read-back would flatter.
|
|
15
|
+
*
|
|
16
|
+
* Run: node scripts/test-guidance.mjs (part of `npm test`)
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from 'node:fs/promises'
|
|
19
|
+
import { Context, Service } from '@deepseek-ai/cordis'
|
|
20
|
+
|
|
21
|
+
import { apply } from '../src/index.js'
|
|
22
|
+
|
|
23
|
+
let failures = 0
|
|
24
|
+
const check = (label, ok, extra = '') => {
|
|
25
|
+
console.log((ok ? 'PASS ' : 'FAIL ') + label + (extra ? ' -> ' + extra : ''))
|
|
26
|
+
if (!ok) failures += 1
|
|
27
|
+
}
|
|
28
|
+
/** Let pending Cordis inject fibers and disposals settle. */
|
|
29
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
30
|
+
|
|
31
|
+
/** The whole prompt section this plugin registers, as the tests see it. */
|
|
32
|
+
const SECTION_NAME = 'graft-status:usage'
|
|
33
|
+
const GUIDANCE_ORDER = 950
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A prompt-registry stand-in that behaves like the real `systemPrompt`
|
|
37
|
+
* service where it matters. It is a Cordis Service, so the traceable-context
|
|
38
|
+
* overlay applies, and `section()` creates its undo effect on the CALLER's
|
|
39
|
+
* context — the same ownership `SystemPrompt.section()` gets through
|
|
40
|
+
* `ScopedLayers`, and the reason an agent's section can die with the agent.
|
|
41
|
+
*/
|
|
42
|
+
function fakePromptRegistry(sections, removed) {
|
|
43
|
+
return class FakePromptRegistry extends Service {
|
|
44
|
+
constructor(ctx) {
|
|
45
|
+
super(ctx, 'systemPrompt')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
section(section) {
|
|
49
|
+
sections.push(section)
|
|
50
|
+
return this.ctx.effect(() => () => {
|
|
51
|
+
removed.push(section.name)
|
|
52
|
+
}, `fake.section(${section.name})`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Mount the stand-in on a context as the `systemPrompt` service. */
|
|
58
|
+
function providePromptRegistry(ctx, sections, removed) {
|
|
59
|
+
ctx.plugin(fakePromptRegistry(sections, removed))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log('--- apply() against a real Cordis context ---')
|
|
63
|
+
const root = new Context()
|
|
64
|
+
const registeredTools = []
|
|
65
|
+
const sections = []
|
|
66
|
+
const removed = []
|
|
67
|
+
root.plugin(fakePromptRegistry(sections, removed))
|
|
68
|
+
root.reflect.provide('tools', {
|
|
69
|
+
register(tool) {
|
|
70
|
+
registeredTools.push(tool)
|
|
71
|
+
return () => {}
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
let threw
|
|
76
|
+
try {
|
|
77
|
+
await apply(root, {})
|
|
78
|
+
} catch (error) {
|
|
79
|
+
threw = error
|
|
80
|
+
}
|
|
81
|
+
check('apply() mounts without throwing', threw === undefined, threw ? String(threw) : '')
|
|
82
|
+
check('the five graft tools register', registeredTools.length === 5, registeredTools.map((t) => t.name).join(', '))
|
|
83
|
+
|
|
84
|
+
console.log('\n--- the guidance rides agent/created, not the removed agent/session-start ---')
|
|
85
|
+
check(
|
|
86
|
+
'the source never names the removed event',
|
|
87
|
+
!(await readFile(new URL('../src/index.js', import.meta.url), 'utf8')).includes('agent/session-start'),
|
|
88
|
+
)
|
|
89
|
+
check('no guidance before any agent exists', sections.length === 0, JSON.stringify(sections))
|
|
90
|
+
|
|
91
|
+
console.log('\n--- an agent with the prompt registry gets the section ---')
|
|
92
|
+
const agent = { ctx: new Context() }
|
|
93
|
+
providePromptRegistry(agent.ctx, sections, removed)
|
|
94
|
+
root.emit('agent/created', { agent })
|
|
95
|
+
await settle()
|
|
96
|
+
check('the section registered on the agent scope', sections.length === 1, JSON.stringify(sections[0]?.name))
|
|
97
|
+
check('under its own name', sections[0]?.name === SECTION_NAME, sections[0]?.name)
|
|
98
|
+
check('at the documented order', sections[0]?.order === GUIDANCE_ORDER, String(sections[0]?.order))
|
|
99
|
+
check('with the graft guidance text', /graft_ask/.test(sections[0]?.text ?? ''), (sections[0]?.text ?? '').split('\n')[0])
|
|
100
|
+
|
|
101
|
+
console.log('\n--- a second announce does not duplicate it ---')
|
|
102
|
+
root.emit('agent/created', { agent })
|
|
103
|
+
await settle()
|
|
104
|
+
check('the section registered exactly once', sections.length === 1, String(sections.length))
|
|
105
|
+
|
|
106
|
+
console.log('\n--- agent disposal takes the section down ---')
|
|
107
|
+
root.emit('agent/disposed', { agent })
|
|
108
|
+
await settle()
|
|
109
|
+
check('the section was disposed with the agent', removed.length === 1, JSON.stringify(removed))
|
|
110
|
+
|
|
111
|
+
console.log('\n--- an agent WITHOUT the prompt registry only loses the sentence ---')
|
|
112
|
+
const bare = { ctx: new Context() }
|
|
113
|
+
let bareThrew
|
|
114
|
+
try {
|
|
115
|
+
root.emit('agent/created', { agent: bare })
|
|
116
|
+
await settle()
|
|
117
|
+
} catch (error) {
|
|
118
|
+
bareThrew = error
|
|
119
|
+
}
|
|
120
|
+
check('a registry-less agent is survived, not crashed', bareThrew === undefined, bareThrew ? String(bareThrew) : '')
|
|
121
|
+
check('and it adds no section', sections.length === 1, String(sections.length))
|
|
122
|
+
|
|
123
|
+
console.log('\n--- agents that already exist when the row mounts are briefed too ---')
|
|
124
|
+
const lateRoot = new Context()
|
|
125
|
+
lateRoot.reflect.provide('tools', { register: () => () => {} })
|
|
126
|
+
providePromptRegistry(lateRoot, sections, removed)
|
|
127
|
+
lateRoot.reflect.provide('agents', { list: () => [agent, bare] })
|
|
128
|
+
const before = sections.length
|
|
129
|
+
let lateThrew
|
|
130
|
+
try {
|
|
131
|
+
await apply(lateRoot, {})
|
|
132
|
+
await settle()
|
|
133
|
+
} catch (error) {
|
|
134
|
+
lateThrew = error
|
|
135
|
+
}
|
|
136
|
+
check('apply() with an agents service does not throw', lateThrew === undefined, lateThrew ? String(lateThrew) : '')
|
|
137
|
+
check('pre-existing agents got the section at mount', sections.length === before + 1, `${sections.length - before} added`)
|
|
138
|
+
check('and the registry-less one stayed silent', sections.every((s) => s.name === SECTION_NAME))
|
|
139
|
+
|
|
140
|
+
console.log('\n--- promptSection: false registers nothing ---')
|
|
141
|
+
const quietRoot = new Context()
|
|
142
|
+
quietRoot.reflect.provide('tools', { register: () => () => {} })
|
|
143
|
+
providePromptRegistry(quietRoot, sections, removed)
|
|
144
|
+
quietRoot.reflect.provide('agents', { list: () => [agent] })
|
|
145
|
+
await apply(quietRoot, { promptSection: false })
|
|
146
|
+
quietRoot.emit('agent/created', { agent })
|
|
147
|
+
await settle()
|
|
148
|
+
check('no guidance section from a suppressed row', sections.length === before + 1, `${sections.length - before - 1} added`)
|
|
149
|
+
|
|
150
|
+
console.log('\n--- teardown ---')
|
|
151
|
+
for (const ctx of [root, agent.ctx, lateRoot, quietRoot]) {
|
|
152
|
+
try {
|
|
153
|
+
await ctx.fiber?.dispose?.()
|
|
154
|
+
} catch {
|
|
155
|
+
// A test context holds only fake services; nothing here may block exit.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
|
|
160
|
+
process.exit(failures === 0 ? 0 : 1)
|
package/scripts/test-harness.mjs
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
15
15
|
import { tmpdir } from 'node:os'
|
|
16
16
|
import { join } from 'node:path'
|
|
17
|
-
import { computeStatus, countWiring, findGraftRoot, freshnessOf } from '../src/index.js'
|
|
17
|
+
import { computeStatus, countWiring, createToolPulse, findGraftRoot, freshnessOf } from '../src/index.js'
|
|
18
|
+
import { graftTools } from '../src/graft-tools.js'
|
|
18
19
|
|
|
19
20
|
let failures = 0
|
|
20
21
|
const check = (label, ok, extra = '') => {
|
|
@@ -113,6 +114,44 @@ check('and a space in the path does not break it', OTHER.includes(' '))
|
|
|
113
114
|
const nowhere = await findGraftRoot(BARE, exists)
|
|
114
115
|
check('an unindexed tree resolves to nothing', nowhere === undefined, String(nowhere))
|
|
115
116
|
|
|
117
|
+
console.log('\n--- git worktrees are their own graft root ---')
|
|
118
|
+
// A linked worktree's .git is a FILE pointing at its admin dir, whose
|
|
119
|
+
// commondir names the shared .git. The one that matters is NESTED inside the
|
|
120
|
+
// main checkout: without the boundary, resolution walked straight past it and
|
|
121
|
+
// the tools answered from the main checkout's graph — wrong paths for this
|
|
122
|
+
// branch's files.
|
|
123
|
+
await mkdir(join(REPO, '.git'), { recursive: true })
|
|
124
|
+
const wt = join(REPO, 'wt')
|
|
125
|
+
const gitDir = join(fixture, 'main.git', 'worktrees', 'wt')
|
|
126
|
+
await mkdir(join(wt, 'src'), { recursive: true })
|
|
127
|
+
await mkdir(gitDir, { recursive: true })
|
|
128
|
+
await writeFile(join(wt, '.git'), `gitdir: ${gitDir}\n`, 'utf8')
|
|
129
|
+
await writeFile(join(gitDir, 'commondir'), '../..\n', 'utf8')
|
|
130
|
+
|
|
131
|
+
const fromWt = await findGraftRoot(join(wt, 'src'), exists)
|
|
132
|
+
check('a nested worktree never answers from the main graph above it', fromWt === undefined, String(fromWt))
|
|
133
|
+
|
|
134
|
+
const mainAgain = await findGraftRoot(REPO, exists)
|
|
135
|
+
check('the main checkout itself still resolves', samePath(mainAgain, REPO), String(mainAgain))
|
|
136
|
+
|
|
137
|
+
const mainStatus = await computeStatus(REPO, io)
|
|
138
|
+
check('a main checkout is not reported as a worktree', mainStatus.worktreeOf === undefined, String(mainStatus.worktreeOf))
|
|
139
|
+
|
|
140
|
+
// Once the worktree has its own index (graft seeds it from main on build),
|
|
141
|
+
// resolution stays inside the worktree and the status names its repo.
|
|
142
|
+
await mkdir(join(wt, 'graft', '.cache'), { recursive: true })
|
|
143
|
+
await writeFile(join(wt, 'graft', 'INDEX.md'), '# graft repo map\n')
|
|
144
|
+
await writeFile(
|
|
145
|
+
join(wt, 'graft', '.cache', 'stats.json'),
|
|
146
|
+
JSON.stringify({ nodeCount: 12, edgeCount: 7, dirty: false, staleCount: 0, syncedAt: '2026-09-11T09:00:00.000Z' }),
|
|
147
|
+
)
|
|
148
|
+
const wtIndexed = await findGraftRoot(join(wt, 'src'), exists)
|
|
149
|
+
check('a worktree with its own index resolves to itself', samePath(wtIndexed, wt), String(wtIndexed))
|
|
150
|
+
|
|
151
|
+
const wtStatus = await computeStatus(wt, io)
|
|
152
|
+
check('status names the main checkout the worktree belongs to', samePath(wtStatus.worktreeOf, fixture), String(wtStatus.worktreeOf))
|
|
153
|
+
check('and still serves the worktree graph', wtStatus.nodeCount === 12, String(wtStatus.nodeCount))
|
|
154
|
+
|
|
116
155
|
console.log('\n--- status from the cache ---')
|
|
117
156
|
const status = await computeStatus(REPO, io)
|
|
118
157
|
check('reports the cache node count', status.nodeCount === 1284, String(status.nodeCount))
|
|
@@ -199,6 +238,53 @@ const cannotSpawn = await startViz('C:/x', 4400, {
|
|
|
199
238
|
})
|
|
200
239
|
check('an unspawnable graft is a reason, not a throw', cannotSpawn.ok === false && /not on PATH/.test(cannotSpawn.reason))
|
|
201
240
|
|
|
241
|
+
/* ========================================================================== *
|
|
242
|
+
* Tool-use pulses: any graft_* call must reach the chip's dot
|
|
243
|
+
* ========================================================================== */
|
|
244
|
+
|
|
245
|
+
console.log('\n--- tool use is recorded against its repo ---')
|
|
246
|
+
const pulse = createToolPulse()
|
|
247
|
+
check('an unused repo has no pulse', pulse.get(REPO) === undefined)
|
|
248
|
+
pulse.note(REPO)
|
|
249
|
+
const first = pulse.get(REPO)
|
|
250
|
+
pulse.note(REPO)
|
|
251
|
+
const second = pulse.get(REPO)
|
|
252
|
+
check('repeated uses count up per root', first.count === 1 && second.count === 2, JSON.stringify(first) + ' vs ' + JSON.stringify(second))
|
|
253
|
+
check('the pulse carries when it last happened', typeof first.at === 'number' && second.at >= first.at)
|
|
254
|
+
check('another repo pulses independently', pulse.get(OTHER) === undefined)
|
|
255
|
+
pulse.note('')
|
|
256
|
+
check('an empty root is ignored', pulse.get('') === undefined)
|
|
257
|
+
|
|
258
|
+
console.log('\n--- every graft tool announces its own use ---')
|
|
259
|
+
const pulsed = []
|
|
260
|
+
const spawnedArgv = []
|
|
261
|
+
const fakeSpawn = (command, argv) => {
|
|
262
|
+
spawnedArgv.push([command, argv])
|
|
263
|
+
return {
|
|
264
|
+
stdout: { on() {} },
|
|
265
|
+
stderr: { on() {} },
|
|
266
|
+
on(event, fn) { if (event === 'close') fn() },
|
|
267
|
+
kill() {},
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const pulseTools = graftTools({
|
|
271
|
+
fallbackCwd: BARE,
|
|
272
|
+
// BARE has no index: the no-graph branch. Everything else resolves to REPO.
|
|
273
|
+
findRoot: async (start) => (start === BARE ? undefined : REPO),
|
|
274
|
+
noteUse: (root) => pulsed.push(root),
|
|
275
|
+
command: { command: 'graft-fake', prefix: [], shell: false },
|
|
276
|
+
spawn: fakeSpawn,
|
|
277
|
+
})
|
|
278
|
+
const ask = pulseTools.find((t) => t.name === 'graft_ask')
|
|
279
|
+
const answered = await ask.execute({ query: 'anything' }, { agent: { session: { header: { cwd: OTHER } } } })
|
|
280
|
+
check('the call still answers', typeof answered.text === 'string' && answered.text !== '', answered.text.slice(0, 60))
|
|
281
|
+
check('rooted at the session repo, not the process cwd', samePath(spawnedArgv[0]?.[1]?.at(-1), REPO), String(spawnedArgv[0]?.[1]?.at(-1)))
|
|
282
|
+
check('one call, one pulse, for the right root', pulsed.length === 1 && samePath(pulsed[0], REPO), JSON.stringify(pulsed))
|
|
283
|
+
|
|
284
|
+
const noGraph = await ask.execute({ query: 'anything' }, { agent: { session: { header: { cwd: BARE } } } })
|
|
285
|
+
check('a workspace with no graph explains itself', /No graft index/.test(noGraph.text))
|
|
286
|
+
check('and does not pulse — there is no green dot to light', pulsed.length === 1, JSON.stringify(pulsed))
|
|
287
|
+
|
|
202
288
|
/* ========================================================================== *
|
|
203
289
|
* The new-tab handle (a source-level guard)
|
|
204
290
|
* ========================================================================== */
|
|
@@ -255,6 +341,138 @@ check('the type and its body register under the same id', clientSource.includes(
|
|
|
255
341
|
check('that dependency is soft too', clientSource.includes('ctx.inject(["sidebarRightTabs", "slots"]') && !/const inject = [[]/.test('') && clientSource.indexOf('const inject = ["slots", "remote"]') !== -1)
|
|
256
342
|
check('the body reads the session from either host', clientSource.includes('props?.sessionId ?? props?.scope?.sessionId'))
|
|
257
343
|
check('the browser tab survives only as the last resort', clientSource.includes('neither sidebar'))
|
|
344
|
+
// The viz panel bar shows which checkout a worktree graph belongs to.
|
|
345
|
+
check('the viz panel names the worktree when the graph serves one', clientSource.includes('"worktree of " + wt') && clientSource.includes('status.worktreeOf'))
|
|
346
|
+
|
|
347
|
+
console.log('\n--- the dot flickers blue while a graft tool runs ---')
|
|
348
|
+
// The pulse rides the status poll, so it must bypass the five-second status
|
|
349
|
+
// cache: a blink that waited out `cacheMs` would land after the call ended.
|
|
350
|
+
const indexSource = await readFile(new URL('../src/index.js', import.meta.url), 'utf8')
|
|
351
|
+
check('the status payload carries the pulse', /toolUse: pulses\.get\(root\)/.test(indexSource))
|
|
352
|
+
check('on the cache-hit path too', (indexSource.match(/toolUse: pulses\.get\(root\)/g) ?? []).length === 2)
|
|
353
|
+
check('the flicker style exists and sits on the dot', clientSource.includes('.gs-chip.gs-live .gs-dot{color:#8b5cf6}'))
|
|
354
|
+
check('the client counts incoming pulses', clientSource.includes('toolUse?.count'))
|
|
355
|
+
check('a fresh pulse blinks, on and off, not solid', /for \(const ms of \[0, 175, 350, 525, 700, 875, 1050, 1225, 1400, 1575, 1750, 1925\]\)/.test(clientSource))
|
|
356
|
+
// Toggles alternate on/off, so an ODD number of marks ends ON — which shipped
|
|
357
|
+
// once as a dot stuck purple after every blink. An even count ends off.
|
|
358
|
+
const marks = clientSource.match(/for \(const ms of \[((?:\d+)(?:, \d+)*)\]\)/)?.[1]?.split(', ').map(Number) ?? []
|
|
359
|
+
check('the blink ends off, back at the resting colour', marks.length > 0 && marks.length % 2 === 0, JSON.stringify(marks))
|
|
360
|
+
check('polling speeds up while graft tools are active', clientSource.includes('settled && !recent ? 4000 : 2000'))
|
|
361
|
+
check('a pulse keeps the fast poll for a tail, not just the blink', clientSource.includes('window.setTimeout(() => setRecent(false), 20000)'))
|
|
362
|
+
check('the flicker timers are cleaned up on unmount', clientSource.includes('for (const t of flickTimers.current) window.clearTimeout(t)'))
|
|
363
|
+
|
|
364
|
+
console.log('\n--- the chip label truncates, the popup builds ---')
|
|
365
|
+
// A long workspace name must collapse to an ellipsis in the trailing-row
|
|
366
|
+
// chip, not stretch the composer. The popup keeps the full path.
|
|
367
|
+
check(
|
|
368
|
+
'the chip label carries its own ellipsis rule',
|
|
369
|
+
clientSource.includes('.gs-chip .gs-label{overflow:hidden;text-overflow:ellipsis'),
|
|
370
|
+
)
|
|
371
|
+
check('and the chip renders the label under that class', clientSource.includes('h("span", { className: "gs-label" }'))
|
|
372
|
+
// The popup's Build button calls a `build` remote method — the same plain
|
|
373
|
+
// offline `graft build` the bare /graft runs — with a busy state while it
|
|
374
|
+
// is in flight, then a status re-read so the card shows the fresh graph.
|
|
375
|
+
check('the client declares a build descriptor', clientSource.includes('descriptor("build", ["sessionId"])'))
|
|
376
|
+
check('the host serves a build method', indexSource.includes('markRemoteMethod(GraftStatusRemote.prototype, \'build\')'))
|
|
377
|
+
// The button rebuilds through the SAME sync-run.js the automatic hooks run —
|
|
378
|
+
// never a bare `graft build`, which writes the graph but NOT stats.json, so
|
|
379
|
+
// the pill kept reading the old `dirty` flag and never flipped to IN SYNC.
|
|
380
|
+
// That shipped once as a press that changed nothing.
|
|
381
|
+
check('the build goes through the shared rebuild engine', /build[\s\S]{0,600}autoSync\.rebuild\(root\)/.test(indexSource))
|
|
382
|
+
check('a finished build drops the stale cache', /build[\s\S]*?cache\.delete\(root\)/.test(indexSource))
|
|
383
|
+
check('the popup has a Build button', clientSource.includes('"Build"'))
|
|
384
|
+
check('it disables while the build runs', clientSource.includes('disabled: building'))
|
|
385
|
+
// The rebuild button sits IN the header row, right of the status pill — not
|
|
386
|
+
// in a footer row of its own.
|
|
387
|
+
check('no footer row survives the move', !clientSource.includes('gs-pop-buildrow'))
|
|
388
|
+
check(
|
|
389
|
+
'one header group, rendered in both heads',
|
|
390
|
+
(clientSource.match(/headGroup\(h\(/g) ?? []).length === 2,
|
|
391
|
+
String((clientSource.match(/headGroup\(h\(/g) ?? []).length),
|
|
392
|
+
)
|
|
393
|
+
// Pill and Build travel as one group pinned right: a wrapper carries the
|
|
394
|
+
// row's only auto margin, so the flex gap is the only thing between the
|
|
395
|
+
// pair and nothing can split them — either apart or across the header.
|
|
396
|
+
check('the header pills travel in one right-pinned group', clientSource.includes('.gs-pop-group{margin-left:auto'))
|
|
397
|
+
check('the pill no longer spaces itself away from the button', !clientSource.includes('.gs-pill{margin-left:auto'))
|
|
398
|
+
// The card itself is click-through (pointer-events:none), which was fine
|
|
399
|
+
// while it was pure text — but any button inside it is dead unless it
|
|
400
|
+
// re-enables events on itself. That shipped once as a button that could
|
|
401
|
+
// never be pressed; this guard is the reason it will not ship again.
|
|
402
|
+
check('the Build button re-enables pointer events on itself', clientSource.includes('.gs-build{') && /\.gs-build\{[^}]*pointer-events:auto/.test(clientSource))
|
|
403
|
+
// The button holds Building… until OBSERVED freshness flips to synced, not
|
|
404
|
+
// until the build promise lands: a build that exits while stats.json still
|
|
405
|
+
// reads dirty (plain `graft build` never patches it) must not re-enable the
|
|
406
|
+
// button against a pill that still says STALE. That shipped once as a press
|
|
407
|
+
// that changed nothing; this guard is the reason it will not ship again.
|
|
408
|
+
check('the button waits for observed sync, not the promise', clientSource.includes('waitForSynced'))
|
|
409
|
+
check('with a bounded wait, not forever', clientSource.includes('SYNC_WAIT_MS'))
|
|
410
|
+
check('the seat hands the waiter to the card', clientSource.includes('onSynced: waitForSynced'))
|
|
411
|
+
check('a build that never reports sync re-enables with an error', clientSource.includes('did not report in sync'))
|
|
412
|
+
|
|
413
|
+
console.log('\n--- Build in a workspace with no index confirms first ---')
|
|
414
|
+
// The prompt is dsh's own themed Modal, not the browser's ugly box: the card
|
|
415
|
+
// requires the primitives package permission-presets already proves a client
|
|
416
|
+
// bundle can require. Guarded — if the package is ever absent the prompt
|
|
417
|
+
// falls back to window.confirm, so the gate can never go missing silently.
|
|
418
|
+
check('the card prefers dsh\'s own Modal', clientSource.includes('Primitives.Modal') && clientSource.includes('dsh-client-ui-primitives'))
|
|
419
|
+
check('the Modal carries the confirm copy', clientSource.includes('Build graft index?'))
|
|
420
|
+
check('its footer holds both accept and decline', clientSource.includes('Build index') && clientSource.includes('"Cancel"'))
|
|
421
|
+
// Guarded require: if primitives is ever unresolvable the gate falls back to
|
|
422
|
+
// the ugly box rather than going missing silently. The fallback is the ONLY
|
|
423
|
+
// window.confirm in the file.
|
|
424
|
+
check('only the fallback uses the browser box', (clientSource.match(/window\.confirm\(/g) ?? []).length === 1)
|
|
425
|
+
// The modal accept must START the build while the modal is still open: it may
|
|
426
|
+
// only close the modal after the build promise is underway. Closing first
|
|
427
|
+
// (unmounting the card's modal subtree in the same tick) shipped once as an
|
|
428
|
+
// accept that dismissed the dialog and built nothing.
|
|
429
|
+
check('the accept starts the build while the modal is open', /startBuild[\s\S]{0,300}setConfirming\(false\)/.test(clientSource))
|
|
430
|
+
check('the modal never closes before the build promise exists', !/setConfirming\(false\)[\s\S]{0,60}Promise\.resolve\(runBuild/.test(clientSource))
|
|
431
|
+
// The confirm modal portals to document.body — OUTSIDE the card's wrap — so
|
|
432
|
+
// the seat's capture-phase click-away closer would unmount the card (modal
|
|
433
|
+
// included) before the accept's own click dispatches. That shipped once as an
|
|
434
|
+
// accept that hid the dialog and built nothing; the closer must let modal
|
|
435
|
+
// clicks through.
|
|
436
|
+
check('the click-away closer lets modal clicks through', /closest\(['"]\[role="dialog"\]/.test(clientSource))
|
|
437
|
+
check('the accept runs the plain offline initialise', /runGraft\(\['build', from\], from/.test(indexSource))
|
|
438
|
+
check('a failed initialise reports graft\'s own message', /produced no index/.test(indexSource))
|
|
439
|
+
check('a fresh initialise resolves as initialising', /initialising: true/.test(indexSource))
|
|
440
|
+
|
|
441
|
+
// The remote descriptors must survive dsh 0.2's typert codec validation, which
|
|
442
|
+
// runs inside `ctx.remote.$mount`: a strict codec needs a typeSymbol and a
|
|
443
|
+
// `create()` factory returning a schema with `parse`. The 0.1 shape (`schema`
|
|
444
|
+
// only) threw "strict codec has no create() factory", rejected apply(), and
|
|
445
|
+
// took the whole plugin down. The client may only use strict codecs for
|
|
446
|
+
// parameters. The descriptor block is lifted out of client.js and evaluated,
|
|
447
|
+
// so this tests the shipped objects, not a copy.
|
|
448
|
+
console.log('\nremote descriptors (dsh 0.2 codec contract)')
|
|
449
|
+
const descriptorBlock = /function identity\(value\) \{[\s\S]*?\r?\n const CONTRIBUTION = \{[\s\S]*?\r?\n \};/.exec(clientSource)?.[0]
|
|
450
|
+
check('descriptor block found in client.js', descriptorBlock !== undefined)
|
|
451
|
+
const CONTRIBUTION = descriptorBlock ? new Function(`${descriptorBlock}; return CONTRIBUTION;`)() : { descriptors: [] }
|
|
452
|
+
function codecProblem(codec, { parameter }) {
|
|
453
|
+
if (!codec || typeof codec !== 'object') return 'missing codec'
|
|
454
|
+
if (codec.mode === 'src-json') return parameter ? 'client parameters must be strict' : undefined
|
|
455
|
+
if (codec.mode !== 'strict') return `unknown mode ${codec.mode}`
|
|
456
|
+
if (typeof codec.typeSymbol !== 'string' || codec.typeSymbol === '') return 'empty typeSymbol'
|
|
457
|
+
if (typeof codec.create !== 'function') return 'strict codec has no create() factory'
|
|
458
|
+
const schema = codec.create()
|
|
459
|
+
if (typeof schema?.parse !== 'function') return 'create() returned no parse()'
|
|
460
|
+
if (codec.create() !== schema) return 'create() is not memoized'
|
|
461
|
+
// dsh 0.1 read `schema` directly; keep it so older hosts still load.
|
|
462
|
+
if (typeof codec.schema?.parse !== 'function') return 'no 0.1 schema fallback'
|
|
463
|
+
return undefined
|
|
464
|
+
}
|
|
465
|
+
check('declares the status, viz and build descriptors', CONTRIBUTION.descriptors.map((d) => d.method).join() === 'status,viz,build')
|
|
466
|
+
for (const d of CONTRIBUTION.descriptors) {
|
|
467
|
+
for (const p of d.parameters) {
|
|
468
|
+
const problem = codecProblem(p.codec, { parameter: true })
|
|
469
|
+
check(`${d.id} parameter ${p.name} codec is valid`, problem === undefined, problem)
|
|
470
|
+
}
|
|
471
|
+
const problem = codecProblem(d.result, { parameter: false })
|
|
472
|
+
check(`${d.id} result codec is valid`, problem === undefined, problem)
|
|
473
|
+
const sample = { ok: true, value: { root: '/r', nodeCount: 1 } }
|
|
474
|
+
check(`${d.id} result decodes as a pass-through`, d.result.create().parse(sample) === sample)
|
|
475
|
+
}
|
|
258
476
|
|
|
259
477
|
await rm(fixture, { recursive: true, force: true })
|
|
260
478
|
|