@steipete/oracle 0.7.6 → 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,12 +2,16 @@ 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
17
  const expected = ${JSON.stringify(name)};
@@ -33,47 +37,240 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
33
37
  };
34
38
 
35
39
  const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
36
- const locateComposerRoot = () => {
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
+ }
37
49
  for (const selector of promptSelectors) {
38
50
  const node = document.querySelector(selector);
39
- if (!node) continue;
40
- return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
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;
41
85
  }
42
86
  return document.querySelector('form') ?? document.body;
43
87
  };
44
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 || '') : '';
45
96
  const chipSelector = [
46
97
  '[data-testid*="attachment"]',
47
98
  '[data-testid*="chip"]',
48
99
  '[data-testid*="upload"]',
100
+ '[data-testid*="file"]',
49
101
  '[aria-label*="Remove"]',
50
102
  'button[aria-label*="Remove"]',
51
103
  '[aria-label*="remove"]',
52
104
  ].join(',');
53
- const candidates = root ? Array.from(root.querySelectorAll(chipSelector)) : [];
54
- const nodes = candidates.length > 0 ? candidates : Array.from(document.querySelectorAll(chipSelector));
55
- for (const node of nodes) {
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;
56
119
  const text = node?.textContent ?? '';
57
120
  const aria = node?.getAttribute?.('aria-label') ?? '';
58
121
  const title = node?.getAttribute?.('title') ?? '';
59
122
  if ([text, aria, title].some(matchesExpected)) {
60
- return true;
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;
61
135
  }
62
136
  }
63
137
 
64
- const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]')).map((btn) =>
65
- btn?.parentElement?.parentElement?.innerText ?? '',
66
- );
67
- if (cardTexts.some(matchesExpected)) return true;
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
+ });
68
172
 
69
- const inputs = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
70
- Array.from(el.files || []).some((f) => matchesExpected(f?.name ?? '')),
71
- );
72
- return Boolean(inputs);
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
+ }
251
+
252
+ return {
253
+ ui: uiMatch,
254
+ input: inputMatch,
255
+ inputCount,
256
+ chipCount: localCandidates.length,
257
+ chipSignature,
258
+ uploading,
259
+ fileCount,
260
+ };
73
261
  })()`,
74
262
  returnByValue: true,
75
263
  });
76
- 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
+ };
77
274
  };
78
275
  // New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
79
276
  await Promise.resolve(runtime.evaluate({
@@ -113,26 +310,126 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
113
310
  })()`,
114
311
  returnByValue: true,
115
312
  })).catch(() => undefined);
313
+ const normalizeForMatch = (value) => String(value || '')
314
+ .toLowerCase()
315
+ .replace(/\s+/g, ' ')
316
+ .trim();
116
317
  const expectedName = path.basename(attachment.path);
117
- 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) {
118
336
  logger(`Attachment already present: ${path.basename(attachment.path)}`);
119
- 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;
120
357
  }
121
358
  const documentNode = await dom.getDocument();
122
359
  const candidateSetup = await runtime.evaluate({
123
360
  expression: `(() => {
124
361
  const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
125
- 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
+ }
126
372
  for (const selector of promptSelectors) {
127
373
  const node = document.querySelector(selector);
128
- if (!node) continue;
129
- 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;
130
407
  }
131
408
  return document.querySelector('form') ?? document.body;
132
409
  };
133
410
  const root = locateComposerRoot();
134
- const localInputs = root ? Array.from(root.querySelectorAll('input[type="file"]')) : [];
135
- 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)};
136
433
  const acceptIsImageOnly = (accept) => {
137
434
  if (!accept) return false;
138
435
  const parts = String(accept)
@@ -141,53 +438,295 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
141
438
  .filter(Boolean);
142
439
  return parts.length > 0 && parts.every((p) => p.startsWith('image/'));
143
440
  };
144
- const chipContainer = root ?? document;
145
- 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"]';
146
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
+ }
147
538
 
148
539
  // Mark candidates with stable indices so we can select them via DOM.querySelector.
149
540
  let idx = 0;
150
- const candidates = inputs.map((el) => {
541
+ let candidates = inputs.map((el) => {
151
542
  const accept = el.getAttribute('accept') || '';
152
- 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);
153
547
  el.setAttribute('data-oracle-upload-candidate', 'true');
154
548
  el.setAttribute('data-oracle-upload-idx', String(idx));
155
- return { idx: idx++, score };
549
+ return { idx: idx++, score, imageOnly };
156
550
  });
551
+ if (!isImageAttachment) {
552
+ const nonImage = candidates.filter((candidate) => !candidate.imageOnly);
553
+ if (nonImage.length > 0) {
554
+ candidates = nonImage;
555
+ }
556
+ }
157
557
 
158
558
  // Prefer higher scores first.
159
559
  candidates.sort((a, b) => b.score - a.score);
160
- 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
+ };
161
569
  })()`,
162
570
  returnByValue: true,
163
571
  });
