Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use tty for containers started with TTY/interactive mode #3900

Merged
merged 1 commit into from Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/renderer/src/lib/ContainerList.svelte
Expand Up @@ -326,7 +326,7 @@ onDestroy(() => {
});

function openDetailsContainer(container: ContainerInfoUI) {
router.goto(`/containers/${container.id}/logs`);
router.goto(`/containers/${container.id}/`);
}

function keydownChoice(e: KeyboardEvent) {
Expand Down
92 changes: 90 additions & 2 deletions packages/renderer/src/lib/container/ContainerDetails.spec.ts
Expand Up @@ -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';
Expand All @@ -30,6 +30,8 @@ import { lastPage } from '/@/stores/breadcrumb';

const listContainersMock = vi.fn();

const getContainerInspectMock = vi.fn();

const myContainer: ContainerInfo = {
Id: 'myContainer',
Labels: {},
Expand All @@ -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'));
Expand All @@ -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' });
Expand Down
32 changes: 32 additions & 0 deletions packages/renderer/src/lib/container/ContainerDetails.svelte
Expand Up @@ -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();
Expand Down Expand Up @@ -80,6 +106,9 @@ function errorCallback(errorMessage: string): void {
<Tab title="Kube" url="kube" />
{/if}
<Tab title="Terminal" url="terminal" />
{#if displayTty}
<Tab title="Tty" url="tty" />
{/if}
</svelte:fragment>
<svelte:fragment slot="content">
<Route path="/summary" breadcrumb="Summary" navigationHint="tab">
Expand All @@ -97,6 +126,9 @@ function errorCallback(errorMessage: string): void {
<Route path="/terminal" breadcrumb="Terminal" navigationHint="tab">
<ContainerDetailsTerminal container="{container}" />
</Route>
<Route path="/tty" breadcrumb="Tty" navigationHint="tab">
<ContainerDetailsTtyTerminal container="{container}" />
</Route>
</svelte:fragment>
</DetailsPage>
{/if}
@@ -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);
});
@@ -0,0 +1,112 @@
<script lang="ts">
import type { ContainerInfoUI } from './ContainerInfoUI';
import { TerminalSettings } from '../../../../main/src/plugin/terminal-settings';
import { router } from 'tinro';
import { onDestroy, onMount } from 'svelte';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { getPanelDetailColor } from '../color/color';
import EmptyScreen from '../ui/EmptyScreen.svelte';
import NoLogIcon from '../ui/NoLogIcon.svelte';

export let container: ContainerInfoUI;
export let screenReaderMode = false;
let terminalXtermDiv: HTMLDivElement;
let attachContainerTerminal: Terminal;
let currentRouterPath: string;
let closed = false;

// update current route scheme
router.subscribe(route => {
currentRouterPath = route.path;
});

// update terminal when receiving data
function receiveDataCallback(data: Buffer) {
attachContainerTerminal.write(data.toString());
}

function receiveEndCallback() {
closed = true;
}

// call exec command
async function attachToContainer() {
if (container.state !== 'RUNNING') {
return;
}

// attach to the container
const callbackId = await window.attachContainer(
container.engineId,
container.id,
receiveDataCallback,
() => {},
receiveEndCallback,
);

// pass data from xterm to container
attachContainerTerminal?.onData(data => {
window.attachContainerSend(callbackId, data);
});
}

// refresh
async function refreshTerminal() {
// missing element, return
if (!terminalXtermDiv) {
return;
}

// grab font size
const fontSize = await window.getConfigurationValue<number>(
TerminalSettings.SectionName + '.' + TerminalSettings.FontSize,
);
const lineHeight = await window.getConfigurationValue<number>(
TerminalSettings.SectionName + '.' + TerminalSettings.LineHeight,
);

attachContainerTerminal = new Terminal({
fontSize,
lineHeight,
screenReaderMode,
theme: {
background: getPanelDetailColor(),
},
});

const fitAddon = new FitAddon();
attachContainerTerminal.loadAddon(fitAddon);

attachContainerTerminal.open(terminalXtermDiv);

// call fit addon each time we resize the window
window.addEventListener('resize', () => {
if (currentRouterPath === `/containers/${container.id}/tty-terminal`) {
fitAddon.fit();
}
});
fitAddon.fit();
}
onMount(async () => {
await refreshTerminal();
await attachToContainer();
});

onDestroy(() => {});
</script>

<div class="h-full" bind:this="{terminalXtermDiv}" class:hidden="{container.state !== 'RUNNING'}"></div>

<EmptyScreen
hidden="{!closed && !(container.state === 'RUNNING')}"
icon="{NoLogIcon}"
title="No TTY"
message="Tty has stopped" />

<EmptyScreen
hidden="{container.state === 'RUNNING'}"
icon="{NoLogIcon}"
title="No TTY"
message="Container is not running" />