perf: optimize app startup and background lifecycles
This commit is contained in:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
72
tests/unit/background-lifecycle.test.ts
Normal file
72
tests/unit/background-lifecycle.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
55
tests/unit/gpu-fallback.test.ts
Normal file
55
tests/unit/gpu-fallback.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
32
tests/unit/host-api-dispatcher.test.ts
Normal file
32
tests/unit/host-api-dispatcher.test.ts
Normal 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' } });
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('可以,先强化鲸鱼的轮廓。');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
48
tests/unit/proxy-fetch.test.ts
Normal file
48
tests/unit/proxy-fetch.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
62
tests/unit/release-job.test.ts
Normal file
62
tests/unit/release-job.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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}”?` });
|
||||
|
||||
35
tests/unit/streaming-zip.test.ts
Normal file
35
tests/unit/streaming-zip.test.ts
Normal 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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user