Merge pull request '修复前端测试状态泄漏与异步断言竞态' (#397) from codex/ci-frontend-stability-20260917 into master
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m15s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m59s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m18s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / AI game creator shell Rust crates (push) Successful in 2m21s
Project CI / Frontend tests (push) Successful in 5m22s
Project CI / Repository checks (push) Successful in 4m26s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / AI game creator shell web tests (push) Successful in 3m53s
Project CI / Native shell tests (push) Successful in 9m49s

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/397
This commit was merged in pull request #397.
This commit is contained in:
2026-09-17 12:29:26 +08:00
7 changed files with 106 additions and 35 deletions
@@ -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 {
+24 -10
View File
@@ -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');
+1
View File
@@ -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',