From 5c1533a0563d6ee38d0305b822446d0281a43648 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Sun, 8 Feb 2026 23:51:53 +0800 Subject: [PATCH] feat(browser): add lightweight scheduler page for headless mode --- e2e/browser-mode/basic.test.ts | 6 +- packages/browser-ui/rsbuild.config.ts | 1 + packages/browser-ui/src/core/channel.ts | 81 +++++++ packages/browser-ui/src/core/runtime.ts | 24 ++ packages/browser-ui/src/hooks/useRpc.ts | 59 +---- packages/browser-ui/src/main.tsx | 228 +++++++------------ packages/browser-ui/src/scheduler.ts | 288 ++++++++++++++++++++++++ packages/browser-ui/src/types.ts | 47 +++- packages/browser/src/hostController.ts | 114 ++++++++-- 9 files changed, 617 insertions(+), 231 deletions(-) create mode 100644 packages/browser-ui/src/core/channel.ts create mode 100644 packages/browser-ui/src/core/runtime.ts create mode 100644 packages/browser-ui/src/scheduler.ts diff --git a/e2e/browser-mode/basic.test.ts b/e2e/browser-mode/basic.test.ts index cad1ce1d6..8a19a5239 100644 --- a/e2e/browser-mode/basic.test.ts +++ b/e2e/browser-mode/basic.test.ts @@ -34,14 +34,16 @@ describe('browser mode - basic', () => { await expectExecSuccess(); expect(cli.stdout).toMatch(/Test Files.*passed/); + expect(cli.stdout).toContain('/scheduler.html'); }); - it('should exit with code 0 when tests pass', async () => { + it('should run headed mode without scheduler page and exit with code 0', async () => { const { cli } = await runBrowserCli('basic', { - args: ['tests/dom.test.ts'], + args: ['--browser.headless', 'false', 'tests/dom.test.ts'], }); await cli.exec; expect(cli.exec.exitCode).toBe(0); + expect(cli.stdout).not.toContain('/scheduler.html'); }); }); diff --git a/packages/browser-ui/rsbuild.config.ts b/packages/browser-ui/rsbuild.config.ts index f38898ea0..9238d9133 100644 --- a/packages/browser-ui/rsbuild.config.ts +++ b/packages/browser-ui/rsbuild.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ source: { entry: { index: './src/main.tsx', + scheduler: './src/scheduler.ts', }, }, output: { diff --git a/packages/browser-ui/src/core/channel.ts b/packages/browser-ui/src/core/channel.ts new file mode 100644 index 000000000..2d0f50392 --- /dev/null +++ b/packages/browser-ui/src/core/channel.ts @@ -0,0 +1,81 @@ +import type { + BrowserClientMessage, + HostRPC, + SnapshotRpcRequest, + SnapshotRpcResponse, +} from '../types'; + +const DISPATCH_MESSAGE_TYPE = '__rstest_dispatch__'; +const SNAPSHOT_RESPONSE_TYPE = '__rstest_snapshot_response__'; + +type SnapshotRpcHandler = Pick< + HostRPC, + | 'resolveSnapshotPath' + | 'readSnapshotFile' + | 'saveSnapshotFile' + | 'removeSnapshotFile' +>; + +const canPostMessage = ( + sourceWindow: MessageEventSource | null, +): sourceWindow is Window => { + return ( + sourceWindow !== null && + typeof (sourceWindow as Window).postMessage === 'function' + ); +}; + +export const readDispatchMessage = ( + event: MessageEvent, +): BrowserClientMessage | null => { + if (event.data?.type !== DISPATCH_MESSAGE_TYPE) { + return null; + } + return event.data.payload as BrowserClientMessage; +}; + +export const forwardSnapshotRpcRequest = async ( + rpc: SnapshotRpcHandler | null | undefined, + request: SnapshotRpcRequest, + sourceWindow: MessageEventSource | null, +): Promise => { + if (!rpc || !canPostMessage(sourceWindow)) { + return; + } + + const sendResponse = (response: SnapshotRpcResponse) => { + sourceWindow.postMessage( + { type: SNAPSHOT_RESPONSE_TYPE, payload: response }, + '*', + ); + }; + + try { + let result: unknown; + switch (request.method) { + case 'resolveSnapshotPath': + result = await rpc.resolveSnapshotPath(request.args.testPath); + break; + case 'readSnapshotFile': + result = await rpc.readSnapshotFile(request.args.filepath); + break; + case 'saveSnapshotFile': + result = await rpc.saveSnapshotFile( + request.args.filepath, + request.args.content, + ); + break; + case 'removeSnapshotFile': + result = await rpc.removeSnapshotFile(request.args.filepath); + break; + default: + result = undefined; + } + sendResponse({ id: request.id, result }); + } catch (error) { + sendResponse({ + id: request.id, + error: error instanceof Error ? error.message : String(error), + }); + } +}; diff --git a/packages/browser-ui/src/core/runtime.ts b/packages/browser-ui/src/core/runtime.ts new file mode 100644 index 000000000..079318767 --- /dev/null +++ b/packages/browser-ui/src/core/runtime.ts @@ -0,0 +1,24 @@ +export const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16_000, 30_000]; + +export const createWebSocketUrl = (wsPort: number): string => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.hostname}:${wsPort}`; +}; + +export const createRunnerUrl = ( + testFile: string, + runnerBase?: string, + testNamePattern?: string, + cacheBust = false, +): string => { + const base = runnerBase || window.location.origin; + const url = new URL('/runner.html', base); + url.searchParams.set('testFile', testFile); + if (testNamePattern) { + url.searchParams.set('testNamePattern', testNamePattern); + } + if (cacheBust) { + url.searchParams.set('t', Date.now().toString()); + } + return url.toString(); +}; diff --git a/packages/browser-ui/src/hooks/useRpc.ts b/packages/browser-ui/src/hooks/useRpc.ts index 648415321..ac15d8d46 100644 --- a/packages/browser-ui/src/hooks/useRpc.ts +++ b/packages/browser-ui/src/hooks/useRpc.ts @@ -1,58 +1,9 @@ import { type BirpcReturn, createBirpc } from 'birpc'; import { useEffect, useRef, useState } from 'react'; -import type { - BrowserClientFileResult, - BrowserClientTestResult, - TestFileInfo, -} from '../types'; +import { createWebSocketUrl, RECONNECT_DELAYS } from '../core/runtime'; +import type { ContainerRPC, HostRPC, TestFileInfo } from '../types'; import { logger } from '../utils/logger'; -// ============================================================================ -// RPC Types -// ============================================================================ - -/** Payload for test file start event */ -export type TestFileStartPayload = { - testPath: string; - projectName: string; -}; - -/** Payload for log event */ -export type LogPayload = { - level: 'log' | 'warn' | 'error' | 'info' | 'debug'; - content: string; - testPath: string; - type: 'stdout' | 'stderr'; - trace?: string; -}; - -/** Payload for fatal error event */ -export type FatalPayload = { - message: string; - stack?: string; -}; - -export type HostRPC = { - rerunTest: (testFile: string, testNamePattern?: string) => Promise; - getTestFiles: () => Promise; - // Test result callbacks from container - onTestFileStart: (payload: TestFileStartPayload) => Promise; - onTestCaseResult: (payload: BrowserClientTestResult) => Promise; - onTestFileComplete: (payload: BrowserClientFileResult) => Promise; - onLog: (payload: LogPayload) => Promise; - onFatal: (payload: FatalPayload) => Promise; - // Snapshot file operations (for browser mode snapshot support) - resolveSnapshotPath: (testPath: string) => Promise; - readSnapshotFile: (filepath: string) => Promise; - saveSnapshotFile: (filepath: string, content: string) => Promise; - removeSnapshotFile: (filepath: string) => Promise; -}; - -export type ContainerRPC = { - onTestFileUpdate: (testFiles: TestFileInfo[]) => void; - reloadTestFile: (testFile: string, testNamePattern?: string) => void; -}; - export type RpcState = { rpc: BirpcReturn | null; loading: boolean; @@ -63,8 +14,6 @@ export type RpcState = { // useRpc Hook - WebSocket connection with reconnect logic // ============================================================================ -const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000]; // Exponential backoff, max 30s - export const useRpc = ( setTestFiles: (files: TestFileInfo[]) => void, wsPort: number | undefined, @@ -104,9 +53,7 @@ export const useRpc = ( const connect = () => { if (!isMounted) return; - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.hostname}:${wsPort}`; - ws = new WebSocket(wsUrl); + ws = new WebSocket(createWebSocketUrl(wsPort)); activeWsRef.current = ws; const methods: ContainerRPC = { diff --git a/packages/browser-ui/src/main.tsx b/packages/browser-ui/src/main.tsx index 7c2cb0e6a..5357f362f 100644 --- a/packages/browser-ui/src/main.tsx +++ b/packages/browser-ui/src/main.tsx @@ -8,14 +8,16 @@ import { SidebarHeader } from './components/SidebarHeader'; import { TestFilesHeader } from './components/TestFilesHeader'; import { TestFilesTree } from './components/TestFilesTree'; import { ViewportFrame } from './components/ViewportFrame'; +import { forwardSnapshotRpcRequest, readDispatchMessage } from './core/channel'; +import { createRunnerUrl } from './core/runtime'; import { useRpc } from './hooks/useRpc'; import type { BrowserClientFileResult, - BrowserClientMessage, BrowserClientTestResult, BrowserHostConfig, + FatalPayload, + LogPayload, SnapshotRpcRequest, - SnapshotRpcResponse, TestFileInfo, } from './types'; import type { @@ -38,20 +40,6 @@ const getDisplayName = (testFile: string): string => { return parts[parts.length - 1] || testFile; }; -const iframeUrlFor = ( - testFile: string, - runnerBase?: string, - testNamePattern?: string, -): string => { - const base = runnerBase || window.location.origin; - const url = new URL('/runner.html', base); - url.searchParams.set('testFile', testFile); - if (testNamePattern) { - url.searchParams.set('testNamePattern', testNamePattern); - } - return url.toString(); -}; - // ============================================================================ // App Component // ============================================================================ @@ -193,7 +181,7 @@ const BrowserRunner: React.FC<{ } return { ...prev, [testFile]: updatedCases }; }); - const newSrc = iframeUrlFor( + const newSrc = createRunnerUrl( testFile, options.runnerUrl, testNamePattern, @@ -321,140 +309,84 @@ const BrowserRunner: React.FC<{ // Handle messages from test runner iframes useEffect(() => { const listener = (event: MessageEvent) => { - if (event.data?.type === '__rstest_dispatch__') { - const message = event.data.payload as BrowserClientMessage | undefined; - if (message?.type === 'file-start') { - const payload = message.payload as { - testPath?: string; - projectName?: string; - }; - const testPath = payload.testPath; - if (typeof testPath === 'string') { - setStatusMap((prev) => ({ ...prev, [testPath]: 'running' })); - setCaseMap((prev) => { - const prevFile = prev[testPath] ?? {}; - const updatedCases: Record = {}; - for (const [key, caseInfo] of Object.entries(prevFile)) { - updatedCases[key] = { ...caseInfo, status: 'running' }; - } - return { ...prev, [testPath]: updatedCases }; - }); - // Forward to host via RPC - rpc?.onTestFileStart({ - testPath, - projectName: payload.projectName ?? '', - }); - } - } else if (message?.type === 'case-result') { - const payload = message.payload as BrowserClientTestResult; - if (payload?.testPath) { - upsertCase(payload.testPath, payload); - // Forward to host via RPC - rpc?.onTestCaseResult(payload); - } - } else if (message?.type === 'file-complete') { - const payload = message.payload as BrowserClientFileResult; - const testPath = payload.testPath; - if (typeof testPath === 'string') { - const passed = - payload.status === 'pass' || payload.status === 'skip'; - setStatusMap((prev) => ({ - ...prev, - [testPath]: passed ? 'pass' : 'fail', - })); - // Replace the caseMap for this file with only the cases that exist in the results - // This ensures deleted test cases are removed from the UI - setCaseMap((prev) => { - const newCases: Record = {}; - for (const result of payload.results ?? []) { - if (result?.testId) { - const parentNames = (result.parentNames ?? []).filter( - Boolean, - ); - const fullName = - [...parentNames, result.name].join(' ') || result.name; - newCases[result.testId] = { - id: result.testId, - name: result.name, - parentNames, - fullName, - status: mapCaseStatus(result.status), - filePath: result.testPath || testPath, - location: result.location, - }; - } - } - return { ...prev, [testPath]: newCases }; - }); - // Forward to host via RPC - rpc?.onTestFileComplete(payload); - } - } else if (message?.type === 'fatal') { - if (active) { - setStatusMap((prev) => ({ ...prev, [active]: 'fail' })); - } - const payload = message.payload as { - message: string; - stack?: string; - }; - // Forward to host via RPC - rpc?.onFatal(payload); - } else if (message?.type === 'log') { - const payload = message.payload as { - level: 'log' | 'warn' | 'error' | 'info' | 'debug'; - content: string; - testPath: string; - type: 'stdout' | 'stderr'; - trace?: string; - }; - // Forward to host via RPC - rpc?.onLog(payload); - } else if (message?.type === 'snapshot-rpc-request') { - // Handle snapshot RPC requests from runner iframes - const request = message.payload as SnapshotRpcRequest; - const sourceWindow = event.source as Window | null; - - if (!rpc || !sourceWindow) { - return; - } + const message = readDispatchMessage(event); + if (!message) { + return; + } - // Forward to host and send response back to iframe - const sendResponse = (response: SnapshotRpcResponse) => { - sourceWindow.postMessage( - { type: '__rstest_snapshot_response__', payload: response }, - '*', - ); - }; - - (async () => { - try { - let result: unknown; - switch (request.method) { - case 'resolveSnapshotPath': - result = await rpc.resolveSnapshotPath(request.args.testPath); - break; - case 'readSnapshotFile': - result = await rpc.readSnapshotFile(request.args.filepath); - break; - case 'saveSnapshotFile': - result = await rpc.saveSnapshotFile( - request.args.filepath, - request.args.content, - ); - break; - case 'removeSnapshotFile': - result = await rpc.removeSnapshotFile(request.args.filepath); - break; + if (message.type === 'file-start') { + const payload = message.payload as { + testPath?: string; + projectName?: string; + }; + const testPath = payload.testPath; + if (typeof testPath === 'string') { + setStatusMap((prev) => ({ ...prev, [testPath]: 'running' })); + setCaseMap((prev) => { + const prevFile = prev[testPath] ?? {}; + const updatedCases: Record = {}; + for (const [key, caseInfo] of Object.entries(prevFile)) { + updatedCases[key] = { ...caseInfo, status: 'running' }; + } + return { ...prev, [testPath]: updatedCases }; + }); + rpc?.onTestFileStart({ + testPath, + projectName: payload.projectName ?? '', + }); + } + } else if (message.type === 'case-result') { + const payload = message.payload as BrowserClientTestResult; + if (payload?.testPath) { + upsertCase(payload.testPath, payload); + rpc?.onTestCaseResult(payload); + } + } else if (message.type === 'file-complete') { + const payload = message.payload as BrowserClientFileResult; + const testPath = payload.testPath; + if (typeof testPath === 'string') { + const passed = payload.status === 'pass' || payload.status === 'skip'; + setStatusMap((prev) => ({ + ...prev, + [testPath]: passed ? 'pass' : 'fail', + })); + setCaseMap((prev) => { + const newCases: Record = {}; + for (const result of payload.results ?? []) { + if (result?.testId) { + const parentNames = (result.parentNames ?? []).filter(Boolean); + const fullName = + [...parentNames, result.name].join(' ') || result.name; + newCases[result.testId] = { + id: result.testId, + name: result.name, + parentNames, + fullName, + status: mapCaseStatus(result.status), + filePath: result.testPath || testPath, + location: result.location, + }; } - sendResponse({ id: request.id, result }); - } catch (error) { - sendResponse({ - id: request.id, - error: error instanceof Error ? error.message : String(error), - }); } - })(); + return { ...prev, [testPath]: newCases }; + }); + rpc?.onTestFileComplete(payload); } + } else if (message.type === 'fatal') { + if (active) { + setStatusMap((prev) => ({ ...prev, [active]: 'fail' })); + } + const payload = message.payload as FatalPayload; + rpc?.onFatal(payload); + } else if (message.type === 'log') { + const payload = message.payload as LogPayload; + rpc?.onLog(payload); + } else if (message.type === 'snapshot-rpc-request') { + void forwardSnapshotRpcRequest( + rpc, + message.payload as SnapshotRpcRequest, + event.source, + ); } }; window.addEventListener('message', listener); @@ -750,7 +682,7 @@ const BrowserRunner: React.FC<{