Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ concurrency:

jobs:
check:
name: Lint & Typecheck
name: Lint, Typecheck & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -37,3 +37,31 @@ jobs:

- name: Unit Tests
run: pnpm test

e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Install Playwright browsers
run: pnpm exec playwright install chromium --with-deps

- name: Run e2e tests
run: pnpm exec playwright test

- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ CLAUDE.local.md
# testing
/coverage

# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

# next.js
/.next/
/out/
Expand Down
7 changes: 6 additions & 1 deletion components/stage/scene-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ export function SceneSidebar({
</div>

{/* Scenes List */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2 space-y-2 scrollbar-hide pt-1">
<div
data-testid="scene-list"
className="flex-1 overflow-y-auto overflow-x-hidden p-2 space-y-2 scrollbar-hide pt-1"
>
{scenes.map((scene, index) => {
const isActive = currentSceneId === scene.id;
const Icon = getSceneTypeIcon(scene.type);
Expand All @@ -147,6 +150,7 @@ export function SceneSidebar({
return (
<div
key={scene.id}
data-testid="scene-item"
onClick={() => {
if (onSceneSelect) {
onSceneSelect(scene.id);
Expand Down Expand Up @@ -175,6 +179,7 @@ export function SceneSidebar({
{index + 1}
</span>
<span
data-testid="scene-title"
className={cn(
'text-xs font-bold truncate transition-colors',
isActive
Expand Down
17 changes: 17 additions & 0 deletions e2e/fixtures/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { test as base } from '@playwright/test';
import { MockApi } from './mock-api';

type Fixtures = {
mockApi: MockApi;
};

export const test = base.extend<Fixtures>({
mockApi: async ({ page }, use) => {
const mockApi = new MockApi(page);
// Always mock server-providers — called on every page load by root layout
await mockApi.mockServerProviders();
await use(mockApi);
},
});

export { expect } from '@playwright/test';
75 changes: 75 additions & 0 deletions e2e/fixtures/mock-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Page } from '@playwright/test';
import { mockOutlines } from './test-data/scene-outlines';
import { mockSceneContentResponse } from './test-data/scene-content';
import { createMockSceneActionsResponse } from './test-data/scene-actions';

/**
* Wraps Playwright's page.route() to mock OpenMAIC API endpoints.
* Supports both JSON and SSE (text/event-stream) responses.
*/
export class MockApi {
constructor(private page: Page) {}

/** Mock the SSE outline streaming endpoint */
async mockSceneOutlinesStream(outlines = mockOutlines) {
await this.page.route('**/api/generate/scene-outlines-stream', (route) => {
const events = outlines
.map(
(outline, i) =>
`data: ${JSON.stringify({ type: 'outline', data: outline, index: i })}\n\n`,
)
.join('');
const done = `data: ${JSON.stringify({ type: 'done', outlines })}\n\n`;

route.fulfill({
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
body: events + done,
});
});
}

/** Mock the scene content generation endpoint */
async mockSceneContent(response = mockSceneContentResponse) {
await this.page.route('**/api/generate/scene-content', (route) => {
route.fulfill({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(response),
});
});
}

/** Mock the scene actions generation endpoint */
async mockSceneActions(stageId = 'test-stage') {
await this.page.route('**/api/generate/scene-actions', (route) => {
route.fulfill({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createMockSceneActionsResponse(stageId)),
});
});
}

/** Mock the server providers endpoint (returns empty — client-side config only) */
async mockServerProviders() {
await this.page.route('**/api/server-providers', (route) => {
route.fulfill({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ providers: {} }),
});
});
}

/** Set up API mocks for the generation flow. Note: server-providers is already mocked by the base fixture. */
async setupGenerationMocks(stageId = 'test-stage') {
await this.mockSceneOutlinesStream();
await this.mockSceneContent();
await this.mockSceneActions(stageId);
}
}
44 changes: 44 additions & 0 deletions e2e/fixtures/test-data/scene-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { defaultTheme } from './scene-content';

/** Mock response for POST /api/generate/scene-actions */
export function createMockSceneActionsResponse(stageId: string) {
return {
success: true,
scene: {
id: 'scene-0',
stageId,
type: 'slide',
title: '光合作用的基本概念',
order: 0,
content: {
type: 'slide',
canvas: {
id: 'slide-0',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: defaultTheme,
elements: [
{
type: 'text',
id: 'title-el',
content: '光合作用的基本概念',
left: 50,
top: 50,
width: 900,
height: 100,
},
],
},
},
actions: [
{
id: 'action-0',
type: 'speech',
agent: 'teacher',
text: '今天我们来学习光合作用的基本概念。',
},
],
},
previousSpeeches: [],
};
}
38 changes: 38 additions & 0 deletions e2e/fixtures/test-data/scene-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { SlideTheme } from '../../../lib/types/slides';
import { mockOutlines } from './scene-outlines';

/** Default theme matching lib/types/slides.ts:SlideTheme */
const defaultTheme: SlideTheme = {
backgroundColor: '#ffffff',
themeColors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'],
fontColor: '#333333',
fontName: 'Microsoft Yahei',
};

/** Mock response for POST /api/generate/scene-content */
export const mockSceneContentResponse = {
success: true,
content: {
type: 'slide',
canvas: {
id: 'slide-0',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: defaultTheme,
elements: [
{
type: 'text',
id: 'title-el',
content: '光合作用的基本概念',
left: 50,
top: 50,
width: 900,
height: 100,
},
],
},
},
effectiveOutline: mockOutlines[0],
};

export { defaultTheme };
29 changes: 29 additions & 0 deletions e2e/fixtures/test-data/scene-outlines.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { SceneOutline } from '../../../lib/types/generation';

/** Mock SceneOutline data matching lib/types/generation.ts:SceneOutline */
export const mockOutlines: SceneOutline[] = [
{
id: 'outline-0',
type: 'slide' as const,
title: '光合作用的基本概念',
description: '介绍光合作用的定义和基本反应方程式',
keyPoints: ['光合作用的定义', '反应方程式', '能量转换'],
order: 0,
},
{
id: 'outline-1',
type: 'slide' as const,
title: '光反应阶段',
description: '光反应中光能的吸收与水的分解',
keyPoints: ['光能吸收', '水的光解', 'ATP 与 NADPH 生成'],
order: 1,
},
{
id: 'outline-2',
type: 'slide' as const,
title: '暗反应阶段',
description: '暗反应中碳固定与糖类合成',
keyPoints: ['CO₂ 固定', 'C₃ 还原', '糖类合成'],
order: 2,
},
];
15 changes: 15 additions & 0 deletions e2e/fixtures/test-data/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** Default settings-storage value for e2e tests (Zustand persist v4 format) */
export function createSettingsStorage(overrides: Record<string, unknown> = {}) {
return JSON.stringify({
state: {
modelId: 'gpt-4o',
providerId: 'openai',
agentMode: 'preset',
selectedAgentIds: [],
ttsEnabled: false,
autoConfigApplied: true,
...overrides,
},
version: 2,
});
}
30 changes: 30 additions & 0 deletions e2e/pages/classroom.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Page, Locator } from '@playwright/test';

export class ClassroomPage {
readonly page: Page;
readonly loadingText: Locator;
readonly sidebarScenes: Locator;

constructor(page: Page) {
this.page = page;
this.loadingText = page.getByText('Loading classroom...');
this.sidebarScenes = page.locator('[data-testid="scene-item"]');
}

async goto(stageId: string) {
await this.page.goto(`/classroom/${stageId}`);
}

async waitForLoaded() {
await this.loadingText.waitFor({ state: 'hidden', timeout: 15_000 });
}

async clickScene(index: number) {
await this.sidebarScenes.nth(index).click();
}

/** Get scene title — it's the second span (first is the number badge) */
getSceneTitle(index: number) {
return this.sidebarScenes.nth(index).locator('[data-testid="scene-title"]');
}
}
21 changes: 21 additions & 0 deletions e2e/pages/generation-preview.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Page, Locator } from '@playwright/test';

export class GenerationPreviewPage {
readonly page: Page;
readonly stepTitle: Locator;
readonly backButton: Locator;

constructor(page: Page) {
this.page = page;
this.stepTitle = page.locator('h2');
this.backButton = page.getByRole('button', { name: /back|/i });
}

async goto() {
await this.page.goto('/generation-preview');
}

async waitForRedirectToClassroom() {
await this.page.waitForURL(/\/classroom\//, { timeout: 30_000 });
}
}
29 changes: 29 additions & 0 deletions e2e/pages/home.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Page, Locator } from '@playwright/test';

export class HomePage {
readonly page: Page;
readonly logo: Locator;
readonly textarea: Locator;
readonly enterButton: Locator;

constructor(page: Page) {
this.page = page;
this.logo = page.locator('img[alt="OpenMAIC"]');
this.textarea = page.locator('textarea');
this.enterButton = page
.getByRole('button', { name: /enter/i })
.or(page.locator('button:has-text("进入课堂")'));
}

async goto() {
await this.page.goto('/');
}

async fillRequirement(text: string) {
await this.textarea.fill(text);
}

async submit() {
await this.enterButton.click();
}
}
Loading
Loading