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
443 lines
20 KiB
JavaScript
443 lines
20 KiB
JavaScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
import './ask.js';
|
|
import './send.js';
|
|
import './read.js';
|
|
import './history.js';
|
|
import './detail.js';
|
|
import './deep-research-result.js';
|
|
import './new.js';
|
|
import './status.js';
|
|
import './image.js';
|
|
import './model.js';
|
|
import './project-list.js';
|
|
import './project-file-add.js';
|
|
|
|
const tempDirs = [];
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
while (tempDirs.length) {
|
|
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function createProjectUploadPageMock() {
|
|
return {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: 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: true });
|
|
if (s.includes('Add files')) return Promise.resolve(true);
|
|
if (s.includes('role="dialog"')) return Promise.resolve(true);
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe('chatgpt browser command registration', () => {
|
|
it('registers the baseline web chat commands with persistent site sessions', () => {
|
|
const expectedAccess = {
|
|
ask: 'write',
|
|
send: 'write',
|
|
read: 'read',
|
|
history: 'read',
|
|
detail: 'read',
|
|
'deep-research-result': 'read',
|
|
new: 'read',
|
|
status: 'read',
|
|
image: 'write',
|
|
model: 'write',
|
|
'project-list': 'read',
|
|
'project-file-add': 'write',
|
|
};
|
|
|
|
for (const [name, access] of Object.entries(expectedAccess)) {
|
|
const cmd = getRegistry().get(`chatgpt/${name}`);
|
|
expect(cmd, `chatgpt/${name}`).toBeDefined();
|
|
expect(cmd.site).toBe('chatgpt');
|
|
expect(cmd.domain).toBe('chatgpt.com');
|
|
expect(cmd.strategy).toBe('cookie');
|
|
expect(cmd.browser).toBe(true);
|
|
expect(cmd.siteSession).toBe('persistent');
|
|
expect(cmd.navigateBefore).toBe(false);
|
|
expect(cmd.access).toBe(access);
|
|
}
|
|
});
|
|
|
|
it('keeps ask timeout as the runtime-visible integer timeout arg', () => {
|
|
const ask = getRegistry().get('chatgpt/ask');
|
|
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: 'project', 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 and project routing options', () => {
|
|
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 }),
|
|
expect.objectContaining({ name: 'project', valueRequired: true }),
|
|
]));
|
|
});
|
|
|
|
it('rejects using project and conversation routing together', 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', project: '12345678', conversation: 'abcdefghi' }))
|
|
.rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
await expect(send.func(page, { prompt: 'hello', project: '12345678', conversation: 'abcdefghi' }))
|
|
.rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
});
|
|
|
|
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 deep research result command with wait options', () => {
|
|
const command = getRegistry().get('chatgpt/deep-research-result');
|
|
expect(command.args).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ name: 'id', positional: true, required: true }),
|
|
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(command.columns).toEqual([
|
|
'conversationId',
|
|
'status',
|
|
'report',
|
|
'sources',
|
|
'progress',
|
|
'asyncTaskConversationId',
|
|
'widgetSessionId',
|
|
'asyncStatus',
|
|
'venusMessageType',
|
|
'venusStatus',
|
|
'waitingForUserUntil',
|
|
'planTitle',
|
|
'planId',
|
|
'url',
|
|
'method',
|
|
'diagnostics',
|
|
]);
|
|
});
|
|
|
|
it('does not return a success row when no completed deep research report exists', async () => {
|
|
const command = getRegistry().get('chatgpt/deep-research-result');
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
|
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
evaluate: vi.fn((script) => {
|
|
const s = String(script);
|
|
if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/');
|
|
if (s.includes("fetch('/backend-api/conversation/requested123'")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
text: JSON.stringify({ mapping: {} }),
|
|
});
|
|
}
|
|
if (s.includes("document.querySelectorAll('iframe')")) {
|
|
return Promise.resolve({
|
|
url: 'https://chatgpt.com/c/requested123',
|
|
title: 'ChatGPT',
|
|
iframes: [],
|
|
deepResearchIframe: null,
|
|
});
|
|
}
|
|
if (s.includes('composerSelectors') && s.includes('hasComposer')) {
|
|
return Promise.resolve({
|
|
url: 'https://chatgpt.com/c/requested123',
|
|
title: 'ChatGPT',
|
|
hasComposer: true,
|
|
isLoggedIn: true,
|
|
hasLoginGate: false,
|
|
});
|
|
}
|
|
if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false);
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
await expect(command.func(page, { id: 'requested123' }))
|
|
.rejects.toBeInstanceOf(EmptyResultError);
|
|
});
|
|
|
|
it('returns structured deep research progress rows without a completed report', async () => {
|
|
const command = getRegistry().get('chatgpt/deep-research-result');
|
|
const payload = {
|
|
conversation_id: 'requested123',
|
|
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: 'Research plan',
|
|
},
|
|
}),
|
|
response_metadata: {
|
|
async_task_conversation_id: 'async-conversation-123',
|
|
'openai/widgetSessionId': 'widget-session-123',
|
|
'openai/asyncStatus': 7,
|
|
venus_message_type: 'initial_loading_message',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
|
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
evaluate: vi.fn((script) => {
|
|
const s = String(script);
|
|
if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/');
|
|
if (s.includes("fetch('/backend-api/conversation/requested123'")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
text: JSON.stringify(payload),
|
|
});
|
|
}
|
|
if (s.includes("document.querySelectorAll('iframe')")) {
|
|
return Promise.resolve({
|
|
url: 'https://chatgpt.com/c/requested123',
|
|
title: 'ChatGPT',
|
|
iframes: [],
|
|
deepResearchIframe: null,
|
|
});
|
|
}
|
|
if (s.includes('composerSelectors') && s.includes('hasComposer')) {
|
|
return Promise.resolve({
|
|
url: 'https://chatgpt.com/c/requested123',
|
|
title: 'ChatGPT',
|
|
hasComposer: true,
|
|
isLoggedIn: true,
|
|
hasLoginGate: false,
|
|
});
|
|
}
|
|
if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false);
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
await expect(command.func(page, { id: 'requested123' })).resolves.toEqual([
|
|
expect.objectContaining({
|
|
conversationId: 'requested123',
|
|
status: 'waiting_for_user',
|
|
report: '',
|
|
sources: [],
|
|
asyncTaskConversationId: 'async-conversation-123',
|
|
widgetSessionId: 'widget-session-123',
|
|
asyncStatus: 7,
|
|
venusMessageType: 'initial_loading_message',
|
|
venusStatus: 'waiting_for_user_response_on_plan',
|
|
waitingForUserUntil: '2026-07-02T02:29:48.298274Z',
|
|
planTitle: 'Research plan',
|
|
planId: 'plan-demo',
|
|
method: 'conversation-widget-progress',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('typed-fails malformed deep research source rows instead of falling back to empty success', async () => {
|
|
const command = getRegistry().get('chatgpt/deep-research-result');
|
|
const report = `# Executive Summary\n\n${'Completed Deep Research report paragraph with enough detail to pass extraction heuristics. '.repeat(12)}\n\n## Sources`;
|
|
const payload = {
|
|
conversation_id: 'requested123',
|
|
mapping: {
|
|
report_node: {
|
|
message: {
|
|
metadata: {
|
|
chatgpt_sdk: {
|
|
widget_state: JSON.stringify({
|
|
status: 'completed',
|
|
report_message: {
|
|
id: 'report-msg',
|
|
content: { parts: [report] },
|
|
metadata: {
|
|
search_result_groups: [
|
|
{ entries: [{ title: 'Source without URL' }] },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const page = {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
|
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
evaluate: vi.fn((script) => {
|
|
const s = String(script);
|
|
if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/');
|
|
if (s.includes("fetch('/backend-api/conversation/requested123'")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
text: JSON.stringify(payload),
|
|
});
|
|
}
|
|
if (s.includes("document.querySelectorAll('iframe')")) {
|
|
return Promise.resolve({
|
|
url: 'https://chatgpt.com/c/requested123',
|
|
title: 'ChatGPT',
|
|
iframes: [],
|
|
deepResearchIframe: null,
|
|
});
|
|
}
|
|
if (s.includes('composerSelectors') && s.includes('hasComposer')) {
|
|
return Promise.resolve({
|
|
url: 'https://chatgpt.com/c/requested123',
|
|
title: 'ChatGPT',
|
|
hasComposer: true,
|
|
isLoggedIn: true,
|
|
hasLoginGate: false,
|
|
});
|
|
}
|
|
if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false);
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
};
|
|
|
|
await expect(command.func(page, { id: 'requested123' }))
|
|
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
});
|
|
|
|
it('registers project routing on chat-starting commands', () => {
|
|
for (const name of ['new', 'image', 'model']) {
|
|
const cmd = getRegistry().get(`chatgpt/${name}`);
|
|
expect(cmd.args).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ name: 'project', valueRequired: true }),
|
|
]));
|
|
}
|
|
});
|
|
|
|
it('starts a new chat inside a project when new receives project routing', async () => {
|
|
const cmd = getRegistry().get('chatgpt/new');
|
|
const page = createProjectUploadPageMock();
|
|
|
|
await expect(cmd.func(page, { project: '12345678' }))
|
|
.resolves.toEqual([{ Status: 'New chat started' }]);
|
|
expect(page.goto).toHaveBeenCalledWith('https://chatgpt.com/g/g-p-12345678', { settleMs: 2000 });
|
|
});
|
|
|
|
it('registers chatgpt model with web model choices', () => {
|
|
const model = getRegistry().get('chatgpt/model');
|
|
expect(model.args).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: 'model',
|
|
positional: true,
|
|
required: true,
|
|
choices: expect.arrayContaining(['fast', 'speed', 'instant', 'balanced', 'balance', 'advanced', 'high', 'thinking', 'very-high', 'ultra', 'xhigh', 'x-high', 'pro', 'professional']),
|
|
}),
|
|
expect.objectContaining({ name: 'project', valueRequired: true }),
|
|
]));
|
|
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' });
|
|
});
|
|
|
|
it('does not expose command-level system proxy mutation for project-file-add', () => {
|
|
const cmd = getRegistry().get('chatgpt/project-file-add');
|
|
expect(cmd.args.map(arg => arg.name)).toEqual(['file', 'id']);
|
|
});
|
|
|
|
it('rejects empty project-file-add file input', async () => {
|
|
const cmd = getRegistry().get('chatgpt/project-file-add');
|
|
await expect(cmd.func(createProjectUploadPageMock(), { file: ' , ', id: '12345678' }))
|
|
.rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
});
|
|
|
|
it('maps successful project-file-add uploads to table rows', 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 cmd = getRegistry().get('chatgpt/project-file-add');
|
|
await expect(cmd.func(createProjectUploadPageMock(), { file: filePath, id: '12345678' }))
|
|
.resolves.toEqual([
|
|
{
|
|
Status: '📄 uploaded to project knowledge',
|
|
File: filePath,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('maps project-file-add local file validation failures to argument errors', async () => {
|
|
const cmd = getRegistry().get('chatgpt/project-file-add');
|
|
await expect(cmd.func(createProjectUploadPageMock(), { file: '/no/such/report.pdf', id: '12345678' }))
|
|
.rejects.toMatchObject({
|
|
code: 'ARGUMENT',
|
|
message: expect.stringContaining('File not found'),
|
|
});
|
|
});
|
|
});
|