@harness-fe/mcp-server 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/auth.d.ts +53 -0
- package/dist/auth.js +212 -0
- package/dist/bridge.d.ts +302 -0
- package/dist/bridge.js +1580 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +277 -0
- package/dist/daemon.d.ts +98 -0
- package/dist/daemon.js +80 -0
- package/dist/dashboardApi.d.ts +40 -0
- package/dist/dashboardApi.js +142 -0
- package/dist/dashboardSpa.d.ts +18 -0
- package/dist/dashboardSpa.js +180 -0
- package/dist/dashboardUrl.d.ts +13 -0
- package/dist/dashboardUrl.js +18 -0
- package/dist/eventsHandler.d.ts +24 -0
- package/dist/eventsHandler.js +114 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.js +923 -0
- package/dist/mcpHttp.d.ts +39 -0
- package/dist/mcpHttp.js +49 -0
- package/dist/openBrowser.d.ts +33 -0
- package/dist/openBrowser.js +63 -0
- package/dist/remoteBridge.d.ts +61 -0
- package/dist/remoteBridge.js +307 -0
- package/dist/replayCreate.d.ts +36 -0
- package/dist/replayCreate.js +156 -0
- package/dist/replayViewer.d.ts +20 -0
- package/dist/replayViewer.js +168 -0
- package/dist/sessionRouter.d.ts +42 -0
- package/dist/sessionRouter.js +88 -0
- package/dist/store/JsonMemoryStore.d.ts +52 -0
- package/dist/store/JsonMemoryStore.js +119 -0
- package/dist/store/JsonTaskStore.d.ts +21 -0
- package/dist/store/JsonTaskStore.js +53 -0
- package/dist/store/JsonlStore.d.ts +128 -0
- package/dist/store/JsonlStore.js +1168 -0
- package/dist/store/MemoryEventStore.d.ts +47 -0
- package/dist/store/MemoryEventStore.js +111 -0
- package/dist/store/WriteQueue.d.ts +51 -0
- package/dist/store/WriteQueue.js +142 -0
- package/dist/store/index.d.ts +6 -0
- package/dist/store/index.js +5 -0
- package/dist/store/types.d.ts +416 -0
- package/dist/store/types.js +19 -0
- package/package.json +63 -0
- package/src/auth.test.ts +90 -0
- package/src/auth.ts +248 -0
- package/src/bridge-auth.test.ts +196 -0
- package/src/bridge.test.ts +1708 -0
- package/src/bridge.ts +1804 -0
- package/src/cli.ts +315 -0
- package/src/daemon.test.ts +123 -0
- package/src/daemon.ts +161 -0
- package/src/dashboardApi.test.ts +235 -0
- package/src/dashboardApi.ts +184 -0
- package/src/dashboardSpa.test.ts +239 -0
- package/src/dashboardSpa.ts +195 -0
- package/src/dashboardUrl.test.ts +46 -0
- package/src/dashboardUrl.ts +28 -0
- package/src/eventsHandler.test.ts +247 -0
- package/src/eventsHandler.ts +136 -0
- package/src/index.ts +26 -0
- package/src/mcp.ts +1407 -0
- package/src/mcpHttp.test.ts +101 -0
- package/src/mcpHttp.ts +88 -0
- package/src/openBrowser.test.ts +103 -0
- package/src/openBrowser.ts +81 -0
- package/src/remoteBridge.test.ts +119 -0
- package/src/remoteBridge.ts +404 -0
- package/src/replay.test.ts +271 -0
- package/src/replayCreate.ts +194 -0
- package/src/replayViewer.ts +173 -0
- package/src/sessionRouter.ts +116 -0
- package/src/store/JsonMemoryStore.test.ts +175 -0
- package/src/store/JsonMemoryStore.ts +128 -0
- package/src/store/JsonTaskStore.test.ts +212 -0
- package/src/store/JsonTaskStore.ts +59 -0
- package/src/store/JsonlStore.test.ts +1538 -0
- package/src/store/JsonlStore.ts +1321 -0
- package/src/store/MemoryEventStore.test.ts +119 -0
- package/src/store/MemoryEventStore.ts +151 -0
- package/src/store/WriteQueue.ts +165 -0
- package/src/store/index.ts +29 -0
- package/src/store/types.ts +517 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { Bridge } from './bridge.js';
|
|
4
|
+
import { startMcpHttpServer } from './mcpHttp.js';
|
|
5
|
+
import { MemoryEventStore } from './store/MemoryEventStore.js';
|
|
6
|
+
import type { EventStore } from './store/types.js';
|
|
7
|
+
|
|
8
|
+
const cleanups: Array<() => Promise<void> | void> = [];
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
while (cleanups.length) {
|
|
12
|
+
const fn = cleanups.shift();
|
|
13
|
+
if (fn) await fn();
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function startBridge(opts: Parameters<typeof Bridge.prototype.constructor>[0] = {}) {
|
|
18
|
+
const bridge = new Bridge({
|
|
19
|
+
port: 0,
|
|
20
|
+
host: '127.0.0.1',
|
|
21
|
+
store: null,
|
|
22
|
+
taskStore: null,
|
|
23
|
+
autoPurge: { enabled: false },
|
|
24
|
+
...opts,
|
|
25
|
+
});
|
|
26
|
+
await bridge.start();
|
|
27
|
+
cleanups.push(() => bridge.stop());
|
|
28
|
+
return bridge;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('mcpHttp', () => {
|
|
32
|
+
it('mounts on the configured path and 404s other paths', async () => {
|
|
33
|
+
const bridge = await startBridge();
|
|
34
|
+
const handle = await startMcpHttpServer(bridge, { path: '/mcp' });
|
|
35
|
+
cleanups.push(() => handle.close());
|
|
36
|
+
|
|
37
|
+
const port = bridge.getBoundPort()!;
|
|
38
|
+
// GET on /mcp without a session id should still be routed to the
|
|
39
|
+
// transport (which decides what to do); /something-else should fall
|
|
40
|
+
// through to the bridge default handler (404).
|
|
41
|
+
const elsewhere = await fetch(`http://127.0.0.1:${port}/elsewhere`);
|
|
42
|
+
expect(elsewhere.status).toBe(404);
|
|
43
|
+
|
|
44
|
+
const mcpRes = await fetch(`http://127.0.0.1:${port}/mcp`);
|
|
45
|
+
// Transport responds (status varies — what we assert is that the
|
|
46
|
+
// request reached the transport, i.e. it's NOT the bridge 404 body).
|
|
47
|
+
const body = await mcpRes.text();
|
|
48
|
+
expect(body).not.toBe('Not Found');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('accepts a custom EventStore and disables resumability when passed null', async () => {
|
|
52
|
+
const bridge = await startBridge();
|
|
53
|
+
|
|
54
|
+
// Spy store records which events the SDK hands it.
|
|
55
|
+
const stored: Array<{ streamId: string; message: JSONRPCMessage }> = [];
|
|
56
|
+
const spy: EventStore = {
|
|
57
|
+
async storeEvent(streamId, message) {
|
|
58
|
+
stored.push({ streamId, message });
|
|
59
|
+
return `${streamId}::${stored.length}`;
|
|
60
|
+
},
|
|
61
|
+
async replayEventsAfter() {
|
|
62
|
+
return '';
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const h1 = await startMcpHttpServer(bridge, { path: '/mcp1', eventStore: spy });
|
|
67
|
+
cleanups.push(() => h1.close());
|
|
68
|
+
expect(h1.path).toBe('/mcp1');
|
|
69
|
+
|
|
70
|
+
// Disabling resumability is a supported configuration.
|
|
71
|
+
const h2 = await startMcpHttpServer(bridge, { path: '/mcp2', eventStore: null });
|
|
72
|
+
cleanups.push(() => h2.close());
|
|
73
|
+
expect(h2.path).toBe('/mcp2');
|
|
74
|
+
|
|
75
|
+
// And the default path keeps the built-in MemoryEventStore.
|
|
76
|
+
const h3 = await startMcpHttpServer(bridge, {
|
|
77
|
+
path: '/mcp3',
|
|
78
|
+
eventStore: new MemoryEventStore({ maxEventsPerStream: 10 }),
|
|
79
|
+
});
|
|
80
|
+
cleanups.push(() => h3.close());
|
|
81
|
+
expect(h3.path).toBe('/mcp3');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('requires token when bridge has auth enabled', async () => {
|
|
85
|
+
const bridge = await startBridge({ auth: { token: 's3cret' } });
|
|
86
|
+
const handle = await startMcpHttpServer(bridge, { path: '/mcp' });
|
|
87
|
+
cleanups.push(() => handle.close());
|
|
88
|
+
const port = bridge.getBoundPort()!;
|
|
89
|
+
|
|
90
|
+
const noAuth = await fetch(`http://127.0.0.1:${port}/mcp`);
|
|
91
|
+
expect(noAuth.status).toBe(401);
|
|
92
|
+
|
|
93
|
+
const withAuth = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
94
|
+
headers: { authorization: 'Bearer s3cret' },
|
|
95
|
+
});
|
|
96
|
+
// 401 specifically would mean auth still blocked us. Anything else
|
|
97
|
+
// (200 / 405 / 406 / 400 — depending on what the SDK does for a
|
|
98
|
+
// bodyless GET) means we made it past the auth layer.
|
|
99
|
+
expect(withAuth.status).not.toBe(401);
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/mcpHttp.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP over HTTP transport mounted onto the bridge's existing HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the bridge's auth wrapper, so the same `--token` that protects
|
|
5
|
+
* the dashboard also protects MCP tool calls. Remote agents talk to
|
|
6
|
+
* `http://<host>:<port>/mcp` and authenticate via `Authorization: Bearer
|
|
7
|
+
* <token>` like any other client.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
13
|
+
import type { Bridge, IBridge } from './bridge.js';
|
|
14
|
+
import { createMcpServer } from './mcp.js';
|
|
15
|
+
import { MemoryEventStore } from './store/MemoryEventStore.js';
|
|
16
|
+
import type { EventStore } from './store/types.js';
|
|
17
|
+
|
|
18
|
+
export interface McpHttpOptions {
|
|
19
|
+
/** URL path the transport listens on. Default `/mcp`. */
|
|
20
|
+
path?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Whether to use stateful sessions (sessionId in headers) or stateless
|
|
23
|
+
* one-shot requests. Stateful is the spec default and matches what
|
|
24
|
+
* Claude Code expects.
|
|
25
|
+
*/
|
|
26
|
+
stateful?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* EventStore for SSE resumability via `Last-Event-ID`. If a client
|
|
29
|
+
* reconnects after a transient disconnect, the transport replays the
|
|
30
|
+
* events it missed. Defaults to a `MemoryEventStore` with conservative
|
|
31
|
+
* caps (1000 events / 5 minutes / 50 MiB total). Pass `null` to
|
|
32
|
+
* disable resumability entirely.
|
|
33
|
+
*/
|
|
34
|
+
eventStore?: EventStore | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface McpHttpHandle {
|
|
38
|
+
/** Close the MCP server and detach the transport. */
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
path: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mount the MCP HTTP transport on the bridge's HTTP server. Bridge must
|
|
45
|
+
* already have been started; calls `prependHttpHandler` so it runs before
|
|
46
|
+
* the dashboard/replay/events handler chain.
|
|
47
|
+
*/
|
|
48
|
+
export async function startMcpHttpServer(
|
|
49
|
+
bridge: IBridge,
|
|
50
|
+
opts: McpHttpOptions = {},
|
|
51
|
+
): Promise<McpHttpHandle> {
|
|
52
|
+
const path = opts.path ?? '/mcp';
|
|
53
|
+
const stateful = opts.stateful !== false;
|
|
54
|
+
const eventStore =
|
|
55
|
+
opts.eventStore === null
|
|
56
|
+
? undefined
|
|
57
|
+
: opts.eventStore ?? new MemoryEventStore();
|
|
58
|
+
|
|
59
|
+
const server = createMcpServer(bridge);
|
|
60
|
+
const transport = new StreamableHTTPServerTransport({
|
|
61
|
+
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
62
|
+
eventStore,
|
|
63
|
+
});
|
|
64
|
+
await server.connect(transport);
|
|
65
|
+
|
|
66
|
+
const b = bridge as Bridge;
|
|
67
|
+
if (typeof b.prependHttpHandler !== 'function') {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'mcpHttp: bridge does not support prependHttpHandler (need a Bridge instance with HTTP server)',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
b.prependHttpHandler(async (req: IncomingMessage, res: ServerResponse) => {
|
|
74
|
+
const url = req.url ?? '';
|
|
75
|
+
const qi = url.indexOf('?');
|
|
76
|
+
const reqPath = qi < 0 ? url : url.slice(0, qi);
|
|
77
|
+
if (reqPath !== path) return false;
|
|
78
|
+
await transport.handleRequest(req, res);
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
path,
|
|
84
|
+
async close() {
|
|
85
|
+
await server.close();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { openBrowser } from './openBrowser.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unit tests for the cross-platform browser launcher. We mock both
|
|
6
|
+
* `spawn` and the platform/env probes so the test runs deterministically
|
|
7
|
+
* on any host — no actual browser windows pop open in CI.
|
|
8
|
+
*/
|
|
9
|
+
describe('openBrowser', () => {
|
|
10
|
+
function spy() {
|
|
11
|
+
return vi.fn(() => ({
|
|
12
|
+
unref: vi.fn(),
|
|
13
|
+
}) as any);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
it('uses `open` on darwin', () => {
|
|
17
|
+
const spawnFn = spy();
|
|
18
|
+
const out = openBrowser('https://example.test', {
|
|
19
|
+
platformOverride: 'darwin',
|
|
20
|
+
envOverride: {},
|
|
21
|
+
spawnOverride: spawnFn,
|
|
22
|
+
});
|
|
23
|
+
expect(out.opened).toBe(true);
|
|
24
|
+
expect(spawnFn).toHaveBeenCalledWith('open', ['https://example.test'], expect.any(Object));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('uses `xdg-open` on linux', () => {
|
|
28
|
+
const spawnFn = spy();
|
|
29
|
+
const out = openBrowser('https://example.test', {
|
|
30
|
+
platformOverride: 'linux',
|
|
31
|
+
envOverride: {},
|
|
32
|
+
spawnOverride: spawnFn,
|
|
33
|
+
});
|
|
34
|
+
expect(out.opened).toBe(true);
|
|
35
|
+
expect(spawnFn).toHaveBeenCalledWith('xdg-open', ['https://example.test'], expect.any(Object));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('uses `cmd /c start "" <url>` on win32 (empty title is required)', () => {
|
|
39
|
+
const spawnFn = spy();
|
|
40
|
+
const out = openBrowser('https://example.test', {
|
|
41
|
+
platformOverride: 'win32',
|
|
42
|
+
envOverride: {},
|
|
43
|
+
spawnOverride: spawnFn,
|
|
44
|
+
});
|
|
45
|
+
expect(out.opened).toBe(true);
|
|
46
|
+
expect(spawnFn).toHaveBeenCalledWith(
|
|
47
|
+
'cmd',
|
|
48
|
+
['/c', 'start', '', 'https://example.test'],
|
|
49
|
+
expect.any(Object),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('short-circuits when HARNESS_FE_HEADLESS=1 is set', () => {
|
|
54
|
+
const spawnFn = spy();
|
|
55
|
+
const out = openBrowser('https://example.test', {
|
|
56
|
+
platformOverride: 'darwin',
|
|
57
|
+
envOverride: { HARNESS_FE_HEADLESS: '1' },
|
|
58
|
+
spawnOverride: spawnFn,
|
|
59
|
+
});
|
|
60
|
+
expect(out.opened).toBe(false);
|
|
61
|
+
expect(out.reason).toMatch(/HEADLESS/);
|
|
62
|
+
expect(spawnFn).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns opened=false on unsupported platforms with a reason', () => {
|
|
66
|
+
const spawnFn = spy();
|
|
67
|
+
const out = openBrowser('https://example.test', {
|
|
68
|
+
platformOverride: 'freebsd' as NodeJS.Platform,
|
|
69
|
+
envOverride: {},
|
|
70
|
+
spawnOverride: spawnFn,
|
|
71
|
+
});
|
|
72
|
+
expect(out.opened).toBe(false);
|
|
73
|
+
expect(out.reason).toMatch(/unsupported platform/);
|
|
74
|
+
expect(spawnFn).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('catches spawn errors and returns opened=false', () => {
|
|
78
|
+
const spawnFn = vi.fn(() => {
|
|
79
|
+
throw new Error('ENOENT: no such file');
|
|
80
|
+
}) as unknown as typeof openBrowser['arguments'][1]['spawnOverride'];
|
|
81
|
+
const out = openBrowser('https://example.test', {
|
|
82
|
+
platformOverride: 'darwin',
|
|
83
|
+
envOverride: {},
|
|
84
|
+
spawnOverride: spawnFn as any,
|
|
85
|
+
});
|
|
86
|
+
expect(out.opened).toBe(false);
|
|
87
|
+
expect(out.reason).toMatch(/ENOENT/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('detaches and unrefs the child so it survives the parent exit', () => {
|
|
91
|
+
const unref = vi.fn();
|
|
92
|
+
const spawnFn = vi.fn(() => ({ unref })) as any;
|
|
93
|
+
openBrowser('https://example.test', {
|
|
94
|
+
platformOverride: 'darwin',
|
|
95
|
+
envOverride: {},
|
|
96
|
+
spawnOverride: spawnFn,
|
|
97
|
+
});
|
|
98
|
+
const opts = (spawnFn.mock.calls[0]?.[2] ?? {}) as { detached?: boolean; stdio?: string };
|
|
99
|
+
expect(opts.detached).toBe(true);
|
|
100
|
+
expect(opts.stdio).toBe('ignore');
|
|
101
|
+
expect(unref).toHaveBeenCalledOnce();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform "open this URL in the user's default browser" — a tiny
|
|
3
|
+
* wrapper around the OS-native command.
|
|
4
|
+
*
|
|
5
|
+
* Detection rules:
|
|
6
|
+
* - darwin → `open <url>`
|
|
7
|
+
* - linux → `xdg-open <url>`
|
|
8
|
+
* - win32 → `cmd /c start "" <url>` (the empty title is required, otherwise `start` treats the URL as a title)
|
|
9
|
+
*
|
|
10
|
+
* Escape hatches:
|
|
11
|
+
* - `HARNESS_FE_HEADLESS=1` short-circuits and returns `false` without
|
|
12
|
+
* spawning anything — useful when the daemon runs in Docker / CI /
|
|
13
|
+
* remote host where there's no GUI to open
|
|
14
|
+
* - any other platform returns `false`
|
|
15
|
+
*
|
|
16
|
+
* The spawned process is detached and stdio'd to ignore so we don't
|
|
17
|
+
* accidentally tie its lifetime to ours.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
export interface OpenBrowserOptions {
|
|
23
|
+
/** Inject an alternate `process.platform` value, for tests. */
|
|
24
|
+
platformOverride?: NodeJS.Platform;
|
|
25
|
+
/** Inject the env lookup, for tests. */
|
|
26
|
+
envOverride?: Record<string, string | undefined>;
|
|
27
|
+
/** Inject the spawn function, for tests. */
|
|
28
|
+
spawnOverride?: typeof spawn;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OpenBrowserResult {
|
|
32
|
+
opened: boolean;
|
|
33
|
+
/** Set when `opened` is false to explain why. */
|
|
34
|
+
reason?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function openBrowser(url: string, opts: OpenBrowserOptions = {}): OpenBrowserResult {
|
|
38
|
+
const env = opts.envOverride ?? process.env;
|
|
39
|
+
if (env.HARNESS_FE_HEADLESS === '1') {
|
|
40
|
+
return { opened: false, reason: 'HARNESS_FE_HEADLESS=1' };
|
|
41
|
+
}
|
|
42
|
+
const platform = opts.platformOverride ?? process.platform;
|
|
43
|
+
const spawnFn = opts.spawnOverride ?? spawn;
|
|
44
|
+
|
|
45
|
+
let cmd: string;
|
|
46
|
+
let args: string[];
|
|
47
|
+
switch (platform) {
|
|
48
|
+
case 'darwin':
|
|
49
|
+
cmd = 'open';
|
|
50
|
+
args = [url];
|
|
51
|
+
break;
|
|
52
|
+
case 'linux':
|
|
53
|
+
cmd = 'xdg-open';
|
|
54
|
+
args = [url];
|
|
55
|
+
break;
|
|
56
|
+
case 'win32':
|
|
57
|
+
// `start` is a cmd builtin, not a standalone exe. The first
|
|
58
|
+
// empty-string arg is the window title — required, because
|
|
59
|
+
// otherwise `start "https://…"` treats the URL as the title
|
|
60
|
+
// and never opens anything.
|
|
61
|
+
cmd = 'cmd';
|
|
62
|
+
args = ['/c', 'start', '', url];
|
|
63
|
+
break;
|
|
64
|
+
default:
|
|
65
|
+
return { opened: false, reason: `unsupported platform: ${platform}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const child = spawnFn(cmd, args, {
|
|
70
|
+
detached: true,
|
|
71
|
+
stdio: 'ignore',
|
|
72
|
+
});
|
|
73
|
+
child.unref();
|
|
74
|
+
return { opened: true };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return {
|
|
77
|
+
opened: false,
|
|
78
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
import { Bridge } from './bridge.js';
|
|
4
|
+
import { RemoteBridge } from './remoteBridge.js';
|
|
5
|
+
import type { Frame, HelloAckFrame, ResponseFrame } from '@harness-fe/protocol';
|
|
6
|
+
|
|
7
|
+
async function spawnLeader(): Promise<Bridge> {
|
|
8
|
+
const bridge = new Bridge({ port: 0, host: '127.0.0.1', tasksFile: '', store: null });
|
|
9
|
+
await bridge.start();
|
|
10
|
+
return bridge;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getPort(bridge: Bridge): number {
|
|
14
|
+
const port = bridge.getBoundPort();
|
|
15
|
+
if (!port) throw new Error('no address');
|
|
16
|
+
return port;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function fakeRuntimeClient(
|
|
20
|
+
port: number,
|
|
21
|
+
tabId: string,
|
|
22
|
+
): Promise<WebSocket> {
|
|
23
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
24
|
+
await new Promise<void>((resolve, reject) => {
|
|
25
|
+
ws.once('open', () => resolve());
|
|
26
|
+
ws.once('error', reject);
|
|
27
|
+
});
|
|
28
|
+
ws.send(
|
|
29
|
+
JSON.stringify({
|
|
30
|
+
type: 'hello',
|
|
31
|
+
id: 'h-rc',
|
|
32
|
+
role: 'runtime-client',
|
|
33
|
+
projectId: 'demo',
|
|
34
|
+
tabId,
|
|
35
|
+
sessionId: 'sess-1',
|
|
36
|
+
page: { url: 'http://localhost:5173/', title: 'Demo' },
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
await new Promise<HelloAckFrame>((resolve, reject) => {
|
|
40
|
+
const timer = setTimeout(() => reject(new Error('hello.ack timeout')), 1000);
|
|
41
|
+
ws.once('message', (raw) => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
resolve(JSON.parse(raw.toString()) as HelloAckFrame);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
return ws;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('RemoteBridge (follower → leader)', () => {
|
|
50
|
+
it('listTabs reflects tabs registered on the leader', async () => {
|
|
51
|
+
const leader = await spawnLeader();
|
|
52
|
+
try {
|
|
53
|
+
const port = getPort(leader);
|
|
54
|
+
const rc = await fakeRuntimeClient(port, 't-remote-1');
|
|
55
|
+
|
|
56
|
+
const follower = new RemoteBridge({ port, host: '127.0.0.1' });
|
|
57
|
+
await follower.connect();
|
|
58
|
+
try {
|
|
59
|
+
const tabs = await follower.listTabs();
|
|
60
|
+
expect(tabs).toHaveLength(1);
|
|
61
|
+
expect(tabs[0].tabId).toBe('t-remote-1');
|
|
62
|
+
} finally {
|
|
63
|
+
await follower.stop();
|
|
64
|
+
rc.close();
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
await leader.stop();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('sendCommand forwards through leader to runtime-client and returns the result', async () => {
|
|
72
|
+
const leader = await spawnLeader();
|
|
73
|
+
try {
|
|
74
|
+
const port = getPort(leader);
|
|
75
|
+
const rc = await fakeRuntimeClient(port, 't-remote-2');
|
|
76
|
+
// Echo: respond ok with { echoed: args }
|
|
77
|
+
rc.on('message', (raw) => {
|
|
78
|
+
const frame = JSON.parse(raw.toString()) as Frame;
|
|
79
|
+
if (frame.type !== 'command') return;
|
|
80
|
+
const reply: ResponseFrame = {
|
|
81
|
+
type: 'response',
|
|
82
|
+
id: frame.id,
|
|
83
|
+
ok: true,
|
|
84
|
+
result: { echoed: frame.args },
|
|
85
|
+
};
|
|
86
|
+
rc.send(JSON.stringify(reply));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const follower = new RemoteBridge({ port, host: '127.0.0.1' });
|
|
90
|
+
await follower.connect();
|
|
91
|
+
try {
|
|
92
|
+
const out = (await follower.sendCommand(
|
|
93
|
+
'page.evaluate',
|
|
94
|
+
{ expr: '1+1' },
|
|
95
|
+
{ tabId: 't-remote-2' },
|
|
96
|
+
)) as { echoed: { expr: string } };
|
|
97
|
+
expect(out.echoed.expr).toBe('1+1');
|
|
98
|
+
} finally {
|
|
99
|
+
await follower.stop();
|
|
100
|
+
rc.close();
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
await leader.stop();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects pending calls when leader disappears', async () => {
|
|
108
|
+
const leader = await spawnLeader();
|
|
109
|
+
const port = getPort(leader);
|
|
110
|
+
const follower = new RemoteBridge({ port, host: '127.0.0.1' });
|
|
111
|
+
await follower.connect();
|
|
112
|
+
// Issue a call with no runtime-client connected → leader will throw; we just
|
|
113
|
+
// need to confirm follower receives the propagated error frame cleanly.
|
|
114
|
+
const callPromise = follower.sendCommand('page.click', { selector: { css: '#x' } });
|
|
115
|
+
await expect(callPromise).rejects.toThrow(/no runtime-client|connection|closed/i);
|
|
116
|
+
await follower.stop();
|
|
117
|
+
await leader.stop();
|
|
118
|
+
});
|
|
119
|
+
});
|