Skip to content

Commit 674121e

Browse files
fix: add CDP endpoint probing, parsing and tests
Introduce robust CDP endpoint handling and tests. Added toProbeUrl validation (supports ws/wss -> http/https, rejects unsupported protocols) and parseCdpPort with CliError on invalid ports. probeCdpEndpoint now selects http/https transport based on the endpoint URL. resolveLiveSiteEndpoint now tries saved, env (normalizes), then default endpoints and returns the source ('saved' | 'env' | 'default'). Wire parseCdpPort into the CLI default endpoint construction. Added comprehensive Vitest coverage in src/connections.test.ts for saving/removing connections, port validation, probing behavior, transport selection, and endpoint fallback logic.
1 parent c735204 commit 674121e

3 files changed

Lines changed: 225 additions & 7 deletions

File tree

src/connections.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { CliError } from './errors.js';
5+
6+
const { TEST_HOME, httpGetMock, httpsGetMock } = vi.hoisted(() => ({
7+
TEST_HOME: '/tmp/opencli-connections-vitest',
8+
httpGetMock: vi.fn(),
9+
httpsGetMock: vi.fn(),
10+
}));
11+
12+
vi.mock('node:os', async () => {
13+
const actual = await vi.importActual<typeof import('node:os')>('node:os');
14+
return {
15+
...actual,
16+
homedir: () => TEST_HOME,
17+
};
18+
});
19+
20+
vi.mock('node:http', async () => {
21+
const actual = await vi.importActual<typeof import('node:http')>('node:http');
22+
return {
23+
...actual,
24+
get: httpGetMock,
25+
};
26+
});
27+
28+
vi.mock('node:https', async () => {
29+
const actual = await vi.importActual<typeof import('node:https')>('node:https');
30+
return {
31+
...actual,
32+
get: httpsGetMock,
33+
};
34+
});
35+
36+
import {
37+
getSavedSiteConnection,
38+
loadConnections,
39+
parseCdpPort,
40+
probeCdpEndpoint,
41+
removeSiteConnection,
42+
resolveLiveSiteEndpoint,
43+
saveSiteConnection,
44+
} from './connections.js';
45+
46+
const CONNECTIONS_PATH = path.join(TEST_HOME, '.opencli', 'connections.json');
47+
48+
function mockRequest() {
49+
return {
50+
on: vi.fn().mockReturnThis(),
51+
setTimeout: vi.fn().mockReturnThis(),
52+
destroy: vi.fn(),
53+
} as any;
54+
}
55+
56+
function mockProbeStatuses(statusByUrl: Record<string, number>) {
57+
const req = mockRequest();
58+
httpGetMock.mockImplementation((input: string | URL, cb: any) => {
59+
cb({ statusCode: statusByUrl[String(input)] ?? 503, resume() {} });
60+
return req;
61+
});
62+
return req;
63+
}
64+
65+
describe('connections', () => {
66+
beforeEach(() => {
67+
fs.rmSync(TEST_HOME, { recursive: true, force: true });
68+
delete process.env.OPENCLI_CDP_ENDPOINT;
69+
httpGetMock.mockReset();
70+
httpsGetMock.mockReset();
71+
});
72+
73+
afterEach(() => {
74+
delete process.env.OPENCLI_CDP_ENDPOINT;
75+
vi.restoreAllMocks();
76+
});
77+
78+
it('saves and removes site connections', () => {
79+
const saved = saveSiteConnection('chatwise', 'http://127.0.0.1:9228/');
80+
81+
expect(saved.endpoint).toBe('http://127.0.0.1:9228');
82+
expect(getSavedSiteConnection('chatwise')?.endpoint).toBe('http://127.0.0.1:9228');
83+
expect(loadConnections().cdp.chatwise?.endpoint).toBe('http://127.0.0.1:9228');
84+
expect(fs.existsSync(CONNECTIONS_PATH)).toBe(true);
85+
86+
removeSiteConnection('chatwise');
87+
88+
expect(getSavedSiteConnection('chatwise')).toBeUndefined();
89+
expect(loadConnections().cdp.chatwise).toBeUndefined();
90+
});
91+
92+
it('validates CDP ports', () => {
93+
expect(parseCdpPort('9222')).toBe(9222);
94+
expect(() => parseCdpPort('abc')).toThrowError(CliError);
95+
expect(() => parseCdpPort('70000')).toThrowError(CliError);
96+
});
97+
98+
it('probes ws endpoints via the http json/version URL', async () => {
99+
const req = mockRequest();
100+
httpGetMock.mockImplementation((input: string | URL, cb: any) => {
101+
cb({ statusCode: 200, resume() {} });
102+
return req;
103+
});
104+
105+
await expect(probeCdpEndpoint('ws://127.0.0.1:9222/devtools/browser/abc')).resolves.toBe(true);
106+
expect(httpGetMock).toHaveBeenCalledTimes(1);
107+
expect(String(httpGetMock.mock.calls[0][0])).toBe('http://127.0.0.1:9222/json/version');
108+
});
109+
110+
it('probes https endpoints with the https transport', async () => {
111+
const req = mockRequest();
112+
httpsGetMock.mockImplementation((input: string | URL, cb: any) => {
113+
cb({ statusCode: 204, resume() {} });
114+
return req;
115+
});
116+
117+
await expect(probeCdpEndpoint('https://example.com:9222')).resolves.toBe(true);
118+
expect(httpsGetMock).toHaveBeenCalledTimes(1);
119+
expect(String(httpsGetMock.mock.calls[0][0])).toBe('https://example.com:9222/json/version');
120+
});
121+
122+
it('rejects unsupported endpoint protocols with a clear error', async () => {
123+
await expect(probeCdpEndpoint('ftp://example.com')).rejects.toMatchObject({
124+
code: 'CONNECT_UNSUPPORTED_PROTOCOL',
125+
});
126+
});
127+
128+
it('resolves a saved endpoint before falling back', async () => {
129+
saveSiteConnection('chatwise', 'http://127.0.0.1:9555');
130+
mockProbeStatuses({
131+
'http://127.0.0.1:9555/json/version': 200,
132+
});
133+
134+
await expect(resolveLiveSiteEndpoint('chatwise')).resolves.toEqual({
135+
endpoint: 'http://127.0.0.1:9555',
136+
source: 'saved',
137+
});
138+
});
139+
140+
it('falls back to the env endpoint when the saved one is unavailable', async () => {
141+
saveSiteConnection('chatwise', 'http://127.0.0.1:9555');
142+
process.env.OPENCLI_CDP_ENDPOINT = 'http://127.0.0.1:9666/';
143+
mockProbeStatuses({
144+
'http://127.0.0.1:9666/json/version': 200,
145+
});
146+
147+
await expect(resolveLiveSiteEndpoint('chatwise')).resolves.toEqual({
148+
endpoint: 'http://127.0.0.1:9666',
149+
source: 'env',
150+
});
151+
});
152+
153+
it('falls back to the default site endpoint when no live saved or env endpoint exists', async () => {
154+
mockProbeStatuses({
155+
'http://127.0.0.1:9228/json/version': 200,
156+
});
157+
158+
await expect(resolveLiveSiteEndpoint('chatwise')).resolves.toEqual({
159+
endpoint: 'http://127.0.0.1:9228',
160+
source: 'default',
161+
});
162+
});
163+
});

