Files
gitmost/packages/mcp/test-e2e.mjs
claude_code deeec50b5f test(e2e): fix remaining server config and mcp image failures
Follow-up to the first e2e fix: with nanoid/editRes.edits resolved, the
suites failed one layer deeper. Both layers were never green since the
e2e jobs were added (non-blocking in CI), so the failures had stacked up.

server e2e (jest-e2e.json) — align module resolution/transform with the
working unit/integration jest configs so AppModule's full import graph
loads:
- moduleFileExtensions: add "tsx" (React-Email .tsx templates are pulled
  in via the auth controller chain).
- transform: ^.+\.(t|j)s$ -> ^.+\.(t|j)sx?$ so .tsx is transformed.
- moduleNameMapper: add ^src/(.*)$ -> <rootDir>/../src/$1 (code imports
  via the absolute 'src/...' alias). Verified locally: the module graph
  now fully resolves (only env vars, supplied by CI, remain).

mcp e2e (test-e2e.mjs) — insert_image/replace_image accept only http(s)
URLs the server fetches; the test passed local file paths and died with
"Invalid image URL". Serve the PNG bytes over a throwaway 127.0.0.1 HTTP
server (the Docmost server runs on the same CI host) and pass URLs. The
featPng negative test is untouched: replaceImage checks the attachmentId
and throws before fetching, so its local path is never validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:54:42 +03:00

488 lines
26 KiB
JavaScript

