merge: integrate performance optimization

This commit is contained in:
2026-08-19 01:21:23 +08:00
121 changed files with 4358 additions and 1399 deletions

View File

@@ -478,10 +478,6 @@ describe('AgentBrowserModule', () => {
expect(view.webContents.debugger.operationLog).toEqual([
'load:about:blank',
'attach:1.3',
'command:Runtime.enable',
'command:Log.enable',
'command:Network.enable',
'command:Page.enable',
'command:Target.setAutoAttach',
'load:http://127.0.0.1:4173/',
]);
@@ -662,11 +658,22 @@ describe('AgentBrowserModule', () => {
projectPath,
url: 'http://localhost:3000',
});
const view = adapter.views[0];
if (!view) throw new Error('browser view was not created');
await module.present({
projectPath,
visible: true,
bounds: { x: 0, y: 0, width: 800, height: 600 },
});
await module.setDiagnostics({ projectPath, enabled: true });
view.webContents.debugger.message('Runtime.consoleAPICalled', {
type: 'log',
args: [{ value: 'ready' }],
});
view.webContents.debugger.message('Network.requestWillBeSent', {
requestId: 'request-1',
request: { url: 'http://localhost:3000/api/data' },
});
const page = await module.readEvents({ projectPath, after: 0 });
expect(page.events.map((event) => event.method)).toEqual([
@@ -863,6 +870,7 @@ describe('AgentBrowserModule', () => {
it('moves oversized events and command results into bounded payload handles', async () => {
const { module, view } = await openBrowser();
await module.setDiagnostics({ projectPath, enabled: true });
const largeText = 'x'.repeat(70 * 1024);
view.webContents.debugger.message('Network.dataReceived', { data: largeText });
@@ -905,6 +913,7 @@ describe('AgentBrowserModule', () => {
it('reports a debugger gap and reattaches with a new generation after DevTools closes', async () => {
const { module, view } = await openBrowser();
await module.setDiagnostics({ projectPath, enabled: true });
view.webContents.debugger.message('Runtime.consoleAPICalled', { type: 'log' });
const before = await module.readEvents({ projectPath, after: 0 });

View File

@@ -97,6 +97,9 @@ function installBrowserApi(
};
return { success: true, browser: current };
}
if (path === '/api/agent-browser/diagnostics') {
return { success: true, browser: current };
}
if (path === '/api/agent-browser/navigate') {
return { success: true, browser: current };
}

View File

@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -93,7 +93,7 @@ describe('Agent Browser Host API routes', () => {
async function temporaryProject(prefix: string): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
return await realpath(directory);
}
it('opens the browser for the active project and emits a visible hint', async () => {

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AiHardware } from '@/pages/AiHardware';
import {
@@ -7,6 +7,7 @@ import {
type AiHardwareConfigurationCatalog,
type AiHardwareOverview,
} from '@/lib/ai-hardware';
import { announceAiHardwareNavigation } from '@/lib/ai-hardware-navigation';
const api = vi.hoisted(() => ({
getAiHardwareOverview: vi.fn(),
@@ -82,6 +83,10 @@ function deferred<T>() {
return { promise, resolve };
}
function selectAgent(agentId: string): void {
act(() => announceAiHardwareNavigation({ agentId }));
}
async function openConfigurationEditor(): Promise<void> {
const editButton = await screen.findByRole('button', { name: '编辑配置' });
await waitFor(() => expect(editButton).toBeEnabled());
@@ -98,6 +103,7 @@ async function openGuidedHotspotStep(): Promise<void> {
describe('AI hardware page', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/ai-hardware');
api.getAiHardwareOverview.mockResolvedValue(activeOverview);
api.getAiHardwareProvisioningCapabilities.mockResolvedValue({ guidedHotspotBinding: false });
api.scanAiHardwareProvisioningHotspots.mockResolvedValue({ platform: 'windows', hotspots: [] });
@@ -113,6 +119,17 @@ describe('AI hardware page', () => {
api.updateAiHardwareAssignment.mockResolvedValue({ data: { ...device, agent_id: agentTwo.id }, revision: 4 });
});
it('presents basic settings as readable summary cards and a bounded prompt preview', async () => {
render(<AiHardware />);
const settings = await screen.findByTestId('ai-hardware-basic-settings');
expect(within(settings).getByTestId('ai-hardware-basic-settings-language')).toHaveTextContent('中文');
expect(within(settings).getByTestId('ai-hardware-basic-settings-language')).toHaveTextContent('zh-CN');
expect(within(settings).getByTestId('ai-hardware-basic-settings-voice')).toHaveTextContent('voice-a');
expect(within(settings).getByTestId('ai-hardware-system-prompt-preview')).toHaveTextContent('保持简洁');
expect(within(settings).getByText('配置版本 r4')).toBeInTheDocument();
});
it('shows loading, disabled, and credential recovery states without exposing diagnostics', async () => {
const pending = deferred<AiHardwareOverview>();
api.getAiHardwareOverview.mockReturnValueOnce(pending.promise);
@@ -142,7 +159,7 @@ describe('AI hardware page', () => {
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '客厅助手' } });
fireEvent.click(screen.getByRole('button', { name: '创建' }));
await waitFor(() => expect(api.createAiHardwareAgent).toHaveBeenCalledWith('客厅助手'));
expect((await screen.findAllByText('客厅助手')).length).toBeGreaterThan(0);
expect(await screen.findByText(/客厅助手.*台设备已绑定/)).toBeInTheDocument();
});
it('provisions an unprovisioned account by creating its first agent', async () => {
@@ -337,12 +354,11 @@ describe('AI hardware page', () => {
render(<AiHardware />);
expect(await screen.findByRole('button', { name: /客厅助手.*1 台设备/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /书房助手.*1 台设备/ })).toBeInTheDocument();
expect(await screen.findByText(/客厅助手.*1 台设备已绑定/)).toBeInTheDocument();
expect(await screen.findByText('设备 device-one')).toBeInTheDocument();
expect(screen.queryByText('设备 device-two')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /书房助手/ }));
selectAgent(agentTwo.id);
expect(await screen.findByText('设备 device-two')).toBeInTheDocument();
expect(screen.queryByText('设备 device-one')).not.toBeInTheDocument();
@@ -357,6 +373,24 @@ describe('AI hardware page', () => {
await waitFor(() => expect(api.bindAiHardwareDevice).toHaveBeenCalledWith('654321', agentTwo.id));
});
it('restores the selected agent from the URL and keeps navigation state shareable', async () => {
const agentTwoDevice = { id: 'device-two', agent_id: agentTwo.id, assignment_revision: 1 };
window.history.replaceState({}, '', `/ai-hardware?agent=${agentTwo.id}`);
api.getAiHardwareOverview.mockResolvedValueOnce({
status: 'active',
agents: [agentOne, agentTwo],
devices: [device, agentTwoDevice],
});
render(<AiHardware />);
expect(await screen.findByText('设备 device-two')).toBeInTheDocument();
expect(window.location.search).toBe(`?agent=${agentTwo.id}`);
selectAgent(agentOne.id);
expect(await screen.findByText(/设备 device.*al-1/)).toBeInTheDocument();
expect(window.location.search).toBe(`?agent=${agentOne.id}`);
});
it('guides the existing hotspot flow, locks navigation while binding, and avoids online claims', async () => {
api.getAiHardwareProvisioningCapabilities.mockResolvedValueOnce({ guidedHotspotBinding: true });
const binding = deferred<typeof device>();
@@ -849,7 +883,7 @@ describe('AI hardware page', () => {
render(<AiHardware />);
expect(await screen.findByText('保持简洁')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /书房助手/ }));
selectAgent(agentTwo.id);
expect(screen.queryByText('保持简洁')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '编辑配置' })).toBeDisabled();

View File

@@ -4,14 +4,14 @@ import { describe, expect, it } from 'vitest';
describe('AI hardware Host API registration', () => {
it('registers the dedicated handler in the Host API route chain', async () => {
const source = await readFile(resolve('electron/api/server.ts'), 'utf8');
const source = await readFile(resolve('electron/api/route-handlers.ts'), 'utf8');
expect(source).toMatch(
/import\s+\{\s*handleAiHardwareRoutes\s*\}\s+from\s+['"]\.\/routes\/ai-hardware['"]/,
);
const routeList = source.match(
/const\s+coreRouteHandlers\s*:\s*RouteHandler\[\]\s*=\s*\[([\s\S]*?)\];/,
/hostApiRouteHandlers[^=]*=\s*\[([\s\S]*?)\];/,
);
expect(routeList?.[1]).toBeDefined();
expect(routeList?.[1].match(/\bhandleAiHardwareRoutes\b/g)).toHaveLength(1);

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BackgroundLifecycleController } from '../../electron/main/background-lifecycle';
describe('BackgroundLifecycleController', () => {
afterEach(() => {
vi.useRealTimers();
});
it('stops an idle hidden/inactive desktop after the grace period', async () => {
vi.useFakeTimers();
const onSleep = vi.fn().mockResolvedValue(undefined);
const onStopRuntime = vi.fn().mockResolvedValue(undefined);
const controller = new BackgroundLifecycleController({
idleStopMs: 60_000,
onSleep,
onStopRuntime,
});
controller.setActivity({ visible: false, module: 'painting' });
await vi.advanceTimersByTimeAsync(59_999);
expect(onSleep).not.toHaveBeenCalled();
expect(onStopRuntime).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(onSleep).toHaveBeenCalledTimes(1);
expect(onStopRuntime).toHaveBeenCalledTimes(1);
controller.dispose();
});
it('keeps the runtime alive while an active task lease exists', async () => {
vi.useFakeTimers();
const onSleep = vi.fn().mockResolvedValue(undefined);
const onStopRuntime = vi.fn().mockResolvedValue(undefined);
const controller = new BackgroundLifecycleController({
idleStopMs: 60_000,
onSleep,
onStopRuntime,
});
const release = controller.acquireLease({ id: 'release:1', kind: 'publish' });
controller.setActivity({ visible: false, module: 'programming' });
await vi.advanceTimersByTimeAsync(60_000);
expect(controller.getLeaseCount()).toBe(1);
expect(onStopRuntime).not.toHaveBeenCalled();
release();
await vi.advanceTimersByTimeAsync(60_000);
expect(onSleep).toHaveBeenCalledTimes(1);
expect(onStopRuntime).toHaveBeenCalledTimes(1);
controller.dispose();
});
it('cancels the idle stop when programming becomes visible again', async () => {
vi.useFakeTimers();
const onSleep = vi.fn();
const onStopRuntime = vi.fn();
const controller = new BackgroundLifecycleController({
idleStopMs: 60_000,
onSleep,
onStopRuntime,
});
controller.setActivity({ visible: false, module: 'learning' });
await vi.advanceTimersByTimeAsync(30_000);
controller.setActivity({ visible: true, module: 'programming' });
await vi.advanceTimersByTimeAsync(90_000);
expect(onSleep).not.toHaveBeenCalled();
expect(onStopRuntime).not.toHaveBeenCalled();
controller.dispose();
});
});

View File

@@ -0,0 +1,55 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
GPU_CRASH_THRESHOLD,
GPU_CRASH_WINDOW_MS,
readGpuFallbackState,
recordGpuCrash,
shouldUseSoftwareRendering,
} from '../../electron/main/gpu-fallback';
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe('GPU fallback policy', () => {
it('enables software rendering only after repeated recent GPU failures', () => {
const now = 1_000_000;
expect(shouldUseSoftwareRendering({ state: null, now })).toBe(false);
expect(shouldUseSoftwareRendering({
state: { crashCount: GPU_CRASH_THRESHOLD, lastCrashAt: now, reason: 'crashed:1' },
now,
})).toBe(true);
expect(shouldUseSoftwareRendering({
state: {
crashCount: GPU_CRASH_THRESHOLD,
lastCrashAt: now - GPU_CRASH_WINDOW_MS - 1,
reason: 'crashed:1',
},
now,
})).toBe(false);
expect(shouldUseSoftwareRendering({ cliDisabled: true, now })).toBe(true);
});
it('persists an atomic crash counter and ignores malformed state', () => {
const directory = mkdtempSync(join(tmpdir(), 'makelore-gpu-'));
temporaryDirectories.push(directory);
const filePath = join(directory, 'nested', 'gpu-fallback.json');
const first = recordGpuCrash(filePath, 'crashed:9', 10);
const second = recordGpuCrash(filePath, 'oom:137', 20);
expect(first.crashCount).toBe(1);
expect(second.crashCount).toBe(2);
expect(readGpuFallbackState(filePath, 20)).toEqual(second);
expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual(second);
mkdirSync(join(directory, 'bad'), { recursive: true });
const malformedPath = join(directory, 'bad', 'gpu-fallback.json');
expect(readGpuFallbackState(malformedPath)).toBeNull();
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { dispatchHostApiRequest } from '@electron/api/host-api-dispatcher';
import { shouldUseLoopbackHostApi } from '@electron/api/host-api-transport';
describe('Host API transport selection', () => {
it('keeps long-lived and binary responses on loopback HTTP', () => {
expect(shouldUseLoopbackHostApi('/api/events')).toBe(true);
expect(shouldUseLoopbackHostApi('/api/events?token=session')).toBe(true);
expect(shouldUseLoopbackHostApi('/api/opencode/events')).toBe(true);
expect(shouldUseLoopbackHostApi('/api/ai-proxy/v1/chat/completions', 'POST')).toBe(true);
expect(shouldUseLoopbackHostApi('/api/image-workspace/workspaces/w1/conversations/c1/events')).toBe(true);
expect(shouldUseLoopbackHostApi('/api/image-workspace/workspaces/w1/assets/a1/content?download=1')).toBe(true);
});
it('dispatches JSON and control requests in Main', () => {
expect(shouldUseLoopbackHostApi('/api/app/runtime-info')).toBe(false);
expect(shouldUseLoopbackHostApi('/api/works/projects/release-jobs', 'POST')).toBe(false);
expect(shouldUseLoopbackHostApi('/api/opencode/status')).toBe(false);
});
it('reuses the existing route contract without opening a socket', async () => {
const data = await dispatchHostApiRequest({
opencodeManager: { getStatus: () => ({ state: 'stopped', port: null }) },
opencodeProjectStore: {},
eventBus: {},
mainWindow: null,
} as never, { path: '/api/app/runtime-info' });
expect(data).toMatchObject({ status: 200, ok: true });
expect(data.json).toMatchObject({ runtime: 'opencode', status: { state: 'stopped' } });
});
});

View File

@@ -89,4 +89,28 @@ describe('Host API IPC proxy', () => {
expect(headers.get('authorization')).toBe('Bearer host-token');
expect(headers.get('x-niancode-renderer-capability')).toBe('renderer-token');
});
it('dispatches short JSON requests in Main without opening loopback HTTP', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.doMock('../../electron/api/host-api-dispatcher', () => ({
dispatchHostApiRequest: vi.fn().mockResolvedValue({
status: 200,
ok: true,
json: { success: true, transport: 'test' },
}),
}));
const { registerHostApiProxyHandlers } = await import('../../electron/main/ipc/host-api-proxy');
registerHostApiProxyHandlers({} as never);
const fetchHandler = handleMock.mock.calls.find(([channel]) => channel === 'hostapi:fetch')?.[1];
const result = await fetchHandler(undefined, { path: '/api/app/runtime-info' });
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({
ok: true,
data: { status: 200, ok: true, json: { success: true, transport: 'test' }, transport: 'dispatcher' },
});
});
});

View File

@@ -1411,8 +1411,8 @@ describe('ImageCanvas Workspace-first design experience', () => {
} satisfies DesignAssistantDeltaEvent);
});
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('可以先增加');
await waitFor(() => expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('可以先增加'));
expect(screen.getByTestId('image-workspace-conversation'))
.not.toHaveTextContent('留白和水流节奏');
@@ -1429,8 +1429,8 @@ describe('ImageCanvas Workspace-first design experience', () => {
} satisfies DesignAssistantDeltaEvent);
});
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('可以先增加留白和水流节奏。');
await waitFor(() => expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('可以先增加留白和水流节奏。'));
act(() => response.resolve({
...conversationFixture(),
turnRevision: 2,
@@ -1475,8 +1475,8 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(await screen.findByText('继续优化海报')).toBeInTheDocument();
expect(await screen.findByText('已完成最新方向整理。')).toBeInTheDocument();
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce();
expect(taskEventSource.close).not.toHaveBeenCalled();
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledTimes(2);
expect(taskEventSource.close).toHaveBeenCalledOnce();
await act(async () => {
response.resolve(canonical);

View File

@@ -258,6 +258,7 @@ describe('AI design task event store', () => {
chunkIndex: 1,
delta: '鲸鱼的轮廓。',
} satisfies DesignAssistantDeltaEvent);
await new Promise<void>((resolve) => setTimeout(resolve, 60));
expect(useImageWorkspaceStore.getState().pendingTurn?.assistantText)
.toBe('可以,先强化鲸鱼的轮廓。');

View File

@@ -62,6 +62,22 @@ describe('light visual system', () => {
expect(transcriptRule).not.toContain('contain-intrinsic-size');
});
it('keeps overlays opaque and disables backdrop sampling', () => {
const overlaySource = [
readSource('components/ui/dialog.tsx'),
readSource('components/ui/sheet.tsx'),
readSource('components/layout/Sidebar.tsx'),
readSource('pages/Chat/ChatMessage.tsx'),
readSource('pages/ImagePromptMuseum/index.tsx'),
readSource('pages/Workbench/index.tsx'),
].join('\n');
const globalsSource = readSource('styles/globals.css');
expect(overlaySource).not.toContain('backdrop-blur');
expect(globalsSource).toContain('backdrop-filter: none !important;');
expect(globalsSource).toContain('-webkit-backdrop-filter: none !important;');
});
it('uses bundled mixed-script typography with language-aware fallbacks', () => {
const globalsSource = readSource('styles/globals.css');
const tailwindSource = readFileSync(join(process.cwd(), 'tailwind.config.js'), 'utf8');
@@ -69,7 +85,7 @@ describe('light visual system', () => {
expect(globalsSource).toContain('font-family: "Makelore Latin";');
expect(globalsSource).toContain('font-family: "Makelore CJK";');
expect(globalsSource).toContain('SourceHanSansSC-Regular.otf');
expect(globalsSource).toContain('SourceHanSansSC-Regular.woff2');
expect(globalsSource).toContain('unicode-range: U+2E80-2EFF');
expect(globalsSource).toContain('font-display: swap;');
expect(tailwindSource).toContain("var(--font-sans)");
@@ -77,8 +93,9 @@ describe('light visual system', () => {
expect(appSource).toContain("root.lang = resolvedLanguage === 'zh' ? 'zh-CN' : resolvedLanguage;");
expect(existsSync(join(process.cwd(), 'src/assets/fonts/InterVariable.woff2'))).toBe(true);
expect(existsSync(join(process.cwd(), 'src/assets/fonts/InterVariable-Italic.woff2'))).toBe(true);
expect(existsSync(join(process.cwd(), 'src/assets/fonts/SourceHanSansSC-Regular.otf'))).toBe(true);
expect(existsSync(join(process.cwd(), 'src/assets/fonts/InterVariable-Italic.woff2'))).toBe(false);
expect(existsSync(join(process.cwd(), 'src/assets/fonts/SourceHanSansSC-Regular.otf'))).toBe(false);
expect(existsSync(join(process.cwd(), 'src/assets/fonts/SourceHanSansSC-Regular.woff2'))).toBe(true);
expect(existsSync(join(process.cwd(), 'public/fonts/licenses/OFL-1.1.txt'))).toBe(true);
expect(existsSync(join(process.cwd(), 'public/fonts/licenses/NOTICE.md'))).toBe(true);
});

View File

@@ -44,7 +44,7 @@ describe('Login page', () => {
vi.unstubAllGlobals();
});
it('keeps the login route reachable from the app router', () => {
it('keeps the login route reachable from the app router', async () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
render(
@@ -53,7 +53,7 @@ describe('Login page', () => {
</MemoryRouter>,
);
expect(screen.getByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument();
});
it('uses Chinese copy and keeps upstream HTML errors concise', () => {

View File

@@ -81,6 +81,7 @@ describe('opencode store', () => {
sessionsByProjectId: {},
selectedSessionId: null,
sessionStatuses: {},
projectEventStreamState: 'closed',
sessionMessages: [],
sessionMessagesBySessionId: {},
streamingMessage: null,
@@ -549,6 +550,51 @@ describe('opencode store', () => {
.not.toHaveProperty('part_background_tool');
});
it('hydrates only selected and active sessions after a project stream reconnect', async () => {
const source = new MockEventSource('/api/opencode/events');
createHostEventSourceMock.mockReturnValue(source);
const project = {
id: 'prj_lazy_hydration',
path: 'D:/repo/lazy-hydration',
name: 'lazy-hydration',
};
const sessions = [
{ id: 'ses_selected' },
{ id: 'ses_active' },
{ id: 'ses_history_only' },
];
useOpencodeStore.setState({
status: { state: 'running', port: 4096, url: 'http://127.0.0.1:4096' },
activeProject: project,
selectedSessionId: 'ses_selected',
sessionStatuses: { ses_active: { type: 'busy' } },
});
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/sessions') return { sessions };
if (path.endsWith('/messages')) {
return { messages: [{ id: `message-${path}`, role: 'assistant', content: 'ready' }] };
}
throw new Error(`Unexpected request: ${path}`);
});
await useOpencodeStore.getState().loadSessions();
await vi.waitFor(() => expect(createHostEventSourceMock).toHaveBeenCalledWith('/api/opencode/events'));
source.emit('server.connected', {});
await vi.waitFor(() => expect(
hostApiFetchMock.mock.calls.filter(([path]) => String(path).endsWith('/messages')),
).toHaveLength(2));
const messageRequests = hostApiFetchMock.mock.calls
.map(([path]) => String(path))
.filter((path) => path.endsWith('/messages'));
expect(messageRequests).toEqual(expect.arrayContaining([
'/api/opencode/sessions/ses_selected/messages',
'/api/opencode/sessions/ses_active/messages',
]));
expect(messageRequests).not.toContain('/api/opencode/sessions/ses_history_only/messages');
});
it('derives reverted session state from loaded server metadata', async () => {
const { hostApiFetch } = await import('@/lib/host-api');
const project = {
@@ -1275,6 +1321,24 @@ describe('opencode store', () => {
expect(useOpencodeStore.getState().sessionMessages).toEqual(loaded);
});
it('deduplicates concurrent history loads for the same session', async () => {
const response = createDeferred<{ messages: unknown[] }>();
hostApiFetchMock.mockReturnValueOnce(response.promise);
const first = useOpencodeStore.getState().loadSessionMessages('ses_deduped');
const second = useOpencodeStore.getState().loadSessionMessages('ses_deduped');
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
response.resolve({
messages: [{ id: 'msg_deduped', role: 'assistant', content: 'Loaded once' }],
});
await expect(Promise.all([first, second])).resolves.toEqual([
[expect.objectContaining({ id: 'msg_deduped' })],
[expect.objectContaining({ id: 'msg_deduped' })],
]);
});
it('loads messages when a fast session selection has no known cache entry', async () => {
const { hostApiFetch } = await import('@/lib/host-api');
vi.mocked(hostApiFetch).mockResolvedValueOnce({
@@ -1309,6 +1373,33 @@ describe('opencode store', () => {
expect(hostApiFetch).not.toHaveBeenCalledWith('/api/opencode/sessions/ses_new/messages');
});
it('bounds renderer transcript caches without deleting historical runtime messages', async () => {
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path.endsWith('/messages')) {
const sessionId = path.split('/').at(-2);
return {
messages: [{ id: `message-${sessionId}`, role: 'assistant', content: `History ${sessionId}` }],
};
}
throw new Error(`Unexpected request: ${path}`);
});
for (let index = 1; index <= 9; index += 1) {
await useOpencodeStore.getState().loadSessionMessages(`ses_${index}`);
}
const cachedSessionIds = Object.keys(useOpencodeStore.getState().sessionMessagesBySessionId);
expect(cachedSessionIds).toHaveLength(8);
expect(cachedSessionIds).toContain('ses_9');
expect(cachedSessionIds).not.toContain('ses_1');
await useOpencodeStore.getState().selectSession('ses_1', { loadMessages: false });
expect(hostApiFetchMock).toHaveBeenLastCalledWith('/api/opencode/sessions/ses_1/messages');
expect(useOpencodeStore.getState().sessionMessages).toEqual([
expect.objectContaining({ id: 'message-ses_1' }),
]);
});
it('queues the full session snapshot after a completed prompt', async () => {
const { hostApiFetch } = await import('@/lib/host-api');
const project = {

View File

@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
import {
mkdir,
mkdtemp,
realpath,
readFile,
rename,
rm,
@@ -74,7 +75,7 @@ async function createProject(projectType: ProjectType = 'mini_game'): Promise<st
await writeFile(join(projectPath, 'index.html'), '<div id="app"></div>', 'utf8');
await writeFile(join(projectPath, 'src', 'main.ts'), 'console.log("ready")\n', 'utf8');
await writeFile(join(projectPath, 'public', 'icon.txt'), 'icon\n', 'utf8');
return projectPath;
return await realpath(projectPath);
}
afterEach(async () => {

View File

@@ -90,7 +90,7 @@ describe('project progress compliance synchronization', () => {
);
});
it('watches every registered project, maps identities, debounces changes, and skips duplicate content', async () => {
it('watches only the active project, maps identities, debounces changes, and skips duplicate content', async () => {
const serverPath = await createProjectDirectory('server');
const localPath = await createProjectDirectory('local');
await writeGameDocuments(serverPath, '# server gdd', '# server tasks');
@@ -98,6 +98,7 @@ describe('project progress compliance synchronization', () => {
const projectStore = createProjectStore(createMemoryProjectStorage());
const serverProject = await projectStore.rememberProject(serverPath);
const localProject = await projectStore.rememberProject(localPath);
await projectStore.setActiveProject(localProject.id);
await writeFile(
join(serverPath, 'works-cloud-deploy.json'),
createServerBindingFile(serverProject.id, 'server-game'),
@@ -115,9 +116,13 @@ describe('project progress compliance synchronization', () => {
synchronizers.push(sync);
await sync.start();
await Promise.all([serverProject.id, localProject.id].map((projectId) => sync.flushProject(projectId)));
await sync.flushProject(localProject.id);
expect(sync.getWatchedProjectIds()).toEqual(expect.arrayContaining([serverProject.id, localProject.id]));
expect(sync.getWatchedProjectIds()).toEqual([localProject.id]);
expect(fetchMock).toHaveBeenCalledTimes(1);
await sync.activate(serverProject.id);
await sync.flushProject(serverProject.id);
expect(sync.getWatchedProjectIds()).toEqual([serverProject.id]);
expect(fetchMock).toHaveBeenCalledTimes(2);
const payloads = fetchMock.mock.calls.map((_, index) => parsePayload(fetchMock, index));
const serverPayload = payloads.find((payload) => payload.project_key === 'server-game');
@@ -377,7 +382,7 @@ describe('project progress compliance synchronization', () => {
expect(parsePayload(fetchMock, 1).progress_text).toContain('# second tasks');
});
it('attaches a watcher when a project is registered after startup', async () => {
it('attaches a watcher only after a project becomes active', async () => {
const projectStore = createProjectStore(createMemoryProjectStorage());
const harness = createWatcherHarness();
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
@@ -394,6 +399,9 @@ describe('project progress compliance synchronization', () => {
await writeGameDocuments(projectPath, '# late gdd', '# late tasks');
const project = await projectStore.rememberProject(projectPath);
expect(sync.getWatchedProjectIds()).toEqual([]);
await projectStore.setActiveProject(project.id);
await sync.activate(project.id);
expect(sync.getWatchedProjectIds()).toContain(project.id);
await sync.flushProject(project.id);
expect(fetchMock).toHaveBeenCalledTimes(1);

View File

@@ -49,7 +49,7 @@ describe('project release builder', () => {
expect(release.contract.source_digest).toBe(createHash('sha256').update(await readFile(release.sourceArchive.path)).digest('hex'));
expect(release.contract.built_archive_digest).toBe(createHash('sha256').update(release.builtArchive.bytes).digest('hex'));
expect(runElectronNodeMock.mock.calls[0][0].args).toEqual([
'npm-cli.js', 'ci', '--ignore-scripts', '--no-audit', '--no-fund',
'npm-cli.js', 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline', '--progress=false',
'--userconfig', expect.stringMatching(/empty-npmrc$/), '--cache', expect.stringMatching(/npm-cache$/),
]);
await rm(release.distRoot, { recursive: true, force: true });

View File

@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { net } from 'electron';
import { proxyAwareFetch } from '@electron/utils/proxy-fetch';
const originalElectronVersion = Object.getOwnPropertyDescriptor(process.versions, 'electron');
function setElectronVersion(version: string | undefined): void {
if (version === undefined) {
delete (process.versions as NodeJS.ProcessVersions & { electron?: string }).electron;
return;
}
Object.defineProperty(process.versions, 'electron', {
configurable: true,
value: version,
});
}
afterEach(() => {
vi.unstubAllGlobals();
vi.mocked(net.fetch).mockReset();
if (originalElectronVersion) {
Object.defineProperty(process.versions, 'electron', originalElectronVersion);
} else {
setElectronVersion(undefined);
}
});
describe('proxyAwareFetch', () => {
it('uses Electron net.fetch without dynamically importing the Electron namespace', async () => {
setElectronVersion('43.4.0');
const response = new Response('electron');
vi.mocked(net.fetch).mockResolvedValue(response);
await expect(proxyAwareFetch('https://example.test')).resolves.toBe(response);
expect(net.fetch).toHaveBeenCalledWith('https://example.test', undefined);
});
it('uses the global fetch outside Electron', async () => {
setElectronVersion(undefined);
const response = new Response('node');
const fetchMock = vi.fn().mockResolvedValue(response);
vi.stubGlobal('fetch', fetchMock);
await expect(proxyAwareFetch('https://example.test')).resolves.toBe(response);
expect(fetchMock).toHaveBeenCalledWith('https://example.test', undefined);
expect(net.fetch).not.toHaveBeenCalled();
});
});

View File

@@ -127,7 +127,7 @@ describe('managed Python runtime resolution', () => {
const runtime = { executable: 'C:\\NianCode\\python\\python.exe', binDir: 'C:\\NianCode\\python', source: 'bundled' as const };
const result = prependPythonToPath({ Path: 'existing-bin' }, runtime, 'win32');
expect(result.Path).toBe(`${runtime.binDir}${delimiter}existing-bin`);
expect(result.Path).toBe(`${runtime.binDir};existing-bin`);
expect(result).not.toHaveProperty('PATH');
});
@@ -154,7 +154,7 @@ describe('managed Python runtime resolution', () => {
uvRuntime,
'win32',
)).toEqual({
Path: `${dirname(uvExecutable)}${delimiter}${dirname(pythonExecutable)}${delimiter}C:\\Windows\\System32`,
Path: `${dirname(uvExecutable)};${dirname(pythonExecutable)};C:\\Windows\\System32`,
NIANCODE_PYTHON_PATH: pythonExecutable,
NIANCODE_UV_PATH: uvExecutable,
});

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest';
import { ReleaseJobManager } from '@electron/services/release-job';
import { ProjectReleaseBuildError } from '@electron/services/project-release-builder';
function preparedRelease() {
return {
sourceArchive: {
path: '/tmp/project.zip',
name: 'project.zip' as const,
bytes: Buffer.from('zip'),
summary: {
archivePath: '/tmp/project.zip',
archiveName: 'project.zip',
sha256: 'a'.repeat(64),
fileCount: 1,
sourceBytes: 3,
archiveBytes: 3,
excludedCount: 0,
excludedPaths: [],
manifest: {
schema_version: 1 as const,
kind: 'web' as const,
runtime: 'static' as const,
build: { preset: 'vite' as const, package_manager: 'npm' as const, entry: 'index.html' as const },
},
},
},
builtArchive: { path: '/tmp/built.zip', name: 'built-project.zip' as const, bytes: Buffer.from('built') },
contract: {} as never,
distRoot: '/tmp/dist',
staticArtifact: {} as never,
dispose: vi.fn(async () => undefined),
};
}
describe('ReleaseJobManager', () => {
it('serializes jobs per project and publishes terminal status', async () => {
const prepare = vi.fn(async () => preparedRelease());
const manager = new ReleaseJobManager({ prepare });
const first = manager.start({ projectId: 'project-1', projectPath: '/tmp/project-1', clientVersion: '2.0.0' });
expect(first.state).toBe('running');
expect(() => manager.start({ projectId: 'project-1', projectPath: '/tmp/project-1', clientVersion: '2.0.0' }))
.toThrowError(ProjectReleaseBuildError);
await vi.waitFor(() => expect(manager.get(first.jobId)?.state).toBe('complete'));
expect(prepare).toHaveBeenCalledTimes(1);
expect(manager.get(first.jobId)?.result?.package.archiveName).toBe('project.zip');
});
it('cancels an in-flight build and releases the project lock', async () => {
let resolveBuild!: (value: ReturnType<typeof preparedRelease>) => void;
const prepare = vi.fn(() => new Promise<ReturnType<typeof preparedRelease>>((resolve) => {
resolveBuild = resolve;
}));
const manager = new ReleaseJobManager({ prepare });
const job = manager.start({ projectId: 'project-2', projectPath: '/tmp/project-2', clientVersion: '2.0.0' });
expect(manager.cancel(job.jobId)?.state).toBe('cancelled');
resolveBuild(preparedRelease());
await vi.waitFor(() => expect(manager.get(job.jobId)?.state).toBe('cancelled'));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(() => manager.start({ projectId: 'project-2', projectPath: '/tmp/project-2', clientVersion: '2.0.0' })).not.toThrow();
});
});

View File

@@ -498,6 +498,33 @@ describe('Sidebar project initialization flow', () => {
expect(sidebar).not.toHaveClass('fixed', 'sidebar-peek-surface', 'pt-10');
});
it('shows Robot agents under the workbench and routes agent actions through the shared URL', async () => {
hostApiFetchMock.mockResolvedValue({
success: true,
data: {
status: 'active',
agents: [
{ id: 'robot-agent-1', name: '桌面助手', config_revision: 2 },
{ id: 'robot-agent-2', name: '书房助手', config_revision: 1 },
],
devices: [{ id: 'robot-device-1', agent_id: 'robot-agent-1', assignment_revision: 3 }],
},
});
render(<MemoryRouter initialEntries={['/ai-hardware']}><Sidebar /></MemoryRouter>);
expect(screen.getByRole('button', { name: '机器人工作台' })).toBeInTheDocument();
expect(await screen.findByTestId('sidebar-robot-agent-robot-agent-1')).toHaveTextContent('桌面助手');
expect(screen.getByTestId('sidebar-robot-agent-robot-agent-1')).toHaveTextContent('1 台设备');
expect(screen.getByTestId('sidebar-robot-agent-robot-agent-2')).toHaveTextContent('0 台设备');
navigateMock.mockClear();
fireEvent.click(screen.getByTestId('sidebar-robot-agent-robot-agent-2'));
expect(navigateMock).toHaveBeenLastCalledWith({ pathname: '/ai-hardware', search: '?agent=robot-agent-2' });
fireEvent.click(screen.getByTestId('sidebar-robot-create-agent'));
expect(navigateMock).toHaveBeenLastCalledWith({ pathname: '/ai-hardware', search: '?create=1' });
});
it('shows an available update beside the lower-left account control', async () => {
hostApiFetchMock.mockResolvedValue({ projects: [], activeProject: null });
render(<MemoryRouter><Sidebar /></MemoryRouter>);
@@ -604,7 +631,8 @@ describe('Sidebar project initialization flow', () => {
fireEvent.click(await screen.findByRole('button', { name: `进入项目 ${project.name}` }));
const projectConfigError = await screen.findByTestId('project-config-error');
expect(projectConfigError).toBeInTheDocument();
expect(screen.getByTestId('project-config-error-overlay')).toHaveClass('fixed', 'inset-0', 'backdrop-blur-md');
expect(screen.getByTestId('project-config-error-overlay')).toHaveClass('fixed', 'inset-0', 'bg-foreground/15');
expect(screen.getByTestId('project-config-error-overlay')).not.toHaveClass('backdrop-blur-md');
expect(projectConfigError.closest('[data-testid="sidebar"]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '移除项目卡片' }));
const removeDialog = screen.getByRole('dialog', { name: `移除项目“${project.name}”?` });

View File

@@ -0,0 +1,35 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it } from 'vitest';
import { writeStoredZip } from '@electron/services/streaming-zip';
describe('streaming ZIP writer', () => {
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => (
rm(directory, { recursive: true, force: true })
)));
});
it('writes deterministic stored entries without buffering the archive', async () => {
const root = await mkdtemp(join(tmpdir(), 'streaming-zip-test-'));
temporaryDirectories.push(root);
await mkdir(join(root, 'assets'));
await writeFile(join(root, 'index.html'), '<main>ok</main>');
await writeFile(join(root, 'assets', '中.js'), 'console.log(1)');
const archivePath = join(root, 'build.zip');
const result = await writeStoredZip([
{ path: 'assets/中.js', source: { filePath: join(root, 'assets', '中.js') } },
{ path: 'index.html', source: { filePath: join(root, 'index.html') } },
], archivePath);
const archiveBytes = await readFile(archivePath);
expect(archiveBytes.subarray(0, 4).toString('hex')).toBe('504b0304');
expect(archiveBytes.includes(Buffer.from('<main>ok</main>'))).toBe(true);
expect(archiveBytes.includes(Buffer.from('console.log(1)'))).toBe(true);
expect(result.archiveBytes).toBe(archiveBytes.length);
expect(result.sha256).toMatch(/^[a-f0-9]{64}$/);
});
});

View File

@@ -2,16 +2,18 @@ import { describe, expect, it } from 'vitest';
import { getNativeWindowMaterialOptions } from '../../electron/main/window-material';
describe('native window material', () => {
it('enables transparent under-window vibrancy on macOS', () => {
it('uses the same opaque light surface on macOS', () => {
expect(getNativeWindowMaterialOptions('darwin')).toEqual({
backgroundColor: '#00000000',
transparent: true,
vibrancy: 'under-window',
visualEffectState: 'active',
backgroundColor: '#FFFFFF',
transparent: false,
visualEffectState: 'inactive',
});
});
it.each(['win32', 'linux', 'freebsd'] as const)('keeps %s on the opaque fallback', (platform) => {
expect(getNativeWindowMaterialOptions(platform)).toEqual({});
it.each(['win32', 'linux', 'freebsd'] as const)('uses the opaque fallback on %s', (platform) => {
expect(getNativeWindowMaterialOptions(platform)).toEqual({
backgroundColor: '#FFFFFF',
transparent: false,
});
});
});

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { shouldUseSecureWorksSquareSessionPersistence } from '@electron/services/works-square-session-persistence-policy';
describe('Works Square session persistence policy', () => {
it('uses OS secure storage only for packaged applications', () => {
expect(shouldUseSecureWorksSquareSessionPersistence(true)).toBe(true);
expect(shouldUseSecureWorksSquareSessionPersistence(false)).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { safeStorage } from 'electron';
const fakeStore = vi.hoisted(() => ({
options: null as Record<string, unknown> | null,
@@ -39,7 +40,7 @@ describe('Works Square encrypted session persistence', () => {
it('stores one encrypted Main-only record and restores it', async () => {
const firstModule = await import('@electron/services/works-square-session');
await firstModule.initializeWorksSquareSession();
await firstModule.initializeWorksSquareSession({ secureStorage: safeStorage });
firstModule.storeWorksSquareSession({
accessToken: 'plain-access-token',
refreshToken: 'plain-refresh-token',
@@ -58,7 +59,7 @@ describe('Works Square encrypted session persistence', () => {
vi.resetModules();
const restoredModule = await import('@electron/services/works-square-session');
await expect(restoredModule.initializeWorksSquareSession()).resolves.toMatchObject({
await expect(restoredModule.initializeWorksSquareSession({ secureStorage: safeStorage })).resolves.toMatchObject({
accessToken: 'plain-access-token',
canRefresh: true,
});
@@ -73,7 +74,7 @@ describe('Works Square encrypted session persistence', () => {
fakeStore.values.set('record', record);
const sessionModule = await import('@electron/services/works-square-session');
await expect(sessionModule.initializeWorksSquareSession()).resolves.toBeNull();
await expect(sessionModule.initializeWorksSquareSession({ secureStorage: safeStorage })).resolves.toBeNull();
expect(fakeStore.values.get('record')).toEqual(record);
expect(sessionModule.getWorksSquareSessionRestoreStatus()).toBe('unavailable');
@@ -87,7 +88,7 @@ describe('Works Square encrypted session persistence', () => {
fakeStore.constructorError = new SyntaxError('Unexpected token in JSON');
const sessionModule = await import('@electron/services/works-square-session');
await expect(sessionModule.initializeWorksSquareSession()).resolves.toBeNull();
await expect(sessionModule.initializeWorksSquareSession({ secureStorage: safeStorage })).resolves.toBeNull();
expect(fakeStore.options).toMatchObject({
name: 'works-square-session',
@@ -97,4 +98,23 @@ describe('Works Square encrypted session persistence', () => {
expect(sessionModule.getWorksSquareSessionRestoreStatus()).toBe('ready');
expect(sessionModule.consumeWorksSquareStartupRuntimeCleanupRequired()).toBe(true);
});
it('keeps unpackaged development sessions in memory without opening the disk store', async () => {
const sessionModule = await import('@electron/services/works-square-session');
await expect(sessionModule.initializeWorksSquareSession()).resolves.toBeNull();
await expect(sessionModule.commitWorksSquareSession({
accessToken: 'development-access-token',
refreshToken: 'development-refresh-token',
expiresAt: Date.now() + 3600_000,
lastActiveAt: Date.now(),
})).resolves.toMatchObject({
accessToken: 'development-access-token',
canRefresh: true,
});
await sessionModule.flushWorksSquareSessionPersistence();
expect(fakeStore.options).toBeNull();
expect(fakeStore.values.size).toBe(0);
});
});