diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
index f9615a790..d631c5051 100644
--- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
+++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
@@ -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);
diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md
index 3dd59f677..36fbbe575 100644
--- a/docs/project-memory/shared-memory/development-workflow.md
+++ b/docs/project-memory/shared-memory/development-workflow.md
@@ -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
确认工作树与目标分支 → 读取入口和当前专题 → 查代码真相 → 小步修改 → 定向验证 → 更新当前文档/记忆 → 检查提交边界
```
diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx
index 656ef8f46..d0c59ef83 100644
--- a/src/components/creation-home/CreationLandingView.test.tsx
+++ b/src/components/creation-home/CreationLandingView.test.tsx
@@ -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);
diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx
index 6018727b4..e29b81828 100644
--- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx
+++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx
@@ -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();
diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
index e33258343..ddcd5eaa6 100644
--- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
+++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
@@ -954,6 +954,7 @@ describe('useImageCanvasProjectPersistence', () => {
beforeEach(() => {
vi.resetAllMocks();
+ window.history.replaceState(null, '', '/editor/canvas');
try {
globalThis.sessionStorage?.clear();
} catch {
diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx
index a5094abb1..0ffd68ffd 100644
--- a/src/hooks/useResolvedAssetReadUrl.test.tsx
+++ b/src/hooks/useResolvedAssetReadUrl.test.tsx
@@ -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(
,
);
+ 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(
{
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');
diff --git a/vitest.config.ts b/vitest.config.ts
index 0af75894a..4d6d56888 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -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',