dsh-delete-session 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1844 @@
1
+ // Regression tests for the multi-select checkbox, plus the integration contract
2
+ // that the slot migration introduced.
3
+ //
4
+ // The shipped defect (fixed in v0.1.8, "only plant multi-select checkboxes
5
+ // inside multi-select"): a plain single delete calls scheduleDecorate(), and
6
+ // decorateRows() planted a `.dsdel-check` on every session row *unconditionally*.
7
+ // So after deleting one session - or after any decorate pass at all - every row
8
+ // grew a round checkbox while the batch bar stayed hidden. The user was left in a
9
+ // half-on multi-select: rows looked selectable, clicking them did nothing the
10
+ // mode explained, and nothing cleared it because `selecting` was false.
11
+ //
12
+ // The invariant under test is the one the fix encodes:
13
+ //
14
+ // a `.dsdel-check` exists <=> multi-select mode is active.
15
+ //
16
+ // The static checker cannot see this: it is a state machine over DOM mutations.
17
+ // Driving the real bundle is the only way to catch a re-introduction, so this
18
+ // file loads src/client.js and reaches the plugin only through the surfaces a
19
+ // user touches - the slots it registers into, the menu rows and dialog those
20
+ // components render, and the DOM it builds. No browser, no DSH server, no tokens.
21
+ //
22
+ // node --test "test/*.test.js"
23
+ import assert from 'node:assert/strict'
24
+ import { test } from 'node:test'
25
+
26
+ // --- a DOM small enough to read, complete enough to run the plugin ----------
27
+
28
+ class StubElement {
29
+ constructor(tag) {
30
+ this.tagName = String(tag).toUpperCase()
31
+ this.children = []
32
+ this.parentElement = null
33
+ this.style = {}
34
+ this.attributes = {}
35
+ this.listeners = new Map()
36
+ this._classes = new Set()
37
+ // `dataset.x` and `data-x` are the same thing in the DOM, and the plugin
38
+ // relies on that: it marks its own <style> through dataset and looks it up
39
+ // later with an attribute selector. Two detached objects would hide it.
40
+ this.dataset = new Proxy(
41
+ {},
42
+ {
43
+ set: (target, key, value) => {
44
+ target[key] = value
45
+ this.attributes[`data-${String(key).replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`] = String(value)
46
+ return true
47
+ },
48
+ },
49
+ )
50
+ this.type = ''
51
+ this.disabled = false
52
+ this.checked = false
53
+ this._text = ''
54
+ this.innerHTML = ''
55
+ }
56
+
57
+ get classList() {
58
+ const set = this._classes
59
+ return {
60
+ add: (...names) => names.forEach((name) => set.add(name)),
61
+ remove: (...names) => names.forEach((name) => set.delete(name)),
62
+ // `toggle(name, force)` is how the checkbox reflects selection; a stub that
63
+ // ignored the force argument would hide exactly the bug under test.
64
+ toggle: (name, force) => {
65
+ const on = force === undefined ? !set.has(name) : Boolean(force)
66
+ if (on) set.add(name)
67
+ else set.delete(name)
68
+ return on
69
+ },
70
+ contains: (name) => set.has(name),
71
+ }
72
+ }
73
+
74
+ get className() {
75
+ return [...this._classes].join(' ')
76
+ }
77
+
78
+ set className(value) {
79
+ this._classes = new Set(String(value).split(/\s+/).filter(Boolean))
80
+ }
81
+
82
+ get firstChild() {
83
+ return this.children[0] ?? null
84
+ }
85
+
86
+ get firstElementChild() {
87
+ return this.children[0] ?? null
88
+ }
89
+
90
+ get textContent() {
91
+ return this._text
92
+ }
93
+
94
+ set textContent(value) {
95
+ this._text = String(value)
96
+ this.children = []
97
+ }
98
+
99
+ appendChild(child) {
100
+ child.parentElement = this
101
+ this.children.push(child)
102
+ return child
103
+ }
104
+
105
+ insertBefore(child, reference) {
106
+ child.parentElement = this
107
+ const index = reference === null || reference === undefined ? this.children.length : this.children.indexOf(reference)
108
+ if (index === -1) this.children.push(child)
109
+ else this.children.splice(index, 0, child)
110
+ return child
111
+ }
112
+
113
+ remove() {
114
+ if (this.parentElement === null) return
115
+ const index = this.parentElement.children.indexOf(this)
116
+ if (index !== -1) this.parentElement.children.splice(index, 1)
117
+ this.parentElement = null
118
+ }
119
+
120
+ getAttribute(name) {
121
+ return name in this.attributes ? this.attributes[name] : null
122
+ }
123
+
124
+ setAttribute(name, value) {
125
+ this.attributes[name] = String(value)
126
+ }
127
+
128
+ addEventListener(type, handler) {
129
+ const list = this.listeners.get(type) ?? []
130
+ list.push(handler)
131
+ this.listeners.set(type, list)
132
+ }
133
+
134
+ removeEventListener() {}
135
+
136
+ /** Dispatch the listeners registered for `type`. */
137
+ fire(type, extra) {
138
+ const event = {
139
+ type,
140
+ target: this,
141
+ preventDefault() {},
142
+ stopPropagation() {},
143
+ ...(extra ?? {}),
144
+ }
145
+ for (const handler of this.listeners.get(type) ?? []) handler(event)
146
+ return event
147
+ }
148
+
149
+ querySelector(selector) {
150
+ return walk(this).filter((node) => matches(node, selector))[0] ?? null
151
+ }
152
+
153
+ querySelectorAll(selector) {
154
+ return walk(this).filter((node) => matches(node, selector))
155
+ }
156
+
157
+ closest(selector) {
158
+ let node = this
159
+ while (node !== null && node !== undefined) {
160
+ if (matches(node, selector)) return node
161
+ node = node.parentElement
162
+ }
163
+ return null
164
+ }
165
+ }
166
+
167
+ /** Pre-order traversal: document order, so `[0]` is the first in the tree. */
168
+ function walk(root) {
169
+ const out = []
170
+ const visit = (node) => {
171
+ for (const child of node.children) {
172
+ out.push(child)
173
+ visit(child)
174
+ }
175
+ }
176
+ visit(root)
177
+ return out
178
+ }
179
+
180
+ function matches(node, selector) {
181
+ // `:scope > .dsdel-check` - a DIRECT child. The plugin uses this to tell its
182
+ // own row checkbox apart from anything nested, so depth has to be respected.
183
+ const scoped = /^:scope > \.([\w-]+)$/.exec(selector)
184
+ if (scoped !== null) return node._classes.has(scoped[1])
185
+
186
+ // `.dsdel-bar-btn:not(.dsdel-bar-danger)` - how the bar finds its Cancel.
187
+ const negated = /^\.([\w-]+):not\(\.([\w-]+)\)$/.exec(selector)
188
+ if (negated !== null) return node._classes.has(negated[1]) && !node._classes.has(negated[2])
189
+
190
+ if (selector.startsWith('.') && !selector.includes(':') && !selector.includes('[')) {
191
+ return node._classes.has(selector.slice(1))
192
+ }
193
+
194
+ const attrEquals = /^\[([\w-]+)="([^"]*)"\]$/.exec(selector)
195
+ if (attrEquals !== null) return node.attributes[attrEquals[1]] === attrEquals[2]
196
+
197
+ const attrOnly = /^\[([\w-]+)\]$/.exec(selector)
198
+ if (attrOnly !== null) return attrOnly[1] in node.attributes
199
+
200
+ return false
201
+ }
202
+
203
+ // --- harness ----------------------------------------------------------------
204
+
205
+ const NS = 'dsh-delete-session'
206
+ const SKIP_KEY = 'dsh-delete-session:skip-confirm'
207
+ const MENU_SLOT = 'sidebar.workspaces.session.menu.item'
208
+
209
+ const SESSION_A = { id: 'session-aaaa1111-2222-4333-8444-555555555555', title: 'first session' }
210
+ const SESSION_B = { id: 'session-bbbb1111-2222-4333-8444-555555555555', title: 'second session' }
211
+
212
+ let bundleFactory = null
213
+
214
+ /**
215
+ * The bundle registers through `window.__ModuleLoader__` once per process and ESM
216
+ * caching means a second `import()` never re-runs it. Caching the factory is also
217
+ * what gives each test its own instance: calling it builds fresh closures over
218
+ * whatever stubs are installed now.
219
+ */
220
+ async function loadPlugin(options = {}) {
221
+ const requests = []
222
+ const rows = []
223
+ const observers = []
224
+
225
+ // Document-level listeners matter: multi-select installs a CAPTURE-phase click
226
+ // handler on `document` to turn a row click into a toggle. A stub that dropped
227
+ // it would make the mode look inert and the toggle untestable.
228
+ const documentListeners = new Map()
229
+ const document = {
230
+ body: new StubElement('body'),
231
+ head: new StubElement('head'),
232
+ createElement: (tag) => new StubElement(tag),
233
+ addEventListener(type, handler) {
234
+ const list = documentListeners.get(type) ?? []
235
+ list.push(handler)
236
+ documentListeners.set(type, list)
237
+ },
238
+ removeEventListener(type, handler) {
239
+ const list = documentListeners.get(type)
240
+ if (list === undefined) return
241
+ const index = list.indexOf(handler)
242
+ if (index !== -1) list.splice(index, 1)
243
+ },
244
+ dispatchEvent() {},
245
+ /** Fire the document-level listeners, as a real bubbling click would. */
246
+ dispatchToDocument(type, event) {
247
+ for (const handler of [...(documentListeners.get(type) ?? [])]) handler(event)
248
+ },
249
+ querySelector: (selector) => document.querySelectorAll(selector)[0] ?? null,
250
+ querySelectorAll(selector) {
251
+ if (selector === '[role="treeitem"]') {
252
+ return walk(document.body).filter((node) => node.getAttribute('role') === 'treeitem')
253
+ }
254
+ if (selector === 'style[data-dsh-delete-session]') {
255
+ return walk(document.head).filter((node) => 'data-dsh-delete-session' in node.attributes)
256
+ }
257
+ // Everything else is a class sweep (`.dsdel-check`, `.dsdel-row-pending`).
258
+ return walk(document.body).filter((node) => matches(node, selector))
259
+ },
260
+ }
261
+
262
+ globalThis.document = document
263
+ globalThis.window = {
264
+ __ModuleLoader__: { load: ({ factory }) => { bundleFactory = factory } },
265
+ location: { reload() {} },
266
+ dispatchEvent() {},
267
+ Event: class { constructor(type) { this.type = type } },
268
+ }
269
+ globalThis.localStorage = makeStorage()
270
+ // Node exposes `navigator` as a getter-only global, so it needs defining rather
271
+ // than assigning. The plugin reads it only to pick zh vs en.
272
+ Object.defineProperty(globalThis, 'navigator', {
273
+ value: { language: 'zh-CN', languages: ['zh-CN'] },
274
+ configurable: true,
275
+ writable: true,
276
+ })
277
+ globalThis.MutationObserver = class {
278
+ constructor(callback) { this.callback = callback; observers.push(this) }
279
+ observe() {}
280
+ disconnect() {}
281
+ }
282
+ // The plugin coalesces decorate passes through rAF. Run the callback on a real
283
+ // microtask-free tick rather than synchronously: `scheduleDecorate` assigns the
284
+ // return value to `decorateRaf` AFTER calling requestAnimationFrame, so a
285
+ // synchronous stub would leave `decorateRaf` permanently truthy and silently
286
+ // swallow every later pass. That is a harness artefact, and it would hide the
287
+ // very regression this file exists to catch.
288
+ let rafId = 0
289
+ const rafQueue = new Map()
290
+ globalThis.requestAnimationFrame = (fn) => {
291
+ const id = ++rafId
292
+ rafQueue.set(id, fn)
293
+ queueMicrotask(() => {
294
+ if (!rafQueue.has(id)) return
295
+ rafQueue.delete(id)
296
+ fn()
297
+ })
298
+ return id
299
+ }
300
+ globalThis.cancelAnimationFrame = (id) => { rafQueue.delete(id) }
301
+ globalThis.fetch = async (url, init) => {
302
+ const href = String(url)
303
+ requests.push({ url: href, init })
304
+ // The artifact scan answers with whatever plan the test supplied; every
305
+ // other route answers with a bare success.
306
+ if (href.endsWith('/dsh-delete-session/artifacts')) {
307
+ const plan = options.plan ?? { created: [], modified: [], refused: [], truncated: false, warnings: [] }
308
+ return { ok: true, status: 200, json: async () => ({ ok: true, ...plan }) }
309
+ }
310
+ // The folder picker asks for one directory listing per navigation.
311
+ if (href.endsWith('/dsh-delete-session/browse')) {
312
+ const asked = JSON.parse((init && init.body) || '{}')
313
+ const fallback = { path: 'C:/ws', parent: 'C:/', entries: [], roots: [], truncated: false, defaultRoot: 'C:/home' }
314
+ const listing = typeof options.browse === 'function' ? options.browse(asked.path) : (options.browse ?? fallback)
315
+ return { ok: true, status: 200, json: async () => ({ ok: true, ...listing }) }
316
+ }
317
+ // The native dialog route. `unsupported` models a platform without one, which
318
+ // is what makes the client fall back to the in-page picker.
319
+ if (href.endsWith('/dsh-delete-session/pick')) {
320
+ const pick = options.pick ?? { cancelled: true, path: null }
321
+ if (pick.unsupported === true) {
322
+ return { ok: false, status: 501, json: async () => ({ ok: false, code: 'unsupported', error: '系统文件夹对话框只支持 Windows' }) }
323
+ }
324
+ return { ok: true, status: 200, json: async () => ({ ok: true, ...pick }) }
325
+ }
326
+ return { ok: true, status: 200, json: async () => ({ ok: true }) }
327
+ }
328
+
329
+ // Every React surface is now a slot occupant: two session-menu rows, the
330
+ // confirmation dialog in shell.overlay, and the plugin page card. Capture each
331
+ // registration so a test can render it the way the host would.
332
+ const slotRegistrations = []
333
+ const slotsStub = {
334
+ inject: (name, fn) => { fn() },
335
+ register: (spec, Component) => { slotRegistrations.push({ spec, Component }); return () => {} },
336
+ }
337
+
338
+ // The plugin registers its locale namespace; capturing it gives the tests the
339
+ // real strings and proves the registration happened.
340
+ const locales = {}
341
+ const localeStub = {
342
+ register: (namespace, dictionaries) => { locales[namespace] = dictionaries },
343
+ // Mirrors dsh-client-locale: a non-string entry is handed back untouched, and
344
+ // only string templates interpolate `{name}` from a params object. Returning
345
+ // a function as-is is exactly what makes the plugin fall back to its own
346
+ // dictionary, which is how parameterised strings keep their arguments.
347
+ translate: (namespace, key, params) => {
348
+ const value = locales[namespace]?.zh?.[key]
349
+ if (value === undefined) return key
350
+ if (typeof value !== 'string') return value
351
+ if (!params) return value
352
+ return value.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
353
+ },
354
+ }
355
+
356
+ // `useSyncExternalStore` is modelled as "render once, re-read on the next call",
357
+ // which is exactly how these tests drive a component: call it again after an
358
+ // action and assert on the tree it returns.
359
+ const reactStub = {
360
+ Fragment: Symbol.for('react.fragment'),
361
+ useState: (initial) => [initial, () => {}],
362
+ useEffect: (effect) => effect(),
363
+ useSyncExternalStore: (subscribe, getSnapshot) => getSnapshot(),
364
+ // React semantics: positional children win, but with none a `children` given
365
+ // inside the props object is kept as-is (the plugin passes the Modal's
366
+ // children that way, so a stub that overwrote it would hide the whole dialog).
367
+ createElement: (type, props, ...children) => ({
368
+ type,
369
+ props: children.length === 0
370
+ ? { ...(props || {}) }
371
+ : { ...(props || {}), children: children.length === 1 ? children[0] : children },
372
+ }),
373
+ }
374
+ const primitivesStub = {
375
+ Button: (props) => ({ type: 'Button', props }),
376
+ Checkbox: (props) => ({ type: 'Checkbox', props }),
377
+ MenuItemButton: (props) => ({ type: 'MenuItemButton', props }),
378
+ Modal: (props) => ({ type: 'Modal', props }),
379
+ }
380
+ const requireStub = (id) => {
381
+ if (id === 'react') return reactStub
382
+ if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitivesStub
383
+ throw new Error('unexpected client require: ' + id)
384
+ }
385
+
386
+ if (bundleFactory === null) await import(new URL('../src/client.js', import.meta.url).href)
387
+ const plugin = bundleFactory(requireStub)
388
+ plugin.apply({
389
+ get: (name) => {
390
+ if (name === 'slots') return slotsStub
391
+ // `lateLocale` models a host where the locale service is only reachable
392
+ // through ctx.inject, which is the path that used to skip registration.
393
+ if (name === 'locale') return options.lateLocale ? null : localeStub
394
+ return null
395
+ },
396
+ inject: (names, cb) => {
397
+ if (typeof cb === 'function') cb(options.lateLocale ? { slots: slotsStub, locale: localeStub } : { slots: slotsStub })
398
+ },
399
+ effect: (fn) => { const dispose = fn(); return () => { if (typeof dispose === 'function') dispose() } },
400
+ })
401
+
402
+ const t = (key, a, b) => {
403
+ const value = locales[NS]?.zh?.[key]
404
+ return typeof value === 'function' ? value(a, b) : value ?? key
405
+ }
406
+
407
+ return { document, requests, rows, observers, slotRegistrations, locales, primitives: primitivesStub, t }
408
+ }
409
+
410
+ function makeStorage() {
411
+ const map = new Map()
412
+ return {
413
+ getItem: (key) => (map.has(key) ? map.get(key) : null),
414
+ setItem: (key, value) => { map.set(key, String(value)) },
415
+ removeItem: (key) => { map.delete(key) },
416
+ clear: () => map.clear(),
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Attach a row carrying the host's own drag-and-drop key. Row identity now comes
422
+ * from `data-row-key`, not from a React fiber; without this the plugin finds no
423
+ * id and skips the row, which would make every assertion below pass vacuously.
424
+ */
425
+ function mountRow(harness, session = SESSION_A) {
426
+ const row = harness.document.createElement('div')
427
+ row.className = 'session_row'
428
+ row.setAttribute('role', 'treeitem')
429
+ row.setAttribute('data-row-key', `session:${session.id}`)
430
+ harness.document.body.appendChild(row)
431
+ harness.rows.push(row)
432
+ return row
433
+ }
434
+
435
+ // --- driving the registered slots -------------------------------------------
436
+
437
+ const menuEntry = (harness, id) =>
438
+ harness.slotRegistrations.find((entry) => entry.spec.name === MENU_SLOT && entry.spec.id === id) ?? null
439
+
440
+ const overlayEntry = (harness) =>
441
+ harness.slotRegistrations.find((entry) => entry.spec.name === 'shell.overlay') ?? null
442
+
443
+ /** Render the "Delete session" row the way the host renders a menu entry. */
444
+ function deleteRow(harness, session = SESSION_A) {
445
+ const entry = menuEntry(harness, NS)
446
+ assert.ok(entry, 'the delete row is registered in the session menu slot')
447
+ const menuOpen = []
448
+ const element = entry.Component({
449
+ sessionId: session.id,
450
+ displayTitle: session.title,
451
+ useMenuOpenState: () => [false, (value) => menuOpen.push(value)],
452
+ t: harness.t,
453
+ })
454
+ return { element, menuOpen }
455
+ }
456
+
457
+ /** Render the "Select multiple" row. */
458
+ function selectRow(harness) {
459
+ const entry = menuEntry(harness, `${NS}-multi`)
460
+ assert.ok(entry, 'the multi-select row is registered in the session menu slot')
461
+ const menuOpen = []
462
+ const element = entry.Component({
463
+ useMenuOpenState: () => [false, (value) => menuOpen.push(value)],
464
+ t: harness.t,
465
+ })
466
+ return { element, menuOpen }
467
+ }
468
+
469
+ /** The element tree the dialog slot occupant renders, or null while closed. */
470
+ function dialogElement(harness) {
471
+ const entry = overlayEntry(harness)
472
+ assert.ok(entry, 'the dialog is registered in the shell.overlay slot')
473
+ return entry.Component({ t: harness.t })
474
+ }
475
+
476
+ /** Pull the pieces out of the rendered Modal element tree. */
477
+ function dialogParts(harness) {
478
+ const modal = dialogElement(harness)
479
+ if (modal === null || modal === undefined) return null
480
+ const kids = (modal.props.children ?? []).filter(Boolean)
481
+ const buttons = (modal.props.footer?.props?.children ?? []).filter(Boolean)
482
+ const byKey = (key) => kids.find((node) => node.props.key === key) ?? null
483
+ const skipLabel = byKey('skip')
484
+ return {
485
+ modal,
486
+ type: modal.type,
487
+ title: modal.props.title,
488
+ open: modal.props.open,
489
+ desc: byKey('desc')?.props.children ?? null,
490
+ skip: skipLabel === null ? null : skipLabel.props.children[0],
491
+ error: byKey('error'),
492
+ cancel: buttons[0],
493
+ confirm: buttons[1],
494
+ }
495
+ }
496
+
497
+ const rowCheckboxes = (harness) =>
498
+ harness.rows.map((row) => row.querySelectorAll(':scope > .dsdel-check').length)
499
+
500
+ const totalCheckboxes = (harness) => harness.document.querySelectorAll('.dsdel-check').length
501
+
502
+ const selectedCheckboxes = (harness) =>
503
+ harness.document.querySelectorAll('.dsdel-check').filter((el) => el.classList.contains('dsdel-check-on')).length
504
+
505
+ const bar = (harness) =>
506
+ walk(harness.document.body).find((node) => node._classes.has('dsdel-bar')) ?? null
507
+
508
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
509
+
510
+ /** Let the queued rAF decorate passes drain. */
511
+ const flush = async () => { await tick() }
512
+
513
+ /**
514
+ * Fire the observers multi-select installed. Outside the mode the plugin installs
515
+ * none at all now that the menu is a slot rather than a portalled DOM node, so an
516
+ * outside pass has to go through a delete path instead (see decorateViaSingleDelete).
517
+ */
518
+ function observerPass(harness) {
519
+ for (const observer of harness.observers) observer.callback()
520
+ }
521
+
522
+ /**
523
+ * Run one decorate pass outside multi-select, through the real user route.
524
+ *
525
+ * `decorateRows()` is a private closure and is deliberately not reachable from a
526
+ * test. Outside the mode no observer decorates either, so the only passes are the
527
+ * plugin's own internal calls from the delete paths. This drives the delete row
528
+ * and the dialog it opens, whose confirm handler is the exact call site the bug
529
+ * shipped from.
530
+ */
531
+ async function decorateViaSingleDelete(harness, session = SESSION_A) {
532
+ const { element } = deleteRow(harness, session)
533
+ element.props.onSelect({ shiftKey: false })
534
+ const parts = dialogParts(harness)
535
+ assert.ok(parts, 'the delete dialog opened')
536
+ parts.confirm.props.onClick()
537
+ await tick()
538
+ }
539
+
540
+ // --- the integration contract the slot migration introduced -----------------
541
+
542
+ test('the two menu rows register into the official session-menu slot', async () => {
543
+ const harness = await loadPlugin()
544
+ const del = menuEntry(harness, NS)
545
+ const multi = menuEntry(harness, `${NS}-multi`)
546
+
547
+ assert.ok(del, 'a delete row is registered')
548
+ assert.ok(multi, 'a multi-select row is registered')
549
+ // A package-namespaced id keeps the rows beside the shipped
550
+ // pin(100)/rename(200)/fork(300)/archive(400)/session-id(450).
551
+ assert.equal(del.spec.id, 'dsh-delete-session')
552
+ assert.equal(multi.spec.id, 'dsh-delete-session-multi')
553
+ assert.equal(del.spec.order, 410, 'the delete row sits directly under archive (400)')
554
+ assert.equal(multi.spec.order, 409, 'the multi-select row sits above the delete row')
555
+ assert.equal(del.spec.locale, NS, 'labels come from the package locale namespace')
556
+ })
557
+
558
+ test('the dialog registers into shell.overlay and renders the host Modal', async () => {
559
+ const harness = await loadPlugin()
560
+ const entry = overlayEntry(harness)
561
+ assert.ok(entry, 'a shell.overlay entry is registered')
562
+ assert.equal(entry.spec.id, 'dsh-delete-session-dialog')
563
+
564
+ assert.equal(dialogElement(harness), null, 'nothing renders while no delete is pending')
565
+
566
+ const { element } = deleteRow(harness)
567
+ element.props.onSelect({ shiftKey: false })
568
+ const parts = dialogParts(harness)
569
+ assert.ok(parts, 'the dialog renders once a delete is requested')
570
+ assert.equal(parts.type, harness.primitives.Modal, 'it is the host Modal primitive, not a hand-built backdrop')
571
+ assert.equal(parts.open, true)
572
+ assert.equal(parts.title, '删除会话')
573
+ assert.equal(parts.cancel.type, harness.primitives.Button)
574
+ assert.equal(parts.confirm.type, harness.primitives.Button)
575
+ })
576
+
577
+ test('the menu rows dismiss the menu through the injected hook', async () => {
578
+ const harness = await loadPlugin()
579
+ const del = deleteRow(harness)
580
+ del.element.props.onSelect({ shiftKey: false })
581
+ assert.deepEqual(del.menuOpen, [false], 'the delete row closes the menu after acting')
582
+
583
+ const multi = selectRow(harness)
584
+ multi.element.props.onSelect()
585
+ assert.deepEqual(multi.menuOpen, [false], 'the multi-select row closes the menu after acting')
586
+ })
587
+
588
+ test('the plugin no longer scans the DOM for a portalled menu', async () => {
589
+ const harness = await loadPlugin()
590
+ mountRow(harness, SESSION_A)
591
+ // The old build installed a MutationObserver on body at apply() time to hunt
592
+ // for `body > [role="menu"]`. The only observer left is the one multi-select
593
+ // installs for row decoration.
594
+ assert.equal(harness.observers.length, 0, 'no observer is installed before multi-select starts')
595
+ })
596
+
597
+ test('the locale namespace is registered even when the locale service arrives late', async () => {
598
+ const harness = await loadPlugin({ lateLocale: true })
599
+ assert.ok(harness.locales[NS], 'the namespace was registered through the inject path')
600
+ assert.equal(harness.locales[NS].zh.multiSelect, '多选')
601
+ assert.equal(harness.locales[NS].zh.menu, '删除会话')
602
+
603
+ // The rows must render real labels, not raw keys, on that path too.
604
+ assert.equal(deleteRow(harness).element.props.children, '删除会话')
605
+ assert.equal(selectRow(harness).element.props.children, '多选')
606
+ })
607
+
608
+ test('the delete row asks the host for its danger styling', async () => {
609
+ const harness = await loadPlugin()
610
+ const { element } = deleteRow(harness)
611
+ // ui-primitives' MenuItemButton takes a real `danger` parameter; colouring the
612
+ // label by hand would leave the icon unstyled, which is the regression this
613
+ // guards.
614
+ assert.equal(element.props.danger, true, 'the row uses the host danger tone')
615
+ assert.equal(element.type, harness.primitives.MenuItemButton, 'the row renders through the host primitive')
616
+ // The migration must not change how the menu looks, so no separator is added.
617
+ // This guards against re-introducing the separator on a later pass.
618
+ assert.equal(selectRow(harness).element.props.separatorBefore, undefined, 'no separator is injected')
619
+ })
620
+
621
+ test('the delete row reads its session id from the host props, not from a fiber', async () => {
622
+ const harness = await loadPlugin()
623
+ const row = mountRow(harness, SESSION_A)
624
+ // Give the row a fiber-shaped property and NO data-row-key: the plugin must
625
+ // ignore React internals entirely now.
626
+ row.__reactFiber$test = { memoizedProps: { node: { id: SESSION_A.id } }, return: null }
627
+
628
+ assert.equal(totalCheckboxes(harness), 0)
629
+
630
+ const { element } = deleteRow(harness, SESSION_A)
631
+ await flush()
632
+ element.props.onSelect({ shiftKey: false })
633
+ const parts = dialogParts(harness)
634
+ assert.ok(parts, 'the dialog opened for the id the host injected')
635
+ assert.equal(harness.requests.length, 0, 'opening the dialog sends nothing yet')
636
+ })
637
+
638
+ test('shift-click forces the dialog even after asking was turned off', async () => {
639
+ const harness = await loadPlugin()
640
+ mountRow(harness, SESSION_A)
641
+ localStorage.setItem(SKIP_KEY, '1')
642
+
643
+ // Plain select honours the opt-out and deletes straight away.
644
+ const plain = deleteRow(harness)
645
+ plain.element.props.onSelect({ shiftKey: false })
646
+ await tick()
647
+ assert.equal(dialogElement(harness), null, 'no dialog when asking is off')
648
+ assert.equal(harness.requests.length, 1, 'the delete went out immediately')
649
+
650
+ // Shift-click always asks.
651
+ const forced = deleteRow(harness)
652
+ forced.element.props.onSelect({ shiftKey: true })
653
+ assert.ok(dialogParts(harness), 'shift-click brings the dialog back')
654
+ assert.equal(harness.requests.length, 1, 'and still sends nothing until confirmed')
655
+ })
656
+
657
+ // --- the regression ---------------------------------------------------------
658
+
659
+ test('a single delete plants no checkbox on any row (the v0.1.8 regression)', async () => {
660
+ const harness = await loadPlugin()
661
+ mountRow(harness, SESSION_A)
662
+ mountRow(harness, SESSION_B)
663
+
664
+ // Deleting one session runs the plugin's own decorate pass with `selecting`
665
+ // false. Pre-fix, decorateRows() inserted a `.dsdel-check` into every row here
666
+ // and left the batch bar hidden - the half-on multi-select the user reported.
667
+ await decorateViaSingleDelete(harness, SESSION_A)
668
+
669
+ assert.equal(totalCheckboxes(harness), 0, 'no checkbox may exist while multi-select is off')
670
+ assert.deepEqual(rowCheckboxes(harness), [0, 0], 'no row carries a stray checkbox')
671
+ assert.equal(bar(harness), null, 'and no batch bar was built')
672
+ })
673
+
674
+ test('deleting one session through the dialog leaves no half-on multi-select', async () => {
675
+ const harness = await loadPlugin()
676
+ mountRow(harness, SESSION_A)
677
+ const otherRow = mountRow(harness, SESSION_B)
678
+
679
+ const { element } = deleteRow(harness, SESSION_A)
680
+ element.props.onSelect({ shiftKey: false })
681
+ const parts = dialogParts(harness)
682
+ assert.ok(parts, 'the delete dialog opened')
683
+ assert.equal(parts.confirm.props.disabled, false, 'confirm is ready without an ack')
684
+
685
+ parts.confirm.props.onClick()
686
+ await tick()
687
+
688
+ // This is the assertion the old build failed: the confirm handler added the id
689
+ // to pendingRemoval and called scheduleDecorate(), and that pass grew a
690
+ // checkbox on EVERY row while the batch bar stayed hidden.
691
+ assert.equal(totalCheckboxes(harness), 0, 'a single delete never grows checkboxes')
692
+ assert.deepEqual(rowCheckboxes(harness), [0, 0])
693
+ assert.equal(bar(harness), null, 'the batch bar is never built outside the mode')
694
+ assert.equal(
695
+ otherRow.classList.contains('dsdel-row-pending'),
696
+ false,
697
+ 'the untouched row is not optimistically hidden',
698
+ )
699
+ assert.equal(
700
+ harness.requests.filter((call) => call.url === '/dsh-delete-session/delete').length,
701
+ 1,
702
+ 'exactly one delete request went out',
703
+ )
704
+ })
705
+
706
+ // --- the healthy path must still work (the fix is not "never show a checkbox") ---
707
+
708
+ test('multi-select still plants exactly one checkbox per selectable row', async () => {
709
+ const harness = await loadPlugin()
710
+ mountRow(harness, SESSION_A)
711
+ mountRow(harness, SESSION_B)
712
+
713
+ selectRow(harness).element.props.onSelect()
714
+ await flush()
715
+
716
+ assert.deepEqual(rowCheckboxes(harness), [1, 1], 'one checkbox per row, and only one')
717
+ assert.equal(totalCheckboxes(harness), 2)
718
+ assert.ok(bar(harness), 'the batch bar exists in selection mode')
719
+ assert.equal(selectedCheckboxes(harness), 0, 'nothing is selected yet')
720
+ assert.equal(harness.observers.length, 1, 'multi-select installs exactly one row observer')
721
+ })
722
+
723
+ test('a later decorate pass reuses the checkbox instead of stacking another', async () => {
724
+ const harness = await loadPlugin()
725
+ mountRow(harness, SESSION_A)
726
+ mountRow(harness, SESSION_B)
727
+
728
+ selectRow(harness).element.props.onSelect()
729
+ await flush()
730
+ const before = harness.rows.map((row) => row.querySelectorAll(':scope > .dsdel-check')[0])
731
+
732
+ // In selection mode the observer installed by enterSelection() calls
733
+ // scheduleDecorate; firing it is how a host re-render reaches decorateRows.
734
+ // An implementation that inserted unconditionally would leave two per row.
735
+ observerPass(harness)
736
+ observerPass(harness)
737
+ await flush()
738
+
739
+ const after = harness.rows.map((row) => row.querySelectorAll(':scope > .dsdel-check')[0])
740
+ assert.deepEqual(rowCheckboxes(harness), [1, 1], 'still exactly one per row')
741
+ assert.deepEqual(after, before, 'the same element is reused, never rebuilt')
742
+ })
743
+
744
+ test('clicking a row toggles its checkbox on and off', async () => {
745
+ const harness = await loadPlugin()
746
+ const first = mountRow(harness, SESSION_A)
747
+ mountRow(harness, SESSION_B)
748
+
749
+ selectRow(harness).element.props.onSelect()
750
+ await flush()
751
+
752
+ // The capture handler resolves the row from `e.target.closest('[role=treeitem]')`,
753
+ // exactly as a click on the row's inner markup would.
754
+ const clickRow = () => harness.document.dispatchToDocument('click', {
755
+ target: first,
756
+ preventDefault() {},
757
+ stopPropagation() {},
758
+ })
759
+
760
+ clickRow()
761
+ await flush()
762
+ assert.equal(selectedCheckboxes(harness), 1, 'the first row reads as selected')
763
+
764
+ clickRow()
765
+ await flush()
766
+ assert.equal(selectedCheckboxes(harness), 0, 'clicking it again deselects it')
767
+ })
768
+
769
+ test('leaving multi-select strips every checkbox', async () => {
770
+ const harness = await loadPlugin()
771
+ mountRow(harness, SESSION_A)
772
+ mountRow(harness, SESSION_B)
773
+
774
+ selectRow(harness).element.props.onSelect()
775
+ await flush()
776
+ const control = bar(harness)
777
+ assert.ok(control, 'the bar exists')
778
+ assert.equal(totalCheckboxes(harness), 2)
779
+
780
+ // Cancel is the bar's non-danger button.
781
+ const cancel = control.querySelector('.dsdel-bar-btn:not(.dsdel-bar-danger)')
782
+ cancel.fire('click', { stopPropagation() {} })
783
+ await flush()
784
+
785
+ assert.equal(totalCheckboxes(harness), 0, 'no checkbox survives the mode')
786
+ assert.deepEqual(rowCheckboxes(harness), [0, 0])
787
+ assert.notEqual(control.style.display, 'flex', 'the bar is hidden again')
788
+ })
789
+
790
+ // --- the two modes must not leak into each other ----------------------------
791
+
792
+ test('entering, leaving, then deleting plants nothing', async () => {
793
+ const harness = await loadPlugin()
794
+ mountRow(harness, SESSION_A)
795
+ mountRow(harness, SESSION_B)
796
+
797
+ selectRow(harness).element.props.onSelect()
798
+ await flush()
799
+ const control = bar(harness)
800
+ control.querySelector('.dsdel-bar-btn:not(.dsdel-bar-danger)').fire('click', { stopPropagation() {} })
801
+ await flush()
802
+
803
+ // Leaving the mode removed the observer that decorates; a later delete must
804
+ // not bring the mode - or its checkboxes - back.
805
+ await decorateViaSingleDelete(harness, SESSION_A)
806
+
807
+ assert.equal(totalCheckboxes(harness), 0, 'the mode leaves no residue behind')
808
+ assert.notEqual(control.style.display, 'flex', 'the bar stays hidden')
809
+ })
810
+
811
+ test('a row whose row key changes still gets no checkbox outside the mode', async () => {
812
+ const harness = await loadPlugin()
813
+ const row = mountRow(harness, SESSION_A)
814
+
815
+ await decorateViaSingleDelete(harness, SESSION_A)
816
+ assert.equal(row.querySelectorAll(':scope > .dsdel-check').length, 0)
817
+
818
+ // The host re-rendered and repointed the row; the next pass must still not plant.
819
+ row.setAttribute('data-row-key', `session:${SESSION_B.id}`)
820
+ await decorateViaSingleDelete(harness, SESSION_B)
821
+ assert.equal(totalCheckboxes(harness), 0, 'a changed row key does not revive the bug')
822
+ })
823
+
824
+ test('a row without a session id is skipped in both modes', async () => {
825
+ const harness = await loadPlugin()
826
+ const orphan = harness.document.createElement('div')
827
+ orphan.className = 'session_row'
828
+ orphan.setAttribute('role', 'treeitem')
829
+ harness.document.body.appendChild(orphan)
830
+ harness.rows.push(orphan)
831
+
832
+ await decorateViaSingleDelete(harness, SESSION_A)
833
+ selectRow(harness).element.props.onSelect()
834
+ await flush()
835
+
836
+ assert.equal(orphan.querySelectorAll(':scope > .dsdel-check').length, 0, 'no id, no checkbox')
837
+ })
838
+
839
+ test('multi-select ignores React internals: a fiber alone never makes a row selectable', async () => {
840
+ const harness = await loadPlugin()
841
+ const row = harness.document.createElement('div')
842
+ row.className = 'session_row'
843
+ row.setAttribute('role', 'treeitem')
844
+ // Exactly the shape the old build walked: a fiber carrying `node.id`.
845
+ row.__reactFiber$test = { memoizedProps: { node: { id: SESSION_A.id, title: SESSION_A.title } }, return: null }
846
+ harness.document.body.appendChild(row)
847
+ harness.rows.push(row)
848
+
849
+ selectRow(harness).element.props.onSelect()
850
+ await flush()
851
+
852
+ assert.equal(
853
+ row.querySelectorAll(':scope > .dsdel-check').length,
854
+ 0,
855
+ 'row identity comes from the host key, never from the fiber tree',
856
+ )
857
+
858
+ // Give it the host's own key and it becomes selectable.
859
+ row.setAttribute('data-row-key', `session:${SESSION_A.id}`)
860
+ observerPass(harness)
861
+ await flush()
862
+ assert.equal(
863
+ row.querySelectorAll(':scope > .dsdel-check').length,
864
+ 1,
865
+ 'the host data-row-key is what makes a row selectable',
866
+ )
867
+ })
868
+
869
+ test('residue left by an older build is swept away by the next pass', async () => {
870
+ const harness = await loadPlugin()
871
+ const row = mountRow(harness, SESSION_A)
872
+ // Simulate what the pre-fix build left behind: a checkbox while the mode is off.
873
+ const stray = harness.document.createElement('span')
874
+ stray.className = 'dsdel-check'
875
+ stray.setAttribute('data-dsh-delete-session', 'check')
876
+ row.insertBefore(stray, row.firstChild)
877
+ assert.equal(totalCheckboxes(harness), 1)
878
+
879
+ await decorateViaSingleDelete(harness, SESSION_A)
880
+
881
+ assert.equal(totalCheckboxes(harness), 0, 'the sweep clears it')
882
+ assert.equal(row.querySelectorAll(':scope > .dsdel-check').length, 0)
883
+ })
884
+
885
+ // --- the plugin page: artifact + ask settings (slot plugins.row.config) -----
886
+
887
+ const OFF_KEY = 'dsh-delete-session:artifacts-off'
888
+ const AUTO_KEY = 'dsh-delete-session:artifacts-auto'
889
+ const SCOPE_KEY = 'dsh-delete-session:artifact-scope'
890
+
891
+ const rowConfig = (harness) =>
892
+ harness.slotRegistrations.find((entry) => entry.spec.name === 'plugins.row.config') ?? null
893
+ // The registration carries no `locale`, so the host injects no `t` into this
894
+ // entry. Rendering it exactly that way is what keeps the module-level fallback
895
+ // honest - a card that only worked with an injected `t` would crash in the app.
896
+ const pageCard = (harness, view = 'page') => rowConfig(harness).Component({ view })
897
+ // The settings belonging to the master switch live in a nested container, so the
898
+ // walk descends into any element that carries a children list.
899
+ const collectChecks = (nodes, harness, out = []) => {
900
+ for (const node of nodes || []) {
901
+ if (node === null || node === undefined) continue
902
+ if (node.type === harness.primitives.Checkbox) out.push(node)
903
+ else if (node.props && Array.isArray(node.props.children)) collectChecks(node.props.children, harness, out)
904
+ }
905
+ return out
906
+ }
907
+ const pageChecks = (harness) => collectChecks(pageCard(harness).props.children, harness)
908
+ const pageCheckbox = (harness) => pageChecks(harness)[0]
909
+ // Selected by their key, not by position: adding a switch to the card must not
910
+ // silently retarget these assertions at a different control.
911
+ const pageCheck = (harness, key) => findByKey(pageCard(harness).props.children, key)[0]
912
+ const offCheckbox = (harness) => pageCheck(harness, 'off')
913
+ const recycleCheckbox = (harness) => pageCheck(harness, 'recycle')
914
+ const autoCheckbox = (harness) => pageCheck(harness, 'auto')
915
+ const scopeCreatedCheckbox = (harness) => pageCheck(harness, 'scopeCreated')
916
+ const scopeAllCheckbox = (harness) => pageCheck(harness, 'scopeAll')
917
+
918
+ /** The body the delete route received, or null when it was never called. */
919
+ const deleteBody = (harness) => {
920
+ const entry = harness.requests.find((call) => call.url === '/dsh-delete-session/delete')
921
+ return entry === undefined ? null : JSON.parse(entry.init.body)
922
+ }
923
+
924
+ test('the plugin page registers a row.config entry under the bundle#row key', async () => {
925
+ const harness = await loadPlugin()
926
+ const entry = rowConfig(harness)
927
+ assert.ok(entry, 'a plugins.row.config entry is registered')
928
+ assert.equal(entry.spec.key, 'dsh-delete-session#dsh-delete-session')
929
+ })
930
+
931
+ test('the row.config summary reports the artifact settings, not the ask toggle', async () => {
932
+ const harness = await loadPlugin()
933
+ // The produced-files feature ships off, so that is what the summary says.
934
+ assert.equal(pageCard(harness, 'summary'), '不处理产出文件')
935
+
936
+ localStorage.setItem(SKIP_KEY, '1')
937
+ assert.equal(pageCard(harness, 'summary'), '不处理产出文件', 'the ask toggle no longer drives the summary')
938
+ localStorage.removeItem(SKIP_KEY)
939
+
940
+ localStorage.setItem(OFF_KEY, '0')
941
+ assert.equal(pageCard(harness, 'summary'), '产出文件先询问')
942
+ localStorage.setItem(AUTO_KEY, '1')
943
+ assert.equal(pageCard(harness, 'summary'), '产出文件自动隔离')
944
+ })
945
+
946
+ test('the page checkbox starts ticked and unticking it deletes without a dialog', async () => {
947
+ const harness = await loadPlugin()
948
+ mountRow(harness, SESSION_A)
949
+ assert.equal(pageCheckbox(harness).props.checked, true, 'asking is the default')
950
+
951
+ pageCheckbox(harness).props.onChange(false)
952
+ assert.equal(localStorage.getItem(SKIP_KEY), '1', 'unticking stores the opt-out')
953
+
954
+ const { element } = deleteRow(harness, SESSION_A)
955
+ element.props.onSelect({ shiftKey: false })
956
+ await tick()
957
+
958
+ assert.equal(dialogElement(harness), null, 'no dialog opens once asking is off')
959
+ assert.equal(
960
+ harness.requests.filter((call) => call.url === '/dsh-delete-session/delete').length,
961
+ 1,
962
+ 'the delete goes out straight away',
963
+ )
964
+ })
965
+
966
+ test(
967
+ 'the dialog offers no way to change whether a delete asks',
968
+ async () => {
969
+ // The switch lives on the plugin page only. A dialog that could flip it
970
+ // would let one click silently change every later delete.
971
+ const harness = await loadPlugin()
972
+ mountRow(harness, SESSION_A)
973
+
974
+ const { element } = deleteRow(harness, SESSION_A)
975
+ element.props.onSelect({ shiftKey: false })
976
+
977
+ assert.equal(dialogParts(harness).skip, null, "the dialog carries no don't-ask-again control")
978
+ assert.equal(
979
+ pageCheckbox(harness).props.checked,
980
+ true,
981
+ 'and the page remains the only place the setting is read from',
982
+ )
983
+ },
984
+ )
985
+ // --- produced files: the opt-in feature -------------------------------------
986
+
987
+ const PLAN = {
988
+ created: [
989
+ { path: 'C:/ws/new-a.md', displayPath: 'C:/ws/new-a.md', ops: 1 },
990
+ { path: 'C:/ws/new-b.md', displayPath: 'C:/ws/new-b.md', ops: 1 },
991
+ ],
992
+ modified: [
993
+ { path: 'C:/ws/old-c.md', displayPath: 'C:/ws/old-c.md', ops: 2 },
994
+ ],
995
+ refused: [{ path: 'C:/elsewhere/x.md', reason: '不在该对话的工作目录内,已跳过' }],
996
+ truncated: false,
997
+ warnings: [],
998
+ }
999
+
1000
+ /** The artifact section's rendered element, or null when it is absent. */
1001
+ const artifactSection = (harness) => {
1002
+ const modal = dialogElement(harness)
1003
+ if (modal === null || modal === undefined) return null
1004
+ const kids = modal.props.children || []
1005
+ // The `createElement` stub keeps `key` inside props, the way the rest of this
1006
+ // file reads it.
1007
+ return kids.find((node) => node && node.props && node.props.key === 'artifacts') ?? null
1008
+ }
1009
+
1010
+ /** One rendered tick row per listed artifact. */
1011
+ const artifactRows = (harness) => {
1012
+ const section = artifactSection(harness)
1013
+ if (section === null) return []
1014
+ return (section.props.children || []).filter((node) => node && node.type === 'label')
1015
+ }
1016
+
1017
+ const artifactInputs = (harness) => artifactRows(harness).map((label) => label.props.children[0])
1018
+
1019
+ /** Open the dialog through the real menu row and let the scan settle. */
1020
+ async function openArtifactDialog(harness, session = SESSION_A) {
1021
+ const { element } = deleteRow(harness, session)
1022
+ element.props.onSelect({ shiftKey: false })
1023
+ await tick()
1024
+ await tick()
1025
+ return dialogParts(harness)
1026
+ }
1027
+
1028
+ test('the produced-files feature ships off: no scan, no section, no quarantine field', async () => {
1029
+ const harness = await loadPlugin({ plan: PLAN })
1030
+ mountRow(harness, SESSION_A)
1031
+ const parts = await openArtifactDialog(harness)
1032
+
1033
+ assert.ok(parts, 'the dialog opened')
1034
+ assert.equal(artifactSection(harness), null, 'no artifact section while the feature is off')
1035
+ assert.equal(
1036
+ harness.requests.filter((call) => call.url === '/dsh-delete-session/artifacts').length,
1037
+ 0,
1038
+ 'no scan was requested',
1039
+ )
1040
+
1041
+ parts.confirm.props.onClick()
1042
+ await tick()
1043
+ const body = deleteBody(harness)
1044
+ assert.equal(body.sessionId, SESSION_A.id)
1045
+ assert.equal('quarantine' in body, false, 'the delete request is byte-identical to the previous version')
1046
+ })
1047
+
1048
+ test('the page hides the artifact sub-controls while the master switch is on', async () => {
1049
+ const harness = await loadPlugin()
1050
+ assert.equal(pageChecks(harness).length, 3, 'ask, recycle and the master switch render')
1051
+ assert.equal(offCheckbox(harness).props.checked, true, 'the master switch starts ticked')
1052
+ })
1053
+
1054
+ test('clearing the master switch reveals the auto and scope controls', async () => {
1055
+ const harness = await loadPlugin()
1056
+ offCheckbox(harness).props.onChange(false)
1057
+
1058
+ assert.equal(localStorage.getItem(OFF_KEY), '0', 'the opt-in was stored')
1059
+ assert.equal(pageChecks(harness).length, 7, 'the sub-controls render once enabled')
1060
+ assert.equal(autoCheckbox(harness).props.checked, false, 'asking is the default once enabled')
1061
+ assert.equal(scopeCreatedCheckbox(harness).props.checked, true, 'created-only is the default scope')
1062
+ assert.equal(scopeAllCheckbox(harness).props.checked, false)
1063
+ })
1064
+
1065
+ test('the scope controls are mutually exclusive', async () => {
1066
+ const harness = await loadPlugin()
1067
+ localStorage.setItem(OFF_KEY, '0')
1068
+
1069
+ scopeAllCheckbox(harness).props.onChange(true)
1070
+ assert.equal(localStorage.getItem(SCOPE_KEY), 'all')
1071
+ assert.equal(scopeAllCheckbox(harness).props.checked, true)
1072
+ assert.equal(scopeCreatedCheckbox(harness).props.checked, false)
1073
+
1074
+ scopeCreatedCheckbox(harness).props.onChange(true)
1075
+ assert.equal(localStorage.getItem(SCOPE_KEY), 'created')
1076
+ assert.equal(scopeCreatedCheckbox(harness).props.checked, true)
1077
+
1078
+ // Unticking never clears the selection: one scope is always in force.
1079
+ scopeCreatedCheckbox(harness).props.onChange(false)
1080
+ assert.equal(localStorage.getItem(SCOPE_KEY), 'created')
1081
+ })
1082
+
1083
+ test('with the feature on the dialog scans and lists the created files', async () => {
1084
+ const harness = await loadPlugin({ plan: PLAN })
1085
+ localStorage.setItem(OFF_KEY, '0')
1086
+ mountRow(harness, SESSION_A)
1087
+
1088
+ const parts = await openArtifactDialog(harness)
1089
+ assert.ok(parts, 'the dialog opened')
1090
+ assert.equal(
1091
+ harness.requests.filter((call) => call.url === '/dsh-delete-session/artifacts').length,
1092
+ 1,
1093
+ 'exactly one scan went out',
1094
+ )
1095
+ assert.ok(artifactSection(harness), 'the artifact section rendered')
1096
+ assert.equal(artifactRows(harness).length, 2, 'the created-only scope lists two rows')
1097
+ assert.equal(artifactInputs(harness).every((input) => input.props.checked === true), true, 'all preselected')
1098
+ })
1099
+
1100
+ test('ask mode sends the ticked paths and drops the unticked ones', async () => {
1101
+ const harness = await loadPlugin({ plan: PLAN })
1102
+ localStorage.setItem(OFF_KEY, '0')
1103
+ mountRow(harness, SESSION_A)
1104
+
1105
+ const parts = await openArtifactDialog(harness)
1106
+ artifactInputs(harness)[0].props.onChange({ target: { checked: false } })
1107
+
1108
+ parts.confirm.props.onClick()
1109
+ await tick()
1110
+
1111
+ const body = deleteBody(harness)
1112
+ assert.equal(body.quarantine.scope, 'created')
1113
+ assert.deepEqual(body.quarantine.paths, ['C:/ws/new-b.md'], 'only the still-ticked row was sent')
1114
+ })
1115
+
1116
+ test('auto mode sends only the scope so the host takes its own full set', async () => {
1117
+ const harness = await loadPlugin({ plan: PLAN })
1118
+ localStorage.setItem(OFF_KEY, '0')
1119
+ localStorage.setItem(AUTO_KEY, '1')
1120
+ mountRow(harness, SESSION_A)
1121
+
1122
+ const parts = await openArtifactDialog(harness)
1123
+ assert.equal(artifactRows(harness).length, 0, 'auto mode renders no tick rows')
1124
+
1125
+ parts.confirm.props.onClick()
1126
+ await tick()
1127
+
1128
+ const body = deleteBody(harness)
1129
+ assert.deepEqual(body.quarantine, { scope: 'created', skipGit: true }, 'no path list is sent in auto mode')
1130
+ })
1131
+
1132
+ test('the "created and changed" scope lists and sends the changed rows too', async () => {
1133
+ const harness = await loadPlugin({ plan: PLAN })
1134
+ localStorage.setItem(OFF_KEY, '0')
1135
+ localStorage.setItem(SCOPE_KEY, 'all')
1136
+ mountRow(harness, SESSION_A)
1137
+
1138
+ const parts = await openArtifactDialog(harness)
1139
+ assert.equal(artifactRows(harness).length, 3, 'created rows plus the changed row')
1140
+
1141
+ parts.confirm.props.onClick()
1142
+ await tick()
1143
+
1144
+ const body = deleteBody(harness)
1145
+ assert.equal(body.quarantine.scope, 'all')
1146
+ assert.deepEqual(
1147
+ [...body.quarantine.paths].sort(),
1148
+ ['C:/ws/new-a.md', 'C:/ws/new-b.md', 'C:/ws/old-c.md'].sort(),
1149
+ )
1150
+ })
1151
+
1152
+ test('a scan failure is shown in the dialog and still allows a plain delete', async () => {
1153
+ const harness = await loadPlugin()
1154
+ localStorage.setItem(OFF_KEY, '0')
1155
+ mountRow(harness, SESSION_A)
1156
+
1157
+ // Break only the scan route.
1158
+ const originalFetch = globalThis.fetch
1159
+ globalThis.fetch = async (url, init) => {
1160
+ if (String(url).endsWith('/artifacts')) {
1161
+ harness.requests.push({ url: String(url), init })
1162
+ return { ok: false, status: 500, json: async () => ({ ok: false, error: 'boom' }) }
1163
+ }
1164
+ return originalFetch(url, init)
1165
+ }
1166
+
1167
+ const parts = await openArtifactDialog(harness)
1168
+ const section = artifactSection(harness)
1169
+ assert.ok(section, 'the section is still rendered')
1170
+ assert.equal(section.props.role, 'alert', 'the failure is announced')
1171
+
1172
+ parts.confirm.props.onClick()
1173
+ await tick()
1174
+ const body = deleteBody(harness)
1175
+ // The plan never loaded, so nothing may be quarantined by accident.
1176
+ assert.deepEqual(body.quarantine, { scope: 'created', paths: [], skipGit: true })
1177
+ })
1178
+
1179
+ // --- the two "don't ask again" paths must not silently skip the feature ------
1180
+
1181
+ test('ask mode keeps the dialog when asking was turned off, so the list is never skipped', async () => {
1182
+ const harness = await loadPlugin({ plan: PLAN })
1183
+ localStorage.setItem(OFF_KEY, '0') // produced-files on
1184
+ localStorage.setItem(SKIP_KEY, '1') // "don't ask again" on
1185
+ mountRow(harness, SESSION_A)
1186
+
1187
+ const { element } = deleteRow(harness)
1188
+ element.props.onSelect({ shiftKey: false })
1189
+ await tick()
1190
+ await tick()
1191
+
1192
+ // The file list only exists in the dialog, so skipping it would make the
1193
+ // feature do nothing at all - worse than either asking or not.
1194
+ assert.ok(dialogElement(harness), 'the dialog still opens')
1195
+ assert.ok(artifactSection(harness), 'with the file list in it')
1196
+ assert.equal(deleteBody(harness), null, 'and nothing was deleted behind the user')
1197
+ })
1198
+
1199
+ test('auto mode with asking turned off deletes straight away and still quarantines', async () => {
1200
+ const harness = await loadPlugin({ plan: PLAN })
1201
+ localStorage.setItem(OFF_KEY, '0')
1202
+ localStorage.setItem(AUTO_KEY, '1')
1203
+ localStorage.setItem(SKIP_KEY, '1')
1204
+ mountRow(harness, SESSION_A)
1205
+
1206
+ const { element } = deleteRow(harness)
1207
+ element.props.onSelect({ shiftKey: false })
1208
+ await tick()
1209
+
1210
+ assert.equal(dialogElement(harness), null, 'no dialog in auto mode')
1211
+ const body = deleteBody(harness)
1212
+ assert.ok(body, 'the delete went out')
1213
+ assert.deepEqual(body.quarantine, { scope: 'created' }, 'the feature is not silently skipped')
1214
+ })
1215
+
1216
+ // --- the configurable save path ---------------------------------------------
1217
+
1218
+ const ROOT_KEY = 'dsh-delete-session:quarantine-root'
1219
+
1220
+ /** Depth-first search for rendered elements carrying one key. */
1221
+ const findByKey = (nodes, key, out = []) => {
1222
+ for (const node of nodes || []) {
1223
+ if (!node || !node.props) continue
1224
+ if (node.props.key === key) {
1225
+ out.push(node)
1226
+ continue
1227
+ }
1228
+ if (Array.isArray(node.props.children)) findByKey(node.props.children, key, out)
1229
+ }
1230
+ return out
1231
+ }
1232
+ const pageRootInput = (harness) => findByKey(pageCard(harness).props.children, 'rootInput')[0]
1233
+ const pageRootReset = (harness) => findByKey(pageCard(harness).props.children, 'reset')[0]
1234
+ const pageRootBrowse = (harness) => findByKey(pageCard(harness).props.children, 'browse')[0]
1235
+
1236
+ const browseRequests = (harness) =>
1237
+ harness.requests.filter((call) => call.url === '/dsh-delete-session/browse')
1238
+ const artifactsBody = (harness) => {
1239
+ const entry = harness.requests.find((call) => call.url === '/dsh-delete-session/artifacts')
1240
+ return entry === undefined ? null : JSON.parse(entry.init.body)
1241
+ }
1242
+
1243
+ const pickerEntry = (harness) =>
1244
+ harness.slotRegistrations.find((entry) => entry.spec.id === 'dsh-delete-session-picker') ?? null
1245
+ const pickerElement = (harness) => pickerEntry(harness).Component({ t: harness.t })
1246
+
1247
+ test('the settings card defaults to the host path and names it', async () => {
1248
+ const harness = await loadPlugin({
1249
+ browse: { path: 'C:/home', parent: null, entries: [], roots: [], truncated: false, defaultRoot: 'C:/home' },
1250
+ })
1251
+ localStorage.setItem(OFF_KEY, '0')
1252
+
1253
+ const input = pageRootInput(harness)
1254
+ assert.ok(input, 'the field renders with the feature on')
1255
+ assert.equal(input.props.value, '', 'an empty value means "use the host default"')
1256
+
1257
+ // The host default arrives from the picker route, so the first render only
1258
+ // arms the request; the placeholder names it once that settles.
1259
+ await tick()
1260
+ await tick()
1261
+ assert.equal(pageRootInput(harness).props.placeholder, 'C:/home')
1262
+
1263
+ assert.equal(localStorage.getItem(ROOT_KEY), null, 'nothing is stored until the user chooses')
1264
+ })
1265
+
1266
+ test('a typed save path is stored and sent with the scan and the delete', async () => {
1267
+ const harness = await loadPlugin({ plan: PLAN })
1268
+ localStorage.setItem(OFF_KEY, '0')
1269
+ mountRow(harness, SESSION_A)
1270
+
1271
+ pageRootInput(harness).props.onChange({ target: { value: 'D:\\quarantine' } })
1272
+ assert.equal(localStorage.getItem(ROOT_KEY), 'D:\\quarantine')
1273
+
1274
+ const parts = await openArtifactDialog(harness)
1275
+ assert.equal(artifactsBody(harness).root, 'D:\\quarantine', 'the scan knows where files would go')
1276
+
1277
+ parts.confirm.props.onClick()
1278
+ await tick()
1279
+ assert.equal(deleteBody(harness).root, 'D:\\quarantine', 'and so does the delete')
1280
+ })
1281
+
1282
+ test('"use default" clears the stored path and omits it from the requests', async () => {
1283
+ const harness = await loadPlugin({ plan: PLAN })
1284
+ localStorage.setItem(OFF_KEY, '0')
1285
+ localStorage.setItem(ROOT_KEY, 'D:\\quarantine')
1286
+ mountRow(harness, SESSION_A)
1287
+
1288
+ pageRootReset(harness).props.onClick()
1289
+ assert.equal(localStorage.getItem(ROOT_KEY), null, 'the override is gone')
1290
+
1291
+ const parts = await openArtifactDialog(harness)
1292
+ assert.equal('root' in artifactsBody(harness), false, 'the scan falls back to the default')
1293
+
1294
+ parts.confirm.props.onClick()
1295
+ await tick()
1296
+ assert.equal('root' in deleteBody(harness), false, 'and so does the delete')
1297
+ })
1298
+
1299
+ test('the browse button opens a picker that walks directories and commits one', async () => {
1300
+ const listing = (target) => {
1301
+ if (target === 'C:/ws') {
1302
+ return {
1303
+ path: 'C:/ws',
1304
+ parent: 'C:/',
1305
+ entries: [{ name: 'keep', path: 'C:/ws/keep' }, { name: 'other', path: 'C:/ws/other' }],
1306
+ roots: [],
1307
+ truncated: false,
1308
+ defaultRoot: 'C:/home',
1309
+ }
1310
+ }
1311
+ return { path: target, parent: 'C:/ws', entries: [], roots: [], truncated: false, defaultRoot: 'C:/home' }
1312
+ }
1313
+ // No native dialog on this platform, so the in-page picker is the fallback.
1314
+ const harness = await loadPlugin({ browse: listing, pick: { unsupported: true } })
1315
+ localStorage.setItem(OFF_KEY, '0')
1316
+ localStorage.setItem(ROOT_KEY, 'C:/ws')
1317
+
1318
+ pageRootBrowse(harness).props.onClick()
1319
+ await tick()
1320
+ await tick()
1321
+
1322
+ const modal = pickerElement(harness)
1323
+ assert.ok(modal, 'the picker opened')
1324
+ const rows = findByKey(modal.props.children || [], 'C:/ws/keep')
1325
+ assert.equal(rows.length, 1, 'the folders are listed')
1326
+
1327
+ // Walking into a folder re-asks the host.
1328
+ rows[0].props.onClick()
1329
+ await tick()
1330
+ await tick()
1331
+ assert.equal(browseRequests(harness).at(-1).init.body, JSON.stringify({ path: 'C:/ws/keep' }))
1332
+ assert.equal(pickerElement(harness).props.children.length > 0, true, 'the listing re-rendered')
1333
+
1334
+ // Committing writes the preference and closes the picker.
1335
+ const footer = pickerElement(harness).props.footer
1336
+ footer[1].props.onClick()
1337
+ assert.equal(localStorage.getItem(ROOT_KEY), 'C:/ws/keep', 'the chosen folder is stored')
1338
+ assert.equal(pickerElement(harness), null, 'the picker closed')
1339
+ })
1340
+
1341
+ // --- dsh-session-relocate integration ---------------------------------------
1342
+ //
1343
+ // That plugin publishes a bulk-move face on `window`. When it is there, one more
1344
+ // button appears between cancel and delete; when it is not, the bar is exactly
1345
+ // what it was before and no other logic changes.
1346
+
1347
+ const barButtons = (harness) => bar(harness).querySelectorAll('.dsdel-bar-btn')
1348
+ const barMove = (harness) => bar(harness).querySelector('.dsdel-bar-move')
1349
+ const barCancel = (harness) => bar(harness).querySelector('.dsdel-bar-cancel')
1350
+ const barDelete = (harness) => bar(harness).querySelector('.dsdel-bar-danger')
1351
+ const barCount = (harness) => bar(harness).querySelector('.dsdel-bar-count')
1352
+ const toasts = (harness) => harness.document.querySelectorAll('.dsdel-toast').map((el) => el.textContent)
1353
+
1354
+ function installRelocate(moveSelection) {
1355
+ globalThis.window.__DSH_SESSION_RELOCATE__ = { version: '0.2.0', supportsBulkMove: true, moveSelection }
1356
+ }
1357
+
1358
+ async function enterMulti(harness) {
1359
+ selectRow(harness).element.props.onSelect()
1360
+ await flush()
1361
+ }
1362
+
1363
+ function clickRowIn(harness, row) {
1364
+ harness.document.dispatchToDocument('click', { target: row, preventDefault() {}, stopPropagation() {} })
1365
+ }
1366
+
1367
+ test('without dsh-session-relocate the bar keeps exactly cancel and delete', async () => {
1368
+ const harness = await loadPlugin()
1369
+ mountRow(harness, SESSION_A)
1370
+ await enterMulti(harness)
1371
+
1372
+ assert.equal(barMove(harness), null, 'no move button is offered')
1373
+ assert.deepEqual(barButtons(harness).map((el) => el.textContent), ['取消', '删除'])
1374
+ })
1375
+
1376
+ test('a relocate plugin that does not claim bulk move changes nothing', async () => {
1377
+ const harness = await loadPlugin()
1378
+ globalThis.window.__DSH_SESSION_RELOCATE__ = { version: '0.2.0', supportsBulkMove: false, moveSelection: async () => ({}) }
1379
+ mountRow(harness, SESSION_A)
1380
+ await enterMulti(harness)
1381
+ assert.deepEqual(barButtons(harness).map((el) => el.textContent), ['取消', '删除'])
1382
+ })
1383
+
1384
+ test('with dsh-session-relocate the move button sits between cancel and delete', async () => {
1385
+ const harness = await loadPlugin()
1386
+ installRelocate(async (ids) => ({ moved: 0, failed: 0, skipped: 0, total: ids.length, results: [] }))
1387
+ mountRow(harness, SESSION_A)
1388
+ await enterMulti(harness)
1389
+
1390
+ assert.deepEqual(barButtons(harness).map((el) => el.textContent), ['取消', '移动', '删除'], 'left to right')
1391
+ })
1392
+
1393
+ test('the move button is disabled until something is selected', async () => {
1394
+ const harness = await loadPlugin()
1395
+ installRelocate(async (ids) => ({ moved: 0, failed: 0, skipped: 0, total: ids.length, results: [] }))
1396
+ const first = mountRow(harness, SESSION_A)
1397
+ await enterMulti(harness)
1398
+
1399
+ assert.equal(barMove(harness).disabled, true, 'nothing selected')
1400
+ assert.equal(barDelete(harness).disabled, true, 'and delete agrees')
1401
+ assert.equal(barCancel(harness).disabled, false, 'cancel is always live')
1402
+
1403
+ clickRowIn(harness, first)
1404
+ await flush()
1405
+ assert.equal(barMove(harness).disabled, false, 'one selection is enough')
1406
+ })
1407
+
1408
+ test('a move reports progress, then summarises, exits and refreshes', async () => {
1409
+ const harness = await loadPlugin()
1410
+ let seen = null
1411
+ let finish
1412
+ const done = new Promise((resolve) => { finish = resolve })
1413
+ installRelocate(async (ids, hooks) => {
1414
+ seen = { ids, hooks }
1415
+ return done
1416
+ })
1417
+
1418
+ const first = mountRow(harness, SESSION_A)
1419
+ const second = mountRow(harness, SESSION_B)
1420
+ await enterMulti(harness)
1421
+ clickRowIn(harness, first)
1422
+ clickRowIn(harness, second)
1423
+ await flush()
1424
+
1425
+ barMove(harness).fire('click', { stopPropagation() {} })
1426
+ await flush()
1427
+
1428
+ assert.deepEqual(seen.ids, [SESSION_A.id, SESSION_B.id], 'the whole selection is handed over')
1429
+ assert.equal(barCount(harness).textContent, '正在移动 0/2…')
1430
+ assert.equal(barMove(harness).disabled, true, 'inert while the move runs')
1431
+ assert.equal(barDelete(harness).disabled, true)
1432
+
1433
+ seen.hooks.onProgress(1, 2)
1434
+ assert.equal(barCount(harness).textContent, '正在移动 1/2…')
1435
+
1436
+ finish({ moved: 2, failed: 0, skipped: 0, total: 2, results: [] })
1437
+ await flush()
1438
+ await flush()
1439
+
1440
+ assert.deepEqual(toasts(harness), ['已移动 2 个'], 'one summary line')
1441
+ assert.notEqual(bar(harness).style.display, 'flex', 'multi-select exited')
1442
+ })
1443
+
1444
+ test('a partial move reports both numbers', async () => {
1445
+ const harness = await loadPlugin()
1446
+ installRelocate(async (ids) => ({ moved: 1, failed: 2, skipped: 0, total: ids.length, results: [] }))
1447
+ const first = mountRow(harness, SESSION_A)
1448
+ await enterMulti(harness)
1449
+ clickRowIn(harness, first)
1450
+ await flush()
1451
+
1452
+ barMove(harness).fire('click', { stopPropagation() {} })
1453
+ await flush()
1454
+ await flush()
1455
+
1456
+ assert.deepEqual(toasts(harness), ['已移动 1 个 / 2 个失败'])
1457
+ })
1458
+
1459
+ test('cancelling the target picker keeps the selection and says nothing', async () => {
1460
+ const harness = await loadPlugin()
1461
+ installRelocate(async () => ({ canceled: true }))
1462
+ const first = mountRow(harness, SESSION_A)
1463
+ await enterMulti(harness)
1464
+ clickRowIn(harness, first)
1465
+ await flush()
1466
+
1467
+ barMove(harness).fire('click', { stopPropagation() {} })
1468
+ await flush()
1469
+ await flush()
1470
+
1471
+ assert.deepEqual(toasts(harness), [], 'nothing happened, so nothing is reported')
1472
+ assert.equal(bar(harness).style.display, 'flex', 'multi-select is still open')
1473
+ assert.equal(barCount(harness).textContent, '已选 1 个', 'the count is back')
1474
+ assert.equal(barMove(harness).disabled, false, 'the retry is available')
1475
+ })
1476
+
1477
+ test('a throwing relocate call ends the batch and reports the error', async () => {
1478
+ const harness = await loadPlugin()
1479
+ installRelocate(async () => { throw new Error('relocate exploded') })
1480
+ const first = mountRow(harness, SESSION_A)
1481
+ await enterMulti(harness)
1482
+ clickRowIn(harness, first)
1483
+ await flush()
1484
+
1485
+ barMove(harness).fire('click', { stopPropagation() {} })
1486
+ await flush()
1487
+ await flush()
1488
+
1489
+ assert.equal(bar(harness).style.display, 'flex', 'still in multi-select')
1490
+ assert.equal(toasts(harness).length, 1, 'the failure is reported')
1491
+ assert.match(toasts(harness)[0], /relocate exploded/)
1492
+ })
1493
+
1494
+ test('Escape stays inert while a move is running', async () => {
1495
+ const harness = await loadPlugin()
1496
+ let finish
1497
+ installRelocate(async () => new Promise((resolve) => { finish = resolve }))
1498
+ const first = mountRow(harness, SESSION_A)
1499
+ await enterMulti(harness)
1500
+ clickRowIn(harness, first)
1501
+ await flush()
1502
+
1503
+ barMove(harness).fire('click', { stopPropagation() {} })
1504
+ await flush()
1505
+
1506
+ harness.document.dispatchToDocument('keydown', { key: 'Escape', stopPropagation() {} })
1507
+ await flush()
1508
+ assert.equal(bar(harness).style.display, 'flex', 'Escape is ignored mid-move')
1509
+
1510
+ finish({ moved: 1, failed: 0, skipped: 0, total: 1, results: [] })
1511
+ await flush()
1512
+ await flush()
1513
+ assert.notEqual(bar(harness).style.display, 'flex', 'and the mode ends when the move does')
1514
+ })
1515
+
1516
+ // --- the result contract is enforced, not guessed ---------------------------
1517
+
1518
+ test('the older "cancelled" spelling of the cancel flag is still honoured', async () => {
1519
+ const harness = await loadPlugin()
1520
+ installRelocate(async () => ({ cancelled: true }))
1521
+ const first = mountRow(harness, SESSION_A)
1522
+ await enterMulti(harness)
1523
+ clickRowIn(harness, first)
1524
+ await flush()
1525
+
1526
+ barMove(harness).fire('click', { stopPropagation() {} })
1527
+ await flush()
1528
+ await flush()
1529
+
1530
+ assert.deepEqual(toasts(harness), [], 'silent, exactly like the "canceled" spelling')
1531
+ assert.equal(bar(harness).style.display, 'flex', 'still in multi-select')
1532
+ })
1533
+
1534
+ test('a result without the agreed counts is reported instead of guessed', async () => {
1535
+ const harness = await loadPlugin()
1536
+ // The shape an earlier draft used: no total. Substituting `total - failed` would
1537
+ // make "genuinely zero failures" and "field missing" look identical.
1538
+ installRelocate(async () => ({ moved: 1, failed: 0 }))
1539
+ const first = mountRow(harness, SESSION_A)
1540
+ await enterMulti(harness)
1541
+ clickRowIn(harness, first)
1542
+ await flush()
1543
+
1544
+ barMove(harness).fire('click', { stopPropagation() {} })
1545
+ await flush()
1546
+ await flush()
1547
+
1548
+ assert.equal(toasts(harness).length, 1)
1549
+ assert.match(toasts(harness)[0], /移动结果缺少字段:total/)
1550
+ })
1551
+
1552
+ // --- capability bits --------------------------------------------------------
1553
+
1554
+ test('the plugin announces itself so a relocate plugin can stand down', async () => {
1555
+ await loadPlugin()
1556
+ assert.equal(globalThis.window.__DSH_DELETE_SESSION__?.supportsBulkMove, true, 'the capability bit is set')
1557
+ })
1558
+
1559
+ // --- the hint under "ask before deleting" must match what actually happens ---
1560
+
1561
+ const pageHint = (harness, key) => findByKey(pageCard(harness).props.children, key)[0]
1562
+
1563
+ test('with asking off, the hint changes when the file list still needs a dialog', async () => {
1564
+ const harness = await loadPlugin()
1565
+ localStorage.setItem(SKIP_KEY, '1')
1566
+
1567
+ // Feature off: nothing else opens a dialog, so "delete straight away" is true.
1568
+ assert.equal(pageHint(harness, 'askHint').props.children, '删除会话会直接执行,不再询问。')
1569
+
1570
+ // Feature on in ask mode: the list lives in the dialog, so it still opens.
1571
+ localStorage.setItem(OFF_KEY, '0')
1572
+ assert.equal(
1573
+ pageHint(harness, 'askHint').props.children,
1574
+ '删除会话仍会弹窗——产出文件要先列清单给你勾选。',
1575
+ 'the promise must match the behaviour',
1576
+ )
1577
+
1578
+ // Feature on in auto mode: no list to show, so straight delete is true again.
1579
+ localStorage.setItem(AUTO_KEY, '1')
1580
+ assert.equal(pageHint(harness, 'askHint').props.children, '删除会话会直接执行,不再询问。')
1581
+ })
1582
+
1583
+ test('with asking on, the hint is the plain confirmation one', async () => {
1584
+ const harness = await loadPlugin()
1585
+ localStorage.setItem(OFF_KEY, '0')
1586
+ assert.equal(pageHint(harness, 'askHint').props.children, '每次删除会话前都会弹出确认框。')
1587
+ })
1588
+
1589
+ // --- the native folder dialog -----------------------------------------------
1590
+ //
1591
+ // The browser cannot hand a page a filesystem path, so the host raises the OS
1592
+ // dialog instead. The in-page picker stays as the fallback for platforms without
1593
+ // one, which the test above already covers.
1594
+
1595
+ test('the folder button stores what the system dialog returned', async () => {
1596
+ const harness = await loadPlugin({ pick: { cancelled: false, path: 'D:/quarantine' } })
1597
+ localStorage.setItem(OFF_KEY, '0')
1598
+
1599
+ pageRootBrowse(harness).props.onClick()
1600
+ await tick()
1601
+ await tick()
1602
+
1603
+ assert.equal(localStorage.getItem(ROOT_KEY), 'D:/quarantine', 'the chosen folder is stored')
1604
+ assert.deepEqual(toasts(harness), ['已打开系统文件夹对话框,请在弹出的窗口里选择', '保存位置已更新'])
1605
+ })
1606
+
1607
+ test('cancelling the system dialog changes nothing', async () => {
1608
+ const harness = await loadPlugin({ pick: { cancelled: true, path: null } })
1609
+ localStorage.setItem(OFF_KEY, '0')
1610
+
1611
+ pageRootBrowse(harness).props.onClick()
1612
+ await tick()
1613
+ await tick()
1614
+
1615
+ assert.equal(localStorage.getItem(ROOT_KEY), null, 'no choice was recorded')
1616
+ assert.deepEqual(toasts(harness), ['已打开系统文件夹对话框,请在弹出的窗口里选择'], 'only the "it opened" note')
1617
+ assert.equal(pickerElement(harness), null, 'and no fallback picker was raised')
1618
+ })
1619
+
1620
+ test('a failing system dialog is reported, not swallowed', async () => {
1621
+ const harness = await loadPlugin()
1622
+ localStorage.setItem(OFF_KEY, '0')
1623
+ const originalFetch = globalThis.fetch
1624
+ globalThis.fetch = async (url, init) => {
1625
+ if (String(url).endsWith('/dsh-delete-session/pick')) {
1626
+ harness.requests.push({ url: String(url), init })
1627
+ return { ok: false, status: 500, json: async () => ({ ok: false, code: 'shell', error: 'shell exploded' }) }
1628
+ }
1629
+ return originalFetch(url, init)
1630
+ }
1631
+
1632
+ pageRootBrowse(harness).props.onClick()
1633
+ await tick()
1634
+ await tick()
1635
+
1636
+ assert.equal(localStorage.getItem(ROOT_KEY), null)
1637
+ assert.match(toasts(harness).at(-1), /shell exploded/)
1638
+ })
1639
+
1640
+ // --- the dialog must not pay for work it cannot use --------------------------
1641
+
1642
+ const scans = (harness) => harness.requests.filter((entry) => String(entry.url).includes('/artifacts'))
1643
+
1644
+ test('auto mode never scans, because the answer is thrown away', async () => {
1645
+ const harness = await loadPlugin()
1646
+ mountRow(harness, SESSION_A)
1647
+ localStorage.setItem(OFF_KEY, '0')
1648
+ localStorage.setItem(AUTO_KEY, '1')
1649
+
1650
+ const { element } = deleteRow(harness)
1651
+ element.props.onSelect({ shiftKey: false })
1652
+ await flush()
1653
+
1654
+ assert.equal(scans(harness).length, 0, 'quarantine is sent as { scope } and the host derives the set')
1655
+ assert.ok(dialogParts(harness), 'the dialog still opens straight away')
1656
+ })
1657
+
1658
+ test('ask mode still scans, because its list is the whole point', async () => {
1659
+ const harness = await loadPlugin({
1660
+ plan: { created: [{ path: 'C:/ws/a.md', displayPath: 'a.md' }], modified: [], refused: [], truncated: false },
1661
+ })
1662
+ mountRow(harness, SESSION_A)
1663
+ localStorage.setItem(OFF_KEY, '0')
1664
+
1665
+ const { element } = deleteRow(harness)
1666
+ element.props.onSelect({ shiftKey: false })
1667
+ await flush()
1668
+
1669
+ assert.equal(scans(harness).length, 1, 'the listing is what the user ticks')
1670
+ })
1671
+
1672
+ test('the counts render as numbers, not as the dictionary function', async () => {
1673
+ // The locale contract hands a function entry back untouched; rendering it
1674
+ // without calling it printed "(created, modified) => ..." into the dialog.
1675
+ // `harness.t` deliberately calls such entries so ordinary tests can read text,
1676
+ // so the contract path is driven here with a translator that behaves like the
1677
+ // host's: strings (interpolated) or the raw entry, nothing else.
1678
+ const hostTranslator = (key) => {
1679
+ const value = harness.locales[NS]?.zh?.[key]
1680
+ return value === undefined ? key : value
1681
+ }
1682
+ const harness = await loadPlugin({
1683
+ plan: {
1684
+ created: [{ path: 'C:/ws/a.md', displayPath: 'a.md' }, { path: 'C:/ws/b.md', displayPath: 'b.md' }],
1685
+ modified: [{ path: 'C:/ws/c.md', displayPath: 'c.md' }],
1686
+ refused: [],
1687
+ truncated: false,
1688
+ },
1689
+ })
1690
+ mountRow(harness, SESSION_A)
1691
+ localStorage.setItem(OFF_KEY, '0')
1692
+
1693
+ const { element } = deleteRow(harness)
1694
+ element.props.onSelect({ shiftKey: false })
1695
+ await flush()
1696
+
1697
+ const modal = overlayEntry(harness).Component({ t: hostTranslator })
1698
+ const kids = modal.props.children.filter(Boolean)
1699
+ const artifactsNode = kids.find((node) => node.props.key === 'artifacts')
1700
+ const countNode = artifactsNode.props.children.find((node) => node.props.key === 'count')
1701
+
1702
+ assert.equal(typeof countNode.props.children, 'string', 'the count is text, not a function')
1703
+ assert.match(countNode.props.children, /新建 2 个 · 改过 1 个/)
1704
+ })
1705
+
1706
+ // --- the dialog must not threaten a deletion it no longer performs -----------
1707
+
1708
+ test('the dialog says the session goes to the Recycle Bin, not that it is gone forever', async () => {
1709
+ const harness = await loadPlugin()
1710
+ mountRow(harness, SESSION_A)
1711
+
1712
+ const { element } = deleteRow(harness)
1713
+ element.props.onSelect({ shiftKey: false })
1714
+
1715
+ const desc = dialogParts(harness).desc
1716
+ assert.match(desc, /回收站/, 'the Recycle Bin is named as the destination')
1717
+ assert.doesNotMatch(desc, /永久删除/, 'it is no longer a permanent delete')
1718
+ assert.doesNotMatch(desc, /不可恢复/, 'and it no longer claims to be unrecoverable')
1719
+ })
1720
+
1721
+ test('the folder row can open the quarantine folder in the file manager', async () => {
1722
+ const harness = await loadPlugin()
1723
+ localStorage.setItem(OFF_KEY, '0')
1724
+ localStorage.setItem(ROOT_KEY, 'D:/quarantine')
1725
+
1726
+ const openButton = findByKey(pageCard(harness).props.children, 'open')[0]
1727
+ assert.ok(openButton, 'an open button sits beside the folder buttons')
1728
+
1729
+ openButton.props.onClick()
1730
+ await tick()
1731
+ await tick()
1732
+
1733
+ const reveal = harness.requests.filter((call) => call.url === '/dsh-delete-session/reveal')
1734
+ assert.equal(reveal.length, 1, 'one reveal request went out')
1735
+ assert.equal(JSON.parse(reveal[0].init.body).path, 'D:/quarantine')
1736
+ assert.match(toasts(harness).at(-1), /已在文件管理器中打开/)
1737
+ })
1738
+
1739
+ test('opening with no stored root falls back to the host default', async () => {
1740
+ const harness = await loadPlugin()
1741
+ localStorage.setItem(OFF_KEY, '0')
1742
+ // The default path is fetched on the first render; mount, then let it settle.
1743
+ pageCard(harness)
1744
+ await tick()
1745
+ await tick()
1746
+
1747
+ const openButton = findByKey(pageCard(harness).props.children, 'open')[0]
1748
+ openButton.props.onClick()
1749
+ await tick()
1750
+ await tick()
1751
+
1752
+ const reveal = harness.requests.filter((call) => call.url === '/dsh-delete-session/reveal')
1753
+ assert.equal(reveal.length, 1)
1754
+ assert.equal(JSON.parse(reveal[0].init.body).path, 'C:/home', 'the default the host reported is used')
1755
+ })
1756
+
1757
+ // --- the git switch ---------------------------------------------------------
1758
+
1759
+ const SKIP_GIT_KEY = 'dsh-delete-session:skip-git'
1760
+
1761
+ test('skipping git repositories is on by default', async () => {
1762
+ const harness = await loadPlugin()
1763
+ localStorage.setItem(OFF_KEY, '0')
1764
+
1765
+ const box = findByKey(pageCard(harness).props.children, 'skipGit')[0]
1766
+ assert.ok(box, 'the switch renders with the artifact controls')
1767
+ assert.equal(box.props.checked, true, 'it starts on, so a project is never carried away by default')
1768
+ assert.equal(box.props.label, '跳过 git 仓库内的文件')
1769
+ })
1770
+
1771
+ test('turning the git switch off is stored and sent with the delete', async () => {
1772
+ const harness = await loadPlugin({ plan: { created: [{ path: 'C:/ws/a.md', displayPath: 'a.md' }], modified: [], refused: [], truncated: false } })
1773
+ localStorage.setItem(OFF_KEY, '0')
1774
+ mountRow(harness, SESSION_A)
1775
+
1776
+ const box = findByKey(pageCard(harness).props.children, 'skipGit')[0]
1777
+ box.props.onChange(false)
1778
+ assert.equal(localStorage.getItem(SKIP_GIT_KEY), '0', 'the choice is remembered')
1779
+
1780
+ const { element } = deleteRow(harness)
1781
+ element.props.onSelect({ shiftKey: false })
1782
+ await flush()
1783
+ // The dialog is open (ask mode is the default), so the delete goes out on confirm.
1784
+ dialogParts(harness).confirm.props.onClick()
1785
+ await flush()
1786
+
1787
+ assert.equal(deleteBody(harness).quarantine.skipGit, false, 'the host is told to stop skipping')
1788
+ })
1789
+
1790
+ test('the scan itself is told whether to skip git files', async () => {
1791
+ // The listing and the move must agree: a file hidden by the scan would
1792
+ // otherwise still be a candidate when the host derives the set itself.
1793
+ const harness = await loadPlugin()
1794
+ localStorage.setItem(OFF_KEY, '0')
1795
+ mountRow(harness, SESSION_A)
1796
+
1797
+ const { element } = deleteRow(harness)
1798
+ element.props.onSelect({ shiftKey: false })
1799
+ await flush()
1800
+
1801
+ assert.equal(artifactsBody(harness).skipGit, true, 'the scan carries the flag')
1802
+ })
1803
+
1804
+ // --- the recycle-bin switch -------------------------------------------------
1805
+
1806
+ const RECYCLE_KEY = 'dsh-delete-session:recycle'
1807
+
1808
+ test('deleting to the system trash is on by default', async () => {
1809
+ const harness = await loadPlugin()
1810
+ const box = recycleCheckbox(harness)
1811
+ assert.ok(box, 'the switch renders with the delete controls')
1812
+ assert.equal(box.props.checked, true, 'a wrong click stays undoable until told otherwise')
1813
+ assert.equal(box.props.label, '删除至回收站')
1814
+ })
1815
+
1816
+ test('the default sentence promises the Recycle Bin', async () => {
1817
+ const harness = await loadPlugin()
1818
+ mountRow(harness, SESSION_A)
1819
+ const { element } = deleteRow(harness)
1820
+ element.props.onSelect({ shiftKey: false })
1821
+
1822
+ const desc = dialogParts(harness).desc
1823
+ assert.match(desc, /回收站/)
1824
+ assert.doesNotMatch(desc, /永久删除|不可恢复/)
1825
+ })
1826
+
1827
+ test('unchecking it switches both the sentence and the request to a hard delete', async () => {
1828
+ const harness = await loadPlugin()
1829
+ mountRow(harness, SESSION_A)
1830
+
1831
+ recycleCheckbox(harness).props.onChange(false)
1832
+ assert.equal(localStorage.getItem(RECYCLE_KEY), '0', 'the choice is remembered')
1833
+
1834
+ const { element } = deleteRow(harness)
1835
+ element.props.onSelect({ shiftKey: false })
1836
+
1837
+ const desc = dialogParts(harness).desc
1838
+ assert.match(desc, /永久删除/, 'the sentence now says what an unchecked box means')
1839
+ assert.doesNotMatch(desc, /回收站/, 'and no longer promises a recoverable delete')
1840
+
1841
+ dialogParts(harness).confirm.props.onClick()
1842
+ await flush()
1843
+ assert.equal(deleteBody(harness).recycle, false, 'the host is told to remove it outright')
1844
+ })