mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
152 lines
9.4 KiB
JavaScript
152 lines
9.4 KiB
JavaScript
// Real panel handlers under the same deterministic hook fixture as native discovery.
|
|
const assert = require('node:assert/strict')
|
|
const {fixture, flatten, text, candidate} = require('./test_native_app_discovery_ui.cjs')
|
|
const logo = f => flatten(f.tree()).find(n => n.props?.id === 'app-logo')
|
|
const posts = f => f.calls.filter(c => c.method === 'POST' && c.url.endsWith('/apps'))
|
|
async function cachedImageContract() {
|
|
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm')
|
|
const root = path.resolve(__dirname, '../../AppImage')
|
|
const ts = require(path.join(root, 'node_modules/typescript'))
|
|
const source = fs.readFileSync(path.join(root, 'components/lxc-app-panel.tsx'), 'utf8')
|
|
const parsed = ts.createSourceFile('panel.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
const fn = parsed.statements.find(n => ts.isFunctionDeclaration(n) && n.name?.text === 'CachedLocalLogo')
|
|
assert.ok(fn)
|
|
const code = ts.transpileModule('export ' + fn.getText(parsed), {compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
|
}}).outputText
|
|
let state, deps, cleanup, pendingEffect
|
|
const requests = [], created = [], revoked = [], exported = {}
|
|
vm.runInNewContext(code, {
|
|
exports: exported, AbortController,
|
|
require: () => ({jsx: (type, props) => ({type, props})}),
|
|
useState: () => [state, value => {state = value}],
|
|
useEffect: (fn, next) => {
|
|
if (!deps || deps[0] !== next[0]) pendingEffect = () => {cleanup?.(); deps = next; cleanup = fn()}
|
|
},
|
|
getAuthToken: () => 'fixture-token', getApiUrl: src => '/proxy' + src,
|
|
fetch: (url, opts) => new Promise(resolve => requests.push({url, opts, resolve})),
|
|
URL: {createObjectURL: () => {const url = 'blob:fixture-' + created.length; created.push(url); return url}, revokeObjectURL: url => revoked.push(url)},
|
|
})
|
|
const render = src => {pendingEffect = null; const tree = exported.CachedLocalLogo({src, className: 'icon'}); pendingEffect?.(); return tree}
|
|
const settle = async () => {for (let n = 0; n < 8; n++) await Promise.resolve()}
|
|
// Flask's real SVG response includes a charset parameter.
|
|
const reply = req => req.resolve({ok: true, blob: async () => ({type: 'image/svg+xml; charset=utf-8', size: 100})})
|
|
const a = '/api/vms/101/apps/logo-cache/' + 'a'.repeat(32)
|
|
const b = '/api/vms/102/apps/logo-cache/' + 'b'.repeat(32)
|
|
render(a)
|
|
assert.equal(requests[0].opts.headers.Authorization, 'Bearer fixture-token')
|
|
assert.equal(requests[0].url, '/proxy' + a)
|
|
reply(requests[0]); await settle()
|
|
assert.equal(render(a).props.src, 'blob:fixture-0')
|
|
assert.equal(render(b), null, 'never flash previous guest icon')
|
|
assert.deepEqual(revoked, ['blob:fixture-0'])
|
|
assert.equal(requests[0].opts.signal.aborted, true)
|
|
cleanup(); reply(requests[1]); await settle()
|
|
assert.equal(created.length, 1, 'late unmounted response creates no blob')
|
|
render(a); requests[2].resolve({ok: false}); await settle()
|
|
assert.equal(render(a), null, 'unauthorized/missing cache never falls back to raw authenticated URL')
|
|
}
|
|
async function editor(ports = [8080]) {
|
|
const f = fixture({nativeCandidate: {...candidate, default_ports: ports}})
|
|
f.render(); await f.settle()
|
|
await f.button('searchApplications').props.onClick(); f.render()
|
|
f.button('registerButton').props.onClick(); f.render(); f.render()
|
|
return f
|
|
}
|
|
async function main() {
|
|
await cachedImageContract()
|
|
const scanning = fixture()
|
|
scanning.renderParent(); await scanning.settle(); scanning.holdScan()
|
|
const pendingScan = scanning.button('searchApplications').props.onClick(); scanning.render()
|
|
scanning.renderParent(101, 1); await scanning.settle()
|
|
scanning.resolveScan(); await pendingScan; scanning.render()
|
|
assert.equal(scanning.button('searchApplications').props.disabled, false, 'revision must not leave an active Native Find stuck busy')
|
|
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')
|
|
select.props.onChange({target: {value: 'https:8443'}}); f.render(); f.render()
|
|
await f.button('discoverLocalLogo').props.onClick(); f.render(); f.render()
|
|
const request = f.calls.find(c => c.url.endsWith('/logo-discovery'))
|
|
assert.deepEqual(JSON.parse(request.body), {port: 8443, scheme: 'https'})
|
|
assert.match(logo(f).props.value, /^\/api\/vms\/101\/apps\/logo-cache\//)
|
|
assert.equal(posts(f).length, 0, 'lookup only changes the draft')
|
|
await f.button('saveButton').props.onClick(); f.render()
|
|
assert.match(JSON.parse(posts(f)[0].body).logo_url, /logo-cache/)
|
|
|
|
for (const action of ['logo-edit', 'port-edit', 'cancel-reopen', 'guest-roundtrip', 'endpoint-change']) {
|
|
const g = await editor([8080, 8443])
|
|
g.holdIcon()
|
|
const pending = g.button('discoverLocalLogo').props.onClick()
|
|
g.render()
|
|
if (action === 'logo-edit') logo(g).props.onChange({target: {value: 'https://example.test/manual.png'}})
|
|
if (action === 'port-edit') {
|
|
const input = flatten(g.tree()).find(n => n.type === 'Input' && n.props.value === 8080)
|
|
assert.ok(input); input.props.onChange({target: {value: '8090'}})
|
|
}
|
|
if (action === 'cancel-reopen') {
|
|
g.button('cancelButton').props.onClick(); g.render()
|
|
g.button('registerButton').props.onClick()
|
|
}
|
|
if (action === 'guest-roundtrip') {
|
|
g.render({vmid: 102}); g.render({vmid: 101}); await g.settle()
|
|
}
|
|
if (action === 'endpoint-change') {
|
|
flatten(g.tree()).find(n => n.props?.['aria-label'] === 'vmLxc.appEditor.localLogoPort').props.onChange({target: {value: 'https:8443'}})
|
|
}
|
|
g.render(); g.render(); g.resolveIcon(); await pending; g.render()
|
|
assert.ok(!String(logo(g)?.props.value || '').includes('/logo-cache/'), action + ' rejects late success')
|
|
assert.equal(posts(g).length, 0)
|
|
assert.equal(g.calls.find(c => c.url.endsWith('/logo-discovery')).signal.aborted, true)
|
|
}
|
|
|
|
for (const code of ['local_logo_unavailable', 'local_logo_stopped']) {
|
|
const g = await editor()
|
|
logo(g).props.onChange({target: {value: 'https://example.test/existing.png'}}); g.render(); g.render()
|
|
g.setIconError({body: {code}})
|
|
await g.button('discoverLocalLogo').props.onClick(); g.render()
|
|
assert.equal(logo(g).props.value, 'https://example.test/existing.png')
|
|
assert.ok(text(g.tree()).includes(code === 'local_logo_stopped' ? 'localLogoStopped' : 'localLogoUnavailable'))
|
|
assert.equal(posts(g).length, 0)
|
|
}
|
|
const wrong = await editor()
|
|
wrong.setIconResult({logo_url: '/api/vms/102/apps/logo-cache/' + 'b'.repeat(32)})
|
|
await wrong.button('discoverLocalLogo').props.onClick(); wrong.render()
|
|
assert.ok(!String(logo(wrong).props.value).includes('/logo-cache/'))
|
|
const immediate = await editor()
|
|
immediate.holdIcon(); immediate.setIconError({body: {code: 'local_logo_unavailable'}})
|
|
const lateError = immediate.button('discoverLocalLogo').props.onClick()
|
|
logo(immediate).props.onChange({target: {value: 'https://example.test/typed.png'}})
|
|
// Deliberately resolve before another render: batched input must invalidate too.
|
|
immediate.resolveIcon(); await lateError; immediate.render()
|
|
assert.equal(logo(immediate).props.value, 'https://example.test/typed.png')
|
|
assert.ok(!text(immediate.tree()).includes('localLogoUnavailable'))
|
|
const noPort = await editor([])
|
|
assert.equal(noPort.button('discoverLocalLogo').props.disabled, true)
|
|
await noPort.button('discoverLocalLogo').props.onClick()
|
|
assert.equal(noPort.calls.filter(c => c.url.endsWith('/logo-discovery')).length, 0)
|
|
console.log('PASS local icons: explicit selected port, draft/save, no-port, errors, cancel/reopen, edit/guest races and forbidden writes')
|
|
}
|
|
main().catch(error => {console.error(error); process.exitCode = 1})
|