Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
206 changes: 206 additions & 0 deletions clis/gemini/deep-research-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IPage } from '../../types.js';

const {
mockClickGeminiConversationByTitle,
mockExportGeminiDeepResearchReport,
mockGetGeminiConversationList,
mockGetGeminiPageState,
mockGetLatestGeminiAssistantResponse,
mockReadGeminiSnapshot,
mockResolveGeminiConversationForQuery,
mockWaitForGeminiTranscript,
} = vi.hoisted(() => ({
mockClickGeminiConversationByTitle: vi.fn(),
mockExportGeminiDeepResearchReport: vi.fn(),
mockGetGeminiConversationList: vi.fn(),
mockGetGeminiPageState: vi.fn(),
mockGetLatestGeminiAssistantResponse: vi.fn(),
mockReadGeminiSnapshot: vi.fn(),
mockResolveGeminiConversationForQuery: vi.fn(),
mockWaitForGeminiTranscript: vi.fn(),
}));

vi.mock('./utils.js', () => ({
GEMINI_DOMAIN: 'gemini.google.com',
clickGeminiConversationByTitle: mockClickGeminiConversationByTitle,
exportGeminiDeepResearchReport: mockExportGeminiDeepResearchReport,
getGeminiConversationList: mockGetGeminiConversationList,
getGeminiPageState: mockGetGeminiPageState,
getLatestGeminiAssistantResponse: mockGetLatestGeminiAssistantResponse,
readGeminiSnapshot: mockReadGeminiSnapshot,
parseGeminiConversationUrl: (value: unknown) => {
const raw = String(value ?? '').trim();
return raw.startsWith('https://gemini.google.com/app/') ? raw : null;
},
parseGeminiTitleMatchMode: (value: unknown) => {
const raw = String(value ?? 'contains').trim().toLowerCase();
if (raw === 'contains' || raw === 'exact') return raw;
return null;
},
resolveGeminiConversationForQuery: mockResolveGeminiConversationForQuery,
waitForGeminiTranscript: mockWaitForGeminiTranscript,
}));

import { deepResearchResultCommand } from './deep-research-result.js';

describe('gemini/deep-research-result', () => {
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
} as unknown as IPage;

beforeEach(() => {
vi.clearAllMocks();
mockGetGeminiPageState.mockResolvedValue({ isSignedIn: true });
mockGetGeminiConversationList.mockResolvedValue([{ Title: 'A title', Url: 'https://gemini.google.com/app/abc' }]);
mockResolveGeminiConversationForQuery.mockReturnValue({ Title: 'A title', Url: 'https://gemini.google.com/app/abc' });
mockClickGeminiConversationByTitle.mockResolvedValue(true);
mockWaitForGeminiTranscript.mockResolvedValue(['line']);
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: 'https://files.example.com/report.md', source: 'network' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('Final answer');
mockReadGeminiSnapshot.mockResolvedValue({
turns: [],
transcriptLines: [],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});
});

it('uses latest conversation when query is empty', async () => {
const result = await deepResearchResultCommand.func!(page, { query: ' ' });

expect(page.goto).toHaveBeenCalledWith('https://gemini.google.com/app/abc', { waitUntil: 'load', settleMs: 2500 });
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});

it('falls back to current page response when query is empty and sidebar has no conversations', async () => {
mockGetGeminiConversationList.mockResolvedValue([]);
mockResolveGeminiConversationForQuery.mockReturnValue(null);

const result = await deepResearchResultCommand.func!(page, { query: '' });

expect(page.goto).not.toHaveBeenCalled();
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});

it('returns a validation message when match mode is invalid', async () => {
const result = await deepResearchResultCommand.func!(page, { query: 'A', match: 'prefix' });
expect(result).toEqual([{ response: 'Invalid match mode. Use contains or exact.' }]);
});

it('returns a signed-out message when Gemini page state indicates logged out', async () => {
mockGetGeminiPageState.mockResolvedValue({ isSignedIn: false });
const result = await deepResearchResultCommand.func!(page, { query: 'A' });
expect(result).toEqual([{ response: 'Not signed in to Gemini.' }]);
});

it('opens matched conversation by URL and returns exported report url', async () => {
const result = await deepResearchResultCommand.func!(page, { query: 'A title', match: 'exact' });

expect(page.goto).toHaveBeenCalledWith('https://gemini.google.com/app/abc', { waitUntil: 'load', settleMs: 2500 });
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});

it('accepts a direct conversation URL and reads response from that page', async () => {
const url = 'https://gemini.google.com/app/direct-id';
const result = await deepResearchResultCommand.func!(page, { query: url, match: 'contains' });

expect(page.goto).toHaveBeenCalledWith(url, { waitUntil: 'load', settleMs: 2500 });
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});

it('passes query and mode into resolveGeminiConversationForQuery', async () => {
const result = await deepResearchResultCommand.func!(page, { query: 'title', match: 'contains' });

expect(mockResolveGeminiConversationForQuery).toHaveBeenCalledWith(
[{ Title: 'A title', Url: 'https://gemini.google.com/app/abc' }],
'title',
'contains',
);
expect(result).toEqual([{ response: 'https://files.example.com/report.md' }]);
});

it('falls back to click-by-title and returns not-found when click fails', async () => {
mockResolveGeminiConversationForQuery.mockReturnValue(null);
mockClickGeminiConversationByTitle.mockResolvedValue(false);

const result = await deepResearchResultCommand.func!(page, { query: 'missing', match: 'contains' });

expect(result).toEqual([{ response: 'No conversation matched: missing' }]);
});

it('returns pending message when export url is unavailable and completion is not confirmed', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });

