| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { describe, test } from 'node:test'; |
| import assert from 'node:assert/strict'; |
| import { readFileSync } from 'node:fs'; |
| import { fileURLToPath } from 'node:url'; |
| import { dirname, join } from 'node:path'; |
| import { detectDockerSelfUpdate, readSelfContainerId } from '../src/dashboard/docker-self-update.js'; |
|
|
| const __dirname = dirname(fileURLToPath(import.meta.url)); |
| const MOD = readFileSync(join(__dirname, '..', 'src/dashboard/docker-self-update.js'), 'utf8'); |
| const API = readFileSync(join(__dirname, '..', 'src/dashboard/api.js'), 'utf8'); |
|
|
| describe('docker self-update detection', () => { |
| test('reports no-docker-sock when /var/run/docker.sock is absent', async () => { |
| |
| |
| |
| const r = await detectDockerSelfUpdate(); |
| assert.equal(r.available, false); |
| |
| |
| |
| |
| assert.match(r.reason, /no-docker-sock|no-self-id|docker-api-unreachable|no-compose-labels/); |
| }); |
|
|
| test('readSelfContainerId returns a hex id or null', () => { |
| const id = readSelfContainerId(); |
| if (id !== null) { |
| assert.match(id, /^[0-9a-f]{12,64}$/, |
| 'container id format should be 12-64 hex chars (docker convention)'); |
| } |
| }); |
| }); |
|
|
| describe('docker self-update module shape', () => { |
| test('uses /var/run/docker.sock unix socket, not a TCP daemon URL', () => { |
| assert.match(MOD, /'\/var\/run\/docker\.sock'/, |
| 'must hardcode /var/run/docker.sock as the daemon socket'); |
| assert.match(MOD, /socketPath:/, |
| 'must use http.request with socketPath option (no docker CLI dependency)'); |
| }); |
|
|
| test('spawns a deployer sidecar that runs docker compose up -d', () => { |
| assert.match(MOD, /docker compose -p/, |
| 'sidecar command must use docker compose -p with the project name'); |
| assert.match(MOD, /up -d/, |
| 'sidecar must run `up -d` to recreate the container with the pulled image'); |
| assert.match(MOD, /AutoRemove: true/, |
| 'sidecar must auto-remove after exit so we do not leak deployer containers'); |
| }); |
|
|
| test('the sidecar sleeps before tearing us down', () => { |
| |
| |
| |
| assert.match(MOD, /DEPLOYER_DELAY_SECONDS/, |
| 'must define a delay constant'); |
| assert.match(MOD, /sleep \$\{DEPLOYER_DELAY_SECONDS\}/, |
| 'sidecar Cmd must sleep for DEPLOYER_DELAY_SECONDS before pulling/recreating'); |
| }); |
|
|
| test('shell-quotes the project name and working dir', () => { |
| |
| |
| |
| assert.match(MOD, /shellQuote\(/); |
| assert.match(MOD, /function shellQuote/); |
| }); |
|
|
| test('aborts when running container has no compose labels', () => { |
| |
| |
| |
| assert.match(MOD, /no-compose-labels/, |
| 'must report no-compose-labels reason when container was not started by compose'); |
| }); |
| }); |
|
|
| describe('docker self-update wired into /self-update', () => { |
| test('/self-update/check falls back to docker when git is unavailable', () => { |
| const m = API.match(/subpath === '\/self-update\/check'[\s\S]+?\n \}/); |
| assert.ok(m); |
| const route = m[0]; |
| assert.match(route, /detectDockerSelfUpdate\(\)/, |
| 'must consult docker mode when git mode reports unavailable'); |
| assert.match(route, /mode: 'docker'/, |
| 'must label the response so the dashboard can switch UI flows'); |
| }); |
|
|
| test('/self-update POST falls back to docker when git is unavailable', () => { |
| const m = API.match(/subpath === '\/self-update' && method === 'POST'[\s\S]+?\n \}/); |
| assert.ok(m); |
| const route = m[0]; |
| assert.match(route, /runDockerSelfUpdate\(\)/, |
| 'POST /self-update must call runDockerSelfUpdate when docker mode is available'); |
| }); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| describe('docker self-update: deployer image pulled (#user 2026-05-01)', () => { |
| test('runDockerSelfUpdate pulls DEPLOYER_IMAGE before creating the container', () => { |
| |
| |
| |
| |
| const m = MOD.match(/dockerPull\(ctx\.image\)[\s\S]{0,1500}?dockerPull\(DEPLOYER_IMAGE\)[\s\S]{0,1500}?\/containers\/create/); |
| assert.ok(m, |
| 'must pull ctx.image, then pull DEPLOYER_IMAGE, then POST /containers/create — in that order'); |
| }); |
|
|
| test('deployer-pull-failed reason is reported when the sidecar pull fails', () => { |
| assert.match(MOD, /reason: 'deployer-pull-failed'/, |
| 'a distinct reason code is needed so the frontend can localize it'); |
| }); |
| }); |
|
|
| describe('dashboard: applyUpdate prefers reason over detail (#user 2026-05-01)', () => { |
| test('docker-mode error path uses r.reason (short code), not r.detail (free text)', () => { |
| const html = readFileSync(join(__dirname, '..', 'src/dashboard/index.html'), 'utf8'); |
| |
| |
| |
| |
| const m = html.match(/translateError\(r\.reason,\s*'error\.updateFailed'\)[\s\S]{0,400}?r\.detail/); |
| assert.ok(m, |
| 'docker-mode error handling must call translateError with r.reason FIRST and only append r.detail as plain suffix'); |
| }); |
| }); |
|
|
| describe('I18n.t: zh-CN DOM fallback hardened against arbitrary keys (#user 2026-05-01)', () => { |
| test('querySelector lookup is guarded by a charset check + try/catch', () => { |
| const html = readFileSync(join(__dirname, '..', 'src/dashboard/index.html'), 'utf8'); |
| |
| |
| |
| assert.match(html, /\/\^\[A-Za-z0-9_\.\-\]\+\$\/\.test\(key\)/, |
| 'must charset-validate the key before constructing a CSS selector'); |
| assert.match(html, /CSS\.escape\(key\)/, |
| 'must CSS.escape the key when building the [data-i18n] selector'); |
| |
| const m = html.match(/try\s*\{[\s\S]{0,500}?document\.querySelector\(`\[data-i18n="\$\{CSS\.escape\(key\)\}"\]`\)[\s\S]{0,200}?\}\s*catch/); |
| assert.ok(m, |
| 'querySelector call must sit inside try/catch so a malformed key cannot throw out of the i18n resolver'); |
| }); |
| }); |
|
|