bd5bfbac2d
Root cause: LLM receiving full 34k-char JRXML would regenerate from scratch
instead of modifying coordinates in-place, shrinking output to ~3k chars.
Solution (programmatic node control, not prompt engineering):
- New agent/jrxml_windower.py: decompose JRXML into header (never sent to
LLM) + individual bands. Split bands >4000 chars at element boundaries.
Reassemble with element count validation (>10% change = rollback).
- Rewrite refine_layout: per-band windowed LLM processing (~2-4k chars
each). LLM cannot "reimagine" the entire report.
- Rewrite map_fields: 100% programmatic regex $F{field_N} -> real name
replacement. Zero LLM calls, zero content loss.
- _sanitize_field_name: non-ASCII chars escaped to _uXXXX_ format for
valid JRXML identifiers.
- Tests: 48 new unit tests (windower 28 + map_fields 20). All passing.
Full suite 385 tests, zero regressions.
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
/**
|
|
* E2E tests: key user flows for the JRXML Agent frontend.
|
|
*
|
|
* Pre-requisites: npm run dev (reuseExistingServer in playwright.config).
|
|
* API calls are intercepted by page.route() — no real backend needed.
|
|
*/
|
|
|
|
import { test, expect } from "@playwright/test";
|
|
|
|
// ── helpers ────────────────────────────────────────────────────
|
|
|
|
function mockApi(page: any) {
|
|
page.route("**/api/health", (route: any) =>
|
|
route.fulfill({ json: { status: "ok", version: "5.0" } })
|
|
);
|
|
|
|
page.route("**/api/sessions", (route: any) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({
|
|
json: {
|
|
session_id: "test12345678",
|
|
session_name: "新建报表 2026-05-22",
|
|
created_at: "2026-05-22T10:00:00.000Z",
|
|
updated_at: "2026-05-22T10:00:00.000Z",
|
|
},
|
|
});
|
|
}
|
|
return route.fulfill({ json: { sessions: [] } });
|
|
});
|
|
|
|
page.route("**/api/sessions/*/chat", (route: any) => {
|
|
const sseBody = [
|
|
"event: node_start",
|
|
'data: {"node":"classify_intent","label":"识别意图","step_index":1}',
|
|
"",
|
|
"event: node_complete",
|
|
'data: {"node":"classify_intent","label":"识别意图","detail":"意图: 新建报表"}',
|
|
"",
|
|
"event: agent_complete",
|
|
'data: {"reason":"done","intent":"initial_generation","status":"pass","jrxml_length":42,"versions":1,"total_duration_ms":1200}',
|
|
"",
|
|
"",
|
|
].join("\n");
|
|
return route.fulfill({
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
body: sseBody,
|
|
});
|
|
});
|
|
|
|
page.route("**/api/upload", (route: any) =>
|
|
route.fulfill({
|
|
json: { file_id: "f001122334455", filename: "test.png", size: 1024 },
|
|
})
|
|
);
|
|
|
|
// Catch-all for GET/DELETE /api/sessions/:id (must fallback for POST to let chat route match)
|
|
page.route("**/api/sessions/**", (route: any) => {
|
|
if (route.request().method() === "DELETE") {
|
|
return route.fulfill({ json: { status: "deleted" } });
|
|
}
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
json: {
|
|
session_id: "test12345678",
|
|
session_name: "测试会话",
|
|
agent_state: { current_jrxml: "<jasperReport/>" },
|
|
},
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
}
|
|
|
|
// ── tests ──────────────────────────────────────────────────────
|
|
|
|
test.describe("Page load", () => {
|
|
test("renders sidebar and input area", async ({ page }) => {
|
|
await mockApi(page);
|
|
await page.goto("/");
|
|
|
|
await expect(page.locator("aside.sidebar")).toBeVisible();
|
|
await expect(page.locator("h2")).toContainText("JRXML");
|
|
await expect(page.locator(".unified-input")).toBeVisible();
|
|
});
|
|
|
|
test("sidebar shows session list header and new button", async ({ page }) => {
|
|
await mockApi(page);
|
|
await page.goto("/");
|
|
|
|
await expect(page.getByText("会话列表")).toBeVisible();
|
|
await expect(page.locator('button[title="新建会话"]')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Session management", () => {
|
|
test("creates new session on button click", async ({ page }) => {
|
|
await mockApi(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator('button[title="新建会话"]').click();
|
|
await expect(page.locator(".session-item")).toBeVisible({ timeout: 5000 });
|
|
await expect(page.locator(".session-item.active")).toBeVisible();
|
|
});
|
|
|
|
test("can delete current session", async ({ page }) => {
|
|
await mockApi(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator('button[title="新建会话"]').click();
|
|
await expect(page.locator(".session-item")).toBeVisible({ timeout: 5000 });
|
|
|
|
page.on("dialog", (dialog) => dialog.accept());
|
|
await page.locator(".btn-delete").click();
|
|
|
|
await expect(page.locator(".session-item")).toHaveCount(0, { timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
test.describe("Chat flow", () => {
|
|
test("sends text and displays user message + process section", async ({ page }) => {
|
|
await mockApi(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator('button[title="新建会话"]').click();
|
|
await expect(page.locator(".session-item")).toBeVisible({ timeout: 5000 });
|
|
|
|
const textarea = page.locator(".unified-input textarea");
|
|
await textarea.fill("生成一个员工名册报表");
|
|
await page.locator(".send-btn").click();
|
|
|
|
await expect(
|
|
page.locator(".chat-messages .message.msg-user").filter({ hasText: "员工名册" })
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
await expect(page.locator(".process-section")).toBeVisible({ timeout: 10000 });
|
|
});
|
|
|
|
test("summary card appears after stream complete", async ({ page }) => {
|
|
await mockApi(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator('button[title="新建会话"]').click();
|
|
await expect(page.locator(".session-item")).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.locator(".unified-input textarea").fill("生成报表");
|
|
await page.locator(".send-btn").click();
|
|
|
|
await expect(page.locator(".summary-card")).toBeVisible({ timeout: 15000 });
|
|
});
|
|
});
|
|
|
|
test.describe("Input UX", () => {
|
|
test("send button disabled when input empty", async ({ page }) => {
|
|
await mockApi(page);
|
|
await page.goto("/");
|
|
|
|
await expect(page.locator(".send-btn")).toBeDisabled();
|
|
});
|
|
|
|
test("send button enabled when text entered", async ({ page }) => {
|
|
await mockApi(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator(".unified-input textarea").fill("Hi");
|
|
await expect(page.locator(".send-btn")).toBeEnabled();
|
|
});
|
|
});
|
|
|
|
// ── KB (Knowledge Base) API mocks ───────────────────────────────
|
|
|
|
function mockKbApi(page: any) {
|
|
mockApi(page);
|
|
|
|
page.route("**/api/users", (route: any) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({
|
|
json: { user_id: "u_e2e_test_001", name: "E2E用户", created_at: "2026-05-23T00:00:00Z" },
|
|
});
|
|
}
|
|
return route.fulfill({
|
|
json: { users: [{ user_id: "u_e2e_test_001", name: "E2E用户", created_at: "2026-05-23T00:00:00Z" }] },
|
|
});
|
|
});
|
|
|
|
page.route("**/api/users/*/kbs", (route: any) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({
|
|
json: {
|
|
kb_id: "kb_e2e_001", user_id: "u_e2e_test_001",
|
|
name: "E2E测试库", description: "",
|
|
created_at: "2026-05-23T00:00:00Z", updated_at: "2026-05-23T00:00:00Z",
|
|
fields: [], templates: [], file_count: 0, chunk_count: 0, parse_status: "empty",
|
|
},
|
|
});
|
|
}
|
|
return route.fulfill({
|
|
json: {
|
|
kbs: [{
|
|
kb_id: "kb_e2e_001", name: "E2E测试库", description: "",
|
|
created_at: "2026-05-23T00:00:00Z", updated_at: "2026-05-23T00:00:00Z",
|
|
field_count: 10, template_count: 3, file_count: 2, chunk_count: 50, parse_status: "ready",
|
|
}],
|
|
},
|
|
});
|
|
});
|
|
|
|
page.route("**/api/kbs/*/status", (route: any) =>
|
|
route.fulfill({ json: { parse_status: "ready", file_count: 2, chunk_count: 50 } })
|
|
);
|
|
page.route("**/api/kbs/*/fields", (route: any) =>
|
|
route.fulfill({ json: {
|
|
fields: [{ name: "billNo", description: "工单号", type: "String" }],
|
|
templates: [{ name: "结算单", file: "结算单.jrxml" }],
|
|
}})
|
|
);
|
|
page.route("**/api/kbs/*", (route: any) => {
|
|
if (route.request().method() === "DELETE") {
|
|
return route.fulfill({ json: { status: "deleted" } });
|
|
}
|
|
return route.fulfill({
|
|
json: {
|
|
kb_id: "kb_e2e_001", user_id: "u_e2e_test_001",
|
|
name: "E2E测试库", description: "",
|
|
fields: [], templates: [],
|
|
file_count: 2, chunk_count: 50, parse_status: "ready",
|
|
},
|
|
});
|
|
});
|
|
|
|
page.route("**/api/kbs/*/upload", (route: any) =>
|
|
route.fulfill({ json: { filename: "test.jrxml", type: "jrxml", error: null } })
|
|
);
|
|
|
|
page.route("**/api/sessions/*/kb", (route: any) => {
|
|
if (route.request().method() === "PUT") {
|
|
return route.fulfill({ json: { kb_id: "kb_e2e_001", kb_name: "E2E测试库" } });
|
|
}
|
|
return route.fulfill({ json: { kb_id: "kb_e2e_001", kb_name: "E2E测试库" } });
|
|
});
|
|
}
|
|
|
|
// ── KB feature tests ────────────────────────────────────────────
|
|
|
|
test.describe("KB selector", () => {
|
|
test("KB selector renders in chat interface", async ({ page }) => {
|
|
await mockKbApi(page);
|
|
await page.goto("/");
|
|
|
|
await expect(page.locator(".kb-selector")).toBeVisible({ timeout: 5000 });
|
|
await expect(page.locator(".kb-label")).toContainText("知识库");
|
|
await expect(page.locator(".kb-select")).toBeVisible();
|
|
await expect(page.locator(".kb-manage-btn")).toBeVisible();
|
|
});
|
|
|
|
test("can select a KB from dropdown", async ({ page }) => {
|
|
await mockKbApi(page);
|
|
await page.goto("/");
|
|
|
|
const select = page.locator(".kb-select");
|
|
await expect(select).toBeVisible({ timeout: 5000 });
|
|
await select.selectOption({ label: "E2E测试库 (10字段, 3模板)" });
|
|
await expect(page.locator(".kb-badge")).toContainText("E2E测试库");
|
|
});
|
|
});
|
|
|
|
test.describe("KB manager", () => {
|
|
test("opens KB manager overlay", async ({ page }) => {
|
|
await mockKbApi(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator(".kb-manage-btn").click();
|
|
await expect(page.locator(".kb-manager")).toBeVisible({ timeout: 3000 });
|
|
await expect(page.locator(".kb-manager h3")).toContainText("知识库管理");
|
|
});
|
|
|
|
test("can close KB manager", async ({ page }) => {
|
|
await mockKbApi(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator(".kb-manage-btn").click();
|
|
await expect(page.locator(".kb-manager")).toBeVisible({ timeout: 3000 });
|
|
await page.locator(".close-btn").click();
|
|
await expect(page.locator(".kb-manager")).not.toBeVisible({ timeout: 3000 });
|
|
});
|
|
|
|
test("create form has name input and create button", async ({ page }) => {
|
|
await mockKbApi(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator(".kb-manage-btn").click();
|
|
await expect(page.locator(".kb-manager")).toBeVisible({ timeout: 3000 });
|
|
|
|
await expect(page.locator('.kb-manager .create-form .kb-input').first()).toBeVisible();
|
|
await expect(page.locator('.kb-manager .create-form button.primary')).toBeVisible();
|
|
});
|
|
|
|
test("KB cards show name, status, and actions", async ({ page }) => {
|
|
await mockKbApi(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator(".kb-manage-btn").click();
|
|
await expect(page.locator(".kb-manager")).toBeVisible({ timeout: 3000 });
|
|
|
|
await expect(page.locator(".kb-card")).toBeVisible({ timeout: 3000 });
|
|
await expect(page.locator(".kb-card strong")).toContainText("E2E测试库");
|
|
await expect(page.locator(".kb-status.ready")).toContainText("就绪");
|
|
await expect(page.locator(".kb-actions button")).toHaveCount(3);
|
|
});
|
|
});
|
|
|
|
test.describe("JRXML upload in chat", () => {
|
|
test("file input accepts .jrxml extension", async ({ page }) => {
|
|
await mockKbApi(page);
|
|
await page.goto("/");
|
|
|
|
const input = page.locator('.unified-input input[type="file"]');
|
|
await expect(input).toHaveAttribute("accept", /\.jrxml/);
|
|
});
|
|
});
|