qwen-code/packages/web-shell/client/components/dialogs/DialogShell.test.tsx
Heyang Wang 6f5d6dfd65
feat(web-shell): add workspace selector button with add/switch dropdown in composer toolbar (#7390)
* feat(web-shell): add managed workspace selector

Let Web Shell create and select daemon-managed workspaces without
changing ownership of existing sessions.

- Add capability-gated existing and scratch workspace registration
- Validate scratch roots, trust provenance, capacity, and shutdown races
- Serialize workspace mutations, session switching, and refresh results
- Add SDK/WebUI wiring and focused cross-package regression coverage

# Conflicts:
#	packages/web-shell/client/App.tsx
#	packages/web-shell/client/components/sidebar/WebShellSidebar.tsx

# Conflicts:
#	packages/cli/src/serve/capabilities.ts
#	packages/cli/src/serve/routes/workspace-management.ts
#	packages/cli/src/serve/server.test.ts
#	packages/sdk-typescript/src/daemon/DaemonClient.ts
#	packages/web-shell/client/App.tsx
#	packages/web-shell/client/components/dialogs/AddWorkspaceDialog.tsx
#	packages/web-shell/client/components/sidebar/WebShellSidebar.tsx

* fix(web-shell): revalidate workspace before session creation

Prevent a stale workspace selection from bypassing the latest trusted
capability snapshot during lazy session creation.

- Validate the selected workspace before passing it to the daemon
- Fall back to the primary workspace when trust has been revoked
- Add a regression test for the pre-effect race window
- Remove stale branch state and clarify add-workspace ownership

* fix(web-shell): improve workspace removal feedback

Keep workspace removal controls legible and make blocked force removals
visibly inactive.

- Size the action menu independently from its narrow icon trigger
- Add a disabled affordance and suppress destructive hover styling
- Cover the removal menu width override with a regression test

* fix(web-shell): centralize existing workspace registration

Route sidebar and composer entry points through the App-owned dialog so
capability gating and workspace reconciliation remain consistent.

- Forward display names only when the daemon advertises support
- Hide and suppress persistence when registration is runtime-only
- Mark directory registrations with existing-workspace provenance
- Cover both entry points and capability combinations with tests

* fix(web-shell): address review feedback on workspace dialogs and capability docs (#7390)

- Document dynamic_workspace_registration and scratch_workspace_registration
  in the conditional serve-features table so the capabilities-docs-contract
  test passes.
- Gate DialogShell backdrop-click and Escape dismissal on the dismissible
  prop so non-dismissible dialogs ignore both gestures.
- Surface an inline error when an added folder registers but the capability
  refresh fails, mirroring the scratch recovery path.
- Add coverage for the active-session workspace switch and the add-folder
  refresh-failure paths.

* fix(web-shell): address review feedback on workspace dialogs and capability docs (#7390)

---------

Co-authored-by: heyang.why <heyang.why@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
2026-07-22 01:10:18 +00:00

165 lines
5.1 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../i18n';
import { ThemeProvider } from '../../themeContext';
import { DialogShell } from './DialogShell';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let container: HTMLDivElement | null = null;
let root: Root | null = null;
function render(showBottom: boolean, onTopClose = vi.fn()) {
root!.render(
<I18nProvider language="en">
<ThemeProvider value="dark">
{showBottom && (
<DialogShell title="Bottom" onClose={vi.fn()}>
<button type="button">bottom</button>
</DialogShell>
)}
<DialogShell title="Top" onClose={onTopClose}>
<button type="button" data-testid="top-focus">
top
</button>
</DialogShell>
</ThemeProvider>
</I18nProvider>,
);
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('DialogShell', () => {
it('restores focus to the remaining top shell when a lower shell unmounts', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => render(true));
const topButton = document.querySelector<HTMLElement>(
'[data-testid="top-focus"]',
)!;
document.querySelector<HTMLElement>('button:not([data-testid])')!.focus();
act(() => render(false));
expect(document.activeElement).toBe(topButton);
});
it('leaves an IME Escape event unhandled', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onClose = vi.fn();
act(() => render(false, onClose));
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
isComposing: true,
key: 'Escape',
});
const target = document.querySelector<HTMLElement>(
'[data-testid="top-focus"]',
)!;
act(() => target.dispatchEvent(event));
expect(event.defaultPrevented).toBe(false);
expect(event.key).toBe('Escape');
expect(onClose).not.toHaveBeenCalled();
});
it('closes once when the backdrop is clicked', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onClose = vi.fn();
act(() => render(false, onClose));
const backdrop = document.querySelector<HTMLElement>(
'[data-slot="dialog-overlay"]',
)!;
act(() => {
backdrop.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
backdrop.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it('stays open when a drag starts in the panel and ends on the backdrop', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onClose = vi.fn();
act(() => render(false, onClose));
const backdrop = document.querySelector<HTMLElement>(
'[data-slot="dialog-overlay"]',
)!;
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
act(() => {
panel.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
backdrop.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onClose).not.toHaveBeenCalled();
});
it('ignores backdrop clicks and Escape when not dismissible', () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onClose = vi.fn();
act(() => {
root!.render(
<I18nProvider language="en">
<ThemeProvider value="dark">
<DialogShell title="Locked" onClose={onClose} dismissible={false}>
<button type="button" data-testid="locked-focus">
locked
</button>
</DialogShell>
</ThemeProvider>
</I18nProvider>,
);
});
const backdrop = document.querySelector<HTMLElement>(
'[data-slot="dialog-overlay"]',
)!;
act(() => {
backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
backdrop.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(onClose).not.toHaveBeenCalled();
const target = document.querySelector<HTMLElement>(
'[data-testid="locked-focus"]',
)!;
act(() =>
target.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Escape',
}),
),
);
expect(onClose).not.toHaveBeenCalled();
});
});