Files
Codeman/test/routes/system-span-displays.test.ts
T
arkonandClaude Opus 4.8 cf6fabc070 fix(web): address self-review findings on #103 (master-safe defaults + hardening)
Make the branch genuinely master-mergeable and fix several review findings:

- Defaults are now prod-safe: CODEMAN_INSTANCE defaults to '' (→ ~/.codeman,
  -L codeman) and the web port back to 3000, so an existing install upgrades
  cleanly. Port also honors a new CODEMAN_PORT env var. Run the beta isolated
  alongside prod with scripts/run-beta.sh (CODEMAN_INSTANCE=beta + PORT 5000).
- .gitignore: anchor the root `public` symlink rule to `/public` (a bare
  `public` also swallowed src/web/public, silently un-staging new web assets);
  ignore the gesture wasm/model binaries explicitly instead.
- span-displays: add a macOS-only guard (400 elsewhere instead of spawning a
  bash that fails invisibly); extract resolveSpanUrl() for unit testing.
- server.ts: memoize asset-version stat() calls (~1s TTL) so each index render
  doesn't re-stat every script/link tag.
- styles.css: hide the multi-monitor button in solo (detached) windows.
- app.js: require two consecutive unanswered roll-calls before redocking, so a
  timer-throttled background popup isn't wrongly un-marked.
- index.html: make the "skip to terminal" link base-href-safe (onclick scroll)
  so it doesn't navigate to the dashboard from a /session/:id window.
- Tests: test/config/instance.test.ts, test/routes/system-span-displays.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:41:46 +02:00

77 lines
3.1 KiB
TypeScript

/**
* POST /api/system/span-displays (multi-monitor launcher) + resolveSpanUrl.
*
* The route shells out to scripts/span-codeman.sh, so we mock child_process.spawn
* to avoid actually opening a browser (and to assert the sanitized URL passed to
* it). process.platform is overridden per-case so the macOS-only guard is tested
* deterministically regardless of where the suite runs.
*
* Port: N/A (app.inject).
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
const spawnMock = vi.hoisted(() => vi.fn(() => ({ on: vi.fn(), unref: vi.fn() })));
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, spawn: spawnMock };
});
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes, resolveSpanUrl } from '../../src/web/routes/system-routes.js';
const REAL_PLATFORM = process.platform;
function setPlatform(p: NodeJS.Platform) {
Object.defineProperty(process, 'platform', { value: p, configurable: true });
}
afterEach(() => {
setPlatform(REAL_PLATFORM);
spawnMock.mockClear();
});
describe('resolveSpanUrl', () => {
it('takes a digits-only port from the Host header, pinned to localhost', () => {
expect(resolveSpanUrl('localhost:5000')).toBe('http://localhost:5000');
// Hostname is discarded — always localhost (same machine).
expect(resolveSpanUrl('attacker.example.com:3000')).toBe('http://localhost:3000');
});
it('falls back to the default port for missing / non-numeric ports', () => {
expect(resolveSpanUrl(undefined)).toBe('http://localhost:3000');
expect(resolveSpanUrl('localhost')).toBe('http://localhost:3000');
expect(resolveSpanUrl('localhost:99;rm -rf /')).toBe('http://localhost:3000');
expect(resolveSpanUrl('localhost:80abc')).toBe('http://localhost:3000');
expect(resolveSpanUrl('x', '5000')).toBe('http://localhost:5000');
});
});
describe('POST /api/system/span-displays', () => {
it('returns 400 (macOS-only) on non-darwin and never spawns', async () => {
setPlatform('linux');
const { app } = await createRouteTestHarness(registerSystemRoutes);
const res = await app.inject({ method: 'POST', url: '/api/system/span-displays' });
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/macOS/i);
expect(spawnMock).not.toHaveBeenCalled();
await app.close();
});
it('spawns the launcher with the sanitized localhost URL on darwin', async () => {
setPlatform('darwin');
const { app } = await createRouteTestHarness(registerSystemRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/system/span-displays',
headers: { host: 'localhost:5000' },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ success: true, url: 'http://localhost:5000' });
expect(spawnMock).toHaveBeenCalledTimes(1);
const [cmd, args] = spawnMock.mock.calls[0] as [string, string[]];
expect(cmd).toBe('bash');
expect(args[0]).toMatch(/span-codeman\.sh$/);
expect(args[1]).toBe('http://localhost:5000');
await app.close();
});
});