dsh-memu-memory 1.1.3 → 1.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/AGENTS.md +1 -1
- package/README.md +31 -2
- package/README.zh.md +30 -2
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +1 -1
- package/docs/emotion-model-spec.md +281 -0
- package/docs/host-api.md +2 -2
- package/docs/memory-semantics.md +12 -10
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +1 -1
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +33 -11
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +115 -0
- package/lib/cycle.js +40 -6
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +221 -19
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +335 -40
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +9 -1
- package/lib/state.js +29 -12
- package/lib/tools.js +49 -2
- package/lib/vector-profile.js +27 -0
- package/package.json +2 -1
- package/scripts/acceptance-local.mjs +2 -1
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +11 -1
- package/scripts/check-all.mjs +10 -1
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +212 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +536 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +208 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +733 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +164 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
// A settings pane that throws during render shows up as a blank panel with no
|
|
2
|
+
// error anywhere — exactly what happened when an effect referenced a callback
|
|
3
|
+
// declared further down (temporal dead zone). This harness runs the real client
|
|
4
|
+
// component with a minimal hook runtime, so a render-time throw fails the build
|
|
5
|
+
// instead of silently blanking the UI. Every page is rendered, because a block
|
|
6
|
+
// only runs when its page is selected.
|
|
7
|
+
import test from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
|
|
10
|
+
const registrations = []
|
|
11
|
+
|
|
12
|
+
function makeElement(type, props, ...children) {
|
|
13
|
+
return { type, props: { ...(props || {}), children: children.length <= 1 ? children[0] : children } }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let hooks = []
|
|
17
|
+
let cursor = 0
|
|
18
|
+
let effects = []
|
|
19
|
+
let dirty = false
|
|
20
|
+
let forced = null
|
|
21
|
+
let generation = 0
|
|
22
|
+
|
|
23
|
+
const React = {
|
|
24
|
+
createElement: makeElement,
|
|
25
|
+
Fragment: Symbol('Fragment'),
|
|
26
|
+
useState(initial) {
|
|
27
|
+
const index = cursor++
|
|
28
|
+
// A setter captured by a component from an earlier test must not write into
|
|
29
|
+
// the hook array this test is using: async effects from a previous render can
|
|
30
|
+
// resolve late, and without this the second test renders with the first one's
|
|
31
|
+
// state.
|
|
32
|
+
const generation_ = generation
|
|
33
|
+
if (hooks[index] === undefined) hooks[index] = typeof initial === 'function' ? initial() : initial
|
|
34
|
+
if (forced && forced.index === index) hooks[index] = forced.value
|
|
35
|
+
return [hooks[index], (next) => {
|
|
36
|
+
if (generation_ !== generation) return
|
|
37
|
+
hooks[index] = typeof next === 'function' ? next(hooks[index]) : next
|
|
38
|
+
dirty = true
|
|
39
|
+
}]
|
|
40
|
+
},
|
|
41
|
+
useCallback: (fn) => fn,
|
|
42
|
+
useMemo: (fn) => fn(),
|
|
43
|
+
useEffect(fn, deps) { effects.push({ fn, deps, index: cursor++ }) },
|
|
44
|
+
useLayoutEffect() {},
|
|
45
|
+
useRef: (value) => ({ current: value }),
|
|
46
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
globalThis.window = {
|
|
50
|
+
__ModuleLoader__: {
|
|
51
|
+
load({ factory }) {
|
|
52
|
+
globalThis.__dshMemuExports = factory((name) => {
|
|
53
|
+
if (name === 'react') return React
|
|
54
|
+
throw new Error(`unexpected require: ${name}`)
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
// The overlay listens for its close message and for Escape.
|
|
59
|
+
addEventListener() {},
|
|
60
|
+
removeEventListener() {},
|
|
61
|
+
}
|
|
62
|
+
// The WebUI button falls back to an in-app overlay when popups are blocked; the
|
|
63
|
+
// stub records what got attached so the test can assert the current tab is never
|
|
64
|
+
// navigated (that would unload the app and look like a forced refresh).
|
|
65
|
+
const appendedNodes = []
|
|
66
|
+
globalThis.document = {
|
|
67
|
+
createElement: (tag) => {
|
|
68
|
+
const node = {
|
|
69
|
+
tag,
|
|
70
|
+
style: {},
|
|
71
|
+
attrs: {},
|
|
72
|
+
children: [],
|
|
73
|
+
href: '',
|
|
74
|
+
textContent: '',
|
|
75
|
+
setAttribute(name, value) { node.attrs[name] = value },
|
|
76
|
+
addEventListener() {},
|
|
77
|
+
appendChild(child) { node.children.push(child); return child },
|
|
78
|
+
remove() { node.removed = true },
|
|
79
|
+
click() { node.clicked = true },
|
|
80
|
+
}
|
|
81
|
+
return node
|
|
82
|
+
},
|
|
83
|
+
querySelector: () => null,
|
|
84
|
+
addEventListener() {},
|
|
85
|
+
removeEventListener() {},
|
|
86
|
+
head: { appendChild() {} },
|
|
87
|
+
body: { appendChild(node) { appendedNodes.push(node); return node } },
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await import('../lib/client.js')
|
|
91
|
+
|
|
92
|
+
const EMPTY_STATE = {
|
|
93
|
+
engine: { status: 'ready', versions: [], currentVersion: '0.11.0b3', recommendedVersion: '0.11.0b3', installed: true, state: {} },
|
|
94
|
+
settings: { autoInject: 'smart', mustRememberCap: 16, unpinBudgetPerSession: 2 },
|
|
95
|
+
libraries: [],
|
|
96
|
+
archivedLibraries: [],
|
|
97
|
+
memuConfig: { provider: 'openai' },
|
|
98
|
+
summaryConfig: {},
|
|
99
|
+
binding: null,
|
|
100
|
+
privateCount: 0,
|
|
101
|
+
pinnedCount: 0,
|
|
102
|
+
globalCount: 0,
|
|
103
|
+
jobs: [],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const RPC_ANSWERS = {
|
|
107
|
+
getState: EMPTY_STATE,
|
|
108
|
+
getSettingsState: EMPTY_STATE,
|
|
109
|
+
getPrompts: { prompts: [
|
|
110
|
+
{ key: 'system-guide', title: 'guide', description: '', variables: ['mustRememberCap'], source: 'default', text: 'a', scope: 'private', domain: null },
|
|
111
|
+
{ key: 'system-guide.rp', title: 'guide rp', description: '', variables: [], source: 'default', text: 'b', scope: 'private', domain: 'rp' },
|
|
112
|
+
{ key: 'global-curator', title: 'curator', description: '', variables: ['input'], source: 'default', text: 'c', scope: 'global', domain: null },
|
|
113
|
+
] },
|
|
114
|
+
listVersions: { ok: true, versions: [] },
|
|
115
|
+
listBackups: { backups: [] },
|
|
116
|
+
listFolders: { folders: [] },
|
|
117
|
+
listLibraries: { libraries: [] },
|
|
118
|
+
listSessions: { sessions: [] },
|
|
119
|
+
getSidebarState: {
|
|
120
|
+
binding: { sessionId: 'probe-session', libraryId: 'lib-1', useLibrary: true, useGlobal: true, scopeKey: '/proj/demo', domain: 'coding' },
|
|
121
|
+
domain: 'coding',
|
|
122
|
+
folder: { scopeKey: '/proj/demo', useLibrary: true, useGlobal: true },
|
|
123
|
+
effective: { useLibrary: true, useGlobal: true, enabled: true, folder: { scopeKey: '/proj/demo', useLibrary: true, useGlobal: true } },
|
|
124
|
+
private: { items: [], active: 0, pinned: 0, total: 0 },
|
|
125
|
+
global: { items: [], active: 0, total: 0 },
|
|
126
|
+
domains: ['rp', 'coding'],
|
|
127
|
+
},
|
|
128
|
+
jobStatus: null,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fakeCtx({ answers = {}, rpcCalls = [] } = {}) {
|
|
132
|
+
return {
|
|
133
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
134
|
+
locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
|
|
135
|
+
connection: {
|
|
136
|
+
rpc: {
|
|
137
|
+
call: async (path, endpoint, payload) => {
|
|
138
|
+
rpcCalls.push({ path, endpoint, payload })
|
|
139
|
+
if (typeof answers[endpoint] === 'function') return answers[endpoint](payload)
|
|
140
|
+
return { ok: true, value: answers[endpoint] ?? RPC_ANSWERS[endpoint] ?? {} }
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
|
|
145
|
+
// The sidebar tab asks for the current conversation through one of these.
|
|
146
|
+
get: (name) => (name === 'uiSession'
|
|
147
|
+
? { adapter: { current: { getSnapshot: () => ({ key: 'probe-session' }) } } }
|
|
148
|
+
: undefined),
|
|
149
|
+
slots: {
|
|
150
|
+
inject: (_name, callback) => callback(scope),
|
|
151
|
+
register: (spec, component) => { registrations.push({ slot: spec.name, id: spec.id, component }); return () => {} },
|
|
152
|
+
},
|
|
153
|
+
inject: (_deps, callback) => callback({
|
|
154
|
+
// Run the effect body: that is where the tab body registers itself.
|
|
155
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
156
|
+
sidebarRightTabs: { register: () => () => {} },
|
|
157
|
+
get: () => undefined,
|
|
158
|
+
}),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const scope = {
|
|
163
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
164
|
+
register: (spec, component) => { registrations.push({ slot: spec.name, id: spec.id, component }); return () => {} },
|
|
165
|
+
inject: (_deps, callback) => callback(scope),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function renderPage(component, props, pageValue, { remount = false } = {}) {
|
|
169
|
+
if (remount) { hooks = []; generation++ }
|
|
170
|
+
effects = []
|
|
171
|
+
cursor = 0
|
|
172
|
+
forced = pageValue === undefined ? null : { index: pageHookIndex, value: pageValue }
|
|
173
|
+
const tree = component(props)
|
|
174
|
+
assert.ok(tree, 'component rendered nothing')
|
|
175
|
+
return { tree, effects }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let pageHookIndex = -1
|
|
179
|
+
|
|
180
|
+
function collectPageHook() {
|
|
181
|
+
// The page state is the only one initialised to the number 0.
|
|
182
|
+
const zeroes = hooks.map((value, index) => (value === 0 ? index : -1)).filter((index) => index >= 0)
|
|
183
|
+
assert.equal(zeroes.length, 1, 'expected exactly one numeric-zero state (the page index)')
|
|
184
|
+
pageHookIndex = zeroes[0]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function treeText(node, depth = 0) {
|
|
188
|
+
if (node === null || node === undefined || depth > 400) return ''
|
|
189
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
|
190
|
+
if (Array.isArray(node)) return node.map((child) => treeText(child, depth + 1)).join(' ')
|
|
191
|
+
// Function components are expanded so their bodies are exercised too — a throw
|
|
192
|
+
// inside Section/Button counts the same as one inside the page.
|
|
193
|
+
if (typeof node.type === 'function') return treeText(node.type(node.props), depth + 1)
|
|
194
|
+
return `${node.props?.className || ''} ${treeText(node.props?.children, depth + 1)}`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test('the settings page renders every page instead of a blank panel', async () => {
|
|
198
|
+
const ctx = fakeCtx()
|
|
199
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
200
|
+
const settings = registrations.find((r) => r.slot === 'settings.section')
|
|
201
|
+
assert.ok(settings?.component, 'settings.section was not registered')
|
|
202
|
+
|
|
203
|
+
// First render: state is still null, which is the loading branch.
|
|
204
|
+
const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
|
|
205
|
+
collectPageHook()
|
|
206
|
+
assert.match(treeText(first.tree), /loading|dshm-settings/)
|
|
207
|
+
|
|
208
|
+
// Let the mount effects fetch and settle, then render with data. Hooks survive
|
|
209
|
+
// between renders, which is how the fetched state reaches the next pass.
|
|
210
|
+
for (const effect of first.effects) effect.fn()
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
213
|
+
|
|
214
|
+
for (let page = 0; page < 11; page++) {
|
|
215
|
+
const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, page)
|
|
216
|
+
const text = treeText(tree)
|
|
217
|
+
assert.match(String(tree.props?.className), /dshm-root/, `page ${page} did not render the shell`)
|
|
218
|
+
// The rail is the nav: every page label has to be there. Counting the shared
|
|
219
|
+
// dshm-nav-item class instead would also pick up the in-page tab strips (memory
|
|
220
|
+
// backend, prompt scope, browser view), which use the same widget on purpose.
|
|
221
|
+
const NAV = ['pageOverview', 'pageConnection', 'pageSettings', 'pageFolders', 'pageLibraries', 'pageBrowser', 'pageGlobal', 'pagePrompts', 'pageStats', 'pageMemories', 'pageLogs']
|
|
222
|
+
assert.ok(NAV.every((key) => text.includes(key)), `page ${page} lost a nav entry`)
|
|
223
|
+
assert.equal((text.match(/dshm-nav-ind/g) || []).length, 1, `page ${page} lost or duplicated its rail indicator`)
|
|
224
|
+
assert.match(text, /dshm-page/, `page ${page} lost the content pane`)
|
|
225
|
+
// Passing a labels map where an id was expected stringifies to this, which is
|
|
226
|
+
// exactly how the sidebar domain picker broke.
|
|
227
|
+
assert.ok(!text.includes('[object Object]'), `page ${page} rendered an object as text`)
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('the sidebar tab renders with the two-layer header', async () => {
|
|
232
|
+
const ctx = fakeCtx()
|
|
233
|
+
const tab = registrations.find((r) => r.slot === 'sidebar.right.pane.tab')
|
|
234
|
+
assert.ok(tab?.component, 'sidebar.right.pane.tab was not registered')
|
|
235
|
+
|
|
236
|
+
// The registered body is a thin wrapper around MemoryTab; hooks only run in the
|
|
237
|
+
// inner component, so unwrap it before rendering.
|
|
238
|
+
const props = { ctx, t: (key) => key, sessionId: 'probe-session' }
|
|
239
|
+
const wrapper = tab.component(props)
|
|
240
|
+
const component = typeof wrapper.type === 'function' ? wrapper.type : tab.component
|
|
241
|
+
const innerProps = typeof wrapper.type === 'function' ? wrapper.props : props
|
|
242
|
+
|
|
243
|
+
hooks = []
|
|
244
|
+
pageHookIndex = -1
|
|
245
|
+
const first = await renderPage(component, innerProps, undefined, { remount: true })
|
|
246
|
+
assert.ok(first.tree, 'sidebar tab rendered nothing')
|
|
247
|
+
|
|
248
|
+
// Feed the fetched state back in, the same way the host would.
|
|
249
|
+
for (const effect of first.effects) effect.fn()
|
|
250
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
252
|
+
|
|
253
|
+
const { tree } = await renderPage(component, innerProps)
|
|
254
|
+
const text = treeText(tree)
|
|
255
|
+
assert.match(text, /layerFolder|layerConversation|noBinding|loading/, 'sidebar lost its two-layer header')
|
|
256
|
+
assert.ok(!text.includes('[object Object]'), 'sidebar rendered an object as text')
|
|
257
|
+
// The domain picker lists ids, translated through the label map.
|
|
258
|
+
assert.match(text, /dmCoding/, 'domain picker did not render its options')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// The browser row is the only place a human can see that a memory is marked
|
|
262
|
+
// "never say this out loud" or "secret" without opening the JSON. The badges are
|
|
263
|
+
// driven by fields that used to be stored and ignored, so a regression here is
|
|
264
|
+
// silent: the data stays correct and the warning disappears.
|
|
265
|
+
function walkNodes(node, visit, depth = 0) {
|
|
266
|
+
if (node === null || node === undefined || depth > 400) return
|
|
267
|
+
if (Array.isArray(node)) {
|
|
268
|
+
for (const child of node) walkNodes(child, visit, depth + 1)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (typeof node !== 'object') return
|
|
272
|
+
if (visit(node, depth) === false) return
|
|
273
|
+
if (typeof node.type === 'function') {
|
|
274
|
+
walkNodes(node.type(node.props), visit, depth + 1)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
walkNodes(node.props?.children, visit, depth + 1)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
test('the memory browser badges what a memory is allowed to say', async () => {
|
|
281
|
+
const ctx = fakeCtx()
|
|
282
|
+
const originalGetState = RPC_ANSWERS.getState
|
|
283
|
+
const originalSettingsState = RPC_ANSWERS.getSettingsState
|
|
284
|
+
const originalLibraries = RPC_ANSWERS.listLibraries
|
|
285
|
+
const loaded = {
|
|
286
|
+
...EMPTY_STATE,
|
|
287
|
+
libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/proj/demo', privateCount: 2 }],
|
|
288
|
+
}
|
|
289
|
+
// The pane reads the library list through both endpoints depending on the page.
|
|
290
|
+
RPC_ANSWERS.getState = loaded
|
|
291
|
+
RPC_ANSWERS.getSettingsState = loaded
|
|
292
|
+
RPC_ANSWERS.browseLibrary = {
|
|
293
|
+
source: 'engine',
|
|
294
|
+
files: [
|
|
295
|
+
{
|
|
296
|
+
id: 'mem-quiet',
|
|
297
|
+
title: 'the attic',
|
|
298
|
+
content: 'he never told his brother',
|
|
299
|
+
layer: 'short_term',
|
|
300
|
+
status: 'active',
|
|
301
|
+
decay: 0.5,
|
|
302
|
+
disclosure: 'never_say',
|
|
303
|
+
visibility: 'secret',
|
|
304
|
+
owner: 'about_user',
|
|
305
|
+
expiresAt: '2026-11-01T09:30:00.000Z',
|
|
306
|
+
},
|
|
307
|
+
{ id: 'mem-loud', title: 'shared fact', content: 'anything', layer: 'long_term', status: 'active', decay: 0.9, disclosure: 'can_tell', visibility: 'shared', owner: 'me' },
|
|
308
|
+
],
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
312
|
+
const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
|
|
313
|
+
assert.ok(settings?.component, 'settings.section was not registered')
|
|
314
|
+
|
|
315
|
+
const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
|
|
316
|
+
collectPageHook()
|
|
317
|
+
for (const effect of first.effects) effect.fn()
|
|
318
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
319
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
320
|
+
|
|
321
|
+
// Open the library the way a click would: pick it, then press reload.
|
|
322
|
+
let tree = (await renderPage(settings.component, { ctx, t: (key) => key }, 5)).tree
|
|
323
|
+
const picker = []
|
|
324
|
+
walkNodes(tree, (node) => {
|
|
325
|
+
// The layer picker of the new-memory form is also a <select>; only this one
|
|
326
|
+
// lists libraries.
|
|
327
|
+
const options = Array.isArray(node.props?.children) ? node.props.children : []
|
|
328
|
+
if (node.type === 'select' && options.some((option) => option?.props?.value === 'lib-1')) picker.push(node)
|
|
329
|
+
})
|
|
330
|
+
assert.equal(picker.length, 1, 'the library picker did not render')
|
|
331
|
+
picker[0].props.onChange({ target: { value: 'lib-1' } })
|
|
332
|
+
|
|
333
|
+
tree = (await renderPage(settings.component, { ctx, t: (key) => key }, 5)).tree
|
|
334
|
+
const reload = []
|
|
335
|
+
walkNodes(tree, (node) => {
|
|
336
|
+
if (typeof node.type === 'function' && node.props?.children === 'reload') reload.push(node)
|
|
337
|
+
})
|
|
338
|
+
assert.equal(reload.length, 1, 'the reload button did not render')
|
|
339
|
+
reload[0].props.onClick()
|
|
340
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
341
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
342
|
+
|
|
343
|
+
tree = (await renderPage(settings.component, { ctx, t: (key) => key }, 5)).tree
|
|
344
|
+
const text = treeText(tree)
|
|
345
|
+
// Every row carries three editor selects, so each enum label also appears in the
|
|
346
|
+
// option lists: two rows = 2 occurrences. A badge is the third.
|
|
347
|
+
const count = (needle) => text.split(needle).length - 1
|
|
348
|
+
assert.match(text, /layerShortTerm/, 'a short-term entry renders its layer instead of an empty tag')
|
|
349
|
+
assert.equal(count('discNeverSay'), 3, 'never_say is visible without opening the entry')
|
|
350
|
+
assert.equal(count('discCanTell'), 2, 'the default say-ability stays quiet — options only')
|
|
351
|
+
assert.equal(count('visSecret'), 3, 'a secret is flagged while scanning')
|
|
352
|
+
assert.equal(count('visShared'), 2, 'and so does the default visibility')
|
|
353
|
+
assert.equal(count('ownerAboutUser'), 3, 'whose fact it is shows up too')
|
|
354
|
+
assert.equal(count('shortTermExpires'), 1, 'and a draft shows when it dies')
|
|
355
|
+
assert.ok(!text.includes('[object Object]'), 'a badge rendered an object as text')
|
|
356
|
+
} finally {
|
|
357
|
+
RPC_ANSWERS.getState = originalGetState
|
|
358
|
+
RPC_ANSWERS.getSettingsState = originalSettingsState
|
|
359
|
+
RPC_ANSWERS.listLibraries = originalLibraries
|
|
360
|
+
delete RPC_ANSWERS.browseLibrary
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('the WebUI buttons open a new tab and never navigate this one', async () => {
|
|
365
|
+
const opened = []
|
|
366
|
+
const navigated = []
|
|
367
|
+
const originalWindow = { open: globalThis.window.open, location: globalThis.window.location }
|
|
368
|
+
globalThis.window.location = { href: 'http://host/app', assign: (url) => navigated.push(url) }
|
|
369
|
+
try {
|
|
370
|
+
const ctx = fakeCtx()
|
|
371
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
372
|
+
const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
|
|
373
|
+
assert.ok(settings?.component, 'settings.section was not registered')
|
|
374
|
+
|
|
375
|
+
const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
|
|
376
|
+
collectPageHook()
|
|
377
|
+
for (const effect of first.effects) effect.fn()
|
|
378
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
379
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
380
|
+
|
|
381
|
+
const buttons = []
|
|
382
|
+
walkNodes((await renderPage(settings.component, { ctx, t: (key) => key }, 0)).tree, (node) => {
|
|
383
|
+
if (node.type === 'button' && node.props?.children === 'openMemu') buttons.push(node)
|
|
384
|
+
})
|
|
385
|
+
assert.equal(buttons.length, 1, 'the WebUI button did not render')
|
|
386
|
+
|
|
387
|
+
globalThis.window.open = (url) => { opened.push(url); return { focus() {}, opener: null } }
|
|
388
|
+
buttons[0].props.onClick()
|
|
389
|
+
assert.equal(opened.length, 1, 'a popup was requested')
|
|
390
|
+
assert.ok(opened[0].endsWith('/dsh-memu-memory/ui#/settings/engine'), `unexpected url: ${opened[0]}`)
|
|
391
|
+
assert.deepEqual(navigated, [], 'no fallback navigation while the popup works')
|
|
392
|
+
|
|
393
|
+
// Blocked popups return null: fall back to an in-app overlay. Navigating this
|
|
394
|
+
// tab would unload the app and look like a forced refresh on the way back.
|
|
395
|
+
appendedNodes.length = 0
|
|
396
|
+
globalThis.window.open = () => null
|
|
397
|
+
buttons[0].props.onClick()
|
|
398
|
+
assert.equal(appendedNodes.length, 1, 'blocked popups open an in-app overlay')
|
|
399
|
+
const overlay = appendedNodes[0]
|
|
400
|
+
assert.equal(overlay.attrs['data-dsh-memu-memory-webui'], '', 'the overlay is marked')
|
|
401
|
+
const frame = overlay.children.find((child) => child.tag === 'iframe')
|
|
402
|
+
assert.ok(frame, 'the overlay hosts an iframe')
|
|
403
|
+
assert.ok(String(frame.src).endsWith('/dsh-memu-memory/ui#/settings/engine'), `unexpected url: ${frame.src}`)
|
|
404
|
+
assert.deepEqual(navigated, [], 'the current tab is never navigated')
|
|
405
|
+
// The close control lives inside the page; the overlay only waits for its message.
|
|
406
|
+
assert.equal(overlay.children.length, 1, 'no floating chrome outside the panel')
|
|
407
|
+
} finally {
|
|
408
|
+
globalThis.window.open = originalWindow.open
|
|
409
|
+
globalThis.window.location = originalWindow.location
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('the sidebar and the settings page hand out the same WebUI button', async () => {
|
|
414
|
+
// Two spellings of one action is how the panes drift: the sidebar used to label
|
|
415
|
+
// this button openSettingsShort while the settings page used openWebUi. Both
|
|
416
|
+
// surfaces now render the same widget with the same label, hint and handler, and
|
|
417
|
+
// the embedded settings page drops its own copy so the sidebar never shows two.
|
|
418
|
+
async function renderSurface(component, props, page) {
|
|
419
|
+
hooks = []
|
|
420
|
+
pageHookIndex = -1
|
|
421
|
+
const first = await renderPage(component, props, page, { remount: true })
|
|
422
|
+
if (page !== undefined) collectPageHook()
|
|
423
|
+
for (const effect of first.effects) effect.fn()
|
|
424
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
425
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
426
|
+
const tree = (await renderPage(component, props, page)).tree
|
|
427
|
+
const found = []
|
|
428
|
+
walkNodes(tree, (node) => {
|
|
429
|
+
if (node.type === 'button' && node.props?.children === 'openMemu') found.push(node)
|
|
430
|
+
})
|
|
431
|
+
return { tree, found }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const ctx = fakeCtx()
|
|
435
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
436
|
+
const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
|
|
437
|
+
const tab = registrations.filter((r) => r.slot === 'sidebar.right.pane.tab').pop()
|
|
438
|
+
assert.ok(settings?.component && tab?.component, 'both surfaces are registered')
|
|
439
|
+
|
|
440
|
+
const inSettings = await renderSurface(settings.component, { ctx, t: (key) => key }, 0)
|
|
441
|
+
assert.equal(inSettings.found.length, 1, 'the settings page lost its WebUI button')
|
|
442
|
+
|
|
443
|
+
const props = { ctx, t: (key) => key, sessionId: 'probe-session' }
|
|
444
|
+
const wrapper = tab.component(props)
|
|
445
|
+
const inner = typeof wrapper.type === 'function' ? wrapper.type : tab.component
|
|
446
|
+
const innerProps = typeof wrapper.type === 'function' ? wrapper.props : props
|
|
447
|
+
const inSidebar = await renderSurface(inner, innerProps, undefined)
|
|
448
|
+
assert.equal(inSidebar.found.length, 1, 'the sidebar lost its WebUI button')
|
|
449
|
+
assert.ok(!treeText(inSidebar.tree).includes('openSettings'), 'a second settings entry must not return')
|
|
450
|
+
|
|
451
|
+
// The panel returns early while the conversation is still unknown, before its
|
|
452
|
+
// `pages` array is built. Effects scheduled by that render still run, and one of
|
|
453
|
+
// them reads `pages` — touching it then is a temporal-dead-zone error that the
|
|
454
|
+
// sidebar surfaces as "dsh-better-sidebar: Cannot access 'pages' before
|
|
455
|
+
// initialization". Rendering without a session must stay quiet.
|
|
456
|
+
for (const empty of [null, undefined]) {
|
|
457
|
+
const quiet = await renderSurface(inner, { ...innerProps, sessionId: empty }, undefined)
|
|
458
|
+
assert.ok(treeText(quiet.tree).includes('loading'), 'the unbound panel shows its loading state')
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const node of [...inSettings.found, ...inSidebar.found]) {
|
|
462
|
+
assert.equal(node.props.className, 'dshm-btn', 'the button bypasses the shared widget')
|
|
463
|
+
assert.equal(node.props.title, 'openWebUiHint', 'the button carries a different hint')
|
|
464
|
+
assert.equal(typeof node.props.onClick, 'function', 'the button has no handler')
|
|
465
|
+
}
|
|
466
|
+
// The old keys are gone rather than kept around as a second spelling.
|
|
467
|
+
const text = `${treeText(inSettings.tree)} ${treeText(inSidebar.tree)}`
|
|
468
|
+
assert.ok(!text.includes('openSettingsShort'), 'the second WebUI label came back')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
test('every pick-one-of-these control uses the same tab strip', async () => {
|
|
472
|
+
// The settings rail, the prompt scope, the browser's active/archived split and
|
|
473
|
+
// the memory backend are one widget (dshm-nav-item). They used to be three
|
|
474
|
+
// widgets with three different hover and focus states, which is what read as
|
|
475
|
+
// "the sidebar and the settings page do not match".
|
|
476
|
+
const ctx = fakeCtx()
|
|
477
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
478
|
+
const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
|
|
479
|
+
|
|
480
|
+
const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
|
|
481
|
+
collectPageHook()
|
|
482
|
+
for (const effect of first.effects) effect.fn()
|
|
483
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
484
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
485
|
+
|
|
486
|
+
const orphans = []
|
|
487
|
+
for (let page = 0; page < 11; page++) {
|
|
488
|
+
const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, page)
|
|
489
|
+
walkNodes(tree, (node) => {
|
|
490
|
+
if (node.type !== 'button') return
|
|
491
|
+
const className = String(node.props?.className || '')
|
|
492
|
+
if (node.props?.role === 'tab' && !className.includes('dshm-nav-item')) {
|
|
493
|
+
orphans.push(`page ${page}: ${JSON.stringify(node.props?.children)}`)
|
|
494
|
+
}
|
|
495
|
+
if (className.includes('dshm-nav-item')) {
|
|
496
|
+
assert.equal(typeof node.props.onClick, 'function', `page ${page} lost a tab handler`)
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
assert.deepEqual(orphans, [], 'a tab strip sits outside the shared widget')
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// Task C: the statistics page is data-driven, so it only paints after two lazy
|
|
504
|
+
// loads (pick the first library, then read its stats). The charts are hand-written
|
|
505
|
+
// SVG — counting nodes is the honest check that thirty bars and one dot per memory
|
|
506
|
+
// actually reached the DOM, and that an empty day did not become NaN.
|
|
507
|
+
test('the statistics page draws the daily bars, the scatter and the shortlist', async () => {
|
|
508
|
+
const ctx = fakeCtx()
|
|
509
|
+
const originalState = RPC_ANSWERS.getSettingsState
|
|
510
|
+
RPC_ANSWERS.getSettingsState = {
|
|
511
|
+
...EMPTY_STATE,
|
|
512
|
+
libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/proj/demo' }],
|
|
513
|
+
}
|
|
514
|
+
RPC_ANSWERS.getStats = {
|
|
515
|
+
libraryId: 'lib-1',
|
|
516
|
+
available: true,
|
|
517
|
+
keptDays: 90,
|
|
518
|
+
windowDays: 30,
|
|
519
|
+
days: Array.from({ length: 30 }, (_, i) => ({
|
|
520
|
+
date: `2026-09-${String(i + 1).padStart(2, '0')}`,
|
|
521
|
+
written: i === 29 ? 2 : 0,
|
|
522
|
+
injected: i % 3,
|
|
523
|
+
recalled: 0,
|
|
524
|
+
archived: 0,
|
|
525
|
+
})),
|
|
526
|
+
totals: { written: 2, injected: 30, recalled: 4, archived: 1 },
|
|
527
|
+
ratio: { windowDays: 7, written: 2, injected: 9 },
|
|
528
|
+
health: { total: 2, active: 2, marginalized: 0, archived: 0, pendingReview: 0, avgStrength: 0.71 },
|
|
529
|
+
distribution: [
|
|
530
|
+
{ id: 'mem-a', title: 'often recalled', layer: 'long_term', ageDays: 3, injectCount: 4, accessCount: 2 },
|
|
531
|
+
{ id: 'mem-b', title: 'never recalled', layer: 'long_term', ageDays: 9, injectCount: 0, accessCount: 0 },
|
|
532
|
+
],
|
|
533
|
+
distributionTotal: 2,
|
|
534
|
+
stale: [{ id: 'mem-b', title: 'never recalled', daysSinceTouch: 9 }],
|
|
535
|
+
}
|
|
536
|
+
const run = async (effectsToRun) => {
|
|
537
|
+
for (const effect of effectsToRun) effect.fn()
|
|
538
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
539
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
543
|
+
const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
|
|
544
|
+
const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
|
|
545
|
+
collectPageHook()
|
|
546
|
+
await run(first.effects)
|
|
547
|
+
|
|
548
|
+
let pass = await renderPage(settings.component, { ctx, t: (key) => key }, 8)
|
|
549
|
+
await run(pass.effects)
|
|
550
|
+
pass = await renderPage(settings.component, { ctx, t: (key) => key }, 8)
|
|
551
|
+
await run(pass.effects)
|
|
552
|
+
const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, 8)
|
|
553
|
+
|
|
554
|
+
const counts = { rects: 0, circles: 0 }
|
|
555
|
+
walkNodes(tree, (node) => {
|
|
556
|
+
if (node.type === 'rect') counts.rects += 1
|
|
557
|
+
if (node.type === 'circle') counts.circles += 1
|
|
558
|
+
})
|
|
559
|
+
assert.equal(counts.rects, 30, 'thirty daily bars, including the quiet days')
|
|
560
|
+
assert.equal(counts.circles, 2, 'one scatter dot per memory')
|
|
561
|
+
const text = treeText(tree)
|
|
562
|
+
assert.match(text, /statsWritten/, 'the window totals render')
|
|
563
|
+
assert.match(text, /statsStaleDays/, 'the archive shortlist renders')
|
|
564
|
+
assert.ok(!text.includes('NaN'), 'an empty day must not paint NaN')
|
|
565
|
+
assert.ok(!text.includes('[object Object]'), 'the page rendered an object as text')
|
|
566
|
+
} finally {
|
|
567
|
+
RPC_ANSWERS.getSettingsState = originalState
|
|
568
|
+
delete RPC_ANSWERS.getStats
|
|
569
|
+
}
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
test('the memory tab is registered exactly once, with or without better-sidebar', () => {
|
|
573
|
+
function appCtx(initialBetter) {
|
|
574
|
+
const seen = { native: [], better: [], pending: {} }
|
|
575
|
+
let better = initialBetter
|
|
576
|
+
const slotsScope = { effect: (fn) => { try { fn() } catch {} }, register: () => () => {}, inject: (_n, cb) => cb(slotsScope) }
|
|
577
|
+
const scope = {
|
|
578
|
+
effect: (fn) => { const dispose = fn(); return () => { try { dispose?.() } catch {} } },
|
|
579
|
+
get: (name) => (name === 'betterSidebar' ? better : undefined),
|
|
580
|
+
sidebarRightTabs: {
|
|
581
|
+
register: (spec) => {
|
|
582
|
+
seen.native.push(spec)
|
|
583
|
+
return () => { const at = seen.native.indexOf(spec); if (at >= 0) seen.native.splice(at, 1) }
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
slots: { inject: (_n, cb) => cb(slotsScope), register: () => () => {} },
|
|
587
|
+
}
|
|
588
|
+
const ctx = {
|
|
589
|
+
effect: () => {},
|
|
590
|
+
locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
|
|
591
|
+
connection: { rpc: { call: async () => ({ ok: true, value: {} }) } },
|
|
592
|
+
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
|
|
593
|
+
get: (name) => (name === 'betterSidebar' ? better : undefined),
|
|
594
|
+
slots: { inject: (_n, cb) => cb(scope), register: () => () => {} },
|
|
595
|
+
inject: (deps, callback) => {
|
|
596
|
+
const key = deps[0]
|
|
597
|
+
seen.pending[key] = callback
|
|
598
|
+
if (key === 'betterSidebar' && !better) return
|
|
599
|
+
callback(scope)
|
|
600
|
+
},
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
ctx,
|
|
604
|
+
seen,
|
|
605
|
+
later(service) { better = service; seen.pending.betterSidebar(scope) },
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
let betterCalls = 0
|
|
610
|
+
const betterService = () => ({ registerTab: () => { betterCalls += 1; return () => {} } })
|
|
611
|
+
|
|
612
|
+
// No better-sidebar: the native tab is the only entry.
|
|
613
|
+
const a = appCtx(null)
|
|
614
|
+
globalThis.__dshMemuExports.apply(a.ctx)
|
|
615
|
+
assert.equal(a.seen.native.length, 1, 'native tab registered when better-sidebar is absent')
|
|
616
|
+
assert.equal(a.seen.better.length, 0)
|
|
617
|
+
|
|
618
|
+
// Installed up front: only its tab, otherwise the start list shows two.
|
|
619
|
+
const b = appCtx(betterService())
|
|
620
|
+
globalThis.__dshMemuExports.apply(b.ctx)
|
|
621
|
+
assert.equal(b.seen.native.length, 0, 'native tab must not register when better-sidebar exists')
|
|
622
|
+
assert.equal(betterCalls, 1, 'its own tab is registered instead')
|
|
623
|
+
|
|
624
|
+
// Installed after us: the native tab we already registered has to go.
|
|
625
|
+
const c = appCtx(null)
|
|
626
|
+
globalThis.__dshMemuExports.apply(c.ctx)
|
|
627
|
+
assert.equal(c.seen.native.length, 1)
|
|
628
|
+
let betterRegistrations = 0
|
|
629
|
+
c.later({ registerTab: () => { betterRegistrations += 1; return () => {} } })
|
|
630
|
+
assert.equal(c.seen.native.length, 0, 'late better-sidebar removes the native tab')
|
|
631
|
+
assert.equal(betterRegistrations, 1, 'and registers its own')
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
// Exercise the actual settings component, not source-text matches: stale vector
|
|
635
|
+
// configuration must not escape the Agent gate, and rendering must never install.
|
|
636
|
+
async function renderVectorSettings(t, overrides = {}, answers = {}, pageValue = 1) {
|
|
637
|
+
const loaded = {
|
|
638
|
+
...EMPTY_STATE,
|
|
639
|
+
...overrides,
|
|
640
|
+
settings: { ...EMPTY_STATE.settings, ...overrides.settings },
|
|
641
|
+
memuConfig: { ...EMPTY_STATE.memuConfig, ...overrides.memuConfig },
|
|
642
|
+
}
|
|
643
|
+
const rpcCalls = []
|
|
644
|
+
const ctx = fakeCtx({ answers: { getSettingsState: loaded, ...answers }, rpcCalls })
|
|
645
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
646
|
+
const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
|
|
647
|
+
assert.ok(settings?.component, 'settings.section was not registered')
|
|
648
|
+
const props = { ctx, t: (key) => key }
|
|
649
|
+
const first = await renderPage(settings.component, props, undefined, { remount: true })
|
|
650
|
+
collectPageHook()
|
|
651
|
+
const cleanups = []
|
|
652
|
+
t.after(() => { for (const cleanup of cleanups.reverse()) cleanup() })
|
|
653
|
+
for (const effect of first.effects) {
|
|
654
|
+
const cleanup = effect.fn()
|
|
655
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
656
|
+
}
|
|
657
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
658
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
659
|
+
const tree = (await renderPage(settings.component, props, pageValue)).tree
|
|
660
|
+
const nodes = []
|
|
661
|
+
walkNodes(tree, (node) => { nodes.push(node) })
|
|
662
|
+
const rerender = async () => {
|
|
663
|
+
const { tree, effects } = await renderPage(settings.component, props, pageValue)
|
|
664
|
+
const nodes = []
|
|
665
|
+
walkNodes(tree, node => { nodes.push(node) })
|
|
666
|
+
return { tree, nodes, effects }
|
|
667
|
+
}
|
|
668
|
+
return { tree, nodes, rpcCalls, rerender }
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const VECTOR_CATALOG_FIXTURE = {
|
|
672
|
+
catalog: [
|
|
673
|
+
{ id: 'fixture-download', displayName: 'Fixture Download Model', availability: 'available', dimension: 384, totalBytes: 100 },
|
|
674
|
+
{ id: 'fixture-pending', displayName: 'Fixture Pending Model', availability: 'pending', pendingReasons: ['SHA-256 pending'], dimension: 384 },
|
|
675
|
+
{ id: 'fixture-installed', displayName: 'Fixture Installed Model', availability: 'available', dimension: 384 },
|
|
676
|
+
],
|
|
677
|
+
installations: { 'fixture-installed': { installed: true, fingerprintMatches: true, status: 'installed' } },
|
|
678
|
+
vector: {},
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
test('the default Agent backend hides vector source, catalog and API configuration', async (t) => {
|
|
682
|
+
const { tree, nodes, rpcCalls } = await renderVectorSettings(t, {
|
|
683
|
+
memuConfig: { source: 'api', model: 'stale-api-model' },
|
|
684
|
+
localVector: VECTOR_CATALOG_FIXTURE,
|
|
685
|
+
})
|
|
686
|
+
const text = treeText(tree)
|
|
687
|
+
assert.match(text, /backendAgent/)
|
|
688
|
+
assert.ok(nodes.some((node) => node.type === 'button' && node.props.children === 'backendAgent' && node.props['aria-selected'] === true))
|
|
689
|
+
for (const hidden of ['vectorSource', 'managedVector', 'Fixture Download Model', 'summaryModel']) {
|
|
690
|
+
assert.ok(!text.includes(hidden), hidden + ' escaped the default Agent gate')
|
|
691
|
+
}
|
|
692
|
+
assert.ok(!nodes.some((node) => node.type === 'input' && node.props.value === 'stale-api-model'))
|
|
693
|
+
assert.ok(!rpcCalls.some((call) => /downloadLocalVector|setEmbeddingSource|switchLocalVector/.test(call.endpoint)))
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
test('local vector settings render catalog controls and native download and migration progress', async (t) => {
|
|
697
|
+
const { tree, nodes, rpcCalls } = await renderVectorSettings(t, {
|
|
698
|
+
settings: { memoryBackend: 'api', vectorSource: 'local' },
|
|
699
|
+
localVector: {
|
|
700
|
+
...VECTOR_CATALOG_FIXTURE,
|
|
701
|
+
installations: {
|
|
702
|
+
...VECTOR_CATALOG_FIXTURE.installations,
|
|
703
|
+
'fixture-download': { status: 'downloading', progress: { files: 2, done: 1, bytes: 25, totalBytes: 100, current: 'model.onnx' } },
|
|
704
|
+
},
|
|
705
|
+
vector: {
|
|
706
|
+
active: { catalogId: 'previous-model' },
|
|
707
|
+
requested: { catalogId: 'fixture-installed' },
|
|
708
|
+
pending: { report: { progress: { completed: 2, total: 8, generation: 3, stage: 'embedding', current: { itemId: 'memory-2' } } } },
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
})
|
|
712
|
+
const text = treeText(tree)
|
|
713
|
+
for (const visible of ['vectorSourceLocal', 'Fixture Download Model', 'Fixture Pending Model', 'SHA-256 pending', 'managedVectorDownload', 'managedVectorSelect', 'managedVectorSwitch', 'managedVectorRollback', 'model.onnx', 'memory-2']) {
|
|
714
|
+
assert.ok(text.includes(visible), visible + ' did not render')
|
|
715
|
+
}
|
|
716
|
+
const download = nodes.filter((node) => node.type === 'button' && node.props.children === 'managedVectorDownload')
|
|
717
|
+
assert.equal(download.length, 1, 'a pending manifest must not get a download button')
|
|
718
|
+
const progress = nodes.filter((node) => node.type === 'progress')
|
|
719
|
+
assert.equal(progress.length, 2)
|
|
720
|
+
assert.ok(progress.some((node) => node.props['aria-label'] === 'managedVectorProgress' && node.props.value === 25 && node.props.max === 100))
|
|
721
|
+
assert.ok(progress.some((node) => node.props['aria-label'] === 'managedVectorRebuilding' && node.props.value === 2 && node.props.max === 8))
|
|
722
|
+
assert.equal(nodes.filter((node) => node.props?.role === 'status' && node.props['aria-live'] === 'polite').length, 2)
|
|
723
|
+
assert.ok(!nodes.some((node) => node.type === 'input' && node.props.type === 'password'), 'API key fields leaked into local source')
|
|
724
|
+
assert.ok(!rpcCalls.some((call) => /downloadLocalVector|selectLocalVector|switchLocalVector/.test(call.endpoint)), 'rendering performed a model operation')
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
test('migration with unknown total renders indeterminate native progress', async (t) => {
|
|
728
|
+
const { tree, nodes } = await renderVectorSettings(t, {
|
|
729
|
+
settings: { memoryBackend: 'api', vectorSource: 'local' },
|
|
730
|
+
localVector: { catalog: [], installations: {}, vector: { pending: { report: { progress: { completed: 0, total: 0, generation: 4, stage: 'preparing' } } } } },
|
|
731
|
+
})
|
|
732
|
+
const progress = nodes.filter((node) => node.type === 'progress')
|
|
733
|
+
assert.equal(progress.length, 1)
|
|
734
|
+
assert.equal(progress[0].props.value, undefined, 'unknown work must not look complete')
|
|
735
|
+
assert.equal(progress[0].props.max, 1)
|
|
736
|
+
assert.match(treeText(tree), /managedVectorGeneration 4/)
|
|
737
|
+
assert.ok(!treeText(tree).includes('NaN'))
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
test('switch waits for two clicks, reports in-flight work, and keeps a failed operation visible', async (t) => {
|
|
741
|
+
let finish
|
|
742
|
+
const response = new Promise(resolve => { finish = resolve })
|
|
743
|
+
const view = await renderVectorSettings(t, {
|
|
744
|
+
settings: { memoryBackend: 'api', vectorSource: 'local' },
|
|
745
|
+
localVector: {
|
|
746
|
+
...VECTOR_CATALOG_FIXTURE,
|
|
747
|
+
runtime: { status: 'installed', ready: true, path: 'runtime' },
|
|
748
|
+
vector: { requested: { catalogId: 'fixture-installed' }, active: { catalogId: 'old-model' } },
|
|
749
|
+
},
|
|
750
|
+
}, { switchLocalVectorProfile: () => response })
|
|
751
|
+
const button = current => current.nodes.find(node => node.type === 'button' && node.props.title === 'managedVectorSwitch')
|
|
752
|
+
await button(view).props.onClick()
|
|
753
|
+
assert.equal(view.rpcCalls.filter(call => call.endpoint === 'switchLocalVectorProfile').length, 0)
|
|
754
|
+
const armed = await view.rerender()
|
|
755
|
+
assert.equal(button(armed).props.children, 'confirmAction')
|
|
756
|
+
const operation = button(armed).props.onClick()
|
|
757
|
+
const waiting = await view.rerender()
|
|
758
|
+
assert.equal(button(waiting).props.disabled, true)
|
|
759
|
+
assert.ok(waiting.nodes.some(node => node.props?.role === 'status' && treeText(node).includes('managedVectorStatus: managedVectorSwitch')))
|
|
760
|
+
assert.match(treeText(waiting.tree), /managedVectorPrerequisites/)
|
|
761
|
+
finish({ ok: false, error: { code: 'LOCAL_VECTOR_RUNTIME_UNAVAILABLE', message: 'a verified managed runtime is required before switching local embeddings' } })
|
|
762
|
+
await operation
|
|
763
|
+
const failed = await view.rerender()
|
|
764
|
+
assert.equal(button(failed).props.disabled, false)
|
|
765
|
+
assert.ok(failed.nodes.some(node => node.props?.role === 'alert' && treeText(node).includes('verified managed runtime')))
|
|
766
|
+
assert.match(treeText(failed.tree), /old-model/)
|
|
767
|
+
assert.match(treeText(failed.tree), /managedVectorRequested/)
|
|
768
|
+
assert.equal(view.rpcCalls.filter(call => call.endpoint === 'switchLocalVectorProfile').length, 1)
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
test('pending manifests show verification and size reasons; persisted migration failures remain visible', async (t) => {
|
|
772
|
+
const { tree, nodes } = await renderVectorSettings(t, {
|
|
773
|
+
settings: { memoryBackend: 'api', vectorSource: 'local' },
|
|
774
|
+
localVector: {
|
|
775
|
+
...VECTOR_CATALOG_FIXTURE,
|
|
776
|
+
installations: { ...VECTOR_CATALOG_FIXTURE.installations, 'fixture-pending': { status: 'absent' } },
|
|
777
|
+
vector: { pending: { status: 'failed', error: {
|
|
778
|
+
code: 'VECTOR_SYNC_FAILED',
|
|
779
|
+
message: 'cannot list shadow database',
|
|
780
|
+
failedItems: [{ itemId: 'mem-failed-1', code: 'VECTOR_READBACK_FAILED', message: 'committed item is missing from shadow' }],
|
|
781
|
+
} } },
|
|
782
|
+
},
|
|
783
|
+
})
|
|
784
|
+
assert.match(treeText(tree), /managedVectorSizePending/)
|
|
785
|
+
assert.match(treeText(tree), /managedVectorPending/)
|
|
786
|
+
assert.match(treeText(tree), /SHA-256 pending/)
|
|
787
|
+
assert.ok(treeText(tree).includes('managedVectorSizePending'), 'pending artifacts must not display a guessed byte count')
|
|
788
|
+
assert.equal(nodes.filter(node => node.type === 'button' && node.props.title === 'managedVectorDownload').length, 1)
|
|
789
|
+
assert.ok(nodes.some(node => node.props?.role === 'alert' && treeText(node).includes('cannot list shadow database')))
|
|
790
|
+
// 重建失败必须说清是哪一条、为什么:只报"未覆盖全部记忆"根本没法修。
|
|
791
|
+
const failedText = treeText(tree)
|
|
792
|
+
assert.match(failedText, /mem-failed-1/)
|
|
793
|
+
assert.match(failedText, /VECTOR_READBACK_FAILED/)
|
|
794
|
+
assert.match(failedText, /committed item is missing from shadow/)
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
test('a finished rebuild reads as switched, not as a running progress bar', async (t) => {
|
|
798
|
+
const { tree } = await renderVectorSettings(t, {
|
|
799
|
+
settings: { memoryBackend: 'api', vectorSource: 'local' },
|
|
800
|
+
localVector: {
|
|
801
|
+
...VECTOR_CATALOG_FIXTURE,
|
|
802
|
+
runtime: { status: 'installed', ready: true, path: 'runtime' },
|
|
803
|
+
vector: {
|
|
804
|
+
active: { catalogId: 'fixture-installed', generation: 1, fingerprint: '47511436abcdef', lastMigration: { committed: 8, total: 8, generation: 1 } },
|
|
805
|
+
indexed: { global: ['a', 'b'], lib: ['c', 'd', 'e'] },
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
})
|
|
809
|
+
const text = treeText(tree)
|
|
810
|
+
assert.match(text, /vectorStageCommitted/)
|
|
811
|
+
assert.match(text, /vectorSwitchDoneHint/)
|
|
812
|
+
assert.doesNotMatch(text, /managedVectorRebuilding/, 'a committed generation must not keep the running label')
|
|
813
|
+
assert.match(text, /vectorRouteLocal/, 'the header must say which route recall uses')
|
|
814
|
+
assert.match(text, /vectorIndexedNow/, 'the card must show the live indexed count, not only the switch snapshot')
|
|
815
|
+
assert.match(text, /vectorIndexedNow 5 vectorIndexedUnit/, 'two global + three library ids')
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
test('API vector settings render provider fields and hide the managed local catalog', async (t) => {
|
|
819
|
+
const { tree, nodes } = await renderVectorSettings(t, {
|
|
820
|
+
settings: { memoryBackend: 'api', vectorSource: 'api' },
|
|
821
|
+
memuConfig: { source: 'api', provider: 'jina', model: 'fixture-api-model', baseUrl: 'https://example.invalid/v1', hasKey: true },
|
|
822
|
+
summaryConfig: { model: 'fixture-summary-model' },
|
|
823
|
+
localVector: VECTOR_CATALOG_FIXTURE,
|
|
824
|
+
})
|
|
825
|
+
const text = treeText(tree)
|
|
826
|
+
for (const visible of ['vectorSourceApi', 'provider', 'model', 'baseUrl', 'apiKey', 'summaryModel']) {
|
|
827
|
+
assert.ok(text.includes(visible), visible + ' did not render')
|
|
828
|
+
}
|
|
829
|
+
assert.ok(nodes.some((node) => node.type === 'select' && node.props.value === 'jina'))
|
|
830
|
+
assert.ok(nodes.some((node) => node.type === 'input' && node.props.value === 'fixture-api-model'))
|
|
831
|
+
assert.ok(nodes.some((node) => node.type === 'input' && node.props.value === 'https://example.invalid/v1'))
|
|
832
|
+
assert.equal(nodes.filter((node) => node.type === 'input' && node.props.type === 'password').length, 2)
|
|
833
|
+
assert.ok(!text.includes('Fixture Download Model'))
|
|
834
|
+
assert.ok(!text.includes('managedVectorDownload'))
|
|
835
|
+
assert.ok(!nodes.some((node) => node.type === 'progress'))
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
test('clicking the API source tab sends the explicit setEmbeddingSource RPC only', async (t) => {
|
|
839
|
+
const { nodes, rpcCalls } = await renderVectorSettings(t, {
|
|
840
|
+
settings: { memoryBackend: 'api', vectorSource: 'local' },
|
|
841
|
+
localVector: VECTOR_CATALOG_FIXTURE,
|
|
842
|
+
})
|
|
843
|
+
const apiTab = nodes.filter((node) => node.type === 'button' && node.props.children === 'vectorSourceApi')
|
|
844
|
+
assert.equal(apiTab.length, 1)
|
|
845
|
+
assert.equal(apiTab[0].props['aria-selected'], false)
|
|
846
|
+
assert.equal(apiTab[0].props.disabled, false)
|
|
847
|
+
assert.equal(rpcCalls.filter((call) => call.endpoint === 'setEmbeddingSource').length, 0)
|
|
848
|
+
await apiTab[0].props.onClick()
|
|
849
|
+
assert.deepEqual(rpcCalls.filter((call) => call.endpoint === 'setEmbeddingSource'), [
|
|
850
|
+
{ path: '/dsh-memu-memory', endpoint: 'setEmbeddingSource', payload: { source: 'api' } },
|
|
851
|
+
])
|
|
852
|
+
assert.ok(!rpcCalls.some((call) => /downloadLocalVector|selectLocalVector|switchLocalVector/.test(call.endpoint)))
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
test('the log page renders rows with copy, download and locate actions', async () => {
|
|
856
|
+
const event = {
|
|
857
|
+
id: 'act-fixture', at: '2026-10-30T03:00:00.000Z', kind: 'consolidate', level: 'warn',
|
|
858
|
+
scope: 'private', sessionId: 'session-abcdef', libraryId: 'lib-1',
|
|
859
|
+
message: '整理超时:fork 已停止', detail: { timedOut: true, childId: 'memu-consolidate-x' }, refs: [], ok: false,
|
|
860
|
+
}
|
|
861
|
+
const check = {
|
|
862
|
+
id: 'act-check', at: '2026-10-30T03:05:00.000Z', kind: 'check', level: 'info',
|
|
863
|
+
scope: 'private', sessionId: 'session-abcdef', libraryId: 'lib-1',
|
|
864
|
+
message: '主动检查跳过 · score 0.12 · large-transcript-thin · 空闲 40 分钟 · 会话 5913 事件',
|
|
865
|
+
detail: { reason: 'large-transcript-thin', score: 0.12, parts: { human: 0.33, content: 0.01, tool: 0 } }, refs: [], ok: false,
|
|
866
|
+
}
|
|
867
|
+
const ctx = fakeCtx({
|
|
868
|
+
answers: { getActivityLog: { events: [event, check], stats: { total: 2, capped: 2000, bytes: 1024 } } },
|
|
869
|
+
})
|
|
870
|
+
globalThis.__dshMemuExports.apply(ctx)
|
|
871
|
+
const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
|
|
872
|
+
const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
|
|
873
|
+
collectPageHook()
|
|
874
|
+
for (const effect of first.effects) effect.fn()
|
|
875
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
876
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
877
|
+
// The log page's own effect only exists once that page renders, so it needs one
|
|
878
|
+
// more render + effect pass before the data is in the hook.
|
|
879
|
+
const mid = await renderPage(settings.component, { ctx, t: (key) => key }, 10)
|
|
880
|
+
for (const effect of mid.effects) effect.fn()
|
|
881
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
882
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
883
|
+
const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, 10)
|
|
884
|
+
const text = treeText(tree)
|
|
885
|
+
assert.match(text, /整理超时/)
|
|
886
|
+
assert.match(text, /logCopyAll/)
|
|
887
|
+
assert.match(text, /logDownload/)
|
|
888
|
+
assert.match(text, /logLocate/, 'a row with a library id offers the correction entry point')
|
|
889
|
+
assert.match(text, /pageLogs/)
|
|
890
|
+
assert.match(text, /logKindCheck/, 'the activity log labels wake-up checks')
|
|
891
|
+
assert.match(text, /large-transcript-thin/, 'the check row keeps its reason and score visible')
|
|
892
|
+
})
|