dsh-memu-memory 1.1.2 → 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 +8 -6
- package/README.md +50 -5
- package/README.zh.md +49 -5
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +6 -4
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +40 -11
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -366
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +635 -0
- package/lib/client.js +728 -58
- 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/curator.js +81 -37
- package/lib/cycle.js +43 -6
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +232 -103
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +267 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +599 -57
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/mood.js +268 -39
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +450 -29
- 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 +54 -5
- package/lib/state.js +137 -172
- package/lib/tools.js +58 -7
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +968 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +7 -5
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +537 -0
- package/scripts/check-all.mjs +29 -1
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
- 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,221 @@
|
|
|
1
|
+
// Task E: the settings pane used to hear only about writes made in this document,
|
|
2
|
+
// and only through a manual reload after a tool call or another window changed
|
|
3
|
+
// something. It now shares the sidebar's heartbeat — poll a two-field marker,
|
|
4
|
+
// refetch only when it moves, send nothing while hidden — and wakes immediately
|
|
5
|
+
// on the cross-window channel without echoing back. These cases drive the real
|
|
6
|
+
// SettingsPage with a fake hook runtime.
|
|
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
|
+
|
|
20
|
+
const React = {
|
|
21
|
+
createElement: makeElement,
|
|
22
|
+
Fragment: Symbol('Fragment'),
|
|
23
|
+
useState(initial) {
|
|
24
|
+
const index = cursor++
|
|
25
|
+
if (hooks[index] === undefined) hooks[index] = typeof initial === "function" ? initial() : initial
|
|
26
|
+
return [hooks[index], (next) => { hooks[index] = typeof next === "function" ? next(hooks[index]) : next }]
|
|
27
|
+
},
|
|
28
|
+
useCallback: (fn) => fn,
|
|
29
|
+
useMemo: (fn) => fn(),
|
|
30
|
+
useEffect(fn, deps) { effects.push({ fn, deps }) },
|
|
31
|
+
useLayoutEffect() {},
|
|
32
|
+
useRef: (value) => ({ current: value }),
|
|
33
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const intervals = []
|
|
37
|
+
globalThis.setInterval = (fn, ms) => { intervals.push({ fn, ms }); return intervals.length }
|
|
38
|
+
globalThis.clearInterval = () => {}
|
|
39
|
+
|
|
40
|
+
const visibilityListeners = new Set()
|
|
41
|
+
globalThis.document = {
|
|
42
|
+
visibilityState: 'visible',
|
|
43
|
+
addEventListener(name, fn) { if (name === "visibilitychange") visibilityListeners.add(fn) },
|
|
44
|
+
removeEventListener(name, fn) { if (name === "visibilitychange") visibilityListeners.delete(fn) },
|
|
45
|
+
createElement: () => ({ style: {}, setAttribute() {}, appendChild() {}, remove() {}, click() {} }),
|
|
46
|
+
querySelector: () => null,
|
|
47
|
+
head: { appendChild() {} },
|
|
48
|
+
body: { appendChild() {} },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The channel is how another document announces a write. The fake records posts so
|
|
52
|
+
// the test can prove an inbound message is never re-announced.
|
|
53
|
+
const channels = []
|
|
54
|
+
class FakeChannel {
|
|
55
|
+
constructor(name) { this.name = name; this.posted = []; channels.push(this) }
|
|
56
|
+
postMessage(data) { this.posted.push(data) }
|
|
57
|
+
unref() {}
|
|
58
|
+
close() {}
|
|
59
|
+
}
|
|
60
|
+
globalThis.BroadcastChannel = FakeChannel
|
|
61
|
+
|
|
62
|
+
globalThis.window = {
|
|
63
|
+
__ModuleLoader__: {
|
|
64
|
+
load({ factory }) {
|
|
65
|
+
globalThis.__dshMemuExports = factory((name) => {
|
|
66
|
+
if (name === "react") return React
|
|
67
|
+
throw new Error("unexpected require: " + name)
|
|
68
|
+
})
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
addEventListener() {},
|
|
72
|
+
removeEventListener() {},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await import('../lib/client.js')
|
|
76
|
+
|
|
77
|
+
function makeSettingsState(server) {
|
|
78
|
+
return {
|
|
79
|
+
engine: { status: 'ready', versions: [], currentVersion: '0.11.0b3', recommendedVersion: '0.11.0b3', installed: true, state: {} },
|
|
80
|
+
settings: { autoInject: 'smart', memoryBackend: 'agent', libraries: [] },
|
|
81
|
+
memuConfig: { provider: 'openai' },
|
|
82
|
+
summaryConfig: {},
|
|
83
|
+
libraries: [],
|
|
84
|
+
archivedLibraries: [],
|
|
85
|
+
revision: server.revision,
|
|
86
|
+
stamp: server.stamp,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function makeCtx() {
|
|
91
|
+
const calls = []
|
|
92
|
+
const server = { revision: 1, stamp: 'state:10:1' }
|
|
93
|
+
const call = async (_path, endpoint, payload) => {
|
|
94
|
+
calls.push({ endpoint, payload })
|
|
95
|
+
if (endpoint === 'getSettingsState') return { ok: true, value: makeSettingsState(server) }
|
|
96
|
+
if (endpoint === 'getSidebarState') {
|
|
97
|
+
if (payload?.revisionOnly === true) return { ok: true, value: { revision: server.revision, stamp: server.stamp } }
|
|
98
|
+
return { ok: true, value: {} }
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, value: {} }
|
|
101
|
+
}
|
|
102
|
+
const ctx = {
|
|
103
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
104
|
+
locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
|
|
105
|
+
connection: { rpc: { call } },
|
|
106
|
+
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
|
|
107
|
+
get: () => undefined,
|
|
108
|
+
slots: { inject: (_name, callback) => callback({}), register: (spec, component) => { registrations.push({ spec, component }); return () => {} } },
|
|
109
|
+
inject: (_deps, callback) => callback({
|
|
110
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
111
|
+
sidebarRightTabs: { register: () => () => {} },
|
|
112
|
+
get: () => undefined,
|
|
113
|
+
}),
|
|
114
|
+
}
|
|
115
|
+
return { ctx, calls, server }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const tick = async (times = 4) => {
|
|
119
|
+
for (let i = 0; i < times; i++) await new Promise((resolve) => setTimeout(resolve, 0))
|
|
120
|
+
}
|
|
121
|
+
const settingsCalls = (calls) => calls.filter((c) => c.endpoint === 'getSettingsState')
|
|
122
|
+
const beatCalls = (calls) => calls.filter((c) => c.endpoint === 'getSidebarState')
|
|
123
|
+
|
|
124
|
+
async function paintSettings(api = globalThis.__dshMemuExports) {
|
|
125
|
+
hooks = []
|
|
126
|
+
cursor = 0
|
|
127
|
+
effects = []
|
|
128
|
+
intervals.length = 0
|
|
129
|
+
visibilityListeners.clear()
|
|
130
|
+
registrations.length = 0
|
|
131
|
+
const harness = makeCtx()
|
|
132
|
+
api.apply(harness.ctx)
|
|
133
|
+
const registration = registrations.filter((r) => r.spec?.name === 'settings.section').at(-1)
|
|
134
|
+
assert.ok(registration?.component, 'the settings section was not registered')
|
|
135
|
+
const tree = registration.component({ ctx: harness.ctx, t: (key) => key })
|
|
136
|
+
assert.ok(tree, 'the settings page rendered nothing')
|
|
137
|
+
for (const effect of effects.slice()) effect.fn()
|
|
138
|
+
await tick()
|
|
139
|
+
return { ...harness, tree }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test('the settings pane arms one heartbeat and seeds it from its first paint', async () => {
|
|
143
|
+
const page = await paintSettings()
|
|
144
|
+
assert.equal(settingsCalls(page.calls).length, 1, 'the first paint is one fetch')
|
|
145
|
+
assert.equal(intervals.length, 1, 'exactly one heartbeat interval is armed on the settings page')
|
|
146
|
+
assert.ok(intervals[0].ms > 0 && intervals[0].ms <= 5000, 'the beat is seconds apart, not minutes')
|
|
147
|
+
assert.equal(visibilityListeners.size, 1, 'returning to the window is listened for')
|
|
148
|
+
|
|
149
|
+
// The first beat must compare against the revision/stamp that came with the
|
|
150
|
+
// paint. If the paint did not seed the marker, this would refetch needlessly.
|
|
151
|
+
await intervals[0].fn()
|
|
152
|
+
await tick()
|
|
153
|
+
assert.equal(beatCalls(page.calls).length, 1, 'one cheap beat was sent')
|
|
154
|
+
assert.equal(settingsCalls(page.calls).length, 1, 'an unchanged marker does not refetch')
|
|
155
|
+
const beat = beatCalls(page.calls).at(-1)
|
|
156
|
+
assert.deepEqual(Object.keys(beat.payload).sort(), ['revisionOnly'], 'the beat stays a bare marker')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a settings write from another window lands within one beat', async () => {
|
|
160
|
+
const page = await paintSettings()
|
|
161
|
+
// Exactly what an out-of-process write looks like here: settings live in
|
|
162
|
+
// state.json, so only the on-disk stamp moves; this process's counter stands still.
|
|
163
|
+
page.server.stamp = 'state:11:1'
|
|
164
|
+
await intervals[0].fn()
|
|
165
|
+
await tick()
|
|
166
|
+
assert.equal(page.server.revision, 1, 'the in-process counter deliberately did not move')
|
|
167
|
+
assert.equal(settingsCalls(page.calls).length, 2, 'the stamp alone must trigger a refetch')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('a channel message wakes the pane immediately and is never echoed back', async () => {
|
|
171
|
+
const page = await paintSettings()
|
|
172
|
+
const channel = channels.at(-1)
|
|
173
|
+
assert.ok(channel, 'the client listens on the shared channel')
|
|
174
|
+
|
|
175
|
+
channel.onmessage({ data: { type: 'memory-write', kind: 'settings', libraryId: null, at: new Date().toISOString() } })
|
|
176
|
+
await tick()
|
|
177
|
+
assert.equal(settingsCalls(page.calls).length, 2, 'an announced write refetches without waiting for a beat')
|
|
178
|
+
assert.equal(channel.posted.length, 0, 'the receiver must not announce the write again')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('a hidden pane sends no beats, and coming back refetches once', async () => {
|
|
182
|
+
const page = await paintSettings()
|
|
183
|
+
await intervals[0].fn()
|
|
184
|
+
await tick()
|
|
185
|
+
assert.equal(beatCalls(page.calls).length, 1, 'the visible pane polls')
|
|
186
|
+
|
|
187
|
+
globalThis.document.visibilityState = 'hidden'
|
|
188
|
+
await intervals[0].fn()
|
|
189
|
+
await tick()
|
|
190
|
+
assert.equal(beatCalls(page.calls).length, 1, 'a hidden pane sends no beats at all')
|
|
191
|
+
for (const listener of visibilityListeners) listener()
|
|
192
|
+
await tick()
|
|
193
|
+
assert.equal(settingsCalls(page.calls).length, 1, 'a hidden pane does not refetch on the event either')
|
|
194
|
+
|
|
195
|
+
globalThis.document.visibilityState = 'visible'
|
|
196
|
+
for (const listener of visibilityListeners) listener()
|
|
197
|
+
await tick()
|
|
198
|
+
assert.equal(settingsCalls(page.calls).length, 2, 'returning to the window refetches')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('without BroadcastChannel the heartbeat alone keeps the pane honest', async () => {
|
|
202
|
+
// Host differences are not defects: a blocked channel must degrade to the beat,
|
|
203
|
+
// not throw or silently stop syncing (TASKS E, acceptance 3).
|
|
204
|
+
const saved = globalThis.BroadcastChannel
|
|
205
|
+
const savedApi = globalThis.__dshMemuExports
|
|
206
|
+
try {
|
|
207
|
+
globalThis.BroadcastChannel = undefined
|
|
208
|
+
await import('../lib/client.js?nochannel')
|
|
209
|
+
const api = globalThis.__dshMemuExports
|
|
210
|
+
assert.notEqual(api, savedApi, 'a fresh module instance was loaded')
|
|
211
|
+
|
|
212
|
+
const page = await paintSettings(api)
|
|
213
|
+
page.server.stamp = 'state:12:1'
|
|
214
|
+
await intervals[0].fn()
|
|
215
|
+
await tick()
|
|
216
|
+
assert.equal(settingsCalls(page.calls).length, 2, 'the beat still hears the write')
|
|
217
|
+
} finally {
|
|
218
|
+
globalThis.BroadcastChannel = saved
|
|
219
|
+
globalThis.__dshMemuExports = savedApi
|
|
220
|
+
}
|
|
221
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-short-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const state = await import('../lib/state.js')
|
|
11
|
+
const memory = await import('../lib/memory.js')
|
|
12
|
+
const inject = await import('../lib/inject.js')
|
|
13
|
+
const privateMod = await import('../lib/private.js')
|
|
14
|
+
|
|
15
|
+
test.after(() => {
|
|
16
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
function execFor(sessionId, cwd) {
|
|
20
|
+
return { agent: { session: { header: { id: sessionId, cwd, agentPreset: 'coding' } } } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function rewindDeadline(libraryId, id, ms = 60_000) {
|
|
24
|
+
const index = state.loadPrivateIndex(libraryId)
|
|
25
|
+
index.entries[id].expiresAt = new Date(Date.now() - ms).toISOString()
|
|
26
|
+
state.savePrivateIndex(libraryId, index)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('short_term writes carry a deadline, and the sweep expires them instead of deleting', async () => {
|
|
30
|
+
const cwd = path.join(home, 'proj-ttl')
|
|
31
|
+
const sessionId = 'short-ttl'
|
|
32
|
+
const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
|
|
33
|
+
const exec = execFor(sessionId, cwd)
|
|
34
|
+
|
|
35
|
+
const { id, entry } = await memory.privateRemember({
|
|
36
|
+
exec,
|
|
37
|
+
content: 'Staging token rotation is due Friday.',
|
|
38
|
+
title: 'staging token rotation',
|
|
39
|
+
layer: 'short_term',
|
|
40
|
+
})
|
|
41
|
+
assert.equal(entry.layer, 'short_term')
|
|
42
|
+
const hours = (Date.parse(entry.expiresAt) - Date.now()) / 3_600_000
|
|
43
|
+
assert.ok(hours > 71 && hours <= 72, `expected a ~72h deadline, got ${hours}`)
|
|
44
|
+
|
|
45
|
+
rewindDeadline(binding.libraryId, id)
|
|
46
|
+
const sweep = memory.expireShortTerm()
|
|
47
|
+
assert.ok(sweep.expired.some((row) => row.id === id), 'the sweep must pick up the past-due entry')
|
|
48
|
+
|
|
49
|
+
const after = state.loadPrivateIndex(binding.libraryId).entries[id]
|
|
50
|
+
assert.equal(after.status, 'expired')
|
|
51
|
+
assert.ok(after.content.includes('Staging token rotation'), 'the body survives — promotion is still possible from the archive view')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('an expired short_term entry leaves the anchor and the recall path', async () => {
|
|
55
|
+
const cwd = path.join(home, 'proj-gone')
|
|
56
|
+
const sessionId = 'short-gone'
|
|
57
|
+
const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
|
|
58
|
+
const exec = execFor(sessionId, cwd)
|
|
59
|
+
|
|
60
|
+
const { id } = await memory.privateRemember({
|
|
61
|
+
exec,
|
|
62
|
+
content: 'The migration checklist lives in docs/migrate.md.',
|
|
63
|
+
title: 'migration checklist',
|
|
64
|
+
layer: 'short_term',
|
|
65
|
+
})
|
|
66
|
+
const before = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 700 })
|
|
67
|
+
assert.ok(before.pointerIds.includes(id))
|
|
68
|
+
const recalled = await memory.privateRecall({ exec, query: 'migration checklist', topK: 5 })
|
|
69
|
+
assert.ok(recalled.files.some((file) => file.name === id))
|
|
70
|
+
|
|
71
|
+
// Expired by the clock alone, before any sweep has run: the deadline itself has
|
|
72
|
+
// to be authoritative, otherwise a dead draft rides along for up to five minutes.
|
|
73
|
+
rewindDeadline(binding.libraryId, id, 1_000)
|
|
74
|
+
const after = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 700 })
|
|
75
|
+
assert.equal(after.pointerIds.includes(id), false)
|
|
76
|
+
assert.equal(after.text.includes(id), false)
|
|
77
|
+
const recalledAgain = await memory.privateRecall({ exec, query: 'migration checklist', topK: 5 })
|
|
78
|
+
assert.equal(recalledAgain.files.some((file) => file.name === id), false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('the anchor shows short_term as a pointer card, never as body text', async () => {
|
|
82
|
+
const cwd = path.join(home, 'proj-card')
|
|
83
|
+
const sessionId = 'short-card'
|
|
84
|
+
const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
|
|
85
|
+
const exec = execFor(sessionId, cwd)
|
|
86
|
+
|
|
87
|
+
const { id } = await memory.privateRemember({
|
|
88
|
+
exec,
|
|
89
|
+
content: 'BODY-MARKER: the whole argument about the pricing page, in detail.',
|
|
90
|
+
title: 'pricing thread',
|
|
91
|
+
layer: 'short_term',
|
|
92
|
+
})
|
|
93
|
+
const anchor = inject.buildAnchor(binding, { ...state.getSettings(), anchorTokens: 700 })
|
|
94
|
+
assert.ok(anchor.text.includes('[recent · short_term]'))
|
|
95
|
+
assert.ok(anchor.text.includes(`[${id}]`))
|
|
96
|
+
assert.ok(anchor.text.includes('pricing thread'))
|
|
97
|
+
assert.ok(anchor.text.includes('小时后过期'))
|
|
98
|
+
assert.equal(anchor.text.includes('BODY-MARKER'), false, 'switching conversations must not inject the previous conversation')
|
|
99
|
+
// A pointer is not content, so it must not be marked as already injected: that
|
|
100
|
+
// would suppress the first real recall of a memory the model was only told about.
|
|
101
|
+
assert.equal(anchor.ids.includes(id), false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('promoting a short_term draft clears its deadline; must_remember stays out of update', async () => {
|
|
105
|
+
const cwd = path.join(home, 'proj-promote')
|
|
106
|
+
const sessionId = 'short-promote'
|
|
107
|
+
const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
|
|
108
|
+
const exec = execFor(sessionId, cwd)
|
|
109
|
+
|
|
110
|
+
const { id } = await memory.privateRemember({
|
|
111
|
+
exec,
|
|
112
|
+
content: 'He said the anniversary matters more than the launch date.',
|
|
113
|
+
title: 'anniversary over launch',
|
|
114
|
+
layer: 'short_term',
|
|
115
|
+
})
|
|
116
|
+
const promoted = await memory.privateUpdate({ exec, id, layer: 'long_term' })
|
|
117
|
+
assert.equal(promoted.entry.layer, 'long_term')
|
|
118
|
+
assert.equal(promoted.entry.expiresAt, undefined)
|
|
119
|
+
assert.equal(promoted.entry.promotedFrom, 'short_term')
|
|
120
|
+
assert.equal(promoted.entry.status, 'active')
|
|
121
|
+
|
|
122
|
+
rewindDeadline(binding.libraryId, id, 1_000)
|
|
123
|
+
const sweep = memory.expireShortTerm()
|
|
124
|
+
assert.equal(sweep.expired.some((row) => row.id === id), false, 'a promoted entry is no longer on a clock')
|
|
125
|
+
|
|
126
|
+
await assert.rejects(
|
|
127
|
+
() => memory.privateUpdate({ exec, id, layer: 'must_remember' }),
|
|
128
|
+
/pin_reason/,
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('the idle pass is told which drafts are about to expire', async () => {
|
|
133
|
+
const cwd = path.join(home, 'proj-brief')
|
|
134
|
+
const sessionId = 'short-brief'
|
|
135
|
+
const binding = state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
|
|
136
|
+
const exec = execFor(sessionId, cwd)
|
|
137
|
+
|
|
138
|
+
const { id } = await memory.privateRemember({
|
|
139
|
+
exec,
|
|
140
|
+
content: 'We were still deciding whether to ship on Monday.',
|
|
141
|
+
title: 'ship date undecided',
|
|
142
|
+
layer: 'short_term',
|
|
143
|
+
})
|
|
144
|
+
const brief = privateMod.shortTermBrief(state.getSessionBinding(sessionId))
|
|
145
|
+
assert.ok(brief.includes('[short_term · 快到期了]'))
|
|
146
|
+
assert.ok(brief.includes(id))
|
|
147
|
+
assert.ok(brief.includes('memu_private_update'))
|
|
148
|
+
const prompt = privateMod.buildPrivateConsolidationPrompt(sessionId)
|
|
149
|
+
assert.ok(prompt.includes(id), 'the consolidation prompt must carry the decision list')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('with shortTermTtlHours=0 the layer is closed instead of silently permanent', async () => {
|
|
153
|
+
const cwd = path.join(home, 'proj-off')
|
|
154
|
+
const sessionId = 'short-off'
|
|
155
|
+
state.ensureSession(sessionId, { cwd, agentPreset: 'coding' })
|
|
156
|
+
const exec = execFor(sessionId, cwd)
|
|
157
|
+
state.updateSettings({ shortTermTtlHours: 0 })
|
|
158
|
+
try {
|
|
159
|
+
await assert.rejects(
|
|
160
|
+
() => memory.privateRemember({ exec, content: 'Temporary note.', layer: 'short_term' }),
|
|
161
|
+
/disabled/,
|
|
162
|
+
)
|
|
163
|
+
} finally {
|
|
164
|
+
state.updateSettings({ shortTermTtlHours: 72 })
|
|
165
|
+
}
|
|
166
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// The sidebar heartbeat used to compare an in-process counter. A write served by a
|
|
2
|
+
// different module instance — the model calling memu_private_remember mid-turn, a
|
|
3
|
+
// curator job, a second window — moved the index on disk but never the counter, so
|
|
4
|
+
// the panel sat stale until someone pressed Refresh by hand. These cases drive the
|
|
5
|
+
// real MemoryTab with a fake hook runtime and prove the panel now refetches when
|
|
6
|
+
// only the on-disk stamp moved.
|
|
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
|
+
|
|
20
|
+
const React = {
|
|
21
|
+
createElement: makeElement,
|
|
22
|
+
Fragment: Symbol('Fragment'),
|
|
23
|
+
useState(initial) {
|
|
24
|
+
const index = cursor++
|
|
25
|
+
if (hooks[index] === undefined) hooks[index] = typeof initial === "function" ? initial() : initial
|
|
26
|
+
return [hooks[index], (next) => { hooks[index] = typeof next === "function" ? next(hooks[index]) : next }]
|
|
27
|
+
},
|
|
28
|
+
useCallback: (fn) => fn,
|
|
29
|
+
useMemo: (fn) => fn(),
|
|
30
|
+
useEffect(fn, deps) { effects.push({ fn, deps }) },
|
|
31
|
+
useLayoutEffect() {},
|
|
32
|
+
useRef: (value) => ({ current: value }),
|
|
33
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// setInterval is the heartbeat itself: the test keeps the callback and fires it by hand,
|
|
37
|
+
// so no fake clock and no real 2.5s wait.
|
|
38
|
+
const intervals = []
|
|
39
|
+
globalThis.setInterval = (fn, ms) => { intervals.push({ fn, ms }); return intervals.length }
|
|
40
|
+
globalThis.clearInterval = () => {}
|
|
41
|
+
|
|
42
|
+
const visibilityListeners = new Set()
|
|
43
|
+
globalThis.document = {
|
|
44
|
+
visibilityState: 'visible',
|
|
45
|
+
addEventListener(name, fn) { if (name === "visibilitychange") visibilityListeners.add(fn) },
|
|
46
|
+
removeEventListener(name, fn) { if (name === "visibilitychange") visibilityListeners.delete(fn) },
|
|
47
|
+
createElement: () => ({ style: {}, setAttribute() {}, appendChild() {}, remove() {}, click() {} }),
|
|
48
|
+
querySelector: () => null,
|
|
49
|
+
head: { appendChild() {} },
|
|
50
|
+
body: { appendChild() {} },
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
globalThis.window = {
|
|
54
|
+
__ModuleLoader__: {
|
|
55
|
+
load({ factory }) {
|
|
56
|
+
globalThis.__dshMemuExports = factory((name) => {
|
|
57
|
+
if (name === "react") return React
|
|
58
|
+
throw new Error("unexpected require: " + name)
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
addEventListener() {},
|
|
63
|
+
removeEventListener() {},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await import('../lib/client.js')
|
|
67
|
+
const SNAPSHOT = {
|
|
68
|
+
binding: { sessionId: 'beat-session', libraryId: 'lib-1', useLibrary: true, useGlobal: true, scopeKey: 'demo' },
|
|
69
|
+
domain: 'coding',
|
|
70
|
+
folder: { scopeKey: 'demo', useLibrary: true, useGlobal: true },
|
|
71
|
+
effective: { useLibrary: true, useGlobal: true, enabled: true, folder: { scopeKey: 'demo', useLibrary: true, useGlobal: true } },
|
|
72
|
+
private: { items: [], active: 0, pinned: 0, total: 0 },
|
|
73
|
+
global: { items: [], active: 0, total: 0 },
|
|
74
|
+
domains: ['rp', 'coding'],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The mock server answers the beat and the full read separately, and the test can
|
|
78
|
+
// move the stamp without touching the counter — exactly the out-of-process write
|
|
79
|
+
// the old heartbeat was blind to.
|
|
80
|
+
function makeCtx() {
|
|
81
|
+
const calls = []
|
|
82
|
+
const server = { revision: 1, stamp: 'state:10:1|index-a' }
|
|
83
|
+
const call = async (_path, endpoint, payload) => {
|
|
84
|
+
calls.push({ endpoint, payload })
|
|
85
|
+
if (endpoint === 'getSidebarState') {
|
|
86
|
+
if (payload?.revisionOnly === true) return { ok: true, value: { revision: server.revision, stamp: server.stamp } }
|
|
87
|
+
return { ok: true, value: { ...SNAPSHOT, revision: server.revision, stamp: server.stamp } }
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, value: {} }
|
|
90
|
+
}
|
|
91
|
+
const ctx = {
|
|
92
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
93
|
+
locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
|
|
94
|
+
connection: { rpc: { call } },
|
|
95
|
+
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
|
|
96
|
+
get: (name) => (name === 'uiSession'
|
|
97
|
+
? { adapter: { current: { getSnapshot: () => ({ key: 'beat-session' }), subscribe: () => () => {} } } }
|
|
98
|
+
: (name === 'sidebarRight' ? undefined : undefined)),
|
|
99
|
+
slots: { inject: (_name, callback) => callback({}), register: (spec, component) => { registrations.push({ spec, component }); return () => {} } },
|
|
100
|
+
// The plugin registers the tab inside inject(): the scope has to run effects now,
|
|
101
|
+
// or the registration never happens and there is nothing to mount.
|
|
102
|
+
inject: (_deps, callback) => callback({
|
|
103
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
104
|
+
sidebarRightTabs: { register: () => () => {} },
|
|
105
|
+
get: () => undefined,
|
|
106
|
+
}),
|
|
107
|
+
}
|
|
108
|
+
return { ctx, calls, server }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fullCalls = (calls) => calls.filter((c) => c.endpoint === 'getSidebarState' && c.payload?.revisionOnly !== true)
|
|
112
|
+
const beatCalls = (calls) => calls.filter((c) => c.payload?.revisionOnly === true)
|
|
113
|
+
|
|
114
|
+
async function mountTab() {
|
|
115
|
+
const harness = makeCtx()
|
|
116
|
+
globalThis.__dshMemuExports.apply(harness.ctx)
|
|
117
|
+
// A later paint must not reuse an earlier mount's registration: its ctx owns a
|
|
118
|
+
// different call log, and the assertions would silently read the wrong one.
|
|
119
|
+
const registration = registrations.filter((r) => r.spec?.name === 'sidebar.right.pane.tab').at(-1)
|
|
120
|
+
assert.ok(registration?.component, 'the memory tab was not registered into the sidebar slot')
|
|
121
|
+
return { component: registration.component, ...harness }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function expand(node, depth = 0) {
|
|
125
|
+
if (!node || typeof node !== 'object' || depth > 200) return
|
|
126
|
+
if (Array.isArray(node)) { for (const child of node) expand(child, depth + 1); return }
|
|
127
|
+
if (typeof node.type === 'function') { expand(node.type(node.props), depth + 1); return }
|
|
128
|
+
expand(node.props?.children, depth + 1)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const tick = async (times = 3) => {
|
|
132
|
+
for (let i = 0; i < times; i++) await new Promise((resolve) => setTimeout(resolve, 0))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Mount, run every effect the tab registered (mount fetch, write listener,
|
|
136
|
+
// heartbeat, visibility listener), and let the first fetch settle.
|
|
137
|
+
async function paintTab() {
|
|
138
|
+
hooks = []
|
|
139
|
+
cursor = 0
|
|
140
|
+
effects = []
|
|
141
|
+
intervals.length = 0
|
|
142
|
+
visibilityListeners.clear()
|
|
143
|
+
registrations.length = 0
|
|
144
|
+
const tab = await mountTab()
|
|
145
|
+
// The registered body is one wrapper element around MemoryTab; function components
|
|
146
|
+
// are not executed by createElement, so the tree has to be walked for the hooks and
|
|
147
|
+
// effects to exist at all.
|
|
148
|
+
expand(tab.component({ ctx: tab.ctx, t: (key) => key, sessionId: 'beat-session' }))
|
|
149
|
+
const started = effects.slice()
|
|
150
|
+
for (const effect of started) effect.fn()
|
|
151
|
+
await tick()
|
|
152
|
+
return { ...tab, started }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test('mounting paints once and arms exactly one heartbeat', async () => {
|
|
156
|
+
const tab = await paintTab()
|
|
157
|
+
assert.equal(fullCalls(tab.calls).length, 1, 'the panel fetches the first screen once on mount')
|
|
158
|
+
assert.equal(intervals.length, 1, 'exactly one heartbeat interval is armed')
|
|
159
|
+
assert.ok(intervals[0].ms > 0 && intervals[0].ms <= 5000, 'the beat is seconds apart, not minutes')
|
|
160
|
+
assert.equal(visibilityListeners.size, 1, 'returning to the window is listened for')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('an unchanged marker does not refetch', async () => {
|
|
164
|
+
const tab = await paintTab()
|
|
165
|
+
await intervals[0].fn()
|
|
166
|
+
await tick()
|
|
167
|
+
assert.equal(beatCalls(tab.calls).length, 1, 'one cheap beat was sent')
|
|
168
|
+
assert.equal(fullCalls(tab.calls).length, 1, 'nothing moved, so nothing was refetched')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// The regression this file exists for: another process saved the index. The counter
|
|
172
|
+
// this process holds does not move, only the on-disk stamp does.
|
|
173
|
+
test('a stamp-only change refetches, even when the counter stands still', async () => {
|
|
174
|
+
const tab = await paintTab()
|
|
175
|
+
tab.server.stamp = 'state:10:1|index-a:bumped'
|
|
176
|
+
await intervals[0].fn()
|
|
177
|
+
await tick()
|
|
178
|
+
assert.equal(tab.server.revision, 1, 'the in-process counter deliberately did not move')
|
|
179
|
+
assert.equal(fullCalls(tab.calls).length, 2, 'the panel must refetch on the stamp alone')
|
|
180
|
+
const last = fullCalls(tab.calls).at(-1)
|
|
181
|
+
assert.equal(last.payload.revisionOnly, undefined, 'the refetch is the full first screen')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('a counter move still refetches, and the beat carries no entries', async () => {
|
|
185
|
+
const tab = await paintTab()
|
|
186
|
+
tab.server.revision = 2
|
|
187
|
+
await intervals[0].fn()
|
|
188
|
+
await tick()
|
|
189
|
+
assert.equal(fullCalls(tab.calls).length, 2, 'a new revision must still refetch')
|
|
190
|
+
const beat = beatCalls(tab.calls).at(-1)
|
|
191
|
+
assert.deepEqual(Object.keys(beat.payload).sort(), ['revisionOnly'], 'the beat stays a bare marker')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('becoming visible refetches once, and a hidden panel does not', async () => {
|
|
195
|
+
const tab = await paintTab()
|
|
196
|
+
for (const listener of visibilityListeners) listener()
|
|
197
|
+
await tick()
|
|
198
|
+
assert.equal(fullCalls(tab.calls).length, 2, 'returning to the window refetches')
|
|
199
|
+
|
|
200
|
+
globalThis.document.visibilityState = 'hidden'
|
|
201
|
+
await intervals[0].fn()
|
|
202
|
+
await tick()
|
|
203
|
+
assert.equal(beatCalls(tab.calls).length, 0, 'a hidden panel sends no beats at all')
|
|
204
|
+
for (const listener of visibilityListeners) listener()
|
|
205
|
+
await tick()
|
|
206
|
+
assert.equal(fullCalls(tab.calls).length, 2, 'a hidden panel does not refetch on the event either')
|
|
207
|
+
globalThis.document.visibilityState = 'visible'
|
|
208
|
+
})
|