mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
19e64da565
* feat(chatgpt): add project commands Add ChatGPT project management adapters for listing visible projects and uploading local files into project knowledge. The new project-list command extracts project links from stable sidebar anchors first, with a React Fiber fallback for sidebar builds that do not expose hrefs. The project-file-add command uploads through the project knowledge flow, validates local files before browser interaction, and waits for filename confirmation before reporting success. For the current ChatGPT project UI, project knowledge uploads live behind the Sources tab rather than the older Add files dialog. The upload helper now prefers that Sources surface, avoids mistaking the chat composer plus button for project knowledge upload, and dispatches a browser-like pointer/mouse sequence so Radix-powered tabs activate reliably in live sessions before setting the source file input. This intentionally avoids command-level system proxy mutation. Users who need a proxy should configure the browser or network environment outside this adapter, rather than letting a single command toggle OS proxy settings. Also update the generated CLI manifest and focused adapter tests for command registration, argument contracts, project id parsing, project link extraction, upload confirmation, live Sources-tab upload behavior, and failure wrapping. Validation: - pnpm exec tsx src/main.ts chatgpt project-list -f json --trace retain-on-failure --window foreground --keep-tab true (live authenticated local session; returned visible projects) - pnpm exec tsx src/main.ts chatgpt project-file-add /tmp/opencli-chatgpt-project-upload-validation-pointer-20260621215147.txt --id 6a1791df8fa88191afb5a016ce1f497e -f json --trace retain-on-failure --window foreground --keep-tab true (live authenticated local session; uploaded one text file to project knowledge) - pnpm exec vitest run --project adapter clis/chatgpt/commands.test.js clis/chatgpt/envelope.test.js clis/chatgpt/image.test.js clis/chatgpt/model.test.js clis/chatgpt/utils.test.js - pnpm exec tsc --noEmit - pnpm run build-manifest * feat(chatgpt): support project chat routing Add --project to chatgpt ask/send so messages can start a new chat inside a specified ChatGPT project. Reject --project with --conversation before navigation, and parse project-scoped /g/g-p-.../c/<id> conversation URLs so ask can report the created conversation id. Validation: - pnpm exec vitest run --project adapter clis/chatgpt/commands.test.js clis/chatgpt/envelope.test.js clis/chatgpt/image.test.js clis/chatgpt/model.test.js clis/chatgpt/utils.test.js - pnpm exec tsc --noEmit - pnpm run build-manifest * feat(chatgpt): extend project routing Add --project routing to chatgpt new, image, and model so project-scoped work is available beyond ask/send. New and image open the specified project before preparing the composer; model opens the project before switching the intelligence level. Validation: - pnpm exec vitest run --project adapter clis/chatgpt/commands.test.js clis/chatgpt/envelope.test.js clis/chatgpt/image.test.js clis/chatgpt/model.test.js clis/chatgpt/utils.test.js - pnpm exec tsc --noEmit - pnpm run build-manifest - pnpm exec tsx src/main.ts chatgpt new --help / image --help / model --help * fix(chatgpt): harden project command boundaries * fix(chatgpt): require stable project ids --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
208 lines
8.2 KiB
JavaScript
208 lines
8.2 KiB
JavaScript
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
getChatGPTVisibleImageUrls: vi.fn(),
|
|
clearChatGPTDraft: vi.fn(),
|
|
prepareChatGPTImagePaths: vi.fn(),
|
|
sendChatGPTMessage: vi.fn(),
|
|
uploadChatGPTImages: vi.fn(),
|
|
navigateToProject: vi.fn(),
|
|
waitForChatGPTImages: vi.fn(),
|
|
getChatGPTImageAssets: vi.fn(),
|
|
saveBase64ToFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./utils.js', () => ({
|
|
clearChatGPTDraft: mocks.clearChatGPTDraft,
|
|
getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
|
|
navigateToProject: mocks.navigateToProject,
|
|
normalizeBooleanFlag: (value, fallback = false) => {
|
|
if (typeof value === 'boolean') return value;
|
|
if (value == null || value === '') return fallback;
|
|
const normalized = String(value).trim().toLowerCase();
|
|
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
|
},
|
|
prepareChatGPTImagePaths: mocks.prepareChatGPTImagePaths,
|
|
sendChatGPTMessage: mocks.sendChatGPTMessage,
|
|
unwrapEvaluateResult: (payload) => {
|
|
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
|
|
return payload.data;
|
|
}
|
|
return payload;
|
|
},
|
|
uploadChatGPTImages: mocks.uploadChatGPTImages,
|
|
waitForChatGPTImages: mocks.waitForChatGPTImages,
|
|
getChatGPTImageAssets: mocks.getChatGPTImageAssets,
|
|
}));
|
|
|
|
vi.mock('@jackwener/opencli/utils', () => ({
|
|
saveBase64ToFile: mocks.saveBase64ToFile,
|
|
}));
|
|
|
|
const { imageCommand, nextAvailablePath, parseImagePaths, resolveOutputDir } = await import('./image.js');
|
|
|
|
function createPage() {
|
|
return {
|
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
evaluate: vi.fn().mockResolvedValue('https://chatgpt.com/c/test-conversation'),
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
mocks.clearChatGPTDraft.mockReset().mockResolvedValue(undefined);
|
|
mocks.prepareChatGPTImagePaths.mockReset().mockImplementation(async (paths) => ({ ok: true, paths }));
|
|
mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
|
|
mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
|
|
mocks.uploadChatGPTImages.mockReset().mockResolvedValue({ ok: true });
|
|
mocks.navigateToProject.mockReset().mockResolvedValue(undefined);
|
|
mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
|
|
mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
|
|
url: 'https://images.example/generated.png',
|
|
dataUrl: 'data:image/png;base64,aGVsbG8=',
|
|
mimeType: 'image/png',
|
|
}]);
|
|
mocks.saveBase64ToFile.mockReset().mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe('chatgpt image output paths', () => {
|
|
it('expands the default and explicit home-relative output directories', () => {
|
|
expect(resolveOutputDir()).toBe(path.join(os.homedir(), 'Pictures', 'chatgpt'));
|
|
expect(resolveOutputDir('~/tmp/chatgpt-images')).toBe(path.join(os.homedir(), 'tmp', 'chatgpt-images'));
|
|
expect(resolveOutputDir('~')).toBe(os.homedir());
|
|
});
|
|
|
|
it('generates a non-overwriting file path when a timestamp collision exists', () => {
|
|
const dir = '/tmp/chatgpt';
|
|
const taken = new Set([
|
|
path.join(dir, 'chatgpt_123.png'),
|
|
path.join(dir, 'chatgpt_123_1.png'),
|
|
]);
|
|
|
|
expect(nextAvailablePath(dir, 'chatgpt_123', '.png', (file) => taken.has(file))).toBe(path.join(dir, 'chatgpt_123_2.png'));
|
|
});
|
|
|
|
it('parses comma-separated image paths', () => {
|
|
expect(parseImagePaths('/tmp/a.png, /tmp/b.jpg')).toEqual(['/tmp/a.png', '/tmp/b.jpg']);
|
|
expect(parseImagePaths([' /tmp/a.png ', '/tmp/b.jpg,/tmp/c.webp'])).toEqual(['/tmp/a.png', '/tmp/b.jpg', '/tmp/c.webp']);
|
|
});
|
|
});
|
|
|
|
describe('chatgpt image upload flow', () => {
|
|
it('starts image generation inside a specified project', async () => {
|
|
const page = createPage();
|
|
await imageCommand.func(page, {
|
|
prompt: 'cat in a lab',
|
|
project: '12345678',
|
|
op: '',
|
|
sd: true,
|
|
timeout: 240,
|
|
});
|
|
|
|
expect(mocks.navigateToProject).toHaveBeenCalledWith(page, '12345678');
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
expect(mocks.clearChatGPTDraft).toHaveBeenCalled();
|
|
expect(mocks.sendChatGPTMessage).toHaveBeenCalledWith(page, 'Generate an image of: cat in a lab');
|
|
});
|
|
|
|
it('uploads local images before sending an edit prompt', async () => {
|
|
mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: true, paths: ['/abs/cat.png', '/abs/dog.jpg'] });
|
|
await imageCommand.func(createPage(), {
|
|
prompt: 'make the background blue',
|
|
image: '/tmp/cat.png,/tmp/dog.jpg',
|
|
op: '',
|
|
sd: true,
|
|
timeout: 240,
|
|
});
|
|
|
|
expect(mocks.clearChatGPTDraft).toHaveBeenCalled();
|
|
expect(mocks.uploadChatGPTImages).toHaveBeenCalledWith(expect.anything(), ['/abs/cat.png', '/abs/dog.jpg']);
|
|
expect(mocks.uploadChatGPTImages.mock.invocationCallOrder[0]).toBeLessThan(
|
|
mocks.getChatGPTVisibleImageUrls.mock.invocationCallOrder[0],
|
|
);
|
|
expect(mocks.sendChatGPTMessage).toHaveBeenCalledWith(expect.anything(), 'Edit the attached images: make the background blue');
|
|
});
|
|
|
|
it('rejects invalid local image paths before browser navigation', async () => {
|
|
mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: false, reason: 'Image not found: /tmp/missing.png' });
|
|
const page = createPage();
|
|
|
|
await expect(imageCommand.func(page, {
|
|
prompt: 'make the background blue',
|
|
image: '/tmp/missing.png',
|
|
op: '',
|
|
sd: false,
|
|
timeout: 240,
|
|
})).rejects.toMatchObject({
|
|
code: 'ARGUMENT',
|
|
message: expect.stringContaining('Image not found'),
|
|
});
|
|
expect(page.goto).not.toHaveBeenCalled();
|
|
expect(mocks.uploadChatGPTImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces upload failures as command execution errors', async () => {
|
|
mocks.uploadChatGPTImages.mockResolvedValue({ ok: false, reason: 'image upload preview did not appear' });
|
|
|
|
await expect(imageCommand.func(createPage(), {
|
|
prompt: 'make the background blue',
|
|
image: '/tmp/cat.png',
|
|
op: '',
|
|
sd: false,
|
|
timeout: 240,
|
|
})).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('image upload preview did not appear'),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('chatgpt image failure contracts', () => {
|
|
it('fails fast when the image prompt cannot be sent', async () => {
|
|
mocks.sendChatGPTMessage.mockResolvedValue(false);
|
|
|
|
await expect(imageCommand.func(createPage(), {
|
|
prompt: 'cat',
|
|
op: '',
|
|
sd: false,
|
|
timeout: 240,
|
|
})).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('Failed to send image prompt to ChatGPT'),
|
|
});
|
|
expect(mocks.waitForChatGPTImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails fast when image generation detection finds no new images', async () => {
|
|
mocks.waitForChatGPTImages.mockResolvedValue([]);
|
|
|
|
await expect(imageCommand.func(createPage(), {
|
|
prompt: 'cat',
|
|
op: '',
|
|
sd: false,
|
|
timeout: 240,
|
|
})).rejects.toMatchObject({
|
|
code: 'EMPTY_RESULT',
|
|
message: expect.stringContaining('chatgpt image returned no data'),
|
|
hint: expect.stringContaining('No generated images were detected'),
|
|
});
|
|
});
|
|
|
|
it('fails fast when generated image assets cannot be exported', async () => {
|
|
mocks.getChatGPTImageAssets.mockResolvedValue([]);
|
|
|
|
await expect(imageCommand.func(createPage(), {
|
|
prompt: 'cat',
|
|
op: '',
|
|
sd: false,
|
|
timeout: 240,
|
|
})).rejects.toMatchObject({
|
|
code: 'COMMAND_EXEC',
|
|
message: expect.stringContaining('Failed to export generated ChatGPT image assets'),
|
|
});
|
|
});
|
|
});
|