Files
jackwener__opencli/clis/chatgpt/image.test.js
Benjamin Liu 25e86532a3 fix(chatgpt): fix image generation detection and output path (#1218)
* fix(chatgpt): fix image generation detection and output path

Three fixes for chatgpt image command:

1. Page navigation: ChatGPT redirects away from the conversation
   after sending. Poll for the /c/ URL after send, then periodically
   reload the conversation page during image wait to pick up
   asynchronously rendered images.

2. Composer selector: add fallback selectors for the chat input
   since ChatGPT uses different aria-labels across UI versions.

3. Output path: the default '~/Pictures/chatgpt' was passed as a
   literal string without tilde expansion, creating a directory
   named '~' in the working directory. Removed the string default
   and use os.homedir() fallback instead.

Fixes #1206

* fix(chatgpt): fail fast on image export failures

* fix(chatgpt): avoid reloads during image generation

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-01 20:01:34 +08:00

93 lines
3.4 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(),
sendChatGPTMessage: vi.fn(),
waitForChatGPTImages: vi.fn(),
getChatGPTImageAssets: vi.fn(),
saveBase64ToFile: vi.fn(),
}));
vi.mock('./utils.js', () => ({
getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
sendChatGPTMessage: mocks.sendChatGPTMessage,
waitForChatGPTImages: mocks.waitForChatGPTImages,
getChatGPTImageAssets: mocks.getChatGPTImageAssets,
}));
vi.mock('@jackwener/opencli/utils', () => ({
saveBase64ToFile: mocks.saveBase64ToFile,
}));
const { imageCommand, nextAvailablePath, 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.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
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'));
});
});
describe('chatgpt image failure contracts', () => {
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,
})).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,
})).rejects.toMatchObject({
code: 'COMMAND_EXEC',
message: expect.stringContaining('Failed to export generated ChatGPT image assets'),
});
});
});