mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
Add harness infrastructure for web-shell package (#6517)
Some checks are pending
Some checks are pending
* 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:
parent
c9a80996d4
commit
5c82857fea
33 changed files with 1873 additions and 48 deletions
58
.github/workflows/ci.yml
vendored
58
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
44
.github/workflows/e2e.yml
vendored
44
.github/workflows/e2e.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ const userShellMsg = (
|
|||
id,
|
||||
role: 'user_shell',
|
||||
command: 'npm test',
|
||||
output: '',
|
||||
});
|
||||
const toolMsg = (id: string): ToolGroupMessage => ({
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
883
packages/web-shell/client/e2e/utils/mockDaemon.ts
Normal file
883
packages/web-shell/client/e2e/utils/mockDaemon.ts
Normal 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);
|
||||
}
|
||||
227
packages/web-shell/client/e2e/utils/sseTransport.ts
Normal file
227
packages/web-shell/client/e2e/utils/sseTransport.ts
Normal 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;
|
||||
}
|
||||
309
packages/web-shell/client/e2e/web-shell.smoke.spec.ts
Normal file
309
packages/web-shell/client/e2e/web-shell.smoke.spec.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
40
packages/web-shell/client/test/reactHarness.tsx
Normal file
40
packages/web-shell/client/test/reactHarness.tsx
Normal 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>;
|
||||
}
|
||||
62
packages/web-shell/client/test/setup.ts
Normal file
62
packages/web-shell/client/test/setup.ts
Normal 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')),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
38
packages/web-shell/playwright.config.ts
Normal file
38
packages/web-shell/playwright.config.ts
Normal 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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
"exclude": [
|
||||
"client/**/*.test.ts",
|
||||
"client/**/*.test.tsx",
|
||||
"client/e2e/**",
|
||||
"client/test/**",
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
|
|
|
|||
26
packages/web-shell/vitest.config.ts
Normal file
26
packages/web-shell/vitest.config.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue