From 92af855ce4c0483569fd8cdde5069d1c56ec849a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 6 Sep 2026 23:11:01 +0200 Subject: [PATCH] fix(base-path): keep the crash beacon under the mount, strip CODEMAN_BASE_URL in tests, add the wiring test The merge-time items from the #381 review. navigator.sendBeacon is not fetch, so the base-aware wrapper never saw the two crash-diag beacons and a sub-path install posted them to the origin root every two seconds. The test suite now strips CODEMAN_BASE_URL like CODEMAN_GESTURE, since the constructor reads it as a fallback and an operator who exports it would see the root-install byte-identity assertions fail. test/base-path-server.test.ts boots a real WebServer under /codeman and checks the ingress strip, the base injection, the rebased redirects, the 404 envelope and a prefixed WebSocket upgrade. Co-Authored-By: Claude Fable 5.1 --- src/web/public/app.js | 4 +- test/base-path-server.test.ts | 78 +++++++++++++++++++++++++++++++++ test/setup.ts | 4 ++ test/test-env-isolation.test.ts | 1 + 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 test/base-path-server.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index cb8bae78..cd95f9da 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -80,7 +80,7 @@ try { const prev = localStorage.getItem('codeman-crash-diag'); if (prev) { console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev); - navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' })); + navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' })); } } catch {} _crashDiag.log('PAGE LOAD'); @@ -89,7 +89,7 @@ _crashDiag.log('PAGE LOAD'); function _crashDiagBeacon() { try { if (_crashDiag._entries.length > 0) { - navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId })); + navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId })); } } catch {} } diff --git a/test/base-path-server.test.ts b/test/base-path-server.test.ts new file mode 100644 index 00000000..38231d98 --- /dev/null +++ b/test/base-path-server.test.ts @@ -0,0 +1,78 @@ +/** + * @fileoverview Server wiring for the reverse-proxy base path (#381): prefixed and + * unprefixed forms both route, the shell gets the base injected, root-absolute + * redirects are rebased without double-prefixing, and a WebSocket upgrade under the + * prefix reaches the terminal route. The pure helpers are covered by + * test/base-path.test.ts; this boots a real WebServer in test mode. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { WebServer } from '../src/web/server.js'; + +const PORT = 3197; + +describe('reverse-proxy base path: server wiring', () => { + let server: WebServer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let app: any; + beforeAll(async () => { + server = new WebServer(PORT, false, true, '127.0.0.1', undefined, false, '/codeman'); + await server.start(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app = (server as any).app; + }); + afterAll(async () => { + await server.stop(); + }); + + it('routes prefixed, unprefixed and /api/v1 forms', async () => { + for (const url of ['/codeman/api/status', '/api/status', '/codeman/api/v1/status']) { + const r = await app.inject({ method: 'GET', url }); + expect(r.statusCode, url).toBe(200); + expect(JSON.parse(r.body).success).toBe(true); + } + }); + + it('serves the shell with the base injected at /codeman, /codeman/ and /codeman/session/:id', async () => { + for (const url of ['/codeman', '/codeman/', '/codeman/session/abc']) { + const r = await app.inject({ method: 'GET', url }); + expect(r.statusCode, url).toBe(200); + expect(r.body).toContain(''); + expect(r.body).toContain('window.__CODEMAN_BASE__="/codeman"'); + } + }); + + it('serves sw.js under the prefix', async () => { + const r = await app.inject({ method: 'GET', url: '/codeman/sw.js' }); + expect(r.statusCode).toBe(200); + expect(r.headers['content-type']).toContain('javascript'); + }); + + it('rebases root-absolute redirects and never double-prefixes', async () => { + const qr = await app.inject({ method: 'GET', url: '/codeman/q/abcdef' }); + expect(qr.statusCode).toBe(302); + expect(qr.headers.location).toBe('/codeman/'); + const wv = await app.inject({ method: 'GET', url: '/codeman/webview/somecap' }); + expect(wv.statusCode).toBe(302); + expect(wv.headers.location).toBe('/codeman/webview/somecap/'); + }); + + it('unknown prefixed API path still gets the 404 envelope', async () => { + const r = await app.inject({ method: 'GET', url: '/codeman/api/nope' }); + expect(r.statusCode).toBe(404); + expect(JSON.parse(r.body).success).toBe(false); + }); + + it('routes a prefixed WebSocket upgrade to the terminal route', async () => { + const { WebSocket } = await import('ws'); + const close = (path: string) => + new Promise<{ code: number; reason: string }>((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, { headers: { origin: `http://127.0.0.1:${PORT}` } }); + ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() })); + ws.on('error', (e) => resolve({ code: -1, reason: String(e) })); + }); + const prefixed = await close('/codeman/ws/sessions/nosuch/terminal'); + const bare = await close('/ws/sessions/nosuch/terminal'); + expect(prefixed).toEqual({ code: 4004, reason: 'Session not found' }); + expect(prefixed).toEqual(bare); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index 2bbb742d..aa2a5945 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -41,6 +41,10 @@ delete process.env.CODEMAN_USERNAME; // __codemanGestureAvailable flag), breaking byte-identity assertions // (test/server-index-title.test.ts) when the shell exports CODEMAN_GESTURE=1. delete process.env.CODEMAN_GESTURE; +// CODEMAN_BASE_URL (#381) is read by the WebServer constructor as a fallback; an +// operator who exports it (exactly who the feature is for) would otherwise see the +// root-install byte-identity assertions fail. +delete process.env.CODEMAN_BASE_URL; // Instance selection is PROCESS-WIDE and is what `src/config/instance.ts` derives // both the data dir and the tmux socket from, so a shell that exports any of these diff --git a/test/test-env-isolation.test.ts b/test/test-env-isolation.test.ts index 07bc2e8b..a0a56d8e 100644 --- a/test/test-env-isolation.test.ts +++ b/test/test-env-isolation.test.ts @@ -30,6 +30,7 @@ const STRIPPED_ENV_VARS: Array<[name: string, why: string]> = [ ['CODEMAN_PASSWORD', 'auth from a running instance would make protected routes behave differently'], ['CODEMAN_USERNAME', 'same, and it changes which owner scoping resolves to'], ['CODEMAN_GESTURE', 'flips renderIndexHtml output and breaks byte-identity assertions'], + ['CODEMAN_BASE_URL', 'mounts the server under a sub-path and breaks the root-install byte-identity assertions'], ['CODEMAN_INSTANCE', 'moves the data dir to ~/.codeman- and the tmux socket to codeman-'], ['CODEMAN_DATA_DIR', 'ABSOLUTE override: bypasses the temp HOME and points the suite at a real data dir'], ['CODEMAN_TMUX_SOCKET', 'renames the socket resolveTmuxSocketName() returns'],