Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts
T
k88936 25c5ecdf6e
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m58s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m34s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m29s
Project CI / Backend tests (pull_request) Failing after 4m38s
Project CI / Frontend tests (pull_request) Successful in 3m33s
Project CI / Repository checks (pull_request) Failing after 4m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m13s
Project CI / Native shell tests (pull_request) Successful in 6m35s
测试:全仓测试统一 Tauri 运行时替身安装点
- 78 处内联 window.__TAURI__ 赋值改为 installTauriRuntime,覆盖 appSurface 之外的 37 个测试文件
- 31 处 delete window.__TAURI__ / window.__TAURI__ = undefined 复位改为 resetTauriRuntime
- 删除 recentProjectsHook、projectCreationDirectory、projectResourceLiveIntegration、useProjectResourceCardPreviews、resourceVersionReplacementModel、unityProjectOpen 里的本地 asTauriInvoke / asTauriListen 适配器与相关类型别名
- designProjectRestore 的 setup 参数由 ReturnType<typeof vi.fn> 收窄为具体 invoke 签名
- 迁移后仅 tests/tauriRuntimeFake.ts 直接读写 window.__TAURI__
2026-10-03 20:19:45 +08:00

426 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach } from 'vitest';
import { ClientAuthErrorWrapper } from '../../src/services/clientAuthErrorWrapper';
import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp';
import {
currentPlatformSessionGeneration,
resetPlatformSessionForTests,
} from '../../src/services/platformSession';
import { installUnhandledRejectionBridge } from '../unhandledRejectionBridge';
import {
act,
AuthenticatedClient,
createTauriEventBridge,
expect,
fireEvent,
installTauriRuntime,
it,
React,
render,
resetTauriRuntime,
screen,
testAuthUser,
vi,
waitFor,
} from './harness';
/**
* 注入 Tauri 事件替身。
*
* jsdom 没有 `__TAURI_INTERNALS__`,`subscribeTauriEvent` 会退回
* `window.__TAURI__.event.listen`,所以按同一入口登记回调:安装走
* harness.installTauriRuntime,事件注册 / 派发由 harness.createTauriEventBridge 负责。
*/
function renderAuthSurface(
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
options: { withEvents?: boolean } = {},
) {
const bridge = createTauriEventBridge();
installTauriRuntime({
core: { invoke },
...(options.withEvents ? { event: { listen: bridge.listen } } : {}),
});
render(
// React 19 的 createElement 的 children 形参只接受 ReactNode,渲染函数子节点改传进 props。
React.createElement(AuthenticatedClient, {
children: ({ user, logout }) =>
React.createElement(
'main',
{ 'aria-label': '已登录' },
React.createElement('span', null, user.displayName),
React.createElement(
'button',
{ type: 'button', onClick: logout },
'退出',
),
),
}),
);
return bridge;
}
async function loginWithCode() {
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
}
/**
* 捕获 window 侧 `unhandledrejection`:返回已收到的 `reason` 列表与释放函数,
* 释放时同时摘掉监听和 Node 侧桥接。
*/
function captureWindowRejections() {
const uninstallBridge = installUnhandledRejectionBridge();
const reasons: unknown[] = [];
const onRejection = (event: Event) => {
reasons.push((event as Event & { reason?: unknown }).reason);
};
window.addEventListener('unhandledrejection', onRejection);
return {
reasons,
uninstall() {
window.removeEventListener('unhandledrejection', onRejection);
uninstallBridge();
},
};
}
export function registerAuthTests() {
afterEach(() => {
resetPlatformSessionForTests();
resetTauriRuntime();
});
it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => {
const rejections = captureWindowRejections();
try {
renderAuthSurface(async (command: string) => {
if (command === 'read_client_auth_state') {
return await new Promise(() => {});
}
return null;
});
await waitFor(() => expect(rejections.reasons).toHaveLength(1), {
timeout: 20_000,
});
expect(rejections.reasons[0]).toBeInstanceOf(Error);
expect((rejections.reasons[0] as Error).message).toContain(
'检查登录状态超时',
);
// 启动检查失败在改动前就会显示原因 + 重试按钮;上报是叠加的,不是替换。
expect(
await screen.findByText(
'检查登录状态超时,请检查服务器地址和网络后重试',
),
).not.toBeNull();
expect(
screen.getByRole('button', { name: '重试登录状态检查' }),
).toHaveProperty('disabled', false);
await screen.findByRole('main', { name: '登录' });
} finally {
rejections.uninstall();
}
}, 30_000);
it('renders the unauthenticated client with the shared light platform theme and product image', async () => {
renderAuthSurface(async () => ({ status: 'unauthenticated' }));
const loginPanel = await screen.findByRole('main', { name: '登录' });
expect(loginPanel.className).toContain('platform-theme--light');
const logo = loginPanel.querySelector('img');
expect(logo?.getAttribute('alt')).toBe('陶泥儿');
expect(logo?.getAttribute('src')).toContain('taonier-product-ip.png');
});
it('logs in with a phone code and never persists a token in the renderer', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_phone_code') {
return { ...testAuthUser, loginMethod: 'phone' };
}
return null;
});
renderAuthSurface(invoke);
await loginWithCode();
expect(await screen.findByLabelText('已登录')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('login_client_with_phone_code', {
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
phone: '13800000000',
code: '123456',
});
// 渲染层不再持久化任何凭据。
expect(window.localStorage.length).toBe(0);
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe(null);
});
it('logs in with the password contract on the frozen server origin', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_password') {
return { ...testAuthUser, loginMethod: 'password' };
}
return null;
});
renderAuthSurface(invoke);
await screen.findByRole('main', { name: '登录' });
fireEvent.click(screen.getByRole('button', { name: '密码登录' }));
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '138 0000 0000' },
});
fireEvent.change(screen.getByLabelText('密码'), {
target: { value: ' secret ' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByLabelText('已登录')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('login_client_with_password', {
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
phone: '13800000000',
password: 'secret',
});
});
it('shows the debug server selection and uses the custom origin for login', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_workspace_preferences') {
return {
projectCreationDirectory: null,
recentWorkspaces: [],
chatPromptPolishReminderDisabled: false,
clientServerSelection: null,
};
}
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_phone_code') {
return testAuthUser;
}
return null;
});
renderAuthSurface(invoke);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('服务器'), {
target: { value: 'custom' },
});
fireEvent.change(screen.getByLabelText('自定义服务器地址'), {
target: { value: 'http://127.0.0.1:10001' },
});
fireEvent.blur(screen.getByLabelText('自定义服务器地址'));
await loginWithCode();
expect(await screen.findByLabelText('已登录')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('login_client_with_phone_code', {
apiBaseUrl: 'http://127.0.0.1:10001',
phone: '13800000000',
code: '123456',
});
expect(invoke).toHaveBeenCalledWith('set_client_server_selection', {
preset: 'custom',
customBaseUrl: 'http://127.0.0.1:10001',
});
});
it('shows the typed business reason when the password login is rejected', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_password') {
// Rust 命令的结构化拒绝:前端只按 `type` 分流,不解析文案。
throw {
type: 'passwordLoginRejected',
serverMessage: '手机号或密码错误',
};
}
return null;
});
renderAuthSurface(invoke);
await screen.findByRole('main', { name: '登录' });
fireEvent.click(screen.getByRole('button', { name: '密码登录' }));
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('密码'), {
target: { value: 'wrong' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(
await screen.findByText('登录失败: 手机号或密码错误'),
).not.toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('shows the system variant copy and still throws it through', async () => {
const rejections = captureWindowRejections();
try {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_phone_code') {
throw { type: 'authNetworkFailure', reason: 'unreachable' };
}
return null;
});
renderAuthSurface(invoke);
await loginWithCode();
// 先等系统变体真的经 unhandledrejection 抛出。
await waitFor(() => expect(rejections.reasons).toHaveLength(1));
// 载体 message 是载荷序列化(上报链路直接取用);结构化诊断在 error 字段里。
expect(rejections.reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper);
expect(
(rejections.reasons[0] as ClientAuthErrorWrapper).error,
).toMatchObject({
type: 'authNetworkFailure',
reason: 'unreachable',
});
// 改动前系统失败也会在登录页给出一行提示;这里恢复同样的可见反馈,只是不泄露传输层细节。
expect(
await screen.findByText(
'登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
),
).not.toBeNull();
expect(document.body.textContent).not.toContain('ECONNREFUSED');
await screen.findByRole('main', { name: '登录' });
} finally {
rejections.uninstall();
}
});
it('keeps a retry when the startup check is rejected by a business variant', async () => {
let reads = 0;
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
reads += 1;
if (reads === 1) {
throw { type: 'serverAddressRejected', reason: 'notHttps' };
}
return { status: 'unauthenticated' };
}
return null;
});
renderAuthSurface(invoke);
expect(
await screen.findByText('服务器地址非法: 远程地址必须使用 https'),
).not.toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' }));
await waitFor(() => expect(reads).toBe(2));
await screen.findByRole('main', { name: '登录' });
});
it('starts every login attempt from a fresh identity generation', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_phone_code') {
return testAuthUser;
}
return null;
});
renderAuthSurface(invoke);
await screen.findByRole('main', { name: '登录' });
const generationBeforeLogin = currentPlatformSessionGeneration();
await loginWithCode();
await screen.findByLabelText('已登录');
expect(currentPlatformSessionGeneration()).toBeGreaterThan(
generationBeforeLogin,
);
});
it('calls the Rust logout command and returns to the login surface', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return {
status: 'authenticated',
user: testAuthUser,
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
};
}
if (command === 'logout_client_session') return null;
return null;
});
renderAuthSurface(invoke);
expect(await screen.findByLabelText('已登录')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
expect(screen.getByText('已退出登录')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('logout_client_session');
});
it('still leaves the workspace when the local runtime clear fails during logout', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return {
status: 'authenticated',
user: testAuthUser,
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
};
}
if (command === 'logout_client_session') {
throw new Error('runner clear rejected');
}
return null;
});
renderAuthSurface(invoke);
expect(await screen.findByLabelText('已登录')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
expect(
screen.getByText(
'已退出登录;本地运行时登录态同步失败,请重启客户端后再登录',
),
).not.toBeNull();
});
it('returns to the login surface when Rust reports the session ended', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return {
status: 'authenticated',
user: testAuthUser,
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
};
}
return null;
});
const bridge = renderAuthSurface(invoke, { withEvents: true });
expect(await screen.findByLabelText('已登录')).not.toBeNull();
await act(async () => {
bridge.emit('agc-client-auth-state-changed', {
status: 'unauthenticated',
});
await Promise.resolve();
});
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
expect(screen.getByText('登录已失效,请重新登录')).not.toBeNull();
});
}