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,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug engine tests: the JDWP launch argument, port allocation, the session
|
|
3
|
+
* registry (with a fake client) and a real-JVM integration run (skipped on
|
|
4
|
+
* machines without a JDK).
|
|
5
|
+
*/
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
9
|
+
import { mkdtemp, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { __test } from '../lib/index.js'
|
|
14
|
+
|
|
15
|
+
const T = __test
|
|
16
|
+
const hasJavac = spawnSync('javac', ['-version'], { stdio: 'ignore' }).status === 0
|
|
17
|
+
const hasJava = spawnSync('java', ['-version'], { stdio: 'ignore' }).status === 0
|
|
18
|
+
|
|
19
|
+
/** A fake JDWP client that records every call (the registry is what we test). */
|
|
20
|
+
function fakeClient(calls) {
|
|
21
|
+
const handlers = new Map()
|
|
22
|
+
const record = (name) => (...args) => {
|
|
23
|
+
calls.push([name, ...args])
|
|
24
|
+
}
|
|
25
|
+
const client = {
|
|
26
|
+
isConnected: true,
|
|
27
|
+
vmSuspended: true,
|
|
28
|
+
allSuspendedThreadIds: [11n],
|
|
29
|
+
currentThreadId: 11n,
|
|
30
|
+
on(event, handler) {
|
|
31
|
+
handlers.set(event, [...(handlers.get(event) ?? []), handler])
|
|
32
|
+
},
|
|
33
|
+
emit(event, payload) {
|
|
34
|
+
for (const handler of handlers.get(event) ?? []) handler(payload)
|
|
35
|
+
},
|
|
36
|
+
connect: record('connect'),
|
|
37
|
+
disconnect: record('disconnect'),
|
|
38
|
+
getBreakpoints: () => [{ requestId: 1, className: 'a.B', line: 7 }],
|
|
39
|
+
setBreakpoint: async (className, line, policy) => {
|
|
40
|
+
calls.push(['setBreakpoint', className, line, policy])
|
|
41
|
+
return { requestId: 2, className, line }
|
|
42
|
+
},
|
|
43
|
+
removeBreakpoint: record('removeBreakpoint'),
|
|
44
|
+
clearAllBreakpoints: record('clearAllBreakpoints'),
|
|
45
|
+
getAllThreads: async () => [{ threadId: 11n, name: 'main' }],
|
|
46
|
+
getFrames: async (threadId, start, max) => {
|
|
47
|
+
calls.push(['getFrames', threadId, start, max])
|
|
48
|
+
return [{ frameId: 5n, location: { classID: 1n, methodID: 2n, index: 3n }, methodName: 'm', lineNumber: 9 }]
|
|
49
|
+
},
|
|
50
|
+
getFrameVariables: async (threadId, frameId, location) => {
|
|
51
|
+
calls.push(['getFrameVariables', threadId, frameId, location])
|
|
52
|
+
return [{ name: 'i', value: 10, signature: 'I', slot: 1, tag: 2 }]
|
|
53
|
+
},
|
|
54
|
+
getObjectDetails: async (objectId) => {
|
|
55
|
+
calls.push(['getObjectDetails', objectId])
|
|
56
|
+
return { kind: 'object', className: 'a.B', signature: 'La/B;', fields: [{ name: 'x', value: 1, signature: 'I', tag: 2 }] }
|
|
57
|
+
},
|
|
58
|
+
stepOver: record('stepOver'),
|
|
59
|
+
stepInto: record('stepInto'),
|
|
60
|
+
stepOut: record('stepOut'),
|
|
61
|
+
resumeVM: record('resumeVM'),
|
|
62
|
+
resumeThread: record('resumeThread'),
|
|
63
|
+
suspendVM: record('suspendVM'),
|
|
64
|
+
}
|
|
65
|
+
return client
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('jdwpAgentArg binds loopback and honours suspend', () => {
|
|
69
|
+
assert.equal(T.jdwpAgentArg(5005), '-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:5005')
|
|
70
|
+
assert.match(T.jdwpAgentArg(6006, true), /suspend=y,address=127\.0\.0\.1:6006$/)
|
|
71
|
+
assert.throws(() => T.jdwpAgentArg(0), /invalid debug port/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('toJsonSafe converts BigInt ids and keeps the shape', () => {
|
|
75
|
+
assert.equal(T.toJsonSafe(123n), '123')
|
|
76
|
+
assert.deepEqual(T.toJsonSafe({ a: 1n, b: [2n, undefined] }), { a: '1', b: ['2', null] })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('allocateDebugPort returns a port number', async () => {
|
|
80
|
+
const port = await T.allocateDebugPort()
|
|
81
|
+
assert.ok(Number.isInteger(port) && port > 1024 && port < 65536, `unexpected port ${port}`)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('waitForJdwpListening waits for the JVM line instead of probing the port', async () => {
|
|
85
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-jdwp-'))
|
|
86
|
+
const logPath = join(dir, 'x.log')
|
|
87
|
+
await writeFile(logPath, '--- started ---\n')
|
|
88
|
+
// not listening yet ⇒ it keeps waiting and then explains why
|
|
89
|
+
await assert.rejects(() => T.waitForJdwpListening(logPath, 5123, 300), /JDWP 端口 5123 没有在 300ms 内就绪/)
|
|
90
|
+
await writeFile(logPath, '--- started ---\nListening for transport dt_socket at address: 5123\n')
|
|
91
|
+
assert.equal(await T.waitForJdwpListening(logPath, 5123, 500), true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('the debug session registry maps the engine API', async () => {
|
|
95
|
+
const calls = []
|
|
96
|
+
const client = fakeClient(calls)
|
|
97
|
+
const sessions = T.createDebugSessions({ clientFactory: () => client })
|
|
98
|
+
const program = { id: 'spring:x#a.B', name: 'x', logPath: '/tmp/x.log' }
|
|
99
|
+
const attached = await sessions.attach(program, 5100)
|
|
100
|
+
assert.deepEqual(calls[0], ['connect', '127.0.0.1', 5100])
|
|
101
|
+
assert.equal(attached.attached, true)
|
|
102
|
+
assert.deepEqual(attached.suspendedThreads, ['11'])
|
|
103
|
+
assert.equal(attached.currentThreadId, '11', 'the stopped thread is reported so the IDE can read its frames')
|
|
104
|
+
|
|
105
|
+
// engine events land in the ring, JSON-safe
|
|
106
|
+
// requestId 1 is the fake client's registered breakpoint (a.B:7) → the event is
|
|
107
|
+
// tagged with it; an unknown request stays untagged.
|
|
108
|
+
client.emit('breakpoint', { requestId: 1, threadId: 11n, location: { index: 3n } })
|
|
109
|
+
client.emit('breakpoint', { requestId: 99, threadId: 11n, location: { index: 4n } })
|
|
110
|
+
const polled = sessions.poll('/tmp/x.log', 0)
|
|
111
|
+
assert.equal(polled.seq >= 2, true)
|
|
112
|
+
const hit = polled.events.find((event) => event.type === 'breakpoint')
|
|
113
|
+
assert.equal(hit.payload.threadId, '11', 'BigInt ids are strings in the event payload')
|
|
114
|
+
// The event is tagged with the breakpoint it belongs to (the fake client reports
|
|
115
|
+
// requestId 1 = a.B:7), so a second consumer can tell whose breakpoint hit.
|
|
116
|
+
assert.equal(hit.payload.className, 'a.B')
|
|
117
|
+
assert.equal(hit.payload.line, 7)
|
|
118
|
+
const unknown = polled.events.filter((event) => event.type === 'breakpoint' && event.payload.requestId === 99)[0]
|
|
119
|
+
assert.equal(unknown.payload.className, undefined, 'an unknown request id is left alone')
|
|
120
|
+
assert.equal(JSON.stringify(polled).includes('"threadId":"11"'), true)
|
|
121
|
+
|
|
122
|
+
assert.equal(sessions.status('/tmp/x.log').attached, true)
|
|
123
|
+
assert.deepEqual(sessions.listBreakpoints('/tmp/x.log'), [{ requestId: 1, className: 'a.B', line: 7 }])
|
|
124
|
+
const threads = await sessions.threads('/tmp/x.log')
|
|
125
|
+
assert.equal(threads[0].threadId, '11')
|
|
126
|
+
const frames = await sessions.stack('/tmp/x.log', '11', 0, 10)
|
|
127
|
+
assert.equal(frames[0].frameId, '5')
|
|
128
|
+
assert.equal(frames[0].methodName, 'm')
|
|
129
|
+
const vars = await sessions.variables('/tmp/x.log', '11', 0)
|
|
130
|
+
assert.equal(vars[0].name, 'i')
|
|
131
|
+
assert.deepEqual(calls.find((call) => call[0] === 'getFrameVariables').slice(2), [5n, { classID: 1n, methodID: 2n, index: 3n }])
|
|
132
|
+
|
|
133
|
+
// An object variable expands on demand (the IDE lazily fetches its fields).
|
|
134
|
+
const expanded = await sessions.expand('/tmp/x.log', '42')
|
|
135
|
+
assert.equal(expanded.kind, 'object')
|
|
136
|
+
assert.equal(expanded.fields[0].name, 'x')
|
|
137
|
+
assert.deepEqual(calls.find((call) => call[0] === 'getObjectDetails').slice(1), [42n])
|
|
138
|
+
await assert.rejects(() => sessions.expand('/tmp/x.log', '4x'), /decimal JDWP object id/)
|
|
139
|
+
|
|
140
|
+
const breakpoint = await sessions.addBreakpoint('/tmp/x.log', ' a.B ', 12, 'all')
|
|
141
|
+
assert.deepEqual(breakpoint, { requestId: 2, className: 'a.B', line: 12 })
|
|
142
|
+
assert.deepEqual(calls.find((call) => call[0] === 'setBreakpoint').slice(1), ['a.B', 12, 'all'])
|
|
143
|
+
|
|
144
|
+
await sessions.step('/tmp/x.log', 'over')
|
|
145
|
+
await sessions.step('/tmp/x.log', 'into')
|
|
146
|
+
await sessions.step('/tmp/x.log', 'out')
|
|
147
|
+
assert.deepEqual(calls.filter((call) => call[0].startsWith('step')).map((call) => call[0]), ['stepOver', 'stepInto', 'stepOut'])
|
|
148
|
+
await sessions.resume('/tmp/x.log')
|
|
149
|
+
await sessions.resume('/tmp/x.log', '11')
|
|
150
|
+
await sessions.pause('/tmp/x.log')
|
|
151
|
+
assert.deepEqual(calls.filter((call) => ['resumeVM', 'resumeThread', 'suspendVM'].includes(call[0])).map((call) => call[0]), ['resumeVM', 'resumeThread', 'suspendVM'])
|
|
152
|
+
|
|
153
|
+
// bad arguments and detached sessions fail with machine codes
|
|
154
|
+
await assert.rejects(() => sessions.addBreakpoint('/tmp/x.log', '', 1), (error) => error.code === 'bad-argument')
|
|
155
|
+
await assert.rejects(() => sessions.stack('/tmp/x.log', 'abc'), (error) => error.code === 'bad-argument')
|
|
156
|
+
await assert.rejects(() => sessions.step('/tmp/x.log', 'sideways'), (error) => error.code === 'bad-argument')
|
|
157
|
+
await assert.rejects(() => sessions.threads('/tmp/none.log'), (error) => error.code === 'not-attached')
|
|
158
|
+
assert.equal(sessions.status('/tmp/none.log').attached, false, 'status never throws (the panel polls it)')
|
|
159
|
+
|
|
160
|
+
assert.deepEqual(await sessions.detach('/tmp/x.log'), { detached: true })
|
|
161
|
+
assert.equal(sessions.status('/tmp/x.log').attached, false)
|
|
162
|
+
assert.deepEqual(await sessions.detach('/tmp/x.log'), { detached: false })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('a start in debug mode puts the JDWP agent into both launch plans', () => {
|
|
166
|
+
const program = { kind: 'spring', dir: '/w/ris/m', mainClass: 'a.B', args: [], logName: 'm', jvmArgs: ['-Xmx1g'] }
|
|
167
|
+
const posix = T.buildStartSpec(program, { workspace: '/w', logsDir: '/w/logs', debug: { port: 5005 } })
|
|
168
|
+
assert.match(posix.command, /-Dcsp\.sentinel\.log\.dir=[^ ]+ -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127\.0\.0\.1:5005 -Xmx1g -cp "target\/classes:\$\(cat target\/cp\.txt\)"/)
|
|
169
|
+
const windows = T.windowsStartSpec(program, { workspace: '/w', logsDir: '/w/logs', debug: { port: 5006, suspend: true } })
|
|
170
|
+
assert.deepEqual(windows.spec.jvmArgs[0], '-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:5006')
|
|
171
|
+
assert.equal(windows.spec.jvmArgs[1], '-Xmx1g')
|
|
172
|
+
// no debug option ⇒ no agent argument
|
|
173
|
+
assert.doesNotMatch(T.buildStartSpec(program, { workspace: '/w', logsDir: '/w/logs' }).command, /-agentlib:jdwp/)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('real JVM: attach, breakpoint hit, stack, variables, step, detach', {
|
|
177
|
+
skip: !hasJavac || !hasJava,
|
|
178
|
+
timeout: 90000,
|
|
179
|
+
}, async () => {
|
|
180
|
+
const dir = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-debug-'))
|
|
181
|
+
await writeFile(join(dir, 'Probe.java'), [
|
|
182
|
+
'public class Probe {',
|
|
183
|
+
' static int tick = 0;',
|
|
184
|
+
' public static void main(String[] args) throws Exception {',
|
|
185
|
+
' for (int i = 0; i < 1000; i++) { tick = work(i); Thread.sleep(100); }',
|
|
186
|
+
' }',
|
|
187
|
+
' static int work(int i) {',
|
|
188
|
+
' int doubled = i * 2;',
|
|
189
|
+
' String label = "tick-" + i;',
|
|
190
|
+
' return doubled + label.length();',
|
|
191
|
+
' }',
|
|
192
|
+
'}',
|
|
193
|
+
].join('\n'))
|
|
194
|
+
const compiled = spawnSync('javac', ['-g', 'Probe.java'], { cwd: dir, stdio: 'ignore' })
|
|
195
|
+
assert.equal(compiled.status, 0, 'the fixture compiles')
|
|
196
|
+
|
|
197
|
+
const port = await T.allocateDebugPort()
|
|
198
|
+
const logPath = join(dir, 'probe.log')
|
|
199
|
+
const { openSync } = await import('node:fs')
|
|
200
|
+
const logFd = openSync(logPath, 'a')
|
|
201
|
+
const child = spawn('java', [
|
|
202
|
+
`-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:${port}`,
|
|
203
|
+
'-cp', dir, 'Probe',
|
|
204
|
+
], { cwd: dir, stdio: ['ignore', logFd, logFd] })
|
|
205
|
+
const sessions = T.createDebugSessions()
|
|
206
|
+
try {
|
|
207
|
+
// same contract as the real start path: wait for the JVM's own JDWP line
|
|
208
|
+
await T.waitForJdwpListening(logPath, port, 30000)
|
|
209
|
+
await sessions.attach({ id: 'spring:probe#Probe', name: 'probe', logPath }, port)
|
|
210
|
+
const breakpoint = await sessions.addBreakpoint(logPath, 'Probe', 8, 'thread')
|
|
211
|
+
assert.equal(breakpoint.className, 'Probe')
|
|
212
|
+
assert.equal(breakpoint.line, 8)
|
|
213
|
+
|
|
214
|
+
// wait for the breakpoint event (the loop calls work() every 100ms)
|
|
215
|
+
const deadline = Date.now() + 15000
|
|
216
|
+
let hit
|
|
217
|
+
while (Date.now() < deadline) {
|
|
218
|
+
const polled = sessions.poll(logPath, 0)
|
|
219
|
+
hit = polled.events.find((event) => event.type === 'breakpoint')
|
|
220
|
+
if (hit !== undefined) break
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
222
|
+
}
|
|
223
|
+
assert.ok(hit !== undefined, 'the breakpoint is hit')
|
|
224
|
+
const threadId = hit.payload.threadId
|
|
225
|
+
assert.equal(typeof threadId, 'string')
|
|
226
|
+
|
|
227
|
+
// The IDE learns "paused" from the SNAPSHOT, not from vmSuspended: a breakpoint
|
|
228
|
+
// suspends only its own thread (SuspendPolicy.EventThread), so the VM keeps
|
|
229
|
+
// running and vmSuspended stays false. Reading the wrong thread is what left
|
|
230
|
+
// the call stack / variables empty in the panel.
|
|
231
|
+
const stopped = sessions.poll(logPath, 0)
|
|
232
|
+
assert.deepEqual(stopped.suspendedThreads, [threadId], 'the hit thread is reported as suspended')
|
|
233
|
+
assert.equal(stopped.currentThreadId, threadId, 'currentThreadId names the thread to read frames from')
|
|
234
|
+
assert.equal(stopped.vmSuspended, false, 'EventThread suspension does not suspend the VM')
|
|
235
|
+
|
|
236
|
+
const frames = await sessions.stack(logPath, threadId, 0, 10)
|
|
237
|
+
assert.equal(frames[0].methodName, 'work')
|
|
238
|
+
assert.equal(frames[0].lineNumber, 8)
|
|
239
|
+
const variables = await sessions.variables(logPath, threadId, 0)
|
|
240
|
+
const byName = Object.fromEntries(variables.map((item) => [item.name, item.stringValue ?? item.value]))
|
|
241
|
+
assert.equal(typeof byName.i, 'number', 'the parameter is readable')
|
|
242
|
+
assert.equal(typeof byName.doubled, 'number', 'the local variable is readable')
|
|
243
|
+
assert.equal(byName.doubled, byName.i * 2, 'the local value matches the fixture logic')
|
|
244
|
+
|
|
245
|
+
await sessions.step(logPath, 'over', threadId)
|
|
246
|
+
await sessions.resume(logPath)
|
|
247
|
+
assert.equal(sessions.status(logPath).attached, true)
|
|
248
|
+
} finally {
|
|
249
|
+
await sessions.detach(logPath).catch(() => {})
|
|
250
|
+
child.kill('SIGKILL')
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
test('a custom startCommand opts into debugging via ${JDWP_ARGS}', async () => {
|
|
256
|
+
const program = { kind: 'spring', dir: '/w/m', startCommand: 'cd /w/m && exec java ${JDWP_ARGS} -cp target/classes a.B', logName: 'm' }
|
|
257
|
+
const debug = T.buildStartSpec(program, { workspace: '/w', logsDir: '/w/logs', debug: { port: 5007 } })
|
|
258
|
+
assert.match(debug.command, /exec java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127\.0\.0\.1:5007 -cp target\/classes a\.B$/)
|
|
259
|
+
const plain = T.buildStartSpec(program, { workspace: '/w', logsDir: '/w/logs' })
|
|
260
|
+
assert.match(plain.command, /exec java +-cp target\/classes a\.B$/, 'the placeholder becomes a blank when not debugging')
|
|
261
|
+
assert.doesNotMatch(plain.command, /-agentlib:jdwp/)
|
|
262
|
+
const windows = T.windowsStartSpec(program, { workspace: '/w', logsDir: '/w/logs', debug: { port: 5008, suspend: true } })
|
|
263
|
+
assert.match(windows.spec.command, /-agentlib:jdwp=.*suspend=y.*address=127\.0\.0\.1:5008/)
|
|
264
|
+
|
|
265
|
+
// No placeholder ⇒ refused before any process is spawned
|
|
266
|
+
await assert.rejects(
|
|
267
|
+
() => T.startProgramDebug('/w', '/w/logs', { kind: 'spring', dir: '/w/m', startCommand: 'exec java -cp x a.B', logName: 'm', logPath: '/w/logs/m.log' }, {}),
|
|
268
|
+
(error) => error.code === 'not-startable' && /JDWP_ARGS/.test(error.message),
|
|
269
|
+
)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
// ── pre-set ("打提前量") breakpoints: no session required ────────────────────
|
|
274
|
+
test('breakpoints can be set before any session and are installed on attach', async () => {
|
|
275
|
+
const calls = []
|
|
276
|
+
const client = fakeClient(calls)
|
|
277
|
+
const sessions = T.createDebugSessions({ clientFactory: () => client })
|
|
278
|
+
const logPath = '/tmp/pre.log'
|
|
279
|
+
|
|
280
|
+
// (a) no session: the add is remembered instead of throwing
|
|
281
|
+
const pre = await sessions.addBreakpoint(logPath, 'cn.com.ris.X', 42)
|
|
282
|
+
assert.equal(pre.pending, true)
|
|
283
|
+
assert.equal(pre.line, 42)
|
|
284
|
+
const listed = sessions.listBreakpoints(logPath)
|
|
285
|
+
assert.equal(listed.length, 1)
|
|
286
|
+
assert.equal(listed[0].pending, true)
|
|
287
|
+
assert.equal(listed[0].className, 'cn.com.ris.X')
|
|
288
|
+
// the poll/status the panel uses expose it too (so the gutter shows the dot)
|
|
289
|
+
assert.equal(sessions.status(logPath).breakpoints.length, 1)
|
|
290
|
+
assert.equal(sessions.poll(logPath, 0).breakpoints.length, 1)
|
|
291
|
+
|
|
292
|
+
// (b) a second pre-set breakpoint, then one is removed by className+line
|
|
293
|
+
await sessions.addBreakpoint(logPath, 'cn.com.ris.Y', 7)
|
|
294
|
+
assert.equal(sessions.listBreakpoints(logPath).length, 2)
|
|
295
|
+
await sessions.removeBreakpoint(logPath, 'pending', 'cn.com.ris.Y', 7)
|
|
296
|
+
assert.deepEqual(sessions.listBreakpoints(logPath).map((bp) => bp.className), ['cn.com.ris.X'])
|
|
297
|
+
|
|
298
|
+
// (c) attaching installs the remembered breakpoints
|
|
299
|
+
const program = { id: 'spring:x#a.B', name: 'x', logPath }
|
|
300
|
+
assert.equal(sessions.pendingCount(logPath), 1, 'the host can ask before booting suspended')
|
|
301
|
+
const attached = await sessions.attach(program, 5100)
|
|
302
|
+
assert.equal(attached.installedPending, 1, 'attach reports what it installed')
|
|
303
|
+
assert.equal(sessions.pendingCount(logPath), 0, 'the pending list is consumed by the attach')
|
|
304
|
+
const installed = calls.filter((call) => call[0] === 'setBreakpoint')
|
|
305
|
+
assert.ok(installed.some((call) => call[1] === 'cn.com.ris.X' && call[2] === 42), 'the pre-set breakpoint reached the new session')
|
|
306
|
+
|
|
307
|
+
// (d) clearing drops the pre-set list as well
|
|
308
|
+
await sessions.detach(logPath)
|
|
309
|
+
await sessions.addBreakpoint(logPath, 'cn.com.ris.Z', 9)
|
|
310
|
+
await sessions.clearBreakpoints(logPath)
|
|
311
|
+
assert.deepEqual(sessions.listBreakpoints(logPath), [])
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
test('a debug RESTART keeps the breakpoints (detach re-queues them for attach)', async () => {
|
|
316
|
+
const calls = []
|
|
317
|
+
const client = fakeClient(calls)
|
|
318
|
+
const sessions = T.createDebugSessions({ clientFactory: () => client })
|
|
319
|
+
const program = { id: 'spring:x#a.B', name: 'x', logPath: '/tmp/restart.log' }
|
|
320
|
+
|
|
321
|
+
await sessions.attach(program, 5100)
|
|
322
|
+
// The fake client reports one live breakpoint (a.B:7), like a real session would.
|
|
323
|
+
assert.equal(sessions.listBreakpoints('/tmp/restart.log').length, 1)
|
|
324
|
+
|
|
325
|
+
// Restart = detach + attach: the breakpoint must survive as a pre-set one…
|
|
326
|
+
await sessions.detach('/tmp/restart.log')
|
|
327
|
+
assert.equal(sessions.pendingCount('/tmp/restart.log'), 1, 'detach re-queued the live breakpoint')
|
|
328
|
+
assert.equal(sessions.listBreakpoints('/tmp/restart.log')[0].className, 'a.B')
|
|
329
|
+
|
|
330
|
+
// …and be installed into the fresh session.
|
|
331
|
+
const before = calls.filter((call) => call[0] === 'setBreakpoint').length
|
|
332
|
+
await sessions.attach(program, 5100)
|
|
333
|
+
const installed = calls.filter((call) => call[0] === 'setBreakpoint')
|
|
334
|
+
assert.ok(installed.length > before, 'the re-attach installed the kept breakpoint')
|
|
335
|
+
assert.ok(installed.slice(before).some((call) => call[1] === 'a.B' && call[2] === 7))
|
|
336
|
+
assert.equal(sessions.pendingCount('/tmp/restart.log'), 0, 'consumed by the attach')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
test('breakpoint_remove accepts className+line (no request id) on a live session', async () => {
|
|
341
|
+
const calls = []
|
|
342
|
+
const client = fakeClient(calls)
|
|
343
|
+
const sessions = T.createDebugSessions({ clientFactory: () => client })
|
|
344
|
+
await sessions.attach({ id: 'spring:x#a.B', name: 'x', logPath: '/tmp/rm.log' }, 5100)
|
|
345
|
+
// The fake session reports a.B:7 (requestId 1); the caller only knows the source location.
|
|
346
|
+
const removed = await sessions.removeBreakpoint('/tmp/rm.log', undefined, 'a.B', 7)
|
|
347
|
+
assert.deepEqual(removed, { removed: true, requestId: 1 })
|
|
348
|
+
assert.ok(calls.some((call) => call[0] === 'removeBreakpoint' && call[1] === 1))
|
|
349
|
+
// Nothing matches ⇒ a clear, actionable error instead of silently doing nothing.
|
|
350
|
+
await assert.rejects(() => sessions.removeBreakpoint('/tmp/rm.log', undefined, 'a.B', 999), /requestId \(or className \+ line\) is required/)
|
|
351
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portability guard for the test harness itself.
|
|
3
|
+
*
|
|
4
|
+
* `npm test` must work on a bare clone: no `npm install`, no network, no
|
|
5
|
+
* internal/machine-specific paths. That is easy to break by accident (a
|
|
6
|
+
* helpful default pointing at a colleague's checkout, an SSR fixture pulled
|
|
7
|
+
* from a private registry), so these checks fail loudly when it happens.
|
|
8
|
+
*/
|
|
9
|
+
import { test } from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
12
|
+
import { join, relative } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
16
|
+
const SELF = 'test/harness.test.mjs'
|
|
17
|
+
|
|
18
|
+
// Assembled from parts so this file does not trip its own scan.
|
|
19
|
+
const HOME_DIR = new RegExp(['', 'home', '[^/\\\\\\s]+', ''].join('/'))
|
|
20
|
+
const USERS_DIR = new RegExp(['', 'Users', '[^/\\\\\\s]+', ''].join('/'))
|
|
21
|
+
const WINDOWS_USERS = new RegExp(['[A-Za-z]:\\\\', 'Users', '[^\\\\\\s]+'].join('\\\\'))
|
|
22
|
+
const INTERNAL_HOSTS = /jk\.ris\.com\.cn|ris\.com\.cn(?![a-z])/i
|
|
23
|
+
|
|
24
|
+
/** Every tracked source/doc file the guard looks at (`node_modules` and the vendored bundles are skipped). */
|
|
25
|
+
async function sourceFiles() {
|
|
26
|
+
const out = []
|
|
27
|
+
const walk = async (dir) => {
|
|
28
|
+
for (const entry of await readdir(join(ROOT, dir), { withFileTypes: true })) {
|
|
29
|
+
const path = `${dir}/${entry.name}`
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
if (entry.name === 'node_modules' || entry.name === 'vendor') continue
|
|
32
|
+
await walk(path)
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (!/\.(mjs|js|json|md|yml|yaml)$/.test(entry.name)) continue
|
|
36
|
+
if (entry.name === 'client-codemirror.js' || entry.name === 'client.js' && dir === 'lib') continue
|
|
37
|
+
if (path === SELF) continue
|
|
38
|
+
out.push(path)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const dir of ['test', 'lib', 'bin', 'skills']) await walk(dir)
|
|
42
|
+
for (const file of ['package.json', 'README.md', 'DEVELOPMENT.md', 'cordis.patch.yml']) out.push(file)
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('the test harness carries no machine-specific path', async () => {
|
|
47
|
+
const offenders = []
|
|
48
|
+
for (const file of await sourceFiles()) {
|
|
49
|
+
const text = await readFile(join(ROOT, file), 'utf8')
|
|
50
|
+
for (const [name, re] of [['home dir', HOME_DIR], ['macOS home', USERS_DIR], ['Windows home', WINDOWS_USERS]]) {
|
|
51
|
+
const match = text.match(re)
|
|
52
|
+
if (match) offenders.push(`${file}: ${name} → ${match[0]}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
assert.deepEqual(offenders, [], 'test/docs must not hard-code a machine path (use a relative candidate or an env var)')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('no internal/private host leaks into tests or docs', async () => {
|
|
59
|
+
const offenders = []
|
|
60
|
+
for (const file of await sourceFiles()) {
|
|
61
|
+
const match = (await readFile(join(ROOT, file), 'utf8')).match(INTERNAL_HOSTS)
|
|
62
|
+
if (match) offenders.push(`${file}: ${match[0]}`)
|
|
63
|
+
}
|
|
64
|
+
assert.deepEqual(offenders, [], 'public files use placeholders such as <internal-nexus>/<internal-git>')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('npm test needs no install and no network', async () => {
|
|
68
|
+
const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8'))
|
|
69
|
+
assert.equal(pkg.scripts.test, 'node --test "test/*.test.mjs"', 'test script stays dependency-free (node:test only)')
|
|
70
|
+
for (const hook of ['pretest', 'posttest']) {
|
|
71
|
+
assert.equal(pkg.scripts[hook], undefined, `${hook} must not exist, or npm test stops being self-contained`)
|
|
72
|
+
}
|
|
73
|
+
// react/react-dom are optional extras: `npm install` provides them for the SSR
|
|
74
|
+
// assertions, a bare clone falls back to the stub and skips those.
|
|
75
|
+
assert.ok(pkg.devDependencies.react, 'react is a devDependency (public registry), never a runtime dependency')
|
|
76
|
+
assert.ok(pkg.devDependencies['react-dom'], 'react-dom is a devDependency (public registry)')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('test files import only builtins and relative modules', async () => {
|
|
80
|
+
const dir = join(ROOT, 'test')
|
|
81
|
+
const offenders = []
|
|
82
|
+
for (const name of await readdir(dir)) {
|
|
83
|
+
if (!name.endsWith('.test.mjs')) continue
|
|
84
|
+
if (relative(ROOT, join(dir, name)) === SELF) continue
|
|
85
|
+
const text = await readFile(join(dir, name), 'utf8')
|
|
86
|
+
for (const match of text.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
|
|
87
|
+
const spec = match[1]
|
|
88
|
+
if (spec.startsWith('node:') || spec.startsWith('.') || spec.startsWith('/')) continue
|
|
89
|
+
offenders.push(`${name}: ${spec}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
assert.deepEqual(offenders, [], 'a test importing a package would need npm install (and a registry) to run')
|
|
93
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A machine with the plugin but WITHOUT git: the file tree and filename search
|
|
3
|
+
* must fall back to the filesystem instead of showing an empty repository.
|
|
4
|
+
*
|
|
5
|
+
* This lives in its own test file on purpose: the host module remembers "git is
|
|
6
|
+
* missing" for the life of the process (so it does not spawn git per tree level),
|
|
7
|
+
* and a dedicated process keeps that stickiness away from the other host tests.
|
|
8
|
+
* Run with PATH emptied, which makes `spawn('git')` fail with ENOENT.
|
|
9
|
+
*/
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { __test } from '../lib/index.js'
|
|
17
|
+
|
|
18
|
+
const { listIdeTree, searchWorkspaceFiles } = __test
|
|
19
|
+
|
|
20
|
+
test('without git, the tree and filename search fall back to the filesystem', async () => {
|
|
21
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-nogit-'))
|
|
22
|
+
// A git REPO (only the .git directory is needed to find the root) whose
|
|
23
|
+
// .gitignore would normally hide README.md — without git there is no filter.
|
|
24
|
+
await mkdir(join(root, '.git'))
|
|
25
|
+
await mkdir(join(root, 'src', 'main', 'java'), { recursive: true })
|
|
26
|
+
await writeFile(join(root, 'README.md'), '# readme\n')
|
|
27
|
+
await writeFile(join(root, 'src', 'main', 'java', 'A.java'), 'class A {}\n')
|
|
28
|
+
await writeFile(join(root, '.gitignore'), 'README.md\n')
|
|
29
|
+
|
|
30
|
+
const savedPath = process.env.PATH
|
|
31
|
+
process.env.PATH = ''
|
|
32
|
+
try {
|
|
33
|
+
const listing = await listIdeTree(root, root)
|
|
34
|
+
const labels = listing.entries.map((entry) => entry.label)
|
|
35
|
+
assert.ok(labels.includes('src'), 'directories are still listed')
|
|
36
|
+
assert.ok(labels.includes('README.md'), 'files are not hidden by a .gitignore we cannot evaluate')
|
|
37
|
+
|
|
38
|
+
const src = listing.entries.find((entry) => entry.label === 'src')
|
|
39
|
+
const javaDir = join(src.path, 'main', 'java')
|
|
40
|
+
const inner = await listIdeTree(javaDir, root)
|
|
41
|
+
assert.deepEqual(inner.entries.map((entry) => entry.label), ['A.java'])
|
|
42
|
+
|
|
43
|
+
const found = await searchWorkspaceFiles(root, 'a.java', 10)
|
|
44
|
+
assert.ok(found.matches.some((path) => path.endsWith('A.java')), 'filename search still answers')
|
|
45
|
+
} finally {
|
|
46
|
+
process.env.PATH = savedPath
|
|
47
|
+
}
|
|
48
|
+
})
|