From 25e341d49707e5875680af219cce7692e653e50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mai=20Hoa=CC=80ng=20Anh=20Vu=CC=83?= Date: Mon, 17 Aug 2026 01:11:49 +0700 Subject: [PATCH] fix(chatgpt): use data-turn to detect upload previews vs generated images `chatgpt image` with 2+ --image attachments could return the just-uploaded reference thumbnails instead of the actual generated image. isUserUploadPreview() classified an as a user upload (to exclude it from waitForChatGPTImages' before/after diff) using two signals, both broken against ChatGPT's current DOM: - turn.querySelector('h4')?.innerText: the heading is visually hidden, so real Chrome's innerText resolves to '' (layout-dependent) even though .textContent correctly reads "You said:" / "ChatGPT said:". jsdom's innerText is always undefined, so the test suite never exercised this path either - it happened to pass via the aria-label/alt fallback below. - button[aria-label^="Open image:"]: ChatGPT's current label for a multi-file attachment reads "Open image N of M: ", which no longer starts with "Open image:", so this selector stopped matching. With both signals dead, classification fell through to alt-text sniffing. Right after upload, an attachment thumbnail's alt/aria-label haven't populated yet, so for a poll or two every uploaded image is misclassified as "new". waitForChatGPTImages returns as soon as two consecutive polls agree on a URL set - long enough for that transient window to win when multiple attachments are involved, so it can return the uploads instead of the real result. Fix: check the turn
's own data-turn="user"|"assistant" attribute first. It's set structurally as soon as the turn mounts, not tied to the attachment's async metadata, so it isn't subject to the race. Keep the heading/aria-label checks as a fallback (now using textContent and a substring aria-label match) for markup that lacks data-turn. Verified live against chatgpt.com: reproduced the bug with 3 reference images, then confirmed the patched build returns exactly the one real generated image instead of the 3 uploaded thumbnails. Adds regression tests for both the data-turn race and the aria-label format change; confirmed both fail against the pre-fix code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L29nrhaeQ4W5rjNr27z47h --- clis/chatgpt/utils.js | 22 +++++++++++++--- clis/chatgpt/utils.test.js | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/clis/chatgpt/utils.js b/clis/chatgpt/utils.js index 1fbafea77..7bde739e3 100644 --- a/clis/chatgpt/utils.js +++ b/clis/chatgpt/utils.js @@ -2560,12 +2560,28 @@ export async function getChatGPTVisibleImageUrls(page) { return /avatar|profile|logo|icon/.test(text); }; const isUserUploadPreview = (img) => { - const alt = (img.getAttribute('alt') || '').toLowerCase(); const turn = img.closest('section[data-testid^="conversation-turn"]'); - const heading = (turn?.querySelector('h4')?.innerText || '').toLowerCase(); + // Authoritative signal first: ChatGPT stamps data-turn directly on the + // turn section as soon as the turn mounts, well before an attached + // image's alt text/aria-label finish populating. Racing multiple + // uploads against that async metadata (the old behaviour here) let + // still-generic upload-preview thumbnails pass as "new" images for a + // poll or two, which is long enough to satisfy the stability check in + // waitForChatGPTImages and return the wrong (uploaded, not generated) + // images when 2+ files were attached. + const turnRole = turn?.getAttribute('data-turn') || ''; + if (turnRole === 'user') return true; + if (turnRole === 'assistant') return false; + // Fallback for markup without data-turn. innerText reads as empty + // on a visually-hidden heading in real Chrome (jsdom has no layout and + // never exposed this gap) - use textContent instead. + const heading = (turn?.querySelector('h4')?.textContent || '').toLowerCase(); if (/you said|你说/.test(heading)) return true; if (/chatgpt|assistant|助手/.test(heading)) return false; - const openButtonLabel = (img.closest('button[aria-label^="Open image:"]')?.getAttribute('aria-label') || '').toLowerCase(); + const alt = (img.getAttribute('alt') || '').toLowerCase(); + // ChatGPT's multi-image "Open image" button label now reads + // "Open image N of M: name", not the older "Open image: name". + const openButtonLabel = (img.closest('button[aria-label*="Open image"]')?.getAttribute('aria-label') || '').toLowerCase(); const previewText = [alt, openButtonLabel].join(' '); return /\.(png|jpe?g|webp|gif|heic|heif)(?:\b|$)/i.test(previewText) || /ref-|reference|参考|upload|uploaded|attachment/.test(previewText); diff --git a/clis/chatgpt/utils.test.js b/clis/chatgpt/utils.test.js index 686f70820..4a205bbcc 100644 --- a/clis/chatgpt/utils.test.js +++ b/clis/chatgpt/utils.test.js @@ -1548,6 +1548,39 @@ describe('chatgpt generated image detection', () => { ]); }); + it('ignores multiple upload-preview thumbnails via data-turn before their alt/aria-label metadata populate', async () => { + // Reproduces a real regression: uploading 2+ reference images made + // waitForChatGPTImages return the just-uploaded thumbnails instead of + // the actual generated image. Right after upload, a thumbnail's alt + // text and "Open image N of M: " aria-label haven't populated + // yet, so the old alt/aria-label-only fallback couldn't tell them + // apart from a real result during that window. `data-turn` on the + // turn
is set immediately and must be checked first. + const page = createDomPage(` + +
+

You said:

+ + + +
+
+

ChatGPT said:

+ Generated image: result +
+ `, (window) => { + for (const img of window.document.querySelectorAll('img')) { + Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 512 }); + Object.defineProperty(img, 'naturalHeight', { configurable: true, value: 512 }); + img.getBoundingClientRect = () => ({ width: 512, height: 512 }); + } + }); + + await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([ + 'https://chatgpt.com/backend-api/generated/foo.webp', + ]); + }); + it('keeps assistant generated images even when they are inside an open-image button', async () => { const page = createDomPage(` @@ -1569,6 +1602,24 @@ describe('chatgpt generated image detection', () => { ]); }); + it('recognizes the "Open image N of M: name" aria-label ChatGPT uses for multi-attachment uploads', async () => { + const page = createDomPage(` + +
+ +
+ `, (window) => { + const img = window.document.querySelector('img'); + Object.defineProperty(img, 'naturalWidth', { configurable: true, value: 512 }); + Object.defineProperty(img, 'naturalHeight', { configurable: true, value: 512 }); + img.getBoundingClientRect = () => ({ width: 512, height: 512 }); + }); + + await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([]); + }); + it('exports assets for generated CSS background images', async () => { const imageUrl = 'https://chatgpt.com/backend-api/generated/foo.webp'; const page = createDomPage(`