@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.
- package/README.md +4 -2
- package/dist/bin/oracle-cli.js +12 -2
- package/dist/src/browser/actions/assistantResponse.js +437 -84
- package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
- package/dist/src/browser/actions/attachments.js +1358 -152
- package/dist/src/browser/actions/modelSelection.js +9 -4
- package/dist/src/browser/actions/navigation.js +160 -5
- package/dist/src/browser/actions/promptComposer.js +54 -11
- package/dist/src/browser/actions/remoteFileTransfer.js +5 -156
- package/dist/src/browser/chromeLifecycle.js +7 -1
- package/dist/src/browser/config.js +9 -3
- package/dist/src/browser/constants.js +4 -1
- package/dist/src/browser/cookies.js +55 -21
- package/dist/src/browser/index.js +321 -65
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +2 -2
- package/dist/src/browser/reattach.js +42 -97
- package/dist/src/browser/reattachHelpers.js +382 -0
- package/dist/src/browser/sessionRunner.js +16 -6
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/browserConfig.js +10 -4
- package/dist/src/cli/browserDefaults.js +3 -0
- package/dist/src/cli/options.js +27 -0
- package/dist/src/cli/sessionDisplay.js +25 -6
- package/dist/src/cli/sessionRunner.js +14 -4
- package/dist/src/gemini-web/executor.js +107 -46
- package/dist/src/oracle/finishLine.js +32 -0
- package/dist/src/oracle/run.js +58 -64
- package/dist/src/remote/server.js +30 -15
- package/dist/src/sessionManager.js +16 -0
- package/package.json +8 -17
|
@@ -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
|
-
|
|
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
|
|
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
|
|
14
|
-
const
|
|
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*="
|
|
17
|
-
'[
|
|
59
|
+
'[data-testid*="upload"]',
|
|
60
|
+
'[aria-label*="Remove"]',
|
|
61
|
+
'[aria-label*="remove"]',
|
|
18
62
|
];
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
90
|
-
|
|
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
|
|
96
|
-
|
|
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 =
|
|
106
|
-
|
|
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
|
-
|
|
541
|
+
let candidates = inputs.map((el) => {
|
|
112
542
|
const accept = el.getAttribute('accept') || '';
|
|
113
|
-
const
|
|
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 {
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
|
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 (
|
|
146
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
|
274
|
-
for (const node of Array.from(
|
|
275
|
-
|
|
276
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
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
|
|
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
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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 (
|
|
331
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
1373
|
+
sawInputMatch = true;
|
|
369
1374
|
}
|
|
370
|
-
|
|
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
|
-
|
|
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
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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) {
|