24b903aaf3
The git-sync converter + engine source lived only on the #119 branch; develop had just the dead compiled build/. Bring the whole package (src + ~700 tests) onto develop under CI, with NO consumer wired — git-sync stays fully inert in develop (nothing in apps/server imports it), so runtime behavior is unchanged. This unblocks #293 (extract the shared converter package from the landed source) and lets #119's functionality land LAST, already writing the canonical format (per the #326 landing order). - packages/git-sync: src (lib converter + engine) + test corpus + configs. - Remove develop's dead committed packages/git-sync/build/; gitignore it (built in CI/Docker via pnpm build, never committed — no src/build drift). - pnpm-lock.yaml: add the @docmost/git-sync importer (a missing workspace package in the lock is a CI blocker). `pnpm install --frozen-lockfile` passes. - NO server integration / loader / Dockerfile runtime changes (those come with #119 at step 6). Verified: tsc clean; vitest 711 passed | 1 expected-fail, 0 failures, 0 type errors; pnpm --frozen-lockfile EXIT 0; apps/server has no git-sync import. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
import { applyPushActions } from '../src/engine/push';
|
|
import type { ApplyPushDeps, PushActions } from '../src/engine/push';
|
|
|
|
const SPACE_ID = 'sp-test';
|
|
|
|
/** A recording client fake; listSpaceTree/createPage configurable per test. */
|
|
function makeClient() {
|
|
return {
|
|
listSpaceTree: vi.fn(async () => ({
|
|
pages: [] as { id: string; parentPageId?: string | null; title?: string }[],
|
|
complete: true,
|
|
})),
|
|
importPageMarkdown: vi.fn(async () => ({ success: true })),
|
|
createPage: vi.fn(
|
|
async (
|
|
title: string,
|
|
_content: string,
|
|
_spaceId: string,
|
|
_parentPageId?: string,
|
|
) => ({ data: { id: 'assigned-id', title }, success: true }),
|
|
),
|
|
deletePage: vi.fn(async () => ({ success: true })),
|
|
movePage: vi.fn(async () => ({ success: true })),
|
|
renamePage: vi.fn(async () => ({ success: true })),
|
|
};
|
|
}
|
|
|
|
function makeGit() {
|
|
return {
|
|
updateRef: vi.fn(async () => {}),
|
|
fastForwardBranch: vi.fn(async () => ({ ok: true })),
|
|
showFileAtRef: vi.fn(async () => null),
|
|
};
|
|
}
|
|
|
|
/** A recording fs fake over a path->text store (writes are read back). */
|
|
function makeFs(initial: Record<string, string> = {}) {
|
|
const store: Record<string, string> = { ...initial };
|
|
const fs = {
|
|
readFile: vi.fn(async (path: string) => {
|
|
if (!(path in store)) throw new Error(`no such file: ${path}`);
|
|
return store[path];
|
|
}),
|
|
writeFile: vi.fn(async (path: string, text: string) => {
|
|
store[path] = text;
|
|
}),
|
|
};
|
|
return { fs, store };
|
|
}
|
|
|
|
function deps(client: any, git: any, fs: ReturnType<typeof makeFs>): ApplyPushDeps {
|
|
return {
|
|
client,
|
|
git: git as any,
|
|
readFile: fs.fs.readFile,
|
|
writeFile: fs.fs.writeFile,
|
|
spaceId: SPACE_ID,
|
|
};
|
|
}
|
|
|
|
function actions(partial: Partial<PushActions>): PushActions {
|
|
return {
|
|
creates: [],
|
|
updates: [],
|
|
deletes: [],
|
|
renamesMoves: [],
|
|
skipped: [],
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// === Finding #6 — adopt must NOT clobber an arbitrary duplicate-title sibling ===
|
|
// The retry-adopt map keys pages by (parentPageId|root, title). When TWO root
|
|
// siblings share the title 'Foo', the key collides and the map keeps the FIRST
|
|
// (p1). A brand-new untracked 'Foo/Foo.md' (no gitmost_id) then "adopts" p1 and
|
|
// pushes its body over it via importPageMarkdown — silently overwriting an
|
|
// arbitrary, possibly unrelated, existing page. Desired: a fresh createPage, or
|
|
// an ambiguity skip — NEVER a silent overwrite of an existing sibling.
|
|
describe('redteam #6 — adopt clobbers wrong duplicate-title sibling', () => {
|
|
it('does NOT overwrite an arbitrary duplicate-title sibling (p1) via importPageMarkdown', async () => {
|
|
const client = makeClient();
|
|
client.listSpaceTree.mockResolvedValue({
|
|
pages: [
|
|
{ id: 'p1', parentPageId: null, title: 'Foo' },
|
|
{ id: 'p2', parentPageId: null, title: 'Foo' },
|
|
],
|
|
complete: true,
|
|
});
|
|
const git = makeGit();
|
|
// A brand-new local file with NO gitmost_id frontmatter.
|
|
const fs = makeFs({ 'Foo/Foo.md': '# Foo\n\nfresh foo body\n' });
|
|
|
|
await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({ creates: [{ path: 'Foo/Foo.md' }] }),
|
|
);
|
|
|
|
// The wrong sibling must never be overwritten with our body.
|
|
const clobberedP1 = client.importPageMarkdown.mock.calls.some(
|
|
(c: any[]) => c[0] === 'p1',
|
|
);
|
|
expect(clobberedP1).toBe(false);
|
|
});
|
|
});
|
|
|
|
// === Finding #12 — new child under new parent must be parented, not put at ROOT ===
|
|
// creates are applied in path order: 'Proj/Apple.md' (Apple < Proj) BEFORE
|
|
// 'Proj/Proj.md'. When Apple is created first, its parent folder-note
|
|
// 'Proj/Proj.md' has no gitmost_id yet, so the parent resolves to null and Apple
|
|
// is created at the SPACE ROOT instead of under Proj. Desired: the parent page is
|
|
// created before its child, so Apple's createPage receives Proj's assigned id.
|
|
describe('redteam #12 — new child under new parent placed at ROOT', () => {
|
|
it('createPage for Apple receives parentPageId === the id assigned to Proj', async () => {
|
|
let seq = 0;
|
|
const client = makeClient();
|
|
client.createPage.mockImplementation(
|
|
async (title: string) => ({
|
|
data: { id: `id-${++seq}`, title },
|
|
success: true,
|
|
}),
|
|
);
|
|
const git = makeGit();
|
|
// Both brand-new local files, neither carrying a gitmost_id yet. writeFile
|
|
// updates the store so readFile reads back any pageId written during the run.
|
|
const fs = makeFs({
|
|
'Proj/Apple.md': '# Apple\n\napple body\n',
|
|
'Proj/Proj.md': '# Proj\n\nproj body\n',
|
|
});
|
|
|
|
await applyPushActions(
|
|
deps(client, git, fs),
|
|
actions({
|
|
creates: [{ path: 'Proj/Apple.md' }, { path: 'Proj/Proj.md' }],
|
|
}),
|
|
);
|
|
|
|
const calls = client.createPage.mock.calls;
|
|
const results = client.createPage.mock.results;
|
|
const projIdx = calls.findIndex((c: any[]) => c[0] === 'Proj');
|
|
const appleIdx = calls.findIndex((c: any[]) => c[0] === 'Apple');
|
|
expect(projIdx).toBeGreaterThanOrEqual(0);
|
|
expect(appleIdx).toBeGreaterThanOrEqual(0);
|
|
const projId = ((await results[projIdx].value) as any).data.id;
|
|
const appleParentPageId = calls[appleIdx][3];
|
|
|
|
// Apple is a child of Proj -> it must be created under Proj, not at ROOT.
|
|
expect(appleParentPageId).toBe(projId);
|
|
});
|
|
});
|