@wenrwa/marketplace-cli 0.4.5 → 0.4.6

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 (112) hide show
  1. package/dist/__tests__/bridge-activity-parser.test.d.ts +1 -0
  2. package/dist/__tests__/bridge-activity-parser.test.js +0 -0
  3. package/dist/__tests__/bridge-activity-tap.test.d.ts +1 -0
  4. package/dist/__tests__/bridge-activity-tap.test.js +178 -0
  5. package/dist/__tests__/bridge-api-client.test.d.ts +1 -0
  6. package/dist/__tests__/bridge-api-client.test.js +69 -0
  7. package/dist/__tests__/bridge-browser-driver.test.d.ts +1 -0
  8. package/dist/__tests__/bridge-browser-driver.test.js +379 -0
  9. package/dist/__tests__/bridge-browser-frames.test.d.ts +1 -0
  10. package/dist/__tests__/bridge-browser-frames.test.js +166 -0
  11. package/dist/__tests__/bridge-browser-idle.test.d.ts +1 -0
  12. package/dist/__tests__/bridge-browser-idle.test.js +259 -0
  13. package/dist/__tests__/bridge-browser-relay.test.d.ts +1 -0
  14. package/dist/__tests__/bridge-browser-relay.test.js +670 -0
  15. package/dist/__tests__/bridge-bundle-installer.test.d.ts +1 -0
  16. package/dist/__tests__/bridge-bundle-installer.test.js +126 -0
  17. package/dist/__tests__/bridge-cdp-client.test.d.ts +1 -0
  18. package/dist/__tests__/bridge-cdp-client.test.js +133 -0
  19. package/dist/__tests__/bridge-chromium-launcher.test.d.ts +1 -0
  20. package/dist/__tests__/bridge-chromium-launcher.test.js +202 -0
  21. package/dist/__tests__/bridge-command-browser-options.test.d.ts +1 -0
  22. package/dist/__tests__/bridge-command-browser-options.test.js +37 -0
  23. package/dist/__tests__/bridge-config-mcp.test.js +4 -0
  24. package/dist/__tests__/bridge-context-manifest-frame.test.d.ts +1 -0
  25. package/dist/__tests__/bridge-context-manifest-frame.test.js +125 -0
  26. package/dist/__tests__/bridge-context-writer.test.d.ts +1 -0
  27. package/dist/__tests__/bridge-context-writer.test.js +161 -0
  28. package/dist/__tests__/bridge-device-registration.test.d.ts +1 -0
  29. package/dist/__tests__/bridge-device-registration.test.js +33 -0
  30. package/dist/__tests__/bridge-folder-picker-runner.test.d.ts +1 -0
  31. package/dist/__tests__/bridge-folder-picker-runner.test.js +37 -0
  32. package/dist/__tests__/bridge-folder-picker.test.d.ts +1 -0
  33. package/dist/__tests__/bridge-folder-picker.test.js +66 -0
  34. package/dist/__tests__/bridge-folder-registry.test.d.ts +1 -0
  35. package/dist/__tests__/bridge-folder-registry.test.js +107 -0
  36. package/dist/__tests__/bridge-frame-pacer.test.d.ts +1 -0
  37. package/dist/__tests__/bridge-frame-pacer.test.js +102 -0
  38. package/dist/__tests__/bridge-install-bundle-frame.test.d.ts +1 -0
  39. package/dist/__tests__/bridge-install-bundle-frame.test.js +107 -0
  40. package/dist/__tests__/bridge-local-add-core.test.d.ts +1 -0
  41. package/dist/__tests__/bridge-local-add-core.test.js +48 -0
  42. package/dist/__tests__/bridge-local-path-validation-fs.test.d.ts +1 -0
  43. package/dist/__tests__/bridge-local-path-validation-fs.test.js +88 -0
  44. package/dist/__tests__/bridge-local-path-validation.test.d.ts +1 -0
  45. package/dist/__tests__/bridge-local-path-validation.test.js +45 -0
  46. package/dist/__tests__/bridge-login-status.test.d.ts +1 -0
  47. package/dist/__tests__/bridge-login-status.test.js +60 -0
  48. package/dist/__tests__/bridge-relay-pucc14.test.d.ts +1 -0
  49. package/dist/__tests__/bridge-relay-pucc14.test.js +230 -0
  50. package/dist/__tests__/bridge-server-local-endpoints.test.d.ts +1 -0
  51. package/dist/__tests__/bridge-server-local-endpoints.test.js +197 -0
  52. package/dist/__tests__/bridge-server.test.js +22 -0
  53. package/dist/__tests__/bridge-workspace-multirepo.test.d.ts +1 -0
  54. package/dist/__tests__/bridge-workspace-multirepo.test.js +317 -0
  55. package/dist/__tests__/devices.test.d.ts +1 -0
  56. package/dist/__tests__/devices.test.js +66 -0
  57. package/dist/__tests__/local.test.d.ts +1 -0
  58. package/dist/__tests__/local.test.js +147 -0
  59. package/dist/bridge/activityParser.d.ts +53 -0
  60. package/dist/bridge/activityParser.js +202 -0
  61. package/dist/bridge/apiClient.d.ts +56 -0
  62. package/dist/bridge/apiClient.js +50 -0
  63. package/dist/bridge/browserDriver.d.ts +98 -0
  64. package/dist/bridge/browserDriver.js +265 -0
  65. package/dist/bridge/browserFrames.d.ts +182 -0
  66. package/dist/bridge/browserFrames.js +179 -0
  67. package/dist/bridge/browserIdle.d.ts +75 -0
  68. package/dist/bridge/browserIdle.js +120 -0
  69. package/dist/bridge/bundleInstaller.d.ts +31 -0
  70. package/dist/bridge/bundleInstaller.js +127 -0
  71. package/dist/bridge/cdpClient.d.ts +47 -0
  72. package/dist/bridge/cdpClient.js +102 -0
  73. package/dist/bridge/chromiumLauncher.d.ts +45 -0
  74. package/dist/bridge/chromiumLauncher.js +113 -0
  75. package/dist/bridge/config.d.ts +3 -0
  76. package/dist/bridge/contextWriter.d.ts +31 -0
  77. package/dist/bridge/contextWriter.js +139 -0
  78. package/dist/bridge/deviceRegistration.d.ts +28 -0
  79. package/dist/bridge/deviceRegistration.js +19 -0
  80. package/dist/bridge/folderPicker.d.ts +34 -0
  81. package/dist/bridge/folderPicker.js +36 -0
  82. package/dist/bridge/folderPickerRunner.d.ts +3 -0
  83. package/dist/bridge/folderPickerRunner.js +25 -0
  84. package/dist/bridge/folderRegistry.d.ts +19 -0
  85. package/dist/bridge/folderRegistry.js +100 -0
  86. package/dist/bridge/framePacer.d.ts +61 -0
  87. package/dist/bridge/framePacer.js +79 -0
  88. package/dist/bridge/localAdd.d.ts +28 -0
  89. package/dist/bridge/localAdd.js +67 -0
  90. package/dist/bridge/localPathValidation.d.ts +20 -0
  91. package/dist/bridge/localPathValidation.js +58 -0
  92. package/dist/bridge/localPathValidationFs.d.ts +2 -0
  93. package/dist/bridge/localPathValidationFs.js +98 -0
  94. package/dist/bridge/loginStatus.d.ts +38 -0
  95. package/dist/bridge/loginStatus.js +56 -0
  96. package/dist/bridge/realPty.js +6 -2
  97. package/dist/bridge/relayClient.d.ts +124 -0
  98. package/dist/bridge/relayClient.js +368 -5
  99. package/dist/bridge/repoCheckout.d.ts +31 -0
  100. package/dist/bridge/repoCheckout.js +18 -0
  101. package/dist/bridge/server.d.ts +33 -0
  102. package/dist/bridge/server.js +98 -10
  103. package/dist/bridge/sessionManager.d.ts +18 -0
  104. package/dist/bridge/sessionManager.js +15 -1
  105. package/dist/commands/bridge.d.ts +6 -0
  106. package/dist/commands/bridge.js +155 -1
  107. package/dist/commands/devices.d.ts +9 -0
  108. package/dist/commands/devices.js +53 -0
  109. package/dist/commands/local.d.ts +9 -0
  110. package/dist/commands/local.js +141 -0
  111. package/dist/index.js +4 -0
  112. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * pucc-int-03 — the bridge's read-only PTY activity tap (ADR-0032).
