9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
1908 lines
87 KiB
JavaScript
1908 lines
87 KiB
JavaScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { JSDOM } from 'jsdom';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import { __test__, getChatGPTDetailRows, getChatGPTImageAssets, getChatGPTResponsePairCounts, getChatGPTVisibleImageUrls, getCurrentChatGPTModel, getCurrentChatGPTTool, getVisibleMessages, isGenerating, navigateToProject, openChatGPTConversation, prepareChatGPTImagePaths, selectChatGPTModel, selectChatGPTTool, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTDeepResearchResult, waitForChatGPTDetailRows, waitForChatGPTImages, waitForChatGPTResponse } from './utils.js';
|
|
|
|
const tempDirs = [];
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
while (tempDirs.length) {
|
|
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function createPageMock({ location = '', generating = [], imageUrls = [] } = {}) {
|
|
let generatingIndex = 0;
|
|
let imageIndex = 0;
|
|
return {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => {
|
|
if (script === 'window.location.href') return Promise.resolve(location);
|
|
if (script.includes('Stop generating') || script.includes('Thinking')) {
|
|
const value = generating[Math.min(generatingIndex, generating.length - 1)] ?? false;
|
|
generatingIndex += 1;
|
|
return Promise.resolve(value);
|
|
}
|
|
if (script.includes("document.querySelectorAll('img')")) {
|
|
const value = imageUrls[Math.min(imageIndex, imageUrls.length - 1)] ?? [];
|
|
imageIndex += 1;
|
|
return Promise.resolve(value);
|
|
}
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
}
|
|
|
|
function createDomEvaluatePage(html) {
|
|
const dom = new JSDOM(html, {
|
|
url: 'https://chatgpt.com/',
|
|
runScripts: 'outside-only',
|
|
});
|
|
for (const node of dom.window.document.querySelectorAll('form, button, [role="menuitemradio"], [role="menuitem"], [role="option"], #prompt-textarea, [data-testid]')) {
|
|
node.getBoundingClientRect = () => ({ width: 120, height: 36 });
|
|
node.scrollIntoView = () => {};
|
|
}
|
|
return {
|
|
dom,
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
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';
|
|
const page = createPageMock({
|
|
location: convUrl,
|
|
generating: [true, true, true, true, true, true],
|
|
});
|
|
|
|
await expect(waitForChatGPTImages(page, [], 18, convUrl)).resolves.toEqual([]);
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('jumps back to the captured conversation when the page drifts away', async () => {
|
|
const convUrl = 'https://chatgpt.com/c/demo';
|
|
const page = createPageMock({
|
|
location: 'https://chatgpt.com/',
|
|
generating: [false],
|
|
imageUrls: [['https://cdn.openai.com/generated/demo.png']],
|
|
});
|
|
|
|
await expect(waitForChatGPTImages(page, [], 3, convUrl)).resolves.toEqual([
|
|
'https://cdn.openai.com/generated/demo.png',
|
|
]);
|
|
expect(page.goto).toHaveBeenCalledWith(convUrl);
|
|
});
|
|
|
|
it('treats query and hash variants as the same conversation', () => {
|
|
expect(__test__.isSameChatGPTConversation(
|
|
'https://chatgpt.com/c/demo?model=gpt-image-1',
|
|
'https://chatgpt.com/c/demo',
|
|
)).toBe(true);
|
|
expect(__test__.isSameChatGPTConversation(
|
|
'https://chatgpt.com/c/other',
|
|
'https://chatgpt.com/c/demo',
|
|
)).toBe(false);
|
|
});
|
|
});
|
|
|
|
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('https://chatgpt.com/g/g-p-12345678-demo/c/abc_123-def')).toBe('abc_123-def');
|
|
expect(__test__.parseChatGPTConversationId('/c/abc_123-def')).toBe('abc_123-def');
|
|
expect(__test__.parseChatGPTConversationId('/g/g-p-12345678-demo/c/abc_123-def')).toBe('abc_123-def');
|
|
});
|
|
|
|
it('rejects invalid detail ids', () => {
|
|
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 });
|
|
});
|
|
});
|
|
|
|
function makeDeepResearchReport() {
|
|
return [
|
|
'# Executive Summary',
|
|
'',
|
|
'This completed Deep Research report is intentionally long enough to pass extraction heuristics.',
|
|
'It summarizes findings, constraints, evidence, and recommendations from multiple public sources.',
|
|
'The extraction path should read this markdown from metadata.chatgpt_sdk.widget_state.report_message.content.parts[0].',
|
|
'Using the conversation payload avoids the cross-origin internal deep research iframe boundary.',
|
|
'The report body includes repeated detail so the parser treats it as a real report, not a short UI preview.',
|
|
'Findings show that reliable automation should prefer captured backend conversation JSON over iframe DOM access.',
|
|
'Recommendations include returning diagnostics when no report is present and bounding source extraction.',
|
|
'References and Sources are represented in metadata content references, safe URLs, and search result groups.',
|
|
'Additional detail confirms that source de-duplication should key by URL and keep a readable title.',
|
|
'This paragraph pads the fixture with realistic report text for the minimum-length guard.',
|
|
'Another paragraph pads the fixture with realistic report text for the minimum-length guard.',
|
|
'A final paragraph pads the fixture with realistic report text for the minimum-length guard.',
|
|
'',
|
|
'## Sources',
|
|
'',
|
|
'- Example source',
|
|
].join('\n');
|
|
}
|
|
|
|
function makeDeepResearchPayload(report = makeDeepResearchReport(), { conversationId = '' } = {}) {
|
|
const payload = {
|
|
mapping: {
|
|
report_node: {
|
|
message: {
|
|
metadata: {
|
|
chatgpt_sdk: {
|
|
widget_state: JSON.stringify({
|
|
status: 'completed',
|
|
report_message: {
|
|
id: 'report-msg',
|
|
content: { parts: [report] },
|
|
metadata: {
|
|
content_references: [
|
|
{ title: 'Reference A', url: 'https://example.com/a' },
|
|
{ matched_text: 'Matched B', url: 'https://example.com/b' },
|
|
],
|
|
safe_urls: ['https://example.com/c'],
|
|
search_result_groups: [
|
|
{ entries: [{ title: 'Reference D', url: 'https://example.com/d' }] },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
if (conversationId) payload.conversation_id = conversationId;
|
|
return payload;
|
|
}
|
|
|
|
function makeDeepResearchProgressPayload() {
|
|
return {
|
|
mapping: {
|
|
progress_node: {
|
|
message: {
|
|
metadata: {
|
|
chatgpt_sdk: {
|
|
widget_state: JSON.stringify({
|
|
status: 'waiting_for_user_response_on_plan',
|
|
waiting_for_user_response_on_plan_until: '2026-07-02T02:29:48.298274Z',
|
|
plan: {
|
|
plan_id: 'plan-demo',
|
|
title: 'LLM 不确定性推理调研计划',
|
|
steps: [
|
|
{ id: 'step-1', title: 'Collect sources', status: 'pending' },
|
|
],
|
|
},
|
|
step_statuses_by_plan: {
|
|
'step-1': 'pending',
|
|
},
|
|
}),
|
|
response_metadata: {
|
|
async_task_conversation_id: '6a45ccdd-811c-83e8-af35-ca4df176e91e',
|
|
'openai/widgetSessionId': 'a9c86290-7600-4c7b-a4cb-11229ff0fa4c',
|
|
'openai/asyncStatus': 7,
|
|
venus_message_type: 'initial_loading_message',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('chatgpt deep research result extraction', () => {
|
|
it('extracts report markdown and sources from conversation widget_state', () => {
|
|
const result = __test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload());
|
|
|
|
expect(result).toMatchObject({
|
|
status: 'completed',
|
|
method: 'conversation-widget-state',
|
|
reportMessageId: 'report-msg',
|
|
reportLength: expect.any(Number),
|
|
});
|
|
expect(result.report).toContain('Executive Summary');
|
|
expect(result.sources).toEqual(expect.arrayContaining([
|
|
{ title: 'Reference A', url: 'https://example.com/a' },
|
|
{ title: 'Matched B', url: 'https://example.com/b' },
|
|
{ title: '', url: 'https://example.com/c' },
|
|
{ title: 'Reference D', url: 'https://example.com/d' },
|
|
]));
|
|
});
|
|
|
|
it('extracts the requested report from captured conversation network entries', () => {
|
|
const shorterReport = `${makeDeepResearchReport()}\n\nshort`;
|
|
const longerReport = `${makeDeepResearchReport()}\n\nAdditional longer section.`;
|
|
const result = __test__.extractDeepResearchFromNetworkEntries([
|
|
{ url: 'https://chatgpt.com/backend-api/bootstrap', responsePreview: '{}' },
|
|
{
|
|
url: 'https://chatgpt.com/backend-api/conversation/requested123',
|
|
responsePreview: JSON.stringify(makeDeepResearchPayload(shorterReport, { conversationId: 'requested123' })),
|
|
},
|
|
{
|
|
url: 'https://chatgpt.com/backend-api/conversation/stale45678',
|
|
responsePreview: JSON.stringify(makeDeepResearchPayload(longerReport, { conversationId: 'stale45678' })),
|
|
},
|
|
], { expectedConversationId: 'requested123' });
|
|
|
|
expect(result.method).toBe('network-conversation-widget-state');
|
|
expect(result.networkUrl).toContain('/conversation/requested123');
|
|
expect(result.report).not.toContain('Additional longer section');
|
|
});
|
|
|
|
it('typed-fails when the conversation payload id does not match the requested id', () => {
|
|
expect(() => __test__.extractDeepResearchFromConversationPayload(
|
|
makeDeepResearchPayload(makeDeepResearchReport(), { conversationId: 'stale45678' }),
|
|
{ expectedConversationId: 'requested123' },
|
|
)).toThrow(CommandExecutionError);
|
|
});
|
|
|
|
it('typed-fails malformed source rows instead of silently dropping them', () => {
|
|
const payload = makeDeepResearchPayload();
|
|
const widget = JSON.parse(payload.mapping.report_node.message.metadata.chatgpt_sdk.widget_state);
|
|
widget.report_message.metadata.search_result_groups = [
|
|
{ entries: [{ title: 'Source without URL' }] },
|
|
];
|
|
payload.mapping.report_node.message.metadata.chatgpt_sdk.widget_state = JSON.stringify(widget);
|
|
|
|
expect(() => __test__.extractDeepResearchFromConversationPayload(payload))
|
|
.toThrow(CommandExecutionError);
|
|
});
|
|
|
|
it('typed-fails malformed conversation payloads instead of treating them as empty reports', () => {
|
|
expect(() => __test__.extractDeepResearchFromConversationPayload({}))
|
|
.toThrow(CommandExecutionError);
|
|
});
|
|
|
|
it('extracts waiting-for-user progress from widget metadata without a report', () => {
|
|
const result = __test__.extractDeepResearchFromConversationPayload(makeDeepResearchProgressPayload());
|
|
|
|
expect(result).toMatchObject({
|
|
status: 'waiting_for_user',
|
|
method: 'conversation-widget-progress',
|
|
asyncTaskConversationId: '6a45ccdd-811c-83e8-af35-ca4df176e91e',
|
|
widgetSessionId: 'a9c86290-7600-4c7b-a4cb-11229ff0fa4c',
|
|
asyncStatus: 7,
|
|
venusMessageType: 'initial_loading_message',
|
|
venusStatus: 'waiting_for_user_response_on_plan',
|
|
waitingForUserUntil: '2026-07-02T02:29:48.298274Z',
|
|
planId: 'plan-demo',
|
|
planTitle: 'LLM 不确定性推理调研计划',
|
|
});
|
|
expect(result.report).toBe('');
|
|
expect(result.progress.planSteps).toEqual([
|
|
{ id: 'step-1', title: 'Collect sources', status: 'pending' },
|
|
]);
|
|
});
|
|
|
|
it('ignores short widget previews that are not completed reports', () => {
|
|
expect(__test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload('short preview'))).toBeNull();
|
|
});
|
|
|
|
it('stops waiting immediately when widget state needs user input', async () => {
|
|
const page = {
|
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => {
|
|
const source = String(script);
|
|
if (source.includes("document.querySelectorAll('iframe')")) {
|
|
return Promise.resolve({
|
|
url: 'https://chatgpt.com/c/6a45ccd8-23b8-83ee-9dbc-6a51d1c8195e',
|
|
title: 'ChatGPT',
|
|
iframes: [],
|
|
deepResearchIframe: null,
|
|
});
|
|
}
|
|
if (source.includes('Stop generating') || source.includes('Thinking')) {
|
|
return Promise.resolve(false);
|
|
}
|
|
if (source.includes('/backend-api/conversation/')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
text: JSON.stringify(makeDeepResearchProgressPayload()),
|
|
});
|
|
}
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
const result = await waitForChatGPTDeepResearchResult(page, { timeoutSeconds: 180, stableSeconds: 3 });
|
|
|
|
expect(result.status).toBe('waiting_for_user');
|
|
expect(result.venusStatus).toBe('waiting_for_user_response_on_plan');
|
|
expect(page.wait).not.toHaveBeenCalledWith(3);
|
|
});
|
|
|
|
it('polls deep research progress with pure client-side sleeps', async () => {
|
|
let now = 0;
|
|
vi.spyOn(Date, 'now').mockImplementation(() => {
|
|
now += 2000;
|
|
return now;
|
|
});
|
|
const payload = makeDeepResearchProgressPayload();
|
|
const widgetState = JSON.parse(payload.mapping.progress_node.message.metadata.chatgpt_sdk.widget_state);
|
|
widgetState.status = 'running';
|
|
payload.mapping.progress_node.message.metadata.chatgpt_sdk.widget_state = JSON.stringify(widgetState);
|
|
const page = {
|
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => {
|
|
const source = String(script);
|
|
if (source.includes("document.querySelectorAll('iframe')")) {
|
|
return Promise.resolve({
|
|
url: 'https://chatgpt.com/c/6a45ccd8-23b8-83ee-9dbc-6a51d1c8195e',
|
|
title: 'ChatGPT',
|
|
iframes: [],
|
|
deepResearchIframe: null,
|
|
});
|
|
}
|
|
if (source.includes('Stop generating') || source.includes('Thinking')) {
|
|
return Promise.resolve(false);
|
|
}
|
|
if (source.includes('/backend-api/conversation/')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
text: JSON.stringify(payload),
|
|
});
|
|
}
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
await expect(waitForChatGPTDeepResearchResult(page, { timeoutSeconds: 5, stableSeconds: 3 }))
|
|
.rejects.toThrow(/deep-research-result timed out/);
|
|
|
|
expect(page.sleep).toHaveBeenCalledWith(3);
|
|
expect(page.wait).not.toHaveBeenCalledWith(3);
|
|
});
|
|
});
|
|
|
|
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 = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
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: 'balanced', label: 'Balanced' });
|
|
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: 'fast', label: 'Fast' });
|
|
return Promise.resolve({});
|
|
}),
|
|
};
|
|
|
|
await expect(selectChatGPTModel(page, 'fast')).resolves.toEqual({ Status: 'Success', Model: 'Fast' });
|
|
expect(page.nativeClick).toHaveBeenNthCalledWith(1, 10, 20);
|
|
expect(page.nativeClick).toHaveBeenNthCalledWith(2, 30, 40);
|
|
});
|
|
|
|
it('sets Advanced through the ChatGPT model config API when browser cookies are available', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 }))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 }));
|
|
let objectCall = 0;
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
nativeClick: vi.fn().mockResolvedValue(undefined),
|
|
getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]),
|
|
evaluate: vi.fn((script) => {
|
|
if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo');
|
|
if (String(script).includes('oai-last-model-config')) return Promise.resolve(true);
|
|
objectCall += 1;
|
|
if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true });
|
|
if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' });
|
|
if (objectCall === 3) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true });
|
|
if (objectCall === 4) return Promise.resolve({ model: 'advanced', label: 'Advanced' });
|
|
return Promise.resolve({});
|
|
}),
|
|
};
|
|
|
|
await expect(selectChatGPTModel(page, 'thinking')).resolves.toEqual({ Status: 'Success', Model: 'Advanced' });
|
|
expect(fetchMock.mock.calls[1][0]).toContain('/backend-api/settings/user_last_used_model_config');
|
|
expect(fetchMock.mock.calls[1][0]).toContain('model_slug=gpt-5-5-thinking');
|
|
expect(fetchMock.mock.calls[1][0]).toContain('thinking_effort=extended');
|
|
expect(page.nativeClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to the visible picker when the model config API does not prove selection', async () => {
|
|
vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 }))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 }));
|
|
let objectCall = 0;
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
nativeClick: vi.fn().mockResolvedValue(undefined),
|
|
getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]),
|
|
evaluate: vi.fn((script) => {
|
|
if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo');
|
|
if (String(script).includes('oai-last-model-config')) return Promise.resolve(true);
|
|
objectCall += 1;
|
|
if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true });
|
|
if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' });
|
|
if (objectCall === 3) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true });
|
|
if (objectCall === 4) return Promise.resolve({ model: 'balanced', label: 'Balanced' });
|
|
if (objectCall === 5) return Promise.resolve({ found: true, x: 10, y: 20 });
|
|
if (objectCall === 6) return Promise.resolve({ found: true, x: 30, y: 40 });
|
|
if (objectCall === 7) return Promise.resolve({ model: 'advanced', label: 'Advanced' });
|
|
return Promise.resolve({});
|
|
}),
|
|
};
|
|
|
|
await expect(selectChatGPTModel(page, 'advanced')).resolves.toEqual({ Status: 'Success', Model: 'Advanced' });
|
|
expect(page.nativeClick).toHaveBeenNthCalledWith(1, 10, 20);
|
|
expect(page.nativeClick).toHaveBeenNthCalledWith(2, 30, 40);
|
|
});
|
|
|
|
it('falls back to the picker when the session API response is malformed', async () => {
|
|
vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(new Response('{', { status: 200 }));
|
|
let objectCall = 0;
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
nativeClick: vi.fn().mockResolvedValue(undefined),
|
|
getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]),
|
|
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: 'balanced', label: 'Balanced' });
|
|
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: 'advanced', label: 'Advanced' });
|
|
return Promise.resolve({});
|
|
}),
|
|
};
|
|
|
|
await expect(selectChatGPTModel(page, 'advanced')).resolves.toEqual({ Status: 'Success', Model: 'Advanced' });
|
|
expect(page.nativeClick).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('maps ChatGPT preference API auth rejection to AuthRequiredError', async () => {
|
|
vi.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 }))
|
|
.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }));
|
|
let objectCall = 0;
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
nativeClick: vi.fn().mockResolvedValue(undefined),
|
|
getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]),
|
|
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: 'balanced', label: 'Balanced' });
|
|
return Promise.resolve({});
|
|
}),
|
|
};
|
|
|
|
await expect(selectChatGPTModel(page, 'advanced')).rejects.toBeInstanceOf(AuthRequiredError);
|
|
});
|
|
|
|
it('selects current Chinese intelligence options by exact visible menu text', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<form>
|
|
<button type="button" data-testid="model-switcher-dropdown-button">GPT-5.5 均衡</button>
|
|
<div id="prompt-textarea" contenteditable="true"></div>
|
|
</form>
|
|
<div role="menu">
|
|
<div role="menuitemradio">极速</div>
|
|
<div role="menuitemradio">均衡</div>
|
|
<div role="menuitemradio">高级</div>
|
|
<div role="menuitemradio">超高</div>
|
|
<div role="menuitemradio">专业</div>
|
|
</div>
|
|
`);
|
|
let clickCount = 0;
|
|
page.wait = vi.fn().mockResolvedValue(undefined);
|
|
page.nativeClick = vi.fn().mockImplementation(async () => {
|
|
clickCount += 1;
|
|
if (clickCount === 2) {
|
|
page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'GPT-5.5 超高'`);
|
|
}
|
|
});
|
|
|
|
await expect(selectChatGPTModel(page, 'very-high')).resolves.toEqual({ Status: 'Success', Model: 'Very High' });
|
|
expect(page.nativeClick).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('verifies selection from stable model test id when the current visible label is unknown', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<form>
|
|
<button type="button" data-testid="model-switcher-dropdown-button">Mode rapide</button>
|
|
<div id="prompt-textarea" contenteditable="true"></div>
|
|
</form>
|
|
<div role="menu">
|
|
<div role="menuitemradio">极速</div>
|
|
</div>
|
|
`);
|
|
let clickCount = 0;
|
|
page.nativeClick = vi.fn().mockImplementation(async () => {
|
|
clickCount += 1;
|
|
if (clickCount === 2) {
|
|
await page.evaluate(`
|
|
const button = document.querySelector('[data-testid="model-switcher-dropdown-button"]');
|
|
button.innerHTML = '<span data-testid="model-switcher-gpt-5-5">Mode rapide</span>';
|
|
`);
|
|
for (const node of page.dom.window.document.querySelectorAll('[data-testid]')) {
|
|
node.getBoundingClientRect = () => ({ width: 120, height: 36 });
|
|
node.scrollIntoView = () => {};
|
|
}
|
|
}
|
|
});
|
|
|
|
await expect(selectChatGPTModel(page, 'instant')).resolves.toEqual({ Status: 'Success', Model: 'Fast' });
|
|
expect(page.nativeClick).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('selects actual English intelligence options by visible menu text', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<form>
|
|
<button type="button" data-testid="model-switcher-dropdown-button">Instant</button>
|
|
<div id="prompt-textarea" contenteditable="true"></div>
|
|
</form>
|
|
<div role="menu" data-testid="composer-intelligence-picker-content">
|
|
<div role="group">
|
|
<div role="menuitemradio">Instant</div>
|
|
<div role="menuitemradio">Medium</div>
|
|
<div role="menuitemradio">High</div>
|
|
<div role="menuitemradio">Extra High</div>
|
|
<div role="menuitemradio">Pro</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
let clickCount = 0;
|
|
page.nativeClick = vi.fn().mockImplementation(async () => {
|
|
clickCount += 1;
|
|
if (clickCount === 2) {
|
|
page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'Extra High'`);
|
|
}
|
|
});
|
|
|
|
await expect(selectChatGPTModel(page, 'extra-high')).resolves.toEqual({ Status: 'Success', Model: 'Very High' });
|
|
expect(page.nativeClick).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('selects Instant when the current precise level is Medium', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<form>
|
|
<button type="button" data-testid="model-switcher-dropdown-button">Medium</button>
|
|
<div id="prompt-textarea" contenteditable="true"></div>
|
|
</form>
|
|
<div role="menu" data-testid="composer-intelligence-picker-content">
|
|
<div role="group">
|
|
<div role="menuitemradio">Instant</div>
|
|
<div role="menuitemradio">Medium</div>
|
|
<div role="menuitemradio">High</div>
|
|
<div role="menuitemradio">Extra High</div>
|
|
<div role="menuitemradio">Pro</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
let clickCount = 0;
|
|
page.nativeClick = vi.fn().mockImplementation(async () => {
|
|
clickCount += 1;
|
|
if (clickCount === 2) {
|
|
page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'Instant'`);
|
|
}
|
|
});
|
|
|
|
await expect(selectChatGPTModel(page, 'instant')).resolves.toEqual({ Status: 'Success', Model: 'Fast' });
|
|
expect(page.nativeClick).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('selects Balanced when the current precise level is Extra High', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<form>
|
|
<button type="button" data-testid="model-switcher-dropdown-button">Extra High</button>
|
|
<div id="prompt-textarea" contenteditable="true"></div>
|
|
</form>
|
|
<div role="menu" data-testid="composer-intelligence-picker-content">
|
|
<div role="group">
|
|
<div role="menuitemradio">Instant</div>
|
|
<div role="menuitemradio">Medium</div>
|
|
<div role="menuitemradio">High</div>
|
|
<div role="menuitemradio">Extra High</div>
|
|
<div role="menuitemradio">Pro</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
let clickCount = 0;
|
|
page.nativeClick = vi.fn().mockImplementation(async () => {
|
|
clickCount += 1;
|
|
if (clickCount === 2) {
|
|
page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'Medium'`);
|
|
}
|
|
});
|
|
|
|
await expect(selectChatGPTModel(page, 'balanced')).resolves.toEqual({ Status: 'Success', Model: 'Balanced' });
|
|
expect(page.nativeClick).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('uses guarded intelligence menu order for unknown localized labels', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<form>
|
|
<button type="button" data-testid="model-switcher-dropdown-button">Mode rapide</button>
|
|
<div id="prompt-textarea" contenteditable="true"></div>
|
|
</form>
|
|
<div role="menu" data-testid="composer-intelligence-picker-content">
|
|
<div role="group">
|
|
<div role="menuitemradio" aria-checked="false">L0</div>
|
|
<div role="menuitemradio" aria-checked="false">L1</div>
|
|
<div role="menuitemradio" aria-checked="false">L2</div>
|
|
<div role="menuitemradio" aria-checked="false">L3</div>
|
|
<div role="menuitemradio" aria-checked="false">L4</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
let clickCount = 0;
|
|
page.nativeClick = vi.fn().mockImplementation(async () => {
|
|
clickCount += 1;
|
|
if (clickCount === 2) {
|
|
const options = page.dom.window.document.querySelectorAll('[role="menuitemradio"]');
|
|
for (const option of options) option.setAttribute('aria-checked', 'false');
|
|
options[3].setAttribute('aria-checked', 'true');
|
|
}
|
|
});
|
|
|
|
await expect(selectChatGPTModel(page, 'extra-high')).resolves.toEqual({ Status: 'Success', Model: 'Very High' });
|
|
expect(page.nativeClick).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it('does not use order fallback outside the guarded five-option intelligence picker', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<form>
|
|
<button type="button" data-testid="model-switcher-dropdown-button">Mode rapide</button>
|
|
<div id="prompt-textarea" contenteditable="true"></div>
|
|
</form>
|
|
<div role="menu">
|
|
<div role="group">
|
|
<div role="menuitemradio">L0</div>
|
|
<div role="menuitemradio">L1</div>
|
|
<div role="menuitemradio">L2</div>
|
|
<div role="menuitemradio">L3</div>
|
|
<div role="menuitemradio">L4</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
page.nativeClick = vi.fn().mockResolvedValue(undefined);
|
|
|
|
await expect(selectChatGPTModel(page, 'extra-high')).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('Could not click the ChatGPT Very High model option'),
|
|
});
|
|
});
|
|
|
|
it('does not use order fallback when the intelligence picker does not expose exactly five options', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<form>
|
|
<button type="button" data-testid="model-switcher-dropdown-button">Mode rapide</button>
|
|
<div id="prompt-textarea" contenteditable="true"></div>
|
|
</form>
|
|
<div role="menu" data-testid="composer-intelligence-picker-content">
|
|
<div role="group">
|
|
<div role="menuitemradio">L0</div>
|
|
<div role="menuitemradio">L1</div>
|
|
<div role="menuitemradio">L2</div>
|
|
<div role="menuitemradio">L3</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
page.nativeClick = vi.fn().mockResolvedValue(undefined);
|
|
|
|
await expect(selectChatGPTModel(page, 'very-high')).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('Could not click the ChatGPT Very High model option'),
|
|
});
|
|
});
|
|
|
|
it('fails closed when the postcondition does not prove the requested model', async () => {
|
|
let objectCall = 0;
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
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: 'balanced', label: 'Balanced' });
|
|
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: 'balanced', label: 'Balanced' });
|
|
return Promise.resolve({});
|
|
}),
|
|
};
|
|
|
|
await expect(selectChatGPTModel(page, 'fast')).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('did not switch to Fast'),
|
|
});
|
|
});
|
|
});
|
|
|
|
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),
|
|
sleep: 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 ask response extraction boundary', () => {
|
|
function createResponseWaitPage(messageSets, { url = 'https://chatgpt.com/c/demo' } = {}) {
|
|
let messageIndex = 0;
|
|
return {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => {
|
|
if (script === 'window.location.href') return Promise.resolve(url);
|
|
if (script.includes('Stop generating') || script.includes('Thinking')) {
|
|
return Promise.resolve(false);
|
|
}
|
|
if (script.includes('data-message-author-role')) {
|
|
const messages = messageSets[Math.min(messageIndex, messageSets.length - 1)] ?? [];
|
|
messageIndex += 1;
|
|
return Promise.resolve(messages.map((message) => ({
|
|
role: message.Role,
|
|
text: message.Text,
|
|
html: message.Text,
|
|
})));
|
|
}
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
}
|
|
|
|
function mockAdvancingClock(stepMs = 1000) {
|
|
let now = 0;
|
|
vi.spyOn(Date, 'now').mockImplementation(() => {
|
|
now += stepMs;
|
|
return now;
|
|
});
|
|
}
|
|
|
|
it('does not return a stale baseline assistant pair for repeated prompts', async () => {
|
|
mockAdvancingClock();
|
|
const baselineMessages = [
|
|
{ Role: 'User', Text: 'repeat this prompt' },
|
|
{ Role: 'Assistant', Text: 'old answer' },
|
|
];
|
|
const page = createResponseWaitPage([
|
|
baselineMessages,
|
|
baselineMessages,
|
|
baselineMessages,
|
|
]);
|
|
|
|
await expect(waitForChatGPTResponse(page, baselineMessages.length, 'repeat this prompt', 4, {
|
|
baselinePairCounts: getChatGPTResponsePairCounts(baselineMessages, 'repeat this prompt'),
|
|
conversationUrl: 'https://chatgpt.com/c/demo',
|
|
})).rejects.toThrow(/chatgpt ask timed out/);
|
|
});
|
|
|
|
it('requires an exact normalized user prompt match instead of substring matching', async () => {
|
|
mockAdvancingClock();
|
|
const staleMessages = [
|
|
{ Role: 'User', Text: 'write a short story' },
|
|
{ Role: 'Assistant', Text: 'old answer' },
|
|
];
|
|
const page = createResponseWaitPage([
|
|
staleMessages,
|
|
staleMessages,
|
|
staleMessages,
|
|
]);
|
|
|
|
await expect(waitForChatGPTResponse(page, 0, 'short', 4, {
|
|
baselinePairKeys: new Set(),
|
|
conversationUrl: 'https://chatgpt.com/c/demo',
|
|
})).rejects.toThrow(/chatgpt ask timed out/);
|
|
});
|
|
|
|
it('does not collapse punctuation-distinct prompts into the requested prompt', async () => {
|
|
mockAdvancingClock();
|
|
const staleMessages = [
|
|
{ Role: 'User', Text: 'what now?' },
|
|
{ Role: 'Assistant', Text: 'old answer' },
|
|
];
|
|
const page = createResponseWaitPage([
|
|
staleMessages,
|
|
staleMessages,
|
|
staleMessages,
|
|
]);
|
|
|
|
await expect(waitForChatGPTResponse(page, 0, 'what now!', 4, {
|
|
baselinePairCounts: getChatGPTResponsePairCounts([], 'what now!'),
|
|
conversationUrl: 'https://chatgpt.com/c/demo',
|
|
})).rejects.toThrow(/chatgpt ask timed out/);
|
|
});
|
|
|
|
it('returns a stable assistant response for the new prompt pair', async () => {
|
|
mockAdvancingClock();
|
|
const baselineMessages = [
|
|
{ Role: 'User', Text: 'repeat this prompt' },
|
|
{ Role: 'Assistant', Text: 'old answer' },
|
|
];
|
|
const newMessages = [
|
|
...baselineMessages,
|
|
{ Role: 'User', Text: 'repeat this prompt' },
|
|
{ Role: 'Assistant', Text: 'new answer' },
|
|
];
|
|
const page = createResponseWaitPage([
|
|
newMessages,
|
|
newMessages,
|
|
newMessages,
|
|
]);
|
|
|
|
await expect(waitForChatGPTResponse(page, baselineMessages.length, 'repeat this prompt', 10, {
|
|
baselinePairCounts: getChatGPTResponsePairCounts(baselineMessages, 'repeat this prompt'),
|
|
conversationUrl: 'https://chatgpt.com/c/demo',
|
|
})).resolves.toBe('new answer');
|
|
});
|
|
|
|
it('accepts a repeated prompt when the new response text matches a visible baseline answer', async () => {
|
|
mockAdvancingClock();
|
|
const baselineMessages = [
|
|
{ Role: 'User', Text: 'repeat this prompt' },
|
|
{ Role: 'Assistant', Text: 'same answer' },
|
|
];
|
|
const newMessages = [
|
|
...baselineMessages,
|
|
{ Role: 'User', Text: 'repeat this prompt' },
|
|
{ Role: 'Assistant', Text: 'same answer' },
|
|
];
|
|
const page = createResponseWaitPage([
|
|
newMessages,
|
|
newMessages,
|
|
newMessages,
|
|
]);
|
|
|
|
await expect(waitForChatGPTResponse(page, baselineMessages.length, 'repeat this prompt', 10, {
|
|
baselinePairCounts: getChatGPTResponsePairCounts(baselineMessages, 'repeat this prompt'),
|
|
conversationUrl: 'https://chatgpt.com/c/demo',
|
|
})).resolves.toBe('same answer');
|
|
});
|
|
|
|
it('fails closed when the browser drifts to another conversation while waiting', async () => {
|
|
mockAdvancingClock();
|
|
const page = createResponseWaitPage([], { url: 'https://chatgpt.com/c/other' });
|
|
|
|
await expect(waitForChatGPTResponse(page, 0, 'hello', 4, {
|
|
conversationUrl: 'https://chatgpt.com/c/demo',
|
|
})).rejects.toThrow(/navigated away from the target conversation/);
|
|
});
|
|
|
|
it('polls with pure client-side sleeps instead of the DOM-stable numeric wait', async () => {
|
|
mockAdvancingClock();
|
|
const page = createResponseWaitPage([[], [], []]);
|
|
|
|
await expect(waitForChatGPTResponse(page, 0, 'unmatched prompt', 4, {}))
|
|
.rejects.toThrow(/chatgpt ask timed out/);
|
|
|
|
// The poll interval must not hit page.wait(number), whose DOM-stable
|
|
// path installs a whole-body MutationObserver and pins renderer CPU
|
|
// for the full 10-20 min a streamed answer takes to settle.
|
|
expect(page.sleep).toHaveBeenCalled();
|
|
expect(page.wait).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses textContent instead of layout-triggering innerText in text-only message polls', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<article data-testid="conversation-turn-2">
|
|
<div data-message-author-role="assistant">
|
|
<div class="markdown">done answer</div>
|
|
</div>
|
|
</article>
|
|
`);
|
|
for (const node of page.dom.window.document.querySelectorAll('*')) {
|
|
node.getBoundingClientRect = () => ({ width: 120, height: 36 });
|
|
}
|
|
Object.defineProperty(page.dom.window.HTMLElement.prototype, 'innerText', {
|
|
configurable: true,
|
|
get() {
|
|
throw new Error('innerText should not be read during text-only polls');
|
|
},
|
|
});
|
|
|
|
await expect(getVisibleMessages(page, { textOnly: true })).resolves.toEqual([
|
|
{
|
|
Index: 1,
|
|
Role: 'Assistant',
|
|
Text: 'done answer',
|
|
Html: '',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('detects a plain-text thinking pill inside a bare [data-message-author-role] turn', async () => {
|
|
// Regression: the scoped scan must cover both message shapes from
|
|
// CONVERSATION_MESSAGE_SELECTOR, not just article conversation turns —
|
|
// with no stop button and no aria-label, a plain-text pill in the
|
|
// role-attribute shape must still read as generating.
|
|
const page = createDomEvaluatePage(`
|
|
<div data-message-author-role="assistant">正在思考中…部分回答内容</div>
|
|
`);
|
|
|
|
await expect(isGenerating(page)).resolves.toBe(true);
|
|
});
|
|
|
|
it('detects a plain-text Thinking pill inside the last article turn', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<article data-testid="conversation-turn-2">
|
|
<div data-message-author-role="assistant">partial answer</div>
|
|
<div>Thinking</div>
|
|
</article>
|
|
`);
|
|
|
|
await expect(isGenerating(page)).resolves.toBe(true);
|
|
});
|
|
|
|
it('reports idle when no generation indicator is present in either shape', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<div data-message-author-role="assistant">done answer</div>
|
|
`);
|
|
|
|
await expect(isGenerating(page)).resolves.toBe(false);
|
|
});
|
|
|
|
it('stays idle when the composer shows Thinking as the selected model', async () => {
|
|
// Regression: 'Thinking' is a supported idle model label (see
|
|
// CHATGPT_MODEL_TARGETS.advanced), rendered as a composer-form button
|
|
// with or without an aria-label. It must not read as generating.
|
|
const page = createDomEvaluatePage(`
|
|
<article data-testid="conversation-turn-2">
|
|
<div data-message-author-role="assistant"><div class="markdown"><p>done answer</p></div></div>
|
|
</article>
|
|
<form>
|
|
<div id="prompt-textarea" contenteditable="true"></div>
|
|
<button aria-label="Thinking"><span data-testid="model-switcher-gpt-5-5-thinking">Thinking</span></button>
|
|
</form>
|
|
`);
|
|
|
|
await expect(isGenerating(page)).resolves.toBe(false);
|
|
});
|
|
|
|
it('ignores answers that merely mention Thinking in prose or code spans', async () => {
|
|
// Regression: a finished review of isGenerating itself contains the
|
|
// literal words "Thinking" / "正在思考" in prose and backticked code
|
|
// spans inside .markdown. That is message content, not a status pill —
|
|
// reading it as generating would block every follow-up send.
|
|
const page = createDomEvaluatePage(`
|
|
<article data-testid="conversation-turn-2">
|
|
<div data-message-author-role="assistant">
|
|
<div class="markdown">
|
|
<p>这个 PR 修改了检测逻辑,旧代码对全页文本匹配 Thinking 与正在思考,存在误报,建议改为在局部范围内检查停止生成按钮的状态。</p>
|
|
<p>位置见 <code>isGenerating</code>,匹配词是 <code>Thinking</code> 和 <code>正在思考</code>。</p>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`);
|
|
|
|
await expect(isGenerating(page)).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('chatgpt current model detection', () => {
|
|
it.each([
|
|
['Instant', { model: 'fast', label: 'Fast' }],
|
|
['Medium', { model: 'balanced', label: 'Balanced' }],
|
|
['Thinking', { model: 'advanced', label: 'Advanced' }],
|
|
['High', { model: 'advanced', label: 'Advanced' }],
|
|
['Extra High', { model: 'very-high', label: 'Very High' }],
|
|
['Pro', { model: 'pro', label: 'Pro' }],
|
|
['GPT-5.5 极速', { model: 'fast', label: 'Fast' }],
|
|
['GPT-5.5 均衡', { model: 'balanced', label: 'Balanced' }],
|
|
['智能水平 高级', { model: 'advanced', label: 'Advanced' }],
|
|
['GPT-5.5 超高', { model: 'very-high', label: 'Very High' }],
|
|
['GPT-5.5 专业', { model: 'pro', label: 'Pro' }],
|
|
['进阶专业', { model: 'pro', label: 'Pro' }],
|
|
])('detects the visible %s model label', async (label, expected) => {
|
|
const page = createDomEvaluatePage(`<form><button>${label}</button></form>`);
|
|
|
|
await expect(getCurrentChatGPTModel(page)).resolves.toEqual(expected);
|
|
});
|
|
|
|
it('uses model-specific test ids before visible text labels', async () => {
|
|
const page = createDomEvaluatePage(`
|
|
<form>
|
|
<button type="button">
|
|
<span data-testid="model-switcher-gpt-5-5-pro">Niveau inconnu</span>
|
|
</button>
|
|
</form>
|
|
`);
|
|
|
|
await expect(getCurrentChatGPTModel(page)).resolves.toEqual({ model: 'pro', label: 'Pro' });
|
|
});
|
|
|
|
it('returns null fields when the model selector is missing', async () => {
|
|
const page = createDomEvaluatePage('<form><button>Send</button></form>');
|
|
|
|
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(`<form><button>${label}</button></form>`);
|
|
|
|
await expect(getCurrentChatGPTTool(page)).resolves.toEqual(expected);
|
|
});
|
|
|
|
it('returns null fields when no supported tool is selected', async () => {
|
|
const page = createDomEvaluatePage('<form><button>添加文件</button></form>');
|
|
|
|
await expect(getCurrentChatGPTTool(page)).resolves.toEqual({
|
|
tool: null,
|
|
label: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('chatgpt send selectors', () => {
|
|
it('inlines the composer locator without returning before caller code runs', () => {
|
|
const dom = new JSDOM('<!doctype html><div id="prompt-textarea" contenteditable="true"></div>', {
|
|
url: 'https://chatgpt.com/',
|
|
runScripts: 'outside-only',
|
|
});
|
|
const composer = dom.window.document.querySelector('#prompt-textarea');
|
|
composer.getBoundingClientRect = () => ({ width: 320, height: 48 });
|
|
|
|
const result = dom.window.eval(`
|
|
(() => {
|
|
${__test__.buildComposerLocatorScript()}
|
|
const composer = findComposer();
|
|
return !!composer && composer.getAttribute(markerAttr) === '1';
|
|
})()
|
|
`);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
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({ ready: true, x: 12, y: 34 });
|
|
if (script.includes('sendBtnFound')) {
|
|
expect(script).toContain('data-testid=\\\"send-button\\\"');
|
|
return Promise.resolve({ sendBtnFound: true });
|
|
}
|
|
if (script.includes('if (sendBtn) sendBtn.click')) {
|
|
expect(script).toContain('data-testid=\\\"send-button\\\"');
|
|
}
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
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 () => {
|
|
const page = {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
nativeType: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => {
|
|
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 });
|
|
}
|
|
if (script.includes('if (sendBtn) sendBtn.click')) {
|
|
expect(script).toContain('#composer-submit-button:not([disabled])');
|
|
}
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true);
|
|
});
|
|
|
|
it('keeps zh-CN aria and placeholder fallbacks without replacing English selectors', () => {
|
|
expect(__test__.COMPOSER_SELECTORS).toEqual(expect.arrayContaining([
|
|
'[aria-label="Chat with ChatGPT"]',
|
|
'[aria-label="与 ChatGPT 聊天"]',
|
|
'[placeholder="Ask anything"]',
|
|
'[placeholder="有问题,尽管问"]',
|
|
'[data-testid="prompt-textarea"]',
|
|
]));
|
|
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__.CLOSE_SIDEBAR_LABELS).toEqual(expect.arrayContaining(['Close sidebar', '关闭边栏']));
|
|
});
|
|
});
|
|
|
|
describe('chatgpt generated image detection', () => {
|
|
function createDomPage(html, setup = () => { }) {
|
|
const dom = new JSDOM(html, {
|
|
url: 'https://chatgpt.com/c/demo',
|
|
runScripts: 'outside-only',
|
|
});
|
|
setup(dom.window);
|
|
return {
|
|
evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
|
|
};
|
|
}
|
|
|
|
it('detects visible CSS background images when ChatGPT does not render a plain img', async () => {
|
|
const page = createDomPage(`
|
|
<!doctype html>
|
|
<main>
|
|
<div class="avatar" style="background-image: url('https://chatgpt.com/avatar.png')"></div>
|
|
<button data-testid="generated-image" style="background-image: url('/backend-api/generated/foo.webp')"></button>
|
|
</main>
|
|
`, (window) => {
|
|
for (const el of window.document.querySelectorAll('div, button')) {
|
|
el.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
|
}
|
|
});
|
|
|
|
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
|
|
'https://chatgpt.com/backend-api/generated/foo.webp',
|
|
]);
|
|
});
|
|
|
|
it('detects visible generated canvases as data URLs when they contain pixels', async () => {
|
|
const page = createDomPage('<!doctype html><canvas width="512" height="512"></canvas>', (window) => {
|
|
const canvas = window.document.querySelector('canvas');
|
|
canvas.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
|
canvas.getContext = () => ({
|
|
getImageData: () => ({ data: new Uint8ClampedArray([255, 0, 0, 255]) }),
|
|
});
|
|
canvas.toDataURL = () => 'data:image/png;base64,ZmFrZQ==';
|
|
});
|
|
|
|
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
|
|
'data:image/png;base64,ZmFrZQ==',
|
|
]);
|
|
});
|
|
|
|
it('samples generated canvas content outside the top-left corner', async () => {
|
|
const page = createDomPage('<!doctype html><canvas width="512" height="512"></canvas>', (window) => {
|
|
const canvas = window.document.querySelector('canvas');
|
|
canvas.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
|
canvas.getContext = () => ({
|
|
getImageData: (x, y) => ({
|
|
data: x > 480 && y > 480
|
|
? new Uint8ClampedArray([255, 0, 0, 255])
|
|
: new Uint8ClampedArray([0, 0, 0, 0]),
|
|
}),
|
|
});
|
|
canvas.toDataURL = () => 'data:image/png;base64,lower-right';
|
|
});
|
|
|
|
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
|
|
'data:image/png;base64,lower-right',
|
|
]);
|
|
});
|
|
|
|
it('samples generated canvas content near the center', async () => {
|
|
const page = createDomPage('<!doctype html><canvas width="512" height="512"></canvas>', (window) => {
|
|
const canvas = window.document.querySelector('canvas');
|
|
canvas.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
|
canvas.getContext = () => ({
|
|
getImageData: (x, y) => {
|
|
const inCenter = x >= 240 && x <= 272 && y >= 240 && y <= 272;
|
|
return { data: new Uint8ClampedArray(inCenter ? [0, 80, 200, 255] : [255, 255, 255, 255]) };
|
|
},
|
|
});
|
|
canvas.toDataURL = () => 'data:image/png;base64,center';
|
|
});
|
|
|
|
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([
|
|
'data:image/png;base64,center',
|
|
]);
|
|
});
|
|
|
|
it('ignores transparent placeholder canvases', async () => {
|
|
const page = createDomPage('<!doctype html><canvas width="512" height="512"></canvas>', (window) => {
|
|
const canvas = window.document.querySelector('canvas');
|
|
canvas.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
|
canvas.getContext = () => ({
|
|
getImageData: () => ({ data: new Uint8ClampedArray([0, 0, 0, 0]) }),
|
|
});
|
|
canvas.toDataURL = () => 'data:image/png;base64,blank';
|
|
});
|
|
|
|
await expect(getChatGPTVisibleImageUrls(page)).resolves.toEqual([]);
|
|
});
|
|
|
|
it('ignores user-uploaded reference image previews', async () => {
|
|
const page = createDomPage(`
|
|
<!doctype html>
|
|
<section data-testid="conversation-turn-1">
|
|
<h4>You said:</h4>
|
|
<button aria-label="Open image: reference.png">
|
|
<img alt="reference.png" src="https://chatgpt.com/backend-api/uploaded/reference.png">
|
|
</button>
|
|
</section>
|
|
<section data-testid="conversation-turn-2">
|
|
<h4>ChatGPT said:</h4>
|
|
<img alt="generated image" src="https://chatgpt.com/backend-api/generated/foo.webp">
|
|
</section>
|
|
`, (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(`
|
|
<!doctype html>
|
|
<section data-testid="conversation-turn-2">
|
|
<h4>ChatGPT said:</h4>
|
|
<button aria-label="Open image: generated image">
|
|
<img alt="generated image" src="https://chatgpt.com/backend-api/generated/foo.webp">
|
|
</button>
|
|
</section>
|
|
`, (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([
|
|
'https://chatgpt.com/backend-api/generated/foo.webp',
|
|
]);
|
|
});
|
|
|
|
it('exports assets for generated CSS background images', async () => {
|
|
const imageUrl = 'https://chatgpt.com/backend-api/generated/foo.webp';
|
|
const page = createDomPage(`
|
|
<!doctype html>
|
|
<button style="background-image: url('/backend-api/generated/foo.webp')"></button>
|
|
`, (window) => {
|
|
const button = window.document.querySelector('button');
|
|
button.getBoundingClientRect = () => ({ width: 512, height: 512 });
|
|
window.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
blob: async () => new window.Blob(['fake-image'], { type: 'image/webp' }),
|
|
});
|
|
});
|
|
|
|
await expect(getChatGPTImageAssets(page, [imageUrl])).resolves.toEqual([
|
|
expect.objectContaining({
|
|
url: imageUrl,
|
|
mimeType: 'image/webp',
|
|
width: 512,
|
|
height: 512,
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('chatgpt image upload helper', () => {
|
|
it('validates local images without a browser page', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'cat.png');
|
|
fs.writeFileSync(filePath, 'fake-png');
|
|
|
|
await expect(prepareChatGPTImagePaths([filePath])).resolves.toEqual({ ok: true, paths: [filePath] });
|
|
await expect(prepareChatGPTImagePaths([path.join(dir, 'missing.png')])).resolves.toMatchObject({
|
|
ok: false,
|
|
reason: expect.stringContaining('Image not found'),
|
|
});
|
|
});
|
|
|
|
it('prefers Browser Bridge file input upload and waits for a preview', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'cat.png');
|
|
fs.writeFileSync(filePath, 'fake-png');
|
|
|
|
const page = {
|
|
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn().mockResolvedValue(true),
|
|
};
|
|
|
|
const result = await uploadChatGPTImages(page, [filePath]);
|
|
|
|
expect(result).toEqual({ ok: true, files: [filePath] });
|
|
expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
|
|
});
|
|
|
|
it('rejects missing files before touching the page', async () => {
|
|
const page = {
|
|
setFileInput: vi.fn(),
|
|
wait: vi.fn(),
|
|
evaluate: vi.fn(),
|
|
};
|
|
|
|
const result = await uploadChatGPTImages(page, ['/no/such/cat.png']);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.reason).toContain('Image not found');
|
|
expect(page.setFileInput).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects non-image extensions', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'report.pdf');
|
|
fs.writeFileSync(filePath, 'fake');
|
|
|
|
const page = {
|
|
setFileInput: vi.fn(),
|
|
wait: vi.fn(),
|
|
evaluate: vi.fn(),
|
|
};
|
|
|
|
const result = await uploadChatGPTImages(page, [filePath]);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.reason).toContain('Unsupported image type');
|
|
expect(page.setFileInput).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes a React-compatible change event in fallback upload', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'cat.png');
|
|
fs.writeFileSync(filePath, 'fake-png');
|
|
|
|
const page = {
|
|
setFileInput: vi.fn().mockRejectedValue(new Error('No element found')),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => {
|
|
if (String(script).includes('new DataTransfer()')) {
|
|
return Promise.resolve({ ok: true });
|
|
}
|
|
return Promise.resolve(true);
|
|
}),
|
|
};
|
|
|
|
const result = await uploadChatGPTImages(page, [filePath]);
|
|
|
|
expect(result).toEqual({ ok: true, files: [filePath] });
|
|
const fallbackScript = page.evaluate.mock.calls
|
|
.map(([script]) => String(script))
|
|
.find(script => script.includes('new DataTransfer()'));
|
|
expect(fallbackScript).toContain('preventDefault()');
|
|
expect(fallbackScript).toContain('stopPropagation()');
|
|
});
|
|
|
|
it('does not treat generic upload controls as uploaded image previews', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'cat.png');
|
|
fs.writeFileSync(filePath, 'fake-png');
|
|
|
|
const dom = new JSDOM(`
|
|
<!doctype html>
|
|
<main>
|
|
<div aria-label="Chat with ChatGPT">
|
|
<button class="upload-button" data-testid="upload-button">Attach</button>
|
|
</div>
|
|
</main>
|
|
`, { url: 'https://chatgpt.com/new', runScripts: 'outside-only' });
|
|
const page = {
|
|
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
|
|
};
|
|
|
|
const result = await uploadChatGPTImages(page, [filePath]);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.reason).toContain('image upload preview did not appear');
|
|
});
|
|
|
|
it('accepts a real uploaded media preview even when the filename text is absent', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'cat.png');
|
|
fs.writeFileSync(filePath, 'fake-png');
|
|
|
|
const dom = new JSDOM(`
|
|
<!doctype html>
|
|
<main>
|
|
<div aria-label="Chat with ChatGPT">
|
|
<img src="blob:https://chatgpt.com/upload-preview">
|
|
</div>
|
|
</main>
|
|
`, { url: 'https://chatgpt.com/new', runScripts: 'outside-only' });
|
|
const img = dom.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 });
|
|
const page = {
|
|
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
|
|
};
|
|
|
|
await expect(uploadChatGPTImages(page, [filePath])).resolves.toEqual({ ok: true, files: [filePath] });
|
|
});
|
|
|
|
it('exposes image MIME inference for fallback upload', () => {
|
|
expect(__test__.imageMimeFromPath('/tmp/a.png')).toBe('image/png');
|
|
expect(__test__.imageMimeFromPath('/tmp/a.webp')).toBe('image/webp');
|
|
expect(__test__.imageMimeFromPath('/tmp/a.jpg')).toBe('image/jpeg');
|
|
});
|
|
});
|
|
|
|
describe('chatgpt project id parsing', () => {
|
|
it('accepts project hex ids and /g/g-p- URLs', () => {
|
|
expect(__test__.parseChatGPTProjectId('12345678abcdef90')).toBe('12345678abcdef90');
|
|
expect(__test__.parseChatGPTProjectId('https://chatgpt.com/g/g-p-12345678abcdef90')).toBe('12345678abcdef90');
|
|
expect(__test__.parseChatGPTProjectId('/g/g-p-abcdef0123456789')).toBe('abcdef0123456789');
|
|
});
|
|
|
|
it('accepts g-p-{hex_id}-{slug} pattern', () => {
|
|
expect(__test__.parseChatGPTProjectId('g-p-12345678-my-project')).toBe('12345678');
|
|
});
|
|
|
|
it('rejects invalid project ids', () => {
|
|
expect(() => __test__.parseChatGPTProjectId('')).toThrow(/project/);
|
|
expect(() => __test__.parseChatGPTProjectId('https://chatgpt.com/')).toThrow(/project/);
|
|
expect(() => __test__.parseChatGPTProjectId('https://evil.test/g/g-p-12345678')).toThrow(/project/);
|
|
expect(() => __test__.parseChatGPTProjectId('http://chatgpt.com/g/g-p-12345678')).toThrow(/project/);
|
|
expect(() => __test__.parseChatGPTProjectId('g-p-a-short')).toThrow(/project/);
|
|
expect(() => __test__.parseChatGPTProjectId('https://chatgpt.com/g/g-p-a-short')).toThrow(/project/);
|
|
});
|
|
});
|
|
|
|
describe('chatgpt project navigation', () => {
|
|
it('verifies the current URL stays bound to the requested project', async () => {
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn().mockResolvedValue({
|
|
url: 'https://chatgpt.com/g/g-p-deadbeef',
|
|
title: 'Other Project',
|
|
hasComposer: true,
|
|
isLoggedIn: true,
|
|
hasLoginGate: false,
|
|
}),
|
|
};
|
|
|
|
await expect(navigateToProject(page, '12345678')).rejects.toBeInstanceOf(CommandExecutionError);
|
|
});
|
|
|
|
it('maps project login redirects to AuthRequiredError', async () => {
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn().mockResolvedValue({
|
|
url: 'https://chatgpt.com/auth/login',
|
|
title: 'Log in',
|
|
hasComposer: false,
|
|
isLoggedIn: false,
|
|
hasLoginGate: true,
|
|
}),
|
|
};
|
|
|
|
await expect(navigateToProject(page, '12345678')).rejects.toBeInstanceOf(AuthRequiredError);
|
|
});
|
|
});
|
|
|
|
describe('chatgpt file path validation', () => {
|
|
it('validates local files for project upload (any type)', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const pdfPath = path.join(dir, 'report.pdf');
|
|
fs.writeFileSync(pdfPath, 'fake-pdf');
|
|
const docxPath = path.join(dir, 'notes.docx');
|
|
fs.writeFileSync(docxPath, 'fake-docx');
|
|
|
|
const { prepareChatGPTFilePaths } = await import('./utils.js');
|
|
await expect(prepareChatGPTFilePaths([pdfPath])).resolves.toEqual({ ok: true, paths: [pdfPath] });
|
|
await expect(prepareChatGPTFilePaths([pdfPath, docxPath])).resolves.toEqual({ ok: true, paths: [pdfPath, docxPath] });
|
|
await expect(prepareChatGPTFilePaths([path.join(dir, 'missing.txt')])).resolves.toMatchObject({
|
|
ok: false,
|
|
reason: expect.stringContaining('File not found'),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('chatgpt project file upload helper', () => {
|
|
it('exposes mimeFromFilePath for fallback upload', () => {
|
|
expect(__test__.mimeFromFilePath('/tmp/report.pdf')).toBe('application/pdf');
|
|
expect(__test__.mimeFromFilePath('/tmp/notes.docx')).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
expect(__test__.mimeFromFilePath('/tmp/data.csv')).toBe('text/csv');
|
|
expect(__test__.mimeFromFilePath('/tmp/code.py')).toBe('text/x-python');
|
|
expect(__test__.mimeFromFilePath('/tmp/image.png')).toBe('image/png');
|
|
expect(__test__.mimeFromFilePath('/tmp/unknown.xyz')).toBe('application/octet-stream');
|
|
});
|
|
|
|
it('exposes PROJECT_LINK_SELECTOR for project link extraction', () => {
|
|
expect(__test__.PROJECT_LINK_SELECTOR).toBe('a[href*="/g/g-p-"]');
|
|
});
|
|
|
|
it('extracts visible project anchors from the sidebar without React Fiber internals', async () => {
|
|
const dom = new JSDOM(`
|
|
<!doctype html>
|
|
<a data-sidebar-item="true" href="/g/g-p-12345678-alpha">
|
|
<span data-testid="project-folder-icon"></span>
|
|
Project Alpha
|
|
</a>
|
|
<a data-sidebar-item="true" href="https://chatgpt.com/g/g-p-12345678-alpha?model=gpt-5">
|
|
<span data-testid="project-folder-icon"></span>
|
|
Duplicate Alpha
|
|
</a>
|
|
<a data-sidebar-item="true" href="/g/g-p-abcdef90">
|
|
<span data-testid="project-folder-icon"></span>
|
|
Project Beta
|
|
</a>
|
|
<a data-sidebar-item="true" href="https://evil.test/g/g-p-badbadbad">
|
|
<span data-testid="project-folder-icon"></span>
|
|
Evil Project
|
|
</a>
|
|
<a data-sidebar-item="true" href="/g/g-p-a-short">
|
|
<span data-testid="project-folder-icon"></span>
|
|
Short Bait
|
|
</a>
|
|
`, {
|
|
url: 'https://chatgpt.com/',
|
|
runScripts: 'outside-only',
|
|
});
|
|
for (const el of dom.window.document.querySelectorAll('[data-sidebar-item="true"]')) {
|
|
el.getBoundingClientRect = () => ({ width: 240, height: 32 });
|
|
}
|
|
|
|
const page = {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
|
|
};
|
|
|
|
const { getProjectList } = await import('./utils.js');
|
|
await expect(getProjectList(page)).resolves.toEqual([
|
|
{
|
|
Index: 1,
|
|
Id: '12345678',
|
|
Title: 'Project Alpha',
|
|
Url: 'https://chatgpt.com/g/g-p-12345678-alpha',
|
|
},
|
|
{
|
|
Index: 2,
|
|
Id: 'abcdef90',
|
|
Title: 'Project Beta',
|
|
Url: 'https://chatgpt.com/g/g-p-abcdef90',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('opens project knowledge dialog by finding an "Add files" button', async () => {
|
|
const page = {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
setFileInput: vi.fn(),
|
|
evaluate: vi.fn((script) => {
|
|
if (String(script).includes('Add files')) {
|
|
return Promise.resolve(true);
|
|
}
|
|
if (String(script).includes('role="dialog"')) {
|
|
return Promise.resolve(true);
|
|
}
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
const { openProjectKnowledgeDialog } = await import('./utils.js');
|
|
const result = await openProjectKnowledgeDialog(page);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('reports failure when no Add files button is found', async () => {
|
|
const page = {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
setFileInput: vi.fn(),
|
|
evaluate: vi.fn().mockResolvedValue(false),
|
|
};
|
|
|
|
const { openProjectKnowledgeDialog } = await import('./utils.js');
|
|
const result = await openProjectKnowledgeDialog(page);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('opens the live project Sources tab upload surface when no Add files dialog exists', async () => {
|
|
const dom = new JSDOM(`
|
|
<!doctype html>
|
|
<button role="tab" aria-selected="true">Chats</button>
|
|
<button role="tab" aria-selected="false" id="project-home-tabs-demo-sources">Sources</button>
|
|
<div role="tabpanel" data-state="inactive"></div>
|
|
`, {
|
|
url: 'https://chatgpt.com/g/g-p-12345678-demo/project',
|
|
runScripts: 'outside-only',
|
|
});
|
|
const sourcesTab = dom.window.document.querySelector('#project-home-tabs-demo-sources');
|
|
sourcesTab.getBoundingClientRect = () => ({ width: 96, height: 32 });
|
|
sourcesTab.addEventListener('click', () => {
|
|
sourcesTab.setAttribute('aria-selected', 'true');
|
|
sourcesTab.dataset.clicked = 'true';
|
|
});
|
|
|
|
const page = {
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(String(script)))),
|
|
};
|
|
|
|
const { openProjectKnowledgeDialog } = await import('./utils.js');
|
|
await expect(openProjectKnowledgeDialog(page)).resolves.toBe(true);
|
|
expect(sourcesTab.dataset.clicked).toBe('true');
|
|
});
|
|
|
|
it('projects file upload uses dialog file input selectors and waits for filename confirmation', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'report.pdf');
|
|
fs.writeFileSync(filePath, 'fake-pdf');
|
|
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
nativeType: vi.fn(),
|
|
setFileInput: vi.fn().mockRejectedValue(new Error('No element found')),
|
|
evaluate: vi.fn((script) => {
|
|
const s = String(script);
|
|
// getPageState returns a specific object
|
|
if (s.includes('isVisible') && s.includes('hasComposer') && s.includes('isLoggedIn')) {
|
|
return Promise.resolve({ session: 'test', data: { url: 'https://chatgpt.com/g/g-p-12345678', title: 'Project', hasComposer: true, isLoggedIn: true, hasLoginGate: false } });
|
|
}
|
|
if (s.includes('expectedFileNames')) {
|
|
return Promise.resolve({ ok: true });
|
|
}
|
|
if (s.includes('new DataTransfer()')) {
|
|
return Promise.resolve({ ok: true });
|
|
}
|
|
if (s.includes('Add files')) return Promise.resolve(true);
|
|
if (s.includes('role="dialog"')) return Promise.resolve(true);
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
const { uploadChatGPTProjectFiles } = await import('./utils.js');
|
|
const result = await uploadChatGPTProjectFiles(page, '12345678', [filePath]);
|
|
|
|
expect(result).toEqual({ ok: true, files: [filePath] });
|
|
expect(page.goto).toHaveBeenCalledWith(
|
|
expect.stringContaining('/g/g-p-12345678'),
|
|
expect.any(Object),
|
|
);
|
|
expect(page.evaluate.mock.calls.some(([script]) => String(script).includes('expectedFileNames'))).toBe(true);
|
|
});
|
|
|
|
it('returns failure when project upload confirmation does not appear', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'missing-confirmation.pdf');
|
|
fs.writeFileSync(filePath, 'fake-pdf');
|
|
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => {
|
|
const s = String(script);
|
|
if (s.includes('isVisible') && s.includes('hasComposer') && s.includes('isLoggedIn')) {
|
|
return Promise.resolve({ session: 'test', data: { url: 'https://chatgpt.com/g/g-p-12345678', title: 'Project', hasComposer: true, isLoggedIn: true, hasLoginGate: false } });
|
|
}
|
|
if (s.includes('expectedFileNames')) return Promise.resolve({ ok: false, reason: 'uploaded file did not appear in project knowledge' });
|
|
if (s.includes('Add files')) return Promise.resolve(true);
|
|
if (s.includes('role="dialog"')) return Promise.resolve(true);
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
const { uploadChatGPTProjectFiles } = await import('./utils.js');
|
|
const result = await uploadChatGPTProjectFiles(page, '12345678', [filePath]);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
reason: expect.stringContaining('uploaded file did not appear'),
|
|
});
|
|
});
|
|
|
|
it('does not treat composer/body filename text as project knowledge confirmation', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
|
|
tempDirs.push(dir);
|
|
const filePath = path.join(dir, 'composer-only.pdf');
|
|
fs.writeFileSync(filePath, 'fake-pdf');
|
|
|
|
const dom = new JSDOM(`
|
|
<!doctype html>
|
|
<main>
|
|
<form data-type="unified-composer">
|
|
<input id="upload-files" type="file">
|
|
<span>composer-only.pdf</span>
|
|
</form>
|
|
</main>
|
|
`, {
|
|
url: 'https://chatgpt.com/g/g-p-12345678',
|
|
runScripts: 'outside-only',
|
|
});
|
|
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
setFileInput: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn((script) => {
|
|
const s = String(script);
|
|
if (s.includes('isVisible') && s.includes('hasComposer') && s.includes('isLoggedIn')) {
|
|
return Promise.resolve({ session: 'test', data: { url: 'https://chatgpt.com/g/g-p-12345678', title: 'Project', hasComposer: true, isLoggedIn: true, hasLoginGate: false } });
|
|
}
|
|
if (s.includes('expectedFileNames')) {
|
|
return Promise.resolve(dom.window.eval(s));
|
|
}
|
|
if (s.includes('Add files')) return Promise.resolve(true);
|
|
if (s.includes('role="dialog"')) return Promise.resolve(true);
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
const { uploadChatGPTProjectFiles } = await import('./utils.js');
|
|
const result = await uploadChatGPTProjectFiles(page, '12345678', [filePath]);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
reason: expect.stringContaining('project knowledge surface'),
|
|
});
|
|
});
|
|
});
|