合并master并保留双侧决策记录

合并 origin/master 的 UI 编辑器、GDD 审批与前端修复。

保留本分支 LLM Router、Direct 过程卡与私有路径相关实现和决策记录。

解决 decision-log 文档冲突,完整保留双方新增决策条目。
This commit is contained in:
2026-09-02 19:31:12 +08:00
39 changed files with 1824 additions and 97 deletions
@@ -16,6 +16,7 @@ import {
resetPlatformSessionStateForTests,
} from '../../src/services/platformSession';
import {
act,
AuthenticatedClient,
expect,
fireEvent,
@@ -35,6 +36,41 @@ export function registerAuthTests() {
delete window.__TAURI__;
});
it('leaves startup loading with an actionable retry after auth service timeout', async () => {
vi.useFakeTimers();
let releaseRefresh: (() => void) | null = null;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
return new Promise<Response>((resolve) => {
releaseRefresh = () => resolve(new Response('', { status: 401 }));
});
}
throw new Error(`unexpected fetch ${String(input)}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }),
),
);
expect(screen.getByRole('main', { name: '登录状态检查' })).not.toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(15_000);
});
expect(screen.getByRole('main', { name: '登录' })).not.toBeNull();
expect(screen.getByRole('alert')).not.toBeNull();
expect(
screen.getByRole('button', { name: '重试登录状态检查' }),
).not.toBeNull();
releaseRefresh?.();
await Promise.resolve();
vi.useRealTimers();
});
it('renders the unauthenticated client with the shared light platform theme and product image', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
@@ -10,6 +10,7 @@ import {
AGC_CLIENT_MARKER_VALUE,
AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
ClientHttpTimeoutError,
fetchClientHttp,
getClientServerBaseUrl,
getClientServerSelection,
@@ -25,6 +26,7 @@ vi.mock('@tauri-apps/plugin-http', () => ({
describe('AGC client HTTP transport', () => {
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
@@ -250,4 +252,62 @@ describe('AGC client HTTP transport', () => {
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
});
it('aborts a stalled Web request at the configured timeout', async () => {
vi.useFakeTimers();
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal('fetch', fetchMock);
const request = fetchClientHttp('/api/auth/me', {}, { timeoutMs: 25 });
const timeoutAssertion = expect(request).rejects.toMatchObject({
name: 'ClientHttpTimeoutError',
code: 'CLIENT_HTTP_TIMEOUT',
timeoutMs: 25,
url: '/api/auth/me',
});
await vi.advanceTimersByTimeAsync(25);
await timeoutAssertion;
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(forwardedInit.signal).toBeInstanceOf(AbortSignal);
expect((forwardedInit.signal as AbortSignal).aborted).toBe(true);
});
it('preserves caller AbortError and does not report it as a timeout', async () => {
const fetchMock = vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_, reject) => {
init.signal?.addEventListener('abort', () => {
reject(
new DOMException('The operation was aborted.', 'AbortError'),
);
});
}),
);
vi.stubGlobal('fetch', fetchMock);
const callerController = new AbortController();
const request = fetchClientHttp(
'/api/auth/me',
{ signal: callerController.signal },
{ timeoutMs: 10_000 },
);
callerController.abort();
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
await expect(request).rejects.not.toBeInstanceOf(ClientHttpTimeoutError);
});
it('allows explicitly disabling the timeout for long-running requests', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchClientHttp('/api/agent/run', {}, { timeoutMs: null }),
).resolves.toBeInstanceOf(Response);
const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(forwardedInit.signal).toBeUndefined();
});
});
@@ -137,6 +137,7 @@ async function renderLoadedSession(state: State) {
const stateStore: IUiDesignStateStore = {
load: vi.fn().mockResolvedValue({ revision: 0, state }),
save: vi.fn(),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
const hook = renderHook(() =>
useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore),
@@ -150,6 +151,7 @@ describe('UiEditorPage', () => {
const stateStore: IUiDesignStateStore = {
load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)),
save: vi.fn(),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
render(
@@ -193,6 +195,7 @@ describe('UiEditorPage', () => {
state: stateWithPages(['page']),
}),
save: vi.fn(),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
const hook = renderHook(
({ resourceId }) =>
@@ -248,6 +251,7 @@ describe('UiEditorPage', () => {
const stateStore: IUiDesignStateStore = {
load: vi.fn(() => new Promise(() => undefined)),
save: vi.fn(),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
const hook = renderHook(() =>
useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore),
@@ -355,6 +359,7 @@ describe('UiEditorPage', () => {
state: stateWithPages(['gameplay-page']),
}),
save: vi.fn(),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
render(
@@ -594,6 +599,7 @@ describe('UiEditorPage', () => {
const stateStore: IUiDesignStateStore = {
load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)),
save: vi.fn().mockRejectedValue(new Error('临时存储不可用')),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
render(
createElement(UiEditorPage, {
@@ -627,6 +633,7 @@ describe('UiEditorPage', () => {
status: 'conflict',
currentRevision: 1,
}),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
render(
createElement(UiEditorPage, {