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.
170 lines
6.3 KiB
TypeScript
170 lines
6.3 KiB
TypeScript
/**
|
|
* test/editor.test.ts — openInEditor validation + spawn wiring (v0.6).
|
|
*
|
|
* Uses editorCmd='true' (the POSIX no-op that exits 0, ignoring args) so the
|
|
* success path actually spawns a harmless process — never opening a real editor.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
import fs from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { openInEditor, openFileInEditor, isGotoEditor } from '../src/http/editor.js'
|
|
import type { Config } from '../src/types.js'
|
|
|
|
function cfg(editorCmd: string): Config {
|
|
// Only editorCmd matters here; the rest is filler to satisfy the type.
|
|
return { editorCmd } as unknown as Config
|
|
}
|
|
|
|
let tmpDir: string
|
|
let tmpFile: string
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'editor-test-'))
|
|
tmpFile = path.join(tmpDir, 'a-file.txt')
|
|
await fs.writeFile(tmpFile, 'hi')
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
describe('openInEditor — validation', () => {
|
|
it('rejects a missing/empty path with 400', async () => {
|
|
expect((await openInEditor(cfg('true'), undefined)).status).toBe(400)
|
|
expect((await openInEditor(cfg('true'), '')).status).toBe(400)
|
|
expect((await openInEditor(cfg('true'), ' ')).status).toBe(400)
|
|
})
|
|
|
|
it('rejects a non-string path with 400', async () => {
|
|
expect((await openInEditor(cfg('true'), 123)).status).toBe(400)
|
|
expect((await openInEditor(cfg('true'), { path: '/x' })).status).toBe(400)
|
|
})
|
|
|
|
it('rejects a relative path with 400', async () => {
|
|
const r = await openInEditor(cfg('true'), 'relative/dir')
|
|
expect(r.ok).toBe(false)
|
|
expect(r.status).toBe(400)
|
|
})
|
|
|
|
it('returns 404 for a non-existent absolute path', async () => {
|
|
const r = await openInEditor(cfg('true'), path.join(tmpDir, 'does-not-exist'))
|
|
expect(r.status).toBe(404)
|
|
})
|
|
|
|
it('returns 400 when the path is a file, not a directory', async () => {
|
|
const r = await openInEditor(cfg('true'), tmpFile)
|
|
expect(r.status).toBe(400)
|
|
})
|
|
})
|
|
|
|
describe('openInEditor — launch', () => {
|
|
it('spawns the editor for a valid directory (204)', async () => {
|
|
const r = await openInEditor(cfg('true'), tmpDir)
|
|
expect(r.ok).toBe(true)
|
|
expect(r.status).toBe(204)
|
|
})
|
|
})
|
|
|
|
// ── W1: openFileInEditor (open a FILE at a line) ──────────────────────────────
|
|
|
|
/** Poll for a file to exist + be non-empty (the editor spawn is fire-and-forget). */
|
|
async function waitForFile(p: string, timeoutMs = 3_000): Promise<string> {
|
|
const start = Date.now()
|
|
for (;;) {
|
|
try {
|
|
const content = await fs.readFile(p, 'utf8')
|
|
if (content.length > 0) return content
|
|
} catch {
|
|
// not written yet
|
|
}
|
|
if (Date.now() - start > timeoutMs) throw new Error(`timeout waiting for ${p}`)
|
|
await new Promise((r) => setTimeout(r, 25))
|
|
}
|
|
}
|
|
|
|
describe('isGotoEditor', () => {
|
|
it('is true for the VS Code family (code/cursor/windsurf/codium)', () => {
|
|
expect(isGotoEditor('code')).toBe(true)
|
|
expect(isGotoEditor('/usr/local/bin/cursor')).toBe(true)
|
|
expect(isGotoEditor('windsurf')).toBe(true)
|
|
expect(isGotoEditor('codium')).toBe(true)
|
|
expect(isGotoEditor('code.cmd')).toBe(true) // Windows shim
|
|
})
|
|
|
|
it('is false for editors without --goto (vim/nano/true)', () => {
|
|
expect(isGotoEditor('vim')).toBe(false)
|
|
expect(isGotoEditor('nano')).toBe(false)
|
|
expect(isGotoEditor('true')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('openFileInEditor — validation', () => {
|
|
it('rejects a missing / empty / non-string file with 400', async () => {
|
|
expect((await openFileInEditor(cfg('true'), undefined)).status).toBe(400)
|
|
expect((await openFileInEditor(cfg('true'), '')).status).toBe(400)
|
|
expect((await openFileInEditor(cfg('true'), ' ')).status).toBe(400)
|
|
expect((await openFileInEditor(cfg('true'), 123)).status).toBe(400)
|
|
})
|
|
|
|
it('rejects a relative file with 400', async () => {
|
|
expect((await openFileInEditor(cfg('true'), 'rel/a.ts')).status).toBe(400)
|
|
})
|
|
|
|
it('returns 404 for a non-existent absolute file', async () => {
|
|
const r = await openFileInEditor(cfg('true'), path.join(tmpDir, 'nope.ts'))
|
|
expect(r.status).toBe(404)
|
|
})
|
|
|
|
it('returns 400 when the path is a directory, not a file', async () => {
|
|
const r = await openFileInEditor(cfg('true'), tmpDir)
|
|
expect(r.status).toBe(400)
|
|
expect(r.error).toContain('not a file')
|
|
})
|
|
|
|
it('rejects a non-integer / zero / out-of-range / non-number line with 400', async () => {
|
|
expect((await openFileInEditor(cfg('true'), tmpFile, 1.5)).status).toBe(400)
|
|
expect((await openFileInEditor(cfg('true'), tmpFile, 0)).status).toBe(400)
|
|
expect((await openFileInEditor(cfg('true'), tmpFile, 1_000_000_001)).status).toBe(400)
|
|
expect((await openFileInEditor(cfg('true'), tmpFile, 'x')).status).toBe(400)
|
|
})
|
|
})
|
|
|
|
describe('openFileInEditor — launch', () => {
|
|
it('spawns the editor for a valid file with no line (204)', async () => {
|
|
const r = await openFileInEditor(cfg('true'), tmpFile)
|
|
expect(r.ok).toBe(true)
|
|
expect(r.status).toBe(204)
|
|
})
|
|
|
|
it('spawns the editor for a valid file + line (204)', async () => {
|
|
const r = await openFileInEditor(cfg('true'), tmpFile, 42)
|
|
expect(r.status).toBe(204)
|
|
})
|
|
|
|
it('passes `--goto <file>:<line>` for a goto-capable editor (basename code)', async () => {
|
|
const argsOut = path.join(tmpDir, 'goto-args.txt')
|
|
const recorder = path.join(tmpDir, 'code') // basename 'code' → isGotoEditor true
|
|
await fs.writeFile(recorder, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsOut}"\n`)
|
|
await fs.chmod(recorder, 0o755)
|
|
|
|
const r = await openFileInEditor(cfg(recorder), tmpFile, 42)
|
|
expect(r.status).toBe(204)
|
|
const recorded = (await waitForFile(argsOut)).trim().split('\n')
|
|
expect(recorded).toEqual(['--goto', `${tmpFile}:42`])
|
|
})
|
|
|
|
it('passes only the bare file for a non-goto editor (drops the line)', async () => {
|
|
const argsOut = path.join(tmpDir, 'bare-args.txt')
|
|
const recorder = path.join(tmpDir, 'myeditor') // basename not in the goto set
|
|
await fs.writeFile(recorder, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsOut}"\n`)
|
|
await fs.chmod(recorder, 0o755)
|
|
|
|
const r = await openFileInEditor(cfg(recorder), tmpFile, 42)
|
|
expect(r.status).toBe(204)
|
|
const recorded = (await waitForFile(argsOut)).trim().split('\n')
|
|
expect(recorded).toEqual([tmpFile])
|
|
})
|
|
})
|