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.
- package/LICENSE +21 -21
- package/README.md +201 -96
- package/cordis.patch.yml +7 -7
- package/package.json +3 -2
- package/src/client.js +1450 -415
- package/src/index.js +2007 -517
- package/test/artifacts.host.test.js +953 -0
- package/test/checkbox.dom.test.js +1844 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
// Offline tests for the produced-files half: log classification, the safety
|
|
2
|
+
// filters, and the quarantine move.
|
|
3
|
+
//
|
|
4
|
+
// These drive src/index.js directly - no DSH process, no browser, no network.
|
|
5
|
+
// The one seam they stub is `sessionPersistence`, because reading a real session
|
|
6
|
+
// log is the caller's job and not what this file is about.
|
|
7
|
+
//
|
|
8
|
+
// node test/artifacts.host.test.js (or: npm test)
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import os from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { after, beforeEach, test } from 'node:test'
|
|
14
|
+
import { internals } from '../src/index.js'
|
|
15
|
+
|
|
16
|
+
const { classifyPaths, collectSessionPaths, planArtifacts, quarantineFiles, runQuarantine, toolTarget } = internals
|
|
17
|
+
const { claimQuarantineDirectory, freeDestination, identityKey, insidePath, listDirectories, resolveQuarantineRoot } = internals
|
|
18
|
+
|
|
19
|
+
/** A real session id: runQuarantine validates the shape before touching anything. */
|
|
20
|
+
const SESSION_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7'
|
|
21
|
+
const SESSION_B = '0f8fad5b-d9cb-469f-a165-70867728950e'
|
|
22
|
+
|
|
23
|
+
// --- scratch trees ----------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const roots = []
|
|
26
|
+
|
|
27
|
+
function scratch(label) {
|
|
28
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `dsdel-${label}-`)))
|
|
29
|
+
roots.push(dir)
|
|
30
|
+
return dir
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
after(() => {
|
|
34
|
+
for (const dir of roots) {
|
|
35
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* already gone */ }
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Pin the DSH home for the entire file. `dshHome()` reads the environment at
|
|
40
|
+
// call time, so any test that reached it without redirecting would move files
|
|
41
|
+
// into the real `~/.dsh`. A mutation run did exactly that once (it disabled the
|
|
42
|
+
// id guard, which let one test fall through to the move), so the redirect now
|
|
43
|
+
// covers every test rather than only the ones that think they need it.
|
|
44
|
+
const HOME_ROOT = scratch('home-root')
|
|
45
|
+
process.env.DSH_HOME = HOME_ROOT
|
|
46
|
+
|
|
47
|
+
// The redirect above is the only thing standing between these tests and the real
|
|
48
|
+
// ~/.dsh, so it is re-asserted before every test rather than trusted once: a
|
|
49
|
+
// test that parks DSH_HOME somewhere and fails to put it back would otherwise
|
|
50
|
+
// hand the next test the real home.
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
assert.equal(process.env.DSH_HOME, HOME_ROOT, 'every test must run against the scratch home')
|
|
53
|
+
assert.ok(HOME_ROOT.startsWith(os.tmpdir()), 'the scratch home must live under tmp, got ' + HOME_ROOT)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
function writeAt(root, relative, content = 'x') {
|
|
57
|
+
const file = path.join(root, relative)
|
|
58
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
59
|
+
fs.writeFileSync(file, content)
|
|
60
|
+
return file
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One tool call plus its successful result, the shape the classifier consumes. */
|
|
64
|
+
function call(callId, name, args) {
|
|
65
|
+
return { type: 'tool/call', data: { callId, name, arguments: JSON.stringify(args) } }
|
|
66
|
+
}
|
|
67
|
+
function result(callId, ok = true) {
|
|
68
|
+
return {
|
|
69
|
+
type: 'tool/result',
|
|
70
|
+
data: ok
|
|
71
|
+
? { message: { toolCallId: callId } }
|
|
72
|
+
: { message: { toolCallId: callId, isError: true }, error: { name: 'FsError', code: 'FS_FAILED' } },
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Rows the way classifyPaths wants them. */
|
|
77
|
+
function rows(paths) {
|
|
78
|
+
const map = new Map()
|
|
79
|
+
for (const p of paths) map.set(p, { path: p, ops: 1 })
|
|
80
|
+
return map
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The identity key the classifier stores for a path that does not exist yet. The
|
|
85
|
+
* tests below use synthetic paths (never touched on disk), so the key falls back
|
|
86
|
+
* to a case-folded resolved spelling on Windows.
|
|
87
|
+
*/
|
|
88
|
+
function folded(p) {
|
|
89
|
+
const resolved = path.resolve(p)
|
|
90
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A context whose persistence service replays fixed events for one session. */
|
|
94
|
+
function stubCtx({ cwd, events = [], statFails = false }) {
|
|
95
|
+
return {
|
|
96
|
+
get(name) {
|
|
97
|
+
if (name !== 'sessionPersistence') return undefined
|
|
98
|
+
return {
|
|
99
|
+
async stat() {
|
|
100
|
+
if (statFails) throw new Error('no record')
|
|
101
|
+
return { header: cwd === undefined ? {} : { cwd } }
|
|
102
|
+
},
|
|
103
|
+
async open() {
|
|
104
|
+
return {
|
|
105
|
+
async read() { return { events } },
|
|
106
|
+
async close() {},
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- toolTarget -------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
test('toolTarget resolves absolute and cwd-relative write targets', () => {
|
|
117
|
+
const cwd = path.resolve(os.tmpdir(), 'some-workspace')
|
|
118
|
+
const absolute = path.resolve(cwd, 'a.md')
|
|
119
|
+
|
|
120
|
+
assert.deepEqual(toolTarget('write', JSON.stringify({ file_path: absolute, content: 'x' }), cwd), {
|
|
121
|
+
path: absolute,
|
|
122
|
+
writes: true,
|
|
123
|
+
})
|
|
124
|
+
assert.deepEqual(toolTarget('edit', JSON.stringify({ file_path: 'sub/b.md' }), cwd), {
|
|
125
|
+
path: path.resolve(cwd, 'sub/b.md'),
|
|
126
|
+
writes: true,
|
|
127
|
+
})
|
|
128
|
+
// str_replace_editor spells the key `path`.
|
|
129
|
+
assert.deepEqual(toolTarget('str_replace_editor', JSON.stringify({ path: 'c.md' }), cwd), {
|
|
130
|
+
path: path.resolve(cwd, 'c.md'),
|
|
131
|
+
writes: true,
|
|
132
|
+
})
|
|
133
|
+
assert.deepEqual(toolTarget('read', JSON.stringify({ file_path: 'd.md' }), cwd), {
|
|
134
|
+
path: path.resolve(cwd, 'd.md'),
|
|
135
|
+
writes: false,
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('toolTarget ignores tools with no path, bad JSON, and an unknown cwd for relative paths', () => {
|
|
140
|
+
const cwd = path.resolve(os.tmpdir(), 'some-workspace')
|
|
141
|
+
assert.equal(toolTarget('bash', JSON.stringify({ command: 'ls' }), cwd), undefined)
|
|
142
|
+
assert.equal(toolTarget('write', 'not json', cwd), undefined)
|
|
143
|
+
assert.equal(toolTarget('write', JSON.stringify({ file_path: 'rel.md' }), undefined), undefined)
|
|
144
|
+
assert.equal(toolTarget('write', JSON.stringify({ file_path: '' }), cwd), undefined)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// --- collectSessionPaths ----------------------------------------------------
|
|
148
|
+
|
|
149
|
+
test('a write with no earlier successful read created the file', async () => {
|
|
150
|
+
const cwd = path.resolve(os.tmpdir(), 'ws')
|
|
151
|
+
const events = [
|
|
152
|
+
call('c1', 'write', { file_path: 'fresh.md' }), result('c1'),
|
|
153
|
+
call('c2', 'read', { file_path: 'old.md' }), result('c2'),
|
|
154
|
+
call('c3', 'write', { file_path: 'old.md' }), result('c3'),
|
|
155
|
+
]
|
|
156
|
+
const { created, modified } = await collectSessionPaths(events, cwd)
|
|
157
|
+
assert.deepEqual([...created.keys()], [folded(path.resolve(cwd, 'fresh.md'))])
|
|
158
|
+
assert.deepEqual([...modified.keys()], [folded(path.resolve(cwd, 'old.md'))])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('a failed write changes nothing', async () => {
|
|
162
|
+
const cwd = path.resolve(os.tmpdir(), 'ws')
|
|
163
|
+
const events = [
|
|
164
|
+
call('c1', 'write', { file_path: 'ghost.md' }), result('c1', false),
|
|
165
|
+
]
|
|
166
|
+
const { created, modified } = await collectSessionPaths(events, cwd)
|
|
167
|
+
assert.equal(created.size, 0)
|
|
168
|
+
assert.equal(modified.size, 0)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('a failed read does not make a later write count as an edit', async () => {
|
|
172
|
+
const cwd = path.resolve(os.tmpdir(), 'ws')
|
|
173
|
+
const events = [
|
|
174
|
+
call('c1', 'read', { file_path: 'new.md' }), result('c1', false),
|
|
175
|
+
call('c2', 'write', { file_path: 'new.md' }), result('c2'),
|
|
176
|
+
]
|
|
177
|
+
const { created, modified } = await collectSessionPaths(events, cwd)
|
|
178
|
+
assert.deepEqual([...created.keys()], [folded(path.resolve(cwd, 'new.md'))])
|
|
179
|
+
assert.equal(modified.size, 0)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('an edit alone is a modification, never a creation', async () => {
|
|
183
|
+
const cwd = path.resolve(os.tmpdir(), 'ws')
|
|
184
|
+
const events = [
|
|
185
|
+
call('c1', 'read', { file_path: 'doc.md' }), result('c1'),
|
|
186
|
+
call('c2', 'edit', { file_path: 'doc.md' }), result('c2'),
|
|
187
|
+
]
|
|
188
|
+
const { created, modified } = await collectSessionPaths(events, cwd)
|
|
189
|
+
assert.equal(created.size, 0)
|
|
190
|
+
assert.deepEqual([...modified.keys()], [folded(path.resolve(cwd, 'doc.md'))])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('repeat writes to one path collapse into one row with an op count', async () => {
|
|
194
|
+
const cwd = path.resolve(os.tmpdir(), 'ws')
|
|
195
|
+
const events = [
|
|
196
|
+
call('c1', 'write', { file_path: 'a.md' }), result('c1'),
|
|
197
|
+
call('c2', 'write', { file_path: 'a.md' }), result('c2'),
|
|
198
|
+
call('c3', 'write', { file_path: 'a.md' }), result('c3'),
|
|
199
|
+
]
|
|
200
|
+
const { created } = await collectSessionPaths(events, cwd)
|
|
201
|
+
assert.equal(created.size, 1)
|
|
202
|
+
assert.equal(created.get(folded(path.resolve(cwd, 'a.md'))).ops, 3)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('results that match no call are ignored', async () => {
|
|
206
|
+
const cwd = path.resolve(os.tmpdir(), 'ws')
|
|
207
|
+
const { created, modified } = await collectSessionPaths([result('nope')], cwd)
|
|
208
|
+
assert.equal(created.size, 0)
|
|
209
|
+
assert.equal(modified.size, 0)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// --- classifyPaths ----------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
test('classifyPaths keeps only existing regular files strictly inside the working directory', async () => {
|
|
215
|
+
const cwd = scratch('cwd')
|
|
216
|
+
const home = scratch('home')
|
|
217
|
+
const inside = writeAt(cwd, 'keep.md', 'hello')
|
|
218
|
+
const nested = writeAt(cwd, 'sub/nested.md', 'hi')
|
|
219
|
+
const dir = path.join(cwd, 'adir')
|
|
220
|
+
fs.mkdirSync(dir)
|
|
221
|
+
const outside = writeAt(scratch('elsewhere'), 'outside.md', 'no')
|
|
222
|
+
|
|
223
|
+
const { allowed, refused } = await classifyPaths(
|
|
224
|
+
rows([inside, nested, dir, outside, path.join(cwd, 'missing.md')]),
|
|
225
|
+
cwd,
|
|
226
|
+
home,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const allowedPaths = allowed.map((row) => row.path).sort()
|
|
230
|
+
assert.deepEqual(allowedPaths, [nested, inside].sort())
|
|
231
|
+
|
|
232
|
+
const reasons = new Map(refused.map((row) => [row.path, row.reason]))
|
|
233
|
+
assert.equal(reasons.get(dir), '不是普通文件,已跳过')
|
|
234
|
+
assert.equal(reasons.get(outside), '不在该对话的工作目录内,已跳过')
|
|
235
|
+
assert.equal(reasons.get(path.join(cwd, 'missing.md')), '文件已不存在')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('classifyPaths refuses files inside the DSH home even when the workspace sits there too', async () => {
|
|
239
|
+
// A file under the DSH home is only reachable through the home branch when the
|
|
240
|
+
// working directory is itself below the home — otherwise the containment check
|
|
241
|
+
// rejects it first, which is the case the previous test covers.
|
|
242
|
+
const home = scratch('home')
|
|
243
|
+
const cwd = path.join(home, 'workspace')
|
|
244
|
+
fs.mkdirSync(cwd, { recursive: true })
|
|
245
|
+
const file = writeAt(cwd, 'inside.md', 'x')
|
|
246
|
+
|
|
247
|
+
const { allowed, refused } = await classifyPaths(rows([file]), cwd, home)
|
|
248
|
+
assert.equal(allowed.length, 0)
|
|
249
|
+
assert.equal(refused.length, 1)
|
|
250
|
+
assert.equal(refused[0].reason, '位于 DSH 数据目录内,已跳过')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('classifyPaths refuses everything when the session recorded no working directory', async () => {
|
|
254
|
+
const home = scratch('home')
|
|
255
|
+
const gone = path.join(scratch('cwd'), 'a.md')
|
|
256
|
+
const { allowed, refused } = await classifyPaths(rows([gone]), undefined, home)
|
|
257
|
+
assert.equal(allowed.length, 0)
|
|
258
|
+
assert.equal(refused.length, 1)
|
|
259
|
+
assert.equal(refused[0].reason, '会话未记录工作目录,无法安全定位')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('classifyPaths refuses everything when the working directory is gone', async () => {
|
|
263
|
+
const cwd = scratch('cwd')
|
|
264
|
+
const home = scratch('home')
|
|
265
|
+
const file = writeAt(scratch('elsewhere'), 'a.md')
|
|
266
|
+
fs.rmSync(cwd, { recursive: true, force: true })
|
|
267
|
+
const { allowed, refused } = await classifyPaths(rows([file]), cwd, home)
|
|
268
|
+
assert.equal(allowed.length, 0)
|
|
269
|
+
assert.equal(refused[0].reason, '会话工作目录已不存在,无法校验归属')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('classifyPaths never lets a sibling with a shared prefix through', async () => {
|
|
273
|
+
const parent = scratch('parent')
|
|
274
|
+
const cwd = path.join(parent, 'project')
|
|
275
|
+
fs.mkdirSync(cwd)
|
|
276
|
+
// `project-evil` shares the `project` prefix but is not inside it.
|
|
277
|
+
const sibling = path.join(parent, 'project-evil.md')
|
|
278
|
+
fs.writeFileSync(sibling, 'x')
|
|
279
|
+
const home = scratch('home')
|
|
280
|
+
|
|
281
|
+
const { allowed, refused } = await classifyPaths(rows([sibling]), cwd, home)
|
|
282
|
+
assert.equal(allowed.length, 0)
|
|
283
|
+
assert.equal(refused[0].reason, '不在该对话的工作目录内,已跳过')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('classifyPaths refuses a symlink whose target lies outside the working directory', async () => {
|
|
287
|
+
const cwd = scratch('cwd')
|
|
288
|
+
const home = scratch('home')
|
|
289
|
+
const outside = writeAt(scratch('elsewhere'), 'secret.md', 'x')
|
|
290
|
+
const link = path.join(cwd, 'link.md')
|
|
291
|
+
try {
|
|
292
|
+
fs.symlinkSync(outside, link)
|
|
293
|
+
} catch {
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Containment must be judged on the resolved target: the link sits inside the
|
|
298
|
+
// working directory, but the file it names does not.
|
|
299
|
+
const { allowed, refused } = await classifyPaths(rows([link]), cwd, home)
|
|
300
|
+
assert.equal(allowed.length, 0, 'a link out of the tree is not this session file')
|
|
301
|
+
assert.equal(refused[0].reason, '不在该对话的工作目录内,已跳过')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// --- quarantineFiles --------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
test('quarantineFiles moves files into a timestamped tree and keeps their structure', async () => {
|
|
307
|
+
const cwd = scratch('cwd')
|
|
308
|
+
const home = scratch('home')
|
|
309
|
+
const top = writeAt(cwd, 'notes.md', 'top-content')
|
|
310
|
+
const deep = writeAt(cwd, path.join('sub', 'deep.txt'), 'deep-content')
|
|
311
|
+
|
|
312
|
+
const outcome = await quarantineFiles([top, deep], cwd, home)
|
|
313
|
+
|
|
314
|
+
assert.equal(outcome.failed.length, 0)
|
|
315
|
+
assert.equal(outcome.moved.length, 2)
|
|
316
|
+
// The originals are gone and the copies carry the same bytes.
|
|
317
|
+
assert.equal(fs.existsSync(top), false)
|
|
318
|
+
assert.equal(fs.existsSync(deep), false)
|
|
319
|
+
const movedTop = path.join(outcome.directory, 'notes.md')
|
|
320
|
+
const movedDeep = path.join(outcome.directory, 'sub', 'deep.txt')
|
|
321
|
+
assert.equal(fs.readFileSync(movedTop, 'utf8'), 'top-content')
|
|
322
|
+
assert.equal(fs.readFileSync(movedDeep, 'utf8'), 'deep-content')
|
|
323
|
+
// The directory lives under the DSH home and is named for the operation.
|
|
324
|
+
assert.ok(outcome.directory.startsWith(home))
|
|
325
|
+
assert.match(path.basename(outcome.directory), /^purge-quarantine-/)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('quarantineFiles reports a missing target instead of throwing', async () => {
|
|
329
|
+
const cwd = scratch('cwd')
|
|
330
|
+
const home = scratch('home')
|
|
331
|
+
const outcome = await quarantineFiles([path.join(cwd, 'never-existed.md')], cwd, home)
|
|
332
|
+
assert.equal(outcome.moved.length, 0)
|
|
333
|
+
assert.equal(outcome.failed.length, 1)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// --- runQuarantine ----------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
test('runQuarantine drops paths the plan never offered', async () => {
|
|
339
|
+
const home = scratch('home')
|
|
340
|
+
const cwd = scratch('cwd')
|
|
341
|
+
const touched = writeAt(cwd, 'touched.md', 'mine')
|
|
342
|
+
const bystander = writeAt(cwd, 'bystander.md', 'not this conversation')
|
|
343
|
+
const previous = process.env.DSH_HOME
|
|
344
|
+
process.env.DSH_HOME = home
|
|
345
|
+
try {
|
|
346
|
+
const ctx = stubCtx({
|
|
347
|
+
cwd,
|
|
348
|
+
events: [call('c1', 'write', { file_path: 'touched.md' }), result('c1')],
|
|
349
|
+
})
|
|
350
|
+
// A tampered request appends a path the scan never listed.
|
|
351
|
+
const outcome = await runQuarantine(ctx, SESSION_ID, {
|
|
352
|
+
scope: 'created',
|
|
353
|
+
paths: [touched, bystander],
|
|
354
|
+
})
|
|
355
|
+
assert.equal(outcome.selected, 1, 'only the planned file was selected')
|
|
356
|
+
assert.equal(outcome.dropped, 1, 'the unplanned path was dropped')
|
|
357
|
+
assert.equal(fs.existsSync(bystander), true, 'the bystander was never touched')
|
|
358
|
+
assert.equal(fs.existsSync(touched), false, 'the planned file moved')
|
|
359
|
+
} finally {
|
|
360
|
+
process.env.DSH_HOME = previous
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('runQuarantine honours the scope: created only versus created plus changed', async () => {
|
|
365
|
+
const home = scratch('home')
|
|
366
|
+
const cwd = scratch('cwd')
|
|
367
|
+
const createdFile = writeAt(cwd, 'new.md', 'new')
|
|
368
|
+
const changedFile = writeAt(cwd, 'old.md', 'old')
|
|
369
|
+
const previous = process.env.DSH_HOME
|
|
370
|
+
process.env.DSH_HOME = home
|
|
371
|
+
try {
|
|
372
|
+
const events = [
|
|
373
|
+
call('c1', 'write', { file_path: 'new.md' }), result('c1'),
|
|
374
|
+
call('c2', 'read', { file_path: 'old.md' }), result('c2'),
|
|
375
|
+
call('c3', 'edit', { file_path: 'old.md' }), result('c3'),
|
|
376
|
+
]
|
|
377
|
+
const createdOnly = await runQuarantine(stubCtx({ cwd, events }), SESSION_ID, { scope: 'created' })
|
|
378
|
+
assert.equal(createdOnly.selected, 1)
|
|
379
|
+
assert.equal(fs.existsSync(createdFile), false)
|
|
380
|
+
assert.equal(fs.existsSync(changedFile), true, 'a changed pre-existing file stays under created scope')
|
|
381
|
+
|
|
382
|
+
const all = await runQuarantine(stubCtx({ cwd, events }), SESSION_ID, { scope: 'all' })
|
|
383
|
+
assert.equal(all.selected, 1, 'only the changed file is left to move')
|
|
384
|
+
assert.equal(fs.existsSync(changedFile), false)
|
|
385
|
+
} finally {
|
|
386
|
+
process.env.DSH_HOME = previous
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('runQuarantine returns null when the caller asked for no quarantine', async () => {
|
|
391
|
+
assert.equal(await runQuarantine({}, SESSION_ID, undefined), null)
|
|
392
|
+
assert.equal(await runQuarantine({}, SESSION_ID, null), null)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
// --- planArtifacts ----------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
test('planArtifacts reports created and changed files plus every skip reason', async () => {
|
|
398
|
+
const home = scratch('home')
|
|
399
|
+
const cwd = scratch('cwd')
|
|
400
|
+
const createdFile = writeAt(cwd, 'new.md', 'new')
|
|
401
|
+
const changedFile = writeAt(cwd, 'old.md', 'old')
|
|
402
|
+
const outside = writeAt(scratch('elsewhere'), 'outside.md', 'no')
|
|
403
|
+
const previous = process.env.DSH_HOME
|
|
404
|
+
process.env.DSH_HOME = home
|
|
405
|
+
try {
|
|
406
|
+
const ctx = stubCtx({
|
|
407
|
+
cwd,
|
|
408
|
+
events: [
|
|
409
|
+
call('c1', 'write', { file_path: 'new.md' }), result('c1'),
|
|
410
|
+
call('c2', 'read', { file_path: 'old.md' }), result('c2'),
|
|
411
|
+
call('c3', 'edit', { file_path: 'old.md' }), result('c3'),
|
|
412
|
+
call('c4', 'write', { file_path: outside }), result('c4'),
|
|
413
|
+
],
|
|
414
|
+
})
|
|
415
|
+
const plan = await planArtifacts(ctx, 'session-x')
|
|
416
|
+
assert.equal(plan.cwd, cwd)
|
|
417
|
+
assert.deepEqual(plan.created.map((row) => row.path), [createdFile])
|
|
418
|
+
assert.deepEqual(plan.modified.map((row) => row.path), [changedFile])
|
|
419
|
+
assert.ok(plan.refused.some((row) => row.path === outside && row.reason === '不在该对话的工作目录内,已跳过'))
|
|
420
|
+
assert.equal(plan.truncated, false)
|
|
421
|
+
} finally {
|
|
422
|
+
process.env.DSH_HOME = previous
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('planArtifacts degrades to a warning when the session record is unreadable', async () => {
|
|
427
|
+
const home = scratch('home')
|
|
428
|
+
const previous = process.env.DSH_HOME
|
|
429
|
+
process.env.DSH_HOME = home
|
|
430
|
+
try {
|
|
431
|
+
const ctx = {
|
|
432
|
+
get(name) {
|
|
433
|
+
if (name !== 'sessionPersistence') return undefined
|
|
434
|
+
return {
|
|
435
|
+
async stat() { throw new Error('no record') },
|
|
436
|
+
async open() { throw new Error('no log') },
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
const plan = await planArtifacts(ctx, 'gone')
|
|
441
|
+
assert.equal(plan.created.length, 0)
|
|
442
|
+
assert.equal(plan.modified.length, 0)
|
|
443
|
+
assert.ok(plan.warnings.length >= 2, 'both the record and the log report a warning')
|
|
444
|
+
} finally {
|
|
445
|
+
process.env.DSH_HOME = previous
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// --- file identity, not path spelling ---------------------------------------
|
|
450
|
+
//
|
|
451
|
+
// A write is only a creation when the file did not exist. Comparing the raw
|
|
452
|
+
// strings would call a write through an alias, or through a differently-cased
|
|
453
|
+
// spelling, a creation — and a creation is exactly what gets moved.
|
|
454
|
+
|
|
455
|
+
test('a write through a symlink alias of a file the session read is a modification', async () => {
|
|
456
|
+
const cwd = scratch('cwd')
|
|
457
|
+
const real = writeAt(cwd, 'real.txt', 'original')
|
|
458
|
+
try {
|
|
459
|
+
fs.symlinkSync(real, path.join(cwd, 'alias.txt'))
|
|
460
|
+
} catch {
|
|
461
|
+
// Without SeCreateSymbolicLinkPrivilege on Windows there is nothing to test.
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const events = [
|
|
466
|
+
call('c1', 'read', { file_path: 'alias.txt' }), result('c1'),
|
|
467
|
+
call('c2', 'write', { file_path: 'real.txt' }), result('c2'),
|
|
468
|
+
]
|
|
469
|
+
const { created, modified } = await collectSessionPaths(events, cwd)
|
|
470
|
+
|
|
471
|
+
assert.equal(created.size, 0, 'the alias names the very file the read observed')
|
|
472
|
+
assert.deepEqual([...modified.keys()], [fs.realpathSync.native(real)])
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('a differently-cased spelling of a file the session read is a modification', async () => {
|
|
476
|
+
const cwd = scratch('cwd')
|
|
477
|
+
const real = writeAt(cwd, 'Doc.md', 'original')
|
|
478
|
+
// Only a case-insensitive filesystem makes these the same file; elsewhere the
|
|
479
|
+
// two spellings really are two files and calling the write a creation is right.
|
|
480
|
+
if (!fs.existsSync(path.join(cwd, 'doc.md'))) return
|
|
481
|
+
|
|
482
|
+
const events = [
|
|
483
|
+
call('c1', 'read', { file_path: 'Doc.md' }), result('c1'),
|
|
484
|
+
call('c2', 'write', { file_path: 'doc.md' }), result('c2'),
|
|
485
|
+
]
|
|
486
|
+
const { created, modified } = await collectSessionPaths(events, cwd)
|
|
487
|
+
|
|
488
|
+
assert.equal(created.size, 0)
|
|
489
|
+
assert.deepEqual([...modified.keys()], [fs.realpathSync.native(real)])
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('an unread file behind an alias still counts as a creation', async () => {
|
|
493
|
+
const cwd = scratch('cwd')
|
|
494
|
+
const other = writeAt(cwd, 'other.txt', 'x')
|
|
495
|
+
try {
|
|
496
|
+
fs.symlinkSync(other, path.join(cwd, 'alias.txt'))
|
|
497
|
+
} catch {
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const events = [
|
|
502
|
+
call('c1', 'read', { file_path: 'other.txt' }), result('c1'),
|
|
503
|
+
// A different file, reached through its own name: this one was never read.
|
|
504
|
+
call('c2', 'write', { file_path: 'fresh.txt' }), result('c2'),
|
|
505
|
+
]
|
|
506
|
+
const { created } = await collectSessionPaths(events, cwd)
|
|
507
|
+
assert.deepEqual([...created.keys()], [folded(path.resolve(cwd, 'fresh.txt'))])
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// --- quarantine collisions --------------------------------------------------
|
|
511
|
+
//
|
|
512
|
+
// The directory name resolves to the second, and a move is an overwrite unless
|
|
513
|
+
// it is made not to be. Two runs landing in one second must not share a tree.
|
|
514
|
+
|
|
515
|
+
test('two quarantines in one second claim separate trees and never overwrite', async () => {
|
|
516
|
+
const home = scratch('home')
|
|
517
|
+
const cwdA = scratch('cwd-a')
|
|
518
|
+
const cwdB = scratch('cwd-b')
|
|
519
|
+
const a = writeAt(cwdA, path.join('src', 'index.js'), 'A-VERSION')
|
|
520
|
+
const b = writeAt(cwdB, path.join('src', 'index.js'), 'B-VERSION')
|
|
521
|
+
|
|
522
|
+
const first = await quarantineFiles([a], cwdA, home)
|
|
523
|
+
const second = await quarantineFiles([b], cwdB, home)
|
|
524
|
+
|
|
525
|
+
assert.equal(first.failed.length, 0)
|
|
526
|
+
assert.equal(second.failed.length, 0)
|
|
527
|
+
assert.notEqual(first.directory, second.directory, 'each run owns its own tree')
|
|
528
|
+
// Both copies survive with their own content: neither run replaced the other.
|
|
529
|
+
assert.equal(fs.readFileSync(path.join(first.directory, 'src', 'index.js'), 'utf8'), 'A-VERSION')
|
|
530
|
+
assert.equal(fs.readFileSync(path.join(second.directory, 'src', 'index.js'), 'utf8'), 'B-VERSION')
|
|
531
|
+
assert.equal(fs.existsSync(a), false)
|
|
532
|
+
assert.equal(fs.existsSync(b), false)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('an occupied quarantine name is skipped rather than written into', async () => {
|
|
536
|
+
const home = scratch('home')
|
|
537
|
+
const cwd = scratch('cwd')
|
|
538
|
+
const file = writeAt(cwd, 'notes.md', 'x')
|
|
539
|
+
|
|
540
|
+
// Occupy the name this second's claim would want first.
|
|
541
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
|
542
|
+
const occupied = path.join(home, `purge-quarantine-${stamp}`)
|
|
543
|
+
fs.mkdirSync(occupied)
|
|
544
|
+
|
|
545
|
+
const outcome = await quarantineFiles([file], cwd, home)
|
|
546
|
+
|
|
547
|
+
assert.equal(outcome.moved.length, 1, 'the file still moved')
|
|
548
|
+
assert.notEqual(outcome.directory, occupied, 'the pre-existing tree is never written into')
|
|
549
|
+
assert.equal(fs.existsSync(path.join(occupied, 'notes.md')), false)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
test('quarantining nothing creates no directory at all', async () => {
|
|
553
|
+
const home = scratch('home')
|
|
554
|
+
const outcome = await quarantineFiles([], scratch('cwd'), home)
|
|
555
|
+
assert.equal(outcome.directory, null)
|
|
556
|
+
assert.equal(outcome.moved.length, 0)
|
|
557
|
+
// A home that stayed empty proves no stray tree was left behind.
|
|
558
|
+
assert.deepEqual(fs.readdirSync(home), [])
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
// --- the helpers the move itself depends on ---------------------------------
|
|
562
|
+
//
|
|
563
|
+
// These are the pieces a mutation could quietly weaken without any end-to-end
|
|
564
|
+
// case noticing, so they are checked directly.
|
|
565
|
+
|
|
566
|
+
test('freeDestination never hands back a taken name', async () => {
|
|
567
|
+
const directory = scratch('quarantine')
|
|
568
|
+
fs.mkdirSync(path.join(directory, 'sub'), { recursive: true })
|
|
569
|
+
const taken = writeAt(directory, path.join('sub', 'notes.md'), 'already here')
|
|
570
|
+
|
|
571
|
+
const free = await freeDestination(directory, path.join('sub', 'notes.md'))
|
|
572
|
+
assert.notEqual(free, taken)
|
|
573
|
+
assert.equal(path.basename(free), 'notes-1.md')
|
|
574
|
+
assert.equal(fs.existsSync(free), false)
|
|
575
|
+
|
|
576
|
+
// An untouched name is returned unchanged.
|
|
577
|
+
assert.equal(await freeDestination(directory, 'brand-new.md'), path.join(directory, 'brand-new.md'))
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
test('insidePath folds case where the filesystem does', () => {
|
|
581
|
+
const cwd = scratch('cwd')
|
|
582
|
+
const child = writeAt(cwd, 'a.md')
|
|
583
|
+
assert.equal(insidePath(cwd, child), true)
|
|
584
|
+
assert.equal(insidePath(cwd, cwd), false, 'a directory is not inside itself')
|
|
585
|
+
assert.equal(insidePath(cwd, path.join(cwd, '..', 'elsewhere.md')), false)
|
|
586
|
+
|
|
587
|
+
if (process.platform === 'win32') {
|
|
588
|
+
// The filesystem ignores case here, so containment has to as well.
|
|
589
|
+
assert.equal(insidePath(cwd.toUpperCase(), child), true)
|
|
590
|
+
assert.equal(insidePath(cwd, child.toUpperCase()), true)
|
|
591
|
+
}
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
test('identityKey gives an alias and its target the same key', async () => {
|
|
595
|
+
const cwd = scratch('cwd')
|
|
596
|
+
const real = writeAt(cwd, 'real.txt', 'x')
|
|
597
|
+
try {
|
|
598
|
+
fs.symlinkSync(real, path.join(cwd, 'alias.txt'))
|
|
599
|
+
} catch {
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
assert.equal(await identityKey(path.join(cwd, 'alias.txt')), await identityKey(real))
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
test('claimQuarantineDirectory never hands out a directory another run owns', async () => {
|
|
606
|
+
const home = scratch('home')
|
|
607
|
+
const first = await claimQuarantineDirectory(home)
|
|
608
|
+
const second = await claimQuarantineDirectory(home)
|
|
609
|
+
assert.ok(first)
|
|
610
|
+
assert.ok(second)
|
|
611
|
+
assert.notEqual(first, second, 'the second claim moved aside')
|
|
612
|
+
assert.equal(fs.statSync(first).isDirectory(), true)
|
|
613
|
+
assert.equal(fs.statSync(second).isDirectory(), true)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
test('claimQuarantineDirectory creates a quarantine root that does not exist yet', async () => {
|
|
617
|
+
const parent = scratch('parent')
|
|
618
|
+
const home = path.join(parent, 'not-there-yet')
|
|
619
|
+
const claimed = await claimQuarantineDirectory(home)
|
|
620
|
+
// A missing home must not turn the first ever delete into "cannot claim".
|
|
621
|
+
assert.ok(claimed, 'the root was created')
|
|
622
|
+
assert.equal(fs.statSync(claimed).isDirectory(), true)
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
// --- one identity, one bucket -----------------------------------------------
|
|
626
|
+
|
|
627
|
+
test('a file the session created and then edited stays created, never both', async () => {
|
|
628
|
+
const cwd = scratch('cwd')
|
|
629
|
+
writeAt(cwd, 'mine.md', 'x')
|
|
630
|
+
const events = [
|
|
631
|
+
call('c1', 'write', { file_path: 'mine.md' }), result('c1'),
|
|
632
|
+
call('c2', 'read', { file_path: 'mine.md' }), result('c2'),
|
|
633
|
+
call('c3', 'edit', { file_path: 'mine.md' }), result('c3'),
|
|
634
|
+
]
|
|
635
|
+
const { created, modified } = await collectSessionPaths(events, cwd)
|
|
636
|
+
|
|
637
|
+
assert.equal(created.size, 1)
|
|
638
|
+
assert.equal(modified.size, 0, 'listing it twice would make a scope=all run target it twice')
|
|
639
|
+
assert.equal([...created.values()][0].ops, 2, 'both writes are still counted')
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
test('an id that is not a UUID is refused before anything is touched', async () => {
|
|
643
|
+
const cwd = scratch('cwd')
|
|
644
|
+
const file = writeAt(cwd, 'a.md', 'x')
|
|
645
|
+
const ctx = stubCtx({ cwd, events: [call('c1', 'write', { file_path: 'a.md' }), result('c1')] })
|
|
646
|
+
|
|
647
|
+
await assert.rejects(
|
|
648
|
+
() => runQuarantine(ctx, '../../etc/passwd', { scope: 'created' }),
|
|
649
|
+
(error) => error && error.status === 400,
|
|
650
|
+
'the guard lives in runQuarantine too, not only at the route',
|
|
651
|
+
)
|
|
652
|
+
assert.equal(fs.existsSync(file), true, 'nothing was moved')
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
test('a plan handed in for another session is refused, not acted on', async () => {
|
|
656
|
+
const cwdA = scratch('cwd-a')
|
|
657
|
+
const cwdB = scratch('cwd-b')
|
|
658
|
+
const aOnly = writeAt(cwdA, 'a-only.txt', 'A')
|
|
659
|
+
const bOnly = writeAt(cwdB, 'b-only.txt', 'B')
|
|
660
|
+
const ctxA = stubCtx({ cwd: cwdA, events: [call('c1', 'write', { file_path: 'a-only.txt' }), result('c1')] })
|
|
661
|
+
const ctxB = stubCtx({ cwd: cwdB, events: [call('c1', 'write', { file_path: 'b-only.txt' }), result('c1')] })
|
|
662
|
+
|
|
663
|
+
const planA = await planArtifacts(ctxA, SESSION_ID)
|
|
664
|
+
|
|
665
|
+
await assert.rejects(
|
|
666
|
+
() => runQuarantine(ctxB, SESSION_B, { scope: 'created' }, planA),
|
|
667
|
+
(error) => error && error.status === 400,
|
|
668
|
+
'a plan carries its owner, so it cannot be replayed against a neighbour',
|
|
669
|
+
)
|
|
670
|
+
assert.equal(fs.existsSync(aOnly), true, "the other session's file was never moved")
|
|
671
|
+
assert.equal(fs.existsSync(bOnly), true, 'and neither was this one')
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
// --- the configurable save path ---------------------------------------------
|
|
675
|
+
//
|
|
676
|
+
// A user-chosen quarantine root must never be a place where "quarantining" means
|
|
677
|
+
// shuffling the files back into the tree they came from, or into the store DSH
|
|
678
|
+
// sweeps during a delete.
|
|
679
|
+
|
|
680
|
+
test('an empty save path falls back to the DSH home', async () => {
|
|
681
|
+
for (const value of [undefined, null, '']) {
|
|
682
|
+
const { root, error } = await resolveQuarantineRoot(value, undefined)
|
|
683
|
+
assert.equal(error, undefined)
|
|
684
|
+
assert.equal(root, process.env.DSH_HOME)
|
|
685
|
+
}
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
test('a relative save path is refused', async () => {
|
|
689
|
+
const { root, error } = await resolveQuarantineRoot('some/where', undefined)
|
|
690
|
+
assert.equal(root, undefined)
|
|
691
|
+
assert.match(error, /绝对路径/)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
test('a save path that does not exist is refused', async () => {
|
|
695
|
+
const missing = path.join(scratch('parent'), 'not-here')
|
|
696
|
+
const { root, error } = await resolveQuarantineRoot(missing, undefined)
|
|
697
|
+
assert.equal(root, undefined)
|
|
698
|
+
assert.match(error, /不存在/)
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
test('a save path that is a file is refused', async () => {
|
|
702
|
+
const file = writeAt(scratch('parent'), 'a-file.txt')
|
|
703
|
+
const { root, error } = await resolveQuarantineRoot(file, undefined)
|
|
704
|
+
assert.equal(root, undefined)
|
|
705
|
+
assert.match(error, /不是目录/)
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
test('a save path inside the sessions store is refused', async () => {
|
|
709
|
+
const sessions = path.join(process.env.DSH_HOME, 'sessions')
|
|
710
|
+
fs.mkdirSync(sessions, { recursive: true })
|
|
711
|
+
const { root, error } = await resolveQuarantineRoot(sessions, undefined)
|
|
712
|
+
assert.equal(root, undefined)
|
|
713
|
+
assert.match(error, /会话存储目录/)
|
|
714
|
+
|
|
715
|
+
const nested = path.join(sessions, 'deep')
|
|
716
|
+
fs.mkdirSync(nested, { recursive: true })
|
|
717
|
+
assert.match((await resolveQuarantineRoot(nested, undefined)).error, /会话存储目录/)
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
test('a save path inside the session working directory is refused', async () => {
|
|
721
|
+
const cwd = scratch('cwd')
|
|
722
|
+
const inside = path.join(cwd, 'nested')
|
|
723
|
+
fs.mkdirSync(inside)
|
|
724
|
+
assert.match((await resolveQuarantineRoot(cwd, cwd)).error, /工作目录/)
|
|
725
|
+
assert.match((await resolveQuarantineRoot(inside, cwd)).error, /工作目录/)
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
test('a usable save path comes back resolved', async () => {
|
|
729
|
+
const chosen = scratch('chosen')
|
|
730
|
+
const { root, error } = await resolveQuarantineRoot(chosen, scratch('cwd'))
|
|
731
|
+
assert.equal(error, undefined)
|
|
732
|
+
assert.equal(root, fs.realpathSync(chosen))
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
test('the picker lists directories only, sorted, with a parent', async () => {
|
|
736
|
+
const dir = scratch('browse')
|
|
737
|
+
fs.mkdirSync(path.join(dir, 'zeta'))
|
|
738
|
+
fs.mkdirSync(path.join(dir, 'alpha'))
|
|
739
|
+
writeAt(dir, 'a-file.txt')
|
|
740
|
+
|
|
741
|
+
const listing = await listDirectories(dir)
|
|
742
|
+
assert.deepEqual(listing.entries.map((entry) => entry.name), ['alpha', 'zeta'], 'folders only, alphabetical')
|
|
743
|
+
assert.equal(listing.path, fs.realpathSync(dir))
|
|
744
|
+
assert.equal(listing.parent, path.dirname(fs.realpathSync(dir)))
|
|
745
|
+
assert.equal(listing.defaultRoot, process.env.DSH_HOME)
|
|
746
|
+
assert.deepEqual(listing.roots, [], 'no drive list below a drive root')
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
test('the picker reports a drive root without a parent and offers the drives', async () => {
|
|
750
|
+
const root = path.parse(process.cwd()).root
|
|
751
|
+
const listing = await listDirectories(root)
|
|
752
|
+
assert.equal(listing.parent, null)
|
|
753
|
+
if (process.platform === 'win32') assert.ok(listing.roots.length > 0, 'the drives are offered at the root')
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
test('the picker refuses a missing directory instead of throwing raw', async () => {
|
|
757
|
+
await assert.rejects(
|
|
758
|
+
() => listDirectories(path.join(scratch('parent'), 'nope')),
|
|
759
|
+
(error) => error && error.status === 400 && /不存在/.test(error.message),
|
|
760
|
+
)
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
test('a chosen save path is actually used for the move', async () => {
|
|
764
|
+
const cwd = scratch('cwd')
|
|
765
|
+
const chosen = scratch('chosen')
|
|
766
|
+
const file = writeAt(cwd, 'mine.md', 'x')
|
|
767
|
+
const outcome = await quarantineFiles([file], cwd, chosen)
|
|
768
|
+
|
|
769
|
+
assert.equal(outcome.failed.length, 0)
|
|
770
|
+
assert.ok(outcome.directory.startsWith(fs.realpathSync(chosen)), 'the folder landed under the chosen path')
|
|
771
|
+
assert.equal(fs.readFileSync(path.join(outcome.directory, 'mine.md'), 'utf8'), 'x')
|
|
772
|
+
assert.equal(fs.existsSync(file), false)
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
// --- deletion is undoable ---------------------------------------------------
|
|
776
|
+
//
|
|
777
|
+
// The Recycle Bin itself is a Windows shell operation and cannot run in a unit
|
|
778
|
+
// test, so these cover the fallback that runs when it is unavailable. The point
|
|
779
|
+
// they pin down is the one that matters: nothing is unlinked, everything moves.
|
|
780
|
+
|
|
781
|
+
test('a retired path is moved aside, never destroyed', async () => {
|
|
782
|
+
const source = path.join(scratch('retire'), 'session-abc')
|
|
783
|
+
fs.mkdirSync(path.join(source, 'nested'), { recursive: true })
|
|
784
|
+
fs.writeFileSync(path.join(source, 'nested', 'log.jsonl'), 'the transcript')
|
|
785
|
+
|
|
786
|
+
const moved = await internals.moveToSessionTrash([source])
|
|
787
|
+
|
|
788
|
+
assert.deepEqual(moved, [source], 'the path was handled')
|
|
789
|
+
assert.equal(fs.existsSync(source), false, 'it left its original location')
|
|
790
|
+
const trash = fs.readdirSync(HOME_ROOT).find((name) => name.startsWith('session-trash-'))
|
|
791
|
+
assert.ok(trash, 'a trash directory was created under the DSH home')
|
|
792
|
+
const restored = path.join(HOME_ROOT, trash, 'session-abc', 'nested', 'log.jsonl')
|
|
793
|
+
assert.equal(fs.readFileSync(restored, 'utf8'), 'the transcript', 'the content survived intact')
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
test('retiring two paths with the same name keeps both copies', async () => {
|
|
797
|
+
const first = path.join(scratch('dup-a'), 'session-same')
|
|
798
|
+
const second = path.join(scratch('dup-b'), 'session-same')
|
|
799
|
+
for (const dir of [first, second]) {
|
|
800
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
801
|
+
fs.writeFileSync(path.join(dir, 'marker.txt'), dir)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
await internals.moveToSessionTrash([first])
|
|
805
|
+
await internals.moveToSessionTrash([second])
|
|
806
|
+
|
|
807
|
+
// Each retire stages into its own folder, so the copies are searched across all
|
|
808
|
+
// of them rather than assumed to share one.
|
|
809
|
+
const trashes = fs.readdirSync(HOME_ROOT).filter((name) => name.startsWith('session-trash-'))
|
|
810
|
+
const markers = []
|
|
811
|
+
for (const trash of trashes) {
|
|
812
|
+
for (const name of fs.readdirSync(path.join(HOME_ROOT, trash))) {
|
|
813
|
+
if (!name.startsWith('session-same')) continue
|
|
814
|
+
markers.push(fs.readFileSync(path.join(HOME_ROOT, trash, name, 'marker.txt'), 'utf8'))
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
assert.equal(markers.length, 2, 'the second retire did not overwrite the first')
|
|
818
|
+
assert.deepEqual(markers.sort(), [first, second].sort(), 'each copy kept its own original')
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
// --- skipping files that live in a git working tree -------------------------
|
|
822
|
+
|
|
823
|
+
/** A tree with one git project and one plain folder beside it. */
|
|
824
|
+
function gitTree() {
|
|
825
|
+
const root = scratch('git-tree')
|
|
826
|
+
fs.mkdirSync(path.join(root, 'proj', '.git'), { recursive: true })
|
|
827
|
+
fs.mkdirSync(path.join(root, 'proj', 'src'), { recursive: true })
|
|
828
|
+
fs.mkdirSync(path.join(root, 'docs'), { recursive: true })
|
|
829
|
+
const code = path.join(root, 'proj', 'src', 'app.js')
|
|
830
|
+
const note = path.join(root, 'docs', 'note.md')
|
|
831
|
+
fs.writeFileSync(code, 'source')
|
|
832
|
+
fs.writeFileSync(note, 'prose')
|
|
833
|
+
const rows = new Map([
|
|
834
|
+
[code, { path: code, ops: ['write'] }],
|
|
835
|
+
[note, { path: note, ops: ['write'] }],
|
|
836
|
+
])
|
|
837
|
+
return { root, code, note, rows }
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
test('skipGit leaves a repository alone and still handles the plain folder', async () => {
|
|
841
|
+
const tree = gitTree()
|
|
842
|
+
const { allowed, refused } = await internals.classifyPaths(tree.rows, tree.root, HOME_ROOT, { skipGit: true })
|
|
843
|
+
|
|
844
|
+
assert.deepEqual(allowed.map((row) => row.path), [fs.realpathSync(tree.note)], 'the prose file is still available')
|
|
845
|
+
assert.equal(refused.length, 1)
|
|
846
|
+
assert.equal(refused[0].path, tree.code)
|
|
847
|
+
assert.match(refused[0].reason, /git/)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
test('without skipGit the repository file is handled like any other', async () => {
|
|
851
|
+
const tree = gitTree()
|
|
852
|
+
const { allowed, refused } = await internals.classifyPaths(tree.rows, tree.root, HOME_ROOT, { skipGit: false })
|
|
853
|
+
|
|
854
|
+
assert.equal(allowed.length, 2, 'both files are candidates')
|
|
855
|
+
assert.equal(refused.length, 0)
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
test('a .git file (worktree or submodule) counts as a repository too', async () => {
|
|
859
|
+
const root = scratch('git-file')
|
|
860
|
+
fs.mkdirSync(path.join(root, 'wt'), { recursive: true })
|
|
861
|
+
// A worktree points at its git dir with a file, not a directory.
|
|
862
|
+
fs.writeFileSync(path.join(root, 'wt', '.git'), 'gitdir: C:/elsewhere')
|
|
863
|
+
const file = path.join(root, 'wt', 'index.js')
|
|
864
|
+
fs.writeFileSync(file, 'x')
|
|
865
|
+
|
|
866
|
+
const rows = new Map([[file, { path: file, ops: ['write'] }]])
|
|
867
|
+
const { allowed } = await internals.classifyPaths(rows, root, HOME_ROOT, { skipGit: true })
|
|
868
|
+
assert.equal(allowed.length, 0, 'the worktree file is skipped')
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
// --- the system trash on macOS and Linux ------------------------------------
|
|
872
|
+
|
|
873
|
+
test('macOS deletes through Finder, one AppleScript statement per path', () => {
|
|
874
|
+
const command = internals.trashCommandFor('darwin', ['/Users/x/session-a', '/Users/x/session-b'])
|
|
875
|
+
|
|
876
|
+
assert.equal(command.program, 'osascript')
|
|
877
|
+
assert.equal(command.args.length, 4, 'two -e flags, one statement each')
|
|
878
|
+
assert.equal(command.args[0], '-e')
|
|
879
|
+
assert.equal(command.args[2], '-e')
|
|
880
|
+
assert.match(command.args[1], /^tell application "Finder" to delete \(POSIX file "/)
|
|
881
|
+
assert.match(command.args[1], /session-a"\)$/)
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
test('the AppleScript literal escapes the path it is given', () => {
|
|
885
|
+
// An unescaped quote would end the string early and turn the path into code.
|
|
886
|
+
const command = internals.trashCommandFor('darwin', ['C:\\tmp\\a"b'])
|
|
887
|
+
const script = command.args[1]
|
|
888
|
+
|
|
889
|
+
assert.ok(script.includes('C:\\\\tmp'), 'backslashes are doubled for AppleScript')
|
|
890
|
+
assert.ok(script.includes('\\"b'), 'double quotes are escaped')
|
|
891
|
+
assert.ok(script.endsWith('")'), 'the statement still closes')
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
test('Linux delegates to gio trash with every path as one argument', () => {
|
|
895
|
+
const command = internals.trashCommandFor('linux', ['/home/x/session-a', '/home/x/session-a.json'])
|
|
896
|
+
|
|
897
|
+
assert.equal(command.program, 'gio')
|
|
898
|
+
assert.deepEqual(command.args, ['trash', '/home/x/session-a', '/home/x/session-a.json'])
|
|
899
|
+
assert.equal(command.args.slice(1).length, 2, 'no shell, so a path cannot become an option list')
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
test('no paths means nothing to ask the platform for', async () => {
|
|
903
|
+
assert.equal(await internals.sendToRecycleBin([]), true, 'an empty retire still reports success')
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
test('warming the compiled helpers never throws before anything is cached', () => {
|
|
907
|
+
// Fire-and-forget: a synchronous throw here would abort plugin loading.
|
|
908
|
+
assert.doesNotThrow(() => internals.warmCompiledHelpers())
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
// --- permanent delete is an explicit choice, not the default ---------------
|
|
912
|
+
|
|
913
|
+
test('removeForever takes the path off the disk and stages nothing', () => {
|
|
914
|
+
const dir = path.join(scratch('forever'), 'session-gone')
|
|
915
|
+
fs.mkdirSync(path.join(dir, 'nested'), { recursive: true })
|
|
916
|
+
fs.writeFileSync(path.join(dir, 'nested', 'log'), 'x')
|
|
917
|
+
const before = fs.readdirSync(HOME_ROOT).filter((name) => name.startsWith('session-trash-')).length
|
|
918
|
+
|
|
919
|
+
const gone = internals.removeForever([dir])
|
|
920
|
+
|
|
921
|
+
assert.deepEqual(gone, [dir], 'the path is reported as removed')
|
|
922
|
+
assert.equal(fs.existsSync(dir), false, 'it is no longer on disk')
|
|
923
|
+
const after = fs.readdirSync(HOME_ROOT).filter((name) => name.startsWith('session-trash-')).length
|
|
924
|
+
assert.equal(after, before, 'a permanent delete leaves no staging copy behind')
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
test('removeForever reports only what actually went away', () => {
|
|
928
|
+
const missing = path.join(scratch('forever2'), 'never-existed')
|
|
929
|
+
const gone = internals.removeForever([missing])
|
|
930
|
+
assert.deepEqual(gone, [], 'a path that was never there is not claimed as deleted')
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
test('the switch decides between staging for the bin and removing outright', async () => {
|
|
934
|
+
const root = scratch('switch')
|
|
935
|
+
|
|
936
|
+
const kept = path.join(root, 'recycled', 'session-keep')
|
|
937
|
+
fs.mkdirSync(kept, { recursive: true })
|
|
938
|
+
fs.writeFileSync(path.join(kept, 'log'), 'x')
|
|
939
|
+
const stagedBefore = fs.readdirSync(HOME_ROOT).filter((n) => n.startsWith('session-trash-')).length
|
|
940
|
+
await internals.retireTargets([kept], true)
|
|
941
|
+
assert.equal(fs.existsSync(kept), false, 'the path left its original location')
|
|
942
|
+
const stagedAfter = fs.readdirSync(HOME_ROOT).filter((n) => n.startsWith('session-trash-')).length
|
|
943
|
+
assert.equal(stagedAfter, stagedBefore + 1, 'recycling stages a copy the shell can pick up')
|
|
944
|
+
|
|
945
|
+
const gone = path.join(root, 'removed', 'session-gone')
|
|
946
|
+
fs.mkdirSync(gone, { recursive: true })
|
|
947
|
+
fs.writeFileSync(path.join(gone, 'log'), 'x')
|
|
948
|
+
const stagedBeforePermanent = fs.readdirSync(HOME_ROOT).filter((n) => n.startsWith('session-trash-')).length
|
|
949
|
+
await internals.retireTargets([gone], false)
|
|
950
|
+
assert.equal(fs.existsSync(gone), false, 'the path is gone')
|
|
951
|
+
const stagedAfterPermanent = fs.readdirSync(HOME_ROOT).filter((n) => n.startsWith('session-trash-')).length
|
|
952
|
+
assert.equal(stagedAfterPermanent, stagedBeforePermanent, 'a permanent delete stages nothing')
|
|
953
|
+
})
|