diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index bd39a5a9a..1eadcbc23 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -104,7 +104,11 @@ export default function Terminal({ ws }: TerminalProps) { return ( <> -
+
term?.scrollToTop()} aria-label="Scroll to top"> diff --git a/app/msw-mock-api.ts b/app/msw-mock-api.ts index 2aa6fc5b6..8348a392f 100644 --- a/app/msw-mock-api.ts +++ b/app/msw-mock-api.ts @@ -54,10 +54,17 @@ const randomStatus = () => { const sleep = async (ms: number) => new Promise((res) => setTimeout(res, ms)) +async function streamString(socket: WebSocket, s: string, delayMs = 50) { + for (const c of s) { + socket.send(c) + await sleep(delayMs) + } +} + export async function startMockAPI() { // dynamic imports to make extremely sure none of this code ends up in the prod bundle const { handlers } = await import('../mock-api/msw/handlers') - const { http, HttpResponse } = await import('msw') + const { http, HttpResponse, ws } = await import('msw') const { setupWorker } = await import('msw/browser') // defined in here because it depends on the dynamic import @@ -77,8 +84,27 @@ export async function startMockAPI() { // don't return anything means fall through to the real handlers }) + // serial console + const secure = window.location.protocol === 'https:' + const protocol = secure ? 'wss' : 'ws' + const serialConsole = `${protocol}://${window.location.host}/v1/instances/:instance/serial-console/stream` + // https://mswjs.io/docs/api/setup-worker/start#options - await setupWorker(interceptAll, ...handlers).start({ + await setupWorker( + interceptAll, + ...handlers, + + ws.link(serialConsole).addEventListener('connection', async ({ client }) => { + client.addEventListener('message', (event) => { + // Mirror client messages back (lets you type in the terminal). If it's + // an enter key, send a newline. + // eslint-disable-next-line @typescript-eslint/no-base-to-string + client.send(event.data.toString() === '13' ? '\r\n' : event.data) + }) + await sleep(1000) // make sure everything is ready first (especially a problem in CI) + await streamString(client.socket, 'Wake up Neo...') + }) + ).start({ quiet: true, // don't log successfully handled requests // custom handler only to make logging less noisy. unhandled requests still // pass through to the server diff --git a/test/e2e/instance-serial.e2e.ts b/test/e2e/instance-serial.e2e.ts index ca6000350..c5082e9a1 100644 --- a/test/e2e/instance-serial.e2e.ts +++ b/test/e2e/instance-serial.e2e.ts @@ -5,7 +5,9 @@ * * Copyright Oxide Computer Company */ -import { clickRowAction, expect, test } from './utils' +import { expect, test, type Page } from '@playwright/test' + +import { clickRowAction } from './utils' test('serial console can connect while starting', async ({ page }) => { // create an instance @@ -29,11 +31,10 @@ test('serial console can connect while starting', async ({ page }) => { await expect(page.getByText('The instance is starting')).toBeVisible() await expect(page.getByText('The instance is')).toBeHidden() - // Here it would be nice to test that the serial console connects, but we - // can't mock websockets with MSW yet: https://github.com/mswjs/msw/pull/2011 + await testSerialConsole(page) }) -test('links in instance actions', async ({ page }) => { +test('serial console for existing instance', async ({ page }) => { await page.goto('/projects/mock-project/instances') await clickRowAction(page, 'db1', 'View serial console') await expect(page).toHaveURL('/projects/mock-project/instances/db1/serial-console') @@ -42,4 +43,23 @@ test('links in instance actions', async ({ page }) => { await page.getByRole('button', { name: 'Instance actions' }).click() await page.getByRole('menuitem', { name: 'View serial console' }).click() await expect(page).toHaveURL('/projects/mock-project/instances/db1/serial-console') + + await testSerialConsole(page) }) + +async function testSerialConsole(page: Page) { + const xterm = page.getByRole('application') + + // MSW mocks a message. use first() because there are multiple copies on screen + await expect(xterm.getByText('Wake up Neo...').first()).toBeVisible() + + // we need to do this for our keypresses to land + await page.locator('.xterm-helper-textarea').focus() + + await xterm.pressSequentially('abc') + await expect(xterm.getByText('Wake up Neo...abc').first()).toBeVisible() + await xterm.press('Enter') + await xterm.pressSequentially('def') + await expect(xterm.getByText('Wake up Neo...abc').first()).toBeVisible() + await expect(xterm.getByText('def').first()).toBeVisible() +} diff --git a/tools/deno/mock-serial-console.ts b/tools/deno/mock-serial-console.ts deleted file mode 100755 index 3796bffca..000000000 --- a/tools/deno/mock-serial-console.ts +++ /dev/null @@ -1,47 +0,0 @@ -#! /usr/bin/env -S deno run --allow-run --allow-net - -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { delay } from 'https://deno.land/std@0.181.0/async/delay.ts' -import { serve } from 'https://deno.land/std@0.181.0/http/server.ts' - -/* - * This exists because MSW does not support websockets. So in MSW mode, we also - * run this little server and configure Vite to proxy WS requests to it. - */ - -async function streamString(socket: WebSocket, s: string, delayMs = 50) { - for (const c of s) { - socket.send(new TextEncoder().encode(c)) - await delay(delayMs) - } -} - -async function serialConsole(req: Request) { - await delay(500) - const { socket, response } = Deno.upgradeWebSocket(req) - socket.binaryType = 'arraybuffer' - - console.info(`New client connected`) - - // send hello as a binary frame for xterm to display - socket.onopen = () => { - setTimeout(() => { - streamString(socket, 'Wake up Neo...') - }, 200) - } - - // echo back binary data - socket.onmessage = (m) => socket.send(m.data) - - socket.onclose = () => console.info('Connection closed') - - return response -} - -serve(serialConsole, { port: 6036 }) diff --git a/vite.config.ts b/vite.config.ts index 68b66e281..9fe5d8eb9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -134,15 +134,6 @@ export default defineConfig(({ mode }) => ({ apiMode === 'dogfood' ? `https://${DOGFOOD_HOST}` : 'http://localhost:12220', changeOrigin: true, }, - '^/v1/instances/[^/]+/serial-console/stream': { - target: - // in msw mode, serial console is served by tools/deno/mock-serial-console.ts - apiMode === 'dogfood' - ? `wss://${DOGFOOD_HOST}` - : 'ws://127.0.0.1:' + (apiMode === 'msw' ? 6036 : 12220), - changeOrigin: true, - ws: true, - }, }, }, preview: { headers },