@bubstack/moe-glass 0.1.0

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 (62) hide show
  1. package/README.md +29 -0
  2. package/agents/browser-user.md +105 -0
  3. package/dist/LICENSE +25 -0
  4. package/dist/index.d.ts +9 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +22517 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/payload.d.ts +214 -0
  9. package/dist/payload.d.ts.map +1 -0
  10. package/dist/payload.js +325 -0
  11. package/dist/payload.js.map +1 -0
  12. package/package.json +59 -0
  13. package/skills/browsing/COMMANDLINE-USAGE.md +595 -0
  14. package/skills/browsing/EXAMPLES.md +717 -0
  15. package/skills/browsing/README.md +55 -0
  16. package/skills/browsing/SKILL.md +478 -0
  17. package/skills/browsing/chrome-ws +1021 -0
  18. package/skills/browsing/chrome-ws-lib.js +461 -0
  19. package/skills/browsing/host-override.js +98 -0
  20. package/skills/browsing/lib/browser-bridge.js +175 -0
  21. package/skills/browsing/lib/browser-session.js +137 -0
  22. package/skills/browsing/lib/capture.js +499 -0
  23. package/skills/browsing/lib/cdp-router.js +72 -0
  24. package/skills/browsing/lib/cdp-utils.js +18 -0
  25. package/skills/browsing/lib/chrome-launcher-helpers.js +374 -0
  26. package/skills/browsing/lib/chrome-process.js +464 -0
  27. package/skills/browsing/lib/console-logging.js +70 -0
  28. package/skills/browsing/lib/cookies.js +17 -0
  29. package/skills/browsing/lib/dialogs-render.js +154 -0
  30. package/skills/browsing/lib/dialogs-router.js +117 -0
  31. package/skills/browsing/lib/dialogs.js +254 -0
  32. package/skills/browsing/lib/element-selector.js +91 -0
  33. package/skills/browsing/lib/evaluation.js +85 -0
  34. package/skills/browsing/lib/extraction.js +55 -0
  35. package/skills/browsing/lib/file-upload.js +56 -0
  36. package/skills/browsing/lib/html-diff.js +122 -0
  37. package/skills/browsing/lib/key-definitions.js +149 -0
  38. package/skills/browsing/lib/keyboard-input.js +288 -0
  39. package/skills/browsing/lib/mouse.js +423 -0
  40. package/skills/browsing/lib/navigation.js +272 -0
  41. package/skills/browsing/lib/page-scripts/dom-summary.js +31 -0
  42. package/skills/browsing/lib/page-scripts/markdown.js +85 -0
  43. package/skills/browsing/lib/page-scripts/permission-shim.js +80 -0
  44. package/skills/browsing/lib/page-session.js +106 -0
  45. package/skills/browsing/lib/profile-lock.js +179 -0
  46. package/skills/browsing/lib/screenshot.js +171 -0
  47. package/skills/browsing/lib/select-option.js +99 -0
  48. package/skills/browsing/lib/session-state.js +66 -0
  49. package/skills/browsing/lib/tabs.js +144 -0
  50. package/skills/browsing/lib/viewport.js +103 -0
  51. package/skills/browsing/lib/websocket-client.js +162 -0
  52. package/skills/browsing/package.json +11 -0
  53. package/skills/browsing/test-chrome-args.js +81 -0
  54. package/skills/browsing/test-cookies.js +21 -0
  55. package/skills/browsing/test-e2e.sh +51 -0
  56. package/skills/browsing/test-extract.sh +17 -0
  57. package/skills/browsing/test-interact.sh +11 -0
  58. package/skills/browsing/test-navigate.sh +9 -0
  59. package/skills/browsing/test-raw.sh +8 -0
  60. package/skills/browsing/test-tabs.sh +15 -0
  61. package/skills/browsing/test-viewport.js +27 -0
  62. package/skills/browsing/test-wait.sh +9 -0
