feat: browser panel with noVNC iframe
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
// BrowserPanel renders a noVNC iframe for viewing the live Playwright browser.
|
||||||
|
//
|
||||||
|
// URL detection strategy:
|
||||||
|
// - Production (cloudflared): When hostname is "research.ampbox.io", use the
|
||||||
|
// dedicated noVNC HTTPS endpoint at vnc.ampbox.io (routed via cloudflared tunnel).
|
||||||
|
// - Local dev (Docker): For any other hostname (e.g. localhost, 192.168.x.x),
|
||||||
|
// connect directly to the noVNC container on port 6080 over plain HTTP.
|
||||||
|
|
||||||
|
export interface BrowserPanelProps {
|
||||||
|
sessionId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVncUrl(): string {
|
||||||
|
const hostname = window.location.hostname
|
||||||
|
if (hostname === 'research.ampbox.io') {
|
||||||
|
return 'https://vnc.ampbox.io/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
|
||||||
|
}
|
||||||
|
return `http://${hostname}:6080/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowserPanel({ sessionId }: BrowserPanelProps) {
|
||||||
|
if (!sessionId) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '2rem',
|
||||||
|
color: '#6a6a8a',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select a session to view the browser
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={getVncUrl()}
|
||||||
|
title="Browser - noVNC"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none',
|
||||||
|
background: '#0a0a1a',
|
||||||
|
}}
|
||||||
|
allow="clipboard-read; clipboard-write"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { BrowserPanel } from '../components/BrowserPanel'
|
||||||
|
|
||||||
|
describe('BrowserPanel', () => {
|
||||||
|
const originalLocation = window.location
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset location before each test
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { hostname: 'localhost' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: originalLocation,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('placeholder (no sessionId)', () => {
|
||||||
|
it('renders placeholder text when sessionId is null', () => {
|
||||||
|
render(<BrowserPanel sessionId={null} />)
|
||||||
|
expect(screen.getByText('Select a session to view the browser')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render an iframe when sessionId is null', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId={null} />)
|
||||||
|
expect(container.querySelector('iframe')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('placeholder div has padding 2rem', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId={null} />)
|
||||||
|
const div = container.firstElementChild as HTMLElement
|
||||||
|
expect(div.style.padding).toBe('2rem')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('placeholder div text has color #6a6a8a', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId={null} />)
|
||||||
|
const div = container.firstElementChild as HTMLElement
|
||||||
|
// jsdom normalizes hex colors to rgb() — #6a6a8a = rgb(106, 106, 138)
|
||||||
|
expect(div.style.color).toBe('rgb(106, 106, 138)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('placeholder div text is centered', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId={null} />)
|
||||||
|
const div = container.firstElementChild as HTMLElement
|
||||||
|
expect(div.style.textAlign).toBe('center')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('iframe (with sessionId)', () => {
|
||||||
|
it('renders an iframe when sessionId is provided', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-abc" />)
|
||||||
|
expect(container.querySelector('iframe')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render placeholder text when sessionId is provided', () => {
|
||||||
|
render(<BrowserPanel sessionId="session-abc" />)
|
||||||
|
expect(screen.queryByText('Select a session to view the browser')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iframe has title "Browser - noVNC"', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-abc" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.title).toBe('Browser - noVNC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iframe has width 100%', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-abc" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.style.width).toBe('100%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iframe has height 100%', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-abc" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.style.height).toBe('100%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iframe has no border', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-abc" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
// jsdom expands border shorthand — check borderStyle which carries the 'none' value
|
||||||
|
expect(iframe.style.borderStyle).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iframe has dark background color #0a0a1a', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-abc" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
// jsdom normalizes hex colors to rgb() — #0a0a1a = rgb(10, 10, 26)
|
||||||
|
expect(iframe.style.background).toBe('rgb(10, 10, 26)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iframe has clipboard allow attribute', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-abc" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.getAttribute('allow')).toBe('clipboard-read; clipboard-write')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getVncUrl() — URL detection', () => {
|
||||||
|
it('returns cloudflared HTTPS URL when hostname is research.ampbox.io', () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { hostname: 'research.ampbox.io' },
|
||||||
|
})
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-1" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.src).toBe(
|
||||||
|
'https://vnc.ampbox.io/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns localhost http URL with port 6080 when hostname is localhost', () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { hostname: 'localhost' },
|
||||||
|
})
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-1" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.src).toBe(
|
||||||
|
'http://localhost:6080/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns http URL with other hostname when not research.ampbox.io', () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { hostname: '192.168.1.100' },
|
||||||
|
})
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-1" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.src).toBe(
|
||||||
|
'http://192.168.1.100:6080/vnc.html?autoconnect=true&resize=remote&quality=6&compression=2'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('URL includes autoconnect=true query param', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-1" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.src).toContain('autoconnect=true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('URL includes resize=remote query param', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-1" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.src).toContain('resize=remote')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('URL includes quality=6 query param', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-1" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.src).toContain('quality=6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('URL includes compression=2 query param', () => {
|
||||||
|
const { container } = render(<BrowserPanel sessionId="session-1" />)
|
||||||
|
const iframe = container.querySelector('iframe') as HTMLIFrameElement
|
||||||
|
expect(iframe.src).toContain('compression=2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user