Files
VoiceStudio/tests/frontend/devBackend.test.mjs
debpalash e6b1179001 feat(dev): loud backend exit banner for bun run dev + docs + changelog (#1164)
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.
2026-07-16 19:28:42 +05:30

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/);
});