test(templates): sweep all draftable template flows

This commit is contained in:
2026-10-06 14:16:45 +08:00
parent 501cbf9e1e
commit 09d31d3595
@@ -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,