新增样例页面验收流程工具

This commit is contained in:
czj
2026-07-01 15:01:28 +08:00
parent b699c227bd
commit 5164eeef4a
2 changed files with 269 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect, type APIRequestContext, type Page } from "@playwright/test";
import type {
ArticleSample,
ExportValidationResult,
SampleResult,
} from "./types";
const exportFileNames = [
"optimized.md",
"optimized.docx",
"qa_report.json",
] as const;
type ExportFileName = (typeof exportFileNames)[number];
interface RunSamplePageFlowOptions {
page: Page;
request: APIRequestContext;
sample: ArticleSample;
baseURL: string;
apiAccessKey: string;
reportDir: string;
timeoutMs: number;
}
interface QaReportJson {
overall_status?: "pass" | "warn" | "fail";
checks?: Array<{ rule_id?: string; status?: "pass" | "warn" | "fail" }>;
}
export async function runSamplePageFlow({
page,
request,
sample,
baseURL,
apiAccessKey,
reportDir,
timeoutMs,
}: RunSamplePageFlowOptions): Promise<SampleResult> {
const startedAt = Date.now();
const sampleDir = join(reportDir, "samples", sample.slug);
const exportsDir = join(sampleDir, "exports");
mkdirSync(exportsDir, { recursive: true });
await page.goto(baseURL);
await fillIfVisible(page, "访问密钥", apiAccessKey);
await fillFirstAvailable(page, ["文章内容", "粘贴文章", "正文"], sample.input.body);
await fillIfVisible(page, "图片描述或图片链接", sample.input.image_lines);
await page.getByLabel("目标平台").selectOption(sample.input.platform);
await fillIfVisible(page, "用户要求", sample.input.user_instructions);
await page.getByRole("button", { name: "开始优化" }).click();
const factCardPanel = page.locator("section").filter({ hasText: "事实卡" });
await expect(factCardPanel.getByText(/可用|待复核/)).toBeVisible({
timeout: timeoutMs,
});
await expect(page.getByText("优化结果")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/优化完成|质检发现需要复核的问题/)).toBeVisible({
timeout: timeoutMs,
});
await expect(page.getByText("质量报告")).toBeVisible();
for (const fileName of exportFileNames) {
await expect(page.getByRole("link", { name: fileName })).toBeVisible();
}
const jobId = await extractJobIdFromExportLink(page);
const exportResults = await validateExports({
request,
baseURL,
apiAccessKey,
jobId,
exportsDir,
});
const qa = readQaReport(exportsDir);
const finalScreenshot = join(sampleDir, "final.png");
await page.screenshot({ path: finalScreenshot, fullPage: true });
const failedExports = exportResults.filter((result) => result.status === "failed");
return {
file: sample.filePath,
name: sample.name,
slug: sample.slug,
status: failedExports.length === 0 ? "passed" : "failed",
duration_ms: Date.now() - startedAt,
job_id: jobId,
qa_status: qa.overall_status,
qa_fail_rules: rulesWithStatus(qa, "fail"),
qa_warn_rules: rulesWithStatus(qa, "warn"),
expected_hard_failures: sample.expectedHardFailures,
expected_warnings: sample.expectedWarnings,
exports: Object.fromEntries(
exportResults.map((result) => [result.fileName, result.status]),
),
llm_tasks: [],
failure_category: failedExports.length > 0 ? "export_failed" : undefined,
failure_message:
failedExports
.map((result) => `${result.fileName}: ${result.error ?? result.statusCode}`)
.join("; ") || undefined,
artifacts: {
final_screenshot: finalScreenshot,
},
};
}
async function fillFirstAvailable(page: Page, labels: string[], value: string) {
for (const label of labels) {
const locator = page.getByLabel(label);
if ((await locator.count()) > 0) {
await locator.fill(value);
return;
}
}
throw new Error(`none of these labels were found: ${labels.join(", ")}`);
}
async function fillIfVisible(page: Page, label: string, value: string) {
const locator = page.getByLabel(label);
if ((await locator.count()) > 0) {
await locator.fill(value);
}
}
async function extractJobIdFromExportLink(page: Page) {
const href = await page
.getByRole("link", { name: "optimized.md" })
.getAttribute("href");
return extractJobIdFromExportHref(href ?? "");
}
export function extractJobIdFromExportHref(href: string) {
const match = href.match(/\/api\/jobs\/([^/]+)\/exports\//);
if (!match?.[1]) {
throw new Error(`could not extract job id from export link: ${href}`);
}
return match[1];
}
async function validateExports({
request,
baseURL,
apiAccessKey,
jobId,
exportsDir,
}: {
request: APIRequestContext;
baseURL: string;
apiAccessKey: string;
jobId: string;
exportsDir: string;
}): Promise<ExportValidationResult[]> {
const results: ExportValidationResult[] = [];
const headers: Record<string, string> | undefined = apiAccessKey
? { "x-api-key": apiAccessKey }
: undefined;
for (const fileName of exportFileNames) {
const response = await request.get(
`${baseURL}/api/jobs/${jobId}/exports/${fileName}`,
{ headers },
);
const buffer = await response.body();
const outputPath = join(exportsDir, fileName);
writeFileSync(outputPath, buffer);
if (!response.ok()) {
results.push({
fileName,
status: "failed",
statusCode: response.status(),
error: response.statusText(),
});
continue;
}
const error = validateExportBody(fileName, buffer);
results.push({
fileName,
status: error ? "failed" : "passed",
statusCode: response.status(),
error,
});
}
return results;
}
export function validateExportBody(fileName: ExportFileName, buffer: Buffer) {
if (buffer.length === 0) return "empty export body";
if (fileName === "optimized.md") {
return buffer.toString("utf8").trim().length > 0
? undefined
: "optimized.md is blank";
}
if (fileName === "optimized.docx") {
return buffer.subarray(0, 2).toString("utf8") === "PK"
? undefined
: "optimized.docx does not look like a zip-based docx";
}
try {
const parsed = JSON.parse(buffer.toString("utf8")) as QaReportJson;
if (!parsed.overall_status || !Array.isArray(parsed.checks)) {
return "qa_report.json must contain overall_status and checks";
}
return undefined;
} catch {
return "qa_report.json is not valid JSON";
}
}
function readQaReport(exportsDir: string): QaReportJson {
const text = readFileSync(join(exportsDir, "qa_report.json"), "utf8");
return JSON.parse(text) as QaReportJson;
}
function rulesWithStatus(report: QaReportJson, status: "warn" | "fail") {
return (report.checks ?? [])
.filter((check) => check.status === status && check.rule_id)
.map((check) => check.rule_id as string);
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
extractJobIdFromExportHref,
validateExportBody,
} from "../e2e/sample-flow/page-flow";
describe("page-flow helpers", () => {
it("extracts job ids from optimized export links", () => {
expect(
extractJobIdFromExportHref("/api/jobs/job_abc123/exports/optimized.md"),
).toBe("job_abc123");
});
it("validates export response bodies", () => {
expect(validateExportBody("optimized.md", Buffer.from("# 标题"))).toBeUndefined();
expect(
validateExportBody(
"optimized.docx",
Buffer.from([0x50, 0x4b, 0x03, 0x04]),
),
).toBeUndefined();
expect(
validateExportBody(
"qa_report.json",
Buffer.from(
JSON.stringify({
overall_status: "pass",
checks: [],
}),
),
),
).toBeUndefined();
expect(validateExportBody("optimized.md", Buffer.from(""))).toBe(
"empty export body",
);
});
});