Files
web-terminal/test/link-paths.test.ts
Yaojia Wang debf47d99e feat(links): clickable URLs + file:line paths in the terminal (W1)
Terminal output URLs and file paths (e.g. src/app.ts:42) are now tappable — the
walk-away device is a phone, so this removes soft-keyboard copy gymnastics.

- public/link-paths.ts (new, pure): findPathMatches — links tokens with a '/',
  a :line suffix, or a code extension (src/app.ts:42, /abs/main.rs:10, README.md)
  while rejecting example.com / v1.2.3 / 12:34 / URL tails.
- terminal-session.ts: hardened URL handler (scheme allowlist http/https/mailto +
  window.open noopener,noreferrer, blocks javascript:/data:/file:) replacing the
  addon default; a path link provider → openPath() POSTs {file,line} to
  /open-in-editor (resolves rel paths against the OSC-7 cwd; in-flight guard).
- src/http/editor.ts: additive openFileInEditor + isGotoEditor (--goto file:line
  only for goto-capable editors; execFile, no shell). openInEditor untouched.
- src/server.ts: /open-in-editor branches on body.file vs body.path (same CSRF guard).

Deviation from the "no server change" brief: an additive openFileInEditor was
required because the existing route only opens directories, not file:line (Option A
in docs/plans/w1-clickable-links.md). Backward-compatible; src/types.ts untouched.

Verified independently: typecheck + build:web clean, 1661 tests pass (link-paths
95%+ cov). Note: xterm buffer-row indexing (getLine(n-1)/range.y=n) is asserted in
jsdom but merits a real-browser smoke check.
2026-07-12 19:44:40 +02:00

124 lines
4.3 KiB
TypeScript

/**
* test/link-paths.test.ts — pure findPathMatches matcher (W1).
*
* Node environment (no DOM): the matcher is deliberately DOM-free so it can be
* unit-tested directly and reused by later diff/preview views.
*/
import { describe, it, expect } from 'vitest'
import { findPathMatches, CODE_EXT } from '../public/link-paths.js'
describe('findPathMatches — file:line paths', () => {
it('matches a src/app.ts:42 path with correct 1-based range', () => {
const line = 'see src/app.ts:42 for'
const matches = findPathMatches(line)
expect(matches).toHaveLength(1)
const m = matches[0]!
expect(m.text).toBe('src/app.ts:42')
expect(m.path).toBe('src/app.ts')
expect(m.line).toBe(42)
expect(m.column).toBeUndefined()
// 'see ' = 4 chars → startX = 5 (1-based), endX inclusive = 5 + 13 - 1 = 17.
expect(m.startX).toBe(5)
expect(m.endX).toBe(17)
expect(line.slice(m.startX - 1, m.endX)).toBe('src/app.ts:42')
})
it('parses :line:col into line + column', () => {
const matches = findPathMatches('./a/b.tsx:10:5')
expect(matches).toHaveLength(1)
const m = matches[0]!
expect(m.path).toBe('./a/b.tsx')
expect(m.line).toBe(10)
expect(m.column).toBe(5)
})
it('matches a bare filename with an allowlisted extension (README.md)', () => {
const matches = findPathMatches('open README.md please')
expect(matches).toHaveLength(1)
expect(matches[0]!.path).toBe('README.md')
expect(matches[0]!.line).toBeUndefined()
})
it('matches main.rs:10 via its :line suffix', () => {
const matches = findPathMatches('at main.rs:10')
expect(matches).toHaveLength(1)
expect(matches[0]!.path).toBe('main.rs')
expect(matches[0]!.line).toBe(10)
})
it('matches an absolute path with a leading slash', () => {
const matches = findPathMatches('Error at /home/u/app.ts:42 now')
expect(matches).toHaveLength(1)
const m = matches[0]!
expect(m.path).toBe('/home/u/app.ts')
expect(m.line).toBe(42)
})
})
describe('findPathMatches — non-paths are rejected', () => {
it('does not match a bare domain (example.com)', () => {
expect(findPathMatches('visit example.com today')).toHaveLength(0)
})
it('does not match a version string (v1.2.3)', () => {
expect(findPathMatches('version v1.2.3 released')).toHaveLength(0)
})
it('does not match a foo.bar token (unknown extension, no slash/line)', () => {
expect(findPathMatches('the foo.bar thing')).toHaveLength(0)
})
it('does not match a bare timestamp (12:34)', () => {
expect(findPathMatches('at 12:34 today')).toHaveLength(0)
})
})
describe('findPathMatches — URL guard', () => {
it('does not double-link the path tail of an http URL (WebLinksAddon owns it)', () => {
expect(findPathMatches('https://host/path.html')).toHaveLength(0)
})
it('does not match a path glued to a URL host (a.ts inside http://x/a.ts)', () => {
expect(findPathMatches('http://example.com/src/a.ts')).toHaveLength(0)
})
})
describe('findPathMatches — multiple + relative paths', () => {
it('returns two disjoint matches for two paths on one line', () => {
const matches = findPathMatches('edit src/a.ts:1 and lib/b.rs:2')
expect(matches).toHaveLength(2)
expect(matches[0]!.path).toBe('src/a.ts')
expect(matches[1]!.path).toBe('lib/b.rs')
// Ranges are disjoint and ordered.
expect(matches[0]!.endX).toBeLessThan(matches[1]!.startX)
})
it('keeps a ../ prefix in the path portion', () => {
const matches = findPathMatches('see ../lib/util.ts:7')
expect(matches).toHaveLength(1)
expect(matches[0]!.path).toBe('../lib/util.ts')
expect(matches[0]!.line).toBe(7)
})
it('matches a nested config.test.ts filename (multi-dot) with the final extension', () => {
const matches = findPathMatches('run test/config.test.ts')
expect(matches).toHaveLength(1)
expect(matches[0]!.path).toBe('test/config.test.ts')
})
it('returns [] for a line with no paths', () => {
expect(findPathMatches('just some plain words here')).toEqual([])
})
})
describe('CODE_EXT allowlist', () => {
it('contains common code extensions and not arbitrary ones', () => {
expect(CODE_EXT.has('ts')).toBe(true)
expect(CODE_EXT.has('md')).toBe(true)
expect(CODE_EXT.has('rs')).toBe(true)
expect(CODE_EXT.has('com')).toBe(false)
expect(CODE_EXT.has('bar')).toBe(false)
})
})