diff --git a/cli-manifest.json b/cli-manifest.json index 944958737..8a7c43587 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -4692,9 +4692,40 @@ "default": false, "required": false, "help": "Start a new chat before sending" + }, + { + "name": "conversation", + "type": "str", + "required": false, + "valueRequired": true, + "help": "Continue an existing ChatGPT conversation ID or /c/ URL" + }, + { + "name": "wait", + "type": "boolean", + "default": true, + "required": false, + "help": "Wait for the assistant response after sending" + }, + { + "name": "deep-research", + "type": "boolean", + "default": false, + "required": false, + "help": "Enable ChatGPT 深度研究 (Deep Research)" + }, + { + "name": "web-search", + "type": "boolean", + "default": false, + "required": false, + "help": "Enable ChatGPT 网页搜索 (Web Search)" } ], "columns": [ + "conversationId", + "conversationUrl", + "tool", "response" ], "type": "js", @@ -4725,12 +4756,35 @@ "default": false, "required": false, "help": "Emit assistant replies as markdown" + }, + { + "name": "wait", + "type": "boolean", + "default": false, + "required": false, + "help": "Wait until the conversation stops generating and stabilizes" + }, + { + "name": "timeout", + "type": "int", + "default": 120, + "required": false, + "help": "Max seconds to wait when --wait is true" + }, + { + "name": "stable", + "type": "int", + "default": 6, + "required": false, + "help": "Seconds the final messages must remain unchanged when --wait is true" } ], "columns": [ "Index", "Role", - "Text" + "Text", + "Generating", + "StableSeconds" ], "type": "js", "modulePath": "chatgpt/detail.js", @@ -4822,6 +4876,38 @@ "navigateBefore": false, "siteSession": "persistent" }, + { + "site": "chatgpt", + "name": "model", + "description": "Switch ChatGPT web model/mode (instant, thinking, pro)", + "access": "write", + "domain": "chatgpt.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "model", + "type": "str", + "required": true, + "positional": true, + "help": "Model/mode to switch to", + "choices": [ + "instant", + "thinking", + "pro" + ] + } + ], + "columns": [ + "Status", + "Model" + ], + "type": "js", + "modulePath": "chatgpt/model.js", + "sourceFile": "chatgpt/model.js", + "navigateBefore": false, + "siteSession": "persistent" + }, { "site": "chatgpt", "name": "new", @@ -4890,6 +4976,13 @@ "default": false, "required": false, "help": "Start a new chat before sending" + }, + { + "name": "conversation", + "type": "str", + "required": false, + "valueRequired": true, + "help": "Continue an existing ChatGPT conversation ID or /c/ URL" } ], "columns": [ diff --git a/clis/chatgpt/ask.js b/clis/chatgpt/ask.js index 24e4f8b96..33d3447b3 100644 --- a/clis/chatgpt/ask.js +++ b/clis/chatgpt/ask.js @@ -1,19 +1,38 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; import { CHATGPT_DOMAIN, CHATGPT_URL, + currentChatGPTUrl, ensureChatGPTComposer, ensureOnChatGPT, getBubbleCount, normalizeBooleanFlag, + openChatGPTConversation, requireNonEmptyPrompt, requirePositiveInt, + parseChatGPTConversationId, sendChatGPTMessage, + selectChatGPTTool, + isGenerating, startNewChat, waitForChatGPTResponse, } from './utils.js'; +async function waitForConversationUrl(page, timeoutSeconds = 30) { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutSeconds * 1000) { + const conversationUrl = await currentChatGPTUrl(page); + try { + const conversationId = parseChatGPTConversationId(conversationUrl); + return { conversationId, conversationUrl }; + } catch { + await page.wait(1); + } + } + throw new CommandExecutionError('ChatGPT did not create a conversation URL after sending the message.'); +} + export const askCommand = cli({ site: 'chatgpt', name: 'ask', @@ -28,8 +47,12 @@ export const askCommand = cli({ { name: 'prompt', positional: true, required: true, help: 'Prompt to send' }, { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' }, { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' }, + { name: 'conversation', valueRequired: true, help: 'Continue an existing ChatGPT conversation ID or /c/ URL' }, + { name: 'wait', type: 'boolean', default: true, help: 'Wait for the assistant response after sending' }, + { name: 'deep-research', type: 'boolean', default: false, help: 'Enable ChatGPT 深度研究 (Deep Research)' }, + { name: 'web-search', type: 'boolean', default: false, help: 'Enable ChatGPT 网页搜索 (Web Search)' }, ], - columns: ['response'], + columns: ['conversationId', 'conversationUrl', 'tool', 'response'], func: async (page, kwargs) => { const prompt = requireNonEmptyPrompt(kwargs.prompt, 'chatgpt ask'); const timeout = requirePositiveInt( @@ -37,8 +60,26 @@ export const askCommand = cli({ 'chatgpt ask --timeout', 'Example: opencli chatgpt ask "hello" --timeout 120', ); + const useDeepResearch = normalizeBooleanFlag(kwargs['deep-research'], false); + const useWebSearch = normalizeBooleanFlag(kwargs['web-search'], false); + const shouldWait = normalizeBooleanFlag(kwargs.wait, true); + if (useDeepResearch && useWebSearch) { + throw new ArgumentError( + 'chatgpt ask cannot enable both --deep-research and --web-search', + 'Choose one ChatGPT composer tool for this message.', + ); + } + if (normalizeBooleanFlag(kwargs.new) && kwargs.conversation) { + throw new ArgumentError( + 'chatgpt ask cannot use --new and --conversation together', + 'Choose either a new chat or an existing conversation.', + ); + } + const tool = useDeepResearch ? 'deep-research' : (useWebSearch ? 'web-search' : null); - if (normalizeBooleanFlag(kwargs.new)) { + if (kwargs.conversation) { + await openChatGPTConversation(page, kwargs.conversation); + } else if (normalizeBooleanFlag(kwargs.new)) { await startNewChat(page); } else { await ensureOnChatGPT(page); @@ -46,6 +87,15 @@ export const askCommand = cli({ // startNewChat / ensureOnChatGPT now wait for the composer selector // after navigating, so the previous standalone 2 s settle is redundant. await ensureChatGPTComposer(page, 'ChatGPT ask requires a logged-in ChatGPT session with a visible composer.'); + const selectedTool = tool ? await selectChatGPTTool(page, tool) : null; + + const settleStart = Date.now(); + while (await isGenerating(page)) { + if (Date.now() - settleStart > timeout * 1000) { + throw new CommandExecutionError('ChatGPT conversation is still generating; wait for it to finish before sending another message.'); + } + await page.wait(3); + } const baseline = await getBubbleCount(page); const sent = await sendChatGPTMessage(page, prompt); @@ -53,6 +103,11 @@ export const askCommand = cli({ throw new CommandExecutionError('Failed to send message to ChatGPT', `Open ${CHATGPT_URL} and verify the composer is ready.`); } - return [{ response: await waitForChatGPTResponse(page, baseline, prompt, timeout) }]; + const { conversationId, conversationUrl } = await waitForConversationUrl(page); + if (!shouldWait) { + return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response: '' }]; + } + const response = await waitForChatGPTResponse(page, baseline, prompt, timeout); + return [{ conversationId, conversationUrl, tool: selectedTool?.Tool ?? '', response }]; }, }); diff --git a/clis/chatgpt/commands.test.js b/clis/chatgpt/commands.test.js index 80d773bd2..5df0bf2eb 100644 --- a/clis/chatgpt/commands.test.js +++ b/clis/chatgpt/commands.test.js @@ -8,6 +8,7 @@ import './detail.js'; import './new.js'; import './status.js'; import './image.js'; +import './model.js'; describe('chatgpt browser command registration', () => { it('registers the baseline web chat commands with persistent site sessions', () => { @@ -20,6 +21,7 @@ describe('chatgpt browser command registration', () => { new: 'read', status: 'read', image: 'write', + model: 'write', }; for (const [name, access] of Object.entries(expectedAccess)) { @@ -40,6 +42,57 @@ describe('chatgpt browser command registration', () => { expect(ask.args).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }), expect.objectContaining({ name: 'new', type: 'boolean', default: false }), + expect.objectContaining({ name: 'conversation', valueRequired: true }), + expect.objectContaining({ name: 'wait', type: 'boolean', default: true }), + expect.objectContaining({ name: 'deep-research', type: 'boolean', default: false }), + expect.objectContaining({ name: 'web-search', type: 'boolean', default: false }), ])); + expect(ask.columns).toEqual(['conversationId', 'conversationUrl', 'tool', 'response']); + }); + + it('registers send conversation routing option', () => { + const send = getRegistry().get('chatgpt/send'); + expect(send.args).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'new', type: 'boolean', default: false }), + expect.objectContaining({ name: 'conversation', valueRequired: true }), + ])); + }); + + it('registers detail wait options and generation state columns', () => { + const detail = getRegistry().get('chatgpt/detail'); + expect(detail.args).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'wait', type: 'boolean', default: false }), + expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }), + expect.objectContaining({ name: 'stable', type: 'int', default: 6 }), + ])); + expect(detail.columns).toEqual(['Index', 'Role', 'Text', 'Generating', 'StableSeconds']); + }); + + it('registers chatgpt model with web model choices', () => { + const model = getRegistry().get('chatgpt/model'); + expect(model.args).toEqual([ + expect.objectContaining({ + name: 'model', + positional: true, + required: true, + choices: ['instant', 'thinking', 'pro'], + }), + ]); + expect(model.columns).toEqual(['Status', 'Model']); + }); + + it('rejects off-domain conversation URLs before ask/send can navigate', async () => { + const ask = getRegistry().get('chatgpt/ask'); + const send = getRegistry().get('chatgpt/send'); + const page = { + goto: () => { + throw new Error('should not navigate'); + }, + }; + + await expect(ask.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' })) + .rejects.toMatchObject({ code: 'ARGUMENT' }); + await expect(send.func(page, { prompt: 'hello', conversation: 'https://evil.test/c/abc_123-def' })) + .rejects.toMatchObject({ code: 'ARGUMENT' }); }); }); diff --git a/clis/chatgpt/detail.js b/clis/chatgpt/detail.js index 54bf5c25c..f71721bd7 100644 --- a/clis/chatgpt/detail.js +++ b/clis/chatgpt/detail.js @@ -5,10 +5,12 @@ import { CHATGPT_URL, CONVERSATION_MESSAGE_SELECTOR, ensureChatGPTLogin, - getVisibleMessages, - messageHtmlToMarkdown, + getChatGPTDetailRows, normalizeBooleanFlag, parseChatGPTConversationId, + requireNonNegativeInt, + requirePositiveInt, + waitForChatGPTDetailRows, } from './utils.js'; export const detailCommand = cli({ @@ -24,11 +26,25 @@ export const detailCommand = cli({ args: [ { name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/ URL' }, { name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' }, + { name: 'wait', type: 'boolean', default: false, help: 'Wait until the conversation stops generating and stabilizes' }, + { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait when --wait is true' }, + { name: 'stable', type: 'int', default: 6, help: 'Seconds the final messages must remain unchanged when --wait is true' }, ], - columns: ['Index', 'Role', 'Text'], + columns: ['Index', 'Role', 'Text', 'Generating', 'StableSeconds'], func: async (page, kwargs) => { const id = parseChatGPTConversationId(kwargs.id); const wantMarkdown = normalizeBooleanFlag(kwargs.markdown, false); + const shouldWait = normalizeBooleanFlag(kwargs.wait, false); + const timeout = requirePositiveInt( + Number(kwargs.timeout ?? 120), + 'chatgpt detail --timeout', + 'Example: opencli chatgpt detail --wait true --timeout 600', + ); + const stableSeconds = requireNonNegativeInt( + Number(kwargs.stable ?? 6), + 'chatgpt detail --stable', + 'Example: opencli chatgpt detail --wait true --stable 6', + ); await page.goto(`${CHATGPT_URL}/c/${id}`, { settleMs: 2000 }); try { await page.wait({ selector: CONVERSATION_MESSAGE_SELECTOR, timeout: 10 }); @@ -36,16 +52,12 @@ export const detailCommand = cli({ // Empty conversation, missing access, or login redirect — handled by ensureChatGPTLogin / EmptyResultError below. } await ensureChatGPTLogin(page, 'ChatGPT detail requires a logged-in ChatGPT session.'); - const messages = await getVisibleMessages(page); + const { messages, rows } = shouldWait + ? await waitForChatGPTDetailRows(page, { wantMarkdown, timeoutSeconds: timeout, stableSeconds }) + : await getChatGPTDetailRows(page, { wantMarkdown }); if (!messages.length) { throw new EmptyResultError('chatgpt detail', `No visible ChatGPT messages were found for conversation ${id}.`); } - return messages.map((message) => ({ - Index: message.Index, - Role: message.Role, - Text: wantMarkdown && message.Role === 'Assistant' && message.Html - ? (messageHtmlToMarkdown(message.Html) || message.Text) - : message.Text, - })); + return rows; }, }); diff --git a/clis/chatgpt/model.js b/clis/chatgpt/model.js new file mode 100644 index 000000000..dbb977dcc --- /dev/null +++ b/clis/chatgpt/model.js @@ -0,0 +1,26 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + CHATGPT_DOMAIN, + CHATGPT_MODEL_CHOICES, + selectChatGPTModel, +} from './utils.js'; + +export const modelCommand = cli({ + site: 'chatgpt', + name: 'model', + access: 'write', + description: 'Switch ChatGPT web model/mode (instant, thinking, pro)', + domain: CHATGPT_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + siteSession: 'persistent', + navigateBefore: false, + args: [ + { name: 'model', required: true, positional: true, help: 'Model/mode to switch to', choices: CHATGPT_MODEL_CHOICES }, + ], + columns: ['Status', 'Model'], + func: async (page, kwargs) => { + const result = await selectChatGPTModel(page, kwargs.model); + return [{ Status: result.Status, Model: result.Model }]; + }, +}); diff --git a/clis/chatgpt/send.js b/clis/chatgpt/send.js index 64ae518b0..5ab5756d8 100644 --- a/clis/chatgpt/send.js +++ b/clis/chatgpt/send.js @@ -1,11 +1,12 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; import { CHATGPT_DOMAIN, CHATGPT_URL, ensureChatGPTComposer, ensureOnChatGPT, normalizeBooleanFlag, + openChatGPTConversation, requireNonEmptyPrompt, sendChatGPTMessage, startNewChat, @@ -24,12 +25,22 @@ export const sendCommand = cli({ args: [ { name: 'prompt', positional: true, required: true, help: 'Prompt to send' }, { name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' }, + { name: 'conversation', valueRequired: true, help: 'Continue an existing ChatGPT conversation ID or /c/ URL' }, ], columns: ['Status', 'InjectedText'], func: async (page, kwargs) => { const prompt = requireNonEmptyPrompt(kwargs.prompt, 'chatgpt send'); - if (normalizeBooleanFlag(kwargs.new)) { + if (normalizeBooleanFlag(kwargs.new) && kwargs.conversation) { + throw new ArgumentError( + 'chatgpt send cannot use --new and --conversation together', + 'Choose either a new chat or an existing conversation.', + ); + } + + if (kwargs.conversation) { + await openChatGPTConversation(page, kwargs.conversation); + } else if (normalizeBooleanFlag(kwargs.new)) { await startNewChat(page); } else { await ensureOnChatGPT(page); diff --git a/clis/chatgpt/utils.js b/clis/chatgpt/utils.js index 422e8f1f6..32c3a3b3d 100644 --- a/clis/chatgpt/utils.js +++ b/clis/chatgpt/utils.js @@ -9,15 +9,29 @@ import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } export const CHATGPT_DOMAIN = 'chatgpt.com'; export const CHATGPT_URL = 'https://chatgpt.com'; +const CHATGPT_MODEL_OPTIONS = { + instant: { label: 'Instant', labels: ['Instant', '即时'], testId: 'model-switcher-gpt-5-5' }, + thinking: { label: 'Thinking', labels: ['Thinking', '思考'], testId: 'model-switcher-gpt-5-5-thinking' }, + pro: { label: 'Pro', labels: ['Pro', '进阶专业'], testId: 'model-switcher-gpt-5-5-pro' }, +}; +export const CHATGPT_MODEL_CHOICES = Object.keys(CHATGPT_MODEL_OPTIONS); + +const CHATGPT_TOOL_OPTIONS = { + 'deep-research': { label: 'Deep Research', labels: ['深度研究', 'Deep Research'] }, + 'web-search': { label: 'Web Search', labels: ['网页搜索', '搜索', 'Web Search', 'Search'] }, +}; +export const CHATGPT_TOOL_CHOICES = Object.keys(CHATGPT_TOOL_OPTIONS); + // Selectors const COMPOSER_SELECTORS = [ + '[contenteditable="true"][role="textbox"]', + '#prompt-textarea[contenteditable="true"]', '[aria-label="Chat with ChatGPT"]', '[aria-label="与 ChatGPT 聊天"]', '[placeholder="Ask anything"]', '[placeholder="有问题,尽管问"]', '#prompt-textarea', '[data-testid="prompt-textarea"]', - '[contenteditable="true"][role="textbox"]', ]; const SEND_BUTTON_SELECTOR = 'button[data-testid="send-button"]:not([disabled])'; const SEND_BUTTON_FALLBACK_SELECTORS = [ @@ -27,6 +41,8 @@ const SEND_BUTTON_LABELS = [ 'Send prompt', 'Send message', 'Send', + '发送', + '发送消息', '发送提示', ]; const CLOSE_SIDEBAR_LABELS = [ @@ -60,12 +76,11 @@ function buildComposerLocatorScript() { }; const findComposer = () => { - const marked = document.querySelector('[' + markerAttr + '="1"]'); - if (marked instanceof HTMLElement && isVisible(marked)) return marked; - for (const selector of ${JSON.stringify(COMPOSER_SELECTORS)}) { - const node = Array.from(document.querySelectorAll(selector)).find(c => c instanceof HTMLElement && isVisible(c)); + const candidates = Array.from(document.querySelectorAll(selector)).filter(c => c instanceof HTMLElement && isVisible(c)); + const node = candidates.find(c => c.isContentEditable) || candidates[0]; if (node instanceof HTMLElement) { + clearMarkers(node); node.setAttribute(markerAttr, '1'); return node; } @@ -102,6 +117,13 @@ export function requirePositiveInt(value, flagLabel, hint) { return value; } +export function requireNonNegativeInt(value, flagLabel, hint) { + if (!Number.isInteger(value) || value < 0) { + throw new ArgumentError(`${flagLabel} must be a non-negative integer`, hint); + } + return value; +} + // ───────────────────────────────────────────────────────────────────────────── // page.evaluate envelope helpers. // @@ -148,11 +170,27 @@ export function requireBooleanEvaluateResult(payload, label) { export function parseChatGPTConversationId(value) { const raw = String(value ?? '').trim(); - const match = raw.match(/(?:^|\/c\/)([A-Za-z0-9_-]{8,})(?:[/?#]|$)/); - if (match) return match[1]; + if (/^https?:\/\//i.test(raw)) { + try { + const parsed = new URL(raw); + if (parsed.protocol !== 'https:' || (parsed.hostname !== CHATGPT_DOMAIN && !parsed.hostname.endsWith(`.${CHATGPT_DOMAIN}`))) { + throw new Error('off-domain'); + } + const match = parsed.pathname.match(/^\/c\/([A-Za-z0-9_-]{8,})$/); + if (match) return match[1]; + } catch { + // Fall through to the shared typed ArgumentError below. + } + throw new ArgumentError( + 'chatgpt detail requires a conversation id or chatgpt.com /c/ URL', + 'Example: opencli chatgpt detail https://chatgpt.com/c/123e4567-e89b-12d3-a456-426614174000', + ); + } + const pathMatch = raw.match(/^\/c\/([A-Za-z0-9_-]{8,})(?:[?#].*)?$/); + if (pathMatch) return pathMatch[1]; if (/^[A-Za-z0-9_-]{8,}$/.test(raw)) return raw; throw new ArgumentError( - 'chatgpt detail requires a conversation id or /c/ URL', + 'chatgpt detail requires a conversation id or chatgpt.com /c/ URL', 'Example: opencli chatgpt detail 123e4567-e89b-12d3-a456-426614174000', ); } @@ -203,6 +241,17 @@ export async function startNewChat(page) { } } +export async function openChatGPTConversation(page, value) { + const id = parseChatGPTConversationId(value); + await page.goto(`${CHATGPT_URL}/c/${id}`, { settleMs: 2000 }); + try { + await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 }); + } catch { + // Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error. + } + return id; +} + export async function getPageState(page) { return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { const isVisible = (el) => { @@ -249,6 +298,240 @@ export async function ensureChatGPTComposer(page, message = 'ChatGPT composer is return state; } +function requireKnownChatGPTModel(model) { + const key = String(model ?? '').trim().toLowerCase(); + const option = CHATGPT_MODEL_OPTIONS[key]; + if (!option) { + throw new ArgumentError( + `Unknown ChatGPT model "${model}"`, + `Choose one of: ${CHATGPT_MODEL_CHOICES.join(', ')}`, + ); + } + return { key, ...option }; +} + +function requireKnownChatGPTTool(tool) { + const key = String(tool ?? '').trim().toLowerCase(); + const option = CHATGPT_TOOL_OPTIONS[key]; + if (!option) { + throw new ArgumentError( + `Unknown ChatGPT tool "${tool}"`, + `Choose one of: ${CHATGPT_TOOL_CHOICES.join(', ')}`, + ); + } + return { key, ...option }; +} + +export async function getCurrentChatGPTModel(page) { + return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const labels = ${JSON.stringify(CHATGPT_MODEL_OPTIONS)}; + const button = Array.from(document.querySelectorAll('form button')).find((node) => { + if (!isVisible(node)) return false; + const text = normalize(node.textContent); + return Object.values(labels).some((entry) => entry.labels.includes(text)); + }); + const label = normalize(button?.textContent || ''); + const entry = Object.entries(labels).find(([, value]) => value.labels.includes(label)); + return { + model: entry?.[0] ?? null, + label: entry?.[1]?.label ?? null, + }; + })()`)), 'chatgpt current model'); +} + +export async function selectChatGPTModel(page, model) { + const target = requireKnownChatGPTModel(model); + if (typeof page.nativeClick !== 'function') { + throw new CommandExecutionError('ChatGPT model selection requires native browser click support.'); + } + await ensureOnChatGPT(page); + await ensureChatGPTComposer(page, 'ChatGPT model selection requires a logged-in ChatGPT session with a visible composer.'); + + const before = await getCurrentChatGPTModel(page); + if (before.model === target.key) { + return { Status: 'Already selected', Model: target.label }; + } + + const menuButton = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const labels = ${JSON.stringify(Object.values(CHATGPT_MODEL_OPTIONS).flatMap((entry) => entry.labels))}; + const button = Array.from(document.querySelectorAll('form button')).find((node) => + isVisible(node) && labels.includes(normalize(node.textContent)) + ); + if (!button) return { found: false }; + button.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = button.getBoundingClientRect(); + return { + found: true, + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }; + })()`)), 'chatgpt model menu button'); + if (!menuButton.found) { + throw new CommandExecutionError('Could not find the ChatGPT model selector in the composer.'); + } + await page.nativeClick(Number(menuButton.x), Number(menuButton.y)); + await page.wait(0.5); + + let optionCenter = null; + for (let attempt = 0; attempt < 10; attempt += 1) { + optionCenter = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const option = document.querySelector(${JSON.stringify(`[data-testid="${target.testId}"]`)}); + if (!(option instanceof HTMLElement) || !isVisible(option)) return { found: false }; + option.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = option.getBoundingClientRect(); + return { + found: true, + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }; + })()`)), 'chatgpt model option click'); + if (optionCenter.found) break; + await page.wait(0.5); + } + if (!optionCenter?.found) { + throw new CommandExecutionError(`Could not click the ChatGPT ${target.label} model option.`); + } + await page.nativeClick(Number(optionCenter.x), Number(optionCenter.y)); + + await page.wait(0.5); + const after = await getCurrentChatGPTModel(page); + if (after.model !== target.key) { + throw new CommandExecutionError(`ChatGPT model did not switch to ${target.label}.`); + } + return { Status: 'Success', Model: target.label }; +} + +export async function getCurrentChatGPTTool(page) { + return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const labels = ${JSON.stringify(CHATGPT_TOOL_OPTIONS)}; + const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node)); + const root = form || document.body; + const nodes = Array.from(root.querySelectorAll('button, [role="button"], [role="menuitemradio"], span, div')); + const node = nodes.find((candidate) => { + if (!isVisible(candidate)) return false; + const text = normalize(candidate.textContent); + return Object.values(labels).some((entry) => entry.labels.includes(text)); + }); + const label = normalize(node?.textContent || ''); + const entry = Object.entries(labels).find(([, value]) => value.labels.includes(label)); + return { + tool: entry?.[0] ?? null, + label: entry?.[1]?.label ?? null, + }; + })()`)), 'chatgpt current tool'); +} + +export async function selectChatGPTTool(page, tool) { + const target = requireKnownChatGPTTool(tool); + if (typeof page.nativeClick !== 'function') { + throw new CommandExecutionError('ChatGPT tool selection requires native browser click support.'); + } + await ensureOnChatGPT(page); + await ensureChatGPTComposer(page, 'ChatGPT tool selection requires a logged-in ChatGPT session with a visible composer.'); + + const before = await getCurrentChatGPTTool(page); + if (before.tool === target.key) { + return { Status: 'Already selected', Tool: target.label }; + } + + const menuButton = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const button = document.querySelector('button[data-testid="composer-plus-btn"]'); + if (!(button instanceof HTMLElement) || !isVisible(button)) return { found: false }; + button.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = button.getBoundingClientRect(); + return { + found: true, + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }; + })()`)), 'chatgpt tools menu button'); + if (!menuButton.found) { + throw new CommandExecutionError('Could not find the ChatGPT tools menu button in the composer.'); + } + await page.nativeClick(Number(menuButton.x), Number(menuButton.y)); + await page.wait(0.5); + + let optionCenter = null; + for (let attempt = 0; attempt < 10; attempt += 1) { + optionCenter = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const labels = ${JSON.stringify(target.labels)}; + const options = Array.from(document.querySelectorAll('[role="menuitemradio"]')); + const option = options.find((node) => node instanceof HTMLElement && isVisible(node) && labels.includes(normalize(node.textContent))); + if (!(option instanceof HTMLElement)) return { found: false }; + const checked = option.getAttribute('aria-checked') === 'true'; + option.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = option.getBoundingClientRect(); + return { + found: true, + checked, + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }; + })()`)), 'chatgpt tool option click'); + if (optionCenter.found) break; + await page.wait(0.5); + } + if (!optionCenter?.found) { + throw new CommandExecutionError(`Could not find the ChatGPT ${target.label} tool option.`); + } + if (!optionCenter.checked) { + await page.nativeClick(Number(optionCenter.x), Number(optionCenter.y)); + } + + await page.wait(0.5); + const after = await getCurrentChatGPTTool(page); + if (after.tool !== target.key) { + throw new CommandExecutionError(`ChatGPT tool did not switch to ${target.label}.`); + } + return { Status: optionCenter.checked ? 'Already selected' : 'Success', Tool: target.label }; +} + export async function clearChatGPTDraft(page) { await page.evaluate(` (() => { @@ -301,11 +584,11 @@ export async function sendChatGPTMessage(page, text) { // findComposer() retries inside a single CDP call, so no fixed sleep is // needed before reading the composer. - const typeResult = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(` + const typeResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(` (() => { ${buildComposerLocatorScript()} const composer = findComposer(); - if (!composer) return false; + if (!composer) return { ready: false }; composer.focus(); if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { composer.value = ''; @@ -317,15 +600,25 @@ export async function sendChatGPTMessage(page, text) { } composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null })); composer.dispatchEvent(new Event('change', { bubbles: true })); - return true; + composer.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = composer.getBoundingClientRect(); + return { + ready: true, + x: Math.round(rect.left + Math.max(8, Math.min(rect.width / 2, rect.width - 8))), + y: Math.round(rect.top + Math.max(8, Math.min(rect.height / 2, rect.height - 8))), + }; })() `)), 'chatgpt composer readiness'); - if (!typeResult) return false; + if (!typeResult.ready) return false; // Use page.type() which is Playwright's native method try { if (page.nativeType) { + if (typeof page.nativeClick === 'function') { + await page.nativeClick(Number(typeResult.x), Number(typeResult.y)); + await page.wait(0.2); + } await page.nativeType(text); } else { throw new Error('nativeType unavailable'); @@ -349,16 +642,31 @@ export async function sendChatGPTMessage(page, text) { await page.wait(0.5); sent = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(` (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; const isUsable = (button) => button + && isVisible(button) && !button.disabled && button.getAttribute('aria-disabled') !== 'true'; - const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)}) - || ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean); - const btns = Array.from(document.querySelectorAll('button')); + const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node)); + const root = form || document.body; + const primary = root.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)}) + || ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => root.querySelector(selector)).find(Boolean); + const btns = Array.from(root.querySelectorAll('button')); const labels = ${JSON.stringify(SEND_BUTTON_LABELS)}; + const looksLikeSend = (button) => { + const label = button.getAttribute('aria-label') || ''; + const text = (button.innerText || button.textContent || '').replace(/\\s+/g, ' ').trim(); + return labels.includes(label) || labels.includes(text) || /send|发送/i.test(label) || /send|发送/i.test(text); + }; const sendBtn = isUsable(primary) ? primary - : btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && isUsable(b)); + : btns.find(b => looksLikeSend(b) && isUsable(b)); return { sendBtnFound: !!sendBtn }; })() `)), 'chatgpt send button readiness'); @@ -371,10 +679,30 @@ export async function sendChatGPTMessage(page, text) { await page.evaluate(` (() => { - const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)}) - || ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean); + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const isUsable = (button) => button + && isVisible(button) + && !button.disabled + && button.getAttribute('aria-disabled') !== 'true'; + const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node)); + const root = form || document.body; + const primary = root.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)}) + || ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => root.querySelector(selector)).find(Boolean); const labels = ${JSON.stringify(SEND_BUTTON_LABELS)}; - const sendBtn = primary || Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled); + const looksLikeSend = (button) => { + const label = button.getAttribute('aria-label') || ''; + const text = (button.innerText || button.textContent || '').replace(/\\s+/g, ' ').trim(); + return labels.includes(label) || labels.includes(text) || /send|发送/i.test(label) || /send|发送/i.test(text); + }; + const sendBtn = isUsable(primary) + ? primary + : Array.from(root.querySelectorAll('button')).find(b => looksLikeSend(b) && isUsable(b)); if (sendBtn) sendBtn.click(); })() `); @@ -437,6 +765,70 @@ export async function getVisibleMessages(page) { })).filter((item) => item.Text); } +function formatChatGPTDetailMessages(messages, { wantMarkdown, generating, stableSeconds }) { + return messages.map((message) => ({ + Index: message.Index, + Role: message.Role, + Text: wantMarkdown && message.Role === 'Assistant' && message.Html + ? (messageHtmlToMarkdown(message.Html) || message.Text) + : message.Text, + Generating: generating, + StableSeconds: stableSeconds, + })); +} + +export async function getChatGPTDetailRows(page, { wantMarkdown = false, stableSeconds = 0 } = {}) { + const generating = await isGenerating(page); + const messages = await getVisibleMessages(page); + return { + messages, + rows: formatChatGPTDetailMessages(messages, { wantMarkdown, generating, stableSeconds }), + generating, + }; +} + +export async function waitForChatGPTDetailRows(page, { wantMarkdown = false, timeoutSeconds = 120, stableSeconds = 6 } = {}) { + const startTime = Date.now(); + let lastKey = ''; + let stableStartedAt = 0; + + while (Date.now() - startTime < timeoutSeconds * 1000) { + const generating = await isGenerating(page); + const messages = await getVisibleMessages(page); + const key = JSON.stringify(messages.map((message) => [message.Role, message.Text])); + if (!generating && messages.length && messages[messages.length - 1]?.Role === 'Assistant') { + if (key === lastKey) { + if (!stableStartedAt) stableStartedAt = Date.now(); + const elapsedSeconds = Math.floor((Date.now() - stableStartedAt) / 1000); + if (elapsedSeconds >= stableSeconds) { + return { + messages, + rows: formatChatGPTDetailMessages(messages, { + wantMarkdown, + generating: false, + stableSeconds: elapsedSeconds, + }), + generating: false, + }; + } + } else { + lastKey = key; + stableStartedAt = Date.now(); + } + } else { + lastKey = key; + stableStartedAt = 0; + } + await page.wait(3); + } + + throw new TimeoutError( + 'chatgpt detail', + timeoutSeconds, + 'Conversation did not finish or stabilize before timeout. Re-run with a higher --timeout if it is still generating.', + ); +} + export function messageHtmlToMarkdown(html) { try { return htmlToMarkdown(html).trim(); @@ -698,9 +1090,14 @@ export async function uploadChatGPTImages(page, imagePaths) { export async function isGenerating(page) { return requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(` (() => { + const text = (document.body?.innerText || '').replace(/\\s+/g, ' '); + if (/正在思考|停止生成|Thinking/.test(text)) return true; return Array.from(document.querySelectorAll('button')).some(b => { const label = b.getAttribute('aria-label') || ''; - return label === 'Stop generating' || label.includes('Thinking'); + return label === 'Stop generating' + || label.includes('Thinking') + || label.includes('停止生成') + || label.includes('正在思考'); }); })() `)), 'chatgpt generation state'); diff --git a/clis/chatgpt/utils.test.js b/clis/chatgpt/utils.test.js index 4eaa5b840..f76e5772c 100644 --- a/clis/chatgpt/utils.test.js +++ b/clis/chatgpt/utils.test.js @@ -3,7 +3,8 @@ import os from 'node:os'; import path from 'node:path'; import { JSDOM } from 'jsdom'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { __test__, getChatGPTImageAssets, getChatGPTVisibleImageUrls, prepareChatGPTImagePaths, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTImages } from './utils.js'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { __test__, getChatGPTDetailRows, getChatGPTImageAssets, getChatGPTVisibleImageUrls, getCurrentChatGPTModel, getCurrentChatGPTTool, isGenerating, openChatGPTConversation, prepareChatGPTImagePaths, selectChatGPTModel, selectChatGPTTool, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTDetailRows, waitForChatGPTImages } from './utils.js'; const tempDirs = []; @@ -37,6 +38,19 @@ function createPageMock({ location = '', generating = [], imageUrls = [] } = {}) }; } +function createDomEvaluatePage(html) { + const dom = new JSDOM(html, { + url: 'https://chatgpt.com/', + runScripts: 'outside-only', + }); + for (const node of dom.window.document.querySelectorAll('button')) { + node.getBoundingClientRect = () => ({ width: 120, height: 36 }); + } + return { + evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(script))), + }; +} + describe('chatgpt image wait contract', () => { it('does not periodically reload the conversation while generation is still active', async () => { const convUrl = 'https://chatgpt.com/c/demo'; @@ -79,6 +93,7 @@ describe('chatgpt conversation id parsing', () => { it('accepts ids and chatgpt conversation URLs', () => { expect(__test__.parseChatGPTConversationId('abc_123-def')).toBe('abc_123-def'); expect(__test__.parseChatGPTConversationId('https://chatgpt.com/c/abc_123-def?model=gpt-5')).toBe('abc_123-def'); + expect(__test__.parseChatGPTConversationId('https://chat.openai.chatgpt.com/c/abc_123-def')).toBe('abc_123-def'); expect(__test__.parseChatGPTConversationId('/c/abc_123-def')).toBe('abc_123-def'); }); @@ -86,6 +101,221 @@ describe('chatgpt conversation id parsing', () => { expect(() => __test__.parseChatGPTConversationId('')).toThrow(/conversation id/); expect(() => __test__.parseChatGPTConversationId('https://chatgpt.com/')).toThrow(/conversation id/); }); + + it('rejects off-domain or ambiguous conversation URLs before routing writes', () => { + expect(() => __test__.parseChatGPTConversationId('https://evil.test/c/abc_123-def')).toThrow(/chatgpt\.com/); + expect(() => __test__.parseChatGPTConversationId('http://chatgpt.com/c/abc_123-def')).toThrow(/chatgpt\.com/); + expect(() => __test__.parseChatGPTConversationId('https://chatgpt.com.evil.test/c/abc_123-def')).toThrow(/chatgpt\.com/); + expect(() => __test__.parseChatGPTConversationId('/c/abc_123-def/extra')).toThrow(/conversation id/); + expect(() => __test__.parseChatGPTConversationId('prefix https://chatgpt.com/c/abc_123-def')).toThrow(/conversation id/); + }); +}); + +describe('chatgpt conversation navigation', () => { + it('opens conversation URLs by parsed id', async () => { + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + }; + + await expect(openChatGPTConversation(page, 'https://chatgpt.com/c/abc_123-def?model=gpt-5')) + .resolves.toBe('abc_123-def'); + expect(page.goto).toHaveBeenCalledWith('https://chatgpt.com/c/abc_123-def', { settleMs: 2000 }); + expect(page.wait).toHaveBeenCalledWith({ selector: '#prompt-textarea, [data-testid="prompt-textarea"]', timeout: 8 }); + }); +}); + +describe('chatgpt model selection validation', () => { + it('rejects unknown model names', async () => { + await expect(selectChatGPTModel({ nativeClick: vi.fn() }, 'unknown')) + .rejects.toBeInstanceOf(ArgumentError); + await expect(selectChatGPTModel({ nativeClick: vi.fn() }, 'unknown')) + .rejects.toThrow('Unknown ChatGPT model "unknown"'); + }); + + it('requires native browser click support', async () => { + await expect(selectChatGPTModel({}, 'pro')) + .rejects.toBeInstanceOf(CommandExecutionError); + await expect(selectChatGPTModel({}, 'pro')) + .rejects.toThrow('ChatGPT model selection requires native browser click support.'); + }); + + it('clicks the model selector and verifies the selected postcondition', async () => { + let objectCall = 0; + const page = { + wait: vi.fn().mockResolvedValue(undefined), + nativeClick: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn((script) => { + if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); + objectCall += 1; + if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); + if (objectCall === 2) return Promise.resolve({ model: 'instant', label: 'Instant' }); + if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 }); + if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 }); + if (objectCall === 5) return Promise.resolve({ model: 'pro', label: 'Pro' }); + return Promise.resolve({}); + }), + }; + + await expect(selectChatGPTModel(page, 'pro')).resolves.toEqual({ Status: 'Success', Model: 'Pro' }); + expect(page.nativeClick).toHaveBeenNthCalledWith(1, 10, 20); + expect(page.nativeClick).toHaveBeenNthCalledWith(2, 30, 40); + }); + + it('fails closed when the postcondition does not prove the requested model', async () => { + let objectCall = 0; + const page = { + wait: vi.fn().mockResolvedValue(undefined), + nativeClick: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn((script) => { + if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); + objectCall += 1; + if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); + if (objectCall === 2) return Promise.resolve({ model: 'instant', label: 'Instant' }); + if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 }); + if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 }); + if (objectCall === 5) return Promise.resolve({ model: 'instant', label: 'Instant' }); + return Promise.resolve({}); + }), + }; + + await expect(selectChatGPTModel(page, 'pro')).rejects.toMatchObject({ + code: 'COMMAND_EXEC', + message: expect.stringContaining('did not switch to Pro'), + }); + }); +}); + +describe('chatgpt tool selection validation', () => { + it('rejects unknown tool names', async () => { + await expect(selectChatGPTTool({ nativeClick: vi.fn() }, 'unknown')) + .rejects.toBeInstanceOf(ArgumentError); + await expect(selectChatGPTTool({ nativeClick: vi.fn() }, 'unknown')) + .rejects.toThrow('Unknown ChatGPT tool "unknown"'); + }); + + it('requires native browser click support', async () => { + await expect(selectChatGPTTool({}, 'deep-research')) + .rejects.toBeInstanceOf(CommandExecutionError); + await expect(selectChatGPTTool({}, 'deep-research')) + .rejects.toThrow('ChatGPT tool selection requires native browser click support.'); + }); +}); + +describe('chatgpt detail completion state', () => { + function createDetailPageMock({ generating = false, messages = [] } = {}) { + return { + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn((script) => { + if (script.includes('Stop generating') || script.includes('Thinking')) { + return Promise.resolve(generating); + } + if (script.includes('data-message-author-role')) { + return Promise.resolve(messages.map((message) => ({ + role: message.Role, + text: message.Text, + html: message.Html ?? message.Text, + }))); + } + return Promise.resolve(undefined); + }), + }; + } + + it('adds generation state to detail rows', async () => { + const page = createDetailPageMock({ + generating: true, + messages: [ + { Role: 'User', Text: 'question' }, + { Role: 'Assistant', Text: 'working' }, + ], + }); + + await expect(getChatGPTDetailRows(page)).resolves.toMatchObject({ + generating: true, + rows: [ + { Index: 1, Role: 'User', Text: 'question', Generating: true, StableSeconds: 0 }, + { Index: 2, Role: 'Assistant', Text: 'working', Generating: true, StableSeconds: 0 }, + ], + }); + }); + + it('waits until assistant output is stable', async () => { + const page = createDetailPageMock({ + generating: false, + messages: [ + { Role: 'User', Text: 'question' }, + { Role: 'Assistant', Text: 'done' }, + ], + }); + + const result = await waitForChatGPTDetailRows(page, { timeoutSeconds: 5, stableSeconds: 0 }); + + expect(result.rows.at(-1)).toMatchObject({ + Role: 'Assistant', + Text: 'done', + Generating: false, + StableSeconds: 0, + }); + }); +}); + +describe('chatgpt generation state', () => { + it('detects zh-CN thinking status text', async () => { + const page = { + evaluate: vi.fn((script) => { + expect(script).toContain('正在思考'); + return Promise.resolve(true); + }), + }; + + await expect(isGenerating(page)).resolves.toBe(true); + }); +}); + +describe('chatgpt current model detection', () => { + it.each([ + ['Instant', { model: 'instant', label: 'Instant' }], + ['Thinking', { model: 'thinking', label: 'Thinking' }], + ['Pro', { model: 'pro', label: 'Pro' }], + ['进阶专业', { model: 'pro', label: 'Pro' }], + ])('detects the visible %s model label', async (label, expected) => { + const page = createDomEvaluatePage(`
`); + + await expect(getCurrentChatGPTModel(page)).resolves.toEqual(expected); + }); + + it('returns null fields when the model selector is missing', async () => { + const page = createDomEvaluatePage('
'); + + await expect(getCurrentChatGPTModel(page)).resolves.toEqual({ + model: null, + label: null, + }); + }); +}); + +describe('chatgpt current tool detection', () => { + it.each([ + ['深度研究', { tool: 'deep-research', label: 'Deep Research' }], + ['Deep Research', { tool: 'deep-research', label: 'Deep Research' }], + ['网页搜索', { tool: 'web-search', label: 'Web Search' }], + ['搜索', { tool: 'web-search', label: 'Web Search' }], + ['Web Search', { tool: 'web-search', label: 'Web Search' }], + ])('detects the visible %s tool label', async (label, expected) => { + const page = createDomEvaluatePage(`
`); + + await expect(getCurrentChatGPTTool(page)).resolves.toEqual(expected); + }); + + it('returns null fields when no supported tool is selected', async () => { + const page = createDomEvaluatePage('
'); + + await expect(getCurrentChatGPTTool(page)).resolves.toEqual({ + tool: null, + label: null, + }); + }); }); describe('chatgpt send selectors', () => { @@ -111,9 +341,10 @@ describe('chatgpt send selectors', () => { it('keeps locale-independent send-button selector before aria-label fallbacks', async () => { const page = { wait: vi.fn().mockResolvedValue(undefined), + nativeClick: vi.fn().mockResolvedValue(undefined), nativeType: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn((script) => { - if (script.includes('findComposer')) return Promise.resolve(true); + if (script.includes('findComposer')) return Promise.resolve({ ready: true, x: 12, y: 34 }); if (script.includes('sendBtnFound')) { expect(script).toContain('data-testid=\\\"send-button\\\"'); return Promise.resolve({ sendBtnFound: true }); @@ -126,6 +357,7 @@ describe('chatgpt send selectors', () => { }; await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true); + expect(page.nativeClick).toHaveBeenCalledWith(12, 34); }); it('uses the composer submit fallback consistently for readiness and click', async () => { @@ -133,7 +365,7 @@ describe('chatgpt send selectors', () => { wait: vi.fn().mockResolvedValue(undefined), nativeType: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn((script) => { - if (script.includes('findComposer')) return Promise.resolve(true); + if (script.includes('findComposer')) return Promise.resolve({ ready: true, x: 12, y: 34 }); if (script.includes('sendBtnFound')) { expect(script).toContain('#composer-submit-button:not([disabled])'); return Promise.resolve({ sendBtnFound: true }); @@ -158,7 +390,7 @@ describe('chatgpt send selectors', () => { ])); expect(__test__.SEND_BUTTON_SELECTOR).toBe('button[data-testid="send-button"]:not([disabled])'); expect(__test__.SEND_BUTTON_FALLBACK_SELECTORS).toContain('#composer-submit-button:not([disabled])'); - expect(__test__.SEND_BUTTON_LABELS).toEqual(expect.arrayContaining(['Send prompt', 'Send message', 'Send', '发送提示'])); + expect(__test__.SEND_BUTTON_LABELS).toEqual(expect.arrayContaining(['Send prompt', 'Send message', 'Send', '发送', '发送消息', '发送提示'])); expect(__test__.CLOSE_SIDEBAR_LABELS).toEqual(expect.arrayContaining(['Close sidebar', '关闭边栏'])); }); });