164
572
  const candidateValue = candidateSetup?.result?.value;
165
573
  const candidateOrder = Array.isArray(candidateValue?.order) ? candidateValue.order : [];
166
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);
167
585
  if (!candidateValue?.ok || candidateOrder.length === 0) {
168
586
  await logDomFailure(runtime, logger, 'file-input-missing');
169
587
  throw new Error('Unable to locate ChatGPT file attachment input.');
170
588
  }
171
- const dispatchEventsFor = (idx) => `(() => {
172
- const el = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
173
- if (el instanceof HTMLInputElement) {
174
- el.dispatchEvent(new Event('input', { bubbles: true }));
175
- 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 };
176
627
  }
177
- 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
+ };
178
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
+ };
179
672
  const composerSnapshotFor = (idx) => `(() => {
180
673
  const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
181
- 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
+ }
182
684
  for (const selector of promptSelectors) {
183
685
  const node = document.querySelector(selector);
184
- if (!node) continue;
185
- 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;
186
719
  }
187
720
  return document.querySelector('form') ?? document.body;
188
721
  };
189
722
  const root = locateComposerRoot();
190
- 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;
191
730
  const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
192
731
  const chips = Array.from(chipContainer.querySelectorAll(chipSelector))
193
732
  .slice(0, 20)
@@ -197,92 +736,392 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
197
736
  title: node.getAttribute?.('title') ?? '',
198
737
  testid: node.getAttribute?.('data-testid') ?? '',
199
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
+ });
200
751
  const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
201
752
  const inputNames =
202
753
  input instanceof HTMLInputElement
203
754
  ? Array.from(input.files || []).map((f) => f?.name ?? '').filter(Boolean)
204
755
  : [];
205
756
  const composerText = (chipContainer.innerText || '').toLowerCase();
206
- 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
+ };
207
764
  })()`;
765
+ let confirmedAttachment = false;
766
+ let lastInputNames = [];
767
+ let lastInputValue = '';
208
768
  let finalSnapshot = null;
209
- for (const idx of candidateOrder) {
210
- const resultNode = await dom.querySelector({
211
- nodeId: documentNode.root.nodeId,
212
- selector: `input[type="file"][data-oracle-upload-idx="${idx}"]`,
213
- });
214
- if (!resultNode?.nodeId) {
215
- continue;
769
+ const resolveInputNameCandidates = () => {
770
+ const snapshot = finalSnapshot;
771
+ const snapshotNames = snapshot?.inputNames;
772
+ if (Array.isArray(snapshotNames) && snapshotNames.length > 0) {
773
+ return snapshotNames;
216
774
  }
217
- await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
218
- await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
219
- await delay(350);
220
- const probeDeadline = Date.now() + 6500;
221
- const pokeIntervalMs = 1200;
222
- let lastPoke = 0;
223
- let seenInputHasFile = false;
224
- while (Date.now() < probeDeadline) {
225
- // ChatGPT's composer can take a moment to hydrate the file-input onChange handler after navigation/model switches.
226
- // If the UI hasn't reacted yet, poke the input a few times to ensure the handler fires once it's mounted.
227
- if (!seenInputHasFile && Date.now() - lastPoke > pokeIntervalMs) {
228
- lastPoke = Date.now();
229
- await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
230
- }
231
- const snapshot = await runtime
232
- .evaluate({ expression: composerSnapshotFor(idx), returnByValue: true })
233
- .then((res) => res?.result?.value)
234
- .catch(() => undefined);
235
- if (snapshot) {
236
- finalSnapshot = {
237
- chipCount: Number(snapshot.chipCount ?? 0),
238
- chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
239
- inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
240
- composerText: typeof snapshot.composerText === 'string' ? snapshot.composerText : '',
241
- };
242
- const inputHasFile = finalSnapshot.inputNames.some((name) => name.toLowerCase().includes(expectedName.toLowerCase()));
243
- seenInputHasFile = seenInputHasFile || inputHasFile;
244
- const expectedLower = expectedName.toLowerCase();
245
- const expectedNoExt = expectedLower.replace(/\.[a-z0-9]{1,10}$/i, '');
246
- const uiAcknowledged = finalSnapshot.chipCount > baselineChipCount ||
247
- (expectedNoExt.length >= 6
248
- ? finalSnapshot.composerText.includes(expectedNoExt)
249
- : finalSnapshot.composerText.includes(expectedLower));
250
- if (inputHasFile && uiAcknowledged) {
251
- logger?.(`Attachment snapshot after setFileInputFiles: chips=${JSON.stringify(finalSnapshot.chips)} input=${JSON.stringify(finalSnapshot.inputNames)}`);
252
- 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' };
253
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' };
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);
254
924
  }
255
- await delay(250);
256
- }
257
- const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
258
- const uiAcknowledged = (finalSnapshot?.chipCount ?? 0) > baselineChipCount;
259
- if (inputHasFile && uiAcknowledged) {
260
- break;
261
925
  }
262
926
  }
263
- const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
264
- 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));
265
938
  if (await waitForAttachmentAnchored(runtime, expectedName, attachmentUiTimeoutMs)) {
266
939
  await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
267
940
  logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
268
- return;
941
+ return true;
269
942
  }
270
- // If ChatGPT never reflects an attachment UI chip/remove control, the file input may be the wrong target:
271
- // sending in this state often drops the attachment silently.
272
- if (inputHasFile) {
273
- await logDomFailure(runtime, logger, 'file-upload-missing');
274
- 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;
275
946
  }
276
947
  await logDomFailure(runtime, logger, 'file-upload-missing');
277
948
  throw new Error('Attachment did not register with the ChatGPT composer in time.');
278
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
+ }
279
1061
  export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
280
1062
  const deadline = Date.now() + timeoutMs;
281
1063
  const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
282
1064
  let inputMatchSince = null;
1065
+ let sawInputMatch = false;
283
1066
  let attachmentMatchSince = null;
1067
+ let lastVerboseLog = 0;
284
1068
  const expression = `(() => {
285
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
+ })();
286
1125
  let button = null;
