/** * End-to-end journeys against a REAL Docmost instance. * * These tests talk to a live server, so they are SKIPPED by default (no live * Docmost is available in CI). They only EXECUTE when the env flag DOCMOST_E2E * is set; otherwise the whole suite is registered via `describe.skip` and * reported as skipped — it must still import cleanly and never error during a * normal `vitest run`. * * How to run (all on one line): * * DOCMOST_E2E=1 \ * DOCMOST_API_URL=https://your-docmost/api \ * DOCMOST_EMAIL=you@example.com \ * DOCMOST_PASSWORD=secret \ * DOCMOST_SPACE_ID= \ * npx vitest run test/e2e-docmost.test.ts * * Optional: DOCMOST_E2E_PAGE_ID= pins the round-trip journey to a * specific page; otherwise it uses the first page found in the space. */ import { mkdtemp, readFile, readdir, mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { DocmostClient } from 'docmost-client'; import { buildVaultLayout, type PageNode } from '../src/layout.js'; // Gate: the journeys run only when DOCMOST_E2E is truthy. By default `d` is // describe.skip, so the suite is registered but not executed. const RUN_E2E = !!process.env.DOCMOST_E2E; const d = RUN_E2E ? describe : describe.skip; // Read live-connection config from the environment. Read lazily (not at module // top-level assertion time) so the skipped path never throws on missing vars. function liveConfig() { const apiUrl = process.env.DOCMOST_API_URL ?? ''; const email = process.env.DOCMOST_EMAIL ?? ''; const password = process.env.DOCMOST_PASSWORD ?? ''; const spaceId = process.env.DOCMOST_SPACE_ID ?? ''; return { apiUrl, email, password, spaceId }; } // Recursively collect every `.md` file path under `root`, relative to `root`, // using forward slashes — mirrors the folder hierarchy the pull writes. async function collectMarkdownFiles( root: string, prefix = '', ): Promise { const out: string[] = []; const entries = await readdir(root, { withFileTypes: true }); for (const e of entries) { const rel = prefix ? `${prefix}/${e.name}` : e.name; if (e.isDirectory()) { out.push(...(await collectMarkdownFiles(join(root, e.name), rel))); } else if (e.isFile() && e.name.endsWith('.md')) { out.push(rel); } } return out; } d('docmost-sync E2E (live server; DOCMOST_E2E gated)', () => { let client: DocmostClient; let vaultPath: string; beforeAll(async () => { const { apiUrl, email, password } = liveConfig(); // Fail loudly if the gate is on but the connection vars are missing — a // run that was asked to hit a live server with no address is a config bug, // not a silent pass. expect(apiUrl, 'DOCMOST_API_URL must be set when DOCMOST_E2E is on').not.toBe( '', ); expect(email, 'DOCMOST_EMAIL must be set').not.toBe(''); expect(password, 'DOCMOST_PASSWORD must be set').not.toBe(''); client = new DocmostClient(apiUrl, email, password); await client.login(); // A throwaway temp vault so the journey never touches the real data/ vault. vaultPath = await mkdtemp(join(tmpdir(), 'docmost-e2e-vault-')); }); afterAll(() => { // The temp vault is under the OS temp dir; leave it for post-mortem // inspection — the OS reclaims it. (No teardown that could mask a failure.) }); // Journey 1 — "pull a space into the vault". // Given the live space, walk its page tree, write one self-contained .md per // page under the deterministic folder hierarchy from buildVaultLayout, and // assert: (a) exactly one file per laid-out page, and (b) each file lands at // the path its layout entry dictates. This re-implements the I/O loop of // src/pull.ts so the assertions can target the produced tree directly. it('pull a space into the vault', async () => { const { spaceId } = liveConfig(); expect(spaceId, 'DOCMOST_SPACE_ID must be set').not.toBe(''); const pages: PageNode[] = await client.listAllSpacePages(spaceId); expect(pages.length).toBeGreaterThan(0); const layout = buildVaultLayout(pages); // Write one file per page at its laid-out destination (mirrors pull.ts). let written = 0; for (const page of pages) { if (!page || !page.id) continue; const entry = layout.get(page.id); if (!entry) continue; const dir = join(vaultPath, ...entry.segments); await mkdir(dir, { recursive: true }); const md = await client.exportPageBody(page.id); await writeFile(join(dir, `${entry.stem}.md`), md, 'utf8'); written++; } // (a) One Markdown file per page that got a layout entry. const expectedPaths = new Set(); for (const page of pages) { const entry = layout.get(page.id); if (!entry) continue; expectedPaths.add([...entry.segments, `${entry.stem}.md`].join('/')); } const actualPaths = await collectMarkdownFiles(vaultPath); expect(actualPaths.length).toBe(written); expect(new Set(actualPaths)).toEqual(expectedPaths); // (b) Correct folder hierarchy: every written file sits exactly where its // layout entry (ancestor folders + stem) places it, and every non-root // page is nested under at least one folder segment. for (const page of pages) { const entry = layout.get(page.id); if (!entry) continue; const rel = [...entry.segments, `${entry.stem}.md`].join('/'); expect(actualPaths).toContain(rel); const body = await readFile(join(vaultPath, rel), 'utf8'); // exportPageBody emits a self-contained file (meta block + body). expect(body.length).toBeGreaterThan(0); } }); // Journey 2 — "round-trip a page without a phantom diff". // SPEC §0 / §11 idempotency guarantee: export -> import -> export must yield a // byte-identical body. We export a real page's self-contained body, import it // back into the SAME page, then export again and assert the two exports are // byte-for-byte equal (so a subsequent pull produces no phantom git diff). it('round-trip a page without a phantom diff', async () => { const { spaceId } = liveConfig(); // Resolve the target page: an explicit override, else the first page found. let pageId = process.env.DOCMOST_E2E_PAGE_ID ?? ''; if (!pageId) { const pages: PageNode[] = await client.listAllSpacePages(spaceId); const first = pages.find((p) => p && p.id); expect(first, 'space has at least one page to round-trip').toBeTruthy(); pageId = first!.id; } // export #1 (self-contained body, SPEC §3). const md1 = await client.exportPageBody(pageId); expect(md1.length).toBeGreaterThan(0); // import the exact bytes back into the same page... await client.importPageMarkdown(pageId, md1); // ...then export #2. The idempotency invariant is byte-identical bodies. const md2 = await client.exportPageBody(pageId); expect(md2).toBe(md1); }); });