@wenrwa/marketplace-cli 0.4.4 → 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 (116) 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-client.test.d.ts +1 -0
  49. package/dist/__tests__/bridge-relay-client.test.js +283 -0
  50. package/dist/__tests__/bridge-relay-mode.test.d.ts +1 -0
  51. package/dist/__tests__/bridge-relay-mode.test.js +33 -0
  52. package/dist/__tests__/bridge-relay-pucc14.test.d.ts +1 -0
  53. package/dist/__tests__/bridge-relay-pucc14.test.js +230 -0
  54. package/dist/__tests__/bridge-server-local-endpoints.test.d.ts +1 -0
  55. package/dist/__tests__/bridge-server-local-endpoints.test.js +197 -0
  56. package/dist/__tests__/bridge-server.test.js +22 -0
  57. package/dist/__tests__/bridge-workspace-multirepo.test.d.ts +1 -0
  58. package/dist/__tests__/bridge-workspace-multirepo.test.js +317 -0
  59. package/dist/__tests__/devices.test.d.ts +1 -0
  60. package/dist/__tests__/devices.test.js +66 -0
  61. package/dist/__tests__/local.test.d.ts +1 -0
  62. package/dist/__tests__/local.test.js +147 -0
  63. package/dist/bridge/activityParser.d.ts +53 -0
  64. package/dist/bridge/activityParser.js +202 -0
  65. package/dist/bridge/apiClient.d.ts +56 -0
  66. package/dist/bridge/apiClient.js +50 -0
  67. package/dist/bridge/browserDriver.d.ts +98 -0
  68. package/dist/bridge/browserDriver.js +265 -0
  69. package/dist/bridge/browserFrames.d.ts +182 -0
  70. package/dist/bridge/browserFrames.js +179 -0
  71. package/dist/bridge/browserIdle.d.ts +75 -0
  72. package/dist/bridge/browserIdle.js +120 -0
  73. package/dist/bridge/bundleInstaller.d.ts +31 -0
  74. package/dist/bridge/bundleInstaller.js +127 -0
  75. package/dist/bridge/cdpClient.d.ts +47 -0
  76. package/dist/bridge/cdpClient.js +102 -0
  77. package/dist/bridge/chromiumLauncher.d.ts +45 -0
  78. package/dist/bridge/chromiumLauncher.js +113 -0
  79. package/dist/bridge/config.d.ts +3 -0
  80. package/dist/bridge/contextWriter.d.ts +31 -0
  81. package/dist/bridge/contextWriter.js +139 -0
  82. package/dist/bridge/deviceRegistration.d.ts +28 -0
  83. package/dist/bridge/deviceRegistration.js +19 -0
  84. package/dist/bridge/folderPicker.d.ts +34 -0
  85. package/dist/bridge/folderPicker.js +36 -0
  86. package/dist/bridge/folderPickerRunner.d.ts +3 -0
  87. package/dist/bridge/folderPickerRunner.js +25 -0
  88. package/dist/bridge/folderRegistry.d.ts +19 -0
  89. package/dist/bridge/folderRegistry.js +100 -0
  90. package/dist/bridge/framePacer.d.ts +61 -0
  91. package/dist/bridge/framePacer.js +79 -0
  92. package/dist/bridge/localAdd.d.ts +28 -0
  93. package/dist/bridge/localAdd.js +67 -0
  94. package/dist/bridge/localPathValidation.d.ts +20 -0
  95. package/dist/bridge/localPathValidation.js +58 -0
  96. package/dist/bridge/localPathValidationFs.d.ts +2 -0
  97. package/dist/bridge/localPathValidationFs.js +98 -0
  98. package/dist/bridge/loginStatus.d.ts +38 -0
  99. package/dist/bridge/loginStatus.js +56 -0
  100. package/dist/bridge/realPty.js +6 -2
  101. package/dist/bridge/relayClient.d.ts +166 -0
  102. package/dist/bridge/relayClient.js +517 -0
  103. package/dist/bridge/repoCheckout.d.ts +31 -0
  104. package/dist/bridge/repoCheckout.js +18 -0
  105. package/dist/bridge/server.d.ts +33 -0
  106. package/dist/bridge/server.js +98 -10
  107. package/dist/bridge/sessionManager.d.ts +18 -0
  108. package/dist/bridge/sessionManager.js +15 -1
  109. package/dist/commands/bridge.d.ts +21 -0
  110. package/dist/commands/bridge.js +194 -1
  111. package/dist/commands/devices.d.ts +9 -0
  112. package/dist/commands/devices.js +53 -0
  113. package/dist/commands/local.d.ts +9 -0
  114. package/dist/commands/local.js +141 -0
  115. package/dist/index.js +7 -1
  116. package/package.json +1 -1
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * pucc-14 — bridge completion: relay-owned sessionId (multiplex v1), the
5
+ * per-session env contract (connectToken/gitCredential/autonomous), and the
6
+ * login_valid report. Uses the same faked-PTY / fake-relay harness as
7
+ * bridge-relay-client.test.ts — no docker, no real shells.
8
+ */
9
+ const ws_1 = require("ws");
10
+ const relayClient_1 = require("../bridge/relayClient");
11
+ const sessionManager_1 = require("../bridge/sessionManager");
12
+ class FakePty {
13
+ opts;
14
+ written = [];
15
+ killed = false;
16
+ constructor(opts) {
17
+ this.opts = opts;
18
+ }
19
+ write(data) { this.written.push(data); }
20
+ resize() { }
21
+ kill() { this.killed = true; }
22
+ dataCb = null;
23
+ onData(cb) { this.dataCb = cb; }
24
+ onExit(_cb) { }
25
+ emitData(d) { this.dataCb?.(d); }
26
+ }
27
+ const makeFactory = () => {
28
+ const ptys = [];
29
+ const factory = { spawn: (o) => { const p = new FakePty(o); ptys.push(p); return p; } };
30
+ return { factory, ptys };
31
+ };
32
+ function frameQueue(ws) {
33
+ const frames = [];
34
+ const waiters = [];
35
+ ws.on('message', (raw) => {
36
+ const f = JSON.parse(String(raw));
37
+ const w = waiters.shift();
38
+ if (w)
39
+ w(f);
40
+ else
41
+ frames.push(f);
42
+ });
43
+ return {
44
+ next: () => frames.length > 0 ? Promise.resolve(frames.shift()) : new Promise((r) => waiters.push(r)),
45
+ };
46
+ }
47
+ function startFakeRelay() {
48
+ return new Promise((resolve) => {
49
+ const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
50
+ const queue = [];
51
+ const waiters = [];
52
+ wss.on('connection', (ws) => { const w = waiters.shift(); if (w)
53
+ w(ws);
54
+ else
55
+ queue.push(ws); });
56
+ wss.on('listening', () => {
57
+ const addr = wss.address();
58
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
59
+ resolve({ wss, port, nextConnection: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => waiters.push(r))) });
60
+ });
61
+ });
62
+ }
63
+ function closeRelay(wss) {
64
+ return new Promise((resolve, reject) => {
65
+ for (const client of wss.clients)
66
+ client.terminate();
67
+ wss.close((err) => (err ? reject(err) : resolve()));
68
+ });
69
+ }
70
+ // ── assembleSessionEnv (pure) ──────────────────────────────────────────────
71
+ describe('assembleSessionEnv — per-session env contract (pucc-04/14)', () => {
72
+ it('merges relay-injected env + connectToken (MCP) + git credential + autonomous flag', () => {
73
+ const env = (0, relayClient_1.assembleSessionEnv)({
74
+ workspaceId: 'ws-7',
75
+ autonomous: true,
76
+ connectToken: 'wm_ct_abc',
77
+ gitCredential: { token: 'ghs_zzz', repoFullName: 'acme/widgets', expiresAt: 'later' },
78
+ env: { WENRWA_WORKSPACE_ID: 'ws-7', WENRWA_CONNECT_TOKEN: 'wm_ct_abc', PRESERVED: '1' },
79
+ });
80
+ expect(env).toEqual({
81
+ WENRWA_WORKSPACE_ID: 'ws-7',
82
+ WENRWA_CONNECT_TOKEN: 'wm_ct_abc',
83
+ PRESERVED: '1',
84
+ HT_AUTONOMOUS: '1',
85
+ WENRWA_GIT_TOKEN: 'ghs_zzz',
86
+ WENRWA_GIT_REPO: 'acme/widgets',
87
+ });
88
+ });
89
+ it('defaults HT_AUTONOMOUS to 0 (supervised) and omits git vars when no credential', () => {
90
+ const env = (0, relayClient_1.assembleSessionEnv)({ workspaceId: 'ws-1' });
91
+ expect(env).toEqual({ WENRWA_WORKSPACE_ID: 'ws-1', HT_AUTONOMOUS: '0' });
92
+ expect(env).not.toHaveProperty('WENRWA_GIT_TOKEN');
93
+ });
94
+ });
95
+ // ── multiplex v1: honor the relay-owned sessionId ──────────────────────────
96
+ describe('relay client — honors the relay-owned sessionId (multiplex v1)', () => {
97
+ it('registers the PTY under the relay-supplied sessionId and echoes it back', async () => {
98
+ const relay = await startFakeRelay();
99
+ const { factory } = makeFactory();
100
+ const sessions = new sessionManager_1.SessionManager(factory);
101
+ const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions });
102
+ const ws = await relay.nextConnection();
103
+ const q = frameQueue(ws);
104
+ await q.next(); // auth
105
+ // The relay OWNS this id (== agentId) and stamps it on the open frame.
106
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'agent-A' }));
107
+ const opened = await q.next();
108
+ expect(opened).toMatchObject({ t: 'opened', sessionId: 'agent-A' });
109
+ expect(sessions.list()).toEqual(['agent-A']);
110
+ handle.close();
111
+ await closeRelay(relay.wss);
112
+ });
113
+ it('routes input/data by the relay-owned id across N concurrent sessions (isolation)', async () => {
114
+ const relay = await startFakeRelay();
115
+ const { factory, ptys } = makeFactory();
116
+ const sessions = new sessionManager_1.SessionManager(factory);
117
+ const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions });
118
+ const ws = await relay.nextConnection();
119
+ const q = frameQueue(ws);
120
+ await q.next(); // auth
121
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'agent-A' }));
122
+ expect((await q.next()).sessionId).toBe('agent-A');
123
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'agent-B' }));
124
+ expect((await q.next()).sessionId).toBe('agent-B');
125
+ // Input stamped with the relay-owned id reaches ONLY that session's PTY.
126
+ ws.send(JSON.stringify({ t: 'input', sessionId: 'agent-A', data: 'to-a' }));
127
+ ws.send(JSON.stringify({ t: 'input', sessionId: 'agent-B', data: 'to-b' }));
128
+ await new Promise((r) => setTimeout(r, 20));
129
+ expect(ptys[0].written).toEqual(['to-a']);
130
+ expect(ptys[1].written).toEqual(['to-b']);
131
+ // PTY output streams back tagged with the SAME relay-owned id so the relay
132
+ // can demux to the right pane.
133
+ ptys[1].emitData('from-b');
134
+ expect(await q.next()).toMatchObject({ t: 'data', sessionId: 'agent-B', data: 'from-b' });
135
+ handle.close();
136
+ await closeRelay(relay.wss);
137
+ });
138
+ it('workspace-scoped open injects connectToken + git credential into the PTY env', async () => {
139
+ const relay = await startFakeRelay();
140
+ const { factory, ptys } = makeFactory();
141
+ const sessions = new sessionManager_1.SessionManager(factory);
142
+ const handle = (0, relayClient_1.startRelayClient)({
143
+ relayUrl: `ws://127.0.0.1:${relay.port}`,
144
+ relayToken: 'tok',
145
+ sessions,
146
+ resolveWorkspaceCwd: async ({ workspaceId }) => `/data/repos/${workspaceId}`,
147
+ });
148
+ const ws = await relay.nextConnection();
149
+ const q = frameQueue(ws);
150
+ await q.next(); // auth
151
+ ws.send(JSON.stringify({
152
+ t: 'open', cols: 80, rows: 24, sessionId: 'agent-A',
153
+ workspaceId: 'ws-7', autonomous: true,
154
+ connectToken: 'wm_ct_xyz',
155
+ gitCredential: { token: 'ghs_secret', repoFullName: 'acme/widgets' },
156
+ env: { WENRWA_WORKSPACE_ID: 'ws-7', WENRWA_CONNECT_TOKEN: 'wm_ct_xyz' },
157
+ }));
158
+ await q.next();
159
+ expect(ptys[0].opts.cwd).toBe('/data/repos/ws-7');
160
+ expect(ptys[0].opts.env).toMatchObject({
161
+ WENRWA_WORKSPACE_ID: 'ws-7',
162
+ WENRWA_CONNECT_TOKEN: 'wm_ct_xyz',
163
+ HT_AUTONOMOUS: '1',
164
+ WENRWA_GIT_TOKEN: 'ghs_secret',
165
+ WENRWA_GIT_REPO: 'acme/widgets',
166
+ });
167
+ handle.close();
168
+ await closeRelay(relay.wss);
169
+ });
170
+ });
171
+ // ── login_valid report ─────────────────────────────────────────────────────
172
+ describe('relay client — login_valid report (pucc-14)', () => {
173
+ it('emits a login-status frame (boolean only) on connect', async () => {
174
+ const relay = await startFakeRelay();
175
+ const { factory } = makeFactory();
176
+ const sessions = new sessionManager_1.SessionManager(factory);
177
+ const handle = (0, relayClient_1.startRelayClient)({
178
+ relayUrl: `ws://127.0.0.1:${relay.port}`,
179
+ relayToken: 'tok',
180
+ sessions,
181
+ checkLoginValid: () => true,
182
+ });
183
+ const ws = await relay.nextConnection();
184
+ const q = frameQueue(ws);
185
+ expect(await q.next()).toEqual({ v: 1, relayToken: 'tok' }); // auth first
186
+ expect(await q.next()).toEqual({ t: 'login-status', valid: true }); // then login-status
187
+ handle.close();
188
+ await closeRelay(relay.wss);
189
+ });
190
+ it('never emits a login-status frame when no checkLoginValid is provided', async () => {
191
+ const relay = await startFakeRelay();
192
+ const { factory } = makeFactory();
193
+ const sessions = new sessionManager_1.SessionManager(factory);
194
+ const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions });
195
+ const ws = await relay.nextConnection();
196
+ const q = frameQueue(ws);
197
+ expect(await q.next()).toEqual({ v: 1, relayToken: 'tok' });
198
+ // Prove no login-status follows: open a session and the next frame is 'opened'.
199
+ ws.send(JSON.stringify({ t: 'open', cols: 80, rows: 24 }));
200
+ expect((await q.next()).t).toBe('opened');
201
+ handle.close();
202
+ await closeRelay(relay.wss);
203
+ });
204
+ it('re-emits only when the login validity CHANGES (poll interval)', async () => {
205
+ const relay = await startFakeRelay();
206
+ const { factory } = makeFactory();
207
+ const sessions = new sessionManager_1.SessionManager(factory);
208
+ let valid = false;
209
+ let pollCb = null;
210
+ const handle = (0, relayClient_1.startRelayClient)({
211
+ relayUrl: `ws://127.0.0.1:${relay.port}`,
212
+ relayToken: 'tok',
213
+ sessions,
214
+ checkLoginValid: () => valid,
215
+ setIntervalFn: (cb) => { pollCb = cb; return 1; },
216
+ clearIntervalFn: () => { },
217
+ });
218
+ const ws = await relay.nextConnection();
219
+ const q = frameQueue(ws);
220
+ await q.next(); // auth
221
+ expect(await q.next()).toEqual({ t: 'login-status', valid: false }); // connect force-emit
222
+ // Poll with no change → no frame. Then flip → exactly one frame.
223
+ pollCb();
224
+ valid = true;
225
+ pollCb();
226
+ expect(await q.next()).toEqual({ t: 'login-status', valid: true });
227
+ handle.close();
228
+ await closeRelay(relay.wss);
229
+ });
230
+ });
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * TDD — lws-05: the three loopback HTTP endpoints backing the arena
5
+ * create-modal's local mode (PRD D3): POST /local/validate-path,
6
+ * POST /local/pick-folder, POST /local/add. Same server as bridge-server
7
+ * .test.ts (discovery/WS) — split into its own file since these are a
8
+ * distinct vertical slice. All handlers are injected opts (no real fs,
9
+ * osascript, or network — per the issue's "no real osascript in tests").
10
+ */
11
+ const server_1 = require("../bridge/server");
12
+ const sessionManager_1 = require("../bridge/sessionManager");
13
+ class FakePty {
14
+ write() { }
15
+ resize() { }
16
+ kill() { }
17
+ onData() { }
18
+ onExit() { }
19
+ }
20
+ const ORIGIN = 'https://app.hiveop.io';
21
+ const TOKEN = 'session-token-123';
22
+ const factory = { spawn: () => new FakePty() };
23
+ async function postJson(port, path, body, opts = {}) {
24
+ const headers = {
25
+ 'content-type': 'application/json',
26
+ origin: opts.origin ?? ORIGIN,
27
+ };
28
+ if (opts.sessionToken !== undefined)
29
+ headers['x-bridge-session-token'] = opts.sessionToken;
30
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
31
+ method: 'POST',
32
+ headers,
33
+ body: JSON.stringify(body),
34
+ });
35
+ return { status: res.status, json: await res.json().catch(() => null) };
36
+ }
37
+ describe('POST /local/validate-path', () => {
38
+ let handle;
39
+ afterEach(async () => {
40
+ await handle.close();
41
+ });
42
+ it('returns the validator result for a valid folder', async () => {
43
+ handle = await (0, server_1.startBridgeServer)({
44
+ sessionToken: TOKEN,
45
+ allowedOrigin: ORIGIN,
46
+ sessions: new sessionManager_1.SessionManager(factory),
47
+ validatePath: async () => ({ ok: true, name: 'my-app', fileCount: 214, isGitRepo: true }),
48
+ });
49
+ const { status, json } = await postJson(handle.port, '/local/validate-path', { path: '/Users/x/my-app' });
50
+ expect(status).toBe(200);
51
+ expect(json).toEqual({ ok: true, name: 'my-app', fileCount: 214, isGitRepo: true });
52
+ });
53
+ it('returns a typed error for an invalid path', async () => {
54
+ handle = await (0, server_1.startBridgeServer)({
55
+ sessionToken: TOKEN,
56
+ allowedOrigin: ORIGIN,
57
+ sessions: new sessionManager_1.SessionManager(factory),
58
+ validatePath: async () => ({ ok: false, error: 'not-exists' }),
59
+ });
60
+ const { status, json } = await postJson(handle.port, '/local/validate-path', { path: '/nope' });
61
+ expect(status).toBe(200);
62
+ expect(json).toEqual({ ok: false, error: 'not-exists' });
63
+ });
64
+ it('rejects a request from a non-arena Origin', async () => {
65
+ handle = await (0, server_1.startBridgeServer)({
66
+ sessionToken: TOKEN,
67
+ allowedOrigin: ORIGIN,
68
+ sessions: new sessionManager_1.SessionManager(factory),
69
+ validatePath: async () => ({ ok: true, name: 'x', fileCount: 1, isGitRepo: false }),
70
+ });
71
+ const { status } = await postJson(handle.port, '/local/validate-path', { path: '/x' }, { origin: 'https://evil.example' });
72
+ expect(status).toBe(403);
73
+ });
74
+ it('500s cleanly when the endpoint is not wired', async () => {
75
+ handle = await (0, server_1.startBridgeServer)({ sessionToken: TOKEN, allowedOrigin: ORIGIN, sessions: new sessionManager_1.SessionManager(factory) });
76
+ const { status } = await postJson(handle.port, '/local/validate-path', { path: '/x' });
77
+ expect(status).toBe(500);
78
+ });
79
+ });
80
+ describe('POST /local/pick-folder', () => {
81
+ let handle;
82
+ afterEach(async () => {
83
+ await handle.close();
84
+ });
85
+ it('returns the chosen path', async () => {
86
+ handle = await (0, server_1.startBridgeServer)({
87
+ sessionToken: TOKEN,
88
+ allowedOrigin: ORIGIN,
89
+ sessions: new sessionManager_1.SessionManager(factory),
90
+ pickFolder: async () => ({ path: '/Users/x/my-app' }),
91
+ });
92
+ const { status, json } = await postJson(handle.port, '/local/pick-folder', {});
93
+ expect(status).toBe(200);
94
+ expect(json).toEqual({ path: '/Users/x/my-app' });
95
+ });
96
+ it('surfaces {cancelled:true} when the user dismisses the dialog', async () => {
97
+ handle = await (0, server_1.startBridgeServer)({
98
+ sessionToken: TOKEN,
99
+ allowedOrigin: ORIGIN,
100
+ sessions: new sessionManager_1.SessionManager(factory),
101
+ pickFolder: async () => ({ cancelled: true }),
102
+ });
103
+ const { json } = await postJson(handle.port, '/local/pick-folder', {});
104
+ expect(json).toEqual({ cancelled: true });
105
+ });
106
+ it('surfaces {unsupported:true} on platforms with no native picker', async () => {
107
+ handle = await (0, server_1.startBridgeServer)({
108
+ sessionToken: TOKEN,
109
+ allowedOrigin: ORIGIN,
110
+ sessions: new sessionManager_1.SessionManager(factory),
111
+ pickFolder: async () => ({ unsupported: true }),
112
+ });
113
+ const { json } = await postJson(handle.port, '/local/pick-folder', {});
114
+ expect(json).toEqual({ unsupported: true });
115
+ });
116
+ it('one picker at a time — a second concurrent call gets 409 busy without invoking the picker again', async () => {
117
+ let resolvePick;
118
+ const pending = new Promise((resolve) => { resolvePick = resolve; });
119
+ const pickFolder = jest.fn().mockReturnValue(pending);
120
+ handle = await (0, server_1.startBridgeServer)({
121
+ sessionToken: TOKEN,
122
+ allowedOrigin: ORIGIN,
123
+ sessions: new sessionManager_1.SessionManager(factory),
124
+ pickFolder,
125
+ });
126
+ const first = postJson(handle.port, '/local/pick-folder', {});
127
+ await new Promise((r) => setTimeout(r, 10)); // let the first request start the picker
128
+ const second = await postJson(handle.port, '/local/pick-folder', {});
129
+ expect(second.status).toBe(409);
130
+ expect(second.json).toEqual({ busy: true });
131
+ expect(pickFolder).toHaveBeenCalledTimes(1);
132
+ resolvePick({ path: '/Users/x/app' });
133
+ const firstResult = await first;
134
+ expect(firstResult.json).toEqual({ path: '/Users/x/app' });
135
+ // The gate releases after completion — a third call runs the picker again.
136
+ const third = await postJson(handle.port, '/local/pick-folder', {});
137
+ expect(pickFolder).toHaveBeenCalledTimes(2);
138
+ expect(third.status).toBe(200);
139
+ });
140
+ });
141
+ describe('POST /local/add', () => {
142
+ let handle;
143
+ afterEach(async () => {
144
+ await handle.close();
145
+ });
146
+ it('requires the session token — missing/wrong is rejected with 401', async () => {
147
+ const addLocalWorkspace = jest.fn();
148
+ handle = await (0, server_1.startBridgeServer)({
149
+ sessionToken: TOKEN,
150
+ allowedOrigin: ORIGIN,
151
+ sessions: new sessionManager_1.SessionManager(factory),
152
+ addLocalWorkspace,
153
+ });
154
+ const missing = await postJson(handle.port, '/local/add', { path: '/x' });
155
+ expect(missing.status).toBe(401);
156
+ const wrong = await postJson(handle.port, '/local/add', { path: '/x' }, { sessionToken: 'wrong' });
157
+ expect(wrong.status).toBe(401);
158
+ expect(addLocalWorkspace).not.toHaveBeenCalled();
159
+ });
160
+ it('creates and returns the workspace summary with a valid session token', async () => {
161
+ handle = await (0, server_1.startBridgeServer)({
162
+ sessionToken: TOKEN,
163
+ allowedOrigin: ORIGIN,
164
+ sessions: new sessionManager_1.SessionManager(factory),
165
+ addLocalWorkspace: async ({ path, name }) => {
166
+ expect(path).toBe('/Users/x/my-app');
167
+ expect(name).toBeUndefined();
168
+ return { status: 'created', workspace: { id: 'ws-1', name: 'my-app' } };
169
+ },
170
+ });
171
+ const { status, json } = await postJson(handle.port, '/local/add', { path: '/Users/x/my-app' }, { sessionToken: TOKEN });
172
+ expect(status).toBe(201);
173
+ expect(json).toEqual({ ok: true, status: 'created', workspace: { id: 'ws-1', name: 'my-app' } });
174
+ });
175
+ it('is idempotent — an already-registered path returns 200 existing, not 201', async () => {
176
+ handle = await (0, server_1.startBridgeServer)({
177
+ sessionToken: TOKEN,
178
+ allowedOrigin: ORIGIN,
179
+ sessions: new sessionManager_1.SessionManager(factory),
180
+ addLocalWorkspace: async () => ({ status: 'existing', workspace: { id: 'ws-1', name: 'my-app' } }),
181
+ });
182
+ const { status, json } = await postJson(handle.port, '/local/add', { path: '/x' }, { sessionToken: TOKEN });
183
+ expect(status).toBe(200);
184
+ expect(json).toEqual({ ok: true, status: 'existing', workspace: { id: 'ws-1', name: 'my-app' } });
185
+ });
186
+ it('surfaces a creation error as ok:false without a 2xx status', async () => {
187
+ handle = await (0, server_1.startBridgeServer)({
188
+ sessionToken: TOKEN,
189
+ allowedOrigin: ORIGIN,
190
+ sessions: new sessionManager_1.SessionManager(factory),
191
+ addLocalWorkspace: async () => ({ status: 'error', message: 'device offline' }),
192
+ });
193
+ const { status, json } = await postJson(handle.port, '/local/add', { path: '/x' }, { sessionToken: TOKEN });
194
+ expect(status).toBe(500);
195
+ expect(json).toEqual({ ok: false, message: 'device offline' });
196
+ });
197
+ });
@@ -125,6 +125,28 @@ describe('bridge WebSocket server', () => {
125
125
  expect(bad.status).toBe(403);
126
126
  expect(bad.headers.get('access-control-allow-origin')).toBeNull();
127
127
  });
