修复前端测试状态泄漏与异步断言竞态 #397
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
|
||||
|
||||
@@ -35,13 +36,21 @@ import UiEditorPage from '../src/view/ui-editor';
|
||||
import { useUiEditorSession } from '../src/view/ui-editor/useUiEditorPage';
|
||||
|
||||
class TestResizeObserver {
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
observe() {}
|
||||
constructor(private callback: ResizeObserverCallback) {}
|
||||
observe(target: Element) {
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
target,
|
||||
contentRect: { width: 800, height: 600 },
|
||||
} as ResizeObserverEntry,
|
||||
],
|
||||
this as unknown as ResizeObserver,
|
||||
);
|
||||
}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver);
|
||||
|
||||
const EMPTY_SNAPSHOT: UiDesignStateSnapshot = {
|
||||
revision: 0,
|
||||
state: {
|
||||
@@ -147,6 +156,16 @@ async function renderLoadedSession(state: State) {
|
||||
}
|
||||
|
||||
describe('UiEditorPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver);
|
||||
vi.mocked(invoke).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps the wallet entry in the resource editor header', async () => {
|
||||
const stateStore: IUiDesignStateStore = {
|
||||
load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)),
|
||||
@@ -185,10 +204,7 @@ describe('UiEditorPage', () => {
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
Object.defineProperty(window, '__TAURI__', {
|
||||
configurable: true,
|
||||
value: { core: { invoke } },
|
||||
});
|
||||
vi.stubGlobal('__TAURI__', { core: { invoke } });
|
||||
const stateStore: IUiDesignStateStore = {
|
||||
load: vi.fn().mockResolvedValue({
|
||||
revision: 0,
|
||||
@@ -509,8 +525,13 @@ describe('UiEditorPage', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const child = await screen.findByText('page-child');
|
||||
fireEvent.click(child);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
});
|
||||
fireEvent.click(screen.getByText('page-child'));
|
||||
await screen.findByDisplayValue('page-child');
|
||||
fireEvent.keyDown(window, { key: 'Delete' });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('page-child')).toBeNull());
|
||||
@@ -533,8 +554,13 @@ describe('UiEditorPage', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const child = await screen.findByText('page-child');
|
||||
fireEvent.click(child);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
});
|
||||
fireEvent.click(screen.getByText('page-child'));
|
||||
await screen.findByDisplayValue('page-child');
|
||||
const dialog = document.createElement('div');
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
## 标准流程
|
||||
|
||||
前端测试稳定性验证使用根目录 `npm test`(与 Frontend tests job 相同),保留 Vitest 的 8 worker 上限。涉及异步资源展示时,组件测试必须 mock 所有会触发的网络请求,每次调用创建独立 `Response`,并等待最终 DOM 状态而非仅等待 fetch 被调用。换签 Hook 的测试通过 `vitest.config.ts` 的 include 纳入全量运行;新增测试文件后需确认实际执行名单,命令参数指定文件不会绕过 include 白名单。排查顺序依赖可使用 `npm test -- --sequence.shuffle --sequence.seed=9467`,但不能以重试成功替代失败原因分析。
|
||||
|
||||
用例隔离必须包括浏览器状态与 mock 实现:修改 `window.history` 后恢复基线路由;`spyOn(window, 'getSelection')` 等 spy 在用例结束后 restore;`clearAllMocks` 仅清调用记录,不能恢复被上一个用例替换的返回值。顺序打乱暴露的失败应修复泄漏来源,保留原有业务断言。
|
||||
|
||||
```text
|
||||
确认工作树与目标分支 → 读取入口和当前专题 → 查代码真相 → 小步修改 → 定向验证 → 更新当前文档/记忆 → 检查提交边界
|
||||
```
|
||||
|
||||
@@ -155,6 +155,26 @@ function SuspendAfterLandingRender({ active }: { active: boolean }) {
|
||||
describe('CreationLandingView', () => {
|
||||
beforeEach(() => {
|
||||
clearSignedAssetReadUrlCache();
|
||||
// 每次换签返回独立响应,筛选等用例也不能触发真实网络请求。
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
if (url.pathname !== '/api/assets/read-url') {
|
||||
throw new Error(`Unexpected test request: ${url.pathname}`);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
objectKey: url.searchParams.get('objectKey') ?? '',
|
||||
signedUrl: 'data:image/png;base64,dGVzdA==',
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
@@ -1304,17 +1324,18 @@ describe('CreationLandingView', () => {
|
||||
const campaignObjectKey =
|
||||
'generated-character-drafts/editor/showcase-campaign/current/campaign.png';
|
||||
const signedCampaignUrl = 'data:image/png;base64,Y2FtcGFpZ24=';
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
objectKey: campaignObjectKey,
|
||||
signedUrl: signedCampaignUrl,
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
objectKey: campaignObjectKey,
|
||||
signedUrl: signedCampaignUrl,
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
listEditorProjectsMock.mockResolvedValue(projectItems);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { type Dispatch, type SetStateAction, useRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
@@ -258,6 +258,10 @@ function KeyboardShortcutsHarness({
|
||||
}
|
||||
|
||||
describe('useImageCanvasKeyboardShortcuts', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('routes undo and redo while ignoring editable inputs', () => {
|
||||
const undoCanvasChange = vi.fn();
|
||||
const redoCanvasChange = vi.fn();
|
||||
|
||||
@@ -954,6 +954,7 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.history.replaceState(null, '', '/editor/canvas');
|
||||
try {
|
||||
globalThis.sessionStorage?.clear();
|
||||
} catch {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../components/common/RuntimeResourcePendingMarker';
|
||||
import { ResolvedAssetImage } from '../components/ResolvedAssetImage';
|
||||
import {
|
||||
clearStoredAccessToken,
|
||||
@@ -215,6 +216,7 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
});
|
||||
|
||||
test('refreshKey changes force a fresh signed url request without mutating OSS signature query', async () => {
|
||||
let requestCount = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
@@ -224,7 +226,7 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||
signedUrl: 'https://signed.example.com/puzzle.png',
|
||||
signedUrl: `https://signed.example.com/puzzle.png?x-oss-signature=version-${++requestCount}`,
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
@@ -255,7 +257,7 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
|
||||
const firstImage = await screen.findByRole('img', { name: '候选图' });
|
||||
expect(firstImage.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/puzzle.png',
|
||||
'https://signed.example.com/puzzle.png?x-oss-signature=version-1',
|
||||
);
|
||||
|
||||
rerender(
|
||||
@@ -267,11 +269,11 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
screen.getByRole('img', { name: '候选图' }).getAttribute('src'),
|
||||
).toBe('https://signed.example.com/puzzle.png?x-oss-signature=version-2');
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('img', { name: '候选图' }).getAttribute('src'),
|
||||
).toBe('https://signed.example.com/puzzle.png');
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => {
|
||||
@@ -300,16 +302,22 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR),
|
||||
).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR),
|
||||
).toBeNull();
|
||||
});
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -339,7 +347,7 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
fallbackSrc="/creation-type-references/puzzle.webp"
|
||||
@@ -350,9 +358,15 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
expect(
|
||||
screen.getByRole('img', { name: '候选图' }).getAttribute('src'),
|
||||
).toBe('/creation-type-references/puzzle.webp');
|
||||
expect(
|
||||
container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR),
|
||||
).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR),
|
||||
).toBeNull();
|
||||
});
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.getByRole('img', { name: '候选图' }).getAttribute('src'),
|
||||
).toBe('/creation-type-references/puzzle.webp');
|
||||
|
||||
@@ -119,6 +119,7 @@ export default defineConfig({
|
||||
'src/components/platform-entry/platformProfile*.test.ts',
|
||||
'src/components/platform-entry/usePlatformProfileCenterController*.test.tsx',
|
||||
'src/hooks/useHostNavigationCanGoBack.test.tsx',
|
||||
'src/hooks/useResolvedAssetReadUrl.test.tsx',
|
||||
'apps/admin-web/src/**/*.test.ts',
|
||||
'apps/admin-web/src/**/*.test.tsx',
|
||||
'apps/ai-game-creator-shell/tests/**/*.test.ts',
|
||||
|
||||
Reference in New Issue
Block a user