Files
makelore/tests/e2e/main-navigation.spec.ts
T
鲨鱼辣椒 436f7ba1ea
Electron E2E / Electron E2E (macos-latest) (push) Waiting to run
Electron E2E / Electron E2E (ubuntu-latest) (push) Waiting to run
Electron E2E / Electron E2E (windows-latest) (push) Waiting to run
test: align configuration coverage with current project workflow
2026-10-14 12:57:07 +08:00

796 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
import { billingSummary } from '../fixtures/permanent-points';
import { createCodingProjectConfigV2 } from '../../electron/coding-projects/project-config';
async function seedInactiveCodingProjects(resources: { rootDir: string; userDataDir: string }) {
const now = '2026-09-06T08:00:00.000Z';
const older = '2026-09-04T08:00:00.000Z';
const projects = [
{
id: 'project-moonlight',
name: '月光小游戏',
path: join(resources.rootDir, '月光小游戏'),
createdAt: older,
updatedAt: now,
lastOpenedAt: now,
projectId: '11111111-1111-4111-8111-111111111111',
},
{
id: 'project-storybook',
name: '故事绘本',
path: join(resources.rootDir, '故事绘本'),
createdAt: older,
updatedAt: older,
lastOpenedAt: older,
projectId: '22222222-2222-4222-8222-222222222222',
},
];
for (const project of projects) {
await mkdir(join(project.path, '.makelore'), { recursive: true });
await mkdir(join(project.path, 'knowledge'), { recursive: true });
await writeFile(join(project.path, '.makelore', 'project.json'), JSON.stringify({
...createCodingProjectConfigV2(project.createdAt, 'custom', project.projectId),
createdAt: project.createdAt,
updatedAt: project.updatedAt,
}));
}
await writeFile(join(resources.userDataDir, 'makelore-projects.json'), JSON.stringify({
projects: {
projects: Object.fromEntries(projects.map(({ projectId: _projectId, ...project }) => [project.id, project])),
activeProjectId: null,
},
}));
}
test.describe('Makelore module navigation without setup flow', () => {
test('opens each home module with the sidebar expanded after a saved collapse', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await app.evaluate(({ ipcMain, BrowserWindow }, summary) => {
const window = BrowserWindow.getAllWindows()[0];
window.setSize(1440, 900);
const session = () => ({ accessToken: 'home-sidebar-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false });
const send = window.webContents.send.bind(window.webContents);
window.webContents.send = (channel: string, ...args: unknown[]) => send(channel, ...(channel === 'auth:session-changed' ? [session()] : args));
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => {
const path = request.path ?? '';
if (path === '/api/auth/session/sync') return result({ success: true, session: session() });
if (path === '/api/auth/me') return result({ success: true, user: { username: 'sidebar-child', userId: 'sidebar-user', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
if (['/api/provider-accounts', '/api/provider-accounts/key-info', '/api/provider-vendors', '/api/providers'].includes(path)) return result([]);
if (path === '/api/provider-accounts/default') return result({ accountId: null });
if (path === '/api/coding/projects') return result({ projects: [], activeProjectId: null });
if (path === '/api/settings') return result({ classroomSidebarWidth: 171, sidebarCollapsed: true, setupComplete: true });
if (path === '/api/user-sync/bootstrap') return result({ success: true, bootstrap: { preferences: {}, providerAccounts: [], partners: [], usageAggregates: [] } });
if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '小麦', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-14T00:00:00Z' } });
if (path === '/api/works/billing/me') return result({ success: true, data: summary });
if (path === '/api/works/image-workspace') return result({ success: true, data: {
capabilities: { conversation: true, generation: false, image: false, video: false, generationOptions: {
image: { available: false, supportedAspectRatios: [], supportedDurationSeconds: [], maxOutputCount: 1, requiresFirstFrame: false, supportedReferenceRoles: [] },
video: { available: false, supportedAspectRatios: [], supportedDurationSeconds: [], maxOutputCount: 1, requiresFirstFrame: true, supportedReferenceRoles: [] },
} }, workspaces: [],
} });
if (path.startsWith('/api/cloud-agents/')) {
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null });
}
return result({ success: true });
});
}, billingSummary());
await page.addInitScript(() => {
const now = Date.now();
localStorage.setItem('niancode-settings', JSON.stringify({ version: 0, state: { classroomSidebarWidth: 171, sidebarCollapsed: true } }));
localStorage.setItem('niancode-auth', JSON.stringify({ version: 4, state: {
authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'home-sidebar-fixture', tokenType: 'Bearer',
expiresAt: now + 3600000, lastActiveAt: now, canRefresh: false, legacyRefreshToken: null,
user: { username: 'sidebar-child', userId: 'sidebar-user', tenantId: null, deptId: null, authorities: [] },
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
} }));
localStorage.setItem('niancode-user-agent-profiles', JSON.stringify({ version: 1, state: {
profilesByUserId: { 'sidebar-user': { displayName: '小麦', age: null, gender: 'undisclosed', avatarUrl: null,
shareAgeWithAgents: false, shareGenderWithAgents: false, analysisEnabled: true,
pendingSync: false, version: 1, updatedAt: '2026-10-14T00:00:00Z' } },
} }));
});
await page.reload({ waitUntil: 'domcontentloaded' });
const savedCollapsed = () => page.evaluate(() => JSON.parse(localStorage.getItem('niancode-settings')!).state.sidebarCollapsed as boolean);
const checkExpanded = async () => {
await expect(page.getByTestId('sidebar')).toBeVisible();
await expect(page.getByTestId('sidebar')).toHaveAttribute('aria-hidden', 'false');
await expect.poll(async () => (await page.getByTestId('sidebar').boundingBox())?.width ?? 0).toBeGreaterThan(150);
await expect.poll(savedCollapsed).toBe(false);
};
const collapseAndReturnHome = async () => {
await page.getByTestId('titlebar-sidebar-toggle').click();
await page.mouse.click(1000, 500);
await expect.poll(savedCollapsed).toBe(true);
await expect(page.getByTestId('sidebar')).toHaveAttribute('aria-hidden', 'true');
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await expect.poll(savedCollapsed).toBe(true);
};
for (const [moduleId, route] of [['programming', /\/chat$/], ['cloud_agents', /\/cloud-agents\?view=mine$/]] as const) {
await expect.poll(savedCollapsed).toBe(true);
await page.getByTestId(`ai-module-option-${moduleId}`).click();
await expect(page).toHaveURL(route);
await checkExpanded();
await collapseAndReturnHome();
await page.getByTestId(`ai-module-option-${moduleId}`).click();
await expect(page).toHaveURL(route);
await checkExpanded();
await collapseAndReturnHome();
}
await page.getByTestId('ai-module-option-painting').click();
await expect(page).toHaveURL(/\/image-canvas$/);
await checkExpanded();
await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveCount(0);
} finally {
await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined);
await closeElectronApp(app);
}
});
test('keeps the home account wallet, profile, settings and logout available without creating a payment', async ({ launchElectronApp }, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await app.evaluate(({ ipcMain, BrowserWindow }, summary) => {
const window = BrowserWindow.getAllWindows()[0];
window.setSize(1440, 900);
const state = { loggedIn: true, requests: [] as { method: string; path: string }[] };
Object.assign(globalThis, { homeAccountFixture: state });
const session = () => state.loggedIn ? { accessToken: 'home-account-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } : null;
// Main pushes its session after each Renderer load. Own that event boundary
// together with sync/me so the isolated fixture cannot receive a guest push.
const send = window.webContents.send.bind(window.webContents);
window.webContents.send = (channel: string, ...args: unknown[]) => send(channel, ...(channel === 'auth:session-changed' ? [session()] : args));
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
const path = request.path ?? '';
const method = (request.method ?? 'GET').toUpperCase();
state.requests.push({ method, path });
if (path === '/api/auth/session/sync') return result({ success: true, session: session() });
if (path === '/api/auth/me') return result({ success: true, user: state.loggedIn ? { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] } : null, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
if (path === '/api/auth/logout' && method === 'POST') { state.loggedIn = false; return result({ success: true }); }
if (method === 'GET') {
if (['/api/provider-accounts', '/api/provider-accounts/key-info', '/api/provider-vendors', '/api/providers'].includes(path)) return result([]);
if (path === '/api/provider-accounts/default') return result({ accountId: null });
if (path === '/api/coding/projects') return result({ projects: [], activeProjectId: null });
if (path === '/api/settings') return result({ classroomSidebarWidth: 171, sidebarCollapsed: false, setupComplete: true });
if (path === '/api/user-sync/bootstrap') return result({ success: true, bootstrap: { preferences: {}, providerAccounts: [], partners: [], usageAggregates: [] } });
}
if (path === '/api/works/user/agent-profile' && method === 'GET') return result({ success: true, profile: { display_name: '小麦', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } });
if (path === '/api/works/billing/me' && method === 'GET') return result({ success: true, data: summary });
if (path.startsWith('/api/works/billing/recharge/orders?') && method === 'GET') return result({ success: true, data: { items: [] } });
if (path === '/api/works/image-workspace' && method === 'GET') return result({ success: true, data: {
capabilities: { conversation: true, generation: false, image: false, video: false, generationOptions: {
image: { available: false, supportedAspectRatios: [], supportedDurationSeconds: [], maxOutputCount: 1, requiresFirstFrame: false, supportedReferenceRoles: [] },
video: { available: false, supportedAspectRatios: [], supportedDurationSeconds: [], maxOutputCount: 1, requiresFirstFrame: true, supportedReferenceRoles: [] },
} }, workspaces: [],
} });
if (path.startsWith('/api/cloud-agents/') && method === 'GET') {
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null });
}
if (path.startsWith('/api/works/billing/') || path === '/api/works/user/agent-profile') throw new Error('Unexpected home account write ' + method + ' ' + path);
return result({ success: true });
});
}, billingSummary());
// The setup-skipping fixture starts as a guest. Hydrate only this isolated
// Renderer with a fake persisted session before the auth store is created.
await page.addInitScript(() => {
const now = Date.now();
localStorage.setItem('niancode-settings', JSON.stringify({ version: 0, state: { classroomSidebarWidth: 171, sidebarCollapsed: false } }));
localStorage.setItem('niancode-auth', JSON.stringify({ version: 4, state: {
authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app',
accessToken: 'home-account-fixture', tokenType: 'Bearer',
expiresAt: now + 3600000, lastActiveAt: now, canRefresh: false, legacyRefreshToken: null,
user: { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] },
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
} }));
// Setup-skipping mode also suppresses the home profile sync. Restore the
// isolated account's cached profile in the store's persisted shape.
localStorage.setItem('niancode-user-agent-profiles', JSON.stringify({ version: 1, state: {
profilesByUserId: { 'home-user': { displayName: '小麦', age: null, gender: 'undisclosed', avatarUrl: null,
shareAgeWithAgents: false, shareGenderWithAgents: false, analysisEnabled: true,
pendingSync: false, version: 1, updatedAt: '2026-10-13T00:00:00Z' } },
} }));
});
await page.reload({ waitUntil: 'domcontentloaded' });
const trigger = page.getByTestId('home-account-menu-trigger');
const menu = page.getByTestId('home-account-menu');
const surface = page.getByTestId('home-account-trigger-surface');
const checkAccountGeometry = async (width: number) => {
await expect.poll(async () => (await menu.boundingBox())!.width).toBeCloseTo(288, 1);
const visible = (await surface.boundingBox())!;
const target = (await trigger.boundingBox())!;
const avatar = (await surface.locator('span').first().boundingBox())!;
await expect.poll(async () => (await surface.locator('svg').boundingBox())!.width).toBeCloseTo(14, 1);
const chevron = (await surface.locator('svg').boundingBox())!;
const panel = (await menu.boundingBox())!;
expect(visible.width).toBeCloseTo(150, 1);
expect(visible.height).toBeCloseTo(42, 1);
expect(avatar.width).toBeCloseTo(28, 1);
expect(avatar.height).toBeCloseTo(28, 1);
await expect(surface).toHaveCSS('font-size', '12px');
expect(chevron.width).toBeCloseTo(14, 1);
expect(chevron.height).toBeCloseTo(14, 1);
expect(target.width).toBeCloseTo(150, 1);
expect(target.height).toBeCloseTo(42, 1);
expect(visible.x).toBeCloseTo(10, 1);
expect(await page.evaluate(() => innerHeight) - visible.y - visible.height).toBeCloseTo(12, 0);
expect(panel.x).toBeCloseTo(visible.x, 1);
expect(visible.y - panel.y - panel.height).toBeCloseTo(8, 1);
expect(panel.y).toBeGreaterThanOrEqual(0);
expect(panel.x + panel.width).toBeLessThanOrEqual(width);
};
const readTriggerAppearance = (accountTrigger: typeof trigger) => accountTrigger.evaluate(element => {
const face = element.firstElementChild!;
const bounds = element.getBoundingClientRect();
const avatar = face.firstElementChild!.getBoundingClientRect();
const arrow = face.querySelector('svg')!.getBoundingClientRect();
const style = getComputedStyle(face);
return { width: bounds.width, height: bounds.height, left: bounds.left, bottom: innerHeight - bounds.bottom,
avatarWidth: avatar.width, avatarHeight: avatar.height, arrowWidth: arrow.width, arrowHeight: arrow.height,
fontSize: style.fontSize, color: style.color, background: style.backgroundColor,
border: style.borderTopColor, radius: style.borderRadius, padding: style.paddingLeft, gap: style.gap };
});
const checkSettingsShell = async () => {
await expect(page.getByTestId('settings-page')).toBeVisible();
await expect(page.getByTestId('sidebar')).toHaveCount(0);
await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveCount(0);
await expect(page.getByTestId('coding-conversation-header')).toHaveCount(0);
await expect(page.getByTestId('settings-back-button')).toBeVisible();
};
await expect(trigger).toContainText('小麦');
const homeAppearance = await readTriggerAppearance(trigger);
await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3);
const footer = await trigger.evaluate(element => element.closest('footer')!.getBoundingClientRect().top);
const cards = await page.getByTestId('ai-module-options').boundingBox();
expect(footer).toBeGreaterThanOrEqual(cards!.y + cards!.height);
await trigger.click();
await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点');
await expect(menu.getByText('永久有效 · 预占 5 点', { exact: true })).toBeVisible();
await expect(menu.getByRole('menuitem', { name: '词元点数与充值', exact: true })).toBeVisible();
await checkAccountGeometry(1440);
await page.screenshot({ path: testInfo.outputPath('home-account-menu-desktop.png') });
await page.keyboard.press('Escape');
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(trigger).toBeFocused();
await trigger.click();
await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).click();
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await trigger.click();
await menu.getByRole('menuitem', { name: '词元点数与充值', exact: true }).click();
const wallet = page.getByTestId('point-wallet-dialog');
await expect(wallet).toBeVisible();
await expect(wallet.getByTestId('point-recharge-recipient')).toHaveText('充值到:小麦(child-wallet)');
await expect(wallet.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled();
await wallet.getByText('我的可用点数', { exact: true }).click();
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await expect(wallet).toBeVisible();
await wallet.getByRole('button', { name: '关闭词元点数', exact: true }).click();
await expect(wallet).toHaveCount(0);
await trigger.click();
await menu.getByRole('menuitem', { name: '个人资料', exact: true }).click();
const profile = page.getByRole('dialog', { name: '个人资料', exact: true });
await expect(profile.getByLabel(/名字/)).toHaveValue('小麦');
await profile.getByRole('button', { name: '关闭个人资料', exact: true }).click();
await trigger.click();
await menu.getByRole('menuitem', { name: '设置', exact: true }).click();
await expect(page).toHaveURL(/\/settings$/);
await checkSettingsShell();
expect(await readTriggerAppearance(trigger)).toEqual(homeAppearance);
await page.getByTestId('settings-back-button').click();
await expect(page).toHaveURL(/\/module-select$/);
await expect(trigger).toContainText('小麦');
let codeAppearance: typeof homeAppearance | undefined;
for (const [label, origin] of [['code', '/chat'], ['agents', '/cloud-agents?view=mine'], ['canvas', '/image-canvas']]) {
await page.evaluate(path => { window.location.hash = '#' + path; }, origin);
const sidebarTrigger = page.getByTestId('sidebar-member-menu-trigger');
await expect(sidebarTrigger).toBeVisible();
// Route changes may replace the visible sidebar during lazy workspace mounting.
await expect.poll(() => readTriggerAppearance(sidebarTrigger)).toEqual(homeAppearance);
const appearance = await readTriggerAppearance(sidebarTrigger);
if (label === 'code') { codeAppearance = appearance; expect(homeAppearance).toEqual(codeAppearance); }
else expect(appearance).toEqual(codeAppearance);
await sidebarTrigger.click();
await page.screenshot({ path: testInfo.outputPath(`account-menu-${label}-desktop.png`) });
await page.getByTestId('sidebar-account-menu').getByRole('menuitem', { name: '设置', exact: true }).click();
await expect(page).toHaveURL(/\/settings$/);
await checkSettingsShell();
expect(await readTriggerAppearance(trigger)).toEqual(codeAppearance);
await page.getByTestId('settings-back-button').click();
await expect.poll(() => page.evaluate(() => window.location.hash)).toBe('#' + origin);
await expect(sidebarTrigger).toBeVisible();
}
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page).toHaveURL(/\/module-select$/);
await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(360, 600); window.setSize(375, 800); });
await expect.poll(() => page.evaluate(() => innerWidth)).toBe(375);
await trigger.click();
await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点');
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await checkAccountGeometry(375);
await page.screenshot({ path: testInfo.outputPath('home-account-menu-375.png') });
await menu.getByRole('menuitem', { name: '设置', exact: true }).click();
await expect(page).toHaveURL(/\/settings$/);
await checkSettingsShell();
await trigger.click();
await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点');
await checkAccountGeometry(375);
await page.screenshot({ path: testInfo.outputPath('settings-account-menu-375.png') });
await page.keyboard.press('Escape');
await page.getByTestId('settings-back-button').click();
await expect(page).toHaveURL(/\/module-select$/);
await page.evaluate(() => { window.location.hash = '#/image-canvas'; });
await expect(page.getByTestId('standalone-account-dock')).toBeVisible();
await expect.poll(() => readTriggerAppearance(trigger)).toEqual(homeAppearance);
await trigger.click();
await checkAccountGeometry(375);
await page.screenshot({ path: testInfo.outputPath('canvas-account-menu-375.png') });
await menu.getByRole('menuitem', { name: '设置', exact: true }).click();
await checkSettingsShell();
await page.getByTestId('settings-back-button').click();
await expect(page).toHaveURL(/\/image-canvas$/);
await page.evaluate(() => { window.location.hash = '#/module-select'; });
await expect(trigger).toBeVisible();
await trigger.click();
await menu.getByRole('menuitem', { name: '退出登录', exact: true }).click();
await expect(page).toHaveURL(/\/login$/);
await expect(trigger).toHaveCount(0);
const requests = await app.evaluate(() => (globalThis as typeof globalThis & { homeAccountFixture: { requests: { method: string; path: string }[] } }).homeAccountFixture.requests);
const billingReads = requests.filter(request => request.path.startsWith('/api/works/billing/'));
expect(billingReads.length).toBeGreaterThan(0);
expect(billingReads.every(request => request.method === 'GET')).toBe(true);
expect(requests.filter(request => request.path === '/api/works/user/agent-profile').every(request => request.method === 'GET')).toBe(true);
expect(requests.filter(request => request.path === '/api/auth/logout' && request.method === 'POST')).toHaveLength(1);
} catch (error) {
const page = app.windows().at(-1);
if (page && !page.isClosed()) await testInfo.attach('account-failure-dom', { body: await page.locator('body').innerText(), contentType: 'text/plain' });
await testInfo.attach('account-fixture-requests', {
body: JSON.stringify(await app.evaluate(() => (globalThis as typeof globalThis & { homeAccountFixture?: unknown }).homeAccountFixture)),
contentType: 'application/json',
});
throw error;
} finally {
await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined);
await closeElectronApp(app);
}
});
test('opens remembered Code projects and keeps shared Code sidebar chrome in settings', async ({
electronTestResources,
launchElectronApp,
}) => {
await seedInactiveCodingProjects(electronTestResources);
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await page.getByTestId('ai-module-option-programming').click();
await expect(page).toHaveURL(/\/chat$/);
await expect(page.getByRole('button', { name: '新增项目' })).toBeVisible();
await expect(page.getByText('已有项目')).toHaveCount(0);
await expect(page.getByText('选择一个项目继续')).toHaveCount(0);
const projectCards = page.getByTestId('coding-existing-projects');
await expect(projectCards).toBeVisible();
await expect(projectCards.getByRole('button')).toHaveCount(2);
await expect(projectCards.getByRole('button').nth(0)).toContainText('月光小游戏');
await expect(projectCards.getByRole('button').nth(1)).toContainText('故事绘本');
await projectCards.getByRole('button', { name: '进入项目 故事绘本' }).click();
await expect(page.getByTestId('coding-chat-panel')).toBeVisible();
await expect(page.getByTestId('coding-existing-projects')).toHaveCount(0);
await expect(page.getByTestId('sidebar-course-project-project-storybook')).toHaveAttribute('aria-current', 'page');
const sidebar = page.getByTestId('sidebar');
const moduleReturn = page.getByTestId('sidebar-module-switcher-trigger');
await expect(moduleReturn).toBeVisible();
await expect(moduleReturn).toHaveText('');
await expect(moduleReturn).toHaveAttribute('aria-label', '返回首页');
await expect(moduleReturn).toHaveAttribute('title', '返回首页');
await expect(page.getByTestId('sidebar-module-return-icon')).toBeVisible();
await expect(sidebar.locator('.classroom-brand')).toHaveCount(0);
await expect(sidebar).not.toContainText('把脑洞变成作品');
const returnBox = await moduleReturn.boundingBox();
const collapseBox = await page.getByRole('button', { name: '折叠侧栏' }).boundingBox();
const sidebarBox = await sidebar.boundingBox();
expect(returnBox).not.toBeNull();
expect(collapseBox).not.toBeNull();
expect(returnBox!.y + returnBox!.height).toBeLessThanOrEqual(sidebarBox!.y);
const headerBox = await page.getByTestId('coding-conversation-header').boundingBox();
expect(Math.abs(returnBox!.y + returnBox!.height / 2 - headerBox!.y - headerBox!.height / 2)).toBeLessThan(1);
expect(returnBox!.width).toBe(32);
expect(Math.abs(returnBox!.y + returnBox!.height / 2 - collapseBox!.y - collapseBox!.height / 2)).toBeLessThan(1);
expect(returnBox!.x + returnBox!.width).toBeLessThanOrEqual(collapseBox!.x);
expect(sidebarBox!.x + sidebarBox!.width - collapseBox!.x - collapseBox!.width).toBeLessThanOrEqual(14);
await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveCount(1);
const community = sidebar.getByRole('button', { name: '作品广场', exact: true });
await expect(community).toHaveCount(1);
const communityBox = await community.boundingBox();
const createBox = await sidebar.getByTestId('sidebar-create-project').boundingBox();
const headingBox = await sidebar.locator('.classroom-projects-heading').boundingBox();
const accountBox = await sidebar.getByTestId('sidebar-member-menu-trigger').boundingBox();
expect(createBox!.y).toBe(sidebarBox!.y);
expect(headingBox!.y - sidebarBox!.y).toBe(56);
expect(communityBox!.x).toBe(createBox!.x);
expect(communityBox!.y).toBeGreaterThan(headingBox!.y + headingBox!.height);
expect(accountBox!.y - communityBox!.y - communityBox!.height).toBeCloseTo(0, 0);
expect(communityBox!.height).toBe(createBox!.height);
await expect(community).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
const buttonStyles = await page.evaluate(() => {
const styles = (selector: string) => {
const s = getComputedStyle(document.querySelector(selector)!);
return [s.borderRadius, s.borderWidth, s.borderColor, s.boxShadow, s.padding, s.fontSize, s.fontWeight];
};
return { community: styles('[data-testid=sidebar] .community-link'), create: styles('[data-testid=sidebar-create-project]') };
});
expect(buttonStyles.community).toEqual(buttonStyles.create);
expect(headingBox!.y).toBeGreaterThanOrEqual(createBox!.y + createBox!.height);
expect(headingBox!.y - createBox!.y - createBox!.height).toBeLessThanOrEqual(14);
await page.screenshot({ path: test.info().outputPath('chat-code-return-entry.png') });
// Exercise native state events without switching the test app into a macOS Space.
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].emit('enter-full-screen'));
await expect.poll(async () => (await moduleReturn.boundingBox())!.x).toBe(createBox!.x);
const fullscreenReturn = await moduleReturn.boundingBox();
expect(fullscreenReturn!.y).toBe(returnBox!.y);
expect(await page.getByRole('button', { name: '折叠侧栏' }).boundingBox()).toEqual(collapseBox);
expect(await sidebar.getByTestId('sidebar-create-project').boundingBox()).toEqual(createBox);
await page.screenshot({ path: test.info().outputPath('chat-fullscreen-return-aligned.png') });
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].emit('leave-full-screen'));
await expect.poll(async () => (await moduleReturn.boundingBox())!.x).toBe(returnBox!.x);
const sidebarResizer = sidebar.getByRole('separator', { name: '调整左侧功能栏宽度' });
await sidebarResizer.press('Home');
await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '128');
const narrowReturn = await moduleReturn.boundingBox();
const narrowToggle = await page.getByRole('button', { name: '折叠侧栏' }).boundingBox();
expect(narrowReturn!.x + narrowReturn!.width).toBeLessThanOrEqual(narrowToggle!.x);
await expect(moduleReturn).toBeVisible();
await expect(page.getByRole('button', { name: '折叠侧栏' })).toBeVisible();
const narrowHeader = await page.getByTestId('coding-conversation-header').boundingBox();
expect(narrowToggle!.x + narrowToggle!.width).toBeLessThanOrEqual(narrowHeader!.x);
expect(await moduleReturn.evaluate(el => {
const b = el.getBoundingClientRect();
return el.contains(document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2));
})).toBe(true);
await expect(community).toBeVisible();
expect(await community.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await page.screenshot({ path: test.info().outputPath('chat-narrow-navigation-row.png') });
await sidebarResizer.dblclick();
await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '171');
const readCodeChrome = () => page.evaluate(() => {
if (document.querySelector('[data-testid="sidebar"] .classroom-brand')) throw new Error('Removed Code brand reappeared');
const selectors = {
sidebar: '[data-testid="sidebar"]',
titlebar: '[data-testid="titlebar-sidebar-surface"]',
navigation: '[data-testid="classroom-titlebar-navigation"]',
home: '[data-testid="sidebar-module-switcher-trigger"]',
toggle: '[data-testid="titlebar-sidebar-toggle"]',
create: '[data-testid="sidebar-create-project"]',
heading: '[data-testid="sidebar"] .classroom-projects-heading',
community: '[data-testid="sidebar"] .community-link[data-variant="sidebar"]',
communityIcon: '[data-testid="sidebar"] .community-link-icon',
account: '[data-testid="sidebar-member-menu-trigger"]',
};
return Object.fromEntries(Object.entries(selectors).map(([name, selector]) => {
const element = document.querySelector(selector);
if (!element) throw new Error('Code chrome element missing: ' + name);
const bounds = element.getBoundingClientRect();
const style = getComputedStyle(element);
const svg = element.querySelector('svg');
return [name, {
x: Number(bounds.x.toFixed(1)), y: Number(bounds.y.toFixed(1)),
width: Number(bounds.width.toFixed(1)), height: Number(bounds.height.toFixed(1)),
// The titlebar surface is empty chrome; its inherited text color
// belongs to the route's content theme and paints no text or icons.
color: name === 'titlebar' ? null : style.color,
background: style.backgroundColor, border: style.borderTopColor,
radius: style.borderRadius, padding: style.padding,
iconWidth: svg ? Number(svg.getBoundingClientRect().width.toFixed(1)) : null,
iconStroke: svg?.getAttribute('stroke-width') ?? null,
}];
}));
});
// Create local metadata only; no prompt is sent and no model runs.
await sidebar.getByRole('button', { name: '新建对话', exact: true }).click();
const conversationActions = sidebar.getByRole('button', { name: '对话操作:新对话', exact: true }).first();
await expect(conversationActions).toBeAttached();
await page.mouse.move(1000, 500);
const chatChrome = await readCodeChrome();
await conversationActions.hover();
await conversationActions.click();
await expect(page.getByRole('menuitem', { name: '会话编程设置', exact: true })).toHaveCount(0);
await expect(page.getByRole('menuitem', { name: '删除对话', exact: true })).toBeVisible();
await page.keyboard.press('Escape');
await sidebar.getByRole('button', { name: '项目操作:故事绘本', exact: true }).click();
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
await expect(page).toHaveURL(/\/project-config$/);
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(moduleReturn).toBeVisible();
const projectBack = page.getByTestId('project-configuration-back-button');
await expect(projectBack).toBeVisible();
await page.mouse.move(1000, 500);
await expect.poll(readCodeChrome).toEqual(chatChrome);
await expect(sidebar).toHaveCSS('background-color', 'rgb(243, 247, 251)');
const defaultModel = page.getByRole('combobox', { name: '默认模型', exact: true });
await expect(defaultModel).toBeVisible();
const originalModel = await defaultModel.inputValue();
await expect(page.getByLabel('项目提示词', { exact: true })).toHaveCount(0);
await expect(page.getByRole('heading', { name: '项目 Skills', exact: true })).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('project-config-shared-code-sidebar.png') });
// Reload directly on the settings URL, without mounting /chat first.
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/project-config$/);
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect.poll(readCodeChrome).toEqual(chatChrome);
const sidebarToggle = page.getByTestId('titlebar-sidebar-toggle');
await sidebarToggle.click();
await page.getByTestId('project-configuration-page').click({ position: { x: 500, y: 20 } });
await expect(sidebar).toHaveAttribute('aria-hidden', 'true');
await expect(moduleReturn).toBeVisible();
await sidebarToggle.click();
await page.mouse.move(1000, 500);
await expect(sidebar).toHaveAttribute('aria-hidden', 'false');
await expect.poll(readCodeChrome).toEqual(chatChrome);
await expect(defaultModel).toHaveValue(originalModel);
await projectBack.click();
await expect(page).toHaveURL(/\/chat$/);
await expect(moduleReturn).toBeVisible();
await sidebar.getByRole('button', { name: '项目操作:故事绘本', exact: true }).click();
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
await page.getByTestId('resource-card-plugins').click();
await expect(page).toHaveURL(/\/project-config\/plugins\?/);
const plugins = page.getByTestId('project-plugins-sheet');
await expect(plugins).toBeVisible();
await expect(moduleReturn).toBeVisible();
await expect.poll(readCodeChrome).toEqual(chatChrome);
await page.screenshot({ path: test.info().outputPath('project-config-plugins-shared-code-sidebar.png') });
await plugins.getByRole('button', { name: '关闭', exact: true }).click();
await expect(page).toHaveURL(/\/project-config(?:\?|$)/);
await expect(plugins).toHaveCount(0);
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(projectBack).toBeVisible();
await sidebar.getByTestId('sidebar-course-project-project-storybook').click();
await expect(page).toHaveURL(/\/chat$/);
await sidebarToggle.click();
await page.getByTestId('coding-chat-panel').click({ position: { x: 500, y: 200 } });
await expect(sidebar).toHaveAttribute('aria-hidden', 'true');
const collapsedToggleBox = await sidebarToggle.boundingBox();
await expect(moduleReturn).toBeVisible();
await sidebarToggle.hover();
await expect(sidebar).toHaveAttribute('aria-hidden', 'false');
expect(await sidebarToggle.boundingBox()).toEqual(collapsedToggleBox);
await expect(moduleReturn).toBeVisible();
await page.screenshot({ path: test.info().outputPath('chat-collapsed-hover-code-return.png') });
await moduleReturn.click();
await expect(page).toHaveURL(/\/module-select$/);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('aligns the desktop home title with the middle of three stacked horizontal module cards', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
const moduleCards = page.locator('[data-testid^="ai-module-option-"]');
await expect(moduleCards.locator('.module-option-card-title')).toHaveText([
'Agents 智能体', 'Code 编程', 'Canvas 设计',
]);
await expect(page.getByTestId('ai-module-option-robot')).toHaveCount(0);
await expect(moduleCards.first().locator('.module-option-card-description')).toHaveText('打造你想象中的AI助手');
await expect(moduleCards.locator('img')).toHaveCount(3);
await expect.poll(async () => await moduleCards.locator('img').evaluateAll((images) =>
images.every((image) => image.complete && image.naturalWidth > 0),
)).toBe(true);
const readCardMetrics = async () => await moduleCards.evaluateAll((elements) => elements.map((element) => {
const bounds = element.getBoundingClientRect();
const image = element.querySelector('.module-option-card-image')?.getBoundingClientRect();
const content = element.querySelector('.module-option-card-content')?.getBoundingClientRect();
return {
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
imageWidth: image?.width ?? 0,
imageHeight: image?.height ?? 0,
imageLeft: image?.left ?? 0,
contentLeft: content?.left ?? 0,
};
}));
await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0);
await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0);
await app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.setMinimumSize(360, 600);
});
for (const width of [1440, 1024, 768, 375]) {
await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, windowWidth === 375 ? 800 : 900), width);
await expect.poll(() => page.evaluate(() => innerWidth)).toBe(width);
const cards = await readCardMetrics();
expect(cards).toHaveLength(3);
expect(Math.max(...cards.map(card => card.left)) - Math.min(...cards.map(card => card.left))).toBeLessThan(1);
expect(Math.max(...cards.map(card => card.width)) - Math.min(...cards.map(card => card.width))).toBeLessThan(1);
expect(Math.max(...cards.map(card => card.height)) - Math.min(...cards.map(card => card.height))).toBeLessThan(1);
const gaps = cards.slice(1).map((card, index) => card.top - cards[index]!.top - cards[index]!.height);
expect(Math.min(...gaps)).toBeGreaterThan(0);
expect(Math.abs(gaps[0]! - gaps[1]!)).toBeLessThan(1);
for (const card of cards) {
expect(card.left).toBeGreaterThanOrEqual(0);
expect(card.left + card.width).toBeLessThanOrEqual(width);
expect(card.imageWidth).toBeLessThan(card.width / 2);
expect(card.imageHeight).toBeGreaterThan(card.height * 0.75);
expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1);
}
const title = page.getByRole('heading', { name: '今天,想创作什么?', exact: true });
const heading = await title.boundingBox();
expect(heading).not.toBeNull();
const textWidth = await title.evaluate(element => {
const range = document.createRange(); range.selectNodeContents(element);
return range.getBoundingClientRect().width;
});
expect(Math.abs(heading!.width - textWidth)).toBeLessThan(1);
if (width >= 1024) {
const shift = await title.evaluate(element => {
const intro = element.parentElement!;
const original = intro.style.transform;
const shiftedLeft = element.getBoundingClientRect().left;
try {
intro.style.transform = 'none';
return shiftedLeft - element.getBoundingClientRect().left;
} finally { intro.style.transform = original; }
});
expect(shift).toBeCloseTo(1.5 * 96 / 2.54, 1);
expect(heading!.x + heading!.width).toBeLessThanOrEqual(cards[0]!.left);
expect(Math.abs(heading!.y + heading!.height / 2 - cards[1]!.top - cards[1]!.height / 2)).toBeLessThan(1);
expect(await moduleCards.evaluateAll(elements => elements.every(element => {
const bounds = element.getBoundingClientRect();
return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) =>
element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)),
);
}))).toBe(true);
} else {
expect(heading!.y + heading!.height).toBeLessThanOrEqual(cards[0]!.top);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements =>
elements.every(element => element.scrollWidth <= element.clientWidth),
)).toBe(true);
await page.screenshot({ path: test.info().outputPath(`module-entry-${width}.png`) });
}
await moduleCards.first().hover();
await moduleCards.first().focus();
await expect.poll(async () => await moduleCards.first().evaluate((element) => {
const surface = element.querySelector<HTMLElement>('.module-option-card-surface');
const image = element.querySelector<HTMLElement>('.module-option-card-image');
const imageMedia = element.querySelector<HTMLElement>('.module-option-card-image-media');
const content = element.querySelector<HTMLElement>('.module-option-card-content');
const title = element.querySelector<HTMLElement>('.module-option-card-title');
return Boolean(surface)
&& Boolean(image)
&& Boolean(imageMedia)
&& Boolean(content)
&& Boolean(title)
&& getComputedStyle(surface).transform === 'none'
&& getComputedStyle(image).transform === 'none'
&& getComputedStyle(imageMedia).transform === 'none'
&& getComputedStyle(content).transform === 'none'
&& getComputedStyle(title).transform === 'none';
})).toBe(true);
} finally {
await closeElectronApp(app);
}
});
test('omits Learning from the module chooser and redirects its retired route', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3);
await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0);
await page.evaluate(() => {
window.location.hash = '#/learning';
});
await expect(page).toHaveURL(/\/module-select$/);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('returns to the module chooser from the sidebar entry', 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([1024, 700]);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await expect(page).toHaveURL(/\/chat$/);
await expect(page.getByText('请先新建项目')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeVisible();
await expect(page.getByRole('img', { name: '麦洛 M 标识' })).toBeVisible();
await expect(page.getByRole('button', { name: '新增项目' })).toBeVisible();
await expect(page.getByTestId('coding-project-starter')).toHaveCount(0);
await expect(page.getByTestId('coding-existing-projects')).toHaveCount(0);
await expect(page.getByText('一念成光,万物可创。')).toHaveCount(0);
await expect(page.getByRole('img', { name: 'Makelore logo' })).toHaveCount(0);
await expect(page.getByTestId('sidebar').locator('.classroom-brand')).toHaveCount(0);
await expect(page.getByTestId('sidebar-module-switcher-trigger')).toHaveText('');
await expect(page.getByTestId('sidebar-module-return-icon')).toBeVisible();
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.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('coding-start-project-button').click();
const createProjectDialog = page.getByRole('dialog', { name: '新建项目' });
await expect(createProjectDialog).toBeVisible();
await createProjectDialog.getByRole('button', { name: '取消' }).first().click();
await expect(page.getByRole('dialog', { name: '新建项目' })).toHaveCount(0);
await expect(page.getByTestId('sidebar-module-switcher')).toBeVisible();
const moduleHomeTrigger = page.getByTestId('sidebar-module-switcher-trigger');
await expect(moduleHomeTrigger).toHaveAttribute('aria-label', '返回首页');
await expect(page.getByTestId('classroom-titlebar-navigation')).toBeVisible();
await expect(moduleHomeTrigger).toHaveCSS('width', '32px');
await expect(moduleHomeTrigger).toHaveCSS('height', '32px');
await expect(page.getByTestId('sidebar-module-switcher-menu')).toHaveCount(0);
await moduleHomeTrigger.click();
await expect(page).toHaveURL(/\/module-select$/);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
await expect(page.getByTestId('ai-module-option-robot')).toHaveCount(0);
await page.evaluate(() => { window.location.hash = '#/ai-hardware'; });
await expect(page).toHaveURL(/\/ai-hardware$/);
await expect(page.getByTestId('ai-hardware-page')).toBeVisible();
await expect(page.getByTestId('sidebar-robot-navigation')).toBeVisible();
await expect(page.getByTestId('sidebar-module-switcher-trigger')).toContainText('Robot 机器');
await page.getByTestId('sidebar-module-switcher-trigger').click();
await expect(page).toHaveURL(/\/module-select$/);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
});