Files
kdletters 8701f43f75 AGC 渲染层网络与状态下沉 Rust,并修复画布交互与失败文案
- 删除渲染层传输层:services/clientApi.ts、clientHttp 的 fetchClientHttp 与超时封装整体退场,账户、认证、素材直传、错误上报与游戏分发发布改走 Rust typed command。
- 新增宿主封装:accountHost、editorAssetHost、platformSessionRetry、agentRuntimeErrorDetail 与 features/app-shell/workspacePreferences;渲染层不再读写 access token / refresh cookie,localStorage 只剩测试态旧键迁移 shim。
- 修复 DirectProject 失败文案 v2 映射与「查看详情」按需读取,画布标签面板高度夹紧、框选每帧重算、文档预览入口与场景标题栏 key、资源筛选面板样式。
- 补齐与调整回归:accountHost、agentRuntimeErrorDetail、clientAuthHost、clientHttp、platformSession、platformSessionRetry、resourceFilterPanelStyle、directThreadChatSubscription 等定向用例,并清理测试 harness 中无调用方的残留。
2026-09-28 14:15:02 +08:00

400 lines
13 KiB
TypeScript
Raw Permalink 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 { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp';
import {
currentPlatformSessionGeneration,
resetPlatformSessionForTests,
} from '../../src/services/platformSession';
import {
act,
AuthenticatedClient,
expect,
fireEvent,
it,
React,
render,
screen,
testAuthUser,
vi,
waitFor,
} from './harness';
type AuthEventBridge = {
listeners: Map<string, (event: { payload: unknown }) => void>;
emit: (event: string, payload: unknown) => void;
};
/**
* 注入 Tauri 事件替身。
*
* jsdom 没有 `__TAURI_INTERNALS__`,`subscribeTauriEvent` 会退回
* `window.__TAURI__.event.listen`,所以这里按同一入口登记回调。
*/
function installTauriBridge(
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
options: { withEvents?: boolean } = {},
): AuthEventBridge {
const listeners = new Map<string, (event: { payload: unknown }) => void>();
window.__TAURI__ = {
core: { invoke },
...(options.withEvents
? {
event: {
listen: async (
event: string,
handler: (payload: { payload: unknown }) => void,
) => {
listeners.set(event, handler);
return () => listeners.delete(event);
},
},
}
: {}),
} as never;
return {
listeners,
emit: (event, payload) => {
const handler = listeners.get(event);
if (handler) handler({ payload });
},
};
}
function renderAuthSurface(
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
options: { withEvents?: boolean } = {},
) {
const bridge = installTauriBridge(invoke, options);
render(
React.createElement(AuthenticatedClient, null, ({ 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: '登录' }));
}
export function registerAuthTests() {
afterEach(() => {
resetPlatformSessionForTests();
delete window.__TAURI__;
});
it('leaves startup loading with an actionable retry after auth check timeout', async () => {
renderAuthSurface(async (command: string) => {
if (command === 'read_client_auth_state') {
return await new Promise(() => {});
}
return null;
});
expect(
await screen.findByText(
'检查登录状态超时,请检查服务器地址和网络后重试',
undefined,
{
timeout: 20_000,
},
),
).not.toBeNull();
expect(
screen.getByRole('button', { name: '重试登录状态检查' }),
).toHaveProperty('disabled', false);
}, 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 backend 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') {
throw new Error('手机号或密码错误');
}
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('keeps transport details out of the login failure copy', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_phone_code') {
throw new Error(
'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
);
}
return null;
});
renderAuthSurface(invoke);
await loginWithCode();
expect(
await screen.findByText(
'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
),
).not.toBeNull();
expect(document.body.textContent).not.toContain('ECONNREFUSED');
});
it('keeps a retry when the startup check reports the service unavailable', async () => {
let reads = 0;
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
reads += 1;
return reads === 1
? {
status: 'unavailable',
errorMessage: 'network-error: 无法连接登录服务',
}
: { status: 'unauthenticated' };
}
return null;
});
renderAuthSurface(invoke);
expect(
await screen.findByText('network-error: 无法连接登录服务'),
).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();
});
}