Skip to content

Commit 6ca9152

Browse files
committed
add vitest + playwright e2e infrastructure
- vitest at root, auto-discovers all tests - playwright-chromium for e2e browser tests - e2e utils: startGame(), getText(), getStore() - ci: replace pr-check.yml with ci.yml (build + test + e2e) - parser: upgrade vitest 0.28→4.0, fix relative paths
1 parent 65df686 commit 6ca9152

13 files changed

Lines changed: 966 additions & 379 deletions

File tree

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, dev]
6+
pull_request:
7+
types: [opened, synchronize]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version-file: package.json
18+
cache: yarn
19+
20+
- run: npm install yarn -g && yarn install
21+
22+
- name: Build
23+
run: yarn build
24+
25+
- name: Test
26+
run: yarn test
27+
28+
test-e2e:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- uses: actions/checkout@v4
32+
33+
- uses: actions/setup-node@v4
34+
with:
35+
node-version-file: package.json
36+
cache: yarn
37+
38+
- run: npm install yarn -g && yarn install
39+
40+
- name: Build parser
41+
run: yarn parser:build
42+
43+
- name: Install Playwright
44+
run: npx playwright install --with-deps chromium
45+
46+
- name: E2E
47+
run: yarn test:e2e

.github/workflows/pr-check.yml

Lines changed: 0 additions & 36 deletions
This file was deleted.

package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"parser:test": "cd packages/parser && yarn test",
1414
"parser:test-coverage": "cd packages/parser && yarn coverage",
1515
"parser:build": "cd packages/parser && yarn build",
16-
"parser:build-ci": "cd packages/parser && yarn build-ci"
16+
"parser:build-ci": "cd packages/parser && yarn build-ci",
17+
"test": "vitest run",
18+
"test:e2e": "vitest run -c vitest.config.e2e.ts"
1719
},
1820
"license": "MPL-2.0",
1921
"workspaces": {
@@ -25,5 +27,10 @@
2527
"engines": {
2628
"node": ">=18"
2729
},
28-
"packageManager": "yarn@1.22.22"
30+
"packageManager": "yarn@1.22.22",
31+
"devDependencies": {
32+
"@vitest/coverage-v8": "^4.0.18",
33+
"playwright-chromium": "^1.58.2",
34+
"vitest": "^4.0.18"
35+
}
2936
}

packages/parser/package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,13 @@
3030
"@rollup/plugin-typescript": "^9.0.2",
3131
"@types/lodash": "^4.14.189",
3232
"@types/node": "^18.14.0",
33-
"@vitest/coverage-c8": "^0.28.5",
3433
"rollup": "^3.29.5",
3534
"rollup-plugin-babel": "^4.4.0",
3635
"rollup-plugin-terser": "^7.0.2",
3736
"rollup-plugin-typescript2": "^0.34.1",
3837
"ts-node": "^10.9.1",
3938
"tslib": "^2.4.1",
40-
"typescript": "^4.9.3",
41-
"vitest": "^0.28.5"
39+
"typescript": "^4.9.3"
4240
},
4341
"type": "module"
4442
}

packages/parser/test/parser.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@ import { ADD_NEXT_ARG_LIST, SCRIPT_CONFIG } from "../src/config/scriptConfig";
33
import { expect, test } from "vitest";
44
import { commandType, ISentence } from "../src/interface/sceneInterface";
55
import * as fsp from 'fs/promises';
6+
import * as path from 'path';
7+
import { fileURLToPath } from 'url';
68
import { fileType } from "../src/interface/assets";
79

