@steipete/oracle 0.7.5 → 0.8.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.
@@ -2,39 +2,275 @@ import path from 'node:path';
2
2
  import { CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from '../constants.js';
3
3
  import { delay } from '../utils.js';
4
4
  import { logDomFailure } from '../domDebug.js';
5
- export async function uploadAttachmentFile(deps, attachment, logger) {
5
+ import { transferAttachmentViaDataTransfer } from './attachmentDataTransfer.js';
6
+ export async function uploadAttachmentFile(deps, attachment, logger, options) {
6
7
  const { runtime, dom } = deps;
7
8
  if (!dom) {
8
9
  throw new Error('DOM domain unavailable while uploading attachments.');
9
10
  }
10
- const isAttachmentPresent = async (name) => {
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) => {
11
15
  const check = await runtime.evaluate({
12
16
  expression: `(() => {
13
- const expected = ${JSON.stringify(name.toLowerCase())};
14
- const selectors = [
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"]',
15
58
  '[data-testid*="attachment"]',
16
- '[data-testid*="chip"]',
17
- '[data-testid*="upload"]'
59
+ '[data-testid*="upload"]',
60
+ '[aria-label*="Remove"]',
61
+ '[aria-label*="remove"]',
18
62
  ];
19
- const chips = selectors.some((selector) =>
20
- Array.from(document.querySelectorAll(selector)).some((node) =>
21
- (node?.textContent || '').toLowerCase().includes(expected),
22
- ),
23
- );
24
- if (chips) return true;
25
- const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]')).map((btn) =>
26
- btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
27
- );
28
- if (cardTexts.some((text) => text.includes(expected))) return true;
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?\\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
+ if (normalize(raw).includes('file')) {
208
+ hasFileHint = true;
209
+ break;
210
+ }
211
+ }
212
+ if (!hasFileHint) continue;
213
+ for (const raw of values) {
214
+ if (!raw) continue;
215
+ const match = normalize(raw).match(countRegex);
216
+ if (match) {
217
+ const parsed = Number(match[1]);
218
+ if (Number.isFinite(parsed)) {
219
+ count = Math.max(count, parsed);
220
+ }
221
+ }
222
+ }
223
+ }
224
+ return count;
225
+ };
226
+ const fileCountSelectors = [
227
+ 'button',
228
+ '[role="button"]',
229
+ '[data-testid*="file"]',
230
+ '[data-testid*="upload"]',
231
+ '[data-testid*="attachment"]',
232
+ '[data-testid*="chip"]',
233
+ '[aria-label*="file"]',
234
+ '[title*="file"]',
235
+ '[aria-label*="attachment"]',
236
+ '[title*="attachment"]',
237
+ ].join(',');
238
+ const fileCountScope = scope ?? root ?? document.body;
239
+ const localFileNodes = fileCountScope
240
+ ? Array.from(fileCountScope.querySelectorAll(fileCountSelectors))
241
+ : [];
242
+ const globalFileNodes = Array.from(document.querySelectorAll(fileCountSelectors));
243
+ let fileCount = collectFileCount(localFileNodes);
244
+ if (!fileCount && globalFileNodes.length > 0) {
245
+ fileCount = collectFileCount(globalFileNodes);
246
+ }
247
+ const hasAttachmentSignal = localCandidates.length > 0 || inputCount > 0 || fileCount > 0 || uploading;
248
+ if (!uiMatch && rootTextRaw && hasAttachmentSignal && matchesExpected(rootTextRaw)) {
249
+ uiMatch = true;
250
+ }
29
251
 
30
- const inputs = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
31
- Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(expected)),
32
- );
33
- return inputs;
252
+ return {
253
+ ui: uiMatch,
254
+ input: inputMatch,
255
+ inputCount,
256
+ chipCount: localCandidates.length,
257
+ chipSignature,
258
+ uploading,
259
+ fileCount,
260
+ };
34
261
  })()`,
35
262
  returnByValue: true,
36
263
  });
37
- return Boolean(check?.result?.value);
264
+ const value = check?.result?.value;
265
+ return {
266
+ ui: Boolean(value?.ui),
267
+ input: Boolean(value?.input),
268
+ inputCount: typeof value?.inputCount === 'number' ? value?.inputCount : 0,
269
+ chipCount: typeof value?.chipCount === 'number' ? value?.chipCount : 0,
270
+ chipSignature: typeof value?.chipSignature === 'string' ? value?.chipSignature : '',
271
+ uploading: Boolean(value?.uploading),
272
+ fileCount: typeof value?.fileCount === 'number' ? value?.fileCount : 0,
273
+ };
38
274
  };
39
275
  // New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
40
276
  await Promise.resolve(runtime.evaluate({
@@ -74,26 +310,126 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
74
310
  })()`,
75
311
  returnByValue: true,
76
312
  })).catch(() => undefined);
313
+ const normalizeForMatch = (value) => String(value || '')
314
+ .toLowerCase()
315
+ .replace(/\s+/g, ' ')
316
+ .trim();
77
317
  const expectedName = path.basename(attachment.path);
78
- if (await isAttachmentPresent(expectedName)) {
318
+ const expectedNameLower = normalizeForMatch(expectedName);
319
+ const expectedNameNoExt = expectedNameLower.replace(/\.[a-z0-9]{1,10}$/i, '');
320
+ const matchesExpectedName = (value) => {
321
+ const normalized = normalizeForMatch(value);
322
+ if (!normalized)
323
+ return false;
324
+ if (normalized.includes(expectedNameLower))
325
+ return true;
326
+ if (expectedNameNoExt.length >= 6 && normalized.includes(expectedNameNoExt))
327
+ return true;
328
+ return false;
329
+ };
330
+ const isImageAttachment = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif)$/i.test(expectedName);
331
+ const attachmentUiTimeoutMs = 25_000;
332
+ const attachmentUiSignalWaitMs = 5_000;
333
+ const initialSignals = await readAttachmentSignals(expectedName);
334
+ let inputConfirmed = false;
335
+ if (initialSignals.ui) {
79
336
  logger(`Attachment already present: ${path.basename(attachment.path)}`);
80
- return;
337
+ return true;
338
+ }
339
+ const isExpectedSatisfied = (signals) => {
340
+ if (expectedCount <= 0)
341
+ return false;
342
+ const fileCount = typeof signals.fileCount === 'number' ? signals.fileCount : 0;
343
+ const chipCount = typeof signals.chipCount === 'number' ? signals.chipCount : 0;
344
+ if (fileCount >= expectedCount)
345
+ return true;
346
+ return Boolean(signals.ui && chipCount >= expectedCount);
347
+ };
348
+ const initialInputSatisfied = expectedCount > 0 ? initialSignals.inputCount >= expectedCount : Boolean(initialSignals.input);
349
+ if (expectedCount > 0 && (initialSignals.fileCount >= expectedCount || initialSignals.inputCount >= expectedCount)) {
350
+ const satisfiedCount = Math.max(initialSignals.fileCount, initialSignals.inputCount);
351
+ logger(`Attachment already present: composer shows ${satisfiedCount} file${satisfiedCount === 1 ? '' : 's'}`);
352
+ return true;
353
+ }
354
+ if (initialInputSatisfied || initialSignals.input) {
355
+ logger(`Attachment already queued in file input: ${path.basename(attachment.path)}`);
356
+ return true;
81
357
  }
82
358
  const documentNode = await dom.getDocument();
83
359
  const candidateSetup = await runtime.evaluate({
84
360
  expression: `(() => {
85
361
  const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
86
- const locateComposerRoot = () => {
362
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
363
+ const findPromptNode = () => {
364
+ for (const selector of promptSelectors) {
365
+ const nodes = Array.from(document.querySelectorAll(selector));
366
+ for (const node of nodes) {
367
+ if (!(node instanceof HTMLElement)) continue;
368
+ const rect = node.getBoundingClientRect();
369
+ if (rect.width > 0 && rect.height > 0) return node;
370
+ }
371
+ }
87
372
  for (const selector of promptSelectors) {
88
373
  const node = document.querySelector(selector);
89
- if (!node) continue;
90
- return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
374
+ if (node) return node;
375
+ }
376
+ return null;
377
+ };
378
+ const attachmentSelectors = [
379
+ 'input[type="file"]',
380
+ '[data-testid*="attachment"]',
381
+ '[data-testid*="upload"]',
382
+ '[aria-label*="Remove"]',
383
+ '[aria-label*="remove"]',
384
+ ];
385
+ const locateComposerRoot = () => {
386
+ const promptNode = findPromptNode();
387
+ if (promptNode) {
388
+ const initial =
389
+ promptNode.closest('[data-testid*="composer"]') ??
390
+ promptNode.closest('form') ??
391
+ promptNode.parentElement ??
392
+ document.body;
393
+ let current = initial;
394
+ let fallback = initial;
395
+ while (current && current !== document.body) {
396
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
397
+ if (hasSend) {
398
+ fallback = current;
399
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
400
+ if (hasAttachment) {
401
+ return current;
402
+ }
403
+ }
404
+ current = current.parentElement;
405
+ }
406
+ return fallback ?? initial;
91
407
  }
92
408
  return document.querySelector('form') ?? document.body;
93
409
  };
94
410
  const root = locateComposerRoot();
95
- const localInputs = root ? Array.from(root.querySelectorAll('input[type="file"]')) : [];
96
- const inputs = localInputs.length > 0 ? localInputs : Array.from(document.querySelectorAll('input[type="file"]'));
411
+ const scope = (() => {
412
+ if (!root) return document.body;
413
+ const parent = root.parentElement;
414
+ const parentHasSend = parent && sendSelectors.some((selector) => parent.querySelector(selector));
415
+ return parentHasSend ? parent : root;
416
+ })();
417
+ const localInputs = scope ? Array.from(scope.querySelectorAll('input[type="file"]')) : [];
418
+ const globalInputs = Array.from(document.querySelectorAll('input[type="file"]'));
419
+ const inputs = [];
420
+ const inputSeen = new Set();
421
+ for (const el of [...localInputs, ...globalInputs]) {
422
+ if (!inputSeen.has(el)) {
423
+ inputSeen.add(el);
424
+ inputs.push(el);
425
+ }
426
+ }
427
+ const baselineInputCount = inputs.reduce((total, el) => {
428
+ if (!(el instanceof HTMLInputElement)) return total;
429
+ const count = Array.from(el.files || []).length;
430
+ return total + count;
431
+ }, 0);
432
+ const isImageAttachment = ${JSON.stringify(isImageAttachment)};
97
433
  const acceptIsImageOnly = (accept) => {
98
434
  if (!accept) return false;
99
435
  const parts = String(accept)
@@ -102,53 +438,295 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
102
438
  .filter(Boolean);
103
439
  return parts.length > 0 && parts.every((p) => p.startsWith('image/'));
104
440
  };
105
- const chipContainer = root ?? document;
106
- const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
441
+ const chipContainer = scope ?? document;
442
+ const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[data-testid*="file"],[aria-label*="Remove"],[aria-label*="remove"]';
107
443
  const baselineChipCount = chipContainer.querySelectorAll(chipSelector).length;
444
+ const baselineChips = Array.from(chipContainer.querySelectorAll(chipSelector))
445
+ .slice(0, 20)
446
+ .map((node) => ({
447
+ text: (node.textContent || '').trim(),
448
+ aria: node.getAttribute?.('aria-label') ?? '',
449
+ title: node.getAttribute?.('title') ?? '',
450
+ testid: node.getAttribute?.('data-testid') ?? '',
451
+ }));
452
+ const uploadingSelectors = ${JSON.stringify(UPLOAD_STATUS_SELECTORS)};
453
+ const baselineUploading = uploadingSelectors.some((selector) => {
454
+ return Array.from(document.querySelectorAll(selector)).some((node) => {
455
+ const ariaBusy = node.getAttribute?.('aria-busy');
456
+ const dataState = node.getAttribute?.('data-state');
457
+ if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
458
+ return true;
459
+ }
460
+ const text = node.textContent?.toLowerCase?.() ?? '';
461
+ return /\\buploading\\b/.test(text) || /\\bprocessing\\b/.test(text);
462
+ });
463
+ });
464
+ const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
465
+ const collectFileCount = (candidates) => {
466
+ let count = 0;
467
+ for (const node of candidates) {
468
+ if (!(node instanceof HTMLElement)) continue;
469
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
470
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
471
+ const aria = node.getAttribute?.('aria-label') ?? '';
472
+ const title = node.getAttribute?.('title') ?? '';
473
+ const tooltip =
474
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
475
+ const text = node.textContent ?? '';
476
+ const parent = node.parentElement;
477
+ const parentText = parent?.textContent ?? '';
478
+ const parentAria = parent?.getAttribute?.('aria-label') ?? '';
479
+ const parentTitle = parent?.getAttribute?.('title') ?? '';
480
+ const parentTooltip =
481
+ parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
482
+ const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
483
+ const values = [
484
+ text,
485
+ aria,
486
+ title,
487
+ tooltip,
488
+ dataTestId,
489
+ parentText,
490
+ parentAria,
491
+ parentTitle,
492
+ parentTooltip,
493
+ parentTestId,
494
+ ];
495
+ let hasFileHint = false;
496
+ for (const raw of values) {
497
+ if (!raw) continue;
498
+ if (String(raw).toLowerCase().includes('file')) {
499
+ hasFileHint = true;
500
+ break;
501
+ }
502
+ }
503
+ if (!hasFileHint) continue;
504
+ for (const raw of values) {
505
+ if (!raw) continue;
506
+ const match = String(raw).toLowerCase().match(countRegex);
507
+ if (match) {
508
+ const parsed = Number(match[1]);
509
+ if (Number.isFinite(parsed)) {
510
+ count = Math.max(count, parsed);
511
+ }
512
+ }
513
+ }
514
+ }
515
+ return count;
516
+ };
517
+ const fileCountSelectors = [
518
+ 'button',
519
+ '[role="button"]',
520
+ '[data-testid*="file"]',
521
+ '[data-testid*="upload"]',
522
+ '[data-testid*="attachment"]',
523
+ '[data-testid*="chip"]',
524
+ '[aria-label*="file"]',
525
+ '[title*="file"]',
526
+ '[aria-label*="attachment"]',
527
+ '[title*="attachment"]',
528
+ ].join(',');
529
+ const fileCountScope = scope ?? root ?? document.body;
530
+ const localFileNodes = fileCountScope
531
+ ? Array.from(fileCountScope.querySelectorAll(fileCountSelectors))
532
+ : [];
533
+ const globalFileNodes = Array.from(document.querySelectorAll(fileCountSelectors));
534
+ let baselineFileCount = collectFileCount(localFileNodes);
535
+ if (!baselineFileCount && globalFileNodes.length > 0) {
536
+ baselineFileCount = collectFileCount(globalFileNodes);
537
+ }
108
538
 
109
539
  // Mark candidates with stable indices so we can select them via DOM.querySelector.
110
540
  let idx = 0;
111
- const candidates = inputs.map((el) => {
541
+ let candidates = inputs.map((el) => {
112
542
  const accept = el.getAttribute('accept') || '';
113
- const score = (el.hasAttribute('multiple') ? 100 : 0) + (!acceptIsImageOnly(accept) ? 10 : 0);
543
+ const imageOnly = acceptIsImageOnly(accept);
544
+ const score =
545
+ (el.hasAttribute('multiple') ? 100 : 0) +
546
+ (!imageOnly ? 20 : isImageAttachment ? 15 : -500);
114
547
  el.setAttribute('data-oracle-upload-candidate', 'true');
115
548
  el.setAttribute('data-oracle-upload-idx', String(idx));
116
- return { idx: idx++, score };
549
+ return { idx: idx++, score, imageOnly };
117
550
  });
551
+ if (!isImageAttachment) {
552
+ const nonImage = candidates.filter((candidate) => !candidate.imageOnly);
553
+ if (nonImage.length > 0) {
554
+ candidates = nonImage;
555
+ }
556
+ }
118
557
 
119
558
  // Prefer higher scores first.
120
559
  candidates.sort((a, b) => b.score - a.score);
121
- return { ok: candidates.length > 0, baselineChipCount, order: candidates.map((c) => c.idx) };
560
+ return {
561
+ ok: candidates.length > 0,
562
+ baselineChipCount,
563
+ baselineChips,
564
+ baselineUploading,
565
+ baselineFileCount,
566
+ baselineInputCount,
567
+ order: candidates.map((c) => c.idx),
568
+ };
122
569
  })()`,
123
570
  returnByValue: true,
124
571
  });
125
572
  const candidateValue = candidateSetup?.result?.value;
126
573
  const candidateOrder = Array.isArray(candidateValue?.order) ? candidateValue.order : [];
127
574
  const baselineChipCount = typeof candidateValue?.baselineChipCount === 'number' ? candidateValue.baselineChipCount : 0;
575
+ const baselineChips = Array.isArray(candidateValue?.baselineChips) ? candidateValue.baselineChips : [];
576
+ const baselineUploading = Boolean(candidateValue?.baselineUploading);
577
+ const baselineFileCount = typeof candidateValue?.baselineFileCount === 'number' ? candidateValue.baselineFileCount : 0;
578
+ const baselineInputCount = typeof candidateValue?.baselineInputCount === 'number' ? candidateValue.baselineInputCount : 0;
579
+ const serializeChips = (chips) => chips
580
+ .map((chip) => [chip.text, chip.aria, chip.title, chip.testid]
581
+ .map((value) => String(value || '').toLowerCase().replace(/\s+/g, ' ').trim())
582
+ .join('|'))
583
+ .join('||');
584
+ const baselineChipSignature = serializeChips(baselineChips);
128
585
  if (!candidateValue?.ok || candidateOrder.length === 0) {
129
586
  await logDomFailure(runtime, logger, 'file-input-missing');
130
587
  throw new Error('Unable to locate ChatGPT file attachment input.');
131
588
  }
132
- const dispatchEventsFor = (idx) => `(() => {
133
- const el = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
134
- if (el instanceof HTMLInputElement) {
135
- el.dispatchEvent(new Event('input', { bubbles: true }));
136
- el.dispatchEvent(new Event('change', { bubbles: true }));
589
+ const hasChipDelta = (signals) => {
590
+ const chipCount = typeof signals.chipCount === 'number' ? signals.chipCount : 0;
591
+ const chipSignature = typeof signals.chipSignature === 'string' ? signals.chipSignature : '';
592
+ if (chipCount > baselineChipCount)
593
+ return true;
594
+ if (baselineChipSignature && chipSignature && chipSignature !== baselineChipSignature)
595
+ return true;
596
+ return false;
597
+ };
598
+ const hasInputDelta = (signals) => (typeof signals.inputCount === 'number' ? signals.inputCount : 0) > baselineInputCount;
599
+ const hasUploadDelta = (signals) => Boolean(signals.uploading && !baselineUploading);
600
+ const hasFileCountDelta = (signals) => (typeof signals.fileCount === 'number' ? signals.fileCount : 0) > baselineFileCount;
601
+ const waitForAttachmentUiSignal = async (timeoutMs) => {
602
+ const deadline = Date.now() + timeoutMs;
603
+ let sawInputSignal = false;
604
+ let latest = null;
605
+ while (Date.now() < deadline) {
606
+ const signals = await readAttachmentSignals(expectedName);
607
+ const chipDelta = hasChipDelta(signals);
608
+ const inputDelta = hasInputDelta(signals) || signals.input;
609
+ const uploadDelta = hasUploadDelta(signals);
610
+ const fileCountDelta = hasFileCountDelta(signals);
611
+ const expectedSatisfied = isExpectedSatisfied(signals);
612
+ if (inputDelta) {
613
+ sawInputSignal = true;
614
+ }
615
+ latest = { signals, chipDelta, inputDelta: sawInputSignal, uploadDelta, fileCountDelta, expectedSatisfied };
616
+ if (signals.ui || chipDelta || uploadDelta || fileCountDelta || expectedSatisfied) {
617
+ return latest;
618
+ }
619
+ await delay(250);
620
+ }
621
+ return latest;
622
+ };
623
+ const inputSnapshotFor = (idx) => `(() => {
624
+ const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
625
+ if (!(input instanceof HTMLInputElement)) {
626
+ return { names: [], value: '', count: 0 };
137
627
  }
138
- return true;
628
+ return {
629
+ names: Array.from(input.files || []).map((file) => file?.name ?? '').filter(Boolean),
630
+ value: input.value || '',
631
+ count: Array.from(input.files || []).length,
632
+ };
139
633
  })()`;
634
+ const parseInputSnapshot = (value) => {
635
+ const snapshot = value;
636
+ const names = Array.isArray(snapshot?.names) ? snapshot?.names ?? [] : [];
637
+ const valueText = typeof snapshot?.value === 'string' ? snapshot.value : '';
638
+ const count = typeof snapshot?.count === 'number' ? snapshot.count : names.length;
639
+ return {
640
+ names,
641
+ value: valueText,
642
+ count: Number.isFinite(count) ? count : names.length,
643
+ };
644
+ };
645
+ const readInputSnapshot = async (idx) => {
646
+ const snapshot = await runtime
647
+ .evaluate({ expression: inputSnapshotFor(idx), returnByValue: true })
648
+ .then((res) => parseInputSnapshot(res?.result?.value))
649
+ .catch(() => parseInputSnapshot(undefined));
650
+ return snapshot;
651
+ };
652
+ const snapshotMatchesExpected = (snapshot) => {
653
+ const nameMatch = snapshot.names.some((name) => matchesExpectedName(name));
654
+ return nameMatch || Boolean(snapshot.value && matchesExpectedName(snapshot.value));
655
+ };
656
+ const inputSignalsFor = (baseline, current) => {
657
+ const baselineCount = baseline.count ?? baseline.names.length;
658
+ const currentCount = current.count ?? current.names.length;
659
+ const countDelta = currentCount > baselineCount;
660
+ const valueDelta = Boolean(current.value) && current.value !== baseline.value;
661
+ const baselineEmpty = baselineCount === 0 && !baseline.value;
662
+ const nameMatch = current.names.some((name) => matchesExpectedName(name)) ||
663
+ (current.value && matchesExpectedName(current.value));
664
+ const touched = nameMatch || countDelta || (baselineEmpty && valueDelta);
665
+ return {
666
+ touched,
667
+ nameMatch,
668
+ countDelta,
669
+ valueDelta,
670
+ };
671
+ };
140
672
  const composerSnapshotFor = (idx) => `(() => {
141
673
  const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
142
- const locateComposerRoot = () => {
674
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
675
+ const findPromptNode = () => {
676
+ for (const selector of promptSelectors) {
677
+ const nodes = Array.from(document.querySelectorAll(selector));
678
+ for (const node of nodes) {
679
+ if (!(node instanceof HTMLElement)) continue;
680
+ const rect = node.getBoundingClientRect();
681
+ if (rect.width > 0 && rect.height > 0) return node;
682
+ }
683
+ }
143
684
  for (const selector of promptSelectors) {
144
685
  const node = document.querySelector(selector);
145
- if (!node) continue;
146
- return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
686
+ if (node) return node;
687
+ }
688
+ return null;
689
+ };
690
+ const composerAttachmentSelectors = [
691
+ 'input[type="file"]',
692
+ '[data-testid*="attachment"]',
693
+ '[data-testid*="upload"]',
694
+ '[aria-label*="Remove"]',
695
+ '[aria-label*="remove"]',
696
+ ];
697
+ const locateComposerRoot = () => {
698
+ const promptNode = findPromptNode();
699
+ if (promptNode) {
700
+ const initial =
701
+ promptNode.closest('[data-testid*="composer"]') ??
702
+ promptNode.closest('form') ??
703
+ promptNode.parentElement ??
704
+ document.body;
705
+ let current = initial;
706
+ let fallback = initial;
707
+ while (current && current !== document.body) {
708
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
709
+ if (hasSend) {
710
+ fallback = current;
711
+ const hasAttachment = composerAttachmentSelectors.some((selector) => current.querySelector(selector));
712
+ if (hasAttachment) {
713
+ return current;
714
+ }
715
+ }
716
+ current = current.parentElement;
717
+ }
718
+ return fallback ?? initial;
147
719
  }
148
720
  return document.querySelector('form') ?? document.body;
149
721
  };
150
722
  const root = locateComposerRoot();
151
- const chipContainer = root ?? document;
723
+ const scope = (() => {
724
+ if (!root) return document.body;
725
+ const parent = root.parentElement;
726
+ const parentHasSend = parent && sendSelectors.some((selector) => parent.querySelector(selector));
727
+ return parentHasSend ? parent : root;
728
+ })();
729
+ const chipContainer = scope ?? document;
152
730
  const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
153
731
  const chips = Array.from(chipContainer.querySelectorAll(chipSelector))
154
732
  .slice(0, 20)
@@ -158,92 +736,392 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
158
736
  title: node.getAttribute?.('title') ?? '',
159
737
  testid: node.getAttribute?.('data-testid') ?? '',
160
738
  }));
739
+ const uploadingSelectors = ${JSON.stringify(UPLOAD_STATUS_SELECTORS)};
740
+ const uploading = uploadingSelectors.some((selector) => {
741
+ return Array.from(document.querySelectorAll(selector)).some((node) => {
742
+ const ariaBusy = node.getAttribute?.('aria-busy');
743
+ const dataState = node.getAttribute?.('data-state');
744
+ if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
745
+ return true;
746
+ }
747
+ const text = node.textContent?.toLowerCase?.() ?? '';
748
+ return /\\buploading\\b/.test(text) || /\\bprocessing\\b/.test(text);
749
+ });
750
+ });
161
751
  const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
162
752
  const inputNames =
163
753
  input instanceof HTMLInputElement
164
754
  ? Array.from(input.files || []).map((f) => f?.name ?? '').filter(Boolean)
165
755
  : [];
166
756
  const composerText = (chipContainer.innerText || '').toLowerCase();
167
- return { chipCount: chipContainer.querySelectorAll(chipSelector).length, chips, inputNames, composerText };
757
+ return {
758
+ chipCount: chipContainer.querySelectorAll(chipSelector).length,
759
+ chips,
760
+ inputNames,
761
+ composerText,
762
+ uploading,
763
+ };
168
764
  })()`;
765
+ let confirmedAttachment = false;
766
+ let lastInputNames = [];
767
+ let lastInputValue = '';
169
768
  let finalSnapshot = null;
170
- for (const idx of candidateOrder) {
171
- const resultNode = await dom.querySelector({
172
- nodeId: documentNode.root.nodeId,
173
- selector: `input[type="file"][data-oracle-upload-idx="${idx}"]`,
174
- });
175
- if (!resultNode?.nodeId) {
176
- continue;
769
+ const resolveInputNameCandidates = () => {
770
+ const snapshot = finalSnapshot;
771
+ const snapshotNames = snapshot?.inputNames;
772
+ if (Array.isArray(snapshotNames) && snapshotNames.length > 0) {
773
+ return snapshotNames;
177
774
  }
178
- await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
179
- await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
180
- await delay(350);
181
- const probeDeadline = Date.now() + 6500;
182
- const pokeIntervalMs = 1200;
183
- let lastPoke = 0;
184
- let seenInputHasFile = false;
185
- while (Date.now() < probeDeadline) {
186
- // ChatGPT's composer can take a moment to hydrate the file-input onChange handler after navigation/model switches.
187
- // If the UI hasn't reacted yet, poke the input a few times to ensure the handler fires once it's mounted.
188
- if (!seenInputHasFile && Date.now() - lastPoke > pokeIntervalMs) {
189
- lastPoke = Date.now();
190
- await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
191
- }
192
- const snapshot = await runtime
193
- .evaluate({ expression: composerSnapshotFor(idx), returnByValue: true })
194
- .then((res) => res?.result?.value)
195
- .catch(() => undefined);
196
- if (snapshot) {
197
- finalSnapshot = {
198
- chipCount: Number(snapshot.chipCount ?? 0),
199
- chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
200
- inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
201
- composerText: typeof snapshot.composerText === 'string' ? snapshot.composerText : '',
202
- };
203
- const inputHasFile = finalSnapshot.inputNames.some((name) => name.toLowerCase().includes(expectedName.toLowerCase()));
204
- seenInputHasFile = seenInputHasFile || inputHasFile;
205
- const expectedLower = expectedName.toLowerCase();
206
- const expectedNoExt = expectedLower.replace(/\.[a-z0-9]{1,10}$/i, '');
207
- const uiAcknowledged = finalSnapshot.chipCount > baselineChipCount ||
208
- (expectedNoExt.length >= 6
209
- ? finalSnapshot.composerText.includes(expectedNoExt)
210
- : finalSnapshot.composerText.includes(expectedLower));
211
- if (inputHasFile && uiAcknowledged) {
212
- logger?.(`Attachment snapshot after setFileInputFiles: chips=${JSON.stringify(finalSnapshot.chips)} input=${JSON.stringify(finalSnapshot.inputNames)}`);
213
- break;
775
+ return lastInputNames;
776
+ };
777
+ if (!inputConfirmed) {
778
+ for (let orderIndex = 0; orderIndex < candidateOrder.length; orderIndex += 1) {
779
+ const idx = candidateOrder[orderIndex];
780
+ const queuedSignals = await readAttachmentSignals(expectedName);
781
+ if (queuedSignals.ui ||
782
+ isExpectedSatisfied(queuedSignals) ||
783
+ hasChipDelta(queuedSignals) ||
784
+ hasUploadDelta(queuedSignals) ||
785
+ hasFileCountDelta(queuedSignals)) {
786
+ confirmedAttachment = true;
787
+ break;
788
+ }
789
+ if (queuedSignals.input || hasInputDelta(queuedSignals)) {
790
+ inputConfirmed = true;
791
+ break;
792
+ }
793
+ const resultNode = await dom.querySelector({
794
+ nodeId: documentNode.root.nodeId,
795
+ selector: `input[type="file"][data-oracle-upload-idx="${idx}"]`,
796
+ });
797
+ if (!resultNode?.nodeId) {
798
+ continue;
799
+ }
800
+ const baselineInputSnapshot = await readInputSnapshot(idx);
801
+ const gatherSignals = async () => {
802
+ const signalResult = await waitForAttachmentUiSignal(attachmentUiSignalWaitMs);
803
+ const postInputSnapshot = await readInputSnapshot(idx);
804
+ const postInputSignals = inputSignalsFor(baselineInputSnapshot, postInputSnapshot);
805
+ const snapshot = await runtime
806
+ .evaluate({ expression: composerSnapshotFor(idx), returnByValue: true })
807
+ .then((res) => res?.result?.value)
808
+ .catch(() => undefined);
809
+ if (snapshot) {
810
+ finalSnapshot = {
811
+ chipCount: Number(snapshot.chipCount ?? 0),
812
+ chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
813
+ inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
814
+ composerText: typeof snapshot.composerText === 'string' ? snapshot.composerText : '',
815
+ uploading: Boolean(snapshot.uploading),
816
+ };
817
+ }
818
+ lastInputNames = postInputSnapshot.names;
819
+ lastInputValue = postInputSnapshot.value;
820
+ return { signalResult, postInputSignals };
821
+ };
822
+ const evaluateSignals = async (signalResult, postInputSignals, immediateInputMatch) => {
823
+ const expectedSatisfied = Boolean(signalResult?.expectedSatisfied) ||
824
+ (signalResult?.signals ? isExpectedSatisfied(signalResult.signals) : false);
825
+ const uiAcknowledged = Boolean(signalResult?.signals?.ui) ||
826
+ Boolean(signalResult?.chipDelta) ||
827
+ Boolean(signalResult?.uploadDelta) ||
828
+ Boolean(signalResult?.fileCountDelta) ||
829
+ expectedSatisfied;
830
+ if (uiAcknowledged) {
831
+ return { status: 'ui' };
832
+ }
833
+ const postSignals = await readAttachmentSignals(expectedName);
834
+ if (postSignals.ui ||
835
+ isExpectedSatisfied(postSignals) ||
836
+ hasChipDelta(postSignals) ||
837
+ hasUploadDelta(postSignals) ||
838
+ hasFileCountDelta(postSignals)) {
839
+ return { status: 'ui' };
840
+ }
841
+ const inputNameCandidates = resolveInputNameCandidates();
842
+ const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
843
+ (lastInputValue && matchesExpectedName(lastInputValue));
844
+ const inputSignal = immediateInputMatch ||
845
+ postInputSignals.touched ||
846
+ Boolean(signalResult?.signals?.input) ||
847
+ Boolean(signalResult?.inputDelta) ||
848
+ inputHasFile ||
849
+ postSignals.input ||
850
+ hasInputDelta(postSignals);
851
+ if (inputSignal) {
852
+ return { status: 'input' };
214
853
  }
854
+ return { status: 'none' };
855
+ };
856
+ const runInputAttempt = async (mode) => {
857
+ let immediateInputSnapshot = await readInputSnapshot(idx);
858
+ let hasExpectedFile = snapshotMatchesExpected(immediateInputSnapshot);
859
+ if (!hasExpectedFile) {
860
+ if (mode === 'set') {
861
+ await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
862
+ }
863
+ else {
864
+ const selector = `input[type="file"][data-oracle-upload-idx="${idx}"]`;
865
+ try {
866
+ await transferAttachmentViaDataTransfer(runtime, attachment, selector);
867
+ }
868
+ catch (error) {
869
+ logger(`Attachment data transfer failed: ${error?.message ?? String(error)}`);
870
+ }
871
+ }
872
+ immediateInputSnapshot = await readInputSnapshot(idx);
873
+ hasExpectedFile = snapshotMatchesExpected(immediateInputSnapshot);
874
+ }
875
+ const immediateSignals = inputSignalsFor(baselineInputSnapshot, immediateInputSnapshot);
876
+ lastInputNames = immediateInputSnapshot.names;
877
+ lastInputValue = immediateInputSnapshot.value;
878
+ const immediateInputMatch = immediateSignals.touched || hasExpectedFile;
879
+ if (immediateInputMatch) {
880
+ inputConfirmed = true;
881
+ }
882
+ const signalState = await gatherSignals();
883
+ const evaluation = await evaluateSignals(signalState.signalResult, signalState.postInputSignals, immediateInputMatch);
884
+ return { evaluation, signalState, immediateInputMatch };
885
+ };
886
+ let result = await runInputAttempt('set');
887
+ if (result.evaluation.status === 'ui') {
888
+ confirmedAttachment = true;
889
+ break;
890
+ }
891
+ if (result.evaluation.status === 'input') {
892
+ logger('Attachment input set; proceeding without UI confirmation.');
893
+ inputConfirmed = true;
894
+ break;
895
+ }
896
+ const lateSignals = await readAttachmentSignals(expectedName);
897
+ if (lateSignals.ui ||
898
+ isExpectedSatisfied(lateSignals) ||
899
+ hasChipDelta(lateSignals) ||
900
+ hasUploadDelta(lateSignals) ||
901
+ hasFileCountDelta(lateSignals)) {
902
+ confirmedAttachment = true;
903
+ break;
904
+ }
905
+ if (lateSignals.input || hasInputDelta(lateSignals)) {
906
+ logger('Attachment input set; proceeding without UI confirmation.');
907
+ inputConfirmed = true;
908
+ break;
909
+ }
910
+ logger('Attachment not acknowledged after file input set; retrying with data transfer.');
911
+ result = await runInputAttempt('transfer');
912
+ if (result.evaluation.status === 'ui') {
913
+ confirmedAttachment = true;
914
+ break;
915
+ }
916
+ if (result.evaluation.status === 'input') {
917
+ logger('Attachment input set; proceeding without UI confirmation.');
918
+ inputConfirmed = true;
919
+ break;
920
+ }
921
+ if (orderIndex < candidateOrder.length - 1) {
922
+ await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
923
+ await delay(150);
215
924
  }
216
- await delay(250);
217
- }
218
- const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
219
- const uiAcknowledged = (finalSnapshot?.chipCount ?? 0) > baselineChipCount;
220
- if (inputHasFile && uiAcknowledged) {
221
- break;
222
925
  }
223
926
  }
224
- const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
225
- const attachmentUiTimeoutMs = 25_000;
927
+ if (confirmedAttachment) {
928
+ const inputNameCandidates = resolveInputNameCandidates();
929
+ const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
930
+ (lastInputValue && matchesExpectedName(lastInputValue));
931
+ await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
932
+ logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
933
+ return true;
934
+ }
935
+ const inputNameCandidates = resolveInputNameCandidates();
936
+ const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
937
+ (lastInputValue && matchesExpectedName(lastInputValue));
226
938
  if (await waitForAttachmentAnchored(runtime, expectedName, attachmentUiTimeoutMs)) {
227
939
  await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
228
940
  logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
229
- return;
941
+ return true;
230
942
  }
231
- // If ChatGPT never reflects an attachment UI chip/remove control, the file input may be the wrong target:
232
- // sending in this state often drops the attachment silently.
233
- if (inputHasFile) {
234
- await logDomFailure(runtime, logger, 'file-upload-missing');
235
- throw new Error('Attachment input accepted the file but ChatGPT did not acknowledge it in the composer UI.');
943
+ if (inputConfirmed || inputHasFile) {
944
+ logger('Attachment input accepted the file but UI did not acknowledge it; continuing with input confirmation only.');
945
+ return true;
236
946
  }
237
947
  await logDomFailure(runtime, logger, 'file-upload-missing');
238
948
  throw new Error('Attachment did not register with the ChatGPT composer in time.');
239
949
  }
950
+ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
951
+ const deadline = Date.now() + Math.max(0, timeoutMs);
952
+ const expression = `(() => {
953
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
954
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
955
+ const findPromptNode = () => {
956
+ for (const selector of promptSelectors) {
957
+ const nodes = Array.from(document.querySelectorAll(selector));
958
+ for (const node of nodes) {
959
+ if (!(node instanceof HTMLElement)) continue;
960
+ const rect = node.getBoundingClientRect();
961
+ if (rect.width > 0 && rect.height > 0) return node;
962
+ }
963
+ }
964
+ for (const selector of promptSelectors) {
965
+ const node = document.querySelector(selector);
966
+ if (node) return node;
967
+ }
968
+ return null;
969
+ };
970
+ const attachmentSelectors = [
971
+ 'input[type="file"]',
972
+ '[data-testid*="attachment"]',
973
+ '[data-testid*="upload"]',
974
+ '[aria-label*="Remove"]',
975
+ '[aria-label*="remove"]',
976
+ ];
977
+ const locateComposerRoot = () => {
978
+ const promptNode = findPromptNode();
979
+ if (promptNode) {
980
+ const initial =
981
+ promptNode.closest('[data-testid*="composer"]') ??
982
+ promptNode.closest('form') ??
983
+ promptNode.parentElement ??
984
+ document.body;
985
+ let current = initial;
986
+ let fallback = initial;
987
+ while (current && current !== document.body) {
988
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
989
+ if (hasSend) {
990
+ fallback = current;
991
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
992
+ if (hasAttachment) {
993
+ return current;
994
+ }
995
+ }
996
+ current = current.parentElement;
997
+ }
998
+ return fallback ?? initial;
999
+ }
1000
+ return document.querySelector('form') ?? document.body;
1001
+ };
1002
+ const root = locateComposerRoot();
1003
+ const scope = (() => {
1004
+ if (!root) return document.body;
1005
+ const parent = root.parentElement;
1006
+ const parentHasSend = parent && sendSelectors.some((selector) => parent.querySelector(selector));
1007
+ return parentHasSend ? parent : root;
1008
+ })();
1009
+ const removeSelectors = [
1010
+ '[aria-label*="Remove"]',
1011
+ '[aria-label*="remove"]',
1012
+ 'button[aria-label*="Remove"]',
1013
+ 'button[aria-label*="remove"]',
1014
+ '[data-testid*="remove"]',
1015
+ '[data-testid*="delete"]',
1016
+ ];
1017
+ const visible = (el) => {
1018
+ if (!(el instanceof HTMLElement)) return false;
1019
+ const rect = el.getBoundingClientRect();
1020
+ return rect.width > 0 && rect.height > 0;
1021
+ };
1022
+ const removeButtons = scope
1023
+ ? Array.from(scope.querySelectorAll(removeSelectors.join(','))).filter(visible)
1024
+ : [];
1025
+ for (const button of removeButtons.slice(0, 20)) {
1026
+ try { button.click(); } catch {}
1027
+ }
1028
+ const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
1029
+ const chipCount = scope ? scope.querySelectorAll(chipSelector).length : 0;
1030
+ const inputs = scope ? Array.from(scope.querySelectorAll('input[type="file"]')) : [];
1031
+ let inputCount = 0;
1032
+ for (const input of inputs) {
1033
+ if (!(input instanceof HTMLInputElement)) continue;
1034
+ inputCount += Array.from(input.files || []).length;
1035
+ try { input.value = ''; } catch {}
1036
+ }
1037
+ const hadAttachments = chipCount > 0 || inputCount > 0 || removeButtons.length > 0;
1038
+ return { removeClicks: removeButtons.length, chipCount, inputCount, hadAttachments };
1039
+ })()`;
1040
+ let sawAttachments = false;
1041
+ let lastState = null;
1042
+ while (Date.now() < deadline) {
1043
+ const response = await Runtime.evaluate({ expression, returnByValue: true });
1044
+ const value = response.result?.value;
1045
+ if (value?.hadAttachments) {
1046
+ sawAttachments = true;
1047
+ }
1048
+ const chipCount = typeof value?.chipCount === 'number' ? value.chipCount : 0;
1049
+ const inputCount = typeof value?.inputCount === 'number' ? value.inputCount : 0;
1050
+ lastState = { chipCount, inputCount };
1051
+ if (chipCount === 0 && inputCount === 0) {
1052
+ return;
1053
+ }
1054
+ await delay(250);
1055
+ }
1056
+ if (sawAttachments) {
1057
+ logger?.(`Attachment cleanup timed out; still saw ${lastState?.chipCount ?? 0} chips and ${lastState?.inputCount ?? 0} inputs.`);
1058
+ throw new Error('Existing attachments still present in composer; aborting to avoid duplicate uploads.');
1059
+ }
1060
+ }
240
1061
  export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
241
1062
  const deadline = Date.now() + timeoutMs;
242
1063
  const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
243
1064
  let inputMatchSince = null;
1065
+ let sawInputMatch = false;
244
1066
  let attachmentMatchSince = null;
1067
+ let lastVerboseLog = 0;
245
1068
  const expression = `(() => {
246
1069
  const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
1070
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
1071
+ const findPromptNode = () => {
1072
+ for (const selector of promptSelectors) {
1073
+ const nodes = Array.from(document.querySelectorAll(selector));
1074
+ for (const node of nodes) {
1075
+ if (!(node instanceof HTMLElement)) continue;
1076
+ const rect = node.getBoundingClientRect();
1077
+ if (rect.width > 0 && rect.height > 0) return node;
1078
+ }
1079
+ }
1080
+ for (const selector of promptSelectors) {
1081
+ const node = document.querySelector(selector);
1082
+ if (node) return node;
1083
+ }
1084
+ return null;
1085
+ };
1086
+ const attachmentSelectors = [
1087
+ 'input[type="file"]',
1088
+ '[data-testid*="attachment"]',
1089
+ '[data-testid*="upload"]',
1090
+ '[aria-label*="Remove"]',
1091
+ '[aria-label*="remove"]',
1092
+ ];
1093
+ const locateComposerRoot = () => {
1094
+ const promptNode = findPromptNode();
1095
+ if (promptNode) {
1096
+ const initial =
1097
+ promptNode.closest('[data-testid*="composer"]') ??
1098
+ promptNode.closest('form') ??
1099
+ promptNode.parentElement ??
1100
+ document.body;
1101
+ let current = initial;
1102
+ let fallback = initial;
1103
+ while (current && current !== document.body) {
1104
+ const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
1105
+ if (hasSend) {
1106
+ fallback = current;
1107
+ const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
1108
+ if (hasAttachment) {
1109
+ return current;
1110
+ }
1111
+ }
1112
+ current = current.parentElement;
1113
+ }
1114
+ return fallback ?? initial;
1115
+ }
1116
+ return document.querySelector('form') ?? document.body;
1117
+ };
1118
+ const composerRoot = locateComposerRoot();
1119
+ const composerScope = (() => {
1120
+ if (!composerRoot) return document;
1121
+ const parent = composerRoot.parentElement;
1122
+ const parentHasSend = parent && sendSelectors.some((selector) => parent.querySelector(selector));
1123
+ return parentHasSend ? parent : composerRoot;
1124
+ })();
247
1125
  let button = null;
248
1126
  for (const selector of sendSelectors) {
249
1127
  button = document.querySelector(selector);
@@ -268,39 +1146,168 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
268
1146
  return /\buploading\b/.test(text) || /\bprocessing\b/.test(text);
269
1147
  });
270
1148
  });
271
- const attachmentSelectors = ['[data-testid*="chip"]', '[data-testid*="attachment"]', '[data-testid*="upload"]'];
1149
+ const attachmentChipSelectors = [
1150
+ '[data-testid*="chip"]',
1151
+ '[data-testid*="attachment"]',
1152
+ '[data-testid*="upload"]',
1153
+ '[data-testid*="file"]',
1154
+ '[aria-label*="Remove"]',
1155
+ 'button[aria-label*="Remove"]',
1156
+ ];
272
1157
  const attachedNames = [];
273
- for (const selector of attachmentSelectors) {
274
- for (const node of Array.from(document.querySelectorAll(selector))) {
275
- const text = node?.textContent?.toLowerCase?.();
276
- if (text) attachedNames.push(text);
1158
+ for (const selector of attachmentChipSelectors) {
1159
+ for (const node of Array.from(composerScope.querySelectorAll(selector))) {
1160
+ if (!node) continue;
1161
+ const text = node.textContent ?? '';
1162
+ const aria = node.getAttribute?.('aria-label') ?? '';
1163
+ const title = node.getAttribute?.('title') ?? '';
1164
+ const parentText = node.parentElement?.parentElement?.innerText ?? '';
1165
+ for (const value of [text, aria, title, parentText]) {
1166
+ const normalized = value?.toLowerCase?.();
1167
+ if (normalized) attachedNames.push(normalized);
1168
+ }
277
1169
  }
278
1170
  }
279
- const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1171
+ const cardTexts = Array.from(composerScope.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
280
1172
  btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
281
1173
  );
282
1174
  attachedNames.push(...cardTexts.filter(Boolean));
283
1175
 
284
1176
  const inputNames = [];
285
- for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
1177
+ const inputScope = composerScope ? Array.from(composerScope.querySelectorAll('input[type="file"]')) : [];
1178
+ const inputNodes = [];
1179
+ const inputSeen = new Set();
1180
+ for (const el of [...inputScope, ...Array.from(document.querySelectorAll('input[type="file"]'))]) {
1181
+ if (!inputSeen.has(el)) {
1182
+ inputSeen.add(el);
1183
+ inputNodes.push(el);
1184
+ }
1185
+ }
1186
+ for (const input of inputNodes) {
286
1187
  if (!(input instanceof HTMLInputElement) || !input.files?.length) continue;
287
1188
  for (const file of Array.from(input.files)) {
288
1189
  if (file?.name) inputNames.push(file.name.toLowerCase());
289
1190
  }
290
1191
  }
291
- const filesAttached = attachedNames.length > 0;
292
- return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames, inputNames };
1192
+ const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1193
+ const fileCountSelectors = [
1194
+ 'button',
1195
+ '[role="button"]',
1196
+ '[data-testid*="file"]',
1197
+ '[data-testid*="upload"]',
1198
+ '[data-testid*="attachment"]',
1199
+ '[data-testid*="chip"]',
1200
+ '[aria-label*="file"]',
1201
+ '[title*="file"]',
1202
+ '[aria-label*="attachment"]',
1203
+ '[title*="attachment"]',
1204
+ ].join(',');
1205
+ const collectFileCount = (nodes) => {
1206
+ let count = 0;
1207
+ for (const node of nodes) {
1208
+ if (!(node instanceof HTMLElement)) continue;
1209
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1210
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
1211
+ const aria = node.getAttribute?.('aria-label') ?? '';
1212
+ const title = node.getAttribute?.('title') ?? '';
1213
+ const tooltip =
1214
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1215
+ const text = node.textContent ?? '';
1216
+ const parent = node.parentElement;
1217
+ const parentText = parent?.textContent ?? '';
1218
+ const parentAria = parent?.getAttribute?.('aria-label') ?? '';
1219
+ const parentTitle = parent?.getAttribute?.('title') ?? '';
1220
+ const parentTooltip =
1221
+ parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
1222
+ const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
1223
+ const candidates = [
1224
+ text,
1225
+ aria,
1226
+ title,
1227
+ tooltip,
1228
+ dataTestId,
1229
+ parentText,
1230
+ parentAria,
1231
+ parentTitle,
1232
+ parentTooltip,
1233
+ parentTestId,
1234
+ ];
1235
+ let hasFileHint = false;
1236
+ for (const raw of candidates) {
1237
+ if (!raw) continue;
1238
+ if (String(raw).toLowerCase().includes('file')) {
1239
+ hasFileHint = true;
1240
+ break;
1241
+ }
1242
+ }
1243
+ if (!hasFileHint) continue;
1244
+ for (const raw of candidates) {
1245
+ if (!raw) continue;
1246
+ const match = String(raw).toLowerCase().match(countRegex);
1247
+ if (match) {
1248
+ const parsed = Number(match[1]);
1249
+ if (Number.isFinite(parsed)) {
1250
+ count = Math.max(count, parsed);
1251
+ }
1252
+ }
1253
+ }
1254
+ }
1255
+ return count;
1256
+ };
1257
+ const localFileCountNodes = composerScope
1258
+ ? Array.from(composerScope.querySelectorAll(fileCountSelectors))
1259
+ : [];
1260
+ let fileCount = collectFileCount(localFileCountNodes);
1261
+ if (!fileCount) {
1262
+ fileCount = collectFileCount(Array.from(document.querySelectorAll(fileCountSelectors)));
1263
+ }
1264
+ const filesAttached = attachedNames.length > 0 || fileCount > 0;
1265
+ return {
1266
+ state: button ? (disabled ? 'disabled' : 'ready') : 'missing',
1267
+ uploading,
1268
+ filesAttached,
1269
+ attachedNames,
1270
+ inputNames,
1271
+ fileCount,
1272
+ };
293
1273
  })()`;
294
1274
  while (Date.now() < deadline) {
295
- const { result } = await Runtime.evaluate({ expression, returnByValue: true });
1275
+ const response = await Runtime.evaluate({ expression, returnByValue: true });
1276
+ const { result } = response;
296
1277
  const value = result?.value;
1278
+ if (!value && logger?.verbose) {
1279
+ const exception = response
1280
+ ?.exceptionDetails;
1281
+ if (exception) {
1282
+ const details = [exception.text, exception.exception?.description]
1283
+ .filter((part) => Boolean(part))
1284
+ .join(' - ');
1285
+ logger(`Attachment wait eval failed: ${details || 'unknown error'}`);
1286
+ }
1287
+ }
297
1288
  if (value) {
1289
+ if (logger?.verbose) {
1290
+ const now = Date.now();
1291
+ if (now - lastVerboseLog > 3000) {
1292
+ lastVerboseLog = now;
1293
+ logger(`Attachment wait state: ${JSON.stringify({
1294
+ state: value.state,
1295
+ uploading: value.uploading,
1296
+ filesAttached: value.filesAttached,
1297
+ attachedNames: (value.attachedNames ?? []).slice(0, 3),
1298
+ inputNames: (value.inputNames ?? []).slice(0, 3),
1299
+ fileCount: value.fileCount ?? 0,
1300
+ })}`);
1301
+ }
1302
+ }
298
1303
  const attachedNames = (value.attachedNames ?? [])
299
1304
  .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
300
1305
  .filter(Boolean);
301
1306
  const inputNames = (value.inputNames ?? [])
302
1307
  .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
303
1308
  .filter(Boolean);
1309
+ const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
1310
+ const fileCountSatisfied = expectedNormalized.length > 0 && fileCount >= expectedNormalized.length;
304
1311
  const matchesExpected = (expected) => {
305
1312
  const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
306
1313
  const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -311,38 +1318,36 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
311
1318
  if (expectedNoExt.length >= 6 && raw.includes(expectedNoExt))
312
1319
  return true;
313
1320
  if (raw.includes('…') || raw.includes('...')) {
314
- const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
315
- const pattern = escaped.replace(/\\…|\\\.\\\.\\\./g, '.*');
316
- try {
317
- const re = new RegExp(pattern);
318
- return re.test(normalizedExpected) || (expectedNoExt.length >= 6 && re.test(expectedNoExt));
319
- }
320
- catch {
321
- return false;
322
- }
1321
+ const marker = raw.includes('…') ? '' : '...';
1322
+ const [prefixRaw, suffixRaw] = raw.split(marker);
1323
+ const prefix = prefixRaw.trim();
1324
+ const suffix = suffixRaw.trim();
1325
+ const target = expectedNoExt.length >= 6 ? expectedNoExt : normalizedExpected;
1326
+ const matchesPrefix = !prefix || target.includes(prefix);
1327
+ const matchesSuffix = !suffix || target.includes(suffix);
1328
+ return matchesPrefix && matchesSuffix;
323
1329
  }
324
1330
  return false;
325
1331
  });
326
1332
  };
327
1333
  const missing = expectedNormalized.filter((expected) => !matchesExpected(expected));
328
- if (missing.length === 0) {
1334
+ if (missing.length === 0 || fileCountSatisfied) {
329
1335
  const stableThresholdMs = value.uploading ? 3000 : 1500;
330
- if (value.state === 'ready') {
331
- if (attachmentMatchSince === null) {
332
- attachmentMatchSince = Date.now();
333
- }
334
- if (Date.now() - attachmentMatchSince > stableThresholdMs) {
335
- return;
336
- }
1336
+ if (attachmentMatchSince === null) {
1337
+ attachmentMatchSince = Date.now();
337
1338
  }
338
- else {
339
- attachmentMatchSince = null;
1339
+ const stable = Date.now() - attachmentMatchSince > stableThresholdMs;
1340
+ if (stable && value.state === 'ready') {
1341
+ return;
1342
+ }
1343
+ if (stable && value.state === 'disabled' && !value.uploading) {
1344
+ return;
340
1345
  }
341
- if (value.state === 'missing' && value.filesAttached) {
1346
+ if (value.state === 'missing' && (value.filesAttached || fileCountSatisfied)) {
342
1347
  return;
343
1348
  }
344
1349
  // If files are attached but button isn't ready yet, give it more time but don't fail immediately.
345
- if (value.filesAttached) {
1350
+ if (value.filesAttached || fileCountSatisfied) {
346
1351
  await delay(500);
347
1352
  continue;
348
1353
  }
@@ -358,16 +1363,19 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
358
1363
  const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
359
1364
  return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
360
1365
  });
361
- if (inputMissing.length === 0 && (value.state === 'ready' || value.state === 'missing')) {
362
- const stableThresholdMs = value.uploading ? 3000 : 1500;
1366
+ const inputStateOk = value.state === 'ready' || value.state === 'missing' || value.state === 'disabled';
1367
+ const inputSeenNow = inputMissing.length === 0 || fileCountSatisfied;
1368
+ const stableThresholdMs = value.uploading ? 3000 : 1500;
1369
+ if (inputSeenNow && inputStateOk) {
363
1370
  if (inputMatchSince === null) {
364
1371
  inputMatchSince = Date.now();
365
1372
  }
366
- if (Date.now() - inputMatchSince > stableThresholdMs) {
367
- return;
368
- }
1373
+ sawInputMatch = true;
369
1374
  }
370
- else {
1375
+ if (inputMatchSince !== null && inputStateOk && Date.now() - inputMatchSince > stableThresholdMs) {
1376
+ return;
1377
+ }
1378
+ if (!inputSeenNow && !sawInputMatch) {
371
1379
  inputMatchSince = null;
372
1380
  }
373
1381
  }
@@ -379,7 +1387,7 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
379
1387
  }
380
1388
  export async function waitForUserTurnAttachments(Runtime, expectedNames, timeoutMs, logger) {
381
1389
  if (!expectedNames || expectedNames.length === 0) {
382
- return;
1390
+ return true;
383
1391
  }
384
1392
  const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
385
1393
  const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
@@ -399,9 +1407,55 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
399
1407
  const title = el.getAttribute('title') || '';
400
1408
  return (aria + ' ' + title).trim().toLowerCase();
401
1409
  }).filter(Boolean);
402
- return { ok: true, text, attrs };
1410
+ const attachmentSelectors = [
1411
+ '[data-testid*="attachment"]',
1412
+ '[data-testid*="upload"]',
1413
+ '[data-testid*="chip"]',
1414
+ '[aria-label*="file"]',
1415
+ '[aria-label*="attachment"]',
1416
+ '[title*="file"]',
1417
+ '[title*="attachment"]',
1418
+ ];
1419
+ const hasAttachmentUi =
1420
+ lastUser.querySelectorAll(attachmentSelectors.join(',')).length > 0 ||
1421
+ attrs.some((attr) => attr.includes('file') || attr.includes('attachment'));
1422
+ const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1423
+ const fileCountNodes = Array.from(lastUser.querySelectorAll('button,span,div,[aria-label],[title]'));
1424
+ let fileCount = 0;
1425
+ for (const node of fileCountNodes) {
1426
+ if (!(node instanceof HTMLElement)) continue;
1427
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1428
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
1429
+ const aria = node.getAttribute?.('aria-label') ?? '';
1430
+ const title = node.getAttribute?.('title') ?? '';
1431
+ const tooltip =
1432
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1433
+ const nodeText = node.textContent ?? '';
1434
+ const candidates = [nodeText, aria, title, tooltip, dataTestId];
1435
+ let hasFileHint = false;
1436
+ for (const raw of candidates) {
1437
+ if (!raw) continue;
1438
+ if (String(raw).toLowerCase().includes('file')) {
1439
+ hasFileHint = true;
1440
+ break;
1441
+ }
1442
+ }
1443
+ if (!hasFileHint) continue;
1444
+ for (const raw of candidates) {
1445
+ if (!raw) continue;
1446
+ const match = String(raw).toLowerCase().match(countRegex);
1447
+ if (match) {
1448
+ const count = Number(match[1]);
1449
+ if (Number.isFinite(count)) {
1450
+ fileCount = Math.max(fileCount, count);
1451
+ }
1452
+ }
1453
+ }
1454
+ }
1455
+ return { ok: true, text, attrs, fileCount, hasAttachmentUi };
403
1456
  })()`;
404
1457
  const deadline = Date.now() + timeoutMs;
1458
+ let sawAttachmentUi = false;
405
1459
  while (Date.now() < deadline) {
406
1460
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
407
1461
  const value = result?.value;
@@ -409,7 +1463,12 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
409
1463
  await delay(200);
410
1464
  continue;
411
1465
  }
1466
+ if (value.hasAttachmentUi) {
1467
+ sawAttachmentUi = true;
1468
+ }
412
1469
  const haystack = [value.text ?? '', ...(value.attrs ?? [])].join('\n');
1470
+ const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
1471
+ const fileCountSatisfied = fileCount >= expectedNormalized.length && expectedNormalized.length > 0;
413
1472
  const missing = expectedNormalized.filter((expected) => {
414
1473
  const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
415
1474
  const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -420,11 +1479,15 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
420
1479
  return false;
421
1480
  return true;
422
1481
  });
423
- if (missing.length === 0) {
424
- return;
1482
+ if (missing.length === 0 || fileCountSatisfied) {
1483
+ return true;
425
1484
  }
426
1485
  await delay(250);
427
1486
  }
1487
+ if (!sawAttachmentUi) {
1488
+ logger?.('Sent user message did not expose attachment UI; skipping attachment verification.');
1489
+ return false;
1490
+ }
428
1491
  logger?.('Sent user message did not show expected attachment names in time.');
429
1492
  await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-missing-user-turn');
430
1493
  throw new Error('Attachment was not present on the sent user message.');
@@ -439,6 +1502,7 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
439
1502
  const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
440
1503
  const matchNode = (node) => {
441
1504
  if (!node) return false;
1505
+ if (node.tagName === 'INPUT' && node.type === 'file') return false;
442
1506
  const text = (node.textContent || '').toLowerCase();
443
1507
  const aria = node.getAttribute?.('aria-label')?.toLowerCase?.() ?? '';
444
1508
  const title = node.getAttribute?.('title')?.toLowerCase?.() ?? '';
@@ -448,7 +1512,7 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
448
1512
  return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
449
1513
  };
450
1514
 
451
- const attachmentSelectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
1515
+ const attachmentSelectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]','[data-testid*="file"]'];
452
1516
  const attachmentMatch = attachmentSelectors.some((selector) =>
453
1517
  Array.from(document.querySelectorAll(selector)).some(matchNode),
454
1518
  );
@@ -463,6 +1527,77 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
463
1527
  return { found: true, source: 'attachment-cards' };
464
1528
  }
465
1529
 
1530
+ const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1531
+ const fileCountNodes = (() => {
1532
+ const nodes = [];
1533
+ const seen = new Set();
1534
+ const add = (node) => {
1535
+ if (!node || seen.has(node)) return;
1536
+ seen.add(node);
1537
+ nodes.push(node);
1538
+ };
1539
+ const root =
1540
+ document.querySelector('[data-testid*="composer"]') || document.querySelector('form') || document.body;
1541
+ const localNodes = root ? Array.from(root.querySelectorAll('button,span,div,[aria-label],[title]')) : [];
1542
+ for (const node of localNodes) add(node);
1543
+ for (const node of Array.from(document.querySelectorAll('button,span,div,[aria-label],[title]'))) {
1544
+ add(node);
1545
+ }
1546
+ return nodes;
1547
+ })();
1548
+ let fileCount = 0;
1549
+ for (const node of fileCountNodes) {
1550
+ if (!(node instanceof HTMLElement)) continue;
1551
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1552
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
1553
+ const aria = node.getAttribute?.('aria-label') ?? '';
1554
+ const title = node.getAttribute?.('title') ?? '';
1555
+ const tooltip =
1556
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1557
+ const text = node.textContent ?? '';
1558
+ const parent = node.parentElement;
1559
+ const parentText = parent?.textContent ?? '';
1560
+ const parentAria = parent?.getAttribute?.('aria-label') ?? '';
1561
+ const parentTitle = parent?.getAttribute?.('title') ?? '';
1562
+ const parentTooltip =
1563
+ parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
1564
+ const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
1565
+ const candidates = [
1566
+ text,
1567
+ aria,
1568
+ title,
1569
+ tooltip,
1570
+ dataTestId,
1571
+ parentText,
1572
+ parentAria,
1573
+ parentTitle,
1574
+ parentTooltip,
1575
+ parentTestId,
1576
+ ];
1577
+ let hasFileHint = false;
1578
+ for (const raw of candidates) {
1579
+ if (!raw) continue;
1580
+ if (String(raw).toLowerCase().includes('file')) {
1581
+ hasFileHint = true;
1582
+ break;
1583
+ }
1584
+ }
1585
+ if (!hasFileHint) continue;
1586
+ for (const raw of candidates) {
1587
+ if (!raw) continue;
1588
+ const match = String(raw).toLowerCase().match(countRegex);
1589
+ if (match) {
1590
+ const count = Number(match[1]);
1591
+ if (Number.isFinite(count)) {
1592
+ fileCount = Math.max(fileCount, count);
1593
+ }
1594
+ }
1595
+ }
1596
+ }
1597
+ if (fileCount > 0) {
1598
+ return { found: true, source: 'file-count' };
1599
+ }
1600
+
466
1601
  return { found: false };
467
1602
  })()`;
468
1603
  while (Date.now() < deadline) {
@@ -488,14 +1623,14 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
488
1623
  if (text.includes(normalized)) return true;
489
1624
  if (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)) return true;
490
1625
  if (text.includes('…') || text.includes('...')) {
491
- const escaped = text.replace(/[.*+?^$\\{\\}()|[\\]\\\\]/g, '\\\\$&');
492
- const pattern = escaped.replaceAll('…', '.*').replaceAll('...', '.*');
493
- try {
494
- const re = new RegExp(pattern);
495
- return re.test(normalized) || (normalizedNoExt.length >= 6 && re.test(normalizedNoExt));
496
- } catch {
497
- return false;
498
- }
1626
+ const marker = text.includes('…') ? '' : '...';
1627
+ const [prefixRaw, suffixRaw] = text.split(marker);
1628
+ const prefix = (prefixRaw ?? '').toLowerCase();
1629
+ const suffix = (suffixRaw ?? '').toLowerCase();
1630
+ const target = normalizedNoExt.length >= 6 ? normalizedNoExt : normalized;
1631
+ const matchesPrefix = !prefix || target.includes(prefix);
1632
+ const matchesSuffix = !suffix || target.includes(suffix);
1633
+ return matchesPrefix && matchesSuffix;
499
1634
  }
500
1635
  return false;
501
1636
  };
@@ -509,6 +1644,7 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
509
1644
  ];
510
1645
  for (const selector of selectors) {
511
1646
  for (const node of Array.from(document.querySelectorAll(selector))) {
1647
+ if (node?.tagName === 'INPUT' && node?.type === 'file') continue;
512
1648
  const text = node?.textContent || '';
513
1649
  const aria = node?.getAttribute?.('aria-label') || '';
514
1650
  const title = node?.getAttribute?.('title') || '';
@@ -523,6 +1659,76 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
523
1659
  if (cards.some(matchesExpected)) {
524
1660
  return { found: true, text: cards.find(matchesExpected) };
525
1661
  }
1662
+ const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
1663
+ const fileCountNodes = (() => {
1664
+ const nodes = [];
1665
+ const seen = new Set();
1666
+ const add = (node) => {
1667
+ if (!node || seen.has(node)) return;
1668
+ seen.add(node);
1669
+ nodes.push(node);
1670
+ };
1671
+ const root =
1672
+ document.querySelector('[data-testid*="composer"]') || document.querySelector('form') || document.body;
1673
+ const localNodes = root ? Array.from(root.querySelectorAll('button,span,div,[aria-label],[title]')) : [];
1674
+ for (const node of localNodes) add(node);
1675
+ for (const node of Array.from(document.querySelectorAll('button,span,div,[aria-label],[title]'))) {
1676
+ add(node);
1677
+ }
1678
+ return nodes;
1679
+ })();
1680
+ let fileCount = 0;
1681
+ for (const node of fileCountNodes) {
1682
+ if (!(node instanceof HTMLElement)) continue;
1683
+ if (node.matches('textarea,input,[contenteditable="true"]')) continue;
1684
+ const dataTestId = node.getAttribute?.('data-testid') ?? '';
1685
+ const aria = node.getAttribute?.('aria-label') ?? '';
1686
+ const title = node.getAttribute?.('title') ?? '';
1687
+ const tooltip =
1688
+ node.getAttribute?.('data-tooltip') ?? node.getAttribute?.('data-tooltip-content') ?? '';
1689
+ const text = node.textContent ?? '';
1690
+ const parent = node.parentElement;
1691
+ const parentText = parent?.textContent ?? '';
1692
+ const parentAria = parent?.getAttribute?.('aria-label') ?? '';
1693
+ const parentTitle = parent?.getAttribute?.('title') ?? '';
1694
+ const parentTooltip =
1695
+ parent?.getAttribute?.('data-tooltip') ?? parent?.getAttribute?.('data-tooltip-content') ?? '';
1696
+ const parentTestId = parent?.getAttribute?.('data-testid') ?? '';
1697
+ const candidates = [
1698
+ text,
1699
+ aria,
1700
+ title,
1701
+ tooltip,
1702
+ dataTestId,
1703
+ parentText,
1704
+ parentAria,
1705
+ parentTitle,
1706
+ parentTooltip,
1707
+ parentTestId,
1708
+ ];
1709
+ let hasFileHint = false;
1710
+ for (const raw of candidates) {
1711
+ if (!raw) continue;
1712
+ if (String(raw).toLowerCase().includes('file')) {
1713
+ hasFileHint = true;
1714
+ break;
1715
+ }
1716
+ }
1717
+ if (!hasFileHint) continue;
1718
+ for (const raw of candidates) {
1719
+ if (!raw) continue;
1720
+ const match = String(raw).toLowerCase().match(countRegex);
1721
+ if (match) {
1722
+ const count = Number(match[1]);
1723
+ if (Number.isFinite(count)) {
1724
+ fileCount = Math.max(fileCount, count);
1725
+ }
1726
+ }
1727
+ }
1728
+ }
1729
+ if (fileCount > 0) {
1730
+ return { found: true, text: 'file-count' };
1731
+ }
526
1732
  return { found: false };
527
1733
  })()`;
528
1734
  while (Date.now() < deadline) {