@harness-fe/mcp-server 4.0.0-next.1 → 4.0.0-next.3

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.
Files changed (100) hide show
  1. package/dist/bin.d.ts +2 -0
  2. package/dist/bin.js +15 -0
  3. package/dist/daemon.d.ts +3 -3
  4. package/dist/daemon.js +1 -1
  5. package/dist/index.d.ts +4 -4
  6. package/dist/index.js +3 -3
  7. package/dist/mcp.d.ts +2 -2
  8. package/dist/mcp.js +49 -15
  9. package/dist/mcpHttp.d.ts +2 -2
  10. package/dist/mcpHttp.js +8 -2
  11. package/package.json +5 -7
  12. package/src/bin.ts +19 -0
  13. package/src/daemon.ts +3 -3
  14. package/src/experimental.test.ts +2 -2
  15. package/src/index.ts +4 -4
  16. package/src/mcp.ts +51 -19
  17. package/src/mcpHttp.test.ts +3 -3
  18. package/src/mcpHttp.ts +10 -4
  19. package/src/mcpLayer.e2e.test.ts +2 -2
  20. package/src/newCapabilities.e2e.test.ts +3 -3
  21. package/dist/auth.d.ts +0 -53
  22. package/dist/auth.js +0 -212
  23. package/dist/bridge.d.ts +0 -323
  24. package/dist/bridge.js +0 -1618
  25. package/dist/cli.d.ts +0 -18
  26. package/dist/cli.js +0 -293
  27. package/dist/dashboardApi.d.ts +0 -40
  28. package/dist/dashboardApi.js +0 -142
  29. package/dist/dashboardSpa.d.ts +0 -18
  30. package/dist/dashboardSpa.js +0 -180
  31. package/dist/dashboardUrl.d.ts +0 -13
  32. package/dist/dashboardUrl.js +0 -18
  33. package/dist/eventsHandler.d.ts +0 -24
  34. package/dist/eventsHandler.js +0 -114
  35. package/dist/identity.d.ts +0 -74
  36. package/dist/identity.js +0 -101
  37. package/dist/openBrowser.d.ts +0 -33
  38. package/dist/openBrowser.js +0 -63
  39. package/dist/remoteBridge.d.ts +0 -61
  40. package/dist/remoteBridge.js +0 -307
  41. package/dist/replayCreate.d.ts +0 -36
  42. package/dist/replayCreate.js +0 -156
  43. package/dist/replayViewer.d.ts +0 -20
  44. package/dist/replayViewer.js +0 -168
  45. package/dist/sessionRouter.d.ts +0 -45
  46. package/dist/sessionRouter.js +0 -88
  47. package/dist/store/JsonMemoryStore.d.ts +0 -52
  48. package/dist/store/JsonMemoryStore.js +0 -119
  49. package/dist/store/JsonTaskStore.d.ts +0 -21
  50. package/dist/store/JsonTaskStore.js +0 -53
  51. package/dist/store/JsonlStore.d.ts +0 -128
  52. package/dist/store/JsonlStore.js +0 -1172
  53. package/dist/store/MemoryEventStore.d.ts +0 -47
  54. package/dist/store/MemoryEventStore.js +0 -111
  55. package/dist/store/WriteQueue.d.ts +0 -51
  56. package/dist/store/WriteQueue.js +0 -142
  57. package/dist/store/index.d.ts +0 -6
  58. package/dist/store/index.js +0 -5
  59. package/dist/store/types.d.ts +0 -427
  60. package/dist/store/types.js +0 -19
  61. package/dist/visitorTimeline.d.ts +0 -24
  62. package/dist/visitorTimeline.js +0 -68
  63. package/src/auth.test.ts +0 -90
  64. package/src/auth.ts +0 -248
  65. package/src/bridge-auth.test.ts +0 -196
  66. package/src/bridge.test.ts +0 -1708
  67. package/src/bridge.ts +0 -1854
  68. package/src/cli.ts +0 -338
  69. package/src/dashboardApi.test.ts +0 -235
  70. package/src/dashboardApi.ts +0 -184
  71. package/src/dashboardSpa.test.ts +0 -239
  72. package/src/dashboardSpa.ts +0 -195
  73. package/src/dashboardUrl.test.ts +0 -46
  74. package/src/dashboardUrl.ts +0 -28
  75. package/src/eventsHandler.test.ts +0 -247
  76. package/src/eventsHandler.ts +0 -136
  77. package/src/identity.test.ts +0 -86
  78. package/src/identity.ts +0 -116
  79. package/src/openBrowser.test.ts +0 -103
  80. package/src/openBrowser.ts +0 -81
  81. package/src/remoteBridge.test.ts +0 -119
  82. package/src/remoteBridge.ts +0 -404
  83. package/src/replay.test.ts +0 -271
  84. package/src/replayCreate.ts +0 -194
  85. package/src/replayViewer.ts +0 -173
  86. package/src/sessionRouter.ts +0 -119
  87. package/src/store/JsonMemoryStore.test.ts +0 -175
  88. package/src/store/JsonMemoryStore.ts +0 -128
  89. package/src/store/JsonTaskStore.test.ts +0 -212
  90. package/src/store/JsonTaskStore.ts +0 -59
  91. package/src/store/JsonlStore.test.ts +0 -1538
  92. package/src/store/JsonlStore.ts +0 -1325
  93. package/src/store/MemoryEventStore.test.ts +0 -119
  94. package/src/store/MemoryEventStore.ts +0 -151
  95. package/src/store/WriteQueue.ts +0 -165
  96. package/src/store/identityTagging.test.ts +0 -67
  97. package/src/store/index.ts +0 -29
  98. package/src/store/types.ts +0 -532
  99. package/src/visitorTimeline.test.ts +0 -197
  100. package/src/visitorTimeline.ts +0 -89
