CreateProcess — which node's spawn uses without a shell — only launches a real .exe, so a `.cmd`/`.bat` browser shim (how most Windows wrappers are written) silently launched nothing. Routing the override through `cmd /c`, the way the default `start` opener already goes, makes .exe, .cmd and .bat all work and keeps node's per-argument quoting so a path with spaces survives. Caught on the Windows VM. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
537 lines
21 KiB
TypeScript
537 lines
21 KiB
TypeScript
/**
|
|
* `codegraph ui` server — the loopback boundary (CG-41).
|
|
*
|
|
* This process serves the user's source code from a port on their machine, so
|
|
* the tests that matter are the refusals: a foreign `Host` (DNS rebinding is
|
|
* the only realistic attack on a loopback code viewer), a traversal out of the
|
|
* asset root, a write method, a cross-origin read. The happy path — index.html
|
|
* and hashed assets — is here mostly so a refusal that accidentally blocks
|
|
* everything can't pass.
|
|
*
|
|
* Requests go through `http.request`, not `fetch`: `Host` is a forbidden header
|
|
* name in undici, and forging it is the whole point of half these cases.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import * as http from 'http';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import {
|
|
browserOpenCommand,
|
|
cacheControlFor,
|
|
contentTypeFor,
|
|
isAllowedHost,
|
|
isAllowedOrigin,
|
|
isSafeRequestPath,
|
|
PathRefusalError,
|
|
resolveProjectFile,
|
|
resolveStaticAsset,
|
|
startUiServer,
|
|
type UiServerHandle,
|
|
} from '../src/ui-server';
|
|
|
|
interface Response {
|
|
status: number;
|
|
headers: http.IncomingHttpHeaders;
|
|
body: string;
|
|
}
|
|
|
|
/**
|
|
* One request with full control over the request line and headers.
|
|
*
|
|
* `setHost: false` stops node from adding its own `Host`, and `path` is sent
|
|
* verbatim — so a traversal case really does put `/../../x` on the wire.
|
|
*/
|
|
function request(
|
|
port: number,
|
|
requestPath: string,
|
|
options: { method?: string; headers?: Record<string, string> } = {}
|
|
): Promise<Response> {
|
|
return new Promise((resolve, reject) => {
|
|
const headers: Record<string, string> = { Host: `127.0.0.1:${port}`, ...options.headers };
|
|
const req = http.request(
|
|
{ host: '127.0.0.1', port, path: requestPath, method: options.method ?? 'GET', headers, setHost: false },
|
|
(res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on('data', (c: Buffer) => chunks.push(c));
|
|
res.on('end', () =>
|
|
resolve({
|
|
status: res.statusCode ?? 0,
|
|
headers: res.headers,
|
|
body: Buffer.concat(chunks).toString('utf-8'),
|
|
})
|
|
);
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
describe('codegraph ui server', () => {
|
|
let tempDir: string;
|
|
let viewerDir: string;
|
|
let projectRoot: string;
|
|
let server: UiServerHandle;
|
|
|
|
beforeAll(async () => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-server-'));
|
|
|
|
// A stand-in for dist/viewer: same shape (index.html + hashed assets/), so
|
|
// the tests don't need the Svelte build to have run.
|
|
viewerDir = path.join(tempDir, 'viewer');
|
|
fs.mkdirSync(path.join(viewerDir, 'assets'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(viewerDir, 'index.html'),
|
|
'<!doctype html><html><body><div id="app"></div>' +
|
|
'<script type="module" src="./assets/index-abc123.js"></script></body></html>'
|
|
);
|
|
fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.js'), 'export const viewer = 1;\n');
|
|
fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.css'), ':root{color:#16150f}\n');
|
|
|
|
projectRoot = path.join(tempDir, 'project');
|
|
fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
|
|
fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
|
|
|
|
// A file OUTSIDE both roots that a traversal would be trying to reach.
|
|
fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
|
|
|
|
server = await startUiServer({ projectRoot, viewerDir, port: 0 });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.close();
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('serving the viewer', () => {
|
|
it('serves index.html at the root', async () => {
|
|
const res = await request(server.port, '/');
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
|
|
expect(res.body).toContain('<div id="app">');
|
|
});
|
|
|
|
it('serves index.html directly too', async () => {
|
|
const res = await request(server.port, '/index.html');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toContain('<div id="app">');
|
|
});
|
|
|
|
it('serves hashed assets with their real content type', async () => {
|
|
const js = await request(server.port, '/assets/index-abc123.js');
|
|
expect(js.status).toBe(200);
|
|
expect(js.headers['content-type']).toBe('text/javascript; charset=utf-8');
|
|
expect(js.body).toContain('export const viewer');
|
|
|
|
const css = await request(server.port, '/assets/index-abc123.css');
|
|
expect(css.status).toBe(200);
|
|
expect(css.headers['content-type']).toBe('text/css; charset=utf-8');
|
|
});
|
|
|
|
it('caches hashed assets forever and index.html never', async () => {
|
|
const asset = await request(server.port, '/assets/index-abc123.js');
|
|
expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable');
|
|
const index = await request(server.port, '/');
|
|
expect(index.headers['cache-control']).toBe('no-store');
|
|
});
|
|
|
|
it('falls back to index.html for an unknown route, but not for a missing asset', async () => {
|
|
// A hash-routed app only ever asks for `/`, but a hand-typed deep path
|
|
// should still open the app.
|
|
const route = await request(server.port, '/s/some-symbol-id');
|
|
expect(route.status).toBe(200);
|
|
expect(route.body).toContain('<div id="app">');
|
|
|
|
// A missing FILE must 404 — answering with HTML would hand the browser a
|
|
// script that isn't one, and hide a broken build.
|
|
const asset = await request(server.port, '/assets/index-doesnotexist.js');
|
|
expect(asset.status).toBe(404);
|
|
});
|
|
|
|
it('answers HEAD with the same headers and no body', async () => {
|
|
const res = await request(server.port, '/', { method: 'HEAD' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
|
|
expect(res.headers['content-length']).toBeDefined();
|
|
expect(res.body).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('binding', () => {
|
|
it('listens on loopback only', () => {
|
|
const address = server.server.address();
|
|
expect(address).not.toBeNull();
|
|
expect(typeof address === 'object' ? address?.address : null).toBe('127.0.0.1');
|
|
expect(server.url).toBe(`http://127.0.0.1:${server.port}`);
|
|
});
|
|
|
|
it('falls back to the next free port when the preferred one is taken', async () => {
|
|
const blocker = http.createServer(() => {});
|
|
await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
|
|
const taken = (blocker.address() as { port: number }).port;
|
|
|
|
const second = await startUiServer({ projectRoot, viewerDir, port: taken });
|
|
try {
|
|
expect(second.port).not.toBe(taken);
|
|
expect(second.port).toBeGreaterThan(taken);
|
|
// …and it actually works on the port it landed on.
|
|
const res = await request(second.port, '/');
|
|
expect(res.status).toBe(200);
|
|
} finally {
|
|
await second.close();
|
|
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it('refuses to move off a port the caller pinned', async () => {
|
|
const blocker = http.createServer(() => {});
|
|
await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
|
|
const taken = (blocker.address() as { port: number }).port;
|
|
|
|
try {
|
|
await expect(
|
|
startUiServer({ projectRoot, viewerDir, port: taken, portFallback: false })
|
|
).rejects.toThrow(/already in use/i);
|
|
} finally {
|
|
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Host allowlist (DNS rebinding)', () => {
|
|
it('serves the loopback names', async () => {
|
|
for (const host of ['127.0.0.1', 'localhost', '[::1]', `localhost:${server.port}`, `[::1]:${server.port}`]) {
|
|
const res = await request(server.port, '/', { headers: { Host: host } });
|
|
expect(res.status, `Host: ${host}`).toBe(200);
|
|
}
|
|
});
|
|
|
|
it('refuses a foreign Host', async () => {
|
|
for (const host of ['evil.example', `evil.example:${server.port}`, 'attacker.localhost.evil.com']) {
|
|
const res = await request(server.port, '/', { headers: { Host: host } });
|
|
expect(res.status, `Host: ${host}`).toBe(403);
|
|
expect(res.body).not.toContain('<div id="app">');
|
|
}
|
|
});
|
|
|
|
it('refuses a loopback Host carrying someone else\u2019s port', async () => {
|
|
const res = await request(server.port, '/', { headers: { Host: '127.0.0.1:9' } });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('refuses a malformed or missing Host', async () => {
|
|
const malformed = await request(server.port, '/', { headers: { Host: '127.0.0.1:notaport' } });
|
|
expect(malformed.status).toBe(403);
|
|
// Node's client insists on sending something for Host, so the empty-value
|
|
// case is covered by the unit assertions on isAllowedHost below.
|
|
});
|
|
|
|
it('refuses before touching the filesystem — even for an asset', async () => {
|
|
const res = await request(server.port, '/assets/index-abc123.js', {
|
|
headers: { Host: 'evil.example' },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).not.toContain('export const viewer');
|
|
});
|
|
});
|
|
|
|
describe('cross-origin', () => {
|
|
it('never sends CORS headers', async () => {
|
|
const res = await request(server.port, '/');
|
|
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
|
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
|
|
expect(res.headers['access-control-allow-methods']).toBeUndefined();
|
|
});
|
|
|
|
it('refuses a request carrying a foreign Origin', async () => {
|
|
const res = await request(server.port, '/', { headers: { Origin: 'https://evil.example' } });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('allows the viewer\u2019s own origin', async () => {
|
|
const res = await request(server.port, '/', {
|
|
headers: { Origin: `http://127.0.0.1:${server.port}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('sends the hardening headers on every response', async () => {
|
|
const res = await request(server.port, '/');
|
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
expect(res.headers['x-frame-options']).toBe('DENY');
|
|
expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'");
|
|
expect(res.headers['content-security-policy']).toContain("connect-src 'self'");
|
|
});
|
|
});
|
|
|
|
describe('read-only', () => {
|
|
it('refuses every method that is not GET or HEAD', async () => {
|
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
|
|
const res = await request(server.port, '/', { method });
|
|
expect(res.status, method).toBe(405);
|
|
expect(res.headers['allow']).toBe('GET, HEAD');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('paths outside the asset root', () => {
|
|
const traversals = [
|
|
'/../secret.txt',
|
|
'/../../secret.txt',
|
|
'/assets/../../secret.txt',
|
|
'/..%2fsecret.txt',
|
|
'/%2e%2e/secret.txt',
|
|
'/%2e%2e%2fsecret.txt',
|
|
'/....//secret.txt',
|
|
];
|
|
|
|
it('never serves a file outside the viewer directory', async () => {
|
|
for (const traversal of traversals) {
|
|
const res = await request(server.port, traversal);
|
|
expect(res.body, traversal).not.toContain('SUPER-SECRET-VALUE');
|
|
expect(res.status, traversal).not.toBe(200);
|
|
}
|
|
});
|
|
|
|
it('404s an absolute system path rather than reading it', async () => {
|
|
const res = await request(server.port, '/etc/passwd');
|
|
expect(res.body).not.toContain('root:');
|
|
// No such file under the viewer root; an extension-less path is a route.
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toContain('<div id="app">');
|
|
|
|
const shadow = await request(server.port, '/etc/hosts.txt');
|
|
expect(shadow.status).toBe(404);
|
|
});
|
|
|
|
it('404s a NUL-truncation attempt', async () => {
|
|
const res = await request(server.port, '/index.html%00.png');
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('/api is reserved', () => {
|
|
it('404s as JSON, never as the app shell', async () => {
|
|
const res = await request(server.port, '/api/nodes');
|
|
expect(res.status).toBe(404);
|
|
expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
|
|
expect(JSON.parse(res.body)).toHaveProperty('error');
|
|
expect(res.body).not.toContain('<div id="app">');
|
|
});
|
|
|
|
it('hands requests to a mounted handler with a decoded path and query', async () => {
|
|
const seen: Array<{ pathname: string; symbol: string | null; root: string }> = [];
|
|
const withApi = await startUiServer({
|
|
projectRoot,
|
|
viewerDir,
|
|
port: 0,
|
|
api: (_req, res, ctx) => {
|
|
seen.push({
|
|
pathname: ctx.pathname,
|
|
symbol: ctx.query.get('symbol'),
|
|
root: ctx.projectRoot,
|
|
});
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end('{"ok":true}');
|
|
return true;
|
|
},
|
|
});
|
|
try {
|
|
const res = await request(withApi.port, '/api/node?symbol=parse%20Token');
|
|
expect(res.status).toBe(200);
|
|
expect(JSON.parse(res.body)).toEqual({ ok: true });
|
|
expect(seen).toEqual([{ pathname: '/api/node', symbol: 'parse Token', root: projectRoot }]);
|
|
} finally {
|
|
await withApi.close();
|
|
}
|
|
});
|
|
|
|
it('turns a throwing handler into a JSON 500, not a crashed server', async () => {
|
|
const withApi = await startUiServer({
|
|
projectRoot,
|
|
viewerDir,
|
|
port: 0,
|
|
api: () => {
|
|
throw new Error('handler blew up');
|
|
},
|
|
});
|
|
try {
|
|
const res = await request(withApi.port, '/api/boom');
|
|
expect(res.status).toBe(500);
|
|
expect(JSON.parse(res.body).error).toContain('handler blew up');
|
|
// Still alive afterwards.
|
|
expect((await request(withApi.port, '/')).status).toBe(200);
|
|
} finally {
|
|
await withApi.close();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('resolveProjectFile — the source read chokepoint', () => {
|
|
let tempDir: string;
|
|
let projectRoot: string;
|
|
|
|
beforeAll(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-paths-'));
|
|
projectRoot = path.join(tempDir, 'project');
|
|
fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
|
|
fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
|
|
fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('resolves a file inside the project', () => {
|
|
expect(resolveProjectFile(projectRoot, 'src/auth.ts')).toBe(
|
|
fs.realpathSync(path.join(projectRoot, 'src', 'auth.ts'))
|
|
);
|
|
});
|
|
|
|
it('refuses traversal out of the project', () => {
|
|
for (const escape of ['../secret.txt', 'src/../../secret.txt', '..%2fsecret.txt']) {
|
|
expect(() => resolveProjectFile(projectRoot, escape), escape).toThrow(PathRefusalError);
|
|
}
|
|
});
|
|
|
|
it('refuses an absolute path', () => {
|
|
expect(() => resolveProjectFile(projectRoot, path.join(tempDir, 'secret.txt'))).toThrow(
|
|
PathRefusalError
|
|
);
|
|
});
|
|
|
|
it('refuses an empty path', () => {
|
|
expect(() => resolveProjectFile(projectRoot, '')).toThrow(PathRefusalError);
|
|
expect(() => resolveProjectFile(projectRoot, ' ')).toThrow(PathRefusalError);
|
|
});
|
|
|
|
it('refuses a NUL byte', () => {
|
|
expect(() => resolveProjectFile(projectRoot, 'src/auth.ts%00.png')).toThrow(PathRefusalError);
|
|
});
|
|
|
|
// `/etc` resolves to a non-existent `C:\etc` on Windows, so the sensitive-path
|
|
// list only means anything on POSIX.
|
|
it.runIf(process.platform !== 'win32')('refuses a sensitive system directory as the root', () => {
|
|
expect(() => resolveProjectFile('/etc', 'passwd')).toThrow(PathRefusalError);
|
|
expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError);
|
|
});
|
|
|
|
it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => {
|
|
const link = path.join(projectRoot, 'src', 'escape.ts');
|
|
fs.symlinkSync(path.join(tempDir, 'secret.txt'), link);
|
|
try {
|
|
expect(() => resolveProjectFile(projectRoot, 'src/escape.ts')).toThrow(PathRefusalError);
|
|
} finally {
|
|
fs.unlinkSync(link);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('security helpers', () => {
|
|
it('isAllowedHost accepts only loopback names on our port', () => {
|
|
expect(isAllowedHost('127.0.0.1', 4747)).toBe(true);
|
|
expect(isAllowedHost('127.0.0.1:4747', 4747)).toBe(true);
|
|
expect(isAllowedHost('localhost:4747', 4747)).toBe(true);
|
|
expect(isAllowedHost('LOCALHOST', 4747)).toBe(true);
|
|
expect(isAllowedHost('[::1]:4747', 4747)).toBe(true);
|
|
|
|
expect(isAllowedHost(undefined, 4747)).toBe(false);
|
|
expect(isAllowedHost('', 4747)).toBe(false);
|
|
expect(isAllowedHost('evil.example', 4747)).toBe(false);
|
|
expect(isAllowedHost('127.0.0.1:4748', 4747)).toBe(false);
|
|
expect(isAllowedHost('127.0.0.1.evil.example', 4747)).toBe(false);
|
|
expect(isAllowedHost('localhost.evil.example:4747', 4747)).toBe(false);
|
|
expect(isAllowedHost('127.0.0.1:4747:4747', 4747)).toBe(false);
|
|
// Unbracketed IPv6 is malformed per RFC 7230 — rejected, not guessed at.
|
|
expect(isAllowedHost('::1', 4747)).toBe(false);
|
|
// A non-loopback address that merely resolves here still fails the check.
|
|
expect(isAllowedHost('192.168.1.5:4747', 4747)).toBe(false);
|
|
});
|
|
|
|
it('isAllowedOrigin allows absent and same-origin, refuses everything else', () => {
|
|
expect(isAllowedOrigin(undefined, 4747)).toBe(true);
|
|
expect(isAllowedOrigin('http://127.0.0.1:4747', 4747)).toBe(true);
|
|
expect(isAllowedOrigin('http://localhost:4747', 4747)).toBe(true);
|
|
expect(isAllowedOrigin('http://[::1]:4747', 4747)).toBe(true);
|
|
|
|
expect(isAllowedOrigin('null', 4747)).toBe(false);
|
|
expect(isAllowedOrigin('https://evil.example', 4747)).toBe(false);
|
|
expect(isAllowedOrigin('http://127.0.0.1:4748', 4747)).toBe(false);
|
|
expect(isAllowedOrigin('file://', 4747)).toBe(false);
|
|
expect(isAllowedOrigin('not a url', 4747)).toBe(false);
|
|
});
|
|
|
|
it('isSafeRequestPath rejects a `..` segment however it is spelled', () => {
|
|
expect(isSafeRequestPath('/')).toBe(true);
|
|
expect(isSafeRequestPath('/assets/index-abc123.js')).toBe(true);
|
|
expect(isSafeRequestPath('/s/Some.Symbol')).toBe(true);
|
|
|
|
expect(isSafeRequestPath('/../secret')).toBe(false);
|
|
expect(isSafeRequestPath('/a/../../secret')).toBe(false);
|
|
expect(isSafeRequestPath('/%2e%2e/secret')).toBe(false);
|
|
expect(isSafeRequestPath('/..%2Fsecret')).toBe(false);
|
|
expect(isSafeRequestPath('/a%00b')).toBe(false);
|
|
expect(isSafeRequestPath('/a\\b')).toBe(false);
|
|
expect(isSafeRequestPath('/%zz')).toBe(false);
|
|
});
|
|
|
|
it('resolveStaticAsset returns null for anything that is not a file in the root', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-static-'));
|
|
try {
|
|
fs.mkdirSync(path.join(dir, 'assets'));
|
|
fs.writeFileSync(path.join(dir, 'index.html'), 'x');
|
|
expect(resolveStaticAsset(dir, '/index.html')).toBe(
|
|
fs.realpathSync(path.join(dir, 'index.html'))
|
|
);
|
|
expect(resolveStaticAsset(dir, '/assets')).toBeNull(); // a directory
|
|
expect(resolveStaticAsset(dir, '/missing.js')).toBeNull();
|
|
expect(resolveStaticAsset(dir, '/../etc/passwd')).toBeNull();
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('contentTypeFor covers the viewer bundle and defaults safely', () => {
|
|
expect(contentTypeFor('a/index.html')).toBe('text/html; charset=utf-8');
|
|
expect(contentTypeFor('a/index-abc.js')).toBe('text/javascript; charset=utf-8');
|
|
expect(contentTypeFor('a/archivo.woff2')).toBe('font/woff2');
|
|
expect(contentTypeFor('a/thing.unknownext')).toBe('application/octet-stream');
|
|
});
|
|
|
|
it('cacheControlFor pins hashed assets and never index.html', () => {
|
|
expect(cacheControlFor(path.join('assets', 'index-abc.js'))).toContain('immutable');
|
|
expect(cacheControlFor('index.html')).toBe('no-store');
|
|
});
|
|
});
|
|
|
|
describe('browserOpenCommand', () => {
|
|
it('uses the platform opener', () => {
|
|
expect(browserOpenCommand('http://x', 'darwin')).toEqual({ command: 'open', args: ['http://x'] });
|
|
expect(browserOpenCommand('http://x', 'linux')).toEqual({ command: 'xdg-open', args: ['http://x'] });
|
|
expect(browserOpenCommand('http://x', 'win32')).toEqual({
|
|
command: 'cmd',
|
|
args: ['/c', 'start', '', 'http://x'],
|
|
});
|
|
});
|
|
|
|
it('honours the CODEGRAPH_BROWSER override', () => {
|
|
expect(browserOpenCommand('http://x', 'darwin', 'firefox')).toEqual({
|
|
command: 'firefox',
|
|
args: ['http://x'],
|
|
});
|
|
// Windows routes the override through cmd so a `.cmd`/`.bat` shim — which
|
|
// CreateProcess cannot launch directly — still works.
|
|
expect(browserOpenCommand('http://x', 'win32', 'C:\\tools\\open.cmd')).toEqual({
|
|
command: 'cmd',
|
|
args: ['/c', 'C:\\tools\\open.cmd', 'http://x'],
|
|
});
|
|
for (const off of ['none', 'NONE', '0', 'false', 'off', '', ' ']) {
|
|
expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull();
|
|
}
|
|
});
|
|
});
|