Files
LinkPort/scripts/verify-package.mjs

67 lines
4.3 KiB
JavaScript

import assert from 'node:assert/strict'
import { spawn, execFileSync } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { readdirSync, statSync, mkdtempSync, rmSync } from 'node:fs'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { resolve, join, dirname, basename, sep } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { resolveProjectPath } from './project-paths.mjs'
assert(process.argv[2], 'Provide the single-binary release directory or executable')
const input = resolveProjectPath(process.argv[2])
const directory = statSync(input).isDirectory() ? input : dirname(input)
const filename = process.platform === 'win32' ? 'linkport.exe' : 'linkport'
const binary = join(directory, filename)
assert(input === directory || input === binary, `Expected ${filename}`)
assert.deepEqual(readdirSync(directory), [filename], 'Release must contain only the executable')
assert(statSync(binary).isFile(), 'Expected an executable file')
const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('LINKPORT_')))
const version = JSON.parse(execFileSync(binary, ['--version', '--json'], { encoding: 'utf8', windowsHide: true, cwd: directory, env: inherited }))
assert.equal(version.platform, `${process.platform === 'win32' ? 'windows' : process.platform}/${process.arch === 'x64' ? 'amd64' : process.arch}`)
assert.match(version.version, /^\d+\.\d+\.\d+/)
assert.match(version.commit, /^(?:[a-f0-9]{40}|[a-f0-9]{64})(?:-dirty)?$|^unknown$/)
assert(Number.isFinite(Date.parse(version.buildTime)), 'Build timestamp must be embedded')
const temporaryRoot = resolve(tmpdir())
const temp = mkdtempSync(join(temporaryRoot, 'linkport-package-smoke-'))
let child
try {
const listener = createServer()
await new Promise((resolve, reject) => { listener.once('error', reject); listener.listen(0, '127.0.0.1', resolve) })
const port = listener.address().port
await new Promise(resolve => listener.close(resolve))
const password = randomBytes(24).toString('base64url')
child = spawn(binary, [], { cwd: temp, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'], env: { ...inherited, LINKPORT_NO_BROWSER: '1', LINKPORT_AUTH_ENABLED: 'true', LINKPORT_ADMIN_USER: 'package-test', LINKPORT_ADMIN_PASSWORD: password, LINKPORT_DATA: join(temp, 'test.sqlite'), LINKPORT_HTTP_ADDR: `127.0.0.1:${port}`, LINKPORT_SECURE_COOKIE: 'false' } })
let startupError = ''
child.stderr.on('data', b => { startupError += b.toString() })
child.on('error', e => { startupError += e.message })
const base = `http://127.0.0.1:${port}`
let healthy = false
for (let i = 0; i < 60; i++) {
try { healthy = (await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(500) })).ok } catch {}
if (healthy) break
if (child.exitCode !== null) throw new Error(`Package exited during startup: ${startupError}`)
await delay(100)
}
assert(healthy, 'Packaged application did not start')
const page = await fetch(base)
assert.equal(page.status, 200)
const html = await page.text()
const assets = [...html.matchAll(/(?:src|href)="(\/assets\/[^" ]+)"/g)].map(m => m[1])
assert(assets.length >= 2, 'Built frontend was not embedded')
for (const asset of assets) assert.equal((await fetch(base + asset)).status, 200, `Missing embedded ${asset}`)
const auth = await fetch(`${base}/api/v1/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'package-test', password }) })
assert.equal(auth.status, 200)
const cookie = auth.headers.get('set-cookie').split(';')[0]
const proxies = await fetch(`${base}/api/v1/proxies`, { headers: { Cookie: cookie } })
assert.equal(proxies.status, 200)
assert.deepEqual(await proxies.json(), [])
assert.deepEqual(readdirSync(directory), [filename], 'Verification must not write to the release directory')
console.log('Single binary verified: embedded version, isolated single-SQLite startup, embedded web assets and administrator login.')
} finally {
if (child && child.exitCode === null) { await new Promise(resolve => { child.once('exit', resolve); child.kill() }) }
const cleanup = resolve(temp)
assert(cleanup.startsWith(temporaryRoot + sep) && basename(cleanup).startsWith('linkport-package-smoke-'), 'Unexpected cleanup path')
rmSync(cleanup, { recursive: true, force: true })
}