287
1126
  for (const selector of sendSelectors) {
288
1127
  button = document.querySelector(selector);
@@ -307,39 +1146,168 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
307
1146
  return /\buploading\b/.test(text) || /\bprocessing\b/.test(text);
308
1147
  });
309
1148
  });
310
- 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
+ ];
311
1157
  const attachedNames = [];
312
- for (const selector of attachmentSelectors) {
313
- for (const node of Array.from(document.querySelectorAll(selector))) {
314
- const text = node?.textContent?.toLowerCase?.();
315
- 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
+ }
316
1169
  }
317
1170
  }
318
- const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
1171
+ const cardTexts = Array.from(composerScope.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
319
1172
  btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
320
1173
  );
321
1174
  attachedNames.push(...cardTexts.filter(Boolean));
322
1175
 
323
1176
  const inputNames = [];
324
- 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) {
325
1187
  if (!(input instanceof HTMLInputElement) || !input.files?.length) continue;
326
1188
  for (const file of Array.from(input.files)) {
327
1189
  if (file?.name) inputNames.push(file.name.toLowerCase());
328
1190
  }
329
1191
  }
330
- const filesAttached = attachedNames.length > 0;
331
- 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
+ };
332
1273
  })()`;
333
1274
  while (Date.now() < deadline) {
334
- const { result } = await Runtime.evaluate({ expression, returnByValue: true });
1275
+ const response = await Runtime.evaluate({ expression, returnByValue: true });
1276
+ const { result } = response;
335
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
+ }
336
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
+ }
337
1303
  const attachedNames = (value.attachedNames ?? [])
338
1304
  .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
339
1305
  .filter(Boolean);
340
1306
  const inputNames = (value.inputNames ?? [])
341
1307
  .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
342
1308
  .filter(Boolean);
1309
+ const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
1310
+ const fileCountSatisfied = expectedNormalized.length > 0 && fileCount >= expectedNormalized.length;
343
1311
  const matchesExpected = (expected) => {
344
1312
  const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
345
1313
  const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -363,24 +1331,23 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
363
1331
  });
364
1332
  };
365
1333
  const missing = expectedNormalized.filter((expected) => !matchesExpected(expected));
366
- if (missing.length === 0) {
1334
+ if (missing.length === 0 || fileCountSatisfied) {
367
1335
  const stableThresholdMs = value.uploading ? 3000 : 1500;
368
- if (value.state === 'ready') {
369
- if (attachmentMatchSince === null) {
370
- attachmentMatchSince = Date.now();
371
- }
372
- if (Date.now() - attachmentMatchSince > stableThresholdMs) {
373
- return;
374
- }
1336
+ if (attachmentMatchSince === null) {
1337
+ attachmentMatchSince = Date.now();
375
1338
  }
376
- else {
377
- attachmentMatchSince = null;
1339
+ const stable = Date.now() - attachmentMatchSince > stableThresholdMs;
1340
+ if (stable && value.state === 'ready') {
1341
+ return;
378
1342
  }
379
- if (value.state === 'missing' && value.filesAttached) {
1343
+ if (stable && value.state === 'disabled' && !value.uploading) {
1344
+ return;
1345
+ }
1346
+ if (value.state === 'missing' && (value.filesAttached || fileCountSatisfied)) {
380
1347
  return;
381
1348
  }
382
1349
  // If files are attached but button isn't ready yet, give it more time but don't fail immediately.
383
- if (value.filesAttached) {
1350
+ if (value.filesAttached || fileCountSatisfied) {
384
1351
  await delay(500);
385
1352
  continue;
386
1353
  }
@@ -396,16 +1363,19 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
396
1363
  const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
397
1364
  return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
398
1365
  });
399
- if (inputMissing.length === 0 && (value.state === 'ready' || value.state === 'missing')) {
400
- 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) {
401
1370
  if (inputMatchSince === null) {
402
1371
  inputMatchSince = Date.now();
403
1372
  }
404
- if (Date.now() - inputMatchSince > stableThresholdMs) {
405
- return;
406
- }
1373
+ sawInputMatch = true;
407
1374
  }
408
- else {
1375
+ if (inputMatchSince !== null && inputStateOk && Date.now() - inputMatchSince > stableThresholdMs) {
1376
+ return;
1377
+ }
1378
+ if (!inputSeenNow && !sawInputMatch) {
409
1379
  inputMatchSince = null;
410
1380
  }
411
1381
  }
@@ -417,7 +1387,7 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
417
1387
  }
418
1388
  export async function waitForUserTurnAttachments(Runtime, expectedNames, timeoutMs, logger) {
419
1389
  if (!expectedNames || expectedNames.length === 0) {
420
- return;
1390
+ return true;
421
1391
  }
422
1392
  const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
423
1393
  const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
@@ -437,9 +1407,55 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
437
1407
  const title = el.getAttribute('title') || '';
438
1408
  return (aria + ' ' + title).trim().toLowerCase();
439
1409
  }).filter(Boolean);
440
- 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 };
441
1456
  })()`;