5
+ *
6
+ * Proves the relay client observes Claude Code tool-use in the PTY output and
7
+ * emits a coalesced `activity` frame ({ filePaths, thinking }) to the relay —
8
+ * WITHOUT altering the `data` frames forwarded to the browser, and never on a
9
+ * parse miss. Same faked-PTY / fake-relay harness as bridge-relay-pucc14.test.
10
+ */
11
+ const ws_1 = require("ws");
12
+ const relayClient_1 = require("../bridge/relayClient");
13
+ const sessionManager_1 = require("../bridge/sessionManager");
14
+ const ESC = '\u001b'; // ESC byte
15
+ class FakePty {
16
+ opts;
17
+ written = [];
18
+ killed = false;
19
+ constructor(opts) {
20
+ this.opts = opts;
21
+ }
22
+ write(data) { this.written.push(data); }
23
+ resize() { }
24
+ kill() { this.killed = true; }
25
+ dataCb = null;
26
+ onData(cb) { this.dataCb = cb; }
27
+ onExit(_cb) { }
28
+ emitData(d) { this.dataCb?.(d); }
29
+ }
30
+ const makeFactory = () => {
31
+ const ptys = [];
32
+ const factory = { spawn: (o) => { const p = new FakePty(o); ptys.push(p); return p; } };
33
+ return { factory, ptys };
34
+ };
35
+ function collectFrames(ws) {
36
+ const frames = [];
37
+ ws.on('message', (raw) => frames.push(JSON.parse(String(raw))));
38
+ return frames;
39
+ }
40
+ function startFakeRelay() {
41
+ return new Promise((resolve) => {
42
+ const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
43
+ const queue = [];
44
+ const waiters = [];
45
+ wss.on('connection', (ws) => { const w = waiters.shift(); if (w)
46
+ w(ws);
47
+ else
48
+ queue.push(ws); });
49
+ wss.on('listening', () => {
50
+ const addr = wss.address();
51
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
52
+ resolve({ wss, port, nextConnection: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => waiters.push(r))) });
53
+ });
54
+ });
55
+ }
56
+ function closeRelay(wss) {
57
+ return new Promise((resolve, reject) => {
58
+ for (const client of wss.clients)
59
+ client.terminate();
60
+ wss.close((err) => (err ? reject(err) : resolve()));
61
+ });
62
+ }
63
+ const settle = () => new Promise((r) => setTimeout(r, 20));
64
+ describe('bridge activity tap (pucc-int-03)', () => {
65
+ it('emits an activity frame with filePaths/thinking parsed from PTY output', async () => {
66
+ const relay = await startFakeRelay();
67
+ const { factory, ptys } = makeFactory();
68
+ const sessions = new sessionManager_1.SessionManager(factory);
69
+ // throttle 0 → emit immediately so the assertion is deterministic.
70
+ const handle = (0, relayClient_1.startRelayClient)({
71
+ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions, activityThrottleMs: 0,
72
+ });
73
+ const ws = await relay.nextConnection();
74
+ const frames = collectFrames(ws);
75
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'agent-A' }));
76
+ await settle();
77
+ ptys[0].emitData(`${ESC}[1m⏺${ESC}[0m Edit(src/foo.ts)\r\n`);
78
+ await settle();
79
+ const activity = frames.find((f) => f.t === 'activity');
80
+ expect(activity).toMatchObject({ t: 'activity', sessionId: 'agent-A', filePaths: ['src/foo.ts'], thinking: 'Editing foo.ts' });
81
+ handle.close();
82
+ await closeRelay(relay.wss);
83
+ });
84
+ it('still forwards the raw data frame to the browser UNALTERED', async () => {
85
+ const relay = await startFakeRelay();
86
+ const { factory, ptys } = makeFactory();
87
+ const sessions = new sessionManager_1.SessionManager(factory);
88
+ const handle = (0, relayClient_1.startRelayClient)({
89
+ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions, activityThrottleMs: 0,
90
+ });
91
+ const ws = await relay.nextConnection();
92
+ const frames = collectFrames(ws);
93
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'agent-A' }));
94
+ await settle();
95
+ const raw = `${ESC}[1m⏺${ESC}[0m Read(a.ts)\r\n`;
96
+ ptys[0].emitData(raw);
97
+ await settle();
98
+ // The data frame is byte-identical to the PTY output — the tap did not touch it.
99
+ const data = frames.find((f) => f.t === 'data');
100
+ expect(data).toEqual({ t: 'data', sessionId: 'agent-A', data: raw });
101
+ handle.close();
102
+ await closeRelay(relay.wss);
103
+ });
104
+ it('coalesces a burst of tool-use into a single throttled activity frame', async () => {
105
+ const relay = await startFakeRelay();
106
+ const { factory, ptys } = makeFactory();
107
+ const sessions = new sessionManager_1.SessionManager(factory);
108
+ let clock = 1000;
109
+ let scheduled = null;
110
+ const handle = (0, relayClient_1.startRelayClient)({
111
+ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions,
112
+ activityThrottleMs: 1000,
113
+ nowFn: () => clock,
114
+ setTimeoutFn: (cb) => { scheduled = cb; return 1; },
115
+ clearTimeoutFn: () => { },
116
+ });
117
+ const ws = await relay.nextConnection();
118
+ const frames = collectFrames(ws);
119
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'agent-A' }));
120
+ await settle();
121
+ // First signal fires immediately (lastEmit=0, elapsed >= throttle).
122
+ ptys[0].emitData('⏺ Edit(one.ts)\n');
123
+ await settle();
124
+ // Two more within the SAME window are coalesced behind the timer, not sent.
125
+ ptys[0].emitData('⏺ Edit(two.ts)\n');
126
+ ptys[0].emitData('⏺ Read(three.ts)\n');
127
+ await settle();
128
+ let activities = frames.filter((f) => f.t === 'activity');
129
+ expect(activities).toHaveLength(1);
130
+ expect(activities[0].filePaths).toEqual(['one.ts']);
131
+ // Advance the clock and fire the pending flush → the coalesced batch emits.
132
+ clock += 1000;
133
+ scheduled();
134
+ await settle();
135
+ activities = frames.filter((f) => f.t === 'activity');
136
+ expect(activities).toHaveLength(2);
137
+ expect(activities[1].filePaths).toEqual(['two.ts', 'three.ts']);
138
+ expect(activities[1].thinking).toBe('Reading three.ts'); // latest thinking wins
139
+ handle.close();
140
+ await closeRelay(relay.wss);
141
+ });
142
+ it('a parse miss (ordinary output) emits NO activity frame', async () => {
143
+ const relay = await startFakeRelay();
144
+ const { factory, ptys } = makeFactory();
145
+ const sessions = new sessionManager_1.SessionManager(factory);
146
+ const handle = (0, relayClient_1.startRelayClient)({
147
+ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions, activityThrottleMs: 0,
148
+ });
149
+ const ws = await relay.nextConnection();
150
+ const frames = collectFrames(ws);
151
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'agent-A' }));
152
+ await settle();
153
+ ptys[0].emitData('$ ls -la\r\ntotal 24\r\ndrwxr-xr-x 5 me staff\r\n');
154
+ await settle();
155
+ expect(frames.some((f) => f.t === 'activity')).toBe(false);
156
+ expect(frames.some((f) => f.t === 'data')).toBe(true); // data still flowed
157
+ handle.close();
158
+ await closeRelay(relay.wss);
159
+ });
160
+ it('emitActivity:false disables the tap entirely (data still flows)', async () => {
161
+ const relay = await startFakeRelay();
162
+ const { factory, ptys } = makeFactory();
163
+ const sessions = new sessionManager_1.SessionManager(factory);
164
+ const handle = (0, relayClient_1.startRelayClient)({
165
+ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions, emitActivity: false, activityThrottleMs: 0,
166
+ });
167
+ const ws = await relay.nextConnection();
168
+ const frames = collectFrames(ws);
169
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'agent-A' }));
170
+ await settle();
171
+ ptys[0].emitData('⏺ Edit(src/foo.ts)\n');
172
+ await settle();
173
+ expect(frames.some((f) => f.t === 'activity')).toBe(false);
174
+ expect(frames.some((f) => f.t === 'data')).toBe(true);
175
+ handle.close();
176
+ await closeRelay(relay.wss);
177
+ });
178
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * TDD — lws-03: bridge/apiClient.ts. A thin, injectable fetch wrapper the
5
+ * bridge daemon uses to talk to the backend AS ITSELF (Bearer Connect Token)
6
+ * — distinct from utils/client.ts, which authenticates the interactive CLI
7
+ * via the legacy X-Wallet-Pubkey login flow (~/.wen-market/config.json).
8
+ */
9
+ const apiClient_1 = require("../bridge/apiClient");
10
+ const OPTS = { apiUrl: 'https://api.x', connectToken: 'wm_ct_' + 'a'.repeat(64) };
11
+ function fakeFetch(status, body) {
12
+ const fetchImpl = jest.fn().mockResolvedValue({
13
+ ok: status >= 200 && status < 300,
14
+ status,
15
+ json: async () => body,
16
+ });
17
+ return fetchImpl;
18
+ }
19
+ describe('bridge apiClient', () => {
20
+ it('registerDevice posts to /me/devices with the Bearer connect token', async () => {
21
+ const fetchImpl = fakeFetch(201, { success: true, device: { id: 'd1', name: 'bunny-mbp' } });
22
+ const result = await (0, apiClient_1.registerDevice)({ ...OPTS, fetchImpl }, 'bunny-mbp');
23
+ expect(result.device.id).toBe('d1');
24
+ const [url, init] = fetchImpl.mock.calls[0];
25
+ expect(url).toBe('https://api.x/me/devices');
26
+ expect(init.method).toBe('POST');
27
+ expect(init.headers.Authorization).toBe(`Bearer ${OPTS.connectToken}`);
28
+ expect(JSON.parse(init.body)).toEqual({ name: 'bunny-mbp' });
29
+ });
30
+ it('listDevices gets /me/devices', async () => {
31
+ const fetchImpl = fakeFetch(200, { success: true, devices: [{ id: 'd1' }] });
32
+ const result = await (0, apiClient_1.listDevices)({ ...OPTS, fetchImpl });
33
+ expect(result.devices).toHaveLength(1);
34
+ expect(fetchImpl.mock.calls[0][0]).toBe('https://api.x/me/devices');
35
+ expect(fetchImpl.mock.calls[0][1].method).toBe('GET');
36
+ });
37
+ it('revokeDevice deletes /me/devices/:id', async () => {
38
+ const fetchImpl = fakeFetch(200, { success: true });
39
+ await (0, apiClient_1.revokeDevice)({ ...OPTS, fetchImpl }, 'd1');
40
+ expect(fetchImpl.mock.calls[0][0]).toBe('https://api.x/me/devices/d1');
41
+ expect(fetchImpl.mock.calls[0][1].method).toBe('DELETE');
42
+ });
43
+ it('createLocalWorkspace posts kind:local to /workspaces', async () => {
44
+ const fetchImpl = fakeFetch(201, { success: true, workspace: { id: 'ws-1', name: 'my-app' } });
45
+ const result = await (0, apiClient_1.createLocalWorkspace)({ ...OPTS, fetchImpl }, { name: 'my-app' });
46
+ expect(result.workspace.id).toBe('ws-1');
47
+ const [url, init] = fetchImpl.mock.calls[0];
48
+ expect(url).toBe('https://api.x/workspaces');
49
+ expect(JSON.parse(init.body)).toEqual({ name: 'my-app', kind: 'local' });
50
+ });
51
+ it('deleteWorkspace deletes /workspaces/:id', async () => {
52
+ const fetchImpl = fakeFetch(200, { success: true });
53
+ await (0, apiClient_1.deleteWorkspace)({ ...OPTS, fetchImpl }, 'ws-1');
54
+ expect(fetchImpl.mock.calls[0][0]).toBe('https://api.x/workspaces/ws-1');
55
+ expect(fetchImpl.mock.calls[0][1].method).toBe('DELETE');
56
+ });
57
+ it('throws a BridgeApiError carrying the backend error code on failure', async () => {
58
+ const fetchImpl = fakeFetch(401, { success: false, error: { code: 'INVALID_CONNECT_TOKEN', message: 'Connect token is invalid or revoked.' } });
59
+ await expect((0, apiClient_1.listDevices)({ ...OPTS, fetchImpl })).rejects.toThrow(apiClient_1.BridgeApiError);
60
+ try {
61
+ await (0, apiClient_1.listDevices)({ ...OPTS, fetchImpl });
62
+ }
63
+ catch (err) {
64
+ expect(err).toBeInstanceOf(apiClient_1.BridgeApiError);
65
+ expect(err.code).toBe('INVALID_CONNECT_TOKEN');
66
+ expect(err.status).toBe(401);
67
+ }
68
+ });
69
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,379 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * cbs-02 — bridge CDP browser driver.
5
+ *
6
+ * The deep module owning headless Chromium: spawn (child_process, NOT
7
+ * node-pty/SessionManager), a minimal CDP client over `ws`, screencast
8
+ * frames piped through the cbs-01 keep-newest pacer, and BrowserInputEvent
9
+ * -> CDP input dispatch. Tested end-to-end against a MockCdpEndpoint
10
+ * (in-process injectable transport — no real socket, no real Chromium) and
11
+ * a fake child process (no real spawn).
12
+ *
13
+ * docs/prd/cloud-browser-session.md + issues/cbs-02-bridge-cdp-driver.md.
14
+ */
15
+ const browserDriver_1 = require("../bridge/browserDriver");
16
+ const browserFrames_1 = require("../bridge/browserFrames");
17
+ class FakeChildProcess {
18
+ killed = false;
19
+ killCount = 0;
20
+ stderr = { on: () => { } };
21
+ exitHandlers = [];
22
+ on(event, cb) {
23
+ if (event === 'exit')
24
+ this.exitHandlers.push(cb);
25
+ }
26
+ kill() {
27
+ this.killed = true;
28
+ this.killCount += 1;
29
+ }
30
+ emitExit(code, signal = null) {
31
+ for (const cb of this.exitHandlers)
32
+ cb(code, signal);
33
+ }
34
+ }
35
+ /** In-process CDP endpoint: scripts command responses and lets the test
36
+ * fire raw CDP events, with no real socket involved. */
37
+ class MockCdpEndpoint {
38
+ sent = [];
39
+ handlers = new Map();
40
+ onMessage = null;
41
+ onCloseCb = null;
42
+ closed = false;
43
+ transport = {
44
+ send: (data) => {
45
+ const msg = JSON.parse(data);
46
+ this.sent.push(msg);
47
+ const handler = this.handlers.get(msg.method);
48
+ const result = handler ? handler(msg.params ?? {}) : {};
49
+ queueMicrotask(() => this.onMessage?.(JSON.stringify({ id: msg.id, result })));
50
+ },
51
+ close: () => {
52
+ if (this.closed)
53
+ return;
54
+ this.closed = true;
55
+ queueMicrotask(() => this.onCloseCb?.(undefined));
56
+ },
57
+ on: (event, cb) => {
58
+ if (event === 'open')
59
+ queueMicrotask(() => cb());
60
+ else if (event === 'message')
61
+ this.onMessage = cb;
62
+ else if (event === 'close')
63
+ this.onCloseCb = cb;
64
+ // 'error' intentionally ignored
65
+ },
66
+ };
67
+ on(method, handler) {
68
+ this.handlers.set(method, handler);
69
+ }
70
+ emitEvent(method, params, sessionId) {
71
+ this.onMessage?.(JSON.stringify({ method, params, ...(sessionId ? { sessionId } : {}) }));
72
+ }
73
+ triggerClose(info) {
74
+ if (this.closed)
75
+ return;
76
+ this.closed = true;
77
+ this.onCloseCb?.(info);
78
+ }
79
+ sentCalls(method) {
80
+ return this.sent.filter((m) => m.method === method);
81
+ }
82
+ }
83
+ /** Wires a fresh driver + mock endpoint + fake child, with the standard
84
+ * attach handshake pre-scripted (empty target list -> createTarget ->
85
+ * attachToTarget). Individual tests add/override handlers as needed. */
86
+ function setup(opts) {
87
+ const endpoint = new MockCdpEndpoint();
88
+ const proc = new FakeChildProcess();
89
+ endpoint.on('Target.getTargets', () => ({
90
+ targetInfos: opts?.existingPageTargetId ? [{ targetId: opts.existingPageTargetId, type: 'page' }] : [],
91
+ }));
92
+ endpoint.on('Target.createTarget', () => ({ targetId: 'target-new' }));
93
+ endpoint.on('Target.attachToTarget', () => ({ sessionId: 'sess-1' }));
94
+ const launchChromiumFn = () => ({ proc, wsUrl: Promise.resolve('ws://mock-devtools') });
95
+ const wsFactory = (_url) => endpoint.transport;
96
+ const driver = (0, browserDriver_1.createBrowserDriver)({ launchChromiumFn, wsFactory, width: 1280, height: 800, quality: 60 });
97
+ return { driver, endpoint, proc };
98
+ }
99
+ describe('createBrowserDriver (cbs-02)', () => {
100
+ it('start(url): attaches to the first page target and enables screencast, then navigates', async () => {
101
+ const { driver, endpoint } = setup({ existingPageTargetId: 'target-existing' });
102
+ await driver.start('https://example.com');
103
+ const methods = endpoint.sent.map((m) => m.method);
104
+ expect(methods).toEqual([
105
+ 'Target.getTargets',
106
+ 'Target.attachToTarget',
107
+ 'Page.enable',
108
+ 'Page.startScreencast',
109
+ 'Page.navigate',
110
+ ]);
111
+ const attach = endpoint.sentCalls('Target.attachToTarget')[0];
112
+ expect(attach.params).toEqual({ targetId: 'target-existing', flatten: true });
113
+ const screencast = endpoint.sentCalls('Page.startScreencast')[0];
114
+ expect(screencast.sessionId).toBe('sess-1');
115
+ expect(screencast.params).toEqual({ format: 'jpeg', quality: 60, maxWidth: 1280, maxHeight: 800 });
116
+ const nav = endpoint.sentCalls('Page.navigate')[0];
117
+ expect(nav.params).toEqual({ url: 'https://example.com' });
118
+ expect(nav.sessionId).toBe('sess-1');
119
+ });
120
+ it('start(url): with no existing page target, creates one before attaching', async () => {
121
+ const { driver, endpoint } = setup(); // no existingPageTargetId -> empty target list
122
+ await driver.start('https://example.com');
123
+ const methods = endpoint.sent.map((m) => m.method);
124
+ expect(methods.slice(0, 2)).toEqual(['Target.getTargets', 'Target.createTarget']);
125
+ expect(endpoint.sentCalls('Target.attachToTarget')[0].params).toEqual({ targetId: 'target-new', flatten: true });
126
+ });
127
+ it('emits an "opening" state synchronously when start() begins', () => {
128
+ const { driver } = setup({ existingPageTargetId: 't1' });
129
+ const states = [];
130
+ driver.onState((s) => states.push(s));
131
+ void driver.start('https://example.com');
132
+ expect(states[0]).toEqual({ state: 'opening' });
133
+ });
134
+ it('screencast frames: acks CDP immediately and delivers through onFrame', async () => {
135
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
136
+ const frames = [];
137
+ driver.onFrame((f) => frames.push(f));
138
+ await driver.start('https://example.com');
139
+ endpoint.emitEvent('Page.screencastFrame', { data: 'BASE64JPEG', sessionId: 42, metadata: { deviceWidth: 1280, deviceHeight: 800 } }, 'sess-1');
140
+ // CDP ack fires immediately (independent of the pacer/consumer ack).
141
+ const acks = endpoint.sentCalls('Page.screencastFrameAck');
142
+ expect(acks).toHaveLength(1);
143
+ expect(acks[0].params).toEqual({ sessionId: 42 });
144
+ expect(frames).toHaveLength(1);
145
+ expect(frames[0]).toEqual({ seq: 1, data: 'BASE64JPEG', w: 1280, h: 800 });
146
+ expect((0, browserFrames_1.isBrowserScreenFrame)({ t: 'browser-frame', sessionId: 'x', ...frames[0] })).toBe(true);
147
+ });
148
+ it('respects the cbs-01 keep-newest pacer under a slow consumer (mock consumer never acks)', async () => {
149
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
150
+ const frames = [];
151
+ driver.onFrame((f) => frames.push(f));
152
+ await driver.start('https://example.com');
153
+ for (let i = 1; i <= 5; i++) {
154
+ endpoint.emitEvent('Page.screencastFrame', { data: `frame-${i}`, sessionId: i, metadata: {} }, 'sess-1');
155
+ }
156
+ // Only the first frame was delivered — the consumer never called ackFrame().
157
+ expect(frames.map((f) => f.data)).toEqual(['frame-1']);
158
+ // Every screencastFrame is CDP-acked regardless of pacer state.
159
+ expect(endpoint.sentCalls('Page.screencastFrameAck')).toHaveLength(5);
160
+ driver.ackFrame(); // consumer catches up -> newest queued frame (5) delivers
161
+ expect(frames.map((f) => f.data)).toEqual(['frame-1', 'frame-5']);
162
+ driver.ackFrame(); // nothing queued -> no-op
163
+ expect(frames.map((f) => f.data)).toEqual(['frame-1', 'frame-5']);
164
+ });
165
+ it('input: mouse down/up/move dispatch Input.dispatchMouseEvent with exact coords/button/modifiers', async () => {
166
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
167
+ await driver.start('https://example.com');
168
+ driver.input({ kind: 'mouse', type: 'down', x: 10, y: 20, button: 'left', modifiers: { shift: true, ctrl: true } });
169
+ driver.input({ kind: 'mouse', type: 'move', x: 15, y: 25 });
170
+ driver.input({ kind: 'mouse', type: 'up', x: 10, y: 20, button: 'left' });
171
+ const calls = endpoint.sentCalls('Input.dispatchMouseEvent');
172
+ expect(calls).toHaveLength(3);
173
+ expect(calls[0]).toMatchObject({
174
+ sessionId: 'sess-1',
175
+ params: { type: 'mousePressed', x: 10, y: 20, button: 'left', clickCount: 1, modifiers: 10 }, // shift(8)+ctrl(2)
176
+ });
177
+ expect(calls[1]).toMatchObject({
178
+ params: { type: 'mouseMoved', x: 15, y: 25, button: 'none', clickCount: 0, modifiers: 0 },
179
+ });
180
+ expect(calls[2]).toMatchObject({
181
+ params: { type: 'mouseReleased', x: 10, y: 20, button: 'left', clickCount: 1, modifiers: 0 },
182
+ });
183
+ });
184
+ it('input: wheel dispatches Input.dispatchMouseEvent type mouseWheel with deltas', async () => {
185
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
186
+ await driver.start('https://example.com');
187
+ driver.input({ kind: 'wheel', x: 5, y: 6, deltaX: 0, deltaY: 120 });
188
+ const calls = endpoint.sentCalls('Input.dispatchMouseEvent').filter((c) => c.params.type === 'mouseWheel');
189
+ expect(calls).toHaveLength(1);
190
+ expect(calls[0].params).toEqual({ type: 'mouseWheel', x: 5, y: 6, deltaX: 0, deltaY: 120 });
191
+ expect(calls[0].sessionId).toBe('sess-1');
192
+ });
193
+ it('input: key down/up dispatch Input.dispatchKeyEvent with key/code and a modifiers bitmask', async () => {
194
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
195
+ await driver.start('https://example.com');
196
+ driver.input({ kind: 'key', type: 'down', key: 'a', code: 'KeyA', modifiers: { meta: true, alt: true } });
197
+ driver.input({ kind: 'key', type: 'up', key: 'a', code: 'KeyA' });
198
+ const calls = endpoint.sentCalls('Input.dispatchKeyEvent');
199
+ expect(calls).toHaveLength(2);
200
+ expect(calls[0].params).toEqual({ type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 5 }); // alt(1)+meta(4)
201
+ expect(calls[1].params).toEqual({ type: 'keyUp', key: 'a', code: 'KeyA', modifiers: 0 });
202
+ expect(calls[0].sessionId).toBe('sess-1');
203
+ });
204
+ it('input: insertText passes the text straight through to Input.insertText', async () => {
205
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
206
+ await driver.start('https://example.com');
207
+ driver.input({ kind: 'insertText', text: 'hunter2' });
208
+ const calls = endpoint.sentCalls('Input.insertText');
209
+ expect(calls).toHaveLength(1);
210
+ expect(calls[0].params).toEqual({ text: 'hunter2' });
211
+ expect(calls[0].sessionId).toBe('sess-1');
212
+ });
213
+ it('navigate(url) after start sends Page.navigate again on the attached target session', async () => {
214
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
215
+ await driver.start('https://example.com');
216
+ endpoint.sent.length = 0;
217
+ await driver.navigate('https://example.com/page2');
218
+ const calls = endpoint.sentCalls('Page.navigate');
219
+ expect(calls).toHaveLength(1);
220
+ expect(calls[0].params).toEqual({ url: 'https://example.com/page2' });
221
+ expect(calls[0].sessionId).toBe('sess-1');
222
+ });
223
+ it('back()/forward() read Page.getNavigationHistory then call Page.navigateToHistoryEntry with the right entry', async () => {
224
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
225
+ endpoint.on('Page.getNavigationHistory', () => ({
226
+ currentIndex: 1,
227
+ entries: [{ id: 100 }, { id: 200 }, { id: 300 }],
228
+ }));
229
+ await driver.start('https://example.com');
230
+ endpoint.sent.length = 0;
231
+ await driver.back();
232
+ let calls = endpoint.sentCalls('Page.navigateToHistoryEntry');
233
+ expect(calls).toHaveLength(1);
234
+ expect(calls[0].params).toEqual({ entryId: 100 });
235
+ expect(calls[0].sessionId).toBe('sess-1');
236
+ endpoint.sent.length = 0;
237
+ await driver.forward();
238
+ calls = endpoint.sentCalls('Page.navigateToHistoryEntry');
239
+ expect(calls).toHaveLength(1);
240
+ expect(calls[0].params).toEqual({ entryId: 300 });
241
+ });
242
+ it('back() at the start of history is a no-op (no entry to navigate to)', async () => {
243
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
244
+ endpoint.on('Page.getNavigationHistory', () => ({ currentIndex: 0, entries: [{ id: 100 }] }));
245
+ await driver.start('https://example.com');
246
+ endpoint.sent.length = 0;
247
+ await driver.back();
248
+ expect(endpoint.sentCalls('Page.navigateToHistoryEntry')).toHaveLength(0);
249
+ });
250
+ it('reload() sends Page.reload on the attached target session', async () => {
251
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
252
+ await driver.start('https://example.com');
253
+ endpoint.sent.length = 0;
254
+ await driver.reload();
255
+ expect(endpoint.sentCalls('Page.reload')).toHaveLength(1);
256
+ expect(endpoint.sentCalls('Page.reload')[0].sessionId).toBe('sess-1');
257
+ });
258
+ it('Target.targetInfoChanged for the attached page emits onState open with url/title', async () => {
259
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
260
+ const states = [];
261
+ driver.onState((s) => states.push(s));
262
+ await driver.start('https://example.com');
263
+ endpoint.emitEvent('Target.targetInfoChanged', { targetInfo: { targetId: 't1', url: 'https://example.com/', title: 'Example' } });
264
+ // An unrelated target's change must not surface as our session's state.
265
+ endpoint.emitEvent('Target.targetInfoChanged', { targetInfo: { targetId: 'other-target', url: 'https://evil.example/', title: 'Nope' } });
266
+ const openStates = states.filter((s) => s.state === 'open');
267
+ expect(openStates).toEqual([{ state: 'open', url: 'https://example.com/', title: 'Example' }]);
268
+ expect((0, browserFrames_1.isBrowserStateFrame)({ t: 'browser-state', sessionId: 'x', ...openStates[0] })).toBe(true);
269
+ });
270
+ it('transport close emits onState closed with a reason and never hangs', async () => {
271
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
272
+ const states = [];
273
+ driver.onState((s) => states.push(s));
274
+ await driver.start('https://example.com');
275
+ endpoint.triggerClose({ code: 1006, reason: 'abnormal' });
276
+ await new Promise((r) => queueMicrotask(() => r()));
277
+ const closedStates = states.filter((s) => s.state === 'closed');
278
+ expect(closedStates).toHaveLength(1);
279
+ expect(closedStates[0].reason).toBeTruthy();
280
+ });
281
+ it('a crashed/exited Chromium process surfaces a closed state rather than hanging', async () => {
282
+ const { driver, proc } = setup({ existingPageTargetId: 't1' });
283
+ const states = [];
284
+ driver.onState((s) => states.push(s));
285
+ await driver.start('https://example.com');
286
+ proc.emitExit(1, null);
287
+ const closedStates = states.filter((s) => s.state === 'closed');
288
+ expect(closedStates).toHaveLength(1);
289
+ expect(closedStates[0].reason).toBeTruthy();
290
+ });
291
+ it('stop() kills the child process and closes the CDP transport; double-stop is safe', async () => {
292
+ const { driver, proc } = setup({ existingPageTargetId: 't1' });
293
+ await driver.start('https://example.com');
294
+ driver.stop();
295
+ expect(proc.killed).toBe(true);
296
+ expect(proc.killCount).toBe(1);
297
+ expect(() => driver.stop()).not.toThrow();
298
+ expect(proc.killCount).toBe(1); // second stop() did not kill again
299
+ });
300
+ it('stop() emits a closed state exactly once even though the transport also closes', async () => {
301
+ const { driver, endpoint } = setup({ existingPageTargetId: 't1' });
302
+ const states = [];
303
+ driver.onState((s) => states.push(s));
304
+ await driver.start('https://example.com');
305
+ driver.stop();
306
+ endpoint.triggerClose(); // the mock's close() no-ops after driver already closed it, but simulate a stray event
307
+ await new Promise((r) => queueMicrotask(() => r()));
308
+ expect(states.filter((s) => s.state === 'closed')).toHaveLength(1);
309
+ });
310
+ // cbs-06 — sleep() is the explicit-💤/idle-close teardown: same physical
311
+ // kill as stop(), but the wire state is 'asleep' (intentional, resumable)
312
+ // rather than 'closed' (terminal) — see browserFrames.ts's BrowserSessionState.
313
+ describe('sleep() (cbs-06)', () => {
314
+ it('kills the child process and closes the CDP transport, emitting asleep (not closed)', async () => {
315
+ const { driver, proc, endpoint } = setup({ existingPageTargetId: 't1' });
316
+ const states = [];
317
+ driver.onState((s) => states.push(s));
318
+ await driver.start('https://example.com');
319
+ endpoint.sent.length = 0;
320
+ driver.sleep();
321
+ expect(proc.killed).toBe(true);
322
+ expect(proc.killCount).toBe(1);
323
+ const terminal = states.filter((s) => s.state === 'asleep' || s.state === 'closed');
324
+ expect(terminal).toHaveLength(1);
325
+ expect(terminal[0].state).toBe('asleep');
326
+ expect(terminal[0].reason).toBeTruthy();
327
+ expect((0, browserFrames_1.isBrowserStateFrame)({ t: 'browser-state', sessionId: 'x', ...terminal[0] })).toBe(true);
328
+ });
329
+ it('double-sleep is a safe no-op (second call does not re-kill or re-emit)', async () => {
330
+ const { driver, proc } = setup({ existingPageTargetId: 't1' });
331
+ const states = [];
332
+ driver.onState((s) => states.push(s));
333
+ await driver.start('https://example.com');
334
+ driver.sleep();
335
+ expect(() => driver.sleep()).not.toThrow();
336
+ expect(proc.killCount).toBe(1);
337
+ expect(states.filter((s) => s.state === 'asleep')).toHaveLength(1);
338
+ });
339
+ it('sleep-of-nothing (never started) is a safe no-op that does not throw', () => {
340
+ const { driver } = setup({ existingPageTargetId: 't1' });
341
+ expect(() => driver.sleep()).not.toThrow();
342
+ });
343
+ it('sleep() then stop() emits only the first terminal state (asleep wins, no closed follows)', async () => {
344
+ const { driver, proc } = setup({ existingPageTargetId: 't1' });
345
+ const states = [];
346
+ driver.onState((s) => states.push(s));
347
+ await driver.start('https://example.com');
348
+ driver.sleep();
349
+ driver.stop();
350
+ expect(proc.killCount).toBe(1); // stop() after sleep() does not kill again
351
+ const terminal = states.filter((s) => s.state === 'asleep' || s.state === 'closed');
352
+ expect(terminal).toHaveLength(1);
353
+ expect(terminal[0].state).toBe('asleep');
354
+ });
355
+ it('stop() then sleep() emits only the first terminal state (closed wins, no asleep follows)', async () => {
356
+ const { driver, proc } = setup({ existingPageTargetId: 't1' });
357
+ const states = [];
358
+ driver.onState((s) => states.push(s));
359
+ await driver.start('https://example.com');
360
+ driver.stop();
361
+ driver.sleep();
362
+ expect(proc.killCount).toBe(1); // sleep() after stop() does not kill again
363
+ const terminal = states.filter((s) => s.state === 'asleep' || s.state === 'closed');
364
+ expect(terminal).toHaveLength(1);
365
+ expect(terminal[0].state).toBe('closed');
366
+ });
367
+ it('a genuine Chromium exit after sleep() does not add a second (closed) state frame', async () => {
368
+ const { driver, proc } = setup({ existingPageTargetId: 't1' });
369
+ const states = [];
370
+ driver.onState((s) => states.push(s));
371
+ await driver.start('https://example.com');
372
+ driver.sleep();
373
+ proc.emitExit(0, null); // process reaping fires after we already killed it
374
+ const terminal = states.filter((s) => s.state === 'asleep' || s.state === 'closed');
375
+ expect(terminal).toHaveLength(1);
376
+ expect(terminal[0].state).toBe('asleep');
377
+ });
378
+ });
379
+ });
@@ -0,0 +1 @@
1
+ export {};