const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const check = { status: 'checked', command: 'check', requestId: 'checked-request', plan: {
version: '3.2.0', revision: 'a'.repeat(40), updateAvailable: true, requiresComposeAdoption: false, retiredServices: [],
} }
const overview = (job = check, engine = { status: 'checked' }) => ({ acceptingRequests: true, engine, request: job, installed: {}, backend: { version: '3.1.0' } })
async function setup(t, api, saved = null) {
const mounted = [], unmounted = [], calls = [], storage = new Map(saved ? [['fedeo:update:admin', saved]] : [])
let nextId = 0
const context = vm.createContext({
ref: vue.ref, computed: vue.computed, onMounted: fn => mounted.push(fn), onBeforeUnmount: fn => unmounted.push(fn),
useAuthStore: () => ({ user: { id: 'admin' } }),
useNuxtApp: () => ({ $api: async (path, options) => { calls.push({ path, options }); return api(path, options) } }),
localStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) },
crypto: { randomUUID: () => `request-id-${++nextId}` }, setTimeout: () => 1, clearTimeout() {},
})
const source = fs.readFileSync(__dirname + '/../composables/useSelfhostUpdates.ts', 'utf8').replace(/export /g, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }) + '\nglobalThis.ui = useSelfhostUpdates()', context)
mounted.forEach(fn => fn()); await new Promise(setImmediate)
t.after(() => unmounted.forEach(fn => fn()))
return { ui: context.ui, calls, storage }
}
test('checked version enables one apply; duplicate click is blocked and polling survives restart', async t => {
let state = overview(), offline = false, finish
const f = await setup(t, async (path, options) => {
if (offline) throw new Error('network')
if (options.method === 'POST') return new Promise(resolve => { finish = resolve })
return path.endsWith('/status') ? state : state.request
})
assert.equal(f.ui.canApply.value, true)
const start = f.ui.apply(); await f.ui.apply()
assert.equal(f.calls.filter(c => c.options.method === 'POST').length, 1)
const body = f.calls.at(-1).options.body
assert.equal(body.version, '3.2.0'); assert.equal(body.checkRequestId, 'checked-request')
state = overview({ status: 'running', command: 'apply', requestId: body.requestId }, { status: 'running', recoveryRequired: true })
finish(state.request); await start
await f.ui.refresh(); assert.equal(f.ui.recovery.value, false)
offline = true; await f.ui.refresh()
assert.match(f.ui.message.value, /Host führt das Update weiter/)
assert.equal(f.ui.canApply.value, false)
offline = false; state = overview({ ...state.request, status: 'succeeded' }, { status: 'succeeded' })
await f.ui.refresh(); assert.equal(f.ui.job.value.status, 'succeeded')
})
test('lost start response is recovered after reload without replaying an update', async t => {
const first = await setup(t, async (path, options) => {
if (options.method === 'POST') throw new Error('response lost')
return path.endsWith('/status') ? overview() : check
})
await first.ui.apply()
const id = first.ui.pending.value.body.requestId
const saved = first.storage.get('fedeo:update:admin')
const running = { status: 'running', command: 'apply', requestId: id }
const second = await setup(t, async path => path.endsWith('/status') ? overview(running) : running, saved)
assert.equal(second.ui.pending.value, null)
assert.equal(second.ui.job.value.requestId, id)
assert.equal(second.calls.some(c => c.options.method === 'POST'), false)
})
test('unconfirmed submission retains its original ID for explicit retry', async t => {
const saved = JSON.stringify({ pending: { command: 'apply', body: { requestId: 'original-request', version: '3.2.0', checkRequestId: 'checked-request' } } })
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') return { status: 'queued', command: 'apply', requestId: options.body.requestId }
if (path.endsWith('/status')) return overview()
throw { statusCode: 404 }
}, saved)
assert.equal(f.ui.canApply.value, false)
await f.ui.sendPending()
assert.equal(f.calls.at(-1).options.body.requestId, 'original-request')
})
test('recovery, up-to-date, unmanaged compose and other active jobs prevent applying', async t => {
for (const state of [overview(check, { status: 'failed', recoveryRequired: true }),
overview({ ...check, status: 'up_to_date' }),
overview({ ...check, plan: { ...check.plan, requiresComposeAdoption: true } }),
overview({ status: 'running', command: 'apply', requestId: 'someone-else' })]) {
const f = await setup(t, async () => state)
assert.equal(f.ui.canApply.value, false)
await f.ui.apply(); assert.equal(f.calls.some(c => c.options.method === 'POST'), false)
}
})
test('a completed storage migration stays blocked until the host handover is complete', async t => {
let state = { ...overview(check, { status: 'failed', recoveryRequired: true }),
storage: { provider: 'seaweedfs', migrationPending: true } }
const f = await setup(t, async path => path.endsWith('/status') ? state : state.request)
assert.equal(f.ui.overview.value.storage.migrationPending, true)
assert.equal(f.ui.canApply.value, false)
state = { ...overview({ ...check, plan: { ...check.plan, storageProvider: 'seaweedfs', storageImageRetained: true } }),
storage: { provider: 'seaweedfs', migrationPending: false } }
await f.ui.refresh()
assert.equal(f.ui.canApply.value, true)
assert.equal(f.ui.job.value.plan.storageProvider, 'seaweedfs')
})
test('an older polling response cannot erase a new submission whose response was lost', async t => {
let hold = false, release
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') throw new Error('response lost')
if (path.endsWith('/status')) return overview()
if (hold) return new Promise(resolve => { release = resolve })
return check
})
hold = true
const polling = f.ui.refresh()
await new Promise(setImmediate)
await f.ui.apply()
const id = f.ui.pending.value.body.requestId
release(check); await polling
assert.equal(f.ui.pending.value.body.requestId, id)
assert.equal(f.ui.canApply.value, false)
})
test('failed jobs automatically show logs and refresh them only while open', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request', error: { code: 'UPDATE_FAILED', message: 'Auftrag fehlgeschlagen.' } }
let content = 'pull access denied'
const f = await setup(t, async path => {
if (path.endsWith('/logs')) return { requestId: failed.requestId, logs: [{ source: 'commands', content, truncated: false }] }
return path.endsWith('/status') ? { ...overview(failed), capabilities: { requestLogs: true } } : failed
})
assert.equal(f.ui.logsOpen.value, true)
assert.equal(f.ui.logEntries.value[0].content, content)
content = 'final failure detail'
await f.ui.refresh()
assert.equal(f.ui.logEntries.value[0].content, content)
await f.ui.toggleLogs()
const count = f.calls.filter(c => c.path.endsWith('/logs')).length
await f.ui.refresh()
assert.equal(f.ui.logsOpen.value, false)
assert.equal(f.calls.filter(c => c.path.endsWith('/logs')).length, count)
})
test('older host services explain the required upgrade without requesting unsupported endpoints', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request' }
const f = await setup(t, async path => path.endsWith('/status') ? overview(failed) : failed)
assert.equal(f.ui.logsOpen.value, true)
assert.match(f.ui.logsMessage.value, /Host-Update-Dateien aktualisieren/)
assert.equal(f.calls.some(c => c.path.endsWith('/logs')), false)
assert.equal(f.ui.connected.value, true)
})
test('log failures preserve job state and revoked admin access clears the displayed logs', async t => {
let failure
const f = await setup(t, async path => {
if (path.endsWith('/logs')) {
if (failure) throw failure
return { requestId: check.requestId, logs: [{ source: 'commands', content: 'diagnostic', truncated: false }] }
}
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
await f.ui.toggleLogs()
failure = { statusCode: 503 }
await f.ui.refreshLogs()
assert.match(f.ui.logsMessage.value, /konnte nicht geladen/)
assert.equal(f.ui.connected.value, true)
assert.equal(f.ui.job.value.status, 'checked')
failure = { statusCode: 403 }
await f.ui.refreshLogs()
assert.equal(f.ui.accessDenied.value, true)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('a delayed log response cannot appear under a newly submitted job', async t => {
let release
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') return { status: 'queued', requestId: options.body.requestId, command: 'check' }
if (path.endsWith('/logs')) return new Promise(resolve => { release = resolve })
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
const loading = f.ui.toggleLogs()
await new Promise(setImmediate)
await f.ui.check()
release({ requestId: check.requestId, logs: [{ source: 'commands', content: 'previous job', truncated: false }] })
await loading
assert.notEqual(f.ui.job.value.requestId, check.requestId)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('the update card renders log output as escaped text and explains truncation', async t => {
const f = await setup(t, async path => path.endsWith('/status') ? overview() : check)
f.ui.logsOpen.value = true
const { parse, compileScript } = require('@vue/compiler-sfc')
const descriptor = parse(fs.readFileSync(__dirname + '/../components/SelfhostUpdateCard.vue', 'utf8')).descriptor
const compiled = compileScript(descriptor, { id: 'update-card-test', inlineTemplate: true }).content
const exports = {}
const context = vm.createContext({ exports, require, ref: vue.ref, computed: vue.computed,
useRuntimeConfig: () => ({ public: {} }), useSelfhostUpdates: () => ({ ...f.ui,
logEntries: vue.ref([{ source: 'commands', content: '', truncated: true }]) }) })
vm.runInContext(ts.transpile(compiled, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
const app = vue.createSSRApp(exports.default)
for (const name of ['UCard', 'UIcon', 'UBadge', 'UAlert', 'UProgress', 'UButton', 'UModal', 'UCheckbox']) {
app.component(name, { setup: (_props, { slots }) => () => vue.h('div', slots.default?.()) })
}
const html = await require('@vue/server-renderer').renderToString(app)
assert.match(html, /Werkzeugausgaben/)
assert.match(html, /Gekürzte Anzeige/)
assert.match(html, /<script>/)
assert.doesNotMatch(html, /