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();
+
+ 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();
+
+ // 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();
+
+ 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();
+ });
});
diff --git a/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx b/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx
index b88d96510e..e8396167fc 100644
--- a/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx
+++ b/plugins/scaffolder-react/src/next/components/TaskLogStream/TaskLogStream.tsx
@@ -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 (
l.join('\n'))
.filter(Boolean)
diff --git a/plugins/scaffolder-react/src/next/hooks/useDownloadLogs.ts b/plugins/scaffolder-react/src/next/hooks/useDownloadLogs.ts
new file mode 100644
index 0000000000..1399e2f285
--- /dev/null
+++ b/plugins/scaffolder-react/src/next/hooks/useDownloadLogs.ts
@@ -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]);
+};
diff --git a/yarn.lock b/yarn.lock
index 7d27834ec3..f90fa64053 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7084,6 +7084,7 @@ __metadata:
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
+ react-router: ^6.30.3
react-router-dom: ^6.30.2
peerDependenciesMeta:
"@types/react":