Files
dsh-worktable/04_test/widget-mount.test.mjs
T

367 lines
22 KiB
JavaScript

import assert from 'node:assert/strict'
import { test } from 'node:test'
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
const require = createRequire(new URL('../01_content/package.json', import.meta.url))
const { buildSync, transformSync } = require('esbuild')
function sourceModule(relative) {
const code = buildSync({ entryPoints: [fileURLToPath(new URL(relative, import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text
const module = { exports: {} }
runInNewContext(code, { module, exports: module.exports })
return module.exports
}
const { WidgetMountRegistry, widgetManifestPath, widgetTargets, newWidgetId, WIDGET_BINDINGS_KEY } = sourceModule('../01_content/src/client/widgetMount.ts')
const paths = sourceModule('../01_content/src/client/pathutil.ts')
const indexSource = readFileSync(new URL('../01_content/src/client/index.tsx', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const splitSource = readFileSync(new URL('../01_content/src/client/split.tsx', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
function section(source, start, end) {
const a = source.indexOf(start), b = source.indexOf(end, a + start.length)
assert.ok(a >= 0 && b > a, 'source section missing: ' + start)
return source.slice(a, b)
}
function evaluate(code, bindings = {}) {
const module = { exports: {} }
runInNewContext(transformSync(code, { loader: 'tsx', format: 'cjs' }).code, { module, exports: module.exports, ...bindings })
return module.exports
}
function storage(seed = {}) {
const values = new Map(Object.entries(seed))
return { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), values }
}
const specOf = id => ({ id, title: id, left: null, top: [{ id: 'p1', title: '窗口1', content: null }], main: [{ id: 'p2', title: '窗口2', content: null }] })
const resultOf = (b, extra = {}) => JSON.stringify({ version: 2, projectId: b.projectId, paneId: b.paneId, bindingId: b.bindingId, window: b.window, path: 'daily-station.html', kind: 'html', ...extra })
function registryFixture() {
const store = storage()
let next = 0
return { store, registry: new WidgetMountRegistry(store, () => 'binding-' + (++next)) }
}
test('new project in an old/shared folder has no auto-mount ownership', () => {
const { registry } = registryFixture()
const a = specOf('A'), b = specOf('B'), folder = 'C:/shared'
registry.begin('A', a, folder, 'S', '窗口1')
assert.equal(registry.entries('B').length, 0)
const ownedByA = registry.entries('A')[0]
assert.equal(registry.resolve(ownedByA, resultOf(ownedByA), b, folder), null)
assert.equal(b.top[0].content, null)
})
test('same folder and same display name still yield distinct project paths', () => {
const { registry } = registryFixture()
const a = specOf('A'), b = specOf('B')
a.title = b.title = '同名项目'
const aa = registry.begin('A', a, 'C:/shared', 'S', '窗口1')[0]
const bb = registry.begin('B', b, 'C:/shared', 'S', '窗口1')[0]
assert.notEqual(widgetManifestPath(aa), widgetManifestPath(bb))
assert.equal(registry.resolve(bb, resultOf(aa), b, 'C:/shared'), null)
})
test('raw legacy object/array never authorizes a current result', () => {
const { registry } = registryFixture(), spec = specOf('A')
const b = registry.begin('A', spec, '/shared', 'S', '窗口1')[0]
for (const raw of ['{"window":"窗口1","kind":"html","path":"old.html"}', '[{"window":"窗口1","path":"old.html"}]']) {
assert.equal(registry.resolve(b, raw, spec, '/shared'), null)
}
})
test('ownership, window, kind and malformed JSON are checked independently', () => {
const { registry } = registryFixture(), spec = specOf('A')
const b = registry.begin('A', spec, '/shared', 'S', '窗口1')[0]
for (const extra of [{ version: 1 }, { projectId: 'B' }, { paneId: 'missing' }, { bindingId: 'old' }, { window: '窗口99' }, { kind: 'unknown' }, { path: '' }]) {
assert.equal(registry.resolve(b, resultOf(b, extra), spec, '/shared'), null)
}
assert.equal(registry.resolve(b, '{bad', spec, '/shared'), null)
assert.ok(registry.resolve(b, resultOf(b), spec, '/shared'))
})
test('refresh restores valid ownership and independent multi-window bindings', () => {
const { registry, store } = registryFixture(), spec = specOf('A')
const entries = registry.begin('A', spec, 'C:/shared', 'S', '窗口1')
assert.equal(entries.length, 2)
assert.notEqual(widgetManifestPath(entries[0]), widgetManifestPath(entries[1]))
const restored = new WidgetMountRegistry(store)
for (const b of entries) assert.ok(restored.resolve(b, resultOf(b), spec, 'C:/shared'))
assert.ok(store.values.has(WIDGET_BINDINGS_KEY))
})
test('re-customizing one pane rejects its old writer without canceling another pane', () => {
const { registry } = registryFixture(), spec = specOf('A')
const old = registry.begin('A', spec, '/shared', 'S1', '窗口1')
const next = registry.begin('A', spec, '/shared', 'S2', '窗口1')
assert.notEqual(widgetManifestPath(old[0]), widgetManifestPath(next[0]))
assert.equal(registry.resolve(old[0], resultOf(old[0]), spec, '/shared'), null)
assert.ok(registry.resolve(old[1], resultOf(old[1]), spec, '/shared'))
})
test('manual revocation survives reload and an unrelated custom task', () => {
const { registry, store } = registryFixture(), spec = specOf('A')
const old = registry.begin('A', spec, '/shared', 'S', '窗口1')
registry.revokePane('A', 'p1')
const restored = new WidgetMountRegistry(store, () => 'binding-new')
restored.begin('A', spec, '/shared', 'S', '窗口2')
assert.equal(restored.entries('A').some(b => b.paneId === 'p1'), false)
assert.equal(restored.resolve(old[0], resultOf(old[0]), spec, '/shared'), null)
})
test('another session receives only its own window bindings in the real task prompt', () => {
const { registry } = registryFixture(), spec = specOf('A')
const first = registry.begin('A', spec, '/shared', 'S1', '窗口1')
const second = registry.begin('A', spec, '/shared', 'S2', '窗口2')
const build = evaluate(section(indexSource, 'function buildWindowTaskText(', '\n/** 自定义窗口任务') + '\nexport { buildWindowTaskText }', { KNOWLEDGE_PACK: 'generic knowledge', widgetManifestPath }).buildWindowTaskText
const prompt = build('A', '项目 A', '窗口2', 'widget', '/shared', 'send', second)
assert.deepEqual(Array.from(second, b => b.sessionId), ['S2'])
assert.ok(prompt.includes(widgetManifestPath(second[0])))
assert.ok(!prompt.includes(widgetManifestPath(first[0])))
assert.ok(registry.resolve(first[0], resultOf(first[0]), spec, '/shared'))
})
test('changing directory or removing target makes a previously valid result invalid', () => {
const { registry } = registryFixture(), spec = specOf('A')
const b = registry.begin('A', spec, '/shared', 'S', '窗口1')[0]
assert.equal(registry.resolve(b, resultOf(b), spec, '/other'), null)
assert.equal(registry.resolve(b, resultOf(b), { ...spec, top: [] }, '/shared'), null)
registry.revokeProject('A')
assert.equal(registry.resolve(b, resultOf(b), spec, '/shared'), null)
})
test('moved panes resolve by stable id rather than their previous row/index', () => {
const { registry } = registryFixture(), spec = specOf('A')
const b = registry.begin('A', spec, '/shared', 'S', '窗口1')[0]
const moved = { ...spec, top: [spec.main[0]], main: [spec.top[0]] }
const resolved = registry.resolve(b, resultOf(b), moved, '/shared')
assert.equal(resolved.target.row, 'main')
assert.equal(resolved.target.pane.id, 'p1')
})
test('storage failure prevents sending with a nonpersistent new binding', () => {
const registry = new WidgetMountRegistry({ getItem: () => null, setItem() { throw new Error('quota') } }, () => 'binding-test')
assert.throws(() => registry.begin('A', specOf('A'), '/shared', 'S', '窗口1'), /quota/)
assert.equal(registry.entries().length, 0)
})
test('project ids are independent even when generated within one clock tick', () => {
assert.equal(new Set(Array.from({ length: 100 }, () => newWidgetId('layout'))).size, 100)
})
// Execute the real async loader and real lockPane method, not a reimplementation.
function mountingFixture() {
const { registry, store } = registryFixture()
const specs = { A: specOf('A'), B: specOf('B') }, folders = { A: 'C:/shared', B: 'C:/shared' }
const lockMethod = section(splitSource, ' lockPane(row, i, content) {', '\n /** 更新指定标签页')
const splitStore = evaluate('const splitStore = { active: true, spec: null, onSpecMutated: null, persist() {}, notify() {}, ' + lockMethod + ' }; export { splitStore }', { tabTitleOf: c => c.title }).splitStore
splitStore.spec = specs.A
splitStore.onSpecMutated = s => { specs[s.id] = s }
const calls = [], reads = [], files = new Map()
const lock = splitStore.lockPane
splitStore.lockPane = function (...args) { calls.push(args); lock.apply(this, args) }
const pendingMountRef = { current: {} }, mountedWidgetRef = { current: {} }, mountedRef = { current: true }
const mountContent = evaluate(section(indexSource, 'function buildMountContent(', '\n/** 每会话最近观察') + '\nexport { buildMountContent }', paths).buildMountContent
const bindings = {
useCallback: fn => fn, widgetRegistry: registry,
widgetProjectOf: id => ({ folder: folders[id], spec: splitStore.active && splitStore.spec?.id === id ? splitStore.spec : specs[id] }),
widgetManifestPath, mountedRef, mountedWidgetRef, pendingMountRef, widgetReadSeqRef: { current: {} }, splitStore, buildMountContent: mountContent,
recordMountedWidget: (id, raw) => { mountedWidgetRef.current[id] = raw }, persistPendingWidgets() {},
async fetch(url) {
const path = new URL(url, 'http://fixture').searchParams.get('path')
reads.push(path)
const value = files.get(path)
return value ? { ok: true, text: typeof value === 'function' ? value : async () => value } : { ok: false }
},
}
const apply = evaluate(section(indexSource, ' const applyWidgetManifest = useCallback(', '\n // 完成边沿') + '\nexport { applyWidgetManifest }', bindings).applyWidgetManifest
return { registry, store, specs, folders, splitStore, calls, reads, files, pendingMountRef, mountedWidgetRef, mountedRef, apply }
}
test('actual loader never reads the root legacy file and cannot mount project A in B', async () => {
const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0]
f.files.set('C:/shared/widget-result.json', '{"window":"窗口1","path":"old.html"}')
f.files.set(widgetManifestPath(b), resultOf(b))
f.splitStore.spec = f.specs.B
await f.apply(b, null)
assert.equal(f.calls.length, 0)
assert.ok(f.pendingMountRef.current[b.bindingId])
assert.equal(f.specs.B.top[0].content, null)
assert.deepEqual(f.reads, [widgetManifestPath(b)])
})
test('actual loader blocks wrong identity and nonexistent window rather than falling back to window1', async () => {
const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0]
await f.apply(b, resultOf(b, { projectId: 'B' }))
await f.apply(b, resultOf(b, { window: '窗口99' }))
assert.equal(f.calls.length, 0)
})
test('a next completion reloads the same HTML even when the manifest text is unchanged', async () => {
const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0]
const raw = resultOf(b)
await f.apply(b, raw)
const firstTab = f.splitStore.spec.top[0].tabs[0].id
await f.apply(b, raw)
assert.equal(f.calls.length, 1)
await f.apply(b, raw, true)
assert.equal(f.calls.length, 2)
assert.notEqual(f.splitStore.spec.top[0].tabs[0].id, firstTab)
})
test('old reads finishing after rebind, directory change or manual replacement cannot overwrite a pane', async () => {
for (const action of ['rebind', 'folder', 'manual', 'unmount']) {
const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0]
let finish
const waiting = new Promise(resolve => { finish = resolve })
f.files.set(widgetManifestPath(b), () => waiting)
const loading = f.apply(b, null)
await Promise.resolve()
if (action === 'rebind') f.registry.begin('A', f.specs.A, f.folders.A, 'S2', '窗口1')
if (action === 'folder') f.folders.A = 'C:/other'
if (action === 'manual') f.registry.revokePane('A', b.paneId)
if (action === 'unmount') f.mountedRef.current = false
finish(resultOf(b))
await loading
assert.equal(f.calls.length, 0, action)
}
})
test('pending result is revalidated after refresh/move and rejected after manual close', async () => {
const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0]
f.splitStore.active = false
await f.apply(b, resultOf(b))
const pending = f.pendingMountRef.current[b.bindingId]
f.files.set(widgetManifestPath(b), resultOf(b))
f.splitStore.active = true
f.splitStore.spec = { ...f.specs.A, top: [f.specs.A.main[0]], main: [f.specs.A.top[0]] }
await f.apply(pending.binding, null, true)
assert.equal(f.calls[0][0], 'main')
f.registry.revokePane('A', 'p1')
await f.apply(pending.binding, null, true)
assert.equal(f.calls.length, 1)
})
test('loading legacy project state preserves its saved windows without using old pending', () => {
const saved = specOf('old')
saved.top[0].tabs = [{ id: 'old-tab', content: { kind: 'iframe', url: 'old.html' } }]
const store = storage({ 'dsh.worktable.projects.v1': JSON.stringify({ layouts: [saved], folders: { old: 'C:/shared' } }), 'dsh.worktable.pendingMount.v1': JSON.stringify({ fresh: { content: 'old.html' } }) })
const load = evaluate(section(indexSource, 'function loadProjects()', '\nconst clamp') + '\nexport { loadProjects }', { localStorage: store, PROJECTS_KEY: 'dsh.worktable.projects.v1', DEFAULT_PROJECTS: {} }).loadProjects
assert.equal(load().layouts[0].top[0].tabs[0].content.url, 'old.html')
assert.equal(new WidgetMountRegistry(store).entries().length, 0)
assert.ok(!indexSource.includes("getItem('dsh.worktable.pendingMount.v1')"))
})
test('real project opening retains failed pending reads and retries on the next open', async () => {
for (const failure of ['http', 'read']) {
const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0]
f.splitStore.active = false
await f.apply(b, resultOf(b))
f.splitStore.open = spec => { f.splitStore.active = true; f.splitStore.spec = spec }
if (failure === 'read') f.files.set(widgetManifestPath(b), async () => { throw new Error('read failed') })
const open = evaluate(section(indexSource, ' const openSplit = useCallback(', '\n /** 打开「工作台」控制室') + '\nexport { openSplit }', {
useCallback: fn => fn, engineIdsRef: { current: new Set() }, projects: { views: {} }, splitStore: f.splitStore,
currentSessionOf: () => null, sessionBridge: null, projectAttachRef: {}, projectsRef: { current: { projects: { bindings: {} } } },
ackProjectNotify() {}, pendingMountRef: f.pendingMountRef, applyWidgetManifest: f.apply, persistPendingWidgets() {},
}).openSplit
open(f.specs.A)
await new Promise(resolve => setImmediate(resolve))
assert.equal(f.calls.length, 0, failure)
assert.ok(f.pendingMountRef.current[b.bindingId], failure + ' keeps retry record')
f.splitStore.active = false
f.files.set(widgetManifestPath(b), resultOf(b))
open(f.specs.A)
await new Promise(resolve => setImmediate(resolve))
assert.equal(f.calls.length, 1, failure)
assert.equal(f.pendingMountRef.current[b.bindingId], undefined, failure + ' clears only on success')
}
})
test('actual startup sweep ignores every unregistered project, even with a legacy result in its folder', async () => {
const f = mountingFixture()
f.files.set('C:/shared/widget-result.json', '{"window":"窗口1","path":"old.html","kind":"html"}')
const effects = []
evaluate(section(indexSource, ' // 只扫描已明确登记', '\n /** 收集某会话') + '\nexport {}', {
useEffect: fn => effects.push(fn), projects: { folders: f.folders }, widgetTick: 0, widgetRegistry: f.registry, applyWidgetManifest: f.apply,
})
effects[0]()
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(f.reads, [])
assert.equal(f.calls.length, 0)
})
test('actual completion effect processes both projects sharing one session and supports the next round', async () => {
const f = mountingFixture()
const aa = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0]
const bb = f.registry.begin('B', f.specs.B, f.folders.B, 'S', '窗口1')[0]
f.files.set(widgetManifestPath(aa), resultOf(aa))
f.files.set(widgetManifestPath(bb), resultOf(bb))
const effects = [], snapshot = { snapshot: { byId: { S: { completed: true } } } }
evaluate(section(indexSource, ' // 完成边沿只触发读取', '\n // 只扫描已明确登记') + '\nexport {}', {
useEffect: fn => effects.push(fn), sessionsSnapshotStore: snapshot, widgetRegistry: f.registry, projects: { bindings: { A: 'S', B: 'S' } },
doneSeenRef: { current: {} }, clearNotifyAck() {}, mountConsumedRef: { current: new Set() }, applyWidgetManifest: f.apply, notifyTick: 0, widgetTick: 0,
})
effects[0]()
await new Promise(resolve => setImmediate(resolve))
assert.equal(f.calls.length, 1)
assert.ok(f.pendingMountRef.current[bb.bindingId])
const firstTab = f.splitStore.spec.top[0].tabs[0].id
effects[0]()
await new Promise(resolve => setImmediate(resolve))
assert.equal(f.calls.length, 1)
snapshot.snapshot.byId.S.completed = false
effects[0]()
snapshot.snapshot.byId.S.completed = true
effects[0]()
await new Promise(resolve => setImmediate(resolve))
assert.equal(f.calls.length, 2)
assert.notEqual(f.splitStore.spec.top[0].tabs[0].id, firstTab)
})
test('a slower earlier read cannot overwrite a newer result from the same binding', async () => {
const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0]
let finishOld
f.files.set(widgetManifestPath(b), () => new Promise(resolve => { finishOld = resolve }))
const oldRead = f.apply(b, null, true)
await Promise.resolve()
f.files.set(widgetManifestPath(b), resultOf(b, { path: 'new.html' }))
await f.apply(b, null, true)
finishOld(resultOf(b, { path: 'old.html' }))
await oldRead
assert.equal(f.calls.length, 1)
assert.equal(f.splitStore.spec.top[0].tabs[0].content.title, 'new.html')
})
test('completion of one session never reads or reloads another session window in the same project', async () => {
const f = mountingFixture()
const first = f.registry.begin('A', f.specs.A, f.folders.A, 'S1', '窗口1')[0]
f.registry.begin('A', f.specs.A, f.folders.A, 'S2', '窗口2')
const second = f.registry.entries('A').find(b => b.sessionId === 'S2')
f.files.set(widgetManifestPath(first), resultOf(first))
f.files.set(widgetManifestPath(second), resultOf(second))
await f.apply(first, resultOf(first))
const firstTab = f.splitStore.spec.top[0].tabs[0].id
const effects = [], snapshot = { snapshot: { byId: { S1: { completed: false }, S2: { completed: true } } } }
evaluate(section(indexSource, ' // 完成边沿只触发读取', '\n // 只扫描已明确登记') + '\nexport {}', {
useEffect: fn => effects.push(fn), sessionsSnapshotStore: snapshot, widgetRegistry: f.registry, projects: { bindings: { A: 'S2' } },
doneSeenRef: { current: {} }, clearNotifyAck() {}, mountConsumedRef: { current: new Set() }, applyWidgetManifest: f.apply, notifyTick: 0, widgetTick: 0,
})
effects[0]()
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(f.reads, [widgetManifestPath(second)])
assert.equal(f.splitStore.spec.top[0].tabs[0].id, firstTab)
assert.equal(f.calls.length, 2)
snapshot.snapshot.byId.S1.completed = true
effects[0]()
await new Promise(resolve => setImmediate(resolve))
assert.equal(f.calls.length, 3)
assert.notEqual(f.splitStore.spec.top[0].tabs[0].id, firstTab)
})
test('real closeTab revokes ownership before saving the empty window', async () => {
const f = mountingFixture(), b = f.registry.begin('A', f.specs.A, f.folders.A, 'S', '窗口1')[0]
await f.apply(b, resultOf(b))
const method = evaluate('const close = { ' + section(splitSource, ' closeTab(row, i, tabId) {', '\n moveTab(') + ' }; export { close }').close.closeTab
f.splitStore.onPaneContentEdited = (id, paneId) => f.registry.revokePane(id, paneId)
method.call(f.splitStore, 'top', 0, f.splitStore.spec.top[0].tabs[0].id)
assert.equal(f.splitStore.spec.top[0].tabs.length, 0)
await f.apply(b, resultOf(b), true)
assert.equal(f.calls.length, 1)
})
test('actual new-layout builder keeps both projects empty and independently identified', () => {
const build = evaluate(section(indexSource, 'function buildLayout(', '\n/** 布局缩略图') + '\nexport { buildLayout }', {
PRESET_DEFS: [{ id: 'grid', leftCount: 0, topCount: 2, contentCount: 1 }], newWidgetId,
}).buildLayout
const a = build('grid', 'A'), b = build('grid', 'B')
assert.notEqual(a.id, b.id)
a.top[0].content = { kind: 'iframe', url: 'old.html' }
assert.equal(b.top[0].content, null)
assert.equal(b.main[0].content, null)
})
test('actual task prompt uses only this project binding paths and preserves follow-up instructions', () => {
const { registry } = registryFixture(), spec = specOf('A')
const owned = registry.begin('A', spec, 'C:/shared', 'S', '窗口1')
registry.begin('B', specOf('B'), 'C:/shared', 'S', '窗口1')
const build = evaluate(section(indexSource, 'function buildWindowTaskText(', '\n/** 自定义窗口任务') + '\nexport { buildWindowTaskText }', { KNOWLEDGE_PACK: 'generic knowledge', widgetManifestPath }).buildWindowTaskText
const prompt = build('A', '项目 A', '窗口1', 'new widget', 'C:/shared', 'send', owned)
for (const b of owned) assert.ok(prompt.includes(widgetManifestPath(b)))
assert.ok(prompt.includes('继续修改同名作品'))
assert.ok(!prompt.includes('project-B'))
assert.ok(prompt.includes('不读写项目根目录旧的 widget-result.json'))
})