128
+ it('lws-03: discovery advertises registered local workspaces, empty by default', async () => {
129
+ const res = await fetch(`http://127.0.0.1:${handle.port}/discovery`, { headers: { origin: ORIGIN } });
130
+ const body = await res.json();
131
+ expect(body.localWorkspaces).toEqual([]);
132
+ });
133
+ it('lws-03: discovery reflects the registry live — no restart needed — via a getter closure', async () => {
134
+ await handle.close();
135
+ const factory = { spawn: () => { const p = new FakePty(); ptys.push(p); return p; } };
136
+ let entries = [];
137
+ handle = await (0, server_1.startBridgeServer)({
138
+ sessionToken: TOKEN,
139
+ allowedOrigin: ORIGIN,
140
+ sessions: new sessionManager_1.SessionManager(factory),
141
+ getLocalWorkspaces: () => entries,
142
+ });
143
+ const before = await (await fetch(`http://127.0.0.1:${handle.port}/discovery`, { headers: { origin: ORIGIN } })).json();
144
+ expect(before.localWorkspaces).toEqual([]);
145
+ // Simulate `wen-market local add` mutating the registry mid-run.
146
+ entries = [{ workspaceId: 'ws-1', name: 'my-app', path: 'my-app' }];
147
+ const after = await (await fetch(`http://127.0.0.1:${handle.port}/discovery`, { headers: { origin: ORIGIN } })).json();
148
+ expect(after.localWorkspaces).toEqual([{ workspaceId: 'ws-1', name: 'my-app', path: 'my-app' }]);
149
+ });
128
150
  it('answers the Chrome Private-Network-Access preflight for the arena origin', async () => {
129
151
  const res = await fetch(`http://127.0.0.1:${handle.port}/discovery`, {
130
152
  method: 'OPTIONS',