Skip to content

Commit db94dea

Browse files
authored
Mock serial console websocket with MSW (#2703)
mock serial console websocket with msw
1 parent 6ed7d86 commit db94dea

File tree

5 files changed

+57
-63
lines changed

5 files changed

+57
-63
lines changed

app/components/Terminal.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,11 @@ export default function Terminal({ ws }: TerminalProps) {
104104

105105
return (
106106
<>
107-
<div className="h-full w-[calc(100%-3rem)] text-mono-code" ref={terminalRef} />
107+
<div
108+
role="application"
109+
className="h-full w-[calc(100%-3rem)] text-mono-code"
110+
ref={terminalRef}
111+
/>
108112
<div className="absolute right-0 top-0 space-y-2 text-default">
109113
<ScrollButton onClick={() => term?.scrollToTop()} aria-label="Scroll to top">
110114
<DirectionUpIcon aria-hidden />

app/msw-mock-api.ts

+28-2
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,17 @@ const randomStatus = () => {
5454

5555
const sleep = async (ms: number) => new Promise((res) => setTimeout(res, ms))
5656

57+
async function streamString(socket: WebSocket, s: string, delayMs = 50) {
58+
for (const c of s) {
59+
socket.send(c)
60+
await sleep(delayMs)
61+
}
62+
}
63+
5764
export async function startMockAPI() {
5865
// dynamic imports to make extremely sure none of this code ends up in the prod bundle
5966
const { handlers } = await import('../mock-api/msw/handlers')
60-
const { http, HttpResponse } = await import('msw')
67+
const { http, HttpResponse, ws } = await import('msw')
6168
const { setupWorker } = await import('msw/browser')
6269

6370
// defined in here because it depends on the dynamic import
@@ -77,8 +84,27 @@ export async function startMockAPI() {
7784
// don't return anything means fall through to the real handlers
7885
})
7986

87+
// serial console
88+
const secure = window.location.protocol === 'https:'
89+
const protocol = secure ? 'wss' : 'ws'
90+
const serialConsole = `${protocol}://${window.location.host}/v1/instances/:instance/serial-console/stream`
91+
8092
// https://mswjs.io/docs/api/setup-worker/start#options
81-
await setupWorker(interceptAll, ...handlers).start({
93+
await setupWorker(
94+
interceptAll,
95+
...handlers,
96+
97+
ws.link(serialConsole).addEventListener('connection', async ({ client }) => {
98+
client.addEventListener('message', (event) => {
99+
// Mirror client messages back (lets you type in the terminal). If it's
100+
// an enter key, send a newline.
101+
// eslint-disable-next-line @typescript-eslint/no-base-to-string
102+
client.send(event.data.toString() === '13' ? '\r\n' : event.data)
103+
})
104+
await sleep(1000) // make sure everything is ready first (especially a problem in CI)
105+
await streamString(client.socket, 'Wake up Neo...')
106+
})
107+
).start({
82108
quiet: true, // don't log successfully handled requests
83109
// custom handler only to make logging less noisy. unhandled requests still
84110
// pass through to the server

test/e2e/instance-serial.e2e.ts

+24-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { clickRowAction, expect, test } from './utils'
8+
import { expect, test, type Page } from '@playwright/test'
9+
10+
import { clickRowAction } from './utils'
911

1012
test('serial console can connect while starting', async ({ page }) => {
1113
// create an instance
@@ -29,11 +31,10 @@ test('serial console can connect while starting', async ({ page }) => {
2931
await expect(page.getByText('The instance is starting')).toBeVisible()
3032
await expect(page.getByText('The instance is')).toBeHidden()
3133

32-
// Here it would be nice to test that the serial console connects, but we
33-
// can't mock websockets with MSW yet: https://github.com/mswjs/msw/pull/2011
34+
await testSerialConsole(page)
3435
})
3536

36-
test('links in instance actions', async ({ page }) => {
37+
test('serial console for existing instance', async ({ page }) => {
3738
await page.goto('/projects/mock-project/instances')
3839
await clickRowAction(page, 'db1', 'View serial console')
3940
await expect(page).toHaveURL('/projects/mock-project/instances/db1/serial-console')
@@ -42,4 +43,23 @@ test('links in instance actions', async ({ page }) => {
4243
await page.getByRole('button', { name: 'Instance actions' }).click()
4344
await page.getByRole('menuitem', { name: 'View serial console' }).click()
4445
await expect(page).toHaveURL('/projects/mock-project/instances/db1/serial-console')
46+
47+
await testSerialConsole(page)
4548
})
49+
50+
async function testSerialConsole(page: Page) {
51+
const xterm = page.getByRole('application')
52+
53+
// MSW mocks a message. use first() because there are multiple copies on screen
54+
await expect(xterm.getByText('Wake up Neo...').first()).toBeVisible()
55+
56+
// we need to do this for our keypresses to land
57+
await page.locator('.xterm-helper-textarea').focus()
58+
59+
await xterm.pressSequentially('abc')
60+
await expect(xterm.getByText('Wake up Neo...abc').first()).toBeVisible()
61+
await xterm.press('Enter')
62+
await xterm.pressSequentially('def')
63+
await expect(xterm.getByText('Wake up Neo...abc').first()).toBeVisible()
64+
await expect(xterm.getByText('def').first()).toBeVisible()
65+
}

tools/deno/mock-serial-console.ts

-47
This file was deleted.

vite.config.ts

-9
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,6 @@ export default defineConfig(({ mode }) => ({
134134
apiMode === 'dogfood' ? `https://${DOGFOOD_HOST}` : 'http://localhost:12220',
135135
changeOrigin: true,
136136
},
137-
'^/v1/instances/[^/]+/serial-console/stream': {
138-
target:
139-
// in msw mode, serial console is served by tools/deno/mock-serial-console.ts
140-
apiMode === 'dogfood'
141-
? `wss://${DOGFOOD_HOST}`
142-
: 'ws://127.0.0.1:' + (apiMode === 'msw' ? 6036 : 12220),
143-
changeOrigin: true,
144-
ws: true,
145-
},
146137
},
147138
},
148139
preview: { headers },

0 commit comments

Comments
 (0)