mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-24 15:29:47 +08:00
feat(e2e): authoring confirm flow — propose_action card + real-browser test
- server.ts: add details.toolExecutions to the propose_action response path so the frontend can render ProposedActionPreview from the JSON body. - ToolExecutionSteps.tsx: add data-testid="confirm-action" to the confirm button so the E2E selector can click it. - agent-session.ts: move terminalToolResultTail guard before isLlmStubEnabled check to prevent stub re-proposing after a terminal tool result. - playwright.config.ts: use reuseExistingServer=false and dedicated ports 4580/4581 (separate from the dev server's 4567/4569) so INKOS_AGENT_LLM_STUB=1 is always active and no real LLM call is made with the fake API key. - e2e/authoring-confirm.spec.ts: new E2E spec covering the full confirm flow. - e2e/fixtures/seed-authoring-confirm.ts: seed fixture that writes story graph, fake secrets, and a fresh session before each test run.
This commit is contained in:
@@ -890,11 +890,11 @@ async function runAgentSessionUnlocked(
|
||||
return convertAgentMessagesForModel(messages, model);
|
||||
},
|
||||
streamFn: (streamModel, context, options) => {
|
||||
if (isLlmStubEnabled()) return stubAgentStream(streamModel, context);
|
||||
if (terminalToolResultTail) {
|
||||
terminalToolResultTail = false;
|
||||
return localAssistantStopStream(streamModel);
|
||||
}
|
||||
if (isLlmStubEnabled()) return stubAgentStream(streamModel, context);
|
||||
return guardedStreamSimple(streamModel, context, options);
|
||||
},
|
||||
getApiKey: (provider: string) => {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Real-browser multi-step E2E: user drives the agent authoring confirm flow.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Open the film tree page (#/film/<id>) — the project must already exist.
|
||||
* 2. Click the "AI 对话创作" button (data-testid="open-authoring").
|
||||
* 3. Type "帮我搭一个三幕结构" into the chat textarea and press Enter.
|
||||
* 4. Wait for the agent to stream and render the proposed-action confirm card.
|
||||
* 5. Click the confirm button (data-testid="confirm-action").
|
||||
* 6. Wait for execution, then cross-check the graph via the API endpoint.
|
||||
*
|
||||
* The dev server runs with INKOS_AGENT_LLM_STUB=1 (set in playwright.config.ts),
|
||||
* so the agent deterministically proposes draft_structure when the user message
|
||||
* mentions "结构". No real LLM call is made.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { seedAuthoringConfirm, E2E_AUTHOR_ID } from "./fixtures/seed-authoring-confirm";
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await seedAuthoringConfirm();
|
||||
});
|
||||
|
||||
test("user drives agent to draft a structure via the confirm flow", async ({ page }) => {
|
||||
// Step 1: navigate to the film tree page
|
||||
await page.goto(`/#/film/${E2E_AUTHOR_ID}`);
|
||||
|
||||
// Wait for the tree page to mount (the film-title testid is set in StoryGraphTree
|
||||
// only after the graph loads; the project may not have a graph yet, so we wait
|
||||
// for the page's main content area to appear instead)
|
||||
await expect(page.getByTestId("open-authoring")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Step 2: click the authoring button → navigates to #/film-author/<id>
|
||||
await page.getByTestId("open-authoring").click();
|
||||
|
||||
// The ChatPage loads and immediately creates/loads an authoring session.
|
||||
// Wait for the chat input to become enabled (session ready).
|
||||
const chatInput = page.getByPlaceholder("输入指令...");
|
||||
await expect(chatInput).toBeVisible({ timeout: 20_000 });
|
||||
await expect(chatInput).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
// Step 3: type the instruction and press Enter to send
|
||||
await chatInput.fill("帮我搭一个三幕结构");
|
||||
await chatInput.press("Enter");
|
||||
|
||||
// Step 4: wait for the agent to stream and render the proposed-action card.
|
||||
// The stub emits propose_action immediately, but the SSE pipeline still goes
|
||||
// through the server → SSE event → store update → React render cycle.
|
||||
// We give it up to 60 s to account for any startup lag on the CI machine.
|
||||
await expect(page.getByTestId("confirm-action")).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// Step 5: click the confirm button
|
||||
await page.getByTestId("confirm-action").click();
|
||||
|
||||
// Step 6: cross-check via the graph API endpoint.
|
||||
// The stub's stubChatCompletion returns STRUCTURE_JSON (4 nodes) when the
|
||||
// prompt mentions "骨架/nodes/结构". Poll until the graph has ≥4 nodes.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(
|
||||
`/api/v1/projects/${E2E_AUTHOR_ID}/story-graph`,
|
||||
);
|
||||
if (!res.ok()) return 0;
|
||||
const g = (await res.json()) as { nodes: unknown[] };
|
||||
return g.nodes?.length ?? 0;
|
||||
},
|
||||
{ timeout: 60_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
saveStoryGraph,
|
||||
StoryGraphSchema,
|
||||
createAndPersistBookSession,
|
||||
saveSecrets,
|
||||
} from "@actalk/inkos-core";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readdir, rm } from "node:fs/promises";
|
||||
|
||||
const dir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const E2E_ROOT = resolve(dir, "../../../..", "test-project");
|
||||
|
||||
export const E2E_AUTHOR_ID = "e2e-authoring-confirm";
|
||||
|
||||
/**
|
||||
* Seed the project directory that the dev server needs for the authoring
|
||||
* confirm E2E. The dev server uses test-project/ as its INKOS_PROJECT_ROOT.
|
||||
* We create:
|
||||
* - test-project/interactive-films/<E2E_AUTHOR_ID>/story-graph.json
|
||||
* (minimal graph so StoryGraphTree renders and shows the open-authoring button)
|
||||
* - test-project/.inkos/secrets.json
|
||||
* (a fake DeepSeek API key so the UI's model picker goes to "ready" state
|
||||
* and ChatPage auto-selects a model, allowing sendMessage to proceed past
|
||||
* the "请先选择一个模型" guard)
|
||||
* - a fresh book session so the agent endpoint can load it
|
||||
*
|
||||
* IMPORTANT: we do NOT touch test-project/inkos.json — that file is shared
|
||||
* across all E2E tests and must not be overwritten.
|
||||
*
|
||||
* The actual LLM calls are bypassed by INKOS_AGENT_LLM_STUB=1 set in
|
||||
* playwright.config.ts, so the fake API key is never sent to any real service.
|
||||
*/
|
||||
export async function seedAuthoringConfirm(): Promise<void> {
|
||||
// Delete all stale sessions for this project from previous test runs.
|
||||
// ChatPage picks the most-recently-updated session (ids[0] from listBookSessions);
|
||||
// stale sessions with old message history can confuse the test.
|
||||
const sessionsDir = resolve(E2E_ROOT, ".inkos", "sessions");
|
||||
try {
|
||||
const files = await readdir(sessionsDir);
|
||||
await Promise.all(
|
||||
files
|
||||
.filter((f) => f.startsWith(`e2e-confirm-seed-`) && f.endsWith(".jsonl"))
|
||||
.map((f) => rm(resolve(sessionsDir, f), { force: true })),
|
||||
);
|
||||
} catch {
|
||||
// sessions dir may not exist on first run — ignore
|
||||
}
|
||||
|
||||
// Write a minimal story graph so StoryGraphTree can load the film page
|
||||
// without returning an error. The agent will overwrite this with a 4-node
|
||||
// graph once the confirm flow executes.
|
||||
await saveStoryGraph(
|
||||
E2E_ROOT,
|
||||
E2E_AUTHOR_ID,
|
||||
StoryGraphSchema.parse({
|
||||
schemaVersion: 1,
|
||||
projectId: E2E_AUTHOR_ID,
|
||||
title: "E2E 确认流程样例",
|
||||
worldAnchor: {
|
||||
storyCore: "测试",
|
||||
theme: "测试",
|
||||
genre: "现代",
|
||||
worldRules: "无设定",
|
||||
durationMinutes: 10,
|
||||
},
|
||||
variables: [],
|
||||
characters: [],
|
||||
nodes: [
|
||||
{
|
||||
id: "s",
|
||||
type: "start",
|
||||
title: "开场",
|
||||
sceneDesc: "起始场景",
|
||||
choices: [{ id: "c1", text: "继续", targetNodeId: "e" }],
|
||||
},
|
||||
{
|
||||
id: "e",
|
||||
type: "ending",
|
||||
title: "结局",
|
||||
choices: [],
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{ id: "g", nodeId: "e", title: "结局", type: "good", description: "" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Write a fake secrets.json so the UI's /api/v1/services endpoint reports
|
||||
// DeepSeek as "connected". The dev server then returns DeepSeek's static
|
||||
// model list from /api/v1/services/models, which allows ChatPage to
|
||||
// auto-select a model and pass the sendMessage guard.
|
||||
// The INKOS_AGENT_LLM_STUB=1 env var ensures no real API call is made.
|
||||
await saveSecrets(E2E_ROOT, {
|
||||
services: {
|
||||
deepseek: { apiKey: "stub-key-e2e-not-real" },
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-create a session so the directory tree is complete before the browser
|
||||
// loads. ChatPage will POST /api/v1/chat/sessions to create its own session,
|
||||
// but the directory must already exist.
|
||||
const sessionId = `e2e-confirm-seed-${Date.now()}`;
|
||||
await createAndPersistBookSession(
|
||||
E2E_ROOT,
|
||||
E2E_AUTHOR_ID,
|
||||
sessionId,
|
||||
"interactive-film-authoring",
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,20 @@ export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: "http://localhost:4567",
|
||||
baseURL: "http://localhost:4580",
|
||||
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.
|
||||
// Always start a dedicated E2E server with INKOS_AGENT_LLM_STUB=1 so the
|
||||
// agent uses the deterministic stub and never makes real LLM calls.
|
||||
// reuseExistingServer: false ensures the stub env var is always active —
|
||||
// if an existing dev server (started without the stub) were reused, the
|
||||
// agent would attempt a real LLM call with the fake API key and hang.
|
||||
// Ports 4580/4581 are dedicated to E2E to avoid conflict with the dev server.
|
||||
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,
|
||||
command: "INKOS_AGENT_LLM_STUB=1 INKOS_STUDIO_PORT=4581 INKOS_PROJECT_ROOT=../../test-project tsx watch --clear-screen=false src/api/index.ts & INKOS_AGENT_LLM_STUB=1 INKOS_STUDIO_PORT=4581 vite --host --port 4580 ; kill %1 2>/dev/null",
|
||||
url: "http://localhost:4580",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
cwd: ".",
|
||||
},
|
||||
|
||||
@@ -4024,6 +4024,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
sessionKind,
|
||||
...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}),
|
||||
},
|
||||
details: { toolExecutions: collectedToolExecs },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -529,6 +529,7 @@ function ProposedActionPreview({
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-action"
|
||||
onClick={() => onProposedAction?.(details)}
|
||||
disabled={!onProposedAction || streaming || locked}
|
||||
className="rounded-lg bg-primary px-3.5 py-2 text-[15px] leading-6 font-medium text-primary-foreground disabled:opacity-50"
|
||||
|
||||
Reference in New Issue
Block a user