opengstack 0.13.7 → 0.13.8
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/bin/opengstack.js +35 -90
- package/package.json +2 -3
- package/scripts/install-skills.js +29 -58
- package/skills/browse/bin/find-browse +21 -0
- package/skills/browse/bin/remote-slug +14 -0
- package/skills/browse/scripts/build-node-server.sh +48 -0
- package/skills/browse/src/activity.ts +208 -0
- package/skills/browse/src/browser-manager.ts +959 -0
- package/skills/browse/src/buffers.ts +137 -0
- package/skills/browse/src/bun-polyfill.cjs +109 -0
- package/skills/browse/src/cli.ts +678 -0
- package/skills/browse/src/commands.ts +128 -0
- package/skills/browse/src/config.ts +150 -0
- package/skills/browse/src/cookie-import-browser.ts +625 -0
- package/skills/browse/src/cookie-picker-routes.ts +230 -0
- package/skills/browse/src/cookie-picker-ui.ts +688 -0
- package/skills/browse/src/find-browse.ts +61 -0
- package/skills/browse/src/meta-commands.ts +550 -0
- package/skills/browse/src/platform.ts +17 -0
- package/skills/browse/src/read-commands.ts +358 -0
- package/skills/browse/src/server.ts +1192 -0
- package/skills/browse/src/sidebar-agent.ts +280 -0
- package/skills/browse/src/sidebar-utils.ts +21 -0
- package/skills/browse/src/snapshot.ts +407 -0
- package/skills/browse/src/url-validation.ts +95 -0
- package/skills/browse/src/write-commands.ts +364 -0
- package/skills/browse/test/activity.test.ts +120 -0
- package/skills/browse/test/adversarial-security.test.ts +32 -0
- package/skills/browse/test/browser-manager-unit.test.ts +17 -0
- package/skills/browse/test/bun-polyfill.test.ts +72 -0
- package/skills/browse/test/commands.test.ts +2075 -0
- package/skills/browse/test/compare-board.test.ts +342 -0
- package/skills/browse/test/config.test.ts +316 -0
- package/skills/browse/test/cookie-import-browser.test.ts +519 -0
- package/skills/browse/test/cookie-picker-routes.test.ts +260 -0
- package/skills/browse/test/file-drop.test.ts +271 -0
- package/skills/browse/test/find-browse.test.ts +50 -0
- package/skills/browse/test/findport.test.ts +191 -0
- package/skills/browse/test/fixtures/basic.html +33 -0
- package/skills/browse/test/fixtures/cursor-interactive.html +22 -0
- package/skills/browse/test/fixtures/dialog.html +15 -0
- package/skills/browse/test/fixtures/empty.html +2 -0
- package/skills/browse/test/fixtures/forms.html +55 -0
- package/skills/browse/test/fixtures/iframe.html +30 -0
- package/skills/browse/test/fixtures/network-idle.html +30 -0
- package/skills/browse/test/fixtures/qa-eval-checkout.html +108 -0
- package/skills/browse/test/fixtures/qa-eval-spa.html +98 -0
- package/skills/browse/test/fixtures/qa-eval.html +51 -0
- package/skills/browse/test/fixtures/responsive.html +49 -0
- package/skills/browse/test/fixtures/snapshot.html +55 -0
- package/skills/browse/test/fixtures/spa.html +24 -0
- package/skills/browse/test/fixtures/states.html +17 -0
- package/skills/browse/test/fixtures/upload.html +25 -0
- package/skills/browse/test/gstack-config.test.ts +138 -0
- package/skills/browse/test/gstack-update-check.test.ts +514 -0
- package/skills/browse/test/handoff.test.ts +235 -0
- package/skills/browse/test/path-validation.test.ts +91 -0
- package/skills/browse/test/platform.test.ts +37 -0
- package/skills/browse/test/server-auth.test.ts +65 -0
- package/skills/browse/test/sidebar-agent-roundtrip.test.ts +226 -0
- package/skills/browse/test/sidebar-agent.test.ts +199 -0
- package/skills/browse/test/sidebar-integration.test.ts +320 -0
- package/skills/browse/test/sidebar-unit.test.ts +96 -0
- package/skills/browse/test/snapshot.test.ts +467 -0
- package/skills/browse/test/state-ttl.test.ts +35 -0
- package/skills/browse/test/test-server.ts +57 -0
- package/skills/browse/test/url-validation.test.ts +72 -0
- package/skills/browse/test/watch.test.ts +129 -0
- package/skills/careful/bin/check-careful.sh +112 -0
- package/skills/cso/ACKNOWLEDGEMENTS.md +14 -0
- package/skills/freeze/bin/check-freeze.sh +79 -0
- package/skills/qa/references/issue-taxonomy.md +85 -0
- package/skills/qa/templates/qa-report-template.md +126 -0
- package/skills/review/TODOS-format.md +62 -0
- package/skills/review/checklist.md +220 -0
- package/skills/review/design-checklist.md +132 -0
- package/skills/review/greptile-triage.md +220 -0
- /package/{autoplan → skills/autoplan}/SKILL.md +0 -0
- /package/{autoplan → skills/autoplan}/SKILL.md.tmpl +0 -0
- /package/{benchmark → skills/benchmark}/SKILL.md +0 -0
- /package/{benchmark → skills/benchmark}/SKILL.md.tmpl +0 -0
- /package/{browse → skills/browse}/SKILL.md +0 -0
- /package/{browse → skills/browse}/SKILL.md.tmpl +0 -0
- /package/{canary → skills/canary}/SKILL.md +0 -0
- /package/{canary → skills/canary}/SKILL.md.tmpl +0 -0
- /package/{careful → skills/careful}/SKILL.md +0 -0
- /package/{careful → skills/careful}/SKILL.md.tmpl +0 -0
- /package/{codex → skills/codex}/SKILL.md +0 -0
- /package/{codex → skills/codex}/SKILL.md.tmpl +0 -0
- /package/{connect-chrome → skills/connect-chrome}/SKILL.md +0 -0
- /package/{connect-chrome → skills/connect-chrome}/SKILL.md.tmpl +0 -0
- /package/{cso → skills/cso}/SKILL.md +0 -0
- /package/{cso → skills/cso}/SKILL.md.tmpl +0 -0
- /package/{design-consultation → skills/design-consultation}/SKILL.md +0 -0
- /package/{design-consultation → skills/design-consultation}/SKILL.md.tmpl +0 -0
- /package/{design-review → skills/design-review}/SKILL.md +0 -0
- /package/{design-review → skills/design-review}/SKILL.md.tmpl +0 -0
- /package/{design-shotgun → skills/design-shotgun}/SKILL.md +0 -0
- /package/{design-shotgun → skills/design-shotgun}/SKILL.md.tmpl +0 -0
- /package/{document-release → skills/document-release}/SKILL.md +0 -0
- /package/{document-release → skills/document-release}/SKILL.md.tmpl +0 -0
- /package/{freeze → skills/freeze}/SKILL.md +0 -0
- /package/{freeze → skills/freeze}/SKILL.md.tmpl +0 -0
- /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md +0 -0
- /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md.tmpl +0 -0
- /package/{guard → skills/guard}/SKILL.md +0 -0
- /package/{guard → skills/guard}/SKILL.md.tmpl +0 -0
- /package/{investigate → skills/investigate}/SKILL.md +0 -0
- /package/{investigate → skills/investigate}/SKILL.md.tmpl +0 -0
- /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md +0 -0
- /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md.tmpl +0 -0
- /package/{office-hours → skills/office-hours}/SKILL.md +0 -0
- /package/{office-hours → skills/office-hours}/SKILL.md.tmpl +0 -0
- /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md +0 -0
- /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md.tmpl +0 -0
- /package/{plan-design-review → skills/plan-design-review}/SKILL.md +0 -0
- /package/{plan-design-review → skills/plan-design-review}/SKILL.md.tmpl +0 -0
- /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md +0 -0
- /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md.tmpl +0 -0
- /package/{qa → skills/qa}/SKILL.md +0 -0
- /package/{qa → skills/qa}/SKILL.md.tmpl +0 -0
- /package/{qa-only → skills/qa-only}/SKILL.md +0 -0
- /package/{qa-only → skills/qa-only}/SKILL.md.tmpl +0 -0
- /package/{retro → skills/retro}/SKILL.md +0 -0
- /package/{retro → skills/retro}/SKILL.md.tmpl +0 -0
- /package/{review → skills/review}/SKILL.md +0 -0
- /package/{review → skills/review}/SKILL.md.tmpl +0 -0
- /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md +0 -0
- /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md.tmpl +0 -0
- /package/{setup-deploy → skills/setup-deploy}/SKILL.md +0 -0
- /package/{setup-deploy → skills/setup-deploy}/SKILL.md.tmpl +0 -0
- /package/{ship → skills/ship}/SKILL.md +0 -0
- /package/{ship → skills/ship}/SKILL.md.tmpl +0 -0
- /package/{unfreeze → skills/unfreeze}/SKILL.md +0 -0
- /package/{unfreeze → skills/unfreeze}/SKILL.md.tmpl +0 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write commands — navigate and interact with pages (side effects)
|
|
3
|
+
*
|
|
4
|
+
* goto, back, forward, reload, click, fill, select, hover, type,
|
|
5
|
+
* press, scroll, wait, viewport, cookie, header, useragent
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BrowserManager } from './browser-manager';
|
|
9
|
+
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
|
|
10
|
+
import { validateNavigationUrl } from './url-validation';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { TEMP_DIR, isPathWithin } from './platform';
|
|
14
|
+
|
|
15
|
+
export async function handleWriteCommand(
|
|
16
|
+
command: string,
|
|
17
|
+
args: string[],
|
|
18
|
+
bm: BrowserManager
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const page = bm.getPage();
|
|
21
|
+
// Frame-aware target for locator-based operations (click, fill, etc.)
|
|
22
|
+
const target = bm.getActiveFrameOrPage();
|
|
23
|
+
const inFrame = bm.getFrame() !== null;
|
|
24
|
+
|
|
25
|
+
switch (command) {
|
|
26
|
+
case 'goto': {
|
|
27
|
+
if (inFrame) throw new Error('Cannot use goto inside a frame. Run \'frame main\' first.');
|
|
28
|
+
const url = args[0];
|
|
29
|
+
if (!url) throw new Error('Usage: browse goto <url>');
|
|
30
|
+
await validateNavigationUrl(url);
|
|
31
|
+
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
32
|
+
const status = response?.status() || 'unknown';
|
|
33
|
+
return `Navigated to ${url} (${status})`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
case 'back': {
|
|
37
|
+
if (inFrame) throw new Error('Cannot use back inside a frame. Run \'frame main\' first.');
|
|
38
|
+
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
39
|
+
return `Back → ${page.url()}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
case 'forward': {
|
|
43
|
+
if (inFrame) throw new Error('Cannot use forward inside a frame. Run \'frame main\' first.');
|
|
44
|
+
await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
45
|
+
return `Forward → ${page.url()}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'reload': {
|
|
49
|
+
if (inFrame) throw new Error('Cannot use reload inside a frame. Run \'frame main\' first.');
|
|
50
|
+
await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
51
|
+
return `Reloaded ${page.url()}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
case 'click': {
|
|
55
|
+
const selector = args[0];
|
|
56
|
+
if (!selector) throw new Error('Usage: browse click <selector>');
|
|
57
|
+
|
|
58
|
+
// Auto-route: if ref points to a real <option> inside a <select>, use selectOption
|
|
59
|
+
const role = bm.getRefRole(selector);
|
|
60
|
+
if (role === 'option') {
|
|
61
|
+
const resolved = await bm.resolveRef(selector);
|
|
62
|
+
if ('locator' in resolved) {
|
|
63
|
+
const optionInfo = await resolved.locator.evaluate(el => {
|
|
64
|
+
if (el.tagName !== 'OPTION') return null; // custom [role=option], not real <option>
|
|
65
|
+
const option = el as HTMLOptionElement;
|
|
66
|
+
const select = option.closest('select');
|
|
67
|
+
if (!select) return null;
|
|
68
|
+
return { value: option.value, text: option.text };
|
|
69
|
+
});
|
|
70
|
+
if (optionInfo) {
|
|
71
|
+
await resolved.locator.locator('xpath=ancestor::select').selectOption(optionInfo.value, { timeout: 5000 });
|
|
72
|
+
return `Selected "${optionInfo.text}" (auto-routed from click on <option>) → now at ${page.url()}`;
|
|
73
|
+
}
|
|
74
|
+
// Real <option> with no parent <select> or custom [role=option] — fall through to normal click
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const resolved = await bm.resolveRef(selector);
|
|
79
|
+
try {
|
|
80
|
+
if ('locator' in resolved) {
|
|
81
|
+
await resolved.locator.click({ timeout: 5000 });
|
|
82
|
+
} else {
|
|
83
|
+
await target.locator(resolved.selector).click({ timeout: 5000 });
|
|
84
|
+
}
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
// Enhanced error guidance: clicking <option> elements always fails (not visible / timeout)
|
|
87
|
+
const isOption = 'locator' in resolved
|
|
88
|
+
? await resolved.locator.evaluate(el => el.tagName === 'OPTION').catch(() => false)
|
|
89
|
+
: await target.locator(resolved.selector).evaluate(
|
|
90
|
+
el => el.tagName === 'OPTION'
|
|
91
|
+
).catch(() => false);
|
|
92
|
+
if (isOption) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Cannot click <option> elements. Use 'browse select <parent-select> <value>' instead of 'click' for dropdown options.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
// Wait for network to settle (catches XHR/fetch triggered by clicks)
|
|
100
|
+
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
|
101
|
+
return `Clicked ${selector} → now at ${page.url()}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case 'fill': {
|
|
105
|
+
const [selector, ...valueParts] = args;
|
|
106
|
+
const value = valueParts.join(' ');
|
|
107
|
+
if (!selector || !value) throw new Error('Usage: browse fill <selector> <value>');
|
|
108
|
+
const resolved = await bm.resolveRef(selector);
|
|
109
|
+
if ('locator' in resolved) {
|
|
110
|
+
await resolved.locator.fill(value, { timeout: 5000 });
|
|
111
|
+
} else {
|
|
112
|
+
await target.locator(resolved.selector).fill(value, { timeout: 5000 });
|
|
113
|
+
}
|
|
114
|
+
// Wait for network to settle (form validation XHRs)
|
|
115
|
+
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
|
116
|
+
return `Filled ${selector}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'select': {
|
|
120
|
+
const [selector, ...valueParts] = args;
|
|
121
|
+
const value = valueParts.join(' ');
|
|
122
|
+
if (!selector || !value) throw new Error('Usage: browse select <selector> <value>');
|
|
123
|
+
const resolved = await bm.resolveRef(selector);
|
|
124
|
+
if ('locator' in resolved) {
|
|
125
|
+
await resolved.locator.selectOption(value, { timeout: 5000 });
|
|
126
|
+
} else {
|
|
127
|
+
await target.locator(resolved.selector).selectOption(value, { timeout: 5000 });
|
|
128
|
+
}
|
|
129
|
+
// Wait for network to settle (dropdown-triggered requests)
|
|
130
|
+
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
|
131
|
+
return `Selected "${value}" in ${selector}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'hover': {
|
|
135
|
+
const selector = args[0];
|
|
136
|
+
if (!selector) throw new Error('Usage: browse hover <selector>');
|
|
137
|
+
const resolved = await bm.resolveRef(selector);
|
|
138
|
+
if ('locator' in resolved) {
|
|
139
|
+
await resolved.locator.hover({ timeout: 5000 });
|
|
140
|
+
} else {
|
|
141
|
+
await target.locator(resolved.selector).hover({ timeout: 5000 });
|
|
142
|
+
}
|
|
143
|
+
return `Hovered ${selector}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case 'type': {
|
|
147
|
+
const text = args.join(' ');
|
|
148
|
+
if (!text) throw new Error('Usage: browse type <text>');
|
|
149
|
+
await page.keyboard.type(text);
|
|
150
|
+
return `Typed ${text.length} characters`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'press': {
|
|
154
|
+
const key = args[0];
|
|
155
|
+
if (!key) throw new Error('Usage: browse press <key> (e.g., Enter, Tab, Escape)');
|
|
156
|
+
await page.keyboard.press(key);
|
|
157
|
+
return `Pressed ${key}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'scroll': {
|
|
161
|
+
const selector = args[0];
|
|
162
|
+
if (selector) {
|
|
163
|
+
const resolved = await bm.resolveRef(selector);
|
|
164
|
+
if ('locator' in resolved) {
|
|
165
|
+
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
|
166
|
+
} else {
|
|
167
|
+
await target.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
|
|
168
|
+
}
|
|
169
|
+
return `Scrolled ${selector} into view`;
|
|
170
|
+
}
|
|
171
|
+
await target.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
172
|
+
return 'Scrolled to bottom';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case 'wait': {
|
|
176
|
+
const selector = args[0];
|
|
177
|
+
if (!selector) throw new Error('Usage: browse wait <selector|--networkidle|--load|--domcontentloaded>');
|
|
178
|
+
if (selector === '--networkidle') {
|
|
179
|
+
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
|
180
|
+
await page.waitForLoadState('networkidle', { timeout });
|
|
181
|
+
return 'Network idle';
|
|
182
|
+
}
|
|
183
|
+
if (selector === '--load') {
|
|
184
|
+
await page.waitForLoadState('load');
|
|
185
|
+
return 'Page loaded';
|
|
186
|
+
}
|
|
187
|
+
if (selector === '--domcontentloaded') {
|
|
188
|
+
await page.waitForLoadState('domcontentloaded');
|
|
189
|
+
return 'DOM content loaded';
|
|
190
|
+
}
|
|
191
|
+
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
|
192
|
+
const resolved = await bm.resolveRef(selector);
|
|
193
|
+
if ('locator' in resolved) {
|
|
194
|
+
await resolved.locator.waitFor({ state: 'visible', timeout });
|
|
195
|
+
} else {
|
|
196
|
+
await target.locator(resolved.selector).waitFor({ state: 'visible', timeout });
|
|
197
|
+
}
|
|
198
|
+
return `Element ${selector} appeared`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case 'viewport': {
|
|
202
|
+
const size = args[0];
|
|
203
|
+
if (!size || !size.includes('x')) throw new Error('Usage: browse viewport <WxH> (e.g., 375x812)');
|
|
204
|
+
const [w, h] = size.split('x').map(Number);
|
|
205
|
+
await bm.setViewport(w, h);
|
|
206
|
+
return `Viewport set to ${w}x${h}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case 'cookie': {
|
|
210
|
+
const cookieStr = args[0];
|
|
211
|
+
if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie <name>=<value>');
|
|
212
|
+
const eq = cookieStr.indexOf('=');
|
|
213
|
+
const name = cookieStr.slice(0, eq);
|
|
214
|
+
const value = cookieStr.slice(eq + 1);
|
|
215
|
+
const url = new URL(page.url());
|
|
216
|
+
await page.context().addCookies([{
|
|
217
|
+
name,
|
|
218
|
+
value,
|
|
219
|
+
domain: url.hostname,
|
|
220
|
+
path: '/',
|
|
221
|
+
}]);
|
|
222
|
+
return `Cookie set: ${name}=****`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case 'header': {
|
|
226
|
+
const headerStr = args[0];
|
|
227
|
+
if (!headerStr || !headerStr.includes(':')) throw new Error('Usage: browse header <name>:<value>');
|
|
228
|
+
const sep = headerStr.indexOf(':');
|
|
229
|
+
const name = headerStr.slice(0, sep).trim();
|
|
230
|
+
const value = headerStr.slice(sep + 1).trim();
|
|
231
|
+
await bm.setExtraHeader(name, value);
|
|
232
|
+
const sensitiveHeaders = ['authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token'];
|
|
233
|
+
const redactedValue = sensitiveHeaders.includes(name.toLowerCase()) ? '****' : value;
|
|
234
|
+
return `Header set: ${name}: ${redactedValue}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case 'useragent': {
|
|
238
|
+
const ua = args.join(' ');
|
|
239
|
+
if (!ua) throw new Error('Usage: browse useragent <string>');
|
|
240
|
+
bm.setUserAgent(ua);
|
|
241
|
+
const error = await bm.recreateContext();
|
|
242
|
+
if (error) {
|
|
243
|
+
return `User agent set to "${ua}" but: ${error}`;
|
|
244
|
+
}
|
|
245
|
+
return `User agent set: ${ua}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
case 'upload': {
|
|
249
|
+
const [selector, ...filePaths] = args;
|
|
250
|
+
if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2...]');
|
|
251
|
+
|
|
252
|
+
// Validate all files exist before upload
|
|
253
|
+
for (const fp of filePaths) {
|
|
254
|
+
if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const resolved = await bm.resolveRef(selector);
|
|
258
|
+
if ('locator' in resolved) {
|
|
259
|
+
await resolved.locator.setInputFiles(filePaths);
|
|
260
|
+
} else {
|
|
261
|
+
await target.locator(resolved.selector).setInputFiles(filePaths);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const fileInfo = filePaths.map(fp => {
|
|
265
|
+
const stat = fs.statSync(fp);
|
|
266
|
+
return `${path.basename(fp)} (${stat.size}B)`;
|
|
267
|
+
}).join(', ');
|
|
268
|
+
return `Uploaded: ${fileInfo}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case 'dialog-accept': {
|
|
272
|
+
const text = args.length > 0 ? args.join(' ') : null;
|
|
273
|
+
bm.setDialogAutoAccept(true);
|
|
274
|
+
bm.setDialogPromptText(text);
|
|
275
|
+
return text
|
|
276
|
+
? `Dialogs will be accepted with text: "${text}"`
|
|
277
|
+
: 'Dialogs will be accepted';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case 'dialog-dismiss': {
|
|
281
|
+
bm.setDialogAutoAccept(false);
|
|
282
|
+
bm.setDialogPromptText(null);
|
|
283
|
+
return 'Dialogs will be dismissed';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'cookie-import': {
|
|
287
|
+
const filePath = args[0];
|
|
288
|
+
if (!filePath) throw new Error('Usage: browse cookie-import <json-file>');
|
|
289
|
+
// Path validation — prevent reading arbitrary files
|
|
290
|
+
if (path.isAbsolute(filePath)) {
|
|
291
|
+
const safeDirs = [TEMP_DIR, process.cwd()];
|
|
292
|
+
const resolved = path.resolve(filePath);
|
|
293
|
+
if (!safeDirs.some(dir => isPathWithin(resolved, dir))) {
|
|
294
|
+
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (path.normalize(filePath).includes('..')) {
|
|
298
|
+
throw new Error('Path traversal sequences (..) are not allowed');
|
|
299
|
+
}
|
|
300
|
+
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
301
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
302
|
+
let cookies: any[];
|
|
303
|
+
try { cookies = JSON.parse(raw); } catch { throw new Error(`Invalid JSON in ${filePath}`); }
|
|
304
|
+
if (!Array.isArray(cookies)) throw new Error('Cookie file must contain a JSON array');
|
|
305
|
+
|
|
306
|
+
// Auto-fill domain from current page URL when missing (consistent with cookie command)
|
|
307
|
+
const pageUrl = new URL(page.url());
|
|
308
|
+
const defaultDomain = pageUrl.hostname;
|
|
309
|
+
|
|
310
|
+
for (const c of cookies) {
|
|
311
|
+
if (!c.name || c.value === undefined) throw new Error('Each cookie must have "name" and "value" fields');
|
|
312
|
+
if (!c.domain) c.domain = defaultDomain;
|
|
313
|
+
if (!c.path) c.path = '/';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await page.context().addCookies(cookies);
|
|
317
|
+
return `Loaded ${cookies.length} cookies from ${filePath}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case 'cookie-import-browser': {
|
|
321
|
+
// Two modes:
|
|
322
|
+
// 1. Direct CLI import: cookie-import-browser <browser> --domain <domain> [--profile <profile>]
|
|
323
|
+
// 2. Open picker UI: cookie-import-browser [browser]
|
|
324
|
+
const browserArg = args[0];
|
|
325
|
+
const domainIdx = args.indexOf('--domain');
|
|
326
|
+
const profileIdx = args.indexOf('--profile');
|
|
327
|
+
const profile = (profileIdx !== -1 && profileIdx + 1 < args.length) ? args[profileIdx + 1] : 'Default';
|
|
328
|
+
|
|
329
|
+
if (domainIdx !== -1 && domainIdx + 1 < args.length) {
|
|
330
|
+
// Direct import mode — no UI
|
|
331
|
+
const domain = args[domainIdx + 1];
|
|
332
|
+
const browser = browserArg || 'comet';
|
|
333
|
+
const result = await importCookies(browser, [domain], profile);
|
|
334
|
+
if (result.cookies.length > 0) {
|
|
335
|
+
await page.context().addCookies(result.cookies);
|
|
336
|
+
}
|
|
337
|
+
const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`];
|
|
338
|
+
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
|
|
339
|
+
return msg.join(' ');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Picker UI mode — open in user's browser
|
|
343
|
+
const port = bm.serverPort;
|
|
344
|
+
if (!port) throw new Error('Server port not available');
|
|
345
|
+
|
|
346
|
+
const browsers = findInstalledBrowsers();
|
|
347
|
+
if (browsers.length === 0) {
|
|
348
|
+
throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`;
|
|
352
|
+
try {
|
|
353
|
+
Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' });
|
|
354
|
+
} catch {
|
|
355
|
+
// open may fail silently — URL is in the message below
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
default:
|
|
362
|
+
throw new Error(`Unknown write command: ${command}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import { filterArgs, emitActivity, getActivityAfter, getActivityHistory, subscribe } from '../src/activity';
|
|
3
|
+
|
|
4
|
+
describe('filterArgs — privacy filtering', () => {
|
|
5
|
+
it('redacts fill value for password fields', () => {
|
|
6
|
+
expect(filterArgs('fill', ['#password', 'mysecret123'])).toEqual(['#password', '[REDACTED]']);
|
|
7
|
+
expect(filterArgs('fill', ['input[type=passwd]', 'abc'])).toEqual(['input[type=passwd]', '[REDACTED]']);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('preserves fill value for non-password fields', () => {
|
|
11
|
+
expect(filterArgs('fill', ['#email', 'user@test.com'])).toEqual(['#email', 'user@test.com']);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('redacts type command args', () => {
|
|
15
|
+
expect(filterArgs('type', ['my password'])).toEqual(['[REDACTED]']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('redacts Authorization header', () => {
|
|
19
|
+
expect(filterArgs('header', ['Authorization:Bearer abc123'])).toEqual(['Authorization:[REDACTED]']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('preserves non-sensitive headers', () => {
|
|
23
|
+
expect(filterArgs('header', ['Content-Type:application/json'])).toEqual(['Content-Type:application/json']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('redacts cookie values', () => {
|
|
27
|
+
expect(filterArgs('cookie', ['session_id=abc123'])).toEqual(['session_id=[REDACTED]']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('redacts sensitive URL query params', () => {
|
|
31
|
+
const result = filterArgs('goto', ['https://example.com?api_key=secret&page=1']);
|
|
32
|
+
expect(result[0]).toContain('api_key=%5BREDACTED%5D');
|
|
33
|
+
expect(result[0]).toContain('page=1');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('preserves non-sensitive URL query params', () => {
|
|
37
|
+
const result = filterArgs('goto', ['https://example.com?page=1&sort=name']);
|
|
38
|
+
expect(result[0]).toBe('https://example.com?page=1&sort=name');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('handles empty args', () => {
|
|
42
|
+
expect(filterArgs('click', [])).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('handles non-URL non-sensitive args', () => {
|
|
46
|
+
expect(filterArgs('click', ['@e3'])).toEqual(['@e3']);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('emitActivity', () => {
|
|
51
|
+
it('emits with auto-incremented id', () => {
|
|
52
|
+
const e1 = emitActivity({ type: 'command_start', command: 'goto', args: ['https://example.com'] });
|
|
53
|
+
const e2 = emitActivity({ type: 'command_end', command: 'goto', status: 'ok', duration: 100 });
|
|
54
|
+
expect(e2.id).toBe(e1.id + 1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('truncates long results', () => {
|
|
58
|
+
const longResult = 'x'.repeat(500);
|
|
59
|
+
const entry = emitActivity({ type: 'command_end', command: 'text', result: longResult });
|
|
60
|
+
expect(entry.result!.length).toBeLessThanOrEqual(203); // 200 + "..."
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('applies privacy filtering', () => {
|
|
64
|
+
const entry = emitActivity({ type: 'command_start', command: 'type', args: ['my secret password'] });
|
|
65
|
+
expect(entry.args).toEqual(['[REDACTED]']);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('getActivityAfter', () => {
|
|
70
|
+
it('returns entries after cursor', () => {
|
|
71
|
+
const e1 = emitActivity({ type: 'command_start', command: 'test1' });
|
|
72
|
+
const e2 = emitActivity({ type: 'command_start', command: 'test2' });
|
|
73
|
+
const result = getActivityAfter(e1.id);
|
|
74
|
+
expect(result.entries.some(e => e.id === e2.id)).toBe(true);
|
|
75
|
+
expect(result.gap).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns all entries when cursor is 0', () => {
|
|
79
|
+
emitActivity({ type: 'command_start', command: 'test3' });
|
|
80
|
+
const result = getActivityAfter(0);
|
|
81
|
+
expect(result.entries.length).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('getActivityHistory', () => {
|
|
86
|
+
it('returns limited entries', () => {
|
|
87
|
+
for (let i = 0; i < 5; i++) {
|
|
88
|
+
emitActivity({ type: 'command_start', command: `history-test-${i}` });
|
|
89
|
+
}
|
|
90
|
+
const result = getActivityHistory(3);
|
|
91
|
+
expect(result.entries.length).toBeLessThanOrEqual(3);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('subscribe', () => {
|
|
96
|
+
it('receives new events', async () => {
|
|
97
|
+
const received: any[] = [];
|
|
98
|
+
const unsub = subscribe((entry) => received.push(entry));
|
|
99
|
+
|
|
100
|
+
emitActivity({ type: 'command_start', command: 'sub-test' });
|
|
101
|
+
|
|
102
|
+
// queueMicrotask is async — wait a tick
|
|
103
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
104
|
+
|
|
105
|
+
expect(received.length).toBeGreaterThanOrEqual(1);
|
|
106
|
+
expect(received[received.length - 1].command).toBe('sub-test');
|
|
107
|
+
unsub();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('stops receiving after unsubscribe', async () => {
|
|
111
|
+
const received: any[] = [];
|
|
112
|
+
const unsub = subscribe((entry) => received.push(entry));
|
|
113
|
+
unsub();
|
|
114
|
+
|
|
115
|
+
emitActivity({ type: 'command_start', command: 'should-not-see' });
|
|
116
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
117
|
+
|
|
118
|
+
expect(received.filter(e => e.command === 'should-not-see').length).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adversarial security tests — XSS and boundary-check hardening
|
|
3
|
+
*
|
|
4
|
+
* Test 19: Sidepanel escapes entry.command in activity feed (prevents XSS)
|
|
5
|
+
* Test 20: Freeze hook uses trailing slash in boundary check (prevents prefix collision)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test, expect } from 'bun:test';
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
|
|
12
|
+
describe('Adversarial security', () => {
|
|
13
|
+
test('sidepanel escapes entry.command in activity feed', () => {
|
|
14
|
+
const source = fs.readFileSync(
|
|
15
|
+
path.join(import.meta.dir, '../../extension/sidepanel.js'),
|
|
16
|
+
'utf-8',
|
|
17
|
+
);
|
|
18
|
+
// entry.command must be wrapped in escapeHtml() to prevent XSS injection
|
|
19
|
+
// via crafted command names in the activity feed
|
|
20
|
+
expect(source).toContain('escapeHtml(entry.command');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('freeze hook uses trailing slash in boundary check', () => {
|
|
24
|
+
const source = fs.readFileSync(
|
|
25
|
+
path.join(import.meta.dir, '../../freeze/bin/check-freeze.sh'),
|
|
26
|
+
'utf-8',
|
|
27
|
+
);
|
|
28
|
+
// The boundary check must use "${FREEZE_DIR}/" with a trailing slash
|
|
29
|
+
// to prevent prefix collision (e.g., /app matching /application)
|
|
30
|
+
expect(source).toContain('"${FREEZE_DIR}/"');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// ─── BrowserManager basic unit tests ─────────────────────────────
|
|
4
|
+
|
|
5
|
+
describe('BrowserManager defaults', () => {
|
|
6
|
+
it('getConnectionMode defaults to launched', async () => {
|
|
7
|
+
const { BrowserManager } = await import('../src/browser-manager');
|
|
8
|
+
const bm = new BrowserManager();
|
|
9
|
+
expect(bm.getConnectionMode()).toBe('launched');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('getRefMap returns empty array initially', async () => {
|
|
13
|
+
const { BrowserManager } = await import('../src/browser-manager');
|
|
14
|
+
const bm = new BrowserManager();
|
|
15
|
+
expect(bm.getRefMap()).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll } from 'bun:test';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
// Load the polyfill into a fresh object (don't clobber globalThis.Bun)
|
|
5
|
+
const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
|
6
|
+
|
|
7
|
+
describe('bun-polyfill', () => {
|
|
8
|
+
// We test the polyfill by requiring it in a subprocess under Node.js
|
|
9
|
+
// since it's designed for Node, not Bun.
|
|
10
|
+
|
|
11
|
+
test('Bun.sleep resolves after delay', async () => {
|
|
12
|
+
const result = Bun.spawnSync(['node', '-e', `
|
|
13
|
+
require('${polyfillPath}');
|
|
14
|
+
(async () => {
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
await Bun.sleep(50);
|
|
17
|
+
const elapsed = Date.now() - start;
|
|
18
|
+
console.log(elapsed >= 40 ? 'OK' : 'TOO_FAST');
|
|
19
|
+
})();
|
|
20
|
+
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
21
|
+
expect(result.stdout.toString().trim()).toBe('OK');
|
|
22
|
+
expect(result.exitCode).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('Bun.spawnSync runs a command and returns stdout', () => {
|
|
26
|
+
const result = Bun.spawnSync(['node', '-e', `
|
|
27
|
+
require('${polyfillPath}');
|
|
28
|
+
const r = Bun.spawnSync(['echo', 'hello'], { stdout: 'pipe' });
|
|
29
|
+
console.log(r.stdout.toString().trim());
|
|
30
|
+
console.log('exit:' + r.exitCode);
|
|
31
|
+
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
32
|
+
const lines = result.stdout.toString().trim().split('\n');
|
|
33
|
+
expect(lines[0]).toBe('hello');
|
|
34
|
+
expect(lines[1]).toBe('exit:0');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('Bun.spawn launches a process with pid', async () => {
|
|
38
|
+
const result = Bun.spawnSync(['node', '-e', `
|
|
39
|
+
require('${polyfillPath}');
|
|
40
|
+
const p = Bun.spawn(['echo', 'test'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
41
|
+
console.log(typeof p.pid === 'number' ? 'HAS_PID' : 'NO_PID');
|
|
42
|
+
console.log(typeof p.kill === 'function' ? 'HAS_KILL' : 'NO_KILL');
|
|
43
|
+
console.log(typeof p.unref === 'function' ? 'HAS_UNREF' : 'NO_UNREF');
|
|
44
|
+
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
45
|
+
const lines = result.stdout.toString().trim().split('\n');
|
|
46
|
+
expect(lines[0]).toBe('HAS_PID');
|
|
47
|
+
expect(lines[1]).toBe('HAS_KILL');
|
|
48
|
+
expect(lines[2]).toBe('HAS_UNREF');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('Bun.serve creates an HTTP server that responds', async () => {
|
|
52
|
+
const result = Bun.spawnSync(['node', '-e', `
|
|
53
|
+
require('${polyfillPath}');
|
|
54
|
+
const server = Bun.serve({
|
|
55
|
+
port: 0, // Note: polyfill uses port directly, so we pick one
|
|
56
|
+
hostname: '127.0.0.1',
|
|
57
|
+
fetch(req) {
|
|
58
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
// The polyfill doesn't support port 0, so we test the object shape
|
|
64
|
+
console.log(typeof server.stop === 'function' ? 'HAS_STOP' : 'NO_STOP');
|
|
65
|
+
console.log(typeof server.port === 'number' ? 'HAS_PORT' : 'NO_PORT');
|
|
66
|
+
server.stop();
|
|
67
|
+
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
68
|
+
const lines = result.stdout.toString().trim().split('\n');
|
|
69
|
+
expect(lines[0]).toBe('HAS_STOP');
|
|
70
|
+
expect(lines[1]).toBe('HAS_PORT');
|
|
71
|
+
});
|
|
72
|
+
});
|