ui-server.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. /**
  2. * `codegraph ui` server — the loopback boundary (CG-41).
  3. *
  4. * This process serves the user's source code from a port on their machine, so
  5. * the tests that matter are the refusals: a foreign `Host` (DNS rebinding is
  6. * the only realistic attack on a loopback code viewer), a traversal out of the
  7. * asset root, a write method, a cross-origin read. The happy path — index.html
  8. * and hashed assets — is here mostly so a refusal that accidentally blocks
  9. * everything can't pass.
  10. *
  11. * Requests go through `http.request`, not `fetch`: `Host` is a forbidden header
  12. * name in undici, and forging it is the whole point of half these cases.
  13. */
  14. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  15. import * as http from 'http';
  16. import * as fs from 'fs';
  17. import * as os from 'os';
  18. import * as path from 'path';
  19. import {
  20. browserOpenCommand,
  21. cacheControlFor,
  22. contentTypeFor,
  23. isAllowedHost,
  24. isAllowedOrigin,
  25. isSafeRequestPath,
  26. PathRefusalError,
  27. resolveProjectFile,
  28. resolveStaticAsset,
  29. startUiServer,
  30. type UiServerHandle,
  31. } from '../src/ui-server';
  32. interface Response {
  33. status: number;
  34. headers: http.IncomingHttpHeaders;
  35. body: string;
  36. }
  37. /**
  38. * One request with full control over the request line and headers.
  39. *
  40. * `setHost: false` stops node from adding its own `Host`, and `path` is sent
  41. * verbatim — so a traversal case really does put `/../../x` on the wire.
  42. */
  43. function request(
  44. port: number,
  45. requestPath: string,
  46. options: { method?: string; headers?: Record<string, string> } = {}
  47. ): Promise<Response> {
  48. return new Promise((resolve, reject) => {
  49. const headers: Record<string, string> = { Host: `127.0.0.1:${port}`, ...options.headers };
  50. const req = http.request(
  51. { host: '127.0.0.1', port, path: requestPath, method: options.method ?? 'GET', headers, setHost: false },
  52. (res) => {
  53. const chunks: Buffer[] = [];
  54. res.on('data', (c: Buffer) => chunks.push(c));
  55. res.on('end', () =>
  56. resolve({
  57. status: res.statusCode ?? 0,
  58. headers: res.headers,
  59. body: Buffer.concat(chunks).toString('utf-8'),
  60. })
  61. );
  62. }
  63. );
  64. req.on('error', reject);
  65. req.end();
  66. });
  67. }
  68. describe('codegraph ui server', () => {
  69. let tempDir: string;
  70. let viewerDir: string;
  71. let projectRoot: string;
  72. let server: UiServerHandle;
  73. beforeAll(async () => {
  74. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-server-'));
  75. // A stand-in for dist/viewer: same shape (index.html + hashed assets/), so
  76. // the tests don't need the Svelte build to have run.
  77. viewerDir = path.join(tempDir, 'viewer');
  78. fs.mkdirSync(path.join(viewerDir, 'assets'), { recursive: true });
  79. fs.writeFileSync(
  80. path.join(viewerDir, 'index.html'),
  81. '<!doctype html><html><body><div id="app"></div>' +
  82. '<script type="module" src="./assets/index-abc123.js"></script></body></html>'
  83. );
  84. fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.js'), 'export const viewer = 1;\n');
  85. fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.css'), ':root{color:#16150f}\n');
  86. projectRoot = path.join(tempDir, 'project');
  87. fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
  88. fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
  89. // A file OUTSIDE both roots that a traversal would be trying to reach.
  90. fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
  91. server = await startUiServer({ projectRoot, viewerDir, port: 0 });
  92. });
  93. afterAll(async () => {
  94. await server?.close();
  95. fs.rmSync(tempDir, { recursive: true, force: true });
  96. });
  97. describe('serving the viewer', () => {
  98. it('serves index.html at the root', async () => {
  99. const res = await request(server.port, '/');
  100. expect(res.status).toBe(200);
  101. expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
  102. expect(res.body).toContain('<div id="app">');
  103. });
  104. it('serves index.html directly too', async () => {
  105. const res = await request(server.port, '/index.html');
  106. expect(res.status).toBe(200);
  107. expect(res.body).toContain('<div id="app">');
  108. });
  109. it('serves hashed assets with their real content type', async () => {
  110. const js = await request(server.port, '/assets/index-abc123.js');
  111. expect(js.status).toBe(200);
  112. expect(js.headers['content-type']).toBe('text/javascript; charset=utf-8');
  113. expect(js.body).toContain('export const viewer');
  114. const css = await request(server.port, '/assets/index-abc123.css');
  115. expect(css.status).toBe(200);
  116. expect(css.headers['content-type']).toBe('text/css; charset=utf-8');
  117. });
  118. it('caches hashed assets forever and index.html never', async () => {
  119. const asset = await request(server.port, '/assets/index-abc123.js');
  120. expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable');
  121. const index = await request(server.port, '/');
  122. expect(index.headers['cache-control']).toBe('no-store');
  123. });
  124. it('falls back to index.html for an unknown route, but not for a missing asset', async () => {
  125. // A hash-routed app only ever asks for `/`, but a hand-typed deep path
  126. // should still open the app.
  127. const route = await request(server.port, '/s/some-symbol-id');
  128. expect(route.status).toBe(200);
  129. expect(route.body).toContain('<div id="app">');
  130. // A missing FILE must 404 — answering with HTML would hand the browser a
  131. // script that isn't one, and hide a broken build.
  132. const asset = await request(server.port, '/assets/index-doesnotexist.js');
  133. expect(asset.status).toBe(404);
  134. });
  135. it('answers HEAD with the same headers and no body', async () => {
  136. const res = await request(server.port, '/', { method: 'HEAD' });
  137. expect(res.status).toBe(200);
  138. expect(res.headers['content-type']).toBe('text/html; charset=utf-8');
  139. expect(res.headers['content-length']).toBeDefined();
  140. expect(res.body).toBe('');
  141. });
  142. });
  143. describe('binding', () => {
  144. it('listens on loopback only', () => {
  145. const address = server.server.address();
  146. expect(address).not.toBeNull();
  147. expect(typeof address === 'object' ? address?.address : null).toBe('127.0.0.1');
  148. expect(server.url).toBe(`http://127.0.0.1:${server.port}`);
  149. });
  150. it('falls back to the next free port when the preferred one is taken', async () => {
  151. const blocker = http.createServer(() => {});
  152. await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
  153. const taken = (blocker.address() as { port: number }).port;
  154. const second = await startUiServer({ projectRoot, viewerDir, port: taken });
  155. try {
  156. expect(second.port).not.toBe(taken);
  157. expect(second.port).toBeGreaterThan(taken);
  158. // …and it actually works on the port it landed on.
  159. const res = await request(second.port, '/');
  160. expect(res.status).toBe(200);
  161. } finally {
  162. await second.close();
  163. await new Promise<void>((resolve) => blocker.close(() => resolve()));
  164. }
  165. });
  166. it('refuses to move off a port the caller pinned', async () => {
  167. const blocker = http.createServer(() => {});
  168. await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
  169. const taken = (blocker.address() as { port: number }).port;
  170. try {
  171. await expect(
  172. startUiServer({ projectRoot, viewerDir, port: taken, portFallback: false })
  173. ).rejects.toThrow(/already in use/i);
  174. } finally {
  175. await new Promise<void>((resolve) => blocker.close(() => resolve()));
  176. }
  177. });
  178. });
  179. describe('Host allowlist (DNS rebinding)', () => {
  180. it('serves the loopback names', async () => {
  181. for (const host of ['127.0.0.1', 'localhost', '[::1]', `localhost:${server.port}`, `[::1]:${server.port}`]) {
  182. const res = await request(server.port, '/', { headers: { Host: host } });
  183. expect(res.status, `Host: ${host}`).toBe(200);
  184. }
  185. });
  186. it('refuses a foreign Host', async () => {
  187. for (const host of ['evil.example', `evil.example:${server.port}`, 'attacker.localhost.evil.com']) {
  188. const res = await request(server.port, '/', { headers: { Host: host } });
  189. expect(res.status, `Host: ${host}`).toBe(403);
  190. expect(res.body).not.toContain('<div id="app">');
  191. }
  192. });
  193. it('refuses a loopback Host carrying someone else\u2019s port', async () => {
  194. const res = await request(server.port, '/', { headers: { Host: '127.0.0.1:9' } });
  195. expect(res.status).toBe(403);
  196. });
  197. it('refuses a malformed or missing Host', async () => {
  198. const malformed = await request(server.port, '/', { headers: { Host: '127.0.0.1:notaport' } });
  199. expect(malformed.status).toBe(403);
  200. // Node's client insists on sending something for Host, so the empty-value
  201. // case is covered by the unit assertions on isAllowedHost below.
  202. });
  203. it('refuses before touching the filesystem — even for an asset', async () => {
  204. const res = await request(server.port, '/assets/index-abc123.js', {
  205. headers: { Host: 'evil.example' },
  206. });
  207. expect(res.status).toBe(403);
  208. expect(res.body).not.toContain('export const viewer');
  209. });
  210. });
  211. describe('cross-origin', () => {
  212. it('never sends CORS headers', async () => {
  213. const res = await request(server.port, '/');
  214. expect(res.headers['access-control-allow-origin']).toBeUndefined();
  215. expect(res.headers['access-control-allow-credentials']).toBeUndefined();
  216. expect(res.headers['access-control-allow-methods']).toBeUndefined();
  217. });
  218. it('refuses a request carrying a foreign Origin', async () => {
  219. const res = await request(server.port, '/', { headers: { Origin: 'https://evil.example' } });
  220. expect(res.status).toBe(403);
  221. });
  222. it('allows the viewer\u2019s own origin', async () => {
  223. const res = await request(server.port, '/', {
  224. headers: { Origin: `http://127.0.0.1:${server.port}` },
  225. });
  226. expect(res.status).toBe(200);
  227. });
  228. it('sends the hardening headers on every response', async () => {
  229. const res = await request(server.port, '/');
  230. expect(res.headers['x-content-type-options']).toBe('nosniff');
  231. expect(res.headers['x-frame-options']).toBe('DENY');
  232. expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'");
  233. expect(res.headers['content-security-policy']).toContain("connect-src 'self'");
  234. });
  235. });
  236. describe('read-only', () => {
  237. it('refuses every method that is not GET or HEAD', async () => {
  238. for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
  239. const res = await request(server.port, '/', { method });
  240. expect(res.status, method).toBe(405);
  241. expect(res.headers['allow']).toBe('GET, HEAD');
  242. }
  243. });
  244. });
  245. describe('paths outside the asset root', () => {
  246. const traversals = [
  247. '/../secret.txt',
  248. '/../../secret.txt',
  249. '/assets/../../secret.txt',
  250. '/..%2fsecret.txt',
  251. '/%2e%2e/secret.txt',
  252. '/%2e%2e%2fsecret.txt',
  253. '/....//secret.txt',
  254. ];
  255. it('never serves a file outside the viewer directory', async () => {
  256. for (const traversal of traversals) {
  257. const res = await request(server.port, traversal);
  258. expect(res.body, traversal).not.toContain('SUPER-SECRET-VALUE');
  259. expect(res.status, traversal).not.toBe(200);
  260. }
  261. });
  262. it('404s an absolute system path rather than reading it', async () => {
  263. const res = await request(server.port, '/etc/passwd');
  264. expect(res.body).not.toContain('root:');
  265. // No such file under the viewer root; an extension-less path is a route.
  266. expect(res.status).toBe(200);
  267. expect(res.body).toContain('<div id="app">');
  268. const shadow = await request(server.port, '/etc/hosts.txt');
  269. expect(shadow.status).toBe(404);
  270. });
  271. it('404s a NUL-truncation attempt', async () => {
  272. const res = await request(server.port, '/index.html%00.png');
  273. expect(res.status).toBe(404);
  274. });
  275. });
  276. describe('/api is reserved', () => {
  277. it('404s as JSON, never as the app shell', async () => {
  278. const res = await request(server.port, '/api/nodes');
  279. expect(res.status).toBe(404);
  280. expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
  281. expect(JSON.parse(res.body)).toHaveProperty('error');
  282. expect(res.body).not.toContain('<div id="app">');
  283. });
  284. it('hands requests to a mounted handler with a decoded path and query', async () => {
  285. const seen: Array<{ pathname: string; symbol: string | null; root: string }> = [];
  286. const withApi = await startUiServer({
  287. projectRoot,
  288. viewerDir,
  289. port: 0,
  290. api: (_req, res, ctx) => {
  291. seen.push({
  292. pathname: ctx.pathname,
  293. symbol: ctx.query.get('symbol'),
  294. root: ctx.projectRoot,
  295. });
  296. res.writeHead(200, { 'Content-Type': 'application/json' });
  297. res.end('{"ok":true}');
  298. return true;
  299. },
  300. });
  301. try {
  302. const res = await request(withApi.port, '/api/node?symbol=parse%20Token');
  303. expect(res.status).toBe(200);
  304. expect(JSON.parse(res.body)).toEqual({ ok: true });
  305. expect(seen).toEqual([{ pathname: '/api/node', symbol: 'parse Token', root: projectRoot }]);
  306. } finally {
  307. await withApi.close();
  308. }
  309. });
  310. it('turns a throwing handler into a JSON 500, not a crashed server', async () => {
  311. const withApi = await startUiServer({
  312. projectRoot,
  313. viewerDir,
  314. port: 0,
  315. api: () => {
  316. throw new Error('handler blew up');
  317. },
  318. });
  319. try {
  320. const res = await request(withApi.port, '/api/boom');
  321. expect(res.status).toBe(500);
  322. expect(JSON.parse(res.body).error).toContain('handler blew up');
  323. // Still alive afterwards.
  324. expect((await request(withApi.port, '/')).status).toBe(200);
  325. } finally {
  326. await withApi.close();
  327. }
  328. });
  329. });
  330. });
  331. describe('resolveProjectFile — the source read chokepoint', () => {
  332. let tempDir: string;
  333. let projectRoot: string;
  334. beforeAll(() => {
  335. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-paths-'));
  336. projectRoot = path.join(tempDir, 'project');
  337. fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
  338. fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n');
  339. fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n');
  340. });
  341. afterAll(() => {
  342. fs.rmSync(tempDir, { recursive: true, force: true });
  343. });
  344. it('resolves a file inside the project', () => {
  345. expect(resolveProjectFile(projectRoot, 'src/auth.ts')).toBe(
  346. fs.realpathSync(path.join(projectRoot, 'src', 'auth.ts'))
  347. );
  348. });
  349. it('refuses traversal out of the project', () => {
  350. for (const escape of ['../secret.txt', 'src/../../secret.txt', '..%2fsecret.txt']) {
  351. expect(() => resolveProjectFile(projectRoot, escape), escape).toThrow(PathRefusalError);
  352. }
  353. });
  354. it('refuses an absolute path', () => {
  355. expect(() => resolveProjectFile(projectRoot, path.join(tempDir, 'secret.txt'))).toThrow(
  356. PathRefusalError
  357. );
  358. });
  359. it('refuses an empty path', () => {
  360. expect(() => resolveProjectFile(projectRoot, '')).toThrow(PathRefusalError);
  361. expect(() => resolveProjectFile(projectRoot, ' ')).toThrow(PathRefusalError);
  362. });
  363. it('refuses a NUL byte', () => {
  364. expect(() => resolveProjectFile(projectRoot, 'src/auth.ts%00.png')).toThrow(PathRefusalError);
  365. });
  366. // `/etc` resolves to a non-existent `C:\etc` on Windows, so the sensitive-path
  367. // list only means anything on POSIX.
  368. it.runIf(process.platform !== 'win32')('refuses a sensitive system directory as the root', () => {
  369. expect(() => resolveProjectFile('/etc', 'passwd')).toThrow(PathRefusalError);
  370. expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError);
  371. });
  372. it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => {
  373. const link = path.join(projectRoot, 'src', 'escape.ts');
  374. fs.symlinkSync(path.join(tempDir, 'secret.txt'), link);
  375. try {
  376. expect(() => resolveProjectFile(projectRoot, 'src/escape.ts')).toThrow(PathRefusalError);
  377. } finally {
  378. fs.unlinkSync(link);
  379. }
  380. });
  381. });
  382. describe('security helpers', () => {
  383. it('isAllowedHost accepts only loopback names on our port', () => {
  384. expect(isAllowedHost('127.0.0.1', 4747)).toBe(true);
  385. expect(isAllowedHost('127.0.0.1:4747', 4747)).toBe(true);
  386. expect(isAllowedHost('localhost:4747', 4747)).toBe(true);
  387. expect(isAllowedHost('LOCALHOST', 4747)).toBe(true);
  388. expect(isAllowedHost('[::1]:4747', 4747)).toBe(true);
  389. expect(isAllowedHost(undefined, 4747)).toBe(false);
  390. expect(isAllowedHost('', 4747)).toBe(false);
  391. expect(isAllowedHost('evil.example', 4747)).toBe(false);
  392. expect(isAllowedHost('127.0.0.1:4748', 4747)).toBe(false);
  393. expect(isAllowedHost('127.0.0.1.evil.example', 4747)).toBe(false);
  394. expect(isAllowedHost('localhost.evil.example:4747', 4747)).toBe(false);
  395. expect(isAllowedHost('127.0.0.1:4747:4747', 4747)).toBe(false);
  396. // Unbracketed IPv6 is malformed per RFC 7230 — rejected, not guessed at.
  397. expect(isAllowedHost('::1', 4747)).toBe(false);
  398. // A non-loopback address that merely resolves here still fails the check.
  399. expect(isAllowedHost('192.168.1.5:4747', 4747)).toBe(false);
  400. });
  401. it('isAllowedOrigin allows absent and same-origin, refuses everything else', () => {
  402. expect(isAllowedOrigin(undefined, 4747)).toBe(true);
  403. expect(isAllowedOrigin('http://127.0.0.1:4747', 4747)).toBe(true);
  404. expect(isAllowedOrigin('http://localhost:4747', 4747)).toBe(true);
  405. expect(isAllowedOrigin('http://[::1]:4747', 4747)).toBe(true);
  406. expect(isAllowedOrigin('null', 4747)).toBe(false);
  407. expect(isAllowedOrigin('https://evil.example', 4747)).toBe(false);
  408. expect(isAllowedOrigin('http://127.0.0.1:4748', 4747)).toBe(false);
  409. expect(isAllowedOrigin('file://', 4747)).toBe(false);
  410. expect(isAllowedOrigin('not a url', 4747)).toBe(false);
  411. });
  412. it('isSafeRequestPath rejects a `..` segment however it is spelled', () => {
  413. expect(isSafeRequestPath('/')).toBe(true);
  414. expect(isSafeRequestPath('/assets/index-abc123.js')).toBe(true);
  415. expect(isSafeRequestPath('/s/Some.Symbol')).toBe(true);
  416. expect(isSafeRequestPath('/../secret')).toBe(false);
  417. expect(isSafeRequestPath('/a/../../secret')).toBe(false);
  418. expect(isSafeRequestPath('/%2e%2e/secret')).toBe(false);
  419. expect(isSafeRequestPath('/..%2Fsecret')).toBe(false);
  420. expect(isSafeRequestPath('/a%00b')).toBe(false);
  421. expect(isSafeRequestPath('/a\\b')).toBe(false);
  422. expect(isSafeRequestPath('/%zz')).toBe(false);
  423. });
  424. it('resolveStaticAsset returns null for anything that is not a file in the root', () => {
  425. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-static-'));
  426. try {
  427. fs.mkdirSync(path.join(dir, 'assets'));
  428. fs.writeFileSync(path.join(dir, 'index.html'), 'x');
  429. expect(resolveStaticAsset(dir, '/index.html')).toBe(
  430. fs.realpathSync(path.join(dir, 'index.html'))
  431. );
  432. expect(resolveStaticAsset(dir, '/assets')).toBeNull(); // a directory
  433. expect(resolveStaticAsset(dir, '/missing.js')).toBeNull();
  434. expect(resolveStaticAsset(dir, '/../etc/passwd')).toBeNull();
  435. } finally {
  436. fs.rmSync(dir, { recursive: true, force: true });
  437. }
  438. });
  439. it('contentTypeFor covers the viewer bundle and defaults safely', () => {
  440. expect(contentTypeFor('a/index.html')).toBe('text/html; charset=utf-8');
  441. expect(contentTypeFor('a/index-abc.js')).toBe('text/javascript; charset=utf-8');
  442. expect(contentTypeFor('a/archivo.woff2')).toBe('font/woff2');
  443. expect(contentTypeFor('a/thing.unknownext')).toBe('application/octet-stream');
  444. });
  445. it('cacheControlFor pins hashed assets and never index.html', () => {
  446. expect(cacheControlFor(path.join('assets', 'index-abc.js'))).toContain('immutable');
  447. expect(cacheControlFor('index.html')).toBe('no-store');
  448. });
  449. });
  450. describe('browserOpenCommand', () => {
  451. it('uses the platform opener', () => {
  452. expect(browserOpenCommand('http://x', 'darwin')).toEqual({ command: 'open', args: ['http://x'] });
  453. expect(browserOpenCommand('http://x', 'linux')).toEqual({ command: 'xdg-open', args: ['http://x'] });
  454. expect(browserOpenCommand('http://x', 'win32')).toEqual({
  455. command: 'cmd',
  456. args: ['/c', 'start', '', 'http://x'],
  457. });
  458. });
  459. it('honours the CODEGRAPH_BROWSER override', () => {
  460. expect(browserOpenCommand('http://x', 'darwin', 'firefox')).toEqual({
  461. command: 'firefox',
  462. args: ['http://x'],
  463. });
  464. // Windows routes the override through cmd so a `.cmd`/`.bat` shim — which
  465. // CreateProcess cannot launch directly — still works.
  466. expect(browserOpenCommand('http://x', 'win32', 'C:\\tools\\open.cmd')).toEqual({
  467. command: 'cmd',
  468. args: ['/c', 'C:\\tools\\open.cmd', 'http://x'],
  469. });
  470. for (const off of ['none', 'NONE', '0', 'false', 'off', '', ' ']) {
  471. expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull();
  472. }
  473. });
  474. });