feat(cli): export chat history in /bug and prefill GitHub issue (#16115)

This commit is contained in:
N. Taylor Mullen
2026-01-08 03:43:55 -08:00
committed by GitHub
parent 722c4933dc
commit 030847a80a
6 changed files with 234 additions and 101 deletions

View File

@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mocked } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { SlashCommand, CommandContext } from './types.js';
@@ -13,7 +12,11 @@ import type { Content } from '@google/genai';
import { AuthType, type GeminiClient } from '@google/gemini-cli-core';
import * as fsPromises from 'node:fs/promises';
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
import { chatCommand } from './chatCommand.js';
import {
serializeHistoryToMarkdown,
exportHistoryToFile,
} from '../utils/historyExportUtils.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
@@ -24,8 +27,18 @@ vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
}));
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
return {
...actual,
exportHistoryToFile: vi.fn(),
};
});
describe('chatCommand', () => {
const mockFs = fsPromises as Mocked<typeof fsPromises>;
const mockFs = vi.mocked(fsPromises);
const mockExport = vi.mocked(exportHistoryToFile);
let mockContext: CommandContext;
let mockGetChat: ReturnType<typeof vi.fn>;
@@ -448,9 +461,10 @@ describe('chatCommand', () => {
process.cwd(),
'gemini-conversation-1234567890.json',
);
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -462,9 +476,10 @@ describe('chatCommand', () => {
const filePath = 'my-chat.json';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -476,30 +491,10 @@ describe('chatCommand', () => {
const filePath = 'my-chat.md';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const expectedContent = `## USER 🧑‍💻
context
---
## MODEL ✨
context response
---
## USER 🧑‍💻
Hello
---
## MODEL ✨
Hi there!`;
expect(actualContent).toEqual(expectedContent);
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -510,7 +505,7 @@ Hi there!`;
it('should return an error for unsupported file extensions', async () => {
const filePath = 'my-chat.txt';
const result = await shareCommand?.action?.(mockContext, filePath);
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(mockExport).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
@@ -523,7 +518,7 @@ Hi there!`;
{ role: 'user', parts: [{ text: 'context' }] },
]);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(mockExport).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -533,7 +528,7 @@ Hi there!`;
it('should handle errors during file writing', async () => {
const error = new Error('Permission denied');
mockFs.writeFile.mockRejectedValue(error);
mockExport.mockRejectedValue(error);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(result).toEqual({
type: 'message',
@@ -546,14 +541,9 @@ Hi there!`;
const filePath = 'my-chat.json';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const parsedContent = JSON.parse(actualContent as string);
expect(Array.isArray(parsedContent)).toBe(true);
parsedContent.forEach((item: Content) => {
expect(item).toHaveProperty('role');
expect(item).toHaveProperty('parts');
expect(Array.isArray(item.parts)).toBe(true);
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
});
@@ -561,15 +551,9 @@ Hi there!`;
const filePath = 'my-chat.md';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const entries = (actualContent as string).split('\n\n---\n\n');
expect(entries.length).toBe(mockHistory.length);
entries.forEach((entry: string, index: number) => {
const { role, parts } = mockHistory[index];
const text = parts.map((p) => p.text).join('');
const roleIcon = role === 'user' ? '🧑‍💻' : '✨';
expect(entry).toBe(`## ${role.toUpperCase()} ${roleIcon}\n\n${text}`);
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
});
});