@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
package/scripts/ai.test.mjs
CHANGED
|
@@ -11,6 +11,10 @@ const instrumented = source.replace(hook, `
|
|
|
11
11
|
Object.assign(exports, {
|
|
12
12
|
aiConfig, setAiConfig, aiConfigReady, ollamaChatUrl,
|
|
13
13
|
requestUserAi, requestAiKeywords, testAiService, LOGS,
|
|
14
|
+
modelsUrl, fetchAiModels,
|
|
15
|
+
readIndexCache, serializeHarvest,
|
|
16
|
+
readDshModelConfig, otherPluginFingerprint, DEEP_SLOTS,
|
|
17
|
+
log, flushLogs, discardPendingLogs, sessionLogText, logTarget, readLogCount,
|
|
14
18
|
})
|
|
15
19
|
${hook}`)
|
|
16
20
|
const storage = () => {
|
|
@@ -30,6 +34,8 @@ const plain = (value) => JSON.parse(JSON.stringify(value))
|
|
|
30
34
|
const ollama = { provider: 'ollama', baseUrl: '', model: 'local-model:7b', apiKey: '' }
|
|
31
35
|
const openai = { provider: 'openai', baseUrl: 'https://example.test/v1', model: 'cloud-model', apiKey: 'test-secret' }
|
|
32
36
|
const anthropic = { ...openai, provider: 'anthropic', baseUrl: 'https://example.test' }
|
|
37
|
+
const modelHook = ' const model = createModel(ctx)'
|
|
38
|
+
|
|
33
39
|
const reply = (provider, content = '{"keywords":["Language"]}') => provider === 'ollama'
|
|
34
40
|
? { message: { role: 'assistant', content }, done: true }
|
|
35
41
|
: provider === 'anthropic'
|
|
@@ -49,16 +55,73 @@ function load(fetchImpl = async () => json(reply('ollama'))) {
|
|
|
49
55
|
setTimeout,
|
|
50
56
|
clearTimeout,
|
|
51
57
|
fetch: async (url, options) => {
|
|
52
|
-
const call = { url, ...options, body: JSON.parse(options.body) }
|
|
58
|
+
const call = { url, ...options, body: options.body ? JSON.parse(options.body) : undefined }
|
|
53
59
|
calls.push(call)
|
|
54
60
|
return fetchImpl(call)
|
|
55
61
|
},
|
|
56
62
|
}
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
const instrumentedModel = instrumented.replace(modelHook, modelHook + '\n exports.__indexTestModel = model')
|
|
64
|
+
vm.runInNewContext(instrumentedModel, sandbox, { filename: 'lib/client.js' })
|
|
65
|
+
const api = handoff.factory(() => ({ createElement: () => null }))
|
|
59
66
|
return { api, calls, sandbox }
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
test('persistent settings index reuses matching fingerprints and drops invalid rows', () => {
|
|
70
|
+
const { api, sandbox } = load()
|
|
71
|
+
const records = {
|
|
72
|
+
'settings.general.item': {
|
|
73
|
+
row1: { title: 'Enable Workshop card', idx: 1, text: 'Enable Workshop card and save.', inner: [] },
|
|
74
|
+
broken: { idx: 2 },
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
assert.equal(Object.keys(api.serializeHarvest(new Map())).length, 0)
|
|
78
|
+
sandbox.localStorage.setItem('dsh-settings-search:index:v1', JSON.stringify({
|
|
79
|
+
version: 1,
|
|
80
|
+
fingerprint: 'manager:test',
|
|
81
|
+
updatedAt: 1,
|
|
82
|
+
harvest: records,
|
|
83
|
+
}))
|
|
84
|
+
const cached = api.readIndexCache()
|
|
85
|
+
assert.equal(cached.fingerprint, 'manager:test')
|
|
86
|
+
assert.equal(cached.harvest.get('settings.general.item')?.get('row1')?.title, 'Enable Workshop card')
|
|
87
|
+
assert.equal(cached.harvest.get('settings.general.item')?.has('broken'), false)
|
|
88
|
+
|
|
89
|
+
const ctx = {
|
|
90
|
+
effect: (fn) => fn(),
|
|
91
|
+
get: () => null,
|
|
92
|
+
inject: () => {},
|
|
93
|
+
locale: {
|
|
94
|
+
register: () => {},
|
|
95
|
+
bind: () => () => '',
|
|
96
|
+
subscribe: () => () => {},
|
|
97
|
+
},
|
|
98
|
+
slots: {
|
|
99
|
+
register: (name, id, options) => ({ name, id, options }),
|
|
100
|
+
inject: (_slot, registrar) => registrar(),
|
|
101
|
+
entries: (slot) => slot === 'settings.general.item'
|
|
102
|
+
? [{ options: { id: 'row1', order: 1 } }]
|
|
103
|
+
: [],
|
|
104
|
+
subscribe: () => () => {},
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
api.apply(ctx)
|
|
108
|
+
const model = api.__indexTestModel
|
|
109
|
+
assert.equal(model.indexFingerprintReady(), true)
|
|
110
|
+
assert.equal(model.applyIndexFingerprint('manager:test'), false)
|
|
111
|
+
assert.ok(model.readDeep().some((item) => item.label === 'Enable Workshop card'))
|
|
112
|
+
assert.equal(model.applyIndexFingerprint('manager:changed'), true)
|
|
113
|
+
assert.equal(model.readDeep().some((item) => item.label === 'Enable Workshop card'), false)
|
|
114
|
+
assert.equal(model.persistIndex('manager:changed'), false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('readIndexCache rejects malformed persisted index payloads', () => {
|
|
118
|
+
const { api, sandbox } = load()
|
|
119
|
+
for (const value of ['{', '{"version":1}', 'null']) {
|
|
120
|
+
sandbox.localStorage.setItem('dsh-settings-search:index:v1', value)
|
|
121
|
+
assert.equal(api.readIndexCache(), null)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
62
125
|
test('Ollama configuration persists and requires a model but no key', () => {
|
|
63
126
|
const { api, sandbox } = load()
|
|
64
127
|
api.setAiConfig({ ...ollama, model: ' local-model:7b ' })
|
|
@@ -88,6 +151,59 @@ test('Ollama accepts root, native, compatibility, proxy and IPv6 base URLs', ()
|
|
|
88
151
|
assert.equal(api.ollamaChatUrl('https://example.test/ollama/v1/'), 'https://example.test/ollama/api/chat')
|
|
89
152
|
})
|
|
90
153
|
|
|
154
|
+
test('users discover selectable models from each supported API type', async () => {
|
|
155
|
+
const cases = [
|
|
156
|
+
{
|
|
157
|
+
cfg: ollama,
|
|
158
|
+
payload: { models: [{ name: 'qwen3:8b' }, { name: 'llama3.1:8b' }, { name: 'qwen3:8b' }] },
|
|
159
|
+
url: 'http://127.0.0.1:11434/api/tags',
|
|
160
|
+
headers: {},
|
|
161
|
+
models: ['qwen3:8b', 'llama3.1:8b'],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
cfg: openai,
|
|
165
|
+
payload: { data: [{ id: 'gpt-4o-mini' }, { id: 'gpt-4.1-mini' }] },
|
|
166
|
+
url: 'https://example.test/v1/models',
|
|
167
|
+
headers: { authorization: `Bearer ${openai.apiKey}` },
|
|
168
|
+
models: ['gpt-4o-mini', 'gpt-4.1-mini'],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
cfg: anthropic,
|
|
172
|
+
payload: { data: [{ id: 'claude-sonnet-4-20250514' }] },
|
|
173
|
+
url: 'https://example.test/v1/models',
|
|
174
|
+
headers: {
|
|
175
|
+
'x-api-key': anthropic.apiKey,
|
|
176
|
+
'anthropic-version': '2023-06-01',
|
|
177
|
+
'anthropic-dangerous-direct-browser-access': 'true',
|
|
178
|
+
},
|
|
179
|
+
models: ['claude-sonnet-4-20250514'],
|
|
180
|
+
},
|
|
181
|
+
]
|
|
182
|
+
for (const expected of cases) {
|
|
183
|
+
const { api, calls } = load(async () => json(expected.payload))
|
|
184
|
+
assert.deepEqual(plain(await api.fetchAiModels(expected.cfg)), { ok: true, models: expected.models, code: 'ok', detail: '' })
|
|
185
|
+
const [call] = calls
|
|
186
|
+
assert.equal(call.url, expected.url)
|
|
187
|
+
assert.equal(call.method, 'GET')
|
|
188
|
+
assert.equal(call.redirect, 'error')
|
|
189
|
+
assert.deepEqual(plain(call.headers), expected.headers)
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('model discovery rejects unsafe or incomplete sources before fetching', async () => {
|
|
194
|
+
const { api, calls } = load(async () => json({ data: [{ id: 'should-not-load' }] }))
|
|
195
|
+
assert.equal(api.modelsUrl('ollama', 'javascript:alert(1)'), '')
|
|
196
|
+
assert.deepEqual(plain(await api.fetchAiModels({ ...ollama, baseUrl: 'file:///models' })), { ok: false, code: 'unsafe-url', models: [], detail: '' })
|
|
197
|
+
assert.deepEqual(plain(await api.fetchAiModels({ ...openai, apiKey: '' })), { ok: false, code: 'need-config', models: [], detail: '' })
|
|
198
|
+
assert.equal(calls.length, 0)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('model discovery reports provider failures without inventing choices', async () => {
|
|
202
|
+
const { api, calls } = load(async () => json({ error: 'denied' }, 401))
|
|
203
|
+
assert.deepEqual(plain(await api.fetchAiModels(ollama)), { ok: false, code: 'auth', models: [], detail: 'HTTP 401' })
|
|
204
|
+
assert.equal(calls.length, 1)
|
|
205
|
+
})
|
|
206
|
+
|
|
91
207
|
test('Ollama search uses native non-streaming JSON and never sends a stored cloud key', async () => {
|
|
92
208
|
const { api, calls, sandbox } = load(async () => json(reply('ollama', '{"keywords":["Language","Language"," Theme ",42]}')))
|
|
93
209
|
api.setAiConfig({ ...ollama, apiKey: 'test-secret' })
|
|
@@ -207,13 +323,17 @@ for (const cfg of [ollama, openai, anthropic]) {
|
|
|
207
323
|
const { api, sandbox, calls } = load(abortedFetch)
|
|
208
324
|
let expire
|
|
209
325
|
let cleared = false
|
|
326
|
+
const deadline = cfg.provider === 'ollama' ? 120000 : 12000
|
|
327
|
+
const delays = []
|
|
210
328
|
sandbox.setTimeout = (callback, ms) => {
|
|
211
|
-
|
|
212
|
-
|
|
329
|
+
delays.push(ms)
|
|
330
|
+
// The log batch timer shares this seam, so select the request deadline.
|
|
331
|
+
if (ms === deadline) expire = callback
|
|
213
332
|
return 1
|
|
214
333
|
}
|
|
215
334
|
sandbox.clearTimeout = () => { cleared = true }
|
|
216
335
|
const result = api.testAiService(cfg)
|
|
336
|
+
assert.ok(delays.includes(deadline), `the request must use a ${deadline}ms deadline, saw ${delays.join('/')}`)
|
|
217
337
|
expire()
|
|
218
338
|
assert.equal((await result).code, 'timeout')
|
|
219
339
|
assert.equal(calls[0].signal.aborted, true)
|
|
@@ -263,3 +383,268 @@ test('cache respects settings labels and is invalidated when provider configurat
|
|
|
263
383
|
assert.equal(calls.length, 4)
|
|
264
384
|
assert.equal(calls[3].headers.authorization, `Bearer ${openai.apiKey}`)
|
|
265
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)
|