src/connections.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import * as fs from 'node:fs';
22
import * as http from 'node:http';
3+
import * as https from 'node:https';
34
import * as os from 'node:os';
45
import * as path from 'node:path';
6+
import { CliError } from './errors.js';
57
import type { CliCommand } from './registry.js';
68

79
export type SavedConnection = {
810
endpoint: string;
911
updatedAt: string;
1012
};
1113

14+
type LiveEndpointSource = 'saved' | 'env' | 'default';
15+
1216
type ConnectionsFile = {
1317
version: 1;
1418
cdp: Record<string, SavedConnection>;
@@ -34,6 +38,46 @@ function normalizeEndpoint(endpoint: string): string {
3438
return endpoint.trim().replace(/\/+$/, '');
3539
}
3640

41+
function toProbeUrl(endpoint: string): URL {
42+
let url: URL;
43+
try {
44+
url = new URL(normalizeEndpoint(endpoint));
45+
} catch {
46+
throw new CliError(
47+
'CONNECT_INVALID_ENDPOINT',
48+
`Invalid CDP endpoint: ${endpoint}.`,
49+
'Use a full URL such as http://127.0.0.1:9222 or ws://127.0.0.1:9222/devtools/browser/<id>.'
50+
);
51+
}
52+
53+
if (url.protocol === 'ws:') url.protocol = 'http:';
54+
else if (url.protocol === 'wss:') url.protocol = 'https:';
55+
else if (url.protocol !== 'http:' && url.protocol !== 'https:') {
56+
throw new CliError(
57+
'CONNECT_UNSUPPORTED_PROTOCOL',
58+
`Unsupported CDP endpoint protocol: ${url.protocol}`,
59+
'Use an http(s) or ws(s) CDP endpoint.'
60+
);
61+
}
62+
63+
url.pathname = '/json/version';
64+
url.search = '';
65+
url.hash = '';
66+
return url;
67+
}
68+
69+
export function parseCdpPort(value: string): number {
70+
const port = Number.parseInt(value, 10);
71+
if (!Number.isInteger(port) || String(port) !== value.trim() || port < 1 || port > 65535) {
72+
throw new CliError(
73+
'CONNECT_INVALID_PORT',
74+
`Invalid CDP port: ${value}.`,
75+
'Provide an integer between 1 and 65535.'
76+
);
77+
}
78+
return port;
79+
}
80+
3781
export function isDesktopCdpSite(site: string): boolean {
3882
return site in DESKTOP_CDP_SITES;
3983
}
@@ -88,9 +132,10 @@ export function defaultSiteEndpoint(site: string): string | undefined {
88132
}
89133

90134
export async function probeCdpEndpoint(endpoint: string, timeoutMs = 800): Promise<boolean> {
91-
const normalized = normalizeEndpoint(endpoint);
135+
const probeUrl = toProbeUrl(endpoint);
136+
const transport = probeUrl.protocol === 'https:' ? https : http;
92137
return await new Promise<boolean>((resolve) => {
93-
const req = http.get(`${normalized}/json/version`, (res) => {
138+
const req = transport.get(probeUrl, (res) => {
94139
res.resume();
95140
resolve((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300);
96141
});
@@ -102,10 +147,19 @@ export async function probeCdpEndpoint(endpoint: string, timeoutMs = 800): Promi
102147
});
103148
}
104149

105-
export async function resolveLiveSiteEndpoint(site: string): Promise<{ endpoint?: string; source?: 'saved' | 'default' }> {
106-
const saved = getSavedSiteConnection(site)?.endpoint;
107-
if (saved && await probeCdpEndpoint(saved)) {
108-
return { endpoint: saved, source: 'saved' };
150+
export async function resolveLiveSiteEndpoint(site: string): Promise<{ endpoint?: string; source?: LiveEndpointSource }> {
151+
const candidates: Array<{ endpoint?: string; source: LiveEndpointSource; normalize?: boolean }> = [
152+
{ endpoint: getSavedSiteConnection(site)?.endpoint, source: 'saved' },
153+
{ endpoint: process.env.OPENCLI_CDP_ENDPOINT, source: 'env', normalize: true },
154+
{ endpoint: defaultSiteEndpoint(site), source: 'default' },
155+
];
156+
157+
for (const candidate of candidates) {
158+
if (!candidate.endpoint || !await probeCdpEndpoint(candidate.endpoint)) continue;
159+
return {
160+
endpoint: candidate.normalize ? normalizeEndpoint(candidate.endpoint) : candidate.endpoint,
161+
source: candidate.source,
162+
};
109163
}
110164

111165
return {};

src/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
defaultSiteEndpoint,
2323
isDesktopCdpCommand,
2424
isDesktopCdpSite,
25+
parseCdpPort,
2526
probeCdpEndpoint,
2627
removeSiteConnection,
2728
resolveLiveSiteEndpoint,
@@ -186,7 +187,7 @@ program.command('connect')
186187

187188
const endpoint = opts.endpoint
188189
? String(opts.endpoint)
189-
: `http://127.0.0.1:${opts.port ? String(opts.port) : DESKTOP_CDP_SITES[site].defaultPort}`;
190+
: `http://127.0.0.1:${opts.port ? parseCdpPort(String(opts.port)) : DESKTOP_CDP_SITES[site].defaultPort}`;
190191

191192
const ok = await probeCdpEndpoint(endpoint);
192193
if (!ok) {

0 commit comments

Comments
 (0)