File size: 6,770 Bytes
2b64d42 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | // Dashboard /langserver/binary + /langserver/update endpoints.
//
// Issues #7, #10, #49, #87 collectively asked for "how do I get / update
// the language_server_linux_x64 binary?" There's a working install-ls.sh
// shipped in the docker image but every user had to docker-exec into the
// container, run it, then bounce the LS pool by hand. The dashboard now
// exposes:
//
// GET /dashboard/api/langserver/binary → current size/mtime/sha256
// POST /dashboard/api/langserver/update → run install-ls.sh + restart
//
// Static-validate both routes; the live install runs against GitHub
// releases and Exafunction so it's not safe to hit in unit tests.
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';
const __dirname = dirname(fileURLToPath(import.meta.url));
const API_JS = readFileSync(join(__dirname, '..', 'src/dashboard/api.js'), 'utf8');
const LS_JS = readFileSync(join(__dirname, '..', 'src/langserver.js'), 'utf8');
describe('LS binary inspect endpoint (#7/#10/#49)', () => {
test('GET /langserver/binary returns size + mtime + sha256 prefix', () => {
const m = API_JS.match(/subpath === '\/langserver\/binary'[\s\S]+?\n \}/);
assert.ok(m, 'GET /langserver/binary route not found');
const route = m[0];
assert.match(route, /method === 'GET'/);
assert.match(route, /sizeBytes:/);
assert.match(route, /mtime:/);
assert.match(route, /sha256:/);
assert.match(route, /createHash\('sha256'\)/,
'must hash via node:crypto, not shell out to sha256sum');
assert.match(route, /\.slice\(0, 16\)/,
'sha256 should be truncated to 16 hex chars (matches install-ls.sh log)');
});
});
describe('LS binary update endpoint (#7/#10/#49/#87)', () => {
test('POST /langserver/update spawns install-ls.sh', () => {
const m = API_JS.match(/subpath === '\/langserver\/update'[\s\S]+?\n \}/);
assert.ok(m, 'POST /langserver/update route not found');
const route = m[0];
assert.match(route, /method === 'POST'/);
assert.match(route, /spawn\b/);
assert.match(route, /install-ls\.sh/);
assert.match(route, /LS_INSTALL_PATH:\s*config\.lsBinaryPath/,
'must point install-ls.sh at the configured binary path so the right file is overwritten');
});
test('rejects custom URLs from non-allowlisted hosts', () => {
const m = API_JS.match(/subpath === '\/langserver\/update'[\s\S]+?\n \}/);
assert.ok(m);
const route = m[0];
// Defence-in-depth: dashboard auth gates the endpoint, but we also
// refuse to feed an arbitrary URL into the install script. Without
// this guard, an attacker who got past dashboard auth could write
// arbitrary bytes to the LS binary and have node exec them.
assert.match(route, /protocol !== 'https:'/,
'must require https');
assert.match(route, /allowedHosts/,
'must consult an allowlist of hosts, not arbitrary URLs');
assert.match(route, /github\.com/,
'github.com must be in the allowlist (our releases live there)');
});
test('after install, every LS pool entry is restarted', () => {
const m = API_JS.match(/subpath === '\/langserver\/update'[\s\S]+?\n \}/);
assert.ok(m);
const route = m[0];
assert.match(route, /_poolKeys/,
'must enumerate all live LS pool keys (default + per-proxy entries)');
assert.match(route, /restartLsForProxy/,
'must call restartLsForProxy on each entry to swap the binary in flight');
});
test('langserver.js exports _poolKeys + getProxyByKey for the update endpoint', () => {
assert.match(LS_JS, /export function _poolKeys\(\)/);
assert.match(LS_JS, /export function getProxyByKey\(key\)/);
});
});
// User report (2026-05-01): "LS update has no effect" — the toast
// always said "restarted N instances" without telling the user whether
// the binary itself actually changed. Pool was often cold (proxy hadn't
// served a request yet) so N=0, and same-version re-download was
// indistinguishable from a real update.
describe('LS update result is descriptive enough to debug "no effect" reports', () => {
test('captures sha256 BEFORE and AFTER the install-ls.sh run', () => {
const m = API_JS.match(/subpath === '\/langserver\/update'[\s\S]+?\n \}/);
assert.ok(m);
const route = m[0];
// Both snapshots must come from the same hashing path (sha256 of
// the binary file at config.lsBinaryPath). Pin both exist.
assert.match(route, /beforeSha\s*=\s*null/,
'must declare beforeSha BEFORE running the install script');
assert.match(route, /afterSha\s*=\s*null/,
'must declare afterSha AFTER running the install script');
assert.match(route, /binaryChanged\s*=\s*!!\(beforeSha && afterSha && beforeSha !== afterSha\)/,
'must compute binaryChanged so the dashboard can distinguish "no change" from "updated"');
});
test('response includes beforeSha / afterSha / binaryChanged / poolEmpty', () => {
const m = API_JS.match(/subpath === '\/langserver\/update'[\s\S]+?\n \}/);
assert.ok(m);
const route = m[0];
assert.match(route, /beforeSha:\s*beforeSha/,
'response payload must surface beforeSha');
assert.match(route, /afterSha:\s*afterSha/,
'response payload must surface afterSha');
assert.match(route, /binaryChanged,/,
'response payload must surface the binaryChanged flag');
assert.match(route, /poolEmpty:\s*restarted === 0 && restartErrors\.length === 0/,
'poolEmpty distinguishes "cold pool" from "all restarts failed"');
});
test('dashboard toast picks the right message for each outcome', () => {
const HTML = readFileSync(join(__dirname, '..', 'src/dashboard/index.html'), 'utf8');
const m = HTML.match(/async updateLsBinary\(\)[\s\S]+?\n \},/);
assert.ok(m, 'updateLsBinary not found in dashboard');
const fn = m[0];
// Three distinct toast keys cover the three meaningfully-different
// outcomes. A future refactor that drops any of these would silently
// regress the "no effect" UX bug.
assert.match(fn, /lsBinaryAlreadyCurrent/,
'must show a distinct toast when the binary did not change (no upstream release)');
assert.match(fn, /lsBinaryUpdatedColdPool/,
'must show a distinct toast when the binary changed but no live LS was running');
assert.match(fn, /lsBinaryUpdated/,
'must keep the regular toast for binary-changed + live-restart-succeeded');
assert.match(fn, /r\.binaryChanged/,
'toast selection must key on the binaryChanged flag');
assert.match(fn, /r\.poolEmpty/,
'toast selection must key on the poolEmpty flag');
});
});
|