343 lines
21 KiB
JavaScript
343 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const { loginViaAuthForm } = require('./lib/browser-auth-login');
|
|
const assert = require("node:assert");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { chromium } = require("playwright");
|
|
|
|
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
|
const STAMP = Date.now();
|
|
const OUT = process.env.MNOTE_PI_UI_COMPLETION_OUT || path.join(os.tmpdir(), `mnote-pi-ui-completion-${STAMP}`);
|
|
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "180000", 10);
|
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
|
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
|
|
|
function mkdirp(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
async function quickLogin(page) {
|
|
// 7-76 P0: 标准表单登录(无测试快速登录按钮)
|
|
const base =
|
|
(typeof BASE_URL !== "undefined" && BASE_URL) ||
|
|
(typeof baseUrl !== "undefined" && baseUrl) ||
|
|
process.env.MNOTE_UI_BASE_URL ||
|
|
"http://127.0.0.1:3000";
|
|
const timeout =
|
|
(typeof UI_TIMEOUT_MS !== "undefined" && UI_TIMEOUT_MS) ||
|
|
(typeof TIMEOUT !== "undefined" && TIMEOUT) ||
|
|
30_000;
|
|
if (!String(page.url() || "").includes("/auth")) {
|
|
await page.goto(String(base).replace(/\/+$/, "") + "/auth", {
|
|
waitUntil: "commit",
|
|
timeout,
|
|
});
|
|
}
|
|
await loginViaAuthForm(page, {
|
|
baseUrl: base,
|
|
timeoutMs: timeout,
|
|
gotoAuth: false,
|
|
});
|
|
await page
|
|
.waitForURL((url) => !String(url).includes("/auth"), { timeout })
|
|
.catch(() => {});
|
|
}
|
|
|
|
async function startPi(page) {
|
|
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
|
|
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
|
|
await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready"), null, {
|
|
timeout: TIMEOUT,
|
|
});
|
|
const status = await page.request.get(`${BASE}/api/page-ai/pi/status`);
|
|
assert(status.ok(), `Pi status failed: ${status.status()}`);
|
|
return status.json();
|
|
}
|
|
|
|
async function emit(page, payload) {
|
|
await page.evaluate((eventPayload) => {
|
|
window.__mnotePiLabTest.emitRpcEvent(eventPayload);
|
|
}, payload);
|
|
}
|
|
|
|
async function main() {
|
|
mkdirp(OUT);
|
|
const browser = await chromium.launch({
|
|
headless: process.env.MNOTE_PI_UI_COMPLETION_HEADED === "1" ? false : true,
|
|
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
|
});
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
|
|
await context.addInitScript(() => {
|
|
window.__MNOTE_PI_LAB_TEST__ = true;
|
|
});
|
|
const page = await context.newPage();
|
|
const consoleMessages = [];
|
|
page.on("console", (message) => {
|
|
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
|
|
});
|
|
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
|
|
|
|
const result = {
|
|
base: BASE,
|
|
outputDir: OUT,
|
|
screenshots: {},
|
|
checks: {},
|
|
consoleMessages,
|
|
};
|
|
|
|
try {
|
|
await quickLogin(page);
|
|
const startJson = await startPi(page);
|
|
const session = startJson.session || startJson.session || {};
|
|
const sessionId = session.sessionId || startJson.sessionId || (startJson.session && startJson.session.sessionId);
|
|
const sessionRootUri = session.rootUri || (startJson.session && startJson.session.rootUri) || "file:///tmp/mnote-pi-ui-completion";
|
|
result.sessionId = sessionId;
|
|
assert(sessionId, "Pi sessionId missing");
|
|
await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
|
|
|
|
result.checks.composerQuickButtonCount = await page.locator(".wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick]").count();
|
|
result.checks.composerBarCount = await page.locator(".wolai-page-ai-pi-lab-composer-bar").count();
|
|
result.checks.sendModeButtonCount = await page.locator("[data-page-ai-pi-lab-send-mode]").count();
|
|
result.checks.sendInsideInputWrap = await page.locator(".wolai-page-ai-pi-lab-input-wrap [data-page-ai-pi-lab-btn-send]").count() === 1;
|
|
result.checks.bottomQuickKinds = await page.locator(".wolai-page-ai-pi-lab-modebar [data-page-ai-pi-lab-quick]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-quick")));
|
|
result.checks.actionMenuToggleCount = await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").count();
|
|
result.checks.thinkingControlVisible = await page.locator("[data-page-ai-pi-lab-thinking-menu-wrap]").isVisible();
|
|
result.checks.permissionControlVisible = await page.locator("[data-page-ai-pi-lab-permission]").isVisible();
|
|
assert.equal(result.checks.composerQuickButtonCount, 0, "输入框下方不应再重复显示当前页/选区/LightRAG");
|
|
assert.equal(result.checks.composerBarCount, 0, "发送按钮不应单独占用一整行 composer bar");
|
|
assert.equal(result.checks.sendModeButtonCount, 0, "流式插队模式不应作为常驻工具栏按钮");
|
|
assert(result.checks.sendInsideInputWrap, "发送按钮应收进输入框区域");
|
|
assert.deepEqual(result.checks.bottomQuickKinds, ["read-page", "current-folder", "selection", "rag"], "底部工具栏应保留 Pi 实际上下文工具");
|
|
assert.equal(result.checks.actionMenuToggleCount, 1, "+ 菜单应作为输入区操作入口");
|
|
assert(result.checks.thinkingControlVisible, "Pi 官方 thinking level 应在输入区可见");
|
|
assert(result.checks.permissionControlVisible, "MNote 权限/审批状态应在输入区可见");
|
|
|
|
await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click();
|
|
const actionMenu = page.locator("[data-page-ai-pi-lab-action-menu]");
|
|
await actionMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
|
result.checks.actionMenuVisible = await actionMenu.isVisible();
|
|
result.checks.actionMenuItems = await actionMenu.locator("[data-page-ai-pi-lab-menu-action]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-menu-action")));
|
|
result.checks.planModeAvailable = await actionMenu.locator('[data-page-ai-pi-lab-menu-action="plan-mode"]:not(:disabled)', { hasText: "计划评审" }).count() === 1;
|
|
await page.screenshot({ path: path.join(OUT, "00a-plus-permission-thinking-menu.png"), fullPage: false });
|
|
result.screenshots.plusPermissionThinkingMenu = path.join(OUT, "00a-plus-permission-thinking-menu.png");
|
|
assert(result.checks.actionMenuVisible, "+ 菜单应能打开");
|
|
assert(result.checks.actionMenuItems.includes("directory-permission"), "+ 菜单应包含目录权限入口");
|
|
assert(result.checks.actionMenuItems.includes("send-steer"), "+ 菜单应包含执行中引导发送入口");
|
|
assert(result.checks.actionMenuItems.includes("send-followup"), "+ 菜单应包含执行中排队追问入口");
|
|
assert(result.checks.actionMenuItems.includes("plan-mode"), "+ 菜单应包含 Pi Rust plan-mode 计划评审入口");
|
|
assert(result.checks.planModeAvailable, "计划评审应由 Pi Rust 官方 plan-mode 扩展接管并可用");
|
|
await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click();
|
|
|
|
await emit(page, {
|
|
type: "agent_end",
|
|
messages: [{ role: "assistant", content: [{ type: "text", text: "UI_POLISH_MARKDOWN_ACTION" }] }],
|
|
});
|
|
const assistantBubble = page.locator(".wolai-page-ai-pi-lab-message[data-role='assistant'] .wolai-page-ai-pi-lab-bubble").last();
|
|
const copyActions = assistantBubble.locator(".wolai-page-ai-pi-lab-message-actions");
|
|
await copyActions.waitFor({ state: "attached", timeout: TIMEOUT });
|
|
result.checks.copyActionOpacityBeforeHover = await copyActions.evaluate((node) => getComputedStyle(node).opacity);
|
|
result.checks.followupActionCount = await page.locator("[data-page-ai-pi-followup-controls]").count();
|
|
await assistantBubble.hover();
|
|
await page.waitForFunction((selector) => getComputedStyle(document.querySelector(selector)).opacity === "1", ".wolai-page-ai-pi-lab-message[data-role='assistant']:last-of-type .wolai-page-ai-pi-lab-message-actions", { timeout: TIMEOUT }).catch(() => null);
|
|
result.checks.copyActionOpacityAfterHover = await copyActions.evaluate((node) => getComputedStyle(node).opacity);
|
|
await page.screenshot({ path: path.join(OUT, "00-hover-copy-markdown.png"), fullPage: false });
|
|
result.screenshots.hoverCopyMarkdown = path.join(OUT, "00-hover-copy-markdown.png");
|
|
assert.equal(result.checks.copyActionOpacityBeforeHover, "0", "复制 Markdown 默认应隐藏");
|
|
assert.equal(result.checks.copyActionOpacityAfterHover, "1", "复制 Markdown hover 时应显示");
|
|
assert.equal(result.checks.followupActionCount, 0, "页末继续追问/调整回答不应默认显示");
|
|
|
|
await emit(page, {
|
|
type: "extension_ui_request",
|
|
id: "approval-dialog-smoke",
|
|
method: "confirm",
|
|
title: "审批 Pi 工具调用",
|
|
message: "MNote Codex rescue\nmnote.codex_rescue.request",
|
|
mnoteApproval: { approvalId: "approval-dialog-smoke", toolName: "mnote.codex_rescue.request", paramsHash: "smoke" },
|
|
});
|
|
const approvalDialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']");
|
|
await approvalDialog.waitFor({ state: "visible", timeout: TIMEOUT });
|
|
result.checks.approvalDialogVisible = await approvalDialog.isVisible();
|
|
await page.screenshot({ path: path.join(OUT, "00b-approval-dialog.png"), fullPage: false });
|
|
result.screenshots.approvalDialog = path.join(OUT, "00b-approval-dialog.png");
|
|
assert(result.checks.approvalDialogVisible, "Pi 工具审批应显示在 Pi 界面内");
|
|
await approvalDialog.locator("[data-page-ai-pi-lab-ui-cancel]").click();
|
|
|
|
await emit(page, {
|
|
type: "extension_ui_request",
|
|
id: "ask-user-bottom-panel-smoke",
|
|
method: "ask_user",
|
|
title: "选择设置页方案",
|
|
message: "这个请求模拟 Pi ask_user 的输入区上方交互。",
|
|
questions: [{
|
|
header: "布局",
|
|
tab: "layout",
|
|
prompt: "选择 Pi 输入区的交互位置。",
|
|
options: [
|
|
{ label: "底部面板", description: "在输入框上方,不遮住对话。" },
|
|
{ label: "居中弹窗", description: "覆盖 transcript。" },
|
|
],
|
|
allowSkip: false,
|
|
}],
|
|
});
|
|
const askPanel = page.locator("[data-page-ai-pi-lab-ui-dialog='ask_user']");
|
|
await askPanel.waitFor({ state: "visible", timeout: TIMEOUT });
|
|
const askPanelBox = await askPanel.boundingBox();
|
|
const inputWrapBox = await page.locator(".wolai-page-ai-pi-lab-input-wrap").boundingBox();
|
|
const composerContainsAskPanel = await page.locator(".wolai-page-ai-pi-lab-composer [data-page-ai-pi-lab-ui-dialog='ask_user']").count();
|
|
result.checks.askUserBottomPanelVisible = await askPanel.isVisible();
|
|
result.checks.askUserPanelInComposer = composerContainsAskPanel > 0;
|
|
result.checks.askUserPanelAboveInput = !!askPanelBox && !!inputWrapBox && askPanelBox.y + askPanelBox.height <= inputWrapBox.y + 1;
|
|
await page.screenshot({ path: path.join(OUT, "00c-ask-user-bottom-panel.png"), fullPage: false });
|
|
result.screenshots.askUserBottomPanel = path.join(OUT, "00c-ask-user-bottom-panel.png");
|
|
assert(result.checks.askUserBottomPanelVisible, "ask_user 应显示在 Pi 界面内");
|
|
assert(result.checks.askUserPanelInComposer, "ask_user 应挂在 composer 内,而不是页面级弹窗");
|
|
assert(result.checks.askUserPanelAboveInput, "ask_user 面板应贴在输入框上方且不遮住输入框");
|
|
await askPanel.locator("[data-page-ai-pi-lab-ui-cancel]").click();
|
|
|
|
await page.locator("[data-page-ai-pi-lab-history]").click();
|
|
const historyRow = page.locator(`[data-page-ai-pi-lab-history-row="${sessionId}"]`);
|
|
await historyRow.waitFor({ state: "visible", timeout: TIMEOUT });
|
|
result.checks.hasHistoryOpen = await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).count() > 0;
|
|
result.checks.hasHistoryRename = await historyRow.locator(`[data-page-ai-pi-lab-history-rename="${sessionId}"]`).isVisible();
|
|
result.checks.hasHistoryFork = await historyRow.locator(`[data-page-ai-pi-lab-history-fork="${sessionId}"]`).isVisible();
|
|
result.checks.hasHistoryExport = await historyRow.locator(`[data-page-ai-pi-lab-history-export="${sessionId}"]`).isVisible();
|
|
result.checks.hasHistoryDelete = await historyRow.locator(`[data-page-ai-pi-lab-history-delete="${sessionId}"]`).isVisible();
|
|
result.checks.hasHistoryRefresh = await page.locator("[data-page-ai-pi-lab-history-refresh]").isVisible();
|
|
result.checks.hasHistoryClear = await page.locator("[data-page-ai-pi-lab-history-clear]").isVisible();
|
|
result.checks.historyLayerVisible = await page.locator("[data-page-ai-pi-lab-history-layer]").isVisible();
|
|
const historyBox = await page.locator("[data-page-ai-pi-lab-history-panel]").boundingBox();
|
|
const drawerBox = await page.locator(".wolai-page-ai-pi-lab-drawer").boundingBox();
|
|
result.checks.historyDrawerFromLeft = !!historyBox && !!drawerBox && Math.abs(historyBox.x - drawerBox.x) <= 2 && historyBox.height >= drawerBox.height - 4;
|
|
result.checks.commandbarIconCount = await page.locator(".wolai-page-ai-pi-lab-commandbar svg").count();
|
|
result.checks.commandbarRemovedNoopButtons = await page.locator("[data-page-ai-pi-lab-open], [data-page-ai-pi-lab-toggle-artifacts], [data-page-ai-pi-lab-clock], [data-page-ai-pi-lab-notify]").count() === 0;
|
|
await page.screenshot({ path: path.join(OUT, "01-history-session-actions.png"), fullPage: false });
|
|
result.screenshots.historyActions = path.join(OUT, "01-history-session-actions.png");
|
|
assert(result.checks.hasHistoryOpen, "history 缺少打开入口");
|
|
assert(result.checks.hasHistoryRename, "history 缺少重命名入口");
|
|
assert(result.checks.hasHistoryFork, "history 缺少 fork 入口");
|
|
assert(result.checks.hasHistoryExport, "history 缺少导出入口");
|
|
assert(result.checks.hasHistoryDelete, "history 缺少删除入口");
|
|
assert(result.checks.hasHistoryRefresh, "history 缺少刷新入口");
|
|
assert(result.checks.hasHistoryClear, "history 缺少清空入口");
|
|
assert(result.checks.historyLayerVisible, "history 应以左侧抽屉层打开");
|
|
assert(result.checks.historyDrawerFromLeft, "history 应从 Pi 面板左侧弹出");
|
|
assert(result.checks.commandbarRemovedNoopButtons, "顶部工具栏不应保留无实际意义按钮");
|
|
assert(result.checks.commandbarIconCount <= 6, "顶部工具栏应只保留少量有效 SVG 图标");
|
|
|
|
await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).first().click();
|
|
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT });
|
|
await page.locator("[data-page-ai-pi-lab-input]").fill("history continue smoke");
|
|
result.checks.historyReplayBannerVisible = await page.locator("[data-page-ai-pi-lab-replay-banner]").isVisible().catch(() => false);
|
|
result.checks.historyReplayInputDisabled = await page.locator("[data-page-ai-pi-lab-input]").isDisabled();
|
|
result.checks.historyReplaySendDisabled = await page.locator("[data-page-ai-pi-lab-btn-send]").isDisabled();
|
|
await page.screenshot({ path: path.join(OUT, "01b-history-session-continue.png"), fullPage: false });
|
|
result.screenshots.historyReplay = path.join(OUT, "01b-history-session-continue.png");
|
|
assert.equal(result.checks.historyReplayBannerVisible, false, "打开历史 session 后不应显示只读 replay 提示");
|
|
assert.equal(result.checks.historyReplayInputDisabled, false, "打开历史 session 后输入框应可继续编辑");
|
|
assert.equal(result.checks.historyReplaySendDisabled, false, "打开历史 session 后发送按钮应可用");
|
|
await page.locator("[data-page-ai-pi-lab-new]").click();
|
|
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT });
|
|
|
|
const confirmMode = await page.request.fetch(`${BASE}/api/page-ai/pi/configure`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
data: {
|
|
sessionId,
|
|
permissionMode: "confirm",
|
|
},
|
|
});
|
|
const confirmModeJson = await confirmMode.json();
|
|
result.checks.confirmModeConfigured = confirmModeJson.ok === true
|
|
&& ((confirmModeJson.session && confirmModeJson.session.permissionMode === "confirm")
|
|
|| (confirmModeJson.session
|
|
&& confirmModeJson.session.runtimePolicySnapshot
|
|
&& confirmModeJson.session.runtimePolicySnapshot.permissionMode === "confirm"));
|
|
assert(confirmMode.ok(), `configure confirm request failed: ${confirmMode.status()}`);
|
|
assert(result.checks.confirmModeConfigured, "安全审批 smoke 必须先显式切到 confirm 模式");
|
|
|
|
const unapproved = await page.request.fetch(`${BASE}/api/page-ai/pi/tool-call`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
data: {
|
|
sessionId,
|
|
toolName: "mnote.local_file.patch",
|
|
params: {
|
|
rootUri: sessionRootUri,
|
|
path: "note.md",
|
|
operations: [{ op: "append", content: "approval smoke" }],
|
|
},
|
|
},
|
|
});
|
|
const unapprovedJson = await unapproved.json();
|
|
result.checks.unapprovedToolOk = unapprovedJson.ok;
|
|
result.checks.unapprovedToolCode = unapprovedJson.result && unapprovedJson.result.code;
|
|
result.checks.unapprovedApprovalRequired = unapprovedJson.approvalRequired === true;
|
|
assert(unapproved.ok(), `tool-call request failed: ${unapproved.status()}`);
|
|
assert.equal(unapprovedJson.ok, false, "未审批高风险工具不应成功");
|
|
assert.equal(unapprovedJson.result && unapprovedJson.result.code, "page_ai_pi_lab_tool_approval_required");
|
|
assert.equal(unapprovedJson.approvalRequired, true);
|
|
|
|
await emit(page, {
|
|
type: "tool_execution_start",
|
|
toolCallId: "approval-smoke",
|
|
toolName: "mnote.local_file.read",
|
|
args: { path: "note.md" },
|
|
});
|
|
await page.evaluate(() => {
|
|
window.__mnotePiLabTest.emitToolCall({
|
|
toolCallId: "approval-smoke",
|
|
toolName: "mnote.local_file.read",
|
|
allowed: false,
|
|
denyReason: "page_ai_pi_lab_tool_approval_required: Pi 工具 mnote.local_file.read 需要用户审批",
|
|
approvalRequired: true,
|
|
approvalConfirmed: false,
|
|
toolPolicy: "ask",
|
|
});
|
|
});
|
|
await page.locator("[data-page-ai-pi-lab-body]").evaluate((node) => { node.setAttribute("data-rail-open", "true"); });
|
|
await page.locator("[data-page-ai-pi-lab-receipts-section]").evaluate((node) => { node.open = true; });
|
|
await page.locator("[data-page-ai-pi-lab-receipts]").filter({ hasText: "approval required" }).waitFor({ state: "visible", timeout: TIMEOUT });
|
|
result.checks.hasApprovalReceipt = await page.locator("[data-page-ai-pi-lab-receipts]").filter({ hasText: "approval required" }).count() > 0;
|
|
await page.screenshot({ path: path.join(OUT, "02-approval-audit-ui.png"), fullPage: false });
|
|
result.screenshots.approvalAudit = path.join(OUT, "02-approval-audit-ui.png");
|
|
assert(result.checks.hasApprovalReceipt, "approval receipt 未显示");
|
|
|
|
await emit(page, {
|
|
type: "queue_update",
|
|
steering: [{ id: "steer-1", text: "调整语气" }],
|
|
followUp: [{ id: "follow-1", text: "继续回答" }],
|
|
});
|
|
await emit(page, {
|
|
type: "message_update",
|
|
assistantMessageEvent: { type: "text_start" },
|
|
});
|
|
await page.locator("[data-page-ai-pi-lab-btn-abort]").click();
|
|
await page.locator("[data-page-ai-pi-lab-abort-note]").waitFor({ state: "visible", timeout: TIMEOUT });
|
|
result.checks.abortNoteText = await page.locator("[data-page-ai-pi-lab-abort-note]").last().textContent();
|
|
await page.screenshot({ path: path.join(OUT, "03-abort-stopreason-queue.png"), fullPage: false });
|
|
result.screenshots.abort = path.join(OUT, "03-abort-stopreason-queue.png");
|
|
assert(/stopReason=aborted/.test(result.checks.abortNoteText || ""), "abort note 未显示 stopReason");
|
|
assert(/queued messages=2/.test(result.checks.abortNoteText || ""), "abort note 未显示 queued messages");
|
|
result.ok = true;
|
|
} catch (error) {
|
|
result.ok = false;
|
|
result.error = error && error.stack ? error.stack : String(error);
|
|
try {
|
|
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
|
|
result.screenshots.failure = path.join(OUT, "99-failure.png");
|
|
} catch {}
|
|
process.exitCode = 1;
|
|
} finally {
|
|
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
|
await browser.close();
|
|
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
|
|
}
|
|
}
|
|
|
|
main();
|