Files
apes-sidebar/tests/terminal-links.spec.ts
T
多玩幻灵qwq 0314fd9b93 Merge pull request #419 from HuanLinOTO/feat/terminal-ctrl-click-url
feat(terminal): open URLs with Ctrl+Click (Cmd+Click on mac)
2026-08-27 21:44:32 +08:00

288 lines
12 KiB
TypeScript

// @vitest-environment jsdom
/**
* Terminal URL hyperlink helpers: the regex that scopes xterm's
* `registerLinkProvider`, the per-line scanner that turns matches into
* buffer ranges, the modifier gate that keeps a plain click a
* text-selection gesture, and the scheme guard that routes only http(s)
* to `window.open`. Pure-module unit tests — no xterm mount.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_URL_REGEX,
findTerminalUrlsInLine,
buildTerminalLinks,
shouldActivateTerminalLink,
openTerminalUrl,
} from '../src/client/terminal-links.ts'
/** Reset the regex's lastIndex between tests (it carries the `g` flag). */
function reset(): void {
TERMINAL_URL_REGEX.lastIndex = 0
}
afterEach(() => {
reset()
vi.restoreAllMocks()
})
/** Build a fake MouseEvent with only the modifier fields the gate reads. */
function fakeEvent(mods: { ctrlKey?: boolean; metaKey?: boolean } = {}): MouseEvent {
return {
ctrlKey: mods.ctrlKey ?? false,
metaKey: mods.metaKey ?? false,
} as MouseEvent
}
describe('TERMINAL_URL_REGEX', () => {
it('matches a bare http(s) URL', () => {
expect('https://example.com/path?x=1#frag'.match(TERMINAL_URL_REGEX))
.toEqual(['https://example.com/path?x=1#frag'])
expect('http://localhost:3000/'.match(TERMINAL_URL_REGEX))
.toEqual(['http://localhost:3000/'])
})
it('does not match a scheme that is not http(s)', () => {
expect('file:///etc/passwd'.match(TERMINAL_URL_REGEX)).toBeNull()
expect('mailto:foo@bar.com'.match(TERMINAL_URL_REGEX)).toBeNull()
expect('ssh://host/path'.match(TERMINAL_URL_REGEX)).toBeNull()
expect('javascript:alert(1)'.match(TERMINAL_URL_REGEX)).toBeNull()
})
it('does not match a scheme glued to a word character (word-boundary guard)', () => {
// Regression guard: a tool printing `nothttps://example.com` should not
// surface a clickable link — the word boundary rejects the embedded scheme.
expect('nothttps://example.com'.match(TERMINAL_URL_REGEX)).toBeNull()
expect('xhttps://example.com'.match(TERMINAL_URL_REGEX)).toBeNull()
})
it('matches when preceded by a non-word character (quote, bracket, whitespace)', () => {
expect('"https://example.com"'.match(TERMINAL_URL_REGEX))
.toEqual(['https://example.com'])
expect('<https://example.com>'.match(TERMINAL_URL_REGEX))
.toEqual(['https://example.com'])
expect('[see https://example.com for more]'.match(TERMINAL_URL_REGEX))
.toEqual(['https://example.com'])
})
it('excludes trailing wrapping punctuation from the matched URL', () => {
// Shells commonly wrap URLs in quotes / angle brackets / parentheses;
// the closing wrapper must not become part of the link target. The
// character class itself rejects quotes, angle brackets, square and
// curly braces; round parens are kept (Wikipedia URLs carry real
// parens) and trimmed by `findTerminalUrlsInLine` instead (covered
// in its own describe block below).
expect('"https://example.com"'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
expect('<https://example.com>'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
expect('[https://example.com]'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
expect('{https://example.com}'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
expect('`https://example.com`'.match(TERMINAL_URL_REGEX)?.[0]).toBe('https://example.com')
})
it('stops at whitespace (a URL never contains a space)', () => {
expect('run https://example.com now'.match(TERMINAL_URL_REGEX))
.toEqual(['https://example.com'])
})
it('finds multiple URLs on the same line (global flag)', () => {
const matches = 'see https://a.com and http://b.com/path'.match(TERMINAL_URL_REGEX)
expect(matches).toEqual(['https://a.com', 'http://b.com/path'])
})
it('keeps the path, query and fragment intact', () => {
const url = 'https://example.com/a/b/c?d=1&e=2#section-3'
expect(url.match(TERMINAL_URL_REGEX)?.[0]).toBe(url)
})
it('does not match plain text without a scheme', () => {
expect('example.com'.match(TERMINAL_URL_REGEX)).toBeNull()
expect('see the docs at example.com/path'.match(TERMINAL_URL_REGEX)).toBeNull()
})
})
describe('findTerminalUrlsInLine', () => {
it('returns an empty array when the line has no URL', () => {
expect(findTerminalUrlsInLine('just some text')).toEqual([])
expect(findTerminalUrlsInLine('')).toEqual([])
expect(findTerminalUrlsInLine('file:///etc/passwd')).toEqual([])
})
it('returns the URL text and its 0-based start offset', () => {
expect(findTerminalUrlsInLine('see https://example.com now'))
.toEqual([{ start: 4, text: 'https://example.com' }])
expect(findTerminalUrlsInLine('https://example.com'))
.toEqual([{ start: 0, text: 'https://example.com' }])
})
it('returns every URL on the line in source order', () => {
expect(findTerminalUrlsInLine('https://a.com and http://b.com/path')).toEqual([
{ start: 0, text: 'https://a.com' },
{ start: 18, text: 'http://b.com/path' },
])
})
it('trims wrapping punctuation from the matched text', () => {
expect(findTerminalUrlsInLine('"https://example.com"')).toEqual([
{ start: 1, text: 'https://example.com' },
])
expect(findTerminalUrlsInLine('<https://example.com>')).toEqual([
{ start: 1, text: 'https://example.com' },
])
})
it('strips trailing closing parens not balanced by an opening paren', () => {
// Shell-wrapped URLs: `(https://example.com)` — the regex captures the
// trailing `)` (round parens are kept so Wikipedia-style URLs work),
// and findTerminalUrlsInLine trims the unmatched excess.
expect(findTerminalUrlsInLine('(https://example.com)')).toEqual([
{ start: 1, text: 'https://example.com' },
])
expect(findTerminalUrlsInLine('see (https://example.com) here')).toEqual([
{ start: 5, text: 'https://example.com' },
])
})
it('keeps balanced parens inside Wikipedia-style URLs intact', () => {
const url = 'https://en.wikipedia.org/wiki/Python_(programming_language)'
expect(findTerminalUrlsInLine(`see ${url}`)).toEqual([{ start: 4, text: url }])
// Trailing balanced closer is part of the URL, not a wrapper.
expect(findTerminalUrlsInLine(url)).toEqual([{ start: 0, text: url }])
})
it('strips only the unmatched excess when the URL has both its own parens and a wrapper closer', () => {
// URL has 1 opening and 1 closing paren of its own, plus a wrapper
// closing paren: `https://en.wikipedia.org/wiki/Foo_(bar))`
// → opens=1, closers=2, excess=1 → strip one trailing `)`.
expect(findTerminalUrlsInLine('https://en.wikipedia.org/wiki/Foo_(bar))')).toEqual([
{ start: 0, text: 'https://en.wikipedia.org/wiki/Foo_(bar)' },
])
})
it('does not carry lastIndex state across calls (the g flag is module-shared)', () => {
// First call leaves lastIndex set; a second call on a different line
// must still scan from the start.
findTerminalUrlsInLine('https://a.com')
expect(findTerminalUrlsInLine('https://b.com')).toEqual([
{ start: 0, text: 'https://b.com' },
])
// And a third call after a partial match must also reset cleanly.
TERMINAL_URL_REGEX.exec('https://c.com') // leaves lastIndex past the match
expect(findTerminalUrlsInLine('https://d.com')).toEqual([
{ start: 0, text: 'https://d.com' },
])
})
})
describe('buildTerminalLinks', () => {
it('returns an empty array when the line has no URL', () => {
expect(buildTerminalLinks('plain text', 5)).toEqual([])
expect(buildTerminalLinks('', 0)).toEqual([])
})
it('builds a descriptor with xterm 1-based x coords and inclusive end', () => {
// Line: "see https://example.com now"
// 0123456789...
// URL starts at 0-based index 4, length 19 ('https://example.com').
// xterm x is 1-based: start cell = 4+1 = 5, last cell = 4+19 = 23.
expect(buildTerminalLinks('see https://example.com now', 7)).toEqual([
{
range: { start: { x: 5, y: 7 }, end: { x: 23, y: 7 } },
text: 'https://example.com',
},
])
})
it('places a URL at the start of the line at x=1', () => {
expect(buildTerminalLinks('https://example.com', 3)).toEqual([
{
range: { start: { x: 1, y: 3 }, end: { x: 19, y: 3 } },
text: 'https://example.com',
},
])
})
it('uses the buffer line number passed in as the range y', () => {
const links = buildTerminalLinks('https://example.com', 42)
expect(links[0]?.range.start.y).toBe(42)
expect(links[0]?.range.end.y).toBe(42)
})
it('builds one descriptor per URL on a multi-URL line, in source order', () => {
const links = buildTerminalLinks('https://a.com http://b.com', 1)
expect(links).toHaveLength(2)
expect(links[0]?.text).toBe('https://a.com')
expect(links[1]?.text).toBe('http://b.com')
// Non-overlapping ranges, in order.
expect(links[0]!.range.end.x).toBeLessThan(links[1]!.range.start.x)
})
})
describe('shouldActivateTerminalLink', () => {
it('returns false for a plain click (no modifier)', () => {
expect(shouldActivateTerminalLink(fakeEvent())).toBe(false)
})
it('returns true when Ctrl is held (Win/Linux convention)', () => {
expect(shouldActivateTerminalLink(fakeEvent({ ctrlKey: true }))).toBe(true)
})
it('returns true when Cmd is held (mac convention)', () => {
expect(shouldActivateTerminalLink(fakeEvent({ metaKey: true }))).toBe(true)
})
it('returns true when both modifiers are held (defensive — some remotes send both)', () => {
expect(shouldActivateTerminalLink(fakeEvent({ ctrlKey: true, metaKey: true }))).toBe(true)
})
it('returns false when only Shift / Alt are held (those are not open modifiers)', () => {
expect(shouldActivateTerminalLink(fakeEvent())).toBe(false)
// Construct with shiftKey/altKey only; the gate ignores them.
const event = { ctrlKey: false, metaKey: false, shiftKey: true, altKey: true } as MouseEvent
expect(shouldActivateTerminalLink(event)).toBe(false)
})
})
describe('openTerminalUrl', () => {
it('opens an http URL in a new tab with noopener,noreferrer', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null)
expect(openTerminalUrl('http://example.com')).toBe(true)
expect(open).toHaveBeenCalledWith('http://example.com/', '_blank', 'noopener,noreferrer')
})
it('opens an https URL', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null)
expect(openTerminalUrl('https://example.com/path?x=1')).toBe(true)
expect(open).toHaveBeenCalledWith(
'https://example.com/path?x=1',
'_blank',
'noopener,noreferrer',
)
})
it('rejects file:// (printed by tools, never handed to window.open)', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null)
expect(openTerminalUrl('file:///etc/passwd')).toBe(false)
expect(open).not.toHaveBeenCalled()
})
it('rejects mailto:', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null)
expect(openTerminalUrl('mailto:foo@bar.com')).toBe(false)
expect(open).not.toHaveBeenCalled()
})
it('rejects javascript: (defense-in-depth even though the regex skips it)', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null)
expect(openTerminalUrl('javascript:alert(1)')).toBe(false)
expect(open).not.toHaveBeenCalled()
})
it('rejects a malformed URL string without throwing', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null)
expect(openTerminalUrl('https://')).toBe(false)
expect(openTerminalUrl('not a url at all')).toBe(false)
expect(openTerminalUrl('')).toBe(false)
expect(open).not.toHaveBeenCalled()
})
})