@indykish/oracle 0.9.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 (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/assets-oracle-icon.png +0 -0
  4. package/dist/bin/oracle-cli.js +1252 -0
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/scripts/agent-send.js +147 -0
  7. package/dist/scripts/browser-tools.js +536 -0
  8. package/dist/scripts/check.js +21 -0
  9. package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
  10. package/dist/scripts/docs-list.js +110 -0
  11. package/dist/scripts/git-policy.js +125 -0
  12. package/dist/scripts/run-cli.js +14 -0
  13. package/dist/scripts/runner.js +1378 -0
  14. package/dist/scripts/test-browser.js +103 -0
  15. package/dist/scripts/test-remote-chrome.js +68 -0
  16. package/dist/src/bridge/connection.js +103 -0
  17. package/dist/src/bridge/userConfigFile.js +28 -0
  18. package/dist/src/browser/actions/assistantResponse.js +1067 -0
  19. package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
  20. package/dist/src/browser/actions/attachments.js +1910 -0
  21. package/dist/src/browser/actions/domEvents.js +19 -0
  22. package/dist/src/browser/actions/modelSelection.js +485 -0
  23. package/dist/src/browser/actions/navigation.js +445 -0
  24. package/dist/src/browser/actions/promptComposer.js +485 -0
  25. package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
  26. package/dist/src/browser/actions/thinkingTime.js +206 -0
  27. package/dist/src/browser/chromeLifecycle.js +344 -0
  28. package/dist/src/browser/config.js +103 -0
  29. package/dist/src/browser/constants.js +71 -0
  30. package/dist/src/browser/cookies.js +191 -0
  31. package/dist/src/browser/detect.js +164 -0
  32. package/dist/src/browser/domDebug.js +36 -0
  33. package/dist/src/browser/index.js +1741 -0
  34. package/dist/src/browser/modelStrategy.js +13 -0
  35. package/dist/src/browser/pageActions.js +5 -0
  36. package/dist/src/browser/policies.js +43 -0
  37. package/dist/src/browser/profileState.js +280 -0
  38. package/dist/src/browser/prompt.js +152 -0
  39. package/dist/src/browser/promptSummary.js +20 -0
  40. package/dist/src/browser/reattach.js +186 -0
  41. package/dist/src/browser/reattachHelpers.js +382 -0
  42. package/dist/src/browser/sessionRunner.js +119 -0
  43. package/dist/src/browser/types.js +1 -0
  44. package/dist/src/browser/utils.js +122 -0
  45. package/dist/src/browserMode.js +1 -0
  46. package/dist/src/cli/bridge/claudeConfig.js +54 -0
  47. package/dist/src/cli/bridge/client.js +73 -0
  48. package/dist/src/cli/bridge/codexConfig.js +43 -0
  49. package/dist/src/cli/bridge/doctor.js +107 -0
  50. package/dist/src/cli/bridge/host.js +259 -0
  51. package/dist/src/cli/browserConfig.js +278 -0
  52. package/dist/src/cli/browserDefaults.js +81 -0
  53. package/dist/src/cli/bundleWarnings.js +9 -0
  54. package/dist/src/cli/clipboard.js +10 -0
  55. package/dist/src/cli/detach.js +11 -0
  56. package/dist/src/cli/dryRun.js +105 -0
  57. package/dist/src/cli/duplicatePromptGuard.js +14 -0
  58. package/dist/src/cli/engine.js +41 -0
  59. package/dist/src/cli/errorUtils.js +9 -0
  60. package/dist/src/cli/format.js +13 -0
  61. package/dist/src/cli/help.js +77 -0
  62. package/dist/src/cli/hiddenAliases.js +22 -0
  63. package/dist/src/cli/markdownBundle.js +17 -0
  64. package/dist/src/cli/markdownRenderer.js +97 -0
  65. package/dist/src/cli/notifier.js +306 -0
  66. package/dist/src/cli/options.js +281 -0
  67. package/dist/src/cli/oscUtils.js +2 -0
  68. package/dist/src/cli/promptRequirement.js +17 -0
  69. package/dist/src/cli/renderFlags.js +9 -0
  70. package/dist/src/cli/renderOutput.js +26 -0
  71. package/dist/src/cli/rootAlias.js +30 -0
  72. package/dist/src/cli/runOptions.js +78 -0
  73. package/dist/src/cli/sessionCommand.js +111 -0
  74. package/dist/src/cli/sessionDisplay.js +567 -0
  75. package/dist/src/cli/sessionRunner.js +602 -0
  76. package/dist/src/cli/sessionTable.js +92 -0
  77. package/dist/src/cli/tagline.js +258 -0
  78. package/dist/src/cli/tui/index.js +486 -0
  79. package/dist/src/cli/writeOutputPath.js +21 -0
  80. package/dist/src/config.js +26 -0
  81. package/dist/src/gemini-web/client.js +328 -0
  82. package/dist/src/gemini-web/executor.js +285 -0
  83. package/dist/src/gemini-web/index.js +1 -0
  84. package/dist/src/gemini-web/types.js +1 -0
  85. package/dist/src/heartbeat.js +43 -0
  86. package/dist/src/mcp/server.js +40 -0
  87. package/dist/src/mcp/tools/consult.js +290 -0
  88. package/dist/src/mcp/tools/sessionResources.js +75 -0
  89. package/dist/src/mcp/tools/sessions.js +105 -0
  90. package/dist/src/mcp/types.js +22 -0
  91. package/dist/src/mcp/utils.js +37 -0
  92. package/dist/src/oracle/background.js +141 -0
  93. package/dist/src/oracle/claude.js +101 -0
  94. package/dist/src/oracle/client.js +197 -0
  95. package/dist/src/oracle/config.js +227 -0
  96. package/dist/src/oracle/errors.js +132 -0
  97. package/dist/src/oracle/files.js +378 -0
  98. package/dist/src/oracle/finishLine.js +32 -0
  99. package/dist/src/oracle/format.js +30 -0
  100. package/dist/src/oracle/fsAdapter.js +10 -0
  101. package/dist/src/oracle/gemini.js +195 -0
  102. package/dist/src/oracle/logging.js +36 -0
  103. package/dist/src/oracle/markdown.js +46 -0
  104. package/dist/src/oracle/modelResolver.js +183 -0
  105. package/dist/src/oracle/multiModelRunner.js +153 -0
  106. package/dist/src/oracle/oscProgress.js +24 -0
  107. package/dist/src/oracle/promptAssembly.js +13 -0
  108. package/dist/src/oracle/request.js +50 -0
  109. package/dist/src/oracle/run.js +596 -0
  110. package/dist/src/oracle/runUtils.js +31 -0
  111. package/dist/src/oracle/tokenEstimate.js +37 -0
  112. package/dist/src/oracle/tokenStats.js +39 -0
  113. package/dist/src/oracle/tokenStringifier.js +24 -0
  114. package/dist/src/oracle/types.js +1 -0
  115. package/dist/src/oracle.js +12 -0
  116. package/dist/src/oracleHome.js +13 -0
  117. package/dist/src/remote/client.js +129 -0
  118. package/dist/src/remote/health.js +113 -0
  119. package/dist/src/remote/remoteServiceConfig.js +31 -0
  120. package/dist/src/remote/server.js +533 -0
  121. package/dist/src/remote/types.js +1 -0
  122. package/dist/src/sessionManager.js +637 -0
  123. package/dist/src/sessionStore.js +56 -0
  124. package/dist/src/version.js +39 -0
  125. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  126. package/dist/vendor/oracle-notifier/README.md +24 -0
  127. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  128. package/package.json +115 -0
  129. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  130. package/vendor/oracle-notifier/README.md +24 -0
  131. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -0,0 +1,1910 @@
1
+ import path from 'node:path';
2
+ import { CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from '../constants.js';
3
+ import { delay } from '../utils.js';
4
+ import { logDomFailure } from '../domDebug.js';
5
+ import { transferAttachmentViaDataTransfer } from './attachmentDataTransfer.js';
6
+ export async function uploadAttachmentFile(deps, attachment, logger, options) {
7
+ const { runtime, dom, input } = deps;
8
+ if (!dom) {
9
+ throw new Error('DOM domain unavailable while uploading attachments.');
10
+ }
11
+ const expectedCount = typeof options?.expectedCount === 'number' && Number.isFinite(options.expectedCount)
12
+ ? Math.max(0, Math.floor(options.expectedCount))
13
+ : 0;
14
+ const readAttachmentSignals = async (name) => {
15
+ const check = await runtime.evaluate({
16
+ expression: `(() => {
17
+ const expected = ${JSON.stringify(name)};
18
+ const normalizedExpected = String(expected || '').toLowerCase().replace(/\\s+/g, ' ').trim();
19
+ const expectedNoExt = normalizedExpected.replace(/\\.[a-z0-9]{1,10}$/i, '');
20
+ const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
21
+ const matchesExpected = (value) => {
22
+ const text = normalize(value);
23
+ if (!text) return false;
24
+ if (text.includes(normalizedExpected)) return true;
25
+ if (expectedNoExt.length >= 6 && text.includes(expectedNoExt)) return true;
26
+ if (text.includes('…') || text.includes('...')) {
27
+ const marker = text.includes('…') ? '…' : '...';
28
+ const [prefixRaw, suffixRaw] = text.split(marker);
29
+ const prefix = normalize(prefixRaw);
30
+ const suffix = normalize(suffixRaw);
31
+ const target = expectedNoExt.length >= 6 ? expectedNoExt : normalizedExpected;
32
+ const matchesPrefix = !prefix || target.includes(prefix);
33
+ const matchesSuffix = !suffix || target.includes(suffix);
34
+ return matchesPrefix && matchesSuffix;
35
+ }
36
+ return false;
37
+ };
38
+
39
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
40
+ const findPromptNode = () => {
41
+ for (const selector of promptSelectors) {
42
+ const nodes = Array.from(document.querySelectorAll(selector));
43
+ for (const node of nodes) {
44
+ if (!(node instanceof HTMLElement)) continue;
45
+ const rect = node.getBoundingClientRect();
46
+ if (rect.width > 0 && rect.height > 0) return node;
47
+ }
48
+ }
49
+ for (const selector of promptSelectors) {
50
+ const node = document.querySelector(selector);
51
+ if (node) return node;
52
+ }
53
+ return null;
54
+ };
55
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
56
+ const attachmentSelectors = [
57
+ 'input[type="file"]',
58
+ '[data-testid*="attachment"]',
59
+ '[data-testid*="upload"]',
60
+ '[aria-label*="Remove"]',
61
+ '[aria-label*="remove"]',
62
+ ];
63
+ const locateComposerRoot = () => {
64
+ const promptNode = findPromptNode();
65
+ if (promptNode) {
66
+ const initial =
67
+ promptNode.closest('[data-testid*="composer"]') ??
68
+ promptNode.closest('form') ??
69
+ promptNode.parentElement ??
70
+ document.body;
71
+ let current = initial;
72
+ let fallback = initial;
73
+ while (current && current !== document.body) {
74
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
75
+ if (hasSend) {
76
+ fallback = current;
77
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
78
+ if (hasAttachment) {
79
+ return current;
80
+ }
81
+ }
82
+ current = current.parentElement;
83
+ }
84
+ return fallback ?? initial;
85
+ }
86
+ return document.querySelector('form') ?? document.body;
87
+ };
88
+ const root = locateComposerRoot();
89
+ const scope = (() => {
90
+ if (!root) return document.body;
91
+ const parent = root.parentElement;
92
+ const parentHasSend = parent && sendSelectors.some((selector) => parent.querySelector(selector));
93
+ return parentHasSend ? parent : root;
94
+ })();
95
+ const rootTextRaw = root ? (root.innerText || root.textContent || '') : '';
96
+ const chipSelector = [
97
+ '[data-testid*="attachment"]',
98
+ '[data-testid*="chip"]',
99
+ '[data-testid*="upload"]',
100
+ '[data-testid*="file"]',
101
+ '[aria-label*="Remove"]',
102
+ 'button[aria-label*="Remove"]',
103
+ '[aria-label*="remove"]',
104
+ ].join(',');
105
+ const localCandidates = scope ? Array.from(scope.querySelectorAll(chipSelector)) : [];
106
+ const globalCandidates = Array.from(document.querySelectorAll(chipSelector));
107
+ const matchCandidates = localCandidates.length > 0 ? localCandidates : globalCandidates;
108
+ const serializeChip = (node) => {
109
+ const text = node?.textContent ?? '';
110
+ const aria = node?.getAttribute?.('aria-label') ?? '';
111
+ const title = node?.getAttribute?.('title') ?? '';
112
+ const testid = node?.getAttribute?.('data-testid') ?? '';
113
+ return [text, aria, title, testid].map(normalize).join('|');
114
+ };
115
+ const chipSignature = localCandidates.map(serializeChip).join('||');
116
+ let uiMatch = false;
117
+ for (const node of matchCandidates) {
118
+ if (node?.tagName === 'INPUT' && node?.type === 'file') continue;
119
+ const text = node?.textContent ?? '';
120
+ const aria = node?.getAttribute?.('aria-label') ?? '';
121
+ const title = node?.getAttribute?.('title') ?? '';
122
+ if ([text, aria, title].some(matchesExpected)) {
123
+ uiMatch = true;
124
+ break;
125
+ }
126
+ }
127
+
128
+ if (!uiMatch) {
129
+ const removeScope = root ?? document;
130
+ const cardTexts = Array.from(removeScope.querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]')).map(
131
+ (btn) => btn?.parentElement?.parentElement?.innerText ?? '',
132
+ );
133
+ if (cardTexts.some(matchesExpected)) {
134
+ uiMatch = true;
135
+ }
136
+ }
137
+
138
+ const inputScope = scope ? Array.from(scope.querySelectorAll('input[type="file"]')) : [];
139
+ const inputs = [];
140
+ const inputSeen = new Set();
141
+ for (const el of [...inputScope, ...Array.from(document.querySelectorAll('input[type="file"]'))]) {
142
+ if (!inputSeen.has(el)) {
143
+ inputSeen.add(el);
144
+ inputs.push(el);
145
+ }
146
+ }
147
+ const inputNames = [];
148
+ let inputCount = 0;
149
+ for (const el of inputs) {
150
+ if (!(el instanceof HTMLInputElement)) continue;
151
+ const files = Array.from(el.files || []);
152
+ if (files.length > 0) {
153
+ inputCount += files.length;
154
+ for (const file of files) {
155
+ if (file?.name) inputNames.push(file.name);
156
+ }
157
+ }
158
+ }
159
+ const inputMatch = inputNames.some((file) => matchesExpected(file));
160
+ const uploadingSelectors = ${JSON.stringify(UPLOAD_STATUS_SELECTORS)};
161
+ const uploading = uploadingSelectors.some((selector) => {
162
+ return Array.from(document.querySelectorAll(selector)).some((node) => {
163
+ const ariaBusy = node.getAttribute?.('aria-busy');
164
+ const dataState = node.getAttribute?.('data-state');
165
+ if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
166
+ return true;
167
+ }
168
+ const text = node.textContent?.toLowerCase?.() ?? '';
169
+ return /\\buploading\\b/.test(text) || /\\bprocessing\\b/.test(text);
170
+ });
171
+ });
172
+
173
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
174
+ const collectFileCount = (candidates) => {
175
+ let count = 0;
176
+ for (const node of candidates) {
177
+ if (!(node instanceof HTMLElement)) continue;
178
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
179
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
180
+ const aria = node.getAttribute?.('aria-label') ?? '';
181
+ const title = node.getAttribute?.('title') ?? '';
182
+ const tooltip =
183
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
184
+ const text = node.textContent ?? '';
185
+ const parent = node.parentElement;
186
+ const parentText = parent?.textContent ?? '';
187
+ const parentAria = parent?.getAttribute?.('aria-label') ?? '';
188
+ const parentTitle = parent?.getAttribute?.('title') ?? '';
189
+ const parentTooltip =
190
+ parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
191
+ const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
192
+ const values = [
193
+ text,
194
+ aria,
195
+ title,
196
+ tooltip,
197
+ dataTestId,
198
+ parentText,
199
+ parentAria,
200
+ parentTitle,
201
+ parentTooltip,
202
+ parentTestId,
203
+ ];
204
+ let hasFileHint = false;
205
+ for (const raw of values) {
206
+ if (!raw) continue;
207
+ const normalized = normalize(raw);
208
+ if (normalized.includes('file') || normalized.includes('attachment')) {
209
+ hasFileHint = true;
210
+ break;
211
+ }
212
+ }
213
+ if (!hasFileHint) continue;
214
+ for (const raw of values) {
215
+ if (!raw) continue;
216
+ const match = normalize(raw).match(countRegex);
217
+ if (match) {
218
+ const parsed = Number(match[1]);
219
+ if (Number.isFinite(parsed)) {
220
+ count = Math.max(count, parsed);
221
+ }
222
+ }
223
+ }
224
+ }
225
+ return count;
226
+ };
227
+ const fileCountSelectors = [
228
+ 'button',
229
+ '[role="button"]',
230
+ '[data-testid*="file"]',
231
+ '[data-testid*="upload"]',
232
+ '[data-testid*="attachment"]',
233
+ '[data-testid*="chip"]',
234
+ '[aria-label*="file"]',
235
+ '[title*="file"]',
236
+ '[aria-label*="attachment"]',
237
+ '[title*="attachment"]',
238
+ ].join(',');
239
+ const fileCountScope = scope ?? root ?? document.body;
240
+ const localFileNodes = fileCountScope
241
+ ? Array.from(fileCountScope.querySelectorAll(fileCountSelectors))
242
+ : [];
243
+ const globalFileNodes = Array.from(document.querySelectorAll(fileCountSelectors));
244
+ let fileCount = collectFileCount(localFileNodes);
245
+ if (!fileCount && globalFileNodes.length > 0) {
246
+ fileCount = collectFileCount(globalFileNodes);
247
+ }
248
+ const hasAttachmentSignal = localCandidates.length > 0 || inputCount > 0 || fileCount > 0 || uploading;
249
+ if (!uiMatch && rootTextRaw && hasAttachmentSignal && matchesExpected(rootTextRaw)) {
250
+ uiMatch = true;
251
+ }
252
+
253
+ return {
254
+ ui: uiMatch,
255
+ input: inputMatch,
256
+ inputCount,
257
+ chipCount: localCandidates.length,
258
+ chipSignature,
259
+ uploading,
260
+ fileCount,
261
+ };
262
+ })()`,
263
+ returnByValue: true,
264
+ });
265
+ const value = check?.result?.value;
266
+ return {
267
+ ui: Boolean(value?.ui),
268
+ input: Boolean(value?.input),
269
+ inputCount: typeof value?.inputCount === 'number' ? value?.inputCount : 0,
270
+ chipCount: typeof value?.chipCount === 'number' ? value?.chipCount : 0,
271
+ chipSignature: typeof value?.chipSignature === 'string' ? value?.chipSignature : '',
272
+ uploading: Boolean(value?.uploading),
273
+ fileCount: typeof value?.fileCount === 'number' ? value?.fileCount : 0,
274
+ };
275
+ };
276
+ // New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
277
+ // Learned: synthetic `.click()` is sometimes ignored (isTrusted checks). Prefer a CDP mouse click when possible.
278
+ const clickPlusTrusted = async () => {
279
+ if (!input || typeof input.dispatchMouseEvent !== 'function')
280
+ return false;
281
+ const locate = await runtime
282
+ .evaluate({
283
+ expression: `(() => {
284
+ const selectors = [
285
+ '#composer-plus-btn',
286
+ 'button[data-testid="composer-plus-btn"]',
287
+ '[data-testid*="plus"]',
288
+ 'button[aria-label*="add"]',
289
+ 'button[aria-label*="attachment"]',
290
+ 'button[aria-label*="file"]',
291
+ ];
292
+ for (const selector of selectors) {
293
+ const el = document.querySelector(selector);
294
+ if (!(el instanceof HTMLElement)) continue;
295
+ const rect = el.getBoundingClientRect();
296
+ if (rect.width <= 0 || rect.height <= 0) continue;
297
+ el.scrollIntoView({ block: 'center', inline: 'center' });
298
+ const nextRect = el.getBoundingClientRect();
299
+ return { ok: true, x: nextRect.left + nextRect.width / 2, y: nextRect.top + nextRect.height / 2 };
300
+ }
301
+ return { ok: false };
302
+ })()`,
303
+ returnByValue: true,
304
+ })
305
+ .then((res) => res?.result?.value)
306
+ .catch(() => undefined);
307
+ if (!locate?.ok || typeof locate.x !== 'number' || typeof locate.y !== 'number')
308
+ return false;
309
+ const x = locate.x;
310
+ const y = locate.y;
311
+ await input.dispatchMouseEvent({ type: 'mouseMoved', x, y });
312
+ await input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
313
+ await input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
314
+ return true;
315
+ };
316
+ const clickedTrusted = await clickPlusTrusted().catch(() => false);
317
+ if (!clickedTrusted) {
318
+ await Promise.resolve(runtime.evaluate({
319
+ expression: `(() => {
320
+ const selectors = [
321
+ '#composer-plus-btn',
322
+ 'button[data-testid="composer-plus-btn"]',
323
+ '[data-testid*="plus"]',
324
+ 'button[aria-label*="add"]',
325
+ 'button[aria-label*="attachment"]',
326
+ 'button[aria-label*="file"]',
327
+ ];
328
+ for (const selector of selectors) {
329
+ const el = document.querySelector(selector);
330
+ if (el instanceof HTMLElement) {
331
+ el.click();
332
+ return true;
333
+ }
334
+ }
335
+ return false;
336
+ })()`,
337
+ returnByValue: true,
338
+ })).catch(() => undefined);
339
+ }
340
+ await delay(350);
341
+ const normalizeForMatch = (value) => String(value || '')
342
+ .toLowerCase()
343
+ .replace(/\s+/g, ' ')
344
+ .trim();
345
+ const expectedName = path.basename(attachment.path);
346
+ const expectedNameLower = normalizeForMatch(expectedName);
347
+ const expectedNameNoExt = expectedNameLower.replace(/\.[a-z0-9]{1,10}$/i, '');
348
+ const matchesExpectedName = (value) => {
349
+ const normalized = normalizeForMatch(value);
350
+ if (!normalized)
351
+ return false;
352
+ if (normalized.includes(expectedNameLower))
353
+ return true;
354
+ if (expectedNameNoExt.length >= 6 && normalized.includes(expectedNameNoExt))
355
+ return true;
356
+ return false;
357
+ };
358
+ const isImageAttachment = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif)$/i.test(expectedName);
359
+ const attachmentUiTimeoutMs = 25_000;
360
+ const attachmentUiSignalWaitMs = 5_000;
361
+ const initialSignals = await readAttachmentSignals(expectedName);
362
+ let inputConfirmed = false;
363
+ if (initialSignals.ui) {
364
+ logger(`Attachment already present: ${path.basename(attachment.path)}`);
365
+ return true;
366
+ }
367
+ const isExpectedSatisfied = (signals) => {
368
+ if (expectedCount <= 0)
369
+ return false;
370
+ const fileCount = typeof signals.fileCount === 'number' ? signals.fileCount : 0;
371
+ const chipCount = typeof signals.chipCount === 'number' ? signals.chipCount : 0;
372
+ if (fileCount >= expectedCount)
373
+ return true;
374
+ return Boolean(signals.ui && chipCount >= expectedCount);
375
+ };
376
+ const initialInputSatisfied = expectedCount > 0 ? initialSignals.inputCount >= expectedCount : Boolean(initialSignals.input);
377
+ if (expectedCount > 0 && (initialSignals.fileCount >= expectedCount || initialSignals.inputCount >= expectedCount)) {
378
+ const satisfiedCount = Math.max(initialSignals.fileCount, initialSignals.inputCount);
379
+ logger(`Attachment already present: composer shows ${satisfiedCount} file${satisfiedCount === 1 ? '' : 's'}`);
380
+ return true;
381
+ }
382
+ if (initialInputSatisfied || initialSignals.input) {
383
+ logger(`Attachment already queued in file input: ${path.basename(attachment.path)}`);
384
+ return true;
385
+ }
386
+ const documentNode = await dom.getDocument();
387
+ const candidateSetup = await runtime.evaluate({
388
+ expression: `(() => {
389
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
390
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
391
+ const findPromptNode = () => {
392
+ for (const selector of promptSelectors) {
393
+ const nodes = Array.from(document.querySelectorAll(selector));
394
+ for (const node of nodes) {
395
+ if (!(node instanceof HTMLElement)) continue;
396
+ const rect = node.getBoundingClientRect();
397
+ if (rect.width > 0 && rect.height > 0) return node;
398
+ }
399
+ }
400
+ for (const selector of promptSelectors) {
401
+ const node = document.querySelector(selector);
402
+ if (node) return node;
403
+ }
404
+ return null;
405
+ };
406
+ const attachmentSelectors = [
407
+ 'input[type="file"]',
408
+ '[data-testid*="attachment"]',
409
+ '[data-testid*="upload"]',
410
+ '[aria-label*="Remove"]',
411
+ '[aria-label*="remove"]',
412
+ ];
413
+ const locateComposerRoot = () => {
414
+ const promptNode = findPromptNode();
415
+ if (promptNode) {
416
+ const initial =
417
+ promptNode.closest('[data-testid*="composer"]') ??
418
+ promptNode.closest('form') ??
419
+ promptNode.parentElement ??
420
+ document.body;
421
+ let current = initial;
422
+ let fallback = initial;
423
+ while (current && current !== document.body) {
424
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
425
+ if (hasSend) {
426
+ fallback = current;
427
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
428
+ if (hasAttachment) {
429
+ return current;
430
+ }
431
+ }
432
+ current = current.parentElement;
433
+ }
434
+ return fallback ?? initial;
435
+ }
436
+ return document.querySelector('form') ?? document.body;
437
+ };
438
+ const root = locateComposerRoot();
439
+ const scope = (() => {
440
+ if (!root) return document.body;
441
+ const parent = root.parentElement;
442
+ const parentHasSend = parent && sendSelectors.some((selector) => parent.querySelector(selector));
443
+ return parentHasSend ? parent : root;
444
+ })();
445
+ const localInputs = scope ? Array.from(scope.querySelectorAll('input[type="file"]')) : [];
446
+ const globalInputs = Array.from(document.querySelectorAll('input[type="file"]'));
447
+ const inputs = [];
448
+ const inputSeen = new Set();
449
+ for (const el of [...localInputs, ...globalInputs]) {
450
+ if (!inputSeen.has(el)) {
451
+ inputSeen.add(el);
452
+ inputs.push(el);
453
+ }
454
+ }
455
+ const baselineInputCount = inputs.reduce((total, el) => {
456
+ if (!(el instanceof HTMLInputElement)) return total;
457
+ const count = Array.from(el.files || []).length;
458
+ return total + count;
459
+ }, 0);
460
+ const isImageAttachment = ${JSON.stringify(isImageAttachment)};
461
+ const acceptIsImageOnly = (accept) => {
462
+ if (!accept) return false;
463
+ const parts = String(accept)
464
+ .split(',')
465
+ .map((p) => p.trim().toLowerCase())
466
+ .filter(Boolean);
467
+ return parts.length > 0 && parts.every((p) => p.startsWith('image/'));
468
+ };
469
+ const chipContainer = scope ?? document;
470
+ const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[data-testid*="file"],[aria-label*="Remove"],[aria-label*="remove"]';
471
+ const baselineChipCount = chipContainer.querySelectorAll(chipSelector).length;
472
+ const baselineChips = Array.from(chipContainer.querySelectorAll(chipSelector))
473
+ .slice(0, 20)
474
+ .map((node) => ({
475
+ text: (node.textContent || '').trim(),
476
+ aria: node.getAttribute?.('aria-label') ?? '',
477
+ title: node.getAttribute?.('title') ?? '',
478
+ testid: node.getAttribute?.('data-testid') ?? '',
479
+ }));
480
+ const uploadingSelectors = ${JSON.stringify(UPLOAD_STATUS_SELECTORS)};
481
+ const baselineUploading = uploadingSelectors.some((selector) => {
482
+ return Array.from(document.querySelectorAll(selector)).some((node) => {
483
+ const ariaBusy = node.getAttribute?.('aria-busy');
484
+ const dataState = node.getAttribute?.('data-state');
485
+ if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
486
+ return true;
487
+ }
488
+ const text = node.textContent?.toLowerCase?.() ?? '';
489
+ return /\\buploading\\b/.test(text) || /\\bprocessing\\b/.test(text);
490
+ });
491
+ });
492
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
493
+ const collectFileCount = (candidates) => {
494
+ let count = 0;
495
+ for (const node of candidates) {
496
+ if (!(node instanceof HTMLElement)) continue;
497
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
498
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
499
+ const aria = node.getAttribute?.('aria-label') ?? '';
500
+ const title = node.getAttribute?.('title') ?? '';
501
+ const tooltip =
502
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
503
+ const text = node.textContent ?? '';
504
+ const parent = node.parentElement;
505
+ const parentText = parent?.textContent ?? '';
506
+ const parentAria = parent?.getAttribute?.('aria-label') ?? '';
507
+ const parentTitle = parent?.getAttribute?.('title') ?? '';
508
+ const parentTooltip =
509
+ parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
510
+ const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
511
+ const values = [
512
+ text,
513
+ aria,
514
+ title,
515
+ tooltip,
516
+ dataTestId,
517
+ parentText,
518
+ parentAria,
519
+ parentTitle,
520
+ parentTooltip,
521
+ parentTestId,
522
+ ];
523
+ let hasFileHint = false;
524
+ for (const raw of values) {
525
+ if (!raw) continue;
526
+ const lowered = String(raw).toLowerCase();
527
+ if (lowered.includes('file') || lowered.includes('attachment')) {
528
+ hasFileHint = true;
529
+ break;
530
+ }
531
+ }
532
+ if (!hasFileHint) continue;
533
+ for (const raw of values) {
534
+ if (!raw) continue;
535
+ const match = String(raw).toLowerCase().match(countRegex);
536
+ if (match) {
537
+ const parsed = Number(match[1]);
538
+ if (Number.isFinite(parsed)) {
539
+ count = Math.max(count, parsed);
540
+ }
541
+ }
542
+ }
543
+ }
544
+ return count;
545
+ };
546
+ const fileCountSelectors = [
547
+ 'button',
548
+ '[role="button"]',
549
+ '[data-testid*="file"]',
550
+ '[data-testid*="upload"]',
551
+ '[data-testid*="attachment"]',
552
+ '[data-testid*="chip"]',
553
+ '[aria-label*="file"]',
554
+ '[title*="file"]',
555
+ '[aria-label*="attachment"]',
556
+ '[title*="attachment"]',
557
+ ].join(',');
558
+ const fileCountScope = scope ?? root ?? document.body;
559
+ const localFileNodes = fileCountScope
560
+ ? Array.from(fileCountScope.querySelectorAll(fileCountSelectors))
561
+ : [];
562
+ const globalFileNodes = Array.from(document.querySelectorAll(fileCountSelectors));
563
+ let baselineFileCount = collectFileCount(localFileNodes);
564
+ if (!baselineFileCount && globalFileNodes.length > 0) {
565
+ baselineFileCount = collectFileCount(globalFileNodes);
566
+ }
567
+
568
+ // Mark candidates with stable indices so we can select them via DOM.querySelector.
569
+ // Learned: ChatGPT sometimes renders a zero-sized file input that does *not* trigger uploads;
570
+ // keep it as a fallback, but strongly prefer visible (even sr-only 1x1) inputs.
571
+ const localSet = new Set(localInputs);
572
+ let idx = 0;
573
+ let candidates = inputs.map((el) => {
574
+ const accept = el.getAttribute('accept') || '';
575
+ const imageOnly = acceptIsImageOnly(accept);
576
+ const rect = el instanceof HTMLElement ? el.getBoundingClientRect() : { width: 0, height: 0 };
577
+ const visible = rect.width > 0 && rect.height > 0;
578
+ const local = localSet.has(el);
579
+ const score =
580
+ (el.hasAttribute('multiple') ? 100 : 0) +
581
+ (local ? 40 : 0) +
582
+ (visible ? 30 : -200) +
583
+ (!imageOnly ? 30 : isImageAttachment ? 20 : 5);
584
+ el.setAttribute('data-oracle-upload-candidate', 'true');
585
+ el.setAttribute('data-oracle-upload-idx', String(idx));
586
+ return { idx: idx++, score, imageOnly };
587
+ });
588
+
589
+ // When the attachment isn't an image, avoid inputs that only accept images.
590
+ // Some ChatGPT surfaces expose multiple file inputs (e.g. image-only vs generic upload).
591
+ if (!isImageAttachment) {
592
+ const nonImage = candidates.filter((candidate) => !candidate.imageOnly);
593
+ if (nonImage.length > 0) {
594
+ candidates = nonImage;
595
+ }
596
+ }
597
+
598
+ // Prefer higher scores first.
599
+ candidates.sort((a, b) => b.score - a.score);
600
+ return {
601
+ ok: candidates.length > 0,
602
+ baselineChipCount,
603
+ baselineChips,
604
+ baselineUploading,
605
+ baselineFileCount,
606
+ baselineInputCount,
607
+ order: candidates.map((c) => c.idx),
608
+ };
609
+ })()`,
610
+ returnByValue: true,
611
+ });
612
+ const candidateValue = candidateSetup?.result?.value;
613
+ const candidateOrder = Array.isArray(candidateValue?.order) ? candidateValue.order : [];
614
+ const baselineChipCount = typeof candidateValue?.baselineChipCount === 'number' ? candidateValue.baselineChipCount : 0;
615
+ const baselineChips = Array.isArray(candidateValue?.baselineChips) ? candidateValue.baselineChips : [];
616
+ const baselineUploading = Boolean(candidateValue?.baselineUploading);
617
+ const baselineFileCount = typeof candidateValue?.baselineFileCount === 'number' ? candidateValue.baselineFileCount : 0;
618
+ const baselineInputCount = typeof candidateValue?.baselineInputCount === 'number' ? candidateValue.baselineInputCount : 0;
619
+ const serializeChips = (chips) => chips
620
+ .map((chip) => [chip.text, chip.aria, chip.title, chip.testid]
621
+ .map((value) => String(value || '').toLowerCase().replace(/\s+/g, ' ').trim())
622
+ .join('|'))
623
+ .join('||');
624
+ const baselineChipSignature = serializeChips(baselineChips);
625
+ if (!candidateValue?.ok || candidateOrder.length === 0) {
626
+ await logDomFailure(runtime, logger, 'file-input-missing');
627
+ throw new Error('Unable to locate ChatGPT file attachment input.');
628
+ }
629
+ const hasChipDelta = (signals) => {
630
+ const chipCount = typeof signals.chipCount === 'number' ? signals.chipCount : 0;
631
+ const chipSignature = typeof signals.chipSignature === 'string' ? signals.chipSignature : '';
632
+ if (chipCount > baselineChipCount)
633
+ return true;
634
+ if (baselineChipSignature && chipSignature && chipSignature !== baselineChipSignature)
635
+ return true;
636
+ return false;
637
+ };
638
+ const hasInputDelta = (signals) => (typeof signals.inputCount === 'number' ? signals.inputCount : 0) > baselineInputCount;
639
+ const hasUploadDelta = (signals) => Boolean(signals.uploading && !baselineUploading);
640
+ const hasFileCountDelta = (signals) => (typeof signals.fileCount === 'number' ? signals.fileCount : 0) > baselineFileCount;
641
+ const waitForAttachmentUiSignal = async (timeoutMs) => {
642
+ const deadline = Date.now() + timeoutMs;
643
+ let sawInputSignal = false;
644
+ let latest = null;
645
+ while (Date.now() < deadline) {
646
+ const signals = await readAttachmentSignals(expectedName);
647
+ const chipDelta = hasChipDelta(signals);
648
+ const inputDelta = hasInputDelta(signals) || signals.input;
649
+ const uploadDelta = hasUploadDelta(signals);
650
+ const fileCountDelta = hasFileCountDelta(signals);
651
+ const expectedSatisfied = isExpectedSatisfied(signals);
652
+ if (inputDelta) {
653
+ sawInputSignal = true;
654
+ }
655
+ latest = { signals, chipDelta, inputDelta: sawInputSignal, uploadDelta, fileCountDelta, expectedSatisfied };
656
+ if (signals.ui || chipDelta || uploadDelta || fileCountDelta || expectedSatisfied) {
657
+ return latest;
658
+ }
659
+ await delay(250);
660
+ }
661
+ return latest;
662
+ };
663
+ const inputSnapshotFor = (idx) => `(() => {
664
+ const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
665
+ if (!(input instanceof HTMLInputElement)) {
666
+ return { names: [], value: '', count: 0 };
667
+ }
668
+ return {
669
+ names: Array.from(input.files || []).map((file) => file?.name ?? '').filter(Boolean),
670
+ value: input.value || '',
671
+ count: Array.from(input.files || []).length,
672
+ };
673
+ })()`;
674
+ const parseInputSnapshot = (value) => {
675
+ const snapshot = value;
676
+ const names = Array.isArray(snapshot?.names) ? snapshot?.names ?? [] : [];
677
+ const valueText = typeof snapshot?.value === 'string' ? snapshot.value : '';
678
+ const count = typeof snapshot?.count === 'number' ? snapshot.count : names.length;
679
+ return {
680
+ names,
681
+ value: valueText,
682
+ count: Number.isFinite(count) ? count : names.length,
683
+ };
684
+ };
685
+ const readInputSnapshot = async (idx) => {
686
+ const snapshot = await runtime
687
+ .evaluate({ expression: inputSnapshotFor(idx), returnByValue: true })
688
+ .then((res) => parseInputSnapshot(res?.result?.value))
689
+ .catch(() => parseInputSnapshot(undefined));
690
+ return snapshot;
691
+ };
692
+ const snapshotMatchesExpected = (snapshot) => {
693
+ const nameMatch = snapshot.names.some((name) => matchesExpectedName(name));
694
+ return nameMatch || Boolean(snapshot.value && matchesExpectedName(snapshot.value));
695
+ };
696
+ const inputSignalsFor = (baseline, current) => {
697
+ const baselineCount = baseline.count ?? baseline.names.length;
698
+ const currentCount = current.count ?? current.names.length;
699
+ const countDelta = currentCount > baselineCount;
700
+ const valueDelta = Boolean(current.value) && current.value !== baseline.value;
701
+ const baselineEmpty = baselineCount === 0 && !baseline.value;
702
+ const nameMatch = current.names.some((name) => matchesExpectedName(name)) ||
703
+ (current.value && matchesExpectedName(current.value));
704
+ const touched = nameMatch || countDelta || (baselineEmpty && valueDelta);
705
+ return {
706
+ touched,
707
+ nameMatch,
708
+ countDelta,
709
+ valueDelta,
710
+ };
711
+ };
712
+ const composerSnapshotFor = (idx) => `(() => {
713
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
714
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
715
+ const findPromptNode = () => {
716
+ for (const selector of promptSelectors) {
717
+ const nodes = Array.from(document.querySelectorAll(selector));
718
+ for (const node of nodes) {
719
+ if (!(node instanceof HTMLElement)) continue;
720
+ const rect = node.getBoundingClientRect();
721
+ if (rect.width > 0 && rect.height > 0) return node;
722
+ }
723
+ }
724
+ for (const selector of promptSelectors) {
725
+ const node = document.querySelector(selector);
726
+ if (node) return node;
727
+ }
728
+ return null;
729
+ };
730
+ const composerAttachmentSelectors = [
731
+ 'input[type="file"]',
732
+ '[data-testid*="attachment"]',
733
+ '[data-testid*="upload"]',
734
+ '[aria-label*="Remove"]',
735
+ '[aria-label*="remove"]',
736
+ ];
737
+ const locateComposerRoot = () => {
738
+ const promptNode = findPromptNode();
739
+ if (promptNode) {
740
+ const initial =
741
+ promptNode.closest('[data-testid*="composer"]') ??
742
+ promptNode.closest('form') ??
743
+ promptNode.parentElement ??
744
+ document.body;
745
+ let current = initial;
746
+ let fallback = initial;
747
+ while (current && current !== document.body) {
748
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
749
+ if (hasSend) {
750
+ fallback = current;
751
+ const hasAttachment = composerAttachmentSelectors.some((selector) => current.querySelector(selector));
752
+ if (hasAttachment) {
753
+ return current;
754
+ }
755
+ }
756
+ current = current.parentElement;
757
+ }
758
+ return fallback ?? initial;
759
+ }
760
+ return document.querySelector('form') ?? document.body;
761
+ };
762
+ const root = locateComposerRoot();
763
+ const scope = (() => {
764
+ if (!root) return document.body;
765
+ const parent = root.parentElement;
766
+ const parentHasSend = parent && sendSelectors.some((selector) => parent.querySelector(selector));
767
+ return parentHasSend ? parent : root;
768
+ })();
769
+ const chipContainer = scope ?? document;
770
+ const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
771
+ const chips = Array.from(chipContainer.querySelectorAll(chipSelector))
772
+ .slice(0, 20)
773
+ .map((node) => ({
774
+ text: (node.textContent || '').trim(),
775
+ aria: node.getAttribute?.('aria-label') ?? '',
776
+ title: node.getAttribute?.('title') ?? '',
777
+ testid: node.getAttribute?.('data-testid') ?? '',
778
+ }));
779
+ const uploadingSelectors = ${JSON.stringify(UPLOAD_STATUS_SELECTORS)};
780
+ const uploading = uploadingSelectors.some((selector) => {
781
+ return Array.from(document.querySelectorAll(selector)).some((node) => {
782
+ const ariaBusy = node.getAttribute?.('aria-busy');
783
+ const dataState = node.getAttribute?.('data-state');
784
+ if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
785
+ return true;
786
+ }
787
+ const text = node.textContent?.toLowerCase?.() ?? '';
788
+ return /\\buploading\\b/.test(text) || /\\bprocessing\\b/.test(text);
789
+ });
790
+ });
791
+ const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
792
+ const inputNames =
793
+ input instanceof HTMLInputElement
794
+ ? Array.from(input.files || []).map((f) => f?.name ?? '').filter(Boolean)
795
+ : [];
796
+ const composerText = (chipContainer.innerText || '').toLowerCase();
797
+ return {
798
+ chipCount: chipContainer.querySelectorAll(chipSelector).length,
799
+ chips,
800
+ inputNames,
801
+ composerText,
802
+ uploading,
803
+ };
804
+ })()`;
805
+ let confirmedAttachment = false;
806
+ let lastInputNames = [];
807
+ let lastInputValue = '';
808
+ let finalSnapshot = null;
809
+ const resolveInputNameCandidates = () => {
810
+ const snapshot = finalSnapshot;
811
+ const snapshotNames = snapshot?.inputNames;
812
+ if (Array.isArray(snapshotNames) && snapshotNames.length > 0) {
813
+ return snapshotNames;
814
+ }
815
+ return lastInputNames;
816
+ };
817
+ if (!inputConfirmed) {
818
+ for (let orderIndex = 0; orderIndex < candidateOrder.length; orderIndex += 1) {
819
+ const idx = candidateOrder[orderIndex];
820
+ const queuedSignals = await readAttachmentSignals(expectedName);
821
+ if (queuedSignals.ui ||
822
+ isExpectedSatisfied(queuedSignals) ||
823
+ hasChipDelta(queuedSignals) ||
824
+ hasUploadDelta(queuedSignals) ||
825
+ hasFileCountDelta(queuedSignals)) {
826
+ confirmedAttachment = true;
827
+ break;
828
+ }
829
+ if (queuedSignals.input || hasInputDelta(queuedSignals)) {
830
+ inputConfirmed = true;
831
+ break;
832
+ }
833
+ const resultNode = await dom.querySelector({
834
+ nodeId: documentNode.root.nodeId,
835
+ selector: `input[type="file"][data-oracle-upload-idx="${idx}"]`,
836
+ });
837
+ if (!resultNode?.nodeId) {
838
+ continue;
839
+ }
840
+ const baselineInputSnapshot = await readInputSnapshot(idx);
841
+ const gatherSignals = async (waitMs = attachmentUiSignalWaitMs) => {
842
+ const signalResult = await waitForAttachmentUiSignal(waitMs);
843
+ const postInputSnapshot = await readInputSnapshot(idx);
844
+ const postInputSignals = inputSignalsFor(baselineInputSnapshot, postInputSnapshot);
845
+ const snapshot = await runtime
846
+ .evaluate({ expression: composerSnapshotFor(idx), returnByValue: true })
847
+ .then((res) => res?.result?.value)
848
+ .catch(() => undefined);
849
+ if (snapshot) {
850
+ finalSnapshot = {
851
+ chipCount: Number(snapshot.chipCount ?? 0),
852
+ chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
853
+ inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
854
+ composerText: typeof snapshot.composerText === 'string' ? snapshot.composerText : '',
855
+ uploading: Boolean(snapshot.uploading),
856
+ };
857
+ }
858
+ lastInputNames = postInputSnapshot.names;
859
+ lastInputValue = postInputSnapshot.value;
860
+ return { signalResult, postInputSignals };
861
+ };
862
+ const evaluateSignals = async (signalResult, postInputSignals, immediateInputMatch) => {
863
+ const expectedSatisfied = Boolean(signalResult?.expectedSatisfied) ||
864
+ (signalResult?.signals ? isExpectedSatisfied(signalResult.signals) : false);
865
+ const inputNameCandidates = resolveInputNameCandidates();
866
+ const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
867
+ (lastInputValue && matchesExpectedName(lastInputValue));
868
+ const inputEvidence = immediateInputMatch ||
869
+ postInputSignals.touched ||
870
+ Boolean(signalResult?.signals?.input) ||
871
+ Boolean(signalResult?.inputDelta) ||
872
+ inputHasFile;
873
+ const uiDirect = Boolean(signalResult?.signals?.ui) || expectedSatisfied;
874
+ const uiDelta = Boolean(signalResult?.chipDelta) || Boolean(signalResult?.uploadDelta) || Boolean(signalResult?.fileCountDelta);
875
+ if (uiDirect || (uiDelta && inputEvidence)) {
876
+ return { status: 'ui' };
877
+ }
878
+ const postSignals = await readAttachmentSignals(expectedName);
879
+ if (postSignals.ui ||
880
+ isExpectedSatisfied(postSignals) ||
881
+ ((hasChipDelta(postSignals) || hasUploadDelta(postSignals) || hasFileCountDelta(postSignals)) && inputEvidence)) {
882
+ return { status: 'ui' };
883
+ }
884
+ const inputSignal = immediateInputMatch ||
885
+ postInputSignals.touched ||
886
+ Boolean(signalResult?.signals?.input) ||
887
+ Boolean(signalResult?.inputDelta) ||
888
+ inputHasFile ||
889
+ postSignals.input ||
890
+ hasInputDelta(postSignals);
891
+ if (inputSignal) {
892
+ return { status: 'input' };
893
+ }
894
+ return { status: 'none' };
895
+ };
896
+ const runInputAttempt = async (mode) => {
897
+ let immediateInputSnapshot = await readInputSnapshot(idx);
898
+ let hasExpectedFile = snapshotMatchesExpected(immediateInputSnapshot);
899
+ if (!hasExpectedFile) {
900
+ if (mode === 'set') {
901
+ await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
902
+ }
903
+ else {
904
+ const selector = `input[type="file"][data-oracle-upload-idx="${idx}"]`;
905
+ try {
906
+ await transferAttachmentViaDataTransfer(runtime, attachment, selector);
907
+ }
908
+ catch (error) {
909
+ logger(`Attachment data transfer failed: ${error?.message ?? String(error)}`);
910
+ }
911
+ }
912
+ immediateInputSnapshot = await readInputSnapshot(idx);
913
+ hasExpectedFile = snapshotMatchesExpected(immediateInputSnapshot);
914
+ }
915
+ const immediateSignals = inputSignalsFor(baselineInputSnapshot, immediateInputSnapshot);
916
+ lastInputNames = immediateInputSnapshot.names;
917
+ lastInputValue = immediateInputSnapshot.value;
918
+ const immediateInputMatch = immediateSignals.touched || hasExpectedFile;
919
+ if (immediateInputMatch) {
920
+ inputConfirmed = true;
921
+ }
922
+ const signalState = await gatherSignals();
923
+ const evaluation = await evaluateSignals(signalState.signalResult, signalState.postInputSignals, immediateInputMatch);
924
+ return { evaluation, signalState, immediateInputMatch };
925
+ };
926
+ const dispatchInputEvents = async () => {
927
+ await runtime
928
+ .evaluate({
929
+ expression: `(() => {
930
+ const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
931
+ if (!(input instanceof HTMLInputElement)) return false;
932
+ try {
933
+ input.dispatchEvent(new Event('input', { bubbles: true }));
934
+ input.dispatchEvent(new Event('change', { bubbles: true }));
935
+ return true;
936
+ } catch {
937
+ return false;
938
+ }
939
+ })()`,
940
+ returnByValue: true,
941
+ })
942
+ .catch(() => undefined);
943
+ };
944
+ let result = await runInputAttempt('set');
945
+ if (result.evaluation.status === 'ui') {
946
+ confirmedAttachment = true;
947
+ break;
948
+ }
949
+ if (result.evaluation.status === 'input') {
950
+ await dispatchInputEvents();
951
+ await delay(150);
952
+ const forcedState = await gatherSignals(1_500);
953
+ const forcedEvaluation = await evaluateSignals(forcedState.signalResult, forcedState.postInputSignals, result.immediateInputMatch);
954
+ if (forcedEvaluation.status === 'ui') {
955
+ confirmedAttachment = true;
956
+ break;
957
+ }
958
+ if (forcedEvaluation.status === 'input') {
959
+ logger('Attachment input set; proceeding without UI confirmation.');
960
+ inputConfirmed = true;
961
+ break;
962
+ }
963
+ logger('Attachment input set; retrying with data transfer to trigger ChatGPT upload.');
964
+ await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
965
+ await delay(150);
966
+ result = await runInputAttempt('transfer');
967
+ if (result.evaluation.status === 'ui') {
968
+ confirmedAttachment = true;
969
+ break;
970
+ }
971
+ if (result.evaluation.status === 'input') {
972
+ logger('Attachment input set; proceeding without UI confirmation.');
973
+ inputConfirmed = true;
974
+ break;
975
+ }
976
+ }
977
+ const lateSignals = await readAttachmentSignals(expectedName);
978
+ if (lateSignals.ui ||
979
+ isExpectedSatisfied(lateSignals) ||
980
+ hasChipDelta(lateSignals) ||
981
+ hasUploadDelta(lateSignals) ||
982
+ hasFileCountDelta(lateSignals)) {
983
+ confirmedAttachment = true;
984
+ break;
985
+ }
986
+ if (lateSignals.input || hasInputDelta(lateSignals)) {
987
+ logger('Attachment input set; proceeding without UI confirmation.');
988
+ inputConfirmed = true;
989
+ break;
990
+ }
991
+ logger('Attachment not acknowledged after file input set; retrying with data transfer.');
992
+ result = await runInputAttempt('transfer');
993
+ if (result.evaluation.status === 'ui') {
994
+ confirmedAttachment = true;
995
+ break;
996
+ }
997
+ if (result.evaluation.status === 'input') {
998
+ logger('Attachment input set; proceeding without UI confirmation.');
999
+ inputConfirmed = true;
1000
+ break;
1001
+ }
1002
+ if (orderIndex < candidateOrder.length - 1) {
1003
+ await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
1004
+ await delay(150);
1005
+ }
1006
+ }
1007
+ }
1008
+ if (confirmedAttachment) {
1009
+ const inputNameCandidates = resolveInputNameCandidates();
1010
+ const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
1011
+ (lastInputValue && matchesExpectedName(lastInputValue));
1012
+ await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
1013
+ logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
1014
+ return true;
1015
+ }
1016
+ const inputNameCandidates = resolveInputNameCandidates();
1017
+ const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
1018
+ (lastInputValue && matchesExpectedName(lastInputValue));
1019
+ if (await waitForAttachmentAnchored(runtime, expectedName, attachmentUiTimeoutMs)) {
1020
+ await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
1021
+ logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
1022
+ return true;
1023
+ }
1024
+ if (inputConfirmed || inputHasFile) {
1025
+ logger('Attachment input accepted the file but UI did not acknowledge it; continuing with input confirmation only.');
1026
+ return true;
1027
+ }
1028
+ await logDomFailure(runtime, logger, 'file-upload-missing');
1029
+ throw new Error('Attachment did not register with the ChatGPT composer in time.');
1030
+ }
1031
+ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1032
+ const deadline = Date.now() + Math.max(0, timeoutMs);
1033
+ const expression = `(() => {
1034
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
1035
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
1036
+ const findPromptNode = () => {
1037
+ for (const selector of promptSelectors) {
1038
+ const nodes = Array.from(document.querySelectorAll(selector));
1039
+ for (const node of nodes) {
1040
+ if (!(node instanceof HTMLElement)) continue;
1041
+ const rect = node.getBoundingClientRect();
1042
+ if (rect.width > 0 && rect.height > 0) return node;
1043
+ }
1044
+ }
1045
+ for (const selector of promptSelectors) {
1046
+ const node = document.querySelector(selector);
1047
+ if (node) return node;
1048
+ }
1049
+ return null;
1050
+ };
1051
+ const attachmentSelectors = [
1052
+ 'input[type="file"]',
1053
+ '[data-testid*="attachment"]',
1054
+ '[data-testid*="upload"]',
1055
+ '[aria-label*="Remove"]',
1056
+ '[aria-label*="remove"]',
1057
+ ];
1058
+ const locateComposerRoot = () => {
1059
+ const promptNode = findPromptNode();
1060
+ if (promptNode) {
1061
+ const initial =
1062
+ promptNode.closest('[data-testid*="composer"]') ??
1063
+ promptNode.closest('form') ??
1064
+ promptNode.parentElement ??
1065
+ document.body;
1066
+ let current = initial;
1067
+ let fallback = initial;
1068
+ while (current && current !== document.body) {
1069
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
1070
+ if (hasSend) {
1071
+ fallback = current;
1072
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
1073
+ if (hasAttachment) {
1074
+ return current;
1075
+ }
1076
+ }
1077
+ current = current.parentElement;
1078
+ }
1079
+ return fallback ?? initial;
1080
+ }
1081
+ return document.querySelector('form') ?? document.body;
1082
+ };
1083
+ const root = locateComposerRoot();
1084
+ const scope = (() => {
1085
+ if (!root) return document.body;
1086
+ const parent = root.parentElement;
1087
+ const parentHasSend = parent && sendSelectors.some((selector) => parent.querySelector(selector));
1088
+ return parentHasSend ? parent : root;
1089
+ })();
1090
+ const removeSelectors = [
1091
+ '[aria-label="Remove file"]',
1092
+ 'button[aria-label="Remove file"]',
1093
+ '[aria-label*="Remove file"]',
1094
+ '[aria-label*="remove file"]',
1095
+ '[data-testid*="remove-attachment"]',
1096
+ '[data-testid*="attachment-remove"]',
1097
+ ];
1098
+ const visible = (el) => {
1099
+ if (!(el instanceof HTMLElement)) return false;
1100
+ const rect = el.getBoundingClientRect();
1101
+ return rect.width > 0 && rect.height > 0;
1102
+ };
1103
+ const removeButtons = scope
1104
+ ? Array.from(scope.querySelectorAll(removeSelectors.join(','))).filter(visible)
1105
+ : [];
1106
+ for (const button of removeButtons.slice(0, 20)) {
1107
+ try {
1108
+ if (button instanceof HTMLButtonElement) {
1109
+ // Ensure remove buttons never submit the composer form.
1110
+ button.type = 'button';
1111
+ }
1112
+ button.click();
1113
+ } catch {}
1114
+ }
1115
+ const chipCount = removeButtons.length;
1116
+ const inputs = scope ? Array.from(scope.querySelectorAll('input[type="file"]')) : [];
1117
+ let inputCount = 0;
1118
+ for (const input of inputs) {
1119
+ if (!(input instanceof HTMLInputElement)) continue;
1120
+ inputCount += Array.from(input.files || []).length;
1121
+ try { input.value = ''; } catch {}
1122
+ }
1123
+ const hadAttachments = chipCount > 0 || inputCount > 0 || removeButtons.length > 0;
1124
+ return { removeClicks: removeButtons.length, chipCount, inputCount, hadAttachments };
1125
+ })()`;
1126
+ let sawAttachments = false;
1127
+ let lastState = null;
1128
+ while (Date.now() < deadline) {
1129
+ const response = await Runtime.evaluate({ expression, returnByValue: true });
1130
+ const value = response.result?.value;
1131
+ if (value?.hadAttachments) {
1132
+ sawAttachments = true;
1133
+ }
1134
+ const chipCount = typeof value?.chipCount === 'number' ? value.chipCount : 0;
1135
+ const inputCount = typeof value?.inputCount === 'number' ? value.inputCount : 0;
1136
+ lastState = { chipCount, inputCount };
1137
+ if (chipCount === 0 && inputCount === 0) {
1138
+ return;
1139
+ }
1140
+ await delay(250);
1141
+ }
1142
+ if (sawAttachments) {
1143
+ logger?.(`Attachment cleanup timed out; still saw ${lastState?.chipCount ?? 0} chips and ${lastState?.inputCount ?? 0} inputs.`);
1144
+ throw new Error('Existing attachments still present in composer; aborting to avoid duplicate uploads.');
1145
+ }
1146
+ }
1147
+ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
1148
+ const deadline = Date.now() + timeoutMs;
1149
+ const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
1150
+ let inputMatchSince = null;
1151
+ let sawInputMatch = false;
1152
+ let attachmentMatchSince = null;
1153
+ let lastVerboseLog = 0;
1154
+ const expression = `(() => {
1155
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
1156
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
1157
+ const findPromptNode = () => {
1158
+ for (const selector of promptSelectors) {
1159
+ const nodes = Array.from(document.querySelectorAll(selector));
1160
+ for (const node of nodes) {
1161
+ if (!(node instanceof HTMLElement)) continue;
1162
+ const rect = node.getBoundingClientRect();
1163
+ if (rect.width > 0 && rect.height > 0) return node;
1164
+ }
1165
+ }
1166
+ for (const selector of promptSelectors) {
1167
+ const node = document.querySelector(selector);
1168
+ if (node) return node;
1169
+ }
1170
+ return null;
1171
+ };
1172
+ const attachmentSelectors = [
1173
+ 'input[type="file"]',
1174
+ '[data-testid*="attachment"]',
1175
+ '[data-testid*="upload"]',
1176
+ '[aria-label*="Remove"]',
1177
+ '[aria-label*="remove"]',
1178
+ ];
1179
+ const locateComposerRoot = () => {
1180
+ const promptNode = findPromptNode();
1181
+ if (promptNode) {
1182
+ const initial =
1183
+ promptNode.closest('[data-testid*="composer"]') ??
1184
+ promptNode.closest('form') ??
1185
+ promptNode.parentElement ??
1186
+ document.body;
1187
+ let current = initial;
1188
+ let fallback = initial;
1189
+ while (current && current !== document.body) {
1190
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
1191
+ if (hasSend) {
1192
+ fallback = current;
1193
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
1194
+ if (hasAttachment) {
1195
+ return current;
1196
+ }
1197
+ }
1198
+ current = current.parentElement;
1199
+ }
1200
+ return fallback ?? initial;
1201
+ }
1202
+ return document.querySelector('form') ?? document.body;
1203
+ };
1204
+ const composerRoot = locateComposerRoot();
1205
+ const composerScope = (() => {
1206
+ if (!composerRoot) return document;
1207
+ const parent = composerRoot.parentElement;
1208
+ const parentHasSend = parent && sendSelectors.some((selector) => parent.querySelector(selector));
1209
+ return parentHasSend ? parent : composerRoot;
1210
+ })();
1211
+ let button = null;
1212
+ for (const selector of sendSelectors) {
1213
+ button = document.querySelector(selector);
1214
+ if (button) break;
1215
+ }
1216
+ const disabled = button
1217
+ ? button.hasAttribute('disabled') ||
1218
+ button.getAttribute('aria-disabled') === 'true' ||
1219
+ button.getAttribute('data-disabled') === 'true' ||
1220
+ window.getComputedStyle(button).pointerEvents === 'none'
1221
+ : null;
1222
+ const uploadingSelectors = ${JSON.stringify(UPLOAD_STATUS_SELECTORS)};
1223
+ const uploading = uploadingSelectors.some((selector) => {
1224
+ return Array.from(document.querySelectorAll(selector)).some((node) => {
1225
+ const ariaBusy = node.getAttribute?.('aria-busy');
1226
+ const dataState = node.getAttribute?.('data-state');
1227
+ if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
1228
+ return true;
1229
+ }
1230
+ // Avoid false positives from user prompts ("upload:") or generic UI copy; only treat explicit progress strings as uploading.
1231
+ const text = node.textContent?.toLowerCase?.() ?? '';
1232
+ return /\buploading\b/.test(text) || /\bprocessing\b/.test(text);
1233
+ });
1234
+ });
1235
+ const attachmentChipSelectors = [
1236
+ '[data-testid*="chip"]',
1237
+ '[data-testid*="attachment"]',
1238
+ '[data-testid*="upload"]',
1239
+ '[data-testid*="file"]',
1240
+ '[aria-label*="Remove"]',
1241
+ 'button[aria-label*="Remove"]',
1242
+ ];
1243
+ const attachedNames = [];
1244
+ for (const selector of attachmentChipSelectors) {
1245
+ for (const node of Array.from(composerScope.querySelectorAll(selector))) {
1246
+ if (!node) continue;
1247
+ const text = node.textContent ?? '';
1248
+ const aria = node.getAttribute?.('aria-label') ?? '';
1249
+ const title = node.getAttribute?.('title') ?? '';
1250
+ const parentText = node.parentElement?.parentElement?.innerText ?? '';
1251
+ for (const value of [text, aria, title, parentText]) {
1252
+ const normalized = value?.toLowerCase?.();
1253
+ if (normalized) attachedNames.push(normalized);
1254
+ }
1255
+ }
1256
+ }
1257
+ const cardTexts = Array.from(composerScope.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1258
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
1259
+ );
1260
+ attachedNames.push(...cardTexts.filter(Boolean));
1261
+
1262
+ const inputNames = [];
1263
+ const inputScope = composerScope ? Array.from(composerScope.querySelectorAll('input[type="file"]')) : [];
1264
+ const inputNodes = [];
1265
+ const inputSeen = new Set();
1266
+ for (const el of [...inputScope, ...Array.from(document.querySelectorAll('input[type="file"]'))]) {
1267
+ if (!inputSeen.has(el)) {
1268
+ inputSeen.add(el);
1269
+ inputNodes.push(el);
1270
+ }
1271
+ }
1272
+ for (const input of inputNodes) {
1273
+ if (!(input instanceof HTMLInputElement) || !input.files?.length) continue;
1274
+ for (const file of Array.from(input.files)) {
1275
+ if (file?.name) inputNames.push(file.name.toLowerCase());
1276
+ }
1277
+ }
1278
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1279
+ const fileCountSelectors = [
1280
+ 'button',
1281
+ '[role="button"]',
1282
+ '[data-testid*="file"]',
1283
+ '[data-testid*="upload"]',
1284
+ '[data-testid*="attachment"]',
1285
+ '[data-testid*="chip"]',
1286
+ '[aria-label*="file"]',
1287
+ '[title*="file"]',
1288
+ '[aria-label*="attachment"]',
1289
+ '[title*="attachment"]',
1290
+ ].join(',');
1291
+ const collectFileCount = (nodes) => {
1292
+ let count = 0;
1293
+ for (const node of nodes) {
1294
+ if (!(node instanceof HTMLElement)) continue;
1295
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1296
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
1297
+ const aria = node.getAttribute?.('aria-label') ?? '';
1298
+ const title = node.getAttribute?.('title') ?? '';
1299
+ const tooltip =
1300
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1301
+ const text = node.textContent ?? '';
1302
+ const parent = node.parentElement;
1303
+ const parentText = parent?.textContent ?? '';
1304
+ const parentAria = parent?.getAttribute?.('aria-label') ?? '';
1305
+ const parentTitle = parent?.getAttribute?.('title') ?? '';
1306
+ const parentTooltip =
1307
+ parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
1308
+ const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
1309
+ const candidates = [
1310
+ text,
1311
+ aria,
1312
+ title,
1313
+ tooltip,
1314
+ dataTestId,
1315
+ parentText,
1316
+ parentAria,
1317
+ parentTitle,
1318
+ parentTooltip,
1319
+ parentTestId,
1320
+ ];
1321
+ let hasFileHint = false;
1322
+ for (const raw of candidates) {
1323
+ if (!raw) continue;
1324
+ const lowered = String(raw).toLowerCase();
1325
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1326
+ hasFileHint = true;
1327
+ break;
1328
+ }
1329
+ }
1330
+ if (!hasFileHint) continue;
1331
+ for (const raw of candidates) {
1332
+ if (!raw) continue;
1333
+ const match = String(raw).toLowerCase().match(countRegex);
1334
+ if (match) {
1335
+ const parsed = Number(match[1]);
1336
+ if (Number.isFinite(parsed)) {
1337
+ count = Math.max(count, parsed);
1338
+ }
1339
+ }
1340
+ }
1341
+ }
1342
+ return count;
1343
+ };
1344
+ const localFileCountNodes = composerScope
1345
+ ? Array.from(composerScope.querySelectorAll(fileCountSelectors))
1346
+ : [];
1347
+ let fileCount = collectFileCount(localFileCountNodes);
1348
+ if (!fileCount) {
1349
+ fileCount = collectFileCount(Array.from(document.querySelectorAll(fileCountSelectors)));
1350
+ }
1351
+ const filesAttached = attachedNames.length > 0 || fileCount > 0;
1352
+ return {
1353
+ state: button ? (disabled ? 'disabled' : 'ready') : 'missing',
1354
+ uploading,
1355
+ filesAttached,
1356
+ attachedNames,
1357
+ inputNames,
1358
+ fileCount,
1359
+ };
1360
+ })()`;
1361
+ while (Date.now() < deadline) {
1362
+ const response = await Runtime.evaluate({ expression, returnByValue: true });
1363
+ const { result } = response;
1364
+ const value = result?.value;
1365
+ if (!value && logger?.verbose) {
1366
+ const exception = response
1367
+ ?.exceptionDetails;
1368
+ if (exception) {
1369
+ const details = [exception.text, exception.exception?.description]
1370
+ .filter((part) => Boolean(part))
1371
+ .join(' - ');
1372
+ logger(`Attachment wait eval failed: ${details || 'unknown error'}`);
1373
+ }
1374
+ }
1375
+ if (value) {
1376
+ if (logger?.verbose) {
1377
+ const now = Date.now();
1378
+ if (now - lastVerboseLog > 3000) {
1379
+ lastVerboseLog = now;
1380
+ logger(`Attachment wait state: ${JSON.stringify({
1381
+ state: value.state,
1382
+ uploading: value.uploading,
1383
+ filesAttached: value.filesAttached,
1384
+ attachedNames: (value.attachedNames ?? []).slice(0, 3),
1385
+ inputNames: (value.inputNames ?? []).slice(0, 3),
1386
+ fileCount: value.fileCount ?? 0,
1387
+ })}`);
1388
+ }
1389
+ }
1390
+ const attachedNames = (value.attachedNames ?? [])
1391
+ .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
1392
+ .filter(Boolean);
1393
+ const inputNames = (value.inputNames ?? [])
1394
+ .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
1395
+ .filter(Boolean);
1396
+ const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
1397
+ const fileCountSatisfied = expectedNormalized.length > 0 && fileCount >= expectedNormalized.length;
1398
+ const matchesExpected = (expected) => {
1399
+ const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
1400
+ const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
1401
+ const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
1402
+ return attachedNames.some((raw) => {
1403
+ if (raw.includes(normalizedExpected))
1404
+ return true;
1405
+ if (expectedNoExt.length >= 6 && raw.includes(expectedNoExt))
1406
+ return true;
1407
+ if (raw.includes('…') || raw.includes('...')) {
1408
+ const marker = raw.includes('…') ? '…' : '...';
1409
+ const [prefixRaw, suffixRaw] = raw.split(marker);
1410
+ const prefix = prefixRaw.trim();
1411
+ const suffix = suffixRaw.trim();
1412
+ const target = expectedNoExt.length >= 6 ? expectedNoExt : normalizedExpected;
1413
+ const matchesPrefix = !prefix || target.includes(prefix);
1414
+ const matchesSuffix = !suffix || target.includes(suffix);
1415
+ return matchesPrefix && matchesSuffix;
1416
+ }
1417
+ return false;
1418
+ });
1419
+ };
1420
+ const missing = expectedNormalized.filter((expected) => !matchesExpected(expected));
1421
+ if (missing.length === 0 || fileCountSatisfied) {
1422
+ const stableThresholdMs = value.uploading ? 3000 : 1500;
1423
+ if (attachmentMatchSince === null) {
1424
+ attachmentMatchSince = Date.now();
1425
+ }
1426
+ const stable = Date.now() - attachmentMatchSince > stableThresholdMs;
1427
+ if (stable && value.state === 'ready') {
1428
+ return;
1429
+ }
1430
+ // Don't treat disabled button as complete - wait for it to become 'ready'.
1431
+ // The spinner detection is unreliable, so a disabled button likely means upload is in progress.
1432
+ if (value.state === 'missing' && (value.filesAttached || fileCountSatisfied)) {
1433
+ return;
1434
+ }
1435
+ // If files are attached but button isn't ready yet, give it more time but don't fail immediately.
1436
+ if (value.filesAttached || fileCountSatisfied) {
1437
+ await delay(500);
1438
+ continue;
1439
+ }
1440
+ }
1441
+ else {
1442
+ attachmentMatchSince = null;
1443
+ }
1444
+ // Fallback: if the file input has the expected names, allow progress once that condition is stable.
1445
+ // Some ChatGPT surfaces only render the filename after sending the message.
1446
+ const inputMissing = expectedNormalized.filter((expected) => {
1447
+ const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
1448
+ const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
1449
+ const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
1450
+ return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
1451
+ });
1452
+ // Don't include 'disabled' - a disabled button likely means upload is still in progress.
1453
+ const inputStateOk = value.state === 'ready' || value.state === 'missing';
1454
+ const inputSeenNow = inputMissing.length === 0 || fileCountSatisfied;
1455
+ const inputEvidenceOk = Boolean(value.filesAttached) || Boolean(value.uploading) || fileCountSatisfied;
1456
+ const stableThresholdMs = value.uploading ? 3000 : 1500;
1457
+ if (inputSeenNow && inputStateOk && inputEvidenceOk) {
1458
+ if (inputMatchSince === null) {
1459
+ inputMatchSince = Date.now();
1460
+ }
1461
+ sawInputMatch = true;
1462
+ }
1463
+ if (inputMatchSince !== null && inputStateOk && inputEvidenceOk && Date.now() - inputMatchSince > stableThresholdMs) {
1464
+ return;
1465
+ }
1466
+ if (!inputSeenNow && !sawInputMatch) {
1467
+ inputMatchSince = null;
1468
+ }
1469
+ }
1470
+ await delay(250);
1471
+ }
1472
+ logger?.('Attachment upload timed out while waiting for ChatGPT composer to become ready.');
1473
+ await logDomFailure(Runtime, logger ?? (() => { }), 'file-upload-timeout');
1474
+ throw new Error('Attachments did not finish uploading before timeout.');
1475
+ }
1476
+ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeoutMs, logger) {
1477
+ if (!expectedNames || expectedNames.length === 0) {
1478
+ return true;
1479
+ }
1480
+ const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
1481
+ const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
1482
+ const expression = `(() => {
1483
+ const CONVERSATION_SELECTOR = ${conversationSelectorLiteral};
1484
+ const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
1485
+ const userTurns = turns.filter((node) => {
1486
+ const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
1487
+ if (attr === 'user') return true;
1488
+ return Boolean(node.querySelector('[data-message-author-role="user"]'));
1489
+ });
1490
+ const lastUser = userTurns[userTurns.length - 1];
1491
+ if (!lastUser) return { ok: false };
1492
+ const text = (lastUser.innerText || '').toLowerCase();
1493
+ const attrs = Array.from(lastUser.querySelectorAll('[aria-label],[title]')).map((el) => {
1494
+ const aria = el.getAttribute('aria-label') || '';
1495
+ const title = el.getAttribute('title') || '';
1496
+ return (aria + ' ' + title).trim().toLowerCase();
1497
+ }).filter(Boolean);
1498
+ const attachmentSelectors = [
1499
+ '[data-testid*="attachment"]',
1500
+ '[data-testid*="upload"]',
1501
+ '[data-testid*="chip"]',
1502
+ '[aria-label*="file"]',
1503
+ '[aria-label*="attachment"]',
1504
+ '[title*="file"]',
1505
+ '[title*="attachment"]',
1506
+ ];
1507
+ const attachmentUiCount = lastUser.querySelectorAll(attachmentSelectors.join(',')).length;
1508
+ const hasAttachmentUi =
1509
+ attachmentUiCount > 0 || attrs.some((attr) => attr.includes('file') || attr.includes('attachment'));
1510
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1511
+ const fileCountNodes = Array.from(lastUser.querySelectorAll('button,span,div,[aria-label],[title]'));
1512
+ let fileCount = 0;
1513
+ for (const node of fileCountNodes) {
1514
+ if (!(node instanceof HTMLElement)) continue;
1515
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1516
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
1517
+ const aria = node.getAttribute?.('aria-label') ?? '';
1518
+ const title = node.getAttribute?.('title') ?? '';
1519
+ const tooltip =
1520
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1521
+ const nodeText = node.textContent ?? '';
1522
+ const candidates = [nodeText, aria, title, tooltip, dataTestId];
1523
+ let hasFileHint = false;
1524
+ for (const raw of candidates) {
1525
+ if (!raw) continue;
1526
+ const lowered = String(raw).toLowerCase();
1527
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1528
+ hasFileHint = true;
1529
+ break;
1530
+ }
1531
+ }
1532
+ if (!hasFileHint) continue;
1533
+ for (const raw of candidates) {
1534
+ if (!raw) continue;
1535
+ const match = String(raw).toLowerCase().match(countRegex);
1536
+ if (match) {
1537
+ const count = Number(match[1]);
1538
+ if (Number.isFinite(count)) {
1539
+ fileCount = Math.max(fileCount, count);
1540
+ }
1541
+ }
1542
+ }
1543
+ }
1544
+ return { ok: true, text, attrs, fileCount, hasAttachmentUi, attachmentUiCount };
1545
+ })()`;
1546
+ const deadline = Date.now() + timeoutMs;
1547
+ let sawAttachmentUi = false;
1548
+ while (Date.now() < deadline) {
1549
+ const { result } = await Runtime.evaluate({ expression, returnByValue: true });
1550
+ const value = result?.value;
1551
+ if (!value?.ok) {
1552
+ await delay(200);
1553
+ continue;
1554
+ }
1555
+ if (value.hasAttachmentUi) {
1556
+ sawAttachmentUi = true;
1557
+ }
1558
+ const haystack = [value.text ?? '', ...(value.attrs ?? [])].join('\n');
1559
+ const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
1560
+ const attachmentUiCount = typeof value.attachmentUiCount === 'number' ? value.attachmentUiCount : 0;
1561
+ const fileCountSatisfied = fileCount >= expectedNormalized.length && expectedNormalized.length > 0;
1562
+ const attachmentUiSatisfied = attachmentUiCount >= expectedNormalized.length && expectedNormalized.length > 0;
1563
+ const missing = expectedNormalized.filter((expected) => {
1564
+ const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
1565
+ const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
1566
+ const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
1567
+ if (haystack.includes(normalizedExpected))
1568
+ return false;
1569
+ if (expectedNoExt.length >= 6 && haystack.includes(expectedNoExt))
1570
+ return false;
1571
+ return true;
1572
+ });
1573
+ if (missing.length === 0 || fileCountSatisfied || attachmentUiSatisfied) {
1574
+ return true;
1575
+ }
1576
+ await delay(250);
1577
+ }
1578
+ if (!sawAttachmentUi) {
1579
+ logger?.('Sent user message did not expose attachment UI; skipping attachment verification.');
1580
+ return false;
1581
+ }
1582
+ logger?.('Sent user message did not show expected attachment names in time.');
1583
+ await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-missing-user-turn');
1584
+ throw new Error('Attachment was not present on the sent user message.');
1585
+ }
1586
+ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
1587
+ // Attachments can take a few seconds to render in the composer (headless/remote Chrome is slower),
1588
+ // so respect the caller-provided timeout instead of capping at 2s.
1589
+ const deadline = Date.now() + timeoutMs;
1590
+ const expression = `(() => {
1591
+ const expected = ${JSON.stringify(expectedName)};
1592
+ const normalized = expected.toLowerCase();
1593
+ const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
1594
+ const matchesExpectedFileName = (value) => {
1595
+ const text = String(value || '').toLowerCase();
1596
+ if (!text) return false;
1597
+ if (text.includes(normalized)) return true;
1598
+ return normalizedNoExt.length >= 6 && text.includes(normalizedNoExt);
1599
+ };
1600
+ const matchNode = (node) => {
1601
+ if (!node) return false;
1602
+ if (node.tagName === 'INPUT' && node.type === 'file') return false;
1603
+ const text = (node.textContent || '').toLowerCase();
1604
+ const aria = node.getAttribute?.('aria-label')?.toLowerCase?.() ?? '';
1605
+ const title = node.getAttribute?.('title')?.toLowerCase?.() ?? '';
1606
+ const testId = node.getAttribute?.('data-testid')?.toLowerCase?.() ?? '';
1607
+ const alt = node.getAttribute?.('alt')?.toLowerCase?.() ?? '';
1608
+ const candidates = [text, aria, title, testId, alt].filter(Boolean);
1609
+ return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
1610
+ };
1611
+
1612
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
1613
+ for (const input of inputs) {
1614
+ if (!(input instanceof HTMLInputElement)) continue;
1615
+ const files = Array.from(input.files || []);
1616
+ if (files.some((file) => matchesExpectedFileName(file?.name))) {
1617
+ return { found: true, source: 'file-input' };
1618
+ }
1619
+ }
1620
+
1621
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
1622
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
1623
+ const findPromptNode = () => {
1624
+ for (const selector of promptSelectors) {
1625
+ const nodes = Array.from(document.querySelectorAll(selector));
1626
+ for (const node of nodes) {
1627
+ if (!(node instanceof HTMLElement)) continue;
1628
+ const rect = node.getBoundingClientRect();
1629
+ if (rect.width > 0 && rect.height > 0) return node;
1630
+ }
1631
+ }
1632
+ for (const selector of promptSelectors) {
1633
+ const node = document.querySelector(selector);
1634
+ if (node) return node;
1635
+ }
1636
+ return null;
1637
+ };
1638
+ const attachmentSelectors = [
1639
+ 'input[type="file"]',
1640
+ '[data-testid*="attachment"]',
1641
+ '[data-testid*="chip"]',
1642
+ '[data-testid*="upload"]',
1643
+ '[data-testid*="file"]',
1644
+ '[aria-label*="Remove"]',
1645
+ '[aria-label*="remove"]',
1646
+ ];
1647
+ const locateComposerRoot = () => {
1648
+ const promptNode = findPromptNode();
1649
+ if (promptNode) {
1650
+ const initial =
1651
+ promptNode.closest('[data-testid*="composer"]') ??
1652
+ promptNode.closest('form') ??
1653
+ promptNode.parentElement ??
1654
+ document.body;
1655
+ let current = initial;
1656
+ let fallback = initial;
1657
+ while (current && current !== document.body) {
1658
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
1659
+ if (hasSend) {
1660
+ fallback = current;
1661
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
1662
+ if (hasAttachment) return current;
1663
+ }
1664
+ current = current.parentElement;
1665
+ }
1666
+ return fallback ?? initial;
1667
+ }
1668
+ return document.querySelector('form') ?? document.body;
1669
+ };
1670
+ const composerRoot = locateComposerRoot() ?? document.body;
1671
+
1672
+ const attachmentMatch = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]','[data-testid*="file"]'].some((selector) =>
1673
+ Array.from(composerRoot.querySelectorAll(selector)).some(matchNode),
1674
+ );
1675
+ if (attachmentMatch) {
1676
+ return { found: true, source: 'attachments' };
1677
+ }
1678
+
1679
+ const removeButtons = Array.from(
1680
+ (composerRoot ?? document).querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]'),
1681
+ );
1682
+ const visibleRemove = removeButtons.some((btn) => {
1683
+ if (!(btn instanceof HTMLElement)) return false;
1684
+ const rect = btn.getBoundingClientRect();
1685
+ if (rect.width <= 0 || rect.height <= 0) return false;
1686
+ const style = window.getComputedStyle(btn);
1687
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
1688
+ });
1689
+ if (visibleRemove) {
1690
+ return { found: true, source: 'remove-button' };
1691
+ }
1692
+
1693
+ const cardTexts = Array.from(composerRoot.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1694
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
1695
+ );
1696
+ if (cardTexts.some((text) => text.includes(normalized) || (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)))) {
1697
+ return { found: true, source: 'attachment-cards' };
1698
+ }
1699
+
1700
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1701
+ const fileCountNodes = Array.from(composerRoot.querySelectorAll('button,span,div,[aria-label],[title]'));
1702
+ let fileCount = 0;
1703
+ for (const node of fileCountNodes) {
1704
+ if (!(node instanceof HTMLElement)) continue;
1705
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1706
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
1707
+ const aria = node.getAttribute?.('aria-label') ?? '';
1708
+ const title = node.getAttribute?.('title') ?? '';
1709
+ const tooltip =
1710
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1711
+ const text = node.textContent ?? '';
1712
+ const parent = node.parentElement;
1713
+ const parentText = parent?.textContent ?? '';
1714
+ const parentAria = parent?.getAttribute?.('aria-label') ?? '';
1715
+ const parentTitle = parent?.getAttribute?.('title') ?? '';
1716
+ const parentTooltip =
1717
+ parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
1718
+ const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
1719
+ const candidates = [
1720
+ text,
1721
+ aria,
1722
+ title,
1723
+ tooltip,
1724
+ dataTestId,
1725
+ parentText,
1726
+ parentAria,
1727
+ parentTitle,
1728
+ parentTooltip,
1729
+ parentTestId,
1730
+ ];
1731
+ let hasFileHint = false;
1732
+ for (const raw of candidates) {
1733
+ if (!raw) continue;
1734
+ const lowered = String(raw).toLowerCase();
1735
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1736
+ hasFileHint = true;
1737
+ break;
1738
+ }
1739
+ }
1740
+ if (!hasFileHint) continue;
1741
+ for (const raw of candidates) {
1742
+ if (!raw) continue;
1743
+ const match = String(raw).toLowerCase().match(countRegex);
1744
+ if (match) {
1745
+ const count = Number(match[1]);
1746
+ if (Number.isFinite(count)) {
1747
+ fileCount = Math.max(fileCount, count);
1748
+ }
1749
+ }
1750
+ }
1751
+ }
1752
+ if (fileCount > 0) {
1753
+ return { found: true, source: 'file-count' };
1754
+ }
1755
+
1756
+ return { found: false };
1757
+ })()`;
1758
+ while (Date.now() < deadline) {
1759
+ const { result } = await Runtime.evaluate({ expression, returnByValue: true });
1760
+ const value = result?.value;
1761
+ if (value?.found) {
1762
+ return;
1763
+ }
1764
+ await delay(200);
1765
+ }
1766
+ logger?.('Attachment not visible in composer; giving up.');
1767
+ await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-visible');
1768
+ throw new Error('Attachment did not appear in ChatGPT composer.');
1769
+ }
1770
+ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1771
+ const deadline = Date.now() + timeoutMs;
1772
+ const expression = `(() => {
1773
+ const normalized = ${JSON.stringify(expectedName.toLowerCase())};
1774
+ const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
1775
+ const matchesExpected = (value) => {
1776
+ const text = (value ?? '').toLowerCase();
1777
+ if (!text) return false;
1778
+ if (text.includes(normalized)) return true;
1779
+ if (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)) return true;
1780
+ if (text.includes('…') || text.includes('...')) {
1781
+ const marker = text.includes('…') ? '…' : '...';
1782
+ const [prefixRaw, suffixRaw] = text.split(marker);
1783
+ const prefix = (prefixRaw ?? '').toLowerCase();
1784
+ const suffix = (suffixRaw ?? '').toLowerCase();
1785
+ const target = normalizedNoExt.length >= 6 ? normalizedNoExt : normalized;
1786
+ const matchesPrefix = !prefix || target.includes(prefix);
1787
+ const matchesSuffix = !suffix || target.includes(suffix);
1788
+ return matchesPrefix && matchesSuffix;
1789
+ }
1790
+ return false;
1791
+ };
1792
+
1793
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
1794
+ for (const input of inputs) {
1795
+ if (!(input instanceof HTMLInputElement)) continue;
1796
+ for (const file of Array.from(input.files || [])) {
1797
+ if (file?.name && matchesExpected(file.name)) {
1798
+ return { found: true, text: 'file-input' };
1799
+ }
1800
+ }
1801
+ }
1802
+
1803
+ const selectors = [
1804
+ '[data-testid*="attachment"]',
1805
+ '[data-testid*="chip"]',
1806
+ '[data-testid*="upload"]',
1807
+ '[aria-label*="Remove"]',
1808
+ 'button[aria-label*="Remove"]',
1809
+ '[aria-label*="remove"]',
1810
+ 'button[aria-label*="remove"]',
1811
+ ];
1812
+ for (const selector of selectors) {
1813
+ for (const node of Array.from(document.querySelectorAll(selector))) {
1814
+ if (node?.tagName === 'INPUT' && node?.type === 'file') continue;
1815
+ const text = node?.textContent || '';
1816
+ const aria = node?.getAttribute?.('aria-label') || '';
1817
+ const title = node?.getAttribute?.('title') || '';
1818
+ if ([text, aria, title].some(matchesExpected)) {
1819
+ return { found: true, text: (text || aria || title).toLowerCase() };
1820
+ }
1821
+ }
1822
+ }
1823
+ const cards = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1824
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
1825
+ );
1826
+ if (cards.some(matchesExpected)) {
1827
+ return { found: true, text: cards.find(matchesExpected) };
1828
+ }
1829
+ const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1830
+ const fileCountNodes = (() => {
1831
+ const nodes = [];
1832
+ const seen = new Set();
1833
+ const add = (node) => {
1834
+ if (!node || seen.has(node)) return;
1835
+ seen.add(node);
1836
+ nodes.push(node);
1837
+ };
1838
+ const root =
1839
+ document.querySelector('[data-testid*="composer"]') || document.querySelector('form') || document.body;
1840
+ const localNodes = root ? Array.from(root.querySelectorAll('button,span,div,[aria-label],[title]')) : [];
1841
+ for (const node of localNodes) add(node);
1842
+ for (const node of Array.from(document.querySelectorAll('button,span,div,[aria-label],[title]'))) {
1843
+ add(node);
1844
+ }
1845
+ return nodes;
1846
+ })();
1847
+ let fileCount = 0;
1848
+ for (const node of fileCountNodes) {
1849
+ if (!(node instanceof HTMLElement)) continue;
1850
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1851
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
1852
+ const aria = node.getAttribute?.('aria-label') ?? '';
1853
+ const title = node.getAttribute?.('title') ?? '';
1854
+ const tooltip =
1855
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1856
+ const text = node.textContent ?? '';
1857
+ const parent = node.parentElement;
1858
+ const parentText = parent?.textContent ?? '';
1859
+ const parentAria = parent?.getAttribute?.('aria-label') ?? '';
1860
+ const parentTitle = parent?.getAttribute?.('title') ?? '';
1861
+ const parentTooltip =
1862
+ parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
1863
+ const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
1864
+ const candidates = [
1865
+ text,
1866
+ aria,
1867
+ title,
1868
+ tooltip,
1869
+ dataTestId,
1870
+ parentText,
1871
+ parentAria,
1872
+ parentTitle,
1873
+ parentTooltip,
1874
+ parentTestId,
1875
+ ];
1876
+ let hasFileHint = false;
1877
+ for (const raw of candidates) {
1878
+ if (!raw) continue;
1879
+ const lowered = String(raw).toLowerCase();
1880
+ if (lowered.includes('file') || lowered.includes('attachment')) {
1881
+ hasFileHint = true;
1882
+ break;
1883
+ }
1884
+ }
1885
+ if (!hasFileHint) continue;
1886
+ for (const raw of candidates) {
1887
+ if (!raw) continue;
1888
+ const match = String(raw).toLowerCase().match(countRegex);
1889
+ if (match) {
1890
+ const count = Number(match[1]);
1891
+ if (Number.isFinite(count)) {
1892
+ fileCount = Math.max(fileCount, count);
1893
+ }
1894
+ }
1895
+ }
1896
+ }
1897
+ if (fileCount > 0) {
1898
+ return { found: true, text: 'file-count' };
1899
+ }
1900
+ return { found: false };
1901
+ })()`;
1902
+ while (Date.now() < deadline) {
1903
+ const { result } = await Runtime.evaluate({ expression, returnByValue: true });
1904
+ if (result?.value?.found) {
1905
+ return true;
1906
+ }
1907
+ await delay(200);
1908
+ }
1909
+ return false;
1910
+ }