const result = await deepResearchResultCommand.func!(page, { query: 'A title' });

expect(result).toEqual([{ response: 'Deep Research may still be running or preparing export. Please wait and retry later.' }]);
});

it('returns waiting message when deep research is still generating', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockReadGeminiSnapshot.mockResolvedValue({
turns: [],
transcriptLines: [],
composerHasText: false,
isGenerating: true,
structuredTurnsTrusted: true,
});

const result = await deepResearchResultCommand.func!(page, { query: 'A title' });

expect(result).toEqual([{ response: 'Deep Research is still running. Please wait and retry later.' }]);
});

it('returns waiting message when assistant response indicates research in progress', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('正在研究中,请稍候。');

const result = await deepResearchResultCommand.func!(page, { query: 'A title' });

expect(result).toEqual([{ response: 'Deep Research is still running. Please wait and retry later.' }]);
});

it('returns waiting message when transcript indicates in-progress status', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('');
mockReadGeminiSnapshot.mockResolvedValue({
turns: [],
transcriptLines: ['生成研究计划中,请稍候。'],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});

const result = await deepResearchResultCommand.func!(page, { query: 'A title' });

expect(result).toEqual([{ response: 'Deep Research is still running. Please wait and retry later.' }]);
});

it('returns no-docs message when text indicates completed state', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('Researching websites... Completed');
mockReadGeminiSnapshot.mockResolvedValue({
turns: [],
transcriptLines: [],
composerHasText: false,
isGenerating: false,
structuredTurnsTrusted: true,
});

const result = await deepResearchResultCommand.func!(page, { query: 'A title' });

expect(result).toEqual([{ response: 'No Docs URL found. Please check Share & Export -> Export to Docs in Gemini UI.' }]);
});

it('returns pending message when assistant response is empty', async () => {
mockExportGeminiDeepResearchReport.mockResolvedValue({ url: '', source: 'none' });
mockGetLatestGeminiAssistantResponse.mockResolvedValue('');

const result = await deepResearchResultCommand.func!(page, { query: 'A title' });

expect(result).toEqual([{ response: 'Deep Research may still be running or preparing export. Please wait and retry later.' }]);
});
});
116 changes: 116 additions & 0 deletions clis/gemini/deep-research-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
import {
GEMINI_DOMAIN,
clickGeminiConversationByTitle,
exportGeminiDeepResearchReport,
getLatestGeminiAssistantResponse,
getGeminiPageState,
parseGeminiConversationUrl,
parseGeminiTitleMatchMode,
readGeminiSnapshot,
resolveGeminiConversationForQuery,
waitForGeminiTranscript,
getGeminiConversationList,
} from './utils.js';

