返回 CodeWhale
harness.mjs
根目录 / crates / tui / extension-host / test / harness.mjs
1 // A minimal fake core: spawns the committed host bundle and speaks CWX1 to it.
2 // Tests import only dist/ — no type stripping, no install.
3 import { spawn } from 'node:child_process'
4 import { createHash } from 'node:crypto'
5 import { readFileSync } from 'node:fs'
6 import { dirname, join } from 'node:path'
7 import { fileURLToPath } from 'node:url'
8 import { FrameDecoder, encodeFrame, validateMessage } from '../dist/protocol.mjs'
9
10 const here = dirname(fileURLToPath(import.meta.url))
11 export const BUNDLE = join(here, '..', 'dist', 'codewhale-extension-host.mjs')
12 export const FIXTURES = join(here, '..', '..', 'tests', 'fixtures', 'extension_host')
13
14 export function sha256File(path) {
15 return createHash('sha256').update(readFileSync(path)).digest('hex')
16 }
17
18 /** The runtime running these tests also runs the host: `node --test` or `bun test`. */
19 export const IS_BUN = typeof process.versions.bun === 'string'
20
21 /**
22 * The runtime flags and environment the Rust core passes
23 * (`supervisor::runtime_args` / `runtime_env`); keep them in sync. Bun ignores
24 * Node's heap and `__proto__` flags, so it gets its own: never auto-install
25 * packages, ignore `bunfig.toml` and `.env` files in the working directory,
26 * and no ShadowRealm. Node switches off `node:sqlite`, and `node:ffi` where
27 * this Node has it. Both get a blank `NODE_OPTIONS`, so an inherited preload
28 * cannot run before the native-code lockdown.
29 */
30 export const HOST_ARGS = IS_BUN
31 ? ['--no-install', '--no-env-file', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '--no-addons']
32 : [
33 '--max-old-space-size=256',
34 '--disable-proto=throw',
35 '--no-addons',
36 ...(process.platform === 'win32' ? ['--preserve-symlinks', '--preserve-symlinks-main'] : []),
37 '--no-experimental-sqlite',
38 ...(process.allowedNodeEnvironmentFlags.has('--no-experimental-ffi') ? ['--no-experimental-ffi'] : []),
39 ]
40 export const HOST_ENV = { NODE_OPTIONS: '', ...(IS_BUN ? { BUN_JSC_useShadowRealm: '0', BUN_OPTIONS: '', BUN_BE_BUN: '0' } : {}) }
41
42 export const LIMITS = { max_frame: 32 * 1024 * 1024, max_inflight: 256, dispose_deadline_ms: 2000, activate_deadline_ms: 5000 }
43
44 /**
45 * Start a host. `admit(spec, owner)` decides `registry/register`: return a
46 * handle number or `{ refused }`. Registry traffic is recorded in `registry`.
47 * `tier` is the trust tier it serves (`--tier=`, after the bundle, as the Rust
48 * core passes it); `null` passes none, as a host started by hand would.
49 * `coreCall(params, id)` answers a `core/call` request: return a tool result, or
50 * `{ error: { code, message } }`; it may be async, and a request the host
51 * cancelled is not answered. Requests are recorded in `coreCalls`, the host's
52 * `$/cancel` ids in `cancels`.
53 */
54 export async function startHost({ admit, env, ownGroup = false, tier = 'plugin', coreCall, compiledHost, cwd } = {}) {
55 const started = performance.now()
56 // `ownGroup` spawns the host as a process-group leader and tells it so, as
57 // the Rust core does on Unix.
58 const child = spawn(compiledHost ?? process.execPath, [...(compiledHost ? [] : [...HOST_ARGS, BUNDLE]), ...(tier === null ? [] : [`--tier=${tier}`])], {
59 stdio: ['pipe', 'pipe', 'pipe'],
60 cwd,
61 env: { ...process.env, ...HOST_ENV, ...env, ...(compiledHost ? { NODE_OPTIONS: '', BUN_OPTIONS: '', BUN_BE_BUN: '0', BUN_JSC_useShadowRealm: '0' } : {}), ...(ownGroup ? { CODEWHALE_HOST_PROCESS_GROUP: '1' } : {}) },
62 detached: ownGroup,
63 })
64 const decoder = new FrameDecoder()
65 const pending = new Map()
66 const waiters = []
67 let stopping = false
68 const host = {
69 child,
70 stderr: '',
71 logs: [],
72 faulted: [],
73 coreCalls: [],
74 cancels: [],
75 registry: [],
76 hello: null,
77 nextId: 1,
78 nextHandle: 1,
79 exit: new Promise((resolve) => child.on('exit', (code, signal) => resolve({ code, signal }))),
80 send(message) {
81 validateMessage(message, 'core_to_host', tier ?? 'plugin')
82 child.stdin.write(encodeFrame(message))
83 },
84 request(method, params) {
85 const id = host.nextId++
86 const promise = new Promise((resolve, reject) => pending.set(id, { resolve, reject }))
87 host.send({ jsonrpc: '2.0', id, method, params })
88 return { id, promise }
89 },
90 call(method, params) {
91 return host.request(method, params).promise
92 },
93 cancel(id) {
94 host.send({ jsonrpc: '2.0', method: '$/cancel', params: { id } })
95 },
96 waitFor(predicate, timeoutMs = 5000) {
97 return new Promise((resolve, reject) => {
98 const timer = setTimeout(() => reject(new Error('timed out waiting for host message')), timeoutMs)
99 waiters.push({ predicate, resolve: (value) => { clearTimeout(timer); resolve(value) } })
100 })
101 },
102 async stop() {
103 if (child.exitCode === null && child.signalCode === null) {
104 // The child can still have a core/call response queued on the fake
105 // core while it handles stdin EOF. Those replies are intentionally
106 // abandoned once shutdown starts.
107 stopping = true
108 child.stdin.end()
109 await host.exit
110 }
111 },
112 }
113 child.stderr.on('data', (chunk) => { host.stderr += chunk.toString() })
114 child.stdout.on('data', (chunk) => {
115 for (const raw of decoder.push(chunk)) {
116 const message = validateMessage(raw, 'host_to_core', tier ?? 'plugin')
117 for (let i = waiters.length - 1; i >= 0; i--) {
118 if (waiters[i].predicate(message)) waiters.splice(i, 1)[0].resolve(message)
119 }
120 if ('method' in message) {
121 switch (message.method) {
122 case 'host/hello':
123 host.hello = message.params
124 break
125 case 'log':
126 host.logs.push(message.params)
127 break
128 case 'ext/faulted':
129 host.faulted.push(message.params)
130 break
131 case 'registry/register': {
132 host.registry.push({ op: 'register', ...message.params })
133 const verdict = admit ? admit(message.params.spec, message.params.owner) : undefined
134 const result = verdict && typeof verdict === 'object' ? verdict : { handle: verdict ?? host.nextHandle++ }
135 if ('handle' in result) host.registry.at(-1).handle = result.handle
136 host.send({ jsonrpc: '2.0', id: message.id, result })
137 break
138 }
139 case '$/cancel':
140 host.cancels.push(message.params.id)
141 break
142 case 'core/call': {
143 host.coreCalls.push({ id: message.id, ...message.params })
144 const answer = coreCall ? coreCall(message.params, message.id) : { error: { code: -32002, message: 'no core/call handler' } }
145 const id = message.id
146 Promise.resolve(answer).then((value) => {
147 if (host.cancels.includes(id) || child.exitCode !== null || stopping) return
148 if (value && typeof value === 'object' && 'error' in value) host.send({ jsonrpc: '2.0', id, error: value.error })
149 else host.send({ jsonrpc: '2.0', id, result: value })
150 })
151 break
152 }
153 case 'registry/unregister':
154 host.registry.push({ op: 'unregister', ...message.params })
155 host.send({ jsonrpc: '2.0', id: message.id, result: {} })
156 break
157 }
158 } else {
159 const waiter = pending.get(message.id)
160 if (waiter) {
161 pending.delete(message.id)
162 if ('error' in message) waiter.reject(Object.assign(new Error(message.error.message), { code: message.error.code }))
163 else waiter.resolve(message.result)
164 }
165 }
166 }
167 })
168 await host.waitFor((m) => m.method === 'host/hello')
169 const ready = host.waitFor((m) => m.method === 'host/ready')
170 await host.call('host/initialize', { protocol: 1, limits: LIMITS })
171 await ready
172 host.readyMs = performance.now() - started
173 return host
174 }
175
176 let token = 0
177 export function owner(pluginId) {
178 token += 1
179 return { plugin_id: pluginId, generation: 1, owner_token: `token-${pluginId}-${token}-${'0'.repeat(24)}` }
180 }
181
182 /** `extra` adds or replaces `ext/activate` params, such as `config` and `data_dir`. */
183 export async function activate(host, fixture, entryPath = join(FIXTURES, fixture, 'index.mjs'), extra = {}) {
184 const ref = owner(fixture)
185 const result = await host.call('ext/activate', {
186 owner: ref,
187 plugin_name: fixture,
188 entry: { path: entryPath, sha256: sha256File(entryPath) },
189 config: {},
190 ...extra,
191 })
192 return { ref, result }
193 }
194
194 lines Plain Text