// 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: hl lo hi.", "", "| 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(); // A top-level comment requires an inline "selection": exact contiguous text // that exists in the persisted page to anchor on. "Добавленный абзац." is a // plain paragraph re-imported in section 5 and still present here. const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).", "inline", "Добавленный абзац."); 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); });