From 09d31d359553685cffaec307ab6dfee3f139a5fe Mon Sep 17 00:00:00 2001 From: yuqei Date: Tue, 6 Oct 2026 14:16:45 +0800 Subject: [PATCH] test(templates): sweep all draftable template flows --- .../scripts/verify-template-draft-sweep.cjs | 453 ++++++++++++++++-- 1 file changed, 407 insertions(+), 46 deletions(-) diff --git a/yuqei-infra/scripts/verify-template-draft-sweep.cjs b/yuqei-infra/scripts/verify-template-draft-sweep.cjs index 4e48b165..a986aff1 100644 --- a/yuqei-infra/scripts/verify-template-draft-sweep.cjs +++ b/yuqei-infra/scripts/verify-template-draft-sweep.cjs @@ -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 Contract web base URL. Default: http://localhost:3000 --contract-api-base-url Contract API base URL. Default: http://127.0.0.1:8100 - --expected-count Expected published template count. Default: 30 + --expected-count Expected selected template count. Default: 60 + --selection draftable | published. Default: draftable + --template-id Restrict the sweep to one template while retaining the full catalog UI assertion. --output-dir Artifact directory. Default: output/playwright/template-draft-sweep/ --screenshots all | failures | none. Default: all + --browser-executable 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 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,