@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,154 @@
1
+ 'use strict';
2
+
3
+ function renderSyntheticArtifacts(s) {
4
+ const origin = s.payload.url || '(unknown)';
5
+ let markdown;
6
+
7
+ if (s.kind === 'alert') {
8
+ markdown = [
9
+ `# Dialog: alert`,
10
+ `Tab origin: ${origin}`,
11
+ ``,
12
+ `> ${s.payload.message}`,
13
+ ``,
14
+ `Buttons:`,
15
+ ` - dialog::accept (OK)`,
16
+ ``,
17
+ `To interact:`,
18
+ ` click selector="dialog::accept"`,
19
+ ].join('\n');
20
+ } else if (s.kind === 'confirm') {
21
+ markdown = [
22
+ `# Dialog: confirm`,
23
+ `Tab origin: ${origin}`,
24
+ ``,
25
+ `> ${s.payload.message}`,
26
+ ``,
27
+ `Buttons:`,
28
+ ` - dialog::accept (OK)`,
29
+ ` - dialog::dismiss (Cancel)`,
30
+ ``,
31
+ `To interact:`,
32
+ ` click selector="dialog::accept"`,
33
+ ` click selector="dialog::dismiss"`,
34
+ ].join('\n');
35
+ } else if (s.kind === 'prompt') {
36
+ const lines = [
37
+ `# Dialog: prompt`,
38
+ `Tab origin: ${origin}`,
39
+ ``,
40
+ `> ${s.payload.message}`,
41
+ ];
42
+ if (s.payload.defaultPrompt) lines.push(`Default: "${s.payload.defaultPrompt}"`);
43
+ lines.push(``, `Input: dialog::prompt (type text here, then click dialog::accept)`);
44
+ lines.push(`Buttons:`, ` - dialog::accept`, ` - dialog::dismiss`);
45
+ markdown = lines.join('\n');
46
+ } else if (s.kind === 'beforeunload') {
47
+ markdown = [
48
+ `# Dialog: beforeunload`,
49
+ `Tab origin: ${origin}`,
50
+ ``,
51
+ `> ${s.payload.message || 'The page wants to confirm you really want to leave.'}`,
52
+ ``,
53
+ `Buttons:`,
54
+ ` - dialog::accept (Leave)`,
55
+ ` - dialog::dismiss (Stay)`,
56
+ ``,
57
+ `To interact:`,
58
+ ` click selector="dialog::accept"`,
59
+ ` click selector="dialog::dismiss"`,
60
+ ].join('\n');
61
+ } else if (s.kind === 'device-chooser') {
62
+ const kindLabel = { usb: 'USB', bluetooth: 'Bluetooth', serial: 'Serial', hid: 'HID' }[s.payload.deviceKind] || s.payload.deviceKind;
63
+ const lines = [
64
+ `# Dialog: device-chooser (${s.payload.deviceKind})`,
65
+ `Origin requested a ${kindLabel} device.`,
66
+ ``,
67
+ ];
68
+ if (s.payload.devices.length === 0) {
69
+ lines.push(`(No devices visible.)`);
70
+ } else {
71
+ lines.push(`Devices:`);
72
+ for (const d of s.payload.devices) {
73
+ lines.push(` - dialog::device[id="${d.id}"] "${d.name}"`);
74
+ }
75
+ }
76
+ lines.push(``, `Buttons:`, ` - dialog::dismiss (Cancel)`);
77
+ markdown = lines.join('\n');
78
+ } else if (s.kind === 'permission') {
79
+ markdown = [
80
+ `# Dialog: permission`,
81
+ `Origin ${s.payload.origin} requested: ${s.payload.name}`,
82
+ `JS API: ${s.payload.jsApi}`,
83
+ ``,
84
+ `Buttons:`,
85
+ ` - dialog::accept (grant for this origin)`,
86
+ ` - dialog::dismiss (deny for this origin)`,
87
+ ].join('\n');
88
+ } else if (s.kind === 'basic-auth') {
89
+ const header = s.payload.realm
90
+ ? `Origin ${s.payload.origin} — realm "${s.payload.realm}"`
91
+ : `Origin ${s.payload.origin}`;
92
+ markdown = [
93
+ `# Dialog: basic-auth`,
94
+ header,
95
+ ``,
96
+ `Inputs:`,
97
+ ` dialog::username`,
98
+ ` dialog::password`,
99
+ ``,
100
+ `Buttons:`,
101
+ ` - dialog::accept`,
102
+ ` - dialog::dismiss`,
103
+ ].join('\n');
104
+ } else {
105
+ markdown = `# Dialog: ${s.kind}\n(unsupported in this render path)`;
106
+ }
107
+
108
+ const htmlParts = [
109
+ '<!doctype html>',
110
+ '<html><head><title>Dialog</title></head><body>',
111
+ `<h1>Dialog: ${s.kind}</h1>`,
112
+ ];
113
+ if (s.kind === 'prompt') {
114
+ htmlParts.push('<input id="dialog-prompt" type="text">');
115
+ }
116
+ if (s.kind === 'basic-auth') {
117
+ htmlParts.push('<input id="dialog-username" type="text">');
118
+ htmlParts.push('<input id="dialog-password" type="password">');
119
+ }
120
+ if (s.kind === 'device-chooser') {
121
+ for (const d of s.payload.devices) {
122
+ htmlParts.push(`<button data-device-id="${d.id}">${d.name}</button>`);
123
+ }
124
+ }
125
+ const acceptKinds = new Set(['alert', 'confirm', 'prompt', 'beforeunload', 'permission', 'basic-auth']);
126
+ const dismissKinds = new Set(['confirm', 'prompt', 'beforeunload', 'device-chooser', 'permission', 'basic-auth']);
127
+ if (acceptKinds.has(s.kind)) htmlParts.push('<button id="dialog-accept">Accept</button>');
128
+ if (dismissKinds.has(s.kind)) htmlParts.push('<button id="dialog-dismiss">Dismiss</button>');
129
+ htmlParts.push('</body></html>');
130
+ const html = htmlParts.join('\n');
131
+
132
+ return { markdown, html, consoleSnapshot: '' };
133
+ }
134
+
135
+ function renderResponseSummary(s, tabIndex) {
136
+ const lines = [];
137
+ lines.push(`Dialog open on tab ${tabIndex}: ${s.kind}`);
138
+ if (s.payload.message) lines.push(` Message: "${s.payload.message}"`);
139
+ if (s.kind === 'alert') {
140
+ lines.push(` Handle with: click dialog::accept`);
141
+ } else if (s.kind === 'device-chooser') {
142
+ lines.push(` Handle with: click dialog::device[id="..."] | click dialog::dismiss`);
143
+ } else if (s.kind === 'basic-auth') {
144
+ lines.push(` Handle with: type dialog::username, type dialog::password, click dialog::accept | click dialog::dismiss`);
145
+ } else if (s.kind === 'prompt') {
146
+ lines.push(` Handle with: type dialog::prompt, click dialog::accept | click dialog::dismiss`);
147
+ } else {
148
+ lines.push(` Handle with: click dialog::accept | click dialog::dismiss`);
149
+ }
150
+ lines.push(`(no screenshot — dialog overlay is browser-native UI)`);
151
+ return lines.join('\n');
152
+ }
153
+
154
+ module.exports = { renderSyntheticArtifacts, renderResponseSummary };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const JS_KINDS = new Set(['alert', 'confirm', 'prompt', 'beforeunload']);
4
+ const DEVICE_SELECTOR_RE = /^dialog::device\[id="([^"]+)"\]$/;
5
+
6
+ async function tryHandleDialogSelector({ selector, op, payload, state, sendCdpCommand, wsUrl }) {
7
+ if (!selector || !selector.startsWith('dialog::')) {
8
+ return { handled: false };
9
+ }
10
+ if (!state) {
11
+ return { handled: true, error: 'No dialog open on this tab.' };
12
+ }
13
+
14
+ if (selector === 'dialog::accept' && op === 'click') {
15
+ if (JS_KINDS.has(state.kind)) {
16
+ const params = { accept: true };
17
+ if (state.kind === 'prompt' && state.staged.promptText !== undefined) {
18
+ params.promptText = state.staged.promptText;
19
+ }
20
+ await sendCdpCommand(wsUrl, 'Page.handleJavaScriptDialog', params);
21
+ // Clear state.dialogs eagerly. Chrome SHOULD fire
22
+ // Page.javascriptDialogClosed which the dialogs.js event handler also
23
+ // uses to clear state — but in practice that event has been observed to
24
+ // arrive late, get routed to a session without Page.enable, or never
25
+ // fire on transient dialog states (scenario 03 step 6 saw the dialog
26
+ // state persist after a clean accept). Clearing here makes the API
27
+ // contract "accept returned success → state.dialogs has no entry for
28
+ // this session" true unconditionally; the Chrome event becomes a
29
+ // redundant best-effort sweep.
30
+ return { handled: true, clearDialog: true, result: { ok: true } };
31
+ }
32
+ }
33
+
34
+ if (selector === 'dialog::dismiss' && op === 'click') {
35
+ if (JS_KINDS.has(state.kind)) {
36
+ await sendCdpCommand(wsUrl, 'Page.handleJavaScriptDialog', { accept: false });
37
+ return { handled: true, clearDialog: true, result: { ok: true } };
38
+ }
39
+ }
40
+
41
+ if (op === 'type') {
42
+ if (selector === 'dialog::prompt' && state.kind === 'prompt') {
43
+ state.staged.promptText = String(payload ?? '');
44
+ return { handled: true, result: { staged: 'promptText' } };
45
+ }
46
+ if (selector === 'dialog::username' && state.kind === 'basic-auth') {
47
+ state.staged.username = String(payload ?? '');
48
+ return { handled: true, result: { staged: 'username' } };
49
+ }
50
+ if (selector === 'dialog::password' && state.kind === 'basic-auth') {
51
+ state.staged.password = String(payload ?? '');
52
+ return { handled: true, result: { staged: 'password' } };
53
+ }
54
+ }
55
+
56
+ if (op === 'click') {
57
+ const m = DEVICE_SELECTOR_RE.exec(selector);
58
+ if (m && state.kind === 'device-chooser') {
59
+ await sendCdpCommand(wsUrl, 'DeviceAccess.selectPrompt', {
60
+ id: state.payload.requestId,
61
+ deviceId: m[1],
62
+ });
63
+ return { handled: true, clearDialog: true, result: { ok: true } };
64
+ }
65
+ if (selector === 'dialog::dismiss' && state.kind === 'device-chooser') {
66
+ await sendCdpCommand(wsUrl, 'DeviceAccess.cancelPrompt', { id: state.payload.requestId });
67
+ return { handled: true, clearDialog: true, result: { ok: true } };
68
+ }
69
+ }
70
+
71
+ if (op === 'click' && state.kind === 'basic-auth') {
72
+ if (selector === 'dialog::accept') {
73
+ await sendCdpCommand(wsUrl, 'Fetch.continueWithAuth', {
74
+ requestId: state.payload.requestId,
75
+ authChallengeResponse: {
76
+ response: 'ProvideCredentials',
77
+ username: state.staged.username || '',
78
+ password: state.staged.password || '',
79
+ },
80
+ });
81
+ return { handled: true, clearDialog: true, result: { ok: true } };
82
+ }
83
+ if (selector === 'dialog::dismiss') {
84
+ await sendCdpCommand(wsUrl, 'Fetch.continueWithAuth', {
85
+ requestId: state.payload.requestId,
86
+ authChallengeResponse: { response: 'CancelAuth' },
87
+ });
88
+ return { handled: true, clearDialog: true, result: { ok: true } };
89
+ }
90
+ }
91
+
92
+ if (op === 'click' && state.kind === 'permission') {
93
+ const decision = selector === 'dialog::accept' ? 'grant' : (selector === 'dialog::dismiss' ? 'deny' : null);
94
+ if (decision) {
95
+ const id = state.staged._shimId;
96
+ await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
97
+ expression: `window.__dialogShim_resolve('${id}', '${decision}')`,
98
+ });
99
+ return { handled: true, clearDialog: true, result: { ok: true } };
100
+ }
101
+ }
102
+
103
+ const validSelectors = ['dialog::accept', 'dialog::dismiss', 'dialog::prompt', 'dialog::device[id="..."]', 'dialog::username', 'dialog::password'];
104
+ if (op !== 'click' && op !== 'type') {
105
+ return { handled: true, error: `Unsupported operation '${op}' on dialog selector. Only 'click' and 'type' are supported.` };
106
+ }
107
+ return { handled: true, error: `Unknown dialog selector: ${selector}. Valid: ${validSelectors.join(', ')}.` };
108
+ }
109
+
110
+ async function tryHandleDialogSelectorForSession({ selector, op, payload, state, pageSession }) {
111
+ // Adapt pageSession.send to the (sendCdpCommand, wsUrl) shape used by tryHandleDialogSelector.
112
+ // wsUrl is unused — the adapter ignores it and dispatches through pageSession.send.
113
+ const sendCdpCommand = async (_wsUrl, method, params) => pageSession.send(method, params);
114
+ return tryHandleDialogSelector({ selector, op, payload, state, sendCdpCommand, wsUrl: null });
115
+ }
116
+
117
+ module.exports = { tryHandleDialogSelector, tryHandleDialogSelectorForSession };
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ const { renderSyntheticArtifacts } = require('./dialogs-render.js');
4
+ const { SHIM_SOURCE } = require('./page-scripts/permission-shim.js');
5
+
6
+ /**
7
+ * Thrown by the session-boundary dialog gate (wrapWithDialogGate in
8
+ * chrome-ws-lib.js) when a page-target action is attempted while a native
9
+ * browser dialog is open. Callers that want to surface a human-readable
10
+ * refusal (e.g. the MCP layer) catch this and format it; callers that just
11
+ * want to propagate the error can let it bubble.
12
+ */
13
+ class DialogRefusedError extends Error {
14
+ constructor({ dialog, artifacts }) {
15
+ super('Page is behind a dialog. Handle dialog::accept or dialog::dismiss first.');
16
+ this.name = 'DialogRefusedError';
17
+ this.refused = true;
18
+ this.dialog = dialog;
19
+ this.artifacts = artifacts;
20
+ }
21
+ }
22
+
23
+ const PAGE_TARGET_ACTIONS = new Set([
24
+ 'navigate', 'click', 'type', 'extract', 'screenshot', 'eval', 'select', 'attr',
25
+ 'await_element', 'await_text', 'hover', 'drag_drop', 'mouse_move', 'scroll',
26
+ 'double_click', 'right_click', 'file_upload', 'keyboard_press',
27
+ 'set_viewport', 'clear_viewport', 'get_viewport',
28
+ ]);
29
+
30
+ const BROWSER_TARGET_ACTIONS = new Set([
31
+ 'list_tabs', 'new_tab', 'close_tab', 'show_browser', 'hide_browser',
32
+ 'browser_mode', 'set_profile', 'get_profile', 'help', 'clear_cookies',
33
+ ]);
34
+
35
+ function attachDialogs({ state }) {
36
+ if (!state.dialogs) state.dialogs = new Map();
37
+ // Maps CDP targetId → sessionId for bridge-path sessions. Allows getOpen(wsUrl)
38
+ // to find dialog state stored under sessionId by extracting targetId from the wsUrl.
39
+ if (!state._targetIdToSessionId) state._targetIdToSessionId = new Map();
40
+
41
+ function getOpen(wsUrlOrSid) {
42
+ // Direct lookup (works for sessionId keys).
43
+ const direct = state.dialogs.get(wsUrlOrSid);
44
+ if (direct) return direct;
45
+ // Fall back: extract targetId from a ws:// URL and look up the bridge sessionId.
46
+ // Used by callers (e.g. popup integration test, wrapWithDialogGate) that have a
47
+ // wsUrl rather than a sessionId. The _targetIdToSessionId map is populated by
48
+ // attachToPageSession.
49
+ const m = /\/devtools\/page\/([^/]+)$/.exec(wsUrlOrSid);
50
+ if (m) {
51
+ const sid = state._targetIdToSessionId.get(m[1]);
52
+ if (sid) return state.dialogs.get(sid) || null;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function clear(wsUrlOrSid) {
58
+ state.dialogs.delete(wsUrlOrSid);
59
+ // If called with a wsUrl: also clear the bridge-path sessionId entry.
60
+ const wsMatch = /\/devtools\/page\/([^/]+)$/.exec(wsUrlOrSid);
61
+ if (wsMatch) {
62
+ const sid = state._targetIdToSessionId.get(wsMatch[1]);
63
+ if (sid) state.dialogs.delete(sid);
64
+ }
65
+ }
66
+
67
+ // Track which page sessions have already had dialog setup applied.
68
+ if (!state._dialogPageSessions) state._dialogPageSessions = new Set();
69
+
70
+ async function attachToPageSession(pageSession) {
71
+ const sid = pageSession.sessionId;
72
+ if (state._dialogPageSessions.has(sid)) return;
73
+ state._dialogPageSessions.add(sid);
74
+ // Register targetId → sessionId so getOpen(wsUrl) can find dialog state
75
+ // stored under sessionId by extracting targetId from the wsUrl.
76
+ if (pageSession.targetId) {
77
+ state._targetIdToSessionId.set(pageSession.targetId, sid);
78
+ }
79
+ await pageSession.send('Page.enable', {});
80
+ await pageSession.send('DeviceAccess.enable', {});
81
+ await pageSession.send('Fetch.enable', {
82
+ handleAuthRequests: true,
83
+ patterns: [{ urlPattern: '*' }],
84
+ });
85
+ await pageSession.send('Runtime.enable', {});
86
+ await pageSession.send('Page.addScriptToEvaluateOnNewDocument', { source: SHIM_SOURCE });
87
+ await pageSession.send('Runtime.addBinding', { name: '__dialogShim' });
88
+ pageSession.onEvent((msg) => handleCdpEventForSession(sid, msg, pageSession.send));
89
+ }
90
+
91
+ // Equivalent of handleCdpEvent but keyed by sessionId. This is a faithful port —
92
+ // every state.dialogs.get(wsUrl)/set(wsUrl, ...) becomes get(sid)/set(sid, ...).
93
+ // sendPageCmd is pageSession.send — needed to continue intercepted Fetch requests.
94
+ function handleCdpEventForSession(sid, msg, sendPageCmd) {
95
+ if (msg.method === 'Runtime.bindingCalled') {
96
+ if (msg.params.name !== '__dialogShim') return;
97
+ let data;
98
+ try { data = JSON.parse(msg.params.payload); } catch { return; }
99
+ if (data.type === 'permission-request') {
100
+ if (state.dialogs.has(sid)) {
101
+ console.error(`[dialogs] permission request while dialog open on ${sid}; preserving original`);
102
+ return;
103
+ }
104
+ state.dialogs.set(sid, {
105
+ kind: 'permission',
106
+ openedAt: Date.now(),
107
+ payload: { name: data.name, origin: data.origin, jsApi: data.jsApi },
108
+ staged: { _shimId: data.id },
109
+ });
110
+ }
111
+ return;
112
+ }
113
+ if (msg.method === 'Page.javascriptDialogOpening') {
114
+ if (state.dialogs.has(sid)) {
115
+ console.error(`[dialogs] second javascriptDialogOpening on ${sid}; preserving original`);
116
+ return;
117
+ }
118
+ const p = msg.params;
119
+ state.dialogs.set(sid, {
120
+ kind: p.type,
121
+ openedAt: Date.now(),
122
+ payload: {
123
+ message: p.message, defaultPrompt: p.defaultPrompt, url: p.url, hasBrowserHandler: p.hasBrowserHandler,
124
+ },
125
+ staged: {},
126
+ });
127
+ return;
128
+ }
129
+ if (msg.method === 'DeviceAccess.deviceRequestPrompted') {
130
+ if (state.dialogs.has(sid)) {
131
+ console.error(`[dialogs] second prompt on ${sid}; preserving original`);
132
+ return;
133
+ }
134
+ state.dialogs.set(sid, {
135
+ kind: 'device-chooser',
136
+ openedAt: Date.now(),
137
+ payload: {
138
+ requestId: msg.params.id,
139
+ deviceKind: msg.params.deviceKind || 'usb',
140
+ devices: msg.params.devices || [],
141
+ },
142
+ staged: {},
143
+ });
144
+ return;
145
+ }
146
+ if (msg.method === 'Page.javascriptDialogClosed') {
147
+ state.dialogs.delete(sid);
148
+ return;
149
+ }
150
+ if (msg.method === 'Page.frameNavigated') {
151
+ if (msg.params.frame && !msg.params.frame.parentId) {
152
+ state.dialogs.delete(sid);
153
+ }
154
+ return;
155
+ }
156
+ if (msg.method === 'Fetch.requestPaused') {
157
+ const p = msg.params;
158
+ // For non-auth Fetch.requestPaused, continue the request immediately.
159
+ // Auth challenges arrive as Fetch.authRequired (see handler below), not
160
+ // as Fetch.requestPaused with authChallenge — that field is never set by
161
+ // Chrome when handleAuthRequests:true is enabled.
162
+ if (sendPageCmd) {
163
+ sendPageCmd('Fetch.continueRequest', { requestId: p.requestId }).catch(() => {});
164
+ }
165
+ return;
166
+ }
167
+ if (msg.method === 'Fetch.authRequired') {
168
+ const p = msg.params;
169
+ if (state.dialogs.has(sid)) {
170
+ console.error(`[dialogs] auth challenge while dialog open on ${sid}; preserving original`);
171
+ return;
172
+ }
173
+ state.dialogs.set(sid, {
174
+ kind: 'basic-auth',
175
+ openedAt: Date.now(),
176
+ payload: {
177
+ requestId: p.requestId,
178
+ origin: p.authChallenge.origin,
179
+ scheme: p.authChallenge.scheme,
180
+ realm: p.authChallenge.realm || '',
181
+ },
182
+ staged: {},
183
+ });
184
+ return;
185
+ }
186
+ }
187
+
188
+ async function withDialogAwareness(actionName, wsUrl, args, fn) {
189
+ const open = getOpen(wsUrl);
190
+ const isDialogSelector = typeof args?.selector === 'string' && args.selector.startsWith('dialog::');
191
+
192
+ if (open && PAGE_TARGET_ACTIONS.has(actionName) && !isDialogSelector) {
193
+ return {
194
+ refused: true,
195
+ error: 'Page is behind a dialog. Handle dialog::accept or dialog::dismiss first.',
196
+ dialog: open,
197
+ artifacts: renderSyntheticArtifacts(open),
198
+ };
199
+ }
200
+
201
+ if (!open && PAGE_TARGET_ACTIONS.has(actionName)) {
202
+ const before = state.dialogs.has(wsUrl);
203
+ const actionResult = await fn();
204
+ const afterOpen = getOpen(wsUrl);
205
+ if (!before && afterOpen) {
206
+ return {
207
+ midFlight: true,
208
+ actionResult,
209
+ dialog: afterOpen,
210
+ artifacts: renderSyntheticArtifacts(afterOpen),
211
+ };
212
+ }
213
+ return actionResult;
214
+ }
215
+
216
+ return fn();
217
+ }
218
+
219
+ async function withDialogAwarenessForSession(actionName, pageSession, args, fn) {
220
+ const sid = pageSession && pageSession.sessionId;
221
+ const open = sid ? state.dialogs.get(sid) : null;
222
+ const isDialogSelector = typeof args?.selector === 'string' && args.selector.startsWith('dialog::');
223
+
224
+ if (open && PAGE_TARGET_ACTIONS.has(actionName) && !isDialogSelector) {
225
+ return {
226
+ refused: true,
227
+ error: 'Page is behind a dialog. Handle dialog::accept or dialog::dismiss first.',
228
+ dialog: open,
229
+ artifacts: renderSyntheticArtifacts(open),
230
+ };
231
+ }
232
+
233
+ if (!open && PAGE_TARGET_ACTIONS.has(actionName)) {
234
+ const before = sid ? state.dialogs.has(sid) : false;
235
+ const actionResult = await fn();
236
+ const afterOpen = sid ? state.dialogs.get(sid) : null;
237
+ if (!before && afterOpen) {
238
+ return {
239
+ midFlight: true,
240
+ actionResult,
241
+ dialog: afterOpen,
242
+ artifacts: renderSyntheticArtifacts(afterOpen),
243
+ };
244
+ }
245
+ return actionResult;
246
+ }
247
+
248
+ return fn();
249
+ }
250
+
251
+ return { getOpen, clear, attachToPageSession, withDialogAwareness, withDialogAwarenessForSession };
252
+ }
253
+
254
+ module.exports = { attachDialogs, PAGE_TARGET_ACTIONS, BROWSER_TARGET_ACTIONS, DialogRefusedError };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Element selector helpers — pure functions that produce JavaScript source
3
+ * strings to be evaluated in the page via CDP Runtime.evaluate.
4
+ *
5
+ * No closure state, no Chrome session dependency. The returned strings are
6
+ * interpolated into larger CDP `expression` payloads.
7
+ */
8
+
9
+ // Generate element selection code (supports CSS and XPath).
10
+ // Prefers visible elements (non-zero bounding rect) over hidden ones.
11
+ // Falls back to first DOM match with a console.warn if all matches are hidden.
12
+ // For XPath with text()='...', also tries normalize-space() fallback for mixed content elements.
13
+ function getElementSelector(selector) {
14
+ if (selector.startsWith('/') || selector.startsWith('//')) {
15
+ // XPath selector - collect all matches, prefer visible
16
+ const hasTextEquals = /text\(\)\s*=\s*['"]/.test(selector);
17
+ const xpaths = [JSON.stringify(selector)];
18
+ if (hasTextEquals) {
19
+ const fallbackSelector = selector.replace(/text\(\)\s*=\s*(['"])(.*?)\1/g, "normalize-space()=$1$2$1");
20
+ xpaths.push(JSON.stringify(fallbackSelector));
21
+ }
22
+ return `(() => {
23
+ var all = [];
24
+ var seen = new Set();
25
+ [${xpaths.join(', ')}].forEach(function(xpath) {
26
+ var iter = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
27
+ var node;
28
+ while (node = iter.iterateNext()) {
29
+ if (!seen.has(node)) { seen.add(node); all.push(node); }
30
+ }
31
+ });
32
+ if (all.length === 0) return null;
33
+ var visible = all.find(function(el) {
34
+ var r = el.getBoundingClientRect();
35
+ return r.width > 0 && r.height > 0;
36
+ });
37
+ if (visible) return visible;
38
+ console.warn('[moe-glass] All ' + all.length + ' elements matching XPath have zero dimensions; using first match');
39
+ return all[0];
40
+ })()`;
41
+ } else {
42
+ // CSS selector - prefer visible elements
43
+ return `(() => {
44
+ var all = document.querySelectorAll(${JSON.stringify(selector)});
45
+ if (all.length === 0) return null;
46
+ var visible = Array.from(all).find(function(el) {
47
+ var r = el.getBoundingClientRect();
48
+ return r.width > 0 && r.height > 0;
49
+ });
50
+ if (visible) return visible;
51
+ console.warn('[moe-glass] All ' + all.length + ' elements matching ' + ${JSON.stringify(JSON.stringify(selector))} + ' have zero dimensions; using first match');
52
+ return all[0];
53
+ })()`;
54
+ }
55
+ }
56
+
57
+ // Get all matching elements (used by multi-element warnings).
58
+ // For XPath with text()='...', also tries normalize-space() fallback for mixed content elements.
59
+ function getElementSelectorAll(selector) {
60
+ if (selector.startsWith('/') || selector.startsWith('//')) {
61
+ // XPath - get all matches, with fallback for text()='...' patterns
62
+ const hasTextEquals = /text\(\)\s*=\s*['"]/.test(selector);
63
+ if (hasTextEquals) {
64
+ const fallbackSelector = selector.replace(/text\(\)\s*=\s*(['"])(.*?)\1/g, "normalize-space()=$1$2$1");
65
+ return `(() => {
66
+ const result = [];
67
+ const seen = new Set();
68
+ for (const xpath of [${JSON.stringify(selector)}, ${JSON.stringify(fallbackSelector)}]) {
69
+ const iterator = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
70
+ let node;
71
+ while (node = iterator.iterateNext()) {
72
+ if (!seen.has(node)) { seen.add(node); result.push(node); }
73
+ }
74
+ }
75
+ return result;
76
+ })()`;
77
+ }
78
+ return `(() => {
79
+ const result = [];
80
+ const iterator = document.evaluate(${JSON.stringify(selector)}, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
81
+ let node;
82
+ while (node = iterator.iterateNext()) result.push(node);
83
+ return result;
84
+ })()`;
85
+ } else {
86
+ // CSS selector
87
+ return `Array.from(document.querySelectorAll(${JSON.stringify(selector)}))`;
88
+ }
89
+ }
90
+
91
+ module.exports = { getElementSelector, getElementSelectorAll };