429 lines
18 KiB
JavaScript
429 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
"use strict";
|
||
|
|
|
||
|
|
const assert = require("node:assert");
|
||
|
|
const fs = require("node:fs/promises");
|
||
|
|
const path = require("node:path");
|
||
|
|
const { chromium } = require("playwright");
|
||
|
|
const {
|
||
|
|
BASE_URL,
|
||
|
|
UI_TIMEOUT_MS,
|
||
|
|
cleanupDocuments,
|
||
|
|
createTempDocument,
|
||
|
|
ensureAuthenticated,
|
||
|
|
openDocument,
|
||
|
|
requestJson,
|
||
|
|
} = require("./tree-shell-smoke-helpers");
|
||
|
|
|
||
|
|
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persistence-smoke");
|
||
|
|
|
||
|
|
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
|
||
|
|
const payload = await requestJson(
|
||
|
|
requestContext,
|
||
|
|
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||
|
|
{ method: "GET" },
|
||
|
|
);
|
||
|
|
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
|
||
|
|
return payload.result;
|
||
|
|
}
|
||
|
|
|
||
|
|
function findBlockWithText(aggregate, text) {
|
||
|
|
const blocks = aggregate?.body?.blockDocument?.blocks;
|
||
|
|
if (!Array.isArray(blocks)) return null;
|
||
|
|
return blocks.find((block) => typeof block?.text === "string" && block.text.includes(text)) ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForRuntimeIsland(page) {
|
||
|
|
await page.waitForFunction(
|
||
|
|
() => {
|
||
|
|
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||
|
|
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
|
||
|
|
return (
|
||
|
|
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
|
||
|
|
host?.getAttribute("data-runtime-editor-status") !== "error" &&
|
||
|
|
editor instanceof HTMLElement &&
|
||
|
|
editor.isContentEditable
|
||
|
|
);
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
{ timeout: UI_TIMEOUT_MS },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForPageTitleInput(page) {
|
||
|
|
const input = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
|
||
|
|
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||
|
|
return input;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readVisibleTitle(page) {
|
||
|
|
const titleInput = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
|
||
|
|
if (await titleInput.isVisible().catch(() => false)) {
|
||
|
|
return (await titleInput.inputValue()).trim();
|
||
|
|
}
|
||
|
|
const heading = page.locator("h1").first();
|
||
|
|
return ((await heading.textContent()) ?? "").trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readEditorText(page) {
|
||
|
|
return page.evaluate(() => {
|
||
|
|
const editor = document.querySelector(
|
||
|
|
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
|
||
|
|
);
|
||
|
|
return (editor?.textContent ?? "").trim();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function renameThroughPageHead(page, documentId, title) {
|
||
|
|
const titleInput = await waitForPageTitleInput(page);
|
||
|
|
const responsePromise = page.waitForResponse(
|
||
|
|
async (response) => {
|
||
|
|
if (!response.url().includes("/api/documents/title") || response.request().method() !== "POST") {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const payload = response.request().postDataJSON();
|
||
|
|
return (
|
||
|
|
payload?.documentId === documentId &&
|
||
|
|
payload?.title === title &&
|
||
|
|
payload?.commandName === "page.head.updateTitle" &&
|
||
|
|
response.ok()
|
||
|
|
);
|
||
|
|
},
|
||
|
|
{ timeout: UI_TIMEOUT_MS },
|
||
|
|
);
|
||
|
|
await titleInput.click({ timeout: UI_TIMEOUT_MS });
|
||
|
|
await page.keyboard.press("Control+a");
|
||
|
|
await page.keyboard.type(title, { delay: 10 });
|
||
|
|
await titleInput.blur();
|
||
|
|
const response = await responsePromise;
|
||
|
|
return response.status();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function typeIntoEditor(page, documentId, text) {
|
||
|
|
const responsePromise = page.waitForResponse(
|
||
|
|
async (response) => {
|
||
|
|
if (!response.url().includes("/api/documents/save") || response.request().method() !== "POST") {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const payload = response.request().postDataJSON();
|
||
|
|
return payload?.documentId === documentId && response.ok();
|
||
|
|
},
|
||
|
|
{ timeout: UI_TIMEOUT_MS },
|
||
|
|
);
|
||
|
|
const editor = page
|
||
|
|
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||
|
|
.first();
|
||
|
|
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||
|
|
await page.keyboard.press("Control+a");
|
||
|
|
await page.keyboard.press("Backspace");
|
||
|
|
await page.keyboard.type(text, { delay: 10 });
|
||
|
|
const response = await responsePromise;
|
||
|
|
return response.status();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function openPageSettingsDialog(page) {
|
||
|
|
const trigger = page.getByTestId("wolai-page-settings-trigger");
|
||
|
|
await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||
|
|
await trigger.click({ timeout: UI_TIMEOUT_MS });
|
||
|
|
await page.locator('[data-page-option-checkbox="wideLayout"]').waitFor({
|
||
|
|
state: "visible",
|
||
|
|
timeout: UI_TIMEOUT_MS,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForOptionsResponse(page, documentId, mutate) {
|
||
|
|
const responsePromise = page.waitForResponse(
|
||
|
|
async (response) => {
|
||
|
|
if (!response.url().includes("/api/documents/options") || response.request().method() !== "POST") {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const payload = response.request().postDataJSON();
|
||
|
|
return payload?.documentId === documentId && response.ok();
|
||
|
|
},
|
||
|
|
{ timeout: UI_TIMEOUT_MS },
|
||
|
|
);
|
||
|
|
await mutate();
|
||
|
|
const response = await responsePromise;
|
||
|
|
const payload = await response.json();
|
||
|
|
assert.equal(payload?.meta?.commandName, "page.layout.updateOptions", "页面设置写入必须走 page.layout.updateOptions");
|
||
|
|
assert.equal(payload?.meta?.canonicalCommand, "page.layout.updateOptions", "页面设置 canonical command 必须稳定");
|
||
|
|
return response.status();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function setPageOptionsThroughUi(page, documentId) {
|
||
|
|
await openPageSettingsDialog(page);
|
||
|
|
const wideStatus = await waitForOptionsResponse(page, documentId, async () => {
|
||
|
|
await page.locator('[data-page-option-checkbox="wideLayout"]').click({ timeout: UI_TIMEOUT_MS });
|
||
|
|
});
|
||
|
|
const smallStatus = await waitForOptionsResponse(page, documentId, async () => {
|
||
|
|
await page.locator('[data-page-option-checkbox="smallText"]').click({ timeout: UI_TIMEOUT_MS });
|
||
|
|
});
|
||
|
|
const densityStatus = await waitForOptionsResponse(page, documentId, async () => {
|
||
|
|
await page.locator('[data-page-settings-tab="custom"]').click({ timeout: UI_TIMEOUT_MS });
|
||
|
|
await page.locator('[data-page-option-select="layoutDensity"]').selectOption("compact");
|
||
|
|
});
|
||
|
|
return { wideStatus, smallStatus, densityStatus };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readRuntimePageOptions(page) {
|
||
|
|
return page.evaluate(() => {
|
||
|
|
const shell = document.querySelector(".document-shell");
|
||
|
|
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||
|
|
const editorSurface = editorRoot?.querySelector(".editor-surface");
|
||
|
|
const editor = editorRoot?.querySelector(".ProseMirror");
|
||
|
|
const firstParagraph = editorRoot?.querySelector(".ProseMirror p");
|
||
|
|
return {
|
||
|
|
htmlWide: document.documentElement.getAttribute("data-page-wide-layout"),
|
||
|
|
htmlSmall: document.documentElement.getAttribute("data-page-small-text"),
|
||
|
|
htmlDensity: document.documentElement.getAttribute("data-layout-density"),
|
||
|
|
shellWide: shell instanceof HTMLElement ? shell.getAttribute("data-page-wide-layout") : null,
|
||
|
|
shellSmall: shell instanceof HTMLElement ? shell.getAttribute("data-page-small-text") : null,
|
||
|
|
shellDensity: shell instanceof HTMLElement ? shell.getAttribute("data-layout-density") : null,
|
||
|
|
editorRootWide: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-wide-layout") : null,
|
||
|
|
editorRootSmall: editorRoot instanceof HTMLElement ? editorRoot.getAttribute("data-page-small-text") : null,
|
||
|
|
editorSurfaceDensity:
|
||
|
|
editorSurface instanceof HTMLElement ? editorSurface.getAttribute("data-layout-density") : null,
|
||
|
|
editorFontSize: editor instanceof HTMLElement ? window.getComputedStyle(editor).fontSize : null,
|
||
|
|
paragraphMarginBottom:
|
||
|
|
firstParagraph instanceof HTMLElement ? window.getComputedStyle(firstParagraph).marginBottom : null,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readPageSettingsControls(page) {
|
||
|
|
await openPageSettingsDialog(page);
|
||
|
|
return {
|
||
|
|
wideLayout: await page.locator('[data-page-option-checkbox="wideLayout"]').isChecked(),
|
||
|
|
smallText: await page.locator('[data-page-option-checkbox="smallText"]').isChecked(),
|
||
|
|
layoutDensity: await page.locator('[data-page-option-select="layoutDensity"]').inputValue(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForRuntimePageOptions(page) {
|
||
|
|
try {
|
||
|
|
await page.waitForFunction(
|
||
|
|
() => {
|
||
|
|
const shell = document.querySelector(".document-shell");
|
||
|
|
const editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||
|
|
const editorSurface = editorRoot?.querySelector(".editor-surface");
|
||
|
|
return (
|
||
|
|
document.documentElement.getAttribute("data-page-wide-layout") === "true" &&
|
||
|
|
document.documentElement.getAttribute("data-page-small-text") === "true" &&
|
||
|
|
document.documentElement.getAttribute("data-layout-density") === "compact" &&
|
||
|
|
shell?.getAttribute("data-page-wide-layout") === "true" &&
|
||
|
|
shell?.getAttribute("data-page-small-text") === "true" &&
|
||
|
|
shell?.getAttribute("data-layout-density") === "compact" &&
|
||
|
|
editorRoot?.getAttribute("data-page-wide-layout") === "true" &&
|
||
|
|
editorRoot?.getAttribute("data-page-small-text") === "true" &&
|
||
|
|
editorSurface?.getAttribute("data-layout-density") === "compact"
|
||
|
|
);
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
{ timeout: UI_TIMEOUT_MS },
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
const runtime = await readRuntimePageOptions(page).catch((runtimeError) => ({
|
||
|
|
readRuntimeError: runtimeError instanceof Error ? runtimeError.message : String(runtimeError),
|
||
|
|
}));
|
||
|
|
const embedded = await page.evaluate(() => {
|
||
|
|
const node = document.getElementById("__MNOTE_PAGE_AGGREGATE__");
|
||
|
|
try {
|
||
|
|
return node?.textContent ? JSON.parse(node.textContent) : null;
|
||
|
|
} catch (parseError) {
|
||
|
|
return { parseError: parseError instanceof Error ? parseError.message : String(parseError) };
|
||
|
|
}
|
||
|
|
}).catch((embeddedError) => ({
|
||
|
|
readEmbeddedError: embeddedError instanceof Error ? embeddedError.message : String(embeddedError),
|
||
|
|
}));
|
||
|
|
throw new Error(
|
||
|
|
`等待 runtime page options 同步超时:${JSON.stringify({ runtime, embeddedPageOptions: embedded?.layout?.pageOptions ?? null })}\n${
|
||
|
|
error instanceof Error ? error.stack || error.message : String(error)
|
||
|
|
}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForAggregateState(requestContext, workspaceId, documentId, expectedTitle, expectedText) {
|
||
|
|
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||
|
|
let aggregate = null;
|
||
|
|
while (Date.now() < deadline) {
|
||
|
|
aggregate = await fetchPageAggregate(requestContext, workspaceId, documentId);
|
||
|
|
const options = aggregate?.layout?.pageOptions ?? {};
|
||
|
|
const matchedBlock = findBlockWithText(aggregate, expectedText);
|
||
|
|
if (
|
||
|
|
aggregate?.head?.title === expectedTitle &&
|
||
|
|
matchedBlock &&
|
||
|
|
options.wideLayout === true &&
|
||
|
|
options.smallText === true &&
|
||
|
|
options.layoutDensity === "compact"
|
||
|
|
) {
|
||
|
|
return { aggregate, matchedBlock };
|
||
|
|
}
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||
|
|
}
|
||
|
|
throw new Error(
|
||
|
|
`等待 Page Aggregate 刷新持久态同步超时:${JSON.stringify({
|
||
|
|
documentId,
|
||
|
|
expectedTitle,
|
||
|
|
expectedText,
|
||
|
|
lastTitle: aggregate?.head?.title ?? null,
|
||
|
|
lastOptions: aggregate?.layout?.pageOptions ?? null,
|
||
|
|
hasExpectedBlock: Boolean(aggregate && findBlockWithText(aggregate, expectedText)),
|
||
|
|
})}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertRuntimeOptions(runtime, label) {
|
||
|
|
assert.equal(runtime.htmlWide, "true", `${label} html wideLayout 应保持 true`);
|
||
|
|
assert.equal(runtime.htmlSmall, "true", `${label} html smallText 应保持 true`);
|
||
|
|
assert.equal(runtime.htmlDensity, "compact", `${label} html layoutDensity 应保持 compact`);
|
||
|
|
assert.equal(runtime.editorRootWide, "true", `${label} island root wideLayout 应保持 true`);
|
||
|
|
assert.equal(runtime.editorRootSmall, "true", `${label} island root smallText 应保持 true`);
|
||
|
|
assert.equal(runtime.editorSurfaceDensity, "compact", `${label} island surface density 应保持 compact`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
await fs.mkdir(OUT_DIR, { recursive: true });
|
||
|
|
const suffix = Date.now().toString(36);
|
||
|
|
const title = `Page Aggregate refresh ${suffix}`;
|
||
|
|
const bodyText = `Page Aggregate refresh body ${suffix}`;
|
||
|
|
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
|
||
|
|
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
|
||
|
|
const browser = await chromium.launch({ headless: true });
|
||
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||
|
|
const page = await context.newPage();
|
||
|
|
const createdIds = [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
const viewer = await ensureAuthenticated(page, context.request);
|
||
|
|
const target = await createTempDocument(context.request, null);
|
||
|
|
createdIds.push(target.documentId);
|
||
|
|
|
||
|
|
await openDocument(page, target.workspaceId, target.documentId);
|
||
|
|
await waitForPageTitleInput(page);
|
||
|
|
await waitForRuntimeIsland(page);
|
||
|
|
|
||
|
|
const titleStatus = await renameThroughPageHead(page, target.documentId, title);
|
||
|
|
assert((await readVisibleTitle(page)).includes(title), "写入后页头标题应立即显示最新值");
|
||
|
|
|
||
|
|
const saveStatus = await typeIntoEditor(page, target.documentId, bodyText);
|
||
|
|
await page.waitForFunction(
|
||
|
|
(expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText),
|
||
|
|
bodyText,
|
||
|
|
{ timeout: UI_TIMEOUT_MS },
|
||
|
|
);
|
||
|
|
|
||
|
|
const optionStatuses = await setPageOptionsThroughUi(page, target.documentId);
|
||
|
|
await waitForRuntimePageOptions(page);
|
||
|
|
const beforeReloadRuntime = await readRuntimePageOptions(page);
|
||
|
|
assertRuntimeOptions(beforeReloadRuntime, "刷新前");
|
||
|
|
|
||
|
|
const { aggregate: beforeReloadAggregate, matchedBlock: beforeReloadBlock } = await waitForAggregateState(
|
||
|
|
context.request,
|
||
|
|
target.workspaceId,
|
||
|
|
target.documentId,
|
||
|
|
title,
|
||
|
|
bodyText,
|
||
|
|
);
|
||
|
|
|
||
|
|
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||
|
|
await waitForPageTitleInput(page);
|
||
|
|
await waitForRuntimeIsland(page);
|
||
|
|
await page.waitForFunction(
|
||
|
|
(expectedText) => (document.querySelector(".editor-surface .ProseMirror")?.textContent ?? "").includes(expectedText),
|
||
|
|
bodyText,
|
||
|
|
{ timeout: UI_TIMEOUT_MS },
|
||
|
|
);
|
||
|
|
await waitForRuntimePageOptions(page);
|
||
|
|
|
||
|
|
const afterReloadTitle = await readVisibleTitle(page);
|
||
|
|
const afterReloadEditorText = await readEditorText(page);
|
||
|
|
const afterReloadRuntime = await readRuntimePageOptions(page);
|
||
|
|
assert(afterReloadTitle.includes(title), "刷新后页头标题不应回退旧快照");
|
||
|
|
assert(afterReloadEditorText.includes(bodyText), "刷新后正文不应回退旧快照");
|
||
|
|
assertRuntimeOptions(afterReloadRuntime, "刷新后");
|
||
|
|
const afterReloadControls = await readPageSettingsControls(page);
|
||
|
|
assert.equal(afterReloadControls.wideLayout, true, "刷新后页面设置 wideLayout 控件应保持 true");
|
||
|
|
assert.equal(afterReloadControls.smallText, true, "刷新后页面设置 smallText 控件应保持 true");
|
||
|
|
assert.equal(afterReloadControls.layoutDensity, "compact", "刷新后页面设置 layoutDensity 控件应保持 compact");
|
||
|
|
|
||
|
|
const { aggregate: afterReloadAggregate, matchedBlock: afterReloadBlock } = await waitForAggregateState(
|
||
|
|
context.request,
|
||
|
|
target.workspaceId,
|
||
|
|
target.documentId,
|
||
|
|
title,
|
||
|
|
bodyText,
|
||
|
|
);
|
||
|
|
|
||
|
|
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||
|
|
const evidence = {
|
||
|
|
ok: true,
|
||
|
|
baseUrl: BASE_URL,
|
||
|
|
viewerUserId: viewer.userId,
|
||
|
|
workspaceId: target.workspaceId,
|
||
|
|
documentId: target.documentId,
|
||
|
|
expected: {
|
||
|
|
title,
|
||
|
|
bodyText,
|
||
|
|
pageOptions: {
|
||
|
|
wideLayout: true,
|
||
|
|
smallText: true,
|
||
|
|
layoutDensity: "compact",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
commandStatuses: {
|
||
|
|
titleStatus,
|
||
|
|
saveStatus,
|
||
|
|
...optionStatuses,
|
||
|
|
},
|
||
|
|
beforeReload: {
|
||
|
|
aggregate: {
|
||
|
|
title: beforeReloadAggregate.head?.title ?? null,
|
||
|
|
revision: beforeReloadAggregate.body?.revision ?? null,
|
||
|
|
conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null,
|
||
|
|
pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null,
|
||
|
|
blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null,
|
||
|
|
},
|
||
|
|
matchedBlock: {
|
||
|
|
blockId: beforeReloadBlock.blockId,
|
||
|
|
text: beforeReloadBlock.text,
|
||
|
|
revisionRef: beforeReloadBlock.revisionRef,
|
||
|
|
},
|
||
|
|
runtime: beforeReloadRuntime,
|
||
|
|
},
|
||
|
|
afterReload: {
|
||
|
|
aggregate: {
|
||
|
|
title: afterReloadAggregate.head?.title ?? null,
|
||
|
|
revision: afterReloadAggregate.body?.revision ?? null,
|
||
|
|
conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null,
|
||
|
|
pageOptions: afterReloadAggregate.layout?.pageOptions ?? null,
|
||
|
|
blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null,
|
||
|
|
},
|
||
|
|
matchedBlock: {
|
||
|
|
blockId: afterReloadBlock.blockId,
|
||
|
|
text: afterReloadBlock.text,
|
||
|
|
revisionRef: afterReloadBlock.revisionRef,
|
||
|
|
},
|
||
|
|
title: afterReloadTitle,
|
||
|
|
editorText: afterReloadEditorText,
|
||
|
|
runtime: afterReloadRuntime,
|
||
|
|
controls: afterReloadControls,
|
||
|
|
},
|
||
|
|
screenshotPath,
|
||
|
|
evidencePath,
|
||
|
|
};
|
||
|
|
await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
|
||
|
|
console.log(JSON.stringify(evidence, null, 2));
|
||
|
|
} finally {
|
||
|
|
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
|
||
|
|
await page.close().catch(() => undefined);
|
||
|
|
await context.close().catch(() => undefined);
|
||
|
|
await browser.close().catch(() => undefined);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((error) => {
|
||
|
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||
|
|
process.exit(1);
|
||
|
|
});
|