@objectivex666/dsh-settings-search 1.11.0 → 1.13.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/README.en.md +8 -6
- package/README.md +8 -6
- package/lib/client.js +970 -95
- package/lib/index.js +448 -11
- package/package.json +5 -4
- package/scripts/ai.test.mjs +390 -5
- package/scripts/check.mjs +28 -0
- package/scripts/log.test.mjs +345 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-half log store regression tests (`npm test`).
|
|
3
|
+
*
|
|
4
|
+
* The store owns the daily files behind the panel's 「查看日志 / 打开日志文件夹」
|
|
5
|
+
* actions, so the invariants under test are the ones a reader depends on: one
|
|
6
|
+
* file per local day with the date in its name, one line per entry, no forged
|
|
7
|
+
* entries, a per-file cap that continues in a numbered part, a folder budget
|
|
8
|
+
* that never deletes the active file, and a tail that never starts mid-line.
|
|
9
|
+
*/
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { basename, join } from 'node:path'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import test from 'node:test'
|
|
15
|
+
import { apply, createLogRoute, createLogStore, folderLaunchCommand, inject } from '../lib/index.js'
|
|
16
|
+
|
|
17
|
+
const withStore = (options = {}) => {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), 'sss-log-'))
|
|
19
|
+
return {
|
|
20
|
+
dir,
|
|
21
|
+
store: createLogStore({ dir, ...options }),
|
|
22
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A day whose local stamp the store will write into the filename. */
|
|
27
|
+
const day = (iso, hour = '09:00:00') => new Date(`${iso}T${hour}`)
|
|
28
|
+
|
|
29
|
+
test('one file per local day, named with that date', () => {
|
|
30
|
+
const { dir, store, cleanup } = withStore({ now: () => day('2026-10-06', '23:30:00') })
|
|
31
|
+
try {
|
|
32
|
+
store.append(['entry'])
|
|
33
|
+
assert.equal(basename(store.file), 'settings-search-2026-10-06.log')
|
|
34
|
+
assert.equal(store.dir, dir)
|
|
35
|
+
} finally {
|
|
36
|
+
cleanup()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('a new day starts a new file while the previous day is kept', () => {
|
|
41
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-05') })
|
|
42
|
+
try {
|
|
43
|
+
store.append(['yesterday'])
|
|
44
|
+
const yesterday = store.file
|
|
45
|
+
assert.equal(basename(yesterday), 'settings-search-2026-10-05.log')
|
|
46
|
+
// Roll the clock over by rebuilding the store against the same folder.
|
|
47
|
+
const next = createLogStore({ dir: store.dir, now: () => day('2026-10-06') })
|
|
48
|
+
next.append(['today'])
|
|
49
|
+
assert.equal(basename(next.file), 'settings-search-2026-10-06.log')
|
|
50
|
+
assert.equal(readFileSync(yesterday, 'utf8'), 'yesterday\n')
|
|
51
|
+
assert.equal(next.count(), 1, 'the count follows the new day')
|
|
52
|
+
} finally {
|
|
53
|
+
cleanup()
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('the log store appends one line per entry and reads them back', () => {
|
|
58
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-06') })
|
|
59
|
+
try {
|
|
60
|
+
assert.equal(store.append(['first', 'second']), 2)
|
|
61
|
+
assert.equal(store.append([]), 0)
|
|
62
|
+
assert.equal(store.append('not-an-array'), 0)
|
|
63
|
+
const read = store.read()
|
|
64
|
+
assert.equal(read.text, 'first\nsecond\n')
|
|
65
|
+
assert.equal(read.truncated, false)
|
|
66
|
+
assert.equal(read.size, 13)
|
|
67
|
+
} finally {
|
|
68
|
+
cleanup()
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('embedded newlines cannot forge entries and one line stays capped', () => {
|
|
73
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-06') })
|
|
74
|
+
try {
|
|
75
|
+
assert.equal(store.append(['one\ntwo']), 1)
|
|
76
|
+
assert.equal(store.append([`x${'y'.repeat(4000)}`]), 1)
|
|
77
|
+
const lines = store.read().text.split('\n').filter((line) => line !== '')
|
|
78
|
+
assert.equal(lines.length, 2)
|
|
79
|
+
assert.equal(lines[0], 'one two')
|
|
80
|
+
assert.equal(lines[1].length, 1000)
|
|
81
|
+
} finally {
|
|
82
|
+
cleanup()
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('a day that passes its per-file cap continues in a numbered part', () => {
|
|
87
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-06'), maxBytes: 40 })
|
|
88
|
+
try {
|
|
89
|
+
store.append(['x'.repeat(20)])
|
|
90
|
+
const base = store.file
|
|
91
|
+
assert.match(basename(base), /^settings-search-2026-10-06\.log$/)
|
|
92
|
+
store.append(['y'.repeat(20)])
|
|
93
|
+
const part = store.file
|
|
94
|
+
assert.match(basename(part), /^settings-search-2026-10-06\.1\.log$/)
|
|
95
|
+
store.append(['z'])
|
|
96
|
+
assert.equal(readFileSync(base, 'utf8'), `${'x'.repeat(20)}\n${'y'.repeat(20)}\n`)
|
|
97
|
+
assert.equal(readFileSync(part, 'utf8'), 'z\n')
|
|
98
|
+
} finally {
|
|
99
|
+
cleanup()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('the folder is pruned to its budget, never deleting the active file', () => {
|
|
104
|
+
const { dir, store, cleanup } = withStore({ now: () => day('2026-10-04'), maxTotalBytes: 40 })
|
|
105
|
+
try {
|
|
106
|
+
store.append(['a'.repeat(20)])
|
|
107
|
+
const first = store.file
|
|
108
|
+
const second = createLogStore({ dir, now: () => day('2026-10-05'), maxTotalBytes: 40 })
|
|
109
|
+
second.append(['b'.repeat(20)])
|
|
110
|
+
assert.equal(existsSync(first), false, 'the oldest file goes once the folder passes its budget')
|
|
111
|
+
assert.equal(existsSync(second.file), true)
|
|
112
|
+
|
|
113
|
+
const third = createLogStore({ dir, now: () => day('2026-10-06'), maxTotalBytes: 40 })
|
|
114
|
+
third.append(['c'])
|
|
115
|
+
assert.equal(existsSync(second.file), true, 'a file inside the budget survives')
|
|
116
|
+
assert.equal(existsSync(third.file), true, 'the active file is never pruned')
|
|
117
|
+
} finally {
|
|
118
|
+
cleanup()
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('the pre-daily file is folded into today the first time the store runs', () => {
|
|
123
|
+
const { dir, store, cleanup } = withStore({ now: () => day('2026-10-06') })
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(join(dir, 'settings-search.log'), 'old\nline\n', 'utf8')
|
|
126
|
+
assert.equal(store.read().text, 'old\nline\n')
|
|
127
|
+
assert.equal(existsSync(join(dir, 'settings-search.log')), false)
|
|
128
|
+
assert.equal(basename(store.file), 'settings-search-2026-10-06.log')
|
|
129
|
+
assert.equal(store.count(), 2)
|
|
130
|
+
} finally {
|
|
131
|
+
cleanup()
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('a pre-daily leftover file counts as an old file and is pruned first', () => {
|
|
136
|
+
const { dir, store, cleanup } = withStore({ now: () => day('2026-10-06'), maxTotalBytes: 30 })
|
|
137
|
+
try {
|
|
138
|
+
const leftover = join(dir, 'settings-search.log.1')
|
|
139
|
+
writeFileSync(leftover, `${'legacy'.repeat(6)}\n`, 'utf8')
|
|
140
|
+
store.append(['fresh'])
|
|
141
|
+
assert.equal(existsSync(leftover), false)
|
|
142
|
+
assert.equal(existsSync(store.file), true)
|
|
143
|
+
} finally {
|
|
144
|
+
cleanup()
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('the tail read drops the partial first line and reports truncation', () => {
|
|
149
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-06'), tailBytes: 12 })
|
|
150
|
+
try {
|
|
151
|
+
store.append(['aaaa', 'bbbb', 'cccc', 'dddd'])
|
|
152
|
+
const read = store.read()
|
|
153
|
+
assert.equal(read.truncated, true)
|
|
154
|
+
assert.equal(read.text, 'cccc\ndddd\n')
|
|
155
|
+
} finally {
|
|
156
|
+
cleanup()
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('a short tail read never returns stale buffer contents', () => {
|
|
161
|
+
const sentinel = 'LEAKED-HEAP-BYTES'
|
|
162
|
+
const io = {
|
|
163
|
+
openSync: () => 1,
|
|
164
|
+
closeSync: () => {},
|
|
165
|
+
readSync: (_fd, buffer, offset, length) => {
|
|
166
|
+
// Simulate an uninitialized buffer whose read reports fewer bytes than requested.
|
|
167
|
+
for (let at = 0; at < length; at += sentinel.length) {
|
|
168
|
+
buffer.write(sentinel, at, Math.min(sentinel.length, length - at), 'utf8')
|
|
169
|
+
}
|
|
170
|
+
buffer.write('fresh\n', offset, 'utf8')
|
|
171
|
+
return 'fresh\n'.length
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-06'), tailBytes: 64, io })
|
|
175
|
+
try {
|
|
176
|
+
writeFileSync(store.file, `${'a'.repeat(200)}\n`, 'utf8')
|
|
177
|
+
const read = store.read()
|
|
178
|
+
assert.equal(read.truncated, true)
|
|
179
|
+
assert.equal(read.text, '', 'a short read must not decode the untouched buffer')
|
|
180
|
+
} finally {
|
|
181
|
+
cleanup()
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('clear truncates the active file and removes the older daily files', () => {
|
|
186
|
+
const { dir, store, cleanup } = withStore({ now: () => day('2026-10-05') })
|
|
187
|
+
try {
|
|
188
|
+
store.append(['yesterday'])
|
|
189
|
+
const yesterday = store.file
|
|
190
|
+
const today = createLogStore({ dir, now: () => day('2026-10-06') })
|
|
191
|
+
today.append(['today'])
|
|
192
|
+
assert.equal(today.clear(), true)
|
|
193
|
+
assert.equal(statSync(today.file).size, 0)
|
|
194
|
+
assert.equal(existsSync(yesterday), false)
|
|
195
|
+
assert.equal(today.count(), 0)
|
|
196
|
+
assert.deepEqual(readdirSync(dir).filter((name) => name.startsWith('settings-search-')), ['settings-search-2026-10-06.log'])
|
|
197
|
+
} finally {
|
|
198
|
+
cleanup()
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('count reports the entries of the active day', () => {
|
|
203
|
+
const { dir, store, cleanup } = withStore({ now: () => day('2026-10-05') })
|
|
204
|
+
try {
|
|
205
|
+
assert.equal(store.count(), 0, 'a missing file counts zero')
|
|
206
|
+
store.append(['a', 'b'])
|
|
207
|
+
assert.equal(store.count(), 2)
|
|
208
|
+
const next = createLogStore({ dir, now: () => day('2026-10-06') })
|
|
209
|
+
assert.equal(next.count(), 0, 'a new day counts from zero')
|
|
210
|
+
next.append(['c'])
|
|
211
|
+
assert.equal(next.count(), 1)
|
|
212
|
+
next.clear()
|
|
213
|
+
assert.equal(next.count(), 0)
|
|
214
|
+
} finally {
|
|
215
|
+
cleanup()
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('a fresh store reads the count of a file written by an earlier run', () => {
|
|
220
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-06') })
|
|
221
|
+
try {
|
|
222
|
+
store.append(['one', 'two', 'three'])
|
|
223
|
+
const reopened = createLogStore({ dir: store.dir, now: () => day('2026-10-06') })
|
|
224
|
+
assert.equal(reopened.count(), 3)
|
|
225
|
+
assert.equal(reopened.count(), 3, 'the count is cached after the first read')
|
|
226
|
+
} finally {
|
|
227
|
+
cleanup()
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const send = async (route, method, body, query = '') => {
|
|
232
|
+
const request = new Request(`http://dsh.internal${route.path}${query}`, {
|
|
233
|
+
method,
|
|
234
|
+
...(body === undefined
|
|
235
|
+
? {}
|
|
236
|
+
: { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
|
|
237
|
+
})
|
|
238
|
+
const response = await route.fetch(request)
|
|
239
|
+
return { status: response.status, body: await response.json() }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
test('the log route answers the entry count with and without the file tail', async () => {
|
|
243
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-06') })
|
|
244
|
+
const route = createLogRoute(store)
|
|
245
|
+
try {
|
|
246
|
+
await send(route, 'POST', { action: 'append', lines: ['a', 'b'] })
|
|
247
|
+
const appended = await send(route, 'POST', { action: 'append', lines: ['c'] })
|
|
248
|
+
assert.equal(appended.body.count, 3)
|
|
249
|
+
|
|
250
|
+
const full = await send(route, 'GET')
|
|
251
|
+
assert.equal(full.body.count, 3)
|
|
252
|
+
assert.equal(full.body.text, 'a\nb\nc\n')
|
|
253
|
+
|
|
254
|
+
const countOnly = await send(route, 'GET', undefined, '?count=1')
|
|
255
|
+
assert.equal(countOnly.body.count, 3)
|
|
256
|
+
assert.equal(countOnly.body.text, undefined, 'the count-only read must not transfer the tail')
|
|
257
|
+
|
|
258
|
+
const cleared = await send(route, 'POST', { action: 'clear' })
|
|
259
|
+
assert.equal(cleared.body.count, 0)
|
|
260
|
+
} finally {
|
|
261
|
+
cleanup()
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('the log route serves the tail and accepts append/clear only', async () => {
|
|
266
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-06') })
|
|
267
|
+
const route = createLogRoute(store)
|
|
268
|
+
try {
|
|
269
|
+
assert.equal(route.path, '/api/settings-search-log')
|
|
270
|
+
assert.deepEqual(route.methods, ['GET', 'POST'])
|
|
271
|
+
assert.equal(route.requestBody, 'buffered')
|
|
272
|
+
|
|
273
|
+
const appended = await send(route, 'POST', { action: 'append', lines: ['hello'] })
|
|
274
|
+
assert.equal(appended.status, 200)
|
|
275
|
+
assert.equal(appended.body.ok, true)
|
|
276
|
+
assert.equal(appended.body.written, 1)
|
|
277
|
+
|
|
278
|
+
const read = await send(route, 'GET')
|
|
279
|
+
assert.equal(read.body.ok, true)
|
|
280
|
+
assert.equal(read.body.text, 'hello\n')
|
|
281
|
+
assert.equal(read.body.file.endsWith('settings-search-2026-10-06.log'), true)
|
|
282
|
+
assert.equal(read.body.dir, store.dir)
|
|
283
|
+
|
|
284
|
+
assert.equal((await send(route, 'POST', { action: 'nope' })).status, 400)
|
|
285
|
+
assert.equal((await send(route, 'POST')).status, 400)
|
|
286
|
+
assert.equal((await send(route, 'POST', { action: 'clear' })).body.ok, true)
|
|
287
|
+
assert.equal((await send(route, 'GET')).body.text, '')
|
|
288
|
+
} finally {
|
|
289
|
+
cleanup()
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('the folder launcher uses the OS shell open verb, never a hidden file manager', () => {
|
|
294
|
+
const win = folderLaunchCommand('win32', 'C:\\logs\\dsh-settings-search')
|
|
295
|
+
assert.equal(win.command, 'cmd.exe')
|
|
296
|
+
assert.deepEqual(win.args, ['/c', 'start', '', 'C:\\logs\\dsh-settings-search'])
|
|
297
|
+
// Only cmd's own console may hide: starting explorer.exe with CREATE_NO_WINDOW
|
|
298
|
+
// produces a window that never becomes visible.
|
|
299
|
+
assert.equal(win.hideWindow, true)
|
|
300
|
+
assert.deepEqual(folderLaunchCommand('darwin', '/tmp/dsh'), { command: 'open', args: ['/tmp/dsh'], hideWindow: false })
|
|
301
|
+
assert.deepEqual(folderLaunchCommand('linux', '/tmp/dsh'), { command: 'xdg-open', args: ['/tmp/dsh'], hideWindow: false })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('the open action reports the launcher outcome and records the attempt', async () => {
|
|
305
|
+
const { store, cleanup } = withStore({ now: () => day('2026-10-06') })
|
|
306
|
+
try {
|
|
307
|
+
const failing = createLogRoute({ ...store, open: async () => ({ failure: 'boom', command: 'cmd.exe' }) })
|
|
308
|
+
const failed = await send(failing, 'POST', { action: 'open' })
|
|
309
|
+
assert.equal(failed.status, 500)
|
|
310
|
+
assert.equal(failed.body.reason, 'open-failed')
|
|
311
|
+
assert.equal(failed.body.detail, 'boom')
|
|
312
|
+
assert.match(store.read().text, /\[host\] 打开日志文件夹 failed \(cmd\.exe\): boom/)
|
|
313
|
+
|
|
314
|
+
const succeeding = createLogRoute({ ...store, open: async () => ({ failure: undefined, command: 'cmd.exe' }) })
|
|
315
|
+
const opened = await send(succeeding, 'POST', { action: 'open' })
|
|
316
|
+
assert.equal(opened.body.ok, true)
|
|
317
|
+
assert.equal(opened.body.opened, true)
|
|
318
|
+
assert.equal(opened.body.command, 'cmd.exe')
|
|
319
|
+
assert.match(store.read().text, /\[host\] 打开日志文件夹 ok \(cmd\.exe\)/)
|
|
320
|
+
} finally {
|
|
321
|
+
cleanup()
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('the host half registers the route on the connection fetch registry', () => {
|
|
326
|
+
const dir = mkdtempSync(join(tmpdir(), 'sss-log-apply-'))
|
|
327
|
+
const previous = process.env.DSH_SETTINGS_SEARCH_LOG_DIR
|
|
328
|
+
process.env.DSH_SETTINGS_SEARCH_LOG_DIR = dir
|
|
329
|
+
let registered = null
|
|
330
|
+
let disposed = false
|
|
331
|
+
try {
|
|
332
|
+
apply({
|
|
333
|
+
connection: { fetch: { register: (route) => { registered = route; return () => { disposed = true } } } },
|
|
334
|
+
effect: (callback) => { const dispose = callback(); return () => { dispose?.(); disposed = true } },
|
|
335
|
+
})
|
|
336
|
+
assert.deepEqual(inject, ['connection'])
|
|
337
|
+
assert.equal(registered.path, '/api/settings-search-log')
|
|
338
|
+
assert.equal(typeof registered.fetch, 'function')
|
|
339
|
+
assert.equal(disposed, false)
|
|
340
|
+
} finally {
|
|
341
|
+
if (previous === undefined) delete process.env.DSH_SETTINGS_SEARCH_LOG_DIR
|
|
342
|
+
else process.env.DSH_SETTINGS_SEARCH_LOG_DIR = previous
|
|
343
|
+
rmSync(dir, { recursive: true, force: true })
|
|
344
|
+
}
|
|
345
|
+
})
|