package/src/cli.ts DELETED
@@ -1,338 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * CLI entry — boots WS bridge + MCP server (stdio or HTTP).
4
- *
5
- * Usage:
6
- * npx @harness-fe/mcp-server # 127.0.0.1, stdio MCP
7
- * npx @harness-fe/mcp-server --host 0.0.0.0 --token auto
8
- * npx @harness-fe/mcp-server --host 0.0.0.0 --token auto --mcp-transport http
9
- *
10
- * Leader / follower:
11
- * - first process bound to the WS port = leader (in-process Bridge)
12
- * - subsequent processes (EADDRINUSE) become followers that attach to
13
- * the leader via the `mcp.call` control channel using `RemoteBridge`.
14
- *
15
- * This lets multiple Claude Code windows share a single dev-bridge daemon
16
- * (and thus the same browser / vite-plugin connections).
17
- */
18
-
19
- import { randomBytes } from 'node:crypto';
20
- import {
21
- DEFAULT_HOST,
22
- DEFAULT_WS_PORT,
23
- buildHttpUrl,
24
- isLoopbackHost,
25
- parseWsUrl,
26
- } from '@harness-fe/protocol';
27
- import { defaultDataDir, type IBridge } from './bridge.js';
28
- import { createDaemon, type DaemonHandle } from './daemon.js';
29
- import { RemoteBridge } from './remoteBridge.js';
30
- import { startMcpStdioServer } from './mcp.js';
31
-
32
- type McpTransport = 'stdio' | 'http';
33
-
34
- interface CliConfig {
35
- host: string;
36
- port: number;
37
- token: string | undefined;
38
- mcpTransport: McpTransport;
39
- mcpPath: string;
40
- publicHost: string | undefined;
41
- /** Friendly daemon name; surfaces in banner / dashboard. Cosmetic only. */
42
- label: string | undefined;
43
- /** Resolved data directory. Defaults to defaultDataDir(port). */
44
- dataDir: string;
45
- /** Env-var name that gates experimental tools. Undefined = fully on (no gate). */
46
- experimentalEnvVar: string | undefined;
47
- }
48
-
49
- function printHelpAndExit(): never {
50
- const help = `harness-fe — frontend harness MCP daemon
51
-
52
- Usage:
53
- harness-fe [options]
54
-
55
- Options:
56
- --host <addr> Bind address. Default 127.0.0.1.
57
- Use 0.0.0.0 to accept LAN connections (requires --token).
58
- --port <number> TCP port. Default ${DEFAULT_WS_PORT}.
59
- --token <value|auto> Token required for HTTP/WS auth. Pass "auto" to generate one.
60
- Required when --host is not loopback.
61
- --mcp-transport <kind> stdio (default) or http. http mounts /mcp on the bridge.
62
- --mcp-path <path> URL path for the MCP HTTP endpoint. Default /mcp.
63
- --public-host <addr> Override the host printed in outbound URLs. Useful when
64
- binding 0.0.0.0 and the auto-detected LAN IP is wrong.
65
- --experimental-env-var <name>
66
- Restrict experimental (in-testing) tools to machines
67
- where <name> is set to a non-empty value. Omit this and
68
- experimental tools are fully on (the default).
69
- -h, --help Show this help.
70
-
71
- Environment:
72
- HARNESS_FE_HOST Same as --host
73
- HARNESS_FE_TOKEN Same as --token (use "auto" to generate)
74
- HARNESS_FE_MCP_TRANSPORT Same as --mcp-transport
75
- HARNESS_FE_MCP_PATH Same as --mcp-path
76
- HARNESS_FE_EXPERIMENTAL_ENV_VAR Same as --experimental-env-var
77
- HARNESS_FE_URL Full ws:// URL (legacy; --host/--port override it)
78
- `;
79
- process.stderr.write(help);
80
- process.exit(0);
81
- }
82
-
83
- function parseArgs(argv: string[]): CliConfig {
84
- const args = argv.slice(2);
85
-
86
- let host: string | undefined;
87
- let port: number | undefined;
88
- let token: string | undefined;
89
- let mcpTransport: McpTransport | undefined;
90
- let mcpPath: string | undefined;
91
- let publicHost: string | undefined;
92
- let experimentalEnvVar: string | undefined;
93
-
94
- for (let i = 0; i < args.length; i++) {
95
- const a = args[i];
96
- const next = () => {
97
- const v = args[++i];
98
- if (v == null) {
99
- process.stderr.write(`harness-fe: missing value for ${a}\n`);
100
- process.exit(2);
101
- }
102
- return v;
103
- };
104
- switch (a) {
105
- case '-h':
106
- case '--help':
107
- printHelpAndExit();
108
- break;
109
- case '--host':
110
- host = next();
111
- break;
112
- case '--port':
113
- port = Number(next());
114
- if (!Number.isFinite(port) || port <= 0) {
115
- process.stderr.write(`harness-fe: invalid --port\n`);
116
- process.exit(2);
117
- }
118
- break;
119
- case '--token':
120
- token = next();
121
- break;
122
- case '--mcp-transport': {
123
- const v = next();
124
- if (v !== 'stdio' && v !== 'http') {
125
- process.stderr.write(`harness-fe: invalid --mcp-transport (stdio|http)\n`);
126
- process.exit(2);
127
- }
128
- mcpTransport = v;
129
- break;
130
- }
131
- case '--mcp-path':
132
- mcpPath = next();
133
- break;
134
- case '--public-host':
135
- publicHost = next();
136
- break;
137
- case '--experimental-env-var':
138
- experimentalEnvVar = next();
139
- break;
140
- default:
141
- process.stderr.write(`harness-fe: unknown argument ${a}\n`);
142
- process.exit(2);
143
- }
144
- }
145
-
146
- // Apply env fallbacks, then URL fallback for host/port.
147
- const envUrl = process.env.HARNESS_FE_URL;
148
- let envHost: string | undefined;
149
- let envPort: number | undefined;
150
- if (envUrl) {
151
- try {
152
- const parsed = parseWsUrl(envUrl);
153
- envHost = parsed.host;
154
- envPort = parsed.port;
155
- } catch {
156
- // ignore — fall back to defaults below
157
- }
158
- }
159
-
160
- const finalHost = host ?? process.env.HARNESS_FE_HOST ?? envHost ?? DEFAULT_HOST;
161
- const finalPort = port ?? envPort ?? DEFAULT_WS_PORT;
162
-
163
- let finalToken = token ?? process.env.HARNESS_FE_TOKEN;
164
- if (finalToken === 'auto') {
165
- finalToken = randomBytes(24).toString('base64url');
166
- }
167
- if (finalToken === '') finalToken = undefined;
168
-
169
- const finalTransport: McpTransport =
170
- (mcpTransport ?? (process.env.HARNESS_FE_MCP_TRANSPORT as McpTransport | undefined)) ??
171
- 'stdio';
172
- if (finalTransport !== 'stdio' && finalTransport !== 'http') {
173
- process.stderr.write(`harness-fe: invalid mcp transport "${finalTransport}"\n`);
174
- process.exit(2);
175
- }
176
- const finalMcpPath = mcpPath ?? process.env.HARNESS_FE_MCP_PATH ?? '/mcp';
177
-
178
- // Data dir defaults to port-keyed path. Explicit env override wins.
179
- const finalDataDir = process.env.HARNESS_FE_DATA_DIR ?? defaultDataDir(finalPort);
180
-
181
- const finalLabel = process.env.HARNESS_FE_LABEL || undefined;
182
-
183
- // Omitted → undefined → experimental tools fully on (no gate). Supply a
184
- // name only to restrict them to machines where that var is set.
185
- const finalExperimentalEnvVar =
186
- experimentalEnvVar || process.env.HARNESS_FE_EXPERIMENTAL_ENV_VAR || undefined;
187
-
188
- return {
189
- host: finalHost,
190
- port: finalPort,
191
- token: finalToken,
192
- mcpTransport: finalTransport,
193
- mcpPath: finalMcpPath,
194
- publicHost,
195
- label: finalLabel,
196
- dataDir: finalDataDir,
197
- experimentalEnvVar: finalExperimentalEnvVar,
198
- };
199
- }
200
-
201
- function validate(_cfg: CliConfig): void {
202
- // Token requirement is left entirely to the operator. We don't refuse
203
- // a non-loopback bind without a token — that's their call, not ours.
204
- // Warnings are emitted from the banner so the operator sees them; CI /
205
- // automation that pipes stderr can suppress as needed.
206
- }
207
-
208
- function printBanner(cfg: CliConfig, role: 'leader' | 'follower', viewerUrl: string | undefined): void {
209
- const lines: string[] = [];
210
- const labelSuffix = cfg.label ? ` (${cfg.label})` : '';
211
- lines.push(`[harness-fe] ${role}: WS bridge listening on ws://${cfg.host}:${cfg.port}${labelSuffix}`);
212
- if (role === 'leader') {
213
- // Surface the data dir so the user can see exactly where this
214
- // daemon's sessions / recordings / projects are landing.
215
- lines.push(`[harness-fe] data: ${cfg.dataDir}`);
216
- }
217
- const isLan = !isLoopbackHost(cfg.host);
218
- if (isLan) {
219
- lines.push(`[harness-fe] WARNING: bound to non-loopback host ${cfg.host}.`);
220
- if (cfg.token) {
221
- lines.push(`[harness-fe] anyone reaching this host:port with the token can read console / network / recordings.`);
222
- } else {
223
- lines.push(`[harness-fe] no token set — anyone on this network can read console / network / recordings.`);
224
- lines.push(`[harness-fe] add --token auto (or HARNESS_FE_TOKEN=…) to enable auth.`);
225
- }
226
- }
227
- // Always print the dashboard URL. The token (when present) is folded
228
- // into the query so the first hit hands it off to a cookie; without a
229
- // token, auth is disabled and the URL works on its own.
230
- const host = cfg.publicHost ?? viewerHost(viewerUrl) ?? cfg.host;
231
- const dashboard = buildHttpUrl({ host, port: cfg.port, token: cfg.token });
232
- lines.push(`[harness-fe] dashboard: ${dashboard}`);
233
- if (cfg.mcpTransport === 'http') {
234
- const mcp = buildHttpUrl({ host, port: cfg.port, token: cfg.token, path: cfg.mcpPath });
235
- lines.push(`[harness-fe] mcp http: ${mcp}`);
236
- if (cfg.token) {
237
- const mcpNoTok = buildHttpUrl({ host, port: cfg.port, path: cfg.mcpPath });
238
- lines.push(
239
- `[harness-fe] agent config: { "url": "${mcpNoTok}", "headers": { "Authorization": "Bearer ${cfg.token}" } }`,
240
- );
241
- } else {
242
- lines.push(`[harness-fe] agent config: { "url": "${mcp}" } (no auth — token unset)`);
243
- }
244
- }
245
- if (cfg.token) {
246
- lines.push(`[harness-fe] token: ${cfg.token}`);
247
- }
248
- process.stderr.write(lines.join('\n') + '\n');
249
- }
250
-
251
- function viewerHost(viewerUrl: string | undefined): string | undefined {
252
- if (!viewerUrl) return undefined;
253
- try {
254
- return new URL(viewerUrl).hostname;
255
- } catch {
256
- return undefined;
257
- }
258
- }
259
-
260
- async function main() {
261
- const cfg = parseArgs(process.argv);
262
- validate(cfg);
263
-
264
- const { active, shutdown, role } = await startBridgeOrAttach(cfg);
265
- printBanner(cfg, role, active.getViewerBaseUrl());
266
-
267
- if (cfg.mcpTransport === 'stdio') {
268
- await startMcpStdioServer(active, { experimentalEnvVar: cfg.experimentalEnvVar });
269
- process.stderr.write('[harness-fe] MCP stdio server connected\n');
270
- } else {
271
- // HTTP transport: the leader's createDaemon() call already mounted
272
- // /mcp via mcpHttp:true. Followers fall through here with no leader
273
- // attached, so HTTP mode is unsupported for them.
274
- if (role === 'follower') {
275
- process.stderr.write(
276
- '[harness-fe] --mcp-transport=http is only supported on the leader. ' +
277
- 'Another daemon already holds the port; stop it first.\n',
278
- );
279
- await shutdown();
280
- process.exit(2);
281
- }
282
- process.stderr.write(`[harness-fe] MCP http server mounted at ${cfg.mcpPath}\n`);
283
- }
284
-
285
- const onSignal = async () => {
286
- process.stderr.write('[harness-fe] shutting down\n');
287
- await shutdown();
288
- process.exit(0);
289
- };
290
- process.on('SIGINT', onSignal);
291
- process.on('SIGTERM', onSignal);
292
- }
293
-
294
- async function startBridgeOrAttach(
295
- cfg: CliConfig,
296
- ): Promise<{ active: IBridge; shutdown: () => Promise<void>; role: 'leader' | 'follower' }> {
297
- // Leader path: use createDaemon so there's exactly one boot path between
298
- // the CLI and any host application that embeds the daemon. The factory
299
- // mounts /mcp itself when mcpHttp:true, so we don't need to call
300
- // startMcpHttpServer here.
301
- const daemon: DaemonHandle = createDaemon({
302
- port: cfg.port,
303
- host: cfg.host,
304
- dataDir: cfg.dataDir,
305
- label: cfg.label,
306
- token: cfg.token,
307
- publicHost: cfg.publicHost,
308
- mcpHttp: cfg.mcpTransport === 'http',
309
- mcpPath: cfg.mcpPath,
310
- experimentalEnvVar: cfg.experimentalEnvVar,
311
- });
312
- try {
313
- await daemon.start();
314
- return {
315
- active: daemon.bridge,
316
- shutdown: () => daemon.stop(),
317
- role: 'leader',
318
- };
319
- } catch (err) {
320
- if ((err as NodeJS.ErrnoException)?.code !== 'EADDRINUSE') throw err;
321
- // Factory's bridge.start failed on EADDRINUSE; the factory itself
322
- // didn't mount anything else, so there's nothing further to clean up.
323
- }
324
-
325
- // Port already taken — attach as follower.
326
- const remote = new RemoteBridge({ port: cfg.port, host: cfg.host, token: cfg.token });
327
- await remote.connect();
328
- return {
329
- active: remote,
330
- shutdown: () => remote.stop(),
331
- role: 'follower',
332
- };
333
- }
334
-
335
- main().catch((err) => {
336
- process.stderr.write(`[harness-fe] fatal: ${err?.stack ?? err}\n`);
337
- process.exit(1);
338
- });
@@ -1,235 +0,0 @@
1
- /**
2
- * Tests for the JSON API surface consumed by @harness-fe/dashboard-ui.
3
- *
4
- * Mirrors the seed setup used by `dashboard.test.ts` so we have parity:
5
- * anything the HTML dashboard could show should also be reachable via JSON
6
- * once we ship the SPA in PR C.
7
- */
8
-
9
- import { afterEach, describe, expect, it } from 'vitest';
10
- import { mkdtempSync, rmSync } from 'node:fs';
11
- import { tmpdir } from 'node:os';
12
- import { join } from 'node:path';
13
- import { Bridge } from './bridge.js';
14
- import { JsonlStore } from './store/index.js';
15
-
16
- const tempDirs: string[] = [];
17
- function mkTmp(): string {
18
- const dir = mkdtempSync(join(tmpdir(), 'harness-dashboard-api-test-'));
19
- tempDirs.push(dir);
20
- return dir;
21
- }
22
-
23
- afterEach(async () => {
24
- while (tempDirs.length) {
25
- const d = tempDirs.pop()!;
26
- try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
27
- }
28
- });
29
-
30
- async function bootBridge() {
31
- const dir = mkTmp();
32
- const store = new JsonlStore(dir);
33
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, memoryStore: null });
34
- await bridge.start();
35
- const port = bridge.getBoundPort();
36
- if (!port) throw new Error('no port');
37
- return { bridge, store, port };
38
- }
39
-
40
- function seed(store: JsonlStore, projectId: string): string {
41
- const { randomUUID } = require('node:crypto') as typeof import('node:crypto');
42
- const sessionId = randomUUID();
43
- store.upsertProject(projectId, { displayName: projectId });
44
- store.upsertTab('tab-1', { connectedAt: Date.now(), userAgent: 'test-agent' });
45
- store.upsertSession(sessionId, {
46
- tabId: 'tab-1',
47
- startedAt: Date.now(),
48
- url: 'http://localhost:5173/',
49
- title: 'Demo',
50
- participants: [{ projectId, joinedAt: Date.now() }],
51
- });
52
- store.appendEvent(sessionId, { ts: 1000, t: 'log', d: { args: ['hello'] } });
53
- store.appendEvent(sessionId, { ts: 1100, t: 'err', d: { message: 'boom' } });
54
- store.appendRecording(sessionId, {
55
- chunkId: 'rrc_a', startTs: 1000, endTs: 2000, eventCount: 3,
56
- events: [
57
- { type: 4, data: {}, timestamp: 1000 },
58
- { type: 2, data: {}, timestamp: 1100 },
59
- { type: 3, data: {}, timestamp: 2000 },
60
- ],
61
- });
62
- return sessionId;
63
- }
64
-
65
- describe('Dashboard JSON API', () => {
66
- it('GET /api/projects returns project list with recent sessions inline', async () => {
67
- const { bridge, store, port } = await bootBridge();
68
- try {
69
- const sessionId = seed(store, 'my-app');
70
- await store.flush();
71
- const resp = await fetch(`http://127.0.0.1:${port}/api/projects`);
72
- expect(resp.status).toBe(200);
73
- expect(resp.headers.get('content-type')).toMatch(/application\/json/);
74
- const body = await resp.json() as { projects: Array<{ project: { id: string }; recentSessions: Array<{ id: string }> }> };
75
- expect(body.projects).toHaveLength(1);
76
- expect(body.projects[0].project.id).toBe('my-app');
77
- expect(body.projects[0].recentSessions.map((s) => s.id)).toContain(sessionId);
78
- } finally {
79
- await bridge.stop();
80
- }
81
- });
82
-
83
- it('GET /api/projects on empty store returns an empty list (not 500)', async () => {
84
- const { bridge, port } = await bootBridge();
85
- try {
86
- const resp = await fetch(`http://127.0.0.1:${port}/api/projects`);
87
- expect(resp.status).toBe(200);
88
- const body = await resp.json() as { projects: unknown[] };
89
- expect(body.projects).toEqual([]);
90
- } finally {
91
- await bridge.stop();
92
- }
93
- });
94
-
95
- it('GET /api/sessions filters by projectId', async () => {
96
- const { bridge, store, port } = await bootBridge();
97
- try {
98
- seed(store, 'project-a');
99
- seed(store, 'project-b');
100
- await store.flush();
101
- const resp = await fetch(`http://127.0.0.1:${port}/api/sessions?projectId=project-a`);
102
- expect(resp.status).toBe(200);
103
- const body = await resp.json() as { sessions: Array<{ participants: Array<{ projectId: string }> }> };
104
- expect(body.sessions.length).toBeGreaterThan(0);
105
- for (const s of body.sessions) {
106
- expect(s.participants[0].projectId).toBe('project-a');
107
- }
108
- } finally {
109
- await bridge.stop();
110
- }
111
- });
112
-
113
- it('GET /api/sessions/:id returns session + summary + chunks + timeline + exports', async () => {
114
- const { bridge, store, port } = await bootBridge();
115
- try {
116
- const sessionId = seed(store, 'my-app');
117
- await store.flush();
118
- const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/${sessionId}`);
119
- expect(resp.status).toBe(200);
120
- const body = await resp.json() as {
121
- session: { id: string };
122
- summary: { tabs: string[] };
123
- chunks: Array<{ chunkId: string; tabId: string; eventCount: number }>;
124
- timeline: Array<{ t: string }>;
125
- exports: unknown[];
126
- };
127
- expect(body.session.id).toBe(sessionId);
128
- expect(body.summary.tabs).toContain('tab-1');
129
- expect(body.chunks).toHaveLength(1);
130
- expect(body.chunks[0].chunkId).toBe('rrc_a');
131
- expect(body.chunks[0].eventCount).toBe(3);
132
- expect(body.timeline.map((e) => e.t)).toContain('log');
133
- expect(body.timeline.map((e) => e.t)).toContain('err');
134
- expect(body.exports).toEqual([]);
135
- } finally {
136
- await bridge.stop();
137
- }
138
- });
139
-
140
- it('GET /api/sessions/:id returns 404 for unknown id', async () => {
141
- const { bridge, port } = await bootBridge();
142
- try {
143
- const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/no-such-session`);
144
- expect(resp.status).toBe(404);
145
- const body = await resp.json() as { error: string; sessionId: string };
146
- expect(body.error).toMatch(/not found/i);
147
- expect(body.sessionId).toBe('no-such-session');
148
- } finally {
149
- await bridge.stop();
150
- }
151
- });
152
-
153
- it('POST /api/sessions/:id/replay returns exportId + viewerUrl on success', async () => {
154
- const { bridge, store, port } = await bootBridge();
155
- try {
156
- const sessionId = seed(store, 'my-app');
157
- await store.flush();
158
- const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/${sessionId}/replay`, {
159
- method: 'POST',
160
- headers: { 'content-type': 'application/json' },
161
- body: JSON.stringify({ since: 1000, until: 3000, tabId: 'tab-1' }),
162
- });
163
- expect(resp.status).toBe(200);
164
- const body = await resp.json() as { exportId?: string; viewerUrl?: string; error?: string };
165
- expect(body.error).toBeUndefined();
166
- expect(body.exportId).toBeTruthy();
167
- // viewerUrl is best-effort (depends on bridge.getViewerBaseUrl()); just assert shape.
168
- expect(typeof body.exportId === 'string').toBe(true);
169
- } finally {
170
- await bridge.stop();
171
- }
172
- });
173
-
174
- it('POST /api/sessions/:id/replay returns 400 with error message for empty window', async () => {
175
- const { bridge, store, port } = await bootBridge();
176
- try {
177
- const sessionId = seed(store, 'my-app');
178
- await store.flush();
179
- const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/${sessionId}/replay`, {
180
- method: 'POST',
181
- headers: { 'content-type': 'application/json' },
182
- body: JSON.stringify({ since: 999_999_000, until: 999_999_100 }),
183
- });
184
- expect(resp.status).toBe(400);
185
- const body = await resp.json() as { error: string };
186
- expect(body.error).toMatch(/no rrweb chunks|empty|window/i);
187
- } finally {
188
- await bridge.stop();
189
- }
190
- });
191
-
192
- it('POST /api/sessions/:id/replay rejects malformed JSON with 400', async () => {
193
- const { bridge, store, port } = await bootBridge();
194
- try {
195
- const sessionId = seed(store, 'my-app');
196
- await store.flush();
197
- const resp = await fetch(`http://127.0.0.1:${port}/api/sessions/${sessionId}/replay`, {
198
- method: 'POST',
199
- headers: { 'content-type': 'application/json' },
200
- body: 'not json {{{',
201
- });
202
- expect(resp.status).toBe(400);
203
- const body = await resp.json() as { error: string };
204
- expect(body.error).toMatch(/invalid JSON/i);
205
- } finally {
206
- await bridge.stop();
207
- }
208
- });
209
-
210
- it('non-API root path redirects into the SPA (legacy / no longer serves HTML)', async () => {
211
- const { bridge, store, port } = await bootBridge();
212
- try {
213
- seed(store, 'my-app');
214
- await store.flush();
215
- const resp = await fetch(`http://127.0.0.1:${port}/?token=abc`, { redirect: 'manual' });
216
- expect(resp.status).toBe(302);
217
- expect(resp.headers.get('location')).toBe('/dashboard/?token=abc');
218
- } finally {
219
- await bridge.stop();
220
- }
221
- });
222
-
223
- it('unknown /api/* paths return 404 JSON (not the HTML 404 page)', async () => {
224
- const { bridge, port } = await bootBridge();
225
- try {
226
- const resp = await fetch(`http://127.0.0.1:${port}/api/bogus/path`);
227
- expect(resp.status).toBe(404);
228
- expect(resp.headers.get('content-type')).toMatch(/application\/json/);
229
- const body = await resp.json() as { error: string };
230
- expect(body.error).toBe('not found');
231
- } finally {
232
- await bridge.stop();
233
- }
234
- });
235
- });