@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.
- package/dist/__tests__/bridge-activity-parser.test.d.ts +1 -0
- package/dist/__tests__/bridge-activity-parser.test.js +0 -0
- package/dist/__tests__/bridge-activity-tap.test.d.ts +1 -0
- package/dist/__tests__/bridge-activity-tap.test.js +178 -0
- package/dist/__tests__/bridge-api-client.test.d.ts +1 -0
- package/dist/__tests__/bridge-api-client.test.js +69 -0
- package/dist/__tests__/bridge-browser-driver.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-driver.test.js +379 -0
- package/dist/__tests__/bridge-browser-frames.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-frames.test.js +166 -0
- package/dist/__tests__/bridge-browser-idle.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-idle.test.js +259 -0
- package/dist/__tests__/bridge-browser-relay.test.d.ts +1 -0
- package/dist/__tests__/bridge-browser-relay.test.js +670 -0
- package/dist/__tests__/bridge-bundle-installer.test.d.ts +1 -0
- package/dist/__tests__/bridge-bundle-installer.test.js +126 -0
- package/dist/__tests__/bridge-cdp-client.test.d.ts +1 -0
- package/dist/__tests__/bridge-cdp-client.test.js +133 -0
- package/dist/__tests__/bridge-chromium-launcher.test.d.ts +1 -0
- package/dist/__tests__/bridge-chromium-launcher.test.js +202 -0
- package/dist/__tests__/bridge-command-browser-options.test.d.ts +1 -0
- package/dist/__tests__/bridge-command-browser-options.test.js +37 -0
- package/dist/__tests__/bridge-config-mcp.test.js +4 -0
- package/dist/__tests__/bridge-context-manifest-frame.test.d.ts +1 -0
- package/dist/__tests__/bridge-context-manifest-frame.test.js +125 -0
- package/dist/__tests__/bridge-context-writer.test.d.ts +1 -0
- package/dist/__tests__/bridge-context-writer.test.js +161 -0
- package/dist/__tests__/bridge-device-registration.test.d.ts +1 -0
- package/dist/__tests__/bridge-device-registration.test.js +33 -0
- package/dist/__tests__/bridge-folder-picker-runner.test.d.ts +1 -0
- package/dist/__tests__/bridge-folder-picker-runner.test.js +37 -0
- package/dist/__tests__/bridge-folder-picker.test.d.ts +1 -0
- package/dist/__tests__/bridge-folder-picker.test.js +66 -0
- package/dist/__tests__/bridge-folder-registry.test.d.ts +1 -0
- package/dist/__tests__/bridge-folder-registry.test.js +107 -0
- package/dist/__tests__/bridge-frame-pacer.test.d.ts +1 -0
- package/dist/__tests__/bridge-frame-pacer.test.js +102 -0
- package/dist/__tests__/bridge-install-bundle-frame.test.d.ts +1 -0
- package/dist/__tests__/bridge-install-bundle-frame.test.js +107 -0
- package/dist/__tests__/bridge-local-add-core.test.d.ts +1 -0
- package/dist/__tests__/bridge-local-add-core.test.js +48 -0
- package/dist/__tests__/bridge-local-path-validation-fs.test.d.ts +1 -0
- package/dist/__tests__/bridge-local-path-validation-fs.test.js +88 -0
- package/dist/__tests__/bridge-local-path-validation.test.d.ts +1 -0
- package/dist/__tests__/bridge-local-path-validation.test.js +45 -0
- package/dist/__tests__/bridge-login-status.test.d.ts +1 -0
- package/dist/__tests__/bridge-login-status.test.js +60 -0
- package/dist/__tests__/bridge-relay-client.test.d.ts +1 -0
- package/dist/__tests__/bridge-relay-client.test.js +283 -0
- package/dist/__tests__/bridge-relay-mode.test.d.ts +1 -0
- package/dist/__tests__/bridge-relay-mode.test.js +33 -0
- package/dist/__tests__/bridge-relay-pucc14.test.d.ts +1 -0
- package/dist/__tests__/bridge-relay-pucc14.test.js +230 -0
- package/dist/__tests__/bridge-server-local-endpoints.test.d.ts +1 -0
- package/dist/__tests__/bridge-server-local-endpoints.test.js +197 -0
- package/dist/__tests__/bridge-server.test.js +22 -0
- package/dist/__tests__/bridge-workspace-multirepo.test.d.ts +1 -0
- package/dist/__tests__/bridge-workspace-multirepo.test.js +317 -0
- package/dist/__tests__/devices.test.d.ts +1 -0
- package/dist/__tests__/devices.test.js +66 -0
- package/dist/__tests__/local.test.d.ts +1 -0
- package/dist/__tests__/local.test.js +147 -0
- package/dist/bridge/activityParser.d.ts +53 -0
- package/dist/bridge/activityParser.js +202 -0
- package/dist/bridge/apiClient.d.ts +56 -0
- package/dist/bridge/apiClient.js +50 -0
- package/dist/bridge/browserDriver.d.ts +98 -0
- package/dist/bridge/browserDriver.js +265 -0
- package/dist/bridge/browserFrames.d.ts +182 -0
- package/dist/bridge/browserFrames.js +179 -0
- package/dist/bridge/browserIdle.d.ts +75 -0
- package/dist/bridge/browserIdle.js +120 -0
- package/dist/bridge/bundleInstaller.d.ts +31 -0
- package/dist/bridge/bundleInstaller.js +127 -0
- package/dist/bridge/cdpClient.d.ts +47 -0
- package/dist/bridge/cdpClient.js +102 -0
- package/dist/bridge/chromiumLauncher.d.ts +45 -0
- package/dist/bridge/chromiumLauncher.js +113 -0
- package/dist/bridge/config.d.ts +3 -0
- package/dist/bridge/contextWriter.d.ts +31 -0
- package/dist/bridge/contextWriter.js +139 -0
- package/dist/bridge/deviceRegistration.d.ts +28 -0
- package/dist/bridge/deviceRegistration.js +19 -0
- package/dist/bridge/folderPicker.d.ts +34 -0
- package/dist/bridge/folderPicker.js +36 -0
- package/dist/bridge/folderPickerRunner.d.ts +3 -0
- package/dist/bridge/folderPickerRunner.js +25 -0
- package/dist/bridge/folderRegistry.d.ts +19 -0
- package/dist/bridge/folderRegistry.js +100 -0
- package/dist/bridge/framePacer.d.ts +61 -0
- package/dist/bridge/framePacer.js +79 -0
- package/dist/bridge/localAdd.d.ts +28 -0
- package/dist/bridge/localAdd.js +67 -0
- package/dist/bridge/localPathValidation.d.ts +20 -0
- package/dist/bridge/localPathValidation.js +58 -0
- package/dist/bridge/localPathValidationFs.d.ts +2 -0
- package/dist/bridge/localPathValidationFs.js +98 -0
- package/dist/bridge/loginStatus.d.ts +38 -0
- package/dist/bridge/loginStatus.js +56 -0
- package/dist/bridge/realPty.js +6 -2
- package/dist/bridge/relayClient.d.ts +166 -0
- package/dist/bridge/relayClient.js +517 -0
- package/dist/bridge/repoCheckout.d.ts +31 -0
- package/dist/bridge/repoCheckout.js +18 -0
- package/dist/bridge/server.d.ts +33 -0
- package/dist/bridge/server.js +98 -10
- package/dist/bridge/sessionManager.d.ts +18 -0
- package/dist/bridge/sessionManager.js +15 -1
- package/dist/commands/bridge.d.ts +21 -0
- package/dist/commands/bridge.js +194 -1
- package/dist/commands/devices.d.ts +9 -0
- package/dist/commands/devices.js +53 -0
- package/dist/commands/local.d.ts +9 -0
- package/dist/commands/local.js +141 -0
- package/dist/index.js +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* pucc-int-01 — container context writer (fenced managed region in CLAUDE.md).
|
|
5
|
+
*
|
|
6
|
+
* The arena pushes a `context-manifest` (a single formatted string) to the
|
|
7
|
+
* container; the bridge writes it into a FENCED MANAGED REGION of
|
|
8
|
+
* /data/repos/<workspaceId>/CLAUDE.md so Claude Code reads it natively
|
|
9
|
+
* (ADR-0028: a FILE, never PTY input). These tests exercise the PURE writer
|
|
10
|
+
* (fake fs) so path-safety + idempotency are proven without touching disk.
|
|
11
|
+
*/
|
|
12
|
+
const contextWriter_1 = require("../bridge/contextWriter");
|
|
13
|
+
const BASE = '/data/repos';
|
|
14
|
+
const makeFs = (seed = {}) => {
|
|
15
|
+
const files = new Map(Object.entries(seed));
|
|
16
|
+
const dirs = new Set();
|
|
17
|
+
return {
|
|
18
|
+
files,
|
|
19
|
+
dirs,
|
|
20
|
+
deps: {
|
|
21
|
+
reposBaseDir: BASE,
|
|
22
|
+
exists: (p) => files.has(p),
|
|
23
|
+
readFile: (p) => files.get(p) ?? '',
|
|
24
|
+
writeFile: (p, content) => { files.set(p, content); },
|
|
25
|
+
mkdirp: (dir) => { dirs.add(dir); },
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const CLAUDE = '/data/repos/ws-7/CLAUDE.md';
|
|
30
|
+
const region = (body) => `${contextWriter_1.CONTEXT_START_MARKER}\n${body}\n${contextWriter_1.CONTEXT_END_MARKER}`;
|
|
31
|
+
describe('writeContext — creates / updates the fenced managed region', () => {
|
|
32
|
+
it('creates CLAUDE.md holding just the region when none exists', () => {
|
|
33
|
+
const { files, deps } = makeFs();
|
|
34
|
+
const result = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: '## Selected files\n- a.ts' }, deps);
|
|
35
|
+
expect(result.workspaceRoot).toBe('/data/repos/ws-7');
|
|
36
|
+
expect(result.claudeMdPath).toBe(CLAUDE);
|
|
37
|
+
expect(result.action).toBe('created');
|
|
38
|
+
expect(files.get(CLAUDE)).toBe(region('## Selected files\n- a.ts') + '\n');
|
|
39
|
+
// The markers fence the pushed content.
|
|
40
|
+
expect(files.get(CLAUDE)).toContain(contextWriter_1.CONTEXT_START_MARKER);
|
|
41
|
+
expect(files.get(CLAUDE)).toContain(contextWriter_1.CONTEXT_END_MARKER);
|
|
42
|
+
});
|
|
43
|
+
it('appends the region to an existing CLAUDE.md, preserving all user content', () => {
|
|
44
|
+
const user = '# My project\n\nHand-written guidance the user maintains.\n';
|
|
45
|
+
const { files, deps } = makeFs({ [CLAUDE]: user });
|
|
46
|
+
const result = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'pushed context' }, deps);
|
|
47
|
+
expect(result.action).toBe('appended');
|
|
48
|
+
const out = files.get(CLAUDE);
|
|
49
|
+
expect(out.startsWith(user)).toBe(true); // user content untouched, still first
|
|
50
|
+
expect(out).toContain(region('pushed context'));
|
|
51
|
+
});
|
|
52
|
+
it('replaces an existing region in place — idempotent, no duplication on re-send', () => {
|
|
53
|
+
const { files, deps } = makeFs();
|
|
54
|
+
// First send creates it.
|
|
55
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'v1' }, deps);
|
|
56
|
+
const afterFirst = files.get(CLAUDE);
|
|
57
|
+
// Re-sending the SAME content is byte-identical (idempotent).
|
|
58
|
+
const same = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'v1' }, deps);
|
|
59
|
+
expect(same.action).toBe('replaced');
|
|
60
|
+
expect(files.get(CLAUDE)).toBe(afterFirst);
|
|
61
|
+
// Re-sending DIFFERENT content updates the region in place (no second region).
|
|
62
|
+
const updated = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'v2' }, deps);
|
|
63
|
+
expect(updated.action).toBe('replaced');
|
|
64
|
+
const out = files.get(CLAUDE);
|
|
65
|
+
expect(out).toContain(region('v2'));
|
|
66
|
+
expect(out).not.toContain('v1');
|
|
67
|
+
// Exactly one region.
|
|
68
|
+
expect(out.split(contextWriter_1.CONTEXT_START_MARKER).length - 1).toBe(1);
|
|
69
|
+
expect(out.split(contextWriter_1.CONTEXT_END_MARKER).length - 1).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
it('updates the region but keeps user content on both sides intact', () => {
|
|
72
|
+
const before = '# Header the user wrote\n\n';
|
|
73
|
+
const after = '\n## A user section BELOW the region\nkeep me\n';
|
|
74
|
+
const { files, deps } = makeFs({ [CLAUDE]: before + region('old') + after });
|
|
75
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'new' }, deps);
|
|
76
|
+
const out = files.get(CLAUDE);
|
|
77
|
+
expect(out.startsWith(before)).toBe(true);
|
|
78
|
+
expect(out.endsWith(after)).toBe(true);
|
|
79
|
+
expect(out).toContain(region('new'));
|
|
80
|
+
expect(out).not.toContain(region('old'));
|
|
81
|
+
});
|
|
82
|
+
it('re-send with new content does not touch user CLAUDE.md text outside the region', () => {
|
|
83
|
+
const user = '# Keep me\ncustom rule\n';
|
|
84
|
+
const { files, deps } = makeFs({ [CLAUDE]: user });
|
|
85
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'a' }, deps);
|
|
86
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'b' }, deps);
|
|
87
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'c' }, deps);
|
|
88
|
+
const out = files.get(CLAUDE);
|
|
89
|
+
expect(out).toContain('# Keep me');
|
|
90
|
+
expect(out).toContain('custom rule');
|
|
91
|
+
expect(out).toContain(region('c'));
|
|
92
|
+
expect(out).not.toContain(region('a'));
|
|
93
|
+
expect(out).not.toContain(region('b'));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('writeContext — empty content removes the region', () => {
|
|
97
|
+
it('removes the region and keeps the user content when content is empty', () => {
|
|
98
|
+
const user = '# My project\ncustom rule\n';
|
|
99
|
+
const { files, deps } = makeFs({ [CLAUDE]: user + '\n' + region('pushed') + '\n' });
|
|
100
|
+
const result = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: '' }, deps);
|
|
101
|
+
expect(result.action).toBe('removed');
|
|
102
|
+
const out = files.get(CLAUDE);
|
|
103
|
+
expect(out).not.toContain(contextWriter_1.CONTEXT_START_MARKER);
|
|
104
|
+
expect(out).not.toContain(contextWriter_1.CONTEXT_END_MARKER);
|
|
105
|
+
expect(out).toContain('# My project');
|
|
106
|
+
expect(out).toContain('custom rule');
|
|
107
|
+
});
|
|
108
|
+
it('treats whitespace-only content as empty (removes the region)', () => {
|
|
109
|
+
const { files, deps } = makeFs();
|
|
110
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'x' }, deps); // create region
|
|
111
|
+
const result = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: ' \n\t ' }, deps);
|
|
112
|
+
expect(result.action).toBe('removed');
|
|
113
|
+
expect(files.get(CLAUDE)).not.toContain(contextWriter_1.CONTEXT_START_MARKER);
|
|
114
|
+
});
|
|
115
|
+
it('append-then-remove round-trips back to the original user content', () => {
|
|
116
|
+
const user = '# Project\ncustom rule\n';
|
|
117
|
+
const { files, deps } = makeFs({ [CLAUDE]: user });
|
|
118
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'ctx' }, deps);
|
|
119
|
+
(0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: '' }, deps);
|
|
120
|
+
expect(files.get(CLAUDE)).toBe(user);
|
|
121
|
+
});
|
|
122
|
+
it('is a no-op when there is no region and content is empty (no file created)', () => {
|
|
123
|
+
const { files, deps } = makeFs();
|
|
124
|
+
const result = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: '' }, deps);
|
|
125
|
+
expect(result.action).toBe('noop');
|
|
126
|
+
expect(files.has(CLAUDE)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('writeContext — path safety (writes nothing outside the workspace)', () => {
|
|
130
|
+
const badIds = [
|
|
131
|
+
['parent traversal', '../..'],
|
|
132
|
+
['slash injection', 'ws-7/../ws-8'],
|
|
133
|
+
['climb to shared login', '../.claude'],
|
|
134
|
+
['dot', '.'],
|
|
135
|
+
['dotdot', '..'],
|
|
136
|
+
['empty', ''],
|
|
137
|
+
['backslash', 'ws-7\\..\\ws-8'],
|
|
138
|
+
['null byte', 'ws-7\0'],
|
|
139
|
+
];
|
|
140
|
+
it.each(badIds)('refuses workspaceId %s and writes nothing', (_label, badId) => {
|
|
141
|
+
const { files, dirs, deps } = makeFs();
|
|
142
|
+
const result = (0, contextWriter_1.writeContext)({ workspaceId: badId, content: 'malicious' }, deps);
|
|
143
|
+
expect(result.action).toBe('skipped');
|
|
144
|
+
expect(result.workspaceRoot).toBeNull();
|
|
145
|
+
expect(result.claudeMdPath).toBeNull();
|
|
146
|
+
expect(files.size).toBe(0);
|
|
147
|
+
expect(dirs.size).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
it('never targets the shared /data/.claude login', () => {
|
|
150
|
+
const { deps } = makeFs();
|
|
151
|
+
const result = (0, contextWriter_1.writeContext)({ workspaceId: 'ws-7', content: 'x' }, deps);
|
|
152
|
+
// Only ever /data/repos/<id>/CLAUDE.md — never /data/.claude/*.
|
|
153
|
+
expect(result.claudeMdPath).toBe('/data/repos/ws-7/CLAUDE.md');
|
|
154
|
+
expect(result.claudeMdPath.startsWith('/data/repos/ws-7/')).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
it('never writes into a sibling workspace via a crafted id', () => {
|
|
157
|
+
const { files, deps } = makeFs();
|
|
158
|
+
(0, contextWriter_1.writeContext)({ workspaceId: '../ws-OTHER', content: 'steal' }, deps);
|
|
159
|
+
expect(files.size).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* TDD — lws-03: bridge/deviceRegistration.ts. "Connect this machine" — on
|
|
5
|
+
* first `wen-market bridge` run with a Connect Token configured, auto-
|
|
6
|
+
* register the device; idempotent thereafter (persisted deviceId short-
|
|
7
|
+
* circuits re-registration).
|
|
8
|
+
*/
|
|
9
|
+
const deviceRegistration_1 = require("../bridge/deviceRegistration");
|
|
10
|
+
describe('ensureDeviceRegistered', () => {
|
|
11
|
+
const baseConfig = { connectToken: 'wm_ct_' + 'a'.repeat(64) };
|
|
12
|
+
it('registers a device using the hostname when none is bound yet', async () => {
|
|
13
|
+
const registerDevice = jest.fn().mockResolvedValue({ success: true, device: { id: 'd1', name: 'bunny-mbp' } });
|
|
14
|
+
const result = await (0, deviceRegistration_1.ensureDeviceRegistered)(baseConfig, 'https://api.x', {
|
|
15
|
+
registerDevice,
|
|
16
|
+
hostname: () => 'bunny-mbp',
|
|
17
|
+
});
|
|
18
|
+
expect(result).toMatchObject({ registered: true, deviceName: 'bunny-mbp', config: { deviceId: 'd1' } });
|
|
19
|
+
expect(registerDevice).toHaveBeenCalledWith(expect.objectContaining({ apiUrl: 'https://api.x', connectToken: baseConfig.connectToken }), 'bunny-mbp');
|
|
20
|
+
});
|
|
21
|
+
it('is idempotent — skips the network call when a deviceId is already persisted', async () => {
|
|
22
|
+
const registerDevice = jest.fn();
|
|
23
|
+
const config = { ...baseConfig, deviceId: 'd1' };
|
|
24
|
+
const result = await (0, deviceRegistration_1.ensureDeviceRegistered)(config, 'https://api.x', { registerDevice, hostname: () => 'x' });
|
|
25
|
+
expect(registerDevice).not.toHaveBeenCalled();
|
|
26
|
+
expect(result).toMatchObject({ registered: false, config: { deviceId: 'd1' } });
|
|
27
|
+
});
|
|
28
|
+
it('surfaces a clear error instead of throwing when registration fails', async () => {
|
|
29
|
+
const registerDevice = jest.fn().mockRejectedValue(new Error('device registration failed: HTTP 500'));
|
|
30
|
+
const result = await (0, deviceRegistration_1.ensureDeviceRegistered)(baseConfig, 'https://api.x', { registerDevice, hostname: () => 'x' });
|
|
31
|
+
expect('error' in result && result.error).toContain('device registration failed');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* TDD — lws-05: the real execFile-backed PickFolderRunner and the zenity
|
|
5
|
+
* presence probe used by commands/bridge.ts to wire POST /local/pick-folder.
|
|
6
|
+
* child_process is mocked — this test never spawns a real process.
|
|
7
|
+
*/
|
|
8
|
+
const mockExecFile = jest.fn();
|
|
9
|
+
jest.mock('child_process', () => ({
|
|
10
|
+
execFile: (...args) => mockExecFile(...args),
|
|
11
|
+
}));
|
|
12
|
+
const folderPickerRunner_1 = require("../bridge/folderPickerRunner");
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
describe('execFileRunner', () => {
|
|
17
|
+
it('resolves with stdout and exit code 0 on success', async () => {
|
|
18
|
+
mockExecFile.mockImplementation((cmd, args, cb) => cb(null, '/Users/x/app\n', ''));
|
|
19
|
+
const result = await (0, folderPickerRunner_1.execFileRunner)('osascript', ['-e', 'POSIX path of (choose folder)']);
|
|
20
|
+
expect(result).toEqual({ stdout: '/Users/x/app\n', code: 0 });
|
|
21
|
+
expect(mockExecFile).toHaveBeenCalledWith('osascript', ['-e', 'POSIX path of (choose folder)'], expect.any(Function));
|
|
22
|
+
});
|
|
23
|
+
it('rejects when the process errors (e.g. user cancelled)', async () => {
|
|
24
|
+
mockExecFile.mockImplementation((cmd, args, cb) => cb(new Error('User canceled'), '', ''));
|
|
25
|
+
await expect((0, folderPickerRunner_1.execFileRunner)('osascript', ['-e', 'x'])).rejects.toThrow('User canceled');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('hasZenityInstalled', () => {
|
|
29
|
+
it('true when `which zenity` (or equivalent probe) succeeds', async () => {
|
|
30
|
+
mockExecFile.mockImplementation((cmd, args, cb) => cb(null, '/usr/bin/zenity\n', ''));
|
|
31
|
+
expect(await (0, folderPickerRunner_1.hasZenityInstalled)()).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('false when the probe fails (zenity not installed)', async () => {
|
|
34
|
+
mockExecFile.mockImplementation((cmd, args, cb) => cb(new Error('not found'), '', ''));
|
|
35
|
+
expect(await (0, folderPickerRunner_1.hasZenityInstalled)()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* TDD — lws-05: OS-native folder picker (PRD D3b) behind POST
|
|
5
|
+
* /local/pick-folder. `pickerCommandFor` is a pure command table (no spawn —
|
|
6
|
+
* directly testable per platform). `pickFolder` spawns it with an injected
|
|
7
|
+
* runner (NO real osascript/PowerShell/zenity in tests, per the issue).
|
|
8
|
+
*/
|
|
9
|
+
const folderPicker_1 = require("../bridge/folderPicker");
|
|
10
|
+
describe('pickerCommandFor — platform command table', () => {
|
|
11
|
+
it('macOS uses osascript "choose folder"', () => {
|
|
12
|
+
expect((0, folderPicker_1.pickerCommandFor)('darwin', { hasZenity: false })).toEqual({
|
|
13
|
+
cmd: 'osascript',
|
|
14
|
+
args: ['-e', 'POSIX path of (choose folder)'],
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
it('Windows uses a PowerShell FolderBrowserDialog one-liner', () => {
|
|
18
|
+
const table = (0, folderPicker_1.pickerCommandFor)('win32', { hasZenity: false });
|
|
19
|
+
expect(table?.cmd).toBe('powershell.exe');
|
|
20
|
+
expect(table?.args.join(' ')).toMatch(/FolderBrowserDialog/);
|
|
21
|
+
});
|
|
22
|
+
it('Linux with zenity available uses zenity --file-selection --directory', () => {
|
|
23
|
+
expect((0, folderPicker_1.pickerCommandFor)('linux', { hasZenity: true })).toEqual({
|
|
24
|
+
cmd: 'zenity',
|
|
25
|
+
args: ['--file-selection', '--directory'],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it('Linux without zenity is unsupported (null) — falls through to typed path', () => {
|
|
29
|
+
expect((0, folderPicker_1.pickerCommandFor)('linux', { hasZenity: false })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('any other platform is unsupported', () => {
|
|
32
|
+
expect((0, folderPicker_1.pickerCommandFor)('freebsd', { hasZenity: false })).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
function fakeRunner(behavior) {
|
|
36
|
+
return (cmd, args) => new Promise((resolve, reject) => {
|
|
37
|
+
const b = behavior(cmd, args);
|
|
38
|
+
if (b.err)
|
|
39
|
+
reject(b.err);
|
|
40
|
+
else
|
|
41
|
+
resolve({ stdout: b.stdout ?? '', code: b.code ?? 0 });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
describe('pickFolder', () => {
|
|
45
|
+
it('returns the chosen path, trimmed, on success', async () => {
|
|
46
|
+
const run = fakeRunner(() => ({ stdout: '/Users/x/my-app\n', code: 0 }));
|
|
47
|
+
const result = await (0, folderPicker_1.pickFolder)({ platform: 'darwin', hasZenity: false, run });
|
|
48
|
+
expect(result).toEqual({ path: '/Users/x/my-app' });
|
|
49
|
+
});
|
|
50
|
+
it('returns {cancelled:true} when the user dismisses the dialog (non-zero exit, no path)', async () => {
|
|
51
|
+
const run = fakeRunner(() => { throw Object.assign(new Error('User canceled'), { code: 1 }); });
|
|
52
|
+
const result = await (0, folderPicker_1.pickFolder)({ platform: 'darwin', hasZenity: false, run });
|
|
53
|
+
expect(result).toEqual({ cancelled: true });
|
|
54
|
+
});
|
|
55
|
+
it('returns {unsupported:true} on Linux without zenity — never spawns', async () => {
|
|
56
|
+
const run = jest.fn();
|
|
57
|
+
const result = await (0, folderPicker_1.pickFolder)({ platform: 'linux', hasZenity: false, run });
|
|
58
|
+
expect(result).toEqual({ unsupported: true });
|
|
59
|
+
expect(run).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
it('treats empty stdout as a cancel (some pickers exit 0 with nothing chosen)', async () => {
|
|
62
|
+
const run = fakeRunner(() => ({ stdout: ' \n', code: 0 }));
|
|
63
|
+
const result = await (0, folderPicker_1.pickFolder)({ platform: 'darwin', hasZenity: false, run });
|
|
64
|
+
expect(result).toEqual({ cancelled: true });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
/**
|
|
37
|
+
* TDD — lws-03: bridge/folderRegistry.ts. Persisted path→workspaceId map
|
|
38
|
+
* (`~/.wen-market/local-workspaces.json`), survives daemon restart, backs
|
|
39
|
+
* `wen-market local add/list/remove` and the discovery advertisement.
|
|
40
|
+
*/
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const folderRegistry_1 = require("../bridge/folderRegistry");
|
|
45
|
+
describe('folder registry — pure in-memory ops', () => {
|
|
46
|
+
it('upsertEntry adds a path→workspace entry, idempotent by path', () => {
|
|
47
|
+
let registry = loadFolderRegistryFrom({});
|
|
48
|
+
registry = (0, folderRegistry_1.upsertEntry)(registry, '/Users/x/my-app', { workspaceId: 'ws-1', name: 'my-app', addedAt: '2026-08-11T00:00:00.000Z' });
|
|
49
|
+
expect((0, folderRegistry_1.findEntryByPath)(registry, '/Users/x/my-app')?.workspaceId).toBe('ws-1');
|
|
50
|
+
expect(Object.keys(registry.entries)).toHaveLength(1);
|
|
51
|
+
});
|
|
52
|
+
it('findEntryByPath returns null when unregistered', () => {
|
|
53
|
+
const registry = loadFolderRegistryFrom({});
|
|
54
|
+
expect((0, folderRegistry_1.findEntryByPath)(registry, '/nope')).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
it('findEntryByWorkspaceId looks up by the bound workspace id', () => {
|
|
57
|
+
let registry = loadFolderRegistryFrom({});
|
|
58
|
+
registry = (0, folderRegistry_1.upsertEntry)(registry, '/Users/x/my-app', { workspaceId: 'ws-1', name: 'my-app', addedAt: 'now' });
|
|
59
|
+
expect((0, folderRegistry_1.findEntryByWorkspaceId)(registry, 'ws-1')?.path).toBe('/Users/x/my-app');
|
|
60
|
+
expect((0, folderRegistry_1.findEntryByWorkspaceId)(registry, 'nope')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
it('removeEntryByPath drops exactly that entry', () => {
|
|
63
|
+
let registry = loadFolderRegistryFrom({});
|
|
64
|
+
registry = (0, folderRegistry_1.upsertEntry)(registry, '/a', { workspaceId: 'ws-a', name: 'a', addedAt: 'now' });
|
|
65
|
+
registry = (0, folderRegistry_1.upsertEntry)(registry, '/b', { workspaceId: 'ws-b', name: 'b', addedAt: 'now' });
|
|
66
|
+
registry = (0, folderRegistry_1.removeEntryByPath)(registry, '/a');
|
|
67
|
+
expect((0, folderRegistry_1.findEntryByPath)(registry, '/a')).toBeNull();
|
|
68
|
+
expect((0, folderRegistry_1.findEntryByPath)(registry, '/b')).not.toBeNull();
|
|
69
|
+
});
|
|
70
|
+
it('removeEntryByWorkspaceId drops the entry bound to that workspace', () => {
|
|
71
|
+
let registry = loadFolderRegistryFrom({});
|
|
72
|
+
registry = (0, folderRegistry_1.upsertEntry)(registry, '/a', { workspaceId: 'ws-a', name: 'a', addedAt: 'now' });
|
|
73
|
+
registry = (0, folderRegistry_1.removeEntryByWorkspaceId)(registry, 'ws-a');
|
|
74
|
+
expect((0, folderRegistry_1.findEntryByPath)(registry, '/a')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
function loadFolderRegistryFrom(entries) {
|
|
77
|
+
return { v: 1, entries };
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
describe('folder registry — file-backed (survives daemon restart)', () => {
|
|
81
|
+
let dir;
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'folder-registry-test-'));
|
|
84
|
+
});
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
it('returns an empty registry when no file exists yet', () => {
|
|
89
|
+
const registry = (0, folderRegistry_1.loadFolderRegistry)(dir);
|
|
90
|
+
expect(registry).toEqual({ v: 1, entries: {} });
|
|
91
|
+
});
|
|
92
|
+
it('saves and reloads entries 0600, round-tripping across a fresh load (restart)', () => {
|
|
93
|
+
let registry = (0, folderRegistry_1.loadFolderRegistry)(dir);
|
|
94
|
+
registry = (0, folderRegistry_1.upsertEntry)(registry, '/Users/x/my-app', { workspaceId: 'ws-1', name: 'my-app', addedAt: '2026-08-11T00:00:00.000Z' });
|
|
95
|
+
(0, folderRegistry_1.saveFolderRegistry)(registry, dir);
|
|
96
|
+
const file = path.join(dir, 'local-workspaces.json');
|
|
97
|
+
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
|
|
98
|
+
// Simulate the daemon restarting: reload from disk.
|
|
99
|
+
const reloaded = (0, folderRegistry_1.loadFolderRegistry)(dir);
|
|
100
|
+
expect((0, folderRegistry_1.findEntryByPath)(reloaded, '/Users/x/my-app')?.workspaceId).toBe('ws-1');
|
|
101
|
+
});
|
|
102
|
+
it('returns an empty registry on corrupt JSON rather than throwing', () => {
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
104
|
+
fs.writeFileSync(path.join(dir, 'local-workspaces.json'), 'not json{');
|
|
105
|
+
expect((0, folderRegistry_1.loadFolderRegistry)(dir)).toEqual({ v: 1, entries: {} });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* cbs-01 — keep-newest frame pacer (pure, no I/O, no timers).
|
|
5
|
+
*
|
|
6
|
+
* The cloud browser session streams JPEG screencast frames from a headless
|
|
7
|
+
* Chromium down to the pane. The relay/bridge/pane chain must never let a
|
|
8
|
+
* slow consumer balloon memory: at most ONE undelivered frame may sit ahead
|
|
9
|
+
* of the consumer at any time, and a fresh frame arriving while one is
|
|
10
|
+
* already queued REPLACES it (the replaced frame counts as dropped, never
|
|
11
|
+
* queued). Delivery of the queued frame is gated entirely on the consumer's
|
|
12
|
+
* ack — the pacer holds no timers/cadence of its own (see framePacer.ts).
|
|
13
|
+
*/
|
|
14
|
+
const framePacer_1 = require("../bridge/framePacer");
|
|
15
|
+
describe('createFramePacer (cbs-01)', () => {
|
|
16
|
+
it('delivers the first pushed frame immediately', () => {
|
|
17
|
+
const delivered = [];
|
|
18
|
+
const pacer = (0, framePacer_1.createFramePacer)({ onDeliver: (f) => delivered.push(f) });
|
|
19
|
+
pacer.push(1);
|
|
20
|
+
expect(delivered).toEqual([1]);
|
|
21
|
+
expect(pacer.deliveredCount).toBe(1);
|
|
22
|
+
expect(pacer.droppedCount).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
it('slow consumer: drops accumulate while memory stays constant (only one frame ever held)', () => {
|
|
25
|
+
const delivered = [];
|
|
26
|
+
const pacer = (0, framePacer_1.createFramePacer)({ onDeliver: (f) => delivered.push(f) });
|
|
27
|
+
// Push 10 frames back-to-back with ZERO acks — simulates a consumer that
|
|
28
|
+
// never catches up (slow render / stalled network).
|
|
29
|
+
for (let i = 1; i <= 10; i++)
|
|
30
|
+
pacer.push(i);
|
|
31
|
+
// Only the very first frame was ever delivered (awaiting ack for it).
|
|
32
|
+
expect(delivered).toEqual([1]);
|
|
33
|
+
expect(pacer.deliveredCount).toBe(1);
|
|
34
|
+
// Frame 2 fills the empty queue slot (not a drop); frames 3..10 each
|
|
35
|
+
// replace the previously queued frame (8 drops).
|
|
36
|
+
expect(pacer.droppedCount).toBe(8);
|
|
37
|
+
// Acking now delivers the NEWEST queued frame (10), not frame 2 — proof
|
|
38
|
+
// the queue never grew past one slot and always held the latest.
|
|
39
|
+
pacer.ack();
|
|
40
|
+
expect(delivered).toEqual([1, 10]);
|
|
41
|
+
expect(pacer.deliveredCount).toBe(2);
|
|
42
|
+
expect(pacer.droppedCount).toBe(8); // unchanged — the ack didn't drop anything
|
|
43
|
+
// Nothing left queued — a further ack with no new push is a no-op.
|
|
44
|
+
pacer.ack();
|
|
45
|
+
expect(delivered).toEqual([1, 10]);
|
|
46
|
+
expect(pacer.deliveredCount).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
it('fast consumer (acks before the next frame arrives): zero drops', () => {
|
|
49
|
+
const delivered = [];
|
|
50
|
+
const pacer = (0, framePacer_1.createFramePacer)({ onDeliver: (f) => delivered.push(f) });
|
|
51
|
+
for (let i = 1; i <= 20; i++) {
|
|
52
|
+
pacer.push(i);
|
|
53
|
+
pacer.ack(); // consumer keeps up — always acks before the next push
|
|
54
|
+
}
|
|
55
|
+
expect(delivered).toEqual(Array.from({ length: 20 }, (_, i) => i + 1));
|
|
56
|
+
expect(pacer.deliveredCount).toBe(20);
|
|
57
|
+
expect(pacer.droppedCount).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
it('burst then idle: drops during the burst, one final deliver on ack, then silence', () => {
|
|
60
|
+
const delivered = [];
|
|
61
|
+
const pacer = (0, framePacer_1.createFramePacer)({ onDeliver: (f) => delivered.push(f) });
|
|
62
|
+
// Burst of 5 with no acks.
|
|
63
|
+
for (let i = 1; i <= 5; i++)
|
|
64
|
+
pacer.push(i);
|
|
65
|
+
expect(delivered).toEqual([1]);
|
|
66
|
+
expect(pacer.droppedCount).toBe(3); // frame 2 queued free, 3/4/5 each drop the prior queued
|
|
67
|
+
// Idle period: consumer finally acks. Delivers the newest queued (5).
|
|
68
|
+
pacer.ack();
|
|
69
|
+
expect(delivered).toEqual([1, 5]);
|
|
70
|
+
// True idle: no more pushes, repeated acks are no-ops (nothing queued,
|
|
71
|
+
// and nothing in flight to re-deliver).
|
|
72
|
+
pacer.ack();
|
|
73
|
+
pacer.ack();
|
|
74
|
+
expect(delivered).toEqual([1, 5]);
|
|
75
|
+
expect(pacer.deliveredCount).toBe(2);
|
|
76
|
+
expect(pacer.droppedCount).toBe(3);
|
|
77
|
+
});
|
|
78
|
+
it('ack-after-close is a safe no-op', () => {
|
|
79
|
+
const delivered = [];
|
|
80
|
+
const pacer = (0, framePacer_1.createFramePacer)({ onDeliver: (f) => delivered.push(f) });
|
|
81
|
+
pacer.push(1); // delivered, awaiting ack
|
|
82
|
+
pacer.push(2); // queued
|
|
83
|
+
pacer.close();
|
|
84
|
+
expect(() => pacer.ack()).not.toThrow();
|
|
85
|
+
// Nothing further delivered — the queued frame is discarded by close,
|
|
86
|
+
// not force-delivered.
|
|
87
|
+
expect(delivered).toEqual([1]);
|
|
88
|
+
expect(pacer.deliveredCount).toBe(1);
|
|
89
|
+
// A second ack-after-close is equally inert.
|
|
90
|
+
expect(() => pacer.ack()).not.toThrow();
|
|
91
|
+
expect(delivered).toEqual([1]);
|
|
92
|
+
});
|
|
93
|
+
it('push-after-close is also a safe no-op (no delivery, no counter growth)', () => {
|
|
94
|
+
const delivered = [];
|
|
95
|
+
const pacer = (0, framePacer_1.createFramePacer)({ onDeliver: (f) => delivered.push(f) });
|
|
96
|
+
pacer.close();
|
|
97
|
+
expect(() => pacer.push(1)).not.toThrow();
|
|
98
|
+
expect(delivered).toEqual([]);
|
|
99
|
+
expect(pacer.deliveredCount).toBe(0);
|
|
100
|
+
expect(pacer.droppedCount).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* pucc-int-04 — the relay client honors the `install-bundle` frame.
|
|
5
|
+
*
|
|
6
|
+
* platform (pane/arena) → relay → container bridge: the bridge routes an
|
|
7
|
+
* `install-bundle` frame to the injected installer (which writes the FileMap
|
|
8
|
+
* into /data/repos/<workspaceId>) and acks with `bundle-installed`. No PTY /
|
|
9
|
+
* session is involved — installing a crew bundle is purely writing files
|
|
10
|
+
* (ADR-0032: Claude Code picks them up natively; no crewExecutionEngine).
|
|
11
|
+
*
|
|
12
|
+
* Reuses the faked-relay harness (no docker, no real shell).
|
|
13
|
+
*/
|
|
14
|
+
const ws_1 = require("ws");
|
|
15
|
+
const relayClient_1 = require("../bridge/relayClient");
|
|
16
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
17
|
+
class FakePty {
|
|
18
|
+
opts;
|
|
19
|
+
constructor(opts) {
|
|
20
|
+
this.opts = opts;
|
|
21
|
+
}
|
|
22
|
+
write() { }
|
|
23
|
+
resize() { }
|
|
24
|
+
kill() { }
|
|
25
|
+
onData() { }
|
|
26
|
+
onExit() { }
|
|
27
|
+
}
|
|
28
|
+
const makeFactory = () => ({ spawn: (o) => new FakePty(o) });
|
|
29
|
+
function frameQueue(ws) {
|
|
30
|
+
const frames = [];
|
|
31
|
+
const waiters = [];
|
|
32
|
+
ws.on('message', (raw) => {
|
|
33
|
+
const f = JSON.parse(String(raw));
|
|
34
|
+
const w = waiters.shift();
|
|
35
|
+
if (w)
|
|
36
|
+
w(f);
|
|
37
|
+
else
|
|
38
|
+
frames.push(f);
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
next: () => frames.length > 0 ? Promise.resolve(frames.shift()) : new Promise((r) => waiters.push(r)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function startFakeRelay() {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const wss = new ws_1.WebSocketServer({ port: 0, host: '127.0.0.1' });
|
|
47
|
+
const queue = [];
|
|
48
|
+
const waiters = [];
|
|
49
|
+
wss.on('connection', (ws) => { const w = waiters.shift(); if (w)
|
|
50
|
+
w(ws);
|
|
51
|
+
else
|
|
52
|
+
queue.push(ws); });
|
|
53
|
+
wss.on('listening', () => {
|
|
54
|
+
const addr = wss.address();
|
|
55
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
56
|
+
resolve({ wss, port, nextConnection: () => (queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => waiters.push(r))) });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function closeRelay(wss) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
for (const client of wss.clients)
|
|
63
|
+
client.terminate();
|
|
64
|
+
wss.close((err) => (err ? reject(err) : resolve()));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
describe('relay client — install-bundle frame (pucc-int-04)', () => {
|
|
68
|
+
it('routes an install-bundle frame to the injected installer and acks', async () => {
|
|
69
|
+
const relay = await startFakeRelay();
|
|
70
|
+
const sessions = new sessionManager_1.SessionManager(makeFactory());
|
|
71
|
+
const calls = [];
|
|
72
|
+
const handle = (0, relayClient_1.startRelayClient)({
|
|
73
|
+
relayUrl: `ws://127.0.0.1:${relay.port}`,
|
|
74
|
+
relayToken: 'tok',
|
|
75
|
+
sessions,
|
|
76
|
+
installBundle: (bundle) => {
|
|
77
|
+
calls.push(bundle);
|
|
78
|
+
return { written: Object.keys(bundle.files), skipped: [] };
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const ws = await relay.nextConnection();
|
|
82
|
+
const q = frameQueue(ws);
|
|
83
|
+
await q.next(); // auth
|
|
84
|
+
const files = { 'CLAUDE.md': 'crew', '.claude/agents/a.md': 'a' };
|
|
85
|
+
ws.send(JSON.stringify({ t: 'install-bundle', workspaceId: 'ws-7', files, sessionId: 'agent-A' }));
|
|
86
|
+
const ack = await q.next();
|
|
87
|
+
expect(calls).toEqual([{ workspaceId: 'ws-7', files }]);
|
|
88
|
+
expect(ack).toMatchObject({ t: 'bundle-installed', workspaceId: 'ws-7', sessionId: 'agent-A' });
|
|
89
|
+
expect(ack.written.sort()).toEqual(['.claude/agents/a.md', 'CLAUDE.md']);
|
|
90
|
+
// Installing a bundle must not open a PTY session.
|
|
91
|
+
expect(sessions.list()).toEqual([]);
|
|
92
|
+
handle.close();
|
|
93
|
+
await closeRelay(relay.wss);
|
|
94
|
+
});
|
|
95
|
+
it('errors (never crashes) when no installer is wired', async () => {
|
|
96
|
+
const relay = await startFakeRelay();
|
|
97
|
+
const sessions = new sessionManager_1.SessionManager(makeFactory());
|
|
98
|
+
const handle = (0, relayClient_1.startRelayClient)({ relayUrl: `ws://127.0.0.1:${relay.port}`, relayToken: 'tok', sessions });
|
|
99
|
+
const ws = await relay.nextConnection();
|
|
100
|
+
const q = frameQueue(ws);
|
|
101
|
+
await q.next(); // auth
|
|
102
|
+
ws.send(JSON.stringify({ t: 'install-bundle', workspaceId: 'ws-7', files: {} }));
|
|
103
|
+
expect((await q.next()).t).toBe('error');
|
|
104
|
+
handle.close();
|
|
105
|
+
await closeRelay(relay.wss);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|