dsh-ris-ide 0.0.0-stage → 1.6.9
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 -0
- package/README.md +124 -2
- package/bin/run-with-log.ps1 +173 -0
- package/bin/run-with-log.sh +136 -0
- package/cordis.patch.yml +25 -0
- package/lib/client-codemirror.js +29822 -0
- package/lib/client.js +10772 -0
- package/lib/debug/engine.js +537 -0
- package/lib/debug/vendor/LICENSE +21 -0
- package/lib/debug/vendor/README.md +36 -0
- package/lib/debug/vendor/UPSTREAM_COMMIT +1 -0
- package/lib/debug/vendor/jdwp/client.js +1104 -0
- package/lib/debug/vendor/jdwp/constants.js +309 -0
- package/lib/debug/vendor/jdwp/protocol.js +253 -0
- package/lib/debug/vendor/src/jdwp/client.ts +1368 -0
- package/lib/debug/vendor/src/jdwp/constants.ts +310 -0
- package/lib/debug/vendor/src/jdwp/protocol.ts +326 -0
- package/lib/index.js +6112 -0
- package/lib/standalone/vendor/react-dom.js +267 -0
- package/lib/standalone/vendor/react.js +31 -0
- package/lib/standalone/window.html +248 -0
- package/package.json +96 -4
- package/skills/dsh-ris-ide-debug/SKILL.md +158 -0
- package/test/client-viewer.test.mjs +3557 -0
- package/test/debug-engine.test.mjs +351 -0
- package/test/harness.test.mjs +93 -0
- package/test/host-nogit.test.mjs +48 -0
- package/test/host-tail.test.mjs +2846 -0
- package/test/host-window.test.mjs +119 -0
|
@@ -0,0 +1,2846 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-half tests: the tail reader, path containment, the loopback fence, the
|
|
3
|
+
* byte clamps, and the whole route over a real node:http server.
|
|
4
|
+
*
|
|
5
|
+
* These run without a DSH profile: the route handler is a plain function of
|
|
6
|
+
* `(ctx, req, res, ceiling)`, so a fake ctx with a session header and a real
|
|
7
|
+
* listener exercise exactly what the browser will hit.
|
|
8
|
+
*/
|
|
9
|
+
import { test } from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { createServer, request as httpRequest } from 'node:http'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { homedir, tmpdir } from 'node:os'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
import { __test } from '../lib/index.js'
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
resolvePath, isLoopbackRequest, readTail, clampBytes, serveTail, serveStop, handleRequest,
|
|
23
|
+
readPidFile, readService, isAlive, isRunWithLogWrapper, listInstances,
|
|
24
|
+
scanPrograms, buildStartSpec, parseJsonc, readLaunchConfigs, launchFor,
|
|
25
|
+
sanitizeLogName, defaultSpringArgs, quoteShell,
|
|
26
|
+
readProgramConfig, mergeProgramConfig, buildConfigDocument, detectConfigDrift,
|
|
27
|
+
mergeConfigDocuments, discoverPrograms,
|
|
28
|
+
registerAgentTools, resolveProgramRef, takePendingOpens, enqueueOpen,
|
|
29
|
+
profileFromPomText, readProjectProfile, projectRootOf, localYmlFor, normalizeSpringArgs, resolveSpringArgs,
|
|
30
|
+
configMatchesWorkspace, programsFromConfigEntries, decoratePrograms,
|
|
31
|
+
normalizeStringArray, windowsStartSpec, pickGitBash, resolveWindowsRuntime, IS_WINDOWS,
|
|
32
|
+
startProgram, stopLogPath, processGroupLeader, groupMembers, jsonSafe,
|
|
33
|
+
SKILL_NAME, PKG_VERSION, bundledSkillPath, findProjectRoot, skillRootFor, skillFileState, installSkill,
|
|
34
|
+
skillRootsFor, syncInstalledSkills,
|
|
35
|
+
TAIL_PATH, STOP_PATH, INSTANCES_PATH, PROGRAMS_PATH, START_PATH, CONFIG_PATH, OPENS_PATH, DEBUG_PATH,
|
|
36
|
+
DEFAULT_BYTES, HARD_MAX_BYTES, MIN_BYTES, PID_SUFFIX,
|
|
37
|
+
} = __test
|
|
38
|
+
const WRAPPER = fileURLToPath(new URL('../bin/run-with-log.sh', import.meta.url))
|
|
39
|
+
|
|
40
|
+
/** One scratch directory per test process. */
|
|
41
|
+
const scratch = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-'))
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Jenkins/CI 的 `bash -lc`(被测服务的启动命令就是它)会读登录 profile,可能把
|
|
45
|
+
* agent 注入的 PATH 重置掉,导致服务里的 `npm`/`node` 变成 not found(status 127)。
|
|
46
|
+
* 非交互 bash 会在 profile 之后读取 `BASH_ENV`,用它把当前可用的 PATH 再补回去。
|
|
47
|
+
*/
|
|
48
|
+
const ciBashEnv = join(scratch, 'ci-bash-env.sh')
|
|
49
|
+
await writeFile(ciBashEnv, `export PATH="${String(process.env.PATH || '')}"\n`)
|
|
50
|
+
process.env.BASH_ENV = ciBashEnv
|
|
51
|
+
|
|
52
|
+
/** `<n>` numbered lines, LF-terminated. */
|
|
53
|
+
function numberedLines(count, width = 4) {
|
|
54
|
+
let out = ''
|
|
55
|
+
for (let index = 1; index <= count; index += 1) out += `line ${String(index).padStart(width, '0')}\n`
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('readTail returns the whole file when the window is larger than it', async () => {
|
|
60
|
+
const file = join(scratch, 'small.log')
|
|
61
|
+
const content = numberedLines(10)
|
|
62
|
+
await writeFile(file, content)
|
|
63
|
+
const tail = await readTail(file, 64 * 1024)
|
|
64
|
+
assert.equal(tail.mode, 'tail')
|
|
65
|
+
assert.equal(tail.elided, 0)
|
|
66
|
+
assert.equal(tail.content, content)
|
|
67
|
+
assert.equal(tail.size, Buffer.byteLength(content))
|
|
68
|
+
assert.equal(typeof tail.mtimeMs, 'number')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('readTail reads the END of a file that outgrew the window', async () => {
|
|
72
|
+
const file = join(scratch, 'big.log')
|
|
73
|
+
const content = numberedLines(5000)
|
|
74
|
+
await writeFile(file, content)
|
|
75
|
+
const tail = await readTail(file, 4096)
|
|
76
|
+
assert.ok(tail.elided > 0, 'the read must have elided a prefix')
|
|
77
|
+
assert.ok(tail.content.length <= 4096)
|
|
78
|
+
// The window is the file's tail: its last line is the file's last line.
|
|
79
|
+
const fileLines = content.split('\n').filter(line => line !== '')
|
|
80
|
+
const tailLines = tail.content.split('\n').filter(line => line !== '')
|
|
81
|
+
assert.equal(tailLines[tailLines.length - 1], fileLines[fileLines.length - 1])
|
|
82
|
+
assert.equal(tailLines[0], fileLines[fileLines.length - tailLines.length])
|
|
83
|
+
// …and no partial first line survived the cut.
|
|
84
|
+
assert.match(tailLines[0], /^line \d+$/)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('readTail drops the partial first line and repairs a split UTF-8 character', async () => {
|
|
88
|
+
const file = join(scratch, 'utf8.log')
|
|
89
|
+
// 3-byte characters, so a byte window almost always lands mid-character.
|
|
90
|
+
const content = Array.from({ length: 400 }, (_, index) => `中文日志 ${index} ✅`).join('\n') + '\n'
|
|
91
|
+
await writeFile(file, content)
|
|
92
|
+
const tail = await readTail(file, 700)
|
|
93
|
+
assert.ok(tail.elided > 0)
|
|
94
|
+
assert.ok(!tail.content.startsWith('\uFFFD'), 'a split leading character must be dropped, not replaced')
|
|
95
|
+
assert.ok(tail.content.startsWith('中文日志 '), `expected a whole first line, got ${JSON.stringify(tail.content.slice(0, 20))}`)
|
|
96
|
+
assert.ok(content.endsWith(tail.content.slice(-8)))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('readTail serves an unterminated single line as-is (no newline to cut at)', async () => {
|
|
100
|
+
const file = join(scratch, 'one-line.log')
|
|
101
|
+
await writeFile(file, 'x'.repeat(9000))
|
|
102
|
+
const tail = await readTail(file, 2048)
|
|
103
|
+
assert.equal(tail.elided, 9000 - 2048)
|
|
104
|
+
assert.equal(tail.content.length, 2048)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('the live log view shows only the CURRENT run of a reused log file', async () => {
|
|
108
|
+
const { mkdtemp, writeFile } = await import('node:fs/promises')
|
|
109
|
+
const { tmpdir } = await import('node:os')
|
|
110
|
+
const { join } = await import('node:path')
|
|
111
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-run-'))
|
|
112
|
+
const file = join(dir, 'svc.log')
|
|
113
|
+
|
|
114
|
+
// string helper: keep everything from the last start marker on
|
|
115
|
+
const twoRuns = [
|
|
116
|
+
'--- svc started 2026-10-19T10:00:00+08:00 ---',
|
|
117
|
+
'--- command: java ... ---',
|
|
118
|
+
'OLD RUN LINE',
|
|
119
|
+
'--- svc exited 2026-10-19T11:00:00+08:00 (status 143) ---',
|
|
120
|
+
'--- svc started 2026-10-20T20:47:40+08:00 ---',
|
|
121
|
+
'--- command: java ... ---',
|
|
122
|
+
'NEW RUN LINE',
|
|
123
|
+
'',
|
|
124
|
+
].join('\n')
|
|
125
|
+
const trimmed = __test.trimToCurrentRun(twoRuns)
|
|
126
|
+
assert.equal(trimmed.includes('OLD RUN LINE'), false)
|
|
127
|
+
assert.match(trimmed, /^--- svc started 2026-10-20T20:47:40/, 'starts at the newest marker')
|
|
128
|
+
assert.ok(trimmed.includes('NEW RUN LINE'))
|
|
129
|
+
// logs without the wrapper marker (application-owned logs) stay untouched
|
|
130
|
+
assert.equal(__test.trimToCurrentRun('plain app log\n'), 'plain app log\n')
|
|
131
|
+
assert.equal(__test.trimToCurrentRun(''), '')
|
|
132
|
+
// CRLF markers still count
|
|
133
|
+
assert.equal(__test.trimToCurrentRun('a\n--- x started 1 ---\r\nb\n').startsWith('--- x started'), true)
|
|
134
|
+
// byte offset helper
|
|
135
|
+
const buffer = Buffer.from(twoRuns, 'utf8')
|
|
136
|
+
const offset = __test.lastStartMarkerOffset(buffer)
|
|
137
|
+
assert.ok(offset > 0)
|
|
138
|
+
assert.equal(buffer.subarray(offset).toString('utf8').startsWith('--- svc started 2026-10-20'), true)
|
|
139
|
+
|
|
140
|
+
await writeFile(file, twoRuns)
|
|
141
|
+
const live = await __test.readTail(file, 64 * 1024)
|
|
142
|
+
assert.equal(live.content.includes('OLD RUN LINE'), false, 'the default view is this run only')
|
|
143
|
+
assert.ok(live.content.includes('NEW RUN LINE'))
|
|
144
|
+
// paging back through `/tail?before=` must still reach the old run
|
|
145
|
+
const older = await __test.readTailWindow(file, 64 * 1024, live.from)
|
|
146
|
+
assert.ok(older.content.includes('OLD RUN LINE'), 'history stays reachable on demand')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('readTail handles an empty file', async () => {
|
|
150
|
+
const file = join(scratch, 'empty.log')
|
|
151
|
+
await writeFile(file, '')
|
|
152
|
+
const tail = await readTail(file, 1024)
|
|
153
|
+
assert.equal(tail.size, 0)
|
|
154
|
+
assert.equal(tail.elided, 0)
|
|
155
|
+
assert.equal(tail.content, '')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('readTail refuses a missing path and a directory', async () => {
|
|
159
|
+
await assert.rejects(() => readTail(join(scratch, 'nope.log'), 1024), (error) => error.code === 'not-found')
|
|
160
|
+
await assert.rejects(() => readTail(scratch, 1024), (error) => error.code === 'not-a-file')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('resolvePath matches the sidebar read rule: relative joins cwd, absolute stays, no fence by default', async () => {
|
|
164
|
+
const cwd = join(scratch, 'ws')
|
|
165
|
+
await mkdir(join(cwd, 'logs'), { recursive: true })
|
|
166
|
+
assert.equal(resolvePath(cwd, 'app.log'), join(cwd, 'app.log'))
|
|
167
|
+
assert.equal(resolvePath(cwd, 'logs/app.log'), join(cwd, 'logs', 'app.log'))
|
|
168
|
+
assert.equal(resolvePath(cwd, join(cwd, 'logs', 'app.log')), join(cwd, 'logs', 'app.log'))
|
|
169
|
+
// The sidebar's own `ensureWorkspacePath` is plain `resolveTarget` with no
|
|
170
|
+
// containment fence, so an absolute path outside the cwd is readable there —
|
|
171
|
+
// and must be readable here too, or the viewer would silently downgrade to
|
|
172
|
+
// head mode for a log the editor can already preview.
|
|
173
|
+
assert.equal(resolvePath(cwd, '../secrets.log'), resolve(cwd, '../secrets.log'))
|
|
174
|
+
assert.equal(resolvePath(cwd, '/etc/hosts'), '/etc/hosts')
|
|
175
|
+
// `restrictToCwd: true` is the opt-in for deployments that want the fence.
|
|
176
|
+
assert.throws(() => resolvePath(cwd, '../secrets.log', true), (error) => error.code === 'outside-workspace')
|
|
177
|
+
assert.throws(() => resolvePath(cwd, '/etc/hosts', true), (error) => error.code === 'outside-workspace')
|
|
178
|
+
assert.equal(resolvePath(cwd, 'logs/app.log', true), join(cwd, 'logs', 'app.log'))
|
|
179
|
+
assert.equal(resolvePath(cwd, '.', true), cwd)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('isLoopbackRequest admits loopback authorities only', () => {
|
|
183
|
+
const req = (headers) => ({ headers })
|
|
184
|
+
assert.equal(isLoopbackRequest(req({ host: '127.0.0.1:3080' })), true)
|
|
185
|
+
assert.equal(isLoopbackRequest(req({ host: 'localhost:3080' })), true)
|
|
186
|
+
assert.equal(isLoopbackRequest(req({ host: '[::1]:3080' })), true)
|
|
187
|
+
assert.equal(isLoopbackRequest(req({ host: '127.5.6.7' })), true)
|
|
188
|
+
assert.equal(isLoopbackRequest(req({ host: '192.168.1.20:3080' })), false)
|
|
189
|
+
assert.equal(isLoopbackRequest(req({ host: 'evil.example' })), false)
|
|
190
|
+
assert.equal(isLoopbackRequest(req({})), false)
|
|
191
|
+
// An Origin, when present, must name the same authority.
|
|
192
|
+
assert.equal(isLoopbackRequest(req({ host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080' })), true)
|
|
193
|
+
assert.equal(isLoopbackRequest(req({ host: '127.0.0.1:3080', origin: 'http://evil.example' })), false)
|
|
194
|
+
assert.equal(isLoopbackRequest(req({ host: '127.0.0.1:3080', origin: '127.0.0.1:3080' })), false)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('clampBytes clamps both ends and falls back', () => {
|
|
198
|
+
assert.equal(clampBytes('2048', DEFAULT_BYTES, HARD_MAX_BYTES), 2048)
|
|
199
|
+
assert.equal(clampBytes(2048, DEFAULT_BYTES, HARD_MAX_BYTES), 2048)
|
|
200
|
+
assert.equal(clampBytes('0', DEFAULT_BYTES, HARD_MAX_BYTES), MIN_BYTES)
|
|
201
|
+
assert.equal(clampBytes('-5', DEFAULT_BYTES, HARD_MAX_BYTES), MIN_BYTES)
|
|
202
|
+
assert.equal(clampBytes('nonsense', DEFAULT_BYTES, HARD_MAX_BYTES), DEFAULT_BYTES)
|
|
203
|
+
assert.equal(clampBytes(undefined, DEFAULT_BYTES, HARD_MAX_BYTES), DEFAULT_BYTES)
|
|
204
|
+
assert.equal(clampBytes('99999999', DEFAULT_BYTES, HARD_MAX_BYTES), HARD_MAX_BYTES)
|
|
205
|
+
assert.equal(clampBytes('1.9', DEFAULT_BYTES, HARD_MAX_BYTES), MIN_BYTES)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/** Start all routes on a real listener with a fake session store. */
|
|
209
|
+
async function withServer(run, options = { maxBytes: HARD_MAX_BYTES, restrictToCwd: false, stopGraceMs: 5000, minFreeMemoryBytes: 1 }, rootCwd) {
|
|
210
|
+
const cwd = rootCwd ?? join(scratch, `server-ws-${options.restrictToCwd === true ? 'strict' : 'open'}`)
|
|
211
|
+
await mkdir(cwd, { recursive: true })
|
|
212
|
+
const ctx = { sessions: { get: (id) => (id === 's1' ? { header: { cwd } } : undefined) } }
|
|
213
|
+
const server = createServer((req, res) => { handleRequest(ctx, req, res, options) })
|
|
214
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
215
|
+
const port = server.address().port
|
|
216
|
+
const base = `http://127.0.0.1:${port}`
|
|
217
|
+
try {
|
|
218
|
+
return await run({ base, cwd, port })
|
|
219
|
+
} finally {
|
|
220
|
+
await new Promise((resolve) => server.close(resolve))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Poll one predicate until it holds or the deadline passes. */
|
|
225
|
+
async function waitFor(predicate, timeoutMs = 5000) {
|
|
226
|
+
const deadline = Date.now() + timeoutMs
|
|
227
|
+
while (Date.now() < deadline) {
|
|
228
|
+
if (await predicate()) return true
|
|
229
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
230
|
+
}
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Poll until a child process has exited. */
|
|
235
|
+
async function waitForExit(child, timeoutMs = 5000) {
|
|
236
|
+
return waitFor(() => Promise.resolve(child.exitCode !== null || child.signalCode !== null), timeoutMs)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
test('the route end-to-ends a tail through real HTTP', async () => {
|
|
240
|
+
await withServer(async ({ base, cwd }) => {
|
|
241
|
+
const content = numberedLines(2000)
|
|
242
|
+
await writeFile(join(cwd, 'service.log'), content)
|
|
243
|
+
const response = await fetch(`${base}${TAIL_PATH}?sessionId=s1&path=service.log&bytes=2048`)
|
|
244
|
+
assert.equal(response.status, 200)
|
|
245
|
+
const body = await response.json()
|
|
246
|
+
assert.equal(body.ok, true)
|
|
247
|
+
assert.ok(body.elided > 0)
|
|
248
|
+
assert.equal(body.mode, 'tail')
|
|
249
|
+
assert.ok(content.endsWith(body.content))
|
|
250
|
+
assert.equal(body.size, Buffer.byteLength(content))
|
|
251
|
+
// Relative and absolute spellings resolve to the same file.
|
|
252
|
+
const absolute = await fetch(`${base}${TAIL_PATH}?sessionId=s1&path=${encodeURIComponent(join(cwd, 'service.log'))}&bytes=2048`)
|
|
253
|
+
assert.equal((await absolute.json()).content, body.content)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('the route refuses what it must, with machine codes', async () => {
|
|
258
|
+
await withServer(async ({ base, port }) => {
|
|
259
|
+
const get = (query) => fetch(`${base}${TAIL_PATH}?${query}`)
|
|
260
|
+
assert.equal((await get('sessionId=s1')).status, 400) // path missing
|
|
261
|
+
assert.equal((await get('path=x.log')).status, 400) // sessionId missing
|
|
262
|
+
assert.equal((await get('sessionId=nope&path=x.log')).status, 400) // unknown session
|
|
263
|
+
assert.equal((await get('sessionId=s1&path=missing.log')).status, 404) // no such file
|
|
264
|
+
assert.equal((await fetch(`${base}/dsh-ris-ide/other`)).status, 404) // unknown route
|
|
265
|
+
assert.equal((await fetch(`${base}${TAIL_PATH}?sessionId=s1&path=x.log`, { method: 'POST' })).status, 405)
|
|
266
|
+
// Default (parity with the sidebar's own read): no containment fence, so a
|
|
267
|
+
// `..` path is merely "not found", never a policy refusal.
|
|
268
|
+
assert.equal((await get('sessionId=s1&path=..%2Fescape.log')).status, 404)
|
|
269
|
+
assert.equal((await (await get('sessionId=s1&path=..%2Fescape.log')).json()).code, 'not-found')
|
|
270
|
+
// A foreign Host authority is refused before any file work.
|
|
271
|
+
const foreign = await new Promise((resolve, reject) => {
|
|
272
|
+
const call = httpRequest({
|
|
273
|
+
host: '127.0.0.1',
|
|
274
|
+
port,
|
|
275
|
+
path: `${TAIL_PATH}?sessionId=s1&path=x.log`,
|
|
276
|
+
headers: { host: 'evil.example' },
|
|
277
|
+
}, (response) => {
|
|
278
|
+
response.resume()
|
|
279
|
+
response.on('end', () => resolve(response.statusCode))
|
|
280
|
+
})
|
|
281
|
+
call.on('error', reject)
|
|
282
|
+
call.end()
|
|
283
|
+
})
|
|
284
|
+
assert.equal(foreign, 403)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('restrictToCwd: true turns the containment fence on', async () => {
|
|
289
|
+
await withServer(async ({ base, cwd }) => {
|
|
290
|
+
const content = numberedLines(50)
|
|
291
|
+
await writeFile(join(cwd, 'ok.log'), content)
|
|
292
|
+
const inside = await fetch(`${base}${TAIL_PATH}?sessionId=s1&path=ok.log`)
|
|
293
|
+
assert.equal(inside.status, 200)
|
|
294
|
+
const outside = await fetch(`${base}${TAIL_PATH}?sessionId=s1&path=..%2Fescape.log`)
|
|
295
|
+
assert.equal(outside.status, 403)
|
|
296
|
+
assert.equal((await outside.json()).code, 'outside-workspace')
|
|
297
|
+
const absolute = await fetch(`${base}${TAIL_PATH}?sessionId=s1&path=${encodeURIComponent('/etc/hosts')}`)
|
|
298
|
+
assert.equal(absolute.status, 403)
|
|
299
|
+
}, { maxBytes: HARD_MAX_BYTES, restrictToCwd: true })
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
/** One JSON POST to the stop route. */
|
|
303
|
+
function post(base, body) {
|
|
304
|
+
return fetch(`${base}${STOP_PATH}`, {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'content-type': 'application/json' },
|
|
307
|
+
body: JSON.stringify(body),
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
test('readService reads the pidfile and reports liveness', async () => {
|
|
312
|
+
const dir = join(scratch, 'pid-ws')
|
|
313
|
+
await mkdir(dir, { recursive: true })
|
|
314
|
+
const log = join(dir, 'svc.log')
|
|
315
|
+
await writeFile(log, 'x\n')
|
|
316
|
+
assert.equal(await readService(log), undefined, 'no pidfile means no service')
|
|
317
|
+
|
|
318
|
+
const child = spawn('sleep', ['30'], { stdio: 'ignore' })
|
|
319
|
+
try {
|
|
320
|
+
await writeFile(`${log}${PID_SUFFIX}`, `${child.pid} ${process.pid} 1700000000000\n`)
|
|
321
|
+
const service = await readService(log)
|
|
322
|
+
assert.equal(service.pid, child.pid)
|
|
323
|
+
assert.equal(service.wrapperPid, process.pid)
|
|
324
|
+
assert.equal(service.alive, true)
|
|
325
|
+
assert.equal(await isRunWithLogWrapper(process.pid), false, 'the host process is not the wrapper script')
|
|
326
|
+
|
|
327
|
+
// Junk, short, and PID-1 files all read as "no service": a hand-made file
|
|
328
|
+
// can never aim a signal somewhere.
|
|
329
|
+
await writeFile(`${log}${PID_SUFFIX}`, 'not a pidfile\n')
|
|
330
|
+
assert.equal(await readPidFile(log), undefined)
|
|
331
|
+
await writeFile(`${log}${PID_SUFFIX}`, '1 1 1\n')
|
|
332
|
+
assert.equal(await readPidFile(log), undefined)
|
|
333
|
+
} finally {
|
|
334
|
+
child.kill('SIGKILL')
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('the stop route refuses a PID that is not the wrapper script', async () => {
|
|
339
|
+
await withServer(async ({ base, cwd }) => {
|
|
340
|
+
const log = join(cwd, 'fake.log')
|
|
341
|
+
await writeFile(log, 'x\n')
|
|
342
|
+
const child = spawn('sleep', ['30'], { stdio: 'ignore' })
|
|
343
|
+
try {
|
|
344
|
+
await writeFile(`${log}${PID_SUFFIX}`, `${child.pid} ${child.pid} 1\n`)
|
|
345
|
+
const response = await post(base, { sessionId: 's1', path: log })
|
|
346
|
+
assert.equal(response.status, 409)
|
|
347
|
+
assert.equal((await response.json()).code, 'unverifiable')
|
|
348
|
+
assert.equal(isAlive(child.pid), true, 'a refused PID must still be running')
|
|
349
|
+
} finally {
|
|
350
|
+
child.kill('SIGKILL')
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test('the stop route answers no-service without a pidfile and refuses a foreign Host', async () => {
|
|
356
|
+
await withServer(async ({ base, cwd, port }) => {
|
|
357
|
+
const log = join(cwd, 'plain.log')
|
|
358
|
+
await writeFile(log, 'x\n')
|
|
359
|
+
const missing = await post(base, { sessionId: 's1', path: log })
|
|
360
|
+
assert.equal(missing.status, 404)
|
|
361
|
+
assert.equal((await missing.json()).code, 'no-service')
|
|
362
|
+
assert.equal((await post(base, { sessionId: 's1' })).status, 400)
|
|
363
|
+
assert.equal((await fetch(`${base}${TAIL_PATH}`, { method: 'POST' })).status, 405, 'POST to the tail path is the wrong method')
|
|
364
|
+
|
|
365
|
+
const foreign = await new Promise((resolve, reject) => {
|
|
366
|
+
const call = httpRequest({
|
|
367
|
+
host: '127.0.0.1',
|
|
368
|
+
port,
|
|
369
|
+
method: 'POST',
|
|
370
|
+
path: STOP_PATH,
|
|
371
|
+
headers: { host: 'evil.example', 'content-type': 'application/json', 'content-length': 2 },
|
|
372
|
+
}, (response) => {
|
|
373
|
+
response.resume()
|
|
374
|
+
response.on('end', () => resolve(response.statusCode))
|
|
375
|
+
})
|
|
376
|
+
call.on('error', reject)
|
|
377
|
+
call.end('{}')
|
|
378
|
+
})
|
|
379
|
+
assert.equal(foreign, 403)
|
|
380
|
+
})
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('the stop route terminates a real run-with-log.sh service end to end', async () => {
|
|
384
|
+
await withServer(async ({ base, cwd }) => {
|
|
385
|
+
const logDir = join(cwd, 'logs')
|
|
386
|
+
const log = join(logDir, 'stoptest.log')
|
|
387
|
+
const wrapper = spawn(WRAPPER, ['stoptest', 'sleep', '60'], {
|
|
388
|
+
env: { ...process.env, DSH_LOG_DIR: logDir },
|
|
389
|
+
stdio: 'ignore',
|
|
390
|
+
})
|
|
391
|
+
try {
|
|
392
|
+
assert.equal(await waitFor(() => existsSync(`${log}${PID_SUFFIX}`)), true, 'the wrapper writes its pidfile')
|
|
393
|
+
const service = await readService(log)
|
|
394
|
+
assert.equal(service.alive, true)
|
|
395
|
+
assert.equal(await isRunWithLogWrapper(service.wrapperPid), true)
|
|
396
|
+
|
|
397
|
+
const response = await post(base, { sessionId: 's1', path: log })
|
|
398
|
+
assert.equal(response.status, 200)
|
|
399
|
+
const body = await response.json()
|
|
400
|
+
assert.equal(body.ok, true)
|
|
401
|
+
assert.equal(body.stopped, true)
|
|
402
|
+
assert.equal(body.alreadyStopped, false)
|
|
403
|
+
assert.equal(isAlive(body.wrapperPid), false, 'the wrapper must be gone')
|
|
404
|
+
assert.equal(existsSync(`${log}${PID_SUFFIX}`), false, 'the wrapper removes its pidfile')
|
|
405
|
+
assert.equal(await waitForExit(wrapper), true, 'the spawned wrapper process exits')
|
|
406
|
+
// The wrapper writes its exit banner just before it exits; under a loaded
|
|
407
|
+
// parallel test run the read can win the race, so poll briefly.
|
|
408
|
+
let banner = ''
|
|
409
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
410
|
+
banner = await readFile(log, 'utf8')
|
|
411
|
+
if (/stoptest exited/.test(banner)) break
|
|
412
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
413
|
+
}
|
|
414
|
+
assert.match(banner, /stoptest exited/)
|
|
415
|
+
// …and a second stop is a clean "already stopped".
|
|
416
|
+
assert.equal((await post(base, { sessionId: 's1', path: log })).status, 404)
|
|
417
|
+
} finally {
|
|
418
|
+
if (wrapper.exitCode === null && wrapper.signalCode === null) wrapper.kill('SIGKILL')
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('listInstances finds a real run-with-log.sh service and reports it verified', async () => {
|
|
424
|
+
const logDir = join(scratch, 'instances-live')
|
|
425
|
+
const log = join(logDir, 'instlive.log')
|
|
426
|
+
const wrapper = spawn(WRAPPER, ['instlive', 'sleep', '60'], {
|
|
427
|
+
env: { ...process.env, DSH_LOG_DIR: logDir },
|
|
428
|
+
stdio: 'ignore',
|
|
429
|
+
})
|
|
430
|
+
try {
|
|
431
|
+
assert.equal(await waitFor(() => existsSync(`${log}${PID_SUFFIX}`)), true, 'the wrapper writes its pidfile')
|
|
432
|
+
const entries = await listInstances(logDir)
|
|
433
|
+
assert.equal(entries.length, 1, 'exactly the one started instance')
|
|
434
|
+
const only = entries[0]
|
|
435
|
+
assert.equal(only.name, 'instlive', 'the `.log` suffix is stripped from the label')
|
|
436
|
+
assert.equal(only.logPath, log)
|
|
437
|
+
assert.equal(only.verified, true, 'the wrapper PID really is our script')
|
|
438
|
+
assert.equal(only.alive, true)
|
|
439
|
+
assert.equal(typeof only.startedAt, 'number')
|
|
440
|
+
assert.ok(only.size > 0, 'the wrapper already wrote its banner into the log')
|
|
441
|
+
} finally {
|
|
442
|
+
if (wrapper.exitCode === null && wrapper.signalCode === null) wrapper.kill('SIGKILL')
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
test('listInstances ignores junk pidfiles and orders live instances first', async () => {
|
|
447
|
+
const logDir = join(scratch, 'instances-mixed')
|
|
448
|
+
await mkdir(logDir, { recursive: true })
|
|
449
|
+
await writeFile(join(logDir, 'noise.log'), 'not a service\n')
|
|
450
|
+
await writeFile(join(logDir, 'noise.log.pid'), 'junk\n')
|
|
451
|
+
await writeFile(join(logDir, 'orphan.log'), 'log without a pidfile\n')
|
|
452
|
+
assert.deepEqual(await listInstances(logDir), [], 'junk and pidfile-less logs are not instances')
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
test('the instances route lists services and refuses a directory outside the session cwd', async () => {
|
|
456
|
+
await withServer(async ({ base, cwd }) => {
|
|
457
|
+
const logDir = join(cwd, 'logs')
|
|
458
|
+
const log = join(logDir, 'routetest.log')
|
|
459
|
+
const wrapper = spawn(WRAPPER, ['routetest', 'sleep', '60'], {
|
|
460
|
+
env: { ...process.env, DSH_LOG_DIR: logDir },
|
|
461
|
+
stdio: 'ignore',
|
|
462
|
+
})
|
|
463
|
+
try {
|
|
464
|
+
assert.equal(await waitFor(() => existsSync(`${log}${PID_SUFFIX}`)), true, 'the wrapper writes its pidfile')
|
|
465
|
+
const ok = await fetch(`${base}${INSTANCES_PATH}?sessionId=s1`)
|
|
466
|
+
assert.equal(ok.status, 200)
|
|
467
|
+
const body = await ok.json()
|
|
468
|
+
assert.equal(body.ok, true)
|
|
469
|
+
assert.equal(body.dir, logDir, 'the default directory is <cwd>/logs')
|
|
470
|
+
assert.equal(body.entries.length, 1)
|
|
471
|
+
assert.equal(body.entries[0].name, 'routetest')
|
|
472
|
+
assert.equal(body.entries[0].alive, true)
|
|
473
|
+
|
|
474
|
+
// The fence: a directory outside the session cwd is refused, so this route
|
|
475
|
+
// can never be turned into a general-purpose directory lister.
|
|
476
|
+
const outside = await fetch(`${base}${INSTANCES_PATH}?sessionId=s1&dir=/tmp`)
|
|
477
|
+
assert.equal(outside.status, 403)
|
|
478
|
+
// Wrong method / unknown route still answer their own machine codes.
|
|
479
|
+
assert.equal((await fetch(`${base}${INSTANCES_PATH}?sessionId=s1`, { method: 'POST' })).status, 405)
|
|
480
|
+
} finally {
|
|
481
|
+
if (wrapper.exitCode === null && wrapper.signalCode === null) wrapper.kill('SIGKILL')
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
// ── Program discovery + start ───────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
/** A tiny workspace: one Spring module, one frontend, one node_modules decoy. */
|
|
489
|
+
async function makeProgramFixture() {
|
|
490
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-fixture-'))
|
|
491
|
+
const moduleDir = join(root, 'proj-a', 'proj-a-biz')
|
|
492
|
+
await mkdir(join(moduleDir, 'src', 'main', 'java', 'cn', 'x'), { recursive: true })
|
|
493
|
+
await writeFile(join(moduleDir, 'pom.xml'), '<project/>')
|
|
494
|
+
await writeFile(
|
|
495
|
+
join(moduleDir, 'src', 'main', 'java', 'cn', 'x', 'DemoApplication.java'),
|
|
496
|
+
'package cn.x;\n@SpringBootApplication\npublic class DemoApplication { public static void main(String[] args) {} }\n',
|
|
497
|
+
)
|
|
498
|
+
const uiDir = join(root, 'proj-a', 'ui')
|
|
499
|
+
await mkdir(uiDir, { recursive: true })
|
|
500
|
+
await writeFile(join(uiDir, 'package.json'), JSON.stringify({ name: 'demo-ui', scripts: { dev: 'vite' } }))
|
|
501
|
+
await writeFile(join(uiDir, 'pnpm-lock.yaml'), '')
|
|
502
|
+
const decoy = join(root, 'node_modules', 'decoy')
|
|
503
|
+
await mkdir(decoy, { recursive: true })
|
|
504
|
+
await writeFile(join(decoy, 'package.json'), JSON.stringify({ name: 'decoy', scripts: { dev: 'nope' } }))
|
|
505
|
+
await mkdir(join(root, '.vscode'), { recursive: true })
|
|
506
|
+
await writeFile(join(root, '.vscode', 'launch.json'), `{
|
|
507
|
+
// JSONC: comments and trailing commas must be tolerated
|
|
508
|
+
"configurations": [
|
|
509
|
+
{
|
|
510
|
+
"name": "demo",
|
|
511
|
+
"mainClass": "cn.x.DemoApplication",
|
|
512
|
+
"cwd": "\${workspaceFolder}/proj-a/proj-a-biz",
|
|
513
|
+
"args": ["--spring.profiles.active=dev", "--spring.config.additional-location=file:\${workspaceFolder}/local.yml",],
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
}`)
|
|
517
|
+
return { root, moduleDir, uiDir }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** A workspace whose only program is a frontend app that stays alive. */
|
|
521
|
+
async function makeStartableFixture() {
|
|
522
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-start-'))
|
|
523
|
+
const dir = join(root, 'ui')
|
|
524
|
+
await mkdir(dir, { recursive: true })
|
|
525
|
+
await writeFile(join(dir, 'package.json'), JSON.stringify({
|
|
526
|
+
name: 'tick-app',
|
|
527
|
+
scripts: { dev: 'node -e "process.on(\'SIGTERM\',()=>process.exit(0));setInterval(()=>{},1000)"' },
|
|
528
|
+
}))
|
|
529
|
+
return { root }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
test('scanPrograms finds Spring Boot entries and frontend manifests, and skips node_modules', async () => {
|
|
533
|
+
const { root, moduleDir, uiDir } = await makeProgramFixture()
|
|
534
|
+
const logsDir = join(root, 'logs')
|
|
535
|
+
const programs = await scanPrograms(root, logsDir, true)
|
|
536
|
+
const spring = programs.filter((p) => p.kind === 'spring')
|
|
537
|
+
const frontend = programs.filter((p) => p.kind === 'frontend')
|
|
538
|
+
assert.equal(spring.length, 1, 'one Spring Boot entry point')
|
|
539
|
+
assert.equal(frontend.length, 1, 'one frontend manifest')
|
|
540
|
+
assert.equal(programs.some((p) => p.name === 'decoy'), false, 'node_modules is not scanned')
|
|
541
|
+
assert.equal(spring[0].mainClass, 'cn.x.DemoApplication')
|
|
542
|
+
assert.equal(spring[0].dir, moduleDir)
|
|
543
|
+
assert.equal(spring[0].project, 'proj-a')
|
|
544
|
+
assert.equal(spring[0].logName, 'proj-a-proj-a-biz')
|
|
545
|
+
assert.equal(spring[0].logPath, join(logsDir, 'proj-a-proj-a-biz.log'))
|
|
546
|
+
assert.equal(spring[0].hasLog, false)
|
|
547
|
+
assert.equal(spring[0].running, undefined, 'nothing owns its log yet')
|
|
548
|
+
assert.equal(spring[0].launch.mainClass, 'cn.x.DemoApplication', 'launch.json supplies the run configuration')
|
|
549
|
+
assert.deepEqual(spring[0].launch.args, [
|
|
550
|
+
'--spring.profiles.active=dev',
|
|
551
|
+
`--spring.config.additional-location=file:${join(root, 'local.yml')}`,
|
|
552
|
+
])
|
|
553
|
+
assert.equal(frontend[0].dir, uiDir)
|
|
554
|
+
assert.equal(frontend[0].name, 'demo-ui')
|
|
555
|
+
assert.equal(frontend[0].script, 'dev')
|
|
556
|
+
assert.equal(frontend[0].packageManager, 'pnpm', 'inferred from pnpm-lock.yaml')
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
test('parseJsonc tolerates comments and trailing commas', () => {
|
|
560
|
+
assert.deepEqual(parseJsonc('{\n // line\n "a": 1, /* block */\n "b": [1, 2,],\n}'), { a: 1, b: [1, 2] })
|
|
561
|
+
assert.equal(parseJsonc('[1,,]'), null)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
test('readLaunchConfigs/launchFor pick the configuration whose cwd is the module', async () => {
|
|
565
|
+
const { root, moduleDir } = await makeProgramFixture()
|
|
566
|
+
const configs = await readLaunchConfigs(root)
|
|
567
|
+
assert.equal(configs.length, 1)
|
|
568
|
+
assert.equal(configs[0].mainClass, 'cn.x.DemoApplication')
|
|
569
|
+
assert.equal(configs[0].cwd, moduleDir)
|
|
570
|
+
assert.equal(launchFor(configs, moduleDir, root).name, 'demo')
|
|
571
|
+
assert.equal(launchFor(configs, join(root, 'elsewhere'), root), undefined)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
test('buildStartSpec composes the documented Spring Boot command and execs the JVM', () => {
|
|
575
|
+
const workspace = '/ws'
|
|
576
|
+
const logsDir = '/ws/logs'
|
|
577
|
+
const program = { kind: 'spring', dir: '/ws/proj/a-biz', mainClass: 'cn.x.App', launch: null }
|
|
578
|
+
const spec = buildStartSpec(program, { workspace, logsDir })
|
|
579
|
+
assert.equal(spec.cwd, '/ws/proj/a-biz')
|
|
580
|
+
assert.ok(spec.wrapperPath.endsWith('bin/run-with-log.sh'))
|
|
581
|
+
assert.match(spec.command, /mvn -Pdev compile/)
|
|
582
|
+
assert.match(spec.command, /-Dmdep\.includeScope=runtime/)
|
|
583
|
+
assert.match(spec.command, /exec java -DJM\.LOG\.PATH=\/ws\/logs/)
|
|
584
|
+
assert.match(spec.command, /-Dcsp\.sentinel\.log\.dir=\/ws\/logs\/csp/)
|
|
585
|
+
assert.match(spec.command, /-cp "target\/classes:\$\(cat target\/cp\.txt\)" cn\.x\.App --spring\.profiles\.active=dev/)
|
|
586
|
+
|
|
587
|
+
const launched = buildStartSpec(
|
|
588
|
+
Object.assign({}, program, { launch: { mainClass: 'cn.x.Other', args: ['--debug'] } }),
|
|
589
|
+
{ workspace, logsDir },
|
|
590
|
+
)
|
|
591
|
+
assert.match(launched.command, /cn\.x\.Other --spring\.profiles\.active=dev --debug$/)
|
|
592
|
+
|
|
593
|
+
const frontend = buildStartSpec(
|
|
594
|
+
{ kind: 'frontend', dir: '/ws/ui', packageManager: 'pnpm', script: 'dev' },
|
|
595
|
+
{ workspace, logsDir },
|
|
596
|
+
)
|
|
597
|
+
assert.equal(frontend.command, 'cd /ws/ui && exec pnpm run dev')
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
test('quoteShell / sanitizeLogName / defaultSpringArgs behave', async () => {
|
|
601
|
+
assert.equal(quoteShell('plain/path:x'), 'plain/path:x')
|
|
602
|
+
assert.equal(quoteShell('a b'), "'a b'")
|
|
603
|
+
assert.equal(quoteShell("it's"), "'it'\\''s'")
|
|
604
|
+
assert.equal(sanitizeLogName('@ris/ui 3'), 'ris-ui-3')
|
|
605
|
+
assert.equal(sanitizeLogName('///'), 'program')
|
|
606
|
+
// 用一个临时 workspace(而不是某个写死的本机路径):CI/别人的机器上没有那个目录。
|
|
607
|
+
const workspace = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-args-'))
|
|
608
|
+
try {
|
|
609
|
+
await writeFile(join(workspace, 'local.yml'), 'gray.VERSION: ci\n')
|
|
610
|
+
const args = defaultSpringArgs(workspace)
|
|
611
|
+
assert.equal(args[0], '--spring.profiles.active=dev')
|
|
612
|
+
assert.match(args[1] ?? '', /^--spring\.config\.additional-location=file:/)
|
|
613
|
+
} finally {
|
|
614
|
+
await rm(workspace, { recursive: true, force: true })
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
test('the programs route scans over HTTP and the start route refuses bad input', async () => {
|
|
619
|
+
const { root } = await makeProgramFixture()
|
|
620
|
+
await withServer(async ({ base }) => {
|
|
621
|
+
const listing = await fetch(`${base}${PROGRAMS_PATH}?sessionId=s1&refresh=1`)
|
|
622
|
+
assert.equal(listing.status, 200)
|
|
623
|
+
const body = await listing.json()
|
|
624
|
+
assert.equal(body.ok, true)
|
|
625
|
+
assert.equal(body.programs.length, 2)
|
|
626
|
+
assert.equal(body.root, root)
|
|
627
|
+
assert.equal((await fetch(`${base}${PROGRAMS_PATH}`, { method: 'POST' })).status, 405)
|
|
628
|
+
assert.equal((await fetch(`${base}${START_PATH}`)).status, 405)
|
|
629
|
+
|
|
630
|
+
const unknown = await fetch(`${base}${START_PATH}`, {
|
|
631
|
+
method: 'POST',
|
|
632
|
+
headers: { 'content-type': 'application/json' },
|
|
633
|
+
body: JSON.stringify({ sessionId: 's1', id: 'spring:nope#X' }),
|
|
634
|
+
})
|
|
635
|
+
assert.equal(unknown.status, 404)
|
|
636
|
+
assert.equal((await unknown.json()).code, 'unknown-program')
|
|
637
|
+
|
|
638
|
+
const missing = await fetch(`${base}${START_PATH}`, {
|
|
639
|
+
method: 'POST',
|
|
640
|
+
headers: { 'content-type': 'application/json' },
|
|
641
|
+
body: JSON.stringify({ sessionId: 's1' }),
|
|
642
|
+
})
|
|
643
|
+
assert.equal(missing.status, 400)
|
|
644
|
+
}, undefined, root)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
test('the start route launches a program through run-with-log.sh and stop terminates it', async () => {
|
|
648
|
+
const { root } = await makeStartableFixture()
|
|
649
|
+
await withServer(async ({ base }) => {
|
|
650
|
+
const listing = await fetch(`${base}${PROGRAMS_PATH}?sessionId=s1&refresh=1`)
|
|
651
|
+
const programs = (await listing.json()).programs
|
|
652
|
+
const program = programs.find((entry) => entry.kind === 'frontend')
|
|
653
|
+
assert.ok(program !== undefined, 'the fixture frontend program is discovered')
|
|
654
|
+
|
|
655
|
+
const started = await fetch(`${base}${START_PATH}`, {
|
|
656
|
+
method: 'POST',
|
|
657
|
+
headers: { 'content-type': 'application/json' },
|
|
658
|
+
body: JSON.stringify({ sessionId: 's1', id: program.id }),
|
|
659
|
+
})
|
|
660
|
+
const startedBody = await started.json()
|
|
661
|
+
assert.equal(started.status, 200, JSON.stringify(startedBody))
|
|
662
|
+
assert.equal(startedBody.ok, true)
|
|
663
|
+
assert.equal(startedBody.alreadyRunning, false)
|
|
664
|
+
assert.equal(startedBody.program.running.alive, true)
|
|
665
|
+
const logPath = startedBody.program.logPath
|
|
666
|
+
assert.equal(existsSync(logPath), true, 'the wrapper created the log file')
|
|
667
|
+
assert.equal(existsSync(`${logPath}${PID_SUFFIX}`), true, 'the wrapper wrote its pidfile')
|
|
668
|
+
|
|
669
|
+
const again = await (await fetch(`${base}${START_PATH}`, {
|
|
670
|
+
method: 'POST',
|
|
671
|
+
headers: { 'content-type': 'application/json' },
|
|
672
|
+
body: JSON.stringify({ sessionId: 's1', id: program.id }),
|
|
673
|
+
})).json()
|
|
674
|
+
assert.equal(again.alreadyRunning, true, 'a second start is a no-op')
|
|
675
|
+
|
|
676
|
+
const stopped = await fetch(`${base}${STOP_PATH}`, {
|
|
677
|
+
method: 'POST',
|
|
678
|
+
headers: { 'content-type': 'application/json' },
|
|
679
|
+
body: JSON.stringify({ sessionId: 's1', path: logPath }),
|
|
680
|
+
})
|
|
681
|
+
const stoppedBody = await stopped.json()
|
|
682
|
+
assert.equal(stopped.status, 200, JSON.stringify(stoppedBody))
|
|
683
|
+
assert.equal(stoppedBody.stopped, true)
|
|
684
|
+
assert.equal(existsSync(`${logPath}${PID_SUFFIX}`), false, 'the pidfile is gone after the stop')
|
|
685
|
+
}, undefined, root)
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
// ── User config: generate / drift / update ─────────────────────────────────
|
|
690
|
+
|
|
691
|
+
/** Write one `.dsh-ris-ide.json` fixture. */
|
|
692
|
+
async function writeConfig(root, doc) {
|
|
693
|
+
await writeFile(join(root, '.dsh-ris-ide.json'), JSON.stringify(doc, null, 2))
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
test('readProgramConfig tolerates JSONC and ignores invalid rows', async () => {
|
|
697
|
+
const { root } = await makeProgramFixture()
|
|
698
|
+
const empty = await readProgramConfig(root)
|
|
699
|
+
assert.equal(empty.exists, false)
|
|
700
|
+
assert.deepEqual(empty.entries, [])
|
|
701
|
+
|
|
702
|
+
await writeFile(join(root, '.dsh-ris-ide.json'), `{
|
|
703
|
+
// a comment
|
|
704
|
+
"programs": [
|
|
705
|
+
{ "name": "ok", "dir": "proj-a/ui", "startCommand": "true", },
|
|
706
|
+
{ "dir": "no-name" },
|
|
707
|
+
"junk",
|
|
708
|
+
],
|
|
709
|
+
}`)
|
|
710
|
+
const cfg = await readProgramConfig(root)
|
|
711
|
+
assert.equal(cfg.exists, true)
|
|
712
|
+
assert.equal(cfg.entries.length, 1)
|
|
713
|
+
assert.equal(cfg.entries[0].name, 'ok')
|
|
714
|
+
|
|
715
|
+
await writeFile(join(root, '.dsh-ris-ide.json'), '{ nope')
|
|
716
|
+
const broken = await readProgramConfig(root)
|
|
717
|
+
assert.equal(broken.exists, true)
|
|
718
|
+
assert.match(broken.error, /JSON/)
|
|
719
|
+
assert.deepEqual(broken.entries, [])
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
test('mergeProgramConfig applies user fields, adds manual programs and honors disabled', async () => {
|
|
723
|
+
const { root } = await makeProgramFixture()
|
|
724
|
+
const logsDir = join(root, 'logs')
|
|
725
|
+
const scanned = await scanPrograms(root, logsDir, true)
|
|
726
|
+
const frontend = scanned.find((p) => p.kind === 'frontend')
|
|
727
|
+
await writeConfig(root, {
|
|
728
|
+
version: 1,
|
|
729
|
+
programs: [
|
|
730
|
+
{ id: frontend.id, name: 'renamed', logName: 'custom-log', env: { FOO: 'bar' }, args: ['--x'], stopGraceMs: 3000 },
|
|
731
|
+
{ name: 'custom-service', dir: 'proj-a/ui', startCommand: 'exec sleep 3600', logName: 'custom-service' },
|
|
732
|
+
{ name: 'gone', dir: 'nowhere', startCommand: 'true', disabled: true },
|
|
733
|
+
],
|
|
734
|
+
})
|
|
735
|
+
const merged = await scanPrograms(root, logsDir, true)
|
|
736
|
+
const renamed = merged.find((p) => p.id === frontend.id)
|
|
737
|
+
assert.equal(renamed.name, 'renamed')
|
|
738
|
+
assert.equal(renamed.logName, 'custom-log')
|
|
739
|
+
assert.deepEqual(renamed.env, { FOO: 'bar' })
|
|
740
|
+
assert.deepEqual(renamed.args, ['--x'])
|
|
741
|
+
assert.equal(renamed.stopGraceMs, 3000)
|
|
742
|
+
assert.equal(renamed.overridden, true)
|
|
743
|
+
const manual = merged.find((p) => p.name === 'custom-service')
|
|
744
|
+
assert.equal(manual.kind, 'manual')
|
|
745
|
+
assert.equal(manual.startCommand, 'exec sleep 3600')
|
|
746
|
+
assert.equal(manual.fromConfig, true)
|
|
747
|
+
assert.equal(merged.some((p) => p.name === 'gone'), false, 'disabled entries are hidden')
|
|
748
|
+
assert.equal(merged.some((p) => p.kind === 'spring' && p.overridden !== true), true, 'the other program is untouched')
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
test('detectConfigDrift reports added/missing/changed and never blames manual entries', async () => {
|
|
752
|
+
const { root } = await makeProgramFixture()
|
|
753
|
+
const logsDir = join(root, 'logs')
|
|
754
|
+
const plain = await scanPrograms(root, logsDir, true)
|
|
755
|
+
const doc = buildConfigDocument(plain)
|
|
756
|
+
doc.programs.push({ name: 'manual-x', dir: 'proj-a/ui', startCommand: 'exec sleep 1', logName: 'manual-x' })
|
|
757
|
+
doc.programs.push({ id: 'frontend:gone#dev', name: 'gone-app', kind: 'frontend', dir: 'gone', script: 'dev' })
|
|
758
|
+
await writeConfig(root, doc)
|
|
759
|
+
|
|
760
|
+
const ui2 = join(root, 'proj-a', 'ui2')
|
|
761
|
+
await mkdir(ui2, { recursive: true })
|
|
762
|
+
await writeFile(join(ui2, 'package.json'), JSON.stringify({ name: 'ui-2', scripts: { dev: 'vite' } }))
|
|
763
|
+
await scanPrograms(root, logsDir, true) // forceRefresh also refreshes the discovery cache
|
|
764
|
+
const discovered = await discoverPrograms(root)
|
|
765
|
+
const drift = detectConfigDrift(discovered, doc.programs, root)
|
|
766
|
+
assert.equal(drift.stale, true)
|
|
767
|
+
assert.deepEqual(drift.added, ['ui-2'])
|
|
768
|
+
assert.deepEqual(drift.missing, ['gone-app'])
|
|
769
|
+
assert.deepEqual(drift.manual, ['manual-x'])
|
|
770
|
+
assert.deepEqual(drift.changed, [])
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
test('mergeConfigDocuments keeps user edits and additions while adding new programs', async () => {
|
|
774
|
+
const { root } = await makeProgramFixture()
|
|
775
|
+
const logsDir = join(root, 'logs')
|
|
776
|
+
const plain = await scanPrograms(root, logsDir, true)
|
|
777
|
+
const doc = buildConfigDocument(plain)
|
|
778
|
+
const frontend = doc.programs.find((entry) => entry.kind === 'frontend')
|
|
779
|
+
frontend.name = 'renamed-ui'
|
|
780
|
+
frontend.env = { FOO: 'bar' }
|
|
781
|
+
frontend.stopGraceMs = 2500
|
|
782
|
+
doc.programs.push({ name: 'manual-y', dir: 'proj-a/ui', startCommand: 'exec sleep 1', logName: 'manual-y' })
|
|
783
|
+
doc.programs.push({ id: 'frontend:gone#dev', name: 'gone-app', kind: 'frontend', dir: 'gone', script: 'dev' })
|
|
784
|
+
const next = buildConfigDocument(await scanPrograms(root, logsDir, true))
|
|
785
|
+
const updated = mergeConfigDocuments(next, doc.programs, root)
|
|
786
|
+
const kept = updated.programs.find((entry) => entry.kind === 'frontend')
|
|
787
|
+
assert.equal(kept.name, 'renamed-ui')
|
|
788
|
+
assert.deepEqual(kept.env, { FOO: 'bar' })
|
|
789
|
+
assert.equal(kept.stopGraceMs, 2500)
|
|
790
|
+
assert.equal(updated.programs.some((entry) => entry.name === 'manual-y'), true, 'manual additions survive')
|
|
791
|
+
assert.equal(updated.programs.some((entry) => entry.name === 'gone-app'), false, 'stale auto entries are dropped')
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
test('the config route generates, refuses to clobber and updates in place', async () => {
|
|
795
|
+
const { root } = await makeProgramFixture()
|
|
796
|
+
await withServer(async ({ base }) => {
|
|
797
|
+
const post = (body) => fetch(`${base}${CONFIG_PATH}`, {
|
|
798
|
+
method: 'POST',
|
|
799
|
+
headers: { 'content-type': 'application/json' },
|
|
800
|
+
body: JSON.stringify(Object.assign({ sessionId: 's1' }, body)),
|
|
801
|
+
})
|
|
802
|
+
const first = await post({})
|
|
803
|
+
assert.equal(first.status, 200)
|
|
804
|
+
const firstBody = await first.json()
|
|
805
|
+
assert.equal(firstBody.ok, true)
|
|
806
|
+
assert.equal(firstBody.count, 2)
|
|
807
|
+
const configPath = join(root, '.dsh-ris-ide.json')
|
|
808
|
+
assert.equal(existsSync(configPath), true)
|
|
809
|
+
const text = await readFile(configPath, 'utf8')
|
|
810
|
+
assert.match(text, /dsh-ris-ide 程序配置/, 'the generated file carries a header comment')
|
|
811
|
+
|
|
812
|
+
assert.equal((await post({})).status, 409, 'an existing file is not clobbered by a bare generate')
|
|
813
|
+
|
|
814
|
+
const doc = parseJsonc(text)
|
|
815
|
+
const frontend = doc.programs.find((entry) => entry.kind === 'frontend')
|
|
816
|
+
frontend.name = 'renamed-ui'
|
|
817
|
+
frontend.env = { FOO: 'bar' }
|
|
818
|
+
doc.programs.push({ name: 'manual-y', dir: 'proj-a/ui', startCommand: 'exec sleep 1', logName: 'manual-y' })
|
|
819
|
+
await writeFile(configPath, JSON.stringify(doc, null, 2))
|
|
820
|
+
|
|
821
|
+
const update = await post({ mode: 'update' })
|
|
822
|
+
assert.equal(update.status, 200)
|
|
823
|
+
assert.equal((await update.json()).mode, 'update')
|
|
824
|
+
const updated = parseJsonc(await readFile(configPath, 'utf8'))
|
|
825
|
+
const kept = updated.programs.find((entry) => entry.kind === 'frontend')
|
|
826
|
+
assert.equal(kept.name, 'renamed-ui', 'user edit survives an update')
|
|
827
|
+
assert.deepEqual(kept.env, { FOO: 'bar' })
|
|
828
|
+
assert.equal(updated.programs.some((entry) => entry.name === 'manual-y'), true, 'manual addition survives')
|
|
829
|
+
|
|
830
|
+
assert.equal((await post({ mode: 'overwrite' })).status, 200)
|
|
831
|
+
const reset = parseJsonc(await readFile(configPath, 'utf8'))
|
|
832
|
+
assert.equal(reset.programs.some((entry) => entry.name === 'manual-y'), false, 'overwrite drops user additions')
|
|
833
|
+
}, undefined, root)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
test('the programs route reports config drift, and start honors startCommand/logName/grace', async () => {
|
|
837
|
+
const { root } = await makeStartableFixture()
|
|
838
|
+
await writeFile(join(root, '.dsh-ris-ide.json'), JSON.stringify({
|
|
839
|
+
version: 1,
|
|
840
|
+
programs: [{
|
|
841
|
+
name: 'tick-app',
|
|
842
|
+
dir: 'ui',
|
|
843
|
+
startCommand: 'exec node -e "process.on(\'SIGTERM\',()=>process.exit(0));setInterval(()=>{},1000)"',
|
|
844
|
+
logName: 'custom-tick',
|
|
845
|
+
stopGraceMs: 2000,
|
|
846
|
+
}],
|
|
847
|
+
}, null, 2))
|
|
848
|
+
await withServer(async ({ base }) => {
|
|
849
|
+
// a new program appears in the workspace → the config is reported stale
|
|
850
|
+
const extra = join(root, 'ui2')
|
|
851
|
+
await mkdir(extra, { recursive: true })
|
|
852
|
+
await writeFile(join(extra, 'package.json'), JSON.stringify({ name: 'ui-2', scripts: { dev: 'vite' } }))
|
|
853
|
+
const listing = await (await fetch(`${base}${PROGRAMS_PATH}?sessionId=s1&refresh=1`)).json()
|
|
854
|
+
assert.equal(listing.config.exists, true)
|
|
855
|
+
assert.equal(listing.config.status, 'updated', 'a forced rescan reconciles the config')
|
|
856
|
+
assert.equal(listing.config.drift.reconciled, true)
|
|
857
|
+
assert.equal(listing.programs.some((entry) => entry.name === 'ui-2'), true, 'the new program is listed')
|
|
858
|
+
assert.equal((await readFile(join(root, '.dsh-ris-ide.json'), 'utf8')).includes('ui-2'), true, 'and written into the config')
|
|
859
|
+
|
|
860
|
+
const program = listing.programs.find((entry) => entry.name === 'tick-app')
|
|
861
|
+
assert.equal(program.logName, 'custom-tick', 'the config logName wins')
|
|
862
|
+
const started = await (await fetch(`${base}${START_PATH}`, {
|
|
863
|
+
method: 'POST',
|
|
864
|
+
headers: { 'content-type': 'application/json' },
|
|
865
|
+
body: JSON.stringify({ sessionId: 's1', id: program.id }),
|
|
866
|
+
})).json()
|
|
867
|
+
assert.equal(started.ok, true, JSON.stringify(started))
|
|
868
|
+
assert.equal(started.program.logPath, join(root, 'logs', 'custom-tick.log'))
|
|
869
|
+
const record = await readPidFile(started.program.logPath)
|
|
870
|
+
assert.equal(record.graceMs, 2000, 'the per-program stop grace is recorded in the pidfile')
|
|
871
|
+
|
|
872
|
+
const stopped = await (await fetch(`${base}${STOP_PATH}`, {
|
|
873
|
+
method: 'POST',
|
|
874
|
+
headers: { 'content-type': 'application/json' },
|
|
875
|
+
body: JSON.stringify({ sessionId: 's1', path: started.program.logPath }),
|
|
876
|
+
})).json()
|
|
877
|
+
assert.equal(stopped.stopped, true)
|
|
878
|
+
}, undefined, root)
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
// ── Model-facing tools (ris_dev_*) ──────────────────────────────────────────
|
|
883
|
+
|
|
884
|
+
/** A host ctx with one session rooted at `root` and a capturing tool registry. */
|
|
885
|
+
function toolCtx(root) {
|
|
886
|
+
const tools = []
|
|
887
|
+
const ctx = {
|
|
888
|
+
sessions: { get: (id) => (id === 's1' ? { header: { cwd: root } } : undefined) },
|
|
889
|
+
tools: { register: (definition) => { tools.push(definition); return () => {} } },
|
|
890
|
+
effect: (fn) => { fn(); return () => {} },
|
|
891
|
+
logger: { warn: () => {} },
|
|
892
|
+
}
|
|
893
|
+
return { ctx, tools }
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
test('registerAgentTools exposes ris_dev_programs/start/stop/logs and degrades cleanly', () => {
|
|
897
|
+
const { ctx, tools } = toolCtx('/tmp/x')
|
|
898
|
+
const dispose = registerAgentTools(ctx, {})
|
|
899
|
+
assert.deepEqual(tools.map((tool) => tool.name), [
|
|
900
|
+
'ris_dev_programs', 'ris_dev_start', 'ris_dev_stop', 'ris_dev_logs', 'ris_dev_status', 'ris_dev_restart', 'ris_dev_debug',
|
|
901
|
+
'ris_dev_skill',
|
|
902
|
+
])
|
|
903
|
+
for (const tool of tools) {
|
|
904
|
+
assert.equal(typeof tool.description, 'string')
|
|
905
|
+
assert.equal(tool.parameters.type, 'object')
|
|
906
|
+
assert.equal(typeof tool.execute, 'function')
|
|
907
|
+
assert.equal(typeof tool.output.render, 'function')
|
|
908
|
+
assert.ok(Array.isArray(tool.output.render({}, { ok: true })))
|
|
909
|
+
}
|
|
910
|
+
dispose()
|
|
911
|
+
assert.doesNotThrow(() => registerAgentTools(Object.assign({}, ctx, { tools: undefined }), {}), 'no ctx.tools must not throw')
|
|
912
|
+
const off = toolCtx('/tmp/x')
|
|
913
|
+
registerAgentTools(off.ctx, { agentTools: false })
|
|
914
|
+
assert.equal(off.tools.length, 0, 'agentTools: false registers nothing')
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
test('resolveProgramRef matches by id, exact name and a unique fragment', () => {
|
|
918
|
+
const programs = [
|
|
919
|
+
{ id: 'spring:a#A', name: 'a-biz' },
|
|
920
|
+
{ id: 'spring:b#B', name: 'b-biz' },
|
|
921
|
+
]
|
|
922
|
+
assert.equal(resolveProgramRef(programs, { id: 'spring:a#A' }).name, 'a-biz')
|
|
923
|
+
assert.equal(resolveProgramRef(programs, { name: 'b-biz' }).name, 'b-biz')
|
|
924
|
+
assert.equal(resolveProgramRef(programs, { name: 'a-' }).name, 'a-biz')
|
|
925
|
+
assert.equal(resolveProgramRef(programs, { name: 'nope' }), undefined)
|
|
926
|
+
assert.equal(resolveProgramRef(programs, {}), undefined)
|
|
927
|
+
assert.throws(() => resolveProgramRef(programs, { name: '-biz' }), /matches/)
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
test('the tools list, start, read and stop a program through the shared core', async () => {
|
|
931
|
+
const { root } = await makeStartableFixture()
|
|
932
|
+
const { ctx, tools } = toolCtx(root)
|
|
933
|
+
const dispose = registerAgentTools(ctx, {})
|
|
934
|
+
try {
|
|
935
|
+
const exec = { agent: { session: { id: 's1' } }, signal: { throwIfAborted() {} } }
|
|
936
|
+
const tool = (name) => tools.find((definition) => definition.name === name)
|
|
937
|
+
|
|
938
|
+
const listed = await tool('ris_dev_programs').execute({}, exec)
|
|
939
|
+
assert.equal(listed.ok, true)
|
|
940
|
+
assert.equal(listed.programs.length, 1)
|
|
941
|
+
assert.equal(listed.programs[0].running, false)
|
|
942
|
+
|
|
943
|
+
const started = await tool('ris_dev_start').execute({ id: listed.programs[0].id }, exec)
|
|
944
|
+
assert.equal(started.ok, true)
|
|
945
|
+
assert.equal(started.alreadyRunning, false)
|
|
946
|
+
assert.equal(started.program.running, true)
|
|
947
|
+
|
|
948
|
+
const again = await tool('ris_dev_start').execute({ name: 'tick-app' }, exec)
|
|
949
|
+
assert.equal(again.alreadyRunning, true)
|
|
950
|
+
|
|
951
|
+
const logs = await tool('ris_dev_logs').execute({ name: 'tick-app', lines: 5 }, exec)
|
|
952
|
+
assert.equal(logs.ok, true)
|
|
953
|
+
assert.equal(logs.running, true)
|
|
954
|
+
assert.equal(logs.opened, true)
|
|
955
|
+
assert.equal(typeof logs.content, 'string')
|
|
956
|
+
const opens = takePendingOpens()
|
|
957
|
+
assert.equal(opens.length, 1)
|
|
958
|
+
assert.equal(opens[0].sessionId, 's1')
|
|
959
|
+
assert.equal(opens[0].logPath, logs.path)
|
|
960
|
+
|
|
961
|
+
const stopped = await tool('ris_dev_stop').execute({ name: 'tick-app' }, exec)
|
|
962
|
+
assert.equal(stopped.ok, true)
|
|
963
|
+
assert.equal(stopped.stopped, true)
|
|
964
|
+
await assert.rejects(() => tool('ris_dev_stop').execute({ name: 'tick-app' }, exec), /no \.pid record/)
|
|
965
|
+
} finally {
|
|
966
|
+
dispose()
|
|
967
|
+
}
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
test('the opens route drains the queue exactly once', async () => {
|
|
971
|
+
enqueueOpen({ sessionId: 's1', logPath: '/w/logs/a.log', title: 'a' })
|
|
972
|
+
enqueueOpen({ sessionId: 's2', logPath: '/w/logs/b.log', title: 'b' })
|
|
973
|
+
const { ctx } = toolCtx('/w')
|
|
974
|
+
const server = createServer((req, res) => {
|
|
975
|
+
handleRequest(ctx, req, res, { maxBytes: HARD_MAX_BYTES, restrictToCwd: false, stopGraceMs: 5000 })
|
|
976
|
+
})
|
|
977
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
978
|
+
const base = `http://127.0.0.1:${server.address().port}`
|
|
979
|
+
try {
|
|
980
|
+
const first = await (await fetch(`${base}${OPENS_PATH}`)).json()
|
|
981
|
+
assert.equal(first.ok, true)
|
|
982
|
+
assert.equal(first.opens.length, 2)
|
|
983
|
+
const second = await (await fetch(`${base}${OPENS_PATH}`)).json()
|
|
984
|
+
assert.equal(second.opens.length, 0, 'the queue is consume-on-read')
|
|
985
|
+
assert.equal((await fetch(`${base}${OPENS_PATH}`, { method: 'POST' })).status, 405)
|
|
986
|
+
} finally {
|
|
987
|
+
server.closeAllConnections?.()
|
|
988
|
+
await new Promise((resolve) => server.close(resolve))
|
|
989
|
+
}
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
// ── Regressions: stop must free the port; tool output must be lossless JSON ─
|
|
994
|
+
|
|
995
|
+
test('stopping a wrapped command kills its whole process group (pnpm-style grandchildren)', async () => {
|
|
996
|
+
// The reported bug: `pnpm run dev` does not forward SIGTERM, so stopping only
|
|
997
|
+
// the runner left vite holding its port. The wrapper is spawned detached
|
|
998
|
+
// (own group) exactly like the host does it.
|
|
999
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-tree-'))
|
|
1000
|
+
const log = join(dir, 'tree.log')
|
|
1001
|
+
const grandchildFile = join(dir, 'grandchild.pid')
|
|
1002
|
+
const wrapper = spawn(
|
|
1003
|
+
WRAPPER,
|
|
1004
|
+
['tree', 'bash', '-c', 'sleep 300 & echo $! > "$0"; wait', grandchildFile],
|
|
1005
|
+
{ env: { ...process.env, DSH_LOG_DIR: dir }, stdio: 'ignore', detached: true },
|
|
1006
|
+
)
|
|
1007
|
+
wrapper.unref()
|
|
1008
|
+
try {
|
|
1009
|
+
assert.equal(await waitFor(() => existsSync(`${log}${PID_SUFFIX}`)), true, 'the wrapper wrote its pidfile')
|
|
1010
|
+
assert.equal(await waitFor(() => existsSync(grandchildFile)), true, 'the grandchild started')
|
|
1011
|
+
const grandchild = Number((await readFile(grandchildFile, 'utf8')).trim())
|
|
1012
|
+
assert.equal(isAlive(grandchild), true, 'the grandchild (the port holder) is alive')
|
|
1013
|
+
assert.equal(typeof (await processGroupLeader(wrapper.pid)), 'number', 'the detached wrapper leads a group')
|
|
1014
|
+
|
|
1015
|
+
const outcome = await stopLogPath(log, 5000)
|
|
1016
|
+
assert.equal(outcome.stopped, true)
|
|
1017
|
+
await waitFor(() => isAlive(grandchild) === false, 4000)
|
|
1018
|
+
assert.equal(isAlive(grandchild), false, 'the grandchild is gone, so its port is free')
|
|
1019
|
+
assert.equal(existsSync(`${log}${PID_SUFFIX}`), false, 'the pidfile is cleaned up')
|
|
1020
|
+
} finally {
|
|
1021
|
+
try { process.kill(-wrapper.pid, 'SIGKILL') } catch { /* already gone */ }
|
|
1022
|
+
}
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
test('a start whose command dies immediately fails loudly, with the log tail', async () => {
|
|
1026
|
+
const { root } = await makeStartableFixture()
|
|
1027
|
+
await writeFile(join(root, '.dsh-ris-ide.json'), JSON.stringify({
|
|
1028
|
+
version: 1,
|
|
1029
|
+
programs: [{
|
|
1030
|
+
name: 'tick-app',
|
|
1031
|
+
dir: 'ui',
|
|
1032
|
+
startCommand: 'exec sh -c "echo boom: port 8080 already bound; exit 3"',
|
|
1033
|
+
logName: 'dies-fast',
|
|
1034
|
+
}],
|
|
1035
|
+
}, null, 2))
|
|
1036
|
+
const { ctx } = toolCtx(root)
|
|
1037
|
+
const disposers = []
|
|
1038
|
+
ctx.tools = { register: (definition) => { disposers.push(definition); return () => {} } }
|
|
1039
|
+
registerAgentTools(ctx, {})
|
|
1040
|
+
const start = disposers.find((definition) => definition.name === 'ris_dev_start')
|
|
1041
|
+
const exec = { agent: { session: { id: 's1' } }, signal: { throwIfAborted() {} } }
|
|
1042
|
+
await assert.rejects(
|
|
1043
|
+
() => start.execute({ name: 'tick-app' }, exec),
|
|
1044
|
+
(error) => {
|
|
1045
|
+
assert.match(error.message, /start-failed|退出了/)
|
|
1046
|
+
assert.match(error.message, /boom: port 8080 already bound/, 'the log tail explains why')
|
|
1047
|
+
return true
|
|
1048
|
+
},
|
|
1049
|
+
)
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
test('every ris_dev_* result is lossless JSON (no undefined values)', async () => {
|
|
1053
|
+
const assertJsonSafe = (value, path = '$') => {
|
|
1054
|
+
if (value === undefined) assert.fail(`undefined at ${path}`)
|
|
1055
|
+
if (Array.isArray(value)) value.forEach((item, index) => assertJsonSafe(item, `${path}[${index}]`))
|
|
1056
|
+
else if (value !== null && typeof value === 'object') {
|
|
1057
|
+
for (const [key, item] of Object.entries(value)) assertJsonSafe(item, `${path}.${key}`)
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const { root } = await makeStartableFixture()
|
|
1061
|
+
const { ctx, tools } = toolCtx(root)
|
|
1062
|
+
const dispose = registerAgentTools(ctx, {})
|
|
1063
|
+
try {
|
|
1064
|
+
const exec = { agent: { session: { id: 's1' } }, signal: { throwIfAborted() {} } }
|
|
1065
|
+
const byName = (name) => tools.find((definition) => definition.name === name)
|
|
1066
|
+
const listed = await byName('ris_dev_programs').execute({}, exec)
|
|
1067
|
+
assertJsonSafe(listed)
|
|
1068
|
+
assert.equal(listed.programs[0].pid, null, 'a stopped program reports null, not undefined')
|
|
1069
|
+
const started = await byName('ris_dev_start').execute({ id: listed.programs[0].id }, exec)
|
|
1070
|
+
assertJsonSafe(started)
|
|
1071
|
+
const logs = await byName('ris_dev_logs').execute({ name: 'tick-app' }, exec)
|
|
1072
|
+
assertJsonSafe(logs)
|
|
1073
|
+
const stopped = await byName('ris_dev_stop').execute({ name: 'tick-app' }, exec)
|
|
1074
|
+
assertJsonSafe(stopped)
|
|
1075
|
+
takePendingOpens()
|
|
1076
|
+
// jsonSafe is the mechanism; pin its contract directly too
|
|
1077
|
+
assert.deepEqual(jsonSafe({ a: undefined, b: [1, undefined], c: { d: undefined } }), { a: null, b: [1, null], c: { d: null } })
|
|
1078
|
+
} finally {
|
|
1079
|
+
dispose()
|
|
1080
|
+
}
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
test('ris_dev_status answers "is it up?" and ris_dev_restart restarts in place', async () => {
|
|
1085
|
+
const { root } = await makeStartableFixture()
|
|
1086
|
+
const { ctx, tools } = toolCtx(root)
|
|
1087
|
+
const dispose = registerAgentTools(ctx, {})
|
|
1088
|
+
try {
|
|
1089
|
+
const exec = { agent: { session: { id: 's1' } }, signal: { throwIfAborted() {} } }
|
|
1090
|
+
const tool = (name) => tools.find((definition) => definition.name === name)
|
|
1091
|
+
|
|
1092
|
+
const before = await tool('ris_dev_status').execute({ name: 'tick-app' }, exec)
|
|
1093
|
+
assert.equal(before.ok, true)
|
|
1094
|
+
assert.equal(before.found, true)
|
|
1095
|
+
assert.equal(before.running, false, 'nothing is running yet')
|
|
1096
|
+
assert.equal(before.pid, null)
|
|
1097
|
+
assert.equal(before.restartable, true)
|
|
1098
|
+
|
|
1099
|
+
const overall = await tool('ris_dev_status').execute({}, exec)
|
|
1100
|
+
assert.equal(overall.total, 1)
|
|
1101
|
+
assert.equal(overall.running.length, 0)
|
|
1102
|
+
assert.deepEqual(overall.stopped.map((row) => row.name), ['tick-app'])
|
|
1103
|
+
|
|
1104
|
+
const started = await tool('ris_dev_start').execute({ name: 'tick-app' }, exec)
|
|
1105
|
+
assert.equal(started.ok, true)
|
|
1106
|
+
const firstPid = started.program.pid
|
|
1107
|
+
assert.equal(typeof firstPid, 'number')
|
|
1108
|
+
|
|
1109
|
+
const up = await tool('ris_dev_status').execute({ name: 'tick-app' }, exec)
|
|
1110
|
+
assert.equal(up.running, true)
|
|
1111
|
+
assert.equal(up.pid, firstPid)
|
|
1112
|
+
assert.ok(Number.isFinite(up.uptimeMs), 'uptime is reported')
|
|
1113
|
+
assert.equal(up.url, null, 'this fixture logs no URL')
|
|
1114
|
+
|
|
1115
|
+
const restarted = await tool('ris_dev_restart').execute({ name: 'tick-app' }, exec)
|
|
1116
|
+
assert.equal(restarted.ok, true)
|
|
1117
|
+
assert.equal(restarted.restarted, true, 'it was running, so restart really stopped it')
|
|
1118
|
+
assert.equal(restarted.stopped.stopped, true)
|
|
1119
|
+
assert.equal(restarted.alreadyRunning, false)
|
|
1120
|
+
assert.equal(restarted.program.running, true)
|
|
1121
|
+
assert.notEqual(restarted.program.pid, firstPid, 'the new instance has a new pid')
|
|
1122
|
+
|
|
1123
|
+
const afterRestart = await tool('ris_dev_status').execute({ name: 'tick-app' }, exec)
|
|
1124
|
+
assert.equal(afterRestart.running, true)
|
|
1125
|
+
assert.equal(afterRestart.pid, restarted.program.pid)
|
|
1126
|
+
|
|
1127
|
+
// stopping it first must not make restart fail: it degrades to "just start"
|
|
1128
|
+
await tool('ris_dev_stop').execute({ name: 'tick-app' }, exec)
|
|
1129
|
+
const coldRestart = await tool('ris_dev_restart').execute({ name: 'tick-app' }, exec)
|
|
1130
|
+
assert.equal(coldRestart.restarted, false, 'nothing was running')
|
|
1131
|
+
assert.equal(coldRestart.program.running, true)
|
|
1132
|
+
|
|
1133
|
+
// the grouped status view lists it under running now
|
|
1134
|
+
const summary = await tool('ris_dev_status').execute({}, exec)
|
|
1135
|
+
assert.deepEqual(summary.running.map((row) => row.name), ['tick-app'])
|
|
1136
|
+
assert.equal(summary.stopped.length, 0)
|
|
1137
|
+
|
|
1138
|
+
await tool('ris_dev_stop').execute({ name: 'tick-app' }, exec)
|
|
1139
|
+
} finally {
|
|
1140
|
+
dispose()
|
|
1141
|
+
}
|
|
1142
|
+
})
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
// ── Project entry pom drives the Spring profile; project root owns local.yml ─
|
|
1146
|
+
|
|
1147
|
+
test('the Spring profile comes from the project entry pom (runtime args follow it)', async () => {
|
|
1148
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-pom-'))
|
|
1149
|
+
await mkdir(join(root, 'ris', 'm1'), { recursive: true })
|
|
1150
|
+
await writeFile(join(root, 'ris', 'pom.xml'), `<project>
|
|
1151
|
+
<profiles>
|
|
1152
|
+
<profile>
|
|
1153
|
+
<id>dev</id>
|
|
1154
|
+
<properties><profiles.active>dev</profiles.active></properties>
|
|
1155
|
+
<activation><activeByDefault>true</activeByDefault></activation>
|
|
1156
|
+
</profile>
|
|
1157
|
+
<profile><id>prod</id></profile>
|
|
1158
|
+
</profiles>
|
|
1159
|
+
</project>`)
|
|
1160
|
+
// a child module that only declares <parent>
|
|
1161
|
+
await writeFile(join(root, 'ris', 'm1', 'pom.xml'), `<project>
|
|
1162
|
+
<parent>
|
|
1163
|
+
<groupId>cn.com.ris.cloud</groupId>
|
|
1164
|
+
<artifactId>ris</artifactId>
|
|
1165
|
+
<version>3.1.0-SNAPSHOT</version>
|
|
1166
|
+
<relativePath>../pom.xml</relativePath>
|
|
1167
|
+
</parent>
|
|
1168
|
+
</project>`)
|
|
1169
|
+
assert.equal(readProjectProfile(root, join(root, 'ris', 'm1')), 'dev')
|
|
1170
|
+
assert.deepEqual(parseJsonc(JSON.stringify([])), [])
|
|
1171
|
+
|
|
1172
|
+
// a project whose default profile is not dev
|
|
1173
|
+
await mkdir(join(root, 'ris2', 'm2'), { recursive: true })
|
|
1174
|
+
await writeFile(join(root, 'ris2', 'pom.xml'), `<project>
|
|
1175
|
+
<profiles><profile><id>test</id><activation><activeByDefault>true</activeByDefault></activation></profile></profiles>
|
|
1176
|
+
</project>`)
|
|
1177
|
+
assert.equal(readProjectProfile(root, join(root, 'ris2', 'm2')), 'test')
|
|
1178
|
+
// a project with no pom at all falls back to dev (the RISCloud convention)
|
|
1179
|
+
await mkdir(join(root, 'lonely'), { recursive: true })
|
|
1180
|
+
assert.equal(readProjectProfile(root, join(root, 'lonely')), 'dev')
|
|
1181
|
+
|
|
1182
|
+
// argument normalization follows the pom and the project-root local.yml
|
|
1183
|
+
await writeFile(join(root, 'ris2', 'local.yml'), 'x: 1\n')
|
|
1184
|
+
assert.equal(localYmlFor(root, join(root, 'ris2', 'm2')), join(root, 'ris2', 'local.yml'))
|
|
1185
|
+
assert.deepEqual(normalizeSpringArgs(['--spring.profiles.active=dev'], root, join(root, 'ris2', 'm2')), [
|
|
1186
|
+
'--spring.profiles.active=test',
|
|
1187
|
+
`--spring.config.additional-location=file:${join(root, 'ris2', 'local.yml')}`,
|
|
1188
|
+
])
|
|
1189
|
+
assert.equal(profileFromPomText('<project><properties><profiles.active>uat</profiles.active></properties></project>'), 'uat')
|
|
1190
|
+
assert.equal(profileFromPomText('<project/>'), undefined)
|
|
1191
|
+
})
|
|
1192
|
+
|
|
1193
|
+
test('the programs route generates a config on first open, then only touches it when the workspace moved', async () => {
|
|
1194
|
+
const { root } = await makeProgramFixture()
|
|
1195
|
+
// the project root owns local.yml (not the workspace root)
|
|
1196
|
+
await writeFile(join(root, 'proj-a', 'local.yml'), 'gray: {VERSION: test}\n')
|
|
1197
|
+
await withServer(async ({ base }) => {
|
|
1198
|
+
const first = await (await fetch(`${base}${PROGRAMS_PATH}?sessionId=s1`)).json()
|
|
1199
|
+
assert.equal(first.config.status, 'generated', 'no config ⇒ scan + write one')
|
|
1200
|
+
const configPath = join(root, '.dsh-ris-ide.json')
|
|
1201
|
+
assert.equal(existsSync(configPath), true)
|
|
1202
|
+
const generated = parseJsonc(await readFile(configPath, 'utf8'))
|
|
1203
|
+
const spring = generated.programs.find((entry) => entry.kind === 'spring')
|
|
1204
|
+
assert.deepEqual(spring.args, [
|
|
1205
|
+
'--spring.profiles.active=dev',
|
|
1206
|
+
`--spring.config.additional-location=file:${join(root, 'proj-a', 'local.yml')}`,
|
|
1207
|
+
], 'the generated args point at the PROJECT local.yml, not the workspace one')
|
|
1208
|
+
|
|
1209
|
+
// second open: the config matches ⇒ serve from it, no scan, file untouched
|
|
1210
|
+
const before = await readFile(configPath, 'utf8')
|
|
1211
|
+
const second = await (await fetch(`${base}${PROGRAMS_PATH}?sessionId=s1`)).json()
|
|
1212
|
+
assert.equal(second.config.status, 'matched')
|
|
1213
|
+
assert.equal(await readFile(configPath, 'utf8'), before, 'a matched config is not rewritten')
|
|
1214
|
+
assert.equal(second.programs.length, first.programs.length)
|
|
1215
|
+
assert.equal(second.programs.every((row) => row.fromConfig === true), true, 'rows come from the config')
|
|
1216
|
+
|
|
1217
|
+
// the frontend manifest disappears ⇒ mismatch ⇒ auto-update drops the entry
|
|
1218
|
+
await rm(join(root, 'proj-a', 'ui', 'package.json'))
|
|
1219
|
+
const third = await (await fetch(`${base}${PROGRAMS_PATH}?sessionId=s1`)).json()
|
|
1220
|
+
assert.equal(third.config.status, 'updated')
|
|
1221
|
+
assert.equal(third.programs.some((row) => row.kind === 'frontend'), false)
|
|
1222
|
+
assert.equal(parseJsonc(await readFile(configPath, 'utf8')).programs.some((entry) => entry.kind === 'frontend'), false)
|
|
1223
|
+
|
|
1224
|
+
// an explicit rescan always works and leaves the status stable
|
|
1225
|
+
const rescan = await (await fetch(`${base}${PROGRAMS_PATH}?sessionId=s1&refresh=1`)).json()
|
|
1226
|
+
assert.equal(['scanned', 'matched', 'generated', 'updated'].includes(rescan.config.status), true)
|
|
1227
|
+
}, undefined, root)
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
test('configMatchesWorkspace is a cheap, opinionated check', async () => {
|
|
1231
|
+
const { root } = await makeProgramFixture()
|
|
1232
|
+
const programs = await scanPrograms(root, join(root, 'logs'), true)
|
|
1233
|
+
const doc = buildConfigDocument(programs, root)
|
|
1234
|
+
assert.equal(configMatchesWorkspace(root, doc.programs), true, 'a fresh generated document matches')
|
|
1235
|
+
assert.equal(configMatchesWorkspace(root, []), false, 'an empty config never matches')
|
|
1236
|
+
// a stale workspace-root local.yml path is a mismatch when the project has one
|
|
1237
|
+
await writeFile(join(root, 'proj-a', 'local.yml'), 'x: 1\n')
|
|
1238
|
+
const stale = doc.programs.map((entry) => entry.kind === 'spring'
|
|
1239
|
+
? Object.assign({}, entry, { args: ['--spring.profiles.active=dev', '--spring.config.additional-location=file:/elsewhere/local.yml'] })
|
|
1240
|
+
: entry)
|
|
1241
|
+
assert.equal(configMatchesWorkspace(root, stale), false, 'a foreign additional-location is a mismatch')
|
|
1242
|
+
const fresh = buildConfigDocument(programs, root)
|
|
1243
|
+
assert.equal(configMatchesWorkspace(root, fresh.programs), true, 'the regenerated args match')
|
|
1244
|
+
// A deliberately all-disabled config is authoritative (an empty program list),
|
|
1245
|
+
// while a completely empty file means "no config content" and never matches.
|
|
1246
|
+
assert.equal(configMatchesWorkspace(root, [{ disabled: true, name: 'x' }]), true)
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
// ── JVM options (memory etc.) from the config ─────────────────────────────
|
|
1251
|
+
|
|
1252
|
+
test('jvmArgs reach the java command (per program, or from root defaults)', () => {
|
|
1253
|
+
const spec = buildStartSpec(
|
|
1254
|
+
{ kind: 'spring', dir: '/w/ris/m1', mainClass: 'a.B', args: ['--x'], jvmArgs: ['-Xmx2g', '-XX:+UseZGC', '-Dfoo=bar baz'] },
|
|
1255
|
+
{ workspace: '/w', logsDir: '/w/logs' },
|
|
1256
|
+
)
|
|
1257
|
+
// AFTER the plugin's required -D flags (a deliberate override wins), BEFORE -cp.
|
|
1258
|
+
assert.match(spec.command, /-Dcsp\.sentinel\.log\.dir=[^ ]+ -Xmx2g -XX:\+UseZGC '-Dfoo=bar baz' -cp "target\/classes:/)
|
|
1259
|
+
assert.match(spec.command, /a\.B --x$/)
|
|
1260
|
+
|
|
1261
|
+
const noJvm = buildStartSpec({ kind: 'spring', dir: '/w/m', mainClass: 'a.B', args: [] }, { workspace: '/w', logsDir: '/w/logs' })
|
|
1262
|
+
assert.doesNotMatch(noJvm.command, /-Xmx/, 'no jvmArgs means no extra flags')
|
|
1263
|
+
|
|
1264
|
+
const programs = [{ id: 'spring:ris/m1#a.B', kind: 'spring', name: 'm1', relDir: 'ris/m1', dir: '/w/ris/m1', logName: 'm1', mainClass: 'a.B', args: [] }]
|
|
1265
|
+
// root defaults apply to programs that do not set their own
|
|
1266
|
+
assert.deepEqual(mergeProgramConfig(programs, [{ id: 'spring:ris/m1#a.B', name: 'm1' }], '/w', { jvmArgs: ['-Xmx1g'] })[0].jvmArgs, ['-Xmx1g'])
|
|
1267
|
+
// a per-program list wins over the defaults
|
|
1268
|
+
assert.deepEqual(mergeProgramConfig(programs, [{ id: 'spring:ris/m1#a.B', name: 'm1', jvmArgs: ['-Xmx4g'] }], '/w', { jvmArgs: ['-Xmx1g'] })[0].jvmArgs, ['-Xmx4g'])
|
|
1269
|
+
// a user-added program also inherits the defaults
|
|
1270
|
+
const manual = mergeProgramConfig([], [{ name: 'custom', dir: 'x', startCommand: 'true' }], '/w', { jvmArgs: ['-Xmx512m'] })[0]
|
|
1271
|
+
assert.deepEqual(manual.jvmArgs, ['-Xmx512m'])
|
|
1272
|
+
assert.deepEqual(normalizeStringArray([' -Xmx1g ', '', 7, null]), ['-Xmx1g'])
|
|
1273
|
+
assert.equal(normalizeStringArray([]), undefined)
|
|
1274
|
+
})
|
|
1275
|
+
|
|
1276
|
+
test('the generated config documents jvmArgs, and an update keeps the user defaults', async () => {
|
|
1277
|
+
const { root } = await makeProgramFixture()
|
|
1278
|
+
const doc = buildConfigDocument(await scanPrograms(root, join(root, 'logs'), true), root)
|
|
1279
|
+
assert.deepEqual(doc.defaults, { jvmArgs: [] }, 'the template shows where global JVM flags go')
|
|
1280
|
+
const spring = doc.programs.find((entry) => entry.kind === 'spring')
|
|
1281
|
+
assert.deepEqual(spring.jvmArgs, [], 'every entry carries an editable jvmArgs list')
|
|
1282
|
+
|
|
1283
|
+
await withServer(async ({ base }) => {
|
|
1284
|
+
const configPath = join(root, '.dsh-ris-ide.json')
|
|
1285
|
+
const post = (body) => fetch(`${base}${CONFIG_PATH}`, {
|
|
1286
|
+
method: 'POST',
|
|
1287
|
+
headers: { 'content-type': 'application/json' },
|
|
1288
|
+
body: JSON.stringify(Object.assign({ sessionId: 's1' }, body)),
|
|
1289
|
+
})
|
|
1290
|
+
assert.equal((await post({})).status, 200)
|
|
1291
|
+
// the user adds a global memory default and a per-program override
|
|
1292
|
+
const edited = parseJsonc(await readFile(configPath, 'utf8'))
|
|
1293
|
+
edited.defaults = { jvmArgs: ['-Xmx1g'] }
|
|
1294
|
+
const springEntry = edited.programs.find((entry) => entry.kind === 'spring')
|
|
1295
|
+
springEntry.jvmArgs = ['-Xmx3g', '-Dfile.encoding=UTF-8']
|
|
1296
|
+
await writeFile(configPath, JSON.stringify(edited, null, 2))
|
|
1297
|
+
|
|
1298
|
+
assert.equal((await post({ mode: 'update' })).status, 200)
|
|
1299
|
+
const updated = parseJsonc(await readFile(configPath, 'utf8'))
|
|
1300
|
+
assert.deepEqual(updated.defaults, { jvmArgs: ['-Xmx1g'] }, 'the global default survives an update')
|
|
1301
|
+
const kept = updated.programs.find((entry) => entry.kind === 'spring')
|
|
1302
|
+
assert.deepEqual(kept.jvmArgs, ['-Xmx3g', '-Dfile.encoding=UTF-8'], 'the per-program override survives an update')
|
|
1303
|
+
|
|
1304
|
+
// and the start spec really uses them
|
|
1305
|
+
const listing = await (await fetch(`${base}${PROGRAMS_PATH}?sessionId=s1`)).json()
|
|
1306
|
+
const program = listing.programs.find((entry) => entry.kind === 'spring')
|
|
1307
|
+
assert.deepEqual(program.jvmArgs, ['-Xmx3g', '-Dfile.encoding=UTF-8'])
|
|
1308
|
+
const spec = buildStartSpec(program, { workspace: root, logsDir: join(root, 'logs') })
|
|
1309
|
+
assert.match(spec.command, /-Xmx3g -Dfile\.encoding=UTF-8/)
|
|
1310
|
+
}, undefined, root)
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
// ── Windows: native PowerShell wrapper, optional Git Bash runtime ─────────
|
|
1315
|
+
|
|
1316
|
+
test('windowsStartSpec describes the job for run-with-log.ps1', () => {
|
|
1317
|
+
const spring = windowsStartSpec(
|
|
1318
|
+
{ kind: 'spring', dir: 'C:\\ws\\ris\\m1', mainClass: 'a.B', args: ['--spring.profiles.active=dev'], jvmArgs: ['-Xmx2g'], logName: 'm1' },
|
|
1319
|
+
{ workspace: 'C:\\ws', logsDir: 'C:\\ws\\logs' },
|
|
1320
|
+
)
|
|
1321
|
+
assert.equal(spring.platform, 'win32')
|
|
1322
|
+
assert.ok(spring.scriptPath.endsWith('run-with-log.ps1'))
|
|
1323
|
+
assert.deepEqual(spring.spec, {
|
|
1324
|
+
logName: 'm1',
|
|
1325
|
+
logDir: 'C:\\ws\\logs',
|
|
1326
|
+
kind: 'spring',
|
|
1327
|
+
dir: 'C:\\ws\\ris\\m1',
|
|
1328
|
+
mainClass: 'a.B',
|
|
1329
|
+
args: ['--spring.profiles.active=dev'],
|
|
1330
|
+
jvmArgs: ['-Xmx2g'],
|
|
1331
|
+
})
|
|
1332
|
+
// the spec travels as base64 JSON (no quoting/arg-length traps)
|
|
1333
|
+
const roundTrip = JSON.parse(Buffer.from(Buffer.from(JSON.stringify(spring.spec), 'utf8').toString('base64'), 'base64').toString('utf8'))
|
|
1334
|
+
assert.deepEqual(roundTrip, spring.spec)
|
|
1335
|
+
|
|
1336
|
+
const frontend = windowsStartSpec({ kind: 'frontend', dir: 'C:\\ws\\ui', packageManager: 'pnpm', script: 'dev', logName: 'ui' }, { workspace: 'C:\\ws', logsDir: 'C:\\ws\\logs' })
|
|
1337
|
+
assert.deepEqual(frontend.spec, { logName: 'ui', logDir: 'C:\\ws\\logs', kind: 'frontend', dir: 'C:\\ws\\ui', packageManager: 'pnpm', script: 'dev' })
|
|
1338
|
+
|
|
1339
|
+
const custom = windowsStartSpec({ kind: 'spring', dir: 'C:\\ws', startCommand: 'java -jar x.jar', logName: 'x' }, { workspace: 'C:\\ws', logsDir: 'C:\\ws\\logs' })
|
|
1340
|
+
assert.equal(custom.spec.kind, 'command')
|
|
1341
|
+
assert.equal(custom.spec.command, 'java -jar x.jar')
|
|
1342
|
+
assert.equal(IS_WINDOWS, false, 'the test host is Linux; the Windows plan is exercised as data')
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
test('the Git Bash runtime is detected and can be requested explicitly', async () => {
|
|
1346
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-bash-'))
|
|
1347
|
+
const gitDir = join(dir, 'git', 'bin')
|
|
1348
|
+
const wslDir = join(dir, 'system32')
|
|
1349
|
+
await mkdir(gitDir, { recursive: true })
|
|
1350
|
+
await mkdir(wslDir, { recursive: true })
|
|
1351
|
+
const gitBash = join(gitDir, 'bash.exe')
|
|
1352
|
+
const wslBash = join(wslDir, 'bash.exe')
|
|
1353
|
+
await writeFile(gitBash, '')
|
|
1354
|
+
await writeFile(wslBash, '')
|
|
1355
|
+
// WSL's System32\bash.exe must never be picked (different pid/path namespace)
|
|
1356
|
+
assert.equal(pickGitBash([wslBash]), undefined)
|
|
1357
|
+
assert.equal(pickGitBash([wslBash, gitBash]), gitBash)
|
|
1358
|
+
assert.equal(pickGitBash([join(dir, 'missing', 'bash.exe')]), undefined)
|
|
1359
|
+
|
|
1360
|
+
// auto: bash when found, PowerShell when not
|
|
1361
|
+
assert.deepEqual(resolveWindowsRuntime('auto', gitBash), { runtime: 'bash', bashPath: gitBash })
|
|
1362
|
+
assert.deepEqual(resolveWindowsRuntime('powershell', gitBash), { runtime: 'powershell' })
|
|
1363
|
+
// explicit bash must fail loudly when it is not installed
|
|
1364
|
+
assert.throws(() => resolveWindowsRuntime('bash', join(dir, 'missing', 'bash.exe')), /Git Bash/)
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
test('the windows-bash command keeps the POSIX wrapper but uses a Windows classpath', () => {
|
|
1368
|
+
const program = { kind: 'spring', dir: 'C:\\ws\\ris\\m1', mainClass: 'a.B', args: ['--x'], logName: 'm1' }
|
|
1369
|
+
const spec = buildStartSpec(program, { workspace: 'C:\\ws', logsDir: 'C:\\ws\\logs', variant: 'windows-bash' })
|
|
1370
|
+
assert.match(spec.command, /-cp "target\\classes;\$\(cat target\/cp\.txt\)" a\.B --x$/)
|
|
1371
|
+
assert.match(spec.command, /exec java -DJM\.LOG\.PATH=/)
|
|
1372
|
+
// ...while a plain POSIX build keeps ':' and target/classes
|
|
1373
|
+
const posix = buildStartSpec(program, { workspace: '/w', logsDir: '/w/logs' })
|
|
1374
|
+
assert.match(posix.command, /-cp "target\/classes:\$\(cat target\/cp\.txt\)" a\.B --x$/)
|
|
1375
|
+
})
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
// ── Debug routes (HTTP surface) ────────────────────────────────────────────
|
|
1379
|
+
|
|
1380
|
+
test('the debug routes fence methods, require scope and answer a detached session', async () => {
|
|
1381
|
+
const { root } = await makeStartableFixture()
|
|
1382
|
+
await withServer(async ({ base }) => {
|
|
1383
|
+
// unknown action / wrong method / missing scope
|
|
1384
|
+
assert.equal((await fetch(`${base}${DEBUG_PATH}/nope?sessionId=s1`)).status, 404)
|
|
1385
|
+
assert.equal((await fetch(`${base}${DEBUG_PATH}/status`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: 's1' }) })).status, 405)
|
|
1386
|
+
assert.equal((await fetch(`${base}${DEBUG_PATH}/status`)).status, 400, 'sessionId is required')
|
|
1387
|
+
// status on a program that is not attached is a quiet 200 (the panel polls it)
|
|
1388
|
+
const status = await fetch(`${base}${DEBUG_PATH}/status?sessionId=s1&path=logs/tick.log`)
|
|
1389
|
+
assert.equal(status.status, 200)
|
|
1390
|
+
const body = await status.json()
|
|
1391
|
+
assert.equal(body.ok, true)
|
|
1392
|
+
assert.equal(body.attached, false)
|
|
1393
|
+
// poll is quiet too
|
|
1394
|
+
const poll = await (await fetch(`${base}${DEBUG_PATH}/poll?sessionId=s1&path=logs/tick.log&since=0`)).json()
|
|
1395
|
+
assert.deepEqual(poll.events, [])
|
|
1396
|
+
// POST breakpoints needs a path; POST step on a detached session is a 409
|
|
1397
|
+
assert.equal((await fetch(`${base}${DEBUG_PATH}/breakpoints`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: 's1' }) })).status, 400)
|
|
1398
|
+
const step = await fetch(`${base}${DEBUG_PATH}/step`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: 's1', path: 'logs/tick.log', kind: 'over' }) })
|
|
1399
|
+
assert.equal(step.status, 409)
|
|
1400
|
+
assert.equal((await step.json()).code, 'not-attached')
|
|
1401
|
+
// a frontend program cannot be debugged
|
|
1402
|
+
const listing = await (await fetch(`${base}${PROGRAMS_PATH}?sessionId=s1&refresh=1`)).json()
|
|
1403
|
+
const frontend = listing.programs.find((entry) => entry.kind === 'frontend')
|
|
1404
|
+
const started = await fetch(`${base}${DEBUG_PATH}/start`, {
|
|
1405
|
+
method: 'POST',
|
|
1406
|
+
headers: { 'content-type': 'application/json' },
|
|
1407
|
+
body: JSON.stringify({ sessionId: 's1', id: frontend.id }),
|
|
1408
|
+
})
|
|
1409
|
+
assert.equal(started.status, 400, 'frontend is not a Java program')
|
|
1410
|
+
assert.equal((await started.json()).code, 'not-startable')
|
|
1411
|
+
}, undefined, root)
|
|
1412
|
+
})
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
// ── Bundled skill + ris_dev_skill ──────────────────────────────────────────
|
|
1416
|
+
|
|
1417
|
+
test('the bundled skill ships inside the package with valid frontmatter', async () => {
|
|
1418
|
+
assert.equal(SKILL_NAME, 'dsh-ris-ide-debug')
|
|
1419
|
+
const path = bundledSkillPath()
|
|
1420
|
+
assert.ok(existsSync(path), `bundledSkillPath must exist: ${path}`)
|
|
1421
|
+
const content = await readFile(path, 'utf8')
|
|
1422
|
+
assert.ok(content.startsWith('---'), 'the skill must start with YAML frontmatter')
|
|
1423
|
+
assert.match(content, /name: dsh-ris-ide-debug/)
|
|
1424
|
+
assert.match(content, /description:/)
|
|
1425
|
+
// A packaging regression would drop `skills` from `files` and ship no skill.
|
|
1426
|
+
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
|
|
1427
|
+
assert.ok(Array.isArray(manifest.files) && manifest.files.includes('skills'), 'package.json files must include skills')
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1430
|
+
test('installSkill writes the bundled SKILL.md once and is idempotent', async () => {
|
|
1431
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-skill-'))
|
|
1432
|
+
try {
|
|
1433
|
+
// a project root marker, so the default `project` target resolves here
|
|
1434
|
+
await mkdir(join(root, '.git'), { recursive: true })
|
|
1435
|
+
const dir = join(root, '.dsh', 'skills')
|
|
1436
|
+
const bundled = await readFile(bundledSkillPath(), 'utf8')
|
|
1437
|
+
|
|
1438
|
+
const first = await installSkill(root, undefined, dir)
|
|
1439
|
+
assert.equal(first.ok, true)
|
|
1440
|
+
assert.equal(first.changed, true)
|
|
1441
|
+
assert.equal(first.target, 'custom')
|
|
1442
|
+
assert.equal(first.root, resolve(dir))
|
|
1443
|
+
assert.equal(first.bytes, Buffer.byteLength(bundled))
|
|
1444
|
+
assert.equal(await readFile(first.path, 'utf8'), bundled)
|
|
1445
|
+
|
|
1446
|
+
const second = await installSkill(root, undefined, dir)
|
|
1447
|
+
assert.equal(second.changed, false, 'a second install must not rewrite')
|
|
1448
|
+
|
|
1449
|
+
const state = await skillFileState(first.root)
|
|
1450
|
+
assert.equal(state.exists, true)
|
|
1451
|
+
assert.equal(state.upToDate, true)
|
|
1452
|
+
assert.equal(state.bytes, Buffer.byteLength(bundled))
|
|
1453
|
+
|
|
1454
|
+
// The pure target resolution: project/agents stay in the workspace.
|
|
1455
|
+
assert.deepEqual(await skillRootFor(root, 'project'), { root: resolve(root, '.dsh', 'skills'), target: 'project' })
|
|
1456
|
+
assert.deepEqual(await skillRootFor(root, 'agents'), { root: resolve(root, '.agents', 'skills'), target: 'agents' })
|
|
1457
|
+
// `user` is asserted as a pure path only — never installed into the real home.
|
|
1458
|
+
assert.deepEqual(await skillRootFor(root, 'user'), { root: resolve(homedir(), '.dsh', 'skills'), target: 'user' })
|
|
1459
|
+
} finally {
|
|
1460
|
+
await rm(root, { recursive: true, force: true })
|
|
1461
|
+
}
|
|
1462
|
+
})
|
|
1463
|
+
|
|
1464
|
+
test('findProjectRoot walks up to the .git ancestor, not the nested start dir', async () => {
|
|
1465
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-root-'))
|
|
1466
|
+
try {
|
|
1467
|
+
await mkdir(join(root, '.git'), { recursive: true })
|
|
1468
|
+
const nested = join(root, 'ris', 'module', 'src')
|
|
1469
|
+
await mkdir(nested, { recursive: true })
|
|
1470
|
+
assert.equal(await findProjectRoot(nested), resolve(root))
|
|
1471
|
+
assert.notEqual(await findProjectRoot(nested), resolve(nested))
|
|
1472
|
+
} finally {
|
|
1473
|
+
await rm(root, { recursive: true, force: true })
|
|
1474
|
+
}
|
|
1475
|
+
})
|
|
1476
|
+
|
|
1477
|
+
test('skillRootFor refuses an explicit dir outside the workspace and home', async () => {
|
|
1478
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-skill-fence-'))
|
|
1479
|
+
try {
|
|
1480
|
+
const outside = join(tmpdir(), `dsh-ris-ide-outside-${process.pid}`)
|
|
1481
|
+
assert.ok(!outside.startsWith(root), 'the probe dir must be outside the workspace')
|
|
1482
|
+
await assert.rejects(skillRootFor(root, undefined, outside), (error) => {
|
|
1483
|
+
assert.equal(error.code, 'bad-path')
|
|
1484
|
+
assert.match(error.message, /outside the workspace or home/)
|
|
1485
|
+
return true
|
|
1486
|
+
})
|
|
1487
|
+
} finally {
|
|
1488
|
+
await rm(root, { recursive: true, force: true })
|
|
1489
|
+
}
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
test('ris_dev_skill is registered, renders its status and shows the bundled text', async () => {
|
|
1493
|
+
const { ctx, tools } = toolCtx(scratch)
|
|
1494
|
+
const dispose = registerAgentTools(ctx, {})
|
|
1495
|
+
try {
|
|
1496
|
+
const skill = tools.find((tool) => tool.name === 'ris_dev_skill')
|
|
1497
|
+
assert.ok(skill !== undefined, 'ris_dev_skill must be registered')
|
|
1498
|
+
|
|
1499
|
+
const rendered = skill.output.render({}, {
|
|
1500
|
+
action: 'status',
|
|
1501
|
+
roots: [{ target: 'project', path: join(scratch, '.dsh', 'skills', SKILL_NAME, 'SKILL.md'), exists: true, upToDate: true }],
|
|
1502
|
+
})
|
|
1503
|
+
assert.deepEqual(rendered.map((part) => part.type), ['text'])
|
|
1504
|
+
assert.match(rendered[0].text, /up-to-date/)
|
|
1505
|
+
assert.match(rendered[0].text, /project → /)
|
|
1506
|
+
|
|
1507
|
+
const exec = { agent: { session: { id: 's1' } }, signal: { throwIfAborted() {} } }
|
|
1508
|
+
const shown = await skill.execute({ action: 'show' }, exec)
|
|
1509
|
+
assert.equal(shown.ok, true)
|
|
1510
|
+
assert.match(shown.content, /^---/)
|
|
1511
|
+
assert.match(shown.source, /skills[/\\]dsh-ris-ide-debug[/\\]SKILL\.md$/)
|
|
1512
|
+
} finally {
|
|
1513
|
+
dispose()
|
|
1514
|
+
}
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
// ── Skill auto-update (sync) ───────────────────────────────────────────────
|
|
1519
|
+
|
|
1520
|
+
/** The installed SKILL.md a given root would hold. */
|
|
1521
|
+
function skillFileIn(root) {
|
|
1522
|
+
return join(root, SKILL_NAME, 'SKILL.md')
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
test('syncInstalledSkills refreshes a stale copy and never creates a missing one', async () => {
|
|
1526
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-sync-'))
|
|
1527
|
+
const homeUser = join(homedir(), '.dsh', 'skills')
|
|
1528
|
+
const homeAgents = join(homedir(), '.agents', 'skills')
|
|
1529
|
+
const homeBefore = { user: existsSync(skillFileIn(homeUser)), 'user-agents': existsSync(skillFileIn(homeAgents)) }
|
|
1530
|
+
try {
|
|
1531
|
+
await mkdir(join(root, '.git'), { recursive: true })
|
|
1532
|
+
const projectRoot = join(root, '.dsh', 'skills')
|
|
1533
|
+
const projectFile = skillFileIn(projectRoot)
|
|
1534
|
+
await mkdir(dirname(projectFile), { recursive: true })
|
|
1535
|
+
await writeFile(projectFile, '---\nname: dsh-ris-ide-debug\n# stale copy from an older plugin\n---\n')
|
|
1536
|
+
const bundled = await readFile(bundledSkillPath(), 'utf8')
|
|
1537
|
+
|
|
1538
|
+
const result = await syncInstalledSkills(root)
|
|
1539
|
+
assert.deepEqual(result.map((entry) => entry.target), ['project', 'agents', 'user', 'user-agents'], 'all four roots, in order')
|
|
1540
|
+
const by = Object.fromEntries(result.map((entry) => [entry.target, entry]))
|
|
1541
|
+
|
|
1542
|
+
// The existing copy is rewritten in place.
|
|
1543
|
+
assert.deepEqual(by.project, {
|
|
1544
|
+
root: resolve(projectRoot),
|
|
1545
|
+
target: 'project',
|
|
1546
|
+
path: projectFile,
|
|
1547
|
+
existed: true,
|
|
1548
|
+
changed: true,
|
|
1549
|
+
upToDate: true,
|
|
1550
|
+
})
|
|
1551
|
+
assert.equal(await readFile(projectFile, 'utf8'), bundled, 'the stale copy is replaced with the bundled text')
|
|
1552
|
+
|
|
1553
|
+
// A missing copy is reported, never created.
|
|
1554
|
+
assert.deepEqual(by.agents, {
|
|
1555
|
+
root: resolve(root, '.agents', 'skills'),
|
|
1556
|
+
target: 'agents',
|
|
1557
|
+
path: skillFileIn(join(root, '.agents', 'skills')),
|
|
1558
|
+
existed: false,
|
|
1559
|
+
changed: false,
|
|
1560
|
+
upToDate: false,
|
|
1561
|
+
})
|
|
1562
|
+
assert.ok(!existsSync(skillFileIn(join(root, '.agents', 'skills'))), 'sync must not create a missing copy')
|
|
1563
|
+
|
|
1564
|
+
// The home roots are only reported; if a copy already exists there this
|
|
1565
|
+
// machine's sync legitimately refreshes it (that is the feature), so the
|
|
1566
|
+
// never-created assertion is conditional on the pre-state.
|
|
1567
|
+
for (const target of ['user', 'user-agents']) {
|
|
1568
|
+
const homeRoot = target === 'user' ? homeUser : homeAgents
|
|
1569
|
+
if (homeBefore[target]) {
|
|
1570
|
+
assert.equal(by[target].existed, true)
|
|
1571
|
+
assert.equal(by[target].upToDate, true)
|
|
1572
|
+
} else {
|
|
1573
|
+
assert.deepEqual(by[target], {
|
|
1574
|
+
root: resolve(homeRoot),
|
|
1575
|
+
target,
|
|
1576
|
+
path: skillFileIn(homeRoot),
|
|
1577
|
+
existed: false,
|
|
1578
|
+
changed: false,
|
|
1579
|
+
upToDate: false,
|
|
1580
|
+
})
|
|
1581
|
+
assert.ok(!existsSync(skillFileIn(homeRoot)), `sync must not create ${target}`)
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
} finally {
|
|
1585
|
+
await rm(root, { recursive: true, force: true })
|
|
1586
|
+
}
|
|
1587
|
+
})
|
|
1588
|
+
|
|
1589
|
+
test('a second syncInstalledSkills is a no-op for every existing copy', async () => {
|
|
1590
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-sync2-'))
|
|
1591
|
+
try {
|
|
1592
|
+
await mkdir(join(root, '.git'), { recursive: true })
|
|
1593
|
+
const projectFile = skillFileIn(join(root, '.dsh', 'skills'))
|
|
1594
|
+
await mkdir(dirname(projectFile), { recursive: true })
|
|
1595
|
+
await writeFile(projectFile, 'stale\n')
|
|
1596
|
+
|
|
1597
|
+
const first = await syncInstalledSkills(root)
|
|
1598
|
+
assert.equal(first.find((entry) => entry.target === 'project').changed, true, 'the first sync fixes the stale copy')
|
|
1599
|
+
|
|
1600
|
+
const again = await syncInstalledSkills(root)
|
|
1601
|
+
assert.equal(again.length, first.length)
|
|
1602
|
+
for (const entry of again) {
|
|
1603
|
+
assert.equal(entry.changed, false, `${entry.target} must not be rewritten twice`)
|
|
1604
|
+
assert.equal(entry.upToDate, entry.existed, `${entry.target}: upToDate exactly when a copy exists`)
|
|
1605
|
+
}
|
|
1606
|
+
assert.deepEqual(again.find((entry) => entry.target === 'project'), {
|
|
1607
|
+
root: resolve(root, '.dsh', 'skills'),
|
|
1608
|
+
target: 'project',
|
|
1609
|
+
path: projectFile,
|
|
1610
|
+
existed: true,
|
|
1611
|
+
changed: false,
|
|
1612
|
+
upToDate: true,
|
|
1613
|
+
})
|
|
1614
|
+
assert.equal(again.find((entry) => entry.target === 'agents').existed, false)
|
|
1615
|
+
} finally {
|
|
1616
|
+
await rm(root, { recursive: true, force: true })
|
|
1617
|
+
}
|
|
1618
|
+
})
|
|
1619
|
+
|
|
1620
|
+
test('skillRootsFor maps the four targets to their roots', async () => {
|
|
1621
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-roots-'))
|
|
1622
|
+
try {
|
|
1623
|
+
await mkdir(join(root, '.git'), { recursive: true })
|
|
1624
|
+
assert.deepEqual(await skillRootsFor(root), [
|
|
1625
|
+
{ root: resolve(root, '.dsh', 'skills'), target: 'project' },
|
|
1626
|
+
{ root: resolve(root, '.agents', 'skills'), target: 'agents' },
|
|
1627
|
+
{ root: resolve(homedir(), '.dsh', 'skills'), target: 'user' },
|
|
1628
|
+
{ root: resolve(homedir(), '.agents', 'skills'), target: 'user-agents' },
|
|
1629
|
+
])
|
|
1630
|
+
} finally {
|
|
1631
|
+
await rm(root, { recursive: true, force: true })
|
|
1632
|
+
}
|
|
1633
|
+
})
|
|
1634
|
+
|
|
1635
|
+
test('ris_dev_skill defaults to sync and auto-updates an existing copy', async () => {
|
|
1636
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-skill-sync-'))
|
|
1637
|
+
try {
|
|
1638
|
+
await mkdir(join(root, '.git'), { recursive: true })
|
|
1639
|
+
const projectFile = skillFileIn(join(root, '.dsh', 'skills'))
|
|
1640
|
+
await mkdir(dirname(projectFile), { recursive: true })
|
|
1641
|
+
await writeFile(projectFile, 'old skill text\n')
|
|
1642
|
+
|
|
1643
|
+
const { ctx, tools } = toolCtx(root)
|
|
1644
|
+
const dispose = registerAgentTools(ctx, {})
|
|
1645
|
+
try {
|
|
1646
|
+
const skill = tools.find((tool) => tool.name === 'ris_dev_skill')
|
|
1647
|
+
assert.ok(skill !== undefined)
|
|
1648
|
+
// The schema advertises sync as the default, and says so in prose.
|
|
1649
|
+
assert.ok(skill.parameters.properties.action.enum.includes('sync'))
|
|
1650
|
+
assert.match(skill.parameters.properties.action.description, /default/)
|
|
1651
|
+
assert.match(skill.description, /auto-update/i)
|
|
1652
|
+
|
|
1653
|
+
const exec = { agent: { session: { id: 's1' } }, signal: { throwIfAborted() {} } }
|
|
1654
|
+
const result = await skill.execute({}, exec)
|
|
1655
|
+
assert.equal(result.ok, true)
|
|
1656
|
+
assert.equal(result.action, 'sync', 'no action means sync')
|
|
1657
|
+
assert.ok(result.changed >= 1, 'the stale project copy was refreshed')
|
|
1658
|
+
const project = result.roots.find((entry) => entry.target === 'project')
|
|
1659
|
+
assert.equal(project.existed, true)
|
|
1660
|
+
assert.equal(project.changed, true)
|
|
1661
|
+
assert.equal(project.upToDate, true)
|
|
1662
|
+
assert.equal(await readFile(projectFile, 'utf8'), await readFile(bundledSkillPath(), 'utf8'))
|
|
1663
|
+
|
|
1664
|
+
// status reports the plugin version the copies are tracking.
|
|
1665
|
+
const status = await skill.execute({ action: 'status' }, exec)
|
|
1666
|
+
assert.equal(status.ok, true)
|
|
1667
|
+
assert.equal(status.pluginVersion, PKG_VERSION)
|
|
1668
|
+
} finally {
|
|
1669
|
+
dispose()
|
|
1670
|
+
}
|
|
1671
|
+
} finally {
|
|
1672
|
+
await rm(root, { recursive: true, force: true })
|
|
1673
|
+
}
|
|
1674
|
+
})
|
|
1675
|
+
|
|
1676
|
+
test('PKG_VERSION tracks package.json', async () => {
|
|
1677
|
+
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
|
|
1678
|
+
assert.equal(PKG_VERSION, manifest.version)
|
|
1679
|
+
assert.match(PKG_VERSION, /^\d+\.\d+\.\d+/)
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
// ── gitignore-aware, compact-folding IDE tree ───────────────────────────────
|
|
1684
|
+
test('listIdeTree hides ignored files and folds package chains only under java', async () => {
|
|
1685
|
+
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises')
|
|
1686
|
+
const { execFile } = await import('node:child_process')
|
|
1687
|
+
const { promisify } = await import('node:util')
|
|
1688
|
+
const run = promisify(execFile)
|
|
1689
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-tree-'))
|
|
1690
|
+
try {
|
|
1691
|
+
await run('git', ['init', '-q'], { cwd: root })
|
|
1692
|
+
await writeFile(join(root, '.gitignore'), 'ignored/\n*.log\nempty-ignored/\n')
|
|
1693
|
+
await writeFile(join(root, 'a.java'), 'class A {}\n')
|
|
1694
|
+
await writeFile(join(root, 'debug.log'), 'noise\n')
|
|
1695
|
+
await mkdir(join(root, 'ignored'), { recursive: true })
|
|
1696
|
+
await writeFile(join(root, 'ignored', 'X.java'), 'class X {}\n')
|
|
1697
|
+
const java = join(root, 'src', 'main', 'java')
|
|
1698
|
+
const pkg = join(java, 'cn', 'com', 'ris')
|
|
1699
|
+
await mkdir(pkg, { recursive: true })
|
|
1700
|
+
await writeFile(join(pkg, 'Foo.java'), 'class Foo {}\n')
|
|
1701
|
+
// A tracked chain AND an EMPTY sibling package: the empty folder is invisible
|
|
1702
|
+
// to `git ls-files`, so it must still stop the fold (it used to be swallowed,
|
|
1703
|
+
// leaving one long `cn.com.ris.cloud...` row and hiding `test` entirely).
|
|
1704
|
+
await mkdir(join(pkg, 'cloud', 'admin', 'controller'), { recursive: true })
|
|
1705
|
+
await writeFile(join(pkg, 'cloud', 'admin', 'controller', 'Cloud.java'), 'class Cloud {}\n')
|
|
1706
|
+
await mkdir(join(pkg, 'test'), { recursive: true })
|
|
1707
|
+
// Freshly created package folders are EMPTY: git cannot see them, but the tree
|
|
1708
|
+
// must (unless gitignore hides them).
|
|
1709
|
+
await mkdir(join(java, 'empty'), { recursive: true })
|
|
1710
|
+
await mkdir(join(java, 'empty-ignored'), { recursive: true })
|
|
1711
|
+
// A single-child folder chain OUTSIDE java must stay as separate rows.
|
|
1712
|
+
await mkdir(join(root, 'docs', 'guide'), { recursive: true })
|
|
1713
|
+
await writeFile(join(root, 'docs', 'guide', 'readme.md'), '# guide\n')
|
|
1714
|
+
|
|
1715
|
+
const level = await __test.listIdeTree(root, root)
|
|
1716
|
+
const labels = level.entries.map((entry) => entry.label)
|
|
1717
|
+
assert.ok(labels.includes('a.java'), 'a tracked/untracked-but-not-ignored file shows')
|
|
1718
|
+
assert.equal(labels.includes('debug.log'), false, '*.log is ignored')
|
|
1719
|
+
assert.equal(labels.includes('ignored'), false, 'an ignored directory disappears')
|
|
1720
|
+
// src -> main -> java is a single-child chain, but it is NOT a package tail.
|
|
1721
|
+
assert.equal(labels.includes('src'), true, 'src/main/java is not compacted into one row')
|
|
1722
|
+
assert.equal(level.entries.some((entry) => entry.compact === true), false, 'nothing folds outside java')
|
|
1723
|
+
assert.equal(labels.includes('docs'), true, 'the docs chain is not compacted either')
|
|
1724
|
+
|
|
1725
|
+
// Descend the plain folders: no compaction until the `java` folder is entered.
|
|
1726
|
+
const src = level.entries.find((entry) => entry.label === 'src')
|
|
1727
|
+
assert.equal(src.compact, false, 'a plain folder is not a compact row')
|
|
1728
|
+
const main = await __test.listIdeTree(src.path, root)
|
|
1729
|
+
assert.deepEqual(main.entries.map((entry) => entry.label), ['main'])
|
|
1730
|
+
const javaLevel = await __test.listIdeTree(join(java), root)
|
|
1731
|
+
const javaLabels = javaLevel.entries.map((entry) => entry.label)
|
|
1732
|
+
assert.ok(javaLabels.includes('empty'), 'an EMPTY folder under java shows (git cannot see it)')
|
|
1733
|
+
assert.equal(javaLabels.includes('empty-ignored'), false, 'an empty but gitignored folder stays hidden')
|
|
1734
|
+
const chain = javaLevel.entries.find((entry) => entry.compact === true)
|
|
1735
|
+
assert.ok(chain !== undefined, 'below java the single-child package chain folds')
|
|
1736
|
+
assert.equal(chain.label, 'cn.com.ris')
|
|
1737
|
+
assert.equal(chain.path, pkg, 'the folded path is the expansion target')
|
|
1738
|
+
|
|
1739
|
+
// Inside the folded dir the boundary is normal again: the empty `test` package
|
|
1740
|
+
// still shows next to the tracked `cloud/...` chain.
|
|
1741
|
+
const inner = await __test.listIdeTree(chain.path, root)
|
|
1742
|
+
assert.deepEqual(inner.entries.map((entry) => entry.label), ['cloud.admin.controller', 'test', 'Foo.java'])
|
|
1743
|
+
assert.equal(inner.entries.find((entry) => entry.label === 'test').compact, false, 'an empty leaf is a plain row')
|
|
1744
|
+
assert.equal(inner.entries.find((entry) => entry.label === 'cloud.admin.controller').compact, true)
|
|
1745
|
+
} finally {
|
|
1746
|
+
await rm(root, { recursive: true, force: true })
|
|
1747
|
+
}
|
|
1748
|
+
})
|
|
1749
|
+
|
|
1750
|
+
test('a CRLF .gitignore with a trailing blank line does not hide empty folders', async () => {
|
|
1751
|
+
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises')
|
|
1752
|
+
const { execFile } = await import('node:child_process')
|
|
1753
|
+
const { promisify } = await import('node:util')
|
|
1754
|
+
const run = promisify(execFile)
|
|
1755
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-crlf-'))
|
|
1756
|
+
try {
|
|
1757
|
+
await run('git', ['init', '-q'], { cwd: root })
|
|
1758
|
+
// A Windows-style .gitignore that ends in a blank line: `git check-ignore`
|
|
1759
|
+
// then reports an EMPTY pattern matching every directory. Taking the bare
|
|
1760
|
+
// pathname from that answer hid `ris-upms-biz/src/main/java/cn/com/ris/test`.
|
|
1761
|
+
await writeFile(join(root, '.gitignore'), 'ignored/\r\n\r\n')
|
|
1762
|
+
const javaDir = join(root, 'src', 'main', 'java')
|
|
1763
|
+
const pkg = join(javaDir, 'cn', 'com', 'ris')
|
|
1764
|
+
await mkdir(join(pkg, 'test'), { recursive: true })
|
|
1765
|
+
await mkdir(join(pkg, 'ignored'), { recursive: true })
|
|
1766
|
+
await mkdir(join(pkg, 'cloud'), { recursive: true })
|
|
1767
|
+
await writeFile(join(pkg, 'cloud', 'Cloud.java'), 'class Cloud {}\n')
|
|
1768
|
+
|
|
1769
|
+
const javaLevel = await __test.listIdeTree(javaDir, root)
|
|
1770
|
+
const chain = javaLevel.entries.find((entry) => entry.compact === true)
|
|
1771
|
+
assert.ok(chain !== undefined, 'the package chain still folds')
|
|
1772
|
+
assert.equal(chain.label, 'cn.com.ris', 'the empty `test` sibling stops the fold at ris')
|
|
1773
|
+
assert.equal(chain.path, pkg)
|
|
1774
|
+
|
|
1775
|
+
const inner = await __test.listIdeTree(chain.path, root)
|
|
1776
|
+
const labels = inner.entries.map((entry) => entry.label)
|
|
1777
|
+
assert.ok(labels.includes('test'), 'the CRLF blank-line quirk must not hide an empty package')
|
|
1778
|
+
assert.equal(labels.includes('ignored'), false, 'a real gitignore pattern still hides its folder')
|
|
1779
|
+
} finally {
|
|
1780
|
+
await rm(root, { recursive: true, force: true })
|
|
1781
|
+
}
|
|
1782
|
+
})
|
|
1783
|
+
|
|
1784
|
+
test('findGitRoot walks up to the repo, and an unversioned tree lists raw', async () => {
|
|
1785
|
+
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises')
|
|
1786
|
+
const { execFile } = await import('node:child_process')
|
|
1787
|
+
const { promisify } = await import('node:util')
|
|
1788
|
+
const run = promisify(execFile)
|
|
1789
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-tree2-'))
|
|
1790
|
+
try {
|
|
1791
|
+
await run('git', ['init', '-q'], { cwd: root })
|
|
1792
|
+
const nested = join(root, 'a', 'b')
|
|
1793
|
+
await mkdir(nested, { recursive: true })
|
|
1794
|
+
await writeFile(join(nested, 'x.txt'), 'x')
|
|
1795
|
+
assert.equal(await __test.findGitRoot(nested, root), root)
|
|
1796
|
+
assert.equal(await __test.findGitRoot(nested, join(root, 'a')), undefined, 'the search stops at the limit')
|
|
1797
|
+
|
|
1798
|
+
// No repo anywhere -> unfiltered listing (nothing is "ignored").
|
|
1799
|
+
const plain = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-tree3-'))
|
|
1800
|
+
try {
|
|
1801
|
+
await writeFile(join(plain, 'y.txt'), 'y')
|
|
1802
|
+
const level = await __test.listIdeTree(plain, plain)
|
|
1803
|
+
assert.deepEqual(level.entries.map((entry) => entry.label), ['y.txt'])
|
|
1804
|
+
} finally {
|
|
1805
|
+
await rm(plain, { recursive: true, force: true })
|
|
1806
|
+
}
|
|
1807
|
+
} finally {
|
|
1808
|
+
await rm(root, { recursive: true, force: true })
|
|
1809
|
+
}
|
|
1810
|
+
})
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
// ── resource guard rails: memory floor + log rotation ───────────────────────
|
|
1814
|
+
test('availableMemoryBytes reports /proc data on Linux', async () => {
|
|
1815
|
+
const free = await __test.availableMemoryBytes()
|
|
1816
|
+
assert.equal(typeof free, 'number')
|
|
1817
|
+
if (process.platform === 'linux') assert.ok(free > 0, 'Linux exposes MemAvailable')
|
|
1818
|
+
assert.equal(__test.MIN_FREE_MEMORY_BYTES, 1024 * 1024 * 1024, 'the floor is 1 GiB')
|
|
1819
|
+
assert.equal(__test.LOG_ROTATE_BYTES, 32 * 1024 * 1024, 'logs rotate at 32 MiB')
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1822
|
+
test('assertEnoughMemory refuses only when the floor is not met', async () => {
|
|
1823
|
+
await __test.assertEnoughMemory(1) // 1 byte: always fine
|
|
1824
|
+
await assert.rejects(__test.assertEnoughMemory(Number.MAX_SAFE_INTEGER), /可用内存仅|拒绝启动/)
|
|
1825
|
+
})
|
|
1826
|
+
|
|
1827
|
+
test('rotateLogIfLarge keeps one backup and truncates the live file', async () => {
|
|
1828
|
+
const { mkdtemp, writeFile, stat, rm } = await import('node:fs/promises')
|
|
1829
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-rotate-'))
|
|
1830
|
+
try {
|
|
1831
|
+
const live = join(dir, 'svc.log')
|
|
1832
|
+
await writeFile(live, 'x'.repeat(4096))
|
|
1833
|
+
assert.equal(await __test.rotateLogIfLarge(live, 1024), true, 'a big log rotates')
|
|
1834
|
+
assert.equal((await stat(live)).size, 0, 'the live file is truncated in place (the JVM keeps its fd)')
|
|
1835
|
+
assert.equal((await stat(live + '.1')).size, 4096, 'the previous content is kept as .1')
|
|
1836
|
+
assert.equal(await __test.rotateLogIfLarge(live, 1024), false, 'a small log is left alone')
|
|
1837
|
+
assert.equal(await __test.rotateLogIfLarge(join(dir, 'missing.log')), false, 'a missing log is a no-op')
|
|
1838
|
+
} finally {
|
|
1839
|
+
await rm(dir, { recursive: true, force: true })
|
|
1840
|
+
}
|
|
1841
|
+
})
|
|
1842
|
+
|
|
1843
|
+
test('rotateActiveLogs rotates tracked logs and forgets missing ones', async () => {
|
|
1844
|
+
const { mkdtemp, writeFile, stat, rm } = await import('node:fs/promises')
|
|
1845
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-rotate2-'))
|
|
1846
|
+
try {
|
|
1847
|
+
const big = join(dir, 'big.log')
|
|
1848
|
+
const gone = join(dir, 'gone.log')
|
|
1849
|
+
await writeFile(big, 'y'.repeat(2048))
|
|
1850
|
+
__test.trackActiveLog(big)
|
|
1851
|
+
__test.trackActiveLog(gone) // never created → must be pruned, not thrown on
|
|
1852
|
+
|
|
1853
|
+
const rotated = await __test.rotateActiveLogs(1024)
|
|
1854
|
+
assert.ok(rotated.includes(big), 'the tracked big log was rotated by the periodic sweep')
|
|
1855
|
+
assert.equal((await stat(big)).size, 0)
|
|
1856
|
+
assert.equal((await stat(big + '.1')).size, 2048)
|
|
1857
|
+
|
|
1858
|
+
const again = await __test.rotateActiveLogs(1024)
|
|
1859
|
+
assert.equal(again.includes(big), false, 'a small log is skipped on the next sweep')
|
|
1860
|
+
assert.equal(again.includes(gone), false, 'a missing log is not reported as rotated')
|
|
1861
|
+
} finally {
|
|
1862
|
+
await rm(dir, { recursive: true, force: true })
|
|
1863
|
+
}
|
|
1864
|
+
})
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
// ── /debugs: the session list behind the IDE's auto-switch ──────────────────
|
|
1868
|
+
test('the debug session list route exists and starts empty', async () => {
|
|
1869
|
+
assert.equal(__test.DEBUGS_PATH, `${__test.ROUTE_PREFIX}/debugs`)
|
|
1870
|
+
const sessions = await import('../lib/debug/engine.js')
|
|
1871
|
+
const engine = sessions.createDebugSessions({ eventLimit: 10 })
|
|
1872
|
+
assert.deepEqual(engine.listSessions(), [], 'no sessions before anyone attaches')
|
|
1873
|
+
assert.equal(engine.size, 0)
|
|
1874
|
+
})
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
// ── ris_dev_debug state rendering (the agent's pre-flight snapshot) ─────────
|
|
1878
|
+
test('the debug state snapshot renders one readable line', () => {
|
|
1879
|
+
const line = __test.renderDebug({
|
|
1880
|
+
ok: true,
|
|
1881
|
+
program: { id: 'spring:x#a.B', name: 'ris-auth', kind: 'spring', logPath: '/w/logs/a.log' },
|
|
1882
|
+
running: { alive: true, pid: 445482, rssBytes: 650 * 1024 * 1024 },
|
|
1883
|
+
debuggable: true,
|
|
1884
|
+
debug: { attached: true, port: 51234, vmSuspended: true, breakpoints: [] },
|
|
1885
|
+
breakpoints: [
|
|
1886
|
+
{ requestId: 3, className: 'cn.com.ris.AuthApplication', line: 18 },
|
|
1887
|
+
{ requestId: 'pending', className: 'cn.com.ris.ModelHelper', line: 168, pending: true },
|
|
1888
|
+
],
|
|
1889
|
+
pendingBreakpoints: 1,
|
|
1890
|
+
})
|
|
1891
|
+
assert.match(line, /ris-auth \(spring\)/)
|
|
1892
|
+
assert.match(line, /running pid 445482/)
|
|
1893
|
+
assert.match(line, /attached port 51234 SUSPENDED/)
|
|
1894
|
+
assert.match(line, /cn\.com\.ris\.AuthApplication:18/)
|
|
1895
|
+
assert.match(line, /cn\.com\.ris\.ModelHelper:168\(预置\)/)
|
|
1896
|
+
})
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
// ── /stream: SSE delta reads ───────────────────────────────────────────────
|
|
1900
|
+
test('readFileRange returns exactly the appended bytes', async () => {
|
|
1901
|
+
const { writeFile, appendFile } = await import('node:fs/promises')
|
|
1902
|
+
const { mkdtemp } = await import('node:fs/promises')
|
|
1903
|
+
const { tmpdir } = await import('node:os')
|
|
1904
|
+
const { join } = await import('node:path')
|
|
1905
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-range-'))
|
|
1906
|
+
const file = join(dir, 'x.log')
|
|
1907
|
+
await writeFile(file, 'one\ntwo\n')
|
|
1908
|
+
const first = await __test.readFileRange(file, 0, 8)
|
|
1909
|
+
assert.equal(first, 'one\ntwo\n')
|
|
1910
|
+
await appendFile(file, 'three\n')
|
|
1911
|
+
const delta = await __test.readFileRange(file, 8, 14)
|
|
1912
|
+
assert.equal(delta, 'three\n', 'only the new bytes are returned')
|
|
1913
|
+
assert.equal(await __test.readFileRange(file, 14, 14), '')
|
|
1914
|
+
})
|
|
1915
|
+
|
|
1916
|
+
test('readTailWindow pages backwards with abutting, whole-line windows', async () => {
|
|
1917
|
+
const { mkdtemp, writeFile } = await import('node:fs/promises')
|
|
1918
|
+
const { tmpdir } = await import('node:os')
|
|
1919
|
+
const { join } = await import('node:path')
|
|
1920
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-window-'))
|
|
1921
|
+
const file = join(dir, 'svc.log')
|
|
1922
|
+
const lines = Array.from({ length: 400 }, (_, index) => `line ${index} ${'x'.repeat(40)}`)
|
|
1923
|
+
await writeFile(file, lines.join('\n') + '\n')
|
|
1924
|
+
|
|
1925
|
+
const newest = await __test.readTailWindow(file, 1500)
|
|
1926
|
+
assert.equal(newest.mode, 'tail')
|
|
1927
|
+
assert.equal(newest.more, true)
|
|
1928
|
+
assert.ok(newest.content.endsWith('\n'), 'the newest window ends on a whole line')
|
|
1929
|
+
const older = await __test.readTailWindow(file, 1500, newest.from)
|
|
1930
|
+
assert.equal(older.to, newest.from, 'the windows abut exactly')
|
|
1931
|
+
assert.equal(older.more, true)
|
|
1932
|
+
const joined = older.content + newest.content
|
|
1933
|
+
const expected = lines.slice(lines.length - (joined.split('\n').length - 1)).join('\n') + '\n'
|
|
1934
|
+
assert.equal(joined, expected, 'old + new is the exact tail, without duplicated or split lines')
|
|
1935
|
+
|
|
1936
|
+
// Paging all the way to the start stops reporting more.
|
|
1937
|
+
let cursor = older.from
|
|
1938
|
+
let pages = 2
|
|
1939
|
+
while (cursor > 0 && pages < 40) {
|
|
1940
|
+
const page = await __test.readTailWindow(file, 1500, cursor)
|
|
1941
|
+
cursor = page.from
|
|
1942
|
+
pages += 1
|
|
1943
|
+
if (page.from === 0) assert.equal(page.more, false)
|
|
1944
|
+
}
|
|
1945
|
+
assert.equal(cursor, 0, 'the oldest window starts at byte 0')
|
|
1946
|
+
assert.ok(pages > 2, 'this file needed several pages')
|
|
1947
|
+
})
|
|
1948
|
+
|
|
1949
|
+
test('the SSE stream route is advertised', () => {
|
|
1950
|
+
assert.equal(__test.STREAM_PATH, `${__test.ROUTE_PREFIX}/stream`)
|
|
1951
|
+
})
|
|
1952
|
+
|
|
1953
|
+
|
|
1954
|
+
test('the SSE stream pushes a tail snapshot, then append deltas', async () => {
|
|
1955
|
+
const { mkdtemp, writeFile, appendFile } = await import('node:fs/promises')
|
|
1956
|
+
const { tmpdir } = await import('node:os')
|
|
1957
|
+
const { join } = await import('node:path')
|
|
1958
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-sse-'))
|
|
1959
|
+
const file = join(dir, 'svc.log')
|
|
1960
|
+
await writeFile(file, 'hello\n')
|
|
1961
|
+
|
|
1962
|
+
const chunks = []
|
|
1963
|
+
const handlers = {}
|
|
1964
|
+
const res = {
|
|
1965
|
+
writeHead(status, headers) { this.status = status; this.headers = headers },
|
|
1966
|
+
flushHeaders() {},
|
|
1967
|
+
write(chunk) { chunks.push(String(chunk)); return true },
|
|
1968
|
+
end() { for (const fn of handlers.close ?? []) fn() },
|
|
1969
|
+
on(event, fn) { (handlers[event] ??= []).push(fn) },
|
|
1970
|
+
}
|
|
1971
|
+
const req = {
|
|
1972
|
+
method: 'GET',
|
|
1973
|
+
headers: { host: '127.0.0.1:3080' },
|
|
1974
|
+
url: `/dsh-ris-ide/stream?sessionId=s1&topics=tail&tail=${encodeURIComponent(file)}`,
|
|
1975
|
+
}
|
|
1976
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
1977
|
+
await __test.serveStream(ctx, req, res)
|
|
1978
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
1979
|
+
assert.equal(res.status, 200)
|
|
1980
|
+
assert.match(res.headers['content-type'], /text\/event-stream/)
|
|
1981
|
+
|
|
1982
|
+
const readEvents = () => chunks.join('').split('\n\n').filter(Boolean).map((block) => {
|
|
1983
|
+
const lines = block.split('\n')
|
|
1984
|
+
const event = (lines.find((line) => line.startsWith('event: ')) ?? 'event: message').slice(7)
|
|
1985
|
+
const dataLine = lines.find((line) => line.startsWith('data: '))
|
|
1986
|
+
return { event, data: dataLine === undefined ? undefined : JSON.parse(dataLine.slice(6)) }
|
|
1987
|
+
})
|
|
1988
|
+
let events = readEvents()
|
|
1989
|
+
assert.ok(events.some((item) => item.event === 'hello'), 'the stream opens with hello')
|
|
1990
|
+
const snapshot = events.find((item) => item.event === 'tail')
|
|
1991
|
+
assert.ok(snapshot !== undefined, 'a tail snapshot is pushed first')
|
|
1992
|
+
assert.equal(snapshot.data.content, 'hello\n')
|
|
1993
|
+
|
|
1994
|
+
await appendFile(file, 'world\n')
|
|
1995
|
+
await new Promise((resolve) => setTimeout(resolve, 700))
|
|
1996
|
+
events = readEvents()
|
|
1997
|
+
const appended = events.filter((item) => item.event === 'append')
|
|
1998
|
+
assert.ok(appended.length > 0, 'appended bytes are pushed')
|
|
1999
|
+
assert.equal(appended.map((item) => item.data.text).join(''), 'world\n')
|
|
2000
|
+
|
|
2001
|
+
res.end()
|
|
2002
|
+
})
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
// ── a stale session id must not blank the panel: the client's cwd is enough ──
|
|
2006
|
+
test('a stale session id still answers when the client sends an absolute cwd', async () => {
|
|
2007
|
+
const { mkdtemp } = await import('node:fs/promises')
|
|
2008
|
+
const { tmpdir } = await import('node:os')
|
|
2009
|
+
const { join } = await import('node:path')
|
|
2010
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-stale-'))
|
|
2011
|
+
const staleCtx = { sessions: { get: () => undefined } }
|
|
2012
|
+
const read = async (url) => {
|
|
2013
|
+
const chunks = []
|
|
2014
|
+
const res = { status: 0, writeHead(s) { this.status = s }, write(c) { chunks.push(String(c)); return true }, end(c) { if (c) chunks.push(String(c)) } }
|
|
2015
|
+
await __test.serveTree(staleCtx, { method: 'GET', headers: { host: '127.0.0.1:3080' }, url }, res)
|
|
2016
|
+
return { status: res.status, body: chunks.join('') }
|
|
2017
|
+
}
|
|
2018
|
+
const without = await read(`/dsh-ris-ide/tree?sessionId=session-gone&path=.`)
|
|
2019
|
+
assert.equal(without.status, 400, 'no cwd and no session: rejected')
|
|
2020
|
+
const withCwd = await read(`/dsh-ris-ide/tree?sessionId=session-gone&cwd=${encodeURIComponent(dir)}&path=.`)
|
|
2021
|
+
assert.equal(withCwd.status, 200, 'cwd alone is enough')
|
|
2022
|
+
assert.match(withCwd.body, /"ok":true/)
|
|
2023
|
+
})
|
|
2024
|
+
|
|
2025
|
+
// ── a fresh tree read must show a file created after the last listing ──
|
|
2026
|
+
test('fresh=1 re-reads the git-visible set so a just-copied file appears', async () => {
|
|
2027
|
+
const { mkdtemp, writeFile } = await import('node:fs/promises')
|
|
2028
|
+
const { tmpdir } = await import('node:os')
|
|
2029
|
+
const { join } = await import('node:path')
|
|
2030
|
+
const { execFileSync } = await import('node:child_process')
|
|
2031
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-fresh-'))
|
|
2032
|
+
execFileSync('git', ['init', '-q'], { cwd: dir })
|
|
2033
|
+
await writeFile(join(dir, 'a.txt'), 'a\n')
|
|
2034
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
2035
|
+
const list = async (fresh) => {
|
|
2036
|
+
const chunks = []
|
|
2037
|
+
const res = { writeHead() {}, write(c) { chunks.push(String(c)); return true }, end(c) { if (c) chunks.push(String(c)) } }
|
|
2038
|
+
const query = `sessionId=s1&path=${encodeURIComponent(dir)}` + (fresh ? '&fresh=1' : '')
|
|
2039
|
+
await __test.serveTree(ctx, { method: 'GET', headers: { host: '127.0.0.1:3080' }, url: `/dsh-ris-ide/tree?${query}` }, res)
|
|
2040
|
+
const payload = JSON.parse(chunks.join(''))
|
|
2041
|
+
return payload.entries.map((entry) => entry.name)
|
|
2042
|
+
}
|
|
2043
|
+
const first = await list(false)
|
|
2044
|
+
assert.ok(first.includes('a.txt'))
|
|
2045
|
+
// the copy lands after the listing cache was built
|
|
2046
|
+
await writeFile(join(dir, 'a - 副本.txt'), 'a\n')
|
|
2047
|
+
const stale = await list(false)
|
|
2048
|
+
assert.equal(stale.includes('a - 副本.txt'), false, 'cached listing hides the new copy')
|
|
2049
|
+
const fresh = await list(true)
|
|
2050
|
+
assert.ok(fresh.includes('a - 副本.txt'), 'fresh=1 reveals it')
|
|
2051
|
+
})
|
|
2052
|
+
|
|
2053
|
+
test('filter=all shows gitignored files, the default hides them', async () => {
|
|
2054
|
+
const { mkdtemp, mkdir, writeFile } = await import('node:fs/promises')
|
|
2055
|
+
const { tmpdir } = await import('node:os')
|
|
2056
|
+
const { join } = await import('node:path')
|
|
2057
|
+
const { execFileSync } = await import('node:child_process')
|
|
2058
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-filter-'))
|
|
2059
|
+
execFileSync('git', ['init', '-q'], { cwd: dir })
|
|
2060
|
+
await writeFile(join(dir, '.gitignore'), 'secret.txt\nignored-dir/\n')
|
|
2061
|
+
await writeFile(join(dir, 'visible.txt'), 'v\n')
|
|
2062
|
+
await writeFile(join(dir, 'secret.txt'), 's\n')
|
|
2063
|
+
await mkdir(join(dir, 'ignored-dir'))
|
|
2064
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
2065
|
+
const list = async (extra) => {
|
|
2066
|
+
const chunks = []
|
|
2067
|
+
const res = { writeHead() {}, write(c) { chunks.push(String(c)); return true }, end(c) { if (c) chunks.push(String(c)) } }
|
|
2068
|
+
const query = `sessionId=s1&cwd=${encodeURIComponent(dir)}&path=${encodeURIComponent(dir)}${extra}`
|
|
2069
|
+
await __test.serveTree(ctx, { method: 'GET', headers: { host: '127.0.0.1:3080' }, url: `/dsh-ris-ide/tree?${query}` }, res)
|
|
2070
|
+
return JSON.parse(chunks.join(''))
|
|
2071
|
+
}
|
|
2072
|
+
const filtered = await list('')
|
|
2073
|
+
const filteredNames = filtered.entries.map((entry) => entry.name)
|
|
2074
|
+
assert.ok(filteredNames.includes('visible.txt'))
|
|
2075
|
+
assert.equal(filteredNames.includes('secret.txt'), false, 'the default hides gitignored files')
|
|
2076
|
+
assert.equal(filteredNames.includes('ignored-dir'), false)
|
|
2077
|
+
assert.equal(filtered.git, true, 'the client needs to know git is what is filtering')
|
|
2078
|
+
assert.equal(filtered.filtered, true)
|
|
2079
|
+
|
|
2080
|
+
// The title-bar eye toggles this: everything comes back, hidden dirs included.
|
|
2081
|
+
const all = await list('&filter=all')
|
|
2082
|
+
const allNames = all.entries.map((entry) => entry.name)
|
|
2083
|
+
assert.ok(allNames.includes('secret.txt'))
|
|
2084
|
+
assert.ok(allNames.includes('ignored-dir'))
|
|
2085
|
+
assert.equal(all.filtered, false)
|
|
2086
|
+
assert.equal(all.git, true, 'git is still available, so the toggle can be turned back on')
|
|
2087
|
+
})
|
|
2088
|
+
|
|
2089
|
+
// ── the tree topic: filesystem changes reach the browser without a manual ⟳ ──
|
|
2090
|
+
test('the tree topic pushes changed relative paths from a recursive watch', async () => {
|
|
2091
|
+
const { mkdtemp, mkdir, writeFile } = await import('node:fs/promises')
|
|
2092
|
+
const { tmpdir } = await import('node:os')
|
|
2093
|
+
const { join } = await import('node:path')
|
|
2094
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-tree-'))
|
|
2095
|
+
await mkdir(join(dir, 'svc', 'src'), { recursive: true })
|
|
2096
|
+
|
|
2097
|
+
const chunks = []
|
|
2098
|
+
const handlers = {}
|
|
2099
|
+
let ended = false
|
|
2100
|
+
const res = {
|
|
2101
|
+
writeHead(status, headers) { this.status = status; this.headers = headers },
|
|
2102
|
+
flushHeaders() {},
|
|
2103
|
+
write(chunk) { if (ended) return false; chunks.push(String(chunk)); return true },
|
|
2104
|
+
end() { if (ended) return; ended = true; for (const fn of handlers.close ?? []) fn() },
|
|
2105
|
+
on(event, fn) { (handlers[event] ??= []).push(fn) },
|
|
2106
|
+
}
|
|
2107
|
+
const req = {
|
|
2108
|
+
method: 'GET',
|
|
2109
|
+
headers: { host: '127.0.0.1:3080' },
|
|
2110
|
+
url: `/dsh-ris-ide/stream?sessionId=s1&topics=tree`,
|
|
2111
|
+
}
|
|
2112
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
2113
|
+
await __test.serveStream(ctx, req, res)
|
|
2114
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
2115
|
+
assert.equal(res.status, 200)
|
|
2116
|
+
|
|
2117
|
+
// A directory earns a watcher when the client lists it (that is "it is open").
|
|
2118
|
+
const listChunks = []
|
|
2119
|
+
await __test.serveTree(ctx, {
|
|
2120
|
+
method: 'GET',
|
|
2121
|
+
headers: { host: '127.0.0.1:3080' },
|
|
2122
|
+
url: `/dsh-ris-ide/tree?sessionId=s1&path=${encodeURIComponent(join(dir, 'svc', 'src'))}`,
|
|
2123
|
+
}, { writeHead(status, headers) { this.status = status; this.headers = headers }, write(c) { listChunks.push(String(c)); return true }, end(c) { if (c) listChunks.push(String(c)) } })
|
|
2124
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
2125
|
+
|
|
2126
|
+
await writeFile(join(dir, 'svc', 'src', 'Probe.java'), 'class Probe {}\n')
|
|
2127
|
+
// TREE_DEBOUNCE_MS is 800ms on purpose (resource guard); leave headroom.
|
|
2128
|
+
await new Promise((resolve) => setTimeout(resolve, 1600))
|
|
2129
|
+
const events = chunks.join('').split('\n\n').filter(Boolean).map((block) => {
|
|
2130
|
+
const lines = block.split('\n')
|
|
2131
|
+
return {
|
|
2132
|
+
event: (lines.find((line) => line.startsWith('event: ')) ?? 'event: message').slice(7),
|
|
2133
|
+
data: (() => {
|
|
2134
|
+
const line = lines.find((item) => item.startsWith('data: '))
|
|
2135
|
+
return line === undefined ? undefined : JSON.parse(line.slice(6))
|
|
2136
|
+
})(),
|
|
2137
|
+
}
|
|
2138
|
+
})
|
|
2139
|
+
const treeEvents = events.filter((item) => item.event === 'tree')
|
|
2140
|
+
assert.ok(treeEvents.length > 0, 'a tree frame is pushed after the write')
|
|
2141
|
+
const frame = treeEvents.find((item) => Array.isArray(item.data?.changed) && item.data.changed.length > 0)
|
|
2142
|
+
assert.ok(frame !== undefined, 'the frame carries changed paths')
|
|
2143
|
+
assert.ok(frame.data.changed.some((path) => String(path).includes('Probe.java')), 'the new file is named')
|
|
2144
|
+
assert.equal(frame.data.watched, true)
|
|
2145
|
+
res.end()
|
|
2146
|
+
})
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
// ── filename search must never answer "nothing" just because cwd is a subdir ──
|
|
2150
|
+
test('searchWorkspaceFiles climbs to the enclosing repo and falls back to a raw walk', async () => {
|
|
2151
|
+
const { mkdtemp, mkdir, writeFile } = await import('node:fs/promises')
|
|
2152
|
+
const { tmpdir } = await import('node:os')
|
|
2153
|
+
const { join } = await import('node:path')
|
|
2154
|
+
const { execFileSync } = await import('node:child_process')
|
|
2155
|
+
|
|
2156
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-search-'))
|
|
2157
|
+
await execFileSync('git', ['init', '-q'], { cwd: root })
|
|
2158
|
+
await mkdir(join(root, 'svc', 'src'), { recursive: true })
|
|
2159
|
+
await writeFile(join(root, 'svc', 'src', 'RisAuthApplication.java'), 'class RisAuthApplication {}\n')
|
|
2160
|
+
await writeFile(join(root, 'README.md'), '# x\n')
|
|
2161
|
+
await execFileSync('git', ['add', '-A'], { cwd: root })
|
|
2162
|
+
|
|
2163
|
+
// A subdirectory of a repo (the "opened a program directory" case): climbing finds it.
|
|
2164
|
+
const nested = await __test.searchWorkspaceFiles(join(root, 'svc', 'src'), 'risauth', 10)
|
|
2165
|
+
assert.equal(nested.roots.length, 1)
|
|
2166
|
+
assert.ok(nested.matches.some((path) => path.endsWith('RisAuthApplication.java')))
|
|
2167
|
+
|
|
2168
|
+
// No git anywhere: the raw walk still finds the file.
|
|
2169
|
+
const bare = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-bare-'))
|
|
2170
|
+
await mkdir(join(bare, 'a', 'b'), { recursive: true })
|
|
2171
|
+
await writeFile(join(bare, 'a', 'b', 'ModelHelper.java'), 'class ModelHelper {}\n')
|
|
2172
|
+
const raw = await __test.searchWorkspaceFiles(bare, 'modelhelper', 10)
|
|
2173
|
+
assert.ok(raw.matches.some((path) => path.endsWith('ModelHelper.java')), 'raw walk fallback')
|
|
2174
|
+
})
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
// ── our own workspace file API (no dsh-better-sidebar) ─────────────────────
|
|
2178
|
+
test('the fs routes read text, refuse binary/oversized and write back', async () => {
|
|
2179
|
+
const { mkdtemp, writeFile: write } = await import('node:fs/promises')
|
|
2180
|
+
const { tmpdir } = await import('node:os')
|
|
2181
|
+
const { join } = await import('node:path')
|
|
2182
|
+
const { Readable } = await import('node:stream')
|
|
2183
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-fs-'))
|
|
2184
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
2185
|
+
|
|
2186
|
+
/** Drive one route handler with fake req/res. */
|
|
2187
|
+
async function call(pathname, { query = {}, body } = {}) {
|
|
2188
|
+
const chunks = []
|
|
2189
|
+
const res = {
|
|
2190
|
+
status: 0,
|
|
2191
|
+
headers: {},
|
|
2192
|
+
writeHead(status, headers) { this.status = status; this.headers = headers },
|
|
2193
|
+
write(chunk) { chunks.push(String(chunk)); return true },
|
|
2194
|
+
end(chunk) { if (chunk !== undefined && chunk !== null) chunks.push(String(chunk)) },
|
|
2195
|
+
on() {},
|
|
2196
|
+
}
|
|
2197
|
+
const url = `/dsh-ris-ide/${pathname}?` + new URLSearchParams(query).toString()
|
|
2198
|
+
const req = body === undefined
|
|
2199
|
+
? { method: 'GET', url, headers: { host: '127.0.0.1:3080' } }
|
|
2200
|
+
: Object.assign(Readable.from([JSON.stringify(body)]), { method: 'POST', url, headers: { host: '127.0.0.1:3080' } })
|
|
2201
|
+
await __test.serveFs(ctx, req, res, {})
|
|
2202
|
+
const text = chunks.join('')
|
|
2203
|
+
return { status: res.status, body: text === '' ? undefined : JSON.parse(text) }
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
const textFile = join(dir, 'A.java')
|
|
2207
|
+
await write(textFile, 'class A {}\n')
|
|
2208
|
+
const read = await call('fs/read', { query: { sessionId: 's1', path: textFile } })
|
|
2209
|
+
assert.equal(read.status, 200)
|
|
2210
|
+
assert.equal(read.body.kind, 'text')
|
|
2211
|
+
assert.equal(read.body.content, 'class A {}\n')
|
|
2212
|
+
|
|
2213
|
+
const binaryFile = join(dir, 'x.bin')
|
|
2214
|
+
await write(binaryFile, Buffer.from([0x00, 0x01, 0x02]))
|
|
2215
|
+
assert.equal((await call('fs/read', { query: { sessionId: 's1', path: binaryFile } })).body.kind, 'binary')
|
|
2216
|
+
|
|
2217
|
+
const bigFile = join(dir, 'big.txt')
|
|
2218
|
+
await write(bigFile, 'x'.repeat(600 * 1024))
|
|
2219
|
+
assert.equal((await call('fs/read', { query: { sessionId: 's1', path: bigFile } })).body.kind, 'too-large')
|
|
2220
|
+
|
|
2221
|
+
const written = await call('fs/write', { body: { sessionId: 's1', path: textFile, content: 'class A { int x; }\n' } })
|
|
2222
|
+
assert.equal(written.status, 200)
|
|
2223
|
+
assert.equal(written.body.ok, true)
|
|
2224
|
+
assert.equal((await call('fs/read', { query: { sessionId: 's1', path: textFile } })).body.content, 'class A { int x; }\n')
|
|
2225
|
+
})
|
|
2226
|
+
|
|
2227
|
+
// ── a browser that cancels an image/file stream must not kill the host ─────
|
|
2228
|
+
test('route bodies can never escape as uncaught exceptions', async () => {
|
|
2229
|
+
// 1) A route body that throws SYNCHRONOUSLY (not just a rejected promise) is
|
|
2230
|
+
// answered with 500 instead of throwing out of the HTTP handler.
|
|
2231
|
+
const chunks = []
|
|
2232
|
+
const res = {
|
|
2233
|
+
status: 0, headers: {}, headersSent: false, writableEnded: false, destroyed: false,
|
|
2234
|
+
writeHead(status, headers) { this.status = status; this.headers = headers; this.headersSent = true },
|
|
2235
|
+
end(text) { chunks.push(String(text)); this.writableEnded = true },
|
|
2236
|
+
destroy() { this.destroyed = true },
|
|
2237
|
+
}
|
|
2238
|
+
__test.serveSafely(res, () => { throw new Error('sync boom') })
|
|
2239
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
2240
|
+
assert.equal(res.status, 500)
|
|
2241
|
+
assert.match(chunks.join(''), /sync boom/)
|
|
2242
|
+
|
|
2243
|
+
// 2) A response that throws while writing (dead socket) must not throw back.
|
|
2244
|
+
const dead = {
|
|
2245
|
+
headersSent: false, writableEnded: false, destroyed: false,
|
|
2246
|
+
writeHead() { throw new Error('socket is gone') },
|
|
2247
|
+
end() { throw new Error('socket is gone') },
|
|
2248
|
+
destroy() { this.destroyed = true },
|
|
2249
|
+
}
|
|
2250
|
+
__test.writeTailError(dead, new Error('after destroy'))
|
|
2251
|
+
assert.equal(dead.destroyed, true)
|
|
2252
|
+
|
|
2253
|
+
// 3) handleRequest's own fence: routing on a half-dead response never throws.
|
|
2254
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: process.cwd() } }) } }
|
|
2255
|
+
assert.doesNotThrow(() => {
|
|
2256
|
+
__test.handleRequest(ctx, { method: 'GET', url: '/dsh-ris-ide/nope', headers: { host: '127.0.0.1:1' } }, dead, {})
|
|
2257
|
+
})
|
|
2258
|
+
})
|
|
2259
|
+
|
|
2260
|
+
test('a cancelled file stream is destroyed, never answered a second time', async () => {
|
|
2261
|
+
const { mkdtemp, writeFile: write } = await import('node:fs/promises')
|
|
2262
|
+
const { tmpdir } = await import('node:os')
|
|
2263
|
+
const { join } = await import('node:path')
|
|
2264
|
+
const { Writable } = await import('node:stream')
|
|
2265
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-abort-'))
|
|
2266
|
+
const image = join(dir, 'shot.png')
|
|
2267
|
+
await write(image, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]))
|
|
2268
|
+
|
|
2269
|
+
// The fake response starts a 200 (the stream headers), then dies on the first
|
|
2270
|
+
// byte — exactly what an aborted img request looks like to the host.
|
|
2271
|
+
let writeHeads = 0
|
|
2272
|
+
let destroyedByGuard = false
|
|
2273
|
+
const res = new Writable({
|
|
2274
|
+
write(chunk, encoding, callback) { callback(new Error('client went away')) },
|
|
2275
|
+
})
|
|
2276
|
+
res.headersSent = false
|
|
2277
|
+
res.writeHead = function (status) {
|
|
2278
|
+
writeHeads += 1
|
|
2279
|
+
if (writeHeads > 1) throw new Error('headers already sent')
|
|
2280
|
+
this.headersSent = true
|
|
2281
|
+
this.status = status
|
|
2282
|
+
}
|
|
2283
|
+
const originalDestroy = res.destroy.bind(res)
|
|
2284
|
+
res.destroy = function (error) { destroyedByGuard = true; return originalDestroy(error) }
|
|
2285
|
+
|
|
2286
|
+
const req = { method: 'GET', headers: { host: '127.0.0.1:3080' }, url: `/dsh-ris-ide/fs/file?sessionId=s1&path=${encodeURIComponent(image)}` }
|
|
2287
|
+
// Before the guard this rejected (writeTailError → writeHead on a sent response),
|
|
2288
|
+
// which surfaced as `fatal load failure: ERR_HTTP_HEADERS_SENT` and killed dsh.
|
|
2289
|
+
await __test.serveFs({ sessions: { get: () => ({ header: { cwd: dir } }) } }, req, res, {})
|
|
2290
|
+
assert.equal(writeHeads, 1, 'the headers went out exactly once')
|
|
2291
|
+
assert.equal(res.status, 200)
|
|
2292
|
+
assert.equal(destroyedByGuard, true, 'the aborted response is destroyed, not re-answered')
|
|
2293
|
+
})
|
|
2294
|
+
|
|
2295
|
+
test('a response that already started is never written again', async () => {
|
|
2296
|
+
// writeTailError itself must be a no-op on a sent response.
|
|
2297
|
+
const sent = { headersSent: true, writableEnded: false, destroyed: false, writeHead() { throw new Error('must not reach writeHead') }, destroy() { this.destroyed = true } }
|
|
2298
|
+
assert.doesNotThrow(() => __test.writeTailError(sent, new Error('boom')))
|
|
2299
|
+
assert.equal(sent.destroyed, true)
|
|
2300
|
+
|
|
2301
|
+
// serveSafely answers a fresh response as JSON, and destroys a started one.
|
|
2302
|
+
const chunks = []
|
|
2303
|
+
const fresh = {
|
|
2304
|
+
status: 0,
|
|
2305
|
+
headers: {},
|
|
2306
|
+
headersSent: false,
|
|
2307
|
+
writableEnded: false,
|
|
2308
|
+
destroyed: false,
|
|
2309
|
+
writeHead(status, headers) { this.status = status; this.headers = headers; this.headersSent = true },
|
|
2310
|
+
end(text) { chunks.push(String(text)); this.writableEnded = true },
|
|
2311
|
+
destroy() { this.destroyed = true },
|
|
2312
|
+
}
|
|
2313
|
+
await __test.serveSafely(fresh, async () => { throw new Error('boom') })
|
|
2314
|
+
assert.equal(fresh.status, 500)
|
|
2315
|
+
assert.equal(JSON.parse(chunks.join('')).ok, false)
|
|
2316
|
+
|
|
2317
|
+
const started = {
|
|
2318
|
+
headersSent: true,
|
|
2319
|
+
writableEnded: false,
|
|
2320
|
+
destroyed: false,
|
|
2321
|
+
writeHead() { throw new Error('must not reach writeHead') },
|
|
2322
|
+
end() { throw new Error('must not end') },
|
|
2323
|
+
destroy() { this.destroyed = true },
|
|
2324
|
+
}
|
|
2325
|
+
await __test.serveSafely(started, async () => { throw new Error('boom') })
|
|
2326
|
+
assert.equal(started.destroyed, true)
|
|
2327
|
+
})
|
|
2328
|
+
|
|
2329
|
+
|
|
2330
|
+
// ── lazy client chunks (the CodeMirror editor bundle) ──────────────────────
|
|
2331
|
+
test('the bundle route serves the built CodeMirror chunk and refuses other names', async () => {
|
|
2332
|
+
async function call(query) {
|
|
2333
|
+
const chunks = []
|
|
2334
|
+
const res = {
|
|
2335
|
+
status: 0,
|
|
2336
|
+
headers: {},
|
|
2337
|
+
writeHead(status, headers) { this.status = status; this.headers = headers },
|
|
2338
|
+
write(chunk) { chunks.push(String(chunk)); return true },
|
|
2339
|
+
end(chunk) { if (chunk !== undefined && chunk !== null) chunks.push(String(chunk)) },
|
|
2340
|
+
on() {},
|
|
2341
|
+
}
|
|
2342
|
+
const req = { method: 'GET', url: `/dsh-ris-ide/bundle?${new URLSearchParams(query).toString()}`, headers: { host: '127.0.0.1:3080' } }
|
|
2343
|
+
await __test.serveBundle(req, res)
|
|
2344
|
+
return { status: res.status, headers: res.headers, body: chunks.join('') }
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
const chunk = await call({ name: 'codemirror' })
|
|
2348
|
+
assert.equal(chunk.status, 200)
|
|
2349
|
+
assert.match(chunk.headers['content-type'], /text\/javascript/)
|
|
2350
|
+
assert.match(chunk.body.slice(0, 200), /globalThis\.__dshRisIdeChunks__/, 'the chunk parks its factory on the plugin registry')
|
|
2351
|
+
assert.match(chunk.body, /exports\.mount = mount/, 'and exports the editor mount')
|
|
2352
|
+
|
|
2353
|
+
const unknown = await call({ name: 'nope' })
|
|
2354
|
+
assert.equal(unknown.status, 404)
|
|
2355
|
+
assert.match(unknown.body, /unknown chunk/)
|
|
2356
|
+
})
|
|
2357
|
+
|
|
2358
|
+
|
|
2359
|
+
// ── the watch filter must use the tree's own ignore rules (no git per event) ──
|
|
2360
|
+
test('treeWatchAccepts keeps visible files and drops gitignored churn', async () => {
|
|
2361
|
+
const { mkdtemp, mkdir, writeFile } = await import('node:fs/promises')
|
|
2362
|
+
const { tmpdir } = await import('node:os')
|
|
2363
|
+
const { join } = await import('node:path')
|
|
2364
|
+
const { execFileSync } = await import('node:child_process')
|
|
2365
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-watch-'))
|
|
2366
|
+
await execFileSync('git', ['init', '-q'], { cwd: root })
|
|
2367
|
+
await writeFile(join(root, '.gitignore'), 'logs/\ntarget/\n')
|
|
2368
|
+
await mkdir(join(root, 'src'), { recursive: true })
|
|
2369
|
+
await writeFile(join(root, 'src', 'A.java'), 'class A {}\n')
|
|
2370
|
+
await mkdir(join(root, 'logs'), { recursive: true })
|
|
2371
|
+
await writeFile(join(root, 'logs', 'svc.log'), 'tick\n')
|
|
2372
|
+
|
|
2373
|
+
const accepted = await __test.treeWatchAccepts(root, ['src/A.java', 'logs/svc.log', 'target/x/classes/Y.class', '.git/refs/heads/main'])
|
|
2374
|
+
assert.deepEqual(accepted, ['src/A.java'], 'only the visible file survives')
|
|
2375
|
+
})
|
|
2376
|
+
|
|
2377
|
+
test('SSE frames are dropped when the socket is backed up', () => {
|
|
2378
|
+
assert.equal(__test.shouldDropSseFrame(0), false)
|
|
2379
|
+
assert.equal(__test.shouldDropSseFrame(undefined), false)
|
|
2380
|
+
assert.equal(__test.shouldDropSseFrame(__test.SSE_MAX_BUFFER_BYTES), false)
|
|
2381
|
+
assert.equal(__test.shouldDropSseFrame(__test.SSE_MAX_BUFFER_BYTES + 1), true)
|
|
2382
|
+
})
|
|
2383
|
+
|
|
2384
|
+
// ── undo trash: hidden from the tree, and restorable by an explicit name ─────
|
|
2385
|
+
test('the undo trash is never a tree row and move honours an explicit name', async () => {
|
|
2386
|
+
const { mkdtemp, mkdir, readdir, writeFile } = await import('node:fs/promises')
|
|
2387
|
+
const { tmpdir } = await import('node:os')
|
|
2388
|
+
const { join } = await import('node:path')
|
|
2389
|
+
const { Readable } = await import('node:stream')
|
|
2390
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-trash-'))
|
|
2391
|
+
await mkdir(join(dir, 'src'), { recursive: true })
|
|
2392
|
+
await writeFile(join(dir, 'src', 'A.java'), 'class A {}\n')
|
|
2393
|
+
|
|
2394
|
+
const treeChunks = []
|
|
2395
|
+
const treeRes = { writeHead(status, headers) { this.status = status; this.headers = headers }, write(c) { treeChunks.push(String(c)); return true }, end(c) { if (c !== undefined && c !== null) treeChunks.push(String(c)) } }
|
|
2396
|
+
const treeReq = { method: 'GET', headers: { host: '127.0.0.1:3080' }, url: '/dsh-ris-ide/tree?sessionId=s1' }
|
|
2397
|
+
await __test.serveTree({ sessions: { get: () => ({ header: { cwd: dir } }) } }, treeReq, treeRes)
|
|
2398
|
+
const entries = JSON.parse(treeChunks.join('')).entries.map((entry) => entry.name)
|
|
2399
|
+
assert.deepEqual(entries, ['src'], 'the trash never appears as a row')
|
|
2400
|
+
|
|
2401
|
+
// move A.java into the trash under a parked name, then move it back by name
|
|
2402
|
+
async function op(pathname, body) {
|
|
2403
|
+
const chunks = []
|
|
2404
|
+
const res = { writeHead(status, headers) { this.status = status; this.headers = headers }, write(c) { chunks.push(String(c)); return true }, end(c) { if (c !== undefined) chunks.push(String(c)) }, on() {} }
|
|
2405
|
+
const req = Object.assign(Readable.from([JSON.stringify(body)]), { method: 'POST', url: `/dsh-ris-ide/${pathname}`, headers: { host: '127.0.0.1:3080' } })
|
|
2406
|
+
await __test.serveFsOp({ sessions: { get: () => ({ header: { cwd: dir } }) } }, req, res, {})
|
|
2407
|
+
return { status: res.status, body: JSON.parse(chunks.join('') || 'null') }
|
|
2408
|
+
}
|
|
2409
|
+
await op('fs/mkdir', { sessionId: 's1', path: join(dir, '.dsh-ris-ide-trash') })
|
|
2410
|
+
const parked = await op('fs/move', { sessionId: 's1', from: join(dir, 'src', 'A.java'), toDir: join(dir, '.dsh-ris-ide-trash'), name: '1730-A.java' })
|
|
2411
|
+
assert.equal(parked.status, 200)
|
|
2412
|
+
assert.equal(parked.body.path, join(dir, '.dsh-ris-ide-trash', '1730-A.java'))
|
|
2413
|
+
const restored = await op('fs/move', { sessionId: 's1', from: join(dir, '.dsh-ris-ide-trash', '1730-A.java'), toDir: join(dir, 'src'), name: 'A.java' })
|
|
2414
|
+
assert.equal(restored.status, 200)
|
|
2415
|
+
assert.equal(restored.body.path, join(dir, 'src', 'A.java'))
|
|
2416
|
+
assert.equal((await readdir(join(dir, 'src'))).includes('A.java'), true)
|
|
2417
|
+
assert.equal((await op('fs/move', { sessionId: 's1', from: join(dir, 'src', 'A.java'), toDir: join(dir, 'src'), name: '../evil' })).status, 400)
|
|
2418
|
+
})
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
// ── file-tree mutations (menu / shortcuts / drag&drop) ─────────────────────
|
|
2422
|
+
test('fs op routes create, copy, move and remove without clobbering', async () => {
|
|
2423
|
+
const { mkdtemp, mkdir, readdir, readFile, writeFile: write } = await import('node:fs/promises')
|
|
2424
|
+
const { tmpdir } = await import('node:os')
|
|
2425
|
+
const { join } = await import('node:path')
|
|
2426
|
+
const { Readable } = await import('node:stream')
|
|
2427
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-ops-'))
|
|
2428
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
2429
|
+
|
|
2430
|
+
async function call(pathname, body) {
|
|
2431
|
+
const chunks = []
|
|
2432
|
+
const res = {
|
|
2433
|
+
status: 0,
|
|
2434
|
+
writeHead(status, headers) { this.status = status; this.headers = headers },
|
|
2435
|
+
write(chunk) { chunks.push(String(chunk)); return true },
|
|
2436
|
+
end(chunk) { if (chunk !== undefined && chunk !== null) chunks.push(String(chunk)) },
|
|
2437
|
+
on() {},
|
|
2438
|
+
}
|
|
2439
|
+
const req = Object.assign(Readable.from([JSON.stringify(body)]), {
|
|
2440
|
+
method: 'POST',
|
|
2441
|
+
url: `/dsh-ris-ide/${pathname}`,
|
|
2442
|
+
headers: { host: '127.0.0.1:3080' },
|
|
2443
|
+
})
|
|
2444
|
+
await __test.serveFsOp(ctx, req, res, {})
|
|
2445
|
+
const text = chunks.join('')
|
|
2446
|
+
return { status: res.status, body: text === '' ? undefined : JSON.parse(text) }
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
const pkgDir = join(dir, 'src/main/java/cn/com/demo')
|
|
2450
|
+
assert.equal((await call('fs/mkdir', { sessionId: 's1', cwd: dir, path: pkgDir })).status, 200)
|
|
2451
|
+
const created = await call('fs/create', { sessionId: 's1', cwd: dir, path: join(pkgDir, 'Demo.java'), content: 'class Demo {}' })
|
|
2452
|
+
assert.equal(created.status, 200)
|
|
2453
|
+
assert.equal(await readFile(join(pkgDir, 'Demo.java'), 'utf8'), 'class Demo {}')
|
|
2454
|
+
// never overwrite an existing file
|
|
2455
|
+
assert.equal((await call('fs/create', { sessionId: 's1', cwd: dir, path: join(pkgDir, 'Demo.java') })).status, 409)
|
|
2456
|
+
|
|
2457
|
+
// copy into another directory keeps the basename
|
|
2458
|
+
const copyDir = join(dir, 'backup')
|
|
2459
|
+
await mkdir(copyDir, { recursive: true })
|
|
2460
|
+
const copied = await call('fs/copy', { sessionId: 's1', cwd: dir, from: join(pkgDir, 'Demo.java'), toDir: copyDir })
|
|
2461
|
+
assert.equal(copied.status, 200)
|
|
2462
|
+
assert.equal(await readFile(join(copyDir, 'Demo.java'), 'utf8'), 'class Demo {}')
|
|
2463
|
+
// …and a second copy refuses the collision
|
|
2464
|
+
assert.equal((await call('fs/copy', { sessionId: 's1', cwd: dir, from: join(pkgDir, 'Demo.java'), toDir: copyDir })).status, 409)
|
|
2465
|
+
|
|
2466
|
+
// move
|
|
2467
|
+
const movedDir = join(dir, 'moved')
|
|
2468
|
+
await mkdir(movedDir, { recursive: true })
|
|
2469
|
+
const moved = await call('fs/move', { sessionId: 's1', cwd: dir, from: join(pkgDir, 'Demo.java'), toDir: movedDir })
|
|
2470
|
+
assert.equal(moved.status, 200)
|
|
2471
|
+
assert.equal((await readdir(pkgDir)).includes('Demo.java'), false)
|
|
2472
|
+
|
|
2473
|
+
// non-empty directory needs explicit recursive
|
|
2474
|
+
assert.equal((await call('fs/remove', { sessionId: 's1', cwd: dir, path: movedDir })).status, 409)
|
|
2475
|
+
const removed = await call('fs/remove', { sessionId: 's1', cwd: dir, path: movedDir, recursive: true })
|
|
2476
|
+
assert.equal(removed.status, 200)
|
|
2477
|
+
assert.equal((await readdir(dir)).includes('moved'), false)
|
|
2478
|
+
|
|
2479
|
+
// the workspace root is never a legal mutation target
|
|
2480
|
+
assert.equal((await call('fs/mkdir', { sessionId: 's1', cwd: dir, path: dir })).status, 409)
|
|
2481
|
+
})
|
|
2482
|
+
|
|
2483
|
+
test('pasted OS files upload and import into the tree', async () => {
|
|
2484
|
+
const { mkdtemp, readFile, writeFile } = await import('node:fs/promises')
|
|
2485
|
+
const { tmpdir } = await import('node:os')
|
|
2486
|
+
const { join } = await import('node:path')
|
|
2487
|
+
const { Readable } = await import('node:stream')
|
|
2488
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-paste-'))
|
|
2489
|
+
const outside = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-src-'))
|
|
2490
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
2491
|
+
|
|
2492
|
+
function response() {
|
|
2493
|
+
const chunks = []
|
|
2494
|
+
const res = {
|
|
2495
|
+
status: 0,
|
|
2496
|
+
writeHead(status) { this.status = status },
|
|
2497
|
+
write(chunk) { chunks.push(String(chunk)); return true },
|
|
2498
|
+
end(chunk) { if (chunk !== undefined && chunk !== null) chunks.push(String(chunk)) },
|
|
2499
|
+
on() {},
|
|
2500
|
+
}
|
|
2501
|
+
return { res, read: () => (chunks.join('') === '' ? undefined : JSON.parse(chunks.join(''))) }
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
async function upload(query, bytes) {
|
|
2505
|
+
const { res, read } = response()
|
|
2506
|
+
const req = Object.assign(Readable.from([Buffer.from(bytes)]), {
|
|
2507
|
+
method: 'POST',
|
|
2508
|
+
url: '/dsh-ris-ide/fs/upload?' + new URLSearchParams(query).toString(),
|
|
2509
|
+
headers: { host: '127.0.0.1:3080' },
|
|
2510
|
+
})
|
|
2511
|
+
await __test.serveFsImport(ctx, req, res, {})
|
|
2512
|
+
return { status: res.status, body: read() }
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
async function importPath(body) {
|
|
2516
|
+
const { res, read } = response()
|
|
2517
|
+
const req = Object.assign(Readable.from([JSON.stringify(body)]), {
|
|
2518
|
+
method: 'POST',
|
|
2519
|
+
url: '/dsh-ris-ide/fs/import',
|
|
2520
|
+
headers: { host: '127.0.0.1:3080' },
|
|
2521
|
+
})
|
|
2522
|
+
await __test.serveFsImport(ctx, req, res, {})
|
|
2523
|
+
return { status: res.status, body: read() }
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// A pasted/dropped File arrives as raw bytes; binary must survive untouched.
|
|
2527
|
+
const target = join(dir, 'pasted.bin')
|
|
2528
|
+
const first = await upload({ sessionId: 's1', cwd: dir, dir, name: 'pasted.bin' }, [0, 1, 2, 255])
|
|
2529
|
+
assert.equal(first.status, 200)
|
|
2530
|
+
assert.equal(first.body.bytes, 4)
|
|
2531
|
+
assert.deepEqual([...await readFile(target)], [0, 1, 2, 255])
|
|
2532
|
+
assert.equal((await upload({ sessionId: 's1', cwd: dir, dir, name: 'pasted.bin' }, 'x')).status, 409)
|
|
2533
|
+
assert.equal((await upload({ sessionId: 's1', cwd: dir, dir, name: 'pasted.bin', overwrite: '1' }, 'new')).status, 200)
|
|
2534
|
+
assert.equal(await readFile(target, 'utf8'), 'new')
|
|
2535
|
+
|
|
2536
|
+
// A path copied outside the workspace is imported in (the destination stays guarded).
|
|
2537
|
+
const sourceFile = join(outside, 'notes.md')
|
|
2538
|
+
await writeFile(sourceFile, '# hi\n')
|
|
2539
|
+
const imported = await importPath({ sessionId: 's1', cwd: dir, from: sourceFile, dir })
|
|
2540
|
+
assert.equal(imported.status, 200)
|
|
2541
|
+
assert.equal(await readFile(join(dir, 'notes.md'), 'utf8'), '# hi\n')
|
|
2542
|
+
assert.equal((await importPath({ sessionId: 's1', cwd: dir, from: sourceFile, dir })).status, 409)
|
|
2543
|
+
assert.equal((await importPath({ sessionId: 's1', cwd: dir, from: sourceFile, dir, overwrite: true })).status, 200)
|
|
2544
|
+
// …and a leaf name can never escape the destination directory
|
|
2545
|
+
assert.equal((await importPath({ sessionId: 's1', cwd: dir, from: sourceFile, dir, name: '../evil.md' })).status, 400)
|
|
2546
|
+
})
|
|
2547
|
+
|
|
2548
|
+
// ── the debug stream: a page reload must still show its pre-set breakpoints ──
|
|
2549
|
+
test('the debug stream carries pending breakpoints, so a reload shows its dots', async () => {
|
|
2550
|
+
const { mkdtemp } = await import('node:fs/promises')
|
|
2551
|
+
const { tmpdir } = await import('node:os')
|
|
2552
|
+
const { join } = await import('node:path')
|
|
2553
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-debug-stream-'))
|
|
2554
|
+
const logPath = join(dir, 'app.log')
|
|
2555
|
+
// Pre-set breakpoint: no debug session at all, exactly the state after a reload.
|
|
2556
|
+
await __test.debugSessions.addBreakpoint(logPath, 'a.B', 12)
|
|
2557
|
+
|
|
2558
|
+
const chunks = []
|
|
2559
|
+
const handlers = {}
|
|
2560
|
+
let ended = false
|
|
2561
|
+
const res = {
|
|
2562
|
+
writeHead(status, headers) { this.status = status; this.headers = headers },
|
|
2563
|
+
flushHeaders() {},
|
|
2564
|
+
write(chunk) { if (ended) return false; chunks.push(String(chunk)); return true },
|
|
2565
|
+
end() { if (ended) return; ended = true; for (const fn of handlers.close ?? []) fn() },
|
|
2566
|
+
on(event, fn) { (handlers[event] ??= []).push(fn) },
|
|
2567
|
+
}
|
|
2568
|
+
const req = {
|
|
2569
|
+
method: 'GET',
|
|
2570
|
+
headers: { host: '127.0.0.1:3080' },
|
|
2571
|
+
url: `/dsh-ris-ide/stream?sessionId=s1&topics=debug&path=${encodeURIComponent(logPath)}`,
|
|
2572
|
+
}
|
|
2573
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
2574
|
+
try {
|
|
2575
|
+
await __test.serveStream(ctx, req, res)
|
|
2576
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
2577
|
+
assert.equal(res.status, 200)
|
|
2578
|
+
const frames = chunks.join('').split('\n\n').filter((block) => block.startsWith('event: debug\n'))
|
|
2579
|
+
assert.ok(frames.length > 0, 'the debug topic pushes an immediate frame')
|
|
2580
|
+
const payload = JSON.parse(frames[0].split('\n').find((line) => line.startsWith('data: ')).slice(5))
|
|
2581
|
+
assert.equal(payload.ok, true, 'the frame is a success payload for the client')
|
|
2582
|
+
assert.deepEqual(payload.breakpoints, [{ requestId: 'pending', pending: true, className: 'a.B', line: 12, suspendPolicy: 'thread' }])
|
|
2583
|
+
} finally {
|
|
2584
|
+
await __test.debugSessions.clearBreakpoints(logPath)
|
|
2585
|
+
res.end()
|
|
2586
|
+
}
|
|
2587
|
+
})
|
|
2588
|
+
|
|
2589
|
+
// ── dependency sources: jar entries, sources.jar extraction, settings ────────
|
|
2590
|
+
/** Build a minimal stored-only zip so the jar reader is exercised offline. */
|
|
2591
|
+
function buildZip(entries) {
|
|
2592
|
+
const locals = []
|
|
2593
|
+
const centrals = []
|
|
2594
|
+
let offset = 0
|
|
2595
|
+
for (const entry of entries) {
|
|
2596
|
+
const name = Buffer.from(entry.name, 'utf8')
|
|
2597
|
+
const data = Buffer.from(entry.data)
|
|
2598
|
+
const local = Buffer.alloc(30)
|
|
2599
|
+
local.writeUInt32LE(0x04034b50, 0)
|
|
2600
|
+
local.writeUInt16LE(20, 4)
|
|
2601
|
+
local.writeUInt16LE(0, 6)
|
|
2602
|
+
local.writeUInt16LE(0, 8)
|
|
2603
|
+
local.writeUInt16LE(0, 10)
|
|
2604
|
+
local.writeUInt16LE(0, 12)
|
|
2605
|
+
local.writeUInt32LE(data.length, 18)
|
|
2606
|
+
local.writeUInt32LE(data.length, 22)
|
|
2607
|
+
local.writeUInt16LE(name.length, 26)
|
|
2608
|
+
local.writeUInt16LE(0, 28)
|
|
2609
|
+
const central = Buffer.alloc(46)
|
|
2610
|
+
central.writeUInt32LE(0x02014b50, 0)
|
|
2611
|
+
central.writeUInt16LE(20, 4)
|
|
2612
|
+
central.writeUInt16LE(20, 6)
|
|
2613
|
+
central.writeUInt16LE(0, 8)
|
|
2614
|
+
central.writeUInt16LE(0, 10)
|
|
2615
|
+
central.writeUInt16LE(0, 12)
|
|
2616
|
+
central.writeUInt16LE(0, 14)
|
|
2617
|
+
central.writeUInt32LE(0, 16)
|
|
2618
|
+
central.writeUInt32LE(data.length, 20)
|
|
2619
|
+
central.writeUInt32LE(data.length, 24)
|
|
2620
|
+
central.writeUInt16LE(name.length, 28)
|
|
2621
|
+
central.writeUInt32LE(offset, 42)
|
|
2622
|
+
locals.push(local, name, data)
|
|
2623
|
+
centrals.push(central, name)
|
|
2624
|
+
offset += local.length + name.length + data.length
|
|
2625
|
+
}
|
|
2626
|
+
const centralDirectory = Buffer.concat(centrals)
|
|
2627
|
+
const eocd = Buffer.alloc(22)
|
|
2628
|
+
eocd.writeUInt32LE(0x06054b50, 0)
|
|
2629
|
+
eocd.writeUInt16LE(entries.length, 8)
|
|
2630
|
+
eocd.writeUInt16LE(entries.length, 10)
|
|
2631
|
+
eocd.writeUInt32LE(centralDirectory.length, 12)
|
|
2632
|
+
eocd.writeUInt32LE(offset, 16)
|
|
2633
|
+
return Buffer.concat([...locals, centralDirectory, eocd])
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
test('the jar reader lists entries and extracts stored bytes', async () => {
|
|
2637
|
+
const { mkdtemp, writeFile: write } = await import('node:fs/promises')
|
|
2638
|
+
const { tmpdir } = await import('node:os')
|
|
2639
|
+
const { join } = await import('node:path')
|
|
2640
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-zip-'))
|
|
2641
|
+
const jar = join(dir, 'dep-1.0.jar')
|
|
2642
|
+
const classBytes = Buffer.from([0xca, 0xfe, 0xba, 0xbe, 1, 2, 3])
|
|
2643
|
+
await write(jar, buildZip([{ name: 'pkg/Dep.class', data: classBytes }, { name: 'META-INF/MANIFEST.MF', data: 'Manifest-Version: 1.0\n' }]))
|
|
2644
|
+
const entries = await __test.readZipEntries(jar)
|
|
2645
|
+
assert.deepEqual(entries.map((entry) => entry.name), ['pkg/Dep.class', 'META-INF/MANIFEST.MF'])
|
|
2646
|
+
const bytes = await __test.readZipEntry(jar, entries[0])
|
|
2647
|
+
assert.deepEqual(bytes, classBytes)
|
|
2648
|
+
assert.equal(await __test.readZipEntries(join(dir, 'missing.jar')).then((list) => list.length), 0)
|
|
2649
|
+
})
|
|
2650
|
+
|
|
2651
|
+
test('a dependency class resolves from its sources.jar into the workspace cache', async () => {
|
|
2652
|
+
const { mkdtemp, mkdir, writeFile: write } = await import('node:fs/promises')
|
|
2653
|
+
const { tmpdir } = await import('node:os')
|
|
2654
|
+
const { join } = await import('node:path')
|
|
2655
|
+
const cwd = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-src-'))
|
|
2656
|
+
const programDir = join(cwd, 'prog')
|
|
2657
|
+
await mkdir(join(programDir, 'target'), { recursive: true })
|
|
2658
|
+
const binaries = join(programDir, 'libs', 'dep-1.0.jar')
|
|
2659
|
+
const sources = join(programDir, 'libs', 'dep-1.0-sources.jar')
|
|
2660
|
+
await mkdir(join(programDir, 'libs'), { recursive: true })
|
|
2661
|
+
await write(binaries, buildZip([{ name: 'pkg/Dep.class', data: Buffer.from([0xca, 0xfe]) }]))
|
|
2662
|
+
await write(sources, buildZip([{ name: 'pkg/Dep.java', data: 'package pkg;\npublic class Dep { int value; }\n' }]))
|
|
2663
|
+
await write(join(programDir, 'target', 'cp.txt'), [binaries, '/nonexistent/other.jar'].join('\n'))
|
|
2664
|
+
|
|
2665
|
+
const resolved = await __test.resolveDependencySource(cwd, programDir, 'pkg.Dep')
|
|
2666
|
+
assert.ok(resolved !== undefined, 'the sources.jar entry is found')
|
|
2667
|
+
assert.equal(resolved.kind, 'sources')
|
|
2668
|
+
assert.equal(resolved.jar, sources)
|
|
2669
|
+
assert.equal(resolved.path, join(cwd, '.dsh-ris-ide-cache', 'sources', 'pkg.Dep.java'))
|
|
2670
|
+
const text = await (await import('node:fs/promises')).readFile(resolved.path, 'utf8')
|
|
2671
|
+
assert.match(text, /public class Dep/)
|
|
2672
|
+
|
|
2673
|
+
assert.equal(await __test.resolveDependencySource(cwd, programDir, 'pkg.Missing'), undefined)
|
|
2674
|
+
})
|
|
2675
|
+
|
|
2676
|
+
test('a rebuilt jar invalidates the cached dependency source', async () => {
|
|
2677
|
+
const { mkdtemp, mkdir, readFile: read, stat, writeFile: write } = await import('node:fs/promises')
|
|
2678
|
+
const { tmpdir } = await import('node:os')
|
|
2679
|
+
const { join } = await import('node:path')
|
|
2680
|
+
const cwd = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-cache-'))
|
|
2681
|
+
const programDir = join(cwd, 'prog')
|
|
2682
|
+
const binaries = join(programDir, 'libs', 'dep-1.0.jar')
|
|
2683
|
+
const sources = join(programDir, 'libs', 'dep-1.0-sources.jar')
|
|
2684
|
+
await mkdir(join(programDir, 'libs'), { recursive: true })
|
|
2685
|
+
await mkdir(join(programDir, 'target'), { recursive: true })
|
|
2686
|
+
await write(binaries, buildZip([{ name: 'pkg/Dep.class', data: Buffer.from([0xca, 0xfe]) }]))
|
|
2687
|
+
await write(sources, buildZip([{ name: 'pkg/Dep.java', data: 'package pkg;\npublic class Dep {}\n' }]))
|
|
2688
|
+
await write(join(programDir, 'target', 'cp.txt'), binaries)
|
|
2689
|
+
|
|
2690
|
+
const first = await __test.resolveDependencySource(cwd, programDir, 'pkg.Dep')
|
|
2691
|
+
const firstText = await read(first.path, 'utf8')
|
|
2692
|
+
assert.match(firstText, /public class Dep/)
|
|
2693
|
+
const firstInfo = await stat(first.path)
|
|
2694
|
+
|
|
2695
|
+
// Rebuilding the source jar must invalidate the cached copy: the cache is keyed
|
|
2696
|
+
// by the jar's size/mtime and the entry, not by "the file exists therefore it's
|
|
2697
|
+
// correct" — otherwise an updated dependency keeps serving old sources.
|
|
2698
|
+
await new Promise((resolve) => setTimeout(resolve, 12))
|
|
2699
|
+
await write(sources, buildZip([{ name: 'pkg/Dep.java', data: 'package pkg;\n/** rebuilt */\npublic class Dep {}\n' }]))
|
|
2700
|
+
const again = await __test.resolveDependencySource(cwd, programDir, 'pkg.Dep')
|
|
2701
|
+
assert.equal(again.path, first.path)
|
|
2702
|
+
assert.match(await read(again.path, 'utf8'), /rebuilt/)
|
|
2703
|
+
assert.ok((await stat(again.path)).mtimeMs > firstInfo.mtimeMs)
|
|
2704
|
+
|
|
2705
|
+
const manifest = await __test.readCacheManifest(join(cwd, '.dsh-ris-ide-cache'))
|
|
2706
|
+
assert.equal(manifest.entries['sources/pkg.Dep.java'].kind, 'sources')
|
|
2707
|
+
})
|
|
2708
|
+
|
|
2709
|
+
test('the dependency cache prunes aged and oversized entries', async () => {
|
|
2710
|
+
const { mkdtemp, mkdir, readdir, utimes, writeFile: write } = await import('node:fs/promises')
|
|
2711
|
+
const { tmpdir } = await import('node:os')
|
|
2712
|
+
const { join } = await import('node:path')
|
|
2713
|
+
const cwd = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-gc-'))
|
|
2714
|
+
const root = join(cwd, '.dsh-ris-ide-cache')
|
|
2715
|
+
const dir = join(root, 'sources')
|
|
2716
|
+
await mkdir(dir, { recursive: true })
|
|
2717
|
+
const writeOne = async (name, text, ageMs) => {
|
|
2718
|
+
const file = join(dir, name)
|
|
2719
|
+
await write(file, text)
|
|
2720
|
+
if (ageMs > 0) {
|
|
2721
|
+
const past = new Date(Date.now() - ageMs)
|
|
2722
|
+
await utimes(file, past, past)
|
|
2723
|
+
}
|
|
2724
|
+
return file
|
|
2725
|
+
}
|
|
2726
|
+
await writeOne('Old.java', '0123456789', 2 * 60 * 60 * 1000)
|
|
2727
|
+
await writeOne('Mid.java', '0123456789', 0)
|
|
2728
|
+
await writeOne('New.java', '0123456789', 0)
|
|
2729
|
+
|
|
2730
|
+
// 1) TTL: files untouched for longer than the window go first.
|
|
2731
|
+
await __test.pruneIdeCache(root, { force: true, maxBytes: 1024 * 1024, maxFiles: 100, maxAgeMs: 60 * 60 * 1000 })
|
|
2732
|
+
assert.deepEqual((await readdir(dir)).sort(), ['Mid.java', 'New.java'])
|
|
2733
|
+
|
|
2734
|
+
// 2) Size budget: drop the oldest remaining until the tree fits.
|
|
2735
|
+
await __test.pruneIdeCache(root, { force: true, maxBytes: 15, maxFiles: 100, maxAgeMs: 0 })
|
|
2736
|
+
assert.deepEqual(await readdir(dir), ['New.java'])
|
|
2737
|
+
})
|
|
2738
|
+
|
|
2739
|
+
test('the visual debug settings persist the decompiler jar without touching the program config', async () => {
|
|
2740
|
+
const { mkdtemp, readFile: read, writeFile: write } = await import('node:fs/promises')
|
|
2741
|
+
const { tmpdir } = await import('node:os')
|
|
2742
|
+
const { join } = await import('node:path')
|
|
2743
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-settings-'))
|
|
2744
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
2745
|
+
const answer = () => {
|
|
2746
|
+
const chunks = []
|
|
2747
|
+
const res = {
|
|
2748
|
+
status: 0, headers: {}, headersSent: false, writableEnded: false, destroyed: false,
|
|
2749
|
+
writeHead(status, headers) { this.status = status; this.headers = headers; this.headersSent = true },
|
|
2750
|
+
end(text) { chunks.push(String(text)); this.writableEnded = true },
|
|
2751
|
+
destroy() { this.destroyed = true },
|
|
2752
|
+
}
|
|
2753
|
+
return { res, json: () => JSON.parse(chunks.join('')) }
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
const posted = answer()
|
|
2757
|
+
await __test.serveSettings(ctx, {
|
|
2758
|
+
method: 'POST', headers: { host: '127.0.0.1:3080' }, url: '/dsh-ris-ide/settings',
|
|
2759
|
+
[Symbol.asyncIterator]: async function* () { yield Buffer.from(JSON.stringify({ sessionId: 's1', decompilerJar: '/opt/cfr/cfr.jar', decompilerArgs: ['{classfile}'] })) },
|
|
2760
|
+
}, posted.res, {})
|
|
2761
|
+
assert.equal(posted.res.status, 200)
|
|
2762
|
+
assert.equal(posted.json().decompiler.jar, '/opt/cfr/cfr.jar')
|
|
2763
|
+
const settingsPath = join(dir, '.dsh-ris-ide.settings.json')
|
|
2764
|
+
const saved = JSON.parse(await read(settingsPath, 'utf8'))
|
|
2765
|
+
assert.deepEqual(saved.decompiler, { jar: '/opt/cfr/cfr.jar', args: ['{classfile}'] })
|
|
2766
|
+
assert.equal(await read(join(dir, '.dsh-ris-ide.json'), 'utf8').catch(() => undefined), undefined, 'the program config stays untouched')
|
|
2767
|
+
|
|
2768
|
+
const got = answer()
|
|
2769
|
+
await __test.serveSettings(ctx, { method: 'GET', headers: { host: '127.0.0.1:3080' }, url: '/dsh-ris-ide/settings?sessionId=s1' }, got.res, {})
|
|
2770
|
+
assert.equal(got.res.status, 200)
|
|
2771
|
+
assert.equal(got.json().decompiler.jar, '/opt/cfr/cfr.jar')
|
|
2772
|
+
assert.equal(got.json().cacheDir, join(dir, '.dsh-ris-ide-cache'))
|
|
2773
|
+
|
|
2774
|
+
// Clearing the field removes the key from the settings file.
|
|
2775
|
+
const cleared = answer()
|
|
2776
|
+
await __test.serveSettings(ctx, {
|
|
2777
|
+
method: 'POST', headers: { host: '127.0.0.1:3080' }, url: '/dsh-ris-ide/settings',
|
|
2778
|
+
[Symbol.asyncIterator]: async function* () { yield Buffer.from(JSON.stringify({ sessionId: 's1', decompilerJar: '' })) },
|
|
2779
|
+
}, cleared.res, {})
|
|
2780
|
+
assert.equal(JSON.parse(await read(settingsPath, 'utf8')).decompiler, undefined)
|
|
2781
|
+
})
|
|
2782
|
+
|
|
2783
|
+
// ── windowed reads power the read-only preview of oversized text files ─────
|
|
2784
|
+
test('fs/window pages a large file and never splits a multi-byte character', async () => {
|
|
2785
|
+
const { mkdtemp, writeFile: write } = await import('node:fs/promises')
|
|
2786
|
+
const { tmpdir } = await import('node:os')
|
|
2787
|
+
const { join } = await import('node:path')
|
|
2788
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-window-'))
|
|
2789
|
+
const ctx = { sessions: { get: () => ({ header: { cwd: dir } }) } }
|
|
2790
|
+
|
|
2791
|
+
/** Drive fs/window with fake req/res. */
|
|
2792
|
+
async function call(query) {
|
|
2793
|
+
const chunks = []
|
|
2794
|
+
const res = {
|
|
2795
|
+
status: 0,
|
|
2796
|
+
headers: {},
|
|
2797
|
+
writeHead(status, headers) { this.status = status; this.headers = headers },
|
|
2798
|
+
write(chunk) { chunks.push(String(chunk)); return true },
|
|
2799
|
+
end(chunk) { if (chunk !== undefined && chunk !== null) chunks.push(String(chunk)) },
|
|
2800
|
+
on() {},
|
|
2801
|
+
}
|
|
2802
|
+
const url = '/dsh-ris-ide/fs/window?' + new URLSearchParams(query).toString()
|
|
2803
|
+
const req = { method: 'GET', url, headers: { host: '127.0.0.1:3080' } }
|
|
2804
|
+
await __test.serveFs(ctx, req, res, {})
|
|
2805
|
+
return { status: res.status, body: JSON.parse(chunks.join('')) }
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
// 'ab中文cd' = 10 bytes; a 3-byte window lands INSIDE 中, so its tail is held
|
|
2809
|
+
// back for the next request instead of being decoded as U+FFFD.
|
|
2810
|
+
const text = join(dir, 'mixed.txt')
|
|
2811
|
+
await write(text, 'ab中文cd')
|
|
2812
|
+
const head = await call({ sessionId: 's1', path: text, offset: 0, length: 3 })
|
|
2813
|
+
assert.equal(head.body.kind, 'text-window')
|
|
2814
|
+
assert.equal(head.body.text, 'ab')
|
|
2815
|
+
assert.equal(head.body.next, 2)
|
|
2816
|
+
assert.equal(head.body.eof, false)
|
|
2817
|
+
const middle = await call({ sessionId: 's1', path: text, offset: head.body.next, length: 3 })
|
|
2818
|
+
assert.equal(middle.body.text, '中')
|
|
2819
|
+
assert.equal(middle.body.next, 5)
|
|
2820
|
+
|
|
2821
|
+
// A window at/after EOF answers cleanly instead of reading past the end.
|
|
2822
|
+
const tail = await call({ sessionId: 's1', path: text, offset: 999, length: 10 })
|
|
2823
|
+
assert.equal(tail.body.eof, true)
|
|
2824
|
+
assert.equal(tail.body.text, '')
|
|
2825
|
+
assert.equal(tail.body.next, 10)
|
|
2826
|
+
|
|
2827
|
+
// A big text file pages by offset; the last page reports eof.
|
|
2828
|
+
const big = join(dir, 'big.txt')
|
|
2829
|
+
await write(big, 'x'.repeat(600 * 1024))
|
|
2830
|
+
const first = await call({ sessionId: 's1', path: big, offset: 0, length: 1024 })
|
|
2831
|
+
assert.equal(first.body.text.length, 1024)
|
|
2832
|
+
assert.equal(first.body.size, 600 * 1024)
|
|
2833
|
+
const last = await call({ sessionId: 's1', path: big, offset: 600 * 1024 - 100, length: 1024 })
|
|
2834
|
+
assert.equal(last.body.eof, true)
|
|
2835
|
+
assert.equal(last.body.text.length, 100)
|
|
2836
|
+
|
|
2837
|
+
// Binary is rejected on the FIRST window (later windows never fetch).
|
|
2838
|
+
const bin = join(dir, 'x.bin')
|
|
2839
|
+
await write(bin, Buffer.from([0x61, 0x00, 0x62]))
|
|
2840
|
+
assert.equal((await call({ sessionId: 's1', path: bin, offset: 0, length: 1024 })).body.kind, 'binary')
|
|
2841
|
+
|
|
2842
|
+
// The UTF-8 trimmer keeps complete bytes only.
|
|
2843
|
+
assert.equal(__test.utf8CompleteLength(Buffer.from('ab中', 'utf8').subarray(0, 3)), 2)
|
|
2844
|
+
assert.equal(__test.utf8CompleteLength(Buffer.from('ab中', 'utf8')), 5)
|
|
2845
|
+
assert.equal(__test.utf8CompleteLength(Buffer.alloc(0)), 0)
|
|
2846
|
+
})
|