Add harness infrastructure for web-shell package (#6517)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run

* test(web-shell): add browser and lint harness

* test(web-shell): harden browser smoke harness

* fix(web-shell): guard mock daemon model state

* test(web-shell): remove unused scenario harness

* fix(web-shell): remove stale lint disables

* test(web-shell): make matchMedia stub writable

* fix(web-shell): exclude tests from package typecheck

* test(web-shell): tighten mock daemon route contract

* Update packages/web-shell/client/e2e/utils/mockDaemon.ts

Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>

* test(web-shell): clear stale SSE connections

* ci(web-shell): gate smoke on full CI profile

---------

Co-authored-by: ermin.zem <ermin.zem@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
This commit is contained in:
ermin.zem 2026-07-09 16:11:58 +08:00 committed by GitHub
parent c9a80996d4
commit 5c82857fea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1873 additions and 48 deletions

View file

@ -365,6 +365,64 @@ jobs:
name: 'coverage-reports-22.x-ubuntu-latest'
path: 'packages/*/coverage'
web_shell_e2e_smoke:
name: 'web-shell E2E Smoke (ubuntu-latest, Node 22.x)'
needs:
- 'classify_pr'
- 'test'
if: |-
${{
!cancelled() &&
github.event_name == 'pull_request' &&
needs.classify_pr.outputs.skip_ci != 'true' &&
needs.test.outputs.ci_profile == 'full'
}}
runs-on: 'ubuntu-latest'
timeout-minutes: 20
permissions:
contents: 'read'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
with:
ref: "${{ format('refs/pull/{0}/head', github.event.pull_request.number) }}"
fetch-depth: 1
- name: 'Set up Node.js 22.x'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci --prefer-offline --no-audit --progress=false
- name: 'Install Playwright Chromium'
run: 'npx playwright install --with-deps chromium'
- name: 'Run web-shell browser smoke'
run: 'npm run test:e2e:smoke --workspace=packages/web-shell'
- name: 'Upload web-shell Playwright artifacts'
if: '${{ always() }}'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'web-shell-e2e-smoke'
path: |-
packages/web-shell/client/e2e/test-results
packages/web-shell/client/e2e/playwright-report
if-no-files-found: 'ignore'
# macOS/Windows: slowest/costliest runners, rare platform regressions — run
# only in the merge queue. Skipped on PR (ubuntu is the fast PR signal) and on
# push (the queue already tested the merged tree, so a post-merge re-run is

View file

@ -144,3 +144,47 @@ jobs:
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
run: 'npm run test:e2e'
web-shell-browser-regression:
name: 'web-shell Browser Regression'
runs-on: 'ubuntu-latest'
if: |-
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci --prefer-offline --no-audit --progress=false
- name: 'Install Playwright Chromium'
run: 'npx playwright install --with-deps chromium'
- name: 'Run web-shell browser regression'
run: 'npm run test:e2e --workspace=packages/web-shell'
- name: 'Upload web-shell Playwright artifacts'
if: '${{ always() }}'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'web-shell-browser-regression'
path: |-
packages/web-shell/client/e2e/test-results
packages/web-shell/client/e2e/playwright-report
if-no-files-found: 'ignore'

2
.gitignore vendored
View file

@ -76,6 +76,8 @@ bundle
# Test report files
junit.xml
packages/*/coverage/
packages/web-shell/client/e2e/playwright-report/
packages/web-shell/client/e2e/test-results/
# PR body draft
pr_body.md

View file

@ -170,6 +170,82 @@ export default tseslint.config(
'default-case': 'error',
},
},
{
files: [
'packages/web-shell/client/**/*.{ts,tsx}',
'packages/web-shell/*.config.ts',
],
plugins: {
import: importPlugin,
},
settings: {
'import/resolver': {
node: true,
},
},
languageOptions: {
globals: {
...globals.browser,
...globals.es2021,
...globals.node,
},
},
rules: {
'react/prop-types': 'off',
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false },
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'no-console': ['error', { allow: ['warn', 'error'] }],
'no-debugger': 'error',
'object-shorthand': 'error',
'prefer-const': ['error', { destructuring: 'all' }],
},
},
{
files: [
'packages/web-shell/client/**/*.test.{ts,tsx}',
'packages/web-shell/client/test/**/*.{ts,tsx}',
],
plugins: {
vitest,
},
languageOptions: {
globals: {
...globals.browser,
...globals.es2021,
...globals.vitest,
},
},
rules: {
...vitest.configs.recommended.rules,
'vitest/expect-expect': 'off',
'vitest/no-commented-out-tests': 'off',
'no-console': 'off',
},
},
{
files: ['packages/web-shell/client/e2e/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.es2021,
...globals.node,
},
},
rules: {
'no-console': 'off',
},
},
{
// Enforce kebab-case filenames
files: ['packages/core/src/**/*.ts', 'packages/cli/src/**/*.ts'],

1
package-lock.json generated
View file

@ -27380,6 +27380,7 @@
"shiki": "^1.29.2"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@qwen-code/sdk": "file:../sdk-typescript",
"@qwen-code/webui": "file:../webui",
"@types/node": "^22.0.0",

View file

@ -627,12 +627,6 @@ function isAlreadyDispatched(error: unknown): error is AlreadyDispatchedError {
);
}
function logSessionNoticesHook(notices: readonly DaemonSessionNotice[]): void {
if (notices.length > 0) {
console.info('[web-shell] useSessionNotices()', { notices });
}
}
function shouldToastNotice(notice: DaemonSessionNotice): boolean {
return (
notice.category === 'validation' ||
@ -1817,11 +1811,9 @@ export function App({
(error: unknown, fallback: string) => {
if (isAbortError(error)) return;
if (isDaemonTurnError(error)) {
console.debug('[web-shell] turn error rendered in transcript', error);
return;
}
if (isAlreadyDispatched(error)) {
console.debug('[web-shell] error already handled by notice', error);
return;
}
const message = formatError(error, fallback);
@ -1913,13 +1905,10 @@ export function App({
);
useEffect(() => {
logSessionNoticesHook(notices);
for (const notice of notices) {
if (shouldToastNotice(notice)) {
pushToast(toastToneFromNotice(notice), notice.message);
} else if (notice.category === 'lifecycle') {
console.debug('[web-shell] daemon notice', notice);
} else {
} else if (notice.category !== 'lifecycle') {
console.warn('[web-shell] daemon notice', notice);
}
dismissNotice(notice.id);

View file

@ -704,7 +704,12 @@ function SlashCommandPanel({
} as CSSProperties;
return createPortal(
<div ref={panelRef} className={styles.slashPortalLayer} style={themeVars}>
<div
ref={panelRef}
className={styles.slashPortalLayer}
style={themeVars}
data-web-shell-slash-menu
>
<div
className={styles.slashPanel}
style={positionedPanelStyle}
@ -1299,10 +1304,11 @@ export const ChatEditor = memo(
const showCancelButton = isRunning && !core.hasContent;
return (
<div className={styles.editorShell} data-composer>
<div className={styles.editorShell} data-composer data-web-shell-composer>
<div
ref={containerRef}
className={styles.container}
data-web-shell-composer-surface
data-dac-glow
onClick={() => {
setModeDropdownOpen(false);
@ -1457,7 +1463,7 @@ export const ChatEditor = memo(
!
</span>
)}
<div ref={core.containerRef} />
<div ref={core.containerRef} data-web-shell-composer-editor />
</div>
<div className={styles.toolbar}>
<div className={styles.toolbarLeading}>
@ -1486,6 +1492,7 @@ export const ChatEditor = memo(
<button
ref={modeBtnRef}
className={styles.toolBtn}
data-web-shell-mode-button
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
@ -1521,6 +1528,7 @@ export const ChatEditor = memo(
<button
ref={modelBtnRef}
className={styles.toolBtn}
data-web-shell-model-button
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
@ -1646,6 +1654,7 @@ export const ChatEditor = memo(
? !onCancel
: core.disabled || !core.hasContent
}
data-web-shell-composer-submit
onClick={(e) => {
e.stopPropagation();
if (isPreparing) {

View file

@ -135,6 +135,7 @@ const userShellMsg = (
id,
role: 'user_shell',
command: 'npm test',
output: '',
});
const toolMsg = (id: string): ToolGroupMessage => ({
id,

View file

@ -115,7 +115,9 @@ function makeBackgroundAgentToolGroup(id: string): Message {
};
}
function makeMultiToolGroup(id: string): Message {
function makeMultiToolGroup(
id: string,
): Extract<Message, { role: 'tool_group' }> {
return {
id,
role: 'tool_group',
@ -126,15 +128,19 @@ function makeMultiToolGroup(id: string): Message {
};
}
function makeUserMessage(id: string): Message {
function makeUserMessage(id: string): Extract<Message, { role: 'user' }> {
return { id, role: 'user', content: 'hello' };
}
function makeUserShellMessage(id: string): Message {
return { id, role: 'user_shell', command: 'npm test' };
function makeUserShellMessage(
id: string,
): Extract<Message, { role: 'user_shell' }> {
return { id, role: 'user_shell', command: 'npm test', output: '' };
}
function makeAssistantMessage(id: string): Message {
function makeAssistantMessage(
id: string,
): Extract<Message, { role: 'assistant' }> {
return { id, role: 'assistant', content: 'response' };
}
@ -310,7 +316,9 @@ describe('groupParallelAgents', () => {
});
describe('getTurnTimelineNode', () => {
const item = (message: Message): DisplayItem => ({
const item = (
message: Message,
): Extract<DisplayItem, { type: 'message' }> => ({
type: 'message',
key: message.id,
message,

View file

@ -456,7 +456,6 @@ function isHideableStep(item: DisplayItem, isFinalAnswer: boolean): boolean {
// assign to `never` here. At runtime (e.g. a newer daemon sending an
// unknown role) it falls through as not-hideable — kept visible rather
// than crashing the transcript or vanishing from a collapsed turn.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = item.message;
return false;
}
@ -538,7 +537,6 @@ export function getTurnTimelineNode(
case 'insight_error':
return { kind: 'none', timestamp: message.timestamp };
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = message;
return { kind: 'none' };
}
@ -679,7 +677,6 @@ function timelineDetailSnippetForMessage(
case 'insight_error':
return '';
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = message;
return '';
}
@ -882,7 +879,6 @@ export function getSessionTimelineSignature(
case 'insight_error':
return base;
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = message;
return base;
}
@ -2856,6 +2852,7 @@ export const MessageList = memo(
<div
ref={containerRef}
className={styles.list}
data-web-shell-message-list
onClickCapture={handleDisclosureClickCapture}
>
{showLoadingSkeleton && (
@ -2886,6 +2883,7 @@ export const MessageList = memo(
visibleItems[virtualRow.index - headerOffset],
),
)}
data-web-shell-message-row
style={{
position: 'absolute',
top: 0,
@ -2907,6 +2905,7 @@ export const MessageList = memo(
key={key}
data-index={index}
className={getRowClassName(item)}
data-web-shell-message-row
>
{renderVirtualItem(index)}
</div>

View file

@ -23,7 +23,12 @@ export function ToastHost({
}: ToastHostProps) {
if (toasts.length === 0) return null;
return (
<div className={styles.host} role="status" aria-live="polite">
<div
className={styles.host}
role="status"
aria-live="polite"
data-web-shell-toast-host
>
{toasts.map((toast) => (
<ToastItem
key={toast.id}
@ -52,7 +57,11 @@ function ToastItem({
}, [autoDismissMs, onDismiss, toast.id]);
return (
<div className={`${styles.toast} ${styles[toast.tone]}`}>
<div
className={`${styles.toast} ${styles[toast.tone]}`}
data-web-shell-toast
data-tone={toast.tone}
>
<div className={styles.message}>{toast.message}</div>
<button
type="button"

View file

@ -77,6 +77,7 @@ export function ApprovalModeDialog({
approvalModes.length > 0 ? `mode-opt-${activeIndex}` : undefined
}
aria-label={t('mode.select')}
data-web-shell-approval-mode-dialog
>
{approvalModes.map((mode, index) => {
const selected = index === activeIndex;
@ -92,6 +93,8 @@ export function ApprovalModeDialog({
className={`${styles.row} ${selected ? styles.selected : ''} ${
isCurrent ? dp('dialog-current') : ''
}`}
data-web-shell-approval-mode-option
data-mode-id={mode.id}
onClick={() => confirm(index)}
onMouseMove={() => moveHighlight(index)}
>

View file

@ -236,6 +236,8 @@ export function DialogShell({
aria-modal="true"
aria-label={title}
tabIndex={-1}
data-web-shell-dialog
data-web-shell-dialog-title={title}
>
<header className={styles.header}>
<div className={styles.titleWrap}>

View file

@ -176,6 +176,7 @@ export function ModelDialog({
? t('model.setVision')
: t('model.select')
}
data-web-shell-model-dialog
>
{availableModels.length === 0 ? (
<div className={styles.empty}>{t('model.none')}</div>
@ -200,6 +201,8 @@ export function ModelDialog({
className={`${styles.row} ${selected ? styles.selected : ''} ${
isCurrent ? dp('dialog-current') : ''
}`}
data-web-shell-model-option
data-model-id={model.id}
onClick={() => confirm(index)}
onMouseMove={() => moveHighlight(index)}
>

View file

@ -73,7 +73,10 @@ export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) {
}, []);
return (
<div className={dp('picker', 'picker-in-shell')}>
<div
className={dp('picker', 'picker-in-shell')}
data-web-shell-resume-dialog
>
<div className={dp('picker-search')}>
<span className={dp('picker-search-label')}>
{t('resume.search')}:{' '}
@ -131,6 +134,7 @@ export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) {
active={index === selectedIdx}
current={s.sessionId === currentSessionId}
currentLabel={t('resume.current')}
resumeSelector
onClick={() => confirm(index)}
onActivate={() => setSelectedIdx(index)}
/>

View file

@ -29,6 +29,8 @@ interface SessionRowProps {
leading?: ReactNode;
/** Trailing slot in the title row, e.g. a status badge. */
trailing?: ReactNode;
/** Test-only selector for the resume dialog's session options. */
resumeSelector?: boolean;
onClick: () => void;
/**
* Pointer moved over the row (real movement see useListboxKeyboard). This
@ -56,6 +58,7 @@ export function SessionRow({
ariaSelected,
leading,
trailing,
resumeSelector,
onClick,
onActivate,
}: SessionRowProps) {
@ -78,6 +81,8 @@ export function SessionRow({
disabled ? 'disabled' : undefined,
)}
title={current ? currentLabel : undefined}
data-web-shell-resume-session={resumeSelector ? '' : undefined}
data-session-id={resumeSelector ? session.sessionId : undefined}
onClick={onClick}
onMouseMove={onActivate}
>

View file

@ -62,6 +62,7 @@ export function ThemeDialog({
aria-activedescendant={
themes.length > 0 ? `theme-opt-${selectedIdx}` : undefined
}
data-web-shell-theme-dialog
>
{themes.map((theme, index) => {
const selected = theme.id === currentTheme;
@ -77,6 +78,8 @@ export function ThemeDialog({
index === selectedIdx ? 'selected' : undefined,
selected ? 'dialog-current' : undefined,
)}
data-web-shell-theme-option
data-theme-id={theme.id}
onClick={() => confirm(index)}
onMouseMove={() => setSelectedIdx(index)}
>

View file

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { StrictMode, act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider, type WebShellLanguage } from '../../i18n';
import { immediateClipboardWrite } from '../../test/reactHarness';
import { EnhancedMarkdownTable } from './EnhancedMarkdownTable';
(
@ -172,7 +173,7 @@ function textButtonContaining(
}
function mockClipboard() {
const writeText = vi.fn(() => Promise.resolve());
const writeText = vi.fn(() => immediateClipboardWrite());
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },

View file

@ -274,6 +274,7 @@ export function ToolApproval({
? `${styles.approval} ${styles.floating}`
: styles.approval
}
data-web-shell-permission-panel
>
<div className={styles.header}>
<span className={styles.icon}>?</span>
@ -317,6 +318,8 @@ export function ToolApproval({
className={`${styles.option} ${getOptionClassName(option)} ${
isSelected ? styles.optionActive : ''
}`}
data-web-shell-permission-option
data-option-id={option.id}
onClick={() => confirm(option.id)}
>
<span className={styles.pointer}>{isSelected ? '' : ' '}</span>

View file

@ -0,0 +1,883 @@
import type { Page, Route } from '@playwright/test';
import {
DAEMON_APPROVAL_MODES,
type DaemonApprovalMode,
type DaemonCapabilities,
type DaemonEvent,
type DaemonRestoredSession,
type DaemonSession,
type DaemonSessionState,
type DaemonSessionSummary,
type DaemonWorkspaceExtensionsStatus,
type DaemonWorkspaceMcpResourcesStatus,
type DaemonWorkspaceMcpStatus,
type DaemonWorkspaceMcpToolsStatus,
type DaemonWorkspaceProvidersStatus,
type DaemonWorkspaceSettingsStatus,
type DaemonWorkspaceSkillsStatus,
type DaemonWorkspaceToolsStatus,
type DaemonWorkspaceVoiceStatus,
type PermissionResponse,
type PromptRequest,
} from '@qwen-code/sdk/daemon';
import { installSseTransport, type SseTransport } from './sseTransport';
export interface DaemonRequestRecord {
method: string;
path: string;
body: unknown;
headers: Record<string, string>;
}
export interface WebShellDaemonScenario {
workspaceCwd: string;
sessionId: string;
clientId: string;
displayName: string;
currentModel: string;
currentMode: string;
capabilities: DaemonCapabilities;
providers: DaemonWorkspaceProvidersStatus;
skills: DaemonWorkspaceSkillsStatus;
settings: DaemonWorkspaceSettingsStatus;
sessions: DaemonSessionSummary[];
events: DaemonEvent[];
state: DaemonSessionState;
}
export interface MockDaemonController {
scenario: WebShellDaemonScenario;
sse: SseTransport<DaemonEvent>;
requests: readonly DaemonRequestRecord[];
sendEvent(event: DaemonEvent): Promise<void>;
burstEvents(events: readonly DaemonEvent[]): Promise<void>;
promptRequests(): DaemonRequestRecord[];
permissionRequests(): DaemonRequestRecord[];
modelRequests(): DaemonRequestRecord[];
}
type ScenarioOverrides = Partial<
Omit<
WebShellDaemonScenario,
'capabilities' | 'providers' | 'skills' | 'settings' | 'sessions' | 'state'
>
> & {
capabilities?: Partial<DaemonCapabilities>;
providers?: Partial<DaemonWorkspaceProvidersStatus>;
skills?: Partial<DaemonWorkspaceSkillsStatus>;
settings?: Partial<DaemonWorkspaceSettingsStatus>;
sessions?: DaemonSessionSummary[];
state?: Partial<DaemonSessionState>;
};
const now = '2026-07-03T00:00:00.000Z';
export function applyScenarioCurrentModel(
scenario: WebShellDaemonScenario,
modelId: string,
): void {
scenario.currentModel = modelId;
const models =
scenario.state.models && typeof scenario.state.models === 'object'
? scenario.state.models
: {};
scenario.state.models = {
...models,
currentModelId: modelId,
};
scenario.providers = {
...scenario.providers,
current: {
...scenario.providers.current,
modelId,
fastModelId: modelId,
},
providers: scenario.providers.providers.map((provider) => ({
...provider,
models: provider.models?.map((model) => ({
...model,
isCurrent: model.modelId === modelId,
})),
})),
};
}
export function createWebShellDaemonScenario(
overrides: ScenarioOverrides = {},
): WebShellDaemonScenario {
const workspaceCwd = overrides.workspaceCwd ?? '/tmp/qwen-web-shell-e2e';
const sessionId = overrides.sessionId ?? 'web-shell-e2e-session';
const clientId = overrides.clientId ?? 'web-shell-e2e-client';
const displayName = overrides.displayName ?? 'E2E Harness Session';
const currentModel = overrides.currentModel ?? 'qwen-test';
const currentMode = overrides.currentMode ?? 'default';
const state: DaemonSessionState = {
displayName,
models: {
currentModelId: currentModel,
availableModels: [
{
modelId: currentModel,
baseModelId: currentModel,
name: 'Qwen Test',
contextLimit: 32_768,
},
{
modelId: 'qwen-test-alt',
baseModelId: 'qwen-test-alt',
name: 'Qwen Test Alt',
contextLimit: 16_384,
},
],
},
modes: {
currentModeId: currentMode,
},
...(overrides.state ?? {}),
};
const capabilities: DaemonCapabilities = {
v: 1,
mode: 'http-bridge',
features: [
'session_events',
'permission_vote',
'session_permission_vote',
'session_scope_override',
'workspace_settings',
'workspace_voice',
],
modelServices: ['qwen-test'],
transports: ['rest-sse'],
workspaceCwd,
qwenCodeVersion: '0.0.0-e2e',
...(overrides.capabilities ?? {}),
};
const providers: DaemonWorkspaceProvidersStatus = {
v: 1,
workspaceCwd,
initialized: true,
acpChannelLive: true,
approvalMode: currentMode as DaemonWorkspaceProvidersStatus['approvalMode'],
current: {
authType: 'qwen-oauth',
modelId: currentModel,
fastModelId: currentModel,
},
providers: [
{
kind: 'model_provider',
status: 'ok',
authType: 'qwen-oauth',
current: true,
models: [
{
modelId: currentModel,
baseModelId: currentModel,
name: 'Qwen Test',
contextLimit: 32_768,
isCurrent: true,
isRuntime: true,
},
{
modelId: 'qwen-test-alt',
baseModelId: 'qwen-test-alt',
name: 'Qwen Test Alt',
contextLimit: 16_384,
isCurrent: false,
isRuntime: true,
},
],
},
],
...(overrides.providers ?? {}),
};
const skills: DaemonWorkspaceSkillsStatus = {
v: 1,
workspaceCwd,
initialized: true,
skills: [],
...(overrides.skills ?? {}),
};
const settings: DaemonWorkspaceSettingsStatus = {
v: 1,
settings: [],
...(overrides.settings ?? {}),
};
const sessions = overrides.sessions ?? [
{
sessionId,
workspaceCwd,
createdAt: now,
updatedAt: now,
displayName,
clientCount: 1,
hasActivePrompt: false,
},
{
sessionId: 'previous-session',
workspaceCwd,
createdAt: now,
updatedAt: now,
displayName: 'Previous Session',
clientCount: 0,
hasActivePrompt: false,
},
];
return {
workspaceCwd,
sessionId,
clientId,
displayName,
currentModel,
currentMode,
capabilities,
providers,
skills,
settings,
sessions,
events: overrides.events ?? [],
state,
};
}
export async function installMockDaemon(
page: Page,
scenario: WebShellDaemonScenario,
options: { baseURL?: string } = {},
): Promise<MockDaemonController> {
const baseURL = options.baseURL ?? getPlaywrightBaseURL();
const baseOrigin = new URL(baseURL).origin;
const requests: DaemonRequestRecord[] = [];
const sse = await installSseTransport<DaemonEvent>(page, { baseURL });
await page.route(`${baseOrigin}/**`, async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
const path = url.pathname;
if (url.origin !== baseOrigin) {
await route.fallback();
return;
}
if (!isDaemonPath(path)) {
await route.fallback();
return;
}
const body = readRequestBody(request.postData());
requests.push({
method,
path,
body,
headers: request.headers(),
});
if (!isDaemonRoute(method, path)) {
await methodNotAllowed(route, method, path);
return;
}
await handleDaemonRoute(route, method, path, scenario, body);
});
return {
scenario,
sse,
requests,
sendEvent: (event) => sse.send(event),
burstEvents: (events) => sse.burst(events),
promptRequests: () =>
requests.filter((request) =>
/\/session\/[^/]+\/prompt\/?$/.test(request.path),
),
permissionRequests: () =>
requests.filter((request) => /\/permission\/[^/]+$/.test(request.path)),
modelRequests: () =>
requests.filter((request) =>
/\/session\/[^/]+\/model$/.test(request.path),
),
};
}
export function userTextEvent(
text: string,
options: { id?: number; sessionId?: string } = {},
): DaemonEvent {
return sessionUpdateEvent(
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text },
...(options.sessionId ? { sessionId: options.sessionId } : {}),
},
options.id,
);
}
export function assistantTextEvent(
text: string,
options: { id?: number; sessionId?: string } = {},
): DaemonEvent {
return sessionUpdateEvent(
{
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
...(options.sessionId ? { sessionId: options.sessionId } : {}),
},
options.id,
);
}
export function turnCompleteEvent(
promptId: string,
options: { id?: number; sessionId?: string } = {},
): DaemonEvent {
return {
...(options.id !== undefined ? { id: options.id } : {}),
v: 1,
type: 'turn_complete',
data: {
promptId,
sessionId: options.sessionId,
stopReason: 'end_turn',
},
};
}
export function replayCompleteEvent(
options: { replayedCount?: number; sessionId?: string } = {},
): DaemonEvent {
return {
v: 1,
type: 'replay_complete',
data: {
sessionId: options.sessionId,
replayedCount: options.replayedCount ?? 0,
},
};
}
export function permissionRequestEvent(
requestId: string,
options: { id?: number; sessionId?: string } = {},
): DaemonEvent {
return {
...(options.id !== undefined ? { id: options.id } : {}),
v: 1,
type: 'permission_request',
data: {
requestId,
sessionId: options.sessionId,
toolCall: {
name: 'Bash',
input: {
command: 'printf web-shell-e2e',
},
},
options: [
{ optionId: 'allow_once', label: 'Allow once' },
{ optionId: 'reject_once', label: 'Reject' },
],
},
};
}
function sessionUpdateEvent(
update: Record<string, unknown>,
id?: number,
): DaemonEvent {
return {
...(id !== undefined ? { id } : {}),
v: 1,
type: 'session_update',
data: { update },
};
}
function getPlaywrightBaseURL(): string {
const port = process.env['PLAYWRIGHT_PORT'] ?? '5174';
return process.env['PLAYWRIGHT_BASE_URL'] ?? `http://127.0.0.1:${port}`;
}
function readRequestBody(raw: string | null): unknown {
if (!raw) return undefined;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
function isDaemonPath(path: string): boolean {
return (
path === '/health' ||
path === '/capabilities' ||
path === '/workspace/settings' ||
path === '/workspace/providers' ||
path === '/workspace/skills' ||
path === '/workspace/tools' ||
path === '/workspace/extensions' ||
path === '/workspace/mcp' ||
path === '/workspace/voice' ||
/^\/workspace\/mcp\/[^/]+\/tools\/?$/.test(path) ||
/^\/workspace\/mcp\/[^/]+\/resources\/?$/.test(path) ||
/^\/workspace\/.+\/sessions\/?$/.test(path) ||
path === '/session' ||
/^\/permission\/[^/]+\/?$/.test(path) ||
/^\/session\/[^/]+\/pending-prompts(?:\/[^/]+)?\/?$/.test(path) ||
/^\/session\/[^/]+\/(load|resume|prompt|permission\/[^/]+|context|supported-commands|events|model|approval-mode|heartbeat|cancel|detach)\/?$/.test(
path,
)
);
}
function isDaemonRoute(method: string, path: string): boolean {
if (method === 'GET' && (path === '/health' || path === '/capabilities')) {
return true;
}
if (
(method === 'GET' || method === 'POST') &&
path === '/workspace/settings'
) {
return true;
}
if (method === 'GET' && path === '/workspace/providers') return true;
if (method === 'GET' && path === '/workspace/skills') return true;
if (method === 'GET' && path === '/workspace/tools') return true;
if (method === 'GET' && path === '/workspace/extensions') return true;
if (method === 'GET' && path === '/workspace/mcp') return true;
if (method === 'GET' && path === '/workspace/voice') return true;
if (method === 'GET' && /^\/workspace\/mcp\/[^/]+\/tools\/?$/.test(path)) {
return true;
}
if (
method === 'GET' &&
/^\/workspace\/mcp\/[^/]+\/resources\/?$/.test(path)
) {
return true;
}
if (method === 'GET' && /^\/workspace\/.+\/sessions\/?$/.test(path)) {
return true;
}
if (method === 'POST' && path === '/session') return true;
if (method === 'POST' && /^\/permission\/[^/]+\/?$/.test(path)) return true;
if (
(method === 'GET' || method === 'DELETE') &&
/^\/session\/[^/]+\/pending-prompts(?:\/[^/]+)?\/?$/.test(path)
) {
return true;
}
if (method === 'GET' && /^\/session\/[^/]+\/events\/?$/.test(path)) {
return true;
}
if (
method === 'POST' &&
/^\/session\/[^/]+\/(load|resume|prompt|permission\/[^/]+|model|approval-mode|heartbeat|cancel|detach)\/?$/.test(
path,
)
) {
return true;
}
return (
method === 'GET' &&
/^\/session\/[^/]+\/(context|supported-commands)\/?$/.test(path)
);
}
async function handleDaemonRoute(
route: Route,
method: string,
path: string,
scenario: WebShellDaemonScenario,
body: unknown,
): Promise<void> {
if (method === 'GET' && path === '/health') {
await json(route, { ok: true, healthy: true });
return;
}
if (method === 'GET' && path === '/capabilities') {
await json(route, scenario.capabilities);
return;
}
if (method === 'GET' && path === '/workspace/providers') {
await json(route, scenario.providers);
return;
}
if (method === 'GET' && path === '/workspace/skills') {
await json(route, scenario.skills);
return;
}
if (method === 'GET' && path === '/workspace/settings') {
await json(route, scenario.settings);
return;
}
if (method === 'POST' && path === '/workspace/settings') {
await json(route, {
key: getRecordValue(body, 'key') ?? 'unknown',
scope: 'workspace',
value: getRecordValue(body, 'value'),
requiresRestart: false,
});
return;
}
if (method === 'GET' && path === '/workspace/tools') {
await json(route, workspaceTools(scenario));
return;
}
if (method === 'GET' && path === '/workspace/extensions') {
await json(route, workspaceExtensions(scenario));
return;
}
if (method === 'GET' && path === '/workspace/mcp') {
await json(route, workspaceMcp(scenario));
return;
}
if (method === 'GET' && path === '/workspace/voice') {
await json(route, workspaceVoice(scenario));
return;
}
if (method === 'GET' && /^\/workspace\/mcp\/[^/]+\/tools\/?$/.test(path)) {
const serverName = decodeURIComponent(path.split('/')[3] ?? 'server');
await json(route, workspaceMcpTools(scenario, serverName));
return;
}
if (
method === 'GET' &&
/^\/workspace\/mcp\/[^/]+\/resources\/?$/.test(path)
) {
const serverName = decodeURIComponent(path.split('/')[3] ?? 'server');
await json(route, workspaceMcpResources(scenario, serverName));
return;
}
if (method === 'GET' && /^\/workspace\/.+\/sessions\/?$/.test(path)) {
await json(route, { sessions: scenario.sessions });
return;
}
if (method === 'POST' && path === '/session') {
await json(route, sessionEnvelope(scenario, { attached: false }));
return;
}
const sessionMatch = path.match(
/^\/session\/([^/]+)\/([^/]+)(?:\/([^/]+))?\/?$/,
);
if (sessionMatch) {
const sessionId = decodeURIComponent(sessionMatch[1]);
const action = sessionMatch[2];
const extra = sessionMatch[3] ? decodeURIComponent(sessionMatch[3]) : '';
if (action === 'load' || action === 'resume') {
await json(route, restoredSessionEnvelope(scenario, sessionId));
return;
}
if (action === 'prompt') {
if (!isPromptRequest(body)) {
await badRequest(route, 'Invalid prompt request.');
return;
}
await json(
route,
{
promptId: promptIdFor(body),
lastEventId: maxEventId(scenario.events),
},
202,
);
return;
}
if (action === 'pending-prompts') {
if (method === 'DELETE') {
await json(route, { removed: true });
return;
}
await json(route, { pendingPrompts: [] });
return;
}
if (action === 'permission') {
await json(route, {});
return;
}
if (action === 'context') {
await json(route, {
v: 1,
sessionId,
workspaceCwd: scenario.workspaceCwd,
state: scenario.state,
});
return;
}
if (action === 'supported-commands') {
await json(route, {
v: 1,
sessionId,
availableCommands: [],
availableSkills: [],
});
return;
}
if (action === 'model') {
const modelId = readStringField(body, 'modelId');
if (!modelId) {
await badRequest(route, 'Invalid model request.');
return;
}
applyScenarioCurrentModel(scenario, modelId);
await json(route, { sessionId, modelId });
return;
}
if (action === 'approval-mode') {
const mode = readStringField(body, 'mode');
if (!mode || !isApprovalMode(mode)) {
await badRequest(route, 'Invalid approval mode request.');
return;
}
const previous = scenario.currentMode;
scenario.currentMode = mode;
scenario.state.modes = {
...(isRecord(scenario.state.modes) ? scenario.state.modes : {}),
currentModeId: mode,
};
scenario.providers = {
...scenario.providers,
approvalMode: mode,
};
await json(route, {
sessionId,
previous,
mode,
persisted: getRecordValue(body, 'persist') === true,
});
return;
}
if (action === 'heartbeat') {
await json(route, { ok: true });
return;
}
if (action === 'cancel') {
await json(route, {});
return;
}
if (action === 'detach') {
await route.fulfill({ status: 204, body: '' });
return;
}
if (action === 'events' || extra) {
await route.fulfill({
status: 200,
headers: { 'content-type': 'text/event-stream' },
body: ': web-shell mock daemon\n\n',
});
return;
}
}
if (method === 'POST' && /^\/permission\/[^/]+\/?$/.test(path)) {
const response = body as PermissionResponse | undefined;
await json(route, response ?? {});
return;
}
await json(
route,
{ error: `Unhandled mock daemon route: ${method} ${path}` },
404,
);
}
function sessionEnvelope(
scenario: WebShellDaemonScenario,
options: { attached: boolean },
): DaemonSession {
return {
sessionId: scenario.sessionId,
workspaceCwd: scenario.workspaceCwd,
attached: options.attached,
clientId: scenario.clientId,
createdAt: now,
hasActivePrompt: false,
};
}
function restoredSessionEnvelope(
scenario: WebShellDaemonScenario,
sessionId: string,
): DaemonRestoredSession {
return {
sessionId,
workspaceCwd: scenario.workspaceCwd,
attached: true,
clientId: scenario.clientId,
createdAt: now,
hasActivePrompt: false,
state: scenario.state,
compactedReplay: scenario.events,
liveJournal: [],
lastEventId: maxEventId(scenario.events),
};
}
function maxEventId(events: readonly DaemonEvent[]): number {
return events.reduce((max, event) => Math.max(max, event.id ?? max), 0);
}
function promptIdFor(body: PromptRequest): string {
const meta = body?._meta;
if (meta && typeof meta['promptId'] === 'string') return meta['promptId'];
return 'prompt-e2e';
}
function isPromptRequest(body: unknown): body is PromptRequest {
if (!isRecord(body)) return false;
const prompt = body['prompt'];
return Array.isArray(prompt) && prompt.some(isPromptContentBlock);
}
function isPromptContentBlock(block: unknown): boolean {
if (!isRecord(block)) return false;
if (block['type'] === 'text') {
return readStringField(block, 'text') !== undefined;
}
if (block['type'] === 'image') {
return (
readStringField(block, 'data') !== undefined ||
readStringField(block, 'url') !== undefined
);
}
return false;
}
function readStringField(body: unknown, key: string): string | undefined {
const value = getRecordValue(body, key);
return typeof value === 'string' && value.trim().length > 0
? value
: undefined;
}
function isApprovalMode(mode: string): mode is DaemonApprovalMode {
const modes: readonly string[] = DAEMON_APPROVAL_MODES;
return modes.includes(mode);
}
function getRecordValue(body: unknown, key: string): unknown {
if (!isRecord(body)) return undefined;
return body[key];
}
function isRecord(body: unknown): body is Record<string, unknown> {
return typeof body === 'object' && body !== null;
}
function workspaceMcp(
scenario: WebShellDaemonScenario,
): DaemonWorkspaceMcpStatus {
return {
v: 1,
workspaceCwd: scenario.workspaceCwd,
initialized: true,
discoveryState: 'completed',
servers: [],
errors: [],
clientCount: 0,
budgetMode: 'off',
budgets: [],
};
}
function workspaceMcpTools(
scenario: WebShellDaemonScenario,
serverName: string,
): DaemonWorkspaceMcpToolsStatus {
return {
v: 1,
workspaceCwd: scenario.workspaceCwd,
serverName,
initialized: true,
acpChannelLive: true,
tools: [],
errors: [],
};
}
function workspaceMcpResources(
scenario: WebShellDaemonScenario,
serverName: string,
): DaemonWorkspaceMcpResourcesStatus {
return {
v: 1,
workspaceCwd: scenario.workspaceCwd,
serverName,
initialized: true,
acpChannelLive: true,
resources: [],
errors: [],
};
}
function workspaceTools(
scenario: WebShellDaemonScenario,
): DaemonWorkspaceToolsStatus {
return {
v: 1,
workspaceCwd: scenario.workspaceCwd,
initialized: true,
acpChannelLive: true,
tools: [],
errors: [],
};
}
function workspaceExtensions(
scenario: WebShellDaemonScenario,
): DaemonWorkspaceExtensionsStatus {
return {
v: 1,
workspaceCwd: scenario.workspaceCwd,
initialized: true,
extensions: [],
errors: [],
};
}
function workspaceVoice(
scenario: WebShellDaemonScenario,
): DaemonWorkspaceVoiceStatus {
return {
v: 1,
workspaceCwd: scenario.workspaceCwd,
enabled: false,
mode: 'hold',
language: 'en',
voiceModel: null,
availableVoiceModels: [],
};
}
async function json(route: Route, body: unknown, status = 200): Promise<void> {
await route.fulfill({
status,
contentType: 'application/json',
headers: {
'access-control-allow-origin': '*',
'cache-control': 'no-store',
},
body: JSON.stringify(body),
});
}
async function badRequest(route: Route, error: string): Promise<void> {
await json(route, { error }, 400);
}
async function methodNotAllowed(
route: Route,
method: string,
path: string,
): Promise<void> {
await json(route, { error: `Method not allowed: ${method} ${path}` }, 405);
}

View file

@ -0,0 +1,227 @@
import type { Page } from '@playwright/test';
export interface SseConnectionRecord {
url: string;
sessionId: string;
headers: Record<string, string>;
connectedAt: number;
}
export interface SseTransport<TEvent> {
waitForConnection(
sessionId?: string,
options?: { timeout?: number },
): Promise<SseConnectionRecord>;
connections(): Promise<SseConnectionRecord[]>;
send(event: TEvent): Promise<void>;
burst(events: readonly TEvent[]): Promise<void>;
split(event: TEvent, chunkSizes?: readonly number[]): Promise<void>;
close(): Promise<void>;
error(message?: string): Promise<void>;
}
interface BrowserSseHarness {
connections: SseConnectionRecord[];
writeFrame: (frame: string) => void;
writeSplitFrame: (frame: string, chunkSizes: readonly number[]) => void;
close: () => void;
error: (message: string) => void;
}
declare global {
interface Window {
__webShellSseHarness?: BrowserSseHarness;
}
}
export async function installSseTransport<TEvent>(
page: Page,
options: { baseURL: string },
): Promise<SseTransport<TEvent>> {
await page.addInitScript(({ baseURL }) => {
const baseOrigin = new URL(baseURL, window.location.href).origin;
const originalFetch = window.fetch.bind(window);
const encoder = new TextEncoder();
const controllers: ReadableStreamDefaultController<Uint8Array>[] = [];
const connections: SseConnectionRecord[] = [];
function removeController(
target: ReadableStreamDefaultController<Uint8Array>,
) {
const index = controllers.indexOf(target);
if (index >= 0) {
controllers.splice(index, 1);
}
}
function removeConnection(target: SseConnectionRecord) {
const index = connections.indexOf(target);
if (index >= 0) {
connections.splice(index, 1);
}
}
function activeControllers() {
const active: ReadableStreamDefaultController<Uint8Array>[] = [];
for (const controller of controllers) {
try {
const _desiredSize = controller.desiredSize;
active.push(controller);
} catch {
continue;
}
}
if (active.length !== controllers.length) {
controllers.splice(0, controllers.length, ...active);
}
return active;
}
function writeBytes(bytes: Uint8Array) {
for (const controller of activeControllers()) {
controller.enqueue(bytes);
}
}
function finishActiveStreams(
finish: (controller: ReadableStreamDefaultController<Uint8Array>) => void,
) {
const active = activeControllers();
controllers.length = 0;
connections.length = 0;
for (const controller of active) {
finish(controller);
}
}
window.__webShellSseHarness = {
connections,
writeFrame(frame: string) {
writeBytes(encoder.encode(frame));
},
writeSplitFrame(frame: string, chunkSizes: readonly number[]) {
const bytes = encoder.encode(frame);
let offset = 0;
for (const size of chunkSizes) {
const nextOffset = Math.min(bytes.length, offset + Math.max(1, size));
writeBytes(bytes.slice(offset, nextOffset));
offset = nextOffset;
if (offset >= bytes.length) return;
}
writeBytes(bytes.slice(offset));
},
close() {
finishActiveStreams((controller) => controller.close());
},
error(message: string) {
finishActiveStreams((controller) =>
controller.error(new Error(message)),
);
},
};
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const url = new URL(request.url, window.location.href);
const match =
url.origin === baseOrigin &&
/^\/session\/[^/]+\/events\/?$/.test(url.pathname);
if (!match) return originalFetch(input, init);
const sessionId = decodeURIComponent(url.pathname.split('/')[2] ?? '');
let streamController: ReadableStreamDefaultController<Uint8Array> | null =
null;
let connectionRecord: SseConnectionRecord | null = null;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
controllers.push(controller);
connectionRecord = {
url: url.href,
sessionId,
headers: Object.fromEntries(request.headers.entries()),
connectedAt: Date.now(),
};
connections.push(connectionRecord);
},
cancel() {
if (streamController) {
removeController(streamController);
}
if (connectionRecord) {
removeConnection(connectionRecord);
}
},
});
return Promise.resolve(
new Response(stream, {
status: 200,
headers: {
'cache-control': 'no-cache',
'content-type': 'text/event-stream',
},
}),
);
};
}, options);
const frameFor = (event: TEvent) => `data: ${JSON.stringify(event)}\n\n`;
const transport: SseTransport<TEvent> = {
async waitForConnection(sessionId, { timeout = 10_000 } = {}) {
await page.waitForFunction(
({ targetSessionId }) => {
const harness = window.__webShellSseHarness;
if (!harness) return false;
return harness.connections.some(
(connection) =>
!targetSessionId || connection.sessionId === targetSessionId,
);
},
{ targetSessionId: sessionId },
{ timeout },
);
const connections = await transport.connections();
const match = [...connections]
.reverse()
.find((connection) => !sessionId || connection.sessionId === sessionId);
if (!match) throw new Error('SSE connection was not recorded.');
return match;
},
connections() {
return page.evaluate(
() => window.__webShellSseHarness?.connections ?? [],
);
},
send(event) {
return page.evaluate((frame) => {
window.__webShellSseHarness?.writeFrame(frame);
}, frameFor(event));
},
async burst(events) {
for (const event of events) {
await transport.send(event);
}
},
split(event, chunkSizes = [7, 3, 11]) {
return page.evaluate(
({ frame, sizes }) => {
window.__webShellSseHarness?.writeSplitFrame(frame, sizes);
},
{ frame: frameFor(event), sizes: chunkSizes },
);
},
close() {
return page.evaluate(() => window.__webShellSseHarness?.close());
},
error(message = 'SSE transport error') {
return page.evaluate(
(reason) => window.__webShellSseHarness?.error(reason),
message,
);
},
};
return transport;
}

View file

@ -0,0 +1,309 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
permissionRequestEvent,
replayCompleteEvent,
turnCompleteEvent,
userTextEvent,
type DaemonRequestRecord,
type MockDaemonController,
type WebShellDaemonScenario,
} from './utils/mockDaemon';
test('loads replayed transcript and connects to fake daemon @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('Hello from replay', { id: 1 }),
assistantTextEvent('Hello from fake daemon', { id: 2 }),
turnCompleteEvent('prompt-replay', { id: 3 }),
],
});
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
await expect(page.locator('[data-web-shell-message-list]')).toContainText(
'Hello from replay',
);
await expect(page.locator('[data-web-shell-message-list]')).toContainText(
'Hello from fake daemon',
);
});
test('submits a prompt and renders a streamed assistant response @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
await fillComposer(page, 'Ping from browser smoke');
await page.locator('[data-web-shell-composer-submit]').click();
await expect.poll(() => daemon.promptRequests().length).toBe(1);
const promptRequest = firstRequest(daemon.promptRequests());
expect(promptRequest.method).toBe('POST');
expect(promptRequest.path).toBe(`/session/${scenario.sessionId}/prompt`);
expectPromptBodyToContainText(
requestBodyRecord(promptRequest),
'Ping from browser smoke',
);
await daemon.sse.split(assistantTextEvent('Pong from fake SSE', { id: 10 }));
await daemon.sendEvent(turnCompleteEvent('prompt-e2e', { id: 11 }));
await expect(page.locator('[data-web-shell-message-list]')).toContainText(
'Pong from fake SSE',
);
});
test('keeps later SSE connections alive when an earlier one is cancelled @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
await page.reload();
await daemon.sse.waitForConnection(scenario.sessionId);
await completeReplay(page, daemon, scenario.sessionId);
await fillComposer(page, 'Second connection should still stream');
await page.locator('[data-web-shell-composer-submit]').click();
await expect.poll(() => daemon.promptRequests().length).toBe(1);
await daemon.sse.split(
assistantTextEvent('Reconnect-safe SSE payload', { id: 20 }),
);
await daemon.sendEvent(turnCompleteEvent('prompt-reconnect', { id: 21 }));
await expect(page.locator('[data-web-shell-message-list]')).toContainText(
'Reconnect-safe SSE payload',
);
});
test('clears fake SSE connection records when streams close or error @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
const baseURL = String(testInfo.project.use.baseURL);
await page.goto('data:text/html,<html></html>');
await openRawSseConnection(page, baseURL, scenario.sessionId);
const firstConnection = await daemon.sse.waitForConnection(
scenario.sessionId,
);
expect(firstConnection.sessionId).toBe(scenario.sessionId);
await daemon.sse.close();
await expect
.poll(async () => (await daemon.sse.connections()).length)
.toBe(0);
await openRawSseConnection(page, baseURL, scenario.sessionId);
const secondConnection = await daemon.sse.waitForConnection(
scenario.sessionId,
);
expect(secondConnection.sessionId).toBe(scenario.sessionId);
await daemon.sse.error('test SSE error');
await expect
.poll(async () => (await daemon.sse.connections()).length)
.toBe(0);
});
test('submits permission decisions through the fake daemon @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
events: [permissionRequestEvent('perm-1', { id: 1 })],
});
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
await expect(page.locator('[data-web-shell-permission-panel]')).toBeVisible();
await page
.locator('[data-web-shell-permission-option][data-option-id="allow_once"]')
.click();
await expect.poll(() => daemon.permissionRequests().length).toBe(1);
const permissionRequest = firstRequest(daemon.permissionRequests());
expect(permissionRequest.method).toBe('POST');
expect(permissionRequest.path).toBe(
`/session/${scenario.sessionId}/permission/perm-1`,
);
expect(requestBodyRecord(permissionRequest)).toEqual({
outcome: { outcome: 'selected', optionId: 'allow_once' },
});
});
test('opens slash menu, resume dialog, model dialog, and theme dialog @smoke', async ({
page,
}, testInfo) => {
const resumedSessionId = 'previous-session';
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
await fillComposer(page, '/');
await expect(page.locator('[data-web-shell-slash-menu]')).toBeVisible();
await submitLocalCommand(page, '/resume');
await expect(page.locator('[data-web-shell-resume-dialog]')).toBeVisible();
await page
.locator(
`[data-web-shell-resume-session][data-session-id="${resumedSessionId}"]`,
)
.click();
await expect(page.locator('[data-web-shell-resume-dialog]')).toHaveCount(0);
await completeReplay(page, daemon, resumedSessionId);
await submitLocalCommand(page, '/model');
await expect(page.locator('[data-web-shell-model-dialog]')).toBeVisible();
await page
.locator('[data-web-shell-model-option][data-model-id="qwen-test-alt"]')
.click();
await expect(page.locator('[data-web-shell-model-dialog]')).toHaveCount(0);
await expect.poll(() => daemon.modelRequests().length).toBe(1);
const modelRequest = firstRequest(daemon.modelRequests());
expect(modelRequest.method).toBe('POST');
expect(modelRequest.path).toBe(`/session/${resumedSessionId}/model`);
expect(requestBodyRecord(modelRequest)).toEqual({
modelId: 'qwen-test-alt',
});
await page.reload();
await completeReplay(page, daemon);
await submitLocalCommand(page, '/model');
await expect(page.locator('[data-web-shell-model-dialog]')).toBeVisible();
await expect(
page.locator(
'[data-web-shell-model-option][data-model-id="qwen-test-alt"]',
),
).toHaveAttribute('aria-selected', 'true');
await page.getByRole('button', { name: 'close' }).click();
await expect(page.locator('[data-web-shell-model-dialog]')).toHaveCount(0);
await submitLocalCommand(page, '/theme');
await expect(page.locator('[data-web-shell-theme-dialog]')).toBeVisible();
await page
.locator('[data-web-shell-theme-option][data-theme-id="light"]')
.click();
await expect(page.locator('[data-web-shell-theme-dialog]')).toHaveCount(0);
});
async function installScenario(
page: Page,
scenario: WebShellDaemonScenario,
testInfo: TestInfo,
): Promise<MockDaemonController> {
return installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
}
async function gotoSession(
page: Page,
scenario: WebShellDaemonScenario,
daemon: MockDaemonController,
): Promise<void> {
await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`);
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
await completeReplay(
page,
daemon,
scenario.sessionId,
scenario.events.length,
);
}
async function completeReplay(
page: Page,
daemon: MockDaemonController,
sessionId?: string,
replayedCount = 0,
): Promise<void> {
const connection = await daemon.sse.waitForConnection(sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: connection.sessionId,
replayedCount,
}),
);
await expect(page.getByText('Loading...')).toHaveCount(0);
}
async function fillComposer(page: Page, text: string): Promise<void> {
const editor = page.locator('[data-web-shell-composer-editor] .cm-content');
await editor.click();
await page.keyboard.press(
process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
);
await page.keyboard.type(text);
}
async function submitLocalCommand(page: Page, text: string): Promise<void> {
await fillComposer(page, text);
await page.locator('[data-web-shell-composer-submit]').click();
}
async function openRawSseConnection(
page: Page,
baseURL: string,
sessionId: string,
): Promise<void> {
await page.evaluate(
async ({ baseURL, sessionId }) => {
const response = await fetch(
`${baseURL}/session/${encodeURIComponent(sessionId)}/events`,
);
const holder = window as Window & {
__webShellRawSseResponses?: Response[];
};
holder.__webShellRawSseResponses ??= [];
holder.__webShellRawSseResponses.push(response);
},
{ baseURL, sessionId },
);
}
function firstRequest(
requests: readonly DaemonRequestRecord[],
): DaemonRequestRecord {
const request = requests[0];
if (!request) throw new Error('Expected a recorded daemon request.');
return request;
}
function requestBodyRecord(
request: DaemonRequestRecord,
): Record<string, unknown> {
expect(typeof request.body).toBe('object');
expect(request.body).not.toBeNull();
expect(Array.isArray(request.body)).toBe(false);
return request.body as Record<string, unknown>;
}
function expectPromptBodyToContainText(
body: Record<string, unknown>,
text: string,
): void {
const prompt = body['prompt'];
expect(Array.isArray(prompt)).toBe(true);
const blocks = prompt as readonly unknown[];
expect(
blocks.some(
(block) =>
isRecord(block) && block['type'] === 'text' && block['text'] === text,
),
).toBe(true);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

View file

@ -1,11 +1,10 @@
import {
ViewPlugin,
Decoration,
DecorationSet,
EditorView,
ViewUpdate,
WidgetType,
} from '@codemirror/view';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import type { CommandInfo } from '../adapters/types';
import { getSlashCommandArgumentHint } from '../completions/slashCompletion';

View file

@ -533,7 +533,6 @@ export function useQueuedPrompts({
if (streamingState !== 'idle') return;
const ctrl = midTurnEnqueueAbortRef.current;
if (!ctrl) return;
console.debug('[mid-turn] turn settled; cancelling any in-flight push');
ctrl.abort();
midTurnEnqueueAbortRef.current = null;
}, [streamingState]);
@ -914,16 +913,6 @@ export function useQueuedPrompts({
useDaemonMidTurnInjected();
useEffect(() => {
if (!sessionId || midTurnInjectedBatches.length === 0) return;
if (
clientId === undefined &&
midTurnInjectedBatches.some(
(b) => b.sessionId === sessionId && b.originatorClientId !== undefined,
)
) {
console.debug(
'[mid-turn] originator-stamped batches but no client id; dedupe skipped (may resend next turn)',
);
}
const next = removeInjectedFromQueue(
queuedPromptsRef.current,
midTurnInjectedBatches,

View file

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
@ -55,7 +55,19 @@
because logComponentRender only exists in react-dom development bundles.
-->
<script>
!function(){if(typeof performance==='undefined'||!performance.measure)return;var m=performance.measure.bind(performance);performance.measure=function(){try{return m.apply(this,arguments)}catch(e){if(e instanceof DOMException&&e.name==='DataCloneError')return;throw e}}}();
!(function () {
if (typeof performance === 'undefined' || !performance.measure) return;
var m = performance.measure.bind(performance);
performance.measure = function () {
try {
return m.apply(this, arguments);
} catch (e) {
if (e instanceof DOMException && e.name === 'DataCloneError')
return;
throw e;
}
};
})();
</script>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>

View file

@ -0,0 +1,40 @@
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
export function mountReact(node: ReactNode): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(node);
});
mounted.push({ root, container });
return container;
}
export function cleanupReact(): void {
for (const { root, container } of mounted.splice(0)) {
act(() => {
root.unmount();
});
container.remove();
}
}
export async function flushReact(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
export function immediateClipboardWrite(): Promise<void> {
return {
then(onFulfilled?: (value: void) => unknown) {
onFulfilled?.(undefined);
return Promise.resolve();
},
} as Promise<void>;
}

View file

@ -0,0 +1,62 @@
import { vi } from 'vitest';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const globalWithDom = globalThis as typeof globalThis & {
Element?: typeof Element;
ResizeObserver?: typeof ResizeObserver;
};
if (typeof globalWithDom.ResizeObserver === 'undefined') {
globalWithDom.ResizeObserver = class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
} as typeof ResizeObserver;
}
if (
typeof globalWithDom.Element !== 'undefined' &&
!globalWithDom.Element.prototype.scrollIntoView
) {
globalWithDom.Element.prototype.scrollIntoView = () => {};
}
if (typeof navigator !== 'undefined' && !navigator.clipboard) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn(() => Promise.resolve()),
},
});
}
if (typeof window !== 'undefined' && !window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
if (typeof navigator !== 'undefined' && !navigator.mediaDevices) {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(() =>
Promise.reject(new Error('getUserMedia is not mocked for this test')),
),
},
});
}

View file

@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useState } from 'react';
import type React from 'react';
import { useEffect, useState } from 'react';
import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../i18n';
import { useVoiceCapture } from './useVoiceCapture';

View file

@ -19,8 +19,17 @@
"scripts": {
"dev": "vite",
"build": "vite build && vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json",
"test": "vitest run",
"test:ci": "vitest run"
"lint": "cd ../.. && eslint packages/web-shell --ext .ts,.tsx",
"lint:fix": "cd ../.. && eslint packages/web-shell --ext .ts,.tsx --fix",
"format:check": "cd ../.. && prettier --experimental-cli --check packages/web-shell",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --config vitest.config.ts",
"test:ci": "vitest run --config vitest.config.ts --coverage",
"test:coverage": "vitest run --config vitest.config.ts --coverage",
"test:e2e:smoke": "playwright test --config playwright.config.ts --grep @smoke",
"test:e2e": "playwright test --config playwright.config.ts",
"test:e2e:report": "playwright show-report client/e2e/playwright-report",
"verify": "npm run lint && npm run format:check && npm run typecheck && npm run test:ci"
},
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
@ -46,6 +55,7 @@
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@qwen-code/sdk": "file:../sdk-typescript",
"@qwen-code/webui": "file:../webui",
"@types/node": "^22.0.0",

View file

@ -0,0 +1,38 @@
import { defineConfig, devices } from '@playwright/test';
const port = Number(process.env['PLAYWRIGHT_PORT'] ?? 5174);
const baseURL =
process.env['PLAYWRIGHT_BASE_URL'] ?? `http://127.0.0.1:${port}`;
export default defineConfig({
testDir: './client/e2e',
outputDir: './client/e2e/test-results',
timeout: 60_000,
expect: {
timeout: 10_000,
},
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
reporter: [
['line'],
['html', { outputFolder: 'client/e2e/playwright-report', open: 'never' }],
],
webServer: {
command: `npm run dev -- --host 127.0.0.1 --port ${port}`,
url: baseURL,
reuseExistingServer: !process.env['CI'],
timeout: 120_000,
},
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View file

@ -10,5 +10,12 @@
"noEmit": true
},
"include": ["client/**/*.ts", "client/**/*.tsx"],
"exclude": ["node_modules", "dist"]
"exclude": [
"client/**/*.test.ts",
"client/**/*.test.tsx",
"client/e2e/**",
"client/test/**",
"node_modules",
"dist"
]
}

View file

@ -10,6 +10,8 @@
"exclude": [
"client/**/*.test.ts",
"client/**/*.test.tsx",
"client/e2e/**",
"client/test/**",
"node_modules",
"dist"
]

View file

@ -0,0 +1,26 @@
import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
root: 'client',
test: {
setupFiles: ['./test/setup.ts'],
exclude: [...configDefaults.exclude, 'e2e/**'],
reporters: ['default', ['junit', { suiteName: '@qwen-code/web-shell' }]],
outputFile: {
junit: '../junit.xml',
},
coverage: {
provider: 'v8',
reportsDirectory: '../coverage',
reporter: ['text-summary', 'json-summary', 'html'],
include: ['**/*.{ts,tsx}'],
exclude: [
'**/*.test.{ts,tsx}',
'**/test/**',
'**/e2e/**',
'**/*.d.ts',
'vite-env.d.ts',
],
},
},
});