dsh-ris-ide 0.0.0-stage → 1.6.8

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.
@@ -0,0 +1,3557 @@
1
+ /**
2
+ * Client-half tests: the platform module contract, the viewer registration,
3
+ * prefix-safe URL building, the pure state helpers, an SSR render of the
4
+ * component, and the two-tier read (tail route → fs.read fallback) against a
5
+ * real HTTP listener driven by the host half's own reader.
6
+ *
7
+ * The client bundle is loaded exactly the way the browser loads it: a fake
8
+ * `window.__ModuleLoader__` captures the `{ id, factory }` registration, and
9
+ * the factory runs with a `require` that answers `react`.
10
+ */
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import { createServer } from 'node:http'
14
+ import { createRequire } from 'node:module'
15
+ import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
16
+ import { tmpdir } from 'node:os'
17
+ import { join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ import { __test as hostTest } from '../lib/index.js'
21
+
22
+ const require = createRequire(import.meta.url)
23
+ const ROOT = fileURLToPath(new URL('..', import.meta.url))
24
+
25
+ /**
26
+ * Where a *real* React (with `react-dom/server`) can be loaded from, or `''`
27
+ * when there is none — the tests then fall back to the stub below.
28
+ *
29
+ * Candidates, in order:
30
+ * 1. `DSH_RIS_IDE_REACT_ROOT` — explicit override (CI cache, custom layout);
31
+ * 2. this repo's own `node_modules` — after `npm install`: `react`/`react-dom`
32
+ * are devDependencies, so a plain checkout works anywhere;
33
+ * 3. a sibling `dsh-better-sidebar` checkout — local-dev convenience only,
34
+ * resolved *relative* to this file.
35
+ *
36
+ * No absolute machine path is baked in and nothing reaches the network:
37
+ * `npm test` must pass on a bare clone with no `npm install` at all.
38
+ */
39
+ function findReactRoot() {
40
+ const candidates = [
41
+ process.env.DSH_RIS_IDE_REACT_ROOT,
42
+ join(ROOT, 'node_modules'),
43
+ join(ROOT, '..', 'tools', 'dsh-better-sidebar', 'node_modules'),
44
+ join(ROOT, '..', 'dsh-better-sidebar', 'node_modules'),
45
+ ].filter(Boolean)
46
+ for (const root of candidates) {
47
+ try {
48
+ require(`${root}/react`)
49
+ require(`${root}/react-dom/server`)
50
+ return root
51
+ } catch { /* try the next candidate */ }
52
+ }
53
+ return ''
54
+ }
55
+
56
+ const REACT_ROOT = findReactRoot()
57
+
58
+ /** Real React/ReactDOM when the sibling checkout has them (SSR test), else a stub. */
59
+ function loadReact() {
60
+ try {
61
+ return { value: require(`${REACT_ROOT}/react`), real: true }
62
+ } catch {
63
+ return { value: stubReact(), real: false }
64
+ }
65
+ }
66
+
67
+ /** Minimal hook surface: enough to load and call the module without a DOM. */
68
+ function stubReact() {
69
+ // React 把 children 也放进 props.children;stub 必须一致,否则纯断言(treeLabel 等)
70
+ // 在 CI(没有真实 React)下会拿到 undefined。
71
+ const element = (type, props, ...children) => {
72
+ const next = Object.assign({}, props)
73
+ if (children.length === 1) next.children = children[0]
74
+ else if (children.length > 1) next.children = children
75
+ return { type, props: next, children }
76
+ }
77
+ return {
78
+ Component: class Component {
79
+ constructor(props) { this.props = props || {} }
80
+ setState(next) { this.state = Object.assign({}, this.state, typeof next === 'function' ? next(this.state) : next) }
81
+ },
82
+ createElement: element,
83
+ useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
84
+ useEffect: () => {},
85
+ useRef: (initial) => ({ current: initial }),
86
+ useCallback: (fn) => fn,
87
+ }
88
+ }
89
+
90
+ /** Capture the module registration the way the platform loader does. */
91
+ let registration = null
92
+ globalThis.window = { __ModuleLoader__: { load: (entry) => { registration = entry } } }
93
+ await import('../lib/client.js')
94
+
95
+ const { value: React, real: reactIsReal } = loadReact()
96
+
97
+ /**
98
+ * The standalone window mounts through `react-dom/client`; the factory's require
99
+ * is fixed at load time, so the stub lives here and records what was rendered
100
+ * (no DOM needed — the real panel is exercised by the SSR test below).
101
+ */
102
+ const standaloneRoots = []
103
+ const stubReactDomClient = {
104
+ createRoot: (container) => ({
105
+ render: (element) => { standaloneRoots.push({ container, element }) },
106
+ }),
107
+ }
108
+
109
+ const client = registration.factory((name) => {
110
+ if (name === 'react') return React
111
+ if (name === 'react-dom/client') return stubReactDomClient
112
+ throw new Error(`unexpected module request: ${name}`)
113
+ })
114
+ const T = client.__test
115
+
116
+ /**
117
+ * Run `apply` with a context whose effects are tracked, and return a disposer.
118
+ * The plugin now owns a long-lived interval (the open-request poller), so a test
119
+ * that leaves the effects undisposed would keep the process alive.
120
+ */
121
+ function applyTracked(args) {
122
+ const disposers = []
123
+ const ctx = Object.assign({}, args, {
124
+ effect: (fn) => { const dispose = fn(); disposers.push(dispose); return dispose },
125
+ })
126
+ client.apply(ctx)
127
+ return () => {
128
+ for (const dispose of disposers) {
129
+ try { if (typeof dispose === 'function') dispose() } catch { /* teardown only */ }
130
+ }
131
+ }
132
+ }
133
+
134
+
135
+ /** A client context shaped like the official DSH right-Sidebar services. */
136
+ function rightSidebarContext() {
137
+ const state = { types: [], slots: [], opened: [] }
138
+ const disposers = []
139
+ const ctx = {
140
+ effect: (fn) => { const dispose = fn(); if (typeof dispose === 'function') disposers.push(dispose); return () => {} },
141
+ sidebarRightTabs: {
142
+ register: (definition) => { state.types.push(definition); return () => {} },
143
+ },
144
+ slots: {
145
+ inject: (name, fn) => fn(),
146
+ register: (spec, component) => { state.slots.push({ spec, component }); return () => {} },
147
+ },
148
+ sidebarRight: {
149
+ openTab: (kind) => { state.opened.push(kind) },
150
+ },
151
+ }
152
+ return { ctx, state, disposers }
153
+ }
154
+
155
+ test('closing the active tab works on chords browsers do not reserve', () => {
156
+ // browser-reachable chords
157
+ assert.equal(T.closeTabChord({ altKey: true, key: 'w' }), true, 'Alt+W')
158
+ assert.equal(T.closeTabChord({ ctrlKey: true, altKey: true, key: 'W' }), true, 'Ctrl+Alt+W')
159
+ assert.equal(T.closeTabChord({ ctrlKey: true, key: 'F4' }), true, 'Ctrl+F4')
160
+ // Ctrl+W is still claimed (it works in the desktop shell even though a plain
161
+ // browser may close the tab first)
162
+ assert.equal(T.closeTabChord({ ctrlKey: true, key: 'w' }), true)
163
+ assert.equal(T.closeTabChord({ metaKey: true, key: 'w' }), true)
164
+ // never on plain typing or unrelated chords
165
+ assert.equal(T.closeTabChord({ key: 'w' }), false)
166
+ assert.equal(T.closeTabChord({ key: 'q' }), false)
167
+ assert.equal(T.closeTabChord({ ctrlKey: true, shiftKey: true, key: 'w' }), false, 'Ctrl+Shift+W closes the window')
168
+ assert.equal(T.closeTabChord(undefined), false)
169
+ })
170
+
171
+ test('double-clicking a title-bar control does not maximise the pane', () => {
172
+ const control = { closest: (selector) => selector.includes('button') ? { tagName: 'BUTTON' } : null }
173
+ const text = { closest: () => null }
174
+ const nothing = { }
175
+ assert.equal(T.headerDoubleClickIsTitle(control), false, 'debug toolbar buttons')
176
+ assert.equal(T.headerDoubleClickIsTitle(text), true, 'the title text itself')
177
+ assert.equal(T.headerDoubleClickIsTitle(nothing), true, 'an element without closest')
178
+ assert.equal(T.headerDoubleClickIsTitle(null), true)
179
+ assert.equal(T.headerDoubleClickIsTitle(undefined), true)
180
+ // the search box in the editor header is an input
181
+ const input = { closest: (selector) => selector.includes('input') ? { tagName: 'INPUT' } : null }
182
+ assert.equal(T.headerDoubleClickIsTitle(input), false)
183
+ })
184
+
185
+ test('only invisible outcomes and failures raise a toast', async () => {
186
+ const { readFile } = await import('node:fs/promises')
187
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
188
+ // Visible-in-the-UI successes stay silent: the row/tab/marker already changed.
189
+ for (const silent of ['已保存 ', '已启动 ', '已停止 ', '已切换到', '已关闭其他', '已加断点 ',
190
+ '已删除断点', '已标记断点', '已预置断点', '已撤销:', '已新建文件 ', '已新建文件夹 ',
191
+ '已粘贴到', '已移动到', '已自动切换到调试中的项目', '已还原布局尺寸']) {
192
+ assert.equal(source.includes(silent), false, `${silent} must not toast`)
193
+ }
194
+ // Failures and warnings keep their toast (nothing else would tell the user).
195
+ for (const kept of ['保存失败:', '启动失败:', '停止失败:', '加断点失败:', '删断点失败:',
196
+ '清断点失败:', '撤销失败:', '移动失败:', '粘贴失败:', '新建文件失败:',
197
+ '取线程失败:', '取栈失败:', '取变量失败:', '文件不存在,已从最近历史移除:',
198
+ '已复制路径:', '已复制文件名:']) {
199
+ assert.ok(source.includes(kept), `${kept} must stay`)
200
+ }
201
+ // An explicit ⟳ is a user action whose result may be invisible (nothing changed),
202
+ // so it keeps a short confirmation toast.
203
+ assert.ok(source.includes('文件树已刷新'), 'manual tree refresh confirms with a toast')
204
+ // The clipboard and the trash are not visible anywhere else: keep telling them.
205
+ assert.ok(source.includes("'已复制 ' : '已剪切 '"), 'clipboard state is invisible: keep it')
206
+ assert.ok(source.includes("clipboard.mode === 'copy' ? '已复制 ' : '已剪切 '"), 'the paste hint strip too')
207
+ assert.ok(source.includes('已移到回收站'))
208
+ })
209
+
210
+ test('the tab dropdown lists open files then recent history, capped at 30', () => {
211
+ assert.equal(T.TAB_MENU_LIMIT, 30)
212
+ // open tabs stay in strip order; recent files append (newest first), no dupes
213
+ const rows = T.tabMenuEntries(['/a.java', '/b.java'], ['/c.java', '/a.java', '/d.java'])
214
+ assert.deepEqual(rows.map((row) => [row.path, row.open]), [
215
+ ['/a.java', true], ['/b.java', true], ['/c.java', false], ['/d.java', false],
216
+ ])
217
+ // the cap applies to the total, open tabs included
218
+ const many = Array.from({ length: 40 }, (_, i) => '/f' + i)
219
+ assert.equal(T.tabMenuEntries(many.slice(0, 5), many.slice(5), 30).length, 30)
220
+ assert.equal(T.tabMenuEntries(many, [], 30).length, 30)
221
+ assert.deepEqual(T.tabMenuEntries([], []), [])
222
+ assert.deepEqual(T.tabMenuEntries(undefined, undefined), [])
223
+ })
224
+
225
+ test('the empty search box offers at most 20 recent files', () => {
226
+ assert.equal(T.SEARCH_RECENT_LIMIT, 20)
227
+ const rows = T.searchHistoryEntries(['/a.java', '/b.java'], ['/b.java'])
228
+ assert.deepEqual(rows, [{ path: '/a.java', open: false }, { path: '/b.java', open: true }])
229
+ // the recorded list itself is capped, so the dropdown can never exceed it
230
+ const many = Array.from({ length: 40 }, (_, i) => '/f' + i)
231
+ assert.equal(T.searchHistoryEntries(many, [], 20).length, 20)
232
+ assert.equal(T.pushRecent(many, '/new').length, 30, 'default push cap stays the tab-menu cap')
233
+ assert.equal(T.pushRecent(many, '/new', T.SEARCH_RECENT_LIMIT).length, 20)
234
+ // duplicates in storage cannot duplicate rows
235
+ assert.deepEqual(T.searchHistoryEntries(['/a', '/a', ''], []), [{ path: '/a', open: false }])
236
+ assert.deepEqual(T.searchHistoryEntries(undefined, undefined), [])
237
+ })
238
+
239
+ test('a history entry whose file vanished is detected before opening', async () => {
240
+ const realFetch = globalThis.fetch
241
+ const calls = []
242
+ globalThis.fetch = async (href, init) => {
243
+ calls.push({ href: String(href), method: (init && init.method) || 'GET' })
244
+ return { ok: true, status: 404, json: async () => ({ ok: false, message: 'not-found' }) }
245
+ }
246
+ try {
247
+ assert.equal(await T.fileMissing({ sessionId: 's1', cwd: '/w' }, '/w/gone.java'), true)
248
+ assert.match(calls[0].href, /dsh-ris-ide\/fs\/read/)
249
+ assert.match(calls[0].href, /path=%2Fw%2Fgone\.java/)
250
+ // 200 means it still exists
251
+ globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ ok: true, kind: 'text', content: 'x' }) })
252
+ assert.equal(await T.fileMissing({ sessionId: 's1' }, '/w/here.java'), false)
253
+ // a failed request is not proof of deletion: keep the row
254
+ globalThis.fetch = async () => { throw new Error('offline') }
255
+ assert.equal(await T.fileMissing({ sessionId: 's1' }, '/w/maybe.java'), false)
256
+ assert.equal(await T.fileMissing({ sessionId: 's1' }, ''), true)
257
+ } finally {
258
+ globalThis.fetch = realFetch
259
+ }
260
+ })
261
+
262
+ test('recent files are moved to the front, deduped and bounded', () => {
263
+ assert.deepEqual(T.pushRecent(['/a', '/b'], '/c'), ['/c', '/a', '/b'])
264
+ assert.deepEqual(T.pushRecent(['/a', '/b'], '/a'), ['/a', '/b'])
265
+ assert.deepEqual(T.pushRecent([], '/a', 2), ['/a'])
266
+ assert.deepEqual(T.pushRecent(['/a', '/b'], '/c', 2), ['/c', '/a'])
267
+ assert.equal(T.pushRecent(Array.from({ length: 40 }, (_, i) => '/f' + i), '/new').length, 30)
268
+ assert.deepEqual(T.pushRecent(undefined, '/a'), ['/a'])
269
+ assert.deepEqual(T.pushRecent(['/a'], ''), ['/a'])
270
+ })
271
+
272
+ test('bulk close separates victims from the dirty ones it must ask about', () => {
273
+ const dirty = (path) => path === '/b.java' || path === '/c.java'
274
+ const others = T.bulkClosePlan(['/a.java', '/b.java', '/c.java'], '/a.java', dirty, true)
275
+ assert.deepEqual(others.victims, ['/b.java', '/c.java'])
276
+ assert.deepEqual(others.dirty, ['/b.java', '/c.java'])
277
+ const all = T.bulkClosePlan(['/a.java', '/b.java'], '/a.java', dirty, false)
278
+ assert.deepEqual(all.victims, ['/a.java', '/b.java'])
279
+ assert.deepEqual(all.dirty, ['/b.java'])
280
+ assert.deepEqual(T.bulkClosePlan([], undefined, dirty, false), { victims: [], dirty: [] })
281
+ })
282
+
283
+ test('the editor surfaces the batch-close prompt and the tab dropdown', async () => {
284
+ const { readFile } = await import('node:fs/promises')
285
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
286
+ for (const action of ['bulk-save-close', 'bulk-discard-close', 'bulk-cancel-close', 'tabs-menu']) {
287
+ assert.ok(source.includes(`'data-action': '${action}'`), `${action} is wired`)
288
+ }
289
+ assert.match(source, /'data-bulk-prompt': '1'/)
290
+ assert.match(source, /'data-tabs-menu': '1'/)
291
+ assert.match(source, /recent: recentFiles/, 'the history survives a reload')
292
+ assert.match(source, /'data-search-history': '1'/, 'the empty search box shows the history')
293
+ assert.match(source, /'data-search-history-row': row.path/)
294
+ })
295
+
296
+ test('the bundle registers under the package name and exports a cordis plugin', () => {
297
+ assert.equal(registration.id, 'dsh-ris-ide')
298
+ assert.equal(client.name, 'dsh-ris-ide')
299
+ assert.equal(typeof client.apply, 'function')
300
+ assert.deepEqual(client.inject, ['sidebarRight', 'sidebarRightTabs', 'slots'], 'only the official DSH services')
301
+ assert.equal(typeof T, 'object')
302
+ })
303
+
304
+
305
+
306
+ test('apply registers the console tab with the official right-Sidebar registry', () => {
307
+ const { ctx, state } = rightSidebarContext()
308
+ client.apply(ctx)
309
+ assert.equal(state.types.length, 1, 'one tab type')
310
+ const definition = state.types[0]
311
+ assert.equal(definition.id, 'dsh-ris-ide')
312
+ assert.equal(definition.kind, 'dsh-ris-ide:ide')
313
+ assert.equal(definition.multiple, false)
314
+ // The sidebar carries the COMPACT console, not the IDE: the label says so.
315
+ assert.equal(definition.title(), 'RIS控制台')
316
+ assert.equal(definition.guide[0].title(), 'RIS控制台')
317
+
318
+ const body = state.slots.find((entry) => entry.spec.name === 'sidebar.right.pane.tab')
319
+ assert.ok(body !== undefined, 'the body is registered in the tab slot')
320
+ assert.equal(body.spec.key, 'dsh-ris-ide')
321
+ assert.equal(typeof body.component, 'function')
322
+ const injected = body.spec.inject('s1')
323
+ assert.equal(injected.sessionId, 's1')
324
+ assert.equal(injected.visible, true)
325
+ assert.equal(injected.compact, true, 'the sidebar tab renders the compact shape')
326
+ assert.ok(state.slots.some((entry) => entry.spec.name === 'sidebar.right.pane.tab.title' && entry.spec.key === 'dsh-ris-ide'))
327
+ })
328
+
329
+ test('the compact panel is console rows plus a resizable 240px log pane', () => {
330
+ assert.equal(T.COMPACT_PANE_DEFAULT, 240)
331
+ assert.equal(T.readCompactPaneHeight(), 240, 'nothing saved yet')
332
+ const storage = {
333
+ getItem: (key) => (key === T.COMPACT_UI_KEY ? '360' : null),
334
+ setItem: () => {},
335
+ }
336
+ const previous = globalThis.window.localStorage
337
+ globalThis.window.localStorage = storage
338
+ try {
339
+ assert.equal(T.readCompactPaneHeight(), 360, 'a dragged height is remembered')
340
+ storage.getItem = () => '20'
341
+ assert.equal(T.readCompactPaneHeight(), 120, 'clamped at the bottom')
342
+ storage.getItem = () => '5000'
343
+ assert.equal(T.readCompactPaneHeight(), 720, 'clamped at the top')
344
+ storage.getItem = () => 'not-a-number'
345
+ assert.equal(T.readCompactPaneHeight(), 240, 'garbage falls back to the default')
346
+ } finally {
347
+ globalThis.window.localStorage = previous
348
+ }
349
+ })
350
+
351
+ test('the compact panel remembers which section is maximized', () => {
352
+ assert.equal(T.readCompactMax(), '', 'nothing saved yet: the split view')
353
+ const storage = {
354
+ getItem: (key) => (key === T.COMPACT_MAX_KEY ? 'log' : null),
355
+ setItem: () => {},
356
+ }
357
+ const previous = globalThis.window.localStorage
358
+ globalThis.window.localStorage = storage
359
+ try {
360
+ assert.equal(T.readCompactMax(), 'log', 'the maximized log is remembered')
361
+ storage.getItem = () => 'console'
362
+ assert.equal(T.readCompactMax(), 'console')
363
+ storage.getItem = () => 'editor'
364
+ assert.equal(T.readCompactMax(), '', 'an unknown section degrades to the split view')
365
+ storage.getItem = () => 'not-json'
366
+ assert.equal(T.readCompactMax(), '')
367
+ } finally {
368
+ globalThis.window.localStorage = previous
369
+ }
370
+ })
371
+
372
+ test('the compact shape renders the console and the IDE-window button, not the full workbench', { skip: !reactIsReal }, () => {
373
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
374
+ const markup = renderToStaticMarkup(React.createElement(T.ProgramsPanel, { sessionId: 's1', cwd: '/w/project', visible: true, compact: true }))
375
+ assert.match(markup, /data-panel="compact"/)
376
+ assert.match(markup, /data-compact-bar="1"/)
377
+ assert.match(markup, /data-action="ide-window"/, 'the panel top-right opens the IDE window')
378
+ assert.match(markup, /data-splitter="compact-log"/, 'the log pane has a drag handle')
379
+ // Each title bar keeps its own maximize toggle (console = the top bar, log =
380
+ // the log header); the log header no longer carries the IDE-window button.
381
+ assert.match(markup, /data-action="compact-max-console"/, 'the console title bar can maximize itself')
382
+ assert.match(markup, /data-action="compact-max-log"/, 'the log title bar can maximize itself')
383
+ const logHeadAt = markup.indexOf('data-compact-log')
384
+ assert.ok(logHeadAt > 0)
385
+ const logHead = markup.slice(logHeadAt)
386
+ assert.doesNotMatch(logHead, /data-action="ide-window"/, 'the log header has no IDE button any more')
387
+ // The full IDE's grid belongs to the standalone window only.
388
+ assert.doesNotMatch(markup, /data-ide-grid/)
389
+ })
390
+
391
+ test('the compact log title bar owns the follow switch and its maximize toggle', { skip: !reactIsReal }, () => {
392
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
393
+ const render = (props) => renderToStaticMarkup(React.createElement(T.CompactLogHead, Object.assign({
394
+ logPath: '/w/logs/ris-model-biz.log',
395
+ programName: 'ris-model-biz',
396
+ logFileName: 'ris-model-biz.log',
397
+ following: true,
398
+ maximized: false,
399
+ onToggleFollow() {},
400
+ onToggleMaximize() {},
401
+ }, props)))
402
+ const markup = render({})
403
+ assert.match(markup, /data-action="compact-follow"/, 'the follow/pause button lives in the log title bar')
404
+ assert.match(markup, /aria-pressed="true"/, 'following is the default: the button is ON')
405
+ assert.match(markup, /title="跟随中:点击暂停跟随"/)
406
+ assert.match(markup, /data-action="compact-max-log"/, 'the same bar keeps its maximize toggle ')
407
+ assert.match(markup, /⤢/, 'not maximized: the button offers maximize')
408
+ assert.doesNotMatch(markup, /data-action="ide-window"/, 'the log bar no longer opens the IDE window')
409
+ // The log's A−/A+ pair lives here too (shared PREF_FONT_SIZE with the IDE pane).
410
+ assert.match(markup, /data-action="compact-font-down"/, 'the log bar can shrink its font')
411
+ assert.match(markup, /data-action="compact-font-up"/, 'the log bar can grow its font')
412
+ assert.match(markup, /title="放大日志字号(当前 12.5px)"/, 'the size is stated from the prop')
413
+ const bigger = render({ fontSize: 15 })
414
+ assert.match(bigger, /title="缩小日志字号(当前 15px)"/)
415
+ // No log file = nothing to size or follow: the bar only keeps the maximize seat.
416
+ const empty = render({ logPath: '', logFileName: '' })
417
+ assert.doesNotMatch(empty, /compact-font-/, 'no font buttons without a log file')
418
+ assert.doesNotMatch(empty, /compact-follow/, 'no follow button without a log file')
419
+ // Paused state flips both the label and the pressed state.
420
+ const paused = render({ following: false, maximized: true })
421
+ assert.match(paused, /aria-pressed="false"/)
422
+ assert.match(paused, /title="已暂停:点击继续跟随(自动滚到最新)"/)
423
+ assert.match(paused, /⤡/, 'maximized: the button offers restore')
424
+ // One button metric for the whole compact panel: 20px tall, glyph-only = 20x20.
425
+ assert.equal((markup.match(/height:20px/g) || []).length >= 2, true, 'both log-bar buttons are 20px tall')
426
+ assert.equal((markup.match(/width:20px/g) || []).length >= 2, true, 'both log-bar buttons are 20px wide')
427
+ })
428
+
429
+ test('apply adds a Session-header button immediately right of DSH\'s open-in-app action', () => {
430
+ const { ctx, state } = rightSidebarContext()
431
+ client.apply(ctx)
432
+ const header = state.slots.find((entry) => entry.spec.name === 'conversation.session.header.utilities')
433
+ assert.ok(header !== undefined, 'the header utilities slot gets the button')
434
+ assert.equal(header.spec.id, 'ris-ide-window')
435
+ // open-in-app owns -10 and schedule-catalog -5: -8 sits between them, i.e. the
436
+ // second icon, to the RIGHT of the VS Code button.
437
+ assert.equal(header.spec.order, -8)
438
+ assert.equal(typeof header.component, 'function')
439
+ if (reactIsReal) {
440
+ // Hooks need a renderer, so the button is asserted through a real SSR pass.
441
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
442
+ const markup = renderToStaticMarkup(React.createElement(header.component, { sessionId: 's1' }))
443
+ assert.match(markup, /data-action="ris-ide-window"/)
444
+ assert.match(markup, /RIS-IDE/)
445
+ assert.match(markup, /title="在独立窗口中打开RIS-IDE"/, 'the tooltip is the short wording')
446
+ }
447
+ })
448
+
449
+ test('openStandaloneWindow hands the scope to the plugin\'s own page', () => {
450
+ const previousWindow = globalThis.window
451
+ const previousDocument = globalThis.document
452
+ const opened = []
453
+ globalThis.window = {
454
+ open: (...args) => { opened.push(args); return { focus() {} } },
455
+ }
456
+ globalThis.document = { baseURI: 'http://127.0.0.1:3080/' }
457
+ try {
458
+ T.openStandaloneWindow({ sessionId: 's1', cwd: '/w/project', programId: 'p1' })
459
+ assert.equal(opened.length, 1)
460
+ const [url, name, features] = opened[0]
461
+ const parsed = new URL(url)
462
+ // Same origin as the DSH page: no new port, cookie auth comes along.
463
+ assert.equal(parsed.origin, 'http://127.0.0.1:3080')
464
+ assert.equal(parsed.pathname, '/dsh-ris-ide/window')
465
+ assert.equal(parsed.searchParams.get('sessionId'), 's1')
466
+ assert.equal(parsed.searchParams.get('cwd'), '/w/project')
467
+ assert.equal(parsed.searchParams.get('programId'), 'p1')
468
+ assert.equal(parsed.searchParams.get('debug'), null, 'plain open: no auto-debug')
469
+ // One IDE window per workspace: the name is derived from the cwd, so a
470
+ // second click on the same workspace reuses this window, while another
471
+ // workspace keeps its own window.
472
+ assert.equal(name, T.standaloneWindowName('/w/project'), 'a workspace-keyed window name')
473
+ assert.equal(parsed.searchParams.get('wname'), name, 'the page claims the same name the opener used')
474
+ assert.match(name, /^dsh-ris-ide-window-[0-9a-z]+$/)
475
+ // A program-subdirectory cwd roots the panel there, but the WINDOW still
476
+ // follows the workspace (`windowCwd`), so it never opens a second window.
477
+ globalThis.window.localStorage = { getItem: () => '/w/project' }
478
+ T.openStandaloneWindow({ sessionId: 's1', cwd: '/w/project/sub', programId: 'p1' })
479
+ const scoped = new URL(opened[opened.length - 1][0])
480
+ assert.equal(scoped.searchParams.get('cwd'), '/w/project/sub', 'the panel keeps its rooted cwd')
481
+ assert.equal(opened[opened.length - 1][1], T.standaloneWindowName('/w/project'), 'the window name stays workspace-keyed')
482
+ assert.match(features, /popup=yes/)
483
+ // The compact panel's 🐞 button: same window, plus "start the JDWP session".
484
+ T.openStandaloneWindow({ sessionId: 's1', cwd: '/w/project', programId: 'p1', debug: true })
485
+ assert.equal(new URL(opened[opened.length - 1][0]).searchParams.get('debug'), '1')
486
+ } finally {
487
+ globalThis.window = previousWindow
488
+ globalThis.document = previousDocument
489
+ }
490
+ })
491
+
492
+ test('openStandaloneWindow asks for the whole screen work area (maximized)', () => {
493
+ const previousWindow = globalThis.window
494
+ const previousDocument = globalThis.document
495
+ const opened = []
496
+ globalThis.window = {
497
+ screen: { availWidth: 1920, availHeight: 1040 },
498
+ open: (...args) => { opened.push(args); return { focus() {} } },
499
+ }
500
+ globalThis.document = { baseURI: 'http://127.0.0.1:3080/' }
501
+ try {
502
+ T.openStandaloneWindow({ sessionId: 's1' })
503
+ const features = opened[0][2]
504
+ assert.match(features, /width=1920/, 'the work area width, not a fixed width')
505
+ assert.match(features, /height=1040/, 'the work area height, not a fixed height')
506
+ assert.match(features, /left=0/, 'pinned to the left edge')
507
+ assert.match(features, /top=0/, 'pinned to the top edge')
508
+ assert.match(features, /popup=yes/)
509
+ // No screen information (odd hosts): keep the old fallback instead of NaN.
510
+ globalThis.window = { open: (...args) => { opened.push(args); return { focus() {} } } }
511
+ T.openStandaloneWindow({ sessionId: 's1' })
512
+ assert.match(opened[1][2], /width=1500/)
513
+ } finally {
514
+ globalThis.window = previousWindow
515
+ globalThis.document = previousDocument
516
+ }
517
+ })
518
+
519
+ test('openStandaloneWindow reports a blocked popup instead of failing silently', () => {
520
+ const previousWindow = globalThis.window
521
+ const previousDocument = globalThis.document
522
+ globalThis.window = { open: () => null }
523
+ globalThis.document = { baseURI: 'http://127.0.0.1:3080/' }
524
+ try {
525
+ assert.throws(() => T.openStandaloneWindow({ sessionId: 's1' }), /被拦截/)
526
+ } finally {
527
+ globalThis.window = previousWindow
528
+ globalThis.document = previousDocument
529
+ }
530
+ })
531
+
532
+ test('mountStandalone renders the IDE panel with no DSH context at all', async () => {
533
+ const previousWindow = globalThis.window
534
+ const previousDocument = globalThis.document
535
+ globalThis.window = { open: () => ({}) }
536
+ globalThis.document = { baseURI: 'http://127.0.0.1:3080/' }
537
+ standaloneRoots.length = 0
538
+ try {
539
+ const container = { id: 'root' }
540
+ await T.mountStandalone(container, { sessionId: 's1', cwd: '/w/project' })
541
+ assert.equal(standaloneRoots.length, 1, 'one react-dom root is created')
542
+ assert.equal(standaloneRoots[0].container, container)
543
+ const { element } = standaloneRoots[0]
544
+ // The panel sits under the error boundary, so one render error cannot blank
545
+ // the window; the boundary itself is the outermost component.
546
+ assert.equal(element.type, T.IdeErrorBoundary, 'the panel is wrapped in the error boundary')
547
+ const panel = element.props.children
548
+ assert.equal(typeof panel.type, 'function', 'the panel component, not a wrapper')
549
+ assert.equal(panel.props.sessionId, 's1')
550
+ assert.equal(panel.props.cwd, '/w/project')
551
+ assert.equal(panel.props.visible, true)
552
+ } finally {
553
+ globalThis.window = previousWindow
554
+ globalThis.document = previousDocument
555
+ }
556
+ })
557
+
558
+ test('routeUrl keeps a reverse-proxy directory prefix', () => {
559
+ const previous = globalThis.document
560
+ globalThis.document = { baseURI: 'http://host/dataops/proxy/3080/?token=abc#x' }
561
+ try {
562
+ assert.equal(T.routeUrl('dsh-ris-ide/tail').href, 'http://host/dataops/proxy/3080/dsh-ris-ide/tail')
563
+ assert.equal(T.routeUrl('/sidebar/api/fs.read').href, 'http://host/dataops/proxy/3080/sidebar/api/fs.read')
564
+ } finally {
565
+ globalThis.document = previous
566
+ }
567
+ })
568
+
569
+ test('state helpers report the shape the viewer renders', () => {
570
+ assert.equal(T.countLines(''), 0)
571
+ assert.equal(T.countLines('a\n'), 1)
572
+ assert.equal(T.countLines('a\nb'), 2)
573
+ assert.equal(T.formatBytes(512), '512 B')
574
+ assert.equal(T.formatBytes(2048), '2.0 KB')
575
+ assert.equal(T.formatBytes(3 * 1024 * 1024), '3.0 MB')
576
+
577
+ const tail = T.stateFromPayload({ ok: true, content: 'a\nb\n', mode: 'tail', size: 4, elided: 99 })
578
+ assert.equal(tail.lines, 2)
579
+ assert.equal(tail.mode, 'tail')
580
+ assert.equal(tail.elided, 99)
581
+ assert.equal(tail.error, undefined)
582
+
583
+ const head = T.stateFromPayload({ ok: true, content: 'only\n', mode: 'head', truncated: true, note: 'head-mode' })
584
+ assert.equal(head.mode, 'head')
585
+ assert.equal(head.truncated, true)
586
+
587
+ assert.equal(T.stateFromPayload(undefined).loading, true)
588
+ assert.equal(T.stateFromPayload({ ok: false, error: 'boom' }).error, 'boom')
589
+ assert.match(T.metaText(tail), /4 B/)
590
+ assert.match(T.metaText(tail), /2 行/)
591
+ assert.match(T.metaText(tail), /省略前 99 B/)
592
+ assert.match(T.metaText(head), /头部模式/)
593
+ })
594
+
595
+ test('the component renders the tail it was handed', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
596
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
597
+ const markup = renderToStaticMarkup(React.createElement(T.LogTail, {
598
+ scope: { sessionId: 's1', cwd: '/tmp' },
599
+ path: 'service.log',
600
+ customData: { ok: true, content: 'BOOT-OK\nlistening on 16220\n', mode: 'tail', size: 34, elided: 0, mtimeMs: 1 },
601
+ }))
602
+ assert.match(markup, /BOOT-OK/)
603
+ assert.match(markup, /listening on 16220/)
604
+ // The viewer BODY has no bar since 1.6.1: follow/wrap/size are drawn by the log
605
+ // pane's title bar (see the workbench title-bar tests below).
606
+ assert.doesNotMatch(markup, /暂停跟随|继续跟随|换行:开/)
607
+ assert.doesNotMatch(markup, /data-action="follow"/)
608
+ assert.match(markup, /data-dsh-ris-ide="viewer"/)
609
+
610
+ const headMarkup = renderToStaticMarkup(React.createElement(T.LogTail, {
611
+ scope: { sessionId: 's1', cwd: '/tmp' },
612
+ path: 'service.log',
613
+ customData: { ok: true, content: 'HEAD\n', mode: 'head', truncated: true, note: 'head-mode' },
614
+ }))
615
+ assert.match(headMarkup, /退回头部读取/)
616
+ })
617
+
618
+ /** One listener that answers both tiers; `disableTail` flips the first off. */
619
+ async function withLogServer(run) {
620
+ const cwd = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-client-'))
621
+ const file = join(cwd, 'service.log')
622
+ let content = ''
623
+ for (let index = 1; index <= 3000; index += 1) content += `tick ${index}\n`
624
+ await writeFile(file, content)
625
+
626
+ const state = { disableTail: false, disableStop: false, disableConfig: false, tailCalls: 0, headCalls: 0, stopCalls: 0, stopBody: undefined, configCalls: 0, configBody: undefined, opens: [], openCalls: 0 }
627
+ const server = createServer((req, res) => {
628
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
629
+ if (req.method === 'GET' && url.pathname === '/dsh-ris-ide/opens') {
630
+ state.openCalls += 1
631
+ res.writeHead(200, { 'content-type': 'application/json' })
632
+ res.end(JSON.stringify({ ok: true, opens: state.opens }))
633
+ state.opens = []
634
+ return
635
+ }
636
+ if (req.method === 'POST' && url.pathname === '/dsh-ris-ide/config') {
637
+ state.configCalls += 1
638
+ const chunks = []
639
+ req.on('data', (chunk) => chunks.push(chunk))
640
+ req.on('end', () => {
641
+ state.configBody = JSON.parse(Buffer.concat(chunks).toString('utf8'))
642
+ res.writeHead(state.disableConfig ? 409 : 200, { 'content-type': 'application/json' })
643
+ res.end(JSON.stringify(state.disableConfig
644
+ ? { ok: false, code: 'config-exists', message: 'exists' }
645
+ : { ok: true, path: '/w/.dsh-ris-ide.json', count: 3, mode: state.configBody.mode ?? 'generate' }))
646
+ })
647
+ return
648
+ }
649
+ if (req.method === 'POST' && url.pathname === '/dsh-ris-ide/stop') {
650
+ state.stopCalls += 1
651
+ const chunks = []
652
+ req.on('data', (chunk) => chunks.push(chunk))
653
+ req.on('end', () => {
654
+ state.stopBody = JSON.parse(Buffer.concat(chunks).toString('utf8'))
655
+ res.writeHead(state.disableStop ? 409 : 200, { 'content-type': 'application/json' })
656
+ res.end(JSON.stringify(state.disableStop
657
+ ? { ok: false, code: 'unverifiable', message: 'nope' }
658
+ : { ok: true, stopped: true, alreadyStopped: false, forced: false, waitedMs: 12, pid: 4242, wrapperPid: 4241 }))
659
+ })
660
+ return
661
+ }
662
+ if (req.method === 'GET' && url.pathname === '/dsh-ris-ide/tail') {
663
+ state.tailCalls += 1
664
+ if (state.disableTail) {
665
+ res.writeHead(403, { 'content-type': 'application/json' })
666
+ res.end(JSON.stringify({ ok: false, code: 'forbidden', message: 'loopback requests only' }))
667
+ return
668
+ }
669
+ void hostTest.readTail(file, Number(url.searchParams.get('bytes') ?? 2048)).then((tail) => {
670
+ res.writeHead(200, { 'content-type': 'application/json' })
671
+ res.end(JSON.stringify({ ok: true, ...tail }))
672
+ })
673
+ return
674
+ }
675
+ if (req.method === 'GET' && url.pathname === '/dsh-ris-ide/fs/read') {
676
+ // The head tier is OUR route now (the sidebar api is gone).
677
+ state.headCalls += 1
678
+ res.writeHead(200, { 'content-type': 'application/json' })
679
+ res.end(JSON.stringify({
680
+ ok: true,
681
+ kind: 'text',
682
+ content: `HEAD-ONLY for ${url.searchParams.get('path')}\n`,
683
+ truncated: true,
684
+ mtimeMs: 7,
685
+ }))
686
+ return
687
+ }
688
+ res.writeHead(404)
689
+ res.end()
690
+ })
691
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
692
+ const previousDocument = globalThis.document
693
+ globalThis.document = { baseURI: `http://127.0.0.1:${server.address().port}/` }
694
+ try {
695
+ return await run({ cwd, file, content, state })
696
+ } finally {
697
+ globalThis.document = previousDocument
698
+ // `fetch` keeps connections alive; without this `server.close()` would wait
699
+ // for a pooled socket and the test file would never finish.
700
+ server.closeAllConnections?.()
701
+ await new Promise((resolve) => server.close(resolve))
702
+ }
703
+ }
704
+
705
+ test('readLog prefers the host tail route and falls back to fs.read', async () => {
706
+ await withLogServer(async ({ file, content, state }) => {
707
+ const scope = { sessionId: 's1', cwd: '/tmp' }
708
+
709
+ const tail = await T.readLog(file, scope, undefined, 2048)
710
+ assert.equal(tail.ok, true)
711
+ assert.equal(tail.mode, 'tail')
712
+ assert.equal(state.tailCalls, 1)
713
+ assert.equal(state.headCalls, 0, 'the fallback must not run when the tail route answers')
714
+ assert.ok(content.endsWith(tail.content), 'the tail must be the file end')
715
+ assert.ok(tail.content.length <= 2048)
716
+
717
+ // Refuse the tail route: the viewer must degrade to the head tier with a note.
718
+ state.disableTail = true
719
+ const head = await T.readLog(file, scope, undefined, 2048)
720
+ assert.equal(head.ok, true)
721
+ assert.equal(head.mode, 'head')
722
+ assert.equal(head.note, 'head-mode')
723
+ assert.equal(head.truncated, true)
724
+ assert.match(head.content, /^HEAD-ONLY for /)
725
+ assert.equal(state.headCalls, 1)
726
+ })
727
+ })
728
+
729
+ test('readLog reports an error only when both tiers fail', async () => {
730
+ const previousDocument = globalThis.document
731
+ globalThis.document = { baseURI: 'http://127.0.0.1:1/' }
732
+ try {
733
+ const result = await T.readLog('x.log', { sessionId: 's1' }, undefined, 2048)
734
+ assert.equal(result.ok, false)
735
+ assert.equal(typeof result.error, 'string')
736
+ assert.ok(result.error.length > 0)
737
+ } finally {
738
+ globalThis.document = previousDocument
739
+ }
740
+ })
741
+
742
+ test('parseLine decorates timestamps and levels', () => {
743
+ const info = T.parseLine('2026-10-16 20:55:51.123 INFO 1 --- [main] Started')
744
+ assert.equal(info.level, 'INFO')
745
+ assert.deepEqual(info.parts.map((part) => part.text), [
746
+ '2026-10-16 20:55:51.123', ' ', 'INFO', ' 1 --- [main] Started',
747
+ ])
748
+ assert.ok(info.parts[0].style.opacity < 1, 'the timestamp is dimmed')
749
+ assert.equal(info.parts[2].style.fontWeight, 600, 'the level token is bold')
750
+ assert.equal(info.parts[2].style.color, undefined, 'INFO keeps the text color')
751
+
752
+ const error = T.parseLine('2026-10-16 20:55:51 ERROR boom')
753
+ assert.equal(error.level, 'ERROR')
754
+ assert.equal(error.parts[2].style.color, '#e5534b')
755
+
756
+ const warn = T.parseLine('WARN something')
757
+ assert.equal(warn.level, 'WARN')
758
+ assert.equal(warn.parts[0].style.color, '#d29922')
759
+
760
+ const plain = T.parseLine('just a line')
761
+ assert.equal(plain.level, undefined)
762
+ assert.deepEqual(plain.parts, [{ text: 'just a line' }])
763
+
764
+ const empty = T.parseLine('')
765
+ assert.deepEqual(empty.parts, [{ text: '' }])
766
+ })
767
+
768
+ test('ANSI SGR becomes styled spans and other CSI sequences disappear', () => {
769
+ const colored = T.parseLine('\u001B[31merror\u001B[0m plain')
770
+ assert.equal(colored.parts.length, 2)
771
+ assert.equal(colored.parts[0].text, 'error')
772
+ assert.equal(colored.parts[0].style.color, '#e5534b')
773
+ assert.equal(colored.parts[1].text, ' plain')
774
+
775
+ const merged = T.ansiSegments('\u001B[32mok\u001B[32m!\u001B[0m done')
776
+ assert.deepEqual(merged.map((part) => part.text), ['ok!', ' done'])
777
+
778
+ const cursor = T.parseLine('\u001B[2K\u001B[1Gclean')
779
+ assert.deepEqual(cursor.parts.map((part) => part.text), ['clean'])
780
+
781
+ assert.deepEqual(T.applySgr({}, '31'), { color: '#e5534b' })
782
+ assert.deepEqual(T.applySgr({ color: '#e5534b' }, '1'), { color: '#e5534b', fontWeight: 600 })
783
+ assert.deepEqual(T.applySgr({ color: '#e5534b', fontWeight: 600 }, '0'), {})
784
+ assert.deepEqual(T.applySgr({}, '38;2;1;2;3'), { color: 'rgb(1,2,3)' })
785
+ assert.deepEqual(T.applySgr({}, '38;5;196'), { color: 'rgb(255,55,55)' })
786
+ })
787
+
788
+ test('the log view keeps only the current run of a reused log file', () => {
789
+ const twoRuns = [
790
+ '--- svc started 2026-10-19T10:00:00+08:00 ---',
791
+ 'OLD LINE',
792
+ '--- svc exited 2026-10-19T11:00:00+08:00 (status 143) ---',
793
+ '--- svc started 2026-10-20T20:47:40+08:00 ---',
794
+ 'NEW LINE',
795
+ '',
796
+ ].join('\n')
797
+ const trimmed = T.trimLogToCurrentRun(twoRuns)
798
+ assert.equal(trimmed.includes('OLD LINE'), false)
799
+ assert.match(trimmed, /^--- svc started 2026-10-20T20:47:40/)
800
+ assert.ok(trimmed.includes('NEW LINE'))
801
+ // marker at position 0 / no marker / empty: unchanged
802
+ const single = '--- svc started 1 ---\nbody\n'
803
+ assert.equal(T.trimLogToCurrentRun(single), single)
804
+ assert.equal(T.trimLogToCurrentRun('plain\n'), 'plain\n')
805
+ assert.equal(T.trimLogToCurrentRun(''), '')
806
+ assert.equal(T.trimLogToCurrentRun(undefined), undefined)
807
+ })
808
+
809
+ test('parseLog splits, drops the trailing blank and caps the window', () => {
810
+ assert.deepEqual(T.parseLog('a\nb\n').map((line) => line.parts[0].text), ['a', 'b'])
811
+ assert.deepEqual(T.parseLog(''), [])
812
+ assert.equal(T.parseLog('x\n'.repeat(5000)).length, 4000, 'the cap keeps the window bounded')
813
+ })
814
+
815
+ test('clampFontSize and serviceText hold the supported ranges', () => {
816
+ assert.equal(T.clampFontSize('14'), 14)
817
+ assert.equal(T.clampFontSize(9), 10)
818
+ assert.equal(T.clampFontSize(99), 20)
819
+ assert.equal(T.clampFontSize('nonsense'), T.DEFAULT_FONT_SIZE)
820
+ assert.equal(T.serviceText(undefined), '')
821
+ assert.equal(T.serviceText({ pid: 7, alive: true }), '运行中 (pid 7)')
822
+ assert.equal(T.serviceText({ pid: 7, alive: false }), '已停止 (pid 7)')
823
+ // Liveness without a pid (legacy row / fixture) keeps the label readable.
824
+ assert.equal(T.serviceText({ alive: true }), '运行中')
825
+ // Both buttons were removed in 1.2.8 (stop duplicates the console; 到底部
826
+ // duplicates the follow toggle).
827
+ assert.match(T.MONO_FAMILY, /Ubuntu Sans Mono/)
828
+ })
829
+
830
+ test('the log viewer is read-only: no stop button, no redundant 到底部', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
831
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
832
+ const render = (customData) => renderToStaticMarkup(React.createElement(T.LogTail, {
833
+ scope: { sessionId: 's1', cwd: '/tmp' },
834
+ path: 'service.log',
835
+ customData: customData,
836
+ }))
837
+
838
+ const running = render({
839
+ ok: true, content: 'BOOT-OK\n', mode: 'tail', serviceKnown: true,
840
+ service: { pid: 4242, wrapperPid: 4241, alive: true },
841
+ })
842
+ assert.match(running, /data-service="running"/)
843
+ assert.ok(!running.includes('data-action="stop"'), 'stopping lives in the console, not the log viewer')
844
+ assert.ok(!running.includes('停止服务'))
845
+ assert.ok(!running.includes('data-action="bottom"'), 'following already snaps to the bottom')
846
+ // 1.6.1: the in-body bar is gone — its controls live in the log pane title bar.
847
+ assert.ok(!running.includes('换行:开'), 'no wrap label in the body')
848
+ assert.ok(!running.includes('data-action="font-up"'), 'no size button in the body')
849
+ assert.ok(!running.includes('data-action="follow"'), 'no follow button in the body')
850
+ assert.ok(!running.includes('运行中'), 'the service label is in the pane title bar')
851
+ assert.match(running, /data-wrap="on"/, 'wrap is on by default (body wraps)')
852
+
853
+ const exited = render({
854
+ ok: true, content: 'BYE\n', mode: 'tail', serviceKnown: true,
855
+ service: { pid: 4242, alive: false },
856
+ })
857
+ assert.match(exited, /data-service="stopped"/)
858
+ assert.ok(!exited.includes('data-action="stop"'), 'an exited service offers no stop button')
859
+
860
+ const none = render({ ok: true, content: 'x\n', mode: 'tail', serviceKnown: true })
861
+ assert.match(none, /data-service="none"/)
862
+ assert.ok(!none.includes('data-action="stop"'))
863
+ })
864
+
865
+ test('requestStop posts the scope and reports the outcome', async () => {
866
+ await withLogServer(async ({ file, state }) => {
867
+ const ok = await T.requestStop(file, { sessionId: 's1', cwd: '/tmp' })
868
+ assert.equal(ok.ok, true)
869
+ assert.equal(ok.stopped, true)
870
+ assert.equal(state.stopCalls, 1)
871
+ assert.deepEqual(state.stopBody, { sessionId: 's1', cwd: '/tmp', path: file })
872
+
873
+ state.disableStop = true
874
+ const refused = await T.requestStop(file, { sessionId: 's1' })
875
+ assert.equal(refused.ok, false)
876
+ assert.equal(refused.code, 'unverifiable')
877
+ assert.match(refused.error, /nope/)
878
+ })
879
+ })
880
+
881
+
882
+
883
+ test('openInstanceLog opens the IDE tab through the official right sidebar', () => {
884
+ const { ctx, state } = rightSidebarContext()
885
+ T.openInstanceLog(ctx, { sessionId: 's1', cwd: '/w' }, '/w/logs/ris-model.log', 'ris-model 日志')
886
+ assert.deepEqual(state.opened, ['dsh-ris-ide:ide'])
887
+ assert.equal(typeof T.selectProgram, 'function', 'the pending program helper is part of the surface')
888
+ })
889
+
890
+ test('openInstanceLog refuses loudly when the right sidebar is absent', () => {
891
+ // Regression guard: the panel used to reference `apply`'s `ctx` from module
892
+ // scope, which surfaced as "ctx is not defined" when the human clicked.
893
+ assert.throws(() => T.openInstanceLog(null, { sessionId: 's1' }, '/x.log', 'x'), /右侧栏服务不可用/)
894
+ assert.throws(() => T.openInstanceLog({}, { sessionId: 's1' }, '/x.log', 'x'), /右侧栏服务不可用/)
895
+ })
896
+
897
+
898
+ test('generateConfig asks the host to write or update the config', async () => {
899
+ await withLogServer(async ({ state }) => {
900
+ const scope = { sessionId: 's1', cwd: '/w' }
901
+ const generated = await T.generateConfig(scope)
902
+ assert.equal(generated.ok, true)
903
+ assert.equal(generated.path, '/w/.dsh-ris-ide.json')
904
+ assert.equal(generated.mode, 'generate')
905
+ assert.equal(state.configCalls, 1)
906
+ assert.deepEqual(state.configBody, { sessionId: 's1', cwd: '/w' })
907
+
908
+ const updated = await T.generateConfig(scope, 'update')
909
+ assert.equal(updated.ok, true)
910
+ assert.equal(updated.mode, 'update')
911
+ assert.deepEqual(state.configBody, { sessionId: 's1', cwd: '/w', mode: 'update' })
912
+
913
+ state.disableConfig = true
914
+ const refused = await T.generateConfig(scope, 'overwrite')
915
+ assert.equal(refused.ok, false)
916
+ assert.equal(refused.code, 'config-exists')
917
+ assert.match(refused.error, /exists/)
918
+ })
919
+ })
920
+
921
+
922
+ test('the plugin keeps its SSE connections inside the per-origin budget', async () => {
923
+ const { readFile } = await import('node:fs/promises')
924
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
925
+ // HTTP/1.1 allows 6 connections per origin; the host itself holds one for its
926
+ // session stream. One shared hub + one tail + one debug stream is the budget.
927
+ const opened = (source.match(/new EventSource\(/g) || []).length
928
+ assert.ok(opened <= 3, `expected at most 3 EventSources, found ${opened}`)
929
+ assert.match(source, /function risStreamHub\(\)/, 'the hub exists')
930
+ for (const topic of ['tree', 'programs', 'debugs', 'opens']) {
931
+ assert.match(source, new RegExp("topics: \\['" + topic + "\\'\\]"), `${topic} rides the shared hub`)
932
+ }
933
+ assert.match(source, /function useDocumentVisible\(\)/, 'background tabs release their streams')
934
+ // The regression that blocked the whole GUI: six streams per IDE panel.
935
+ assert.doesNotMatch(source, /url\.searchParams\.set\('topics', 'tree'\)/)
936
+ })
937
+
938
+ test('the client plugin drains host-side open requests into the IDE tab', async () => {
939
+ await withLogServer(async ({ state }) => {
940
+ state.opens = [{ sessionId: 's1', cwd: '/w', logPath: '/w/logs/ris-model.log', title: 'ris-model 日志' }]
941
+ const { ctx, state: right } = rightSidebarContext()
942
+ const dispose = applyTracked(ctx)
943
+ await T.drainOpenRequests()
944
+ dispose()
945
+ assert.equal(state.openCalls >= 1, true, 'the queue is drained at least once')
946
+ assert.deepEqual(right.opened, ['dsh-ris-ide:ide'], 'a pending log opens the IDE tab')
947
+ })
948
+ })
949
+
950
+
951
+ test('classNameFromPath derives the Java class name from RIS source paths', () => {
952
+ const fromPath = client.__test.classNameFromPath
953
+ assert.equal(fromPath('/ws/ris/ris-model/ris-model-biz/src/main/java/cn/com/ris/cloud/model/ModelHelper.java'), 'cn.com.ris.cloud.model.ModelHelper')
954
+ assert.equal(fromPath('ris/x/src/main/java/cn/com/X.java'), 'cn.com.X')
955
+ assert.equal(fromPath('/ws/src/main/java/Probe.java'), 'Probe')
956
+ assert.equal(fromPath('/ws/ris/x/src/main/java/cn/com/X.java'.replace(/\//g, '\\')), 'cn.com.X')
957
+ assert.equal(fromPath('/ws/ris/x/src/main/java/cn/com/X.kt'), undefined)
958
+ assert.equal(fromPath(''), undefined)
959
+ assert.equal(fromPath(undefined), undefined)
960
+ })
961
+
962
+ test('requestDebug builds read URLs and write bodies', async () => {
963
+ const calls = []
964
+ const original = globalThis.fetch
965
+ globalThis.fetch = async (url, init) => {
966
+ calls.push({ url: String(url), init })
967
+ return { ok: true, status: 200, json: async () => ({ ok: true, attached: true, seq: 3, events: [] }) }
968
+ }
969
+ try {
970
+ const scope = { sessionId: 's1', cwd: '/w' }
971
+ const polled = await client.__test.requestDebug('poll', scope, { path: '/w/logs/x.log', since: 2 })
972
+ assert.deepEqual(polled, { ok: true, attached: true, seq: 3, events: [] })
973
+ assert.match(calls[0].url, /dsh-ris-ide\/debug\/poll\?/)
974
+ assert.match(calls[0].url, /sessionId=s1/)
975
+ assert.match(calls[0].url, /since=2/)
976
+ assert.equal(calls[0].init.method, undefined, 'reads are GET')
977
+
978
+ await client.__test.requestDebug('breakpoints', scope, { path: '/w/logs/x.log', add: { className: 'a.B', line: 7 } })
979
+ assert.equal(calls[1].init.method, 'POST')
980
+ const body = JSON.parse(calls[1].init.body)
981
+ assert.deepEqual(body, { path: '/w/logs/x.log', add: { className: 'a.B', line: 7 }, sessionId: 's1', cwd: '/w' })
982
+
983
+ globalThis.fetch = async () => ({ ok: false, status: 409, json: async () => ({ ok: false, code: 'not-attached', message: 'nope' }) })
984
+ const failed = await client.__test.requestDebug('step', scope, { path: '/w/logs/x.log', kind: 'over' })
985
+ assert.equal(failed.ok, false)
986
+ assert.equal(failed.code, 'not-attached')
987
+ } finally {
988
+ globalThis.fetch = original
989
+ }
990
+ })
991
+
992
+
993
+ test('fsTree asks our tree route and keeps the folded labels', async () => {
994
+ const calls = []
995
+ const original = globalThis.fetch
996
+ globalThis.fetch = async (url, init) => {
997
+ calls.push({ url: String(url), init })
998
+ return {
999
+ ok: true,
1000
+ status: 200,
1001
+ json: async () => ({
1002
+ ok: true,
1003
+ path: '/w/proj',
1004
+ entries: [
1005
+ { name: 'model', label: 'model', path: '/w/proj/model', isDir: true, compact: false },
1006
+ { name: 'cloud', label: 'cn.com.ris.cloud.model', path: '/w/proj/cn/com/ris/cloud/model', isDir: true, compact: true },
1007
+ { name: 'a.java', label: 'a.java', path: '/w/proj/a.java', isDir: false },
1008
+ ],
1009
+ truncated: false,
1010
+ }),
1011
+ }
1012
+ }
1013
+ try {
1014
+ const tree = await T.fsTree({ sessionId: 's1', cwd: '/w' }, '/w/proj')
1015
+ assert.equal(tree.entries.length, 3)
1016
+ assert.equal(tree.entries[1].label, 'cn.com.ris.cloud.model', 'the compact label survives')
1017
+ assert.equal(tree.entries[1].path, '/w/proj/cn/com/ris/cloud/model', 'the folded path is the expansion target')
1018
+ assert.equal(tree.path, '/w/proj')
1019
+ assert.equal(tree.truncated, false)
1020
+
1021
+ const url = new URL(calls[0].url, 'http://dsh.internal')
1022
+ assert.equal(url.pathname.endsWith('/dsh-ris-ide/tree'), true, `unexpected url ${calls[0].url}`)
1023
+ assert.equal(url.searchParams.get('path'), '/w/proj')
1024
+ assert.equal(url.searchParams.get('sessionId'), 's1')
1025
+ assert.equal(url.searchParams.get('cwd'), '/w')
1026
+ } finally {
1027
+ globalThis.fetch = original
1028
+ }
1029
+ })
1030
+
1031
+ test('fsTree surfaces the host message instead of the HTTP status', async () => {
1032
+ const original = globalThis.fetch
1033
+ globalThis.fetch = async () => ({
1034
+ ok: false,
1035
+ status: 400,
1036
+ json: async () => ({ ok: false, message: 'boom' }),
1037
+ })
1038
+ try {
1039
+ await assert.rejects(T.fsTree({ sessionId: 's1-fail', cwd: '/w' }, '/w/proj-fail'), /boom/)
1040
+ } finally {
1041
+ globalThis.fetch = original
1042
+ }
1043
+ })
1044
+
1045
+ test('fsRead asks our own fs/read route and rejects binary or oversized files', async () => {
1046
+ const original = globalThis.fetch
1047
+ const calls = []
1048
+ const answer = (payload) => async (href) => {
1049
+ calls.push(String(href))
1050
+ return { ok: true, status: 200, json: async () => payload }
1051
+ }
1052
+ const scope = { sessionId: 's1', cwd: '/w' }
1053
+ try {
1054
+ globalThis.fetch = answer({ ok: true, kind: 'text', content: 'hello', size: 5, mtimeMs: 1 })
1055
+ assert.equal(await T.fsRead(scope, '/w/a.java'), 'hello')
1056
+ const url = new URL(calls[0])
1057
+ assert.equal(url.pathname.endsWith('/dsh-ris-ide/fs/read'), true, 'must not use the sidebar api')
1058
+ assert.equal(url.searchParams.get('path'), '/w/a.java')
1059
+ assert.equal(url.searchParams.get('sessionId'), 's1')
1060
+
1061
+ globalThis.fetch = answer({ ok: true, kind: 'binary', size: 5 })
1062
+ await assert.rejects(T.fsRead(scope, '/w/a.png'), /二进制/)
1063
+
1064
+ globalThis.fetch = answer({ ok: true, kind: 'too-large', size: 9000000 })
1065
+ await assert.rejects(T.fsRead(scope, '/w/big.log'), /过大/)
1066
+ } finally {
1067
+ globalThis.fetch = original
1068
+ }
1069
+ })
1070
+
1071
+ test('fsSearch asks our gitignore-aware host route and returns absolute files', async () => {
1072
+ const original = globalThis.fetch
1073
+ const calls = []
1074
+ try {
1075
+ globalThis.fetch = async (href, init) => {
1076
+ calls.push({ href: String(href), method: (init && init.method) || 'GET' })
1077
+ return { ok: true, status: 200, json: async () => ({ ok: true, matches: ['/w/ris/a.java', '/w/b.ts'], truncated: false, files: 13755 }) }
1078
+ }
1079
+ const result = await T.fsSearch({ sessionId: 's1', cwd: '/w' }, 'a.java')
1080
+ assert.deepEqual(result.matches, ['/w/ris/a.java', '/w/b.ts'])
1081
+ assert.deepEqual(result.dirs, {}, 'the gitignore search returns files only')
1082
+ assert.equal(calls.length, 1)
1083
+ const url = new URL(calls[0].href)
1084
+ assert.equal(url.pathname.endsWith('/dsh-ris-ide/search'), true, 'must use this plugin\'s search route')
1085
+ assert.equal(url.searchParams.get('q'), 'a.java')
1086
+ assert.equal(url.searchParams.get('cwd'), '/w')
1087
+ assert.equal(url.searchParams.get('sessionId'), 's1')
1088
+ } finally {
1089
+ globalThis.fetch = original
1090
+ }
1091
+ })
1092
+
1093
+ test('fsWrite posts JSON to our own fs/write route and returns the fresh baseline', async () => {
1094
+ const calls = []
1095
+ const original = globalThis.fetch
1096
+ globalThis.fetch = async (url, init) => {
1097
+ calls.push({ url: String(url), init })
1098
+ return { ok: true, status: 200, json: async () => ({ ok: true, mtimeMs: 123 }) }
1099
+ }
1100
+ try {
1101
+ const written = await T.fsWrite({ sessionId: 's1', cwd: '/w' }, '/w/a.java', 'new text')
1102
+ assert.equal(written.mtimeMs, 123)
1103
+ const url = new URL(calls[0].url, 'http://dsh.internal')
1104
+ assert.ok(url.pathname.endsWith('/dsh-ris-ide/fs/write'), `unexpected url ${calls[0].url}`)
1105
+ assert.equal(calls[0].init.method, 'POST')
1106
+ assert.match(calls[0].init.headers['content-type'], /application\/json/)
1107
+ assert.deepEqual(JSON.parse(calls[0].init.body), { sessionId: 's1', cwd: '/w', path: '/w/a.java', content: 'new text' })
1108
+
1109
+ globalThis.fetch = async () => ({ ok: false, status: 409, json: async () => ({ ok: false, message: 'conflict' }) })
1110
+ await assert.rejects(T.fsWrite({ sessionId: 's1', cwd: '/w' }, '/w/a.java', 'x'), /conflict/)
1111
+ } finally {
1112
+ globalThis.fetch = original
1113
+ }
1114
+ })
1115
+
1116
+ test('undo back to the saved text is not dirty any more', () => {
1117
+ assert.equal(T.docDirty({ kind: 'text', text: 'a', saved: 'a' }, { dirty: true }), false, 'mirrored text wins over the stale toolbar flag')
1118
+ assert.equal(T.docDirty({ kind: 'text', text: 'a', saved: 'b' }, undefined), true)
1119
+ assert.equal(T.docDirty(undefined, { dirty: true }), true, 'before the text is mirrored the toolbar flag is the only signal')
1120
+ assert.equal(T.docDirty(undefined, undefined), false)
1121
+ assert.equal(T.docDirty({ kind: 'media' }, { dirty: true }), true)
1122
+ assert.equal(T.fileBaseName('/w/ris/src/A.java'), 'A.java')
1123
+ assert.equal(T.fileBaseName('/w/ris'), 'ris')
1124
+ assert.equal(T.fileBaseName(''), '')
1125
+ })
1126
+
1127
+ test('the tree colour-codes file kinds', () => {
1128
+ assert.equal(T.fileKind({ isDir: true, name: 'src', path: '/w/src' }), 'dir')
1129
+ assert.equal(T.fileKind({ name: 'A.java', path: '/w/A.java' }), 'java')
1130
+ assert.equal(T.fileKind({ name: 'app.yml', path: '/w/app.yml' }), 'yaml')
1131
+ assert.equal(T.fileKind({ name: 'README', path: '/w/README' }), 'other')
1132
+ assert.equal(T.fileKind({ name: 'x.unknown', path: '/w/x.unknown' }), 'other')
1133
+ assert.equal(T.fileKind({ name: 'README.md', path: '/w/README.md' }), 'md')
1134
+ assert.equal(T.fileKind({ name: '.gitignore', path: '/w/.gitignore' }), 'dot')
1135
+ assert.equal(T.fileKind({ isDir: true, name: '.github', path: '/w/.github' }), 'dot')
1136
+ assert.equal(T.fileKind({ name: 'A.java', path: '/w/.cache/A.java' }), 'java', 'a dot FOLDER is what greys, not a file inside it')
1137
+ assert.notEqual(T.fileKindColor('java'), 'inherit')
1138
+ assert.notEqual(T.fileKindColor('java'), T.fileKindColor('css'), 'different kinds differ')
1139
+ assert.equal(T.fileKindColor('md'), '#9ecbff', 'markdown has its own colour')
1140
+ assert.equal(T.fileKindColor('dot'), '#8b949e', 'dot entries are grey')
1141
+ assert.equal(T.fileKindColor('nope'), 'inherit')
1142
+ })
1143
+
1144
+ test('new file names keep exactly one suffix', () => {
1145
+ assert.equal(T.normalizeFileName('ModelHelper', 'java'), 'ModelHelper.java')
1146
+ assert.equal(T.normalizeFileName('ModelHelper.java', 'java'), 'ModelHelper.java')
1147
+ assert.equal(T.normalizeFileName('ModelHelper.JAVA', 'java'), 'ModelHelper.JAVA')
1148
+ assert.equal(T.normalizeFileName('helper.js', 'java'), 'helper.js', 'another extension is respected')
1149
+ assert.equal(T.normalizeFileName('config.', 'yml'), 'config.yml')
1150
+ assert.equal(T.normalizeFileName(' ', 'xml'), '')
1151
+ assert.equal(T.normalizeFileName('.gitignore', 'txt'), '.gitignore')
1152
+ })
1153
+
1154
+ test('a package name becomes directories under src/main/java', () => {
1155
+ const dir = '/w/ris/ris-model/src/main/java'
1156
+ assert.equal(T.newFilePath(dir, 'cn.com.ris', 'ModelHelper', 'java'), `${dir}/cn/com/ris/ModelHelper.java`)
1157
+ assert.equal(T.newFilePath(dir, ' cn.com.ris. ', 'ModelHelper.java', 'java'), `${dir}/cn/com/ris/ModelHelper.java`)
1158
+ assert.equal(T.newFilePath(dir, '', 'Plain', 'java'), `${dir}/Plain.java`)
1159
+ assert.equal(T.newFilePath(`${dir}/`, '', 'Plain', 'java'), `${dir}/Plain.java`)
1160
+ assert.equal(T.isJavaSourceDir(`${dir}/cn`), true)
1161
+ assert.equal(T.isJavaSourceDir('/w/src/main/kotlin'), false)
1162
+ assert.equal(T.isJavaSourceDir('/w/ris-model/src/main/java2'), false)
1163
+ })
1164
+
1165
+ test('a package name creates multi-level folders, and only for legal identifiers', () => {
1166
+ assert.equal(T.packagePathOf('cn.com.ris'), 'cn/com/ris')
1167
+ assert.equal(T.packagePathOf(' .cn.com.ris. '), 'cn/com/ris', 'surrounding dots are ignored')
1168
+ assert.equal(T.packagePathOf('model'), 'model')
1169
+ assert.equal(T.packagePathOf('_a.$b.c1'), '_a/$b/c1', 'Java identifier rules allow _ $ and digits after the first char')
1170
+ assert.equal(T.packagePathOf('cn..ris'), '', 'an empty segment is not a package')
1171
+ assert.equal(T.packagePathOf('cn/ris'), '', 'slashes are refused (the path is built here, not typed)')
1172
+ assert.equal(T.packagePathOf('cn.9ris'), '', 'a segment cannot start with a digit')
1173
+ assert.equal(T.packagePathOf(' . '), '')
1174
+ assert.equal(T.packagePathOf(''), '')
1175
+ })
1176
+
1177
+ test('the tree offers 新建包名 only under java, and a package becomes folders', async () => {
1178
+ const { readFile } = await import('node:fs/promises')
1179
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1180
+ // Menu entry sits right after 新建文件夹 and is guarded by isJavaSourceDir.
1181
+ assert.ok(source.indexOf("'data-tree-action': 'new-package'") > source.indexOf("'data-tree-action': 'new-dir'"), '新建包名 comes after 新建文件夹')
1182
+ assert.match(source, /isJavaSourceDir\(treeMenu\.dir\)\n\s+\? h\('button', \{ key: 'new-package'/)
1183
+ assert.match(source, /'data-tree-action': 'new-package'[^\n]*openCreatePrompt\('package', treeMenu\.dir\)/)
1184
+ // The prompt has a package mode with dotted-name hint and a path preview.
1185
+ assert.match(source, /prompt\.mode === 'package'/)
1186
+ assert.match(source, /mode === 'package'\n\s+\? '新建包名'/)
1187
+ assert.match(source, /isPackage \? '包名' : '文件夹名'/)
1188
+ // Submitting creates the multi-level directory (host mkdir is recursive) and expands it.
1189
+ assert.match(source, /packageRelative = packagePathOf\(name\)/)
1190
+ assert.match(source, /await fsMkdir\(scope, packageDir\)/)
1191
+ assert.match(source, /next\[packageBase\] = true/)
1192
+ // The file prompt under java has ONE field: dots build the package folders,
1193
+ // so the old second "包名" input is gone (and no prefill logic remains).
1194
+ assert.doesNotMatch(source, /guessPackage/)
1195
+ assert.doesNotMatch(source, /data-tree-prompt-package/)
1196
+ assert.match(source, /javaFilePath\(prompt\.dir, name, prompt\.ext\)/)
1197
+ assert.match(source, /名称不合法:如 cloud\.test/, 'the invalid-name toast stays short too')
1198
+ })
1199
+
1200
+ test('under java one dotted name builds package folders and the file', () => {
1201
+ const java = '/w/ris/ris-model/src/main/java'
1202
+ assert.equal(T.javaFilePath(java, 'cloud.test', 'java'), `${java}/cloud/test.java`)
1203
+ assert.equal(T.javaFilePath(java, 'cn.com.ris.Foo', 'java'), `${java}/cn/com/ris/Foo.java`)
1204
+ assert.equal(T.javaFilePath(java, 'Cloud.Test.java', 'java'), `${java}/Cloud/Test.java`, 'a typed suffix is not part of the file name')
1205
+ assert.equal(T.javaFilePath(java, 'ModelHelper', 'java'), `${java}/ModelHelper.java`)
1206
+ assert.equal(T.javaFilePath(java, ' .cloud.test. ', 'java'), `${java}/cloud/test.java`, 'stray edge dots are ignored')
1207
+ assert.equal(T.javaFilePath(java, 'notes.md', ''), `${java}/notes.md`, '普通文件 keeps dots in the name')
1208
+ assert.equal(T.javaFilePath(java, 'cloud..test', 'java'), '', 'empty segment is refused')
1209
+ assert.equal(T.javaFilePath(java, 'cloud/test', 'java'), '', 'slashes are refused')
1210
+ assert.equal(T.javaFilePath(java, '9cloud.test', 'java'), '', 'a package segment cannot start with a digit')
1211
+ assert.equal(T.javaFilePath(java, '', 'java'), '')
1212
+ })
1213
+
1214
+ test('a stopped host produces a readable tree error and retries with backoff', () => {
1215
+ // A stale session id is rejected with 400: say so instead of retrying forever.
1216
+ const stale = new Error('no working directory for session')
1217
+ stale.status = 400
1218
+ assert.match(T.treeErrorMessage(stale), /会话上下文已失效/)
1219
+ assert.match(T.treeErrorMessage(new Error('Failed to fetch')), /宿主未响应/)
1220
+ assert.match(T.treeErrorMessage(new TypeError('fetch failed')), /宿主未响应/)
1221
+ assert.match(T.treeErrorMessage(new Error('HTTP 500')), /HTTP 500/)
1222
+ assert.deepEqual([0, 1, 2, 3, 4, 5, 99].map((n) => T.treeRetryDelay(n)), [800, 1600, 3200, 6000, 10000, 10000, 10000])
1223
+ assert.equal(T.treeRetryDelay(-3), 800)
1224
+ })
1225
+
1226
+ test('a stale session still works because the workspace path is remembered', async () => {
1227
+ const { readFile } = await import('node:fs/promises')
1228
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1229
+ assert.match(source, /WORKSPACE_CWD_KEY = 'dsh-ris-ide:last-cwd'/)
1230
+ assert.match(source, /function rememberWorkspaceCwd\(/)
1231
+ assert.match(source, /function knownWorkspaceCwd\(/)
1232
+ assert.match(source, /function scopeCwd\(/)
1233
+ assert.match(source, /var routeCwd = scopeCwd\(scope\)/, 'routes fall back to the remembered cwd')
1234
+ // Tree answers must teach the WORKSPACE cwd, never the listed directory: the
1235
+ // latter made an expanded folder look like a new workspace root (tree re-rooted
1236
+ // and flashed).
1237
+ assert.match(source, /rememberWorkspaceCwd\(treeCwd\)/, 'tree answers teach the workspace cwd')
1238
+ assert.doesNotMatch(source, /rememberWorkspaceCwd\(listing\.path\)/, 'never remember the listed dir')
1239
+ assert.match(source, /rememberWorkspaceCwd\(payload\.root\)/, 'programs answers teach the path')
1240
+ })
1241
+
1242
+ test('the selected range is highlighted stronger than the other matches', async () => {
1243
+ const { readFile } = await import('node:fs/promises')
1244
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1245
+ assert.match(source, /var SELECTION_CSS = /)
1246
+ assert.match(source, /rgba\(86,182,194,0\.42\)/, 'selection is opaque teal')
1247
+ // the active-line tint must not cover the selection on the current line(s)
1248
+ assert.match(source, /cm-selectionLayer\{z-index:5 !important\}/)
1249
+ assert.match(source, /dsh-ris-ide-has-selection \.cm-activeLine/, 'selection hides the current-line tint')
1250
+ assert.match(source, /dsh-ris-ide-has-selection \.cm-activeLineGutter/)
1251
+ assert.match(source, /rgba\(86,182,194,0\.50\)/, 'focused selection is stronger')
1252
+ assert.match(source, /rgba\(210,153,34,0\.45\)/, 'the current match is amber')
1253
+ const chunk = await readFile(new URL('../lib/client-codemirror.js', import.meta.url), 'utf8')
1254
+ assert.match(chunk, /cm-selectionMatch/, 'the editor highlights other matches')
1255
+ assert.match(chunk, /210, 153, 34/, 'other matches are amber')
1256
+ assert.match(chunk, /86, 182, 194, 0\.4/, 'the selection itself is teal and opaque enough')
1257
+ assert.match(chunk, /dsh-ris-ide-has-selection/, 'the editor marks itself while a selection exists')
1258
+ })
1259
+
1260
+ test('the tree clipboard keys only fire while the tree owns the focus', async () => {
1261
+ const { readFile } = await import('node:fs/promises')
1262
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1263
+ assert.match(source, /var root = treeListRef\.current/)
1264
+ assert.match(source, /if \(!inside\) return/)
1265
+ assert.match(source, /tabIndex: -1/)
1266
+ })
1267
+
1268
+ test('a forced tree read asks the host for a fresh listing', async () => {
1269
+ const { readFile } = await import('node:fs/promises')
1270
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1271
+ assert.match(source, /if \(force === true\) url\.searchParams\.set\('fresh', '1'\)/)
1272
+ })
1273
+
1274
+ test('the tree 复制 menu copies, while 创建副本 makes the duplicate', async () => {
1275
+ assert.equal(T.duplicateName('A.java', false), 'A - 副本.java')
1276
+ assert.equal(T.duplicateName('ModelHelper.java', false), 'ModelHelper - 副本.java')
1277
+ assert.equal(T.duplicateName('A.java', false, 2), 'A - 副本2.java')
1278
+ assert.equal(T.duplicateName('Makefile', false), 'Makefile - 副本')
1279
+ assert.equal(T.duplicateName('.gitignore', false), '.gitignore - 副本')
1280
+ assert.equal(T.duplicateName('pkg', true), 'pkg - 副本')
1281
+ assert.equal(T.duplicateName('pkg', true, 3), 'pkg - 副本3')
1282
+ assert.equal(T.duplicateName('', false), '')
1283
+ const { readFile } = await import('node:fs/promises')
1284
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1285
+ // 复制 = clipboard copy (the normal meaning); 创建副本 = the in-place duplicate.
1286
+ assert.match(source, /'data-tree-action': 'copy'/, '复制 copies to the tree clipboard')
1287
+ assert.match(source, /beginCopy\('copy'\)/)
1288
+ assert.match(source, /'data-tree-action': 'duplicate'/, '创建副本 duplicates in place')
1289
+ assert.match(source, /setPrompt\(\{ mode: 'duplicate'/)
1290
+ assert.match(source, /setPromptName\(duplicateName\(/)
1291
+ assert.match(source, /副本名称不能与原文件相同/)
1292
+ // A paste collision is a choice, not a failure: 覆盖 / 改名 / 取消.
1293
+ assert.match(source, /'data-tree-paste-conflict': '1'/)
1294
+ assert.match(source, /data-tree-paste-overwrite/)
1295
+ assert.match(source, /pasteConflictRename/)
1296
+ // …and moving an entry into its own folder stays a silent no-op.
1297
+ assert.match(source, /if \(from === dirPath \|\| parentOf\(from\) === dirPath\) return/)
1298
+ })
1299
+
1300
+ test('renaming a file or folder keeps tabs, drafts and history pointing at it', async () => {
1301
+ // paths under a renamed folder follow it; unrelated paths stay untouched
1302
+ assert.equal(T.remapPath('/w/a.java', '/w/a.java', '/w/b.java'), '/w/b.java')
1303
+ assert.equal(T.remapPath('/w/pkg/A.java', '/w/pkg', '/w/lib'), '/w/lib/A.java')
1304
+ assert.equal(T.remapPath('/w/pkg/deep/A.java', '/w/pkg', '/w/lib'), '/w/lib/deep/A.java')
1305
+ assert.equal(T.remapPath('/w/other.java', '/w/pkg', '/w/lib'), '/w/other.java')
1306
+ assert.equal(T.remapPath('/w/pkg2/A.java', '/w/pkg', '/w/lib'), '/w/pkg2/A.java', 'prefix must be a path segment')
1307
+ assert.equal(T.remapPath('', '/w/pkg', '/w/lib'), '')
1308
+ assert.equal(T.remapPath('/w/a', '', '/w/b'), '/w/a')
1309
+ const { readFile } = await import('node:fs/promises')
1310
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1311
+ assert.match(source, /'data-tree-action': 'rename'/, 'the tree menu offers 重命名')
1312
+ assert.match(source, /openRenamePrompt\(findEntry\(selectedPath\)\)/, 'F2 renames the selected entry')
1313
+ assert.match(source, /onRename: renamePaths/, 'the workbench remaps open tabs')
1314
+ })
1315
+
1316
+ test('the new-file menu starts with a plain file that keeps its name verbatim', async () => {
1317
+ // no suffix: the typed name is the file name (Makefile, .gitignore, README…)
1318
+ assert.equal(T.normalizeFileName('Makefile', ''), 'Makefile')
1319
+ assert.equal(T.normalizeFileName('.gitignore', ''), '.gitignore')
1320
+ assert.equal(T.normalizeFileName('notes.txt', ''), 'notes.txt')
1321
+ assert.equal(T.newFilePath('/w', '', 'Makefile', ''), '/w/Makefile')
1322
+ assert.equal(T.newFilePath('/w', '', 'README.md', ''), '/w/README.md')
1323
+ // a suffixed type still auto-completes
1324
+ assert.equal(T.normalizeFileName('ModelHelper', 'java'), 'ModelHelper.java')
1325
+ const { readFile } = await import('node:fs/promises')
1326
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1327
+ assert.match(source, /var NEW_FILE_TYPES = \[\n(?:\s*\/\/[^\n]*\n)*\s*\{ ext: '', label: '普通文件' \},/, 'plain file is the first entry')
1328
+ assert.match(source, /新建普通文件/)
1329
+ assert.match(source, /如 Makefile、README/, 'the plain-file hint stays short')
1330
+ })
1331
+
1332
+ test('the tree watch only reloads loaded directories and keeps a short cache', () => {
1333
+ const levels = {
1334
+ '/w': [{ name: 'ris', path: '/w/ris', isDir: true }],
1335
+ '/w/ris': [{ name: 'src', path: '/w/ris/src', isDir: true }],
1336
+ }
1337
+ // a changed file inside a loaded folder reloads THAT folder
1338
+ assert.deepEqual(T.changedLevelsToRefresh(levels, '/w', '.', ['ris/A.java']), ['/w/ris'])
1339
+ // a changed file under the loaded root but in an unloaded folder reloads the root
1340
+ assert.deepEqual(T.changedLevelsToRefresh(levels, '/w', '.', ['db/x.sql']), ['/w'])
1341
+ // a change deep inside an unloaded folder re-renders nothing at all
1342
+ assert.deepEqual(T.changedLevelsToRefresh(levels, '/w', '.', ['ris/src/main/A.java']), [])
1343
+ // a directory event for a loaded folder reloads it
1344
+ assert.deepEqual(T.changedLevelsToRefresh(levels, '/w', '.', ['ris']), ['/w/ris'])
1345
+ assert.deepEqual(T.changedLevelsToRefresh(levels, '/w', '.', []), [])
1346
+ assert.equal(T.TREE_REFRESH_THROTTLE_MS, 800)
1347
+ // Watcher events can concern rows the tree does not draw (gitignored churn,
1348
+ // file content); an identical level must not re-render.
1349
+ const level = [
1350
+ { name: 'A.java', path: '/w/A.java', isDir: false, kind: 'java' },
1351
+ { name: 'src', path: '/w/src', isDir: true, kind: 'dir' },
1352
+ ]
1353
+ const clone = level.map((entry) => Object.assign({}, entry))
1354
+ assert.equal(T.sameTreeEntries(level, clone), true, 'same rows are a no-op')
1355
+ assert.equal(T.sameTreeEntries(level, clone.concat([{ name: 'B.java', path: '/w/B.java', isDir: false }])), false, 'a new row re-renders')
1356
+ assert.equal(T.sameTreeEntries(level, [{ name: 'src', path: '/w/src', isDir: true, kind: 'dir' }]), false, 'a removed row re-renders')
1357
+ assert.equal(T.sameTreeEntries(undefined, clone), false, 'the first load always renders')
1358
+ assert.equal(T.treeListingFresh(1000, 1500, 1500), true)
1359
+ assert.equal(T.treeListingFresh(1000, 2500, 1500), false)
1360
+ assert.equal(T.treeListingFresh(2000, 1500, 1500), false, 'clock skew is not fresh')
1361
+ assert.equal(T.TREE_LIST_TTL_MS, 1500)
1362
+ })
1363
+
1364
+ test('deletes park in the undo trash under a unique, recognisable name', () => {
1365
+ assert.equal(T.TREE_TRASH_DIR, '.dsh-ris-ide-trash')
1366
+ assert.equal(T.trashDirFor('/w/ris'), '/w/ris/.dsh-ris-ide-trash')
1367
+ assert.equal(T.trashDirFor('/w/ris/'), '/w/ris/.dsh-ris-ide-trash')
1368
+ assert.equal(T.trashNameFor('/w/ris/src/ModelHelper.java', 1730), '1730-ModelHelper.java')
1369
+ assert.equal(T.trashNameFor('/w/ris/src', 1730), '1730-src')
1370
+ })
1371
+
1372
+ test('rich formats hand off to the official preview addresses', () => {
1373
+ assert.equal(T.shouldUseOfficialPreview('/w/a.pdf'), true)
1374
+ assert.equal(T.shouldUseOfficialPreview('/w/a.xlsx'), true)
1375
+ assert.equal(T.shouldUseOfficialPreview('/w/a.java'), false)
1376
+ assert.equal(T.shouldUseOfficialPreview('/w/a.md'), false)
1377
+ assert.equal(T.shouldUseOfficialPreview('/w/noext'), false)
1378
+ assert.equal(
1379
+ T.officialPreviewAddress({ sessionId: 's1', cwd: '/w' }, '/w/ris/a.pdf'),
1380
+ 'dsh-resource://file/session/s1/ris/a.pdf',
1381
+ )
1382
+ assert.equal(
1383
+ T.officialPreviewAddress({ sessionId: 's1', cwd: '/w' }, '/w/a b.pdf'),
1384
+ 'dsh-resource://file/session/s1/a%20b.pdf',
1385
+ )
1386
+ assert.equal(
1387
+ T.officialPreviewAddress({ sessionId: 's1', cwd: '/w' }, '/other/a.pdf'),
1388
+ 'dsh-resource://file/absolute/other/a.pdf',
1389
+ )
1390
+ assert.equal(
1391
+ T.officialPreviewAddress({ sessionId: 's1', cwd: 'C:\\w' }, 'C:\\w\\a.pdf'),
1392
+ 'dsh-resource://file/session/s1/a.pdf',
1393
+ )
1394
+ })
1395
+
1396
+ test('markdown/html get an on-demand official preview, not the auto hand-off', () => {
1397
+ assert.equal(T.supportsTextPreview('/w/README.md'), true)
1398
+ assert.equal(T.supportsTextPreview('/w/docs/a.markdown'), true)
1399
+ assert.equal(T.supportsTextPreview('/w/page.html'), true)
1400
+ assert.equal(T.supportsTextPreview('/w/page.HTM'), true)
1401
+ assert.equal(T.supportsTextPreview('/w/Model.java'), false)
1402
+ assert.equal(T.supportsTextPreview('/w/README'), false)
1403
+ assert.equal(T.supportsTextPreview(undefined), false)
1404
+ // The auto hand-off still leaves Markdown in the editor (it must stay editable).
1405
+ assert.equal(T.shouldUseOfficialPreview('/w/README.md'), false)
1406
+ assert.equal(T.shouldUseOfficialPreview('/w/page.html'), false)
1407
+ })
1408
+
1409
+ test('the inline Markdown preview escapes content and only builds safe tags', () => {
1410
+ assert.match(T.renderMarkdown('# Title'), /<h1>Title<\/h1>/)
1411
+ const escaped = T.renderMarkdown('<script>alert(1)</script>')
1412
+ assert.doesNotMatch(escaped, /<script>/)
1413
+ assert.match(escaped, /&lt;script&gt;/)
1414
+ assert.doesNotMatch(T.renderMarkdown('[x](javascript:alert(1))'), /javascript:/)
1415
+ assert.doesNotMatch(T.renderMarkdown('[x](vbscript:msgbox)'), /vbscript:/)
1416
+ assert.match(T.renderMarkdown('**bold** and `code`'), /<strong>bold<\/strong>/)
1417
+ assert.match(T.renderMarkdown('```js\nconst a = 1\n```'), /<pre data-lang="js"><code>const a = 1<\/code><\/pre>/)
1418
+ const table = T.renderMarkdown('| a | b |\n| --- | --- |\n| 1 | 2 |')
1419
+ assert.match(table, /<th>a<\/th>/)
1420
+ assert.match(table, /<td>2<\/td>/)
1421
+ // relative images resolve through the IDE media route
1422
+ const media = T.renderMarkdown('![logo](img/logo.png)', { mediaUrl: (src) => '/media?path=' + encodeURIComponent(src) })
1423
+ assert.match(media, /src="\/media\?path=img%2Flogo\.png"/)
1424
+ })
1425
+
1426
+ test('OS clipboard paths are parsed for import', () => {
1427
+ const text = '# copied files\nfile:///tmp/a%20b.md\n/tmp/c.txt\n/tmp/c.txt\nhttps://example.com/x\n'
1428
+ assert.deepEqual(T.parseClipboardPaths(text), ['/tmp/a b.md', '/tmp/c.txt'])
1429
+ assert.deepEqual(T.parseClipboardPaths('C:\\w\\a.txt'), ['C:\\w\\a.txt'])
1430
+ assert.deepEqual(T.parseClipboardPaths(''), [])
1431
+ assert.deepEqual(T.parseClipboardPaths(undefined), [])
1432
+ })
1433
+
1434
+ test('preview images resolve relative to the Markdown file, not the tree', () => {
1435
+ const scope = { sessionId: 's1', cwd: '/w/ris' }
1436
+ const rel = T.previewAssetUrl(scope, '/w/ris/docs/README.md', '../docs/screenshots/ris-console.png')
1437
+ const url = new URL(rel, 'http://localhost')
1438
+ assert.equal(url.pathname, '/dsh-ris-ide/fs/file')
1439
+ assert.equal(url.searchParams.get('sessionId'), 's1')
1440
+ assert.equal(url.searchParams.get('path'), '/w/ris/docs/screenshots/ris-console.png')
1441
+ assert.equal(T.previewAssetUrl(scope, '/w/ris/README.md', 'https://example.com/a.png'), 'https://example.com/a.png')
1442
+ assert.equal(T.previewAssetUrl(scope, '/w/ris/README.md', ''), '')
1443
+ // Preview URLs carry NO cache-buster: a changing `t=` would regenerate the
1444
+ // preview HTML on every render and reset the scroll position.
1445
+ assert.equal(url.searchParams.get('t'), null, 'no cache-buster in a preview image URL')
1446
+ const again = T.previewAssetUrl(scope, '/w/ris/docs/README.md', '../docs/screenshots/ris-console.png')
1447
+ assert.equal(again, rel, 'the same image resolves to the same URL across renders')
1448
+ })
1449
+
1450
+ test('the preview HTML is memoised (unrelated re-renders must not reset the scroll)', async () => {
1451
+ const { readFile } = await import('node:fs/promises')
1452
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1453
+ assert.match(source, /var previewHtml = React\.useMemo\(function \(\) \{/, 'the HTML is memoised')
1454
+ assert.match(source, /\}, \[previewingFile, openFile, previewText\]\)/, 'only file/text/dirty input regenerates it')
1455
+ assert.match(source, /dangerouslySetInnerHTML: \{ __html: previewHtml \}/, 'the pane renders the memoised HTML')
1456
+ assert.doesNotMatch(source, /__html: renderMarkdown\(/, 'no per-render renderMarkdown in the pane')
1457
+ assert.match(source, /fileMediaUrl\(scope, \(absolute \? '\/' : ''\) \+ stack\.join\('\/'\)\)?.*?cacheBust: false/, 'preview images opt out of the cache-buster')
1458
+ })
1459
+
1460
+ test('the preview memo is declared after scopeRef (an early var read crashed the preview)', async () => {
1461
+ const { readFile } = await import('node:fs/promises')
1462
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1463
+ const scopeAt = source.indexOf('var scopeRef = useRef({ sessionId: sessionId, cwd: cwd })')
1464
+ const memoAt = source.indexOf('var previewHtml = React.useMemo(function () {')
1465
+ assert.ok(scopeAt > 0, 'scopeRef is declared')
1466
+ assert.ok(memoAt > 0, 'the memo exists')
1467
+ assert.ok(memoAt > scopeAt, 'previewHtml must be initialised after scopeRef (var hoisting)')
1468
+ assert.match(source, /scopeRef === undefined \? \{ sessionId: sessionId, cwd: cwd \}/, 'the memo also has a defensive fallback')
1469
+ })
1470
+
1471
+ test('previewAssetUrl lives at the shared scope (it once reached for the tree-only parentOf)', async () => {
1472
+ const { readFile } = await import('node:fs/promises')
1473
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1474
+ assert.match(source, /\n {4}function previewAssetUrl\(scope, mdPath, raw\) \{/, 'shared scope definition')
1475
+ assert.doesNotMatch(source, /\n {6}function previewAssetUrl\(mdPath, raw\)/, 'no workbench-local copy')
1476
+ assert.match(source, /previewAssetUrl\(scope, openFile, src\)/, 'the pane passes a resolved scope')
1477
+ assert.match(source, /scopeRef\.current/, 'the resolved scope comes from scopeRef')
1478
+ })
1479
+
1480
+ test('the tree mutations post JSON to the fs op routes', async () => {
1481
+ const calls = []
1482
+ const original = globalThis.fetch
1483
+ globalThis.fetch = async (url, init) => {
1484
+ calls.push({ url: String(url), init })
1485
+ return { ok: true, status: 200, json: async () => ({ ok: true, path: '/w/x' }) }
1486
+ }
1487
+ try {
1488
+ const scope = { sessionId: 's1', cwd: '/w' }
1489
+ await T.fsMkdir(scope, '/w/newdir')
1490
+ await T.fsCreateFile(scope, '/w/A.java', 'class A {}')
1491
+ await T.fsCopyEntry(scope, '/w/A.java', '/w/dest')
1492
+ await T.fsMoveEntry(scope, '/w/A.java', '/w/dest')
1493
+ await T.fsRemoveEntry(scope, '/w/A.java', true)
1494
+ const routes = calls.map((call) => new URL(call.url).pathname.replace(/^.*\/dsh-ris-ide\//, ''))
1495
+ assert.deepEqual(routes, ['fs/mkdir', 'fs/create', 'fs/copy', 'fs/move', 'fs/remove'])
1496
+ for (const call of calls) assert.equal(call.init.method, 'POST')
1497
+ const createBody = JSON.parse(calls[1].init.body)
1498
+ assert.deepEqual(createBody, { sessionId: 's1', cwd: '/w', path: '/w/A.java', content: 'class A {}' })
1499
+ assert.equal(JSON.parse(calls[2].init.body).toDir, '/w/dest')
1500
+ assert.equal(JSON.parse(calls[4].init.body).recursive, true)
1501
+ } finally {
1502
+ globalThis.fetch = original
1503
+ }
1504
+ })
1505
+
1506
+ test('absolutePath resolves cwd-relative paths and keeps absolute ones', () => {
1507
+ assert.equal(T.absolutePath('/w', 'ris/a.java'), '/w/ris/a.java')
1508
+ assert.equal(T.absolutePath('/w/', 'ris/a.java'), '/w/ris/a.java')
1509
+ assert.equal(T.absolutePath('/w', '/x/y'), '/x/y')
1510
+ assert.equal(T.absolutePath('/w', 'C:\\x\\y'), 'C:/x/y')
1511
+ })
1512
+
1513
+ test('the editor chunk never puts null into the language compartment', async () => {
1514
+ const { readFile } = await import('node:fs/promises')
1515
+ const source = await readFile(new URL('../src/chunks/codemirror.js', import.meta.url), 'utf8')
1516
+ assert.match(source, /languageCompartment\.of\(languageForPath\(options\.path\) \|\| \[\]\)/, 'mount guards plain-text files')
1517
+ assert.match(source, /languageCompartment\.reconfigure\(languageForPath\(path\) \|\| \[\]\)/, 'setLanguage guards too')
1518
+ // Regression: EditorState.create crashed on `null.extension` for files with no language.
1519
+ assert.doesNotMatch(source, /languageCompartment\.of\(languageForPath\(options\.path\)\)/)
1520
+ })
1521
+
1522
+ test('the text editor loads the CodeMirror chunk and shows a loading state first', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1523
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
1524
+ const editor = renderToStaticMarkup(React.createElement(T.IdeTextEditor, {
1525
+ path: '/w/a.java', text: 'class A {}\n', fontRatio: 1,
1526
+ }))
1527
+ assert.match(editor, /data-codemirror-loading="1"/, 'the chunk is fetched on mount, not during SSR')
1528
+ assert.equal(typeof T.loadRisIdeChunk, 'function', 'the loader is part of the test surface')
1529
+ })
1530
+
1531
+ test('the workbench renders the debug tabs above the reused log viewer', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1532
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
1533
+ const markup = renderToStaticMarkup(React.createElement(client.__test.ProgramWorkbench, {
1534
+ scope: { sessionId: 's1', cwd: '/w' },
1535
+ visible: false,
1536
+ tab: {
1537
+ id: 'dsh-ris-ide:program',
1538
+ type: 'dsh-ris-ide:program',
1539
+ title: 'ris-model-biz',
1540
+ meta: { programId: 'spring:x#a.B', logPath: '/w/logs/ris-model-biz.log', name: 'ris-model-biz', kind: 'spring' },
1541
+ },
1542
+ }))
1543
+ assert.match(markup, /data-workbench/)
1544
+ assert.match(markup, /ris-model-biz/)
1545
+ for (const label of ['断点', '线程', '调用栈', '变量', '事件']) assert.match(markup, new RegExp(label))
1546
+ // The section switcher is a TAB STRIP (multi-tab), not a row of buttons: one
1547
+ // tablist, five tabs, exactly one selected, attached to the content panel.
1548
+ assert.match(markup, /data-debug-tabs="1"/, 'the debug sections are a tab strip')
1549
+ assert.equal((markup.match(/role="tab"/g) || []).length, 5, 'five section tabs')
1550
+ assert.equal((markup.match(/aria-selected="true"/g) || []).length, 1, 'exactly one active tab')
1551
+ assert.match(markup, /role="tabpanel"/)
1552
+ assert.match(markup, /data-debug-section="breakpoints"/, 'the active tab drives the content panel')
1553
+ assert.match(markup, /border-radius:4px 4px 0 0/, 'tabs look like attached page tabs')
1554
+ assert.match(markup, /border-bottom:none/, 'the tab sits on the strip border')
1555
+ assert.match(markup, /调试/)
1556
+ assert.match(markup, /data-debug-head="1"/, 'the debug pane title bar is the debug toolbar')
1557
+ assert.doesNotMatch(markup, /收起调试区/, 'the collapse control is gone')
1558
+ assert.doesNotMatch(markup, /data-tab-close="debug"/, 'no per-program debug tab strip')
1559
+ assert.doesNotMatch(markup, /data-tab-close="log"/, 'no per-program log tab strip')
1560
+
1561
+ const frontend = renderToStaticMarkup(React.createElement(client.__test.ProgramWorkbench, {
1562
+ scope: { sessionId: 's1', cwd: '/w' },
1563
+ visible: false,
1564
+ tab: { meta: { programId: 'frontend:ui#dev', logPath: '/w/logs/ui.log', name: 'ui', kind: 'frontend' } },
1565
+ }))
1566
+ // The top bar carries the WORKSPACE path (one IDE tab for the whole workspace),
1567
+ // not one program's name — and a frontend debug tab has no debug controls.
1568
+ assert.match(frontend, /data-workbench-path="1"/)
1569
+ assert.doesNotMatch(frontend, /非 Java 程序/, 'the top bar no longer names a program kind')
1570
+ assert.doesNotMatch(frontend, /data-action="debug-start"/)
1571
+ })
1572
+
1573
+ test('a mouse wheel over a tab strip scrolls it sideways, not the page', () => {
1574
+ // The strip must overflow horizontally; otherwise the default wheels through.
1575
+ assert.equal(T.tabStripWheelDelta({ deltaY: 120, deltaX: 0 }, 640, 300), 120)
1576
+ assert.equal(T.tabStripWheelDelta({ deltaY: -120, deltaX: 0 }, 640, 300), -120)
1577
+ assert.equal(T.tabStripWheelDelta({ deltaY: 120, deltaX: 0 }, 300, 300), 0, 'all tabs fit')
1578
+ assert.equal(T.tabStripWheelDelta({ deltaY: 120, deltaX: 0 }, 200, 300), 0)
1579
+ // A horizontal trackpad gesture belongs to the browser.
1580
+ assert.equal(T.tabStripWheelDelta({ deltaX: 80, deltaY: 0 }, 640, 300), 0)
1581
+ assert.equal(T.tabStripWheelDelta({ deltaX: 80, deltaY: 10 }, 640, 300), 0)
1582
+ // Non-pixel deltaModes are normalised so one notch still moves a readable amount.
1583
+ assert.equal(T.tabStripWheelDelta({ deltaY: 3, deltaMode: 1 }, 640, 300), 48)
1584
+ assert.equal(T.tabStripWheelDelta({ deltaY: 1, deltaMode: 2 }, 640, 300), 300)
1585
+ assert.equal(T.tabStripWheelDelta(undefined, 640, 300), 0)
1586
+ assert.equal(T.tabStripWheelDelta(null, 640, 300), 0)
1587
+ })
1588
+
1589
+ test('tab strips take a NON-passive wheel listener (React onWheel is passive)', () => {
1590
+ const listeners = []
1591
+ const node = {
1592
+ scrollWidth: 640, clientWidth: 300, scrollLeft: 0,
1593
+ addEventListener(type, fn, options) { listeners.push({ type, fn, options }) },
1594
+ removeEventListener(type, fn) { listeners.push({ type: 'remove', fn }) },
1595
+ }
1596
+ const detach = T.attachTabStripWheel(node)
1597
+ assert.equal(listeners.length, 1)
1598
+ assert.equal(listeners[0].type, 'wheel')
1599
+ assert.deepEqual(listeners[0].options, { passive: false }, 'preventDefault works only when non-passive')
1600
+
1601
+ // The listener scrolls the strip sideways and swallows the page scroll.
1602
+ let prevented = 0
1603
+ listeners[0].fn({ currentTarget: node, deltaY: 120, deltaX: 0, preventDefault() { prevented += 1 } })
1604
+ assert.equal(node.scrollLeft, 120)
1605
+ assert.equal(prevented, 1)
1606
+
1607
+ // A strip whose tabs all fit keeps the default (no preventDefault needed).
1608
+ let preventedFitting = 0
1609
+ listeners[0].fn({ currentTarget: { scrollWidth: 300, clientWidth: 300, scrollLeft: 0 }, deltaY: 120, deltaX: 0, preventDefault() { preventedFitting += 1 } })
1610
+ assert.equal(preventedFitting, 0)
1611
+
1612
+ detach()
1613
+ assert.equal(listeners[1].type, 'remove')
1614
+ assert.equal(listeners[1].fn, listeners[0].fn)
1615
+ })
1616
+
1617
+ test('the source tab bar scrolls its inner strip from anywhere on the row', () => {
1618
+ const strip = { scrollWidth: 900, clientWidth: 400, scrollLeft: 0 }
1619
+ const bar = { scrollWidth: 400, clientWidth: 400, querySelector: (selector) => (selector === '[data-tab-strip]' ? strip : null) }
1620
+ assert.equal(T.tabStripOf(bar), strip, 'the bar resolves its inner scrolling strip')
1621
+ assert.equal(T.tabStripOf(strip), strip, 'an overflowing node is its own strip')
1622
+ assert.equal(T.tabStripOf(null), null)
1623
+ assert.equal(T.tabStripOf(undefined), null)
1624
+
1625
+ // Wheel over the BAR (e.g. over a ◀ ▶ paging button) still moves the tabs.
1626
+ const listeners = []
1627
+ const attachNode = {
1628
+ scrollWidth: 400, clientWidth: 400, querySelector: bar.querySelector,
1629
+ addEventListener(type, fn, options) { listeners.push({ type, fn, options }) },
1630
+ removeEventListener() {},
1631
+ }
1632
+ const detach = T.attachTabStripWheel(attachNode)
1633
+ assert.deepEqual(listeners[0].options, { passive: false })
1634
+ let prevented = 0
1635
+ listeners[0].fn({ currentTarget: attachNode, deltaY: 100, deltaX: 0, preventDefault() { prevented += 1 } })
1636
+ assert.equal(strip.scrollLeft, 100)
1637
+ assert.equal(prevented, 1)
1638
+ detach()
1639
+ })
1640
+
1641
+ test('the top-bar controls share one 20px row height', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1642
+ const markup = renderWorkbench({ onConfig: { open() {}, generate() {}, exists: true } })
1643
+ const head = headOf(markup)
1644
+ const tags = (head.match(/<button[^>]*>/g) || []).filter((tag) => /data-action="/.test(tag))
1645
+ assert.ok(tags.length >= 5, 'the toolbar carries the A-/A+, theme and config/reset buttons')
1646
+ for (const tag of tags) {
1647
+ const action = /data-action="([^"]+)"/.exec(tag)[1]
1648
+ assert.match(tag, /height:20px/, `${action} must sit on the shared 20px row`)
1649
+ assert.match(tag, /min-height:20px/, `${action} must pin the minimum too`)
1650
+ assert.match(tag, /align-items:center/, `${action} must centre its glyph vertically`)
1651
+ }
1652
+ })
1653
+
1654
+ test('the debug toolbar icons are inline SVG, not emoji', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1655
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
1656
+ const start = renderToStaticMarkup(T.ideBadgeIcon('bug', 'play', 14))
1657
+ assert.match(start, /data-icon="bug\+play"/, 'bug + play badge')
1658
+ assert.match(start, /<svg/, 'inline svg')
1659
+ const redebug = renderToStaticMarkup(T.ideBadgeIcon('bug', 'rotate', 14))
1660
+ assert.match(redebug, /data-icon="bug\+rotate"/)
1661
+ const step = renderToStaticMarkup(T.ideIcon('stepOver', 14))
1662
+ assert.match(step, /data-icon="stepOver"/)
1663
+ const all = ['bug', 'bugOff', 'play', 'playCircle', 'pauseCircle', 'rotate', 'arrowsRotate', 'stop', 'stepOver', 'stepInto', 'stepOut']
1664
+ .map((name) => renderToStaticMarkup(T.ideIcon(name, 12)))
1665
+ for (const markup of all) assert.match(markup, /<svg/, 'every icon renders')
1666
+ })
1667
+
1668
+ test('log search finds every occurrence per line, case-insensitively', () => {
1669
+ const lines = ['INFO started', 'ERROR boom ERROR again', 'ok']
1670
+ assert.deepEqual(T.findLogMatches(lines, 'error'), [
1671
+ { line: 1, from: 0, to: 5 },
1672
+ { line: 1, from: 11, to: 16 },
1673
+ ])
1674
+ assert.deepEqual(T.findLogMatches(lines, 'ERROR'), [{ line: 1, from: 0, to: 5 }, { line: 1, from: 11, to: 16 }])
1675
+ assert.deepEqual(T.findLogMatches(lines, ''), [])
1676
+ assert.deepEqual(T.findLogMatches(lines, 'nope'), [])
1677
+ assert.deepEqual(T.findLogMatches([], 'x'), [])
1678
+ // non-overlapping scan: "aaa" searched as "aa" matches once at 0 (then 2 is past end)
1679
+ assert.deepEqual(T.findLogMatches(['aaaa'], 'aa'), [{ line: 0, from: 0, to: 2 }, { line: 0, from: 2, to: 4 }])
1680
+ assert.deepEqual(T.findLogMatches(['中文中文'], '中文'), [{ line: 0, from: 0, to: 2 }, { line: 0, from: 2, to: 4 }])
1681
+ })
1682
+
1683
+ test('only DSH\'s own chords are swallowed inside the IDE', () => {
1684
+ // Every chord taken from DSH's web catalog is blocked.
1685
+ const dsh = [
1686
+ ['/'], ['n'], ['k'], ['o'],
1687
+ ]
1688
+ for (const [key] of dsh) assert.equal(T.shouldBlockHostChord({ ctrlKey: true, key }, false), true, `Ctrl+${key}`)
1689
+ for (const key of ['o', 'a', 'b']) assert.equal(T.shouldBlockHostChord({ ctrlKey: true, shiftKey: true, key }, false), true, `Ctrl+Shift+${key}`)
1690
+ for (const key of ['f', 'g', 'b', 'p', 't', ',' ]) assert.equal(T.shouldBlockHostChord({ ctrlKey: true, altKey: true, key }, false), true, `Ctrl+Alt+${key}`)
1691
+ assert.equal(T.shouldBlockHostChord({ ctrlKey: true, key: '`' }, false), true, 'Ctrl+` terminal')
1692
+ // Browser and native shortcuts are NOT the host's, so they pass.
1693
+ for (const key of ['c', 'v', 'x', 'a', 'z', 'y', 's', 'f', 'w', 'b', 'e', 'i', 'j', 'l', 'u', 'p']) {
1694
+ assert.equal(T.shouldBlockHostChord({ ctrlKey: true, key }, false), false, `Ctrl+${key} is native/IDE`)
1695
+ }
1696
+ assert.equal(T.shouldBlockHostChord({ ctrlKey: true, key: 'F5' }, false), false, 'Ctrl+F5 reloads the page')
1697
+ assert.equal(T.shouldBlockHostChord({ ctrlKey: true, shiftKey: true, key: 'R' }, false), false, 'Ctrl+Shift+R hard reload')
1698
+ assert.equal(T.shouldBlockHostChord({ ctrlKey: true, shiftKey: true, key: 'i' }, false), false, 'devtools')
1699
+ for (const key of ['F1', 'F2', 'F5', 'F9', 'F11', 'F12']) {
1700
+ assert.equal(T.shouldBlockHostChord({ key }, false), false, `${key} belongs to the browser`)
1701
+ }
1702
+ assert.equal(T.shouldBlockHostChord({ key: 'F3' }, false), false, 'IDE find alias')
1703
+ assert.equal(T.shouldBlockHostChord({ ctrlKey: true, key: 'r' }, false), false, 'Ctrl+R is the panel find chord')
1704
+ assert.equal(T.shouldBlockHostChord(undefined, false), false)
1705
+ })
1706
+
1707
+ test('only plain Ctrl+R is taken over; hard reload stays the browser\'s', () => {
1708
+ assert.equal(T.isBrowserReloadChord({ ctrlKey: true, key: 'r' }), true)
1709
+ assert.equal(T.isBrowserReloadChord({ metaKey: true, key: 'R' }), true)
1710
+ assert.equal(T.isBrowserReloadChord({ ctrlKey: true, shiftKey: true, key: 'r' }), false, 'Ctrl+Shift+R must still reload')
1711
+ assert.equal(T.isBrowserReloadChord({ ctrlKey: true, key: 'f5' }), false)
1712
+ assert.equal(T.isBrowserReloadChord({ key: 'F5' }), false)
1713
+ assert.equal(T.isBrowserReloadChord({ ctrlKey: true, altKey: true, key: 'r' }), false)
1714
+ assert.equal(T.isBrowserReloadChord(undefined), false)
1715
+ })
1716
+
1717
+ test('the shielded set is exactly DSH\'s catalog', () => {
1718
+ assert.ok(T.DSH_CHORDS.has('ctrl+n'))
1719
+ assert.ok(T.DSH_CHORDS.has('ctrl+alt+f'), 'fork session')
1720
+ assert.ok(T.DSH_CHORDS.has('ctrl+shift+a'), 'archive session')
1721
+ assert.ok(T.DSH_CHORDS.has('ctrl+/'), 'shortcut catalog')
1722
+ assert.equal(T.DSH_CHORDS.has('ctrl+f5'), false)
1723
+ assert.equal(T.DSH_CHORDS.has('f5'), false)
1724
+ assert.equal(T.chordOf({ ctrlKey: true, shiftKey: true, key: 'A' }), 'ctrl+shift+a')
1725
+ assert.equal(T.chordOf({ metaKey: true, key: 'n' }), 'ctrl+n')
1726
+ assert.equal(T.chordOf({ ctrlKey: true, altKey: true, key: ',' }), 'ctrl+alt+,')
1727
+ assert.equal(T.chordOf({ key: 'F5' }), '', 'no modifier: not a chord')
1728
+ })
1729
+
1730
+ test('Ctrl+R focuses the workspace search (and the browser reload is suppressed)', () => {
1731
+ assert.equal(T.GLOBAL_FIND_KEY, 'r')
1732
+ assert.equal(T.GLOBAL_FIND_LABEL, 'Ctrl+R / F3')
1733
+ assert.equal(T.isGlobalFindChord({ ctrlKey: true, key: 'r' }), true)
1734
+ assert.equal(T.isGlobalFindChord({ metaKey: true, key: 'R' }), true)
1735
+ assert.equal(T.isGlobalFindChord({ key: 'F3' }), true)
1736
+ // Ctrl+Shift+R (hard reload) is not the find chord and is swallowed by the shield.
1737
+ assert.equal(T.isGlobalFindChord({ ctrlKey: true, shiftKey: true, key: 'r' }), false)
1738
+ assert.equal(T.isGlobalFindChord({ ctrlKey: true, altKey: true, key: 'f' }), false, 'Ctrl+Alt+F is DSH fork')
1739
+ assert.equal(T.isGlobalFindChord({ ctrlKey: true, key: 'f' }), false, 'Ctrl+F belongs to editors')
1740
+ assert.equal(T.isGlobalFindChord(undefined), false)
1741
+ })
1742
+
1743
+ test('Ctrl+F goes to the workspace search unless the editor owns it', () => {
1744
+ assert.equal(T.globalFindShouldFocusSearch({ tagName: 'DIV' }), true)
1745
+ assert.equal(T.globalFindShouldFocusSearch({}), true, 'focus lost on the panel still works')
1746
+ assert.equal(T.globalFindShouldFocusSearch({ tagName: 'INPUT' }), false, 'the search box keeps its own editing')
1747
+ assert.equal(T.globalFindShouldFocusSearch({ tagName: 'textarea' }), false, 'built-in editor')
1748
+ assert.equal(T.globalFindShouldFocusSearch({ tagName: 'DIV', isContentEditable: true }), false, 'CodeMirror')
1749
+ assert.equal(T.globalFindShouldFocusSearch({ tagName: 'DIV', defaultPrevented: true }), false, 'editor already handled it')
1750
+ })
1751
+
1752
+ test('the help panel data covers tree, editor and debug shortcuts', () => {
1753
+ const sections = T.helpSections()
1754
+ assert.deepEqual(sections.map((section) => section.title), ['通用', '文件树', '编辑器', '调试(仅 Java)', '使用说明'])
1755
+ for (const section of sections) {
1756
+ assert.ok(section.rows.length > 0, `${section.title} has rows`)
1757
+ for (const row of section.rows) {
1758
+ assert.equal(typeof row.keys, 'string')
1759
+ assert.ok(row.keys.length > 0)
1760
+ assert.ok(row.desc.length > 0)
1761
+ }
1762
+ }
1763
+ const treeKeys = sections.find((section) => section.title === '文件树').rows.map((row) => row.keys).join(' ')
1764
+ assert.match(treeKeys, /Ctrl\+Z/, 'tree undo is documented')
1765
+ const editorKeys = sections.find((section) => section.title === '编辑器').rows.map((row) => row.keys).join(' ')
1766
+ assert.match(editorKeys, /Ctrl\+Shift\+K/)
1767
+ assert.match(editorKeys, /Alt\+\u2191/)
1768
+ })
1769
+
1770
+ test('the workbench renders the bottom-right help button', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1771
+ const markup = renderWorkbench({})
1772
+ assert.match(markup, /data-action="help"/, 'the ? button exists')
1773
+ })
1774
+
1775
+ test('the tree title bar carries the refresh button and the .gitignore eye', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1776
+ const markup = renderWorkbench({})
1777
+ assert.match(markup, /data-action="tree-refresh"/, '⟳ reloads the loaded tree levels')
1778
+ assert.match(markup, /data-action="reveal-active"/, 'the locate button stays')
1779
+ assert.match(markup, /data-action="tree-filter"/, 'the .gitignore toggle sits left of maximize')
1780
+ assert.match(markup, /data-filter-state="git"/, 'default state is "filtered"')
1781
+ const filterAt = markup.indexOf('data-action="tree-filter"')
1782
+ const filterMarkup = markup.slice(filterAt, filterAt + 600)
1783
+ assert.match(filterMarkup, /data-icon="eye"/, 'an eye (not a funnel/triangle) reads as visibility')
1784
+ })
1785
+
1786
+ test('the tree .gitignore eye fetches unfiltered listings, then re-reads the tree', async () => {
1787
+ const { readFile } = await import('node:fs/promises')
1788
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1789
+ assert.match(source, /var TREE_FILTER_ALL = false/, 'filtering is the default state')
1790
+ assert.match(source, /if \(TREE_FILTER_ALL\) url\.searchParams\.set\('filter', 'all'\)/, 'the toggle reaches the host')
1791
+ assert.match(source, /\(TREE_FILTER_ALL \? '\\u0000all' : ''\)/, 'the cache key folds in the mode')
1792
+ assert.match(source, /function toggleTreeFilter\(\) \{/)
1793
+ assert.match(source, /setTreeRefreshTick\(function \(value\) \{ return value \+ 1 \}\)/, 'a flip reloads root + expanded folders')
1794
+ assert.match(source, /data-filter-state': treeShowAll === true \? 'all' : 'git'/)
1795
+ assert.match(source, /ideIcon\(treeShowAll === true \? 'eyeOff' : 'eye', 14\)/, 'eye-off = ignored files shown too')
1796
+ assert.match(source, /disabled: treeGit !== true/, 'no git → nothing to filter')
1797
+ assert.match(source, /onTreeMeta: function \(meta\) \{ setTreeGit\(meta\.git === true\) \}/, 'the listing tells the bar whether git exists')
1798
+ assert.match(source, /function reportTreeMeta\(listing\)/)
1799
+ // The old funnel glyph is gone: it read as a stray triangle in a 20px row.
1800
+ assert.doesNotMatch(source, /viewBox: '0 0 16 16', stroke: true,\n\s+inner: '<path d="M2 3h12l-4\.6 5\.4/)
1801
+ })
1802
+
1803
+ test('there is no workbench top bar: the tree header is the path, the source header is the toolbar', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1804
+ const markup = renderWorkbench({ onConfig: { open() {}, generate() {}, exists: true } })
1805
+ assert.doesNotMatch(markup, /data-workbench-head="1"/, 'the top bar is gone')
1806
+
1807
+ // tree header: the workspace title IS the path, ellipsised when the column is narrow
1808
+ const treeHead = markup.slice(markup.indexOf('data-ide-pane="tree"'), markup.indexOf('data-ide-tree="1"'))
1809
+ const pathTag = (treeHead.match(/<span[^>]*data-workbench-path="1"[^>]*>/) || [''])[0]
1810
+ assert.match(treeHead, /data-workbench-path="1">\/w</, 'the tree header shows the workspace path')
1811
+ assert.match(pathTag, /text-overflow:ellipsis/, 'the path ellipsises instead of overflowing')
1812
+ assert.match(pathTag, /flex:1 1 auto/, 'the path takes the header row it has')
1813
+
1814
+ // source header: search grows, action group keeps its own width
1815
+ const head = headOf(markup)
1816
+ const searchTag = (head.match(/<input[^>]*data-action="tree-search"[^>]*>/) || [''])[0]
1817
+ assert.match(searchTag, /flex:1 1 auto/, 'the search box must grow')
1818
+ const actionsTag = (head.match(/<span[^>]*data-head-actions="1"[^>]*>/) || [''])[0]
1819
+ assert.match(actionsTag, /flex:0 0 auto/, 'the action group keeps its own width')
1820
+ assert.doesNotMatch(head, /style="flex:1"/, 'no spacer spans left in the toolbar row')
1821
+ })
1822
+
1823
+ test('the console debug button is orange and the toolbar search renders a dropdown', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1824
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
1825
+ const markup = renderWorkbench({
1826
+ programs: [
1827
+ { id: 'spring:a#A', name: 'a-biz', kind: 'spring', logPath: '/w/logs/a.log', running: { alive: false } },
1828
+ ],
1829
+ })
1830
+ const debugTag = ((markup.match(/<button[^>]*>/g) || []).filter((tag) => tag.includes('data-action="console-debug"'))[0]) || ''
1831
+ assert.match(debugTag, /color:#d29922/, 'the debug button must be orange')
1832
+ assert.match(debugTag, /border:1px solid color-mix\(in srgb, #d29922/, 'the orange border belongs with it')
1833
+
1834
+ // The search box owns a dropdown container anchored under it; it renders
1835
+ // nothing until there is a result.
1836
+ assert.match(markup, /data-search-box="1"/, 'the search input sits in a relative box')
1837
+ assert.doesNotMatch(markup, /data-search-results="1"/, 'no dropdown before a search runs')
1838
+
1839
+ // The dropdown itself: files vs directories, and its own picker.
1840
+ const picked = []
1841
+ const hits = renderToStaticMarkup(React.createElement(T.SearchResults, {
1842
+ hits: { matches: ['/w/a.java', '/w/src'], dirs: { '/w/src': true } },
1843
+ busy: false,
1844
+ onPick: (path, isDir) => { picked.push([path, isDir]) },
1845
+ }))
1846
+ assert.match(hits, /data-search-results="1"/)
1847
+ assert.match(hits, /匹配 2 个/)
1848
+ assert.match(hits, /title="\/w\/a\.java"[^>]*data-search-hit="file"/)
1849
+ assert.match(hits, /title="\/w\/src"[^>]*data-search-hit="dir"/)
1850
+ assert.match(renderToStaticMarkup(React.createElement(T.SearchResults, { hits: { matches: [], dirs: {} }, busy: true })), /检索中…/)
1851
+ assert.equal(renderToStaticMarkup(React.createElement(T.SearchResults, { hits: null })), '')
1852
+ assert.deepEqual(picked, [], 'rendering must not fire a pick')
1853
+ })
1854
+
1855
+ test('the breakpoint list is global: one program\'s poll never drops another program\'s dots', () => {
1856
+ T.__setGlobalBreakpoints([])
1857
+ const first = T.mergeGlobalBreakpoints({ logPath: '/w/a.log', breakpoints: [{ requestId: 7, className: 'a.B', line: 10 }] })
1858
+ assert.deepEqual(first, [{ requestId: 7, className: 'a.B', line: 10, logPath: '/w/a.log' }], 'host rows carry their program')
1859
+ const both = T.mergeGlobalBreakpoints({ logPath: '/w/b.log', breakpoints: [{ requestId: 8, className: 'b.C', line: 2 }] })
1860
+ assert.deepEqual(both.map((bp) => `${bp.logPath}|${bp.className}:${bp.line}`), ['/w/a.log|a.B:10', '/w/b.log|b.C:2'], 'a poll only replaces its own slice')
1861
+ // The program's own snapshot is the truth for that program: entries it no longer
1862
+ // reports must go (this is what used to leave "historical" dots around).
1863
+ const pruned = T.mergeGlobalBreakpoints({ logPath: '/w/a.log', breakpoints: [] })
1864
+ assert.deepEqual(pruned.map((bp) => bp.className), ['b.C'], 'only the polled program is pruned')
1865
+ // An optimistic local dot survives for a little while, then it is pruned too.
1866
+ T.__setGlobalBreakpoints([{ requestId: 'pending', className: 'c.D', line: 4, pending: true, logPath: '/w/c.log', optimistic: true, optimisticAt: Date.now() }])
1867
+ assert.equal(T.mergeGlobalBreakpoints({ logPath: '/w/c.log', breakpoints: [] }).length, 1, 'a fresh optimistic dot survives its snapshot')
1868
+ T.__setGlobalBreakpoints([{ requestId: 'pending', className: 'c.E', line: 4, pending: true, logPath: '/w/c.log', optimistic: true, optimisticAt: Date.now() - 60000 }])
1869
+ assert.equal(T.mergeGlobalBreakpoints({ logPath: '/w/c.log', breakpoints: [] }).length, 0, 'an expired optimistic dot is pruned')
1870
+ // Once the host reports the class+line, the optimistic dot is replaced by it.
1871
+ T.__setGlobalBreakpoints([{ requestId: 'pending', className: 'c.F', line: 6, pending: true, logPath: '/w/c.log', optimistic: true, optimisticAt: Date.now() }])
1872
+ const confirmed = T.mergeGlobalBreakpoints({ logPath: '/w/c.log', breakpoints: [{ requestId: 11, className: 'c.F', line: 6 }] })
1873
+ assert.deepEqual(confirmed, [{ requestId: 11, className: 'c.F', line: 6, logPath: '/w/c.log' }])
1874
+ T.__setGlobalBreakpoints([])
1875
+ })
1876
+
1877
+ test('the debug pane names Java locations by class, never by file path', async () => {
1878
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1879
+ assert.match(source, /function breakpointLabel\(bp\) \{\n return bp === null \|\| bp === undefined \|\| bp\.className === undefined \? '\?' : String\(bp\.className\)/)
1880
+ assert.match(source, /\}, method \+ '@' \+ line\)/, 'stack rows show method@line')
1881
+ assert.match(source, /var title = \(className === '' \? '' : className \+ '\.'\) \+ method \+ ':' \+ line/, 'stack titles are class.method:line')
1882
+ assert.doesNotMatch(source, /annotateBreakpointPaths/, 'breakpoint rows are not path-annotated')
1883
+ assert.doesNotMatch(source, /annotateFramePaths/, 'stack rows are not path-annotated')
1884
+ })
1885
+
1886
+ test('panel paths are relative to the workspace root, absolute only outside it', () => {
1887
+ assert.equal(T.displayWorkspacePath('/w/src/a.java', '/w'), 'src/a.java')
1888
+ assert.equal(T.displayWorkspacePath('/w/src/a.java', '/w/'), 'src/a.java', 'a trailing separator is ignored')
1889
+ assert.equal(T.displayWorkspacePath('/elsewhere/a.java', '/w'), '/elsewhere/a.java', 'outside stays absolute')
1890
+ assert.equal(T.displayWorkspacePath('/w', '/w'), '/w')
1891
+ assert.equal(T.displayWorkspacePath('', '/w'), '')
1892
+ assert.equal(T.displayWorkspacePath('C:\\w\\a.java', 'C:\\w'), 'a.java')
1893
+ })
1894
+
1895
+ test('search results show workspace-relative text with the absolute path as title', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1896
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
1897
+ const markup = renderToStaticMarkup(React.createElement(T.SearchResults, {
1898
+ hits: { matches: ['/w/src/a.java', '/outside/b.java'], dirs: {} },
1899
+ busy: false,
1900
+ cwd: '/w',
1901
+ onPick: () => {},
1902
+ }))
1903
+ assert.match(markup, /title="\/w\/src\/a\.java"/)
1904
+ assert.match(markup, />src\/a\.java</, 'the visible text is relative')
1905
+ assert.match(markup, /title="\/outside\/b\.java"/)
1906
+ assert.match(markup, />\/outside\/b\.java</, 'outside the workspace the text stays absolute')
1907
+ })
1908
+
1909
+ test('Ctrl+R turns a single-line editor selection into the search query', async () => {
1910
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1911
+ assert.match(source, /function selectedEditorText\(\)/)
1912
+ assert.match(source, /if \(text === '' \|\| text\.indexOf\('\\n'\) !== -1 \|\| text\.length > GLOBAL_FIND_SELECTION_MAX\) return ''/, 'multiline / oversized selections are ignored')
1913
+ assert.match(source, /if \(element\.closest\('\.cm-content'\) === null\) return ''/, 'only editor selections count')
1914
+ assert.match(source, /var picked = selectedEditorText\(\)[\s\S]{0,400}?setTreeQuery\(picked\)/, 'the pick becomes the query')
1915
+ assert.match(source, /h\(SearchResults, \{ hits: searchHits, busy: searchBusy, onPick: pickSearchHit, fontRatio: fontRatio, cwd: cwd \}\)/, 'results know the workspace root')
1916
+ })
1917
+
1918
+ test('an unfocused editor stops painting its selection', async () => {
1919
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1920
+ assert.match(source, /\[data-workbench="ris-ide"\] \.cm-editor:not\(\.cm-focused\) \.cm-selectionBackground/)
1921
+ assert.match(source, /:not\(\.cm-focused\) \.cm-selectionBackground'\s*\n\s*\+ '\{background:transparent !important\}'/)
1922
+ })
1923
+
1924
+ test('the active source tab is scrolled into the strip', async () => {
1925
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
1926
+ assert.match(source, /var tabElsRef = useRef\(\{\}\)/)
1927
+ assert.match(source, /'data-tab-file': path/)
1928
+ assert.match(source, /var tab = tabElsRef\.current\[openFile\][\s\S]{0,700}?strip\.scrollLeft = Math\.max\(0, strip\.scrollLeft - \(stripBox\.left - tabBox\.left\) - margin\)/, 'a tab left of the viewport scrolls it in')
1929
+ assert.match(source, /\}, \[openFile, openFiles\.length\]\)/, 'the active file drives it')
1930
+ })
1931
+
1932
+ test('treeLabel draws folded separators as their own bold spans', () => {
1933
+ const plain = T.treeLabel({}, { name: 'a.java', label: 'a.java', compact: false })
1934
+ assert.equal(plain, 'a.java', 'normal rows stay one string')
1935
+ const folded = T.treeLabel({ foldSeparator: { fontWeight: 700 } }, { compact: true, label: 'cn.com.ris' })
1936
+ assert.ok(Array.isArray(folded), 'a folded chain renders parts + separators')
1937
+ assert.equal(folded.length, 5, 'two separators + three parts')
1938
+ assert.equal(folded[1].type, 'span')
1939
+ assert.equal(folded[1].props.children, '.', 'the separator character survives')
1940
+ assert.equal(folded[1].props.style.fontWeight, 700)
1941
+ })
1942
+
1943
+ test('fsTree fails loudly on a host without the tree route (no sidebar fallback)', async () => {
1944
+ const original = globalThis.fetch
1945
+ globalThis.fetch = async () => ({ ok: false, status: 404, json: async () => ({ ok: false, message: 'unknown route /dsh-ris-ide/tree' }) })
1946
+ try {
1947
+ await assert.rejects(T.fsTree({ sessionId: 's1', cwd: '/w' }, '/w'), /unknown route/)
1948
+ } finally {
1949
+ globalThis.fetch = original
1950
+ }
1951
+ })
1952
+
1953
+ test('the saved layout (maximised + theme + font + sizes) restores on init, even from a legacy key', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
1954
+ const hadLocalStorage = Object.prototype.hasOwnProperty.call(globalThis, 'localStorage')
1955
+ const previousLocalStorage = globalThis.localStorage
1956
+ try {
1957
+ // A legacy per-program blob: the panel used to key the layout by program id.
1958
+ const legacy = JSON.stringify({
1959
+ ui: { maximized: 'editor', section: 'threads' },
1960
+ theme: 'light',
1961
+ font: 15,
1962
+ sizes: { left: 420, leftBottom: 300, rightBottom: 220, debugWidth: 320 },
1963
+ files: [],
1964
+ file: undefined,
1965
+ })
1966
+ const store = new Map([['dsh-ris-ide:ui:spring:ris/ris-model#a.B', legacy]])
1967
+ globalThis.localStorage = {
1968
+ get length() { return store.size },
1969
+ key: (index) => Array.from(store.keys())[index],
1970
+ getItem: (key) => (store.has(key) ? store.get(key) : null),
1971
+ setItem: (key, value) => { store.set(key, value) },
1972
+ }
1973
+ const markup = renderWorkbench({ tab: { id: T.PROGRAM_TAB_ID, type: T.PROGRAM_TAB_ID, title: 'workspace' } })
1974
+ const root = workbenchRootTagOf(markup)
1975
+ assert.match(root, /font-size:15px/, 'the persisted font size is applied at init')
1976
+ assert.match(root, /data-theme="page"/, 'the IDE itself no longer stores a colour scheme')
1977
+ assert.doesNotMatch(markup, /data-ide-pane="tree"/, 'the persisted maximised pane is applied at init')
1978
+ assert.match(markup, /data-ide-pane="editor"/)
1979
+ } finally {
1980
+ if (hadLocalStorage) globalThis.localStorage = previousLocalStorage
1981
+ else globalThis.localStorage = undefined
1982
+ }
1983
+ })
1984
+
1985
+ test('readSavedUi prefers the exact key, falls back to a legacy one, and survives junk', () => {
1986
+ const hadLocalStorage = Object.prototype.hasOwnProperty.call(globalThis, 'localStorage')
1987
+ const previousLocalStorage = globalThis.localStorage
1988
+ try {
1989
+ const store = new Map([
1990
+ ['dsh-ris-ide:ui:workspace', JSON.stringify({ ui: { maximized: null }, font: 13 })],
1991
+ ['dsh-ris-ide:ui:spring:x#y', JSON.stringify({ ui: { maximized: 'tree' }, font: 18 })],
1992
+ ])
1993
+ globalThis.localStorage = {
1994
+ get length() { return store.size },
1995
+ key: (index) => Array.from(store.keys())[index],
1996
+ getItem: (key) => (store.has(key) ? store.get(key) : null),
1997
+ setItem: () => {},
1998
+ }
1999
+ assert.equal(T.readSavedUi('dsh-ris-ide:ui:workspace').font, 13, 'the exact key wins')
2000
+
2001
+ const withoutExact = new Map(Array.from(store.entries()).filter(([key]) => key !== 'dsh-ris-ide:ui:workspace'))
2002
+ globalThis.localStorage = {
2003
+ get length() { return withoutExact.size },
2004
+ key: (index) => Array.from(withoutExact.keys())[index],
2005
+ getItem: (key) => (withoutExact.has(key) ? withoutExact.get(key) : null),
2006
+ setItem: () => {},
2007
+ }
2008
+ assert.equal(T.readSavedUi('dsh-ris-ide:ui:workspace').font, 18, 'a legacy per-program key is adopted')
2009
+
2010
+ withoutExact.set('dsh-ris-ide:ui:broken', '{not json')
2011
+ assert.ok(T.readSavedUi('dsh-ris-ide:ui:nothing') !== null, 'junk does not throw')
2012
+ } finally {
2013
+ if (hadLocalStorage) globalThis.localStorage = previousLocalStorage
2014
+ else globalThis.localStorage = undefined
2015
+ }
2016
+ })
2017
+
2018
+ test('childEntryFor follows the RENDERED rows, so a folded chain still locates', () => {
2019
+ const levels = {
2020
+ '/w': [
2021
+ { name: 'ris', label: 'ris', path: '/w/ris', isDir: true },
2022
+ { name: 'README.md', label: 'README.md', path: '/w/README.md', isDir: false },
2023
+ ],
2024
+ '/w/ris': [
2025
+ // The whole src/main/java/cn/com/ris/cloud/model chain is ONE row.
2026
+ { name: 'model', label: 'src.main.java.cn.com.ris.cloud.model', path: '/w/ris/src/main/java/cn/com/ris/cloud/model', isDir: true, compact: true },
2027
+ { name: 'README.md', label: 'README.md', path: '/w/ris/README.md', isDir: false },
2028
+ ],
2029
+ '/w/ris/src/main/java/cn/com/ris/cloud/model': [
2030
+ { name: 'Foo.java', label: 'Foo.java', path: '/w/ris/src/main/java/cn/com/ris/cloud/model/Foo.java', isDir: false },
2031
+ ],
2032
+ }
2033
+ const file = '/w/ris/src/main/java/cn/com/ris/cloud/model/Foo.java'
2034
+ const chain = []
2035
+ let current = '/w'
2036
+ for (let guard = 0; guard < 16; guard += 1) {
2037
+ const next = T.childEntryFor(levels[current] || [], file)
2038
+ if (next === undefined) break
2039
+ chain.push(next.path)
2040
+ current = next.path
2041
+ }
2042
+ assert.deepEqual(chain, ['/w/ris', '/w/ris/src/main/java/cn/com/ris/cloud/model'],
2043
+ 'the folded row is the one that gets expanded (no raw ancestors)')
2044
+ assert.equal(current, '/w/ris/src/main/java/cn/com/ris/cloud/model')
2045
+ // No match, a file target, and junk entries are all safe.
2046
+ assert.equal(T.childEntryFor(levels['/w'], '/w/other/x.txt'), undefined)
2047
+ assert.equal(T.childEntryFor([{ path: file, isDir: false }], file), undefined)
2048
+ assert.equal(T.childEntryFor(undefined, file), undefined)
2049
+ assert.equal(T.childEntryFor([null, 'x', { path: '/w/ris', isDir: false }], file), undefined)
2050
+ })
2051
+
2052
+ test('the locate walk against a real single-child package chain reaches the file parent', async () => {
2053
+ const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises')
2054
+ const { execFile } = await import('node:child_process')
2055
+ const { promisify } = await import('node:util')
2056
+ const run = promisify(execFile)
2057
+ const root = await mkdtemp(join(tmpdir(), 'dsh-ris-ide-locate-'))
2058
+ try {
2059
+ await run('git', ['init', '-q'], { cwd: root })
2060
+ await writeFile(join(root, '.gitignore'), 'target/\n')
2061
+ const pkg = join(root, 'src', 'main', 'java', 'cn', 'com', 'ris', 'cloud', 'model')
2062
+ await mkdir(pkg, { recursive: true })
2063
+ const file = join(pkg, 'Foo.java')
2064
+ await writeFile(file, 'class Foo {}\n')
2065
+
2066
+ const levels = {}
2067
+ let current = root
2068
+ const chain = []
2069
+ for (let guard = 0; guard < 16; guard += 1) {
2070
+ levels[current] = (await hostTest.listIdeTree(current, root)).entries
2071
+ const next = T.childEntryFor(levels[current], file)
2072
+ if (next === undefined) break
2073
+ chain.push(next.path)
2074
+ current = next.path
2075
+ }
2076
+ assert.equal(current, pkg, 'the walk lands on the folded package dir')
2077
+ const rows = levels[current].map((entry) => entry.label)
2078
+ assert.deepEqual(rows, ['Foo.java'], 'the file row is rendered there')
2079
+ // Outside java every folder keeps its own row; only the package tail is folded.
2080
+ assert.deepEqual(chain.slice(0, 3), [
2081
+ join(root, 'src'),
2082
+ join(root, 'src', 'main'),
2083
+ join(root, 'src', 'main', 'java'),
2084
+ ], 'the walk descends the real src/main/java folders')
2085
+ assert.equal(chain[chain.length - 1], pkg, 'the last row is the FOLDED package node')
2086
+ assert.ok(levels[join(root, 'src', 'main', 'java')].some((entry) => entry.compact === true),
2087
+ 'below java the package chain is a single compact row')
2088
+ } finally {
2089
+ await rm(root, { recursive: true, force: true })
2090
+ }
2091
+ })
2092
+
2093
+ test('sourceRelativeSuffix maps debugger class names to source paths', () => {
2094
+ assert.equal(T.sourceRelativeSuffix('cn.com.ris.cloud.model.ModelHelper'), 'cn/com/ris/cloud/model/ModelHelper.java')
2095
+ assert.equal(T.sourceRelativeSuffix('cn.com.ris.Foo$Bar'), 'cn/com/ris/Foo.java', 'inner classes fold to the outer file')
2096
+ assert.equal(T.sourceRelativeSuffix(' a.B '), 'a/B.java', 'whitespace is trimmed')
2097
+ assert.equal(T.sourceRelativeSuffix(''), undefined)
2098
+ assert.equal(T.sourceRelativeSuffix(undefined), undefined)
2099
+ assert.equal(T.sourceRelativeSuffix(null), undefined)
2100
+ })
2101
+
2102
+ test('treeIndent steps 11px per depth from a 6px base', () => {
2103
+ assert.equal(T.treeIndent(0), 6)
2104
+ assert.equal(T.treeIndent(1), 17)
2105
+ assert.equal(T.treeIndent(3), 39)
2106
+ assert.equal(T.treeIndent(-2), 6, 'a bad depth never pulls rows out of the pane')
2107
+ })
2108
+
2109
+ test('parseGutterLine reads a line number and rejects spacers', () => {
2110
+ assert.equal(T.parseGutterLine('42'), 42)
2111
+ assert.equal(T.parseGutterLine(' 7\n'), 7)
2112
+ assert.equal(T.parseGutterLine(''), undefined)
2113
+ assert.equal(T.parseGutterLine(' '), undefined)
2114
+ assert.equal(T.parseGutterLine('·'), undefined, 'folded pseudo-lines are not clickable rows')
2115
+ assert.equal(T.parseGutterLine(undefined), undefined)
2116
+ })
2117
+
2118
+ test('formatMemory and heapHint surface memory pressure', () => {
2119
+ assert.equal(T.formatMemory(0), '')
2120
+ assert.equal(T.formatMemory(undefined), '')
2121
+ assert.equal(T.formatMemory(650 * 1024 * 1024), '650 MB')
2122
+ assert.equal(T.formatMemory(1.9 * 1024 * 1024 * 1024), '1.9 GB')
2123
+ assert.equal(T.heapHint({ jvmArgs: ['-Xmx512m'] }), '', 'configured bounds need no hint')
2124
+ assert.match(T.heapHint({}), /defaults\.jvmArgs/)
2125
+ assert.match(T.heapHint(undefined), /defaults\.jvmArgs/)
2126
+ })
2127
+
2128
+ test('Eclipse debug keys: F5 into, F6 over, F7 out, F8 resume', () => {
2129
+ const suspended = { attached: true, suspended: true }
2130
+ const running = { attached: true, suspended: false }
2131
+ assert.deepEqual(T.debugShortcut('F5', suspended), { kind: 'run', mode: 'step-into' })
2132
+ assert.deepEqual(T.debugShortcut('F6', suspended), { kind: 'run', mode: 'step-over' })
2133
+ assert.deepEqual(T.debugShortcut('F7', suspended), { kind: 'run', mode: 'step-out' })
2134
+ assert.deepEqual(T.debugShortcut('F8', suspended), { kind: 'run', mode: 'resume' })
2135
+ assert.deepEqual(T.debugShortcut('F8', running), { kind: 'run', mode: 'resume' }, 'resume works while running too (no-op on the host)')
2136
+ assert.deepEqual(T.debugShortcut('F5', running), { kind: 'swallow' }, 'F5 is swallowed while a session runs (never reloads the page)')
2137
+ assert.deepEqual(T.debugShortcut('F6', running), { kind: 'swallow' })
2138
+ assert.deepEqual(T.debugShortcut('F7', running), { kind: 'swallow' })
2139
+ assert.equal(T.debugShortcut('F5', { attached: false }), undefined, 'nothing is claimed without a session')
2140
+ assert.equal(T.debugShortcut('F5', undefined), undefined)
2141
+ assert.equal(T.debugShortcut('Enter', suspended), undefined, 'unrelated keys pass through')
2142
+ })
2143
+
2144
+ test('a located tree row is scrolled into the tree list, not the page', () => {
2145
+ // Row below the fold: scroll down by exactly the overflow.
2146
+ assert.equal(T.revealScrollTop(500, 520, 100, 400, 0), 120)
2147
+ // Row above the fold: scroll up.
2148
+ assert.equal(T.revealScrollTop(50, 70, 100, 400, 300), 250)
2149
+ // Already visible: untouched (and never negative).
2150
+ assert.equal(T.revealScrollTop(150, 170, 100, 400, 300), 300)
2151
+ assert.equal(T.revealScrollTop(0, 5, 100, 400, 0), 0)
2152
+ })
2153
+
2154
+
2155
+ test('the console highlights the row of the project being shown', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2156
+ const tab = { id: T.PROGRAM_TAB_ID, type: T.PROGRAM_TAB_ID, title: 'workspace', meta: { logPath: '/w/logs/a.log', name: 'a-biz', kind: 'spring' } }
2157
+ const markup = renderWorkbench({ tab, programs: [
2158
+ { id: 'spring:a#A', name: 'a-biz', kind: 'spring', logPath: '/w/logs/a.log', running: { alive: true } },
2159
+ { id: 'spring:b#B', name: 'b-biz', kind: 'spring', logPath: '/w/logs/b.log', running: { alive: true } },
2160
+ ] })
2161
+ assert.match(markup, /data-console-on="1"/, 'the active project row is marked')
2162
+ const selected = markup.slice(markup.indexOf('data-console-on="1"'))
2163
+ assert.match(selected.slice(0, 600), /a-biz/, 'the marked row is the selected program')
2164
+ assert.equal((markup.match(/data-console-on="1"/g) || []).length, 1, 'exactly one row is highlighted')
2165
+ })
2166
+
2167
+ test('the log pane reads any program that has a log file, running or not', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2168
+ const tab = { id: T.PROGRAM_TAB_ID, type: T.PROGRAM_TAB_ID, title: 'workspace', meta: { logPath: '/w/logs/a.log', name: 'a-biz', kind: 'spring' } }
2169
+ const programsOf = (alive, hasLog) => ([{ id: 'spring:a#A', name: 'a-biz', kind: 'spring', logPath: '/w/logs/a.log', hasLog, running: { alive } }])
2170
+
2171
+ const running = renderWorkbench({ tab, programs: programsOf(true, true) })
2172
+ assert.match(running, /data-log-slot="1"/, 'a running program mounts the log viewer')
2173
+ assert.match(running, /data-dsh-ris-ide="viewer"/)
2174
+ assert.match(running, /data-console-pick="\/w\/logs\/a\.log"/, 'the console row can switch to it')
2175
+
2176
+ // Historical log of a STOPPED program is still readable — no start required.
2177
+ const stopped = renderWorkbench({ tab, programs: programsOf(false, true) })
2178
+ assert.match(stopped, /data-log-slot="1"/, 'a stopped program with a log file still shows it')
2179
+ assert.match(stopped, /data-dsh-ris-ide="viewer"/)
2180
+
2181
+ // Only a program with no log file at all shows the placeholder.
2182
+ const noLog = renderWorkbench({ tab, programs: programsOf(false, false) })
2183
+ assert.doesNotMatch(noLog, /data-log-slot="1"|data-dsh-ris-ide="viewer"/)
2184
+ assert.match(noLog, /还没有日志文件/)
2185
+ assert.doesNotMatch(noLog, /data-tab-close="(log|debug)"/, 'no program tab strips remain')
2186
+ })
2187
+
2188
+ test('requestDebugs asks the session list route', async () => {
2189
+ const original = globalThis.fetch
2190
+ const calls = []
2191
+ globalThis.fetch = async (href) => {
2192
+ calls.push(String(href))
2193
+ return { ok: true, status: 200, json: async () => ({ ok: true, sessions: [{ logPath: '/w/logs/a.log', attached: true, vmSuspended: true, seq: 3 }] }) }
2194
+ }
2195
+ try {
2196
+ const result = await T.requestDebugs({ sessionId: 's1', cwd: '/w' })
2197
+ assert.equal(result.ok, true)
2198
+ assert.equal(result.sessions.length, 1)
2199
+ const url = new URL(calls[0])
2200
+ assert.equal(url.pathname.endsWith('/dsh-ris-ide/debugs'), true, `unexpected url ${calls[0]}`)
2201
+ assert.equal(url.searchParams.get('sessionId'), 's1')
2202
+ } finally {
2203
+ globalThis.fetch = original
2204
+ }
2205
+ })
2206
+
2207
+ test('the log pane title bar carries the service state (the body has no bar)', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2208
+ const tab = { id: T.PROGRAM_TAB_ID, type: T.PROGRAM_TAB_ID, title: 'workspace', meta: { logPath: '/w/logs/a.log', name: 'a-biz', kind: 'spring' } }
2209
+ const program = (running) => ([{ id: 'spring:a#A', name: 'a-biz', kind: 'spring', logPath: '/w/logs/a.log', hasLog: true, running }])
2210
+
2211
+ const head = logHeadOf(renderWorkbench({ tab, programs: program({ pid: 445482, alive: true }) }))
2212
+ assert.match(head, /data-log-service="running"/)
2213
+ assert.match(head, /运行中 \(pid 445482\)/)
2214
+ assert.doesNotMatch(head, /省略前/, 'the elided-size note is gone from the bar')
2215
+ assert.doesNotMatch(head, /474\.0 KB/, 'the size is gone from the bar')
2216
+ assert.doesNotMatch(head, /12 行/, 'the line count is gone from the bar')
2217
+
2218
+ const stopped = logHeadOf(renderWorkbench({ tab, programs: program({ pid: 445482, alive: false }) }))
2219
+ assert.match(stopped, /data-log-service="stopped"/)
2220
+ assert.match(stopped, /已停止 \(pid 445482\)/)
2221
+
2222
+ // No pidfile at all: "未运行" instead of an empty title bar.
2223
+ const none = logHeadOf(renderWorkbench({ tab, programs: program(undefined) }))
2224
+ assert.match(none, /data-log-service="none"/)
2225
+ assert.match(none, /未运行/)
2226
+
2227
+ // The body itself no longer renders any of the old bar.
2228
+ const markup = renderWorkbench({ tab, programs: program({ pid: 445482, alive: true }) })
2229
+ const body = markup.slice(markup.indexOf('data-log-slot="1"'))
2230
+ assert.doesNotMatch(body, /换行:开|暂停跟随|继续跟随|运行中|已停止/)
2231
+ assert.doesNotMatch(body, /data-action="(wrap|font-down|font-up|follow)"/)
2232
+ })
2233
+
2234
+ test('the search toolbar does not clip its results dropdown', () => {
2235
+ // Regression: `editorPaneHead` used to be `overflow: hidden`; the dropdown is its
2236
+ // absolutely-positioned child, so the rows were in the DOM but invisible.
2237
+ for (const ratio of [1, 1.25]) {
2238
+ const styles = T.stylesFor(ratio)
2239
+ assert.equal(styles.editorPaneHead.overflow, 'visible', 'the header must not clip the dropdown')
2240
+ assert.equal(styles.editorPaneHead.position, 'relative')
2241
+ assert.ok(Number(styles.editorPaneHead.zIndex) >= 10, 'the header paints above the editor body')
2242
+ assert.equal(styles.searchDropdown.position, 'absolute')
2243
+ assert.ok(Number(styles.searchDropdown.zIndex) >= 60)
2244
+ assert.ok(styles.searchDropdown.maxHeight > 0)
2245
+ }
2246
+ })
2247
+
2248
+ test('search replies become dropdown payloads without crashing on `dirs`', () => {
2249
+ // The host /search route answers files only: `dirs` is an empty OBJECT. Treating
2250
+ // it as an array (`dirs.forEach`) threw and made every filename search look dead.
2251
+ const hostReply = T.searchHitPayload({ matches: ['/w/ris/ris-auth/A.java'], dirs: {} }, '/w')
2252
+ assert.deepEqual(hostReply.matches, ['/w/ris/ris-auth/A.java'])
2253
+ assert.deepEqual(hostReply.dirs, {})
2254
+
2255
+ // No dirs at all (legacy/partial reply) must also be fine.
2256
+ const bare = T.searchHitPayload({ matches: ['x/B.java'] }, '/w')
2257
+ assert.deepEqual(bare.matches, ['/w/x/B.java'], 'relative legacy paths resolve against cwd')
2258
+
2259
+ // An array of directories (older sidebar shape) still works.
2260
+ const legacy = T.searchHitPayload({ matches: [], dirs: ['/w/src'] }, '/w')
2261
+ assert.deepEqual(legacy.dirs, { '/w/src': true })
2262
+
2263
+ // Junk in, empty out — never a throw.
2264
+ assert.deepEqual(T.searchHitPayload(undefined, '/w'), { matches: [], dirs: {} })
2265
+ })
2266
+
2267
+ test('the search dropdown renders hits, empty state and errors', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2268
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
2269
+ const render = (hits) => renderToStaticMarkup(React.createElement(T.SearchResults, { hits, fontRatio: 1 }))
2270
+ const withHits = render({ matches: ['/w/ris/ris-auth/A.java'], dirs: {} })
2271
+ assert.match(withHits, /data-search-results="1"/)
2272
+ assert.match(withHits, /A\.java/)
2273
+ assert.match(withHits, /匹配 1 个/)
2274
+ assert.match(render({ matches: [], dirs: {} }), /没有匹配的文件/)
2275
+ assert.match(render({ error: 'boom' }), /检索失败:boom/)
2276
+ })
2277
+
2278
+ test('the log pane title bar holds icon buttons (no refresh, no find button)', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2279
+ const tab = { id: T.PROGRAM_TAB_ID, type: T.PROGRAM_TAB_ID, title: 'workspace', meta: { logPath: '/w/logs/a.log', name: 'a-biz', kind: 'spring' } }
2280
+ const programs = [{ id: 'spring:a#A', name: 'a-biz', kind: 'spring', logPath: '/w/logs/a.log', hasLog: true, running: { pid: 42, alive: true } }]
2281
+ const head = logHeadOf(renderWorkbench({ tab, programs }))
2282
+
2283
+ assert.doesNotMatch(head, /data-action="refresh"/, 'no manual refresh: the viewer follows the tail on its own')
2284
+ assert.doesNotMatch(head, />刷新</)
2285
+ // The controls that moved into the title bar, all fixed-width icon buttons
2286
+ // (the old in-body text labels are gone).
2287
+ for (const action of ['wrap', 'font-down', 'font-up', 'follow']) {
2288
+ assert.match(head, new RegExp(`data-action="${action}"`), `missing ${action}`)
2289
+ }
2290
+ assert.ok((head.match(/width:22px/g) || []).length >= 4, 'every control is a 22px icon button')
2291
+ assert.match(head, /data-icon="wrap"/, 'wrap is drawn as an inline SVG icon')
2292
+ assert.match(head, /data-icon="follow"/, 'follow is drawn as an inline SVG icon')
2293
+ assert.doesNotMatch(head, />换行:开<|>换行:关<|>暂停跟随<|>继续跟随</, 'no text labels left in the title bar')
2294
+ // Find stays a Ctrl+F shortcut with its own body bar: no title-bar button.
2295
+ assert.doesNotMatch(head, /data-action="log-find"/)
2296
+
2297
+ // No log viewer mounted → no viewer controls at all.
2298
+ const empty = logHeadOf(renderWorkbench({}))
2299
+ for (const action of ['wrap', 'font-down', 'font-up', 'follow']) {
2300
+ assert.equal(empty.includes(`data-action="${action}"`), false, `${action} must not render without a viewer`)
2301
+ }
2302
+ })
2303
+
2304
+ test('canOpenLog only allows a running program with a log file', () => {
2305
+ assert.equal(T.canOpenLog({ running: { alive: true }, hasLog: true }), true)
2306
+ assert.equal(T.canOpenLog({ running: { alive: false } }), false, 'a stopped row has no live log to load')
2307
+ assert.equal(T.canOpenLog({ running: null }), false)
2308
+ assert.equal(T.canOpenLog({ hasLog: false, running: { alive: true } }), false, 'no log file → nothing to open')
2309
+ assert.equal(T.canOpenLog(null), false)
2310
+ assert.equal(T.canOpenLog(undefined), false)
2311
+ })
2312
+
2313
+ test('debug polling is fast only while attached', () => {
2314
+ assert.equal(T.debugPollDelay(true), 700)
2315
+ assert.equal(T.debugPollDelay(false), 5000, 'an idle debug tab backs off to one poll per five seconds')
2316
+ assert.equal(T.debugPollDelay(undefined), 5000, 'unknown = not attached')
2317
+ })
2318
+
2319
+ test('the status bar has a 💬 message button at its right, and the log caps at 200', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2320
+ const markup = renderWorkbench({})
2321
+ const button = markup.indexOf('data-action="messages"')
2322
+ const debugState = markup.indexOf('未调试')
2323
+ assert.ok(button > -1, 'the message button exists')
2324
+ assert.ok(button > debugState, 'it sits at the RIGHT end of the status bar')
2325
+ assert.ok(markup.indexOf('双击标题最大化') < button, 'after the shortcut hints')
2326
+ assert.ok(!markup.includes('data-message-panel'), 'the log panel starts closed')
2327
+ })
2328
+
2329
+ test('dropMessage removes exactly one entry', () => {
2330
+ const list = [{ id: 1, text: 'a' }, { id: 2, text: 'b' }, { id: 3, text: 'c' }]
2331
+ assert.deepEqual(T.dropMessage(list, 2).map((message) => message.id), [1, 3])
2332
+ assert.deepEqual(T.dropMessage(list, '2').map((message) => message.id), [1, 3], 'ids compare as strings')
2333
+ assert.equal(T.dropMessage(list, 99).length, 3, 'an unknown id is a no-op')
2334
+ assert.deepEqual(T.dropMessage(undefined, 1), [])
2335
+ })
2336
+
2337
+ test('formatClock and capMessages keep the message log readable', () => {
2338
+ const stamp = new Date(2026, 9, 19, 8, 5, 3).getTime()
2339
+ assert.equal(T.formatClock(stamp), '08:05:03', 'zero-padded local clock')
2340
+ assert.equal(T.formatClock(undefined), '--:--:--')
2341
+ const many = Array.from({ length: 205 }, (_, index) => ({ id: index, text: 'm' + index, at: stamp }))
2342
+ const capped = T.capMessages(many)
2343
+ assert.equal(capped.length, 200)
2344
+ assert.equal(capped[199].id, 204, 'the newest message is kept')
2345
+ assert.equal(capped[0].id, 5, 'the oldest ones are dropped')
2346
+ assert.deepEqual(T.capMessages(undefined), [])
2347
+ })
2348
+
2349
+ test('floating hints: newest last, capped at five, five-second life', () => {
2350
+ assert.equal(T.TOAST_TTL_MS, 5000)
2351
+ const list = [1, 2, 3, 4, 5, 6, 7].map((id) => ({ id, text: 'm' + id }))
2352
+ const capped = T.capToasts(list)
2353
+ assert.equal(capped.length, 5, 'the stack never grows past five rows')
2354
+ assert.deepEqual(capped.map((item) => item.id), [3, 4, 5, 6, 7], 'the OLDEST hints are dropped')
2355
+ assert.deepEqual(T.capToasts([{ id: 1 }], 5), [{ id: 1 }], 'short lists pass through')
2356
+ assert.deepEqual(T.capToasts([]), [])
2357
+ assert.deepEqual(T.capToasts(undefined), [], 'junk is inert')
2358
+ })
2359
+
2360
+ test('every advisory strip carries a ✕ to close it', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2361
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
2362
+ const logMarkup = renderToStaticMarkup(React.createElement(T.LogTail, {
2363
+ scope: { sessionId: 's1', cwd: '/tmp' },
2364
+ path: 'service.log',
2365
+ customData: { ok: true, content: 'HEAD\n', mode: 'head', truncated: true },
2366
+ }))
2367
+ assert.match(logMarkup, /data-notice="log-head"/)
2368
+ assert.match(logMarkup, /data-notice-close="log-head"/, 'the head-mode notice is closable')
2369
+
2370
+ const workbench = renderWorkbench({ configNote: '配置已自动生成' })
2371
+ assert.match(workbench, /data-notice="config-note"/)
2372
+ assert.match(workbench, /data-notice-close="config-note"/, 'the config note is closable')
2373
+ })
2374
+
2375
+ test('driftSignature is stable per drift payload', () => {
2376
+ const a = { added: ['x'], missing: ['y'], changed: [] }
2377
+ assert.equal(T.driftSignature(a), T.driftSignature({ added: ['x'], missing: ['y'], changed: [] }))
2378
+ assert.notEqual(T.driftSignature(a), T.driftSignature({ added: ['x', 'z'], missing: ['y'], changed: [] }))
2379
+ assert.equal(T.driftSignature(null), 'none')
2380
+ assert.equal(T.driftSignature(undefined), 'none')
2381
+ })
2382
+
2383
+ test('the debug toolbar keeps every control visible, disabled while inapplicable', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2384
+ const markup = renderWorkbench({})
2385
+ const head = markup.slice(markup.indexOf('data-debug-head="1"'))
2386
+ for (const action of ['debug-stop', 'debug-pause', 'debug-resume', 'step-over', 'step-into', 'step-out']) {
2387
+ const tag = new RegExp(`<button[^>]*data-action="${action}"[^>]*>`).exec(head)
2388
+ assert.ok(tag !== null, `missing ${action}: the toolbar must not reshuffle`)
2389
+ assert.match(tag[0], /disabled/, `${action} is disabled while there is no suspended session`)
2390
+ }
2391
+ })
2392
+
2393
+ test('the tree dialog hints stay short (labels + one-example placeholders)', async () => {
2394
+ const { readFile } = await import('node:fs/promises')
2395
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
2396
+ // One example, no explanatory sentence in the dialogs.
2397
+ assert.match(source, /如 Makefile、README/)
2398
+ assert.match(source, /如 ModelHelper 或 cloud\.test/)
2399
+ assert.match(source, /如 cn\.com\.ris'/)
2400
+ assert.match(source, /如 model-new/)
2401
+ assert.doesNotMatch(source, /例如/)
2402
+ assert.doesNotMatch(source, /按输入保存,不自动加后缀/)
2403
+ assert.doesNotMatch(source, /会创建多级目录/)
2404
+ assert.doesNotMatch(source, /覆盖会删除目标现有内容/)
2405
+ assert.doesNotMatch(source, /目标已存在同名条目/)
2406
+ })
2407
+
2408
+ test('every breakpoint row deletes from the LEFT, pre-set breakpoints included', async () => {
2409
+ const { readFile } = await import('node:fs/promises')
2410
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
2411
+ // One shared button, rendered before the row text in all three branches.
2412
+ assert.match(source, /var removeButton = h\('button', \{[\s\S]{0,240}?'data-bp-remove': '1'/)
2413
+ assert.match(source, /removeBreakpoint\(bp\.pending === true \? 'pending' : bp\.requestId, bp\)/, 'a pre-set breakpoint removes by marker')
2414
+ assert.match(source, /key: 'bp-pending-'[\s\S]{0,400}?removeButton,\s*h\('span'/, 'pending row: delete button first')
2415
+ assert.match(source, /key: 'bp-failed-'[\s\S]{0,900}?h\('button', \{[\s\S]{0,400}?'data-bp-remove': '1'[\s\S]{0,900}?h\('span'/, 'failed row: delete button first')
2416
+ assert.match(source, /key: 'bp-' \+ bp\.requestId[\s\S]{0,200}?removeButton,/, 'live row: delete button first')
2417
+ assert.doesNotMatch(source, /'除去'/, 'the failed row uses the same wording')
2418
+ // Icon-only like the title-bar controls: one fixed 20px square with ×,
2419
+ // not a wide text button that eats row width.
2420
+ assert.match(source, /width: 20, minWidth: 20, height: 20, padding: '0 0'/, 'the row delete is a 20px square icon button')
2421
+ assert.equal(source.split("'data-bp-remove': '1'").length - 1, 2, 'one shared + one failed-row icon button')
2422
+ const breakpointRows = source.slice(source.indexOf('var rows = []'), source.indexOf("} else if (ui.section === 'threads')"))
2423
+ assert.equal(breakpointRows.split("}, '×')").length - 1, 2, 'both buttons render the × glyph, no text label')
2424
+ })
2425
+
2426
+ test('the breakpoint clear-all is a title-bar icon, not an in-body text button', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2427
+ const markup = renderWorkbench({})
2428
+ const head = markup.indexOf('data-debug-head="1"')
2429
+ assert.ok(head > -1, 'the debug pane has a title bar')
2430
+ const refresh = markup.indexOf('data-action="debug-refresh"', head)
2431
+ const clear = markup.indexOf('data-action="breakpoints-clear"', head)
2432
+ assert.ok(clear > refresh, 'the clear icon sits in the same title bar, after refresh')
2433
+ assert.ok(!markup.includes('data-bp-toolbar'), 'the in-body 全部清除 bar is gone')
2434
+ assert.ok(!markup.includes('全部清除'), 'no text button left')
2435
+ })
2436
+
2437
+ /** SSR-render the IDE workbench with the props a real program tab passes. */
2438
+ function renderWorkbench(extra) {
2439
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
2440
+ return renderToStaticMarkup(React.createElement(T.ProgramWorkbench, Object.assign({
2441
+ scope: { sessionId: 's1', cwd: '/w' },
2442
+ visible: false,
2443
+ tab: {
2444
+ id: T.PROGRAM_TAB_ID,
2445
+ type: T.PROGRAM_TAB_ID,
2446
+ title: 'ris-model-biz',
2447
+ meta: { programId: 'spring:x#a.B', logPath: '/w/logs/ris-model-biz.log', name: 'ris-model-biz', kind: 'spring' },
2448
+ },
2449
+ programs: [],
2450
+ }, extra)))
2451
+ }
2452
+
2453
+ /** Just the top bar: from its marker up to the IDE grid that follows it. */
2454
+ /** The source pane's header — the IDE toolbar now (there is no workbench top bar). */
2455
+ function headOf(markup) {
2456
+ const start = markup.indexOf('data-editor-head="1"')
2457
+ const end = markup.indexOf('data-editor-pane="1"', start)
2458
+ assert.ok(start !== -1 && end > start, 'the source pane must render a toolbar header')
2459
+ return markup.slice(start, end)
2460
+ }
2461
+
2462
+ /**
2463
+ * SSR the workbench with a file already open (via the saved-UI localStorage the
2464
+ * workbench reads on mount) — the toolbar is rendered from that state, so a
2465
+ * reference to a helper that only exists in another component scope throws here
2466
+ * instead of in the browser (that is exactly how `previewCurrentFile` broke).
2467
+ */
2468
+ function renderWorkbenchWithFile(file) {
2469
+ const previous = Object.prototype.hasOwnProperty.call(globalThis, 'localStorage') ? globalThis.localStorage : undefined
2470
+ globalThis.localStorage = { getItem: (key) => (String(key).startsWith('dsh-ris-ide:ui:') ? JSON.stringify({ files: [file], file }) : null), setItem() {} }
2471
+ try {
2472
+ return renderWorkbench({})
2473
+ } finally {
2474
+ if (previous === undefined) delete globalThis.localStorage
2475
+ else globalThis.localStorage = previous
2476
+ }
2477
+ }
2478
+
2479
+ test('Markdown/HTML files get the 预览/编辑 switch above the editor, not in the toolbar', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2480
+ const markdown = renderWorkbenchWithFile('/w/README.md')
2481
+ assert.match(markdown, /data-action="preview-enter"/, 'a .md file offers the inline preview')
2482
+ assert.match(markdown, /data-preview-toggle="closed"/, 'the switch starts in edit mode')
2483
+ assert.doesNotMatch(markdown, /data-action="preview"/, 'the generic workbench toolbar stays generic')
2484
+ const html = renderWorkbenchWithFile('/w/page.html')
2485
+ assert.match(html, /data-action="preview-enter"/, 'a .html file offers it too')
2486
+ const java = renderWorkbenchWithFile('/w/ModelHelper.java')
2487
+ assert.doesNotMatch(java, /data-action="preview-enter"/, 'plain source files do not')
2488
+ assert.doesNotMatch(java, /data-preview-toggle/, 'and get no switch at all')
2489
+ })
2490
+
2491
+ /**
2492
+ * The LOG pane's title bar: from its marker to the status bar. The log pane is
2493
+ * the LAST slot of the IDE grid, so everything between is its title bar.
2494
+ */
2495
+ function logHeadOf(markup) {
2496
+ const start = markup.indexOf('data-log-head="1"')
2497
+ assert.ok(start !== -1, 'the log pane must render its own title bar')
2498
+ const end = markup.indexOf('data-ide-status', start)
2499
+ return markup.slice(start, end === -1 ? undefined : end)
2500
+ }
2501
+
2502
+ test('a first ever open starts with the source pane maximised, and that state is saved', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2503
+ const hadLocalStorage = Object.prototype.hasOwnProperty.call(globalThis, 'localStorage')
2504
+ const previousLocalStorage = globalThis.localStorage
2505
+ const writes = []
2506
+ try {
2507
+ // No saved layout at all: the workbench should collapse to the source pane.
2508
+ globalThis.localStorage = { getItem: () => null, setItem: (key, value) => { writes.push({ key, value: JSON.parse(value) }) } }
2509
+ const first = renderWorkbench({})
2510
+ assert.match(first, /data-ide-pane="editor"/, 'the first open shows the source pane')
2511
+ assert.doesNotMatch(first, /data-ide-pane="tree"/, 'the other panes are maximised away')
2512
+ // Persistence round-trip: the same `ui.maximized` the effect writes is what a
2513
+ // later open reads back (effects do not run under SSR, so emulate the write).
2514
+ globalThis.localStorage = { getItem: () => JSON.stringify({ ui: { maximized: 'editor' }, files: [], sizes: {} }), setItem() {} }
2515
+ const again = renderWorkbench({})
2516
+ assert.match(again, /data-ide-pane="editor"/, 'the saved maximised state restores')
2517
+ assert.doesNotMatch(again, /data-ide-pane="tree"/)
2518
+
2519
+ // A stored layout (even "not maximised") always wins over the default.
2520
+ globalThis.localStorage = { getItem: () => JSON.stringify({ ui: { maximized: null }, files: [], sizes: {} }), setItem() {} }
2521
+ const restored = renderWorkbench({})
2522
+ assert.match(restored, /data-ide-pane="tree"/, 'a saved non-maximised layout restores the grid')
2523
+ assert.match(restored, /data-splitter="left"/)
2524
+ } finally {
2525
+ if (hadLocalStorage) globalThis.localStorage = previousLocalStorage
2526
+ else globalThis.localStorage = undefined
2527
+ }
2528
+ })
2529
+
2530
+ test('the IDE grid has four draggable splitters and applies the default pane sizes', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2531
+ const markup = renderWorkbench({ onConfig: { open() {}, generate() {}, exists: true } })
2532
+ for (const kind of ['left', 'leftBottom', 'rightBottom', 'debugWidth']) {
2533
+ assert.match(markup, new RegExp(`data-splitter="${kind}"`), `missing the ${kind} splitter`)
2534
+ }
2535
+ assert.equal((markup.match(/width:300px/g) || []).length, 2, 'the left column AND the debug pane default to 300px')
2536
+ assert.equal((markup.match(/height:260px/g) || []).length, 2, 'both bottom rows default to 260px')
2537
+ const debugSplitter = (markup.match(/<div[^>]*data-splitter="debugWidth"[^>]*>/) || [''])[0]
2538
+ assert.match(debugSplitter, /col-resize/, 'the debug splitter resizes a WIDTH (left/right drag)')
2539
+ assert.match(markup, /data-debug-row="1"/, 'debug and log share one resizable bottom row')
2540
+ })
2541
+
2542
+ test('the IDE top bar hosts the workspace search and only shows config buttons when hosted', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2543
+ const head = headOf(renderWorkbench({ onConfig: { open() {}, generate() {}, exists: true } }))
2544
+ assert.match(head, /data-action="tree-search"/, 'the tree search input sits in the top bar')
2545
+ assert.match(head, /data-action="config-open"/)
2546
+ assert.match(head, /data-action="config-generate"/)
2547
+
2548
+ const bare = headOf(renderWorkbench({}))
2549
+ assert.match(bare, /data-action="tree-search"/, 'the search belongs to the top bar itself')
2550
+ assert.doesNotMatch(bare, /data-action="config-open"/, 'no host callback means no config button')
2551
+ assert.doesNotMatch(bare, /data-action="config-generate"/)
2552
+
2553
+ // Restore-layout is a top-bar control and needs no host callback either. It
2554
+ // resets ONLY the pane sizes; maximise mode is deliberately left alone.
2555
+ for (const bar of [head, bare]) {
2556
+ assert.match(bar, /data-action="layout-reset"/, 'the restore-layout button sits at the top bar right end')
2557
+ assert.match(bar, /title="还原布局尺寸/)
2558
+ assert.match(bar, /data-action="layout-reset"[^>]*>⟲</, 'the button is labelled ⟲')
2559
+ const resetTag = bar.match(/<button[^>]*data-action="layout-reset"[^>]*>/)
2560
+ assert.ok(resetTag !== null)
2561
+ assert.match(resetTag[0], /width:22px/, 'the restore-layout control is an icon button')
2562
+ assert.match(resetTag[0], /text-align:center/)
2563
+ }
2564
+ // The config controls are icon buttons too.
2565
+ for (const action of ['config-open', 'config-generate']) {
2566
+ const tag = head.match(new RegExp(`<button[^>]*data-action="${action}"[^>]*>`))
2567
+ assert.ok(tag !== null, `missing ${action}`)
2568
+ assert.match(tag[0], /width:22px/)
2569
+ }
2570
+ })
2571
+
2572
+ test('debug controls live in the debug pane toolbar, not in the top bar', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2573
+ const markup = renderWorkbench({})
2574
+ assert.match(markup, /data-debug-head="1"/, 'the controls live in the debug pane header')
2575
+ assert.doesNotMatch(headOf(markup), /data-action="debug-start"/, 'the start button moved out of the top bar')
2576
+
2577
+ const headStart = markup.indexOf('data-debug-head="1"')
2578
+ const refresh = markup.indexOf('data-action="debug-refresh"', headStart)
2579
+ assert.ok(refresh > headStart, 'the refresh button lives in the debug pane header')
2580
+ assert.match(markup.slice(headStart, refresh), /data-action="debug-start"/, 'start precedes refresh in the same header')
2581
+ })
2582
+
2583
+ test('the breakpoint class/line form is gone and the debug/log tabs are closable', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2584
+ const markup = renderWorkbench({})
2585
+ assert.doesNotMatch(markup, /类名,如/, 'the manual class/line form was removed')
2586
+ assert.doesNotMatch(markup, /<button[^>]*>[^<]*加断点/, 'the 加断点 button went with the form')
2587
+ assert.match(markup, /双击添加/, 'the empty state teaches double-click breakpoints')
2588
+
2589
+ assert.doesNotMatch(markup, /data-tab-close="debug"/, 'the debug pane has no tab strip')
2590
+ assert.doesNotMatch(markup, /data-tab-close="log"/, 'the log pane has no tab strip')
2591
+ assert.match(markup, /在控制台点击一个项目/, 'the log pane explains how to pick a project')
2592
+ })
2593
+
2594
+ // ── The source pane delegating to the sidebar's own viewers ────────────────
2595
+
2596
+ /**
2597
+ * `apply` with a fake sidebar service: this is what publishes `panelCtx`, the
2598
+ * module-level context `SidebarEditorPane` resolves its viewer through.
2599
+ */
2600
+ function applySidebar(service) {
2601
+ return applyTracked({
2602
+ betterSidebar: Object.assign({ registerFileViewer: () => () => {} }, service),
2603
+ })
2604
+ }
2605
+
2606
+ /** SSR-render one file pane exactly like the IDE's source tab does. */
2607
+ function renderPane(extra) {
2608
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
2609
+ return renderToStaticMarkup(React.createElement(T.SidebarEditorPane, Object.assign({
2610
+ scope: { sessionId: 's1', cwd: '/w' },
2611
+ path: '/w/README.md',
2612
+ title: 'README.md',
2613
+ text: 'hello',
2614
+ visible: true,
2615
+ breakpoints: [],
2616
+ onToggleBreakpoint: () => {},
2617
+ }, extra)))
2618
+ }
2619
+
2620
+ test('capOpenFiles appends under the cap and evicts only the oldest clean tab', () => {
2621
+ const dirty = new Set()
2622
+ const isDirty = (path) => dirty.has(path)
2623
+
2624
+ // (a) under the cap: appended in open order.
2625
+ assert.deepEqual(T.capOpenFiles(['a', 'b'], 'c', 20, isDirty), ['a', 'b', 'c'])
2626
+
2627
+ // (b) already open: the SAME list comes back (no reorder, no duplicate).
2628
+ const open = ['a', 'b']
2629
+ assert.equal(T.capOpenFiles(open, 'a', 20, isDirty), open, 'an already-open path must be a no-op')
2630
+
2631
+ // (c) at the cap with clean tabs: the OLDEST clean tab goes, order is kept.
2632
+ const full = Array.from({ length: 20 }, (_, index) => `f${index}`)
2633
+ assert.deepEqual(T.capOpenFiles(full, 'new', 20, isDirty), [...full.slice(1), 'new'])
2634
+
2635
+ // (d) every tab dirty: the open is refused rather than losing a draft.
2636
+ assert.equal(T.capOpenFiles(full, 'new', 20, () => true), null)
2637
+
2638
+ // (e) dirty tabs are never evicted while an older clean one exists.
2639
+ dirty.add('f0')
2640
+ dirty.add('f1')
2641
+ const capped = T.capOpenFiles(full, 'new', 20, isDirty)
2642
+ assert.equal(capped.length, 20)
2643
+ assert.ok(capped.includes('f0') && capped.includes('f1'), 'dirty tabs survive')
2644
+ assert.ok(!capped.includes('f2'), 'the oldest clean tab is the victim')
2645
+ assert.deepEqual(capped, ['f0', 'f1', ...full.slice(3), 'new'])
2646
+ })
2647
+
2648
+
2649
+
2650
+
2651
+
2652
+ test('the workbench restores open source tabs from localStorage and shows the workspace path', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2653
+ const hadLocalStorage = Object.prototype.hasOwnProperty.call(globalThis, 'localStorage')
2654
+ const previousLocalStorage = globalThis.localStorage
2655
+ const keys = []
2656
+ globalThis.localStorage = {
2657
+ getItem: (key) => {
2658
+ keys.push(key)
2659
+ return key === 'dsh-ris-ide:ui:workspace'
2660
+ ? JSON.stringify({ ui: {}, files: ['/w/a.java', '/w/b.java'], file: '/w/a.java', sizes: {} })
2661
+ : null
2662
+ },
2663
+ setItem: () => {},
2664
+ }
2665
+ try {
2666
+ const markup = renderWorkbench({
2667
+ scope: { sessionId: 's1', cwd: '/w/work' },
2668
+ // No program meta → the WORKSPACE storage key, the one this test restores.
2669
+ tab: { id: T.PROGRAM_TAB_ID, type: T.PROGRAM_TAB_ID, title: 'workspace' },
2670
+ })
2671
+ assert.ok(keys.includes('dsh-ris-ide:ui:workspace'), `unexpected storage key ${keys.join(', ')}`)
2672
+ assert.match(markup, /data-workbench-path="1">\/w\/work</, 'the top bar shows the workspace path')
2673
+ assert.equal((markup.match(/data-tab-close="source"/g) || []).length, 2, 'both restored files get a tab')
2674
+ assert.match(markup, /a\.java/)
2675
+ assert.match(markup, /b\.java/)
2676
+ assert.match(markup, /data-tab-page="prev"/)
2677
+ assert.match(markup, /data-tab-page="next"/)
2678
+ // The two bulk-close buttons moved into the tab strip's right-click menu,
2679
+ // so they are not part of the rendered bar any more.
2680
+ assert.doesNotMatch(markup, /data-action="close-others"/)
2681
+ assert.doesNotMatch(markup, /data-action="close-all"/)
2682
+ } finally {
2683
+ if (hadLocalStorage) globalThis.localStorage = previousLocalStorage
2684
+ else globalThis.localStorage = undefined
2685
+ }
2686
+ })
2687
+
2688
+ test('the console starts and stops FRONTEND programs too, and only Spring gets debug', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2689
+ const markup = renderWorkbench({
2690
+ programs: [
2691
+ { id: 'frontend:ui#dev', name: 'ris-ui-3', kind: 'frontend', logPath: '/w/logs/ui.log', running: { alive: true } },
2692
+ { id: 'frontend:x#dev', name: 'ris-x', kind: 'frontend', logPath: '/w/logs/x.log', running: { alive: false } },
2693
+ ],
2694
+ })
2695
+ assert.equal((markup.match(/data-action="console-stop"/g) || []).length, 1, 'the running frontend gets a stop button')
2696
+ assert.equal((markup.match(/data-action="console-start"/g) || []).length, 1, 'the stopped frontend gets a start button')
2697
+ assert.match(markup, /data-action="console-stop" title="停止 ris-ui-3"/)
2698
+ assert.match(markup, /data-action="console-start" title="启动 ris-x"/)
2699
+ assert.doesNotMatch(markup, /console-debug/, 'debug stays Spring-only')
2700
+ })
2701
+
2702
+ test('console and debug-toolbar icon buttons are fixed-width while text buttons are not', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2703
+ const markup = renderWorkbench({
2704
+ programs: [
2705
+ { id: 'frontend:ui#dev', name: 'ris-ui-3', kind: 'frontend', logPath: '/w/logs/ui.log', running: { alive: true } },
2706
+ { id: 'frontend:x#dev', name: 'ris-x', kind: 'frontend', logPath: '/w/logs/x.log', running: { alive: false } },
2707
+ { id: 'spring:a#A', name: 'a-biz', kind: 'spring', logPath: '/w/logs/a.log', running: { alive: true } },
2708
+ ],
2709
+ })
2710
+ // Each OPENING TAG carrying a data-action: style precedes it in the SSR output.
2711
+ const tags = markup.match(/<button[^>]*>/g) || []
2712
+
2713
+ const consoleTags = tags.filter((tag) => /data-action="console-/.test(tag))
2714
+ assert.ok(!consoleTags.some((tag) => tag.includes('data-action="console-log"')), 'the log button is gone')
2715
+ for (const action of ['console-start', 'console-stop', 'console-debug']) {
2716
+ assert.ok(consoleTags.some((tag) => tag.includes(`data-action="${action}"`)), `missing ${action}`)
2717
+ }
2718
+ for (const tag of consoleTags) {
2719
+ const action = /data-action="([^"]+)"/.exec(tag)[1]
2720
+ // Console buttons are 25px (22px + the user-requested 3px), the rest of the
2721
+ // 22px icon family stays as-is.
2722
+ assert.match(tag, /width:25px/, `${action} must be fixed-width (25px)`)
2723
+ assert.match(tag, /text-align:center/, `${action} must be centred`)
2724
+ }
2725
+
2726
+ // The console's STOP button is the red tone; the others stay neutral.
2727
+ const stopTag = consoleTags.find((tag) => tag.includes('data-action="console-stop"'))
2728
+ const startTag = consoleTags.find((tag) => tag.includes('data-action="console-start"'))
2729
+ assert.match(stopTag, /color:#e5534b/, 'the console stop button must be red')
2730
+ assert.match(stopTag, /border:1px solid color-mix\(in srgb, #e5534b/, 'the red border belongs with it')
2731
+ assert.match(startTag, /color:#3fb950/, 'the console start button must be green')
2732
+ assert.match(startTag, /border:1px solid color-mix\(in srgb, #3fb950/, 'the green border belongs with it')
2733
+
2734
+ // The debug toolbar's own icons get the same treatment.
2735
+ for (const action of ['debug-start', 'debug-refresh']) {
2736
+ const tag = tags.find((candidate) => candidate.includes(`data-action="${action}"`))
2737
+ assert.ok(tag !== undefined, `missing ${action}`)
2738
+ assert.match(tag, /width:22px/, `${action} must be fixed-width`)
2739
+ }
2740
+
2741
+ // The bulk-close actions live in the tab-strip context menu now (not in the bar).
2742
+ assert.equal(tags.some((tag) => /data-action="close-others"|data-action="close-all"/.test(tag)), false)
2743
+ })
2744
+
2745
+ // ── IDE-wide content font size (A−/A+) ─────────────────────────────────────
2746
+
2747
+ /** The workbench's own `<style>` child (React escapes its quotes as &quot;). */
2748
+ function styleChildOf(markup) {
2749
+ const match = /<style>([\s\S]*?)<\/style>/.exec(markup)
2750
+ assert.ok(match !== null, 'the workbench must render its .cm-editor font rule')
2751
+ return match[1]
2752
+ }
2753
+
2754
+ /** Opening tag of the tree list: its size is an author-level 12px block. */
2755
+ function treeTagOf(markup) {
2756
+ const match = /<div[^>]*data-tree-root="[^"]*"[^>]*>/.exec(markup)
2757
+ assert.ok(match !== null, 'the tree list must render')
2758
+ return match[0]
2759
+ }
2760
+
2761
+ /** Opening tag of the workbench root (`data-workbench="ris-ide"`). */
2762
+ function workbenchRootTagOf(markup) {
2763
+ const match = /<div[^>]*data-workbench="ris-ide"[^>]*>/.exec(markup)
2764
+ assert.ok(match !== null, 'the workbench root must render')
2765
+ return match[0]
2766
+ }
2767
+
2768
+ test('the top bar offers A-/A+ for the IDE-wide content font size', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2769
+ const head = headOf(renderWorkbench({}))
2770
+ assert.match(head, /data-action="font-minus"/, 'A- sits in the top bar')
2771
+ assert.match(head, /data-action="font-plus"/, 'A+ sits in the top bar')
2772
+ assert.match(head, /data-action="font-minus"[^>]*title="减小各面板字号(当前 12\.5px)"[^>]*>A-</, 'A- is labelled and shows the current size')
2773
+ assert.match(head, /data-action="font-plus"[^>]*title="增大各面板字号(当前 12\.5px)"[^>]*>A\+</, 'A+ is labelled and shows the current size')
2774
+ })
2775
+
2776
+ test('the default IDE font size is 12.5px in the CodeMirror rule and the tree stays 12px', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2777
+ const markup = renderWorkbench({})
2778
+ assert.match(styleChildOf(markup), /\.cm-editor\{font-size:12\.5px !important\}/)
2779
+ assert.match(styleChildOf(markup), /\.cm-scroller\{line-height:1\.5\}/)
2780
+ assert.match(workbenchRootTagOf(markup), /font-size:12\.5px/, 'the workbench root carries the content size')
2781
+ assert.match(treeTagOf(markup), /font-size:12px/, 'the tree block is authored at 12px and unscaled by default')
2782
+ })
2783
+
2784
+ test('a stored font size scales the IDE blocks through the style proxy', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2785
+ const hadLocalStorage = Object.prototype.hasOwnProperty.call(globalThis, 'localStorage')
2786
+ const previousLocalStorage = globalThis.localStorage
2787
+ const keys = []
2788
+ globalThis.localStorage = {
2789
+ getItem: (key) => {
2790
+ keys.push(key)
2791
+ return key === 'dsh-ris-ide:ui:workspace'
2792
+ ? JSON.stringify({ font: 15, files: [], ui: {}, sizes: {} })
2793
+ : null
2794
+ },
2795
+ setItem: () => {},
2796
+ }
2797
+ try {
2798
+ // Meta-less tab → the workspace key (a program meta would use its own key).
2799
+ const markup = renderWorkbench({ tab: { id: T.PROGRAM_TAB_ID, type: T.PROGRAM_TAB_ID, title: 'workspace' } })
2800
+ assert.ok(keys.includes('dsh-ris-ide:ui:workspace'), `unexpected storage keys ${keys.join(', ')}`)
2801
+ assert.match(workbenchRootTagOf(markup), /font-size:15px/, 'the stored size reaches the workbench root')
2802
+ assert.match(styleChildOf(markup), /\.cm-editor\{font-size:15px !important\}/, 'the stored size reaches CodeMirror')
2803
+ // 12px authored × (15 / 12.5) = 14.4 → nearest 0.5px = 14.5
2804
+ assert.match(treeTagOf(markup), /font-size:14\.5px/, 'author-level blocks scale with the ratio')
2805
+ } finally {
2806
+ if (hadLocalStorage) globalThis.localStorage = previousLocalStorage
2807
+ else globalThis.localStorage = undefined
2808
+ // No module-global ratio to reset any more: scaling is per-tree (stylesFor).
2809
+ }
2810
+ })
2811
+
2812
+ test('clampUiFont keeps the IDE font inside 10-18px in half steps', () => {
2813
+ assert.equal(T.clampUiFont(9), 10)
2814
+ assert.equal(T.clampUiFont(19), 18)
2815
+ assert.equal(T.clampUiFont(12.3), 12.5)
2816
+ assert.equal(T.clampUiFont('abc'), 12.5)
2817
+ assert.equal(T.clampUiFont(13.7), 13.5)
2818
+ assert.equal(T.clampUiFont(12.5), 12.5)
2819
+ })
2820
+
2821
+ test('viewerFontCss pins the embedded sidebar viewer to the current size', () => {
2822
+ const css = T.viewerFontCss(14)
2823
+ assert.equal(typeof css, 'string')
2824
+ assert.match(css, /\.cm-editor\{font-size:14px !important\}/)
2825
+ assert.match(css, /\.cm-scroller\{line-height:1\.5\}/)
2826
+ })
2827
+
2828
+ test('LogTail multiplies its own font size by the IDE-wide scale', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2829
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
2830
+ // Minimal viewer payload, the same shape the existing LogTail SSR tests use.
2831
+ const customData = { ok: true, content: 'BOOT-OK\n', mode: 'tail', size: 9, elided: 0, mtimeMs: 1 }
2832
+ const render = (extra) => renderToStaticMarkup(React.createElement(T.LogTail, Object.assign({
2833
+ scope: { sessionId: 's1', cwd: '/w' },
2834
+ path: '/w/x.log',
2835
+ customData,
2836
+ }, extra)))
2837
+ const viewerTagOf = (markup) => {
2838
+ const match = /<div[^>]*data-dsh-ris-ide="viewer"[^>]*>/.exec(markup)
2839
+ assert.ok(match !== null, 'the viewer root must render')
2840
+ return match[0]
2841
+ }
2842
+
2843
+ // NOTE: `PREF_FONT_SIZE` defaults to the authored 12.5px, but the viewer's own
2844
+ // size state goes through `clampFontSize`, which steps in WHOLE pixels (its own
2845
+ // A−/A+ keeps integer steps), so the effective base is 13px of the 12.5px
2846
+ // authored default. The IDE-wide ratio multiplies THAT base.
2847
+ assert.match(viewerTagOf(render({})), /font-size:13px/, 'no scale = the viewer pref (12.5 → 13 by the integer clamp)')
2848
+ assert.match(viewerTagOf(render({ scale: 1 })), /font-size:13px/, 'scale 1 is the identity (the sidebar viewer passes nothing)')
2849
+ // 13 × 1.2 = 15.6 → clampFontSize → 16px.
2850
+ assert.match(viewerTagOf(render({ scale: 1.2 })), /font-size:16px/, 'the IDE ratio multiplies the viewer pref (13 × 1.2 → 16)')
2851
+ })
2852
+
2853
+ // ── Colour scheme (auto / dark / light) ────────────────────────────────────
2854
+
2855
+ /** Stub the IDE's per-tab localStorage entry; returns a restore function. */
2856
+ function stubWorkspaceUi(patch, keys) {
2857
+ const hadLocalStorage = Object.prototype.hasOwnProperty.call(globalThis, 'localStorage')
2858
+ const previousLocalStorage = globalThis.localStorage
2859
+ globalThis.localStorage = {
2860
+ getItem: (key) => {
2861
+ if (keys !== undefined) keys.push(key)
2862
+ return key === 'dsh-ris-ide:ui:workspace'
2863
+ ? JSON.stringify(Object.assign({ files: [], ui: {}, sizes: {} }, patch))
2864
+ : null
2865
+ },
2866
+ setItem: () => {},
2867
+ }
2868
+ return () => {
2869
+ if (hadLocalStorage) globalThis.localStorage = previousLocalStorage
2870
+ else globalThis.localStorage = undefined
2871
+ }
2872
+ }
2873
+
2874
+ test('the workbench follows the DSH theme and pins no colours of its own', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2875
+ const markup = renderWorkbench({})
2876
+ const root = workbenchRootTagOf(markup)
2877
+ assert.match(root, /data-theme="page"/, 'no theme service in SSR → the page decides')
2878
+ assert.doesNotMatch(root, /background:#ffffff/)
2879
+ assert.doesNotMatch(root, /background:#1b1d23/)
2880
+ assert.doesNotMatch(root, /color-scheme:/, 'the IDE pins nothing')
2881
+ // The embedded editor still states the PAGE-matching surface so its one-*/one-dark
2882
+ // palette never lands on the wrong background.
2883
+ assert.match(styleChildOf(markup), /\.cm-editor\{background:#1b1d23/, 'SSR probes dark → dark surface')
2884
+ })
2885
+
2886
+ test('the appearance button drives DSH, not a private IDE scheme', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2887
+ // No theme service (an older host, or SSR): nothing to drive → no control at all.
2888
+ T.__setPanelCtx(undefined)
2889
+ assert.doesNotMatch(headOf(renderWorkbench({})), /data-action="theme"/, 'without a theme service the control is hidden')
2890
+
2891
+ const calls = []
2892
+ const service = {
2893
+ getTheme: () => ({ preference: 'system', revision: 3 }),
2894
+ setTheme: (id) => { calls.push(id) },
2895
+ }
2896
+ const disposed = []
2897
+ T.__setPanelCtx({
2898
+ get: (name) => (name === 'theme' ? service : undefined),
2899
+ on: (name, listener) => { if (name === 'theme/change') disposed.push(listener); return () => {} },
2900
+ })
2901
+ try {
2902
+ const markup = renderWorkbench({})
2903
+ const head = headOf(markup)
2904
+ assert.match(head, /data-action="theme" data-theme-state="system"/, 'the button mirrors DSH preference')
2905
+ assert.match(head, /data-action="theme"[^>]*>🌓</, 'system uses the follow glyph')
2906
+ assert.match(workbenchRootTagOf(markup), /data-theme="system"/, 'the root reports the DSH preference')
2907
+ } finally {
2908
+ T.__setPanelCtx(undefined)
2909
+ }
2910
+ assert.deepEqual(calls, [], 'rendering alone never mutates the DSH theme')
2911
+ })
2912
+
2913
+
2914
+ test('viewerThemeCss states the surface that matches the page palette', () => {
2915
+ // Light page → light surface + one-light token colours.
2916
+ const light = T.viewerThemeCss(false)
2917
+ assert.match(light, /\.cm-editor\{background:#ffffff !important;color:#1f2328 !important\}/)
2918
+ assert.match(light, /\.tok-keyword\{color:#cf222e/)
2919
+
2920
+ // Dark page → dark surface + one-dark token colours.
2921
+ const dark = T.viewerThemeCss(true)
2922
+ assert.match(dark, /\.cm-editor\{background:#1b1d23 !important;color:#d8dbe0 !important\}/)
2923
+ assert.match(dark, /\.tok-keyword\{color:#569cd6/)
2924
+
2925
+ // No argument → probe the page (SSR has no DOM, so it resolves dark).
2926
+ assert.match(T.viewerThemeCss(), /\.cm-editor\{background:#1b1d23/)
2927
+ })
2928
+
2929
+ test('font scaling is per-tree: the IDE ratio reaches its own title bar, not the plain viewer', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
2930
+ const restore = stubWorkspaceUi({ font: 15 })
2931
+ let markup
2932
+ try {
2933
+ markup = renderWorkbench({ tab: { id: T.PROGRAM_TAB_ID, type: T.PROGRAM_TAB_ID, title: 'workspace' } })
2934
+ } finally {
2935
+ restore()
2936
+ }
2937
+ assert.match(workbenchRootTagOf(markup), /font-size:15px/, 'sanity: the IDE rendered at 15px')
2938
+ // The log pane's TITLE BAR (which owns the viewer chrome since 1.6.1) scales
2939
+ // with the IDE ratio: 11 × 1.2 → 13. A plain viewer never sees that ratio.
2940
+ assert.match(logHeadOf(markup), /font-size:13px/, 'the log title bar follows the IDE font ratio')
2941
+
2942
+ const { renderToStaticMarkup } = require(`${REACT_ROOT}/react-dom/server`)
2943
+ const customData = { ok: true, content: 'BOOT-OK\n', mode: 'tail', size: 9, elided: 0, mtimeMs: 1 }
2944
+ const renderLog = (extra) => renderToStaticMarkup(React.createElement(T.LogTail, Object.assign({
2945
+ scope: { sessionId: 's1', cwd: '/w' },
2946
+ path: '/w/x.log',
2947
+ customData,
2948
+ }, extra)))
2949
+ const rootTagOf = (logMarkup) => {
2950
+ const match = /<div[^>]*data-dsh-ris-ide="viewer"[^>]*>/.exec(logMarkup)
2951
+ assert.ok(match !== null, 'the viewer root must render')
2952
+ return match[0]
2953
+ }
2954
+
2955
+ // No fontRatio — exactly what the sidebar's own `.log` viewer passes. The
2956
+ // 15px IDE ratio was module-global in 1.1.1 and leaked here; it must not now.
2957
+ const plain = renderLog({})
2958
+ assert.match(rootTagOf(plain), /font-size:13px/, 'content keeps its own base (12.5 → 13 by the integer clamp)')
2959
+
2960
+ // Inside the IDE the viewer gets fontRatio: its own content still does not move
2961
+ // (only the explicit scale does).
2962
+ const ratioOnly = renderLog({ fontRatio: 1.2 })
2963
+ assert.match(rootTagOf(ratioOnly), /font-size:13px/, 'content ignores fontRatio — only the explicit scale moves it')
2964
+
2965
+ // ...and the explicit scale still moves the content.
2966
+ const both = renderLog({ fontRatio: 1.2, scale: 1.2 })
2967
+ assert.match(rootTagOf(both), /font-size:16px/, 'content follows the explicit scale too (13 × 1.2 → 16)')
2968
+ })
2969
+
2970
+ test('blank and comment-only Java lines refuse a breakpoint', async () => {
2971
+ const F = T.isNonExecutableJavaLine
2972
+ assert.equal(F(''), true)
2973
+ assert.equal(F(' \t '), true, 'whitespace only')
2974
+ assert.equal(F('// note'), true, 'line comment')
2975
+ assert.equal(F(' /* block */'), true, 'block comment')
2976
+ assert.equal(F(' * javadoc'), true, 'javadoc continuation')
2977
+ assert.equal(F(' */'), true, 'block comment close')
2978
+ assert.equal(F('int x = 1;'), false, 'real code stays breakpointable')
2979
+ assert.equal(F('a * b;'), false, 'a multiplication is executable')
2980
+ assert.equal(F(undefined), false, 'unknown text is not refused')
2981
+
2982
+ const { readFile } = await import('node:fs/promises')
2983
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
2984
+ assert.match(source, /isNonExecutableJavaLine\(texts\[line - 1\]\)/, 'the gutter click consults the guard before adding a dot')
2985
+ })
2986
+
2987
+ test('both gutters take breakpoint gestures: line-number click adds, double-click removes', async () => {
2988
+ const { readFile } = await import('node:fs/promises')
2989
+ const src = await readFile(new URL('../src/chunks/codemirror.js', import.meta.url), 'utf8')
2990
+ const built = await readFile(new URL('../lib/client-codemirror.js', import.meta.url), 'utf8')
2991
+ // The line-number gutter is a full-width click target, not just the narrow dot column.
2992
+ assert.match(src, /lineNumbers\(\{\s*\n\s*domEventHandlers: \{/, 'the line-number gutter carries its own handlers')
2993
+ assert.match(src, /breakpointGesture\(options, 'single'\)/)
2994
+ assert.match(src, /breakpointGesture\(options, 'double'\)/)
2995
+ assert.match(src, /gesture === 'single' && marked/, 'a single click on a dot does nothing')
2996
+ assert.match(src, /gesture === 'double' && !marked/, 'a double-click only removes an existing dot')
2997
+ // Breakpoints arrive in click order; an unsorted RangeSetBuilder add throws and
2998
+ // takes the whole gutter row (line numbers included) off screen.
2999
+ assert.match(src, /\[\.\.\.breakpointLines\]\.sort\(/)
3000
+ // The committed artifact must already be the rebuilt chunk (CI does not build).
3001
+ assert.match(built, /function breakpointGesture/, 'the built chunk ships the gesture helper')
3002
+ assert.match(built, /breakpointGesture\(options, "double"\)/, 'the built chunk ships the double-click removal')
3003
+ assert.match(built, /\[\.\.\.breakpointLines\]\.sort\(/, 'the built chunk sorts marker lines')
3004
+ assert.match(built, /cm-lineNumbers/, 'the line-number gutter is still rendered')
3005
+
3006
+ const client = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3007
+ assert.match(client, /'单击加断点'/, 'the fallback editor teaches the same gesture')
3008
+ assert.match(client, /'双击删除断点'/)
3009
+ assert.match(client, /onClick: function \(\) \{ if \(!hasBp\) props\.onToggleBreakpoint\(line\) \}/)
3010
+ assert.match(client, /onDoubleClick: function \(\) \{ if \(hasBp\) props\.onToggleBreakpoint\(line\) \}/)
3011
+ })
3012
+
3013
+ test('the debug stream frame applies without an ok flag (older hosts omit it)', async () => {
3014
+ const { readFile } = await import('node:fs/promises')
3015
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3016
+ // A reload is served by the SSE debug topic; a frame that is not an explicit
3017
+ // error must reach `merge`, otherwise the pane shows no pre-set breakpoints.
3018
+ assert.match(source, /if \(result === null \|\| result === undefined \|\| result\.ok === false\) return/)
3019
+ const head = source.slice(source.indexOf('async function applyPollResult(result) {'))
3020
+ assert.doesNotMatch(head.slice(0, 400), /ok !== true/, 'requiring ok===true silently dropped stream frames')
3021
+ })
3022
+
3023
+ test('a pre-set (pending) breakpoint toggles OFF from the gutter', async () => {
3024
+ const { readFile } = await import('node:fs/promises')
3025
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3026
+ // Every dot is `pending` until a debug session installs it; excluding pending
3027
+ // rows from `existing` made a double-click ADD instead of remove (dot stayed).
3028
+ const start = source.indexOf('async function toggleBreakpoint(line) {')
3029
+ assert.ok(start > 0, 'toggleBreakpoint is present')
3030
+ const toggler = source.slice(start, start + 6000)
3031
+ assert.match(toggler, /bp\.className === className && bp\.line === line && bp\.failed !== true/)
3032
+ assert.doesNotMatch(toggler, /bp\.pending !== true && bp\.failed !== true/, 'a pending row is removable like any other')
3033
+ assert.match(toggler, /requestId: 'pending'/, 'the pending marker is what the host resolves by class+line')
3034
+ })
3035
+
3036
+ test('a breakpoint suspends ONE thread: suspendedThreads is what "paused" means', () => {
3037
+ const snap = T.debugSnapshotSuspended
3038
+ const thread = T.debugSuspendedThreadId
3039
+ // JDWP breakpoints/steps use SuspendPolicy.EventThread: vmSuspended stays false.
3040
+ assert.equal(snap({ vmSuspended: false, suspendedThreads: ['7'] }), true)
3041
+ assert.equal(snap({ vmSuspended: true, suspendedThreads: [] }), true)
3042
+ assert.equal(snap({ vmSuspended: false, suspendedThreads: [] }), false)
3043
+ assert.equal(snap(null), false)
3044
+ assert.equal(snap(undefined), false)
3045
+ assert.equal(thread({ vmSuspended: false, suspendedThreads: ['5', '7'], currentThreadId: '7' }), '7')
3046
+ assert.equal(thread({ suspendedThreads: ['5', '7'] }), '7', 'newest suspended thread is the fallback')
3047
+ assert.equal(thread({ currentThreadId: 11 }), '11', 'BigInt-safe JSON ids are strings')
3048
+ assert.equal(thread({ suspendedThreads: [] }), undefined)
3049
+ })
3050
+
3051
+ test('a stopped thread drives the pane: buttons, frames, and the 参数 tab', async () => {
3052
+ const { readFile } = await import('node:fs/promises')
3053
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3054
+
3055
+ // 1) The pane must not scroll as a whole: only the section body scrolls.
3056
+ assert.match(source, /debug: h\('div', \{ style: S\.debugSlot, 'data-debug-slot': '1' \}, pane\)/)
3057
+ assert.doesNotMatch(source, /debug: h\('div', \{ style: S\.ideBody \}, pane\)/, 'the debug pane is not wrapped in the shared scroller')
3058
+ assert.match(source, /debugSlot: \{ flex: '1 1 0', minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' \}/)
3059
+ assert.match(source, /debugContent: \{ flex: 1, minHeight: 0, overflowY: 'auto'/)
3060
+
3061
+ // 2) merge keeps the stopped-thread ids and derives "paused" from them.
3062
+ assert.match(source, /suspended: debugSnapshotSuspended\(payload\)/)
3063
+ assert.match(source, /suspendedThreads: Array\.isArray\(payload\.suspendedThreads\)/)
3064
+ assert.match(source, /currentThreadId: payload\.currentThreadId/)
3065
+
3066
+ // 3) Frames come from the stopped thread, not threads[0]; hitting a breakpoint
3067
+ // lands on the 参数 tab (and the editor already follows the frame).
3068
+ assert.match(source, /await loadThreads\(debugSuspendedThreadId\(result\)\)/)
3069
+ assert.match(source, /section: 'variables'/, 'a suspend switches the debug pane to 参数')
3070
+ assert.doesNotMatch(source, /threads\[0\]\.threadId/, 'threads[0] is not necessarily the stopped thread')
3071
+ assert.match(source, /busyRef\.current \|\| target/, 'sanity: poll guard still present')
3072
+
3073
+ // The SSE debug subscription stays mounted across suspends, so its handler must
3074
+ // read a REF: reading `dbg` from the effect closure would always see the state
3075
+ // from subscribe time (no tab switch after the first hit, empty frames).
3076
+ assert.match(source, /var dbgRef = useRef\(dbg\)/)
3077
+ assert.match(source, /var wasSuspended = dbgRef\.current\.suspended === true/)
3078
+ assert.match(source, /var known = dbgRef\.current\.currentThreadId/)
3079
+ })
3080
+
3081
+ test('the debug pane scrolls inside its tab page, not with the whole pane', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, () => {
3082
+ const markup = renderWorkbench({})
3083
+ const at = markup.indexOf('data-debug-slot="1"')
3084
+ assert.ok(at > -1, 'the debug pane has its own slot wrapper')
3085
+ const window = markup.slice(Math.max(0, at - 400), at + 60)
3086
+ assert.ok(window.includes('overflow:hidden'), 'the slot clips; the tab strip must not scroll away')
3087
+ assert.ok(window.includes('flex-direction:column') || window.includes('flexDirection'), 'the slot stacks tab strip + body')
3088
+ assert.ok(markup.indexOf('data-debug-tabs="1"') > -1, 'the section tab strip still renders')
3089
+ })
3090
+
3091
+ test('one IDE window per workspace: the window name is keyed by the workspace cwd', async () => {
3092
+ const { readFile } = await import('node:fs/promises')
3093
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3094
+ assert.equal(T.standaloneWindowName('/w/alpha'), T.standaloneWindowName('/w/alpha/'), 'a trailing slash must reuse the same window')
3095
+ assert.notEqual(T.standaloneWindowName('/w/alpha'), T.standaloneWindowName('/w/beta'), 'different workspaces get their own window')
3096
+ assert.ok(T.standaloneWindowName('/w/alpha').startsWith(T.WINDOW_NAME + '-'))
3097
+ assert.notEqual(T.standaloneWindowKey('/w/alpha'), T.standaloneWindowKey('/w/beta'))
3098
+ // The opener must use the workspace-keyed name, not the fixed one; the URL
3099
+ // carries it as `wname` so /window can claim the very same lock even when the
3100
+ // panel itself is rooted at a program subdirectory.
3101
+ assert.match(source, /var windowCwd = typeof opts\.windowCwd === 'string'/)
3102
+ assert.match(source, /var windowName = standaloneWindowName\(windowCwd !== undefined \? windowCwd : cwd\)/)
3103
+ assert.match(source, /url\.searchParams\.set\('wname', windowName\)/)
3104
+ assert.match(source, /window\.open\(url\.href, windowName, features\)/)
3105
+ })
3106
+
3107
+ test('the standalone window claims a single-instance lock before booting the IDE', async () => {
3108
+ const { readFile } = await import('node:fs/promises')
3109
+ const html = await readFile(new URL('../lib/standalone/window.html', import.meta.url), 'utf8')
3110
+ const from = html.indexOf('id="root"')
3111
+ const to = html.indexOf('window/vendor/react.js')
3112
+ assert.ok(from > 0 && to > from, 'the claim runs before the panel loads')
3113
+ const claim = html.slice(from, to)
3114
+ assert.match(claim, /navigator\.locks/, 'Web Locks is the primary mechanism')
3115
+ assert.match(claim, /locks\.request\(key, \{ ifAvailable: true \}/)
3116
+ assert.match(claim, /BroadcastChannel/, 'fallback channel exists')
3117
+ assert.match(claim, /params\.get\('wname'\)/, 'the opener hands the workspace window name over')
3118
+ assert.match(claim, /var key = windowName/, 'the lock key IS the window name')
3119
+ assert.match(claim, /type: 'focus'/, 'the surviving window is nudged forward')
3120
+ // A reused window (same name, e.g. after a dsh restart) can still see the old
3121
+ // document's lock for a moment: retry before believing there is a real second
3122
+ // window, and NEVER close this one (that made it flash and vanish).
3123
+ assert.match(claim, /if \(attempt < 5\) \{ setTimeout\(tryClaim, 120 \* attempt\)/, 'the claim retries through a navigation race')
3124
+ assert.match(claim, /仍然在这里打开/, 'the user can override the single-window guard')
3125
+ // Focusing another browser window is not reliably allowed, so the duplicate
3126
+ // offers an explicit "close this window" action instead — and only that button
3127
+ // may close it (never an automatic close at boot).
3128
+ assert.match(claim, /关闭这个窗口/, 'the duplicate window can be closed on demand')
3129
+ assert.equal((claim.match(/window\.close\(\)/g) || []).length, 1, 'closing happens only from the button')
3130
+ // Booting waits for the claim, so the notice cannot be raced by a mounted panel.
3131
+ assert.match(html, /window\.__risIdeClaimReady/)
3132
+ assert.match(html, /window\.__risIdeBootPanel = bootPanel/)
3133
+ assert.match(html, /blocked === true\) return/)
3134
+ // The same hash function as client.js ⇒ both compute the same key for a cwd.
3135
+ assert.equal(T.standaloneWindowKey('/w/alpha'), '1ys5bqv', 'pure hash: same on every machine')
3136
+ })
3137
+
3138
+ test('object variables expand on demand through debug/expand, one click at a time', async () => {
3139
+ const { readFile } = await import('node:fs/promises')
3140
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3141
+
3142
+ // The variables tab renders a TREE: rows carry their key and a ▸/▾ toggle, and
3143
+ // a click fetches children lazily (no deep copy of a big model object).
3144
+ assert.match(source, /function variableRows\(items, parentKey, depth\)/)
3145
+ assert.match(source, /'data-var-expand': key/)
3146
+ assert.match(source, /'data-var-key': key/)
3147
+ assert.match(source, /await call\('expand', \{ objectId: item\.objectId \}\)/)
3148
+ assert.match(source, /setVarTree\(function \(prev\)/)
3149
+ // Arrays list their entries as [i] and say when the host capped the list.
3150
+ assert.match(source, /name: '\[' \+ entry\.index \+ '\]'/)
3151
+ assert.match(source, /还有 .* 项(只列前 100)/)
3152
+ // The GET whitelist must include the new read action (`requestDebug` would
3153
+ // otherwise refuse it before reaching the host).
3154
+ assert.match(source, /var DEBUG_READ_ACTIONS = \[[^\]]*'expand'/)
3155
+ })
3156
+
3157
+ test('a frame without source in the workspace is ignored quietly', async () => {
3158
+ const { readFile } = await import('node:fs/promises')
3159
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3160
+
3161
+ // A JDK/dependency frame must not pop a toast (用户要求: 类文件缺失就忽略,
3162
+ // 不要弹错误窗). The debug pane shows one quiet banner instead.
3163
+ assert.doesNotMatch(source, /源码不在工作区/)
3164
+ assert.match(source, /'data-missing-source': '1'/)
3165
+ assert.match(source, /'无源码 · '/)
3166
+ // The miss stays cached so a step storm never re-scans the workspace.
3167
+ assert.match(source, /sourcePathRef\.current\[cleaned\] = found === undefined \? null : found/)
3168
+ })
3169
+
3170
+ test('a debug start, stop or resume re-reads the log pane', async () => {
3171
+ const { readFile } = await import('node:fs/promises')
3172
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3173
+
3174
+ // Debug start/stop restarts the service and a resume un-freezes the JVM: the
3175
+ // log viewer must pull the tail instead of waiting for a frame that may never
3176
+ // come (the workbench passes a nonce down to LogTail).
3177
+ assert.match(source, /var \[logNonce, setLogNonce\] = useState\(0\)/)
3178
+ assert.match(source, /reloadNonce: logNonce/)
3179
+ assert.match(source, /props\.reloadNonce/)
3180
+ assert.match(source, /var reloadSeenRef = useRef\(props\.reloadNonce\)/)
3181
+ assert.match(source, /setLogNonce\(function \(value\) \{ return value \+ 1 \}\)/)
3182
+ const bumps = source.split('setLogNonce(function (value) { return value + 1 })').length - 1
3183
+ assert.ok(bumps >= 3, `start, stop and resume each bump the log nonce (found ${bumps})`)
3184
+ })
3185
+
3186
+ test('a suspended line that is outside the viewport is scrolled into view', async () => {
3187
+ const { readFile } = await import('node:fs/promises')
3188
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3189
+ const chunkSource = await readFile(new URL('../src/chunks/codemirror.js', import.meta.url), 'utf8')
3190
+ const chunk = await readFile(new URL('../lib/client-codemirror.js', import.meta.url), 'utf8')
3191
+
3192
+ // The chunk highlights the line and scrolls ONLY when it is off screen (a step
3193
+ // that stays visible must not move the viewport).
3194
+ assert.match(chunkSource, /function applyDebugLine\(view, line\)/)
3195
+ assert.match(chunkSource, /target >= viewport\.fromLine && target <= viewport\.toLine/)
3196
+ assert.match(chunkSource, /if \(!visible\) effects\.push\(EditorView\.scrollIntoView\(pos, \{ y: 'center' \}\)\)/)
3197
+ assert.match(chunkSource, /applyDebugLine\(view, line\)/)
3198
+ assert.match(chunk, /applyDebugLine/)
3199
+ assert.match(chunk, /EditorView\.scrollIntoView\(pos, \{ y: "center" \}\)/)
3200
+
3201
+ // A parked tab is display:none (zero-height viewport), so the scroll decision
3202
+ // is deferred until it becomes the active tab; the debug effect depends on it.
3203
+ assert.match(source, /if \(props\.visible === false\) return/)
3204
+ assert.match(source, /\[props\.debugLine, props\.debugNonce, props\.visible\]/)
3205
+ })
3206
+
3207
+ test('the variable tree keeps its open rows across a step and only objects get a caret', async () => {
3208
+ const { readFile } = await import('node:fs/promises')
3209
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3210
+
3211
+ // A primitive/string/null row has no children: no ▸, no click, nothing to open.
3212
+ assert.match(source, /var expandable = \(raw\.kind === 'object' \|\| raw\.kind === 'array'\) && typeof raw\.objectId === 'string' && raw\.objectId !== '' && depth < 8/)
3213
+
3214
+ // A step re-reads this frame's variables but keeps `expanded` and the item
3215
+ // behind each open row, so the tree does not collapse under the user.
3216
+ assert.match(source, /setVarTree\(function \(prev\) \{ return \{ expanded: prev\.expanded, children: \{\}, loading: \{\}, items: prev\.items \} \}\)/)
3217
+ assert.match(source, /items: Object\.assign\(\{\}, prev\.items, \{ \[key\]: item \}\)/)
3218
+ // ... and every open node is re-fetched once, so the values follow the new frame.
3219
+ assert.match(source, /function loadVariableChildren\(key, item\)/)
3220
+ assert.match(source, /void loadVariableChildren\(key, item\)/)
3221
+ assert.match(source, /\[dbg\.variables, ui\.frameIndex\]/)
3222
+
3223
+ // A stale JDWP id must be a toast + a cleared loading flag, never a crash.
3224
+ assert.match(source, /try \{\s*result = await call\('expand'/)
3225
+ assert.match(source, /catch \(problem\) \{\s*result = \{ ok: false, error: messageOf\(problem\) \}\s*\}/)
3226
+ assert.match(source, /note\('展开失败:' \+ result\.error\)/)
3227
+ })
3228
+
3229
+ test('a render failure inside the panel shows a retry banner instead of taking dsh down', async () => {
3230
+ const { readFile } = await import('node:fs/promises')
3231
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3232
+
3233
+ // A class boundary is the only hook React gives for render errors; it renders a
3234
+ // banner with the message and a retry button.
3235
+ // CI loads this bundle with a hooks-only React stub (no `React.Component`), so
3236
+ // the class extends a guarded base that is `React.Component` in real hosts.
3237
+ assert.match(source, /var ErrorBoundaryBase = React !== null && React !== undefined && typeof React\.Component === 'function'/)
3238
+ assert.match(source, /class IdeErrorBoundary extends ErrorBoundaryBase/)
3239
+ assert.match(source, /static getDerivedStateFromError\(error\)/)
3240
+ assert.match(source, /'data-ide-crash': '1'/)
3241
+ assert.match(source, /'data-ide-crash-retry': '1'/)
3242
+ assert.equal(typeof T.IdeErrorBoundary, 'function')
3243
+
3244
+ // BOTH mount points are wrapped (the dsh sidebar tab and the standalone window).
3245
+ assert.match(source, /function IdePanelBoundary\(props\) \{ return h\(IdeErrorBoundary, null, h\(ProgramsPanel, props\)\) \}/)
3246
+ assert.match(source, /root\.render\(h\(IdeErrorBoundary, null, h\(ProgramsPanel, \{ sessionId: sessionId, cwd: cwd, visible: true, compact: false \}\)\)\)/)
3247
+ })
3248
+
3249
+ test('a start switches to that program log before waiting for the spawn', async () => {
3250
+ const { readFile } = await import('node:fs/promises')
3251
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3252
+
3253
+ // Clicking start must show this program's log immediately: the host call can
3254
+ // block for START_WAIT_MS, and Maven compile prints nothing for tens of seconds.
3255
+ assert.match(source, /async function consoleStart\(program\) \{\n\s*\/\/[\s\S]*?\n\s*selectProgram\(program\)[\s\S]*?setConsoleBusy\(program\.id\)[\s\S]*?requestStart\(program\.id, scopeRef\.current\)/)
3256
+ // The sidebar rows do the same before awaiting requestStart.
3257
+ assert.match(source, /if \(kind === 'start'\) \{ setActive\(program\); broadcastLogFocus\(program\.logPath\) \}/)
3258
+ // …and every window watching another log is moved by the same broadcast.
3259
+ assert.match(source, /function broadcastLogFocus\(logPath\)/)
3260
+ })
3261
+
3262
+ test('the debug pane has a visual settings dialog for dependency sources', { skip: !reactIsReal ? 'React unavailable for SSR' : false }, async () => {
3263
+ const { readFile } = await import('node:fs/promises')
3264
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3265
+ const markup = renderWorkbench({})
3266
+ assert.match(markup, /data-action="debug-settings"/, 'the debug title bar carries the ⚙ button')
3267
+ assert.match(source, /'data-debug-settings-jar': '1'/)
3268
+ assert.match(source, /'data-debug-settings-args': '1'/)
3269
+ assert.match(source, /'data-debug-settings-save': '1'/)
3270
+ assert.match(source, /fsSaveSettings\(scopeRef\.current, \{ decompilerJar: jar, decompilerArgs: args \}\)/)
3271
+ assert.match(source, /fsDependencySource\(scopeRef\.current, cleaned/, 'a missing workspace source asks the host for the jar source')
3272
+ assert.match(source, /dsh-ris-ide\/debug\/source/)
3273
+ assert.match(source, /-sources\.jar/)
3274
+ assert.match(source, /\.dsh-ris-ide-cache/)
3275
+ })
3276
+
3277
+ test('the IDE console masks a program row while it starts, stops or debugs, and the compact note expires', async () => {
3278
+ const { readFile } = await import('node:fs/promises')
3279
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3280
+ // Per-program busy id: one row is masked, not the whole console.
3281
+ assert.match(source, /var \[consoleBusy, setConsoleBusy\] = useState\(''\)/)
3282
+ assert.match(source, /var busy = consoleBusy === program\.id/)
3283
+ assert.match(source, /setConsoleBusy\(program\.id\)/)
3284
+ assert.match(source, /data-program-loading': program\.id/)
3285
+ // The mask is the same overlay the compact console uses, on a positioned row.
3286
+ assert.match(source, /position: 'relative', overflow: 'hidden' \}\),\n\s*'data-console-on'/)
3287
+ assert.match(source, /PROGRAM_LOADING_CSS/)
3288
+ // A transient top-bar note clears itself after 5 seconds.
3289
+ assert.match(source, /\}, 5000\)\n\s*return function \(\) \{ clearTimeout\(timer\) \}/)
3290
+ })
3291
+
3292
+ test('the console row masks itself while a start/stop is in flight', async () => {
3293
+ const { readFile } = await import('node:fs/promises')
3294
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3295
+ assert.match(source, /'data-program-loading': program\.id/)
3296
+ assert.match(source, /className: 'dsh-ris-ide-loading'/)
3297
+ assert.match(source, /dshRisIdeSpin/)
3298
+ assert.match(source, /alive \? '停止中…' : '启动中…'/)
3299
+ // The mask swallows the click, and the row is the containing block for it.
3300
+ assert.match(source, /onClick: function \(event\) \{ event\.stopPropagation\(\); event\.preventDefault\(\) \}/)
3301
+ assert.match(source, /position: 'relative', overflow: 'hidden'/)
3302
+ // Rows keep their own height so the list scrolls; a shrinkable row used to
3303
+ // squash the whole project list into the panel (no scrollbar at all).
3304
+ assert.match(source, /flex: '0 0 auto', minWidth: 0, cursor: 'pointer', position: 'relative', overflow: 'hidden'/)
3305
+ })
3306
+
3307
+ test('debug start switches the log pane first, and the variable caret is borderless', async () => {
3308
+ const { readFile } = await import('node:fs/promises')
3309
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3310
+ const from = source.indexOf('async function startDebug() {')
3311
+ assert.ok(from > 0)
3312
+ const slice = source.slice(from, from + 1800)
3313
+ // The log view jumps to the program BEFORE the host is asked to restart it
3314
+ // (waiting for the reply made it look like the switch happened ~40s late).
3315
+ const switched = slice.indexOf('setActivePath(logPath)')
3316
+ const started = slice.indexOf("await call('start'")
3317
+ assert.ok(switched > 0 && started > 0 && switched < started, 'switch the log first')
3318
+ assert.match(slice, /setLogNonce\(function \(value\) \{ return value \+ 1 \}\)/)
3319
+ // The switch follows the user into other windows, and the tail is re-read the
3320
+ // moment the restarted service prints its first byte (Maven is quiet).
3321
+ assert.match(slice, /broadcastLogFocus\(logPath\)/)
3322
+ assert.match(slice, /watchLogOutput\(logPath, baseline\)/)
3323
+ // Tree caret: glyph only, no bordered button box.
3324
+ assert.match(source, /var caretStyle = \{[\s\S]{0,400}?border: 'none'/)
3325
+ assert.match(source, /loading \? '…' : \(open \? '▾' : '▸'\)/)
3326
+ // ... and the caret no longer borrows the bordered `S.buttonOff` style.
3327
+ const caretAt = source.indexOf('var caretStyle = {')
3328
+ assert.ok(caretAt > 0)
3329
+ assert.ok(source.slice(caretAt, caretAt + 500).includes("border: 'none'"))
3330
+ // The ⚙ dialog says up front that it is optional, and names the tool jar.
3331
+ assert.match(source, /可选配置:不填也能看依赖类源码/)
3332
+ assert.match(source, /反编译工具 jar(可选)/)
3333
+ })
3334
+
3335
+ test('a fast second F8 cannot resume twice, and a running thread never reports 取栈失败', async () => {
3336
+ const { readFile } = await import('node:fs/promises')
3337
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3338
+ // One control call at a time; a resume flips the UI to "running" immediately.
3339
+ assert.match(source, /if \(controlBusyRef\.current\) return/)
3340
+ assert.match(source, /controlBusyRef\.current = true/)
3341
+ assert.match(source, /if \(action === 'resume'\) \{[\s\S]{0,400}?suspended: false, suspendedThreads: \[\], frames: \[\], variables: \[\]/)
3342
+ // A stack is only read when a thread is actually suspended …
3343
+ assert.match(source, /if \(suspended\) await loadThreads\(debugSuspendedThreadId\(result\)\)/)
3344
+ // … and a failed read for a running thread stays silent instead of toasting.
3345
+ assert.match(source, /if \(dbgRef\.current\.suspended !== true\) return\n\s*return note\('取栈失败:' \+ result\.error\)/)
3346
+ // A hung read cannot leave the editor on 读取中… forever (bounded by a timeout).
3347
+ assert.match(source, /controller\.abort\(\) \}, 15000\)/)
3348
+ // An already-resolved chunk is used on the first render of the next open file
3349
+ // (no "正在加载编辑器…" flash), and dependency resolution says what it is doing.
3350
+ assert.match(source, /var RIS_IDE_CHUNK_VALUES = \{\}/)
3351
+ assert.match(source, /RIS_IDE_CHUNK_VALUES\[name\] = value/)
3352
+ assert.match(source, /useState\(function \(\) \{ return RIS_IDE_CHUNK_VALUES\.codemirror \|\| null \}\)/)
3353
+ assert.match(source, /正在获取依赖源码:' \+ cleaned/)
3354
+ })
3355
+
3356
+ test('the IDE fences off async exceptions instead of letting them blank the page', async () => {
3357
+ const { readFile } = await import('node:fs/promises')
3358
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3359
+ // A plugin handler must never surface as an unhandled rejection: the fence
3360
+ // shows a banner and (in our own window) swallows the event.
3361
+ assert.match(source, /function guardWindowErrors\(ownPage\)/)
3362
+ assert.match(source, /window\.__dshRisIdeErrorFence = true/)
3363
+ assert.match(source, /addEventListener\('unhandledrejection'/)
3364
+ assert.match(source, /addEventListener\('error'/)
3365
+ assert.match(source, /data-ide-error-bar/)
3366
+ assert.match(source, /guardWindowErrors\(true\)/)
3367
+ assert.match(source, /guardWindowErrors\(false\)/)
3368
+ // The debug poll rejects on a stopped program/network loss: it must be a hint.
3369
+ const pollAt = source.indexOf('async function pollOnce(path) {')
3370
+ assert.ok(pollAt > 0)
3371
+ const poll = source.slice(pollAt, pollAt + 1400)
3372
+ assert.match(poll, /catch \(problem\) \{/)
3373
+ assert.match(poll, /调试状态刷新失败/)
3374
+ assert.match(poll, /finally \{\n\s+busyRef\.current = false/)
3375
+ // Streamed debug/session frames are applied defensively too.
3376
+ assert.match(source, /void applyPollResult\(payload\)\.catch\(/)
3377
+ assert.match(source, /void applySessions\(payload\.sessions\)\.catch\(/)
3378
+ })
3379
+
3380
+ test('a breakpoint belongs to the program that owns the file (never the selected log)', async () => {
3381
+ const { readFile } = await import('node:fs/promises')
3382
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3383
+ // While project A was running, adding a breakpoint in project B used the
3384
+ // SELECTED program's debug session, so JDWP answered with a class-not-found
3385
+ // error. The gutter now resolves the owning program from the file path.
3386
+ assert.match(source, /function programForSourcePath\(path\) \{/)
3387
+ assert.match(source, /var dir = item\.dir/)
3388
+ assert.ok(source.includes("path.indexOf(dir + '/') === 0"))
3389
+ assert.match(source, /var owner = programForSourcePath\(openFile\)/)
3390
+ assert.match(source, /var breakPath = owner !== undefined/)
3391
+ assert.match(source, /call\('breakpoints', \{ add: \{ className: className, line: line \} \}, breakPath\)/)
3392
+ assert.match(source, /call\('breakpoints', \{ remove: \{ requestId: existing\.requestId, className: className, line: line \} \}, breakPath\)/)
3393
+ })
3394
+
3395
+ test('following the debugger activates the file it stops in, even when already open', async () => {
3396
+ // Stepping into file B (opened as a tab), then hitting a breakpoint back in A:
3397
+ // A was already open, so openPath() was skipped and the editor stayed on B
3398
+ // while the highlight moved to A. followDebugLocation must activate the tab.
3399
+ const { readFile } = await import('node:fs/promises')
3400
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3401
+ const at = source.indexOf('async function followDebugLocation(frame)')
3402
+ assert.ok(at !== -1)
3403
+ const body = source.slice(at, at + 3000)
3404
+ assert.match(body, /var alreadyOpen = openFiles\.indexOf\(path\) !== -1/)
3405
+ assert.match(body, /if \(alreadyOpen && openFile !== path\) setOpenFile\(path\)/)
3406
+ })
3407
+
3408
+ test('a file the debugger opened on its own closes when the debug line leaves it', async () => {
3409
+ // Debug-owned tabs should not pile up while stepping: when the debug location
3410
+ // moves to another file, the previous debug-opened tab is closed — unless the
3411
+ // user edited it (dirty tabs are never thrown away) or opened it deliberately.
3412
+ const { readFile } = await import('node:fs/promises')
3413
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3414
+ assert.ok(source.includes('var debugOpenedRef = useRef([])'))
3415
+ assert.ok(source.includes('var debugOpeningRef = useRef(false)'))
3416
+ const at = source.indexOf('function closeDebugOpenedExcept(currentPath)')
3417
+ assert.ok(at !== -1, 'the helper must exist')
3418
+ const body = source.slice(at, at + 1200)
3419
+ assert.match(body, /isDirty\(item\) === true/)
3420
+ assert.match(body, /setOpenFiles\(function \(previous\) \{/)
3421
+ assert.match(body, /victims\.forEach\(function \(item\) \{ forgetDoc\(item\) \}\)/)
3422
+ // followDebugLocation records ownership and closes the previous debug tab.
3423
+ const follow = source.indexOf('async function followDebugLocation(frame)')
3424
+ const followBody = source.slice(follow, follow + 3000)
3425
+ assert.match(followBody, /if \(debugOpenedRef\.current\.indexOf\(path\) === -1\) debugOpenedRef\.current = debugOpenedRef\.current\.concat\(\[path\]\)/)
3426
+ assert.match(followBody, /closeDebugOpenedExcept\(path\)/)
3427
+ assert.match(followBody, /debugOpeningRef\.current = true/)
3428
+ })
3429
+
3430
+ test('every change of the suspended top frame moves the editor there', async () => {
3431
+ const { readFile } = await import('node:fs/promises')
3432
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3433
+ // A dedicated effect watches dbg.frames/top frame, so a stop that arrives through
3434
+ // ANY path (SSE event, poll, manual stack read, program auto-switch) is followed:
3435
+ // switch file, scroll the line into view, highlight it.
3436
+ assert.ok(source.includes("var followedFrameRef = useRef('')"))
3437
+ assert.ok(source.includes("followedFrameRef.current = String(frame.className || '') + ':' + line"))
3438
+ assert.ok(source.includes('}, [dbg.frames, dbg.suspended])'))
3439
+ assert.ok(source.includes('if (dbg.suspended !== true) {'))
3440
+ assert.ok(source.includes('followedFrameRef.current = \'\''))
3441
+ assert.ok(source.includes('void followDebugLocation(top)'))
3442
+ })
3443
+
3444
+ test('progress goes to the status bar; toasts stay for things the user must know', async () => {
3445
+ const { readFile } = await import('node:fs/promises')
3446
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3447
+ // The static bottom help text moved into the ? panel.
3448
+ assert.ok(!source.includes('Ctrl+S 保存 · Ctrl+W 关页签 · 拖分隔条调大小 · 双击标题最大化'))
3449
+ assert.ok(!source.includes("' · F5 步入 · F6 单步 · F7 步出 · F8 继续'"))
3450
+ // A status channel exists and the dependency-source progress uses it (no toast).
3451
+ assert.ok(source.includes("var [statusText, setStatusText] = useState('')"))
3452
+ assert.ok(source.includes("statusNote('正在获取依赖源码:' + cleaned + '…', 0)"))
3453
+ assert.ok(source.includes("statusNote('正在启动 ' + program.name + '…', 0)"))
3454
+ assert.ok(!source.includes("note('正在获取依赖源码:"))
3455
+ assert.ok(source.includes("'data-ide-status-text': statusText === '' ? '0' : '1'"))
3456
+ })
3457
+
3458
+ test('while a debug session is attached, F5 cannot reload the window', async () => {
3459
+ const { readFile } = await import('node:fs/promises')
3460
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3461
+ // The state makes the key meaningless, not the handler: F5/F6/F7 still get
3462
+ // claimed (and swallowed) while the VM runs.
3463
+ assert.match(source, /if \(state\.suspended !== true\) return \{ kind: 'swallow' \}/)
3464
+ assert.match(source, /if \(action\.kind === 'swallow'\) return/)
3465
+ })
3466
+
3467
+ test('only the standalone IDE window claims browser shortcuts; a DSH page keeps them', async () => {
3468
+ const { readFile } = await import('node:fs/promises')
3469
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3470
+ // The DSH page marks itself as NOT the IDE window; the standalone window marks itself.
3471
+ assert.ok(source.includes("if (typeof window !== 'undefined') window.__dshRisIdeWindow = false"))
3472
+ assert.ok(source.includes("if (typeof window !== 'undefined') window.__dshRisIdeWindow = true"))
3473
+ assert.ok(source.includes('function inStandaloneIdeWindow()'))
3474
+ // Ownership is the standalone window, FULL STOP: a DSH page never claims keys.
3475
+ assert.match(source, /function ownsKeyTarget\(target\) \{[\s\S]{0,500}?return inStandaloneIdeWindow\(\)/)
3476
+ assert.ok(!source.includes('if (inStandaloneIdeWindow()) return true'), 'no page-vs-panel split any more')
3477
+ // Ctrl+W, the find chord and the debug keys still check ownership first.
3478
+ const guards = source.split('if (!ownsKeyTarget(event.target)) return').length - 1
3479
+ assert.equal(guards, 3, 'Ctrl+W, the find chord and debug keys all bail out on foreign targets')
3480
+ // The panel net only works in the IDE window and only claims the reload chord:
3481
+ // the host's other chords (Ctrl+N/O/K/...) are released everywhere.
3482
+ assert.match(source, /if \(!inStandaloneIdeWindow\(\)\) return\n\s*if \(isBrowserReloadChord\(event\)\)/)
3483
+ assert.ok(!source.includes('shouldBlockHostChord({ key: event.key'), 'DSH chords are released; the helper stays for its own unit test')
3484
+ const previous = Object.prototype.hasOwnProperty.call(globalThis, 'window') ? globalThis.window : undefined
3485
+ try {
3486
+ globalThis.window = { __dshRisIdeWindow: true }
3487
+ assert.equal(T.inStandaloneIdeWindow(), true)
3488
+ globalThis.window = { __dshRisIdeWindow: false }
3489
+ assert.equal(T.inStandaloneIdeWindow(), false)
3490
+ } finally {
3491
+ if (previous === undefined) delete globalThis.window
3492
+ else globalThis.window = previous
3493
+ }
3494
+ })
3495
+
3496
+ test('Ctrl+F searches what the editor, log or fallback editor has highlighted', async () => {
3497
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3498
+ const chunkSource = await readFile(new URL('../src/chunks/codemirror.js', import.meta.url), 'utf8')
3499
+ const chunk = await readFile(new URL('../lib/client-codemirror.js', import.meta.url), 'utf8')
3500
+
3501
+ // CodeMirror path: the highlighted text becomes the panel query before it opens.
3502
+ assert.match(chunkSource, /function searchSelectedText\(view\)/)
3503
+ assert.match(chunkSource, /setSearchQuery\.of\(new SearchQuery\(\{ search: picked, literal: true \}\)\)/)
3504
+ assert.match(chunk, /setSearchQuery\.of\(new SearchQuery\(\{/)
3505
+ assert.match(chunk, /literal: true/)
3506
+ assert.match(chunk, /search: picked/)
3507
+ const seeded = chunk.split('searchSelectedText(instance);').length - 1
3508
+ assert.ok(seeded >= 2, 'both the menu item and the Mod-f keymap seed the query from the selection')
3509
+
3510
+ // Log viewer + plain-text fallback editor: their own find bars get the highlight.
3511
+ assert.match(source, /function selectedLineTextIn\(element\)/)
3512
+ assert.match(source, /var highlighted = selectedLineTextIn\(event\.currentTarget\)/)
3513
+ assert.match(source, /var highlighted = firstSelectedLine\(text\.slice\(findArea\.selectionStart, findArea\.selectionEnd\)\)/)
3514
+ // The workspace file search stays out of it: Ctrl+F never writes the tree query.
3515
+ const logStart = source.indexOf("'data-dsh-ris-ide': 'viewer'")
3516
+ assert.ok(logStart !== -1)
3517
+ const logHandler = source.slice(logStart, logStart + 1400)
3518
+ assert.doesNotMatch(logHandler, /setTreeQuery/)
3519
+ })
3520
+
3521
+ test('an oversized text file opens a read-only windowed preview', async () => {
3522
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
3523
+ assert.match(source, /function fsWindowRead\(scope, path, offset, length\)/)
3524
+ assert.match(source, /dsh-ris-ide\/fs\/window/)
3525
+ assert.match(source, /var LARGE_TEXT_WINDOW_BYTES = 256 \* 1024/)
3526
+ assert.match(source, /var LARGE_TEXT_MAX_BYTES = 16 \* 1024 \* 1024/)
3527
+ assert.match(source, /async function loadLargeTextPage\(path\)/)
3528
+ assert.match(source, /function LargeTextPreview\(props\)/)
3529
+ assert.match(source, /只读预览 · 大文件/)
3530
+ assert.match(source, /onLoadMore: function \(\) \{ void loadLargeTextPage\(path\) \}/)
3531
+ // Oversized reads no longer fall into the empty "文件过大" error state.
3532
+ assert.doesNotMatch(source, /code === 'binary' \|\| code === 'too-large'/)
3533
+ // The preview mounts no editor: there is nothing to edit or save.
3534
+ const branchStart = source.indexOf("} else if (doc.kind === 'large-text') {")
3535
+ assert.ok(branchStart !== -1)
3536
+ const branch = source.slice(branchStart, source.indexOf("} else if (doc.kind === 'error') {", branchStart))
3537
+ assert.doesNotMatch(branch, /IdeTextEditor/)
3538
+ // Above the cap the FRONT is freed and the reader keeps going.
3539
+ assert.match(source, /function trimLargeTextFront\(text, maxBytes\)/)
3540
+ assert.match(source, /var trimmed = trimLargeTextFront\(loaded, LARGE_TEXT_MAX_BYTES\)/)
3541
+ assert.match(source, /dropLines: \(typeof current\.dropLines === 'number' \? current\.dropLines : 0\) \+ trimmed\.droppedLines/)
3542
+ assert.match(source, /function reloadLargeTextFromStart\(path\)/)
3543
+ assert.match(source, /'data-large-text-restart': '1'/)
3544
+ assert.match(source, /已释放前面 ' \+ dropLines \+ ' 行/)
3545
+ })
3546
+
3547
+ test('trimLargeTextFront frees whole lines and keeps the line count aligned', () => {
3548
+ const text = Array.from({ length: 200 }, (_, index) => 'line ' + index).join('\n')
3549
+ const totalLines = text.split('\n').length
3550
+ const trimmed = T.trimLargeTextFront(text, 100)
3551
+ assert.ok(trimmed.text.length <= 100, 'kept tail stays under the cap')
3552
+ assert.ok(trimmed.droppedLines > 0, 'something was freed')
3553
+ assert.ok(trimmed.text.startsWith('line '), 'the tail starts at a line boundary, not mid-line')
3554
+ assert.equal(trimmed.text.split('\n').length + trimmed.droppedLines, totalLines, 'freed lines + kept lines cover the file')
3555
+ assert.deepEqual(T.trimLargeTextFront('short', 100), { text: 'short', droppedLines: 0, droppedChars: 0 })
3556
+ assert.deepEqual(T.trimLargeTextFront('', 100), { text: '', droppedLines: 0, droppedChars: 0 })
3557
+ })