const DEEP_RESEARCH_WAITING_MESSAGE = 'Deep Research is still running. Please wait and retry later.';
const DEEP_RESEARCH_NO_DOCS_MESSAGE = 'No Docs URL found. Please check Share & Export -> Export to Docs in Gemini UI.';
const DEEP_RESEARCH_PENDING_MESSAGE = 'Deep Research may still be running or preparing export. Please wait and retry later.';

function isDeepResearchInProgress(text: string): boolean {
return /\bresearching(?:\s+websites?)?\b|research in progress|working on your research|generating research plan|gathering sources|creating report|planning research|正在研究|研究中|调研中|生成研究计划|搜集资料|请稍候|稍候|请等待/i.test(text);
}

function isDeepResearchCompleted(text: string): boolean {
return /\bcompleted\b|research complete|completed research|report completed|已完成|研究完成|完成了研究|报告已完成/i.test(text);
}

async function resolveDeepResearchExportResponse(page: IPage, timeoutSeconds: number): Promise<string> {
const exported = await exportGeminiDeepResearchReport(page, timeoutSeconds);
if (exported.url) return exported.url;

const snapshot = await readGeminiSnapshot(page).catch(() => null);
if (snapshot?.isGenerating) return DEEP_RESEARCH_WAITING_MESSAGE;

const latest = await getLatestGeminiAssistantResponse(page).catch(() => '');
const turnTail = Array.isArray(snapshot?.turns)
? snapshot.turns.slice(-6).map((turn) => String(turn?.Text ?? '')).join('\n')
: '';
const transcriptTail = Array.isArray(snapshot?.transcriptLines)
? snapshot.transcriptLines.slice(-30).join('\n')
: '';
const statusText = [latest, turnTail, transcriptTail]
.map((value) => String(value ?? '').trim())
.filter(Boolean)
.join('\n');

if (statusText && isDeepResearchInProgress(statusText) && !isDeepResearchCompleted(statusText)) {
return DEEP_RESEARCH_WAITING_MESSAGE;
}

if (statusText && isDeepResearchCompleted(statusText)) {
return DEEP_RESEARCH_NO_DOCS_MESSAGE;
}

return DEEP_RESEARCH_PENDING_MESSAGE;
}

export const deepResearchResultCommand = cli({
site: 'gemini',
name: 'deep-research-result',
description: 'Export Deep Research report URL from a Gemini conversation',
domain: GEMINI_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
defaultFormat: 'plain',
args: [
{ name: 'query', positional: true, required: false, help: 'Conversation title or URL (optional; defaults to latest conversation)' },
{ name: 'match', required: false, default: 'contains', choices: ['contains', 'exact'], help: 'Match mode' },
{ name: 'timeout', type: 'int', required: false, default: 120, help: 'Max seconds to wait for Docs export (default: 120)' },
],
columns: ['response'],
func: async (page: IPage, kwargs: any) => {
const query = String(kwargs.query ?? '').trim();
const matchMode = parseGeminiTitleMatchMode(kwargs.match);
const timeoutRaw = Number.parseInt(String(kwargs.timeout ?? ''), 10);
const timeoutSeconds = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 120;
if (!matchMode) {
return [{ response: 'Invalid match mode. Use contains or exact.' }];
}

const state = await getGeminiPageState(page);
if (state.isSignedIn === false) {
return [{ response: 'Not signed in to Gemini.' }];
}

const conversationUrl = parseGeminiConversationUrl(query);
if (conversationUrl) {
await page.goto(conversationUrl, { waitUntil: 'load', settleMs: 2500 });
await page.wait(1);
await waitForGeminiTranscript(page);
return [{ response: await resolveDeepResearchExportResponse(page, timeoutSeconds) }];
}

const conversations = await getGeminiConversationList(page);
const picked = resolveGeminiConversationForQuery(conversations, query, matchMode);

if (picked?.Url) {
await page.goto(picked.Url, { waitUntil: 'load', settleMs: 2500 });
await page.wait(1);
await waitForGeminiTranscript(page);
} else if (query) {
if (matchMode === 'exact') {
return [{ response: `No conversation matched: ${query}` }];
}
const clicked = await clickGeminiConversationByTitle(page, query);
if (!clicked) {
return [{ response: `No conversation matched: ${query}` }];
}
await waitForGeminiTranscript(page);
}

return [{ response: await resolveDeepResearchExportResponse(page, timeoutSeconds) }];
},
});
Loading