feat: add log download btn for LogViewer

Signed-off-by: Tanish Sharma <tanish.sharma@siemens.com>
This commit is contained in:
Tanish Sharma
2025-11-28 09:12:32 +00:00
parent c8ae765724
commit 470f72d835
13 changed files with 228 additions and 2 deletions
@@ -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]);
};