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'],