feat: add log download btn for LogViewer
Signed-off-by: Tanish Sharma <tanish.sharma@siemens.com>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { ReactNode } from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TaskLogStream } from './TaskLogStream';
|
||||
|
||||
// The <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
|
||||
@@ -25,6 +26,22 @@ jest.mock('react-virtualized-auto-sizer', () => ({
|
||||
}) => <>{props.children({ width: 400, height: 200 })}</>,
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
Reflect.defineProperty(window.URL, 'createObjectURL', {
|
||||
writable: true,
|
||||
value: jest.fn((_blob: any) => 'blob:mock-url'),
|
||||
});
|
||||
Reflect.defineProperty(window.URL, 'revokeObjectURL', {
|
||||
writable: true,
|
||||
value: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(window.URL, 'createObjectURL');
|
||||
Reflect.deleteProperty(window.URL, 'revokeObjectURL');
|
||||
});
|
||||
|
||||
describe('TaskLogStream', () => {
|
||||
it('should render a log stream with the correct log lines', async () => {
|
||||
const logs = { step: ['line 1', 'line 2'], step2: ['line 3'] };
|
||||
@@ -47,4 +64,99 @@ describe('TaskLogStream', () => {
|
||||
|
||||
await expect(findAllByRole('row')).resolves.toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render download button', async () => {
|
||||
const logs = { step: ['line 1', 'line 2'], step2: ['line 3'] };
|
||||
|
||||
const { getByRole } = await renderInTestApp(<TaskLogStream logs={logs} />);
|
||||
|
||||
const downloadButton = getByRole('button', { name: /download/i });
|
||||
expect(downloadButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should download logs when download button is clicked', async () => {
|
||||
const logs = {
|
||||
step1: ['line 1', 'line 2'],
|
||||
step2: ['line 3', 'line 4'],
|
||||
};
|
||||
|
||||
const { getByRole } = await renderInTestApp(<TaskLogStream logs={logs} />);
|
||||
|
||||
// Mock only the anchor element creation
|
||||
const mockAnchor = document.createElement('a');
|
||||
const clickSpy = jest.spyOn(mockAnchor, 'click');
|
||||
const removeSpy = jest.spyOn(mockAnchor, 'remove');
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tagName: string) => {
|
||||
if (tagName === 'a') {
|
||||
return mockAnchor;
|
||||
}
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
const downloadButton = getByRole('button', { name: /download/i });
|
||||
await userEvent.click(downloadButton);
|
||||
|
||||
// Verify file download was triggered
|
||||
expect(createElementSpy).toHaveBeenCalledWith('a');
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
|
||||
// Verify the download filename contains .log extension
|
||||
expect(mockAnchor.download).toMatch(/\.log$/);
|
||||
|
||||
// Verify href was set
|
||||
expect(mockAnchor.href).toContain('blob:');
|
||||
|
||||
// Restore mocks
|
||||
createElementSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should create blob with correct log content when downloading', async () => {
|
||||
const logs = {
|
||||
step1: ['line 1', 'line 2'],
|
||||
step2: ['line 3'],
|
||||
};
|
||||
|
||||
const { getByRole } = await renderInTestApp(<TaskLogStream logs={logs} />);
|
||||
|
||||
let capturedBlob: Blob | null = null;
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockImplementation((blob: any) => {
|
||||
capturedBlob = blob;
|
||||
return 'blob:mock-url';
|
||||
});
|
||||
|
||||
const mockAnchor = document.createElement('a');
|
||||
const clickSpy = jest.spyOn(mockAnchor, 'click');
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tagName: string) => {
|
||||
if (tagName === 'a') {
|
||||
return mockAnchor;
|
||||
}
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
const downloadButton = getByRole('button', { name: /download/i });
|
||||
await userEvent.click(downloadButton);
|
||||
|
||||
// Verify file download was triggered
|
||||
expect(createElementSpy).toHaveBeenCalledWith('a');
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
|
||||
// Verify blob was created with correct content
|
||||
expect(capturedBlob).toBeInstanceOf(Blob);
|
||||
expect(capturedBlob!?.type).toBe('text/plain');
|
||||
|
||||
// Restore mocks
|
||||
createElementSpy.mockRestore();
|
||||
createObjectURLSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { LogViewer } from '@backstage/core-components';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { useDownloadLogs } from '../../hooks/useDownloadLogs';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -32,9 +33,13 @@ const useStyles = makeStyles({
|
||||
*/
|
||||
export const TaskLogStream = (props: { logs: { [k: string]: string[] } }) => {
|
||||
const styles = useStyles();
|
||||
|
||||
const onDownloadLogs = useDownloadLogs(props.logs);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<LogViewer
|
||||
onDownloadLog={onDownloadLogs}
|
||||
text={Object.values(props.logs)
|
||||
.map(l => l.join('\n'))
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
export const useDownloadLogs = (logs: { [k: string]: string[] }) => {
|
||||
const { taskId } = useParams<{ taskId: string }>();
|
||||
return useCallback(() => {
|
||||
const element = document.createElement('a');
|
||||
const file = new Blob(
|
||||
[
|
||||
Object.values(logs)
|
||||
.map(l => l.join('\n'))
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
],
|
||||
{ type: 'text/plain' },
|
||||
);
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `${taskId}.log`;
|
||||
element.click();
|
||||
URL.revokeObjectURL(element.href);
|
||||
element.remove();
|
||||
}, [logs, taskId]);
|
||||
};
|
||||
Reference in New Issue
Block a user