Browse Source

test(@vben-core/popup-ui): add modal mouse and keyboard regression tests (#8306)

* test(@vben-core/popup-ui): add modal mouse and keyboard regression tests

Co-Authored-By: EvoX <evox@evomap.ai>

* fix(apps): pass withCredentials as request config, not body, for refresh/logout

Co-Authored-By: EvoX <evox@evomap.ai>

* style(@vben-core/popup-ui): apply oxfmt formatting to modal interaction tests

Co-Authored-By: EvoX <evox@evomap.ai>

---------

Co-authored-by: EvoX <evox@evomap.ai>
pull/8317/head
俞可 · YU KE 1 month ago
committed by GitHub
parent
commit
0750bb0176
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      apps/web-antd/src/api/core/auth.ts
  2. 12
      apps/web-antdv-next/src/api/core/auth.ts
  3. 12
      apps/web-ele/src/api/core/auth.ts
  4. 12
      apps/web-naive/src/api/core/auth.ts
  5. 12
      apps/web-tdesign/src/api/core/auth.ts
  6. 203
      packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal-interaction.test.ts

12
apps/web-antd/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

12
apps/web-antdv-next/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

12
apps/web-ele/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

12
apps/web-naive/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

12
apps/web-tdesign/src/api/core/auth.ts

@ -29,16 +29,20 @@ export async function loginApi(data: AuthApi.LoginParams) {
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
return baseRequestClient.post<AuthApi.RefreshTokenResult>(
'/auth/refresh',
undefined,
{
withCredentials: true,
},
);
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
return baseRequestClient.post('/auth/logout', undefined, {
withCredentials: true,
});
}

203
packages/@core/ui-kit/popup-ui/src/modal/__tests__/modal-interaction.test.ts

@ -0,0 +1,203 @@
import type { App } from 'vue';
import { createApp, defineComponent, h, nextTick, onMounted } from 'vue';
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useVbenModal } from '../use-modal';
vi.mock('@vben-core/preferences', () => ({
usePreferences: () => ({
globalEscapeShortcutKey: { value: true },
}),
}));
let activeApp: App | undefined;
interface MountOptions {
closeOnClickModal?: boolean;
closeOnPressEscape?: boolean;
onCancel?: () => void;
onConfirm?: () => void;
}
/**
* 挂载一个受控的 Modal 组件,用于验证鼠标点击与键盘交互后的关闭行为和事件副作用。
*/
async function mountModal(options: MountOptions = {}) {
const mainContent = document.createElement('main');
mainContent.id = ELEMENT_ID_MAIN_CONTENT;
mainContent.innerHTML = '<div><div></div></div>';
document.body.append(mainContent);
let capturedApi: ReturnType<typeof useVbenModal>[1] | undefined;
const Consumer = defineComponent(() => {
const [Modal, modalApi] = useVbenModal({
appendToMain: true,
cancelText: 'Cancel',
confirmText: 'Confirm',
closeOnClickModal: options.closeOnClickModal ?? true,
closeOnPressEscape: options.closeOnPressEscape ?? true,
title: 'Interaction modal',
...(options.onCancel ? { onCancel: options.onCancel } : {}),
...(options.onConfirm ? { onConfirm: options.onConfirm } : {}),
});
capturedApi = modalApi;
onMounted(() => {
modalApi.open();
});
return () => h(Modal);
});
const host = document.createElement('div');
document.body.append(host);
activeApp = createApp(() => h(Consumer));
activeApp.mount(host);
await nextTick();
await nextTick();
// reka-ui 在 setTimeout(0) 之后才把 document 级的 pointerdown 监听挂上,
// 等待一个宏任务,确保遮罩交互在监听器就绪后触发。
await new Promise((resolve) => setTimeout(resolve, 0));
if (!capturedApi) {
throw new Error('modal api was not captured');
}
return { modalApi: capturedApi };
}
function buttonByText(text: string): HTMLElement | undefined {
return [...document.querySelectorAll('button')].find(
(element) => element.textContent?.trim() === text,
);
}
function pressEscape() {
document.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Escape',
}),
);
}
function pointerDownOverlay() {
const overlay = document.querySelector('[data-dismissable-modal]');
if (!(overlay instanceof HTMLElement)) {
return false;
}
overlay.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, cancelable: true }),
);
return true;
}
async function settle() {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 80));
}
afterEach(() => {
activeApp?.unmount();
activeApp = undefined;
document.body.innerHTML = '';
vi.restoreAllMocks();
});
describe('vben modal interactions', () => {
it('fires confirm exactly once on confirm click and does not auto-close', async () => {
const onConfirm = vi.fn();
const { modalApi } = await mountModal({ onConfirm });
const button = buttonByText('Confirm');
expect(button).toBeInstanceOf(HTMLElement);
if (!(button instanceof HTMLElement)) return;
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(modalApi.store.state.isOpen).toBe(true);
});
it('closes the modal when the confirm handler closes the api', async () => {
const onConfirm = vi.fn();
const apiRef: {
current?: ReturnType<typeof useVbenModal>[1];
} = {};
const { modalApi } = await mountModal({
onConfirm: () => {
onConfirm();
void apiRef.current?.close();
},
});
apiRef.current = modalApi;
const button = buttonByText('Confirm');
expect(button).toBeInstanceOf(HTMLElement);
if (!(button instanceof HTMLElement)) return;
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onConfirm).toHaveBeenCalledTimes(1);
await expect.poll(() => modalApi.store.state.isOpen).toBe(false);
});
it('closes on cancel click when no cancel hook is provided', async () => {
const { modalApi } = await mountModal();
const button = buttonByText('Cancel');
expect(button).toBeInstanceOf(HTMLElement);
if (!(button instanceof HTMLElement)) return;
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect.poll(() => modalApi.store.state.isOpen).toBe(false);
});
it('fires cancel exactly once and does not auto-close when a cancel hook exists', async () => {
const onCancel = vi.fn();
const { modalApi } = await mountModal({ onCancel });
const button = buttonByText('Cancel');
expect(button).toBeInstanceOf(HTMLElement);
if (!(button instanceof HTMLElement)) return;
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(onCancel).toHaveBeenCalledTimes(1);
expect(modalApi.store.state.isOpen).toBe(true);
});
it('closes when Escape is pressed', async () => {
const { modalApi } = await mountModal();
pressEscape();
await expect.poll(() => modalApi.store.state.isOpen).toBe(false);
});
it('stays open on Escape when closeOnPressEscape is false', async () => {
const { modalApi } = await mountModal({ closeOnPressEscape: false });
pressEscape();
await settle();
expect(modalApi.store.state.isOpen).toBe(true);
});
it('closes when the dismissable overlay is pointer-downed', async () => {
const { modalApi } = await mountModal();
expect(pointerDownOverlay()).toBe(true);
await expect.poll(() => modalApi.store.state.isOpen).toBe(false);
});
it('stays open on overlay pointer-down when closeOnClickModal is false', async () => {
const { modalApi } = await mountModal({ closeOnClickModal: false });
expect(pointerDownOverlay()).toBe(true);
await settle();
expect(modalApi.store.state.isOpen).toBe(true);
});
});
Loading…
Cancel
Save