Files
obelisk/tests/app-file-reference.test.mjs
T
tommy0103andClaude Opus 5 c3751f0f6b feat(app): open local file references at the referenced line
Mark absolute Markdown links and inline-code relative paths as file
references, resolve them against the originating message cwd, and open
them in the configured editor. Local links no longer navigate the SPA
away from the running renderer.

The canonical transcript assembly projected messages down to seven
fields, so cwd and session_id never reached the renderer. Both are added
to SessionDetailMessage: cwd because the working directory can change
mid-session, session_id because it scopes which roots a reference may
resolve inside. The main process derives those roots from the database
rather than trusting anything the renderer sends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:03:21 +08:00

82 lines
3.6 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
buildEditorUrl,
fileReferenceCandidates,
normalizeRoots,
resolveFileReference,
} from '../app/src/main/file-reference.ts';
function tempProject() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'obelisk-file-ref-'));
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(path.join(root, 'src', 'app.ts'), 'export const a = 1;\n');
return fs.realpathSync(root);
}
test('normalizeRoots keeps absolute paths and drops the rest', () => {
assert.deepEqual(normalizeRoots(['/tmp/a', 'relative', '', null, undefined, '/tmp/a']), ['/tmp/a']);
});
test('candidates cover absolute, cwd-relative and project-relative shapes', () => {
const candidates = fileReferenceCandidates({ rawPath: 'src/app.ts', cwd: '/tmp/proj' });
assert.ok(candidates.includes(path.join('/tmp/proj', 'src/app.ts')));
// A leading slash in transcripts usually means "project root", not filesystem root.
const rooted = fileReferenceCandidates({ rawPath: '/src/app.ts', cwd: '/tmp/proj' });
assert.ok(rooted.includes('/src/app.ts'));
assert.ok(rooted.includes(path.join('/tmp/proj', 'src/app.ts')));
});
test('resolves a relative reference against the message cwd', () => {
const root = tempProject();
assert.equal(
resolveFileReference({ rawPath: 'src/app.ts', cwd: root, roots: [] }),
path.join(root, 'src', 'app.ts'),
);
});
test('refuses paths outside the session roots', () => {
const root = tempProject();
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'obelisk-outside-'));
fs.writeFileSync(path.join(outside, 'secret.txt'), 'nope\n');
assert.equal(resolveFileReference({ rawPath: path.join(outside, 'secret.txt'), cwd: root }), null);
assert.equal(resolveFileReference({ rawPath: '../../etc/hosts', cwd: root }), null);
});
test('refuses a symlink that escapes the session roots', () => {
const root = tempProject();
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'obelisk-outside-'));
const secret = path.join(outside, 'secret.txt');
fs.writeFileSync(secret, 'nope\n');
fs.symlinkSync(secret, path.join(root, 'escape.txt'));
assert.equal(resolveFileReference({ rawPath: 'escape.txt', cwd: root }), null);
});
test('refuses directories, missing files and rootless queries', () => {
const root = tempProject();
assert.equal(resolveFileReference({ rawPath: 'src', cwd: root }), null);
assert.equal(resolveFileReference({ rawPath: 'src/nope.ts', cwd: root }), null);
assert.equal(resolveFileReference({ rawPath: 'src/app.ts', cwd: null, roots: [] }), null);
});
test('builds editor URLs with line and column', () => {
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: 42 }), 'vscode://file/p/a.ts:42');
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: 42, column: 7 }), 'vscode://file/p/a.ts:42:7');
assert.equal(buildEditorUrl({ filePath: '/p/a.ts' }), 'vscode://file/p/a.ts');
assert.equal(buildEditorUrl({ scheme: 'cursor', filePath: '/p/a.ts', line: 3 }), 'cursor://file/p/a.ts:3');
});
test('falls back to the default scheme and encodes spaces', () => {
assert.equal(buildEditorUrl({ scheme: 'evil:', filePath: '/p/a.ts' }), 'vscode://file/p/a.ts');
assert.equal(buildEditorUrl({ filePath: '/p/a b.ts', line: 1 }), 'vscode://file/p/a%20b.ts:1');
});
test('ignores a zero or negative line number', () => {
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: 0 }), 'vscode://file/p/a.ts');
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: -3 }), 'vscode://file/p/a.ts');
});