From c4b68176828433d136fb10332915dd9e4606df33 Mon Sep 17 00:00:00 2001 From: Florent Benoit Date: Thu, 14 Sep 2023 10:58:04 +0200 Subject: [PATCH] feat: use tty for containers started with TTY/interactive mode note: if you start with interactive mode, if you start a container, you are redirected to the tty tab of the container if you click on the details of a container, you're redirected to the tty tab if the container has been launched using tty/interactive else you see the logs In the details of a container, tty tab is only available if container is started with tty/interactive mode else it's hidden. fixes https://github.com/containers/podman-desktop/issues/970 Signed-off-by: Florent Benoit --- .../renderer/src/lib/ContainerList.svelte | 2 +- .../lib/container/ContainerDetails.spec.ts | 92 +++++++++++++- .../src/lib/container/ContainerDetails.svelte | 32 +++++ .../ContainerDetailsTtyTerminal.spec.ts | 82 +++++++++++++ .../ContainerDetailsTtyTerminal.svelte | 112 ++++++++++++++++++ .../renderer/src/lib/image/RunImage.spec.ts | 72 ++++++++++- .../renderer/src/lib/image/RunImage.svelte | 28 ++++- tests/src/model/pages/run-image-page.ts | 10 +- 8 files changed, 418 insertions(+), 12 deletions(-) create mode 100644 packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.spec.ts create mode 100644 packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.svelte diff --git a/packages/renderer/src/lib/ContainerList.svelte b/packages/renderer/src/lib/ContainerList.svelte index b4d63972d8df..80ac8846d7e7 100644 --- a/packages/renderer/src/lib/ContainerList.svelte +++ b/packages/renderer/src/lib/ContainerList.svelte @@ -326,7 +326,7 @@ onDestroy(() => { }); function openDetailsContainer(container: ContainerInfoUI) { - router.goto(`/containers/${container.id}/logs`); + router.goto(`/containers/${container.id}/`); } function keydownChoice(e: KeyboardEvent) { diff --git a/packages/renderer/src/lib/container/ContainerDetails.spec.ts b/packages/renderer/src/lib/container/ContainerDetails.spec.ts index d3d1e22b9163..6760fb76e492 100644 --- a/packages/renderer/src/lib/container/ContainerDetails.spec.ts +++ b/packages/renderer/src/lib/container/ContainerDetails.spec.ts @@ -17,7 +17,7 @@ ***********************************************************************/ import '@testing-library/jest-dom/vitest'; -import { test, expect, vi, beforeAll } from 'vitest'; +import { test, expect, vi, beforeAll, beforeEach } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/svelte'; import ContainerDetails from './ContainerDetails.svelte'; @@ -30,6 +30,8 @@ import { lastPage } from '/@/stores/breadcrumb'; const listContainersMock = vi.fn(); +const getContainerInspectMock = vi.fn(); + const myContainer: ContainerInfo = { Id: 'myContainer', Labels: {}, @@ -49,12 +51,93 @@ const myContainer: ContainerInfo = { const deleteContainerMock = vi.fn(); +vi.mock('xterm', () => { + return { + Terminal: vi.fn().mockReturnValue({ loadAddon: vi.fn(), open: vi.fn(), write: vi.fn(), clear: vi.fn() }), + }; +}); + beforeAll(() => { (window as any).listContainers = listContainersMock; (window as any).deleteContainer = deleteContainerMock; + (window as any).getContainerInspect = getContainerInspectMock; + + (window as any).getConfigurationValue = vi.fn().mockReturnValue(12); + + (window as any).logsContainer = vi.fn(); + (window as any).matchMedia = vi.fn().mockReturnValue({ + addListener: vi.fn(), + }); + (window as any).ResizeObserver = vi.fn().mockReturnValue({ observe: vi.fn(), unobserve: vi.fn() }); +}); + +beforeEach(() => {}); + +test('Expect logs when tty is not enabled', async () => { + router.goto('/'); + + containersInfos.set([myContainer]); + + // spy router.goto + const routerGotoSpy = vi.spyOn(router, 'goto'); + + getContainerInspectMock.mockResolvedValue({ + Config: { + Tty: false, + }, + }); + + // render the component + render(ContainerDetails, { containerID: 'myContainer' }); + + // wait router.goto is called + while (routerGotoSpy.mock.calls.length === 0) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // grab current route and check we have been redirected to tty + const currentRoute = window.location; + expect(currentRoute.href).toBe('http://localhost:3000/logs'); + + expect(routerGotoSpy).toBeCalledWith('/logs'); +}); + +test('Expect show tty if container has tty enabled', async () => { + router.goto('/'); + + containersInfos.set([myContainer]); + + // spy router.goto + const routerGotoSpy = vi.spyOn(router, 'goto'); + + getContainerInspectMock.mockResolvedValue({ + Config: { + Tty: true, + OpenStdin: true, + }, + }); + + // render the component + render(ContainerDetails, { containerID: 'myContainer' }); + + // wait router.goto is called + while (routerGotoSpy.mock.calls.length === 0) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // grab current route and check we have been redirected to tty + const currentRoute = window.location; + expect(currentRoute.href).toBe('http://localhost:3000/tty'); + + expect(routerGotoSpy).toBeCalledWith('/tty'); }); test('Expect redirect to previous page if container is deleted', async () => { + router.goto('/'); + + getContainerInspectMock.mockResolvedValue({ + Config: {}, + }); const routerGotoSpy = vi.spyOn(router, 'goto'); listContainersMock.mockResolvedValue([myContainer]); window.dispatchEvent(new CustomEvent('extensions-already-started')); @@ -74,9 +157,14 @@ test('Expect redirect to previous page if container is deleted', async () => { // render the component render(ContainerDetails, { containerID: 'myContainer' }); + // wait router.goto is called + while (routerGotoSpy.mock.calls.length === 0) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + // grab current route const currentRoute = window.location; - expect(currentRoute.href).toBe('http://localhost:3000/'); + expect(currentRoute.href).toBe('http://localhost:3000/logs'); // click on delete container button const deleteButton = screen.getByRole('button', { name: 'Delete Container' }); diff --git a/packages/renderer/src/lib/container/ContainerDetails.svelte b/packages/renderer/src/lib/container/ContainerDetails.svelte index 43cf49dc8a71..2d2a776e8197 100644 --- a/packages/renderer/src/lib/container/ContainerDetails.svelte +++ b/packages/renderer/src/lib/container/ContainerDetails.svelte @@ -18,19 +18,45 @@ import ContainerStatistics from './ContainerStatistics.svelte'; import DetailsPage from '../ui/DetailsPage.svelte'; import Tab from '../ui/Tab.svelte'; import ErrorMessage from '../ui/ErrorMessage.svelte'; +import ContainerDetailsTtyTerminal from './ContainerDetailsTtyTerminal.svelte'; +import { router } from 'tinro'; export let containerID: string; let container: ContainerInfoUI; + let detailsPage: DetailsPage; +let displayTty = false; + +// update current route scheme +let currentRouterPath: string; + onMount(() => { const containerUtils = new ContainerUtils(); + + router.subscribe(route => { + currentRouterPath = route.path; + }); + // loading container info return containersInfos.subscribe(containers => { const matchingContainer = containers.find(c => c.Id === containerID); if (matchingContainer) { container = containerUtils.getContainerInfoUI(matchingContainer); + + // look if tty is supported by this container + window.getContainerInspect(container.engineId, container.id).then(inspect => { + displayTty = (inspect.Config.Tty || false) && (inspect.Config.OpenStdin || false); + // if we comes with a / redirect to /logs or to /tty if tty is supported + if (currentRouterPath.endsWith('/')) { + if (displayTty) { + router.goto(`${currentRouterPath}tty`); + } else { + router.goto(`${currentRouterPath}logs`); + } + } + }); } else if (detailsPage) { // the container has been deleted detailsPage.close(); @@ -80,6 +106,9 @@ function errorCallback(errorMessage: string): void { {/if} + {#if displayTty} + + {/if} @@ -97,6 +126,9 @@ function errorCallback(errorMessage: string): void { + + + {/if} diff --git a/packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.spec.ts b/packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.spec.ts new file mode 100644 index 000000000000..6f3f184e7c71 --- /dev/null +++ b/packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.spec.ts @@ -0,0 +1,82 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import '@testing-library/jest-dom/vitest'; +import { test, expect, vi, beforeAll } from 'vitest'; +import { render, waitFor } from '@testing-library/svelte'; +import type { ContainerInfoUI } from './ContainerInfoUI'; +import ContainerDetailsTtyTerminal from './ContainerDetailsTtyTerminal.svelte'; + +const getConfigurationValueMock = vi.fn(); +const attachContainerMock = vi.fn(); + +beforeAll(() => { + (window as any).getConfigurationValue = getConfigurationValueMock; + (window as any).attachContainer = attachContainerMock; + (window as any).attachContainerSend = vi.fn(); + + (window as any).matchMedia = vi.fn().mockReturnValue({ + addListener: vi.fn(), + }); +}); + +test('expect being able to attach terminal ', async () => { + const container: ContainerInfoUI = { + id: 'myContainer', + state: 'RUNNING', + engineId: 'podman', + } as unknown as ContainerInfoUI; + + let onDataCallback: (data: Buffer) => void = () => {}; + + const sendCallbackId = 12345; + attachContainerMock.mockImplementation( + ( + _engineId: string, + _containerId: string, + onData: (data: Buffer) => void, + _onError: (error: string) => void, + _onEnd: () => void, + ) => { + onDataCallback = onData; + // return a callback id + return sendCallbackId; + }, + ); + + // render the component with a terminal + const renderObject = render(ContainerDetailsTtyTerminal, { container, screenReaderMode: true }); + + // wait attachContainerMock is called + await waitFor(() => expect(attachContainerMock).toHaveBeenCalled()); + + // write some data on the terminal + onDataCallback(Buffer.from('hello\nworld')); + + // wait 1s + await new Promise(resolve => setTimeout(resolve, 1000)); + + // search a div having aria-live="assertive" attribute + const terminalLinesLiveRegion = renderObject.container.querySelector('div[aria-live="assertive"]'); + + // check the content + expect(terminalLinesLiveRegion).toHaveTextContent('hello world'); + + // check we have called attachContainer + expect(attachContainerMock).toHaveBeenCalledTimes(1); +}); diff --git a/packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.svelte b/packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.svelte new file mode 100644 index 000000000000..0cb7223406c0 --- /dev/null +++ b/packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.svelte @@ -0,0 +1,112 @@ + + +
+ +