// End-to-end test of the docmost-mcp client against a live Docmost server.
// Creates a throwaway page, exercises every code path, cleans up after itself.
// Usage: DOCMOST_API_URL=... DOCMOST_EMAIL=... DOCMOST_PASSWORD=... node test-e2e.mjs
import { DocmostClient } from "./build/client.js";
import axios from "axios";
import { writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { deflateSync } from "node:zlib";
import { createServer } from "node:http";
const API = process.env.DOCMOST_API_URL;
if (!API || !process.env.DOCMOST_EMAIL || !process.env.DOCMOST_PASSWORD) {
console.error("Set DOCMOST_API_URL, DOCMOST_EMAIL and DOCMOST_PASSWORD env variables.");
process.exit(2);
}
const APP = API.replace(/\/api\/?$/, "");
const client = new DocmostClient(API, process.env.DOCMOST_EMAIL, process.env.DOCMOST_PASSWORD);
let failed = 0;
const check = (name, cond, extra = "") => {
console.log(`${cond ? "OK " : "FAIL"} ${name}${extra ? " — " + extra : ""}`);
if (!cond) failed++;
};
// Minimal solid-color PNG encoder using Node built-ins only (no dependencies).
// Returns a valid PNG buffer for a 1x1 image of the given RGB color.
const crc32 = (buf) => {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc ^= buf[i];
for (let k = 0; k < 8; k++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
return (crc ^ 0xffffffff) >>> 0;
};
const pngChunk = (type, data) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, "ascii");
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crc]);
};
const makePng = (r, g, b) => {
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(1, 0); // width
ihdr.writeUInt32BE(1, 4); // height
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: truecolor RGB
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
// One scanline: filter byte 0 followed by one RGB pixel.
const raw = Buffer.from([0, r, g, b]);
const idat = deflateSync(raw);
return Buffer.concat([
sig,
pngChunk("IHDR", ihdr),
pngChunk("IDAT", idat),
pngChunk("IEND", Buffer.alloc(0)),
]);
};
const MD = `:::info
**Тестовый callout.** Он должен стать узлом callout, а не blockquote.
:::
Первый абзац с **жирным** и [ссылкой](https://example.com). Маркер тут [1] стоит.
## Раздел два
| Колонка А | Колонка Б |
| --- | --- |
| раз | два |
| три | четыре |
Последний абзац со словом БУКВОЕД для замены.
`;
async function main() {
const spaces = await client.getSpaces();
const spaceId = spaces[0].id;
let pageId = null;
try {
// 1. create_page: title with spaces must survive (was: underscores bug)
const created = await client.createPage("Тест апгрейда MCP сервера", MD, spaceId);
pageId = created.data.id;
check("create_page: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title);
check("create_page: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId);
// 2. get_page_json: raw ProseMirror with callout + table
const pj = await client.getPageJson(pageId);
const types = pj.content.content.map((n) => n.type);
check("get_page_json: callout node present", types.includes("callout"), types.join(","));
check("get_page_json: table node present", types.includes("table"));
check("get_page_json: slugId present", !!pj.slugId);
// 3. edit_page_text: surgical replace, ids preserved
const idsBefore = JSON.stringify(
pj.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
);
const editRes = await client.editPageText(pageId, [
{ find: "БУКВОЕД", replace: "КНИГОЛЮБ" },
{ find: "[1]", replace: "[42]" },
]);
check("edit_page_text: both edits applied", editRes.applied.every((e) => e.replacements === 1));
await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence
const pj2 = await client.getPageJson(pageId);
const text2 = JSON.stringify(pj2.content);
check("edit_page_text: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]"));
check("edit_page_text: old text gone", !text2.includes("БУКВОЕД"));
const idsAfter = JSON.stringify(
pj2.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
);
check("edit_page_text: block ids preserved", idsBefore === idsAfter);
check("edit_page_text: callout survived", JSON.stringify(pj2.content).includes('"callout"'));
check("edit_page_text: table survived", pj2.content.content.some((n) => n.type === "table"));
// 4. error reporting: ambiguous and missing finds
let err1 = "";
try { await client.editPageText(pageId, [{ find: "Колонка", replace: "X" }]); } catch (e) { err1 = e.message; }
check("edit_page_text: ambiguous match rejected", err1.includes("matches"), err1);
let err2 = "";
try { await client.editPageText(pageId, [{ find: "НЕСУЩЕСТВУЮЩЕЕ", replace: "X" }]); } catch (e) { err2 = e.message; }
check("edit_page_text: missing text reported", err2.includes("not found"), err2);
// 5. update_page (markdown): table + callout must survive the re-import
await client.updatePage(pageId, MD + "\nДобавленный абзац.\n");
await new Promise((r) => setTimeout(r, 16000));
const pj3 = await client.getPageJson(pageId);
const types3 = pj3.content.content.map((n) => n.type);
check("update_page md: callout survives re-import", types3.includes("callout"), types3.join(","));
check("update_page md: table survives re-import", types3.includes("table"));
const tableNode = pj3.content.content.find((n) => n.type === "table");
const cellText = JSON.stringify(tableNode);
check("update_page md: table cells intact", cellText.includes("четыре") && cellText.includes("Колонка А"));
// 6. update_page_json: lossless write round-trip
pj3.content.content.push({
type: "paragraph",
attrs: { id: "testidjsonpush", indent: 0, textAlign: null },
content: [{ type: "text", text: "Абзац, добавленный через update_page_json." }],
});
await client.updatePageJson(pageId, pj3.content);
await new Promise((r) => setTimeout(r, 16000));
const pj4 = await client.getPageJson(pageId);
const lastNode = pj4.content.content[pj4.content.content.length - 1];
check("update_page_json: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через update_page_json"));
check("update_page_json: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id);
// 6b. images: upload / insert / replace (clean src, fresh attachment on replace).
// insert_image / replace_image take an http(s) URL that the SERVER fetches;
// local file paths are intentionally unsupported. The Docmost server runs on
// the same host as this test, so serve the PNG bytes over a throwaway
// localhost HTTP server it can reach.
const bytesA = makePng(255, 0, 0); // red
const bytesB = makePng(0, 0, 255); // blue (a DIFFERENT valid PNG)
const imgServer = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "image/png" });
res.end(req.url === "/b.png" ? bytesB : bytesA);
});
await new Promise((resolve, reject) => {
imgServer.once("error", reject);
imgServer.listen(0, "127.0.0.1", resolve);
});
const imgPort = imgServer.address().port;
const urlA = `http://127.0.0.1:${imgPort}/a.png`;
const urlB = `http://127.0.0.1:${imgPort}/b.png`;
try {
// Independent login to fetch file bytes with the same cookie the editor uses.
const login = await axios.post(
`${API}/auth/login`,
{ email: process.env.DOCMOST_EMAIL, password: process.env.DOCMOST_PASSWORD },
{ validateStatus: () => true },
);
const token = (login.headers["set-cookie"] || [])
.find((c) => c.startsWith("authToken="))
?.split(";")[0]
.split("=")[1];
const fetchFile = (src) =>
axios.get(`${APP}${src}`, {
headers: { Cookie: `authToken=${token}` },
responseType: "arraybuffer",
validateStatus: () => true,
});
// insert_image: append the first PNG, src must be clean (no ?v=) and fetchable.
const ins = await client.insertImage(pageId, urlA);
check("insert_image: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src);
const fileA = await fetchFile(ins.src);
check("insert_image: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`);
check(
"insert_image: content-type is image/*",
String(fileA.headers["content-type"] || "").startsWith("image/"),
String(fileA.headers["content-type"]),
);
await new Promise((r) => setTimeout(r, 16000));
const pjImg = await client.getPageJson(pageId);
const findImage = (nodes, id) => {
for (const n of nodes || []) {
if (n.type === "image" && (!id || n.attrs?.attachmentId === id)) return n;
const found = findImage(n.content, id);
if (found) return found;
}
return null;
};
const imgNode = findImage(pjImg.content.content);
const oldAttachmentId = imgNode?.attrs?.attachmentId;
check("insert_image: image node present after persist", !!oldAttachmentId, oldAttachmentId);
// replace_image: must create a NEW attachment with a clean, fetchable URL.
// The 200 fetch is the assertion that catches the in-place-overwrite HTTP 500 regression.
const rep = await client.replaceImage(pageId, oldAttachmentId, urlB);
check("replace_image: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`);
check("replace_image: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src);
const fileB = await fetchFile(rep.src);
check("replace_image: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`);
check(
"replace_image: new content-type is image/*",
String(fileB.headers["content-type"] || "").startsWith("image/"),
String(fileB.headers["content-type"]),
);
await new Promise((r) => setTimeout(r, 16000));
const pjImg2 = await client.getPageJson(pageId);
check("replace_image: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId);
check("replace_image: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId);
} finally {
imgServer.close();
}
// 6c. rich formatting: callout type, task list, inline marks, table alignment,
// and literal $-pattern edits. Runs on its own throwaway page so it does not
// disturb the markdown-export assumptions of later sections.
{
const findNodes = (n, t, acc = []) => {
if (!n) return acc;
if (n.type === t) acc.push(n);
for (const ch of n.content || []) findNodes(ch, t, acc);
return acc;
};
const marksOf = (n, acc = new Set()) => {
if (!n) return acc;
for (const m of n.marks || []) acc.add(m.type);
for (const ch of n.content || []) marksOf(ch, acc);
return acc;
};
const FMD = [
":::warning", "Warning callout with СЛОВО.", ":::", "",
"- [x] done", "- [ ] todo", "",
"Marks: <mark>hl</mark> <sub>lo</sub> <sup>hi</sup>.", "",
"| L | C | R |", "|:--|:-:|--:|", "| a | b | c |", "",
"Edit anchor PRICEMARK.",
].join("\n");
const featPng = join(tmpdir(), `mcp-e2e-feat-${Date.now()}.png`);
writeFileSync(featPng, makePng(0, 255, 0));
const fp = await client.createPage("E2E features " + Date.now(), "init", spaceId);
const fid = fp.data.id;
try {
await client.updatePage(fid, FMD);
await new Promise((r) => setTimeout(r, 16000));
const fj = (await client.getPageJson(fid)).content;
check("feature: callout type 'warning' preserved (was coerced to info)", findNodes(fj, "callout").some((n) => n.attrs?.type === "warning"), JSON.stringify(findNodes(fj, "callout").map((n) => n.attrs?.type)));
check("feature: task list imported (taskList + 2 taskItems)", findNodes(fj, "taskList").length >= 1 && findNodes(fj, "taskItem").length === 2, `tl=${findNodes(fj, "taskList").length} ti=${findNodes(fj, "taskItem").length}`);
check("feature: task checked states preserved", findNodes(fj, "taskItem").some((n) => n.attrs?.checked === true) && findNodes(fj, "taskItem").some((n) => n.attrs?.checked === false));
const mk = [...marksOf(fj)];
check("feature: highlight/subscript/superscript marks imported", ["highlight", "subscript", "superscript"].every((m) => mk.includes(m)), mk.join(","));
check("feature: table cell alignment imported", JSON.stringify(findNodes(fj, "tableHeader").map((n) => n.attrs?.align)) === '["left","center","right"]', JSON.stringify(findNodes(fj, "tableHeader").map((n) => n.attrs?.align)));
const fmd = (await client.getPage(fid)).data.content;
check("feature: md export emits task checkboxes", fmd.includes("- [x]") && fmd.includes("- [ ]"));
check("feature: md export emits table alignment markers", /:--|:-:|--:/.test(fmd));
await client.editPageText(fid, [{ find: "PRICEMARK", replace: "$& costs $100" }]);
await new Promise((r) => setTimeout(r, 16000));
const ftext = JSON.stringify((await client.getPageJson(fid)).content);
check("feature: edit_page_text inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs"));
let badThrew = false;
try { await client.replaceImage(fid, "00000000-0000-0000-0000-000000000000", featPng); } catch (e) { badThrew = /no image with attachmentId/.test(e.message); }
check("feature: replace_image with unknown id throws (no orphan upload)", badThrew);
} finally {
try { await client.deletePage(fid); } catch {}
try { unlinkSync(featPng); } catch {}
}
}
// 6d. node ops: patch / insert / delete a block by id on a throwaway page.
// Three paragraphs are written with KNOWN ids via update_page_json so the
// ids can be targeted directly; each op is verified via getPageJson after
// the standard 16s persistence wait.
{
const np = await client.createPage("E2E node-ops " + Date.now(), "init", spaceId);
const nid = np.data.id;
try {
const mkPara = (id, text) => ({
type: "paragraph",
attrs: { id, indent: 0, textAlign: null },
content: [{ type: "text", text }],
});
// Seed three paragraphs with known ids.
await client.updatePageJson(nid, {
type: "doc",
content: [
mkPara("nodeops-a", "Alpha paragraph."),
mkPara("nodeops-b", "Bravo paragraph."),
mkPara("nodeops-c", "Charlie paragraph."),
],
});
await new Promise((r) => setTimeout(r, 16000));
// Read back the ids the server actually assigned.
const seed = (await client.getPageJson(nid)).content;
const seedIds = seed.content.map((n) => n.attrs?.id);
check("node_ops: three seed paragraphs present", seed.content.length === 3, seedIds.join(","));
const [idA, idB, idC] = seedIds;
// patchNode: replace the middle paragraph; siblings' ids must be unchanged.
await client.patchNode(nid, idB, mkPara(idB, "Bravo PATCHED."));
await new Promise((r) => setTimeout(r, 16000));
const afterPatch = (await client.getPageJson(nid)).content;
const patchText = JSON.stringify(afterPatch);
check("node_ops: patchNode applied new text", patchText.includes("Bravo PATCHED.") && !patchText.includes("Bravo paragraph."));
const patchIds = afterPatch.content.map((n) => n.attrs?.id);
check("node_ops: patchNode kept sibling ids", patchIds[0] === idA && patchIds[2] === idC, patchIds.join(","));
// insertNode: place a new block after the first paragraph.
await client.insertNode(
nid,
mkPara("nodeops-ins", "Inserted paragraph."),
{ position: "after", anchorNodeId: idA },
);
await new Promise((r) => setTimeout(r, 16000));
const afterIns = (await client.getPageJson(nid)).content;
const insIds = afterIns.content.map((n) => n.attrs?.id);
const insText = afterIns.content.map((n) => JSON.stringify(n.content)).join("|");
check("node_ops: insertNode added a block", afterIns.content.length === 4 && insText.includes("Inserted paragraph."));
check("node_ops: insertNode placed block right after anchor", insIds[0] === idA && insIds[1] !== idB && insIds[2] === idB, insIds.join(","));
// deleteNode: remove the last (Charlie) paragraph.
await client.deleteNode(nid, idC);
await new Promise((r) => setTimeout(r, 16000));
const afterDel = (await client.getPageJson(nid)).content;
const delText = JSON.stringify(afterDel);
check("node_ops: deleteNode removed the block", !delText.includes("Charlie paragraph.") && !afterDel.content.some((n) => n.attrs?.id === idC));
} finally {
try { await client.deletePage(nid); } catch {}
}
}
// 6e. rename_page: title-only update must leave the content untouched.
{
const rp = await client.createPage("E2E rename before " + Date.now(), "Rename body marker RENAMEBODY.", spaceId);
const rid = rp.data.id;
try {
const beforeJson = (await client.getPageJson(rid)).content;
const beforeContent = JSON.stringify(beforeJson);
const newTitle = "E2E rename AFTER " + Date.now();
const rr = await client.renamePage(rid, newTitle);
check("rename_page: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr));
await new Promise((r) => setTimeout(r, 16000));
const afterJson = await client.getPageJson(rid);
check("rename_page: title changed", afterJson.title === newTitle, afterJson.title);
check("rename_page: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY"));
const afterMd = (await client.getPage(rid)).data;
check("rename_page: get_page reflects new title", afterMd.title === newTitle, afterMd.title);
} finally {
try { await client.deletePage(rid); } catch {}
}
}
// 6f. update_page_json title-only: omitting content updates the title and
// leaves the body intact; supplying neither content nor title throws.
{
const up = await client.createPage("E2E upj-title before " + Date.now(), "Title-only body marker UPJTITLEBODY.", spaceId);
const uid = up.data.id;
try {
const beforeContent = JSON.stringify((await client.getPageJson(uid)).content);
const newTitle = "E2E upj-title AFTER " + Date.now();
const ur = await client.updatePageJson(uid, undefined, newTitle);
check("update_page_json title-only: succeeds", ur.success === true, JSON.stringify(ur));
await new Promise((r) => setTimeout(r, 16000));
const afterJson = await client.getPageJson(uid);
check("update_page_json title-only: title updated", afterJson.title === newTitle, afterJson.title);
check("update_page_json title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY"));
let upjErr = "";
try { await client.updatePageJson(uid); } catch (e) { upjErr = e.message; }
check("update_page_json: neither content nor title throws", upjErr.includes("nothing to update"), upjErr);
} finally {
try { await client.deletePage(uid); } catch {}
}
}
// 6g. copy_page_content: B's body becomes a copy of A's body, server-side,
// while B's title/slugId stay put. Both pages are throwaways.
{
let aid = null;
let bid = null;
try {
const aPage = await client.createPage("E2E copy SOURCE " + Date.now(), "Source marker COPYSOURCE only here.\n\nSecond source paragraph.", spaceId);
aid = aPage.data.id;
const bPage = await client.createPage("E2E copy TARGET " + Date.now(), "Target marker COPYTARGET only here.", spaceId);
bid = bPage.data.id;
const aJson = await client.getPageJson(aid);
const bBefore = await client.getPageJson(bid);
const bTitleBefore = bBefore.title;
const bSlugBefore = bBefore.slugId;
const aNodeCount = aJson.content.content.length;
const cr = await client.copyPageContent(aid, bid);
check("copy_page_content: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr));
await new Promise((r) => setTimeout(r, 16000));
const bAfter = await client.getPageJson(bid);
const bText = JSON.stringify(bAfter.content);
check("copy_page_content: B now has A's marker", bText.includes("COPYSOURCE"));
check("copy_page_content: B's old marker gone", !bText.includes("COPYTARGET"));
check("copy_page_content: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`);
check("copy_page_content: B title unchanged", bAfter.title === bTitleBefore, bAfter.title);
check("copy_page_content: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId);
// Source must be left untouched by the copy.
const aAfter = JSON.stringify((await client.getPageJson(aid)).content);
check("copy_page_content: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE"));
let copyErr = "";
try { await client.copyPageContent(aid, aid); } catch (e) { copyErr = e.message; }
check("copy_page_content: self-copy rejected", copyErr.includes("same page"), copyErr);
} finally {
try { if (bid) await client.deletePage(bid); } catch {}
try { if (aid) await client.deletePage(aid); } catch {}
}
}
// 7. shares: create (idempotent), public access, list, unshare
const share = await client.sharePage(pageId);
check("share_page: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl);
const share2 = await client.sharePage(pageId);
check("share_page: idempotent", share2.key === share.key);
const anon = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
check("share_page: anonymous access works", anon.status === 200);
const shares = await client.listShares();
check("list_shares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl));
const un = await client.unsharePage(pageId);
check("unshare_page: success", un.success === true);
const anon2 = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
check("unshare_page: public access revoked", anon2.status !== 200, `status=${anon2.status}`);
// 8. get_page markdown round-trip sanity (table separator present)
const md = await client.getPage(pageId);
check("get_page md: table separator emitted", md.data.content.includes("| --- |"), "");
check("get_page md: callout exported as :::", md.data.content.includes(":::info"));
// 9. comments: create / list / reply / update / check_new / delete
const beforeComments = new Date(Date.now() - 1000).toISOString();
const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).");
check("create_comment: created", !!c1.data.id, c1.data.id);
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id);
const list = await client.listComments(pageId);
check("list_comments: both visible", list.length === 2, `count=${list.length}`);
await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
const got = await client.getComment(c1.data.id);
check("update_comment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content);
const news = await client.checkNewComments(spaceId, beforeComments, pageId);
check("check_new_comments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`);
await client.deleteComment(reply.data.id);
await client.deleteComment(c1.data.id);
const listAfter = await client.listComments(pageId);
check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
} finally {
if (pageId) {
await client.deletePage(pageId);
console.log("cleanup: test page deleted");
}
}
console.log(failed === 0 ? "\nALL TESTS PASSED" : `\n${failed} TESTS FAILED`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error("FATAL:", e.message);
process.exit(2);
});