diff --git a/packages/renderer/src/lib/ContainerList.svelte b/packages/renderer/src/lib/ContainerList.svelte
index b4d63972d8df8..80ac8846d7e73 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 d3d1e22b9163e..6760fb76e4929 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 43cf49dc8a715..2d2a776e81975 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 0000000000000..6f3f184e7c71b
--- /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 0000000000000..0cb7223406c08
--- /dev/null
+++ b/packages/renderer/src/lib/container/ContainerDetailsTtyTerminal.svelte
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
diff --git a/packages/renderer/src/lib/image/RunImage.spec.ts b/packages/renderer/src/lib/image/RunImage.spec.ts
index 2e73f5b1bb18e..9abe2753ecfa9 100644
--- a/packages/renderer/src/lib/image/RunImage.spec.ts
+++ b/packages/renderer/src/lib/image/RunImage.spec.ts
@@ -19,13 +19,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import '@testing-library/jest-dom/vitest';
-import { test, vi, type Mock, beforeAll, describe, expect } from 'vitest';
+import { test, vi, type Mock, beforeAll, describe, expect, beforeEach } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { runImageInfo } from '../../stores/run-image-store';
import RunImage from '/@/lib/image/RunImage.svelte';
import type { ImageInspectInfo } from '../../../../main/src/plugin/api/image-inspect-info';
import { mockBreadcrumb } from '../../stores/breadcrumb';
import userEvent from '@testing-library/user-event';
+import { router } from 'tinro';
// fake the window.events object
beforeAll(() => {
@@ -38,17 +39,21 @@ beforeAll(() => {
(window as any).getImageInspect = vi.fn();
(window as any).listNetworks = vi.fn().mockResolvedValue([]);
(window as any).listContainers = vi.fn().mockResolvedValue([]);
- (window as any).createAndStartContainer = vi.fn();
+ (window as any).createAndStartContainer = vi.fn().mockResolvedValue({ id: '1234' });
mockBreadcrumb();
});
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
async function waitRender() {
const result = render(RunImage);
//wait until dataReady is true
- while (result.component.$$.ctx[29] !== true) {
- await new Promise(resolve => setTimeout(resolve, 100));
+ while (result.component.$$.ctx[30] !== true) {
+ await new Promise(resolve => setTimeout(resolve, 150));
}
return result;
}
@@ -311,8 +316,13 @@ describe('RunImage', () => {
});
test('Expect to see an error if the container/host ranges have different size', async () => {
+ router.goto('/basic');
+
await createRunImage(undefined, ['command1', 'command2']);
+ const link1 = screen.getByRole('link', { name: 'Basic' });
+ await fireEvent.click(link1);
+
const customMappingButton = screen.getByRole('button', { name: 'Add custom port mapping' });
await fireEvent.click(customMappingButton);
@@ -335,4 +345,58 @@ describe('RunImage', () => {
'Error: host and container port ranges (9000-9001:9000-9003) have different lengths: 2 vs 4',
);
});
+
+ test('Expect that container is created and redirected to tty page', async () => {
+ const gotoSpy = vi.spyOn(router, 'goto');
+
+ await createRunImage('entrypoint', []);
+
+ const link = screen.getByRole('button', { name: 'Start Container' });
+
+ await fireEvent.click(link);
+
+ // wait few time
+ await new Promise(resolve => setTimeout(resolve, 200));
+
+ // expect to be redirected to tty page
+ expect(gotoSpy).toHaveBeenCalledWith('/containers/1234/tty');
+ });
+
+ test('Expect that container is created and redirected to containers page', async () => {
+ const gotoSpy = vi.spyOn(router, 'goto');
+
+ router.goto('/advanced');
+
+ await createRunImage('', []);
+
+ const link1 = screen.getByRole('link', { name: 'Basic' });
+ await fireEvent.click(link1);
+
+ // select another tab
+ const advancedTab = screen.getByRole('link', { name: 'Advanced' });
+ await fireEvent.click(advancedTab);
+
+ // wait
+ await new Promise(resolve => setTimeout(resolve, 150));
+
+ // remove the tty and openStdin checkboxes
+
+ // uncheck tty box Attach a pseudo terminal
+ const ttyCheckbox = screen.getByRole('checkbox', { name: 'Attach a pseudo terminal' });
+ await fireEvent.click(ttyCheckbox);
+
+ // uncheck openStdin box Keep STDIN open even if not attached
+ const openStdinCheckbox = screen.getByRole('checkbox', { name: 'Use interactive' });
+ await fireEvent.click(openStdinCheckbox);
+
+ const link = screen.getByRole('button', { name: 'Start Container' });
+
+ await fireEvent.click(link);
+
+ // wait few time
+ await new Promise(resolve => setTimeout(resolve, 200));
+
+ // expect to be redirected to containers page as there is no tty
+ expect(gotoSpy).toHaveBeenCalledWith('/containers');
+ });
});
diff --git a/packages/renderer/src/lib/image/RunImage.svelte b/packages/renderer/src/lib/image/RunImage.svelte
index fcb08b675f38d..b10229597b69f 100644
--- a/packages/renderer/src/lib/image/RunImage.svelte
+++ b/packages/renderer/src/lib/image/RunImage.svelte
@@ -80,6 +80,7 @@ let networkingModeUserContainer = '';
// tty
let useTty = true;
+let useInteractive = true;
let runUser: string | undefined = undefined;
let dataReady = false;
@@ -303,6 +304,7 @@ async function startContainer() {
const ReadonlyRootfs = readOnly;
const Tty = useTty;
+ const OpenStdin = useInteractive;
const HostConfig: HostConfig = {
Binds,
AutoRemove: autoRemove,
@@ -336,6 +338,7 @@ async function startContainer() {
HostConfig,
ExposedPorts,
Tty,
+ OpenStdin,
};
if (command.trim().length > 0) {
options.Cmd = splitSpacesHandlingDoubleQuotes(command);
@@ -353,14 +356,19 @@ async function startContainer() {
}
try {
- await window.createAndStartContainer(imageInspectInfo.engineId, options);
+ const data = await window.createAndStartContainer(imageInspectInfo.engineId, options);
+
+ // redirect to containers if no tty, else redirect to the container details
+ if (Tty && OpenStdin) {
+ router.goto(`/containers/${data.id}/tty`);
+ } else {
+ router.goto('/containers');
+ }
} catch (e) {
createError = String(e);
console.error('Error while creating container', e);
return;
}
- // redirect to containers
- window.location.href = '#/containers';
}
function addPortsFromRange(
@@ -681,9 +689,21 @@ function checkContainerName(event: any) {
-
+
Attach a pseudo terminal
+
+
+ Interactive: Keep STDIN open even if not attached
+