Makelore 2.0 initial clean snapshot
This commit is contained in:
37
tests/e2e/app-smoke.spec.ts
Normal file
37
tests/e2e/app-smoke.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { closeElectronApp, expect, test } from './fixtures/electron';
|
||||
|
||||
test.describe('NianCode Electron smoke flows', () => {
|
||||
test('shows the setup wizard on a fresh profile', async ({ page }) => {
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
await expect(page.getByTestId('setup-welcome-step')).toBeVisible();
|
||||
await expect(page.getByTestId('setup-skip-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can skip setup and continue to the login page', async ({ page }) => {
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
await page.getByTestId('setup-skip-button').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expect(page.getByTestId('main-layout')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('persists skipped setup across relaunch for the same isolated profile', async ({ electronApp, launchElectronApp }) => {
|
||||
const firstWindow = await electronApp.firstWindow();
|
||||
await firstWindow.waitForLoadState('domcontentloaded');
|
||||
await firstWindow.getByTestId('setup-skip-button').click();
|
||||
await expect(firstWindow.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
|
||||
await closeElectronApp(electronApp);
|
||||
|
||||
const relaunchedApp = await launchElectronApp();
|
||||
try {
|
||||
const relaunchedWindow = await relaunchedApp.firstWindow();
|
||||
await relaunchedWindow.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(relaunchedWindow.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
await expect(relaunchedWindow.getByTestId('setup-page')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(relaunchedApp);
|
||||
}
|
||||
});
|
||||
});
|
||||
94
tests/e2e/channels-account-id-validation.spec.ts
Normal file
94
tests/e2e/channels-account-id-validation.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const testConfigResponses = {
|
||||
channelsAccounts: {
|
||||
success: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [
|
||||
{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
agents: {
|
||||
success: true,
|
||||
agents: [],
|
||||
},
|
||||
credentialsValidate: {
|
||||
success: true,
|
||||
valid: true,
|
||||
warnings: [],
|
||||
},
|
||||
channelConfig: {
|
||||
success: true,
|
||||
},
|
||||
};
|
||||
|
||||
test.describe('Channels account ID validation', () => {
|
||||
test('rejects non-canonical custom account ID before save', async ({ electronApp, page }) => {
|
||||
await electronApp.evaluate(({ ipcMain }, responses) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__niancodeE2eChannelConfigSaveCount = 0;
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
|
||||
const method = request?.method ?? 'GET';
|
||||
const path = request?.path ?? '';
|
||||
|
||||
if (path === '/api/channels/accounts' && method === 'GET') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: responses.channelsAccounts } };
|
||||
}
|
||||
if (path === '/api/agents' && method === 'GET') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: responses.agents } };
|
||||
}
|
||||
if (path === '/api/channels/credentials/validate' && method === 'POST') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: responses.credentialsValidate } };
|
||||
}
|
||||
if (path === '/api/channels/config' && method === 'POST') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__niancodeE2eChannelConfigSaveCount += 1;
|
||||
return { ok: true, data: { status: 200, ok: true, json: responses.channelConfig } };
|
||||
}
|
||||
if (path.startsWith('/api/channels/config/') && method === 'GET') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: { success: true, values: {} } } };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` },
|
||||
};
|
||||
});
|
||||
}, testConfigResponses);
|
||||
|
||||
await completeSetup(page);
|
||||
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
await expect(page.getByTestId('channels-page')).toBeVisible();
|
||||
await expect(page.getByText('Feishu / Lark')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Add Account|account\.add/i }).click();
|
||||
await expect(page.getByText(/Configure Feishu \/ Lark|dialog\.configureTitle/)).toBeVisible();
|
||||
|
||||
await page.locator('#account-id').fill('测试账号');
|
||||
await page.locator('#appId').fill('cli_test');
|
||||
await page.locator('#appSecret').fill('secret_test');
|
||||
|
||||
await page.getByRole('button', { name: /Save & Connect|dialog\.saveAndConnect/ }).click();
|
||||
await expect(page.getByText(/account\.invalidCanonicalId|must use lowercase letters/i).first()).toBeVisible();
|
||||
|
||||
const saveCalls = await electronApp.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const count = Number((globalThis as any).__niancodeE2eChannelConfigSaveCount || 0);
|
||||
return { count };
|
||||
});
|
||||
expect(saveCalls.count).toBe(0);
|
||||
});
|
||||
});
|
||||
133
tests/e2e/channels-binding-regression.spec.ts
Normal file
133
tests/e2e/channels-binding-regression.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
test.describe('Channels binding regression', () => {
|
||||
test('keeps newly added non-default Feishu accounts unassigned until the user binds an agent', async ({ electronApp, page }) => {
|
||||
await electronApp.evaluate(({ ipcMain }) => {
|
||||
const state = {
|
||||
nextAccountId: 'feishu-a1b2c3d4',
|
||||
saveCount: 0,
|
||||
bindingCount: 0,
|
||||
channels: [
|
||||
{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [
|
||||
{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
agentId: 'main',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
agents: [
|
||||
{ id: 'main', name: 'Main Agent' },
|
||||
{ id: 'code', name: 'Code Agent' },
|
||||
],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__niancodeE2eBindingRegression = state;
|
||||
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
|
||||
const method = request?.method ?? 'GET';
|
||||
const path = request?.path ?? '';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const current = (globalThis as any).__niancodeE2eBindingRegression as typeof state;
|
||||
|
||||
if (path === '/api/channels/accounts' && method === 'GET') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: { success: true, channels: current.channels } } };
|
||||
}
|
||||
if (path === '/api/agents' && method === 'GET') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: { success: true, agents: current.agents } } };
|
||||
}
|
||||
if (path === '/api/channels/credentials/validate' && method === 'POST') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: { success: true, valid: true, warnings: [] } } };
|
||||
}
|
||||
if (path === '/api/channels/config' && method === 'POST') {
|
||||
current.saveCount += 1;
|
||||
const body = JSON.parse(request?.body ?? '{}') as { accountId?: string };
|
||||
const accountId = body.accountId || current.nextAccountId;
|
||||
const feishu = current.channels[0];
|
||||
if (!feishu.accounts.some((account) => account.accountId === accountId)) {
|
||||
feishu.accounts.push({
|
||||
accountId,
|
||||
name: accountId,
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: false,
|
||||
});
|
||||
}
|
||||
return { ok: true, data: { status: 200, ok: true, json: { success: true } } };
|
||||
}
|
||||
if (path === '/api/channels/binding' && method === 'PUT') {
|
||||
current.bindingCount += 1;
|
||||
const body = JSON.parse(request?.body ?? '{}') as { channelType?: string; accountId?: string; agentId?: string };
|
||||
if (body.channelType === 'feishu' && body.accountId) {
|
||||
const feishu = current.channels[0];
|
||||
const account = feishu.accounts.find((entry) => entry.accountId === body.accountId);
|
||||
if (account) {
|
||||
account.agentId = body.agentId;
|
||||
}
|
||||
}
|
||||
return { ok: true, data: { status: 200, ok: true, json: { success: true } } };
|
||||
}
|
||||
if (path === '/api/channels/binding' && method === 'DELETE') {
|
||||
current.bindingCount += 1;
|
||||
return { ok: true, data: { status: 200, ok: true, json: { success: true } } };
|
||||
}
|
||||
if (path.startsWith('/api/channels/config/') && method === 'GET') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: { success: true, values: {} } } };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` },
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
await completeSetup(page);
|
||||
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
await expect(page.getByTestId('channels-page')).toBeVisible();
|
||||
await expect(page.getByText('Feishu / Lark')).toBeVisible();
|
||||
|
||||
const feishuGroupHeader = page.locator('div.rounded-2xl').filter({ hasText: 'Feishu / Lark' }).first();
|
||||
await expect(feishuGroupHeader).toContainText(/Connected|已连接|接続済み|Подключён/);
|
||||
|
||||
await page.getByRole('button', { name: /Add Account|添加账号|アカウントを追加/ }).click();
|
||||
await expect(page.getByText(/Configure Feishu \/ Lark|dialog\.configureTitle/)).toBeVisible();
|
||||
|
||||
const accountIdInput = page.locator('#account-id');
|
||||
const newAccountId = await accountIdInput.inputValue();
|
||||
await expect(accountIdInput).toHaveValue(/feishu-/);
|
||||
await page.locator('#appId').fill('cli_test');
|
||||
await page.locator('#appSecret').fill('secret_test');
|
||||
|
||||
await page.getByRole('button', { name: /Save & Connect|dialog\.saveAndConnect/ }).click();
|
||||
await expect(page.getByText(/Configure Feishu \/ Lark|dialog\.configureTitle/)).toBeHidden();
|
||||
|
||||
const newAccountRow = page.locator('div.rounded-xl').filter({ hasText: newAccountId }).first();
|
||||
await expect(newAccountRow).toBeVisible();
|
||||
const bindingSelect = newAccountRow.locator('select');
|
||||
await expect(bindingSelect).toHaveValue('');
|
||||
|
||||
await bindingSelect.selectOption('code');
|
||||
await expect(bindingSelect).toHaveValue('code');
|
||||
|
||||
const counters = await electronApp.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const state = (globalThis as any).__niancodeE2eBindingRegression as { saveCount: number; bindingCount: number };
|
||||
return { saveCount: state.saveCount, bindingCount: state.bindingCount };
|
||||
});
|
||||
|
||||
expect(counters.saveCount).toBe(1);
|
||||
expect(counters.bindingCount).toBe(1);
|
||||
});
|
||||
});
|
||||
104
tests/e2e/language-russian.spec.ts
Normal file
104
tests/e2e/language-russian.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
async function openSettingsFromSidebarAccountMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId('sidebar-member-menu-trigger').click();
|
||||
await page.getByTestId('sidebar-nav-settings').click();
|
||||
}
|
||||
|
||||
async function expectSidebarSettingsLabel(page: Page, label: string): Promise<void> {
|
||||
await page.getByTestId('sidebar-member-menu-trigger').click();
|
||||
await expect(page.getByTestId('sidebar-nav-settings')).toContainText(label);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
test.describe('Russian language localization', () => {
|
||||
test('shows Russian language option in setup wizard', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp();
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
|
||||
// Should see the setup wizard
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
|
||||
// Should have Russian language button visible
|
||||
const russianButton = page.locator('button', { hasText: 'Русский' });
|
||||
await expect(russianButton).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('can switch to Russian language in setup wizard', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp();
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
|
||||
// Click Russian language button
|
||||
const russianButton = page.locator('button', { hasText: 'Русский' });
|
||||
await russianButton.click();
|
||||
|
||||
// Verify UI renders in Russian by checking for Russian-only text
|
||||
// "Добро пожаловать" is unique to Russian and won't appear in English
|
||||
await expect(page.locator('h2')).toContainText('Добро пожаловать');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('Russian language persists after skipping setup', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp();
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
|
||||
await expect(page.getByTestId('setup-page')).toBeVisible();
|
||||
|
||||
// Switch to Russian
|
||||
const russianButton = page.locator('button', { hasText: 'Русский' });
|
||||
await russianButton.click();
|
||||
|
||||
// Skip setup
|
||||
await page.getByTestId('setup-skip-button').click();
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
// Navigate to Settings to verify language persistence
|
||||
await openSettingsFromSidebarAccountMenu(page);
|
||||
await expect(page.getByTestId('settings-page')).toBeVisible();
|
||||
|
||||
// Verify sidebar shows Russian text (not English)
|
||||
// "Настройки" is Russian-only, English is "Settings"
|
||||
await expectSidebarSettingsLabel(page, 'Настройки');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('can switch to Russian in Settings page', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
// Navigate to Settings (in English by default after skipSetup)
|
||||
await openSettingsFromSidebarAccountMenu(page);
|
||||
await expect(page.getByTestId('settings-page')).toBeVisible();
|
||||
|
||||
// Click Russian language button
|
||||
const russianButton = page.locator('button', { hasText: 'Русский' });
|
||||
await russianButton.click();
|
||||
|
||||
// Verify sidebar switched to Russian
|
||||
// "Настройки" is Russian-only, English is "Settings"
|
||||
await expectSidebarSettingsLabel(page, 'Настройки');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
77
tests/e2e/main-navigation.spec.ts
Normal file
77
tests/e2e/main-navigation.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
test.describe('NianCode main navigation without setup flow', () => {
|
||||
test('navigates between core pages with setup bypassed', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
const minimumSize = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.getMinimumSize());
|
||||
|
||||
expect(minimumSize).toEqual([1100, 700]);
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await expect(page.getByText('请先新建项目')).toBeVisible();
|
||||
await expect(page.getByText('一念成光,万物可创。')).toBeVisible();
|
||||
await expect(page.getByRole('img', { name: 'Makelore logo' })).toBeVisible();
|
||||
await expect(page.getByText('Makelore 工作台', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/NITU/)).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-create-project')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar-nav-publish')).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-nav-project-config')).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-nav-agent-chat')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '添加已有项目' })).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-nav-character')).toHaveText('角色场景');
|
||||
await expect(page.getByText('成长成就在这里')).toHaveCount(0);
|
||||
await expect(page.getByText('已创建 Phaser 2D 小游戏起步工程')).toHaveCount(0);
|
||||
await expect(page.getByTestId('game-asset-browser')).toHaveCount(0);
|
||||
await expect(page.getByTestId('game-asset-review-summary')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId('resource-card-models').click();
|
||||
await expect(page.getByTestId('nitu-model-config-refresh-button')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByTestId('resource-card-skills').click();
|
||||
await expect(page.getByRole('heading', { name: '可用技能库' })).toBeVisible();
|
||||
await expect(page.getByTestId('skill-library-card-youth-plain-language')).toContainText('青少年通俗表达');
|
||||
await expect(page.getByTestId('skill-library-card-youth-plain-language')).not.toContainText('youth-plain-language');
|
||||
await expect(page.getByTestId('skill-library-card-pm-project-plan')).toContainText('通用软件项目规划');
|
||||
await expect(page.getByTestId('skill-library-card-pm-project-plan')).not.toContainText('pm-project-plan');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId('models-page')).toBeVisible();
|
||||
await expect(page.getByTestId('models-page-title')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('sidebar-module-switcher')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar-module-switcher-trigger')).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.getByTestId('sidebar-module-switcher-trigger').click();
|
||||
await expect(page.getByTestId('sidebar-module-programming')).toHaveAttribute('aria-current', 'page');
|
||||
await page.getByTestId('sidebar-module-painting').click();
|
||||
await expect(page.getByTestId('image-canvas-page')).toBeVisible();
|
||||
await expect(page.getByTestId('image-workspace-unavailable')).toBeVisible();
|
||||
await expect(page.getByText('创作空间暂不可用').first()).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar-image-workspace')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar-create-image-project')).toBeVisible();
|
||||
await expect(page.getByText('本地开发')).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-reset-local-image-workspace')).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-create-project')).toHaveCount(0);
|
||||
await expect(page.getByText('制作中心')).toHaveCount(0);
|
||||
await expect(page.getByText('任务模块')).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-module-painting')).toHaveAttribute('aria-current', 'page');
|
||||
await page.getByTestId('sidebar-module-switcher-trigger').click();
|
||||
await page.getByTestId('sidebar-module-programming').click();
|
||||
await expect(page.getByText('请先新建项目')).toBeVisible();
|
||||
await expect(page.getByTestId('chat-operation-page')).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-nav-asset-square')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId('sidebar-nav-agents').click();
|
||||
await expect(page.getByTestId('agents-page')).toBeVisible();
|
||||
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
await expect(page.getByTestId('channels-page')).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
132
tests/e2e/opencode-image-compression.spec.ts
Normal file
132
tests/e2e/opencode-image-compression.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
getStableWindow,
|
||||
test,
|
||||
} from './fixtures/electron';
|
||||
|
||||
test.describe('OpenCode image compression composer', () => {
|
||||
test('preprocesses a 4K image and lets the user switch variants', async ({
|
||||
launchElectronApp,
|
||||
}) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
await app.evaluate(async () => {
|
||||
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
||||
const activeProject = {
|
||||
id: 'prj_e2e_compression',
|
||||
path: 'D:/e2e/image-compression',
|
||||
name: 'image-compression',
|
||||
createdAt: '2026-07-18T00:00:00.000Z',
|
||||
updatedAt: '2026-07-18T00:00:00.000Z',
|
||||
lastOpenedAt: '2026-07-18T00:00:00.000Z',
|
||||
};
|
||||
const respond = (json: unknown, status = 200) => ({
|
||||
ok: true,
|
||||
data: {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json,
|
||||
},
|
||||
});
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (
|
||||
_event: unknown,
|
||||
request: { path?: string; method?: string },
|
||||
) => {
|
||||
const path = request.path ?? '';
|
||||
const method = request.method ?? 'GET';
|
||||
if (path === '/api/opencode/status') {
|
||||
return respond({
|
||||
state: 'running',
|
||||
port: 4096,
|
||||
url: 'http://127.0.0.1:4096',
|
||||
});
|
||||
}
|
||||
if (path.startsWith('/api/opencode/projects')) {
|
||||
return respond({ projects: [activeProject], activeProject });
|
||||
}
|
||||
if (path === '/api/opencode/config-summary') {
|
||||
return respond({
|
||||
model: 'niancode-user-models/qwen3.7-plus',
|
||||
smallModel: null,
|
||||
providerIds: ['niancode-user-models'],
|
||||
providerCount: 1,
|
||||
});
|
||||
}
|
||||
if (path === '/api/opencode/sessions') {
|
||||
return respond({
|
||||
sessions: [{
|
||||
id: 'ses_e2e_compression',
|
||||
title: 'Image compression',
|
||||
updatedAt: '2026-07-18T00:00:00.000Z',
|
||||
}],
|
||||
});
|
||||
}
|
||||
if (path === '/api/opencode/sessions/status') {
|
||||
return respond({
|
||||
statuses: { ses_e2e_compression: { type: 'idle' } },
|
||||
});
|
||||
}
|
||||
if (
|
||||
path === '/api/opencode/sessions/ses_e2e_compression/messages'
|
||||
&& method === 'GET'
|
||||
) {
|
||||
return respond({ messages: [] });
|
||||
}
|
||||
if (path.endsWith('/todos')) return respond({ todos: [] });
|
||||
if (path === '/api/provider-accounts') return respond([]);
|
||||
if (path === '/api/provider-accounts/key-info') return respond([]);
|
||||
if (path === '/api/provider-vendors') return respond([]);
|
||||
if (path === '/api/provider-accounts/default') {
|
||||
return respond({ accountId: null });
|
||||
}
|
||||
return respond({});
|
||||
});
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('sidebar-nav-agent-chat')).toBeVisible();
|
||||
await page.getByTestId('sidebar-nav-agent-chat').click();
|
||||
await expect(page.getByTestId('opencode-message-composer')).toBeVisible();
|
||||
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
channels: 4,
|
||||
background: { r: 64, g: 210, b: 200, alpha: 1 },
|
||||
},
|
||||
}).png().toBuffer();
|
||||
await page.getByTestId('opencode-file-attachment-input').setInputFiles({
|
||||
name: 'four-k.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: png,
|
||||
});
|
||||
|
||||
const card = page.getByTestId('opencode-composer-attachment');
|
||||
await expect(card).toContainText('3840×2160');
|
||||
await expect(card).toContainText('1930×1086');
|
||||
await expect(page.getByTestId('opencode-attachment-use-compressed'))
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByTestId('opencode-attachment-token-estimate'))
|
||||
.toContainText('约2049 Token');
|
||||
|
||||
await page.getByTestId('opencode-attachment-use-original').click();
|
||||
await expect(page.getByTestId('opencode-attachment-use-original'))
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByTestId('opencode-attachment-token-estimate'))
|
||||
.toContainText('约8102 Token');
|
||||
|
||||
await page.getByTestId('opencode-attachment-use-compressed').click();
|
||||
await expect(page.getByTestId('opencode-attachment-use-compressed'))
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('button', { name: '移除附件 four-k.png' }).click();
|
||||
await expect(card).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
332
tests/e2e/opencode-slash-commands.spec.ts
Normal file
332
tests/e2e/opencode-slash-commands.spec.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import type { ElectronApplication } from 'playwright-core';
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
interface CapturedSlashRequest {
|
||||
path: string;
|
||||
method: string;
|
||||
body?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function readCapturedRequests(
|
||||
electronApp: ElectronApplication,
|
||||
): Promise<CapturedSlashRequest[]> {
|
||||
const requests = await electronApp.evaluate(() => {
|
||||
type MainCapturedSlashRequest = {
|
||||
path: string;
|
||||
method: string;
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
type MainState = {
|
||||
captured: MainCapturedSlashRequest[];
|
||||
commandFailure: string | null;
|
||||
};
|
||||
const mainGlobal = globalThis as typeof globalThis & {
|
||||
__niancodeSlashE2EState?: MainState;
|
||||
};
|
||||
return structuredClone(
|
||||
mainGlobal.__niancodeSlashE2EState?.captured ?? [],
|
||||
);
|
||||
});
|
||||
return requests.filter((request) => request.method !== 'GET');
|
||||
}
|
||||
|
||||
async function setSlashCommandFailure(
|
||||
electronApp: ElectronApplication,
|
||||
message: string | null,
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate((value) => {
|
||||
type MainCapturedSlashRequest = {
|
||||
path: string;
|
||||
method: string;
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
type MainState = {
|
||||
captured: MainCapturedSlashRequest[];
|
||||
commandFailure: string | null;
|
||||
};
|
||||
const mainGlobal = globalThis as typeof globalThis & {
|
||||
__niancodeSlashE2EState?: MainState;
|
||||
};
|
||||
if (!mainGlobal.__niancodeSlashE2EState) {
|
||||
throw new Error('Slash E2E Main state is unavailable');
|
||||
}
|
||||
mainGlobal.__niancodeSlashE2EState.commandFailure = value;
|
||||
}, message);
|
||||
}
|
||||
|
||||
async function installSlashCommandHost(
|
||||
electronApp: ElectronApplication,
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(async () => {
|
||||
const { ipcMain } = process.mainModule!.require(
|
||||
'electron',
|
||||
) as typeof import('electron');
|
||||
type MainCapturedSlashRequest = {
|
||||
path: string;
|
||||
method: string;
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
type MainState = {
|
||||
captured: MainCapturedSlashRequest[];
|
||||
commandFailure: string | null;
|
||||
};
|
||||
const mainGlobal = globalThis as typeof globalThis & {
|
||||
__niancodeSlashE2EState?: MainState;
|
||||
};
|
||||
const state: MainState = {
|
||||
captured: [],
|
||||
commandFailure: null,
|
||||
};
|
||||
mainGlobal.__niancodeSlashE2EState = state;
|
||||
const project = {
|
||||
id: 'prj_slash_e2e',
|
||||
path: 'D:/e2e/slash',
|
||||
name: 'slash',
|
||||
createdAt: '2026-07-18T00:00:00.000Z',
|
||||
updatedAt: '2026-07-18T00:00:00.000Z',
|
||||
lastOpenedAt: '2026-07-18T00:00:00.000Z',
|
||||
};
|
||||
const session = {
|
||||
id: 'ses_slash_e2e',
|
||||
title: 'Slash E2E',
|
||||
agent: 'game-development',
|
||||
};
|
||||
const status = {
|
||||
state: 'running',
|
||||
port: 4096,
|
||||
url: 'http://127.0.0.1:4096',
|
||||
};
|
||||
const respond = (json: unknown, responseStatus = 200) => ({
|
||||
ok: true,
|
||||
data: {
|
||||
status: responseStatus,
|
||||
ok: responseStatus >= 200 && responseStatus < 300,
|
||||
json,
|
||||
},
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (
|
||||
_event,
|
||||
request: {
|
||||
path?: string;
|
||||
method?: string;
|
||||
body?: string | null;
|
||||
},
|
||||
) => {
|
||||
const path = request.path ?? '';
|
||||
const method = request.method ?? 'GET';
|
||||
const body = request.body
|
||||
? JSON.parse(request.body) as Record<string, unknown>
|
||||
: undefined;
|
||||
state.captured.push({
|
||||
path,
|
||||
method,
|
||||
...(body ? { body } : {}),
|
||||
});
|
||||
|
||||
if (path === '/api/opencode/status') return respond(status);
|
||||
if (path === '/api/opencode/health') {
|
||||
return respond({ ok: true, status });
|
||||
}
|
||||
if (
|
||||
path === '/api/opencode/projects'
|
||||
|| path.startsWith('/api/opencode/projects?')
|
||||
) {
|
||||
return respond({
|
||||
projects: [project],
|
||||
activeProject: project,
|
||||
});
|
||||
}
|
||||
if (
|
||||
path === '/api/opencode/projects/active'
|
||||
&& method === 'GET'
|
||||
) {
|
||||
return respond({
|
||||
projects: [project],
|
||||
activeProject: project,
|
||||
});
|
||||
}
|
||||
if (path.startsWith('/api/opencode/projects/config?')) {
|
||||
return respond({
|
||||
status: 'missing',
|
||||
knowledgeFiles: [],
|
||||
});
|
||||
}
|
||||
if (path.startsWith('/api/opencode/projects/template?')) {
|
||||
return respond({ status: 'missing' });
|
||||
}
|
||||
if (path === '/api/opencode/config-summary') {
|
||||
return respond({
|
||||
model: 'niancode-user-models/qwen3.7-plus',
|
||||
smallModel: null,
|
||||
providerIds: ['niancode-user-models'],
|
||||
enabledProviderIds: ['niancode-user-models'],
|
||||
providerCount: 1,
|
||||
});
|
||||
}
|
||||
if (path === '/api/provider-accounts') {
|
||||
return respond([]);
|
||||
}
|
||||
if (path === '/api/provider-accounts/key-info') {
|
||||
return respond([]);
|
||||
}
|
||||
if (path === '/api/provider-vendors') {
|
||||
return respond([]);
|
||||
}
|
||||
if (path === '/api/provider-accounts/default') {
|
||||
return respond({ accountId: null });
|
||||
}
|
||||
if (path === '/api/opencode/sessions') {
|
||||
return respond({ sessions: [session] });
|
||||
}
|
||||
if (path === '/api/opencode/sessions/status') {
|
||||
return respond({
|
||||
statuses: {
|
||||
ses_slash_e2e: { type: 'idle' },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
path
|
||||
=== '/api/opencode/sessions/ses_slash_e2e/messages'
|
||||
&& method === 'GET'
|
||||
) {
|
||||
return respond({ messages: [] });
|
||||
}
|
||||
if (
|
||||
path
|
||||
=== '/api/opencode/sessions/ses_slash_e2e/todos'
|
||||
) {
|
||||
return respond({ todos: [] });
|
||||
}
|
||||
if (
|
||||
path
|
||||
=== '/api/opencode/sessions/ses_slash_e2e/diff'
|
||||
) {
|
||||
return respond({ diffs: [] });
|
||||
}
|
||||
if (path === '/api/opencode/questions') {
|
||||
return respond({ questions: [] });
|
||||
}
|
||||
if (path === '/api/opencode/permissions') {
|
||||
return respond({ permissions: [] });
|
||||
}
|
||||
if (path === '/api/opencode/files/status') {
|
||||
return respond({ files: [] });
|
||||
}
|
||||
if (path === '/api/opencode/commands') {
|
||||
return respond({
|
||||
commands: [{
|
||||
name: 'Review',
|
||||
hints: ['$ARGUMENTS'],
|
||||
}],
|
||||
shareEnabled: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
path
|
||||
=== '/api/opencode/sessions/ses_slash_e2e/summarize'
|
||||
&& method === 'POST'
|
||||
) {
|
||||
return respond({ success: true }, 202);
|
||||
}
|
||||
if (
|
||||
path
|
||||
=== '/api/opencode/sessions/ses_slash_e2e/command'
|
||||
&& method === 'POST'
|
||||
) {
|
||||
return state.commandFailure
|
||||
? respond(
|
||||
{
|
||||
success: false,
|
||||
error: state.commandFailure,
|
||||
},
|
||||
500,
|
||||
)
|
||||
: respond({ success: true }, 202);
|
||||
}
|
||||
throw new Error(
|
||||
`Unexpected hostapi request: ${method} ${path}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('OpenCode slash commands', () => {
|
||||
test.afterEach(async ({ electronApp }) => {
|
||||
await electronApp.evaluate(async () => {
|
||||
const mainGlobal = globalThis as typeof globalThis & {
|
||||
__niancodeSlashE2EState?: unknown;
|
||||
};
|
||||
delete mainGlobal.__niancodeSlashE2EState;
|
||||
});
|
||||
});
|
||||
|
||||
test('selects on first Enter, executes on second Enter, blocks unknown commands, and preserves failures', async ({
|
||||
electronApp,
|
||||
page,
|
||||
}) => {
|
||||
await completeSetup(page);
|
||||
await installSlashCommandHost(electronApp);
|
||||
await page.reload();
|
||||
await page.getByTestId('sidebar-module-programming').click();
|
||||
await expect(page).toHaveURL(/\/opencode-chat$/);
|
||||
const composer = page.getByRole('textbox');
|
||||
await expect(composer).toBeVisible();
|
||||
|
||||
await composer.fill('/comp');
|
||||
await composer.press('Enter');
|
||||
await expect(composer).toHaveValue('/compact');
|
||||
await expect(
|
||||
page.getByText('再次按 Enter 执行'),
|
||||
).toBeVisible();
|
||||
expect(await readCapturedRequests(electronApp)).toEqual([]);
|
||||
|
||||
await composer.press('Enter');
|
||||
await expect(composer).toHaveValue('');
|
||||
await expect.poll(async () => (
|
||||
await readCapturedRequests(electronApp)
|
||||
).some((request) => (
|
||||
request.path.endsWith('/summarize')
|
||||
))).toBe(true);
|
||||
|
||||
await composer.fill('/unknown do-not-send');
|
||||
await composer.press('Enter');
|
||||
await expect(
|
||||
page.getByText('未知命令 /unknown'),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByTestId('opencode-message-composer')
|
||||
.evaluate((form: HTMLFormElement) => form.requestSubmit());
|
||||
expect((
|
||||
await readCapturedRequests(electronApp)
|
||||
).some((request) => (
|
||||
request.path.endsWith('/messages')
|
||||
&& request.method === 'POST'
|
||||
))).toBe(false);
|
||||
await expect(composer).toHaveValue(
|
||||
'/unknown do-not-send',
|
||||
);
|
||||
|
||||
await setSlashCommandFailure(electronApp, 'review failed');
|
||||
await composer.fill('/Review staged changes ');
|
||||
await composer.press('Enter');
|
||||
await expect(page.getByText('review failed')).toBeVisible();
|
||||
await expect(composer).toHaveValue(
|
||||
'/Review staged changes ',
|
||||
);
|
||||
|
||||
await setSlashCommandFailure(electronApp, null);
|
||||
await composer.press('Enter');
|
||||
await expect(composer).toHaveValue('');
|
||||
const command = (await readCapturedRequests(electronApp))
|
||||
.findLast((request) => request.path.endsWith('/command'));
|
||||
expect(command?.body).toMatchObject({
|
||||
command: 'Review',
|
||||
arguments: ' staged changes ',
|
||||
agent: 'game-development',
|
||||
model: 'niancode-user-models/qwen3.7-plus',
|
||||
});
|
||||
});
|
||||
});
|
||||
37
tests/e2e/project-superpowers-toggle.spec.ts
Normal file
37
tests/e2e/project-superpowers-toggle.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
test.describe('Project-level Superpowers setting', () => {
|
||||
test('starts game projects with Superpowers disabled', async ({ launchElectronApp }) => {
|
||||
const parentPath = await mkdtemp(path.join(tmpdir(), 'niancode-superpowers-e2e-'));
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
await app.evaluate(({ ipcMain }, selectedPath) => {
|
||||
ipcMain.removeHandler('dialog:open');
|
||||
ipcMain.handle('dialog:open', async () => ({ canceled: false, filePaths: [selectedPath] }));
|
||||
}, parentPath);
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await page.getByTestId('sidebar-create-project').click();
|
||||
await expect(page.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked();
|
||||
await page.getByRole('button', { name: '选择路径' }).click();
|
||||
await expect(page.getByLabel('项目路径')).toHaveValue(parentPath);
|
||||
await page.getByTestId('project-template-option-game-development').click();
|
||||
await page.getByRole('button', { name: '确认创建' }).click();
|
||||
|
||||
await expect(page.getByTestId('sidebar-nav-project-config')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar-nav-publish')).toHaveCount(0);
|
||||
await page.getByTestId('resource-card-skills').click();
|
||||
await expect(page.getByRole('switch', { name: '启用 Superpowers' })).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(page.getByText('已关闭')).toBeVisible();
|
||||
await page.getByRole('button', { name: '关闭' }).click();
|
||||
await expect(page.getByTestId('resource-card-publish')).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await rm(parentPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
243
tests/e2e/provider-lifecycle.spec.ts
Normal file
243
tests/e2e/provider-lifecycle.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const TEST_PROVIDER_ID = 'moonshot-e2e';
|
||||
const TEST_PROVIDER_LABEL = 'Moonshot E2E';
|
||||
|
||||
async function seedTestProvider(page: Parameters<typeof completeSetup>[0]): Promise<void> {
|
||||
await page.evaluate(async ({ providerId, providerLabel }) => {
|
||||
const now = new Date().toISOString();
|
||||
await window.electron.ipcRenderer.invoke('provider:save', {
|
||||
id: providerId,
|
||||
name: providerLabel,
|
||||
type: 'moonshot',
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
model: 'kimi-k2.6',
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}, { providerId: TEST_PROVIDER_ID, providerLabel: TEST_PROVIDER_LABEL });
|
||||
}
|
||||
|
||||
test.describe('NianCode provider lifecycle', () => {
|
||||
test('shows a saved provider and removes it cleanly after deletion', async ({ page }) => {
|
||||
await completeSetup(page);
|
||||
await seedTestProvider(page);
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId('providers-settings')).toBeVisible();
|
||||
await expect(page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toContainText(TEST_PROVIDER_LABEL);
|
||||
|
||||
await page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`).hover();
|
||||
await page.getByTestId(`provider-delete-${TEST_PROVIDER_ID}`).click();
|
||||
|
||||
await expect(page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toHaveCount(0);
|
||||
await expect(page.getByText(TEST_PROVIDER_LABEL)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('does not redisplay a deleted provider after relaunch', async ({ electronApp, launchElectronApp, page }) => {
|
||||
await completeSetup(page);
|
||||
await seedTestProvider(page);
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toContainText(TEST_PROVIDER_LABEL);
|
||||
|
||||
await page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`).hover();
|
||||
await page.getByTestId(`provider-delete-${TEST_PROVIDER_ID}`).click();
|
||||
await expect(page.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toHaveCount(0);
|
||||
|
||||
await electronApp.close();
|
||||
|
||||
const relaunchedApp = await launchElectronApp();
|
||||
try {
|
||||
const relaunchedPage = await relaunchedApp.firstWindow();
|
||||
await relaunchedPage.waitForLoadState('domcontentloaded');
|
||||
await expect(relaunchedPage.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
await relaunchedPage.getByTestId('sidebar-nav-models').click();
|
||||
await expect(relaunchedPage.getByTestId('providers-settings')).toBeVisible();
|
||||
await expect(relaunchedPage.getByTestId(`provider-card-${TEST_PROVIDER_ID}`)).toHaveCount(0);
|
||||
await expect(relaunchedPage.getByText(TEST_PROVIDER_LABEL)).toHaveCount(0);
|
||||
} finally {
|
||||
await relaunchedApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('trims whitespace before validating and saving a custom provider key', async ({ electronApp, page }) => {
|
||||
await completeSetup(page);
|
||||
|
||||
await electronApp.evaluate(async ({ app: _app }) => {
|
||||
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
||||
|
||||
let accounts: Array<Record<string, unknown>> = [];
|
||||
let keyInfo: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = [];
|
||||
let statuses: Array<Record<string, unknown>> = [];
|
||||
let defaultAccountId: string | null = null;
|
||||
|
||||
const respond = (json: unknown, status = 200) => ({
|
||||
ok: true,
|
||||
data: {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json,
|
||||
},
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string; body?: string | null }) => {
|
||||
const path = request?.path ?? '';
|
||||
const method = request?.method ?? 'GET';
|
||||
const body = request?.body ? JSON.parse(request.body) : null;
|
||||
|
||||
// New account-based endpoints (preferred path).
|
||||
if (path === '/api/provider-accounts' && method === 'GET') return respond(accounts);
|
||||
if (path === '/api/provider-accounts/key-info' && method === 'GET') return respond(keyInfo);
|
||||
if (path === '/api/provider-vendors' && method === 'GET') return respond([]);
|
||||
if (path === '/api/provider-accounts/default' && method === 'GET') return respond({ accountId: defaultAccountId });
|
||||
|
||||
if (path === '/api/provider-accounts/validate' && method === 'POST') {
|
||||
if (body?.apiKey !== 'sk-lm-test') {
|
||||
return respond({ valid: false, error: `unexpected key: ${String(body?.apiKey)}` }, 400);
|
||||
}
|
||||
return respond({ valid: true });
|
||||
}
|
||||
|
||||
if (path === '/api/provider-accounts' && method === 'POST') {
|
||||
accounts = [body.account];
|
||||
keyInfo = [{
|
||||
accountId: body.account.id,
|
||||
hasKey: Boolean(body.apiKey),
|
||||
keyMasked: body.apiKey ? 'sk-***' : null,
|
||||
}];
|
||||
// Keep statuses populated for any consumer still on the legacy path.
|
||||
statuses = [{
|
||||
id: body.account.id,
|
||||
name: body.account.label,
|
||||
type: body.account.vendorId,
|
||||
baseUrl: body.account.baseUrl,
|
||||
model: body.account.model,
|
||||
enabled: body.account.enabled,
|
||||
createdAt: body.account.createdAt,
|
||||
updatedAt: body.account.updatedAt,
|
||||
hasKey: Boolean(body.apiKey),
|
||||
keyMasked: body.apiKey ? 'sk-***' : null,
|
||||
}];
|
||||
return respond({ success: true });
|
||||
}
|
||||
|
||||
if (path === '/api/provider-accounts/default' && method === 'PUT') {
|
||||
defaultAccountId = body?.accountId ?? null;
|
||||
return respond({ success: true });
|
||||
}
|
||||
|
||||
// ── Legacy compatibility shims ─────────────────────────────
|
||||
// Older renderer builds still reach for these. Keeping them
|
||||
// wired up here exercises the backward-compat path in the
|
||||
// route layer (it returns the same data, just without the
|
||||
// newer key-info payload structure).
|
||||
if (path === '/api/providers' && method === 'GET') return respond(statuses);
|
||||
if (path === '/api/providers/validate' && method === 'POST') {
|
||||
if (body?.apiKey !== 'sk-lm-test') {
|
||||
return respond({ valid: false, error: `unexpected key: ${String(body?.apiKey)}` }, 400);
|
||||
}
|
||||
return respond({ valid: true });
|
||||
}
|
||||
|
||||
return respond({});
|
||||
});
|
||||
});
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId('providers-settings')).toBeVisible();
|
||||
|
||||
await page.getByTestId('providers-add-button').click();
|
||||
await expect(page.getByTestId('add-provider-dialog')).toBeVisible();
|
||||
|
||||
await page.getByTestId('add-provider-type-custom').click();
|
||||
await page.getByTestId('add-provider-name-input').fill('LM Studio Local');
|
||||
await page.getByTestId('add-provider-api-key-input').fill(' sk-lm-test \n');
|
||||
await page.getByTestId('add-provider-base-url-input').fill('http://127.0.0.1:1234/v1');
|
||||
await page.getByTestId('add-provider-model-id-input').fill('local-model');
|
||||
await page.getByTestId('add-provider-submit-button').click();
|
||||
|
||||
await expect(page.getByTestId('provider-card-custom')).toContainText('LM Studio Local');
|
||||
});
|
||||
|
||||
test('edit form validates the new API key inline before saving (single button)', async ({ electronApp, page }) => {
|
||||
await completeSetup(page);
|
||||
|
||||
await electronApp.evaluate(async ({ app: _app }) => {
|
||||
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
||||
|
||||
const provider = {
|
||||
id: 'moonshot-edit',
|
||||
vendorId: 'moonshot',
|
||||
label: 'Moonshot Edit',
|
||||
authMode: 'api_key',
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
model: 'kimi-k2.6',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
let storedKey = 'sk-existing';
|
||||
let keyInfo = [{ accountId: provider.id, hasKey: true, keyMasked: 'sk-***' }];
|
||||
|
||||
const respond = (json: unknown, status = 200) => ({
|
||||
ok: true,
|
||||
data: {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json,
|
||||
},
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string; body?: string | null }) => {
|
||||
const path = request?.path ?? '';
|
||||
const method = request?.method ?? 'GET';
|
||||
const body = request?.body ? JSON.parse(request.body) : null;
|
||||
|
||||
if (path === '/api/provider-accounts' && method === 'GET') return respond([provider]);
|
||||
if (path === '/api/provider-accounts/key-info' && method === 'GET') return respond(keyInfo);
|
||||
if (path === '/api/provider-vendors' && method === 'GET') return respond([]);
|
||||
if (path === '/api/provider-accounts/default' && method === 'GET') return respond({ accountId: provider.id });
|
||||
|
||||
if (path === '/api/provider-accounts/validate' && method === 'POST') {
|
||||
if (body?.apiKey === 'sk-good') {
|
||||
return respond({ valid: true });
|
||||
}
|
||||
return respond({ valid: false, error: 'Invalid API key' }, 400);
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/provider-accounts/') && method === 'PUT') {
|
||||
if (body?.apiKey) storedKey = body.apiKey;
|
||||
keyInfo = [{ accountId: provider.id, hasKey: Boolean(storedKey), keyMasked: 'sk-***' }];
|
||||
return respond({ success: true });
|
||||
}
|
||||
|
||||
if (path === '/api/providers' && method === 'GET') return respond([provider]);
|
||||
|
||||
return respond({});
|
||||
});
|
||||
});
|
||||
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId('providers-settings')).toBeVisible();
|
||||
await expect(page.getByTestId('provider-card-moonshot-edit')).toBeVisible();
|
||||
|
||||
await page.getByTestId('provider-card-moonshot-edit').hover();
|
||||
await page.getByTestId('provider-edit-moonshot-edit').click();
|
||||
|
||||
await page.getByTestId('provider-edit-key-input-moonshot-edit').fill('sk-bad');
|
||||
await page.getByTestId('provider-edit-save-moonshot-edit').click();
|
||||
await expect(page.getByTestId('provider-edit-validation-error-moonshot-edit')).toContainText('Invalid API key');
|
||||
|
||||
await page.getByTestId('provider-edit-key-input-moonshot-edit').fill('sk-good');
|
||||
await expect(page.getByTestId('provider-edit-validation-error-moonshot-edit')).toHaveCount(0);
|
||||
await page.getByTestId('provider-edit-save-moonshot-edit').click();
|
||||
|
||||
await expect(page.getByTestId('provider-edit-save-moonshot-edit')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
44
tests/e2e/scrollbar-visibility.spec.ts
Normal file
44
tests/e2e/scrollbar-visibility.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
test.describe('hover-only scrollbar visibility', () => {
|
||||
test('hides scrollbars until a scroll container is hovered', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await page.getByTestId('sidebar-nav-models').click();
|
||||
await expect(page.getByTestId('models-page')).toBeVisible();
|
||||
|
||||
const scrollContainer = page.locator('[data-testid="models-page"] .overflow-y-auto').first();
|
||||
await expect(scrollContainer).toBeVisible();
|
||||
|
||||
const beforeHover = await scrollContainer.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const thumbStyle = window.getComputedStyle(element, '::-webkit-scrollbar-thumb');
|
||||
return {
|
||||
scrollbarWidth: style.scrollbarWidth,
|
||||
thumbBackground: thumbStyle.backgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
await expect(scrollContainer).toHaveCSS('scrollbar-width', 'thin');
|
||||
expect(beforeHover.thumbBackground).toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
await scrollContainer.hover();
|
||||
|
||||
const afterHover = await scrollContainer.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const thumbStyle = window.getComputedStyle(element, '::-webkit-scrollbar-thumb');
|
||||
return {
|
||||
scrollbarWidth: style.scrollbarWidth,
|
||||
thumbBackground: thumbStyle.backgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
expect(afterHover.scrollbarWidth).toBe('thin');
|
||||
expect(afterHover.thumbBackground).not.toBe('rgba(0, 0, 0, 0)');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
54
tests/e2e/settings-proxy.spec.ts
Normal file
54
tests/e2e/settings-proxy.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
async function ensureSwitchState(toggle: Locator, checked: boolean): Promise<void> {
|
||||
const currentState = await toggle.getAttribute('data-state');
|
||||
const isChecked = currentState === 'checked';
|
||||
if (isChecked !== checked) {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function readProxyEnabled(page: Page): Promise<boolean> {
|
||||
return await page.evaluate(async () => {
|
||||
const settings = await window.electron.ipcRenderer.invoke('settings:getAll');
|
||||
return Boolean(settings?.proxyEnabled);
|
||||
});
|
||||
}
|
||||
|
||||
async function openSettingsFromSidebarAccountMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId('sidebar-member-menu-trigger').click();
|
||||
await page.getByTestId('sidebar-nav-settings').click();
|
||||
}
|
||||
|
||||
test.describe('NianCode developer proxy settings', () => {
|
||||
test('keeps proxy save available when disabling proxy in developer mode', async ({ page }) => {
|
||||
await completeSetup(page);
|
||||
|
||||
await openSettingsFromSidebarAccountMenu(page);
|
||||
await expect(page.getByTestId('settings-page')).toBeVisible();
|
||||
|
||||
const devModeToggle = page.getByTestId('settings-dev-mode-switch');
|
||||
await expect(devModeToggle).toBeVisible();
|
||||
await ensureSwitchState(devModeToggle, true);
|
||||
|
||||
const proxySection = page.getByTestId('settings-proxy-section');
|
||||
const proxyToggle = page.getByTestId('settings-proxy-toggle');
|
||||
const proxySaveButton = page.getByTestId('settings-proxy-save-button');
|
||||
|
||||
await expect(proxySection).toBeVisible();
|
||||
await expect(proxyToggle).toBeVisible();
|
||||
await expect(proxySaveButton).toBeVisible();
|
||||
|
||||
await ensureSwitchState(proxyToggle, true);
|
||||
await expect(proxySaveButton).toBeEnabled();
|
||||
await proxySaveButton.click();
|
||||
await expect.poll(async () => await readProxyEnabled(page)).toBe(true);
|
||||
|
||||
await ensureSwitchState(proxyToggle, false);
|
||||
await expect(proxySaveButton).toBeVisible();
|
||||
await expect(proxySaveButton).toBeEnabled();
|
||||
await proxySaveButton.click();
|
||||
await expect.poll(async () => await readProxyEnabled(page)).toBe(false);
|
||||
});
|
||||
});
|
||||
22
tests/e2e/sidebar-update.spec.ts
Normal file
22
tests/e2e/sidebar-update.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
test.describe('NianCode sidebar updates', () => {
|
||||
test('shows an available update beside the lower-left account control', async ({ electronApp, page }) => {
|
||||
await completeSetup(page);
|
||||
|
||||
const account = page.getByTestId('sidebar-member-menu-trigger');
|
||||
const update = page.getByTestId('sidebar-update-button');
|
||||
await expect(update).toBeHidden();
|
||||
|
||||
await electronApp.evaluate(({ BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0]?.webContents.send('update:status-changed', {
|
||||
status: 'available',
|
||||
info: { version: '9.9.9' },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(update).toBeVisible();
|
||||
await expect(update).toHaveAttribute('aria-label', '下载新版本 9.9.9');
|
||||
await expect(account.locator('xpath=..').getByTestId('sidebar-update-button')).toBeVisible();
|
||||
});
|
||||
});
|
||||
46
tests/e2e/zoom-shortcuts.spec.ts
Normal file
46
tests/e2e/zoom-shortcuts.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ElectronApplication } from '@playwright/test';
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
async function getZoomLevel(app: ElectronApplication): Promise<number> {
|
||||
return await app.evaluate(({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
return win?.webContents.getZoomLevel() ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function sendZoomShortcut(app: ElectronApplication, action: 'in' | 'out'): Promise<void> {
|
||||
await app.evaluate(({ BrowserWindow }, zoomAction) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
const contents = win?.webContents;
|
||||
if (!contents) return;
|
||||
|
||||
const input = zoomAction === 'out'
|
||||
? { key: '-', code: 'Minus', control: true, meta: false, alt: false }
|
||||
: { key: '=', code: 'Equal', control: true, meta: false, alt: false };
|
||||
|
||||
contents.emit('before-input-event', { preventDefault() {} }, input);
|
||||
}, action);
|
||||
}
|
||||
|
||||
test.describe('NianCode window zoom shortcuts', () => {
|
||||
test('can zoom back in after zooming out with keyboard shortcuts', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
await app.evaluate(({ BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0]?.webContents.setZoomLevel(0);
|
||||
});
|
||||
|
||||
await sendZoomShortcut(app, 'out');
|
||||
await expect.poll(async () => await getZoomLevel(app)).toBe(-1);
|
||||
|
||||
await sendZoomShortcut(app, 'in');
|
||||
await expect.poll(async () => await getZoomLevel(app)).toBe(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user