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('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, /