wiki-viewer 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/README.md +328 -99
- package/.next/standalone/agents/bootstrap-prompt.md +1 -0
- package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
- package/.next/standalone/bin/wiki-viewer.js +57 -4
- package/.next/standalone/docs/agent-collab-plan.md +1615 -0
- package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
- package/.next/standalone/package.json +19 -2
- package/.next/standalone/pnpm-lock.yaml +1368 -325
- package/.next/standalone/pnpm-workspace.yaml +7 -1
- package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
- package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
- package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
- package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
- package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
- package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
- package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
- package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
- package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
- package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
- package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
- package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
- package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
- package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
- package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
- package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
- package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
- package/.next/standalone/src/app/api/system/config/route.ts +5 -1
- package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
- package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
- package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/route.ts +10 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
- package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
- package/.next/standalone/src/app/globals.css +70 -0
- package/.next/standalone/src/app/page.tsx +461 -217
- package/.next/standalone/src/app/signin/page.tsx +217 -0
- package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
- package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
- package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
- package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
- package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
- package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
- package/.next/standalone/src/components/editor/editor.tsx +513 -10
- package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
- package/.next/standalone/src/components/editor/extensions.ts +2 -0
- package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
- package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
- package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
- package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
- package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
- package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
- package/.next/standalone/src/lib/auth/client.ts +4 -0
- package/.next/standalone/src/lib/auth/csrf.ts +68 -0
- package/.next/standalone/src/lib/auth/server.ts +164 -0
- package/.next/standalone/src/lib/config.ts +4 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
- package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
- package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
- package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
- package/.next/standalone/src/lib/proof/activity.ts +74 -0
- package/.next/standalone/src/lib/proof/auth.ts +132 -0
- package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
- package/.next/standalone/src/lib/proof/blocks.ts +73 -0
- package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
- package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
- package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
- package/.next/standalone/src/lib/proof/glob.ts +51 -0
- package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
- package/.next/standalone/src/lib/proof/mutex.ts +32 -0
- package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
- package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
- package/.next/standalone/src/lib/proof/pending.ts +97 -0
- package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
- package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/registry.ts +190 -0
- package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
- package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
- package/.next/standalone/src/lib/proof/types.ts +170 -0
- package/.next/standalone/src/lib/proof-config.ts +14 -0
- package/.next/standalone/src/middleware.ts +38 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
- package/.next/standalone/src/stores/editor-store.ts +115 -9
- package/.next/standalone/src/stores/proof-store.ts +123 -0
- package/.next/standalone/src/stores/view-width-store.ts +62 -0
- package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
- package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
- package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
- package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
- package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
- package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
- package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
- package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
- package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
- package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
- package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
- package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
- package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
- package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
- package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
- package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
- package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
- package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
- package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
- package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
- package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
- package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
- package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
- package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
- package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
- package/README.md +328 -99
- package/bin/wiki-viewer.js +57 -4
- package/package.json +19 -2
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { applyOps, readSnapshot } from "../../lib/proof/ops-applier.js";
|
|
7
|
+
import { setRootDir } from "../../lib/root-dir.js";
|
|
8
|
+
|
|
9
|
+
let tmpRoot: string;
|
|
10
|
+
|
|
11
|
+
before(async () => {
|
|
12
|
+
tmpRoot = await mkdtemp(path.join(tmpdir(), "wiki-comment-test-"));
|
|
13
|
+
setRootDir(tmpRoot);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
after(async () => {
|
|
17
|
+
await rm(tmpRoot, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function writeDoc(name: string, content: string): Promise<void> {
|
|
21
|
+
await writeFile(path.join(tmpRoot, name), content, "utf-8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("comment.add — sidecar has comment with one turn", async () => {
|
|
25
|
+
await writeDoc("cadd.md", "# Title\n\nA paragraph.\n");
|
|
26
|
+
const snap = await readSnapshot(tmpRoot, "cadd.md");
|
|
27
|
+
assert.ok(snap !== null);
|
|
28
|
+
const paraRef = snap!.blocks[1].ref;
|
|
29
|
+
|
|
30
|
+
const result = await applyOps({
|
|
31
|
+
rootDir: tmpRoot,
|
|
32
|
+
mdPath: "cadd.md",
|
|
33
|
+
baseRevision: 0,
|
|
34
|
+
by: "human",
|
|
35
|
+
ops: [{ type: "comment.add", ref: paraRef, text: "First comment." }],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.ok(result.ok, `expected ok: ${JSON.stringify(result)}`);
|
|
39
|
+
assert.equal(result.ok ? result.snapshot.comments.length : -1, 1);
|
|
40
|
+
const c = result.ok ? result.snapshot.comments[0] : null;
|
|
41
|
+
assert.ok(c !== null);
|
|
42
|
+
assert.equal(c!.resolved, false);
|
|
43
|
+
assert.equal(c!.ref, paraRef);
|
|
44
|
+
assert.equal(c!.turns.length, 1);
|
|
45
|
+
assert.equal(c!.turns[0].text, "First comment.");
|
|
46
|
+
assert.equal(c!.turns[0].by, "human");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("comment.reply — existing comment gains second turn", async () => {
|
|
50
|
+
await writeDoc("creply.md", "# Title\n\nParagraph.\n");
|
|
51
|
+
const snap = await readSnapshot(tmpRoot, "creply.md");
|
|
52
|
+
const paraRef = snap!.blocks[1].ref;
|
|
53
|
+
|
|
54
|
+
const addResult = await applyOps({
|
|
55
|
+
rootDir: tmpRoot,
|
|
56
|
+
mdPath: "creply.md",
|
|
57
|
+
baseRevision: 0,
|
|
58
|
+
by: "human",
|
|
59
|
+
ops: [{ type: "comment.add", ref: paraRef, text: "Why?" }],
|
|
60
|
+
});
|
|
61
|
+
assert.ok(addResult.ok);
|
|
62
|
+
const commentId = addResult.ok ? addResult.snapshot.comments[0]?.id : null;
|
|
63
|
+
assert.ok(commentId);
|
|
64
|
+
|
|
65
|
+
const replyResult = await applyOps({
|
|
66
|
+
rootDir: tmpRoot,
|
|
67
|
+
mdPath: "creply.md",
|
|
68
|
+
baseRevision: 1,
|
|
69
|
+
by: "ai:claude",
|
|
70
|
+
ops: [{ type: "comment.reply", commentId: commentId!, text: "Because reasons." }],
|
|
71
|
+
});
|
|
72
|
+
assert.ok(replyResult.ok, `expected ok: ${JSON.stringify(replyResult)}`);
|
|
73
|
+
const c = replyResult.ok
|
|
74
|
+
? replyResult.snapshot.comments.find((x) => x.id === commentId)
|
|
75
|
+
: null;
|
|
76
|
+
assert.ok(c, "comment should be in snapshot");
|
|
77
|
+
assert.equal(c!.turns.length, 2);
|
|
78
|
+
assert.equal(c!.turns[1].text, "Because reasons.");
|
|
79
|
+
assert.equal(c!.turns[1].by, "ai:claude");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("comment.resolve — resolved=true + event emitted", async () => {
|
|
83
|
+
await writeDoc("cresolve.md", "# Title\n\nParagraph.\n");
|
|
84
|
+
const snap = await readSnapshot(tmpRoot, "cresolve.md");
|
|
85
|
+
const paraRef = snap!.blocks[1].ref;
|
|
86
|
+
|
|
87
|
+
const addResult = await applyOps({
|
|
88
|
+
rootDir: tmpRoot,
|
|
89
|
+
mdPath: "cresolve.md",
|
|
90
|
+
baseRevision: 0,
|
|
91
|
+
by: "human",
|
|
92
|
+
ops: [{ type: "comment.add", ref: paraRef, text: "Open thread." }],
|
|
93
|
+
});
|
|
94
|
+
assert.ok(addResult.ok);
|
|
95
|
+
const commentId = addResult.ok ? addResult.snapshot.comments[0]?.id : null;
|
|
96
|
+
assert.ok(commentId);
|
|
97
|
+
|
|
98
|
+
const resolveResult = await applyOps({
|
|
99
|
+
rootDir: tmpRoot,
|
|
100
|
+
mdPath: "cresolve.md",
|
|
101
|
+
baseRevision: 1,
|
|
102
|
+
by: "human",
|
|
103
|
+
ops: [{ type: "comment.resolve", commentId: commentId! }],
|
|
104
|
+
});
|
|
105
|
+
assert.ok(resolveResult.ok, `expected ok: ${JSON.stringify(resolveResult)}`);
|
|
106
|
+
const c = resolveResult.ok
|
|
107
|
+
? resolveResult.snapshot.comments.find((x) => x.id === commentId)
|
|
108
|
+
: null;
|
|
109
|
+
assert.ok(c, "comment still present in snapshot");
|
|
110
|
+
assert.equal(c!.resolved, true);
|
|
111
|
+
// event emitted
|
|
112
|
+
const evt = resolveResult.ok
|
|
113
|
+
? resolveResult.emittedEvents.find((e) => e.type === "comment.resolved")
|
|
114
|
+
: null;
|
|
115
|
+
assert.ok(evt, "comment.resolved event should be emitted");
|
|
116
|
+
assert.equal((evt as unknown as { commentId: string } | null)?.commentId, commentId);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("comment.reopen — resolved=false + event emitted", async () => {
|
|
120
|
+
await writeDoc("creopen.md", "# Title\n\nParagraph.\n");
|
|
121
|
+
const snap = await readSnapshot(tmpRoot, "creopen.md");
|
|
122
|
+
const paraRef = snap!.blocks[1].ref;
|
|
123
|
+
|
|
124
|
+
// add then resolve
|
|
125
|
+
const addResult = await applyOps({
|
|
126
|
+
rootDir: tmpRoot,
|
|
127
|
+
mdPath: "creopen.md",
|
|
128
|
+
baseRevision: 0,
|
|
129
|
+
by: "human",
|
|
130
|
+
ops: [{ type: "comment.add", ref: paraRef, text: "Thread." }],
|
|
131
|
+
});
|
|
132
|
+
assert.ok(addResult.ok);
|
|
133
|
+
const commentId = addResult.ok ? addResult.snapshot.comments[0]?.id : null;
|
|
134
|
+
assert.ok(commentId);
|
|
135
|
+
|
|
136
|
+
const resolveResult = await applyOps({
|
|
137
|
+
rootDir: tmpRoot,
|
|
138
|
+
mdPath: "creopen.md",
|
|
139
|
+
baseRevision: 1,
|
|
140
|
+
by: "human",
|
|
141
|
+
ops: [{ type: "comment.resolve", commentId: commentId! }],
|
|
142
|
+
});
|
|
143
|
+
assert.ok(resolveResult.ok);
|
|
144
|
+
|
|
145
|
+
const reopenResult = await applyOps({
|
|
146
|
+
rootDir: tmpRoot,
|
|
147
|
+
mdPath: "creopen.md",
|
|
148
|
+
baseRevision: 2,
|
|
149
|
+
by: "human",
|
|
150
|
+
ops: [{ type: "comment.reopen", commentId: commentId! }],
|
|
151
|
+
});
|
|
152
|
+
assert.ok(reopenResult.ok, `expected ok: ${JSON.stringify(reopenResult)}`);
|
|
153
|
+
const c = reopenResult.ok
|
|
154
|
+
? reopenResult.snapshot.comments.find((x) => x.id === commentId)
|
|
155
|
+
: null;
|
|
156
|
+
assert.ok(c);
|
|
157
|
+
assert.equal(c!.resolved, false);
|
|
158
|
+
// event emitted
|
|
159
|
+
const evt = reopenResult.ok
|
|
160
|
+
? reopenResult.emittedEvents.find((e) => e.type === "comment.reopened")
|
|
161
|
+
: null;
|
|
162
|
+
assert.ok(evt, "comment.reopened event should be emitted");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("comment.reply to nonexistent commentId → 409 COMMENT_NOT_FOUND", async () => {
|
|
166
|
+
await writeDoc("cnotfound.md", "# Title\n\nParagraph.\n");
|
|
167
|
+
const result = await applyOps({
|
|
168
|
+
rootDir: tmpRoot,
|
|
169
|
+
mdPath: "cnotfound.md",
|
|
170
|
+
baseRevision: 0,
|
|
171
|
+
by: "human",
|
|
172
|
+
ops: [{ type: "comment.reply", commentId: "cdeadbeef", text: "Ghost reply." }],
|
|
173
|
+
});
|
|
174
|
+
assert.ok(!result.ok);
|
|
175
|
+
assert.equal(result.ok ? "" : result.code, "COMMENT_NOT_FOUND");
|
|
176
|
+
assert.equal(result.ok ? 0 : result.status, 409);
|
|
177
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §7.6 Roundtrip fidelity: proof-span survives markdownToHtml → htmlToMarkdown.
|
|
3
|
+
*/
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { describe, it, test } from "node:test";
|
|
6
|
+
import { markdownToHtml } from "@/lib/markdown/to-html";
|
|
7
|
+
import { htmlToMarkdown } from "@/lib/markdown/to-markdown";
|
|
8
|
+
import { ProofSpan } from "@/components/editor/extensions/proof-span";
|
|
9
|
+
|
|
10
|
+
const SPAN_TAG = "proof-span";
|
|
11
|
+
|
|
12
|
+
/** Collect all attribute key=value pairs from a proof-span tag in a string. */
|
|
13
|
+
function extractSpanAttrs(src: string): Map<string, string> {
|
|
14
|
+
const match = src.match(/<proof-span\b([^>]*)>/);
|
|
15
|
+
if (!match) return new Map();
|
|
16
|
+
const attrsRaw = match[1];
|
|
17
|
+
const map = new Map<string, string>();
|
|
18
|
+
const attrRe = /([a-z-]+)="([^"]*)"/g;
|
|
19
|
+
let m: RegExpExecArray | null;
|
|
20
|
+
while ((m = attrRe.exec(attrsRaw)) !== null) {
|
|
21
|
+
map.set(m[1], m[2]);
|
|
22
|
+
}
|
|
23
|
+
return map;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("editor-roundtrip proof-span", () => {
|
|
27
|
+
it("preserves proof-span tag and inner content through html roundtrip", async () => {
|
|
28
|
+
const md =
|
|
29
|
+
'Hello <proof-span id="p123" origin="ai" by="ai:claude" at="2026-01-01T00:00:00Z" basis="described">hello world</proof-span> end.';
|
|
30
|
+
|
|
31
|
+
const html = await markdownToHtml(md);
|
|
32
|
+
|
|
33
|
+
// The HTML should contain a proof-span element (TipTap schema is client-side;
|
|
34
|
+
// here we verify the remark pipeline preserves raw HTML passthrough).
|
|
35
|
+
assert.ok(html.includes(SPAN_TAG), `HTML should contain <${SPAN_TAG}> tag:\n${html}`);
|
|
36
|
+
assert.ok(html.includes("hello world"), "HTML should contain inner text");
|
|
37
|
+
|
|
38
|
+
const roundtripped = htmlToMarkdown(html);
|
|
39
|
+
|
|
40
|
+
assert.ok(
|
|
41
|
+
roundtripped.includes(`<${SPAN_TAG}`),
|
|
42
|
+
`Roundtripped markdown should contain <${SPAN_TAG}> tag:\n${roundtripped}`,
|
|
43
|
+
);
|
|
44
|
+
assert.ok(
|
|
45
|
+
roundtripped.includes("hello world"),
|
|
46
|
+
"Roundtripped markdown should contain inner text",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const origAttrs = extractSpanAttrs(md);
|
|
50
|
+
const rtAttrs = extractSpanAttrs(roundtripped);
|
|
51
|
+
|
|
52
|
+
// Every attr in the original must survive the roundtrip.
|
|
53
|
+
for (const [key, value] of origAttrs) {
|
|
54
|
+
assert.equal(
|
|
55
|
+
rtAttrs.get(key),
|
|
56
|
+
value,
|
|
57
|
+
`Attribute '${key}' should survive roundtrip. Expected '${value}', got '${rtAttrs.get(key)}'`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("TipTap ProofSpan mark schema matches spec", () => {
|
|
63
|
+
const inst = ProofSpan.configure({});
|
|
64
|
+
assert.equal(inst.name, "proofSpan");
|
|
65
|
+
const spec = (inst as { config: { parseHTML?: () => unknown[] } }).config;
|
|
66
|
+
assert.ok(spec.parseHTML, "parseHTML defined");
|
|
67
|
+
const parseRules = (spec.parseHTML as () => Array<{ tag: string }>)();
|
|
68
|
+
assert.equal(parseRules[0].tag, "proof-span");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("preserves proof-span with wiki-link child", async () => {
|
|
72
|
+
const md =
|
|
73
|
+
'Text <proof-span id="p456" origin="ai" by="ai:cursor" at="2026-01-01T00:00:00Z" basis="inferred">see [[my-page]]</proof-span>.';
|
|
74
|
+
|
|
75
|
+
const html = await markdownToHtml(md);
|
|
76
|
+
const roundtripped = htmlToMarkdown(html);
|
|
77
|
+
|
|
78
|
+
assert.ok(roundtripped.includes(`<${SPAN_TAG}`), "span tag preserved");
|
|
79
|
+
// Wiki-link should be preserved (either as [[my-page]] or as anchor markup)
|
|
80
|
+
assert.ok(
|
|
81
|
+
roundtripped.includes("my-page"),
|
|
82
|
+
"wiki-link slug preserved inside span",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { readSnapshot } from "../../lib/proof/ops-applier.js";
|
|
7
|
+
import { setRootDir } from "../../lib/root-dir.js";
|
|
8
|
+
|
|
9
|
+
let tmpRoot: string;
|
|
10
|
+
|
|
11
|
+
before(async () => {
|
|
12
|
+
tmpRoot = await mkdtemp(path.join(tmpdir(), "wiki-ext-edit-test-"));
|
|
13
|
+
setRootDir(tmpRoot);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
after(async () => {
|
|
17
|
+
await rm(tmpRoot, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("readSnapshot: detects external edit and emits file.externallyEdited event", async () => {
|
|
21
|
+
const mdFile = path.join(tmpRoot, "ext-edit.md");
|
|
22
|
+
const original = "# Title\n\nOriginal content.\n";
|
|
23
|
+
|
|
24
|
+
// Write initial content
|
|
25
|
+
await writeFile(mdFile, original, "utf-8");
|
|
26
|
+
|
|
27
|
+
// Take first snapshot — establishes fingerprint in sidecar
|
|
28
|
+
const snap0 = await readSnapshot(tmpRoot, "ext-edit.md");
|
|
29
|
+
assert.ok(snap0 !== null);
|
|
30
|
+
assert.equal(snap0!.revision, 0);
|
|
31
|
+
|
|
32
|
+
// Externally modify the file on disk (bypass ops-applier)
|
|
33
|
+
const modified = "# Title\n\nExternally modified content.\n";
|
|
34
|
+
await writeFile(mdFile, modified, "utf-8");
|
|
35
|
+
|
|
36
|
+
// readSnapshot should detect the mismatch and bump revision + emit event
|
|
37
|
+
const snap1 = await readSnapshot(tmpRoot, "ext-edit.md");
|
|
38
|
+
assert.ok(snap1 !== null);
|
|
39
|
+
assert.equal(snap1!.revision, 1, "revision should be bumped after external edit");
|
|
40
|
+
|
|
41
|
+
// The last event in the sidecar should be file.externallyEdited
|
|
42
|
+
// We verify via the lastEventId being > the initial snap
|
|
43
|
+
assert.ok(snap1!.lastEventId >= 0, "lastEventId should be set");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("readSnapshot: no false positive when file unchanged", async () => {
|
|
47
|
+
const mdFile = path.join(tmpRoot, "no-change.md");
|
|
48
|
+
await writeFile(mdFile, "# Stable\n\nContent.\n", "utf-8");
|
|
49
|
+
|
|
50
|
+
const snap0 = await readSnapshot(tmpRoot, "no-change.md");
|
|
51
|
+
assert.ok(snap0 !== null);
|
|
52
|
+
assert.equal(snap0!.revision, 0);
|
|
53
|
+
|
|
54
|
+
// Read again without any external change
|
|
55
|
+
const snap1 = await readSnapshot(tmpRoot, "no-change.md");
|
|
56
|
+
assert.ok(snap1 !== null);
|
|
57
|
+
assert.equal(snap1!.revision, 0, "revision must not change when file is unmodified");
|
|
58
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic tests for withFileLock / withFileMutex cross-process safety layer.
|
|
3
|
+
*/
|
|
4
|
+
import { test, before, after } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
let tmpHome: string;
|
|
11
|
+
|
|
12
|
+
before(async () => {
|
|
13
|
+
tmpHome = await mkdtemp(path.join(tmpdir(), "file-lock-test-"));
|
|
14
|
+
process.env.HOME = tmpHome;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
after(async () => {
|
|
18
|
+
await rm(tmpHome, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
import { withFileLock } from "../../lib/proof/file-lock.js";
|
|
22
|
+
import { withFileMutex } from "../../lib/proof/mutex.js";
|
|
23
|
+
|
|
24
|
+
test("withFileLock: acquires and releases without deadlock", async () => {
|
|
25
|
+
const result = await withFileLock("test-key-1", async () => {
|
|
26
|
+
return 42;
|
|
27
|
+
});
|
|
28
|
+
assert.equal(result, 42);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("withFileLock: sequential calls on same key succeed", async () => {
|
|
32
|
+
const r1 = await withFileLock("test-key-seq", async () => "first");
|
|
33
|
+
const r2 = await withFileLock("test-key-seq", async () => "second");
|
|
34
|
+
assert.equal(r1, "first");
|
|
35
|
+
assert.equal(r2, "second");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("withFileLock: propagates errors and releases lock", async () => {
|
|
39
|
+
await assert.rejects(
|
|
40
|
+
() => withFileLock("test-key-err", async () => { throw new Error("boom"); }),
|
|
41
|
+
/boom/,
|
|
42
|
+
);
|
|
43
|
+
// Should be releasable again
|
|
44
|
+
const r = await withFileLock("test-key-err", async () => "ok");
|
|
45
|
+
assert.equal(r, "ok");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("withFileMutex: serialises concurrent in-process callers", async () => {
|
|
49
|
+
const results: number[] = [];
|
|
50
|
+
await Promise.all([
|
|
51
|
+
withFileMutex("mutex-concurrent-key", async () => {
|
|
52
|
+
results.push(1);
|
|
53
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
54
|
+
results.push(2);
|
|
55
|
+
}),
|
|
56
|
+
withFileMutex("mutex-concurrent-key", async () => {
|
|
57
|
+
results.push(3);
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
// Serialised: 1, 2 must appear before 3.
|
|
61
|
+
assert.deepEqual(results, [1, 2, 3]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("withFileMutex: different keys run concurrently", async () => {
|
|
65
|
+
const log: string[] = [];
|
|
66
|
+
await Promise.all([
|
|
67
|
+
withFileMutex("key-A", async () => {
|
|
68
|
+
log.push("A-start");
|
|
69
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
70
|
+
log.push("A-end");
|
|
71
|
+
}),
|
|
72
|
+
withFileMutex("key-B", async () => {
|
|
73
|
+
log.push("B-start");
|
|
74
|
+
log.push("B-end");
|
|
75
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
// B can start before A ends (different keys = no contention).
|
|
78
|
+
assert.ok(log.includes("B-start"));
|
|
79
|
+
assert.ok(log.includes("A-end"));
|
|
80
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { matchGlob } from "../../lib/proof/glob.js";
|
|
4
|
+
|
|
5
|
+
// ── ** matches ────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
test("** matches file in root", () => {
|
|
8
|
+
assert.ok(matchGlob("**/*", "notes.md"));
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("** matches file in subdirectory", () => {
|
|
12
|
+
assert.ok(matchGlob("**/*", "work/notes.md"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("** matches deeply nested file", () => {
|
|
16
|
+
assert.ok(matchGlob("**/*", "a/b/c/d/notes.md"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("**/*.md matches only .md files at any depth", () => {
|
|
20
|
+
assert.ok(matchGlob("**/*.md", "notes.md"));
|
|
21
|
+
assert.ok(matchGlob("**/*.md", "work/notes.md"));
|
|
22
|
+
assert.ok(matchGlob("**/*.md", "a/b/notes.md"));
|
|
23
|
+
assert.ok(!matchGlob("**/*.md", "notes.txt"));
|
|
24
|
+
assert.ok(!matchGlob("**/*.md", "work/notes.txt"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("** matches zero path segments (root file)", () => {
|
|
28
|
+
assert.ok(matchGlob("**/*", "root.md"));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ── * does not cross / ────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
test("* does not match path separator", () => {
|
|
34
|
+
assert.ok(!matchGlob("*.md", "work/notes.md"));
|
|
35
|
+
assert.ok(matchGlob("*.md", "notes.md"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("* matches empty string at same level", () => {
|
|
39
|
+
assert.ok(matchGlob("notes*", "notes.md"));
|
|
40
|
+
assert.ok(matchGlob("notes*", "notes"));
|
|
41
|
+
assert.ok(!matchGlob("notes*", "a/notes.md"));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── ? matches single non-slash char ──────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
test("? matches single character", () => {
|
|
47
|
+
assert.ok(matchGlob("note?.md", "notes.md"));
|
|
48
|
+
assert.ok(matchGlob("note?.md", "notex.md"));
|
|
49
|
+
assert.ok(!matchGlob("note?.md", "notexy.md"));
|
|
50
|
+
assert.ok(!matchGlob("note?.md", "work/notes.md"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── Literal match ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
test("Literal pattern matches exact path", () => {
|
|
56
|
+
assert.ok(matchGlob("notes/work.md", "notes/work.md"));
|
|
57
|
+
assert.ok(!matchGlob("notes/work.md", "notes/other.md"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("Literal pattern anchored — no partial match", () => {
|
|
61
|
+
assert.ok(!matchGlob("notes", "notes/work.md"));
|
|
62
|
+
assert.ok(!matchGlob("notes.md", "xnotes.md"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── Prefix patterns ───────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
test("work/** matches files under work/", () => {
|
|
68
|
+
assert.ok(matchGlob("work/**", "work/notes.md"));
|
|
69
|
+
assert.ok(matchGlob("work/**", "work/sub/notes.md"));
|
|
70
|
+
assert.ok(!matchGlob("work/**", "other/notes.md"));
|
|
71
|
+
assert.ok(!matchGlob("work/**", "notes.md"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── Regex special char escaping ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
test("Dots in pattern treated as literal", () => {
|
|
77
|
+
// notes.md pattern: dot is literal, must not match notesXmd
|
|
78
|
+
assert.ok(matchGlob("notes.md", "notes.md"));
|
|
79
|
+
assert.ok(!matchGlob("notes.md", "notesXmd"));
|
|
80
|
+
// Pattern without dots matches its own literal value
|
|
81
|
+
assert.ok(matchGlob("notesXmd", "notesXmd"));
|
|
82
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper: create a signed-in user session for use in route handler tests.
|
|
3
|
+
* Callers must set process.env.HOME to an isolated tmpdir before importing auth/server.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export async function makeUserSession(): Promise<string> {
|
|
7
|
+
const { auth, authReady } = await import("../../../lib/auth/server.js");
|
|
8
|
+
await authReady();
|
|
9
|
+
const res = await auth.api.signUpEmail({
|
|
10
|
+
body: {
|
|
11
|
+
email: `t${Date.now()}${Math.random().toString(36).slice(2, 6)}@test.local`,
|
|
12
|
+
password: "test1234!",
|
|
13
|
+
name: "Test User",
|
|
14
|
+
},
|
|
15
|
+
asResponse: true,
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
const text = await res.text();
|
|
19
|
+
throw new Error("signUpEmail failed: " + res.status + " " + text);
|
|
20
|
+
}
|
|
21
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
22
|
+
const cookies = setCookie
|
|
23
|
+
.split(/,(?=[^ ])/)
|
|
24
|
+
.map((c) => c.split(";")[0].trim())
|
|
25
|
+
.join("; ");
|
|
26
|
+
return cookies;
|
|
27
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { markdownToHtml } from "../../lib/markdown/to-html.js";
|
|
4
|
+
|
|
5
|
+
// These tests guard the read-only viewer path (sanitize: true). The sanitize
|
|
6
|
+
// pipeline must actually run end to end: a missing HTML parser previously made
|
|
7
|
+
// it throw, which blanked the viewer body. Keep runtime coverage here.
|
|
8
|
+
|
|
9
|
+
test("sanitize path renders body, tables, and headings", async () => {
|
|
10
|
+
const md = `## Section
|
|
11
|
+
|
|
12
|
+
<table><tbody><tr><td><p>Cell</p></td></tr></tbody></table>
|
|
13
|
+
|
|
14
|
+
Paragraph text.`;
|
|
15
|
+
const html = await markdownToHtml(md, { sanitize: true });
|
|
16
|
+
assert.ok(html.includes("<h2"), "heading should render");
|
|
17
|
+
assert.ok(html.includes("<table"), "raw HTML table should survive");
|
|
18
|
+
assert.ok(html.includes("Paragraph text."), "body should not be dropped");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("sanitize path strips scripts, event handlers, and javascript: urls", async () => {
|
|
22
|
+
const md = `<img src=x onerror="alert(1)">
|
|
23
|
+
<script>alert(2)</script>
|
|
24
|
+
|
|
25
|
+
[x](javascript:alert(3))`;
|
|
26
|
+
const html = await markdownToHtml(md, { sanitize: true });
|
|
27
|
+
assert.ok(!html.includes("onerror"), "onerror handler must be stripped");
|
|
28
|
+
assert.ok(!html.includes("<script"), "script tag must be stripped");
|
|
29
|
+
assert.ok(!html.includes("javascript:"), "javascript: url must be stripped");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("sanitize path preserves the wiki-link contract", async () => {
|
|
33
|
+
const html = await markdownToHtml("See [[my-page|Alias]] and [[other#sec]].", {
|
|
34
|
+
sanitize: true,
|
|
35
|
+
});
|
|
36
|
+
assert.ok(html.includes('data-wiki-link="true"'), "wiki-link marker kept");
|
|
37
|
+
assert.ok(html.includes('data-slug="my-page"'), "slug kept");
|
|
38
|
+
assert.ok(html.includes('data-alias="Alias"'), "alias kept");
|
|
39
|
+
assert.ok(html.includes('data-anchor="sec"'), "anchor kept");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("unsanitized editor path leaves HTML untouched", async () => {
|
|
43
|
+
const md = `<table><tbody><tr><td>x</td></tr></tbody></table>`;
|
|
44
|
+
const html = await markdownToHtml(md);
|
|
45
|
+
assert.ok(html.includes("<table"), "editor path keeps raw HTML");
|
|
46
|
+
});
|