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.
This commit is contained in:
@@ -10,12 +10,47 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Captures the handler passed to WebLinksAddon so a test can assert the hardened
|
||||
// activation handler is wired (W1). vi.hoisted so the mock factory can see it.
|
||||
const webLinksState = vi.hoisted(
|
||||
() => ({ handler: undefined as ((e: MouseEvent, uri: string) => void) | undefined }),
|
||||
)
|
||||
|
||||
// A link registered via the (mocked) provider — the shape terminal-session builds.
|
||||
interface TestLink {
|
||||
text: string
|
||||
range: { start: { x: number; y: number }; end: { x: number; y: number } }
|
||||
activate: (event: MouseEvent, text: string) => void
|
||||
}
|
||||
interface TestLinkProvider {
|
||||
provideLinks: (bufferLineNumber: number, callback: (links: TestLink[] | undefined) => void) => void
|
||||
}
|
||||
|
||||
// ── Stub xterm + addons (heavy DOM/canvas deps we don't need here) ────────────
|
||||
class FakeTerminal {
|
||||
options: Record<string, unknown> = {}
|
||||
cols = 80
|
||||
rows = 24
|
||||
parser = { registerOscHandler: vi.fn() }
|
||||
/** Text the fake buffer returns for any getLine() (drives the link provider). */
|
||||
lineText = ''
|
||||
/** The link provider registered by TerminalSession (W1). */
|
||||
captured: TestLinkProvider | undefined = undefined
|
||||
/** OSC handlers registered by TerminalSession, keyed by code (7 = cwd). */
|
||||
oscHandlers: Record<number, (data: string) => boolean> = {}
|
||||
parser = {
|
||||
registerOscHandler: vi.fn((code: number, handler: (data: string) => boolean) => {
|
||||
this.oscHandlers[code] = handler
|
||||
}),
|
||||
}
|
||||
buffer = {
|
||||
active: {
|
||||
getLine: (_y: number) => ({ translateToString: (_trim?: boolean) => this.lineText }),
|
||||
},
|
||||
}
|
||||
registerLinkProvider = vi.fn((p: TestLinkProvider) => {
|
||||
this.captured = p
|
||||
return { dispose: vi.fn() }
|
||||
})
|
||||
loadAddon = vi.fn()
|
||||
open = vi.fn()
|
||||
write = vi.fn()
|
||||
@@ -45,7 +80,13 @@ vi.mock('@xterm/addon-search', () => ({
|
||||
clearDecorations = vi.fn()
|
||||
},
|
||||
}))
|
||||
vi.mock('@xterm/addon-web-links', () => ({ WebLinksAddon: class {} }))
|
||||
vi.mock('@xterm/addon-web-links', () => ({
|
||||
WebLinksAddon: class {
|
||||
constructor(handler?: (e: MouseEvent, uri: string) => void) {
|
||||
webLinksState.handler = handler
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// ── Mock WebSocket ────────────────────────────────────────────────────────────
|
||||
class MockWebSocket {
|
||||
@@ -109,7 +150,20 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const { TerminalSession } = await import('../public/terminal-session.js')
|
||||
const { TerminalSession, openWebLink } = await import('../public/terminal-session.js')
|
||||
|
||||
function term(s: unknown): FakeTerminal {
|
||||
return (s as unknown as { term: FakeTerminal }).term
|
||||
}
|
||||
|
||||
/** Drive the captured link provider for one buffer row, returning its links. */
|
||||
function provideLinks(t: FakeTerminal, bufferLineNumber = 1): TestLink[] | undefined {
|
||||
let result: TestLink[] | undefined
|
||||
t.captured!.provideLinks(bufferLineNumber, (links) => {
|
||||
result = links
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function setLocation(protocol: string, host: string): void {
|
||||
vi.stubGlobal('location', { protocol, host })
|
||||
@@ -611,3 +665,168 @@ describe('approve(mode?) — B4 permission-mode relay', () => {
|
||||
expect(s.pendingApproval).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── W1 clickable file paths (custom link provider) ────────────────────────────
|
||||
|
||||
describe('clickable file paths (W1)', () => {
|
||||
beforeEach(() => setLocation('http:', 'lan:3000'))
|
||||
|
||||
it('registers a link provider on construction', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
expect(term(s).registerLinkProvider).toHaveBeenCalledOnce()
|
||||
expect(term(s).captured).toBeTruthy()
|
||||
})
|
||||
|
||||
it('provideLinks yields one link matching findPathMatches (text + 1-based range)', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const t = term(s)
|
||||
t.lineText = 'see src/app.ts:42 for'
|
||||
const links = provideLinks(t, 1)!
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]!.text).toBe('src/app.ts:42')
|
||||
expect(links[0]!.range).toEqual({ start: { x: 5, y: 1 }, end: { x: 17, y: 1 } })
|
||||
})
|
||||
|
||||
it('provideLinks returns undefined for a line with no paths', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const t = term(s)
|
||||
t.lineText = 'just some plain words'
|
||||
expect(provideLinks(t, 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('provideLinks returns undefined when the buffer row is missing', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const t = term(s)
|
||||
t.buffer.active.getLine = () => undefined as never
|
||||
expect(provideLinks(t, 999)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('activate() POSTs {file,line} for an absolute path (no cwd needed)', () => {
|
||||
const fetchMock = vi.fn(async (_u: string, _o: RequestInit) => ({ ok: true }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const t = term(s)
|
||||
t.lineText = 'open /home/u/app.ts:42 now'
|
||||
const links = provideLinks(t, 1)!
|
||||
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
const call = fetchMock.mock.calls[0]!
|
||||
expect(call[0]).toBe('/open-in-editor')
|
||||
expect(call[1].method).toBe('POST')
|
||||
expect(JSON.parse(String(call[1].body))).toEqual({ file: '/home/u/app.ts', line: 42 })
|
||||
})
|
||||
|
||||
it('resolves a relative path against the OSC-7 cwd', () => {
|
||||
const fetchMock = vi.fn(async (_u: string, _o: RequestInit) => ({ ok: true }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const t = term(s)
|
||||
t.oscHandlers[7]!('file:///work/here') // set cwd via the real OSC-7 handler
|
||||
t.lineText = 'edit src/app.ts:42'
|
||||
const links = provideLinks(t, 1)!
|
||||
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]![1].body))).toEqual({
|
||||
file: '/work/here/src/app.ts',
|
||||
line: 42,
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses ../ against the cwd', () => {
|
||||
const fetchMock = vi.fn(async (_u: string, _o: RequestInit) => ({ ok: true }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const t = term(s)
|
||||
t.oscHandlers[7]!('file:///work/here')
|
||||
t.lineText = 'see ../lib/util.ts:7'
|
||||
const links = provideLinks(t, 1)!
|
||||
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]![1].body))).toEqual({
|
||||
file: '/work/lib/util.ts',
|
||||
line: 7,
|
||||
})
|
||||
})
|
||||
|
||||
it('relative path with null cwd → no fetch, writes a status line', () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const t = term(s)
|
||||
t.lineText = 'edit src/app.ts:42'
|
||||
const links = provideLinks(t, 1)!
|
||||
t.write.mockClear()
|
||||
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(t.write).toHaveBeenCalled()
|
||||
expect(String(t.write.mock.calls[0]![0])).toContain('working dir unknown')
|
||||
})
|
||||
|
||||
it('in-flight guard: two rapid activate calls → one fetch', () => {
|
||||
const fetchMock = vi.fn(() => new Promise<Response>(() => {})) // never resolves
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const t = term(s)
|
||||
t.lineText = 'open /a/b.ts:1'
|
||||
const links = provideLinks(t, 1)!
|
||||
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
||||
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('writes a status line when the server rejects the open (non-ok)', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: false, status: 404 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const t = term(s)
|
||||
t.lineText = 'open /home/u/app.ts:42'
|
||||
const links = provideLinks(t, 1)!
|
||||
t.write.mockClear()
|
||||
links[0]!.activate({} as MouseEvent, links[0]!.text)
|
||||
await new Promise((r) => setTimeout(r, 0)) // flush the fetch().then() chain
|
||||
expect(t.write).toHaveBeenCalled()
|
||||
expect(String(t.write.mock.calls[0]![0])).toContain('404')
|
||||
})
|
||||
|
||||
it('disposes the link provider on dispose()', () => {
|
||||
const s = new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
const disposable = term(s).registerLinkProvider.mock.results[0]!.value as {
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
s.dispose()
|
||||
expect(disposable.dispose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── W1 URL activation hardening ───────────────────────────────────────────────
|
||||
|
||||
describe('openWebLink (W1 URL hardening)', () => {
|
||||
it('passes a handler function to WebLinksAddon (not the default activation)', () => {
|
||||
new TerminalSession({ sessionId: null, onSessionId: vi.fn() })
|
||||
expect(typeof webLinksState.handler).toBe('function')
|
||||
})
|
||||
|
||||
it('opens http/https/mailto with noopener,noreferrer', () => {
|
||||
const openSpy = vi.fn()
|
||||
vi.stubGlobal('open', openSpy)
|
||||
openWebLink('https://example.com/x')
|
||||
expect(openSpy).toHaveBeenCalledWith('https://example.com/x', '_blank', 'noopener,noreferrer')
|
||||
openWebLink('http://lan:3000')
|
||||
openWebLink('mailto:a@b.com')
|
||||
expect(openSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('blocks javascript: / data: / file: URIs (no window.open)', () => {
|
||||
const openSpy = vi.fn()
|
||||
vi.stubGlobal('open', openSpy)
|
||||
openWebLink('javascript:alert(1)')
|
||||
openWebLink('data:text/html,<script>alert(1)</script>')
|
||||
openWebLink('file:///etc/passwd')
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores an unparseable URI without throwing', () => {
|
||||
const openSpy = vi.fn()
|
||||
vi.stubGlobal('open', openSpy)
|
||||
expect(() => openWebLink('not a url')).not.toThrow()
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user