/** * 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 { 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 :` 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]) }) })