@@ -0,0 +1,99 @@
1
+ const { getElementSelectorAll } = require('./element-selector');
2
+ const { throwIfExceptionDetails } = require('./cdp-utils');
3
+
4
+ /**
5
+ * Native HTML `<select>` element control.
6
+ *
7
+ * Each requested value matches an `<option>` by its `value` attribute
8
+ * first, then by trimmed visible label. Arrays of values require a
9
+ * `<select multiple>` — passing more than one to a single-select is an
10
+ * error. Selection replaces (every existing `selected` is cleared
11
+ * before applying the new set), matching Playwright's `selectOption`
12
+ * semantics.
13
+ *
14
+ * Multi-element warning (JRV-129): if the selector matches more than
15
+ * one element on the page, we use the element at `index` (default 0)
16
+ * and emit a warning so the caller knows the selector is ambiguous.
17
+ *
18
+ * `attachSelectOption({ getPageSession })` returns the bound action.
19
+ */
20
+ function attachSelectOption({ getPageSession }) {
21
+ async function selectOption(tabIndexOrWsUrl, selector, value, index = 0) {
22
+ const pageSession = await getPageSession(tabIndexOrWsUrl);
23
+ const values = Array.isArray(value) ? value : [value];
24
+
25
+ const countJs = `${getElementSelectorAll(selector)}.length`;
26
+ const countResult = await pageSession.send('Runtime.evaluate', {
27
+ expression: countJs,
28
+ returnByValue: true
29
+ });
30
+ throwIfExceptionDetails(countResult);
31
+ const matchCount = countResult.result.value || 0;
32
+
33
+ let warning = null;
34
+ if (matchCount > 1) {
35
+ warning = `Selector "${selector}" matches ${matchCount} elements. Using element at index ${index}. Use a more specific selector or pass index parameter.`;
36
+ console.error(`WARNING: ${warning}`);
37
+ }
38
+
39
+ const js = `
40
+ (() => {
41
+ const elements = ${getElementSelectorAll(selector)};
42
+ const el = elements[${index}];
43
+ if (!el) return { success: false, error: 'Element not found at index ${index}' };
44
+ if (el.tagName !== 'SELECT') return { success: false, error: 'Element is not a SELECT' };
45
+
46
+ const requested = ${JSON.stringify(values)};
47
+ if (requested.length > 1 && !el.multiple) {
48
+ return { success: false, error: 'Cannot select multiple values on a non-multiple <select>' };
49
+ }
50
+
51
+ const options = Array.from(el.options);
52
+ const matched = [];
53
+ const unmatched = [];
54
+ for (const v of requested) {
55
+ const opt = options.find(o => o.value === v) ||
56
+ options.find(o => o.textContent.trim() === v);
57
+ if (opt) matched.push(opt);
58
+ else unmatched.push(v);
59
+ }
60
+ if (unmatched.length) {
61
+ return { success: false, error: 'No matching option for: ' + JSON.stringify(unmatched) };
62
+ }
63
+
64
+ for (const o of options) o.selected = false;
65
+ for (const o of matched) o.selected = true;
66
+ el.dispatchEvent(new Event('change', { bubbles: true }));
67
+
68
+ return {
69
+ success: true,
70
+ matchCount: elements.length,
71
+ matched: matched.map(o => ({ value: o.value, text: o.textContent.trim() }))
72
+ };
73
+ })()
74
+ `;
75
+
76
+ const result = await pageSession.send('Runtime.evaluate', {
77
+ expression: js,
78
+ returnByValue: true
79
+ });
80
+ throwIfExceptionDetails(result);
81
+
82
+ const resultValue = result.result.value;
83
+ if (!resultValue.success) {
84
+ throw new Error(resultValue.error);
85
+ }
86
+
87
+ return {
88
+ success: true,
89
+ matchCount: resultValue.matchCount,
90
+ matched: resultValue.matched,
91
+ warning,
92
+ selectedIndex: index
93
+ };
94
+ }
95
+
96
+ return { selectOption };
97
+ }
98
+
99
+ module.exports = { attachSelectOption };
@@ -0,0 +1,66 @@
1
+ const { createOverride } = require('../host-override');
2
+
3
+ /**
4
+ * Build the per-session mutable state bag.
5
+ *
6
+ * Every Chrome session has a small set of mutable values that the rest of
7
+ * the library reads and writes: the active CDP port, per-tab
8
+ * console-message buffers, the launched Chrome process handle, the chosen
9
+ * profile name and data directory, the headless flag, and the auto-capture
10
+ * session directory and counter.
11
+ *
12
+ * Pulling them into one object (and one file) makes the per-session
13
+ * surface explicit, lets methods that get extracted to sibling files
14
+ * accept it as a single parameter, and keeps the rest of chrome-ws-lib
15
+ * focused on behaviour rather than state.
16
+ *
17
+ * `host`/`port` are forwarded to `createOverride` to seed the per-session
18
+ * host-override; omitting them seeds from the `CHROME_WS_HOST` /
19
+ * `CHROME_WS_PORT` env vars (see host-override.js).
20
+ */
21
+ function createState({ host, port } = {}) {
22
+ const hostOverride = createOverride({ host, port });
23
+
24
+ // CHROME_WS_PROFILE is the way to opt into a stable named profile from
25
+ // outside this process — typically used to share a Chrome instance across
26
+ // MCP restarts or between cooperating tools. When it's set, we treat the
27
+ // profile as explicit and skip auto-disambiguation in chrome-process.js.
28
+ const envProfile = process.env.CHROME_WS_PROFILE;
29
+ const profileFromEnv = envProfile && /^[a-zA-Z0-9_-]+$/.test(envProfile)
30
+ ? envProfile
31
+ : null;
32
+
33
+ return {
34
+ hostOverride,
35
+ rewriteWsUrl: hostOverride.rewriteWsUrl,
36
+
37
+ // Dynamic port: updated by startChrome() when Chrome launches or reconnects.
38
+ activePort: hostOverride.getPort(),
39
+
40
+ // Per-tab buffer of console messages for auto-capture.
41
+ consoleMessages: new Map(),
42
+
43
+ // Auto-capture session: lazily initialised on first capture.
44
+ sessionDir: null,
45
+ captureCounter: 0,
46
+
47
+ // Chrome process management.
48
+ chromeProcess: null,
49
+ chromeHeadless: true,
50
+ chromeUserDataDir: null,
51
+ chromeProfileName: profileFromEnv || 'moe-glass',
52
+ // True when the profile name came from env/set_profile rather than the
53
+ // default. chrome-process.js uses this to decide whether to auto-pick an
54
+ // unused alternate name on startup.
55
+ _profileExplicit: profileFromEnv !== null,
56
+
57
+ // Bridge primitives: the session's BrowserBridge instance and active BrowserSession.
58
+ browserBridge: null,
59
+ browserSession: null,
60
+
61
+ // Sticky tab state: updated by switch_tab, new_tab, close_tab.
62
+ activeTab: 0,
63
+ };
64
+ }
65
+
66
+ module.exports = { createState };
@@ -0,0 +1,144 @@
1
+ const { chromeHttpAt } = require('./chrome-launcher-helpers');
2
+
3
+ /**
4
+ * Tab management plus the two transport helpers it depends on:
5
+ *
6
+ * - `chromeHttp` — the per-session HTTP client, bound to
7
+ * `state.activePort` and the session's host-override.
8
+ * - `resolveWsUrl` — accept a tab index, a numeric string, or a `ws://`
9
+ * URL and return a usable WebSocket URL. Auto-creates a tab if none
10
+ * exist (mirrors the auto-start-Chrome behaviour).
11
+ * - `getTabs` / `newTab` / `closeTab` — list, open, close. List/open
12
+ * rewrite the returned `webSocketDebuggerUrl` through the session's
13
+ * host-override so the URL can actually be connected to from the
14
+ * calling process even when the host-override remaps host/port.
15
+ *
16
+ * All three helpers feed every other attach* in the library, so this
17
+ * module is the foundation the rest sits on.
18
+ *
19
+ * `attachTabs({ state })` returns the bound API. The session state bag
20
+ * carries the host-override (for `getHost` and `rewriteWsUrl`) and the
21
+ * mutable `activePort`, which is everything the transport helpers need.
22
+ */
23
+ function attachTabs({ state, _chromeHttp }) {
24
+ const CHROME_DEBUG_HOST = state.hostOverride.getHost();
25
+ const { rewriteWsUrl } = state;
26
+
27
+ // HTTP request to Chrome's DevTools endpoint on the session's active port.
28
+ // _chromeHttp may be injected for testing; state.chromeHttp is also accepted.
29
+ async function chromeHttp(httpPath, method = 'GET') {
30
+ if (_chromeHttp) return _chromeHttp(httpPath, method);
31
+ return chromeHttpAt(CHROME_DEBUG_HOST, state.activePort, httpPath, method);
32
+ }
33
+
34
+ async function resolveWsUrl(wsUrlOrIndex) {
35
+ if (typeof wsUrlOrIndex === 'string' && wsUrlOrIndex.startsWith('ws://')) {
36
+ return rewriteWsUrl(wsUrlOrIndex, CHROME_DEBUG_HOST, state.activePort);
37
+ }
38
+
39
+ const index = typeof wsUrlOrIndex === 'number' ? wsUrlOrIndex : parseInt(wsUrlOrIndex);
40
+ if (!isNaN(index)) {
41
+ const tabs = await chromeHttp('/json');
42
+ if (!Array.isArray(tabs)) {
43
+ throw new Error('Chrome DevTools returned an invalid response — is Chrome running?');
44
+ }
45
+ const pageTabs = tabs.filter(t => t.type === 'page');
46
+
47
+ // Auto-create tab if none exist (matches the auto-start-Chrome behaviour
48
+ // — callers shouldn't have to special-case "fresh Chrome with no tabs").
49
+ if (pageTabs.length === 0) {
50
+ const newTabInfo = await newTab();
51
+ return newTabInfo.webSocketDebuggerUrl;
52
+ }
53
+
54
+ if (index < 0 || index >= pageTabs.length) {
55
+ throw new Error(`Tab index ${index} out of range (0-${pageTabs.length - 1})`);
56
+ }
57
+ return pageTabs[index].webSocketDebuggerUrl;
58
+ }
59
+
60
+ throw new Error(`Invalid tab specifier: ${wsUrlOrIndex}`);
61
+ }
62
+
63
+ async function getTabs() {
64
+ const tabs = await chromeHttp('/json');
65
+ if (!Array.isArray(tabs)) {
66
+ return [];
67
+ }
68
+ return tabs
69
+ .filter(tab => tab.type === 'page')
70
+ .map(tab => ({
71
+ ...tab,
72
+ webSocketDebuggerUrl: rewriteWsUrl(tab.webSocketDebuggerUrl, CHROME_DEBUG_HOST, state.activePort)
73
+ }));
74
+ }
75
+
76
+ async function newTab(url = 'about:blank') {
77
+ const encoded = encodeURIComponent(url);
78
+ const tab = await chromeHttp(`/json/new?${encoded}`, 'PUT');
79
+ if (tab && typeof tab === 'object') {
80
+ tab.webSocketDebuggerUrl = rewriteWsUrl(tab.webSocketDebuggerUrl, CHROME_DEBUG_HOST, state.activePort);
81
+ }
82
+ return tab;
83
+ }
84
+
85
+ async function closeTab(tabIndexOrWsUrl) {
86
+ const wsUrl = await resolveWsUrl(tabIndexOrWsUrl);
87
+ const tabs = await chromeHttp('/json');
88
+ if (!Array.isArray(tabs)) return;
89
+ const tab = tabs.find(t => t.webSocketDebuggerUrl === wsUrl);
90
+ if (tab) {
91
+ // Release the cached page-session before Chrome tears down the target.
92
+ await state.pageSessionResolver?.release(tab.id);
93
+ await chromeHttp(`/json/close/${tab.id}`, 'GET');
94
+ }
95
+ }
96
+
97
+ return { chromeHttp, resolveWsUrl, getTabs, newTab, closeTab };
98
+ }
99
+
100
+ /**
101
+ * createPageSessionResolver({bridge}) — returns a resolver that caches one
102
+ * pageSession per tab.id. The cache is keyed by tab.id (which is the CDP
103
+ * targetId in our model).
104
+ *
105
+ * Usage:
106
+ * const getPageSession = createPageSessionResolver({ bridge });
107
+ * const ps = await getPageSession(tab); // attaches once
108
+ * await getPageSession(tab); // returns the cached session
109
+ * await getPageSession.release(tab.id); // detaches + removes cache
110
+ */
111
+ function createPageSessionResolver({ bridge }) {
112
+ const cache = new Map();
113
+ async function resolve(tab) {
114
+ if (!tab || !tab.id) throw new Error('createPageSessionResolver: tab.id is required');
115
+ const cached = cache.get(tab.id);
116
+ if (cached) return cached;
117
+ const ps = await bridge.attachPageSession(tab.id);
118
+ cache.set(tab.id, ps);
119
+ return ps;
120
+ }
121
+ resolve.release = async (tabId) => {
122
+ const ps = cache.get(tabId);
123
+ if (!ps) return;
124
+ cache.delete(tabId);
125
+ try { await ps.detach(); } catch { /* best-effort */ }
126
+ };
127
+ // Prime the cache with an already-attached pageSession (from autoAttach).
128
+ // Subsequent resolve(tab) calls for this targetId return the primed session
129
+ // instead of issuing a second Target.attachToTarget. No-op if already cached.
130
+ resolve.prime = (targetId, ps) => {
131
+ if (!cache.has(targetId)) cache.set(targetId, ps);
132
+ };
133
+ // Synchronous cache peek — returns the cached pageSession for a targetId, or
134
+ // null if not yet resolved. Used by wrapWithDialogGate to check dialog state
135
+ // without triggering I/O.
136
+ resolve.peek = (tabId) => cache.get(tabId) || null;
137
+ // Bulk-clear the cache without calling detach. Use when the underlying WebSocket
138
+ // is already dead (e.g. Chrome was killed externally) so detach calls would fail.
139
+ // Callers that want graceful detach should call resolve.release() per-tab first.
140
+ resolve.releaseAll = () => { cache.clear(); };
141
+ return resolve;
142
+ }
143
+
144
+ module.exports = { attachTabs, createPageSessionResolver };
@@ -0,0 +1,103 @@
1
+ // Pixel 7 UA string used for mobile emulation. Matches what Chrome's own
2
+ // device-mode dropdown sends for the same device.
3
+ const MOBILE_USER_AGENT = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
4
+
5
+ /**
6
+ * Viewport / device emulation — set, clear, and read the CDP
7
+ * Emulation.setDeviceMetricsOverride state. Mobile emulation toggles touch
8
+ * input and a Pixel-7-class user-agent string in lockstep with the metrics.
9
+ *
10
+ * `attachViewport({ getPageSession })` returns the bound actions.
11
+ * `getPageSession(tabIndexOrWsUrl)` resolves to a page-session object
12
+ * with a `send(method, params)` interface.
13
+ */
14
+ function attachViewport({ getPageSession }) {
15
+ /**
16
+ * Set device viewport / emulation parameters (CDP: Emulation.setDeviceMetricsOverride).
17
+ *
18
+ * @param {number|string} tabIndexOrWsUrl - Tab index or WebSocket URL
19
+ * @param {Object} params
20
+ * @param {number} [params.width=1200] - CSS pixels (320–7680)
21
+ * @param {number} [params.height=800] - CSS pixels (200–4320)
22
+ * @param {number} [params.deviceScaleFactor=1] - DPI multiplier (0.25–5)
23
+ * @param {boolean} [params.mobile=false] - Touch + mobile UA when true
24
+ */
25
+ async function setViewport(tabIndexOrWsUrl, params) {
26
+ if (!params || typeof params !== 'object') {
27
+ throw new Error('setViewport requires a params object');
28
+ }
29
+
30
+ const ps = await getPageSession(tabIndexOrWsUrl);
31
+
32
+ const viewportParams = {
33
+ width: params.width ?? 1200,
34
+ height: params.height ?? 800,
35
+ deviceScaleFactor: params.deviceScaleFactor !== undefined ? params.deviceScaleFactor : 1,
36
+ mobile: params.mobile === true
37
+ };
38
+
39
+ if (viewportParams.width < 320 || viewportParams.width > 7680) {
40
+ throw new Error(`Invalid viewport width ${viewportParams.width} (must be 320-7680)`);
41
+ }
42
+ if (viewportParams.height < 200 || viewportParams.height > 4320) {
43
+ throw new Error(`Invalid viewport height ${viewportParams.height} (must be 200-4320)`);
44
+ }
45
+ if (viewportParams.deviceScaleFactor < 0.25 || viewportParams.deviceScaleFactor > 5) {
46
+ throw new Error(`Invalid deviceScaleFactor ${viewportParams.deviceScaleFactor} (must be 0.25-5)`);
47
+ }
48
+
49
+ await ps.send('Emulation.setDeviceMetricsOverride', viewportParams);
50
+
51
+ if (viewportParams.mobile) {
52
+ await ps.send('Emulation.setTouchEmulationEnabled', { enabled: true });
53
+ await ps.send('Emulation.setUserAgentOverride', { userAgent: MOBILE_USER_AGENT });
54
+ } else {
55
+ await ps.send('Emulation.setTouchEmulationEnabled', { enabled: false });
56
+ // Empty UA string resets to browser default (CDP convention)
57
+ await ps.send('Emulation.setUserAgentOverride', { userAgent: '' });
58
+ }
59
+
60
+ return { ...viewportParams, touch: viewportParams.mobile };
61
+ }
62
+
63
+ /**
64
+ * Clear viewport emulation (reset to browser default). Clears device
65
+ * metrics, touch emulation, and UA override.
66
+ */
67
+ async function clearViewport(tabIndexOrWsUrl) {
68
+ const ps = await getPageSession(tabIndexOrWsUrl);
69
+ await ps.send('Emulation.clearDeviceMetricsOverride', {});
70
+ await ps.send('Emulation.setTouchEmulationEnabled', { enabled: false });
71
+ await ps.send('Emulation.setUserAgentOverride', { userAgent: '' });
72
+ }
73
+
74
+ /**
75
+ * Get current viewport dimensions from the browser.
76
+ * Returns { innerWidth, innerHeight, outerWidth, outerHeight,
77
+ * devicePixelRatio, orientation }.
78
+ */
79
+ async function getViewport(tabIndexOrWsUrl) {
80
+ const ps = await getPageSession(tabIndexOrWsUrl);
81
+
82
+ const result = await ps.send('Runtime.evaluate', {
83
+ expression: `({
84
+ innerWidth: window.innerWidth,
85
+ innerHeight: window.innerHeight,
86
+ outerWidth: window.outerWidth,
87
+ outerHeight: window.outerHeight,
88
+ devicePixelRatio: window.devicePixelRatio,
89
+ orientation: screen.orientation ? screen.orientation.type : 'unknown'
90
+ })`,
91
+ returnByValue: true
92
+ });
93
+
94
+ if (result.exceptionDetails) {
95
+ throw new Error(`getViewport failed: ${result.exceptionDetails.text}`);
96
+ }
97
+ return result.result?.value || {};
98
+ }
99
+
100
+ return { setViewport, clearViewport, getViewport };
101
+ }
102
+
103
+ module.exports = { attachViewport };
@@ -0,0 +1,162 @@
1
+ const http = require('http');
2
+ const crypto = require('crypto');
3
+
4
+ /**
5
+ * Minimal dependency-free WebSocket client used for CDP transport.
6
+ *
7
+ * We don't pull in `ws` or any other npm package: the MCP server is shipped
8
+ * as a single bundled file and the browsing skill is a plain script, so a
9
+ * zero-dependency Node-only implementation keeps both distribution paths
10
+ * trivial. Only the slice of RFC 6455 we actually need is implemented:
11
+ * client-side handshake, masked text frames out, unmasked text frames in
12
+ * (with 7/16/64-bit length fields), and best-effort close.
13
+ *
14
+ * Event interface mirrors the `ws` package: `on('open'|'message'|'error'|
15
+ * 'close', cb)`. `connect()` returns a Promise that resolves once the
16
+ * upgrade completes.
17
+ */
18
+ class WebSocketClient {
19
+ constructor(url) {
20
+ this.url = new URL(url);
21
+ this.callbacks = {};
22
+ this.socket = null;
23
+ this.buffer = Buffer.alloc(0);
24
+ this.connected = false;
25
+ }
26
+
27
+ on(event, callback) {
28
+ this.callbacks[event] = callback;
29
+ }
30
+
31
+ isConnected() {
32
+ return this.connected && this.socket !== null;
33
+ }
34
+
35
+ connect() {
36
+ return new Promise((resolve, reject) => {
37
+ const key = crypto.randomBytes(16).toString('base64');
38
+
39
+ const options = {
40
+ hostname: this.url.hostname,
41
+ port: this.url.port || 80,
42
+ path: this.url.pathname + this.url.search,
43
+ headers: {
44
+ 'Upgrade': 'websocket',
45
+ 'Connection': 'Upgrade',
46
+ 'Sec-WebSocket-Key': key,
47
+ 'Sec-WebSocket-Version': '13'
48
+ }
49
+ };
50
+
51
+ const req = http.request(options);
52
+
53
+ req.on('upgrade', (_res, socket) => {
54
+ this.socket = socket;
55
+ this.connected = true;
56
+
57
+ socket.on('data', (data) => {
58
+ this.buffer = Buffer.concat([this.buffer, data]);
59
+ this.processFrames();
60
+ });
61
+
62
+ socket.on('error', (err) => {
63
+ this.connected = false;
64
+ if (this.callbacks.error) this.callbacks.error(err);
65
+ });
66
+
67
+ socket.on('close', () => {
68
+ this.connected = false;
69
+ if (this.callbacks.close) this.callbacks.close();
70
+ });
71
+
72
+ if (this.callbacks.open) this.callbacks.open();
73
+ resolve();
74
+ });
75
+
76
+ req.on('error', reject);
77
+ req.end();
78
+ });
79
+ }
80
+
81
+ processFrames() {
82
+ while (this.buffer.length >= 2) {
83
+ const firstByte = this.buffer[0];
84
+ const secondByte = this.buffer[1];
85
+
86
+ const _fin = (firstByte & 0x80) !== 0;
87
+ const opcode = firstByte & 0x0F;
88
+ const _masked = (secondByte & 0x80) !== 0;
89
+ let payloadLen = secondByte & 0x7F;
90
+
91
+ let offset = 2;
92
+
93
+ if (payloadLen === 126) {
94
+ if (this.buffer.length < 4) return;
95
+ payloadLen = this.buffer.readUInt16BE(2);
96
+ offset = 4;
97
+ } else if (payloadLen === 127) {
98
+ if (this.buffer.length < 10) return;
99
+ payloadLen = Number(this.buffer.readBigUInt64BE(2));
100
+ offset = 10;
101
+ }
102
+
103
+ if (this.buffer.length < offset + payloadLen) return;
104
+
105
+ const payload = this.buffer.slice(offset, offset + payloadLen);
106
+ this.buffer = this.buffer.slice(offset + payloadLen);
107
+
108
+ if (opcode === 0x1 && this.callbacks.message) {
109
+ this.callbacks.message(payload.toString('utf8'));
110
+ }
111
+ }
112
+ }
113
+
114
+ send(data) {
115
+ if (!this.socket || !this.connected) {
116
+ throw new Error('WebSocket not connected');
117
+ }
118
+ const payload = Buffer.from(data, 'utf8');
119
+ const payloadLen = payload.length;
120
+
121
+ let frame;
122
+ let offset = 2;
123
+
124
+ if (payloadLen < 126) {
125
+ frame = Buffer.alloc(payloadLen + 6);
126
+ frame[1] = payloadLen | 0x80;
127
+ } else if (payloadLen < 65536) {
128
+ frame = Buffer.alloc(payloadLen + 8);
129
+ frame[1] = 126 | 0x80;
130
+ frame.writeUInt16BE(payloadLen, 2);
131
+ offset = 4;
132
+ } else {
133
+ frame = Buffer.alloc(payloadLen + 14);
134
+ frame[1] = 127 | 0x80;
135
+ frame.writeBigUInt64BE(BigInt(payloadLen), 2);
136
+ offset = 10;
137
+ }
138
+
139
+ frame[0] = 0x81; // FIN + text frame
140
+
141
+ const mask = Buffer.alloc(4);
142
+ crypto.randomFillSync(mask);
143
+ mask.copy(frame, offset);
144
+ offset += 4;
145
+
146
+ for (let i = 0; i < payloadLen; i++) {
147
+ frame[offset + i] = payload[i] ^ mask[i % 4];
148
+ }
149
+
150
+ this.socket.write(frame);
151
+ }
152
+
153
+ close() {
154
+ this.connected = false;
155
+ if (this.socket) {
156
+ this.socket.end();
157
+ this.socket = null;
158
+ }
159
+ }
160
+ }
161
+
162
+ module.exports = { WebSocketClient };
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "chrome-ws",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight Chrome DevTools Protocol client for direct browser control - zero dependencies",
5
+ "bin": {
6
+ "chrome-ws": "./chrome-ws"
7
+ },
8
+ "engines": {
9
+ "node": ">=16.0.0"
10
+ }
11
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ const assert = require('assert');
4
+ const path = require('path');
5
+
6
+ const modulePath = path.join(__dirname, 'chrome-ws-lib.js');
7
+
8
+ function withEnv(env, fn) {
9
+ const prev = {};
10
+ for (const key of Object.keys(env)) {
11
+ prev[key] = process.env[key];
12
+ if (env[key] === undefined) {
13
+ delete process.env[key];
14
+ } else {
15
+ process.env[key] = env[key];
16
+ }
17
+ }
18
+ delete require.cache[require.resolve(modulePath)];
19
+ const mod = require(modulePath).createSession();
20
+ try {
21
+ fn(mod);
22
+ } finally {
23
+ delete require.cache[require.resolve(modulePath)];
24
+ for (const key of Object.keys(env)) {
25
+ if (prev[key] === undefined) {
26
+ delete process.env[key];
27
+ } else {
28
+ process.env[key] = prev[key];
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ const BASE_OPTS = {
35
+ chosenPort: 9222,
36
+ chromeUserDataDir: '/tmp/test-profile',
37
+ chromeHeadless: false,
38
+ };
39
+
40
+ function run() {
41
+ withEnv({ CHROME_EXTRA_ARGS: undefined }, ({ buildChromeArgs }) => {
42
+ assert.strictEqual(typeof buildChromeArgs, 'function', 'buildChromeArgs must be exported');
43
+ const args = buildChromeArgs(BASE_OPTS);
44
+ assert.ok(args.includes('--remote-debugging-port=9222'), 'includes the port flag');
45
+ assert.ok(args.includes('--user-data-dir=/tmp/test-profile'), 'includes the user-data-dir flag');
46
+ assert.ok(args.includes('--metrics-recording-only'), 'includes an expected baseline flag');
47
+ assert.ok(buildChromeArgs({ ...BASE_OPTS, noSandbox: true }).includes('--no-sandbox'), 'noSandbox: true adds --no-sandbox');
48
+ assert.ok(!buildChromeArgs({ ...BASE_OPTS, noSandbox: false }).includes('--no-sandbox'), 'noSandbox: false keeps the sandbox');
49
+ assert.ok(!args.includes('--headless=new'), 'no headless flag when chromeHeadless is false');
50
+ });
51
+
52
+ withEnv({ CHROME_EXTRA_ARGS: undefined }, ({ buildChromeArgs }) => {
53
+ const args = buildChromeArgs({ ...BASE_OPTS, chromeHeadless: true });
54
+ assert.ok(args.includes('--headless=new'), 'headless flag present when chromeHeadless is true');
55
+ });
56
+
57
+ withEnv({ CHROME_EXTRA_ARGS: '--use-gl=angle --use-angle=swiftshader-webgl --enable-unsafe-swiftshader' }, ({ buildChromeArgs }) => {
58
+ const args = buildChromeArgs(BASE_OPTS);
59
+ assert.ok(args.includes('--use-gl=angle'), 'extra arg --use-gl=angle appended');
60
+ assert.ok(args.includes('--use-angle=swiftshader-webgl'), 'extra arg --use-angle=swiftshader-webgl appended');
61
+ assert.ok(args.includes('--enable-unsafe-swiftshader'), 'extra arg --enable-unsafe-swiftshader appended');
62
+ });
63
+
64
+ withEnv({ CHROME_EXTRA_ARGS: ' --flag-a --flag-b\t--flag-c\n' }, ({ buildChromeArgs }) => {
65
+ const args = buildChromeArgs(BASE_OPTS);
66
+ assert.ok(args.includes('--flag-a'), 'splits on multiple spaces');
67
+ assert.ok(args.includes('--flag-b'), 'splits on tab');
68
+ assert.ok(args.includes('--flag-c'), 'splits on newline');
69
+ assert.ok(!args.includes(''), 'no empty tokens from whitespace runs');
70
+ });
71
+
72
+ withEnv({ CHROME_EXTRA_ARGS: '' }, ({ buildChromeArgs }) => {
73
+ const baseline = buildChromeArgs.call(null, BASE_OPTS);
74
+ // An empty env var should not add any tokens
75
+ assert.strictEqual(baseline.filter(a => a === '').length, 0, 'empty env var does not add empty tokens');
76
+ });
77
+
78
+ console.log('All chrome args tests passed.');
79
+ }
80
+
81
+ run();
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Simple test for cookie functions
3
+ * Run with: node test-cookies.js
4
+ */
5
+
6
+ const chromeLib = require('./chrome-ws-lib.js').createSession();
7
+
8
+ async function testCookieFunctionsExist() {
9
+ console.log('Testing that cookie functions are exported...');
10
+
11
+ if (typeof chromeLib.clearCookies !== 'function') {
12
+ throw new Error('clearCookies not exported');
13
+ }
14
+
15
+ console.log('All cookie functions exported');
16
+ }
17
+
18
+ testCookieFunctionsExist().catch(err => {
19
+ console.error('Test failed:', err.message);
20
+ process.exit(1);
21
+ });