test(studio): add Playwright e2e for interactive-film player playthrough

Two tests covering real DOM click paths through StoryPlayer:
- good ending path (trust >= 1 via trustup choice, conditional option visible)
- bad ending path (trust = 0, conditional option hidden by count 0)

Seed fixture writes story-graph.json to test-project/ before browser launch.
INKOS_PROJECT_ROOT points to test-project/ so GET /api/v1/project succeeds.
This commit is contained in:
Ma
2026-06-23 14:58:05 +08:00
parent ac72fbfe08
commit 37f7a5562f
5 changed files with 195 additions and 0 deletions
@@ -0,0 +1,83 @@
import { saveStoryGraph, StoryGraphSchema } from "@actalk/inkos-core";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
// INKOS_PROJECT_ROOT in dev/e2e is ../../test-project (relative to packages/studio).
// From this file (packages/studio/e2e/fixtures/seed-graph.ts) that is 4 levels up,
// then down into test-project.
export const E2E_PROJECT_ROOT = resolve(__dirname, "../../../..", "test-project");
export const E2E_PROJECT_ID = "e2e-player-demo";
const graph = StoryGraphSchema.parse({
schemaVersion: 1,
projectId: E2E_PROJECT_ID,
title: "E2E 试玩剧本",
variables: [{ name: "trust", type: "counter", default: 0, desc: "信任" }],
nodes: [
{
id: "start",
title: "开场",
type: "start",
sceneDesc: "你站在宫门前。",
choices: [
{
id: "trustup",
text: "交出证据(信任+1",
targetNodeId: "mid",
effects: [{ var: "trust", op: "add", value: 1 }],
},
{
id: "hide",
text: "藏起证据",
targetNodeId: "mid",
effects: [],
},
],
},
{
id: "mid",
title: "抉择",
type: "branch",
sceneDesc: "侍卫盯着你。",
choices: [
{
id: "good",
text: "坦白",
targetNodeId: "endGood",
effects: [],
condition: { var: "trust", op: ">=", value: 1 },
},
{
id: "bad",
text: "逃跑",
targetNodeId: "endBad",
effects: [],
},
],
},
{ id: "endGood", title: "真相结局", type: "ending", choices: [] },
{ id: "endBad", title: "逃亡结局", type: "ending", choices: [] },
],
endings: [
{
id: "g",
nodeId: "endGood",
title: "真相大白",
type: "good",
description: "你赢得了信任。",
},
{
id: "b",
nodeId: "endBad",
title: "亡命天涯",
type: "bad",
description: "你消失在夜色里。",
},
],
});
export async function seedE2EGraph(): Promise<void> {
await saveStoryGraph(E2E_PROJECT_ROOT, E2E_PROJECT_ID, graph);
}
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from "@playwright/test";
import { seedE2EGraph, E2E_PROJECT_ID } from "./fixtures/seed-graph";
test.beforeAll(async () => {
await seedE2EGraph(); // 测试准备:把已知图写到磁盘
});
test("用户点真实按钮把剧本玩到 good 结局,变量与结局计数正确", async ({ page }) => {
// 1. 打开播放器页(真实导航)
await page.goto(`/#/play/${E2E_PROJECT_ID}`);
// 2. 点"开始游玩"
await page.getByTestId("player-start").click();
// 3. 开场:HUD 初始 trust=0
await expect(page.getByTestId("player-node-title")).toHaveText("开场");
await expect(page.getByTestId("hud-trust")).toHaveText("0");
// 4. 点"交出证据(信任+1)"——真实选项按钮
await page.getByTestId("choice-trustup").click();
// 5. 到达抉择节点,HUD 变为 trust=1(断言真实渲染状态)
await expect(page.getByTestId("player-node-title")).toHaveText("抉择");
await expect(page.getByTestId("hud-trust")).toHaveText("1");
// 6. 因 trust>=1"坦白"这个有条件选项应可见并可点
await expect(page.getByTestId("choice-good")).toBeVisible();
await page.getByTestId("choice-good").click();
// 7. 到达 good 结局,类型/标题/已解锁计数正确
await expect(page.getByTestId("player-ending")).toBeVisible();
await expect(page.getByTestId("player-ending-type")).toHaveText("good");
await expect(page.getByTestId("player-ending-title")).toHaveText("真相大白");
await expect(page.getByTestId("player-unlocked")).toContainText("1 / 2");
});
test("条件选项在 trust 不足时隐藏(藏起证据路径走向 bad 结局)", async ({ page }) => {
await page.goto(`/#/play/${E2E_PROJECT_ID}`);
await page.getByTestId("player-start").click();
// 选"藏起证据"——trust 保持 0
await page.getByTestId("choice-hide").click();
await expect(page.getByTestId("hud-trust")).toHaveText("0");
// trust<1"坦白"选项应不可见,只能"逃跑"
await expect(page.getByTestId("choice-good")).toHaveCount(0);
await page.getByTestId("choice-bad").click();
await expect(page.getByTestId("player-ending-type")).toHaveText("bad");
});
+2
View File
@@ -27,6 +27,7 @@
"build:server": "tsc -p tsconfig.server.json",
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit"
},
"dependencies": {
@@ -57,6 +58,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@playwright/test": "^1.61.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 60_000,
use: {
baseURL: "http://localhost:4567",
headless: true,
screenshot: "only-on-failure",
},
// Reuse already-running dev server; start one if none is running.
// INKOS_PROJECT_ROOT points to test-project/ which has an inkos.json,
// so the API /api/v1/project endpoint returns valid JSON and the React
// app can reach the ready state.
webServer: {
command: "INKOS_STUDIO_PORT=4569 INKOS_PROJECT_ROOT=../../test-project tsx watch --clear-screen=false src/api/index.ts & vite --host --port 4567 ; kill %1 2>/dev/null",
url: "http://localhost:4567",
reuseExistingServer: true,
timeout: 120_000,
cwd: ".",
},
});
+38
View File
@@ -179,6 +179,9 @@ importers:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@playwright/test':
specifier: ^1.61.0
version: 1.61.0
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.1(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
@@ -1050,6 +1053,11 @@ packages:
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@playwright/test@1.61.0':
resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==}
engines: {node: '>=18'}
hasBin: true
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -2903,6 +2911,11 @@ packages:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3983,6 +3996,16 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
playwright-core@1.61.0:
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.0:
resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
engines: {node: '>=18'}
hasBin: true
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -5974,6 +5997,10 @@ snapshots:
'@opentelemetry/api@1.9.0': {}
'@playwright/test@1.61.0':
dependencies:
playwright: 1.61.0
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
@@ -7932,6 +7959,9 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -9263,6 +9293,14 @@ snapshots:
mlly: 1.8.2
pathe: 2.0.3
playwright-core@1.61.0: {}
playwright@1.61.0:
dependencies:
playwright-core: 1.61.0
optionalDependencies:
fsevents: 2.3.2
points-on-curve@0.2.0: {}
points-on-path@0.2.1: