dsh-memu-memory 1.1.3 → 1.2.1
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 +39 -6
- package/README.zh.md +39 -7
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +3 -3
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +8 -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 +396 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +23 -6
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +335 -35
- 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 +144 -0
- package/lib/curator.js +86 -31
- package/lib/cycle.js +40 -6
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +534 -88
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +430 -43
- 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 +29 -12
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +113 -10
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +51 -0
- package/package.json +4 -2
- 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/scripts/replay.mjs +573 -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 +216 -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 +331 -0
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +489 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +360 -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-commit.test.mjs +277 -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-cascade.test.mjs +190 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +94 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +122 -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 +591 -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/recall-dedup.test.mjs +417 -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 +212 -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 +790 -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 +165 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import sharp from 'sharp'
|
|
8
|
+
import { zipSync, unzipSync } from 'fflate'
|
|
9
|
+
|
|
10
|
+
// W4: every browser-facing entry of this plugin goes through one handler mounted
|
|
11
|
+
// twice (current and legacy prefix). The tests below drive each registered route
|
|
12
|
+
// separately and prove that a 401/403 is decided before body parsing, index or
|
|
13
|
+
// attachment reads, state writes, and imports.
|
|
14
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-webui-auth-'))
|
|
15
|
+
process.env.DSH_MEMU_HOME = home
|
|
16
|
+
|
|
17
|
+
const state = await import('../lib/state.js')
|
|
18
|
+
const { registerRpc } = await import('../lib/rpc.js')
|
|
19
|
+
const { standaloneHtml, standaloneAsset } = await import('../lib/webui.js')
|
|
20
|
+
|
|
21
|
+
test.after(() => { state.flushState(); fs.rmSync(home, { recursive: true, force: true }) })
|
|
22
|
+
|
|
23
|
+
const html = standaloneHtml()
|
|
24
|
+
const clientJs = standaloneAsset('/dsh-memu-memory/ui-assets/client.js')
|
|
25
|
+
const iconPng = standaloneAsset('/dsh-memu-memory/ui-assets/icon.png')
|
|
26
|
+
|
|
27
|
+
const routeSpecs = []
|
|
28
|
+
const auth = { decision: undefined, calls: 0 }
|
|
29
|
+
registerRpc({
|
|
30
|
+
inject: (_deps, build) => build({
|
|
31
|
+
effect: (fn) => { const dispose = fn(); return typeof dispose === 'function' ? dispose : () => {} },
|
|
32
|
+
webServer: { register: (spec) => { routeSpecs.push(spec); return () => {} } },
|
|
33
|
+
connection: { requestRejection: () => { auth.calls += 1; return auth.decision } },
|
|
34
|
+
logger: {},
|
|
35
|
+
}),
|
|
36
|
+
})
|
|
37
|
+
assert.deepEqual(routeSpecs.map((spec) => spec.path), ['/dsh-memu-memory', '/dsh-memu'])
|
|
38
|
+
assert.equal(typeof routeSpecs[0].handler, 'function')
|
|
39
|
+
assert.equal(typeof routeSpecs[1].handler, 'function')
|
|
40
|
+
const prefixes = [
|
|
41
|
+
{ name: 'formal', route: routeSpecs[0], base: '/dsh-memu-memory' },
|
|
42
|
+
{ name: 'legacy', route: routeSpecs[1], base: '/dsh-memu' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const homeRoot = path.resolve(home)
|
|
46
|
+
const probe = { active: false, reads: [], writes: [] }
|
|
47
|
+
const originals = new Map()
|
|
48
|
+
function inHome(file) {
|
|
49
|
+
if (typeof file !== 'string') return false
|
|
50
|
+
const resolved = path.resolve(file)
|
|
51
|
+
return resolved === homeRoot || resolved.startsWith(homeRoot + path.sep)
|
|
52
|
+
}
|
|
53
|
+
function relative(file) { return path.relative(homeRoot, path.resolve(file)).replace(/\\/g, '/') }
|
|
54
|
+
function wrap(name, mode) {
|
|
55
|
+
const original = fs[name]
|
|
56
|
+
originals.set(name, original)
|
|
57
|
+
fs[name] = function (file, ...args) {
|
|
58
|
+
if (probe.active && inHome(file)) (mode === 'write' ? probe.writes : probe.reads).push(relative(file))
|
|
59
|
+
return original.call(this, file, ...args)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const name of ['readFileSync', 'readdirSync', 'existsSync', 'statSync', 'lstatSync', 'createReadStream']) wrap(name, 'read')
|
|
63
|
+
for (const name of ['writeFileSync', 'appendFileSync', 'renameSync', 'mkdirSync', 'rmSync', 'unlinkSync', 'copyFileSync']) wrap(name, 'write')
|
|
64
|
+
{
|
|
65
|
+
const openSync = fs.openSync
|
|
66
|
+
originals.set('openSync', openSync)
|
|
67
|
+
fs.openSync = function (file, flags, ...args) {
|
|
68
|
+
if (probe.active && inHome(file)) {
|
|
69
|
+
const text = typeof flags === 'string' ? flags : 'r'
|
|
70
|
+
const write = /[wa+]/.test(text)
|
|
71
|
+
;(write ? probe.writes : probe.reads).push(relative(file))
|
|
72
|
+
}
|
|
73
|
+
return openSync.call(this, file, flags, ...args)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function resetProbe() { probe.reads.length = 0; probe.writes.length = 0 }
|
|
77
|
+
|
|
78
|
+
function makeResponse() {
|
|
79
|
+
const chunks = []
|
|
80
|
+
let finish
|
|
81
|
+
const done = new Promise((resolve, reject) => {
|
|
82
|
+
finish = resolve
|
|
83
|
+
const timer = setTimeout(() => reject(new Error('response did not end')), 3000)
|
|
84
|
+
timer.unref?.()
|
|
85
|
+
})
|
|
86
|
+
return {
|
|
87
|
+
status: 0,
|
|
88
|
+
headers: {},
|
|
89
|
+
destroyed: false,
|
|
90
|
+
done,
|
|
91
|
+
writeHead(code, headers) { this.status = code; this.headers = headers || {}; return this },
|
|
92
|
+
write(chunk) { if (chunk) chunks.push(Buffer.from(chunk)); return true },
|
|
93
|
+
end(chunk) { if (chunk) chunks.push(Buffer.from(chunk)); finish() },
|
|
94
|
+
destroy(error) { this.destroyed = true; this.error = error; finish() },
|
|
95
|
+
get buffer() { return Buffer.concat(chunks) },
|
|
96
|
+
get text() { return Buffer.concat(chunks).toString('utf8') },
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function send(route, url, { method = 'GET', body, rejection, poison = false } = {}) {
|
|
101
|
+
auth.decision = rejection
|
|
102
|
+
auth.calls = 0
|
|
103
|
+
resetProbe()
|
|
104
|
+
probe.active = true
|
|
105
|
+
const pulls = []
|
|
106
|
+
const req = {
|
|
107
|
+
method,
|
|
108
|
+
url,
|
|
109
|
+
headers: {},
|
|
110
|
+
async *[Symbol.asyncIterator]() {
|
|
111
|
+
pulls.push(auth.calls)
|
|
112
|
+
if (poison) throw new Error('the body was read before authentication')
|
|
113
|
+
if (body !== undefined) yield Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body))
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
const res = makeResponse()
|
|
117
|
+
try {
|
|
118
|
+
await route.handler(req, res)
|
|
119
|
+
await res.done
|
|
120
|
+
} finally {
|
|
121
|
+
probe.active = false
|
|
122
|
+
}
|
|
123
|
+
return { res, pulls, authCalls: auth.calls, reads: [...probe.reads], writes: [...probe.writes] }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function envelope(method, payload = {}, rpcId = 'auth-test') {
|
|
127
|
+
return { type: 'client-request', rpcId, method, payload }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function assertRejected(result, code) {
|
|
131
|
+
assert.equal(result.res.status, code)
|
|
132
|
+
assert.equal(result.res.text, code === 401 ? 'unauthorized' : 'forbidden')
|
|
133
|
+
assert.equal(result.pulls.length, 0, 'request body must not be iterated before requestRejection')
|
|
134
|
+
assert.equal(result.authCalls, 1, 'requestRejection must run exactly once')
|
|
135
|
+
assert.deepEqual(result.reads, [], 'rejected request must not read anything under DSH_MEMU_HOME')
|
|
136
|
+
assert.deepEqual(result.writes, [], 'rejected request must not write anything under DSH_MEMU_HOME')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function snapshotTree(root) {
|
|
140
|
+
const files = {}
|
|
141
|
+
const walk = (dir) => {
|
|
142
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
143
|
+
const full = path.join(dir, entry.name)
|
|
144
|
+
const rel = path.relative(root, full).replace(/\\/g, '/')
|
|
145
|
+
if (entry.isDirectory()) { files[rel + '/'] = true; walk(full) }
|
|
146
|
+
else { const data = fs.readFileSync(full); files[rel] = data.length + ':' + crypto.createHash('sha256').update(data).digest('hex') }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
walk(root)
|
|
150
|
+
return files
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const SENTINEL = 'AUTH-SENTINEL-7f3c2a'
|
|
154
|
+
const IMPORT_SENTINEL = 'IMPORT-SENTINEL-91b0d4'
|
|
155
|
+
const RPC_IMPORT_SENTINEL = 'RPC-IMPORT-SENTINEL-4c8e11'
|
|
156
|
+
|
|
157
|
+
const library = state.createLibrary({ name: 'auth-matrix', cwd: path.join(home, 'auth-matrix') })
|
|
158
|
+
const sessionId = 'auth-session'
|
|
159
|
+
state.setSessionBinding(sessionId, { libraryId: library.id, useLibrary: true, scopeKey: library.scopeKey })
|
|
160
|
+
state.updatePrivateIndex(library.id, (index) => {
|
|
161
|
+
index.entries['mem-auth-sentinel'] = {
|
|
162
|
+
title: 'auth sentinel', content: SENTINEL, status: 'active', layer: 'long_term',
|
|
163
|
+
visibility: 'shared', disclosure: 'can_tell', source: 'user_said',
|
|
164
|
+
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
state.flushState()
|
|
168
|
+
|
|
169
|
+
test('UI and assets: both registered prefixes authorize, and 401/403 never serve or read', async () => {
|
|
170
|
+
const cases = [
|
|
171
|
+
{ route: '/ui', expected: html, type: /text\/html/ },
|
|
172
|
+
{ route: '/settings', expected: html, type: /text\/html/ },
|
|
173
|
+
{ route: '/ui-assets/client.js', expected: clientJs, type: /javascript/ },
|
|
174
|
+
{ route: '/ui-assets/icon.png', expected: iconPng, type: /image\/png/ },
|
|
175
|
+
]
|
|
176
|
+
for (const prefix of prefixes) {
|
|
177
|
+
for (const item of cases) {
|
|
178
|
+
const url = prefix.base + item.route
|
|
179
|
+
const allowed = await send(prefix.route, url)
|
|
180
|
+
assert.equal(allowed.res.status, 200, prefix.name + ' ' + item.route)
|
|
181
|
+
assert.match(allowed.res.headers['Content-Type'], item.type)
|
|
182
|
+
assert.deepEqual(allowed.res.buffer, Buffer.from(item.expected))
|
|
183
|
+
for (const code of [401, 403]) {
|
|
184
|
+
assertRejected(await send(prefix.route, url, { rejection: code }), code)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('POST RPC: requestRejection runs before body parsing and before any write', async () => {
|
|
191
|
+
for (const prefix of prefixes) {
|
|
192
|
+
for (const code of [401, 403]) {
|
|
193
|
+
state.flushState()
|
|
194
|
+
const baseline = snapshotTree(home)
|
|
195
|
+
const name = 'denied-' + prefix.name + '-' + code
|
|
196
|
+
const result = await send(prefix.route, prefix.base + '/createLibrary', {
|
|
197
|
+
method: 'POST', poison: true, rejection: code,
|
|
198
|
+
body: envelope('createLibrary', { name, cwd: path.join(home, name) }),
|
|
199
|
+
})
|
|
200
|
+
assertRejected(result, code)
|
|
201
|
+
assert.equal(state.listLibraries().some((item) => item.name === name), false)
|
|
202
|
+
assert.deepEqual(snapshotTree(home), baseline, 'a rejected createLibrary must not touch state.json')
|
|
203
|
+
}
|
|
204
|
+
const name = 'allowed-' + prefix.name
|
|
205
|
+
const result = await send(prefix.route, prefix.base + '/createLibrary', {
|
|
206
|
+
method: 'POST', body: envelope('createLibrary', { name, cwd: path.join(home, name) }),
|
|
207
|
+
})
|
|
208
|
+
assert.equal(result.res.status, 200)
|
|
209
|
+
const json = JSON.parse(result.res.text)
|
|
210
|
+
assert.equal(json.result.ok, true)
|
|
211
|
+
assert.equal(json.result.value.name, name)
|
|
212
|
+
assert.equal(result.authCalls, 1)
|
|
213
|
+
assert.equal(result.pulls.length, 1)
|
|
214
|
+
assert.equal(result.pulls[0], 1, 'authorized body iteration must happen after requestRejection')
|
|
215
|
+
assert.ok(result.writes.length > 0, 'authorized createLibrary must write the library')
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('GET-side RPC reads: 401/403 return before the private index is read', async () => {
|
|
220
|
+
const urlFor = (prefix) => prefix.base + '/getMemory'
|
|
221
|
+
for (const prefix of prefixes) {
|
|
222
|
+
for (const code of [401, 403]) {
|
|
223
|
+
state.flushState()
|
|
224
|
+
state.clearCaches()
|
|
225
|
+
const baseline = snapshotTree(home)
|
|
226
|
+
const denied = await send(prefix.route, urlFor(prefix), {
|
|
227
|
+
method: 'POST', poison: true, rejection: code,
|
|
228
|
+
body: envelope('getMemory', { libraryId: library.id, id: 'mem-auth-sentinel' }),
|
|
229
|
+
})
|
|
230
|
+
assertRejected(denied, code)
|
|
231
|
+
assert.equal(denied.res.text.includes(SENTINEL), false)
|
|
232
|
+
assert.deepEqual(snapshotTree(home), baseline)
|
|
233
|
+
}
|
|
234
|
+
state.flushState()
|
|
235
|
+
state.clearCaches()
|
|
236
|
+
const allowed = await send(prefix.route, urlFor(prefix), {
|
|
237
|
+
method: 'POST', body: envelope('getMemory', { libraryId: library.id, id: 'mem-auth-sentinel' }),
|
|
238
|
+
})
|
|
239
|
+
assert.equal(allowed.res.status, 200)
|
|
240
|
+
assert.match(allowed.res.text, new RegExp(SENTINEL))
|
|
241
|
+
assert.ok(allowed.reads.length > 0, 'authorized getMemory must read the private index from disk')
|
|
242
|
+
assert.equal(allowed.pulls[0], 1)
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('attachment thumbnails: 401/403 precede authorization and attachment-pool reads', async () => {
|
|
247
|
+
const attachmentLibrary = state.createLibrary({ name: 'auth-attachment', cwd: path.join(home, 'auth-attachment') })
|
|
248
|
+
const attachmentSession = 'auth-attachment-session'
|
|
249
|
+
state.setSessionBinding(attachmentSession, { libraryId: attachmentLibrary.id, useLibrary: true, scopeKey: attachmentLibrary.scopeKey })
|
|
250
|
+
const png = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#336699' } }).png().toBuffer()
|
|
251
|
+
const sha = crypto.createHash('sha256').update(png).digest('hex')
|
|
252
|
+
const blobPath = path.join(home, 'attachments', attachmentLibrary.id, sha + '.png')
|
|
253
|
+
fs.mkdirSync(path.dirname(blobPath), { recursive: true })
|
|
254
|
+
fs.writeFileSync(blobPath, png)
|
|
255
|
+
const poolPath = path.join(home, 'index', attachmentLibrary.id + '.media.json')
|
|
256
|
+
const pool = {
|
|
257
|
+
version: 1,
|
|
258
|
+
blobs: { [sha]: { sha256: sha, bytes: png.length, ext: 'png', mime: 'image/png', w: 8, h: 8, useCount: 1, refs: [], missing: false, captionBy: 'none' } },
|
|
259
|
+
totals: { bytes: png.length, count: 1 },
|
|
260
|
+
}
|
|
261
|
+
fs.writeFileSync(poolPath, JSON.stringify(pool))
|
|
262
|
+
state.updatePrivateIndex(attachmentLibrary.id, (index) => {
|
|
263
|
+
index.entries['mem-auth-image'] = {
|
|
264
|
+
title: 'auth image', content: 'attachment sentinel ' + SENTINEL, status: 'active', layer: 'long_term',
|
|
265
|
+
visibility: 'shared', disclosure: 'can_tell', source: 'user_said',
|
|
266
|
+
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
267
|
+
attachments: [{ sha256: sha, name: 'auth.png', mediaType: 'image/png', bytes: png.length }],
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
state.flushState()
|
|
271
|
+
const url = (prefix) => prefix.base + '/attachment/' + encodeURIComponent(attachmentLibrary.id) + '/' + sha + '?sessionId=' + encodeURIComponent(attachmentSession)
|
|
272
|
+
for (const prefix of prefixes) {
|
|
273
|
+
state.flushState()
|
|
274
|
+
state.clearCaches()
|
|
275
|
+
const allowed = await send(prefix.route, url(prefix))
|
|
276
|
+
assert.equal(allowed.res.status, 200)
|
|
277
|
+
assert.match(allowed.res.headers['Content-Type'], /image\/webp/)
|
|
278
|
+
assert.ok(allowed.res.buffer.length > 0)
|
|
279
|
+
assert.ok(allowed.reads.some((item) => item.endsWith('.media.json')), 'authorized thumbnail reads the media pool')
|
|
280
|
+
}
|
|
281
|
+
for (const prefix of prefixes) {
|
|
282
|
+
for (const code of [401, 403]) {
|
|
283
|
+
state.flushState()
|
|
284
|
+
state.clearCaches()
|
|
285
|
+
const baseline = snapshotTree(home)
|
|
286
|
+
const denied = await send(prefix.route, url(prefix), { rejection: code })
|
|
287
|
+
assertRejected(denied, code)
|
|
288
|
+
assert.equal(denied.res.text.includes('attachment unavailable'), false)
|
|
289
|
+
assert.deepEqual(snapshotTree(home), baseline)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const prefix of prefixes) {
|
|
293
|
+
const rpcPayload = { libraryId: attachmentLibrary.id, sha256: sha, sessionId: attachmentSession }
|
|
294
|
+
for (const code of [401, 403]) {
|
|
295
|
+
state.flushState()
|
|
296
|
+
state.clearCaches()
|
|
297
|
+
const baseline = snapshotTree(home)
|
|
298
|
+
const denied = await send(prefix.route, prefix.base + '/getAttachment', {
|
|
299
|
+
method: 'POST', poison: true, rejection: code, body: envelope('getAttachment', rpcPayload),
|
|
300
|
+
})
|
|
301
|
+
assertRejected(denied, code)
|
|
302
|
+
assert.equal(denied.res.text.includes(sha), false)
|
|
303
|
+
assert.deepEqual(snapshotTree(home), baseline)
|
|
304
|
+
}
|
|
305
|
+
state.flushState()
|
|
306
|
+
state.clearCaches()
|
|
307
|
+
const allowed = await send(prefix.route, prefix.base + '/getAttachment', {
|
|
308
|
+
method: 'POST', body: envelope('getAttachment', rpcPayload),
|
|
309
|
+
})
|
|
310
|
+
assert.equal(allowed.res.status, 200)
|
|
311
|
+
const json = JSON.parse(allowed.res.text)
|
|
312
|
+
assert.equal(json.result.ok, true)
|
|
313
|
+
assert.equal(json.result.value.sha256, sha)
|
|
314
|
+
assert.equal(allowed.pulls[0], 1)
|
|
315
|
+
}
|
|
316
|
+
// Tripwire: a corrupt pool makes an authorized thumbnail fail inside the media
|
|
317
|
+
// path, so a 401/403 instead of that failure proves rejection happened first.
|
|
318
|
+
fs.writeFileSync(poolPath, '{not valid json')
|
|
319
|
+
for (const prefix of prefixes) {
|
|
320
|
+
state.flushState()
|
|
321
|
+
state.clearCaches()
|
|
322
|
+
const tripwire = await send(prefix.route, url(prefix))
|
|
323
|
+
assert.equal(tripwire.res.status, 503)
|
|
324
|
+
assert.equal(tripwire.res.text, 'attachment unavailable')
|
|
325
|
+
for (const code of [401, 403]) {
|
|
326
|
+
const denied = await send(prefix.route, url(prefix), { rejection: code })
|
|
327
|
+
assertRejected(denied, code)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
fs.writeFileSync(poolPath, JSON.stringify(pool))
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('export ZIP: 401/403 precede export dispatch, index reads and ZIP construction', async () => {
|
|
334
|
+
for (const prefix of prefixes) {
|
|
335
|
+
for (const code of [401, 403]) {
|
|
336
|
+
state.flushState()
|
|
337
|
+
state.clearCaches()
|
|
338
|
+
const baseline = snapshotTree(home)
|
|
339
|
+
const denied = await send(prefix.route, prefix.base + '/export/' + library.id + '.zip', { rejection: code })
|
|
340
|
+
assertRejected(denied, code)
|
|
341
|
+
assert.equal(denied.res.text.includes(SENTINEL), false)
|
|
342
|
+
assert.equal(denied.res.text.startsWith('PK'), false)
|
|
343
|
+
assert.deepEqual(snapshotTree(home), baseline)
|
|
344
|
+
}
|
|
345
|
+
state.flushState()
|
|
346
|
+
state.clearCaches()
|
|
347
|
+
const allowed = await send(prefix.route, prefix.base + '/export/' + library.id + '.zip')
|
|
348
|
+
assert.equal(allowed.res.status, 200)
|
|
349
|
+
assert.match(allowed.res.headers['Content-Type'], /application\/zip/)
|
|
350
|
+
assert.equal(allowed.res.buffer.subarray(0, 2).toString('utf8'), 'PK')
|
|
351
|
+
const files = unzipSync(new Uint8Array(allowed.res.buffer))
|
|
352
|
+
assert.ok(files['memories.json'])
|
|
353
|
+
const exported = JSON.parse(Buffer.from(files['memories.json']).toString('utf8'))
|
|
354
|
+
assert.ok(exported.memories.some((item) => String(item.content).includes(SENTINEL)))
|
|
355
|
+
assert.ok(allowed.reads.length > 0, 'authorized export reads the private index')
|
|
356
|
+
const rpcUrl = prefix.base + '/exportLibrary'
|
|
357
|
+
const rpcPayload = { libraryId: library.id, includeAttachments: true }
|
|
358
|
+
for (const code of [401, 403]) {
|
|
359
|
+
state.flushState()
|
|
360
|
+
state.clearCaches()
|
|
361
|
+
const baseline = snapshotTree(home)
|
|
362
|
+
const denied = await send(prefix.route, rpcUrl, {
|
|
363
|
+
method: 'POST', poison: true, rejection: code, body: envelope('exportLibrary', rpcPayload),
|
|
364
|
+
})
|
|
365
|
+
assertRejected(denied, code)
|
|
366
|
+
assert.equal(denied.res.text.includes(SENTINEL), false)
|
|
367
|
+
assert.deepEqual(snapshotTree(home), baseline)
|
|
368
|
+
}
|
|
369
|
+
state.flushState()
|
|
370
|
+
state.clearCaches()
|
|
371
|
+
const rpcAllowed = await send(prefix.route, rpcUrl, {
|
|
372
|
+
method: 'POST', body: envelope('exportLibrary', rpcPayload),
|
|
373
|
+
})
|
|
374
|
+
assert.equal(rpcAllowed.res.status, 200)
|
|
375
|
+
const rpcJson = JSON.parse(rpcAllowed.res.text)
|
|
376
|
+
assert.equal(rpcJson.result.ok, true)
|
|
377
|
+
const rpcZip = unzipSync(new Uint8Array(Buffer.from(rpcJson.result.value.base64, 'base64')))
|
|
378
|
+
const rpcExported = JSON.parse(Buffer.from(rpcZip['memories.json']).toString('utf8'))
|
|
379
|
+
assert.ok(rpcExported.memories.some((item) => String(item.content).includes(SENTINEL)))
|
|
380
|
+
assert.equal(rpcAllowed.pulls[0], 1)
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('import ZIP: 401/403 precede body reads, staging and library writes', async () => {
|
|
385
|
+
const destinations = {}
|
|
386
|
+
for (const prefix of prefixes) destinations[prefix.name] = state.createLibrary({ name: 'auth-import-' + prefix.name, cwd: path.join(home, 'auth-import-' + prefix.name) })
|
|
387
|
+
const archive = Buffer.from(zipSync({
|
|
388
|
+
'memories.json': Buffer.from(JSON.stringify({ format: 'dsh-memu-memory-library@1', memories: [{ id: 'mem-imported', title: 'imported', content: IMPORT_SENTINEL, layer: 'long_term', status: 'active' }] })),
|
|
389
|
+
'manifest.json': Buffer.from(JSON.stringify({ format: 'dsh-memu-memory-media@1', blobs: {} })),
|
|
390
|
+
}))
|
|
391
|
+
for (const prefix of prefixes) {
|
|
392
|
+
const destination = destinations[prefix.name]
|
|
393
|
+
const importUrl = prefix.base + '/import/' + destination.id + '.zip'
|
|
394
|
+
for (const code of [401, 403]) {
|
|
395
|
+
state.flushState()
|
|
396
|
+
state.clearCaches()
|
|
397
|
+
const baseline = snapshotTree(home)
|
|
398
|
+
const denied = await send(prefix.route, importUrl, { method: 'POST', body: archive, poison: true, rejection: code })
|
|
399
|
+
assertRejected(denied, code)
|
|
400
|
+
assert.equal(JSON.stringify(state.loadPrivateIndex(destination.id)).includes(IMPORT_SENTINEL), false)
|
|
401
|
+
assert.deepEqual(snapshotTree(home), baseline, 'a rejected import must not create staging files or touch the library')
|
|
402
|
+
}
|
|
403
|
+
state.flushState()
|
|
404
|
+
state.clearCaches()
|
|
405
|
+
const allowed = await send(prefix.route, importUrl, { method: 'POST', body: archive })
|
|
406
|
+
assert.equal(allowed.res.status, 200)
|
|
407
|
+
assert.equal(JSON.parse(allowed.res.text).ok, true)
|
|
408
|
+
assert.equal(allowed.pulls[0], 1, 'authorized ZIP body is consumed after requestRejection')
|
|
409
|
+
assert.ok(allowed.writes.length > 0, 'authorized import writes the library')
|
|
410
|
+
assert.equal(JSON.stringify(state.loadPrivateIndex(destination.id)).includes(IMPORT_SENTINEL), true)
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
test('importMemories RPC: 401/403 precede JSON parsing and import writes', async () => {
|
|
415
|
+
const destinations = {}
|
|
416
|
+
for (const prefix of prefixes) destinations[prefix.name] = state.createLibrary({ name: 'auth-rpc-import-' + prefix.name, cwd: path.join(home, 'auth-rpc-import-' + prefix.name) })
|
|
417
|
+
for (const prefix of prefixes) {
|
|
418
|
+
const destination = destinations[prefix.name]
|
|
419
|
+
const payload = { libraryId: destination.id, memories: [{ content: RPC_IMPORT_SENTINEL, title: 'rpc imported' }] }
|
|
420
|
+
for (const code of [401, 403]) {
|
|
421
|
+
state.flushState()
|
|
422
|
+
state.clearCaches()
|
|
423
|
+
const baseline = snapshotTree(home)
|
|
424
|
+
const denied = await send(prefix.route, prefix.base + '/importMemories', {
|
|
425
|
+
method: 'POST', poison: true, rejection: code, body: envelope('importMemories', payload),
|
|
426
|
+
})
|
|
427
|
+
assertRejected(denied, code)
|
|
428
|
+
assert.equal(JSON.stringify(state.loadPrivateIndex(destination.id)).includes(RPC_IMPORT_SENTINEL), false)
|
|
429
|
+
assert.deepEqual(snapshotTree(home), baseline)
|
|
430
|
+
}
|
|
431
|
+
state.flushState()
|
|
432
|
+
state.clearCaches()
|
|
433
|
+
const allowed = await send(prefix.route, prefix.base + '/importMemories', {
|
|
434
|
+
method: 'POST', body: envelope('importMemories', payload),
|
|
435
|
+
})
|
|
436
|
+
assert.equal(allowed.res.status, 200)
|
|
437
|
+
const json = JSON.parse(allowed.res.text)
|
|
438
|
+
assert.equal(json.result.ok, true)
|
|
439
|
+
assert.equal(json.result.value.imported, 1)
|
|
440
|
+
assert.equal(allowed.pulls[0], 1)
|
|
441
|
+
assert.equal(JSON.stringify(state.loadPrivateIndex(destination.id)).includes(RPC_IMPORT_SENTINEL), true)
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
test('unsupported methods still authenticate first: 401/403 before 405', async () => {
|
|
446
|
+
for (const prefix of prefixes) {
|
|
447
|
+
for (const code of [401, 403]) {
|
|
448
|
+
assertRejected(await send(prefix.route, prefix.base + '/ui', { method: 'DELETE', rejection: code }), code)
|
|
449
|
+
assertRejected(await send(prefix.route, prefix.base + '/listLibraries', { method: 'GET', rejection: code }), code)
|
|
450
|
+
}
|
|
451
|
+
assert.equal((await send(prefix.route, prefix.base + '/ui', { method: 'DELETE' })).res.status, 405)
|
|
452
|
+
assert.equal((await send(prefix.route, prefix.base + '/listLibraries', { method: 'GET' })).res.status, 405)
|
|
453
|
+
}
|
|
454
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-webui-'))
|
|
7
|
+
process.env.DSH_MEMU_HOME = home
|
|
8
|
+
const state = await import('../lib/state.js')
|
|
9
|
+
const { registerRpc, dispatch } = await import('../lib/rpc.js')
|
|
10
|
+
const { webUiHtml, standaloneHtml, standaloneAsset } = await import('../lib/webui.js')
|
|
11
|
+
test.after(() => { state.flushState(); fs.rmSync(home, { recursive: true, force: true }) })
|
|
12
|
+
function handler(rejection) {
|
|
13
|
+
const routes = []
|
|
14
|
+
registerRpc({ inject: (_deps, callback) => callback({
|
|
15
|
+
effect: fn => fn(), webServer: { register: spec => { routes.push(spec); return () => {} } },
|
|
16
|
+
connection: { requestRejection: () => rejection }, logger: {},
|
|
17
|
+
}) })
|
|
18
|
+
assert.deepEqual(routes.map(route => route.path), ['/dsh-memu-memory', '/dsh-memu'])
|
|
19
|
+
return routes[0].handler
|
|
20
|
+
}
|
|
21
|
+
async function request(url, method = 'GET', body, rejection) {
|
|
22
|
+
const req = { method, url, async *[Symbol.asyncIterator]() { if (body) yield Buffer.from(JSON.stringify(body)) } }
|
|
23
|
+
const res = { status: 0, headers: {}, body: '', writeHead(code, headers) { this.status = code; this.headers = headers || {} }, end(data) { this.body = data ? String(data) : '' } }
|
|
24
|
+
await handler(rejection)(req, res)
|
|
25
|
+
return res
|
|
26
|
+
}
|
|
27
|
+
test('wall and shared settings are served behind the same authenticated route', async () => {
|
|
28
|
+
for (const url of ['/dsh-memu-memory/ui', '/dsh-memu-memory/ui/', '/dsh-memu-memory/settings']) {
|
|
29
|
+
const res = await request(url)
|
|
30
|
+
assert.equal(res.status, 200)
|
|
31
|
+
assert.match(res.body, /<!doctype html>/i)
|
|
32
|
+
assert.match(res.body, /dsh-memu-memory/)
|
|
33
|
+
assert.equal((await request(url, 'GET', null, 403)).status, 403)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
test('standalone settings load the exact client component with local React assets', async () => {
|
|
37
|
+
const html = standaloneHtml()
|
|
38
|
+
assert.match(html, /ui-assets\/client.js/)
|
|
39
|
+
assert.match(html, /ui-assets\/react-dom.js/)
|
|
40
|
+
assert.equal(standaloneAsset('/dsh-memu-memory/ui-assets/client.js'), fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8'))
|
|
41
|
+
for (const name of ['react.js', 'react-dom.js', 'standalone.js', 'client.js', 'graph-view.js']) {
|
|
42
|
+
const res = await request('/dsh-memu-memory/ui-assets/' + name)
|
|
43
|
+
assert.equal(res.status, 200)
|
|
44
|
+
assert.match(res.headers['Content-Type'], /javascript/)
|
|
45
|
+
assert.equal((await request('/dsh-memu-memory/ui-assets/' + name, 'GET', null, 401)).status, 401)
|
|
46
|
+
}
|
|
47
|
+
assert.equal((await request('/dsh-memu-memory/ui-assets/../../../state.json')).status, 405)
|
|
48
|
+
})
|
|
49
|
+
test('all standalone scripts parse and neither shell defines a second settings editor', () => {
|
|
50
|
+
const wall = webUiHtml()
|
|
51
|
+
assert.equal(wall, standaloneHtml(), 'wall and settings must be exactly the same shell')
|
|
52
|
+
assert.doesNotThrow(() => new Function(standaloneAsset('/dsh-memu-memory/ui-assets/standalone.js')))
|
|
53
|
+
assert.doesNotThrow(() => new Function(standaloneAsset('/dsh-memu-memory/ui-assets/graph-view.js')))
|
|
54
|
+
assert.ok(!wall.includes('function renderSettings'))
|
|
55
|
+
assert.ok(!wall.includes('function renderPrompts'))
|
|
56
|
+
assert.ok(!wall.includes('href="/dsh-memu-memory/ui"'), 'standalone header must not split MemU into another destination')
|
|
57
|
+
assert.match(wall, /ui-assets\/client.js/)
|
|
58
|
+
})
|
|
59
|
+
test('the wall uses one bounded preview RPC, not N full-body requests', async () => {
|
|
60
|
+
const library = state.createLibrary({ name: 'wall', cwd: path.join(home, 'wall') })
|
|
61
|
+
state.updatePrivateIndex(library.id, index => {
|
|
62
|
+
for (let i = 0; i < 500; i++) index.entries['mem-' + i] = { title: 't' + i, content: 'x'.repeat(1000), status: 'active', source: 'imagined' }
|
|
63
|
+
})
|
|
64
|
+
const response = await dispatch({}, 'browseWall', {})
|
|
65
|
+
assert.equal(response.total, 500)
|
|
66
|
+
assert.equal(response.items.length, 300)
|
|
67
|
+
assert.ok(response.items.every(item => item.preview.length <= 180 && !Object.hasOwn(item, 'content')))
|
|
68
|
+
const source = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
69
|
+
assert.match(source, /call\('browseWall'/)
|
|
70
|
+
assert.match(source, /function MemoryWallPanel/)
|
|
71
|
+
})
|
|
72
|
+
test('wall pools include review and inactive states; anchor is a view, not a fourth stored layer', () => {
|
|
73
|
+
const source = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
74
|
+
assert.match(source, /\['active', 'browseActiveTab'\]/)
|
|
75
|
+
assert.match(source, /\['review', 'poolReview'\]/)
|
|
76
|
+
assert.match(source, /\['all', 'must_remember', 'long_term', 'short_term', 'anchor'\]/)
|
|
77
|
+
assert.match(source, /value === 'anchor' \? item.layer === 'must_remember'/)
|
|
78
|
+
assert.match(source, /item\.libraryName/)
|
|
79
|
+
assert.match(source, /item\.source/)
|
|
80
|
+
})
|
|
81
|
+
test('the wall drawer reads exactly one memory', async () => {
|
|
82
|
+
const library = state.createLibrary({ name: 'drawer', cwd: path.join(home, 'drawer') })
|
|
83
|
+
state.updatePrivateIndex(library.id, (index) => {
|
|
84
|
+
index.entries['mem-drawer'] = {
|
|
85
|
+
title: 'drawer one',
|
|
86
|
+
content: 'full body that the wall preview does not carry',
|
|
87
|
+
status: 'active',
|
|
88
|
+
layer: 'long_term',
|
|
89
|
+
createdAt: new Date().toISOString(),
|
|
90
|
+
updatedAt: new Date().toISOString(),
|
|
91
|
+
attachments: [{ sha256: 'a'.repeat(64), name: 'drawer.png', mediaType: 'image/png' }],
|
|
92
|
+
}
|
|
93
|
+
index.entries['mem-sealed'] = {
|
|
94
|
+
title: 'sealed', content: 'secret body', status: 'active', visibility: 'secret',
|
|
95
|
+
attachments: [{ sha256: 'b'.repeat(64) }],
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
const one = await dispatch({}, 'getMemory', { libraryId: library.id, id: 'mem-drawer' })
|
|
99
|
+
assert.equal(one.entry.content, 'full body that the wall preview does not carry')
|
|
100
|
+
assert.equal(one.attachments.length, 1)
|
|
101
|
+
assert.equal(one.attachments[0].sha256, 'a'.repeat(64))
|
|
102
|
+
const sealed = await dispatch({}, 'getMemory', { libraryId: library.id, id: 'mem-sealed' })
|
|
103
|
+
assert.equal(sealed.attachments[0].hidden, true)
|
|
104
|
+
assert.equal(sealed.attachments[0].url, undefined, 'a secret attachment must never carry a pixel URL')
|
|
105
|
+
const wall = await dispatch({}, 'browseWall', { libraryId: library.id })
|
|
106
|
+
assert.equal(wall.items.find(item => item.id === 'mem-drawer').attachments[0].sha256, 'a'.repeat(64))
|
|
107
|
+
assert.equal(wall.items.find(item => item.id === 'mem-sealed').attachments[0].hidden, true)
|
|
108
|
+
await assert.rejects(
|
|
109
|
+
async () => { await dispatch({}, 'getMemory', { libraryId: library.id, id: 'missing' }) },
|
|
110
|
+
/unknown memory/,
|
|
111
|
+
)
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
async () => { await dispatch({}, 'getMemory', { id: 'mem-drawer' }) },
|
|
114
|
+
/requires a libraryId/,
|
|
115
|
+
)
|
|
116
|
+
const source = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
117
|
+
assert.match(source, /call\('getMemory'/, 'the drawer fetches one entry')
|
|
118
|
+
assert.match(source, /className: 'dshm-drawer'/, 'the wall renders an in-place drawer')
|
|
119
|
+
assert.match(source, /h\(MemoryAttachments, \{ items: detail\.file\.attachments/, 'the drawer opens the gallery')
|
|
120
|
+
assert.match(source, /function WallCover/, 'cards use the first usable attachment as a cover')
|
|
121
|
+
assert.match(source, /className: 'dshm-wall-cover'/)
|
|
122
|
+
assert.match(source, /detail\.file\?\.status === 'archived'/, 'delete is offered only for archived memories')
|
|
123
|
+
assert.match(source, /call\('deleteMemory', \{ libraryId: current\.item\.libraryId/)
|
|
124
|
+
assert.match(source, /t\('confirmDelete'\)/, 'deleting takes a second click')
|
|
125
|
+
assert.match(source, /current\.file\.status !== 'archived'/, 'the delete handler refuses live memories')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('wall heartbeat retries failed paints, pauses hidden and shares the metadata channel', () => {
|
|
129
|
+
const source = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
130
|
+
assert.match(source, /getSidebarState", \{ revisionOnly: true \}/)
|
|
131
|
+
assert.match(source, /document\.visibilityState === "hidden"/)
|
|
132
|
+
assert.match(source, /visibilitychange/)
|
|
133
|
+
assert.match(source, /result !== false/)
|
|
134
|
+
assert.match(source, /data\.type === "memory-write"/)
|
|
135
|
+
})
|
|
136
|
+
test('the settings button preserves the host SPA and links to individual pages', () => {
|
|
137
|
+
const client = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
138
|
+
// libraryId 选项:整理失败提醒的跳转按钮按库深链(?libraryId=…#/settings/browser)。
|
|
139
|
+
assert.match(client, /function openWebUiPage\(page = null, \{ libraryId = null \} = \{\}\)/)
|
|
140
|
+
assert.match(client, /\/dsh-memu-memory\/ui' \+ query \+ '#\/settings\//)
|
|
141
|
+
assert.match(client, /toggleWebUiOverlay\(url\)/)
|
|
142
|
+
assert.ok(!/window\.location(?:\.href)?\s*=/.test(client))
|
|
143
|
+
// The host ctx is a Cordis service proxy: reading an unknown property such as
|
|
144
|
+
// `ctx.standalone` throws during render and blanked the whole settings pane.
|
|
145
|
+
// The standalone page must opt in through a prop instead.
|
|
146
|
+
assert.ok(!client.includes('ctx.standalone'), 'the shared component must not read ctx.standalone')
|
|
147
|
+
assert.match(client, /function SettingsPage\(\{ ctx, t, embedded, standalone = false \}\)/)
|
|
148
|
+
assert.match(client, /h\(SettingsPage, \{ ctx, t, embedded: false, standalone: true \}\)/)
|
|
149
|
+
assert.match(client, /mountStandalone/)
|
|
150
|
+
})
|
|
151
|
+
test('RPC still uses stable envelopes and rejects non-UI GETs', async () => {
|
|
152
|
+
const res = await request('/dsh-memu-memory/listLibraries', 'POST', { type: 'client-request', rpcId: 'wall-test', method: 'listLibraries', payload: {} })
|
|
153
|
+
assert.equal(res.status, 200)
|
|
154
|
+
const envelope = JSON.parse(res.body)
|
|
155
|
+
assert.equal(envelope.result.ok, true)
|
|
156
|
+
assert.equal(envelope.rpcId, 'wall-test')
|
|
157
|
+
assert.equal((await request('/dsh-memu-memory/listLibraries')).status, 405)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('old bookmarks and RPC URLs stay compatible after the package rename', async () => {
|
|
161
|
+
assert.equal((await request('/dsh-memu/ui')).body, standaloneHtml())
|
|
162
|
+
const res = await request('/dsh-memu/listLibraries', 'POST', { type: 'client-request', rpcId: 'legacy', method: 'listLibraries', payload: {} })
|
|
163
|
+
assert.equal(JSON.parse(res.body).result.ok, true)
|
|
164
|
+
assert.equal((await request('/dsh-memu-memory/ui-assets/icon.png')).headers['Content-Type'], 'image/png')
|
|
165
|
+
})
|