In dev there is no supervisor: concurrently's --kill-others-on-fail tears the whole stack down the moment uvicorn exits, the cause scrolls away with the terminal, and the browser tab just says it can't reach the backend — which is exactly how #1164 arrived with zero diagnostics. - scripts/dev-backend.mjs: dev:api now runs uvicorn through a wrapper (command args byte-identical, stdio inherited). On a non-Ctrl+C, non-zero exit it prints a boxed banner: exit code/signal, the last 20 lines of omnivoice.log (data dir resolved exactly like backend/core/config.py), an OOM hint (SIGKILL/137 + the Linux journalctl -k check), and a pointer to the crash notice the run sentinel raises on the next backend start. Exits with the child's own code so --kill-others-on-fail still works. Verified live: started the dev backend, SIGKILLed it, banner printed with the real log tail and exit code 137. - docs-sync: troubleshooting.md gains §14c (browser/dev/Docker crash forensics: the mode-aware error, the dev banner, run_sentinel.json / last_run_crash.json / GET /system/last-run-crash, cap+ack+version-gate semantics) and §14's crash-notice blockquote no longer implies the notice is desktop-only; CONTRIBUTING.md documents the dev:api wrapper. - CHANGELOG.md: [Unreleased] entry for the #1164 class fix. Tests: tests/frontend/devBackend.test.mjs (5) — the uvicorn args are pinned byte-identical, data-dir resolution mirrors config.py, tail/banner content incl. the OOM shapes.
89 lines
3.2 KiB
JavaScript
89 lines
3.2 KiB
JavaScript
// Unit tests for scripts/dev-backend.mjs — the dev:api wrapper that turns a
|
|
// silent backend death into a loud, diagnosable exit banner (#1164).
|
|
//
|
|
// Load-bearing guarantees:
|
|
// * the uvicorn invocation is byte-identical to the old dev:api script —
|
|
// the wrapper adds forensics, never changes how the backend runs;
|
|
// * the data-dir resolution mirrors backend/core/config.py, so the banner
|
|
// tails the same omnivoice.log the backend writes;
|
|
// * the banner names the exit code/signal, carries the log tail, flags the
|
|
// OOM-kill shapes, and points at the next-start crash notice.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import {
|
|
UVICORN_ARGS,
|
|
buildExitBanner,
|
|
resolveDataDir,
|
|
tailFile,
|
|
} from '../../scripts/dev-backend.mjs';
|
|
|
|
test('uvicorn args stay byte-identical to the historical dev:api command', () => {
|
|
assert.equal(
|
|
['uv', ...UVICORN_ARGS].join(' '),
|
|
'uv run uvicorn main:app --app-dir backend --host 0.0.0.0 --port 3900 --reload --reload-dir backend',
|
|
);
|
|
});
|
|
|
|
test('resolveDataDir mirrors backend/core/config.py::get_app_data_dir', () => {
|
|
assert.equal(resolveDataDir({ OMNIVOICE_DATA_DIR: '/x' }, 'linux', '/home/u'), '/x');
|
|
assert.equal(
|
|
resolveDataDir({}, 'darwin', '/Users/u'),
|
|
path.join('/Users/u', 'Library/Application Support/OmniVoice'),
|
|
);
|
|
assert.equal(
|
|
resolveDataDir({ APPDATA: 'C:\\Users\\u\\AppData\\Roaming' }, 'win32', 'C:\\Users\\u'),
|
|
path.join('C:\\Users\\u\\AppData\\Roaming', 'OmniVoice'),
|
|
);
|
|
assert.equal(resolveDataDir({}, 'linux', '/home/u'), path.join('/home/u', '.omnivoice'));
|
|
});
|
|
|
|
test('tailFile returns the last N lines, and null for a missing file', () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'ov-devbackend-'));
|
|
const log = path.join(dir, 'omnivoice.log');
|
|
writeFileSync(log, ['a', 'b', 'c', 'd', ''].join('\n'));
|
|
assert.equal(tailFile(log, 2), 'c\nd');
|
|
assert.equal(tailFile(path.join(dir, 'missing.log'), 2), null);
|
|
});
|
|
|
|
test('banner names the exit, embeds the log tail, and points at the crash notice', () => {
|
|
const banner = buildExitBanner({
|
|
code: 1,
|
|
signal: null,
|
|
logTail: 'ERROR the last thing logged',
|
|
logPath: '/data/omnivoice.log',
|
|
platform: 'darwin',
|
|
});
|
|
assert.match(banner, /OMNIVOICE BACKEND DIED/);
|
|
assert.match(banner, /exit code 1/);
|
|
assert.match(banner, /ERROR the last thing logged/);
|
|
assert.match(banner, /crash notice in the UI the next time/);
|
|
assert.doesNotMatch(banner, /journalctl/, 'the journalctl hint is Linux-only');
|
|
});
|
|
|
|
test('banner flags OOM-kill shapes and adds the Linux journalctl hint', () => {
|
|
const killed = buildExitBanner({
|
|
code: null,
|
|
signal: 'SIGKILL',
|
|
logTail: null,
|
|
logPath: '/data/omnivoice.log',
|
|
platform: 'linux',
|
|
});
|
|
assert.match(killed, /killed by signal SIGKILL/);
|
|
assert.match(killed, /out-of-memory killer/);
|
|
assert.match(killed, /journalctl -k \| grep -i oom/);
|
|
assert.match(killed, /no omnivoice\.log found/);
|
|
|
|
const oom137 = buildExitBanner({
|
|
code: 137,
|
|
signal: null,
|
|
logTail: '',
|
|
logPath: '/p',
|
|
platform: 'linux',
|
|
});
|
|
assert.match(oom137, /out-of-memory killer/);
|
|
});
|