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.
172 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|