claude-code-session-manager 0.62.0 → 0.62.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.
Files changed (33) hide show
  1. package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-BwN4pNC0.js} +1 -1
  2. package/dist/assets/{index-DOXvph-Z.css → index-CVCCMw5o.css} +1 -1
  3. package/dist/assets/{index-BfiMRkln.js → index-ZjTDk5b7.js} +1083 -1111
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  7. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  8. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  9. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  10. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  11. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  12. package/src/main/config.cjs +1 -22
  13. package/src/main/files.cjs +20 -0
  14. package/src/main/index.cjs +1 -63
  15. package/src/main/ipcSchemas.cjs +11 -209
  16. package/src/main/lib/opsOwnership.cjs +0 -2
  17. package/src/main/lib/summarize.cjs +0 -1
  18. package/src/main/runVerify.cjs +55 -6
  19. package/src/main/scheduler.cjs +46 -7
  20. package/src/preload/api.d.ts +2 -197
  21. package/src/preload/index.cjs +1 -100
  22. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  23. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  24. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  25. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  26. package/src/main/browserAgentServer.cjs +0 -224
  27. package/src/main/browserCapture.cjs +0 -728
  28. package/src/main/browserView.cjs +0 -916
  29. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  30. package/src/main/lib/browserAgentActions.cjs +0 -114
  31. package/src/main/lib/e2eStateMachine.cjs +0 -39
  32. package/src/main/webRemote.cjs +0 -1367
  33. package/src/preload/browserViewPreload.cjs +0 -118
@@ -29,82 +29,6 @@ export interface WriteErrorEvent {
29
29
  reason: string;
30
30
  }
31
31
 
