Files
Genarrative/apps/ai-game-creator-shell/tests/clientApi.test.ts
T
kdletters c146f7f99c
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
建立 AGC 稳定版生命周期基础合同 (#348)
## 变更内容

这是基于 PR #346 的稳定版生命周期基础切换,承接已完成的 HTTP body timeout、Runner blocking worker、最近项目逐项刷新和首页跨页防重:

- 新增统一 `ClientOperation` 合同,固定 operationId、requestId、phase、deadline、scope、cancellable 和 stale 判定;
- 认证 refresh 投影 `auth-refresh` operation;登录、401 refresh、退出共用平台 session generation,并由 `auth-transition` 投影 Runner phase/成功/失败/不确定状态;
- 首页自动创建记录 draft、startMode、phase 和建项后的 project scope,页面卸载不会释放 operation;
- `.app/dev-stack.json` 增加 instanceId、repoRoot、服务级 dataDir/instanceId,AGC Vite marker 增加 repoRoot/processId/port;缺身份的旧状态拒绝 AGC 后端复用;
- 新增稳定版生命周期主规范、开发运维口径和 shared pitfalls。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientOperation.test.ts tests/clientApi.test.ts tests/clientHttp.test.ts tests/recentProjectsModel.test.ts tests/start-dev-stack.test.ts tests/dev-port.test.ts --reporter=dot`(61 passed,2 skipped)
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot`(423/423 passed)
- `node --check scripts/dev.mjs`
- `node --check apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`

`scripts/dev.test.ts` 全量仍有 1 个 Windows 文件权限相关既有失败:bootstrap secret 测试在 Windows 上无法通过 chmod 模拟 0600;本变更未触及该逻辑。

Reviewed-on: #348
2026-09-14 13:56:56 +08:00

186 lines
6.1 KiB
TypeScript

/** @vitest-environment jsdom */
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import {
requestClientApi,
setStoredAuthAccessToken,
} from '../src/services/clientApi';
import {
getClientAuthRefreshOperation,
refreshClientAuthAccessToken,
} from '../src/services/clientAuth';
import {
beginPlatformSessionTransition,
commitAuthenticatedPlatformSession,
currentPlatformSessionGeneration,
resetPlatformSessionStateForTests,
} from '../src/services/platformSession';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
const user = { id: 'session-user' } as AuthUser;
const nativeInvoke = vi.fn(async () => null);
const catalog = { models: [{ id: 'quality', displayName: '高质量' }] };
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status });
beforeEach(async () => {
resetPlatformSessionStateForTests();
window.localStorage.clear();
nativeInvoke.mockClear();
window.__TAURI__ = { core: { invoke: nativeInvoke } };
setStoredAuthAccessToken('expired-token');
await commitAuthenticatedPlatformSession(
user,
currentPlatformSessionGeneration(),
);
nativeInvoke.mockClear();
});
afterEach(() => {
resetPlatformSessionStateForTests();
window.localStorage.clear();
delete window.__TAURI__;
vi.restoreAllMocks();
});
it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => {
let refreshCalls = 0;
let modelCalls = 0;
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input, init) => {
if (input === '/api/auth/refresh') {
refreshCalls += 1;
return json({ token: 'fresh-token' });
}
if (input === '/api/auth/me') return json({ user });
modelCalls += 1;
const token = new Headers(init?.headers).get('Authorization');
if (token === 'Bearer expired-token') return json({}, 401);
expect(token).toBe('Bearer fresh-token');
expect(nativeInvoke).toHaveBeenCalledWith(
'install_platform_account_session',
expect.objectContaining({
accessToken: 'fresh-token',
userId: user.id,
}),
);
return json(catalog);
});
const results = await Promise.all([
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
]);
expect(results).toEqual([catalog, catalog]);
expect(refreshCalls).toBe(1);
expect(modelCalls).toBe(4);
expect(fetch).toHaveBeenCalledTimes(6);
});
it.each([401])('续期失败保留原 HTTP %s,且不重发业务请求', async (status) => {
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(json({}, status))
.mockResolvedValueOnce(json({}, 401));
await expect(
requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'),
).rejects.toMatchObject({ status });
expect(fetch).toHaveBeenCalledTimes(2);
});
it('跳过鉴权的请求不触发续期', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 401));
await expect(
requestClientApi('/api/example', {}, '读取失败', { skipAuth: true }),
).rejects.toMatchObject({ status: 401 });
expect(fetch).toHaveBeenCalledTimes(1);
});
it('403 权限拒绝不触发续期或重发写请求', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 403));
await expect(
requestClientApi(
'/api/example',
{ method: 'POST', body: '{}' },
'权限不足',
),
).rejects.toMatchObject({ status: 403 });
expect(fetch).toHaveBeenCalledTimes(1);
expect(nativeInvoke).not.toHaveBeenCalled();
});
it('续期成功后的再次未授权不循环重试', async () => {
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(json({}, 401))
.mockResolvedValueOnce(json({ token: 'fresh-token' }))
.mockResolvedValueOnce(json({ user }))
.mockResolvedValueOnce(json({}, 401));
await expect(
requestClientApi('/api/llm/models', {}, '读取失败'),
).rejects.toMatchObject({ status: 401 });
expect(fetch).toHaveBeenCalledTimes(4);
});
it('请求期间账号切换后,不替新账号续期或重发旧请求', async () => {
let finish!: (response: Response) => void;
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(
() =>
new Promise<Response>((resolve) => {
finish = resolve;
}),
);
const pending = requestClientApi('/api/llm/models', {}, '读取失败');
const rejection = expect(pending).rejects.toMatchObject({ status: 401 });
const generation = beginPlatformSessionTransition();
setStoredAuthAccessToken('other-token');
await commitAuthenticatedPlatformSession(
{ ...user, id: 'other-user' },
generation,
);
finish(json({}, 401));
await rejection;
expect(fetch).toHaveBeenCalledTimes(1);
});
it('响应体卡住超时后,下一次续期会重新发起请求', async () => {
vi.useFakeTimers();
let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
if (input === '/api/auth/refresh') {
refreshCalls += 1;
}
return Promise.resolve(
new Response(
new ReadableStream<Uint8Array>({
start() {
// Simulate headers returned while the body remains open.
},
}),
{ status: 200 },
),
);
});
const first = refreshClientAuthAccessToken('http://localhost:3000');
const firstAssertion = expect(first).rejects.toThrow();
await vi.advanceTimersByTimeAsync(15_000);
await firstAssertion;
expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({
kind: 'auth-refresh',
phase: 'retryable-failure',
});
const second = refreshClientAuthAccessToken('http://localhost:3000');
const secondAssertion = expect(second).rejects.toThrow();
expect(refreshCalls).toBe(2);
await vi.advanceTimersByTimeAsync(15_000);
await secondAssertion;
});