10+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
11+
const res = (file: string) => path.resolve(__dirname, 'test-resources', file);
12+
813
test("label", async () => {
914

10-
const sceneRaw = await fsp.readFile('test/test-resources/start.txt');
15+
const sceneRaw = await fsp.readFile(res('start.txt'));
1116
const sceneText = sceneRaw.toString();
1217

1318
const parser = new SceneParser((assetList) => {
@@ -32,7 +37,7 @@ test("label", async () => {
3237

3338
test("args", async () => {
3439

35-
const sceneRaw = await fsp.readFile('test/test-resources/start.txt');
40+
const sceneRaw = await fsp.readFile(res('start.txt'));
3641
const sceneText = sceneRaw.toString();
3742

3843
const parser = new SceneParser((assetList) => {
@@ -58,7 +63,7 @@ test("args", async () => {
5863

5964
test("choose", async () => {
6065

61-
const sceneRaw = await fsp.readFile('test/test-resources/choose.txt');
66+
const sceneRaw = await fsp.readFile(res('choose.txt'));
6267
const sceneText = sceneRaw.toString();
6368

6469
const parser = new SceneParser((assetList) => {
@@ -81,7 +86,7 @@ test("choose", async () => {
8186

8287
test("long-script", async () => {
8388

84-
const sceneRaw = await fsp.readFile('test/test-resources/long-script.txt');
89+
const sceneRaw = await fsp.readFile(res('long-script.txt'));
8590
const sceneText = sceneRaw.toString();
8691

8792
const parser = new SceneParser((assetList) => {
@@ -109,7 +114,7 @@ test("long-script", async () => {
109114

110115
test("var", async () => {
111116

112-
const sceneRaw = await fsp.readFile('test/test-resources/var.txt');
117+
const sceneRaw = await fsp.readFile(res('var.txt'));
113118
const sceneText = sceneRaw.toString();
114119

115120
const parser = new SceneParser((assetList) => {

test/e2e/globalSetup.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { TestProject } from 'vitest/node'
2+
import type { BrowserServer } from 'playwright-chromium'
3+
import { chromium } from 'playwright-chromium'
4+
5+
let browserServer: BrowserServer | undefined
6+
7+
export async function setup({ provide }: TestProject): Promise<void> {
8+
browserServer = await chromium.launchServer({
9+
headless: !process.env.WEBGAL_DEBUG,
10+
args: process.env.CI
11+
? ['--no-sandbox', '--disable-setuid-sandbox']
12+
: undefined,
13+
})
14+
provide('wsEndpoint', browserServer.wsEndpoint())
15+
}
16+
17+
export async function teardown(): Promise<void> {
18+
await browserServer?.close()
19+
}
20+
21+
declare module 'vitest' {
22+
export interface ProvidedContext {
23+
wsEndpoint: string
24+
}
25+
}

test/e2e/setup.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Browser, Page } from 'playwright-chromium'
2+
import { chromium } from 'playwright-chromium'
3+
import { beforeAll, inject } from 'vitest'
4+
import type { ChildProcess } from 'node:child_process'
5+
import { spawn } from 'node:child_process'
6+
import path from 'node:path'
7+
8+
export let browser: Browser
9+
export let page: Page
10+
11+
let devServer: ChildProcess | undefined
12+
let devServerUrl: string
13+
14+
async function startDevServer(): Promise<string> {
15+
const root = path.resolve(__dirname, '../../packages/webgal')
16+
17+
return new Promise((resolve, reject) => {
18+
devServer = spawn('npx', ['vite', '--host', '--port', '3099'], {
19+
cwd: root,
20+
stdio: ['ignore', 'pipe', 'pipe'],
21+
env: { ...process.env, NODE_ENV: 'development' },
22+
})
23+
24+
const timeout = setTimeout(() => reject(new Error('Dev server start timeout')), 30_000)
25+
26+
devServer.stdout?.on('data', (data: Buffer) => {
27+
const output = data.toString()
28+
const match = output.match(/Local:\s+(https?:\/\/[^\s]+)/)
29+
if (match) {
30+
clearTimeout(timeout)
31+
resolve(match[1])
32+
}
33+
})
34+
35+
devServer.stderr?.on('data', (data: Buffer) => {
36+
if (process.env.WEBGAL_DEBUG) {
37+
console.error('[vite]', data.toString())
38+
}
39+
})
40+
41+
devServer.on('error', (err) => {
42+
clearTimeout(timeout)
43+
reject(err)
44+
})
45+
})
46+
}
47+
48+
beforeAll(async () => {
49+
const wsEndpoint = inject('wsEndpoint')
50+
browser = await chromium.connect(wsEndpoint)
51+
page = await browser.newPage()
52+
53+
devServerUrl = await startDevServer()
54+
55+
return async () => {
56+
await page?.close()
57+
await browser?.close()
58+
devServer?.kill()
59+
}
60+
})
61+
62+
export { devServerUrl }

test/e2e/smoke.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { page } from './setup'
3+
import { devServerUrl } from './setup'
4+
5+
describe('WebGAL E2E smoke test', () => {
6+
it('should load the title screen', async () => {
7+
await page.goto(devServerUrl)
8+
await page.waitForSelector('.html-body__title-enter', { state: 'visible' })
9+
10+
const hasOverlay = await page.evaluate(() => {
11+
return !!document.querySelector('.html-body__title-enter')
12+
})
13+
expect(hasOverlay).toBe(true)
14+
})
15+
})

test/e2e/utils.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* WebGAL E2E Test Utilities
3+
*
4+
* Encapsulates browser interaction patterns for WebGAL testing.
5+
* Extracted from TDD verification scripts.
6+
*/
7+
import type { Page } from 'playwright-chromium'
8+
9+
/**
10+
* Navigate through WebGAL title screen to start the game.
11+
*
12+
* Steps:
13+
* 1. Click the overlay `.html-body__title-enter`
14+
* 2. Select language (click matching button)
15+
* 3. Click "开始游戏" (must target inner leaf element)
16+
*/
17+
export async function startGame(page: Page, lang = '简体中文'): Promise<void> {
18+
// Click title overlay
19+
await page.evaluate(() => {
20+
const overlay = document.querySelector('.html-body__title-enter') as HTMLElement
21+
overlay?.click()
22+
})
23+
await page.waitForSelector('div[class*="langSelectButton"]', { state: 'visible' })
24+
25+
// Select language
26+
await page.evaluate((lang) => {
27+
const buttons = document.querySelectorAll('div[class*="langSelectButton"]')
28+
for (const btn of buttons) {
29+
if (btn.textContent?.includes(lang)) {
30+
(btn as HTMLElement).click()
31+
return
32+
}
33+
}
34+
}, lang)
35+
await page.waitForFunction(() =>
36+
Array.from(document.querySelectorAll('*')).some(
37+
(el) => el.textContent?.trim() === '开始游戏' && el.children.length === 0,
38+
),
39+
)
40+
41+
// Click "开始游戏" — must target leaf element
42+
await page.evaluate(() => {
43+
const all = document.querySelectorAll('*')
44+
for (const el of all) {
45+
if (el.textContent?.trim() === '开始游戏' && el.children.length === 0) {
46+
(el as HTMLElement).click()
47+
return
48+
}
49+
}
50+
})
51+
await page.waitForSelector('div[class*="_text_"]', { state: 'visible' })
52+
}
53+
54+
/**
55+
* Get current dialog text from the TextBox.
56+
* WebGAL renders text in 3 layers (original + outer stroke + inner fill),
57+
* so "Line 1" appears as "LineLineLine 111" in textContent.
58+
*/
59+
export async function getText(page: Page): Promise<string> {
60+
return page.evaluate(() => {
61+
const el = document.querySelector('div[class*="_text_"]')
62+
return el?.textContent ?? ''
63+
})
64+
}
65+
66+
/**
67+
* Access Redux store via React fiber tree.
68+
* Store is not on window — it's in module scope, accessible through
69+
* React's internal fiber tree at depth ~3.
70+
*/
71+
export async function getStore(page: Page): Promise<any> {
72+
return page.evaluate(() => {
73+
const rootEl = document.getElementById('root')
74+
if (!rootEl) return null
75+
76+
const fiberKey = Object.keys(rootEl).find((k) => k.startsWith('__react'))
77+
if (!fiberKey) return null
78+
79+
// BFS through fiber tree to find store
80+
const queue: any[] = [(rootEl as any)[fiberKey]]
81+
for (let depth = 0; depth < 10 && queue.length > 0; depth++) {
82+
const size = queue.length
83+
for (let i = 0; i < size; i++) {
84+
const node = queue.shift()
85+
if (!node) continue
86+
if (node.memoizedProps?.store) {
87+
return node.memoizedProps.store.getState()
88+
}
89+
if (node.child) queue.push(node.child)
90+
if (node.sibling) queue.push(node.sibling)
91+
}
92+
}
93+
return null
94+
})
95+
}
96+
97+
/**
98+
* Click to advance dialog.
99+
*/
100+
export async function clickToAdvance(page: Page): Promise<void> {
101+
const before = await getText(page)
102+
await page.evaluate(() => {
103+
const textbox = document.querySelector('div[class*="textBox_"]') as HTMLElement
104+
textbox?.click()
105+
})
106+
await page.waitForFunction(
107+
(prev) => {
108+
const el = document.querySelector('div[class*="_text_"]')
109+
return el?.textContent !== prev
110+
},
111+
before,
112+
{ timeout: 5000 },
113+
).catch(() => {})
114+
}

0 commit comments

Comments
 (0)