Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
392 changes: 373 additions & 19 deletions src/features/export/services/PDFPrintService.ts

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions src/features/export/services/__tests__/PDFPrintService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,83 @@ describe('PDFPrintService', () => {
expect(styleText).toContain('background: #fff !important;');
});

it('uses a compact ChatGPT-like conversation layout when requested', async () => {
document.title = 'Stale page title';
window.print = vi.fn();

await PDFPrintService.export(
[{ user: 'A short question', assistant: 'A useful answer', starred: false }],
{
url: 'https://chatgpt.com/c/abc',
exportedAt: new Date().toISOString(),
count: 1,
title: 'Export test',
},
{ appearance: 'chatgpt' },
);

const documentRoot = document.querySelector('.gv-print-document--chatgpt');
const sourceLink = document.querySelector('.gv-print-meta a');
const styleText = document.getElementById('gv-pdf-print-styles')?.textContent ?? '';

expect(documentRoot).toBeTruthy();
expect(sourceLink?.textContent).toBe('ChatGPT conversation');
expect(document.title).toBe('Export test - ChatGPT');
expect(styleText).toContain('.gv-print-document--chatgpt');
expect(styleText).toContain('.gv-print-turn-user .gv-print-turn-text');
expect(styleText).toContain('border-radius: 18px 18px 4px 18px;');
expect(styleText).toContain('.gv-print-turn-text pre');
expect(styleText).toContain('border: 1px solid #d1d5db;');
expect(styleText).toContain('color: #1f1f1f;');
expect(styleText).toContain('display: table-header-group !important;');
expect(styleText).toContain('.gv-print-cover-title::before');
});

it('does not use the generic ChatGPT page title as a conversation title', async () => {
document.title = 'ChatGPT';
window.print = vi.fn();

await PDFPrintService.export(
[{ user: 'Question', assistant: 'Answer', starred: false }],
{
url: 'https://chatgpt.com/',
exportedAt: new Date().toISOString(),
count: 1,
title: '',
},
{ appearance: 'chatgpt' },
);

expect(document.title).toBe('ChatGPT Conversation');
});

it('falls back to captured text when rich user HTML extraction is empty', async () => {
window.print = vi.fn();
const userElement = document.createElement('div');

await PDFPrintService.export(
[
{
user: 'Fallback user message',
assistant: 'Answer',
starred: false,
userElement,
},
],
{
url: 'https://chatgpt.com/c/abc',
exportedAt: new Date().toISOString(),
count: 1,
title: 'Fallback content',
},
{ appearance: 'chatgpt' },
);

expect(document.querySelector('.gv-print-turn-user .gv-print-turn-text')?.textContent).toBe(
'Fallback user message',
);
});

