Skip to content

Commit

Permalink
Mock serial console websocket with MSW (#2703)
Browse files Browse the repository at this point in the history
mock serial console websocket with msw
  • Loading branch information
david-crespo authored Feb 19, 2025
1 parent 6ed7d86 commit db94dea
Show file tree
Hide file tree
Showing 5 changed files with 57 additions and 63 deletions.
6 changes: 5 additions & 1 deletion app/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ export default function Terminal({ ws }: TerminalProps) {

return (
<>
<div className="h-full w-[calc(100%-3rem)] text-mono-code" ref={terminalRef} />
<div
role="application"
className="h-full w-[calc(100%-3rem)] text-mono-code"
ref={terminalRef}
/>
<div className="absolute right-0 top-0 space-y-2 text-default">
<ScrollButton onClick={() => term?.scrollToTop()} aria-label="Scroll to top">
<DirectionUpIcon aria-hidden />
Expand Down
30 changes: 28 additions & 2 deletions app/msw-mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 24 additions & 4 deletions test/e2e/instance-serial.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand All @@ -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()
}
47 changes: 0 additions & 47 deletions tools/deno/mock-serial-console.ts

This file was deleted.

9 changes: 0 additions & 9 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down

0 comments on commit db94dea

Please sign in to comment.