@steipete/oracle 0.12.0 → 0.13.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 +54 -54
- package/dist/bin/oracle-cli.js +15 -6
- package/dist/bin/oracle-mcp.js +0 -0
- package/dist/src/browser/actions/modelSelection.js +126 -25
- package/dist/src/browser/actions/navigation.js +89 -27
- package/dist/src/browser/actions/promptComposer.js +196 -46
- package/dist/src/browser/actions/thinkingTime.js +111 -12
- package/dist/src/browser/config.js +2 -0
- package/dist/src/browser/index.js +43 -4
- package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
- package/dist/src/browser/reattachability.js +22 -0
- package/dist/src/browser/sessionRunner.js +1 -0
- package/dist/src/cli/bridge/doctor.js +7 -2
- package/dist/src/cli/browserConfig.js +9 -1
- package/dist/src/cli/browserDefaults.js +3 -0
- package/dist/src/cli/engine.js +6 -2
- package/dist/src/cli/options.js +4 -0
- package/dist/src/cli/runOptions.js +9 -20
- package/dist/src/cli/sessionDisplay.js +8 -0
- package/dist/src/cli/sessionRunner.js +49 -5
- package/dist/src/config.js +164 -9
- package/dist/src/oracle/providerRoutePlan.js +29 -2
- package/dist/src/oracle/run.js +50 -156
- package/dist/src/sessionManager.js +38 -22
- package/package.json +14 -13
|
@@ -149,7 +149,7 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
|
|
|
149
149
|
});
|
|
150
150
|
const probe = normalizeLoginProbe(outcome.result?.value);
|
|
151
151
|
if (probe.ok) {
|
|
152
|
-
logger(`Login check passed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)})`);
|
|
152
|
+
logger(`Login check passed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, appAuthenticated=${Boolean(probe.appAuthenticated)})`);
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
155
|
const accepted = await attemptWelcomeBackLogin(Runtime, logger);
|
|
@@ -167,9 +167,9 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
|
|
|
167
167
|
logger("Login restored via Welcome back account picker");
|
|
168
168
|
return;
|
|
169
169
|
}
|
|
170
|
-
logger(`Login retry after Welcome back failed (status=${retryProbe.status}, domLoginCta=${Boolean(retryProbe.domLoginCta)})`);
|
|
170
|
+
logger(`Login retry after Welcome back failed (status=${retryProbe.status}, domLoginCta=${Boolean(retryProbe.domLoginCta)}, appAuthenticated=${Boolean(retryProbe.appAuthenticated)})`);
|
|
171
171
|
}
|
|
172
|
-
logger(`Login probe failed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, onAuthPage=${Boolean(probe.onAuthPage)}, url=${probe.pageUrl ?? "n/a"}, error=${probe.error ?? "none"})`);
|
|
172
|
+
logger(`Login probe failed (status=${probe.status}, domLoginCta=${Boolean(probe.domLoginCta)}, onAuthPage=${Boolean(probe.onAuthPage)}, appAuthenticated=${Boolean(probe.appAuthenticated)}, cfBlocked=${Boolean(probe.cfBlocked)}, url=${probe.pageUrl ?? "n/a"}, error=${probe.error ?? "none"})`);
|
|
173
173
|
const domLabel = probe.domLoginCta ? " Login button detected on page." : "";
|
|
174
174
|
const cookieHint = options.remoteSession
|
|
175
175
|
? "The remote Chrome session is not signed into ChatGPT. Sign in there, then rerun."
|
|
@@ -418,51 +418,111 @@ function buildLoginProbeExpression(timeoutMs) {
|
|
|
418
418
|
return false;
|
|
419
419
|
};
|
|
420
420
|
|
|
421
|
-
|
|
421
|
+
// Learned 2026-05-16: ChatGPT's /backend-api/* endpoints now sit behind Cloudflare bot
|
|
422
|
+
// mitigation. Programmatic fetch from the page can return 403 with cf-mitigated:challenge
|
|
423
|
+
// even when the user is logged in via cookies and the SPA renders normally. Detect that
|
|
424
|
+
// case via the response body shape (Cloudflare interstitial HTML) and fall back to a
|
|
425
|
+
// DOM-based logged-in signal instead of looping waitForLogin until the 20-min timeout.
|
|
426
|
+
const isCloudflareBody = (body) => {
|
|
427
|
+
if (typeof body !== 'string' || body.length === 0) return false;
|
|
428
|
+
const head = body.slice(0, 2000).toLowerCase();
|
|
429
|
+
return (
|
|
430
|
+
head.includes('cf-mitigated') ||
|
|
431
|
+
head.includes('cloudflare') ||
|
|
432
|
+
(head.includes('<style global>') && head.includes('scale-appear'))
|
|
433
|
+
);
|
|
434
|
+
};
|
|
435
|
+
const readBackendDetail = async () => {
|
|
422
436
|
try {
|
|
423
|
-
if (typeof fetch
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
437
|
+
if (typeof fetch !== 'function') return { status: 0, cfBlocked: false, error: null };
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
|
|
440
|
+
try {
|
|
441
|
+
const response = await fetch('/backend-api/me', {
|
|
442
|
+
cache: 'no-store',
|
|
443
|
+
credentials: 'include',
|
|
444
|
+
signal: controller.signal,
|
|
445
|
+
});
|
|
446
|
+
let cfBlocked = false;
|
|
447
|
+
if (response.status === 403 || response.status === 503 || response.status === 429) {
|
|
448
|
+
try {
|
|
449
|
+
const text = await response.clone().text();
|
|
450
|
+
cfBlocked = isCloudflareBody(text);
|
|
451
|
+
} catch {}
|
|
436
452
|
}
|
|
453
|
+
return { status: response.status || 0, cfBlocked, error: null };
|
|
454
|
+
} finally {
|
|
455
|
+
clearTimeout(timeout);
|
|
437
456
|
}
|
|
438
457
|
} catch (err) {
|
|
439
|
-
return { status: 0, error: err ? String(err) : 'unknown' };
|
|
458
|
+
return { status: 0, cfBlocked: false, error: err ? String(err) : 'unknown' };
|
|
440
459
|
}
|
|
441
|
-
return { status: 0, error: null };
|
|
442
460
|
};
|
|
443
461
|
|
|
444
|
-
|
|
462
|
+
const hasAppAuthSignal = () => {
|
|
463
|
+
// Composer must be present and visible — the auth/login page never renders one.
|
|
464
|
+
if (typeof document.querySelector !== 'function') return false;
|
|
465
|
+
const composerSelectors = [
|
|
466
|
+
'#prompt-textarea',
|
|
467
|
+
'.ProseMirror',
|
|
468
|
+
'textarea[data-id="prompt-textarea"]',
|
|
469
|
+
'textarea[name="prompt-textarea"]',
|
|
470
|
+
'[contenteditable="true"][role="textbox"]',
|
|
471
|
+
];
|
|
472
|
+
const composer = composerSelectors.map((s) => document.querySelector(s)).find(Boolean);
|
|
473
|
+
if (!composer) return false;
|
|
474
|
+
const rect = composer.getBoundingClientRect && composer.getBoundingClientRect();
|
|
475
|
+
const style = window.getComputedStyle(composer);
|
|
476
|
+
if (!rect || rect.width <= 0 || rect.height <= 0 || style.display === 'none' || style.visibility === 'hidden') {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
// Logged-in users should have an account affordance or prior chat history. Generic
|
|
480
|
+
// composer/model pills also appear in guest sessions, so they are not auth proof.
|
|
481
|
+
const profileButton = document.querySelector('[data-testid="accounts-profile-button"]');
|
|
482
|
+
const historyItem = document.querySelector('[data-testid^="history-item-"]');
|
|
483
|
+
return Boolean(profileButton || historyItem);
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
let backend = await readBackendDetail();
|
|
487
|
+
let status = backend.status;
|
|
488
|
+
let cfBlocked = backend.cfBlocked;
|
|
489
|
+
let error = backend.error;
|
|
445
490
|
let domLoginCta = hasLoginCta();
|
|
491
|
+
let appAuthenticated = hasAppAuthSignal();
|
|
492
|
+
const isRetryableStatus = () =>
|
|
493
|
+
status === 0 || status === 401 || status === 403 || status === 503 || status === 429;
|
|
446
494
|
const settleDeadline = Date.now() + Math.min(${timeoutMs}, 2500);
|
|
447
|
-
while (!domLoginCta && Date.now() < settleDeadline) {
|
|
495
|
+
while (!domLoginCta && status !== 200 && isRetryableStatus() && Date.now() < settleDeadline) {
|
|
448
496
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
449
497
|
domLoginCta = hasLoginCta();
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
498
|
+
appAuthenticated = hasAppAuthSignal();
|
|
499
|
+
backend = await readBackendDetail();
|
|
500
|
+
status = backend.status;
|
|
501
|
+
cfBlocked = backend.cfBlocked;
|
|
502
|
+
error = backend.error;
|
|
455
503
|
}
|
|
456
504
|
|
|
457
505
|
const loginSignals = domLoginCta || onAuthPage;
|
|
506
|
+
// Accept the SPA-level signal only when the API path is blocked or unavailable
|
|
507
|
+
// (CF challenge, throttling, transient 5xx, network shaping) but the DOM shows
|
|
508
|
+
// an authenticated logged-in shell. Plain 401/403 remain authoritative because
|
|
509
|
+
// they can mean the ChatGPT session really expired.
|
|
510
|
+
const apiBlocked =
|
|
511
|
+
cfBlocked ||
|
|
512
|
+
status === 429 ||
|
|
513
|
+
status === 503 ||
|
|
514
|
+
status === 0;
|
|
515
|
+
const ok = !loginSignals && (status === 200 || (apiBlocked && appAuthenticated));
|
|
458
516
|
return {
|
|
459
|
-
ok
|
|
517
|
+
ok,
|
|
460
518
|
status,
|
|
461
519
|
redirected: false,
|
|
462
520
|
url: pageUrl,
|
|
463
521
|
pageUrl,
|
|
464
522
|
domLoginCta,
|
|
465
523
|
onAuthPage,
|
|
524
|
+
appAuthenticated,
|
|
525
|
+
cfBlocked,
|
|
466
526
|
error,
|
|
467
527
|
};
|
|
468
528
|
})()`;
|
|
@@ -487,6 +547,8 @@ function normalizeLoginProbe(raw) {
|
|
|
487
547
|
pageUrl: typeof value.pageUrl === "string" ? value.pageUrl : null,
|
|
488
548
|
domLoginCta: Boolean(value.domLoginCta),
|
|
489
549
|
onAuthPage: Boolean(value.onAuthPage),
|
|
550
|
+
appAuthenticated: Boolean(value.appAuthenticated),
|
|
551
|
+
cfBlocked: Boolean(value.cfBlocked),
|
|
490
552
|
};
|
|
491
553
|
}
|
|
492
554
|
export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
|
|
@@ -166,7 +166,7 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
166
166
|
observedLength,
|
|
167
167
|
});
|
|
168
168
|
}
|
|
169
|
-
const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames);
|
|
169
|
+
const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
|
|
170
170
|
if (!clicked) {
|
|
171
171
|
await input.dispatchKeyEvent({
|
|
172
172
|
type: "keyDown",
|
|
@@ -183,6 +183,7 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
183
183
|
else {
|
|
184
184
|
logger("Clicked send button");
|
|
185
185
|
}
|
|
186
|
+
await deps.onPromptSubmitted?.();
|
|
186
187
|
const commitTimeoutMs = Math.max(60_000, deps.inputTimeoutMs ?? 0);
|
|
187
188
|
// Learned: the send button can succeed but the turn doesn't appear immediately; verify commit via turns/stop button.
|
|
188
189
|
return await verifyPromptCommitted(runtime, prompt, commitTimeoutMs, logger, deps.baselineTurns ?? undefined);
|
|
@@ -282,18 +283,136 @@ async function waitForDomReady(Runtime, logger, timeoutMs = 10_000) {
|
|
|
282
283
|
logger?.(`Page did not reach ready/composer state within ${timeoutMs}ms; continuing cautiously.`);
|
|
283
284
|
}
|
|
284
285
|
function buildAttachmentReadyExpression(attachmentNames) {
|
|
285
|
-
const
|
|
286
|
+
const attachmentExpectations = attachmentNames.map((name) => {
|
|
287
|
+
const normalized = name.toLowerCase().replace(/\s+/g, " ").trim();
|
|
288
|
+
return {
|
|
289
|
+
name: normalized,
|
|
290
|
+
stem: normalized.replace(/\.[a-z0-9]{1,10}$/i, ""),
|
|
291
|
+
extension: normalized.match(/(\.[a-z0-9]{1,10})$/i)?.[1] ?? "",
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
const namesLiteral = JSON.stringify(attachmentExpectations);
|
|
286
295
|
return `(() => {
|
|
287
|
-
const
|
|
296
|
+
const expected = ${namesLiteral};
|
|
297
|
+
const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
298
|
+
const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
299
|
+
const hasNameBoundary = (text, name) => {
|
|
300
|
+
if (!name) return false;
|
|
301
|
+
let from = 0;
|
|
302
|
+
while (from < text.length) {
|
|
303
|
+
const index = text.indexOf(name, from);
|
|
304
|
+
if (index === -1) return false;
|
|
305
|
+
const previous = text[index - 1] || '';
|
|
306
|
+
const next = text[index + name.length] || '';
|
|
307
|
+
const previousOk = !previous || !/[a-z0-9]/.test(previous);
|
|
308
|
+
const nextOk = !next || !/[a-z0-9]/.test(next);
|
|
309
|
+
if (previousOk && nextOk) return true;
|
|
310
|
+
from = index + name.length;
|
|
311
|
+
}
|
|
312
|
+
return false;
|
|
313
|
+
};
|
|
314
|
+
const hasExtensionBoundary = (text, extension) => {
|
|
315
|
+
if (!extension) return false;
|
|
316
|
+
let from = 0;
|
|
317
|
+
while (from < text.length) {
|
|
318
|
+
const index = text.indexOf(extension, from);
|
|
319
|
+
if (index === -1) return false;
|
|
320
|
+
const next = text[index + extension.length] || '';
|
|
321
|
+
if (!next || !/[a-z0-9]/.test(next)) return true;
|
|
322
|
+
from = index + extension.length;
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
};
|
|
326
|
+
const matchesExpected = (value, item) => {
|
|
327
|
+
const text = normalize(value);
|
|
328
|
+
if (!text) return false;
|
|
329
|
+
if (hasNameBoundary(text, item.name)) return true;
|
|
330
|
+
if (
|
|
331
|
+
item.stem &&
|
|
332
|
+
item.stem.length >= 4 &&
|
|
333
|
+
item.extension &&
|
|
334
|
+
text.includes(item.stem + '(') &&
|
|
335
|
+
hasExtensionBoundary(text, item.extension)
|
|
336
|
+
) {
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
if (text.includes('…') || text.includes('...')) {
|
|
340
|
+
const marker = text.includes('…') ? '…' : '...';
|
|
341
|
+
const [prefixRaw, suffixRaw] = text.split(marker);
|
|
342
|
+
const prefix = normalize(prefixRaw);
|
|
343
|
+
const suffix = normalize(suffixRaw);
|
|
344
|
+
const prefixParts = prefix.split(' ').filter(Boolean);
|
|
345
|
+
const suffixParts = suffix.split(' ').filter(Boolean);
|
|
346
|
+
const prefixCandidates = prefixParts.map((_, index) => prefixParts.slice(index).join(' '));
|
|
347
|
+
const suffixCandidates = suffixParts.map((_, index) =>
|
|
348
|
+
suffixParts.slice(0, suffixParts.length - index).join(' '),
|
|
349
|
+
);
|
|
350
|
+
if (prefixCandidates.length === 0 || suffixCandidates.length === 0) return false;
|
|
351
|
+
const targets = [item.name, item.stem && item.stem.length >= 4 ? item.stem : ''].filter(Boolean);
|
|
352
|
+
return targets.some((target) => {
|
|
353
|
+
return prefixCandidates.some((prefixPart) =>
|
|
354
|
+
suffixCandidates.some((suffixPart) => {
|
|
355
|
+
const strongEnough =
|
|
356
|
+
suffixPart.length >= 2 &&
|
|
357
|
+
(prefixPart.length >= 3 || (prefixPart.length >= 2 && suffixPart.length >= 4));
|
|
358
|
+
return strongEnough && target.startsWith(prefixPart) && target.endsWith(suffixPart);
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
};
|
|
365
|
+
// Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
|
|
366
|
+
const attachmentSelectors = [
|
|
367
|
+
'[data-testid*="chip"]',
|
|
368
|
+
'[data-testid*="attachment"]',
|
|
369
|
+
'[data-testid*="upload"]',
|
|
370
|
+
'[data-testid*="file"]',
|
|
371
|
+
'[aria-label*="Remove file"]',
|
|
372
|
+
'button[aria-label*="Remove file"]',
|
|
373
|
+
'[aria-label*="remove file"]',
|
|
374
|
+
'button[aria-label*="remove file"]',
|
|
375
|
+
'[aria-label*="Remove attachment"]',
|
|
376
|
+
'button[aria-label*="Remove attachment"]',
|
|
377
|
+
'[aria-label*="remove attachment"]',
|
|
378
|
+
'button[aria-label*="remove attachment"]',
|
|
379
|
+
];
|
|
380
|
+
const sendButton = sendSelectors
|
|
381
|
+
.map((selector) => document.querySelector(selector))
|
|
382
|
+
.find(Boolean);
|
|
383
|
+
const isUsableComposerRoot = (node) => {
|
|
384
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
385
|
+
if (String(node.tagName || '').toLowerCase() === 'button') return false;
|
|
386
|
+
const testId = String(node.getAttribute?.('data-testid') || '').toLowerCase();
|
|
387
|
+
if (!testId.includes('composer')) return false;
|
|
388
|
+
return !(
|
|
389
|
+
testId.includes('footer') ||
|
|
390
|
+
testId.includes('action') ||
|
|
391
|
+
testId.includes('plus') ||
|
|
392
|
+
testId.includes('send')
|
|
393
|
+
);
|
|
394
|
+
};
|
|
395
|
+
const closestComposerRoot = (node) => {
|
|
396
|
+
let current = node instanceof HTMLElement ? node : null;
|
|
397
|
+
while (current) {
|
|
398
|
+
if (isUsableComposerRoot(current)) return current;
|
|
399
|
+
current = current.parentElement;
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
};
|
|
403
|
+
const firstComposerRoot = () =>
|
|
404
|
+
Array.from(document.querySelectorAll('[data-testid*="composer"]')).find(isUsableComposerRoot) || null;
|
|
288
405
|
const composer =
|
|
289
|
-
|
|
406
|
+
closestComposerRoot(sendButton) ||
|
|
407
|
+
sendButton?.closest?.('form') ||
|
|
408
|
+
firstComposerRoot() ||
|
|
290
409
|
document.querySelector('form') ||
|
|
291
410
|
document.body ||
|
|
292
411
|
document;
|
|
293
412
|
// Walk node + ancestors (up to grandparent) + descendants to gather every textual hint.
|
|
294
413
|
// ChatGPT's current chip DOM nests the filename inside truncated child spans, so checking
|
|
295
414
|
// only the node's own textContent/aria/title misses the match.
|
|
296
|
-
const
|
|
415
|
+
const collectOwnLabelHaystack = (node) => {
|
|
297
416
|
if (!node) return '';
|
|
298
417
|
const pieces = [];
|
|
299
418
|
const pushAttrs = (el) => {
|
|
@@ -310,31 +429,21 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
310
429
|
};
|
|
311
430
|
pushAttrs(node);
|
|
312
431
|
pushText(node);
|
|
432
|
+
return pieces.join(' ').toLowerCase();
|
|
433
|
+
};
|
|
434
|
+
const collectLabelHaystack = (node) => {
|
|
435
|
+
if (!node) return '';
|
|
436
|
+
const pieces = [collectOwnLabelHaystack(node)];
|
|
437
|
+
const push = (el) => {
|
|
438
|
+
const text = collectOwnLabelHaystack(el);
|
|
439
|
+
if (text) pieces.push(text);
|
|
440
|
+
};
|
|
313
441
|
const parent = node.parentElement;
|
|
314
|
-
|
|
315
|
-
pushText(parent);
|
|
442
|
+
push(parent);
|
|
316
443
|
const grandparent = parent?.parentElement;
|
|
317
|
-
|
|
318
|
-
pushText(grandparent);
|
|
444
|
+
push(grandparent);
|
|
319
445
|
return pieces.join(' ').toLowerCase();
|
|
320
446
|
};
|
|
321
|
-
const match = (node, name) => collectLabelHaystack(node).includes(name);
|
|
322
|
-
|
|
323
|
-
// Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
|
|
324
|
-
const attachmentSelectors = [
|
|
325
|
-
'[data-testid*="chip"]',
|
|
326
|
-
'[data-testid*="attachment"]',
|
|
327
|
-
'[data-testid*="upload"]',
|
|
328
|
-
'[data-testid*="file"]',
|
|
329
|
-
'[aria-label*="Remove file"]',
|
|
330
|
-
'button[aria-label*="Remove file"]',
|
|
331
|
-
'[aria-label*="remove file"]',
|
|
332
|
-
'button[aria-label*="remove file"]',
|
|
333
|
-
'[aria-label*="Remove attachment"]',
|
|
334
|
-
'button[aria-label*="Remove attachment"]',
|
|
335
|
-
'[aria-label*="remove attachment"]',
|
|
336
|
-
'button[aria-label*="remove attachment"]',
|
|
337
|
-
];
|
|
338
447
|
const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
|
|
339
448
|
const collectChipNodes = () => {
|
|
340
449
|
const seen = new Set();
|
|
@@ -353,32 +462,66 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
353
462
|
return collected;
|
|
354
463
|
};
|
|
355
464
|
const chipNodes = collectChipNodes();
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
465
|
+
const chipLabels = chipNodes.map((node) => collectLabelHaystack(node));
|
|
466
|
+
const chipOwnLabels = chipNodes.map((node) => collectOwnLabelHaystack(node));
|
|
467
|
+
const hasEllipsisSuffix = (label) => {
|
|
468
|
+
const marker = label.includes('…') ? '…' : label.includes('...') ? '...' : '';
|
|
469
|
+
if (!marker) return false;
|
|
470
|
+
return normalize(label.split(marker)[1] || '').length > 0;
|
|
471
|
+
};
|
|
472
|
+
const chipOwnLabelsWithVisibleNames = chipOwnLabels.filter((label) =>
|
|
473
|
+
/\\.[a-z][a-z0-9]{0,9}(?:\\b|$)/i.test(label) ||
|
|
474
|
+
hasEllipsisSuffix(label),
|
|
475
|
+
);
|
|
476
|
+
const visibleExtensionLabelsMatchExpected = chipOwnLabelsWithVisibleNames.every((label) =>
|
|
477
|
+
expected.some((item) => matchesExpected(label, item)),
|
|
359
478
|
);
|
|
360
|
-
|
|
479
|
+
|
|
480
|
+
const chipsReady = (() => {
|
|
481
|
+
const used = new Set();
|
|
482
|
+
return expected.every((item) => {
|
|
483
|
+
const index = chipLabels.findIndex((label, candidateIndex) =>
|
|
484
|
+
!used.has(candidateIndex) && matchesExpected(label, item),
|
|
485
|
+
);
|
|
486
|
+
if (index === -1) return false;
|
|
487
|
+
used.add(index);
|
|
488
|
+
return true;
|
|
489
|
+
});
|
|
490
|
+
})();
|
|
491
|
+
const inputsReady = expected.every((item) =>
|
|
361
492
|
attachmentRoots.some((root) =>
|
|
362
493
|
Array.from(root.querySelectorAll('input[type="file"]')).some((el) =>
|
|
363
494
|
Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
|
|
364
|
-
file?.name
|
|
495
|
+
matchesExpected(file?.name, item),
|
|
365
496
|
),
|
|
366
497
|
),
|
|
367
498
|
),
|
|
368
499
|
);
|
|
369
500
|
// Count-based fallback: if we cannot match names individually (ChatGPT may strip
|
|
370
501
|
// the filename out of attribute-readable text into a deeply nested span), but we
|
|
371
|
-
// do see at least as many distinct
|
|
372
|
-
//
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
502
|
+
// do see at least as many distinct "Remove" affordances as attachments we
|
|
503
|
+
// uploaded, trust the upload without double-counting nested chip/remove nodes.
|
|
504
|
+
const removeAffordances = [];
|
|
505
|
+
const removeSeen = new Set();
|
|
506
|
+
for (const root of attachmentRoots) {
|
|
507
|
+
for (const node of Array.from(root.querySelectorAll(
|
|
377
508
|
'[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
|
|
378
|
-
)
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
509
|
+
))) {
|
|
510
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
511
|
+
if (node.closest('textarea,[contenteditable="true"]')) continue;
|
|
512
|
+
const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
|
|
513
|
+
const fileSpecific = aria.includes('remove file') || aria.includes('remove attachment');
|
|
514
|
+
const attachmentOwner = node.closest(
|
|
515
|
+
'[data-testid*="chip"], [data-testid*="attachment"], [data-testid*="upload"], [data-testid*="file"]',
|
|
516
|
+
);
|
|
517
|
+
if (!fileSpecific && !attachmentOwner) continue;
|
|
518
|
+
if (removeSeen.has(node)) continue;
|
|
519
|
+
removeSeen.add(node);
|
|
520
|
+
removeAffordances.push(node);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const countReady =
|
|
524
|
+
visibleExtensionLabelsMatchExpected && removeAffordances.length >= expected.length;
|
|
382
525
|
|
|
383
526
|
return chipsReady || inputsReady || countReady;
|
|
384
527
|
})()`;
|
|
@@ -386,7 +529,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
386
529
|
export function buildAttachmentReadyExpressionForTest(attachmentNames) {
|
|
387
530
|
return buildAttachmentReadyExpression(attachmentNames);
|
|
388
531
|
}
|
|
389
|
-
async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
532
|
+
async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTimeoutMs) {
|
|
390
533
|
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
391
534
|
const script = `(() => {
|
|
392
535
|
${buildClickDispatcher()}
|
|
@@ -423,7 +566,8 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
423
566
|
// Give attachment-bearing submissions more headroom. ChatGPT's chip render can
|
|
424
567
|
// settle slowly for multi-file uploads, but plain text sends should keep the
|
|
425
568
|
// shorter historical deadline.
|
|
426
|
-
const
|
|
569
|
+
const timeoutMs = sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs);
|
|
570
|
+
const deadline = Date.now() + timeoutMs;
|
|
427
571
|
while (Date.now() < deadline) {
|
|
428
572
|
if (needAttachment) {
|
|
429
573
|
const ready = await Runtime.evaluate({
|
|
@@ -445,16 +589,22 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
445
589
|
await delay(100);
|
|
446
590
|
}
|
|
447
591
|
if (Array.isArray(attachmentNames) && attachmentNames.length > 0) {
|
|
448
|
-
throw new BrowserAutomationError(
|
|
592
|
+
throw new BrowserAutomationError(`Attachments never reached a clickable send button after ${Math.ceil(timeoutMs / 1000)}s; tune --browser-attachment-timeout.`, {
|
|
449
593
|
stage: "submit-prompt",
|
|
450
594
|
code: "attachment-send-not-ready",
|
|
451
595
|
attachmentNames,
|
|
596
|
+
timeoutMs,
|
|
452
597
|
});
|
|
453
598
|
}
|
|
454
599
|
return false;
|
|
455
600
|
}
|
|
456
|
-
function sendButtonTimeoutMs(attachmentNames) {
|
|
457
|
-
|
|
601
|
+
function sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs) {
|
|
602
|
+
if (!Array.isArray(attachmentNames) || attachmentNames.length === 0) {
|
|
603
|
+
return 20_000;
|
|
604
|
+
}
|
|
605
|
+
return typeof attachmentTimeoutMs === "number" && Number.isFinite(attachmentTimeoutMs)
|
|
606
|
+
? Math.max(1_000, attachmentTimeoutMs)
|
|
607
|
+
: 45_000;
|
|
458
608
|
}
|
|
459
609
|
async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselineTurns) {
|
|
460
610
|
const deadline = Date.now() + timeoutMs;
|