feat: add browser mock mode for UI development

This commit is contained in:
lzt404 committed 2026-09-27 16:33:50 +08:00
1 parent 88f2d304e3
commit 006691d493
5 files changed
+1432 -10

No files matched your search

+20 -10
View File
@@ -1,19 +1,29 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { isTauri } from '@tauri-apps/api/core';
import { AppErrorBoundary } from './AppErrorBoundary';
import App from './App';
import { I18nProvider } from './i18n';
import { initializeTheme } from './theme';
import './styles.css';
initializeTheme();
async function bootstrap() {
if (import.meta.env.DEV && !isTauri()) {
const { installBrowserMock } = await import('./mocks/browserMock');
installBrowserMock();
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nProvider>
<AppErrorBoundary>
<App />
</AppErrorBoundary>
</I18nProvider>
</StrictMode>
);
initializeTheme();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nProvider>
<AppErrorBoundary>
<App />
</AppErrorBoundary>
</I18nProvider>
</StrictMode>,
);
}
void bootstrap();
+53
View File
@@ -0,0 +1,53 @@
#browser-mock-toolbar {
position: fixed;
top: 10px;
right: 12px;
z-index: 2147483647;
display: flex;
align-items: center;
gap: 7px;
min-height: 30px;
padding: 4px 7px;
border: 1px solid color-mix(in srgb, #f59e0b 55%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--panel-bg, #17191f) 92%, transparent);
box-shadow: 0 7px 24px rgb(0 0 0 / 22%);
color: var(--text-primary, #f3f4f6);
font: 12px/1.2 system-ui, sans-serif;
backdrop-filter: blur(10px);
}
#browser-mock-toolbar strong {
padding: 3px 6px;
border-radius: 5px;
background: #f59e0b;
color: #17120a;
font-size: 10px;
letter-spacing: 0.08em;
}
#browser-mock-toolbar select {
width: auto;
min-width: 94px;
min-height: 24px;
padding: 2px 24px 2px 7px;
border-radius: 5px;
font-size: 12px;
}
#browser-mock-toolbar span {
color: var(--text-secondary, #b6bbc6);
white-space: nowrap;
}
@media (max-width: 760px) {
#browser-mock-toolbar {
top: auto;
right: 8px;
bottom: 8px;
}
#browser-mock-toolbar span {
display: none;
}
}
+105
View File
@@ -0,0 +1,105 @@
import { emit } from '@tauri-apps/api/event';
import { mockConvertFileSrc, mockIPC, mockWindows } from '@tauri-apps/api/mocks';
import {
createBrowserMockRuntime,
resolveBrowserMockOptions,
type BrowserMockScenario,
} from './browserMockRuntime';
import './browserMock.css';
const STORAGE_KEY = 'easy-cli-proxy-api.browser-mock-scenario';
export type InstalledBrowserMock = {
scenario: BrowserMockScenario;
delayMs: number;
};
function readStoredScenario() {
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
function storeScenario(scenario: BrowserMockScenario) {
try {
window.localStorage.setItem(STORAGE_KEY, scenario);
} catch {
}
}
function installToolbar(scenario: BrowserMockScenario, delayMs: number) {
document.getElementById('browser-mock-toolbar')?.remove();
const toolbar = document.createElement('aside');
toolbar.id = 'browser-mock-toolbar';
toolbar.setAttribute('aria-label', 'Browser Mock controls');
toolbar.title = '仅在普通浏览器开发模式中显示;Tauri 和正式构建不会启用 Mock。';
const badge = document.createElement('strong');
badge.textContent = 'MOCK';
const select = document.createElement('select');
select.setAttribute('aria-label', 'Browser Mock 场景');
const scenarios: Array<[BrowserMockScenario, string]> = [
['running', '内核运行'],
['stopped', '内核停止'],
['empty', '未安装内核'],
['error', '请求错误'],
];
scenarios.forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
option.selected = value === scenario;
select.append(option);
});
select.addEventListener('change', () => {
const next = select.value as BrowserMockScenario;
storeScenario(next);
const url = new URL(window.location.href);
url.searchParams.set('mock', next);
window.location.assign(url);
});
const hint = document.createElement('span');
hint.textContent = delayMs > 0 ? `${delayMs} ms` : '浏览器调试';
toolbar.append(badge, select, hint);
document.body.append(toolbar);
document.body.dataset.browserMock = scenario;
}
export function installBrowserMock(): InstalledBrowserMock | null {
const options = resolveBrowserMockOptions(window.location.search, readStoredScenario());
if (options.mode === 'off') {
console.info('[Browser Mock] 已通过 ?mock=off 禁用。');
return null;
}
storeScenario(options.mode);
mockWindows('main');
mockConvertFileSrc('windows');
const runtime = createBrowserMockRuntime(
options.mode,
(event, payload) => {
queueMicrotask(() => {
void emit(event, payload);
});
},
options.delayMs,
);
mockIPC((command, payload) => runtime.invoke(command, payload), {
shouldMockEvents: true,
});
installToolbar(options.mode, options.delayMs);
console.info(
`[Browser Mock] 已启用场景“${options.mode}”${options.delayMs ? `,延迟 ${options.delayMs} ms` : ''}。`,
'使用 ?mock=running|stopped|empty|error 切换,?mock=off 禁用。',
);
return { scenario: options.mode, delayMs: options.delayMs };
}
File diff suppressed because it is too large. Load diff
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, test } from 'bun:test';
import {
createBrowserMockRuntime,
resolveBrowserMockOptions,
} from '../src/mocks/browserMockRuntime';
describe('browser mock options', () => {
test('defaults to a running core and accepts stored scenarios', () => {
expect(resolveBrowserMockOptions('', null)).toEqual({ mode: 'running', delayMs: 0 });
expect(resolveBrowserMockOptions('', 'stopped')).toEqual({ mode: 'stopped', delayMs: 0 });
});
test('URL options override storage and clamp delay', () => {
expect(resolveBrowserMockOptions('?mock=empty&mockDelay=9000', 'running'))
.toEqual({ mode: 'empty', delayMs: 5000 });
expect(resolveBrowserMockOptions('?mock=off&mockDelay=25', 'running'))
.toEqual({ mode: 'off', delayMs: 25 });
});
});
describe('browser mock runtime', () => {
test('models the core process lifecycle and emits status events', async () => {
const events: Array<{ event: string; payload: unknown }> = [];
const runtime = createBrowserMockRuntime('stopped', (event, payload) => {
events.push({ event, payload });
});
expect(await runtime.invoke('get_core_status')).toMatchObject({
installed: true,
running: false,
ready: false,
});
expect(await runtime.invoke('start_core_process')).toMatchObject({
running: true,
ready: true,
processId: 42817,
});
expect(events.at(-1)).toMatchObject({
event: 'core-status-changed',
payload: { running: true, ready: true },
});
});
test('persists config and management mutations in memory', async () => {
const runtime = createBrowserMockRuntime('running');
await runtime.invoke('add_core_api_key', { apiKey: 'sk-test', remark: 'test' });
expect(await runtime.invoke('get_core_config_settings')).toMatchObject({
apiKeys: expect.arrayContaining([{ apiKey: 'sk-test', remark: 'test' }]),
});
await runtime.invoke('management_request', {
request: {
method: 'PUT',
path: '/gemini-api-key',
body: [{ 'api-key': 'AIza-test', models: [{ name: 'gemini-test' }] }],
},
});
expect(await runtime.invoke('management_request', {
request: { method: 'GET', path: '/gemini-api-key' },
})).toEqual({
'gemini-api-key': [{ 'api-key': 'AIza-test', models: [{ name: 'gemini-test' }] }],
});
});
test('provides an explicit error scenario', async () => {
const runtime = createBrowserMockRuntime('error');
await expect(runtime.invoke('get_core_status')).rejects.toThrow('Browser Mock error scenario');
expect(await runtime.invoke('plugin:app|version')).toBe('0.2.97-mock');
});
});