mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
feat(import): route Grok exports through the job pipeline and label them
Grok jobs resolve to the OpenAI endpoint and a grok importedFrom source, so their external ids stay in their own namespace. getImporter recognises a bare prod-grok-backend.json and delegates to the same converter the zip path uses, and the confirmation summary names the provider instead of showing the slug.
This commit is contained in:
parent
261c8ee651
commit
7fd841af70
8 changed files with 417 additions and 7 deletions
|
|
@ -12,6 +12,7 @@ const {
|
|||
buildClaudeExportZip,
|
||||
cleanupClaudeExportZips,
|
||||
} = require('~/test/claudeExport');
|
||||
const { bareGrokExport, buildGrokExportZip, cleanupGrokExportZips } = require('~/test/grokExport');
|
||||
const { createModels, createMethods } = require('@librechat/data-schemas');
|
||||
const { FileSources, EModelEndpoint } = require('librechat-data-provider');
|
||||
|
||||
|
|
@ -265,6 +266,7 @@ describe('conversation import job API (real router, real Mongo)', () => {
|
|||
}
|
||||
cleanupChatGptExportZips();
|
||||
cleanupClaudeExportZips();
|
||||
cleanupGrokExportZips();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -413,6 +415,100 @@ describe('conversation import job API (real router, real Mongo)', () => {
|
|||
expect(await Conversation.countDocuments({ user: userId })).toBe(2);
|
||||
});
|
||||
|
||||
it('imports a Grok export .zip through the job API on the OpenAI endpoint', async () => {
|
||||
const filepath = await buildGrokExportZip();
|
||||
|
||||
const uploaded = await request(app)
|
||||
.post('/api/convos/import')
|
||||
.attach('file', filepath)
|
||||
.expect(202);
|
||||
|
||||
expect(uploaded.body.summary).toMatchObject({
|
||||
source: 'grok',
|
||||
conversations: 2,
|
||||
assets: 0,
|
||||
archived: 0,
|
||||
starred: 1,
|
||||
});
|
||||
|
||||
await request(app).post(`/api/convos/import/jobs/${uploaded.body.jobId}/start`).expect(202);
|
||||
const completed = await waitForTerminal(app, uploaded.body.jobId);
|
||||
|
||||
expect(completed.body.phase).toBe('completed');
|
||||
expect(completed.body.report.imported).toBe(2);
|
||||
expect(completed.body.report.errors).toEqual([]);
|
||||
|
||||
const convos = await Conversation.find({ user: userId }).lean();
|
||||
expect(convos).toHaveLength(2);
|
||||
expect(convos.every((convo) => convo.endpoint === EModelEndpoint.openAI)).toBe(true);
|
||||
expect(convos.map((convo) => convo.importedFrom.source).sort()).toEqual(['grok', 'grok']);
|
||||
expect(convos.map((convo) => convo.title).sort()).toEqual([
|
||||
'Amalfi trip planning',
|
||||
'Recovering a cut-off script',
|
||||
]);
|
||||
|
||||
const messages = await mongoose.models.Message.find({ user: userId }).lean();
|
||||
/** Five of six responses: the aborted, textless generation is dropped. */
|
||||
expect(messages).toHaveLength(5);
|
||||
const branch = messages.find((message) => message.text === 'Positano.');
|
||||
expect(branch.sender).toBe('Grok 4.1 Thinking');
|
||||
expect(branch.model).toBe('grok-4-1-thinking-1129');
|
||||
expect(branch.isCreatedByUser).toBe(false);
|
||||
const completedScript = messages.find(
|
||||
(message) => message.text === 'Here is the completed script.',
|
||||
);
|
||||
const prompt = messages.find((message) => message.text === 'Complete this script.');
|
||||
expect(completedScript.parentMessageId).toBe(prompt.messageId);
|
||||
});
|
||||
|
||||
it('imports a bare prod-grok-backend.json upload through the same job API', async () => {
|
||||
const uploaded = await request(app)
|
||||
.post('/api/convos/import')
|
||||
.attach('file', bareGrokExport(), 'prod-grok-backend.json')
|
||||
.expect(202);
|
||||
|
||||
expect(uploaded.body.summary.source).toBe('grok');
|
||||
expect(uploaded.body.summary.conversations).toBe(2);
|
||||
|
||||
await request(app).post(`/api/convos/import/jobs/${uploaded.body.jobId}/start`).expect(202);
|
||||
const completed = await waitForTerminal(app, uploaded.body.jobId);
|
||||
|
||||
expect(completed.body.phase).toBe('completed');
|
||||
expect(completed.body.report.imported).toBe(2);
|
||||
expect(await Conversation.countDocuments({ user: userId })).toBe(2);
|
||||
});
|
||||
|
||||
it('skips a Grok conversation already imported and keeps provider id namespaces apart', async () => {
|
||||
const first = await request(app)
|
||||
.post('/api/convos/import')
|
||||
.attach('file', await buildGrokExportZip())
|
||||
.expect(202);
|
||||
await request(app).post(`/api/convos/import/jobs/${first.body.jobId}/start`).expect(202);
|
||||
await waitForTerminal(app, first.body.jobId);
|
||||
|
||||
const second = await request(app)
|
||||
.post('/api/convos/import')
|
||||
.attach('file', await buildGrokExportZip())
|
||||
.expect(202);
|
||||
await request(app).post(`/api/convos/import/jobs/${second.body.jobId}/start`).expect(202);
|
||||
const completed = await waitForTerminal(app, second.body.jobId);
|
||||
|
||||
expect(completed.body.report.imported).toBe(0);
|
||||
expect(completed.body.report.skipped).toBe(2);
|
||||
|
||||
/** A Claude import on top adds its own conversations rather than being
|
||||
* suppressed by the Grok external ids already recorded. */
|
||||
const claude = await request(app)
|
||||
.post('/api/convos/import')
|
||||
.attach('file', await buildClaudeExportZip())
|
||||
.expect(202);
|
||||
await request(app).post(`/api/convos/import/jobs/${claude.body.jobId}/start`).expect(202);
|
||||
const claudeDone = await waitForTerminal(app, claude.body.jobId);
|
||||
|
||||
expect(claudeDone.body.report.imported).toBe(2);
|
||||
expect(await Conversation.countDocuments({ user: userId })).toBe(4);
|
||||
});
|
||||
|
||||
it('still imports a ChatbotUI-shaped .json upload through the legacy synchronous path', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/convos/import')
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ const { sleep } = require('@librechat/agents');
|
|||
const {
|
||||
isEnabled,
|
||||
runImport,
|
||||
GROK_SOURCE,
|
||||
GROK_ENDPOINT,
|
||||
inspectExport,
|
||||
ImportJobStore,
|
||||
deleteAgentCheckpoints,
|
||||
|
|
@ -354,6 +356,11 @@ function resolveJobTarget(job) {
|
|||
if (job.summary?.source === 'claude') {
|
||||
return { format: 'claude', endpoint: EModelEndpoint.anthropic, source: 'claude' };
|
||||
}
|
||||
/** xAI has no first-class endpoint, so Grok conversations land on OpenAI —
|
||||
* see `GROK_ENDPOINT`, which this must stay in step with. */
|
||||
if (job.summary?.source === 'grok') {
|
||||
return { format: 'grok', endpoint: GROK_ENDPOINT, source: GROK_SOURCE };
|
||||
}
|
||||
return { format: 'chatgpt', endpoint: EModelEndpoint.openAI, source: 'chatgpt' };
|
||||
}
|
||||
|
||||
|
|
@ -439,8 +446,8 @@ async function runImportJob(req, job) {
|
|||
|
||||
/**
|
||||
* Imports a bare export synchronously through the old, un-jobbed importer.
|
||||
* Reached only for uploads that are neither a zip nor recognizable ChatGPT or
|
||||
* Claude content: ChatbotUI and LibreChat exports are detected by CONTENT
|
||||
* Reached only for uploads that are neither a zip nor recognizable ChatGPT,
|
||||
* Claude or Grok content: ChatbotUI and LibreChat exports are detected by CONTENT
|
||||
* (`getImporter`, invoked inside `importConversations`), not by file extension.
|
||||
* The `.zip` exclusion is load-bearing — this path reads `req.file.path` as
|
||||
* JSON, which no archive can satisfy.
|
||||
|
|
@ -467,14 +474,14 @@ async function importLegacyConversation(req, res) {
|
|||
/**
|
||||
* Imports a conversation export and saves it to the database.
|
||||
*
|
||||
* `.zip` uploads and bare `.json` ChatGPT and Claude exports share one
|
||||
* `.zip` uploads and bare `.json` ChatGPT, Claude and Grok exports share one
|
||||
* pipeline: `openArchive` wraps a non-zip file in a single-entry archive, so
|
||||
* `inspectExport` sees the same shape either way and this request only
|
||||
* inspects the upload and returns a summary awaiting confirmation — the
|
||||
* actual import runs after POST /import/jobs/:jobId/start. A bare `.json`
|
||||
* upload that is neither ChatGPT- nor Claude-shaped (ChatbotUI, LibreChat)
|
||||
* falls back to the legacy synchronous importer, which is the only path that
|
||||
* understands those layouts.
|
||||
* upload matching none of those shapes (ChatbotUI, LibreChat) falls back to
|
||||
* the legacy synchronous importer, which is the only path that understands
|
||||
* those layouts.
|
||||
* @route POST /import
|
||||
* @param {Express.Multer.File} req.file - The uploaded export file.
|
||||
* @returns {object} 201 - legacy (non-ChatGPT) JSON import succeeded
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
const { convertConversation, convertClaudeConversation } = require('@librechat/api');
|
||||
const {
|
||||
isGrokExport,
|
||||
GROK_SOURCE,
|
||||
GROK_ENDPOINT,
|
||||
convertConversation,
|
||||
convertGrokConversation,
|
||||
convertClaudeConversation,
|
||||
} = require('@librechat/api');
|
||||
const { logger, getTenantId } = require('@librechat/data-schemas');
|
||||
const {
|
||||
Constants,
|
||||
|
|
@ -46,6 +53,12 @@ function getImporter(jsonData) {
|
|||
return importLibreChatConvo;
|
||||
}
|
||||
|
||||
// For Grok, the only export whose root is an object of conversations
|
||||
if (isGrokExport(jsonData)) {
|
||||
logger.info('Importing Grok conversation');
|
||||
return importGrokConvo;
|
||||
}
|
||||
|
||||
throw new Error('Unsupported import type');
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +165,65 @@ async function importClaudeConvo(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports Grok conversations from provided JSON data.
|
||||
* Delegates conversion of each conversation to `convertGrokConversation`, the
|
||||
* same engine `runImport` uses for zipped Grok exports, so a bare
|
||||
* `prod-grok-backend.json` upload and a zip archive produce identical messages:
|
||||
* branching, per-message models and dropped empty generations included. The
|
||||
* binaries a Grok export ships belong to its `media_posts` rather than to any
|
||||
* conversation, so nothing is lost by this path not resolving assets.
|
||||
*
|
||||
* @param {object} jsonData - Grok export object keyed by `conversations`.
|
||||
* @param {string} requestUserId - The ID of the user who initiated the import process.
|
||||
* @param {Function} builderFactory - Factory function to create a new import batch builder instance.
|
||||
* @param {string} [userRole] - The role of the importing user.
|
||||
* @returns {Promise<void>} Promise that resolves when all conversations have been imported.
|
||||
*/
|
||||
async function importGrokConvo(
|
||||
jsonData,
|
||||
requestUserId,
|
||||
builderFactory = createImportBatchBuilder,
|
||||
userRole,
|
||||
) {
|
||||
try {
|
||||
const importBatchBuilder = builderFactory(requestUserId);
|
||||
const defaultModel = await resolveImportDefaultModel({
|
||||
endpoint: GROK_ENDPOINT,
|
||||
requestUserId,
|
||||
userRole,
|
||||
});
|
||||
|
||||
for (const entry of jsonData.conversations) {
|
||||
const converted = convertGrokConversation(entry, {
|
||||
defaultModel: defaultModel || openAISettings.model.default,
|
||||
});
|
||||
|
||||
importBatchBuilder.startConversation(GROK_ENDPOINT);
|
||||
for (const message of converted.messages) {
|
||||
importBatchBuilder.saveMessage(toSaveMessageDetails(message, GROK_ENDPOINT));
|
||||
}
|
||||
importBatchBuilder.finishConversation(
|
||||
converted.title,
|
||||
converted.createdAt,
|
||||
{
|
||||
isArchived: converted.isArchived,
|
||||
pinned: converted.pinned,
|
||||
model: converted.model,
|
||||
importedFrom: { source: GROK_SOURCE, externalId: converted.externalId },
|
||||
},
|
||||
converted.model,
|
||||
);
|
||||
}
|
||||
|
||||
await importBatchBuilder.saveBatch();
|
||||
logger.info(`user: ${requestUserId} | Grok conversation imported`);
|
||||
} catch (error) {
|
||||
logger.error(`user: ${requestUserId} | Error creating conversation from Grok file`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a LibreChat conversation from JSON.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1665,3 +1665,65 @@ describe('importClaudeConvo', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importGrokConvo', () => {
|
||||
const { EXPORT: grokExport } = require('~/test/grokExport');
|
||||
|
||||
it('routes a Grok export object to the Grok importer', () => {
|
||||
expect(getImporter(grokExport)).toBe(getImporter(grokExport));
|
||||
expect(() => getImporter({ conversations: [] })).toThrow('Unsupported import type');
|
||||
});
|
||||
|
||||
it('imports every conversation on the OpenAI endpoint with per-message models', async () => {
|
||||
const requestUserId = 'user-123';
|
||||
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
|
||||
jest.spyOn(importBatchBuilder, 'saveMessage');
|
||||
jest.spyOn(importBatchBuilder, 'startConversation');
|
||||
jest.spyOn(importBatchBuilder, 'finishConversation');
|
||||
|
||||
const importer = getImporter(grokExport);
|
||||
await importer(grokExport, requestUserId, () => importBatchBuilder);
|
||||
|
||||
expect(importBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.openAI);
|
||||
/** Six responses, one of them an aborted generation with no text. */
|
||||
expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(5);
|
||||
|
||||
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
|
||||
const userMsg = savedMessages.find((msg) => msg.text === 'Where should I stay?');
|
||||
expect(userMsg.isCreatedByUser).toBe(true);
|
||||
expect(userMsg.sender).toBe('user');
|
||||
expect(userMsg.endpoint).toBe(EModelEndpoint.openAI);
|
||||
/** A blank model on a human turn falls back rather than persisting empty. */
|
||||
expect(userMsg.model).toBe(openAISettings.model.default);
|
||||
|
||||
const upperCaseSender = savedMessages.find((msg) => msg.text === 'Positano.');
|
||||
expect(upperCaseSender.isCreatedByUser).toBe(false);
|
||||
expect(upperCaseSender.sender).toBe('Grok 4.1 Thinking');
|
||||
expect(upperCaseSender.model).toBe('grok-4-1-thinking-1129');
|
||||
expect(upperCaseSender.parentMessageId).toBe(userMsg.messageId);
|
||||
|
||||
const sibling = savedMessages.find((msg) => msg.text === 'Ravello.');
|
||||
expect(sibling.parentMessageId).toBe(userMsg.messageId);
|
||||
|
||||
expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith(
|
||||
'Amalfi trip planning',
|
||||
expect.any(Date),
|
||||
{
|
||||
isArchived: false,
|
||||
pinned: false,
|
||||
model: expect.any(String),
|
||||
importedFrom: { source: 'grok', externalId: 'grok-branched' },
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith(
|
||||
'Recovering a cut-off script',
|
||||
expect.any(Date),
|
||||
expect.objectContaining({
|
||||
pinned: true,
|
||||
importedFrom: { source: 'grok', externalId: 'grok-retried' },
|
||||
}),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
151
api/test/grokExport.js
Normal file
151
api/test/grokExport.js
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const JSZip = require('jszip');
|
||||
|
||||
const createdDirs = [];
|
||||
|
||||
/** The per-export uuid directory a real Grok archive nests everything under. */
|
||||
const EXPORT_UUID = 'e618db0b-b890-4728-b3ec-ab25abeb068b';
|
||||
const ENTRY_PATH = `ttl/30d/export_data/${EXPORT_UUID}/prod-grok-backend.json`;
|
||||
/** Real Grok archives write asset paths with a doubled separator after the
|
||||
* service name; mirrored so the archive layer sees production entry names. */
|
||||
const ASSET_PATH = `ttl/30d/export_data/${EXPORT_UUID}/prod-mc-asset-server//0380cd07/content`;
|
||||
|
||||
const EXPORT = {
|
||||
conversations: [
|
||||
{
|
||||
conversation: {
|
||||
id: 'grok-branched',
|
||||
title: 'Amalfi trip planning',
|
||||
summary: '',
|
||||
starred: false,
|
||||
create_time: '2026-07-14T03:25:09.401949Z',
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
/** No `parent_response_id` at all, and a blank model on a human turn. */
|
||||
response: {
|
||||
_id: 'g1',
|
||||
sender: 'human',
|
||||
model: '',
|
||||
message: 'Where should I stay?',
|
||||
create_time: { $date: { $numberLong: '1783999510820' } },
|
||||
},
|
||||
share_link: null,
|
||||
},
|
||||
{
|
||||
/** Uppercase sender, which a real export uses on 206 responses. */
|
||||
response: {
|
||||
_id: 'g2a',
|
||||
sender: 'ASSISTANT',
|
||||
model: 'grok-4-1-thinking-1129',
|
||||
parent_response_id: 'g1',
|
||||
message: 'Positano.',
|
||||
create_time: { $date: { $numberLong: '1783999520820' } },
|
||||
thinking_trace: '<xai:tool_usage_card>\n <xai:tool_name>web_search</xai:tool_name>\n',
|
||||
},
|
||||
share_link: null,
|
||||
},
|
||||
{
|
||||
response: {
|
||||
_id: 'g2b',
|
||||
sender: 'assistant',
|
||||
model: 'grok-3',
|
||||
parent_response_id: 'g1',
|
||||
message: 'Ravello.',
|
||||
create_time: { $date: { $numberLong: '1783999530820' } },
|
||||
},
|
||||
share_link: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
conversation: {
|
||||
id: 'grok-retried',
|
||||
title: '',
|
||||
summary: 'Recovering a cut-off script',
|
||||
starred: true,
|
||||
create_time: '2026-07-15T10:00:00.000000Z',
|
||||
},
|
||||
responses: [
|
||||
{
|
||||
response: {
|
||||
_id: 't1',
|
||||
sender: 'human',
|
||||
model: 'grok-4p3-0430-v2-s320-memory',
|
||||
message: 'Complete this script.',
|
||||
create_time: { $date: { $numberLong: '1784102400000' } },
|
||||
},
|
||||
share_link: null,
|
||||
},
|
||||
{
|
||||
/** An aborted generation with a child: skipped, child re-parented. */
|
||||
response: {
|
||||
_id: 't2',
|
||||
sender: 'ASSISTANT',
|
||||
model: 'grok-4-1-non-thinking-w-tool',
|
||||
parent_response_id: 't1',
|
||||
message: '',
|
||||
partial: true,
|
||||
create_time: { $date: { $numberLong: '1784102410000' } },
|
||||
},
|
||||
share_link: null,
|
||||
},
|
||||
{
|
||||
response: {
|
||||
_id: 't3',
|
||||
sender: 'assistant',
|
||||
model: 'grok-4-mini-thinking-tahoe',
|
||||
parent_response_id: 't2',
|
||||
message: 'Here is the completed script.',
|
||||
create_time: { $date: { $numberLong: '1784102420000' } },
|
||||
},
|
||||
share_link: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
projects: [{ workspace_id: 'w-1', name: 'New Workspace' }],
|
||||
tasks: [],
|
||||
media_posts: [{ id: '0380cd07', media_type: 'image', original_prompt: 'a cat' }],
|
||||
};
|
||||
|
||||
/** The export object on its own, as a bare `prod-grok-backend.json` upload. */
|
||||
function bareGrokExport() {
|
||||
return Buffer.from(JSON.stringify(EXPORT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal Grok export archive for route-level tests: the conversation file
|
||||
* nested under a per-export uuid, the two unrelated JSON files a real export
|
||||
* ships beside it, and one `media_posts` binary no conversation references.
|
||||
* @returns {Promise<string>} path to the written zip
|
||||
*/
|
||||
async function buildGrokExportZip() {
|
||||
const zip = new JSZip();
|
||||
|
||||
zip.file(ENTRY_PATH, JSON.stringify(EXPORT));
|
||||
zip.file(
|
||||
`ttl/30d/export_data/${EXPORT_UUID}/prod-mc-auth-mgmt-api.json`,
|
||||
JSON.stringify({ accounts: [{ email: 'fixture@example.com' }] }),
|
||||
);
|
||||
zip.file(`ttl/30d/export_data/${EXPORT_UUID}/prod-mc-billing.json`, JSON.stringify({}));
|
||||
zip.file(ASSET_PATH, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
|
||||
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-import-grok-route-'));
|
||||
createdDirs.push(dir);
|
||||
const filepath = path.join(dir, 'grok-export.zip');
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
function cleanupGrokExportZips() {
|
||||
while (createdDirs.length > 0) {
|
||||
const dir = createdDirs.pop();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { bareGrokExport, buildGrokExportZip, cleanupGrokExportZips, EXPORT };
|
||||
|
|
@ -14,6 +14,7 @@ const SOURCE_LABELS: Partial<Record<TImportSummary['source'], TranslationKeys>>
|
|||
chatgpt: 'com_ui_import_source_chatgpt',
|
||||
'chatgpt-legacy': 'com_ui_import_source_chatgpt',
|
||||
claude: 'com_ui_import_source_claude',
|
||||
grok: 'com_ui_import_source_grok',
|
||||
};
|
||||
|
||||
function formatAssetSize(bytes: number): string {
|
||||
|
|
|
|||
|
|
@ -179,6 +179,26 @@ describe('Import panel', () => {
|
|||
expect(screen.getByRole('heading', { name: /detected chatgpt export/i })).toHaveFocus();
|
||||
});
|
||||
|
||||
it('names every provider it can detect, and falls back to the raw source', () => {
|
||||
const labels: [TImportSummary['source'], RegExp][] = [
|
||||
['chatgpt', /detected chatgpt export/i],
|
||||
['chatgpt-legacy', /detected chatgpt export/i],
|
||||
['claude', /detected claude export/i],
|
||||
['grok', /detected grok export/i],
|
||||
['librechat', /detected librechat export/i],
|
||||
];
|
||||
|
||||
for (const [source, name] of labels) {
|
||||
dataProvider.useImportJobQuery.mockReturnValue({
|
||||
data: job({ summary: { ...summary(), source } }),
|
||||
});
|
||||
const { unmount } = render(<Import />);
|
||||
expect(screen.getByRole('heading', { name })).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toMatch(/com_ui_import_source/);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the confirm and cancel buttons accessible while busy', () => {
|
||||
dataProvider.useImportJobQuery.mockReturnValue({ data: job() });
|
||||
dataProvider.useStartImportMutation.mockReturnValue({ mutate: jest.fn(), isLoading: true });
|
||||
|
|
|
|||
|
|
@ -1305,6 +1305,7 @@
|
|||
"com_ui_import_report_assets": "{{0}} attachments imported, {{1}} unavailable",
|
||||
"com_ui_import_source_chatgpt": "ChatGPT",
|
||||
"com_ui_import_source_claude": "Claude",
|
||||
"com_ui_import_source_grok": "Grok",
|
||||
"com_ui_import_stat_archived": "{{0}} archived",
|
||||
"com_ui_import_stat_conversations": "{{0}} conversations",
|
||||
"com_ui_import_stat_starred": "{{0}} starred",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue