test(templates): sweep all draftable template flows
This commit is contained in:
@@ -9,9 +9,14 @@ function parseArgs(argv) {
|
||||
const args = {
|
||||
baseUrl: "http://localhost:3000",
|
||||
contractApiBaseUrl: "http://127.0.0.1:8100",
|
||||
expectedCount: 30,
|
||||
expectedCount: 60,
|
||||
selection: "draftable",
|
||||
templateId: "",
|
||||
outputDir: "",
|
||||
screenshots: "all",
|
||||
browserExecutable: "",
|
||||
email: process.env.YUQEI_TEST_EMAIL || "",
|
||||
password: process.env.YUQEI_TEST_PASSWORD || "",
|
||||
headed: false,
|
||||
timeoutMs: 90000,
|
||||
};
|
||||
@@ -27,12 +32,21 @@ function parseArgs(argv) {
|
||||
} else if (arg === "--expected-count") {
|
||||
args.expectedCount = Number.parseInt(requireValue(arg, next), 10);
|
||||
index += 1;
|
||||
} else if (arg === "--selection") {
|
||||
args.selection = requireValue(arg, next);
|
||||
index += 1;
|
||||
} else if (arg === "--template-id") {
|
||||
args.templateId = requireValue(arg, next);
|
||||
index += 1;
|
||||
} else if (arg === "--output-dir") {
|
||||
args.outputDir = requireValue(arg, next);
|
||||
index += 1;
|
||||
} else if (arg === "--screenshots") {
|
||||
args.screenshots = requireValue(arg, next);
|
||||
index += 1;
|
||||
} else if (arg === "--browser-executable") {
|
||||
args.browserExecutable = requireValue(arg, next);
|
||||
index += 1;
|
||||
} else if (arg === "--timeout-ms") {
|
||||
args.timeoutMs = Number.parseInt(requireValue(arg, next), 10);
|
||||
index += 1;
|
||||
@@ -45,12 +59,18 @@ function parseArgs(argv) {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(args.expectedCount) || args.expectedCount < 1) {
|
||||
throw new Error("--expected-count must be a positive integer.");
|
||||
if (!Number.isFinite(args.expectedCount) || args.expectedCount < 0) {
|
||||
throw new Error("--expected-count must be a non-negative integer.");
|
||||
}
|
||||
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 10000) {
|
||||
throw new Error("--timeout-ms must be at least 10000.");
|
||||
}
|
||||
if (!["draftable", "published"].includes(args.selection)) {
|
||||
throw new Error("--selection must be one of: draftable, published.");
|
||||
}
|
||||
if (Boolean(args.email) !== Boolean(args.password)) {
|
||||
throw new Error("YUQEI_TEST_EMAIL and YUQEI_TEST_PASSWORD must be provided together.");
|
||||
}
|
||||
if (!["all", "failures", "none"].includes(args.screenshots)) {
|
||||
throw new Error("--screenshots must be one of: all, failures, none.");
|
||||
}
|
||||
@@ -70,9 +90,13 @@ function printHelp() {
|
||||
Options:
|
||||
--base-url <url> Contract web base URL. Default: http://localhost:3000
|
||||
--contract-api-base-url <url> Contract API base URL. Default: http://127.0.0.1:8100
|
||||
--expected-count <n> Expected published template count. Default: 30
|
||||
--expected-count <n> Expected selected template count. Default: 60
|
||||
--selection <mode> draftable | published. Default: draftable
|
||||
--template-id <id> Restrict the sweep to one template while retaining the full catalog UI assertion.
|
||||
--output-dir <path> Artifact directory. Default: output/playwright/template-draft-sweep/<timestamp>
|
||||
--screenshots <mode> all | failures | none. Default: all
|
||||
--browser-executable <path> Use an installed Chromium browser instead of Playwright's bundled browser.
|
||||
YUQEI_TEST_EMAIL/PASSWORD Optional authenticated test account; both environment variables are required together.
|
||||
--timeout-ms <n> Navigation/action timeout. Default: 90000
|
||||
--headed Run headed browser.
|
||||
`);
|
||||
@@ -102,12 +126,17 @@ async function fetchJson(url) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadPublishedTemplates(contractApiBaseUrl) {
|
||||
async function loadTemplateCatalog(contractApiBaseUrl, selection, templateId) {
|
||||
const url = `${normalizeBaseUrl(contractApiBaseUrl)}/api/v1/templates`;
|
||||
const payload = await fetchJson(url);
|
||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||
return items
|
||||
.filter((item) => item.lifecycle_status === "published")
|
||||
const selected = items
|
||||
.filter((item) =>
|
||||
selection === "published"
|
||||
? item.lifecycle_status === "published"
|
||||
: item.drafting_available === true,
|
||||
)
|
||||
.filter((item) => !templateId || item.id === templateId)
|
||||
.sort((left, right) => {
|
||||
const leftType = String(left.contract_type || "");
|
||||
const rightType = String(right.contract_type || "");
|
||||
@@ -116,6 +145,22 @@ async function loadPublishedTemplates(contractApiBaseUrl) {
|
||||
}
|
||||
return String(left.name || "").localeCompare(String(right.name || ""), "zh-CN");
|
||||
});
|
||||
return {
|
||||
templates: selected,
|
||||
draftableCount: items.filter((item) => item.drafting_available === true).length,
|
||||
};
|
||||
}
|
||||
|
||||
async function login(page, args) {
|
||||
await page.goto(`${normalizeBaseUrl(args.baseUrl)}/login`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: args.timeoutMs,
|
||||
});
|
||||
await page.waitForLoadState("networkidle", { timeout: args.timeoutMs });
|
||||
await page.locator('input[type="email"]').fill(args.email);
|
||||
await page.locator('input[type="password"]').fill(args.password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await page.waitForURL(/\/workspace(?:\?.*)?$/, { timeout: args.timeoutMs });
|
||||
}
|
||||
|
||||
function safeFilename(value) {
|
||||
@@ -153,7 +198,7 @@ function hasMarkdownHeadingArtifacts(text) {
|
||||
|
||||
function detectReadabilityIssues(text) {
|
||||
const checks = [
|
||||
["outer-template-metadata", /TPL-[A-Z0-9-]+|复核状态|合同正文|文书正文|使用边界/],
|
||||
["outer-template-metadata", /\bTPL-[A-Z0-9-]+\b|(?:^|\n)\s*(?:模板编号|复核状态|合同正文|文书正文|使用边界)\s*[::]/m],
|
||||
["duplicate-nei", /内\s+内/],
|
||||
["duplicate-an", /按\s+按/],
|
||||
["duplicate-period", /。。/],
|
||||
@@ -247,6 +292,276 @@ function buildSpecificValue(template, variableName, field) {
|
||||
return `${template.name}${name}测试值`;
|
||||
}
|
||||
|
||||
function cssAttributeValue(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function hasTemplateInput(value) {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === "object") return Object.keys(value).length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesInputCondition(condition, values) {
|
||||
if (!condition) return true;
|
||||
const raw = values[condition.field];
|
||||
const value = typeof raw === "string" ? raw.trim() : raw;
|
||||
switch (condition.operator || "equals") {
|
||||
case "present":
|
||||
return hasTemplateInput(value);
|
||||
case "equals":
|
||||
return value === condition.value;
|
||||
case "not_equals":
|
||||
return hasTemplateInput(value) && value !== condition.value;
|
||||
case "contains_any":
|
||||
return Array.isArray(condition.value) && condition.value.some((token) => String(value || "").includes(token));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function activeVariableNames(template, values) {
|
||||
const specs = template.input_schema?.fields || {};
|
||||
let effective = { ...values };
|
||||
for (let index = 0; index <= Object.keys(specs).length; index += 1) {
|
||||
const inactive = new Set(
|
||||
Object.entries(specs)
|
||||
.filter(([, spec]) => !matchesInputCondition(spec.active_when, effective))
|
||||
.map(([name]) => name),
|
||||
);
|
||||
if (!Object.keys(effective).some((name) => inactive.has(name))) break;
|
||||
effective = Object.fromEntries(Object.entries(effective).filter(([name]) => !inactive.has(name)));
|
||||
}
|
||||
return variableNames(template).all.filter((name) => matchesInputCondition(specs[name]?.active_when, effective));
|
||||
}
|
||||
|
||||
async function selectAvailableOption(select, preferredValue) {
|
||||
const option = await select.locator("option").evaluateAll((options, preferred) => {
|
||||
const match = options.find((item) => String(item.value) === String(preferred))
|
||||
|| options.find((item) => String(item.value || "").trim().length > 0);
|
||||
return match ? { value: match.value, label: (match.textContent || match.value).trim() } : null;
|
||||
}, preferredValue);
|
||||
if (!option) throw new Error("选择字段没有可用选项。");
|
||||
await select.selectOption(option.value);
|
||||
return option;
|
||||
}
|
||||
|
||||
async function fillTemplateField(page, template, variableName) {
|
||||
const selector = `[data-template-field="${cssAttributeValue(variableName)}"]`;
|
||||
const container = page.locator(selector).filter({ visible: true }).first();
|
||||
if ((await container.count()) !== 1) {
|
||||
return {
|
||||
name: `var:${variableName}`,
|
||||
variableName,
|
||||
found: 0,
|
||||
filled: false,
|
||||
required: false,
|
||||
kind: template.input_schema?.fields?.[variableName]?.data_type || "text",
|
||||
value: "",
|
||||
expectedValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
const spec = template.input_schema?.fields?.[variableName] || {};
|
||||
const kind = spec.data_type || "text";
|
||||
const namedControl = container.locator(`[name="var:${cssAttributeValue(variableName)}"]`).first();
|
||||
const required = (await container.locator("[required]").count()) > 0;
|
||||
const sampleValues = template.input_schema?.sample_values;
|
||||
const hasSample = Boolean(sampleValues) && Object.prototype.hasOwnProperty.call(sampleValues, variableName);
|
||||
const sampleValue = hasSample ? sampleValues[variableName] : undefined;
|
||||
const state = {
|
||||
name: `var:${variableName}`,
|
||||
variableName,
|
||||
found: 1,
|
||||
filled: false,
|
||||
required,
|
||||
kind,
|
||||
role: spec.role || "body_fact",
|
||||
value: "",
|
||||
expectedValues: [],
|
||||
};
|
||||
|
||||
// Typed templates ship a validated positive-baseline fixture. An explicitly
|
||||
// empty optional sample is intentional and must not be replaced with made-up
|
||||
// data that could activate warnings or violate a cross-field rule.
|
||||
if (template.input_schema && !required && (!hasSample || !hasTemplateInput(sampleValue))) {
|
||||
return { ...state, skippedReason: "empty_optional_sample" };
|
||||
}
|
||||
|
||||
if (kind === "choice") {
|
||||
const option = await selectAvailableOption(namedControl, hasTemplateInput(sampleValue) ? sampleValue : undefined);
|
||||
return { ...state, filled: true, value: option.value, expectedValues: [option.label] };
|
||||
}
|
||||
if (kind === "period") {
|
||||
const options = await namedControl.locator("option").evaluateAll((items) => items.map((item) => item.value));
|
||||
const sample = sampleValue && typeof sampleValue === "object" && !Array.isArray(sampleValue) ? sampleValue : {};
|
||||
const mode = options.includes(sample.mode)
|
||||
? sample.mode
|
||||
: options.includes("dates") ? "dates" : options.find((value) => value) || options[0];
|
||||
await namedControl.selectOption(mode);
|
||||
if (mode === "relative") {
|
||||
const anchor = String(sample.anchor || "双方完成验收之日");
|
||||
const days = String(sample.days || "365");
|
||||
await container.getByLabel(new RegExp(`${escapeRegex(variableName)}起算事件`)).fill(anchor);
|
||||
await container.getByLabel(new RegExp(`${escapeRegex(variableName)}自然日天数`)).fill(days);
|
||||
return {
|
||||
...state,
|
||||
filled: true,
|
||||
value: { mode, anchor, days },
|
||||
expectedValues: [anchor, days],
|
||||
};
|
||||
}
|
||||
const dates = container.locator('input[type="date"]');
|
||||
const start = String(sample.start || "2026-07-09");
|
||||
await dates.nth(0).fill(start);
|
||||
const value = { mode, start };
|
||||
const expectedValues = [start];
|
||||
if (mode === "dates" && (await dates.count()) > 1) {
|
||||
const end = String(sample.end || "2027-07-08");
|
||||
await dates.nth(1).fill(end);
|
||||
value.end = end;
|
||||
expectedValues.push(end);
|
||||
}
|
||||
return { ...state, filled: true, value, expectedValues };
|
||||
}
|
||||
if (kind === "dispute_resolution") {
|
||||
const options = await namedControl.locator("option").evaluateAll((items) => items.map((item) => item.value));
|
||||
const sample = sampleValue && typeof sampleValue === "object" && !Array.isArray(sampleValue) ? sampleValue : {};
|
||||
const method = options.includes(sample.method)
|
||||
? sample.method
|
||||
: options.includes("litigation") ? "litigation" : options.find((value) => value);
|
||||
if (!method) throw new Error(`${variableName}没有可用的争议解决路径。`);
|
||||
await namedControl.selectOption(method);
|
||||
const venue = String(sample.venue || (method === "arbitration" ? "上海仲裁委员会" : "合同履行地有管辖权的人民法院"));
|
||||
await container.getByLabel(new RegExp(`${escapeRegex(variableName)}机构`)).fill(venue);
|
||||
return { ...state, filled: true, value: { method, venue }, expectedValues: [venue] };
|
||||
}
|
||||
if (kind === "line_items") {
|
||||
const rows = Array.isArray(sampleValue) && sampleValue.length > 0
|
||||
? sampleValue
|
||||
: [{ name: "验证货物", specification: "标准规格", quantity: "10", unit: "件", unit_price: "10000" }];
|
||||
const labels = ["名称", "规格", "数量", "单位", "单价"];
|
||||
const keys = ["name", "specification", "quantity", "unit", "unit_price"];
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
||||
await container.getByRole("button", { name: "添加货物明细", exact: true }).click();
|
||||
for (let fieldIndex = 0; fieldIndex < labels.length; fieldIndex += 1) {
|
||||
await container.getByLabel(`第${rowIndex + 1}项${labels[fieldIndex]}`, { exact: true }).fill(String(rows[rowIndex][keys[fieldIndex]] || ""));
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
filled: true,
|
||||
value: rows,
|
||||
expectedValues: rows.flatMap((row) => [row.name, row.quantity, row.unit_price].filter(hasTemplateInput).map(String)),
|
||||
};
|
||||
}
|
||||
|
||||
let value;
|
||||
if (hasTemplateInput(sampleValue)) value = String(sampleValue);
|
||||
else if (kind === "date") value = "2026-07-09";
|
||||
else if (kind === "money") value = /单价/.test(variableName) ? "10000" : "100000";
|
||||
else if (kind === "decimal") value = "10";
|
||||
else if (kind === "percentage") value = "10";
|
||||
else if (kind === "duration") value = "30日";
|
||||
else {
|
||||
const tagName = await namedControl.evaluate((element) => element.tagName);
|
||||
value = buildSpecificValue(template, variableName, { tagName });
|
||||
}
|
||||
await namedControl.fill(value);
|
||||
return { ...state, filled: true, value, expectedValues: [value] };
|
||||
}
|
||||
|
||||
async function fillVisibleTemplateFields(page, template, fieldStates, fieldValues) {
|
||||
let stablePasses = 0;
|
||||
for (let guard = 0; guard < 100; guard += 1) {
|
||||
const visibleNames = uniqueValues(await page.locator("[data-template-field]:visible").evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-template-field") || ""),
|
||||
));
|
||||
let nextName = visibleNames.find((name) => !fieldStates.has(name));
|
||||
if (!nextName) {
|
||||
for (const name of visibleNames) {
|
||||
const prior = fieldStates.get(name);
|
||||
if (!prior?.filled) {
|
||||
const selector = `[data-template-field="${cssAttributeValue(name)}"]`;
|
||||
if ((await page.locator(`${selector}:visible [required]`).count()) > 0) {
|
||||
nextName = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!nextName) {
|
||||
// A choice can synchronously update React state but mount its dependent
|
||||
// fields on the following render. Require two quiet DOM observations so
|
||||
// a newly-required conditional field is not missed before Next is clicked.
|
||||
if (stablePasses < 2) {
|
||||
stablePasses += 1;
|
||||
await page.waitForTimeout(75);
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
stablePasses = 0;
|
||||
const state = await fillTemplateField(page, template, nextName);
|
||||
fieldStates.set(nextName, state);
|
||||
if (state.filled) fieldValues[nextName] = state.value;
|
||||
}
|
||||
throw new Error("字段填写循环超过安全上限,可能存在动态字段循环。");
|
||||
}
|
||||
|
||||
async function acknowledgeWarningsIfPresent(page) {
|
||||
const acknowledgement = page.locator('section[aria-label="字段校验结果"] label').filter({ hasText: "我已阅读上述提示" }).locator('input[type="checkbox"]');
|
||||
if ((await acknowledgement.count()) > 0 && await acknowledgement.isVisible()) {
|
||||
if (!(await acknowledgement.isChecked())) await acknowledgement.check();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function advanceRequiredWizard(page, template, fieldStatesByName, fieldValues, args) {
|
||||
for (let guard = 0; guard < 50; guard += 1) {
|
||||
await fillVisibleTemplateFields(page, template, fieldStatesByName, fieldValues);
|
||||
const nextButton = page.getByRole("button", { name: "下一步", exact: true });
|
||||
if ((await nextButton.count()) === 0 || !(await nextButton.isVisible())) return;
|
||||
const currentStep = await page.locator(".guided-draft-step").getAttribute("aria-label");
|
||||
await nextButton.click();
|
||||
await page.waitForFunction(
|
||||
(previousStep) => document.querySelector(".guided-draft-step")?.getAttribute("aria-label") !== previousStep
|
||||
|| Boolean(document.querySelector('.action-row [role="alert"]')),
|
||||
currentStep,
|
||||
{ timeout: args.timeoutMs },
|
||||
);
|
||||
const nextStep = await page.locator(".guided-draft-step").getAttribute("aria-label");
|
||||
if (nextStep === currentStep) {
|
||||
const actionMessage = await page.locator('.action-row [role="alert"]').textContent().catch(() => "当前步骤校验失败。");
|
||||
const validationMessage = await page.locator('section[aria-label="字段校验结果"]').textContent().catch(() => "");
|
||||
const currentFields = await page.locator('.guided-draft-step [data-template-field]').evaluateAll((elements) => elements.map((element) => ({
|
||||
name: element.getAttribute("data-template-field") || "",
|
||||
values: Array.from(element.querySelectorAll("input,select,textarea")).map((control) => control.value),
|
||||
})));
|
||||
throw new Error(`${(actionMessage || "当前步骤校验失败。").trim()} ${(validationMessage || "").trim()} current_fields=${JSON.stringify(currentFields)}`.trim());
|
||||
}
|
||||
}
|
||||
throw new Error("必填字段向导超过安全步骤上限。");
|
||||
}
|
||||
|
||||
async function rewindRequiredWizard(page, args) {
|
||||
for (let guard = 0; guard < 50; guard += 1) {
|
||||
const previousButton = page.getByRole("button", { name: "上一步", exact: true });
|
||||
if ((await previousButton.count()) === 0 || !(await previousButton.isVisible()) || await previousButton.isDisabled()) return;
|
||||
const currentStep = await page.locator(".guided-draft-step").getAttribute("aria-label");
|
||||
await previousButton.click();
|
||||
await page.waitForFunction(
|
||||
(previousStep) => document.querySelector(".guided-draft-step")?.getAttribute("aria-label") !== previousStep,
|
||||
currentStep,
|
||||
{ timeout: args.timeoutMs },
|
||||
);
|
||||
}
|
||||
throw new Error("必填字段向导回溯超过安全步骤上限。");
|
||||
}
|
||||
|
||||
async function sweepTemplate(page, template, args) {
|
||||
const templateVariables = variableNames(template);
|
||||
const title = `全量验证-${template.name.replace(/模板$/, "")}草稿`;
|
||||
@@ -257,7 +572,7 @@ async function sweepTemplate(page, template, args) {
|
||||
timeout: args.timeoutMs,
|
||||
});
|
||||
|
||||
await page.getByText(`${args.expectedCount} 个模板`, { exact: true }).first().waitFor({ timeout: args.timeoutMs });
|
||||
await page.getByText(`${args.catalogCount} 个模板`, { exact: true }).first().waitFor({ timeout: args.timeoutMs });
|
||||
await page.locator(".template-draft-summary").filter({ hasText: template.name }).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: args.timeoutMs,
|
||||
@@ -265,39 +580,72 @@ async function sweepTemplate(page, template, args) {
|
||||
await page.locator('input[name="title"]').fill(title);
|
||||
await page.locator('input[name="owner"]').fill(owner);
|
||||
|
||||
const optionalOpen = await page
|
||||
.locator(".template-optional-box")
|
||||
.evaluate((el) => el.hasAttribute("open"))
|
||||
.catch(() => null);
|
||||
const fieldStatesByName = new Map();
|
||||
const fieldValues = {};
|
||||
await advanceRequiredWizard(page, template, fieldStatesByName, fieldValues, args);
|
||||
|
||||
const formFields = await page.locator('input[name^="var:"], textarea[name^="var:"]').evaluateAll((els) =>
|
||||
els.map((el) => ({
|
||||
name: el.getAttribute("name") || "",
|
||||
required: el.hasAttribute("required"),
|
||||
tagName: el.tagName,
|
||||
label: el.closest("label")?.querySelector("span")?.textContent?.trim() || "",
|
||||
})),
|
||||
);
|
||||
const formVariableNames = uniqueValues(formFields.map((field) => field.name.replace(/^var:/, "")));
|
||||
const missingFormVariables = templateVariables.all.filter((name) => !formVariableNames.includes(name));
|
||||
const extraFormVariables = formVariableNames.filter((name) => !templateVariables.all.includes(name));
|
||||
|
||||
const fieldStates = [];
|
||||
for (const formField of formFields) {
|
||||
const variableName = formField.name.replace(/^var:/, "");
|
||||
const value = buildSpecificValue(template, variableName, formField);
|
||||
const selector = `[name="${formField.name.replace(/"/g, '\\"')}"]`;
|
||||
const field = page.locator(selector);
|
||||
const count = await field.count();
|
||||
if (count !== 1) {
|
||||
fieldStates.push({ ...formField, variableName, found: count, filled: false, value });
|
||||
continue;
|
||||
const hasDynamicFields = Object.values(template.input_schema?.fields || {}).some((spec) => spec.active_when || spec.required_when);
|
||||
if (hasDynamicFields) {
|
||||
for (let pass = 0; pass < 3; pass += 1) {
|
||||
const before = Array.from(fieldStatesByName.values()).filter((state) => state.filled).length;
|
||||
await rewindRequiredWizard(page, args);
|
||||
await advanceRequiredWizard(page, template, fieldStatesByName, fieldValues, args);
|
||||
const after = Array.from(fieldStatesByName.values()).filter((state) => state.filled).length;
|
||||
if (after === before) break;
|
||||
}
|
||||
await field.fill(value);
|
||||
fieldStates.push({ ...formField, variableName, found: count, filled: true, value });
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /生成.*草稿/ }).click();
|
||||
const optionalDetails = page.locator("details.template-optional-box");
|
||||
let optionalOpen = null;
|
||||
if ((await optionalDetails.count()) > 0 && await optionalDetails.isVisible()) {
|
||||
optionalOpen = await optionalDetails.evaluate((element) => element.hasAttribute("open"));
|
||||
if (!optionalOpen) {
|
||||
await optionalDetails.locator("summary").click();
|
||||
await page.waitForFunction(() => Boolean(document.querySelector("details.template-optional-box")?.hasAttribute("open")), undefined, { timeout: args.timeoutMs });
|
||||
optionalOpen = true;
|
||||
}
|
||||
await fillVisibleTemplateFields(page, template, fieldStatesByName, fieldValues);
|
||||
}
|
||||
|
||||
const fieldStates = Array.from(fieldStatesByName.values());
|
||||
const formVariableNames = uniqueValues(fieldStates.map((field) => field.variableName));
|
||||
const expectedActiveVariables = activeVariableNames(template, fieldValues);
|
||||
const inactiveVariables = templateVariables.all.filter((name) => !expectedActiveVariables.includes(name));
|
||||
const missingFormVariables = expectedActiveVariables.filter((name) => !formVariableNames.includes(name));
|
||||
const extraFormVariables = formVariableNames.filter((name) => !templateVariables.all.includes(name));
|
||||
|
||||
await acknowledgeWarningsIfPresent(page);
|
||||
const createButton = page.getByRole("button", { name: /生成.*草稿/ });
|
||||
await createButton.click();
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const acknowledgement = document.querySelector('section[aria-label="字段校验结果"] input[type="checkbox"]');
|
||||
return location.pathname.startsWith("/contracts/")
|
||||
|| Boolean(acknowledgement && !acknowledgement.checked)
|
||||
|| Boolean(document.querySelector('.action-row [role="alert"]'));
|
||||
},
|
||||
undefined,
|
||||
{ timeout: args.timeoutMs },
|
||||
);
|
||||
if (!page.url().includes("/contracts/")) {
|
||||
const acknowledgement = page.locator('section[aria-label="字段校验结果"] input[type="checkbox"]');
|
||||
if ((await acknowledgement.count()) > 0 && await acknowledgement.isVisible() && !(await acknowledgement.isChecked())) {
|
||||
await acknowledgement.check();
|
||||
await createButton.click();
|
||||
await page.waitForFunction(
|
||||
() => location.pathname.startsWith("/contracts/") || Boolean(document.querySelector('.action-row [role="alert"]')),
|
||||
undefined,
|
||||
{ timeout: args.timeoutMs },
|
||||
);
|
||||
if (!page.url().includes("/contracts/")) {
|
||||
const message = await page.locator('.action-row [role="alert"]').textContent().catch(() => "确认警告后仍无法生成草稿。");
|
||||
throw new Error((message || "确认警告后仍无法生成草稿。").trim());
|
||||
}
|
||||
} else {
|
||||
const message = await page.locator('.action-row [role="alert"]').textContent().catch(() => "生成草稿失败。");
|
||||
throw new Error((message || "生成草稿失败。").trim());
|
||||
}
|
||||
}
|
||||
await page.waitForURL("**/contracts/**", { timeout: args.timeoutMs });
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.locator(".document-preview").waitFor({ state: "visible", timeout: args.timeoutMs });
|
||||
@@ -321,13 +669,13 @@ async function sweepTemplate(page, template, args) {
|
||||
};
|
||||
});
|
||||
|
||||
const missingFilledFields = fieldStates.filter((item) => !item.filled);
|
||||
const missingFilledFields = fieldStates.filter((item) => item.required && !item.filled);
|
||||
const unresolvedPlaceholders = hasUnresolvedPlaceholders(detail.documentText);
|
||||
const markdownHeadingArtifacts = hasMarkdownHeadingArtifacts(detail.documentText);
|
||||
const readabilityIssues = detectReadabilityIssues(detail.documentText);
|
||||
const missingExpectedValues = fieldStates
|
||||
.filter((item) => item.filled && item.required)
|
||||
.filter((item) => !detailIncludesValue(detail.documentText, item.value))
|
||||
.filter((item) => item.filled && item.required && item.role === "body_fact")
|
||||
.filter((item) => item.expectedValues.length > 0 && item.expectedValues.some((value) => !detailIncludesValue(detail.documentText, value)))
|
||||
.map((item) => item.variableName);
|
||||
|
||||
const pass =
|
||||
@@ -348,7 +696,9 @@ async function sweepTemplate(page, template, args) {
|
||||
pass,
|
||||
optionalOpen,
|
||||
expectedVariableCount: templateVariables.all.length,
|
||||
expectedActiveVariableCount: expectedActiveVariables.length,
|
||||
renderedFieldCount: formVariableNames.length,
|
||||
inactiveVariables,
|
||||
missingFormVariables,
|
||||
extraFormVariables,
|
||||
missingFilledFields,
|
||||
@@ -356,8 +706,9 @@ async function sweepTemplate(page, template, args) {
|
||||
unresolvedPlaceholders,
|
||||
markdownHeadingArtifacts,
|
||||
readabilityIssues,
|
||||
textareaNames: fieldStates.filter((item) => item.tagName === "TEXTAREA").map((item) => item.variableName),
|
||||
inputNames: fieldStates.filter((item) => item.tagName === "INPUT").map((item) => item.variableName),
|
||||
structuredFieldNames: fieldStates.filter((item) => ["period", "dispute_resolution", "line_items"].includes(item.kind)).map((item) => item.variableName),
|
||||
choiceFieldNames: fieldStates.filter((item) => item.kind === "choice").map((item) => item.variableName),
|
||||
scalarFieldNames: fieldStates.filter((item) => !["period", "dispute_resolution", "line_items", "choice"].includes(item.kind)).map((item) => item.variableName),
|
||||
detail,
|
||||
};
|
||||
}
|
||||
@@ -367,7 +718,7 @@ async function verifyTemplateTreeCoverage(page, templates, args) {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: args.timeoutMs,
|
||||
});
|
||||
await page.getByText(`${args.expectedCount} 个模板`, { exact: true }).first().waitFor({ timeout: args.timeoutMs });
|
||||
await page.getByText(`${args.catalogCount} 个模板`, { exact: true }).first().waitFor({ timeout: args.timeoutMs });
|
||||
const treeButtonTexts = await page.locator(".template-tree-pane button").evaluateAll((buttons) =>
|
||||
buttons.map((button) => button.textContent || ""),
|
||||
);
|
||||
@@ -395,12 +746,20 @@ async function main() {
|
||||
const outputDir = resolveOutputDir(args.outputDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const templates = await loadPublishedTemplates(args.contractApiBaseUrl);
|
||||
const catalog = await loadTemplateCatalog(args.contractApiBaseUrl, args.selection, args.templateId);
|
||||
const templates = catalog.templates;
|
||||
args.catalogCount = catalog.draftableCount;
|
||||
const templateCountPass = templates.length === args.expectedCount;
|
||||
|
||||
const browser = await chromium.launch({ headless: !args.headed });
|
||||
const browser = await chromium.launch({
|
||||
headless: !args.headed,
|
||||
...(args.browserExecutable ? { executablePath: args.browserExecutable } : {}),
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
|
||||
page.setDefaultTimeout(args.timeoutMs);
|
||||
if (args.email) {
|
||||
await login(page, args);
|
||||
}
|
||||
|
||||
const treeCoverage = await verifyTemplateTreeCoverage(page, templates, args);
|
||||
const results = [];
|
||||
@@ -439,6 +798,8 @@ async function main() {
|
||||
baseUrl: args.baseUrl,
|
||||
contractApiBaseUrl: args.contractApiBaseUrl,
|
||||
expectedCount: args.expectedCount,
|
||||
selection: args.selection,
|
||||
authenticated: Boolean(args.email),
|
||||
templateCount: templates.length,
|
||||
templateCountPass,
|
||||
treeCoverage,
|
||||
|
||||
Reference in New Issue
Block a user