Files
makelore/tests/e2e/main-navigation.spec.ts

401 lines
22 KiB
TypeScript

import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
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({
schemaVersion: 2,
projectType: 'custom',
projectId: project.projectId,
initialized: true,
agents: [],
knowledgeDirectory: 'knowledge',
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 remembered Code projects and keeps the module return entry in chat instead of project 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();
const brandBox = await sidebar.locator('.classroom-brand').boundingBox();
const returnBox = await moduleReturn.boundingBox();
const collapseBox = await page.getByRole('button', { name: '折叠侧栏' }).boundingBox();
const sidebarBox = await sidebar.boundingBox();
expect(brandBox).not.toBeNull();
expect(returnBox).not.toBeNull();
expect(collapseBox).not.toBeNull();
expect(returnBox!.y + returnBox!.height).toBeLessThanOrEqual(brandBox!.y);
const headerBox = await page.getByTestId('coding-conversation-header').boundingBox();
const tabsBox = await page.getByRole('tablist', { name: '当前工作' }).boundingBox();
expect(Math.abs(returnBox!.y + returnBox!.height / 2 - headerBox!.y - headerBox!.height / 2)).toBeLessThan(1);
expect(Math.abs(brandBox!.y + brandBox!.height / 2 - tabsBox!.y - tabsBox!.height / 2)).toBeLessThan(1);
expect(brandBox!.y - sidebarBox!.y).toBe(0);
expect(brandBox!.height).toBe(tabsBox!.height);
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();
expect(communityBox!.y).toBeGreaterThanOrEqual(brandBox!.y + brandBox!.height);
expect(communityBox!.y - brandBox!.y - brandBox!.height).toBe(10);
expect(createBox!.y - communityBox!.y - communityBox!.height).toBe(10);
expect(createBox!.y - brandBox!.y - brandBox!.height).toBe(61);
expect(communityBox!.height).toBe(createBox!.height);
await expect(community).toHaveCSS('background-color', 'rgb(248, 250, 245)');
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.locator('.classroom-brand').boundingBox()).toEqual(brandBox);
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');
await sidebar.getByRole('button', { name: '项目设置', exact: true }).click();
await expect(page).toHaveURL(/\/project-config$/);
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(sidebar.getByTestId('sidebar-module-switcher')).toHaveCount(0);
const projectBack = page.getByTestId('project-configuration-back-button');
await expect(projectBack).toBeVisible();
await page.screenshot({ path: test.info().outputPath('project-config-without-module-return.png') });
await projectBack.click();
await expect(page).toHaveURL(/\/chat$/);
await expect(moduleReturn).toBeVisible();
await sidebar.getByRole('button', { 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(sidebar.getByTestId('sidebar-module-switcher')).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('project-config-plugins-without-module-return.png') });
await plugins.getByRole('button', { name: '关闭', exact: true }).click();
await expect(page).toHaveURL(/\/project-config$/);
await expect(projectBack).toBeVisible();
await sidebar.getByTestId('sidebar-course-project-project-storybook').click();
await expect(page).toHaveURL(/\/chat$/);
const sidebarToggle = page.getByTestId('titlebar-sidebar-toggle');
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('keeps the four module entries in a proportional vertical stack while resizing the window', 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 设计', 'Robot 机器',
]);
await expect(moduleCards.first().locator('.module-option-card-description')).toHaveText('打造你想象中的AI助手');
await expect(moduleCards.locator('img')).toHaveCount(4);
await expect.poll(async () => await moduleCards.locator('img').evaluateAll((images) =>
images.every((image) => image.complete && image.naturalWidth > 0),
)).toBe(true);
await page.screenshot({ path: test.info().outputPath('module-entry-artwork.png') });
const readCardMetrics = async () => await moduleCards.evaluateAll((elements) => elements.map((element) => {
const bounds = element.getBoundingClientRect();
const image = element.querySelector('.module-option-card-image')?.getBoundingClientRect();
return {
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
imageWidth: image?.width ?? 0,
imageHeight: image?.height ?? 0,
};
}));
const initialMetrics = await readCardMetrics();
expect(initialMetrics).toHaveLength(4);
await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0);
await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0);
await expect.poll(async () => await moduleCards.first().evaluate((element) => {
const surface = element.querySelector<HTMLElement>('.module-option-card-surface');
const frame = element.querySelector<HTMLElement>('.module-option-card-frame');
if (!surface || !frame) return false;
const surfaceStyle = getComputedStyle(surface);
const frameStyle = getComputedStyle(frame);
const surfaceInsets = [surfaceStyle.top, surfaceStyle.right, surfaceStyle.bottom, surfaceStyle.left]
.map((value) => Number.parseFloat(value));
return frameStyle.display === 'none'
&& surfaceInsets.every((value) => value === 0)
&& surfaceStyle.transform === 'none';
})).toBe(true);
const initialImageStyles = await moduleCards.first().evaluate((element) => {
const image = element.querySelector<HTMLElement>('.module-option-card-image');
const cardHeight = element.getBoundingClientRect().height;
if (!image) return null;
const style = getComputedStyle(image);
return {
height: Number.parseFloat(style.height),
cardHeight,
marginTop: Number.parseFloat(style.marginTop),
marginBottom: Number.parseFloat(style.marginBottom),
marginLeft: Number.parseFloat(style.marginLeft),
};
});
expect(initialImageStyles).not.toBeNull();
expect(initialImageStyles!.height).toBeLessThan(initialImageStyles!.cardHeight);
expect(initialImageStyles!.marginTop).toBe(1);
expect(initialImageStyles!.marginBottom).toBe(1);
expect(initialImageStyles!.marginTop).toBe(initialImageStyles!.marginBottom);
expect(initialImageStyles!.marginLeft).toBeGreaterThan(0);
expect(initialImageStyles!.marginLeft).toBe(initialImageStyles!.marginTop);
expect(Math.max(...initialMetrics.map((card) => card.left)) - Math.min(...initialMetrics.map((card) => card.left))).toBeLessThan(1);
expect(initialMetrics.map((card) => card.top)).toEqual(
[...initialMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top),
);
const initialWidth = initialMetrics[0]?.width ?? 0;
expect(initialMetrics[0]?.imageWidth ?? 0).toBeGreaterThan(initialWidth * 0.24);
expect(initialMetrics[0]?.imageWidth ?? 0).toBeLessThan(initialWidth / 2);
expect(initialMetrics[0]?.imageHeight ?? 0).toBeGreaterThan((initialMetrics[0]?.height ?? 0) * 0.75);
await app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.setSize(1100, 700);
});
await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1);
const resizedMetrics = await readCardMetrics();
expect(resizedMetrics).toHaveLength(4);
expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1);
expect(resizedMetrics.map((card) => card.top)).toEqual(
[...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top),
);
expect(resizedMetrics[0]!.imageWidth).toBeGreaterThan(resizedMetrics[0]!.width * 0.24);
expect(resizedMetrics[0]!.imageWidth).toBeLessThan(resizedMetrics[0]!.width / 2);
expect(resizedMetrics[0]!.imageHeight).toBeGreaterThan(resizedMetrics[0]!.height * 0.75);
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(4);
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')).toBeVisible();
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 page.getByTestId('ai-module-option-robot').click();
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);
}
});
});