mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Two required fixes from Ark0N's review of #458: 1. test/frontend-cli-no-id-branching.test.ts's ALLOWED_BRANCHES keyed on <file>::<line>::<expression>. A single inserted line anywhere above an entry shifted every subsequent line number, so all 21 entries went stale simultaneously and the same 21 branches were reported as "new" — on a file six other open PRs also touch. Dropped the line number from the key (<file>::<expression>, matching the backend guard's own design), which collapses 21 line-keyed entries to 11 or-collapse where the same expression recurs at multiple call sites in the same file. 2. test/run-mode-ui.test.ts's terminal-ownership guard scanned method bodies via `^ {2}async (run[A-Za-z]*)\(\) \{$`, which matched the 8 one-line run<Mode>() wrappers PR B2 introduced but not _runCliMode(mode), where the real logic (and the actual risk the guard exists to catch) now lives. Fixed the regex to `^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$` and added _runCliMode to the sanity list. Same-class fix in test/opencode-resize.test.ts, which had the identical blind spot via runOpenCode.toString(). Both reproduced live before fixing (inserted the same comment line; added this.terminal.clear() to _runCliMode) to confirm the bug, then confirmed the fix catches it and the suite stays green otherwise. Also resolves Open Question 2 by dropping window.__codemanCliCatalog entirely: nothing consumed it, and a registry DECLARED_FOR_LATER field costs nothing until read while an unconsumed script tag on every page render is a different trade. Reverts Phase 1 cleanly — server.ts's injection, shortBadge back in types.ts's DECLARED_FOR_LATER list and the pinned guard test, and the three associated render-index-html.test.ts / server-index-title.test.ts assertions. Full gate: 405 files / 7717 tests / 0 failures (net unchanged), typecheck/ lint/format clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
143 lines
7.2 KiB
TypeScript
143 lines
7.2 KiB
TypeScript
/**
|
|
* Verifies that WebServer templates the `<title>` tag in the served
|
|
* index.html with the hostname-aware `codeman:<host>` window title
|
|
* (feature #82). The title must:
|
|
* - default to `codeman:<os.hostname()>` when no override is supplied
|
|
* - honor a custom `titleHostname` passed via the constructor (CLI flag
|
|
* `--title-hostname <host>` plumbs through to here)
|
|
* - HTML-escape the hostname so a value like `<script>foo</script>`
|
|
* can't break out of the title tag
|
|
* - replace the bare `<title>Codeman</title>` literal exactly once
|
|
* - leave the rest of the document byte-for-byte identical to the
|
|
* template on disk
|
|
*
|
|
* Strategy: construct WebServer with port 0 / testMode (no network
|
|
* activity until start()) and call the private `renderIndexHtml()`
|
|
* method directly. The Fastify `/` and `/index.html` route handlers
|
|
* are one-liners that call exactly this method (server.ts:539-544),
|
|
* so testing the render function covers both endpoints without
|
|
* needing to listen on a port.
|
|
*
|
|
* Port: N/A (no server start)
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { readFileSync, mkdtempSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { hostname as osHostname, tmpdir } from 'node:os';
|
|
import { WebServer } from '../src/web/server.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const indexHtmlPath = join(__dirname, '..', 'src', 'web', 'public', 'index.html');
|
|
const rawTemplate = readFileSync(indexHtmlPath, 'utf-8');
|
|
|
|
async function render(host?: string): Promise<string> {
|
|
// 4th arg is the bind host; the title hostname is the 5th arg.
|
|
const server = new WebServer(0, false, true, '127.0.0.1', host);
|
|
// renderIndexHtml is async (it reads settings.json for the gesture bundle).
|
|
return (server as unknown as { renderIndexHtml: () => Promise<string> }).renderIndexHtml();
|
|
}
|
|
|
|
describe('WebServer index.html <title> templating (#82)', () => {
|
|
// renderIndexHtml reads the ambient settings.json (for the gesture bundle and
|
|
// the header-toggle marker-class strips, e.g. showPlanUsageLimits /
|
|
// showMultiMonitorButton). Point it at an empty data dir so this test is
|
|
// deterministic regardless of the developer's real settings — otherwise an
|
|
// enabled toggle would strip a marker class and break the byte-identical
|
|
// assertion below. getDataDir() reads CODEMAN_DATA_DIR fresh per call.
|
|
const _prevDataDir = process.env.CODEMAN_DATA_DIR;
|
|
beforeAll(() => {
|
|
process.env.CODEMAN_DATA_DIR = mkdtempSync(join(tmpdir(), 'codeman-title-test-'));
|
|
});
|
|
afterAll(() => {
|
|
if (_prevDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
|
else process.env.CODEMAN_DATA_DIR = _prevDataDir;
|
|
});
|
|
|
|
it('substitutes the bare <title>Codeman</title> with codeman:<host>', async () => {
|
|
const html = await render('laptop');
|
|
expect(html).toContain('<title>codeman:laptop</title>');
|
|
expect(html).not.toContain('<title>Codeman</title>');
|
|
});
|
|
|
|
it('defaults to os.hostname() when no titleHostname is supplied', async () => {
|
|
const html = await render();
|
|
const expected = `<title>codeman:${osHostname()}</title>`;
|
|
expect(html).toContain(expected);
|
|
});
|
|
|
|
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', async () => {
|
|
// CLI normally guarantees a non-empty string, but the constructor's
|
|
// `titleHostname || getHostname()` guard makes empty fall through —
|
|
// pin that behavior so a future refactor doesn't accidentally ship
|
|
// a `<title>codeman:</title>` to users.
|
|
const html = await render('');
|
|
expect(html).toMatch(/<title>codeman:.+<\/title>/);
|
|
expect(html).not.toContain('<title>codeman:</title>');
|
|
});
|
|
|
|
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', async () => {
|
|
const html = await render('<script>alert(1)</script>');
|
|
expect(html).toContain('<title>codeman:<script>alert(1)</script></title>');
|
|
// The raw closing </title> from the injected payload must NOT appear
|
|
// outside the actual title element — escape-then-substitute prevents
|
|
// an attacker-controlled hostname from terminating the tag early.
|
|
expect(html).not.toContain('<script>alert(1)</script></title>');
|
|
});
|
|
|
|
it('escapes an ampersand without double-encoding existing entities', async () => {
|
|
// The escaper replaces & first, then < and >. A hostname that already
|
|
// contains a literal `&` should render as `&` once, not `&amp;`.
|
|
const html = await render('a&b');
|
|
expect(html).toContain('<title>codeman:a&b</title>');
|
|
expect(html).not.toContain('&amp;');
|
|
});
|
|
|
|
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
|
|
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
|
|
// .js/.css refs, and injects the CLI-availability flags plus the custom-model
|
|
// Run-menu picker's CLI list before </head>; strip all so the title remains
|
|
// the only other change.
|
|
//
|
|
// The flag strips are what keep this test environment-independent. The
|
|
// CLI-availability one used to pass here by luck: that script was injected
|
|
// only where a CLI resolved, so the assertion held on a machine with none
|
|
// installed and would have failed on a developer's box that had them. The
|
|
// custom-model list is injected unconditionally (a plain array, possibly
|
|
// empty), so it needs stripping on every machine, not just where non-empty.
|
|
const html = (await render('laptop'))
|
|
.replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
|
|
.replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '')
|
|
.replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '');
|
|
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
|
|
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
|
|
expect(html.startsWith(beforeTitle)).toBe(true);
|
|
expect(html.endsWith(afterTitle)).toBe(true);
|
|
// Sanity check: length differs only by the title swap.
|
|
const expectedDelta = `<title>codeman:laptop</title>`.length - `<title>Codeman</title>`.length;
|
|
expect(html.length - rawTemplate.length).toBe(expectedDelta);
|
|
});
|
|
|
|
it('replaces the <title> placeholder exactly once', async () => {
|
|
const html = await render('laptop');
|
|
// Defense against a future regression where the template gains a
|
|
// second `<title>Codeman</title>` (e.g. inside a <noscript>) and only
|
|
// the first gets templated — would leave a stale literal in the served
|
|
// HTML that overrides the correct one in some renderers.
|
|
const occurrencesOfNew = html.split('<title>codeman:laptop</title>').length - 1;
|
|
const occurrencesOfOld = html.split('<title>Codeman</title>').length - 1;
|
|
expect(occurrencesOfNew).toBe(1);
|
|
expect(occurrencesOfOld).toBe(0);
|
|
});
|
|
|
|
it('two WebServer instances on different hostnames render distinct titles', async () => {
|
|
const htmlA = await render('host-a');
|
|
const htmlB = await render('host-b');
|
|
expect(htmlA).toContain('<title>codeman:host-a</title>');
|
|
expect(htmlB).toContain('<title>codeman:host-b</title>');
|
|
expect(htmlA).not.toContain('host-b');
|
|
expect(htmlB).not.toContain('host-a');
|
|
});
|
|
});
|