442
1457
  const deadline = Date.now() + timeoutMs;
1458
+ let sawAttachmentUi = false;
443
1459
  while (Date.now() < deadline) {
444
1460
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
445
1461
  const value = result?.value;
@@ -447,7 +1463,12 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
447
1463
  await delay(200);
448
1464
  continue;
449
1465
  }
1466
+ if (value.hasAttachmentUi) {
1467
+ sawAttachmentUi = true;
1468
+ }
450
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;
451
1472
  const missing = expectedNormalized.filter((expected) => {
452
1473
  const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
453
1474
  const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -458,11 +1479,15 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
458
1479
  return false;
459
1480
  return true;
460
1481
  });
461
- if (missing.length === 0) {
462
- return;
1482
+ if (missing.length === 0 || fileCountSatisfied) {
1483
+ return true;
463
1484
  }
464
1485
  await delay(250);
465
1486
  }
1487
+ if (!sawAttachmentUi) {
1488
+ logger?.('Sent user message did not expose attachment UI; skipping attachment verification.');
1489
+ return false;
1490
+ }
466
1491
  logger?.('Sent user message did not show expected attachment names in time.');
467
1492
  await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-missing-user-turn');
468
1493
  throw new Error('Attachment was not present on the sent user message.');
@@ -477,6 +1502,7 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
477
1502
  const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
478
1503
  const matchNode = (node) => {
479
1504
  if (!node) return false;
1505
+ if (node.tagName === 'INPUT' && node.type === 'file') return false;
480
1506
  const text = (node.textContent || '').toLowerCase();
481
1507
  const aria = node.getAttribute?.('aria-label')?.toLowerCase?.() ?? '';
482
1508
  const title = node.getAttribute?.('title')?.toLowerCase?.() ?? '';
@@ -486,7 +1512,7 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
486
1512
  return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
487
1513
  };
488
1514
 
489
- 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"]'];
490
1516
  const attachmentMatch = attachmentSelectors.some((selector) =>
491
1517
  Array.from(document.querySelectorAll(selector)).some(matchNode),
492
1518
  );
@@ -501,6 +1527,77 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
501
1527
  return { found: true, source: 'attachment-cards' };
502
1528
  }
503
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
+
504
1601
  return { found: false };
505
1602
  })()`;
506
1603
  while (Date.now() < deadline) {
@@ -547,6 +1644,7 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
547
1644
  ];
548
1645
  for (const selector of selectors) {
549
1646
  for (const node of Array.from(document.querySelectorAll(selector))) {
1647
+ if (node?.tagName === 'INPUT' && node?.type === 'file') continue;
550
1648
  const text = node?.textContent || '';
551
1649
  const aria = node?.getAttribute?.('aria-label') || '';
552
1650
  const title = node?.getAttribute?.('title') || '';
@@ -561,6 +1659,76 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
561
1659
  if (cards.some(matchesExpected)) {
562
1660
  return { found: true, text: cards.find(matchesExpected) };
563
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
+ }
564
1732
  return { found: false };
565
1733
  })()`;
566
1734
  while (Date.now() < deadline) {