From 37f7a5562f9e62d73ac51d11c0fa6b3c19deef8a Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 23 Jun 2026 14:58:05 +0800 Subject: [PATCH] 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. --- packages/studio/e2e/fixtures/seed-graph.ts | 83 ++++++++++++++++++++++ packages/studio/e2e/player.spec.ts | 50 +++++++++++++ packages/studio/package.json | 2 + packages/studio/playwright.config.ts | 22 ++++++ pnpm-lock.yaml | 38 ++++++++++ 5 files changed, 195 insertions(+) create mode 100644 packages/studio/e2e/fixtures/seed-graph.ts create mode 100644 packages/studio/e2e/player.spec.ts create mode 100644 packages/studio/playwright.config.ts diff --git a/packages/studio/e2e/fixtures/seed-graph.ts b/packages/studio/e2e/fixtures/seed-graph.ts new file mode 100644 index 00000000..effd1a5d --- /dev/null +++ b/packages/studio/e2e/fixtures/seed-graph.ts @@ -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 { + await saveStoryGraph(E2E_PROJECT_ROOT, E2E_PROJECT_ID, graph); +} diff --git a/packages/studio/e2e/player.spec.ts b/packages/studio/e2e/player.spec.ts new file mode 100644 index 00000000..5f3e52fa --- /dev/null +++ b/packages/studio/e2e/player.spec.ts @@ -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"); +}); diff --git a/packages/studio/package.json b/packages/studio/package.json index 091fb99c..ed102bcb 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -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", diff --git a/packages/studio/playwright.config.ts b/packages/studio/playwright.config.ts new file mode 100644 index 00000000..d2191821 --- /dev/null +++ b/packages/studio/playwright.config.ts @@ -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: ".", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e922b1d0..7ea12654 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: