Files
docmost-sync/test/e2e-docmost.test.ts
vvzvlad 90d8f86fda test: add full test suite for docmost-client and remaining modules
Raise coverage from 2.6% to 68% statements by adding 19 test files (~480
tests) covering every module in test-strategy-report.md. No production code
changed — tests reach private logic via (client as any), mock HTTP with
axios-mock-adapter on the real axios instance (interceptors intact), and mock
the Hocuspocus provider with vi.mock + real yjs + fake timers.

Coverage: auth-utils/filters/page-lock/json-edit 100%, diff 99%, node-ops 96%,
transforms 95%, collaboration 86%, layout 91%, client.ts 41% (transport).

- node-ops/transforms/json-edit/page-lock/filters: pure tree/text ops,
  immutability + clone guarantees, throw-vs-noop contracts
- markdown-converter + markdown-document envelope + fast-check round-trip
  property test
- diff, docmost-schema (sanitizeCssColor/clampCalloutType security guards)
- collaboration: pure (buildCollabWsUrl/buildYDoc) + write-path (mutatePageContent
  read-transform-write, false-success suppression)
- client.ts: isSafeUrl/validateDoc* XSS guards, vm-sandbox, REST pagination,
  401 re-auth interceptor, login dedup, uploadImage/createPage multipart guards
- collectRecentSince edge cases; loadSettingsOrExit invalid-value branch
- env-gated E2E skeleton (DOCMOST_E2E)

Two genuine markdown round-trip non-idempotency bugs are documented as it.fails
(code-mark excludes other marks; block-image injects a blank line). Latent:
isSafeUrl allows file:// on link context.

Adds dev-deps: fast-check, @vitest/coverage-v8, axios-mock-adapter; adds the
"coverage" npm script.
2026-06-16 22:50:04 +03:00

172 lines
6.9 KiB
TypeScript

/**
* 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=<spaceId> \
* npx vitest run test/e2e-docmost.test.ts
*
* Optional: DOCMOST_E2E_PAGE_ID=<pageId> 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<string[]> {
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<string>();
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);
});
});