W / test /docker-self-update.test.js
Ac66's picture
Upload folder using huggingface_hub
2b64d42 verified
// Docker self-update endpoint behavior.
//
// User report (2026-04-29): "为什么docker不支持更新 支持呗。。。" — i.e.,
// the dashboard's existing self-update path bails on docker
// deployments with a hint to run `docker compose pull && up -d`
// manually. v2.0.41 wires an opt-in path that uses /var/run/docker.sock
// + a one-shot deployer sidecar to recreate the container in-place.
//
// We can't actually exercise the docker daemon in unit tests (no
// socket on Windows / CI runners), so static-validate the module
// shape and the api.js wiring.
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 () => {
// On the test runner there's no docker.sock (Windows) — verify
// the module returns the structured "not available" shape rather
// than crashing.
const r = await detectDockerSelfUpdate();
assert.equal(r.available, false);
// The reason should be one of the expected enum values; on a
// Windows runner it will be no-docker-sock, on Linux without
// docker the same. Don't pin the exact value because Linux CI
// boxes might have a different shape.
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', () => {
// If the sidecar's compose-up runs immediately, the dashboard's
// HTTP response gets killed before reaching the browser, leaving
// a confusing "request failed" toast. The sleep buys time.
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', () => {
// Both come from compose container labels which we don't fully
// control — defensive single-quote-wrap so a malformed label
// can't break out of the `sh -c "..."` payload.
assert.match(MOD, /shellQuote\(/);
assert.match(MOD, /function shellQuote/);
});
test('aborts when running container has no compose labels', () => {
// Hand-managed `docker run` containers can't be safely recreated
// by `docker compose up -d`; we'd lose env / mounts / network.
// Bail with a clear reason instead.
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');
});
});
// User report (2026-05-01): one-click 「更新并重启」on a host that has
// never pulled `docker:24-cli` failed with the dashboard surfacing
// ✗ Failed to execute 'querySelector' on 'Document': '[data-i18n=
// "error.docker API POST /containers/create -> 404: {"message":
// "No such image: docker:24-cli"} "]' is not a valid selector.
//
// Three compounding bugs:
//
// 1. runDockerSelfUpdate only pulled ctx.image (the windsurf-api
// image) — never pulled DEPLOYER_IMAGE. Fresh hosts hit
// `/containers/create -> 404 No such image: docker:24-cli`.
//
// 2. The dashboard's applyUpdate() picked `r.detail || r.reason`
// when constructing the user message, so the long raw error
// string ("docker API POST /containers/create -> 404: ...") won
// over the short stable code ("deployer-create-failed"). The long
// string then went into translateError -> I18n.t -> querySelector
// and exploded.
//
// 3. I18n.t's zh-CN fallback path did `document.querySelector(
// '[data-i18n="${key}"]')` with no escaping, so any key containing
// `"` / `{` / `:` threw DOMException and broke the resolver.
describe('docker self-update: deployer image pulled (#user 2026-05-01)', () => {
test('runDockerSelfUpdate pulls DEPLOYER_IMAGE before creating the container', () => {
// Pin the call ordering — pull(ctx.image) must come first (the new
// app), then pull(DEPLOYER_IMAGE) (the sidecar runtime), then the
// POST /containers/create. A future refactor that drops the second
// pull will resurrect the 404 No-such-image symptom.
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');
// The two MUST be in this order: the localized message (from
// r.reason) is the i18n payload; r.detail goes to the suffix only
// for debugging visibility. Reversing them lets long unstable
// strings flow into I18n.t and re-trigger the querySelector crash.
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');
// The fallback path must only run on keys that look like real
// dotted i18n identifiers; CSS.escape AND a try/catch keep us safe
// even if a future caller still passes garbage.
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');
// The whole try/catch wraps the document.querySelector call.
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');
});
});