feat: add browser mock mode for UI development
This commit is contained in:
1 parent
88f2d304e3
commit
006691d493
5 files changed
+1432
-10
No files matched your search
+20
-10
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user