diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index e37fea086..76f311094 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -17,8 +17,33 @@ import { AttachAddon } from './AttachAddon' const ScrollButton = classed.button`ml-4 flex h-8 w-8 items-center justify-center rounded-md border border-secondary hover:bg-hover` -function getOptions(): ITerminalOptions { +function getTheme(): ITerminalOptions['theme'] { const style = getComputedStyle(document.body) + return { + background: style.getPropertyValue('--surface-default'), + foreground: style.getPropertyValue('--content-default'), + black: style.getPropertyValue('--surface-default'), + brightBlack: style.getPropertyValue('--content-quinary'), + white: style.getPropertyValue('--content-default'), + brightWhite: style.getPropertyValue('--content-secondary'), + blue: style.getPropertyValue('--content-info-secondary'), + brightBlue: style.getPropertyValue('--content-info'), + green: style.getPropertyValue('--content-success-secondary'), + brightGreen: style.getPropertyValue('--content-success'), + red: style.getPropertyValue('--content-error-secondary'), + brightRed: style.getPropertyValue('--content-error'), + yellow: style.getPropertyValue('--content-notice-secondary'), + brightYellow: style.getPropertyValue('--content-notice'), + cyan: style.getPropertyValue('--content-accent-secondary'), + brightCyan: style.getPropertyValue('--content-accent'), + magenta: style.getPropertyValue('--content-accent-alt-secondary'), + brightMagenta: style.getPropertyValue('--content-accent-alt'), + cursor: style.getPropertyValue('--content-default'), + cursorAccent: style.getPropertyValue('--surface-default'), + } +} + +function getOptions(): ITerminalOptions { return { // it is not easy to figure out what the exact behavior is when scrollback // is not defined because it seems to be used in a bunch of places in the @@ -36,24 +61,7 @@ function getOptions(): ITerminalOptions { fullscreenWin: true, refreshWin: true, }, - theme: { - background: style.getPropertyValue('--surface-default'), - foreground: style.getPropertyValue('--content-default'), - black: style.getPropertyValue('--surface-default'), - brightBlack: style.getPropertyValue('--content-quinary'), - white: style.getPropertyValue('--content-default'), - brightWhite: style.getPropertyValue('--content-secondary'), - blue: style.getPropertyValue('--base-blue-500'), - brightBlue: style.getPropertyValue('--base-blue-900'), - green: style.getPropertyValue('--content-success'), - brightGreen: style.getPropertyValue('--content-success-secondary'), - red: style.getPropertyValue('--content-error'), - brightRed: style.getPropertyValue('--content-error-secondary'), - yellow: style.getPropertyValue('--content-notice'), - brightYellow: style.getPropertyValue('--content-notice-secondary'), - cursor: style.getPropertyValue('--content-default'), - cursorAccent: style.getPropertyValue('--surface-default'), - }, + theme: getTheme(), } } @@ -94,7 +102,20 @@ export function Terminal({ ws }: TerminalProps) { } window.addEventListener('resize', resize) + + // Update terminal colors when the theme changes. getComputedStyle in + // getTheme() forces a synchronous style recalc, so the CSS custom + // properties already reflect the new theme by the time we read them. + const observer = new MutationObserver(() => { + newTerm.options.theme = getTheme() + }) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }) + return () => { + observer.disconnect() newTerm.dispose() window.removeEventListener('resize', resize) } diff --git a/app/msw-mock-api.ts b/app/msw-mock-api.ts index 8348a392f..4b6babe03 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) +/** Stream boot log line-by-line with realistic timing */ +async function streamBootLog(socket: WebSocket, text: string) { + for (const line of text.split('\n')) { + socket.send(line + '\r\n') + if (line === '' || line.startsWith('Welcome to') || line.includes('login:')) { + await sleep(200) + } else if (line.startsWith('[ OK ]') || line.startsWith(' Starting')) { + await sleep(30) + } else { + await sleep(15) + } } } @@ -66,6 +73,7 @@ export async function startMockAPI() { const { handlers } = await import('../mock-api/msw/handlers') const { http, HttpResponse, ws } = await import('msw') const { setupWorker } = await import('msw/browser') + const serialConsoleText = (await import('../mock-api/serial-console.txt?raw')).default // defined in here because it depends on the dynamic import const interceptAll = http.all('/v1/*', async () => { @@ -102,7 +110,7 @@ export async function startMockAPI() { 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...') + await streamBootLog(client.socket, serialConsoleText) }) ).start({ quiet: true, // don't log successfully handled requests diff --git a/app/pages/project/instances/SerialConsolePage.tsx b/app/pages/project/instances/SerialConsolePage.tsx index 32bf55247..0b5328c46 100644 --- a/app/pages/project/instances/SerialConsolePage.tsx +++ b/app/pages/project/instances/SerialConsolePage.tsx @@ -178,7 +178,7 @@ type SkeletonProps = { function SerialSkeleton({ children, animate }: SkeletonProps) { return ( -
+
{[...Array(200)].map((_e, i) => (
+ {/* gradient uses the surface-default token so it works in both themes */}
-
+
{children}
@@ -221,7 +223,7 @@ const CannotConnect = ({ instance }: { instance: Instance }) => ( The instance is

-

+

{isStarting(instance) ? 'Waiting for the instance to start before connecting.' : 'You can only connect to the serial console on a running instance.'} diff --git a/mock-api/serial-console.txt b/mock-api/serial-console.txt new file mode 100644 index 000000000..1810b69dd --- /dev/null +++ b/mock-api/serial-console.txt @@ -0,0 +1,141 @@ +Terminal theme test: +red green yellow blue magenta cyan white +bright-red bright-green bright-yellow bright-blue bright-magenta bright-cyan bright-white bright-black + +Booting `Debian GNU/Linux' + +Loading Linux 6.12.38+deb13-amd64 ... +Loading initial ramdisk ... +EFI stub: Loaded initrd from LINUX_EFI_INITRD_MEDIA_GUID device path +[ 0.000000] Linux version 6.12.38+deb13-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.38-1 (2025-07-16) +[ 0.000000] Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.38+deb13-amd64 root=PARTUUID=3fa1e012-eadc-4f00-b183-2619d1d2321b ro console=tty0 console=ttyS0,115200n8 +[ 0.000000] BIOS-provided physical RAM map: +[ 0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009ffff] usable +[ 0.000000] BIOS-e820: [mem 0x0000000000100000-0x00000000bea59fff] usable +[ 0.000000] BIOS-e820: [mem 0x00000000bea5a000-0x00000000bed59fff] reserved +[ 0.000000] BIOS-e820: [mem 0x0000000100000000-0x000000023fffffff] usable +[ 0.000000] NX (Execute Disable) protection: active +[ 0.000000] DMI: Oxide OxVM, BIOS v0.8 The Aftermath 30, 3185 YOLD +[ 0.000000] tsc: Detected 1996.096 MHz processor +[ 0.103404] percpu: Embedded 66 pages/cpu s233472 r8192 d28672 u1048576 +[ 0.116419] Built 1 zonelists, mobility grouping on. Total pages: 2064887 +[ 0.118081] Policy zone: Normal +[ 0.118583] Kernel command line: BOOT_IMAGE=/boot/vmlinuz-6.12.38+deb13-amd64 root=PARTUUID=3fa1e012-eadc-4f00-b183-2619d1d2321b ro console=tty0 console=ttyS0,115200n8 +[ 0.151479] Memory: 8016340K/8259548K available (18432K kernel code, 4484K rwdata, 12340K rodata, 3892K init, 7620K bss, 243208K reserved, 0K cma-reserved) +[ 0.295826] rcu: Hierarchical SRCU implementation. +[ 0.350553] smpboot: CPU0: AMD EPYC-Rome Processor (family: 0x17, model: 0x31, stepping: 0x0) +[ 0.620003] smp: Brought up 1 node, 2 CPUs +[ 0.673460] pci 0000:00:10.0: [01de:0000] type 00 class 0x010802 conventional PCI endpoint +[ 0.789199] MPTCP token hash table entries: 8192 (order: 5, 196608 bytes, linear) +[ 0.841359] Key type asymmetric registered +[ 1.113771] input: AT Translated Set 2 keyboard as /devices/platform/i8042/serio0/input/input0 +[ 1.565161] nvme nvme0: pci function 0000:00:10.0 +[ 1.849427] tsc: Refined TSC clocksource calibration: 1996.214 MHz +[ 1.935044] EXT4-fs (nvme0n1p1): mounted filesystem dced5a54-4fb7-4dda-abd6-4ea8f50bfe92 ro with ordered data mode. Quota mode: none. +[ 2.403361] nvme0n1: p1 p14 p15 +GROWROOT: CHANGED: partition=1 start=262144 old: size=6027264 end=6289407 new: size=20709343 end=20971486 +[ 2.850589] systemd[1]: Inserted module 'autofs4' +[ 2.915923] systemd[1]: systemd 257.7-1 running in system mode (+PAM +AUDIT +SELINUX +APPARMOR +IMA +SMACK +SECCOMP +GCRYPT +OPENSSL +ACL +BLKID +CURL +ELFUTILS +FIDO2 +IDN2 +IPTC +KMOD +LIBCRYPTSETUP +LIBFDISK +PCRE2 +PWQUALITY +ZSTD +BPF_FRAMEWORK +XKBCOMMON +SYSVINIT) +[ 2.919095] systemd[1]: Detected virtualization bhyve. +[ 2.920733] systemd[1]: Detected architecture x86-64. + +Welcome to Debian GNU/Linux 13 (trixie)! + +[ 2.941482] systemd[1]: Hostname set to . +[ OK ] Created slice system-getty.slice - Slice /system/getty. +[ OK ] Created slice system-modprobe.slice - Slice /system/modprobe. +[ OK ] Created slice system-serial\x2dget…slice - Slice /system/serial-getty. +[ OK ] Created slice user.slice - User and Session Slice. +[ OK ] Started systemd-ask-password-conso…equests to Console Directory Watch. +[ OK ] Reached target paths.target - Path Units. +[ OK ] Reached target remote-fs.target - Remote File Systems. +[ OK ] Reached target slices.target - Slice Units. +[ OK ] Reached target swap.target - Swaps. +[ OK ] Listening on systemd-journald.socket - Journal Sockets. +[ OK ] Listening on systemd-networkd.socket - Network Service Netlink Socket. +[ OK ] Listening on systemd-udevd-control.socket - udev Control Socket. +[ OK ] Listening on systemd-udevd-kernel.socket - udev Kernel Socket. + Mounting dev-hugepages.mount - Huge Pages File System... + Mounting dev-mqueue.mount - POSIX Message Queue File System... + Starting kmod-static-nodes.service…eate List of Static Device Nodes... + Starting modprobe@configfs.service - Load Kernel Module configfs... + Starting modprobe@drm.service - Load Kernel Module drm... + Starting systemd-journald.service - Journal Service... + Starting systemd-modules-load.service - Load Kernel Modules... +[ OK ] Mounted dev-hugepages.mount - Huge Pages File System. +[ OK ] Mounted dev-mqueue.mount - POSIX Message Queue File System. +[ OK ] Mounted tmp.mount - Temporary Directory /tmp. +[ OK ] Finished kmod-static-nodes.service…Create List of Static Device Nodes. +[ OK ] Finished modprobe@configfs.service - Load Kernel Module configfs. +[ OK ] Finished modprobe@drm.service - Load Kernel Module drm. +[ OK ] Started systemd-journald.service - Journal Service. +[ OK ] Finished modprobe@efi_pstore.service - Load Kernel Module efi_pstore. +[ OK ] Finished modprobe@fuse.service - Load Kernel Module fuse. +[ OK ] Finished systemd-fsck-root.service - File System Check on Root Device. +[ OK ] Finished systemd-modules-load.service - Load Kernel Modules. +[ 4.288341] EXT4-fs (nvme0n1p1): re-mounted dced5a54-4fb7-4dda-abd6-4ea8f50bfe92 r/w. +[ OK ] Finished systemd-remount-fs.servic…mount Root and Kernel File Systems. +[ OK ] Finished systemd-sysctl.service - Apply Kernel Variables. + Starting cloud-init-main.service - Cloud-init: Single Process... + Starting systemd-growfs-root.service - Grow Root File System... + Starting systemd-random-seed.service - Load/Save OS Random Seed... +[ 4.602163] EXT4-fs (nvme0n1p1): resized filesystem to 2588667 +[ OK ] Finished systemd-random-seed.service - Load/Save OS Random Seed. +[ OK ] Finished systemd-growfs-root.service - Grow Root File System. +[ OK ] Finished systemd-sysusers.service - Create System Users. + Starting systemd-resolved.service - Network Name Resolution... + Starting systemd-timesyncd.service - Network Time Synchronization... +[ OK ] Started systemd-timesyncd.service - Network Time Synchronization. +[ OK ] Reached target time-set.target - System Time Set. +[ OK ] Started systemd-udevd.service - Ru…anager for Device Events and Files. +[ OK ] Started systemd-resolved.service - Network Name Resolution. +[ OK ] Found device dev-ttyS0.device - /dev/ttyS0. +[ OK ] Reached target local-fs.target - Local File Systems. + Starting apparmor.service - Load AppArmor profiles... + Starting systemd-tmpfiles-setup.se…ate System Files and Directories... +[ OK ] Finished systemd-tmpfiles-setup.se…reate System Files and Directories. +[ OK ] Started cloud-init-main.service - Cloud-init: Single Process. + Starting cloud-init-local.service …-init: Local Stage (pre-network)... +[ OK ] Finished cloud-init-local.service …ud-init: Local Stage (pre-network). +[ OK ] Reached target network-pre.target - Preparation for Network. + Starting systemd-networkd.service - Network Configuration... +[ OK ] Started systemd-networkd.service - Network Configuration. +[ OK ] Reached target network.target - Network. +[ OK ] Finished systemd-networkd-wait-onl… Wait for Network to be Configured. + Starting cloud-init-network.service - Cloud-init: Network Stage... +[ OK ] Finished cloud-init-network.service - Cloud-init: Network Stage. +[ OK ] Reached target network-online.target - Network is Online. +[ OK ] Reached target sysinit.target - System Initialization. +[ OK ] Started apt-daily.timer - Daily apt download activities. +[ OK ] Started dpkg-db-backup.timer - Daily dpkg database backup timer. +[ OK ] Started fstrim.timer - Discard unused filesystem blocks once a week. +[ OK ] Started systemd-tmpfiles-clean.tim…y Cleanup of Temporary Directories. +[ OK ] Reached target timers.target - Timer Units. +[ OK ] Listening on dbus.socket - D-Bus System Message Bus Socket. +[ OK ] Reached target sockets.target - Socket Units. +[ OK ] Reached target basic.target - Basic System. + Starting cloud-config.service - Cloud-init: Config Stage... + Starting dbus.service - D-Bus System Message Bus... + Starting ssh.service - OpenBSD Secure Shell server... + Starting systemd-logind.service - User Login Management... + Starting systemd-user-sessions.service - Permit User Sessions... +[ OK ] Started dbus.service - D-Bus System Message Bus. +[ OK ] Finished systemd-user-sessions.service - Permit User Sessions. +[ OK ] Started getty@tty1.service - Getty on tty1. +[ OK ] Started serial-getty@ttyS0.service - Serial Getty on ttyS0. +[ OK ] Reached target getty.target - Login Prompts. +[ OK ] Started ssh.service - OpenBSD Secure Shell server. +[ OK ] Started systemd-logind.service - User Login Management. +[ OK ] Finished cloud-config.service - Cloud-init: Config Stage. +[ OK ] Finished grub-common.service - Record successful boot for GRUB. +[ OK ] Started unattended-upgrades.service - Unattended Upgrades Shutdown. +[ OK ] Reached target multi-user.target - Multi-User System. +[ OK ] Reached target graphical.target - Graphical Interface. + Starting cloud-final.service - Cloud-init: Final Stage... +[ OK ] Finished cloud-final.service - Cloud-init: Final Stage. +[ OK ] Reached target cloud-init.target - Cloud-init target. +[ OK ] Started polkit.service - Authorization Manager. + +Debian GNU/Linux 13 oxide-instance ttyS0 + +oxide-instance login: \ No newline at end of file diff --git a/test/e2e/instance-serial.e2e.ts b/test/e2e/instance-serial.e2e.ts index c5082e9a1..ae49079f1 100644 --- a/test/e2e/instance-serial.e2e.ts +++ b/test/e2e/instance-serial.e2e.ts @@ -49,17 +49,15 @@ test('serial console for existing instance', async ({ page }) => { async function testSerialConsole(page: Page) { const xterm = page.getByRole('application') + const input = page.getByRole('textbox', { name: 'Terminal input' }) - // MSW mocks a message. use first() because there are multiple copies on screen - await expect(xterm.getByText('Wake up Neo...').first()).toBeVisible() + // Wait for the boot log to finish so typed input does not interleave with it. + await expect(xterm).toContainText('oxide-instance login:', { timeout: 15_000 }) - // we need to do this for our keypresses to land - await page.locator('.xterm-helper-textarea').focus() + await input.focus() + await expect(input).toBeFocused() - 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() + await input.press('Enter') + await input.pressSequentially('def') + await expect(xterm).toContainText('def') } diff --git a/test/e2e/theme.e2e.ts b/test/e2e/theme.e2e.ts index 536e935dc..7b6234b56 100644 --- a/test/e2e/theme.e2e.ts +++ b/test/e2e/theme.e2e.ts @@ -7,6 +7,32 @@ */ import { expect, test } from './utils' +test('Serial console terminal updates colors on theme change', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/serial-console') + + const xterm = page.getByRole('application') + await expect(xterm).toContainText('oxide-instance login:', { timeout: 15_000 }) + + // xterm.js sets background-color inline on the .xterm-viewport element + const viewport = page.locator('.xterm-viewport') + const getBg = () => viewport.evaluate((el) => getComputedStyle(el).backgroundColor) + + const darkBg = await getBg() + + // switch to light via the user menu + await page.getByRole('button', { name: 'User menu' }).click() + await page.getByRole('menuitem', { name: 'Theme' }).click() + await page.getByRole('menuitemradio', { name: 'Light' }).click() + + const lightBg = await getBg() + expect(lightBg).not.toEqual(darkBg) + + // switch back to dark (menu is still open) + await page.getByRole('menuitemradio', { name: 'Dark' }).click() + + expect(await getBg()).toEqual(darkBg) +}) + test('Theme picker changes data-theme on ', async ({ page }) => { // default is light in Playwright, but don't rely on that await page.emulateMedia({ colorScheme: 'light' })