32
- export interface BrowserNavState {
33
- url: string;
34
- title: string;
35
- canGoBack: boolean;
36
- canGoForward: boolean;
37
- loading: boolean;
38
- isSecure: boolean;
39
- }
40
-
41
- /** One captured recorder step (PRD 408 engine → PRD 409 panel). */
42
- export interface RecordStep {
43
- n: number;
44
- /** `select` is accepted by replay/export (PRD 410) for forward-compat;
45
- * the live engine does not emit it yet. */
46
- verb: 'navigate' | 'click' | 'type' | 'select' | 'wait-for' | 'drag';
47
- target: string;
48
- kind?: 'nav' | 'assert';
49
- /** True for `type` steps — the actual typed value is never captured. */
50
- masked?: boolean;
51
- /** Engine-suggested `{{var}}` name for a `type` step (e.g. field name). */
52
- variableSuggestion?: string;
53
- /** Renderer-owned: set once the user checks "parameterize as {{var}}". */
54
- variable?: string | null;
55
- /** `select` steps only — the option value to choose on replay/export. */
56
- value?: string;
57
- /** Click position, or drag-start position for `drag` steps. */
58
- x?: number;
59
- /** Click position, or drag-start position for `drag` steps. */
60
- y?: number;
61
- /** `drag` steps only — drag-end target selector. */
62
- endTarget?: string;
63
- /** `drag` steps only — drag-end x position. */
64
- endX?: number;
65
- /** `drag` steps only — drag-end y position. */
66
- endY?: number;
67
- }
68
-
69
- /** Per-step replay outcome (PRD 410), streamed as `browser:replay-step:<viewId>`. */
70
- export interface ReplayStepResult {
71
- n: number;
72
- status: 'pass' | 'fail';
73
- detail?: string;
74
- }
75
-
76
- /** find-in-page result (PRD 402), streamed as `browser:find-result:<viewId>`. */
77
- export interface FindResult {
78
- requestId: number;
79
- matches: number;
80
- activeMatchOrdinal: number;
81
- }
82
-
83
- /** Element-picker event (PRD 403), streamed as `browser:picker-event:<viewId>`. */
84
- export interface PickerEvent {
85
- type: 'hover' | 'pick' | 'unpick' | 'exit';
86
- selector?: string;
87
- label?: string;
88
- tag?: string;
89
- rect?: { x: number; y: number; width: number; height: number };
90
- /** Present on a 'pick' event fired by an unmodified click — the selection was replaced, not accumulated. */
91
- replace?: boolean;
92
- }
93
-
94
- /** One entry in `~/.claude/session-manager/browser/history.json` (PRD 402). */
95
- export interface BrowserHistoryEntry {
96
- url: string;
97
- title: string;
98
- ts: number;
99
- }
100
-
101
- /** One entry in `~/.claude/session-manager/browser/bookmarks.json` (PRD 402). */
102
- export interface BrowserBookmark {
103
- url: string;
104
- title: string;
105
- ts: number;
106
- }
107
-
108
32
  export interface ReadJsonResult {
109
33
  exists: boolean;
110
34
  raw: string;
@@ -133,7 +57,7 @@ export interface ImportRef {
133
57
  /** Declared owner ids for the single-writer law over a project's
134
58
  * session-manager-operations/ root. Mirrors OWNERS in
135
59
  * src/main/lib/opsOwnership.cjs — one writer per namespace; everyone reads. */
136
- export type OpsWriter = 'epics' | 'scheduler' | 'project-home' | 'browser';
60
+ export type OpsWriter = 'epics' | 'scheduler' | 'project-home';
137
61
 
138
62
  export interface WriteResult {
139
63
  ok: boolean;
@@ -1162,46 +1086,6 @@ export interface PluginInstallProgressEvent {
1162
1086
  line: string;
1163
1087
  }
1164
1088
 
1165
- // ────────────────────────────────────────────── Web Remote (PRD 08)
1166
-
1167
- export interface WebRemoteDevice {
1168
- deviceId: string;
1169
- deviceName: string;
1170
- issuedAt: string;
1171
- lastConnectedAt: string | null;
1172
- }
1173
-
1174
- export interface WebRemoteStatus {
1175
- enabled: boolean;
1176
- remoteControlEnabled: boolean;
1177
- connected: boolean;
1178
- e2eActive: boolean;
1179
- /** True once the user has confirmed the SAS on the desktop. */
1180
- e2eAuthenticated: boolean;
1181
- /** Explicit E2E session state for UI rendering. */
1182
- e2eState: 'idle' | 'pending_sas' | 'authenticated' | 'failed';
1183
- /** 6-digit Short Authentication String pending user confirmation, or null. */
1184
- pendingSas: string | null;
1185
- devices: WebRemoteDevice[];
1186
- }
1187
-
1188
- export interface WebRemotePairResult {
1189
- ok: boolean;
1190
- deviceId?: string;
1191
- error?: string;
1192
- }
1193
-
1194
- export interface WebRemoteMutationResult {
1195
- ok: boolean;
1196
- error?: string;
1197
- }
1198
-
1199
- export interface WebRemoteAuditTailResult {
1200
- ok: boolean;
1201
- lines?: string[];
1202
- error?: string;
1203
- }
1204
-
1205
1089
  // ────────────────────────────────────────────── Chat runner (PRD 319)
1206
1090
 
1207
1091
  export interface ChatRunPayload {
@@ -1344,63 +1228,6 @@ export interface SessionManagerAPI {
1344
1228
  onExit: (tabId: string, handler: (info: PtyExit) => void) => () => void;
1345
1229
  onWriteError: (handler: (ev: WriteErrorEvent) => void) => () => void;
1346
1230
  };
1347
- browser: {
1348
- create: (payload: { viewId: string; partition: string }) => Promise<{ ok: boolean }>;
1349
- setBounds: (payload: { viewId: string; x: number; y: number; width: number; height: number }) => Promise<{ ok: boolean }>;
1350
- show: (viewId: string) => Promise<{ ok: boolean }>;
1351
- hide: (viewId: string) => Promise<{ ok: boolean }>;
1352
- destroy: (viewId: string) => Promise<{ ok: boolean }>;
1353
- navigate: (payload: { viewId: string; url: string }) => Promise<{ ok: boolean; error?: string }>;
1354
- back: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1355
- forward: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1356
- reload: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1357
- stop: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1358
- onNavState: (viewId: string, handler: (state: BrowserNavState) => void) => () => void;
1359
- recordStart: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1360
- recordStop: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1361
- onRecordStep: (viewId: string, handler: (step: RecordStep) => void) => () => void;
1362
- captureDom: (payload: { viewId: string; kind: 'text' | 'html' }) => Promise<
1363
- | { ok: true; url: string; title: string; text: string; truncated?: boolean }
1364
- | { ok: false; error: string }
1365
- >;
1366
- captureShot: (viewId: string) => Promise<
1367
- | { ok: true; url: string; title: string; dataUrl: string }
1368
- | { ok: false; error: string }
1369
- >;
1370
- saveBinary: (path: string, base64: string, writer?: OpsWriter) => Promise<{ ok: boolean; error?: string }>;
1371
- /** Opens a native "Save As" dialog and writes `text` to the chosen path directly
1372
- * (bypasses config.cjs's write-boundary check — the path is user-chosen via OS dialog). */
1373
- saveRecording: (payload: { defaultName: string; text: string }) => Promise<
1374
- | { ok: true; path: string }
1375
- | { ok: false; canceled: true }
1376
- | { ok: false; error: string }
1377
- >;
1378
- replay: (payload: {
1379
- viewId: string;
1380
- steps: RecordStep[];
1381
- values?: Record<string, string>;
1382
- continueOnError?: boolean;
1383
- }) => Promise<{ ok: boolean; error?: string; stopped?: boolean; failedAt?: number }>;
1384
- onReplayStep: (viewId: string, handler: (step: ReplayStepResult) => void) => () => void;
1385
- setZoom: (payload: { viewId: string; factor: number }) => Promise<{ ok: boolean; error?: string; factor?: number }>;
1386
- find: (payload: { viewId: string; text: string; forward?: boolean }) => Promise<{ ok: boolean; error?: string }>;
1387
- stopFind: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1388
- onFindResult: (viewId: string, handler: (result: FindResult) => void) => () => void;
1389
- pickerStart: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1390
- pickerStop: (viewId: string) => Promise<{ ok: boolean; error?: string; wasPicking?: boolean }>;
1391
- onPickerEvent: (viewId: string, handler: (ev: PickerEvent) => void) => () => void;
1392
- /** Selection-scoped capture pipeline (PRD 404): filter -> prune -> summarize -> chunk for 'agent', outerHTML for 'html', CDP AX-tree for 'a11y', fallback-chain for 'selector'. */
1393
- capture: (payload: {
1394
- viewId: string;
1395
- selectors: string[];
1396
- mode: 'agent' | 'html' | 'a11y' | 'selector';
1397
- }) => Promise<
1398
- | { ok: true; mode: string; text: string; meta: { chunks?: number; tokens?: number } }
1399
- | { ok: false; error: string }
1400
- >;
1401
- /** Fired when the embedded page tries to open a new window (target="_blank", window.open, ctrl/cmd-click) — the main process denies the native popup and forwards the URL here. */
1402
- onOpenTabRequest: (handler: (payload: { url: string }) => void) => () => void;
1403
- };
1404
1231
  transcripts: {
1405
1232
  subscribe: (payload: { tabId: string; cwd: string; sessionUuid: string }) => Promise<SubscribeResult>;
1406
1233
  /** Release the sub back to the LRU cache (view-switch). Does not destroy the watcher. */
@@ -1507,6 +1334,7 @@ export interface SessionManagerAPI {
1507
1334
  rename: (path: string, newName: string) => Promise<FilesRenameResult>;
1508
1335
  delete: (path: string) => Promise<FilesDeleteResult>;
1509
1336
  duplicate: (path: string) => Promise<FilesDuplicateResult>;
1337
+ saveBinary: (path: string, base64: string, writer?: OpsWriter) => Promise<{ ok: boolean; error?: string }>;
1510
1338
  };
1511
1339
  docEdit: {
1512
1340
  run: (payload: { path: string; before: string; instruction: string; documentText?: string }) => Promise<DocEditResult>;
@@ -1616,8 +1444,6 @@ export interface SessionManagerAPI {
1616
1444
  | { ok: true; text: string }
1617
1445
  | { ok: false; error?: string }
1618
1446
  >;
1619
- /** Write side — copies a Capture-panel screenshot data URL to the OS clipboard. */
1620
- copyImage: (dataUrl: string) => Promise<{ ok: boolean; error?: string }>;
1621
1447
  /** Write side — copies arbitrary text (e.g. a recorded flow export) to the OS clipboard. */
1622
1448
  writeText: (text: string) => Promise<{ ok: boolean; error?: string }>;
1623
1449
  };
@@ -1704,27 +1530,6 @@ export interface SessionManagerAPI {
1704
1530
  * renderer needs per-row badges without a separate git call per file. */
1705
1531
  fileStatus: (cwd: string) => Promise<GitFileStatusMap>;
1706
1532
  };
1707
- webRemote: {
1708
- getStatus: () => Promise<WebRemoteStatus>;
1709
- enable: () => Promise<WebRemoteMutationResult>;
1710
- disable: () => Promise<WebRemoteMutationResult>;
1711
- /** Allow MUTATE-tier commands (pty spawn/write, scheduler writes). Default off. */
1712
- enableControl: () => Promise<WebRemoteMutationResult>;
1713
- /** Block MUTATE-tier commands — mobile becomes read-only mirror. */
1714
- disableControl: () => Promise<WebRemoteMutationResult>;
1715
- pair: (otp: string) => Promise<WebRemotePairResult>;
1716
- revokeDevice: (deviceId: string) => Promise<WebRemoteMutationResult>;
1717
- auditTail: (lines?: number) => Promise<WebRemoteAuditTailResult>;
1718
- onStatus: (handler: (status: WebRemoteStatus) => void) => () => void;
1719
- onTokenRevoked: (handler: (ev: { deviceId: string }) => void) => () => void;
1720
- /** Revoke ALL paired devices and tear down every session immediately (panic). */
1721
- revokeAll: () => Promise<WebRemoteMutationResult>;
1722
- /** Subscribe to the completion event broadcast after revokeAll. */
1723
- onRevokedAll: (handler: (ev: { revokedCount: number }) => void) => () => void;
1724
- /** Confirm that the SAS shown on the desktop matches the browser — marks the
1725
- * E2E session as authenticated and unblocks MUTATE-tier commands. */
1726
- confirmSas: () => Promise<WebRemoteMutationResult>;
1727
- };
1728
1533
  chat: {
1729
1534
  /** Spawn a headless `claude -p` run for a tab. Results arrive via the on* listeners. */
1730
1535
  run: (payload: ChatRunPayload) => Promise<{ ok: boolean }>;
@@ -56,66 +56,6 @@ contextBridge.exposeInMainWorld('api', {
56
56
  return () => ipcRenderer.removeListener('pty:write-error', listener);
57
57
  },
58
58
  },
59
- browser: {
60
- create: (payload) => ipcRenderer.invoke('browser:create', payload),
61
- setBounds: (payload) => ipcRenderer.invoke('browser:set-bounds', payload),
62
- show: (viewId) => ipcRenderer.invoke('browser:show', { viewId }),
63
- hide: (viewId) => ipcRenderer.invoke('browser:hide', { viewId }),
64
- destroy: (viewId) => ipcRenderer.invoke('browser:destroy', { viewId }),
65
- navigate: (payload) => ipcRenderer.invoke('browser:navigate', payload),
66
- back: (viewId) => ipcRenderer.invoke('browser:back', { viewId }),
67
- forward: (viewId) => ipcRenderer.invoke('browser:forward', { viewId }),
68
- reload: (viewId) => ipcRenderer.invoke('browser:reload', { viewId }),
69
- stop: (viewId) => ipcRenderer.invoke('browser:stop', { viewId }),
70
- onNavState: (viewId, handler) => {
71
- const channel = `browser:nav-state:${viewId}`;
72
- const listener = (_e, state) => handler(state);
73
- ipcRenderer.on(channel, listener);
74
- return () => ipcRenderer.removeListener(channel, listener);
75
- },
76
- recordStart: (viewId) => ipcRenderer.invoke('browser:record-start', { viewId }),
77
- recordStop: (viewId) => ipcRenderer.invoke('browser:record-stop', { viewId }),
78
- onRecordStep: (viewId, handler) => {
79
- const channel = `browser:record-step:${viewId}`;
80
- const listener = (_e, step) => handler(step);
81
- ipcRenderer.on(channel, listener);
82
- return () => ipcRenderer.removeListener(channel, listener);
83
- },
84
- captureDom: (payload) => ipcRenderer.invoke('browser:capture-dom', payload),
85
- captureShot: (viewId) => ipcRenderer.invoke('browser:capture-shot', { viewId }),
86
- saveBinary: (path, base64, writer) => ipcRenderer.invoke('browser:save-binary', { path, base64, writer }),
87
- saveRecording: (payload) => ipcRenderer.invoke('browser:save-recording', payload),
88
- replay: (payload) => ipcRenderer.invoke('browser:replay', payload),
89
- onReplayStep: (viewId, handler) => {
90
- const channel = `browser:replay-step:${viewId}`;
91
- const listener = (_e, step) => handler(step);
92
- ipcRenderer.on(channel, listener);
93
- return () => ipcRenderer.removeListener(channel, listener);
94
- },
95
- setZoom: (payload) => ipcRenderer.invoke('browser:set-zoom', payload),
96
- find: (payload) => ipcRenderer.invoke('browser:find', payload),
97
- stopFind: (viewId) => ipcRenderer.invoke('browser:stop-find', { viewId }),
98
- onFindResult: (viewId, handler) => {
99
- const channel = `browser:find-result:${viewId}`;
100
- const listener = (_e, result) => handler(result);
101
- ipcRenderer.on(channel, listener);
102
- return () => ipcRenderer.removeListener(channel, listener);
103
- },
104
- pickerStart: (viewId) => ipcRenderer.invoke('browser:picker-start', { viewId }),
105
- pickerStop: (viewId) => ipcRenderer.invoke('browser:picker-stop', { viewId }),
106
- onPickerEvent: (viewId, handler) => {
107
- const channel = `browser:picker-event:${viewId}`;
108
- const listener = (_e, ev) => handler(ev);
109
- ipcRenderer.on(channel, listener);
110
- return () => ipcRenderer.removeListener(channel, listener);
111
- },
112
- capture: (payload) => ipcRenderer.invoke('browser:capture', payload),
113
- onOpenTabRequest: (handler) => {
114
- const listener = (_e, payload) => handler(payload);
115
- ipcRenderer.on('browser:open-tab-request', listener);
116
- return () => ipcRenderer.removeListener('browser:open-tab-request', listener);
117
- },
118
- },
119
59
  transcripts: {
120
60
  subscribe: (payload) => ipcRenderer.invoke('transcript:subscribe', payload),
121
61
  unsubscribe: (tabId) => ipcRenderer.invoke('transcript:unsubscribe', { tabId }),
@@ -246,6 +186,7 @@ contextBridge.exposeInMainWorld('api', {
246
186
  rename: (path, newName) => ipcRenderer.invoke('files:rename', { path, newName }),
247
187
  delete: (path) => ipcRenderer.invoke('files:delete', { path }),
248
188
  duplicate: (path) => ipcRenderer.invoke('files:duplicate', { path }),
189
+ saveBinary: (path, base64, writer) => ipcRenderer.invoke('files:save-binary', { path, base64, writer }),
249
190
  },
250
191
  docEdit: {
251
192
  run: (payload) => ipcRenderer.invoke('docedit:run', payload),
@@ -309,7 +250,6 @@ contextBridge.exposeInMainWorld('api', {
309
250
  clipboard: {
310
251
  pasteImage: () => ipcRenderer.invoke('clipboard:paste-image'),
311
252
  pasteText: () => ipcRenderer.invoke('clipboard:paste-text'),
312
- copyImage: (dataUrl) => ipcRenderer.invoke('browser:copy-image', { dataUrl }),
313
253
  writeText: (text) => ipcRenderer.invoke('clipboard:write-text', { text }),
314
254
  },
315
255
  memory: {
@@ -372,45 +312,6 @@ contextBridge.exposeInMainWorld('api', {
372
312
  status: (cwd) => ipcRenderer.invoke('git:status', { cwd }),
373
313
  fileStatus: (cwd) => ipcRenderer.invoke('git:file-status', { cwd }),
374
314
  },
375
- webRemote: {
376
- /** Current connection state (no token values). */
377
- getStatus: () => ipcRenderer.invoke('webRemote:get-status'),
378
- /** Turn remote control on. Initiates relay connection if a device is paired. */
379
- enable: () => ipcRenderer.invoke('webRemote:enable'),
380
- /** Turn remote control off. Immediately drops relay connection. */
381
- disable: () => ipcRenderer.invoke('webRemote:disable'),
382
- /** Allow MUTATE-tier commands (pty spawn/write, scheduler writes). Default off. */
383
- enableControl: () => ipcRenderer.invoke('webRemote:enable-control'),
384
- /** Block MUTATE-tier commands — mobile becomes read-only mirror. */
385
- disableControl: () => ipcRenderer.invoke('webRemote:disable-control'),
386
- /** Pair a new device using the 8-character OTP shown in the web UI. */
387
- pair: (otp) => ipcRenderer.invoke('webRemote:pair', { otp }),
388
- /** Revoke a paired device by its deviceId. */
389
- revokeDevice: (deviceId) => ipcRenderer.invoke('webRemote:revoke-device', { deviceId }),
390
- /** Read the last N lines of today's audit log (default 50). */
391
- auditTail: (lines) => ipcRenderer.invoke('webRemote:audit-tail', lines ? { lines } : {}),
392
- /** Push event from main when connection status changes. */
393
- onStatus: (handler) => {
394
- const listener = (_e, payload) => handler(payload);
395
- ipcRenderer.on('webRemote:status', listener);
396
- return () => ipcRenderer.removeListener('webRemote:status', listener);
397
- },
398
- /** Push event when the relay revokes this device's token. */
399
- onTokenRevoked: (handler) => {
400
- const listener = (_e, payload) => handler(payload);
401
- ipcRenderer.on('webRemote:token-revoked', listener);
402
- return () => ipcRenderer.removeListener('webRemote:token-revoked', listener);
403
- },
404
- /** Revoke ALL paired devices and tear down every session immediately. */
405
- revokeAll: () => ipcRenderer.invoke('webRemote:revoke-all'),
406
- confirmSas: () => ipcRenderer.invoke('webRemote:confirm-sas'),
407
- /** Push event when revokeAll completes (main broadcasts webRemote:revoked-all). */
408
- onRevokedAll: (handler) => {
409
- const listener = (_e, payload) => handler(payload);
410
- ipcRenderer.on('webRemote:revoked-all', listener);
411
- return () => ipcRenderer.removeListener('webRemote:revoked-all', listener);
412
- },
413
- },
414
315
  chat: {
415
316
  /** Spawn a headless claude -p job. Results arrive via the on* listeners. */
416
317
  run: (payload) => ipcRenderer.invoke('chat:run', payload),
@@ -1,235 +0,0 @@
1
- /**
2
- * browserAgentServer.test.cjs — unit tests for the loopback-only browser
3
- * agent HTTP API (PRD 535).
4
- *
5
- * Run: timeout 120 node --test src/main/__tests__/browserAgentServer.test.cjs
6
- */
7
-
8
- 'use strict';
9
-
10
- const { test } = require('node:test');
11
- const assert = require('node:assert/strict');
12
- const http = require('node:http');
13
- const fs = require('node:fs');
14
- const path = require('node:path');
15
- const { createBrowserAgentServer, TOKEN_PATH, resolveTokenPath } = require('../browserAgentServer.cjs');
16
-
17
- function request(port, { method = 'GET', path: reqPath, token, body }) {
18
- return new Promise((resolve, reject) => {
19
- const headers = {};
20
- if (token !== undefined) headers.Authorization = `Bearer ${token}`;
21
- let payload;
22
- if (body !== undefined) {
23
- payload = JSON.stringify(body);
24
- headers['Content-Type'] = 'application/json';
25
- headers['Content-Length'] = Buffer.byteLength(payload);
26
- }
27
- const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
28
- const chunks = [];
29
- res.on('data', (c) => chunks.push(c));
30
- res.on('end', () => {
31
- const text = Buffer.concat(chunks).toString('utf8');
32
- let json = null;
33
- try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
34
- resolve({ status: res.statusCode, json, text });
35
- });
36
- });
37
- req.on('error', reject);
38
- if (payload) req.write(payload);
39
- req.end();
40
- });
41
- }
42
-
43
- function makeFakeRemote() {
44
- const actions = [];
45
- return {
46
- actions,
47
- async listTabs() {
48
- return [{ viewId: 'v1', url: 'https://example.com', title: 'Example', active: true }];
49
- },
50
- async screenshot({ viewId }) {
51
- if (!viewId && viewId !== undefined) return { ok: false, error: 'unknown viewId' };
52
- return { ok: true, viewId: viewId || 'v1', dataUrl: 'data:image/png;base64,AAA', scale: 1 };
53
- },
54
- async action(params) {
55
- actions.push(params);
56
- if (params.action === 'boom') return { ok: false, error: 'unsupported action: boom' };
57
- return { ok: true };
58
- },
59
- async dom({ viewId }) {
60
- return { ok: true, viewId: viewId || 'v1', text: 'hello', elements: [] };
61
- },
62
- };
63
- }
64
-
65
- test('rejects requests without a bearer token', async () => {
66
- const remote = makeFakeRemote();
67
- const server = createBrowserAgentServer(remote);
68
- const { port } = await server.start();
69
- try {
70
- const res = await request(port, { path: '/agent/browser/tabs' });
71
- assert.equal(res.status, 401);
72
- assert.equal(res.json.ok, false);
73
- } finally {
74
- await server.stop();
75
- }
76
- });
77
-
78
- test('rejects requests with a wrong token', async () => {
79
- const remote = makeFakeRemote();
80
- const server = createBrowserAgentServer(remote);
81
- const { port } = await server.start();
82
- try {
83
- const res = await request(port, { path: '/agent/browser/tabs', token: 'wrong-token' });
84
- assert.equal(res.status, 401);
85
- } finally {
86
- await server.stop();
87
- }
88
- });
89
-
90
- test('GET /agent/browser/tabs lists open tabs', async () => {
91
- const remote = makeFakeRemote();
92
- const server = createBrowserAgentServer(remote);
93
- const { port, token } = await server.start();
94
- try {
95
- const res = await request(port, { path: '/agent/browser/tabs', token });
96
- assert.equal(res.status, 200);
97
- assert.equal(res.json.ok, true);
98
- assert.equal(res.json.tabs.length, 1);
99
- assert.equal(res.json.tabs[0].active, true);
100
- } finally {
101
- await server.stop();
102
- }
103
- });
104
-
105
- test('POST /agent/browser/screenshot returns a resized image payload', async () => {
106
- const remote = makeFakeRemote();
107
- const server = createBrowserAgentServer(remote);
108
- const { port, token } = await server.start();
109
- try {
110
- const res = await request(port, { method: 'POST', path: '/agent/browser/screenshot', token, body: {} });
111
- assert.equal(res.status, 200);
112
- assert.equal(res.json.ok, true);
113
- assert.ok(res.json.dataUrl.startsWith('data:image/png'));
114
- } finally {
115
- await server.stop();
116
- }
117
- });
118
-
119
- test('POST /agent/browser/action dispatches a single action', async () => {
120
- const remote = makeFakeRemote();
121
- const server = createBrowserAgentServer(remote);
122
- const { port, token } = await server.start();
123
- try {
124
- const res = await request(port, {
125
- method: 'POST',
126
- path: '/agent/browser/action',
127
- token,
128
- body: { action: 'left_click', coordinate: [10, 20] },
129
- });
130
- assert.equal(res.status, 200);
131
- assert.equal(res.json.ok, true);
132
- assert.equal(remote.actions.length, 1);
133
- assert.deepEqual(remote.actions[0].coordinate, [10, 20]);
134
- } finally {
135
- await server.stop();
136
- }
137
- });
138
-
139
- test('POST /agent/browser/action without an action field is a 400', async () => {
140
- const remote = makeFakeRemote();
141
- const server = createBrowserAgentServer(remote);
142
- const { port, token } = await server.start();
143
- try {
144
- const res = await request(port, { method: 'POST', path: '/agent/browser/action', token, body: {} });
145
- assert.equal(res.status, 400);
146
- assert.equal(res.json.ok, false);
147
- } finally {
148
- await server.stop();
149
- }
150
- });
151
-
152
- test('POST /agent/browser/action surfaces a remote failure as a 400', async () => {
153
- const remote = makeFakeRemote();
154
- const server = createBrowserAgentServer(remote);
155
- const { port, token } = await server.start();
156
- try {
157
- const res = await request(port, { method: 'POST', path: '/agent/browser/action', token, body: { action: 'boom' } });
158
- assert.equal(res.status, 400);
159
- assert.equal(res.json.ok, false);
160
- } finally {
161
- await server.stop();
162
- }
163
- });
164
-
165
- test('GET /agent/browser/dom returns a text snapshot', async () => {
166
- const remote = makeFakeRemote();
167
- const server = createBrowserAgentServer(remote);
168
- const { port, token } = await server.start();
169
- try {
170
- const res = await request(port, { path: '/agent/browser/dom', token });
171
- assert.equal(res.status, 200);
172
- assert.equal(res.json.text, 'hello');
173
- } finally {
174
- await server.stop();
175
- }
176
- });
177
-
178
- test('unknown route is a 404', async () => {
179
- const remote = makeFakeRemote();
180
- const server = createBrowserAgentServer(remote);
181
- const { port, token } = await server.start();
182
- try {
183
- const res = await request(port, { path: '/agent/browser/nope', token });
184
- assert.equal(res.status, 404);
185
- } finally {
186
- await server.stop();
187
- }
188
- });
189
-
190
- test('SM_DEV=1 writes to a dev-suffixed path, never the production browser-agent-api.json', async () => {
191
- const devPath = path.join(path.dirname(TOKEN_PATH), 'browser-agent-api.dev.json');
192
- const prevMtime = fs.existsSync(TOKEN_PATH) ? fs.statSync(TOKEN_PATH).mtimeMs : null;
193
- const prevEnv = process.env.SM_DEV;
194
- const remote = makeFakeRemote();
195
- let server;
196
- try {
197
- process.env.SM_DEV = '1';
198
- server = createBrowserAgentServer(remote);
199
- const { port, token } = await server.start();
200
- assert.equal(resolveTokenPath(), devPath);
201
- const parsed = JSON.parse(fs.readFileSync(devPath, 'utf8'));
202
- assert.equal(parsed.port, port);
203
- assert.equal(parsed.token, token);
204
- if (prevMtime !== null) {
205
- assert.equal(fs.statSync(TOKEN_PATH).mtimeMs, prevMtime);
206
- } else {
207
- assert.equal(fs.existsSync(TOKEN_PATH), false);
208
- }
209
- } finally {
210
- if (server) await server.stop();
211
- if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
212
- fs.rmSync(devPath, { force: true });
213
- }
214
- });
215
-
216
- test('with neither SM_DEV nor SM_E2E set, writes to the original browser-agent-api.json path', async () => {
217
- const prevDev = process.env.SM_DEV;
218
- const prevE2e = process.env.SM_E2E;
219
- const remote = makeFakeRemote();
220
- let server;
221
- try {
222
- delete process.env.SM_DEV;
223
- delete process.env.SM_E2E;
224
- assert.equal(resolveTokenPath(), TOKEN_PATH);
225
- server = createBrowserAgentServer(remote);
226
- const { port, token } = await server.start();
227
- const parsed = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
228
- assert.equal(parsed.port, port);
229
- assert.equal(parsed.token, token);
230
- } finally {
231
- if (server) await server.stop();
232
- if (prevDev === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevDev;
233
- if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
234
- }
235
- });