515 lines
21 KiB
JavaScript
515 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
/**
|
|
* Regression: page-tree expand/collapse icons + persistence + no duplicate rows.
|
|
* Builds a liaibo-like nested local_folder tree, exercises expand/collapse/reload.
|
|
*/
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const http = require("node:http");
|
|
const net = require("node:net");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { spawn } = require("node:child_process");
|
|
const { chromium } = require("playwright");
|
|
const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env");
|
|
|
|
const TASK = "task-page-tree-expand-persist-regression";
|
|
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 60_000);
|
|
const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
|
|
const RESULT_DIR = path.join(__dirname, "..", "tmp", TASK);
|
|
const BINARY =
|
|
process.env.MNOTE_WEB_BIN ||
|
|
path.join(__dirname, "..", "rust", "target", "debug", "mnote-web");
|
|
|
|
function resolveChromiumExecutablePath() {
|
|
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
|
if (explicit && fs.existsSync(explicit)) return explicit;
|
|
return [
|
|
"/usr/bin/google-chrome-stable",
|
|
"/usr/bin/google-chrome",
|
|
"/snap/bin/chromium",
|
|
"/usr/bin/chromium",
|
|
"/usr/bin/chromium-browser",
|
|
].find((candidate) => fs.existsSync(candidate)) || "";
|
|
}
|
|
|
|
function pickPort() {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
const port = address && typeof address === "object" ? address.port : 0;
|
|
server.close(() => resolve(port));
|
|
});
|
|
server.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function waitForHttpOk(url, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
return new Promise((resolve, reject) => {
|
|
const tick = () => {
|
|
if (Date.now() > deadline) {
|
|
reject(new Error(`server_not_ready: ${url}`));
|
|
return;
|
|
}
|
|
const request = http.get(url, (response) => {
|
|
response.resume();
|
|
if (response.statusCode >= 200 && response.statusCode < 500) {
|
|
resolve();
|
|
return;
|
|
}
|
|
setTimeout(tick, 250);
|
|
});
|
|
request.on("error", () => setTimeout(tick, 250));
|
|
};
|
|
tick();
|
|
});
|
|
}
|
|
|
|
function writeTree(root) {
|
|
// Mirror user screenshot shape: root page-group + several sibling folders.
|
|
const space = path.join(root, "liaibo的个人空间");
|
|
const mk = (p, content = null) => {
|
|
if (content == null) {
|
|
fs.mkdirSync(p, { recursive: true });
|
|
return;
|
|
}
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
fs.writeFileSync(p, content, "utf8");
|
|
};
|
|
mk(space);
|
|
mk(path.join(space, "liaibo的个人空间.md"), "# liaibo的个人空间\n");
|
|
for (const name of ["知识", "项目", "工作资料", "日常", "剪藏", "个人"]) {
|
|
mk(path.join(space, name));
|
|
mk(path.join(space, name, `${name}.md`), `# ${name}\n`);
|
|
}
|
|
// Nested under 个人 (matches screenshot after expand)
|
|
for (const name of ["正版软件备份", "个人发展", "杂记", "密码"]) {
|
|
mk(path.join(space, "个人", name));
|
|
mk(path.join(space, "个人", name, `${name}.md`), `# ${name}\n`);
|
|
}
|
|
// Nested under 知识
|
|
for (const name of ["有机化学", "文章"]) {
|
|
mk(path.join(space, "知识", name));
|
|
mk(path.join(space, "知识", name, `${name}.md`), `# ${name}\n`);
|
|
}
|
|
return space;
|
|
}
|
|
|
|
async function signUp(context, baseUrl, actorId) {
|
|
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
|
|
method: "POST",
|
|
data: {
|
|
action: "auth:signIn",
|
|
args: {
|
|
provider: "password",
|
|
params: {
|
|
email: `${actorId}@example.com`,
|
|
username: actorId,
|
|
name: actorId,
|
|
password: TEST_PASSWORD,
|
|
flow: "signUp",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
assert.ok(response.ok(), `signUp failed: ${response.status()} ${await response.text()}`);
|
|
}
|
|
|
|
async function openDefaultLocalWorkspace(page, baseUrl) {
|
|
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first();
|
|
if (await createButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
await createButton.click({ timeout: UI_TIMEOUT_MS });
|
|
await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", {
|
|
timeout: UI_TIMEOUT_MS,
|
|
});
|
|
}
|
|
const rootUri =
|
|
new URL(page.url()).searchParams.get("rootUri") ||
|
|
(await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || ""));
|
|
assert.ok(rootUri, "should open local_folder workspace");
|
|
return {
|
|
rootUri,
|
|
workspaceId: new URL(page.url()).searchParams.get("workspaceId") || "",
|
|
};
|
|
}
|
|
|
|
function fileUrlToPath(rootUri) {
|
|
assert.ok(rootUri.startsWith("file://"), `unexpected rootUri ${rootUri}`);
|
|
return decodeURIComponent(rootUri.slice("file://".length));
|
|
}
|
|
|
|
async function reloadWorkspace(page, baseUrl, rootUri, workspaceId) {
|
|
const url = new URL(baseUrl);
|
|
if (workspaceId) url.searchParams.set("workspaceId", workspaceId);
|
|
url.searchParams.set("sourceKind", "local_folder");
|
|
url.searchParams.set("rootUri", rootUri);
|
|
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", {
|
|
timeout: UI_TIMEOUT_MS,
|
|
});
|
|
await page.waitForTimeout(800);
|
|
}
|
|
|
|
function reportTree(page) {
|
|
return page.evaluate(() => {
|
|
function computeChevron(row) {
|
|
const toggle = row.querySelector("[data-rust-action='toggle']");
|
|
if (!toggle) return null;
|
|
const before = window.getComputedStyle(toggle, "::before");
|
|
const transform = before.transform || "";
|
|
// matrix(0,1,-1,0,0,0) ~= rotate(90deg)
|
|
const rotated =
|
|
transform.includes("matrix(0, 1, -1, 0") ||
|
|
transform.includes("matrix(0,1,-1,0") ||
|
|
(transform.includes("rotate") && !transform.includes("rotate(0"));
|
|
return { transform, rotated };
|
|
}
|
|
const rows = Array.from(
|
|
document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']")
|
|
);
|
|
const byId = new Map();
|
|
const details = rows.map((row) => {
|
|
const nodeId = row.getAttribute("data-node-id") || "";
|
|
byId.set(nodeId, (byId.get(nodeId) || 0) + 1);
|
|
const node = row.closest(".tree-node");
|
|
const children = node && node.querySelector(":scope > .tree-children");
|
|
const toggle = row.querySelector("[data-rust-action='toggle']");
|
|
const title = String((row.querySelector(".tree-link-title") || {}).textContent || "").trim();
|
|
const chevron = computeChevron(row);
|
|
const collapsed = children ? children.classList.contains("tree-children--collapsed") : null;
|
|
const childCount = children ? children.children.length : 0;
|
|
const visuallyOpen = collapsed === false && childCount > 0;
|
|
const aria = row.getAttribute("aria-expanded");
|
|
const dataExpanded = row.getAttribute("data-expanded");
|
|
const iconSaysOpen = chevron ? chevron.rotated : null;
|
|
return {
|
|
nodeId,
|
|
title,
|
|
parentId: row.getAttribute("data-parent-id") || "",
|
|
depth: Number(row.getAttribute("data-depth") || 0),
|
|
aria,
|
|
dataExpanded,
|
|
toggleAria: toggle ? toggle.getAttribute("aria-expanded") : null,
|
|
toggleData: toggle ? toggle.getAttribute("data-expanded") : null,
|
|
collapsed,
|
|
childCount,
|
|
visuallyOpen,
|
|
iconSaysOpen,
|
|
desync:
|
|
toggle != null &&
|
|
((iconSaysOpen === true && !visuallyOpen) ||
|
|
(iconSaysOpen === false && visuallyOpen) ||
|
|
aria !== dataExpanded ||
|
|
(aria === "true") !== visuallyOpen),
|
|
};
|
|
});
|
|
const dups = [...byId.entries()].filter(([, n]) => n > 1).map(([id, n]) => ({ id, n }));
|
|
const titles = details.map((d) => d.title);
|
|
const titleCounts = titles.reduce((m, t) => {
|
|
m[t] = (m[t] || 0) + 1;
|
|
return m;
|
|
}, {});
|
|
// top-level (depth 0 or parent empty) duplicates by title are the screenshot bug
|
|
const topTitles = details.filter((d) => !d.parentId || d.depth === 0).map((d) => d.title);
|
|
const topTitleCounts = topTitles.reduce((m, t) => {
|
|
m[t] = (m[t] || 0) + 1;
|
|
return m;
|
|
}, {});
|
|
const ls = {};
|
|
for (const k of Object.keys(localStorage)) {
|
|
if (!k.includes("sidebarTreeViewState")) continue;
|
|
try {
|
|
ls[k.slice(-60)] = JSON.parse(localStorage.getItem(k) || "null");
|
|
} catch {
|
|
ls[k.slice(-60)] = localStorage.getItem(k);
|
|
}
|
|
}
|
|
return {
|
|
href: location.href,
|
|
rowCount: rows.length,
|
|
dups,
|
|
topTitleCounts,
|
|
desync: details.filter((d) => d.desync),
|
|
openRows: details.filter((d) => d.visuallyOpen).map((d) => d.title),
|
|
sample: details,
|
|
markers: {
|
|
applied: document.documentElement.getAttribute("data-mnote-page-tree-view-state-applied"),
|
|
synced: document.documentElement.getAttribute("data-mnote-page-tree-expand-synced"),
|
|
restored: document.documentElement.getAttribute("data-mnote-page-tree-expansion-restored"),
|
|
saved: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved"),
|
|
loadErr: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-load-error"),
|
|
saveErr: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-save-error"),
|
|
},
|
|
ls,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function clickToggleByTitle(page, title) {
|
|
return page.evaluate((want) => {
|
|
const rows = Array.from(
|
|
document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']")
|
|
);
|
|
for (const row of rows) {
|
|
const t = String((row.querySelector(".tree-link-title") || {}).textContent || "").trim();
|
|
if (t !== want) continue;
|
|
const btn = row.querySelector("[data-rust-action='toggle']");
|
|
if (!btn) return { ok: false, reason: "no_toggle" };
|
|
btn.click();
|
|
return { ok: true, nodeId: row.getAttribute("data-node-id") };
|
|
}
|
|
return { ok: false, reason: "not_found" };
|
|
}, title);
|
|
}
|
|
|
|
async function waitDesyncClear(page, timeoutMs = 3000) {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const r = await reportTree(page);
|
|
if (r.desync.length === 0) return r;
|
|
await page.waitForTimeout(100);
|
|
}
|
|
return reportTree(page);
|
|
}
|
|
|
|
async function main() {
|
|
fs.mkdirSync(RESULT_DIR, { recursive: true });
|
|
if (!fs.existsSync(BINARY)) {
|
|
throw new Error(`binary missing: ${BINARY} (build mnote-web first)`);
|
|
}
|
|
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-expand-reg-"));
|
|
const port = await pickPort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const cpEnv = buildControlPlaneTestEnv(dataRoot);
|
|
const env = {
|
|
...process.env,
|
|
...cpEnv,
|
|
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
|
|
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
|
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
|
|
RUST_LOG: process.env.RUST_LOG || "warn",
|
|
};
|
|
const child = spawn(BINARY, [], {
|
|
cwd: path.join(__dirname, "..", "rust"),
|
|
env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let serverLog = "";
|
|
child.stdout.on("data", (d) => {
|
|
serverLog += d.toString();
|
|
});
|
|
child.stderr.on("data", (d) => {
|
|
serverLog += d.toString();
|
|
});
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath: resolveChromiumExecutablePath() || undefined,
|
|
});
|
|
const results = { steps: [] };
|
|
try {
|
|
await waitForHttpOk(`${baseUrl}/health`, 45_000);
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
const actorId = `expand-reg-${Date.now()}`;
|
|
await signUp(context, baseUrl, actorId);
|
|
const page = await context.newPage();
|
|
const puts = [];
|
|
const gets = [];
|
|
page.on("response", async (res) => {
|
|
if (!res.url().includes("/api/tree/view-state")) return;
|
|
const method = res.request().method();
|
|
let body = null;
|
|
try {
|
|
body = await res.json();
|
|
} catch {}
|
|
const entry = {
|
|
method,
|
|
status: res.status(),
|
|
expandedIds: body && body.result && body.result.state && body.result.state.expandedIds,
|
|
};
|
|
if (method === "PUT") puts.push(entry);
|
|
else gets.push(entry);
|
|
});
|
|
|
|
const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl);
|
|
const rootPath = fileUrlToPath(rootUri);
|
|
// Seed liaibo-like tree into the default local workspace.
|
|
writeTree(rootPath);
|
|
await reloadWorkspace(page, baseUrl, rootUri, workspaceId);
|
|
await page.screenshot({ path: path.join(RESULT_DIR, "01-initial.png") });
|
|
let r = await reportTree(page);
|
|
results.steps.push({ name: "initial", r });
|
|
assert.equal(r.dups.length, 0, `initial dups: ${JSON.stringify(r.dups)}`);
|
|
assert.equal(r.desync.length, 0, `initial desync: ${JSON.stringify(r.desync)}`);
|
|
// Default: expandable rows closed + ▶ (ignore non-toggle leaves)
|
|
for (const d of r.sample.filter((x) => x.toggleAria != null)) {
|
|
assert.equal(d.visuallyOpen, false, `initial open ${d.title}`);
|
|
assert.equal(d.iconSaysOpen, false, `initial icon open ${d.title}`);
|
|
assert.equal(d.aria, "false", `initial aria ${d.title}`);
|
|
}
|
|
|
|
// Expand root then 个人
|
|
let click = await clickToggleByTitle(page, "liaibo的个人空间");
|
|
assert.equal(click.ok, true, `expand root: ${JSON.stringify(click)}`);
|
|
await page.waitForTimeout(700);
|
|
click = await clickToggleByTitle(page, "个人");
|
|
assert.equal(click.ok, true, `expand 个人: ${JSON.stringify(click)}`);
|
|
await page.waitForTimeout(900);
|
|
await page.waitForTimeout(700);
|
|
await page.screenshot({ path: path.join(RESULT_DIR, "02-expanded.png") });
|
|
r = await waitDesyncClear(page);
|
|
results.steps.push({ name: "expanded", r, puts: puts.slice(-2) });
|
|
assert.equal(r.dups.length, 0, `expanded dups: ${JSON.stringify(r.dups)}`);
|
|
assert.equal(r.desync.length, 0, `expanded desync: ${JSON.stringify(r.desync)}`);
|
|
assert.ok(r.openRows.includes("liaibo的个人空间"), "root should be open");
|
|
assert.ok(r.openRows.includes("个人"), "个人 should be open");
|
|
const personalChildren = r.sample.filter((d) => d.title === "正版软件备份");
|
|
assert.equal(personalChildren.length, 1, `个人 children dup: ${JSON.stringify(personalChildren)}`);
|
|
|
|
// Collapse 个人 only
|
|
click = await clickToggleByTitle(page, "个人");
|
|
assert.equal(click.ok, true);
|
|
await page.waitForTimeout(500);
|
|
r = await waitDesyncClear(page);
|
|
results.steps.push({ name: "collapsed-personal", r });
|
|
assert.equal(r.dups.length, 0, `collapse dups ${JSON.stringify(r.dups)}`);
|
|
assert.equal(r.desync.length, 0, `collapse desync ${JSON.stringify(r.desync)}`);
|
|
const personal = r.sample.find((d) => d.title === "个人");
|
|
assert.ok(personal, "个人 row missing");
|
|
assert.equal(personal.visuallyOpen, false, "个人 should be closed");
|
|
assert.equal(personal.iconSaysOpen, false, "个人 icon should be ▶");
|
|
assert.equal(personal.aria, "false");
|
|
const titleCountsUnderRoot = {};
|
|
for (const d of r.sample) {
|
|
if (d.depth === 1) titleCountsUnderRoot[d.title] = (titleCountsUnderRoot[d.title] || 0) + 1;
|
|
}
|
|
for (const [title, count] of Object.entries(titleCountsUnderRoot)) {
|
|
assert.equal(count, 1, `duplicate depth1 title ${title} count=${count}`);
|
|
}
|
|
|
|
// Re-expand 个人, then reload — should restore open state
|
|
click = await clickToggleByTitle(page, "个人");
|
|
assert.equal(click.ok, true);
|
|
await page.waitForTimeout(1000);
|
|
r = await reportTree(page);
|
|
const lsValues = Object.values(r.ls);
|
|
const hasLocal =
|
|
lsValues.some((v) => v && Array.isArray(v.expandedIds) && v.expandedIds.length > 0) ||
|
|
puts.some((p) => p.status === 200);
|
|
assert.ok(hasLocal || puts.length > 0, `persist missing ls=${JSON.stringify(r.ls)} puts=${puts.length}`);
|
|
|
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", {
|
|
timeout: UI_TIMEOUT_MS,
|
|
});
|
|
await page.waitForTimeout(2500);
|
|
await page.screenshot({ path: path.join(RESULT_DIR, "03-after-reload.png") });
|
|
r = await waitDesyncClear(page, 5000);
|
|
results.steps.push({ name: "after-reload-open", r, gets: gets.slice(-4), puts: puts.slice(-2) });
|
|
assert.equal(r.dups.length, 0, `reload dups: ${JSON.stringify(r.dups)}`);
|
|
assert.equal(r.desync.length, 0, `reload desync: ${JSON.stringify(r.desync)}`);
|
|
assert.ok(
|
|
r.openRows.includes("liaibo的个人空间") && r.openRows.includes("个人"),
|
|
`reload should restore expands, openRows=${JSON.stringify(r.openRows)} markers=${JSON.stringify(r.markers)} ls=${JSON.stringify(r.ls)}`
|
|
);
|
|
// Nested children of 个人 must actually reappear after cascade hydrate.
|
|
const afterReloadPersonalChild = r.sample.filter((d) => d.title === "正版软件备份");
|
|
assert.equal(
|
|
afterReloadPersonalChild.length,
|
|
1,
|
|
`reload missing nested child: ${JSON.stringify(afterReloadPersonalChild)} sample=${JSON.stringify(
|
|
r.sample.map((d) => ({ t: d.title, d: d.depth, open: d.visuallyOpen, children: d.childCount }))
|
|
)}`
|
|
);
|
|
const personalAfterReload = r.sample.find((d) => d.title === "个人");
|
|
assert.ok(personalAfterReload && personalAfterReload.childCount >= 4, `个人 childCount after reload ${personalAfterReload?.childCount}`);
|
|
assert.equal(personalAfterReload.iconSaysOpen, true, "个人 icon should be ▼ after restore");
|
|
const d1 = {};
|
|
for (const d of r.sample) {
|
|
if (d.depth === 1) d1[d.title] = (d1[d.title] || 0) + 1;
|
|
}
|
|
for (const [title, count] of Object.entries(d1)) {
|
|
assert.equal(count, 1, `reload duplicate depth1 ${title}=${count}`);
|
|
}
|
|
|
|
// Collapse all expanded, reload — icons must stay ▶
|
|
for (const title of ["个人", "liaibo的个人空间"]) {
|
|
const row = r.sample.find((d) => d.title === title);
|
|
if (row && row.visuallyOpen) {
|
|
await clickToggleByTitle(page, title);
|
|
await page.waitForTimeout(300);
|
|
}
|
|
}
|
|
await page.waitForTimeout(900);
|
|
r = await waitDesyncClear(page);
|
|
results.steps.push({ name: "all-collapsed", r });
|
|
assert.equal(r.desync.length, 0, `all-collapsed desync ${JSON.stringify(r.desync)}`);
|
|
for (const d of r.sample.filter((x) => x.toggleAria != null)) {
|
|
assert.equal(d.visuallyOpen, false, `should be closed ${d.title}`);
|
|
assert.equal(d.iconSaysOpen, false, `icon should be ▶ for ${d.title}`);
|
|
}
|
|
|
|
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
|
await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", {
|
|
timeout: UI_TIMEOUT_MS,
|
|
});
|
|
await page.waitForTimeout(2500);
|
|
await page.screenshot({ path: path.join(RESULT_DIR, "04-reload-collapsed.png") });
|
|
r = await waitDesyncClear(page, 5000);
|
|
results.steps.push({ name: "after-reload-collapsed", r });
|
|
assert.equal(r.dups.length, 0, `collapsed-reload dups ${JSON.stringify(r.dups)}`);
|
|
assert.equal(r.desync.length, 0, `collapsed-reload desync ${JSON.stringify(r.desync)}`);
|
|
assert.deepEqual(r.openRows, [], `collapsed reload openRows=${JSON.stringify(r.openRows)}`);
|
|
for (const d of r.sample.filter((x) => x.toggleAria != null)) {
|
|
assert.equal(d.iconSaysOpen, false, `collapsed reload icon open for ${d.title}`);
|
|
assert.equal(d.aria, "false", `collapsed reload aria for ${d.title}`);
|
|
assert.equal(d.dataExpanded, "false", `collapsed reload data-expanded for ${d.title}`);
|
|
}
|
|
|
|
results.ok = true;
|
|
results.puts = puts;
|
|
results.gets = gets.slice(-6);
|
|
fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(results, null, 2));
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
ok: true,
|
|
resultDir: RESULT_DIR,
|
|
openAfterExpand: results.steps.find((s) => s.name === "expanded")?.r?.openRows,
|
|
afterReload: results.steps.find((s) => s.name === "after-reload-open")?.r?.openRows,
|
|
afterCollapsedReload: results.steps.find((s) => s.name === "after-reload-collapsed")?.r
|
|
?.openRows,
|
|
putCount: puts.length,
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
} catch (error) {
|
|
results.ok = false;
|
|
results.error = String((error && error.stack) || error);
|
|
fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(results, null, 2));
|
|
fs.writeFileSync(path.join(RESULT_DIR, "server.log"), serverLog);
|
|
console.error(results.error);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await browser.close().catch(() => {});
|
|
child.kill("SIGTERM");
|
|
setTimeout(() => {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {}
|
|
}, 1000).unref();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|