mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
test(import): cover drag and drop, button wiring, and fixture structures
Three gaps where the implementation could break with a green suite. The drag listeners and their enter/leave depth counter had no test at all, so replacing the counter with a boolean passed everything. The Confirm and Cancel buttons were only asserted on for labels and disabled state, so the panel could have called mutate with a stale id or nothing at all. And the fixtures' own specs only checked archive layout, so deleting the U+E202 citation marker - which renders as nothing in an editor and in a diff - would have left every citation assertion downstream passing vacuously.
This commit is contained in:
parent
c58f6c54ce
commit
5e9aae7784
3 changed files with 286 additions and 4 deletions
|
|
@ -0,0 +1,107 @@
|
|||
import { render, screen, fireEvent } from 'test/layout-test-utils';
|
||||
import Dropzone from '../Dropzone';
|
||||
|
||||
function transfer(files: File[] = [], types: string[] = ['Files']) {
|
||||
return { types, files };
|
||||
}
|
||||
|
||||
const zip = () => new File(['zip-bytes'], 'export.zip', { type: 'application/zip' });
|
||||
|
||||
/** The tint that appears over the settings row while a file is dragged across
|
||||
* it. Selected on `pointer-events-none` rather than `aria-hidden` alone: the
|
||||
* button's decorative icon is also aria-hidden, and an SVG's `className` is an
|
||||
* `SVGAnimatedString` rather than a string. */
|
||||
function overlayOf(container: HTMLElement): HTMLElement {
|
||||
const overlay = container.querySelector('div[aria-hidden="true"].pointer-events-none');
|
||||
if (!overlay) {
|
||||
throw new Error('drag overlay not found');
|
||||
}
|
||||
return overlay as HTMLElement;
|
||||
}
|
||||
|
||||
describe('Dropzone drag and drop', () => {
|
||||
it('imports a dropped export', () => {
|
||||
const onFile = jest.fn();
|
||||
const file = zip();
|
||||
const { container } = render(<Dropzone onFile={onFile} isUploading={false} />);
|
||||
const zone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: transfer([file]) });
|
||||
|
||||
expect(onFile).toHaveBeenCalledWith(file);
|
||||
});
|
||||
|
||||
/**
|
||||
* The counter is load-bearing: dragenter and dragleave both fire for every
|
||||
* child element crossed, so a plain boolean flickers the overlay off as soon
|
||||
* as the pointer moves between the label and the button. Replacing the
|
||||
* counter with a boolean passes every other test in this file.
|
||||
*/
|
||||
it('keeps the overlay up while the pointer crosses a child element', () => {
|
||||
const { container } = render(<Dropzone onFile={jest.fn()} isUploading={false} />);
|
||||
const zone = container.firstChild as HTMLElement;
|
||||
const child = screen.getByRole('button');
|
||||
const overlay = () => overlayOf(container);
|
||||
|
||||
fireEvent.dragEnter(zone, { dataTransfer: transfer() });
|
||||
expect(overlay().className).toContain('opacity-100');
|
||||
|
||||
fireEvent.dragEnter(child, { dataTransfer: transfer() });
|
||||
fireEvent.dragLeave(child, { dataTransfer: transfer() });
|
||||
expect(overlay().className).toContain('opacity-100');
|
||||
|
||||
fireEvent.dragLeave(zone, { dataTransfer: transfer() });
|
||||
expect(overlay().className).toContain('opacity-0');
|
||||
});
|
||||
|
||||
it('ignores a drag that carries no files', () => {
|
||||
const onFile = jest.fn();
|
||||
const { container } = render(<Dropzone onFile={onFile} isUploading={false} />);
|
||||
const zone = container.firstChild as HTMLElement;
|
||||
const overlay = () => overlayOf(container);
|
||||
|
||||
fireEvent.dragEnter(zone, { dataTransfer: transfer([], ['text/plain']) });
|
||||
expect(overlay().className).toContain('opacity-0');
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: transfer([], ['text/plain']) });
|
||||
expect(onFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A drop accepted mid-upload would start a second, concurrent import of a
|
||||
* different archive over the top of the first. */
|
||||
it('accepts no drop while an upload is already in flight', () => {
|
||||
const onFile = jest.fn();
|
||||
const { container } = render(<Dropzone onFile={onFile} isUploading />);
|
||||
const zone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: transfer([zip()]) });
|
||||
|
||||
expect(onFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('imports a file chosen through the button', () => {
|
||||
const onFile = jest.fn();
|
||||
const file = zip();
|
||||
const { container } = render(<Dropzone onFile={onFile} isUploading={false} />);
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
expect(onFile).toHaveBeenCalledWith(file);
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('recovers after a drag that ends outside the row', () => {
|
||||
const onFile = jest.fn();
|
||||
const { container } = render(<Dropzone onFile={onFile} isUploading={false} />);
|
||||
const zone = container.firstChild as HTMLElement;
|
||||
const overlay = () => overlayOf(container);
|
||||
|
||||
fireEvent.dragEnter(zone, { dataTransfer: transfer() });
|
||||
fireEvent.dragLeave(zone, { dataTransfer: transfer() });
|
||||
expect(overlay().className).toContain('opacity-0');
|
||||
|
||||
fireEvent.dragEnter(zone, { dataTransfer: transfer() });
|
||||
expect(overlay().className).toContain('opacity-100');
|
||||
});
|
||||
});
|
||||
|
|
@ -62,6 +62,8 @@ describe('Import panel', () => {
|
|||
let capturedStartOptions: { onError?: (error: unknown) => void } = {};
|
||||
let showToast: jest.Mock;
|
||||
let uploadMutate: jest.Mock;
|
||||
let startMutate: jest.Mock;
|
||||
let cancelMutate: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedUploadOptions = {};
|
||||
|
|
@ -77,13 +79,18 @@ describe('Import panel', () => {
|
|||
return { mutate: uploadMutate, isLoading: false };
|
||||
},
|
||||
);
|
||||
startMutate = jest.fn();
|
||||
cancelMutate = jest.fn();
|
||||
dataProvider.useStartImportMutation.mockImplementation(
|
||||
(options: typeof capturedStartOptions) => {
|
||||
capturedStartOptions = options ?? {};
|
||||
return { mutate: jest.fn(), isLoading: false };
|
||||
return { mutate: startMutate, isLoading: false };
|
||||
},
|
||||
);
|
||||
dataProvider.useCancelImportMutation.mockReturnValue({ mutate: jest.fn(), isLoading: false });
|
||||
dataProvider.useCancelImportMutation.mockReturnValue({
|
||||
mutate: cancelMutate,
|
||||
isLoading: false,
|
||||
});
|
||||
dataProvider.useImportJobQuery.mockReturnValue({ data: undefined });
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
|
@ -374,12 +381,54 @@ describe('Import panel', () => {
|
|||
|
||||
render(<Import />);
|
||||
/* Exactly once: the count is the <details> summary. It used to also be
|
||||
repeated in the status block above, which this assertion enshrined. */
|
||||
expect(screen.getAllByText(/1 items could not be imported/i)).toHaveLength(1);
|
||||
repeated in the status block above, which this assertion enshrined.
|
||||
Singular, because a single failure is one item, not "1 items". */
|
||||
expect(screen.getAllByText(/^1 item could not be imported$/i)).toHaveLength(1);
|
||||
expect(screen.getByText(/conversation 11 malformed/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/archive truncated/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pluralizes the error count for more than one failure', () => {
|
||||
dataProvider.useImportJobQuery.mockReturnValue({
|
||||
data: job({
|
||||
phase: 'failed',
|
||||
status: 'failed',
|
||||
report: {
|
||||
imported: 10,
|
||||
skipped: 0,
|
||||
assetsImported: 0,
|
||||
assetsUnavailable: 0,
|
||||
errors: ['conversation 11 malformed', 'conversation 12 malformed'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Import />);
|
||||
|
||||
expect(screen.getByText(/^2 items could not be imported$/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pluralizes the report counts, so a single conversation is not "1 conversations"', () => {
|
||||
dataProvider.useImportJobQuery.mockReturnValue({
|
||||
data: job({
|
||||
phase: 'completed',
|
||||
status: 'completed',
|
||||
report: {
|
||||
imported: 1,
|
||||
skipped: 0,
|
||||
assetsImported: 1,
|
||||
assetsUnavailable: 0,
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Import />);
|
||||
|
||||
expect(screen.getByText('1 conversation imported, 0 skipped')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 attachment imported, 0 unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves focus back to the import control after starting another import', () => {
|
||||
dataProvider.useImportJobQuery.mockReturnValue({
|
||||
data: job({
|
||||
|
|
@ -549,6 +598,66 @@ describe('Import panel', () => {
|
|||
expect(window.localStorage.getItem('importJobId')).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The buttons were only ever asserted on for their labels and disabled
|
||||
* state, so the panel could have called mutate with undefined, an empty
|
||||
* string, or a stale id and every test still passed. On a multi-minute
|
||||
* import that is a Cancel button that silently does nothing.
|
||||
*/
|
||||
it('starts the job the panel is currently tracking', () => {
|
||||
window.localStorage.setItem('importJobId', 'job-99');
|
||||
dataProvider.useImportJobQuery.mockReturnValue({ data: job() });
|
||||
|
||||
render(<Import />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^import$/i }));
|
||||
|
||||
expect(startMutate).toHaveBeenCalledWith('job-99');
|
||||
});
|
||||
|
||||
it('cancels the job it is tracking, from the confirmation screen', () => {
|
||||
window.localStorage.setItem('importJobId', 'job-99');
|
||||
dataProvider.useImportJobQuery.mockReturnValue({ data: job() });
|
||||
|
||||
render(<Import />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel import/i }));
|
||||
|
||||
expect(cancelMutate).toHaveBeenCalledWith('job-99');
|
||||
});
|
||||
|
||||
it('cancels the job it is tracking, mid-run', () => {
|
||||
window.localStorage.setItem('importJobId', 'job-99');
|
||||
dataProvider.useImportJobQuery.mockReturnValue({
|
||||
data: job({
|
||||
phase: 'conversations',
|
||||
status: 'active',
|
||||
progress: {
|
||||
conversations: { done: 10, total: 100 },
|
||||
messages: { done: 0, total: 0 },
|
||||
assets: { done: 0, total: 0 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Import />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel import/i }));
|
||||
|
||||
expect(cancelMutate).toHaveBeenCalledWith('job-99');
|
||||
});
|
||||
|
||||
it('starts the job returned by the upload, not a stale one', () => {
|
||||
render(<Import />);
|
||||
|
||||
/* Queued before the state change so the re-render the upload triggers
|
||||
already shows the confirmation screen. */
|
||||
dataProvider.useImportJobQuery.mockReturnValue({ data: job() });
|
||||
act(() => {
|
||||
capturedUploadOptions.onSuccess?.({ jobId: 'job-42', summary: summary() });
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /^import$/i }));
|
||||
|
||||
expect(startMutate).toHaveBeenCalledWith('job-42');
|
||||
});
|
||||
|
||||
it('shows a distinct toast for an unsupported file type', () => {
|
||||
render(<Import />);
|
||||
|
||||
|
|
|
|||
|
|
@ -37,3 +37,69 @@ describe('buildFixtureExport', () => {
|
|||
archive.close();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The fixture's structures, not its layout.
|
||||
*
|
||||
* `CITE` is U+E202, which renders as nothing in an editor and in a diff, so
|
||||
* "tidying" it away is a plausible accident rather than a hypothetical one —
|
||||
* and every citation assertion downstream would still pass, because they match
|
||||
* on the text before the marker. These guards fail loudly instead.
|
||||
*/
|
||||
describe('fixture structures every downstream test depends on', () => {
|
||||
async function readShard(name: string): Promise<Array<Record<string, never>>> {
|
||||
const filepath = await buildFixtureExport();
|
||||
const archive = await openArchive(filepath);
|
||||
try {
|
||||
return JSON.parse((await archive.read(name)).toString('utf8'));
|
||||
} finally {
|
||||
archive.close();
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps a real citation anchor with its content reference', async () => {
|
||||
const shard = await readShard('conversations-000.json');
|
||||
const mapping = (shard[0] as unknown as { mapping: Record<string, { message?: unknown }> })
|
||||
.mapping;
|
||||
const cited = (mapping.a1 as { message: { content: { parts: string[] }; metadata: unknown } })
|
||||
.message;
|
||||
|
||||
expect(cited.content.parts[0]).toBe('Stay in Positano.turn0search0');
|
||||
expect(cited.content.parts[0]).toContain('');
|
||||
expect((cited.metadata as { content_references: unknown[] }).content_references).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the thoughts to reasoning_recap chain the thinking part is built from', async () => {
|
||||
const shard = await readShard('conversations-000.json');
|
||||
const mapping = (
|
||||
shard[0] as unknown as {
|
||||
mapping: Record<string, { message: { content: { content_type: string } } }>;
|
||||
}
|
||||
).mapping;
|
||||
|
||||
expect(mapping.t1.message.content.content_type).toBe('thoughts');
|
||||
expect(mapping.r1.message.content.content_type).toBe('reasoning_recap');
|
||||
});
|
||||
|
||||
it('keeps the multimodal parts the asset pipeline resolves', async () => {
|
||||
const shard = await readShard('conversations-001.json');
|
||||
const mapping = (
|
||||
shard[0] as unknown as {
|
||||
mapping: Record<
|
||||
string,
|
||||
{ message: { content: { parts: Array<{ content_type?: string }> } } }
|
||||
>;
|
||||
}
|
||||
).mapping;
|
||||
|
||||
const types = Object.values(mapping)
|
||||
.flatMap((node) => node.message?.content?.parts ?? [])
|
||||
.map((part) => (typeof part === 'string' ? 'text' : part.content_type));
|
||||
|
||||
expect(types).toContain('image_asset_pointer');
|
||||
expect(types).toContain('audio_transcription');
|
||||
expect(types).toContain('audio_asset_pointer');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue