fix(monitor): preserve app editor across lifecycle refreshes

This commit is contained in:
VAIO73
2026-10-18 19:21:52 +00:00
parent 0a06a81871
commit 3071095109
4 changed files with 82 additions and 25 deletions
@@ -55,6 +55,28 @@ async function editor(ports = [8080]) {
}
async function main() {
await cachedImageContract()
const existing = fixture({apps: [{id: 'existing', name: 'Saved app', ports: [{port: 8080, scheme: 'http'}], state: {}}]})
existing.renderParent(); await existing.settle()
const click = async key => {
const node = flatten(existing.tree()).find(n => ['button', 'Button'].includes(n.type) && text(n).includes('vmLxc.appEditor.' + key))
assert.ok(node, key); await node.props.onClick(); existing.render()
}
await click('editButton'); await click('editFieldsButton')
logo(existing).props.onChange({target: {value: 'https://example.test/draft.png'}}); existing.render()
existing.holdIcon()
const pendingExisting = existing.button('discoverLocalLogo').props.onClick(); existing.render()
const reads = existing.bundleReads()
existing.renderParent(101, 1); await existing.settle(); existing.render()
assert.equal(logo(existing)?.props.value, 'https://example.test/draft.png', 'lifecycle refresh must retain the open existing-app editor and unsaved fields')
assert.ok(existing.bundleReads() > reads, 'revision still reloads the saved app data')
existing.resolveIcon(); await pendingExisting; existing.render(); existing.render()
assert.match(logo(existing).props.value, /logo-cache/, 'successful lookup leaves existing editor open')
assert.equal(existing.calls.filter(c => ['POST', 'PUT', 'DELETE'].includes(c.method) && !c.url.endsWith('/logo-discovery')).length, 0)
await click('cancelButton')
await click('editFieldsButton')
assert.equal(logo(existing).props.value, '', 'cancel discards draft icon')
existing.renderParent(102, 1); await existing.settle(); existing.render()
assert.equal(logo(existing), undefined, 'switching guest must discard the old editor')
const f = await editor([8080, 8443])
assert.equal(f.calls.filter(c => c.url.endsWith('/logo-discovery')).length, 0)
const select = flatten(f.tree()).find(n => n.props?.['aria-label'] === 'vmLxc.appEditor.localLogoPort')
@@ -23,14 +23,15 @@ const candidate = {
evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [],
}
function fixture({nativeCandidate = candidate} = {}) {
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = []
function fixture({nativeCandidate = candidate, apps = []} = {}) {
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [], memoStates = []
const calls = [], cachedWrites = []
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
let cacheGate, resolveCache, cachePending = false
let iconGate, resolveIcon, iconResult = {logo_url: '/api/vms/101/apps/logo-cache/' + 'b'.repeat(32)}, iconError
const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}}
const seed = {sidecar: {vmid: 101, apps}, suggestions: {extras: [], port_suggestions: []}}
let bundleReads = 0
const react = {
useState(initial) {
const index = cursor++
@@ -38,7 +39,12 @@ function fixture({nativeCandidate = candidate} = {}) {
return [states[index], value => {states[index] = typeof value === 'function' ? value(states[index]) : value}]
},
useRef(value) {const index = cursor++; return refs[index] ||= {current: value}},
useMemo(fn) {cursor++; return fn()}, useCallback(fn) {cursor++; return fn},
useMemo(fn, deps) {
const index = cursor++, prior = memoStates[index]
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) memoStates[index] = {deps, value: fn()}
return memoStates[index].value
},
useCallback(fn, deps) {return react.useMemo(() => fn, deps)},
useEffect(fn, deps) {
const index = cursor++, prior = effectStates[index]
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) {
@@ -75,7 +81,7 @@ function fixture({nativeCandidate = candidate} = {}) {
return []
}
const cache = {
getLxcAppsCached: () => null, fetchLxcApps: async () => seed,
getLxcAppsCached: () => null, fetchLxcApps: async () => {bundleReads++; return seed},
subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args),
}
const exports = {}
@@ -97,13 +103,34 @@ function fixture({nativeCandidate = candidate} = {}) {
for (const effect of effects) effect()
return tree
}
// Exercise React's key identity boundary using the actual parent expression.
const parent = ts.createSourceFile('parent.tsx', fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let keyExpression
function visit(node) {
if (ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent) === 'LxcAppPanel') {
keyExpression = node.attributes.properties.find(p => p.name?.getText(parent) === 'key').initializer.expression.getText(parent)
}
ts.forEachChild(node, visit)
}
visit(parent)
const parentKey = new Function('selectedVM', 'return ' + keyExpression)
let lastKey
function renderParent(vmid = 101, revision = 0) {
const key = parentKey({vmid, modal_cache_revision: revision})
if (lastKey !== undefined && lastKey !== key) {
effectStates.forEach(effect => effect?.cleanup?.())
states = []; refs = []; effectStates = []; memoStates = []
}
lastKey = key
return render({vmid, cacheRevision: revision, initialData: seed})
}
const settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()}
const button = key => {
const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key))
assert.ok(match, 'Missing button ' + key)
return match
}
return {render, settle, calls, cachedWrites, button, tree: () => tree,
return {render, renderParent, settle, calls, cachedWrites, button, tree: () => tree, bundleReads: () => bundleReads,
holdIcon: () => {iconGate = new Promise(resolve => {resolveIcon = resolve})},
resolveIcon: () => resolveIcon(), setIconResult: value => {iconResult = value},
setIconError: value => {iconError = value},