@steipete/oracle 0.7.0 → 0.7.2
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/dist/bin/oracle-cli.js +4 -4
- package/dist/src/browser/actions/assistantResponse.js +125 -73
- package/dist/src/browser/actions/attachments.js +307 -130
- package/dist/src/browser/actions/modelSelection.js +127 -7
- package/dist/src/browser/actions/promptComposer.js +60 -59
- package/dist/src/browser/config.js +7 -2
- package/dist/src/browser/constants.js +6 -2
- package/dist/src/browser/index.js +84 -7
- package/dist/src/browser/pageActions.js +1 -1
- package/dist/src/browser/utils.js +10 -0
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/browserConfig.js +40 -9
- package/dist/src/cli/help.js +3 -3
- package/dist/src/cli/options.js +20 -8
- package/dist/src/cli/runOptions.js +4 -1
- package/dist/src/gemini-web/client.js +17 -11
- package/dist/src/gemini-web/executor.js +82 -62
- package/dist/src/mcp/tools/consult.js +4 -1
- package/dist/src/oracle/config.js +1 -1
- package/dist/src/oracle/run.js +15 -4
- package/package.json +16 -16
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
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
|
export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
@@ -22,17 +22,11 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
|
22
22
|
),
|
|
23
23
|
);
|
|
24
24
|
if (chips) return true;
|
|
25
|
-
const cardTexts = Array.from(document.querySelectorAll('[aria-label
|
|
25
|
+
const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]')).map((btn) =>
|
|
26
26
|
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
27
27
|
);
|
|
28
28
|
if (cardTexts.some((text) => text.includes(expected))) return true;
|
|
29
29
|
|
|
30
|
-
const filesPill = Array.from(document.querySelectorAll('button,div')).some((node) => {
|
|
31
|
-
const text = (node?.textContent || '').toLowerCase();
|
|
32
|
-
return /\bfiles\b/.test(text) && text.includes('file');
|
|
33
|
-
});
|
|
34
|
-
if (filesPill) return true;
|
|
35
|
-
|
|
36
30
|
const inputs = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
37
31
|
Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(expected)),
|
|
38
32
|
);
|
|
@@ -85,10 +79,21 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
|
85
79
|
logger(`Attachment already present: ${path.basename(attachment.path)}`);
|
|
86
80
|
return;
|
|
87
81
|
}
|
|
88
|
-
|
|
89
|
-
const
|
|
82
|
+
const documentNode = await dom.getDocument();
|
|
83
|
+
const candidateSetup = await runtime.evaluate({
|
|
90
84
|
expression: `(() => {
|
|
91
|
-
const
|
|
85
|
+
const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
86
|
+
const locateComposerRoot = () => {
|
|
87
|
+
for (const selector of promptSelectors) {
|
|
88
|
+
const node = document.querySelector(selector);
|
|
89
|
+
if (!node) continue;
|
|
90
|
+
return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
|
|
91
|
+
}
|
|
92
|
+
return document.querySelector('form') ?? document.body;
|
|
93
|
+
};
|
|
94
|
+
const root = locateComposerRoot();
|
|
95
|
+
const localInputs = root ? Array.from(root.querySelectorAll('input[type="file"]')) : [];
|
|
96
|
+
const inputs = localInputs.length > 0 ? localInputs : Array.from(document.querySelectorAll('input[type="file"]'));
|
|
92
97
|
const acceptIsImageOnly = (accept) => {
|
|
93
98
|
if (!accept) return false;
|
|
94
99
|
const parts = String(accept)
|
|
@@ -97,73 +102,142 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
|
97
102
|
.filter(Boolean);
|
|
98
103
|
return parts.length > 0 && parts.every((p) => p.startsWith('image/'));
|
|
99
104
|
};
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
const chipContainer = root ?? document;
|
|
106
|
+
const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
|
|
107
|
+
const baselineChipCount = chipContainer.querySelectorAll(chipSelector).length;
|
|
108
|
+
|
|
109
|
+
// Mark candidates with stable indices so we can select them via DOM.querySelector.
|
|
110
|
+
let idx = 0;
|
|
111
|
+
const candidates = inputs.map((el) => {
|
|
112
|
+
const accept = el.getAttribute('accept') || '';
|
|
113
|
+
const score = (el.hasAttribute('multiple') ? 100 : 0) + (!acceptIsImageOnly(accept) ? 10 : 0);
|
|
114
|
+
el.setAttribute('data-oracle-upload-candidate', 'true');
|
|
115
|
+
el.setAttribute('data-oracle-upload-idx', String(idx));
|
|
116
|
+
return { idx: idx++, score };
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Prefer higher scores first.
|
|
120
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
121
|
+
return { ok: candidates.length > 0, baselineChipCount, order: candidates.map((c) => c.idx) };
|
|
107
122
|
})()`,
|
|
108
123
|
returnByValue: true,
|
|
109
124
|
});
|
|
110
|
-
const
|
|
111
|
-
|
|
125
|
+
const candidateValue = candidateSetup?.result?.value;
|
|
126
|
+
const candidateOrder = Array.isArray(candidateValue?.order) ? candidateValue.order : [];
|
|
127
|
+
const baselineChipCount = typeof candidateValue?.baselineChipCount === 'number' ? candidateValue.baselineChipCount : 0;
|
|
128
|
+
if (!candidateValue?.ok || candidateOrder.length === 0) {
|
|
112
129
|
await logDomFailure(runtime, logger, 'file-input-missing');
|
|
113
130
|
throw new Error('Unable to locate ChatGPT file attachment input.');
|
|
114
131
|
}
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
const dispatchEventsFor = (idx) => `(() => {
|
|
133
|
+
const el = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
|
|
134
|
+
if (el instanceof HTMLInputElement) {
|
|
135
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
136
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
120
137
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
.join('\\n');
|
|
133
|
-
const tryFileInput = async () => {
|
|
134
|
-
await dom.setFileInputFiles({ nodeId: resolvedNodeId, files: [attachment.path] });
|
|
135
|
-
await runtime.evaluate({ expression: `(function(){${dispatchEvents} return true;})()`, returnByValue: true });
|
|
138
|
+
return true;
|
|
139
|
+
})()`;
|
|
140
|
+
const composerSnapshotFor = (idx) => `(() => {
|
|
141
|
+
const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
142
|
+
const locateComposerRoot = () => {
|
|
143
|
+
for (const selector of promptSelectors) {
|
|
144
|
+
const node = document.querySelector(selector);
|
|
145
|
+
if (!node) continue;
|
|
146
|
+
return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
|
|
147
|
+
}
|
|
148
|
+
return document.querySelector('form') ?? document.body;
|
|
136
149
|
};
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const chips = Array.from(
|
|
141
|
-
.
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
const root = locateComposerRoot();
|
|
151
|
+
const chipContainer = root ?? document;
|
|
152
|
+
const chipSelector = '[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label*="Remove"],[aria-label*="remove"]';
|
|
153
|
+
const chips = Array.from(chipContainer.querySelectorAll(chipSelector))
|
|
154
|
+
.slice(0, 20)
|
|
155
|
+
.map((node) => ({
|
|
156
|
+
text: (node.textContent || '').trim(),
|
|
157
|
+
aria: node.getAttribute?.('aria-label') ?? '',
|
|
158
|
+
title: node.getAttribute?.('title') ?? '',
|
|
159
|
+
testid: node.getAttribute?.('data-testid') ?? '',
|
|
160
|
+
}));
|
|
161
|
+
const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
|
|
162
|
+
const inputNames =
|
|
163
|
+
input instanceof HTMLInputElement
|
|
164
|
+
? Array.from(input.files || []).map((f) => f?.name ?? '').filter(Boolean)
|
|
165
|
+
: [];
|
|
166
|
+
const composerText = (chipContainer.innerText || '').toLowerCase();
|
|
167
|
+
return { chipCount: chipContainer.querySelectorAll(chipSelector).length, chips, inputNames, composerText };
|
|
147
168
|
})()`;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
169
|
+
let finalSnapshot = null;
|
|
170
|
+
for (const idx of candidateOrder) {
|
|
171
|
+
const resultNode = await dom.querySelector({
|
|
172
|
+
nodeId: documentNode.root.nodeId,
|
|
173
|
+
selector: `input[type="file"][data-oracle-upload-idx="${idx}"]`,
|
|
174
|
+
});
|
|
175
|
+
if (!resultNode?.nodeId) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
|
|
179
|
+
await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
|
|
180
|
+
const probeDeadline = Date.now() + 4000;
|
|
181
|
+
let lastPoke = 0;
|
|
182
|
+
while (Date.now() < probeDeadline) {
|
|
183
|
+
// ChatGPT's composer can take a moment to hydrate the file-input onChange handler after navigation/model switches.
|
|
184
|
+
// If the UI hasn't reacted yet, poke the input a few times to ensure the handler fires once it's mounted.
|
|
185
|
+
if (Date.now() - lastPoke > 650) {
|
|
186
|
+
lastPoke = Date.now();
|
|
187
|
+
await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
|
|
188
|
+
}
|
|
189
|
+
const snapshot = await runtime
|
|
190
|
+
.evaluate({ expression: composerSnapshotFor(idx), returnByValue: true })
|
|
191
|
+
.then((res) => res?.result?.value)
|
|
192
|
+
.catch(() => undefined);
|
|
193
|
+
if (snapshot) {
|
|
194
|
+
finalSnapshot = {
|
|
195
|
+
chipCount: Number(snapshot.chipCount ?? 0),
|
|
196
|
+
chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
|
|
197
|
+
inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
|
|
198
|
+
composerText: typeof snapshot.composerText === 'string' ? snapshot.composerText : '',
|
|
199
|
+
};
|
|
200
|
+
const inputHasFile = finalSnapshot.inputNames.some((name) => name.toLowerCase().includes(expectedName.toLowerCase()));
|
|
201
|
+
const expectedLower = expectedName.toLowerCase();
|
|
202
|
+
const expectedNoExt = expectedLower.replace(/\.[a-z0-9]{1,10}$/i, '');
|
|
203
|
+
const uiAcknowledged = finalSnapshot.chipCount > baselineChipCount ||
|
|
204
|
+
(expectedNoExt.length >= 6
|
|
205
|
+
? finalSnapshot.composerText.includes(expectedNoExt)
|
|
206
|
+
: finalSnapshot.composerText.includes(expectedLower));
|
|
207
|
+
if (inputHasFile && uiAcknowledged) {
|
|
208
|
+
logger?.(`Attachment snapshot after setFileInputFiles: chips=${JSON.stringify(finalSnapshot.chips)} input=${JSON.stringify(finalSnapshot.inputNames)}`);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await delay(200);
|
|
213
|
+
}
|
|
214
|
+
const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
|
|
215
|
+
const uiAcknowledged = (finalSnapshot?.chipCount ?? 0) > baselineChipCount;
|
|
216
|
+
if (inputHasFile && uiAcknowledged) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
154
219
|
}
|
|
155
|
-
const inputHasFile =
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
220
|
+
const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
|
|
221
|
+
const attachmentUiTimeoutMs = 25_000;
|
|
222
|
+
if (await waitForAttachmentAnchored(runtime, expectedName, attachmentUiTimeoutMs)) {
|
|
223
|
+
await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
|
|
224
|
+
logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
|
|
159
225
|
return;
|
|
160
226
|
}
|
|
227
|
+
// If ChatGPT never reflects an attachment UI chip/remove control, the file input may be the wrong target:
|
|
228
|
+
// sending in this state often drops the attachment silently.
|
|
229
|
+
if (inputHasFile) {
|
|
230
|
+
await logDomFailure(runtime, logger, 'file-upload-missing');
|
|
231
|
+
throw new Error('Attachment input accepted the file but ChatGPT did not acknowledge it in the composer UI.');
|
|
232
|
+
}
|
|
161
233
|
await logDomFailure(runtime, logger, 'file-upload-missing');
|
|
162
234
|
throw new Error('Attachment did not register with the ChatGPT composer in time.');
|
|
163
235
|
}
|
|
164
236
|
export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
|
|
165
237
|
const deadline = Date.now() + timeoutMs;
|
|
166
238
|
const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
|
|
239
|
+
let inputMatchSince = null;
|
|
240
|
+
let attachmentMatchSince = null;
|
|
167
241
|
const expression = `(() => {
|
|
168
242
|
const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
169
243
|
let button = null;
|
|
@@ -185,8 +259,9 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
185
259
|
if (ariaBusy === 'true' || dataState === 'loading' || dataState === 'uploading' || dataState === 'pending') {
|
|
186
260
|
return true;
|
|
187
261
|
}
|
|
262
|
+
// Avoid false positives from user prompts ("upload:") or generic UI copy; only treat explicit progress strings as uploading.
|
|
188
263
|
const text = node.textContent?.toLowerCase?.() ?? '';
|
|
189
|
-
return text
|
|
264
|
+
return /\buploading\b/.test(text) || /\bprocessing\b/.test(text);
|
|
190
265
|
});
|
|
191
266
|
});
|
|
192
267
|
const attachmentSelectors = ['[data-testid*="chip"]', '[data-testid*="attachment"]', '[data-testid*="upload"]'];
|
|
@@ -197,32 +272,99 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
197
272
|
if (text) attachedNames.push(text);
|
|
198
273
|
}
|
|
199
274
|
}
|
|
275
|
+
const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
|
|
276
|
+
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
277
|
+
);
|
|
278
|
+
attachedNames.push(...cardTexts.filter(Boolean));
|
|
279
|
+
|
|
280
|
+
const inputNames = [];
|
|
200
281
|
for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
|
|
201
282
|
if (!(input instanceof HTMLInputElement) || !input.files?.length) continue;
|
|
202
283
|
for (const file of Array.from(input.files)) {
|
|
203
|
-
if (file?.name)
|
|
284
|
+
if (file?.name) inputNames.push(file.name.toLowerCase());
|
|
204
285
|
}
|
|
205
286
|
}
|
|
206
|
-
const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
|
|
207
|
-
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
208
|
-
);
|
|
209
|
-
attachedNames.push(...cardTexts.filter(Boolean));
|
|
210
287
|
const filesAttached = attachedNames.length > 0;
|
|
211
|
-
return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames };
|
|
288
|
+
return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames, inputNames };
|
|
212
289
|
})()`;
|
|
213
290
|
while (Date.now() < deadline) {
|
|
214
291
|
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
215
292
|
const value = result?.value;
|
|
216
|
-
if (value
|
|
217
|
-
const
|
|
218
|
-
|
|
293
|
+
if (value) {
|
|
294
|
+
const attachedNames = (value.attachedNames ?? [])
|
|
295
|
+
.map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
|
|
296
|
+
.filter(Boolean);
|
|
297
|
+
const inputNames = (value.inputNames ?? [])
|
|
298
|
+
.map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
|
|
299
|
+
.filter(Boolean);
|
|
300
|
+
const matchesExpected = (expected) => {
|
|
301
|
+
const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
|
|
302
|
+
const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
303
|
+
const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
|
|
304
|
+
return attachedNames.some((raw) => {
|
|
305
|
+
if (raw.includes(normalizedExpected))
|
|
306
|
+
return true;
|
|
307
|
+
if (expectedNoExt.length >= 6 && raw.includes(expectedNoExt))
|
|
308
|
+
return true;
|
|
309
|
+
if (raw.includes('…') || raw.includes('...')) {
|
|
310
|
+
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
311
|
+
const pattern = escaped.replace(/\\…|\\\.\\\.\\\./g, '.*');
|
|
312
|
+
try {
|
|
313
|
+
const re = new RegExp(pattern);
|
|
314
|
+
return re.test(normalizedExpected) || (expectedNoExt.length >= 6 && re.test(expectedNoExt));
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
const missing = expectedNormalized.filter((expected) => !matchesExpected(expected));
|
|
219
324
|
if (missing.length === 0) {
|
|
325
|
+
const stableThresholdMs = value.uploading ? 3000 : 1500;
|
|
220
326
|
if (value.state === 'ready') {
|
|
221
|
-
|
|
327
|
+
if (attachmentMatchSince === null) {
|
|
328
|
+
attachmentMatchSince = Date.now();
|
|
329
|
+
}
|
|
330
|
+
if (Date.now() - attachmentMatchSince > stableThresholdMs) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
attachmentMatchSince = null;
|
|
222
336
|
}
|
|
223
337
|
if (value.state === 'missing' && value.filesAttached) {
|
|
224
338
|
return;
|
|
225
339
|
}
|
|
340
|
+
// If files are attached but button isn't ready yet, give it more time but don't fail immediately.
|
|
341
|
+
if (value.filesAttached) {
|
|
342
|
+
await delay(500);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
attachmentMatchSince = null;
|
|
348
|
+
}
|
|
349
|
+
// Fallback: if the file input has the expected names, allow progress once that condition is stable.
|
|
350
|
+
// Some ChatGPT surfaces only render the filename after sending the message.
|
|
351
|
+
const inputMissing = expectedNormalized.filter((expected) => {
|
|
352
|
+
const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
|
|
353
|
+
const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
354
|
+
const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
|
|
355
|
+
return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
|
|
356
|
+
});
|
|
357
|
+
if (inputMissing.length === 0 && (value.state === 'ready' || value.state === 'missing')) {
|
|
358
|
+
const stableThresholdMs = value.uploading ? 3000 : 1500;
|
|
359
|
+
if (inputMatchSince === null) {
|
|
360
|
+
inputMatchSince = Date.now();
|
|
361
|
+
}
|
|
362
|
+
if (Date.now() - inputMatchSince > stableThresholdMs) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
inputMatchSince = null;
|
|
226
368
|
}
|
|
227
369
|
}
|
|
228
370
|
await delay(250);
|
|
@@ -231,6 +373,58 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
231
373
|
await logDomFailure(Runtime, logger ?? (() => { }), 'file-upload-timeout');
|
|
232
374
|
throw new Error('Attachments did not finish uploading before timeout.');
|
|
233
375
|
}
|
|
376
|
+
export async function waitForUserTurnAttachments(Runtime, expectedNames, timeoutMs, logger) {
|
|
377
|
+
if (!expectedNames || expectedNames.length === 0) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
|
|
381
|
+
const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
382
|
+
const expression = `(() => {
|
|
383
|
+
const CONVERSATION_SELECTOR = ${conversationSelectorLiteral};
|
|
384
|
+
const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
385
|
+
const userTurns = turns.filter((node) => {
|
|
386
|
+
const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
387
|
+
if (attr === 'user') return true;
|
|
388
|
+
return Boolean(node.querySelector('[data-message-author-role="user"]'));
|
|
389
|
+
});
|
|
390
|
+
const lastUser = userTurns[userTurns.length - 1];
|
|
391
|
+
if (!lastUser) return { ok: false };
|
|
392
|
+
const text = (lastUser.innerText || '').toLowerCase();
|
|
393
|
+
const attrs = Array.from(lastUser.querySelectorAll('[aria-label],[title]')).map((el) => {
|
|
394
|
+
const aria = el.getAttribute('aria-label') || '';
|
|
395
|
+
const title = el.getAttribute('title') || '';
|
|
396
|
+
return (aria + ' ' + title).trim().toLowerCase();
|
|
397
|
+
}).filter(Boolean);
|
|
398
|
+
return { ok: true, text, attrs };
|
|
399
|
+
})()`;
|
|
400
|
+
const deadline = Date.now() + timeoutMs;
|
|
401
|
+
while (Date.now() < deadline) {
|
|
402
|
+
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
403
|
+
const value = result?.value;
|
|
404
|
+
if (!value?.ok) {
|
|
405
|
+
await delay(200);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const haystack = [value.text ?? '', ...(value.attrs ?? [])].join('\n');
|
|
409
|
+
const missing = expectedNormalized.filter((expected) => {
|
|
410
|
+
const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
|
|
411
|
+
const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
412
|
+
const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
|
|
413
|
+
if (haystack.includes(normalizedExpected))
|
|
414
|
+
return false;
|
|
415
|
+
if (expectedNoExt.length >= 6 && haystack.includes(expectedNoExt))
|
|
416
|
+
return false;
|
|
417
|
+
return true;
|
|
418
|
+
});
|
|
419
|
+
if (missing.length === 0) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
await delay(250);
|
|
423
|
+
}
|
|
424
|
+
logger?.('Sent user message did not show expected attachment names in time.');
|
|
425
|
+
await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-missing-user-turn');
|
|
426
|
+
throw new Error('Attachment was not present on the sent user message.');
|
|
427
|
+
}
|
|
234
428
|
export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
|
|
235
429
|
// Attachments can take a few seconds to render in the composer (headless/remote Chrome is slower),
|
|
236
430
|
// so respect the caller-provided timeout instead of capping at 2s.
|
|
@@ -238,6 +432,7 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
|
|
|
238
432
|
const expression = `(() => {
|
|
239
433
|
const expected = ${JSON.stringify(expectedName)};
|
|
240
434
|
const normalized = expected.toLowerCase();
|
|
435
|
+
const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
|
|
241
436
|
const matchNode = (node) => {
|
|
242
437
|
if (!node) return false;
|
|
243
438
|
const text = (node.textContent || '').toLowerCase();
|
|
@@ -245,63 +440,26 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
|
|
|
245
440
|
const title = node.getAttribute?.('title')?.toLowerCase?.() ?? '';
|
|
246
441
|
const testId = node.getAttribute?.('data-testid')?.toLowerCase?.() ?? '';
|
|
247
442
|
const alt = node.getAttribute?.('alt')?.toLowerCase?.() ?? '';
|
|
248
|
-
|
|
443
|
+
const candidates = [text, aria, title, testId, alt].filter(Boolean);
|
|
444
|
+
return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
|
|
249
445
|
};
|
|
250
446
|
|
|
251
|
-
const turns = Array.from(document.querySelectorAll('article[data-testid^="conversation-turn"]'));
|
|
252
|
-
const userTurns = turns.filter((node) => node.querySelector('[data-message-author-role="user"]'));
|
|
253
|
-
const lastUser = userTurns[userTurns.length - 1];
|
|
254
|
-
if (lastUser) {
|
|
255
|
-
const turnMatch = Array.from(lastUser.querySelectorAll('*')).some(matchNode);
|
|
256
|
-
if (turnMatch) return { found: true, userTurns: userTurns.length, source: 'turn' };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const composerSelectors = [
|
|
260
|
-
'[data-testid*="composer"]',
|
|
261
|
-
'form textarea',
|
|
262
|
-
'form [data-testid*="attachment"]',
|
|
263
|
-
'[data-testid*="upload"]',
|
|
264
|
-
'[data-testid*="chip"]',
|
|
265
|
-
'form',
|
|
266
|
-
'button',
|
|
267
|
-
'label'
|
|
268
|
-
];
|
|
269
|
-
const composerMatch = composerSelectors.some((selector) =>
|
|
270
|
-
Array.from(document.querySelectorAll(selector)).some(matchNode),
|
|
271
|
-
);
|
|
272
|
-
if (composerMatch) {
|
|
273
|
-
return { found: true, userTurns: userTurns.length, source: 'composer' };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
447
|
const attachmentSelectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
|
|
277
448
|
const attachmentMatch = attachmentSelectors.some((selector) =>
|
|
278
449
|
Array.from(document.querySelectorAll(selector)).some(matchNode),
|
|
279
450
|
);
|
|
280
451
|
if (attachmentMatch) {
|
|
281
|
-
return { found: true,
|
|
452
|
+
return { found: true, source: 'attachments' };
|
|
282
453
|
}
|
|
283
454
|
|
|
284
|
-
const cardTexts = Array.from(document.querySelectorAll('[aria-label
|
|
455
|
+
const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
|
|
285
456
|
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
286
457
|
);
|
|
287
|
-
if (cardTexts.some((text) => text.includes(normalized))) {
|
|
288
|
-
return { found: true,
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const attrMatch = Array.from(document.querySelectorAll('[aria-label], [title], [data-testid]')).some(matchNode);
|
|
292
|
-
if (attrMatch) {
|
|
293
|
-
return { found: true, userTurns: userTurns.length, source: 'attrs' };
|
|
458
|
+
if (cardTexts.some((text) => text.includes(normalized) || (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)))) {
|
|
459
|
+
return { found: true, source: 'attachment-cards' };
|
|
294
460
|
}
|
|
295
461
|
|
|
296
|
-
|
|
297
|
-
if (bodyMatch) {
|
|
298
|
-
return { found: true, userTurns: userTurns.length, source: 'body' };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
302
|
-
Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
|
|
303
|
-
);
|
|
304
|
-
return { found: inputHit, userTurns: userTurns.length, source: inputHit ? 'input' : undefined };
|
|
462
|
+
return { found: false };
|
|
305
463
|
})()`;
|
|
306
464
|
while (Date.now() < deadline) {
|
|
307
465
|
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
@@ -319,28 +477,47 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
|
|
|
319
477
|
const deadline = Date.now() + timeoutMs;
|
|
320
478
|
const expression = `(() => {
|
|
321
479
|
const normalized = ${JSON.stringify(expectedName.toLowerCase())};
|
|
322
|
-
const
|
|
480
|
+
const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
|
|
481
|
+
const matchesExpected = (value) => {
|
|
482
|
+
const text = (value ?? '').toLowerCase();
|
|
483
|
+
if (!text) return false;
|
|
484
|
+
if (text.includes(normalized)) return true;
|
|
485
|
+
if (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)) return true;
|
|
486
|
+
if (text.includes('…') || text.includes('...')) {
|
|
487
|
+
const escaped = text.replace(/[.*+?^$\\{\\}()|[\\]\\\\]/g, '\\\\$&');
|
|
488
|
+
const pattern = escaped.replaceAll('…', '.*').replaceAll('...', '.*');
|
|
489
|
+
try {
|
|
490
|
+
const re = new RegExp(pattern);
|
|
491
|
+
return re.test(normalized) || (normalizedNoExt.length >= 6 && re.test(normalizedNoExt));
|
|
492
|
+
} catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const selectors = [
|
|
500
|
+
'[data-testid*="attachment"]',
|
|
501
|
+
'[data-testid*="chip"]',
|
|
502
|
+
'[data-testid*="upload"]',
|
|
503
|
+
'[aria-label*="Remove"]',
|
|
504
|
+
'button[aria-label*="Remove"]',
|
|
505
|
+
];
|
|
323
506
|
for (const selector of selectors) {
|
|
324
507
|
for (const node of Array.from(document.querySelectorAll(selector))) {
|
|
325
|
-
const text =
|
|
326
|
-
|
|
327
|
-
|
|
508
|
+
const text = node?.textContent || '';
|
|
509
|
+
const aria = node?.getAttribute?.('aria-label') || '';
|
|
510
|
+
const title = node?.getAttribute?.('title') || '';
|
|
511
|
+
if ([text, aria, title].some(matchesExpected)) {
|
|
512
|
+
return { found: true, text: (text || aria || title).toLowerCase() };
|
|
328
513
|
}
|
|
329
514
|
}
|
|
330
515
|
}
|
|
331
|
-
const cards = Array.from(document.querySelectorAll('[aria-label
|
|
516
|
+
const cards = Array.from(document.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
|
|
332
517
|
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
333
518
|
);
|
|
334
|
-
if (cards.some(
|
|
335
|
-
return { found: true, text: cards.find(
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// As a last resort, treat file inputs that hold the target name as anchored. Some UIs delay chip rendering.
|
|
339
|
-
const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
340
|
-
Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
|
|
341
|
-
);
|
|
342
|
-
if (inputHit) {
|
|
343
|
-
return { found: true, text: 'input-only' };
|
|
519
|
+
if (cards.some(matchesExpected)) {
|
|
520
|
+
return { found: true, text: cards.find(matchesExpected) };
|
|
344
521
|
}
|
|
345
522
|
return { found: false };
|
|
346
523
|
})()`;
|