it('injects descendant display override to survive immersive-mode print rules', async () => {
window.print = vi.fn();

Expand Down
11 changes: 11 additions & 0 deletions src/features/plugins/builtin/builtin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ describe('BUILTIN_PLUGINS', () => {
expect(timeline?.i18n?.zh?.settings?.compactView?.label).toBe('使用紧凑索引');
});

it('includes the ChatGPT conversation export native function plugin', () => {
const exporter = BUILTIN_PLUGINS.find((m) => m.id === 'voyager.chatgpt-export');
expect(exporter).toBeDefined();
expect(exporter?.matches).toEqual(['https://chatgpt.com/*', 'https://chat.openai.com/*']);
expect(exporter?.contributes.styles ?? []).toEqual([]);
expect(exporter?.contributes.domOps ?? []).toEqual([]);
expect(exporter?.i18n?.zh?.name).toBe('ChatGPT 对话导出');
expect(exporter?.description).not.toContain('PNG');
expect(exporter?.i18n?.zh?.description).not.toContain('PNG');
});

it('does not expose the retired Claude usage plugin', () => {
const usage = BUILTIN_PLUGINS.find((m) => m.id === 'voyager.claude-usage');
expect(usage).toBeUndefined();
Expand Down
217 changes: 217 additions & 0 deletions src/features/plugins/builtin/chatgptExport/conversation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
buildChatTurns,
buildConversationMetadata,
collectChatGptConversation,
collectMountedChatGptMessages,
} from './conversation';

function turn(index: number, id: string, role: 'user' | 'assistant', text: string): HTMLElement {
const section = document.createElement('section');
section.dataset.testid = `conversation-turn-${index}`;
const message = document.createElement('div');
message.dataset.messageAuthorRole = role;
message.dataset.messageId = id;
const paragraph = document.createElement('p');
paragraph.textContent = text;
message.appendChild(paragraph);
section.appendChild(message);
return section;
}

function fallbackTurn(role: 'user' | 'assistant', text: string): HTMLElement {
const section = document.createElement('section');
const message = document.createElement('div');
message.dataset.messageAuthorRole = role;
message.textContent = text;
section.appendChild(message);
return section;
}

afterEach(() => {
document.body.replaceChildren();
document.title = '';
vi.restoreAllMocks();
});

describe('ChatGPT conversation snapshots', () => {
it('uses stable ids, document order, detached clones, and excludes action text', () => {
const first = turn(0, 'message-user', 'user', 'Hello');
const button = document.createElement('button');
button.textContent = 'Copy';
first.querySelector('[data-message-author-role]')?.appendChild(button);
document.body.append(first, turn(1, 'message-assistant', 'assistant', 'Hi there'));

const messages = collectMountedChatGptMessages();

expect(messages.map(({ id, role, text }) => ({ id, role, text }))).toEqual([
{ id: 'message-user', role: 'user', text: 'Hello' },
{ id: 'message-assistant', role: 'assistant', text: 'Hi there' },
]);
expect(messages[0].element.isConnected).toBe(false);
first.querySelector('p')!.textContent = 'Changed later';
expect(messages[0].element.textContent).toContain('Hello');
});

it('pairs adjacent user/assistant messages and preserves assistant-only selections', () => {
document.body.append(
turn(0, 'u-1', 'user', 'Question'),
turn(1, 'a-1', 'assistant', 'Answer'),
turn(2, 'a-2', 'assistant', 'Selected answer only'),
);
const messages = collectMountedChatGptMessages();

const turns = buildChatTurns(messages);

expect(turns).toHaveLength(2);
expect(turns[0]).toMatchObject({ user: 'Question', assistant: 'Answer' });
expect(turns[1]).toMatchObject({ user: '', assistant: 'Selected answer only' });
expect(turns[1].omitEmptySections).toBe(true);
});

it('walks a virtualised scroll window and restores the original position', async () => {
const scroller = document.createElement('main');
scroller.style.overflowY = 'auto';
let currentTop = 500;
const render = (): void => {
scroller.replaceChildren(
...(currentTop < 300
? [turn(0, 'u-1', 'user', 'Top question'), turn(1, 'a-1', 'assistant', 'Top answer')]
: [
turn(2, 'u-2', 'user', 'Bottom question'),
turn(3, 'a-2', 'assistant', 'Bottom answer'),
]),
);
};
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 1_000 },
scrollTop: {
configurable: true,
get: () => currentTop,
set: (value: number) => {
if (value === currentTop) return;
currentTop = value;
render();
},
},
});
render();
document.body.appendChild(scroller);
const progress = vi.fn();

const messages = await collectChatGptConversation({
signal: new AbortController().signal,
settleMs: 0,
onProgress: progress,
});

expect(messages.map((message) => message.id)).toEqual(['u-1', 'a-1', 'u-2', 'a-2']);
expect(currentTop).toBe(500);
expect(progress).toHaveBeenCalled();
});

it('retains repeated role and text messages across fallback-id window swaps', async () => {
const scroller = document.createElement('main');
scroller.style.overflowY = 'auto';
let currentTop = 0;
const render = (): void => {
const message = fallbackTurn('user', 'Repeated message');
const globalTop = currentTop < 300 ? 0 : 400;
const messageHost = message.querySelector<HTMLElement>('[data-message-author-role]')!;
messageHost.getBoundingClientRect = () =>
({
top: globalTop - currentTop,
bottom: globalTop - currentTop + 40,
left: 0,
right: 200,
width: 200,
height: 40,
x: 0,
y: globalTop - currentTop,
toJSON: () => ({}),
}) as DOMRect;
scroller.replaceChildren(message);
};
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 800 },
scrollTop: {
configurable: true,
get: () => currentTop,
set: (value: number) => {
if (value === currentTop) return;
currentTop = value;
render();
},
},
});
render();
document.body.appendChild(scroller);

const messages = await collectChatGptConversation({
signal: new AbortController().signal,
settleMs: 0,
});

expect(messages).toHaveLength(2);
expect(new Set(messages.map((message) => message.id))).toHaveLength(2);
expect(messages.map((message) => message.text)).toEqual([
'Repeated message',
'Repeated message',
]);
});

it('merges the final scroll window and rejects an incomplete step-limited export', async () => {
const scroller = document.createElement('main');
scroller.style.overflowY = 'auto';
let currentTop = 0;
const render = (): void => {
scroller.replaceChildren(
currentTop < 300
? turn(0, 'u-top', 'user', 'Top message')
: turn(1, 'u-next', 'user', 'Next window message'),
);
};
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, value: 2_000 },
scrollTop: {
configurable: true,
get: () => currentTop,
set: (value: number) => {
if (value === currentTop) return;
currentTop = value;
render();
},
},
});
render();
document.body.appendChild(scroller);
const progress: number[] = [];

await expect(
collectChatGptConversation({
signal: new AbortController().signal,
settleMs: 0,
maxSteps: 1,
onProgress: ({ messages }) => progress.push(messages),
}),
).rejects.toThrow('Conversation collection stopped before reaching the end');

expect(progress.at(-1)).toBe(2);
expect(currentTop).toBe(0);
});

it('builds ChatGPT metadata without Gemini branding', () => {
document.title = 'A useful chat - ChatGPT';
document.body.append(turn(0, 'u-1', 'user', 'Question'));

const metadata = buildConversationMetadata(collectMountedChatGptMessages());

expect(metadata.title).toBe('A useful chat');
expect(metadata.count).toBe(1);
expect(metadata.title).not.toContain('Gemini');
});
});
Loading
Loading