8701f43f75
- 删除渲染层传输层: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 中无调用方的残留。
400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
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();
|
||
});
|
||
}
|