@objectivex666/dsh-settings-search 1.12.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.
@@ -13,6 +13,8 @@ const instrumented = source.replace(hook, `
13
13
  requestUserAi, requestAiKeywords, testAiService, LOGS,
14
14
  modelsUrl, fetchAiModels,
15
15
  readIndexCache, serializeHarvest,
16
+ readDshModelConfig, otherPluginFingerprint, DEEP_SLOTS,
17
+ log, flushLogs, discardPendingLogs, sessionLogText, logTarget, readLogCount,
16
18
  })
17
19
  ${hook}`)
18
20
  const storage = () => {
@@ -321,13 +323,17 @@ for (const cfg of [ollama, openai, anthropic]) {
321
323
  const { api, sandbox, calls } = load(abortedFetch)
322
324
  let expire
323
325
  let cleared = false
326
+ const deadline = cfg.provider === 'ollama' ? 120000 : 12000
327
+ const delays = []
324
328
  sandbox.setTimeout = (callback, ms) => {
325
- assert.equal(ms, cfg.provider === 'ollama' ? 120000 : 12000)
326
- expire = callback
329
+ delays.push(ms)
330
+ // The log batch timer shares this seam, so select the request deadline.
331
+ if (ms === deadline) expire = callback
327
332
  return 1
328
333
  }
329
334
  sandbox.clearTimeout = () => { cleared = true }
330
335
  const result = api.testAiService(cfg)
336
+ assert.ok(delays.includes(deadline), `the request must use a ${deadline}ms deadline, saw ${delays.join('/')}`)
331
337
  expire()
332
338
  assert.equal((await result).code, 'timeout')
333
339
  assert.equal(calls[0].signal.aborted, true)
@@ -377,3 +383,268 @@ test('cache respects settings labels and is invalidated when provider configurat
377
383
  assert.equal(calls.length, 4)
378
384
  assert.equal(calls[3].headers.authorization, `Bearer ${openai.apiKey}`)
379
385
  })
386
+
387
+ // --- DSH default-model import: 0.2 Remote namespaces, the legacy unary client, and failures ---
388
+
389
+ const dshNamespaces = (provider, model, providerProfiles) => ([
390
+ { ns: 'agent-default-model', value: { provider, model, reasoningEffort: 'high' } },
391
+ { ns: 'llm-pi-ai', value: { providers: providerProfiles } },
392
+ ])
393
+ const describeReply = (namespaces) => ({ ok: true, value: { writable: true, hasDocument: true, namespaces } })
394
+
395
+ test('DSH 0.2 remote namespaces supply the default model config without touching credentials', async () => {
396
+ const { api } = load()
397
+ const requested = []
398
+ const ctx = {
399
+ get: (name) => {
400
+ requested.push(name)
401
+ if (name === 'remote.settings') {
402
+ return {
403
+ describe: async () => describeReply(dshNamespaces('openai', 'gpt-x', {
404
+ openai: { api: 'openai-completions', baseURL: 'https://example.test/v1', models: [{ id: 'gpt-x' }] },
405
+ })),
406
+ }
407
+ }
408
+ if (name === 'remote.llm') {
409
+ return {
410
+ listConfigurableProviders: async () => ({
411
+ ok: true,
412
+ value: [{ provider: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }],
413
+ }),
414
+ }
415
+ }
416
+ return undefined
417
+ },
418
+ }
419
+ assert.deepEqual(plain(await api.readDshModelConfig(ctx, (key) => key)), {
420
+ provider: 'openai',
421
+ baseUrl: 'https://example.test/v1',
422
+ model: 'gpt-x',
423
+ })
424
+ assert.deepEqual(requested, ['remote.settings', 'remote.llm'])
425
+ })
426
+
427
+ test('the legacy connection.api unary client still supplies the default model config', async () => {
428
+ const { api } = load()
429
+ const ctx = {
430
+ get: (name) => name === 'connection'
431
+ ? {
432
+ api: {
433
+ settings: {
434
+ describe: async (payload) => {
435
+ assert.deepEqual(plain(payload), {})
436
+ return {
437
+ result: {
438
+ ok: true,
439
+ value: {
440
+ namespaces: dshNamespaces('anthropic', 'claude-x', {
441
+ anthropic: { baseURL: 'https://anthropic.test', models: [{ id: 'claude-x', api: 'anthropic-messages' }] },
442
+ }),
443
+ },
444
+ },
445
+ }
446
+ },
447
+ },
448
+ llm: {
449
+ providers: async () => ({
450
+ result: {
451
+ ok: true,
452
+ value: { providers: [{ provider: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'] }] },
453
+ },
454
+ }),
455
+ },
456
+ },
457
+ }
458
+ : undefined,
459
+ }
460
+ assert.deepEqual(plain(await api.readDshModelConfig(ctx, (key) => key)), {
461
+ provider: 'anthropic',
462
+ baseUrl: 'https://anthropic.test',
463
+ model: 'claude-x',
464
+ })
465
+ })
466
+
467
+ test('a missing llm namespace falls back to the provider profile inside the settings namespace', async () => {
468
+ const { api } = load()
469
+ const ctx = {
470
+ get: (name) => name === 'remote.settings'
471
+ ? {
472
+ describe: async () => describeReply(dshNamespaces('ollama', 'qwen3:8b', {
473
+ ollama: { baseURL: 'http://127.0.0.1:11434/v1', models: [{ id: 'qwen3:8b' }] },
474
+ })),
475
+ }
476
+ : undefined,
477
+ }
478
+ assert.deepEqual(plain(await api.readDshModelConfig(ctx, (key) => key)), {
479
+ provider: 'ollama',
480
+ baseUrl: 'http://127.0.0.1:11434/v1',
481
+ model: 'qwen3:8b',
482
+ })
483
+ })
484
+
485
+ test('a failed describe is reported instead of being mistaken for a missing model', async () => {
486
+ const { api } = load()
487
+ const ctx = {
488
+ get: (name) => name === 'remote.settings'
489
+ ? { describe: async () => ({ ok: false, error: { message: 'settings are read-only' } }) }
490
+ : undefined,
491
+ }
492
+ await assert.rejects(api.readDshModelConfig(ctx, (key) => key), { message: 'settings are read-only' })
493
+ })
494
+
495
+ test('a DSH build with no settings API fails with a readable reason instead of a TypeError', async () => {
496
+ const { api } = load()
497
+ await assert.rejects(api.readDshModelConfig({ get: () => undefined }, (key) => key), { message: 'aiDshImportApi' })
498
+ })
499
+
500
+ test('a default model without an importable Base URL names the model, and no default model imports nothing', async () => {
501
+ const { api } = load()
502
+ const account = {
503
+ get: (name) => name === 'remote.settings'
504
+ ? {
505
+ describe: async () => describeReply([
506
+ { ns: 'agent-default-model', value: { provider: 'deepseek-account', model: 'deepseek-flash' } },
507
+ ]),
508
+ }
509
+ : name === 'remote.llm'
510
+ ? {
511
+ listConfigurableProviders: async () => ({
512
+ ok: true,
513
+ value: [{ provider: 'deepseek-account', settingsNs: 'llm-deepseek-account', settingsPath: [] }],
514
+ }),
515
+ }
516
+ : undefined,
517
+ }
518
+ await assert.rejects(api.readDshModelConfig(account, (key) => key), { message: 'aiDshImportNoBaseUrl' })
519
+
520
+ const idle = {
521
+ get: (name) => name === 'remote.settings'
522
+ ? { describe: async () => describeReply([{ ns: 'agent-default-model', value: {} }]) }
523
+ : undefined,
524
+ }
525
+ assert.equal(await api.readDshModelConfig(idle, (key) => key), null)
526
+ })
527
+
528
+ // --- DSH 0.2 adaptation: plugin-inventory fingerprint and in-page seats ---
529
+
530
+ test('the plugin-inventory fingerprint drops this plugin and follows 0.2 bundle/plugin rows', () => {
531
+ const { api } = load()
532
+ const own = { id: '@objectivex666/dsh-settings-search', npm: '@objectivex666/dsh-settings-search', version: '1.13.0' }
533
+ const shell = { id: '@deepseek-ai/dsh-client-ui-shell', npm: '@deepseek-ai/dsh-client-ui-shell', version: '0.2.0-rc.2', enabled: true }
534
+ const baseline = api.otherPluginFingerprint([own, shell])
535
+ assert.equal(api.otherPluginFingerprint([shell]), baseline, 'this plugin must not affect its own index fingerprint')
536
+ assert.notEqual(api.otherPluginFingerprint([own, { ...shell, version: '0.2.1' }]), baseline, 'a plugin update must invalidate')
537
+ assert.notEqual(api.otherPluginFingerprint([own, { ...shell, enabled: false }]), baseline, 'a plugin toggle must invalidate')
538
+ assert.notEqual(api.otherPluginFingerprint([own]), baseline, 'a plugin removal must invalidate')
539
+ assert.equal(api.otherPluginFingerprint([]), api.otherPluginFingerprint([own]))
540
+ // 0.2 plugin rows carry entryId/moduleName rather than id/name.
541
+ assert.notEqual(
542
+ api.otherPluginFingerprint([own, { id: 'ui-theme', npm: '@deepseek-ai/dsh-client-ui-theme', enabled: true }]),
543
+ baseline,
544
+ )
545
+ })
546
+
547
+ test('legacy plugin-card seats stay indexed for pre-0.2 builds only', () => {
548
+ const { api } = load()
549
+ const seats = Object.fromEntries(api.DEEP_SLOTS.map((config) => [config.slot, config.parent]))
550
+ assert.equal(seats['settings.plugins.tab'], 'plugins', 'the Settings to Plugins tab seat is inside the panel and indexed')
551
+ assert.equal(seats['settings.general.item'], 'general')
552
+ assert.equal(seats['settings.plugin.item'], 'plugins', 'legacy seat stays indexed')
553
+ assert.equal(seats['web-ui.plugin.item'], 'web-ui-plugins', 'legacy seat stays indexed')
554
+ // 0.2 moves the plugin cards to its standalone Plugins panel, which the
555
+ // settings-panel navigation cannot reach: indexing it would dead-end.
556
+ assert.equal(seats['plugins.item'], undefined, 'the standalone panel seat must not be indexed')
557
+ })
558
+
559
+ test('the seat fingerprint stays meaningful on DSH 0.2, where the legacy card seats are empty', () => { const { api } = load()
560
+ const pages = [{ options: { id: 'general', order: 0, label: 'General' } }]
561
+ const ctx = {
562
+ effect: (fn) => fn(),
563
+ get: () => null,
564
+ inject: () => {},
565
+ locale: { register: () => {}, bind: () => () => '', subscribe: () => () => {} },
566
+ slots: {
567
+ register: (name, id, options) => ({ name, id, options }),
568
+ inject: (_slot, registrar) => registrar(),
569
+ entries: (slot) => (slot === 'settings.section' ? pages : []),
570
+ subscribe: () => () => {},
571
+ },
572
+ }
573
+ api.apply(ctx)
574
+ const model = api.__indexTestModel
575
+ const before = model.slotsFingerprint()
576
+ assert.notEqual(before, '[]', 'an empty fingerprint would disable every rebuild signal on 0.2')
577
+ pages.push({ options: { id: 'plugins', order: 15, label: 'Plugins' } })
578
+ assert.notEqual(model.slotsFingerprint(), before, 'a settings page appearing must invalidate a stored index')
579
+ })
580
+
581
+ // --- Log recording: the in-memory ring, the batched host write-through, and the viewer fallback ---
582
+
583
+ const logRouteCalls = (calls) => calls.filter((call) => String(call.url).includes('api/settings-search-log'))
584
+
585
+ test('log entries are batched to the host log route as one formatted line each', () => {
586
+ const { api, calls } = load()
587
+ api.log('info', '应用装载', { version: '1.13.0' })
588
+ api.log('error', '导入失败')
589
+ api.flushLogs()
590
+ const [call] = logRouteCalls(calls)
591
+ assert.ok(call, 'the host log route must be posted to')
592
+ assert.equal(call.method, 'POST')
593
+ assert.deepEqual(plain(call.headers), { 'content-type': 'application/json' })
594
+ assert.equal(call.body.action, 'append')
595
+ assert.equal(call.body.lines.length, 2)
596
+ assert.match(call.body.lines[0], /^\d{4}-\d{2}-\d{2}T[\d:.]+Z \[info\] \[[0-9a-f]{8}:1\] 应用装载 \{"version":"1\.13\.0"\}$/)
597
+ assert.match(call.body.lines[1], /\[error\] \[[0-9a-f]{8}:2\] 导入失败$/)
598
+ })
599
+
600
+ test('the debounce timer ships queued lines, and clearing drops them instead', () => {
601
+ const { api, calls, sandbox } = load()
602
+ let fire = null
603
+ let delay = 0
604
+ sandbox.setTimeout = (callback, ms) => { fire = callback; delay = ms; return 1 }
605
+ sandbox.clearTimeout = () => {}
606
+ api.log('info', 'a')
607
+ assert.equal(delay, 1200, 'the batch must be debounced instead of posting per line')
608
+ assert.equal(logRouteCalls(calls).length, 0)
609
+ fire()
610
+ assert.equal(logRouteCalls(calls).length, 1, 'the debounce must ship the queued line')
611
+
612
+ api.log('info', 'b')
613
+ api.discardPendingLogs()
614
+ api.flushLogs()
615
+ assert.equal(logRouteCalls(calls).length, 1, 'a cleared queue must not be shipped')
616
+ })
617
+
618
+ test('the session log text mirrors the on-disk line format for the viewer fallback', () => {
619
+ const { api } = load()
620
+ assert.deepEqual(plain(api.logTarget()), { dir: '', file: '', state: 'idle', detail: '', count: null })
621
+ api.log('info', 'only line')
622
+ assert.match(api.sessionLogText(), /^\d{4}-\d{2}-\d{2}T[\d:.]+Z \[info\] \[[0-9a-f]{8}:1\] only line$/)
623
+ })
624
+
625
+ test('a failing host log channel never throws and never logs about itself', async () => {
626
+ const { api, calls } = load(async () => { throw new TypeError('Failed to fetch') })
627
+ api.log('info', 'first')
628
+ api.flushLogs()
629
+ await new Promise((resolve) => setTimeout(resolve, 0))
630
+ assert.equal(logRouteCalls(calls).length, 1)
631
+ assert.equal(api.logTarget().state, 'fail')
632
+ // The failed transport must not have queued a log line of its own.
633
+ api.flushLogs()
634
+ assert.equal(logRouteCalls(calls).length, 1)
635
+ })
636
+
637
+ test('the badge count follows the host file, not just this session', async () => {
638
+ // The host reports the entries that exist on disk, including earlier runs.
639
+ const { api, calls } = load(async () => json({ ok: true, count: 7, dir: 'D', file: 'F' }))
640
+ assert.equal(await api.readLogCount(), 7)
641
+ const [call] = logRouteCalls(calls)
642
+ assert.equal(call.method, 'GET')
643
+ assert.match(String(call.url), /api\/settings-search-log\?count=1$/)
644
+ assert.deepEqual(plain(api.logTarget()), { dir: 'D', file: 'F', state: 'ok', detail: '', count: 7 })
645
+
646
+ // A count that fails to arrive leaves the badge on the session fallback.
647
+ const { api: offline } = load(async () => { throw new TypeError('Failed to fetch') })
648
+ assert.equal(await offline.readLogCount(), null)
649
+ assert.equal(offline.logTarget().count, null)
650
+ })
package/scripts/check.mjs CHANGED
@@ -60,6 +60,34 @@ check(versionMatch !== null, 'lib/client.js must define const PKG_VERSION')
60
60
  check(versionMatch?.[1] === pkg.version,
61
61
  `PKG_VERSION (${versionMatch?.[1]}) must equal package.json version (${pkg.version})`)
62
62
 
63
+ // 5. theme variables: every token the bundle styles with must exist in the DSH
64
+ // 0.2 theme registry (verified against @deepseek-ai/dsh-client-ui-theme).
65
+ // A token that 0.2 never defines silently falls back to the inherited value.
66
+ const VERIFIED_THEME_TOKENS = new Set([
67
+ '--dsw-alias-bg-layer-1',
68
+ '--dsw-alias-bg-layer-2',
69
+ '--dsw-alias-bg-layer-3',
70
+ '--dsw-alias-bg-mask-1',
71
+ '--dsw-alias-bg-module-platform',
72
+ '--dsw-alias-bg-overlay',
73
+ '--dsw-alias-border-l1',
74
+ '--dsw-alias-border-l2',
75
+ '--dsw-alias-brand-primary',
76
+ '--dsw-alias-interactive-bg-hover',
77
+ '--dsw-alias-label-dimmed',
78
+ '--dsw-alias-label-primary',
79
+ '--dsw-alias-label-secondary',
80
+ '--dsw-alias-label-tertiary',
81
+ '--dsw-alias-state-error-primary',
82
+ '--dsw-alias-state-success-primary',
83
+ '--dsw-shadow-lv2',
84
+ '--dsw-shadow-lv3',
85
+ ])
86
+ for (const token of new Set([...source.matchAll(/--dsw-[a-z0-9-]+/g)].map((match) => match[0]))) {
87
+ check(VERIFIED_THEME_TOKENS.has(token),
88
+ `lib/client.js uses ${token}, which the DSH 0.2 theme does not define`)
89
+ }
90
+
63
91
  if (failed > 0) {
64
92
  console.error(`${failed} check(s) failed`)
65
93
  process.exit(1)
@@ -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
+ })