Files
Codeman/test/file-link-click.test.ts
T
aakhterandClaude Opus 4.6 b7e94e7068 feat: hostname-aware window title (#82)
Set the browser tab title to codeman:${hostname} instead of the bare
"Codeman" literal. Useful for users running multiple Codeman instances
across hosts (laptop, dev box, NAS) — the OS hostname disambiguates
which tab points at which backend.

Implementation:

- src/cli.ts: new --title-hostname <hostname> flag overrides the
  detected hostname (handy for cosmetic naming or when os.hostname()
  returns something noisy).
- src/web/server.ts: WebServer now accepts an optional titleHostname
  constructor arg (defaults to os.hostname()), composes
  windowTitle = codeman:${titleHostname}, and serves / and
  /index.html by templating that title into the cached index.html
  template (with HTML escaping of the title text).
- src/web/public/notification-manager.js: title-flash logic now uses
  this.originalTitle instead of the hardcoded "Codeman" literal, so
  the tab flash respects the per-host title.
- scripts/browser-comparison.mjs + test/file-link-click.test.ts:
  expectations updated from === "Codeman" to a startsWith("codeman:")
  predicate so they pass regardless of host.

The new index.html templating is intentionally narrow — it only
substitutes the <title> tag and continues to serve everything else
from the static template. No JS-side title injection, so it works
without JavaScript and shows the correct title from the very first
paint.

Note: test/file-link-click.test.ts shows ~49 prettier-reformat lines
that are not part of the feature — they are pre-existing prettier
debt that the pre-commit hook required me to clear. The single
behavioral change is the browserAvailable line.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:11:33 +02:00

396 lines
13 KiB
TypeScript

/**
* File Link Click Tests for Codeman Web UI
*
* Tests that file paths displayed in terminal output are clickable
* and open the log viewer window correctly.
*
* Port allocation: 3154 (see CLAUDE.md test port table)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execSync } from 'node:child_process';
import { WebServer } from '../src/web/server.js';
import { writeFileSync, mkdirSync, rmSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const TEST_PORT = 3154;
const baseUrl = `http://localhost:${TEST_PORT}`;
const BROWSER_TIMEOUT = 30000;
// Helper to run agent-browser commands
function browser(command: string): string {
try {
return execSync(`npx agent-browser ${command}`, {
timeout: BROWSER_TIMEOUT,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (error: any) {
if (error.stderr) {
throw new Error(`agent-browser failed: ${error.stderr}`);
}
throw error;
}
}
function browserJson<T = any>(command: string): T {
const result = browser(`${command} --json`);
const parsed = JSON.parse(result);
if (!parsed.success) {
throw new Error(`agent-browser command failed: ${parsed.error || 'unknown error'}`);
}
return parsed.data;
}
async function waitForElement(selector: string, timeout = 10000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const count = browserJson<{ count: number }>(`get count "${selector}"`);
if (count.count > 0) return true;
} catch {
/* retry */
}
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
function getText(selector: string): string {
try {
return browserJson<{ text: string }>(`get text "${selector}"`).text || '';
} catch {
return '';
}
}
function isVisible(selector: string): boolean {
try {
return browserJson<{ visible: boolean }>(`is visible "${selector}"`).visible;
} catch {
return false;
}
}
function closeBrowser() {
try {
browser('close');
} catch {
/* ignore */
}
}
describe('File Link Click Tests', () => {
let server: WebServer;
let createdSessions: string[] = [];
let browserAvailable = false;
let testLogFile: string;
let testDir: string;
beforeAll(async () => {
closeBrowser();
// Create test directory and log file
testDir = join(tmpdir(), `codeman-link-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
testLogFile = join(testDir, 'test.log');
writeFileSync(testLogFile, '=== Test Log Started ===\n');
server = new WebServer(TEST_PORT, false, true);
await server.start();
await new Promise((r) => setTimeout(r, 1000));
// Test if browser is available
try {
browser(`open ${baseUrl}`);
await new Promise((r) => setTimeout(r, 2000));
const title = browserJson<{ title: string }>('get title');
browserAvailable = title.title.startsWith('codeman:');
} catch (e) {
console.warn('Browser not available, skipping browser tests:', (e as Error).message);
browserAvailable = false;
}
}, 60000);
afterAll(async () => {
closeBrowser();
for (const sessionId of createdSessions) {
try {
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
} catch {
/* ignore */
}
}
await server.stop();
// Cleanup test directory
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
/* ignore */
}
}, 60000);
it('should create shell session and display terminal output', async () => {
if (!browserAvailable) {
console.log('Skipping: browser not available');
return;
}
// Create a shell session via API
const response = await fetch(`${baseUrl}/api/quick-start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName: 'link-test', mode: 'shell' }),
});
const data = await response.json();
expect(data.success).toBe(true);
createdSessions.push(data.session.id);
// Wait for session to appear in UI
await new Promise((r) => setTimeout(r, 2000));
// Check that terminal is visible
const terminalExists = await waitForElement('.xterm-screen', 5000);
expect(terminalExists).toBe(true);
}, 60000);
it('should make file paths clickable in terminal', async () => {
if (!browserAvailable || createdSessions.length === 0) {
console.log('Skipping: browser not available or no session');
return;
}
const sessionId = createdSessions[0];
// Send a command that outputs a file path
// Using 'echo' with the test file path followed by 'tail -f' pattern
const command = `echo "Monitoring file: ${testLogFile}" && echo "tail -f ${testLogFile}"`;
await fetch(`${baseUrl}/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: command + '\r' }),
});
await new Promise((r) => setTimeout(r, 2000));
// Check if xterm contains the file path
// The xterm link provider should detect "tail -f /path/to/file" pattern
const terminalText = getText('.xterm-screen');
console.log('Terminal text:', terminalText.substring(0, 500));
// File path should be visible in terminal
expect(terminalText).toContain(testLogFile);
}, 60000);
it('should open log viewer when clicking file path', async () => {
if (!browserAvailable || createdSessions.length === 0) {
console.log('Skipping: browser not available or no session');
return;
}
// Add some content to the log file for streaming
for (let i = 1; i <= 5; i++) {
appendFileSync(testLogFile, `Log entry ${i}\n`);
}
// Try to click on a link in the terminal
// The link provider registers on text matching "tail -f /path" patterns
// We need to find and click the link
// First, let's check if there are any registered links
// xterm.js links have class 'xterm-link' when hovered
// Try clicking on the terminal area where the file path should be
// The file path should be clickable based on the registerFilePathLinkProvider
// Get terminal dimensions to calculate where to click
try {
// Click somewhere in the terminal where the tail -f line should be
// This is approximate - the link detection works on hover
browser('click ".xterm-screen"');
await new Promise((r) => setTimeout(r, 500));
} catch (e) {
console.log('Click failed:', e);
}
// Check if log viewer window appeared
// The log viewer has class .log-viewer-window
const logViewerExists = await waitForElement('.log-viewer-window', 3000);
// Note: This may fail because clicking the terminal doesn't guarantee
// clicking on the exact link. We need a more precise test.
console.log('Log viewer exists:', logViewerExists);
// For now, just verify the infrastructure is in place
// Real testing would need coordinate-based clicking on the link
}, 60000);
it('should detect file link patterns in terminal output', async () => {
if (!browserAvailable || createdSessions.length === 0) {
console.log('Skipping: browser not available or no session');
return;
}
const sessionId = createdSessions[0];
// Test various patterns that should be detected as links
const patterns = [
`cat ${testLogFile}`,
`head -n 10 ${testLogFile}`,
`less ${testLogFile}`,
`grep "test" ${testLogFile}`,
];
for (const pattern of patterns) {
await fetch(`${baseUrl}/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: `echo "${pattern}"\r` }),
});
await new Promise((r) => setTimeout(r, 500));
}
await new Promise((r) => setTimeout(r, 1000));
// Verify patterns appear in terminal
const terminalText = getText('.xterm-screen');
for (const pattern of patterns) {
expect(terminalText).toContain(testLogFile);
}
}, 60000);
it('should match file paths with various command patterns', () => {
// Unit test for pattern matching logic - runs without browser
// Pattern matches: tail -f /path, grep pattern /path, cat -n /path
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
const extPattern =
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
// Test cmdPattern
const cmdTestCases = [
{ line: 'tail -f /var/log/syslog', expected: '/var/log/syslog' },
{ line: 'cat -n /etc/passwd', expected: '/etc/passwd' },
{ line: 'head -100 /home/user/file.txt', expected: '/home/user/file.txt' },
{ line: 'less /tmp/debug.log', expected: '/tmp/debug.log' },
{ line: 'grep error /var/log/app.log', expected: '/var/log/app.log' },
{ line: 'vim /opt/script.sh', expected: '/opt/script.sh' },
];
for (const tc of cmdTestCases) {
cmdPattern.lastIndex = 0;
const match = cmdPattern.exec(tc.line);
expect(match, `cmdPattern should match: ${tc.line}`).not.toBeNull();
expect(match![2]).toBe(tc.expected);
}
// Test extPattern
const extTestCases = [
{ line: 'Opening /tmp/test.log for reading', expected: '/tmp/test.log' },
{ line: 'File saved to /home/user/data.json', expected: '/home/user/data.json' },
{ line: 'Reading /var/config.yaml', expected: '/var/config.yaml' },
{ line: 'Script at /opt/tools/run.sh', expected: '/opt/tools/run.sh' },
];
for (const tc of extTestCases) {
extPattern.lastIndex = 0;
const match = extPattern.exec(tc.line);
expect(match, `extPattern should match: ${tc.line}`).not.toBeNull();
expect(match![1]).toBe(tc.expected);
}
// Test bashPattern
const bashTestCases = [
{ line: 'Bash(tail -f /var/log/app.log)', expected: '/var/log/app.log' },
{ line: 'Bash(cat /tmp/output.txt)', expected: '/tmp/output.txt' },
];
for (const tc of bashTestCases) {
bashPattern.lastIndex = 0;
const match = bashPattern.exec(tc.line);
expect(match, `bashPattern should match: ${tc.line}`).not.toBeNull();
expect(match![1]).toBe(tc.expected);
}
});
it('should NOT match invalid or unsafe paths', () => {
const extPattern =
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
const invalidCases = [
'This is just text without paths',
'./relative/path.log', // relative path
'C:\\Windows\\path.log', // windows path
'/usr/bin/something.log', // /usr not in allowed prefixes
];
for (const line of invalidCases) {
extPattern.lastIndex = 0;
const match = extPattern.exec(line);
expect(match, `extPattern should NOT match: ${line}`).toBeNull();
}
});
it('should stream file content to log viewer', async () => {
// Test the tail-file API endpoint directly
// First, create a session if we don't have one from browser tests
let sessionId = createdSessions[0];
if (!sessionId) {
const response = await fetch(`${baseUrl}/api/quick-start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName: 'stream-test', mode: 'shell' }),
});
const data = await response.json();
expect(data.success).toBe(true);
sessionId = data.sessionId; // quick-start returns sessionId directly
createdSessions.push(sessionId);
}
// Create an EventSource to test SSE streaming
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
let streamError: Error | null = null;
try {
const response = await fetch(
`${baseUrl}/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(testLogFile)}&lines=10`,
{ signal: controller.signal }
);
expect(response.ok).toBe(true);
expect(response.headers.get('content-type')).toContain('text/event-stream');
// Read a bit of the stream
const reader = response.body?.getReader();
if (reader) {
const { value } = await reader.read();
const text = new TextDecoder().decode(value);
console.log('SSE stream data:', text.substring(0, 200));
// SSE format uses "data: " prefix for messages
expect(text).toContain('data:');
// Should contain connection info or file content
expect(text).toContain('"type"');
reader.releaseLock();
}
} catch (e: any) {
if (e.name !== 'AbortError') {
streamError = e;
}
} finally {
clearTimeout(timeout);
controller.abort();
}
// Re-throw any non-abort errors after cleanup
if (streamError) {
throw streamError;
}
}, 60000);
});