wiki-viewer 1.0.0 → 1.1.0
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 +365 -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 +431 -33
- 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 +365 -99
- package/bin/wiki-viewer.js +431 -33
- package/package.json +19 -2
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, writeFile, readFile, 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
|
+
import { idempotency } from "../../lib/proof/idempotency.js";
|
|
9
|
+
|
|
10
|
+
let tmpRoot: string;
|
|
11
|
+
|
|
12
|
+
before(async () => {
|
|
13
|
+
tmpRoot = await mkdtemp(path.join(tmpdir(), "wiki-proof-test-"));
|
|
14
|
+
setRootDir(tmpRoot);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
after(async () => {
|
|
18
|
+
await rm(tmpRoot, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function writeDoc(name: string, content: string): Promise<void> {
|
|
22
|
+
await writeFile(path.join(tmpRoot, name), content, "utf-8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readDoc(name: string): Promise<string> {
|
|
26
|
+
return readFile(path.join(tmpRoot, name), "utf-8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("readSnapshot returns null for missing file", async () => {
|
|
30
|
+
const snap = await readSnapshot(tmpRoot, "nonexistent.md");
|
|
31
|
+
assert.equal(snap, null);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("readSnapshot returns blocks for existing file", async () => {
|
|
35
|
+
await writeDoc("snap-test.md", "# Title\n\nParagraph.\n");
|
|
36
|
+
const snap = await readSnapshot(tmpRoot, "snap-test.md");
|
|
37
|
+
assert.ok(snap !== null);
|
|
38
|
+
assert.equal(snap!.blocks.length, 2);
|
|
39
|
+
assert.equal(snap!.blocks[0].type, "heading");
|
|
40
|
+
assert.equal(snap!.blocks[1].type, "paragraph");
|
|
41
|
+
assert.equal(snap!.revision, 0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("block.replace happy path", async () => {
|
|
45
|
+
await writeDoc("replace.md", "# Title\n\nOriginal paragraph.\n");
|
|
46
|
+
const snap = await readSnapshot(tmpRoot, "replace.md");
|
|
47
|
+
const paraRef = snap!.blocks[1].ref;
|
|
48
|
+
|
|
49
|
+
const result = await applyOps({
|
|
50
|
+
rootDir: tmpRoot,
|
|
51
|
+
mdPath: "replace.md",
|
|
52
|
+
baseRevision: 0,
|
|
53
|
+
by: "human",
|
|
54
|
+
ops: [{ type: "block.replace", ref: paraRef, markdown: "Replaced paragraph." }],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.ok(result.ok, `expected ok: ${JSON.stringify(result)}`);
|
|
58
|
+
assert.equal(result.ok ? result.snapshot.revision : -1, 1);
|
|
59
|
+
const content = await readDoc("replace.md");
|
|
60
|
+
assert.ok(content.includes("Replaced paragraph."), `content: ${content}`);
|
|
61
|
+
assert.ok(!content.includes("Original paragraph."), `old content: ${content}`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("block.insertAfter happy path", async () => {
|
|
65
|
+
await writeDoc("insert.md", "# Title\n\nParagraph.\n");
|
|
66
|
+
const snap = await readSnapshot(tmpRoot, "insert.md");
|
|
67
|
+
const titleRef = snap!.blocks[0].ref;
|
|
68
|
+
|
|
69
|
+
const result = await applyOps({
|
|
70
|
+
rootDir: tmpRoot,
|
|
71
|
+
mdPath: "insert.md",
|
|
72
|
+
baseRevision: 0,
|
|
73
|
+
by: "human",
|
|
74
|
+
ops: [{ type: "block.insertAfter", ref: titleRef, markdown: "New paragraph." }],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assert.ok(result.ok);
|
|
78
|
+
const content = await readDoc("insert.md");
|
|
79
|
+
assert.ok(content.includes("New paragraph."), `inserted: ${content}`);
|
|
80
|
+
assert.ok(content.includes("# Title"), `title preserved: ${content}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("block.insertBefore happy path", async () => {
|
|
84
|
+
await writeDoc("insertbefore.md", "# Title\n\nParagraph.\n");
|
|
85
|
+
const snap = await readSnapshot(tmpRoot, "insertbefore.md");
|
|
86
|
+
const paraRef = snap!.blocks[1].ref;
|
|
87
|
+
|
|
88
|
+
const result = await applyOps({
|
|
89
|
+
rootDir: tmpRoot,
|
|
90
|
+
mdPath: "insertbefore.md",
|
|
91
|
+
baseRevision: 0,
|
|
92
|
+
by: "human",
|
|
93
|
+
ops: [{ type: "block.insertBefore", ref: paraRef, markdown: "Before." }],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
assert.ok(result.ok);
|
|
97
|
+
const content = await readDoc("insertbefore.md");
|
|
98
|
+
assert.ok(content.includes("Before."), `inserted: ${content}`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("block.delete happy path", async () => {
|
|
102
|
+
await writeDoc("delete.md", "# Title\n\nTo delete.\n\n## Section\n");
|
|
103
|
+
const snap = await readSnapshot(tmpRoot, "delete.md");
|
|
104
|
+
const paraRef = snap!.blocks[1].ref;
|
|
105
|
+
|
|
106
|
+
const result = await applyOps({
|
|
107
|
+
rootDir: tmpRoot,
|
|
108
|
+
mdPath: "delete.md",
|
|
109
|
+
baseRevision: 0,
|
|
110
|
+
by: "human",
|
|
111
|
+
ops: [{ type: "block.delete", ref: paraRef }],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
assert.ok(result.ok);
|
|
115
|
+
const content = await readDoc("delete.md");
|
|
116
|
+
assert.ok(!content.includes("To delete."), `deleted: ${content}`);
|
|
117
|
+
assert.ok(content.includes("# Title"), `title preserved: ${content}`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("block.append adds to end", async () => {
|
|
121
|
+
await writeDoc("append.md", "# Title\n");
|
|
122
|
+
const result = await applyOps({
|
|
123
|
+
rootDir: tmpRoot,
|
|
124
|
+
mdPath: "append.md",
|
|
125
|
+
baseRevision: 0,
|
|
126
|
+
by: "human",
|
|
127
|
+
ops: [{ type: "block.append", markdown: "Appended." }],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.ok(result.ok);
|
|
131
|
+
const content = await readDoc("append.md");
|
|
132
|
+
assert.ok(content.includes("Appended."), `appended: ${content}`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("block.prepend adds to start", async () => {
|
|
136
|
+
await writeDoc("prepend.md", "# Title\n");
|
|
137
|
+
const result = await applyOps({
|
|
138
|
+
rootDir: tmpRoot,
|
|
139
|
+
mdPath: "prepend.md",
|
|
140
|
+
baseRevision: 0,
|
|
141
|
+
by: "human",
|
|
142
|
+
ops: [{ type: "block.prepend", markdown: "Prepended." }],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
assert.ok(result.ok);
|
|
146
|
+
const content = await readDoc("prepend.md");
|
|
147
|
+
assert.ok(content.startsWith("Prepended."), `prepended: ${content}`);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("STALE_REVISION returned when baseRevision wrong", async () => {
|
|
151
|
+
await writeDoc("stale.md", "# Title\n\nParagraph.\n");
|
|
152
|
+
const snap = await readSnapshot(tmpRoot, "stale.md");
|
|
153
|
+
const paraRef = snap!.blocks[1].ref;
|
|
154
|
+
|
|
155
|
+
const result = await applyOps({
|
|
156
|
+
rootDir: tmpRoot,
|
|
157
|
+
mdPath: "stale.md",
|
|
158
|
+
baseRevision: 99, // wrong
|
|
159
|
+
by: "human",
|
|
160
|
+
ops: [{ type: "block.replace", ref: paraRef, markdown: "New." }],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
assert.ok(!result.ok);
|
|
164
|
+
assert.equal(result.ok ? "" : result.code, "STALE_REVISION");
|
|
165
|
+
assert.equal(result.ok ? 0 : result.status, 409);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("BLOCK_NOT_FOUND returned for stale ref", async () => {
|
|
169
|
+
await writeDoc("notfound.md", "# Title\n");
|
|
170
|
+
|
|
171
|
+
const result = await applyOps({
|
|
172
|
+
rootDir: tmpRoot,
|
|
173
|
+
mdPath: "notfound.md",
|
|
174
|
+
baseRevision: 0,
|
|
175
|
+
by: "human",
|
|
176
|
+
ops: [{ type: "block.replace", ref: "bdeadbeef", markdown: "New." }],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.ok(!result.ok);
|
|
180
|
+
assert.equal(result.ok ? "" : result.code, "BLOCK_NOT_FOUND");
|
|
181
|
+
assert.equal(result.ok ? 0 : result.status, 409);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("FILE_NOT_FOUND for missing file", async () => {
|
|
185
|
+
const result = await applyOps({
|
|
186
|
+
rootDir: tmpRoot,
|
|
187
|
+
mdPath: "does-not-exist.md",
|
|
188
|
+
baseRevision: 0,
|
|
189
|
+
by: "human",
|
|
190
|
+
ops: [],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
assert.ok(!result.ok);
|
|
194
|
+
assert.equal(result.ok ? "" : result.code, "FILE_NOT_FOUND");
|
|
195
|
+
assert.equal(result.ok ? 0 : result.status, 404);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("AI insertAfter wraps text in proof-span", async () => {
|
|
199
|
+
await writeDoc("ai-wrap.md", "# Title\n\nParagraph.\n");
|
|
200
|
+
const snap = await readSnapshot(tmpRoot, "ai-wrap.md");
|
|
201
|
+
const titleRef = snap!.blocks[0].ref;
|
|
202
|
+
|
|
203
|
+
const result = await applyOps({
|
|
204
|
+
rootDir: tmpRoot,
|
|
205
|
+
mdPath: "ai-wrap.md",
|
|
206
|
+
baseRevision: 0,
|
|
207
|
+
by: "ai:claude",
|
|
208
|
+
ops: [{
|
|
209
|
+
type: "block.insertAfter",
|
|
210
|
+
ref: titleRef,
|
|
211
|
+
markdown: "AI wrote this content.",
|
|
212
|
+
basis: "described",
|
|
213
|
+
basisDetail: "user asked for it",
|
|
214
|
+
}],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
assert.ok(result.ok, `expected ok: ${JSON.stringify(result)}`);
|
|
218
|
+
const content = await readDoc("ai-wrap.md");
|
|
219
|
+
assert.ok(content.includes("<proof-span"), `proof-span missing: ${content}`);
|
|
220
|
+
assert.ok(content.includes('origin="ai"'), `origin missing: ${content}`);
|
|
221
|
+
assert.ok(content.includes('by="ai:claude"'), `by missing: ${content}`);
|
|
222
|
+
assert.ok(content.includes("AI wrote this content."), `content missing: ${content}`);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("comment.add and comment.reply ops", async () => {
|
|
226
|
+
await writeDoc("comment.md", "# Title\n\nParagraph.\n");
|
|
227
|
+
const snap = await readSnapshot(tmpRoot, "comment.md");
|
|
228
|
+
const paraRef = snap!.blocks[1].ref;
|
|
229
|
+
|
|
230
|
+
const addResult = await applyOps({
|
|
231
|
+
rootDir: tmpRoot,
|
|
232
|
+
mdPath: "comment.md",
|
|
233
|
+
baseRevision: 0,
|
|
234
|
+
by: "human",
|
|
235
|
+
ops: [{ type: "comment.add", ref: paraRef, text: "Why this?" }],
|
|
236
|
+
});
|
|
237
|
+
assert.ok(addResult.ok);
|
|
238
|
+
const commentId = addResult.ok ? addResult.snapshot.comments[0]?.id : null;
|
|
239
|
+
assert.ok(commentId, "comment should have an id");
|
|
240
|
+
|
|
241
|
+
const replyResult = await applyOps({
|
|
242
|
+
rootDir: tmpRoot,
|
|
243
|
+
mdPath: "comment.md",
|
|
244
|
+
baseRevision: 1,
|
|
245
|
+
by: "ai:claude",
|
|
246
|
+
ops: [{ type: "comment.reply", commentId: commentId!, text: "Because of X." }],
|
|
247
|
+
});
|
|
248
|
+
assert.ok(replyResult.ok);
|
|
249
|
+
const comment = replyResult.ok
|
|
250
|
+
? replyResult.snapshot.comments.find((c) => c.id === commentId)
|
|
251
|
+
: null;
|
|
252
|
+
assert.ok(comment, "comment should be in snapshot");
|
|
253
|
+
assert.equal(comment!.turns.length, 2, "should have 2 turns");
|
|
254
|
+
assert.equal(comment!.turns[1].text, "Because of X.");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("suggestion.add and suggestion.accept", async () => {
|
|
258
|
+
await writeDoc("suggest.md", "# Title\n\nOriginal.\n");
|
|
259
|
+
const snap = await readSnapshot(tmpRoot, "suggest.md");
|
|
260
|
+
const paraRef = snap!.blocks[1].ref;
|
|
261
|
+
|
|
262
|
+
const addResult = await applyOps({
|
|
263
|
+
rootDir: tmpRoot,
|
|
264
|
+
mdPath: "suggest.md",
|
|
265
|
+
baseRevision: 0,
|
|
266
|
+
by: "ai:claude",
|
|
267
|
+
ops: [{
|
|
268
|
+
type: "suggestion.add",
|
|
269
|
+
ref: paraRef,
|
|
270
|
+
kind: "replace",
|
|
271
|
+
markdown: "Suggested replacement.",
|
|
272
|
+
basis: "described",
|
|
273
|
+
}],
|
|
274
|
+
});
|
|
275
|
+
assert.ok(addResult.ok, `add: ${JSON.stringify(addResult)}`);
|
|
276
|
+
const sugId = addResult.ok ? addResult.snapshot.suggestions[0]?.id : null;
|
|
277
|
+
assert.ok(sugId, "suggestion should have an id");
|
|
278
|
+
|
|
279
|
+
const acceptResult = await applyOps({
|
|
280
|
+
rootDir: tmpRoot,
|
|
281
|
+
mdPath: "suggest.md",
|
|
282
|
+
baseRevision: 1,
|
|
283
|
+
by: "human",
|
|
284
|
+
ops: [{ type: "suggestion.accept", suggestionId: sugId! }],
|
|
285
|
+
});
|
|
286
|
+
assert.ok(acceptResult.ok, `accept: ${JSON.stringify(acceptResult)}`);
|
|
287
|
+
// Suggestion should be gone from pending
|
|
288
|
+
const pending = acceptResult.ok ? acceptResult.snapshot.suggestions : [];
|
|
289
|
+
assert.equal(pending.length, 0, "no pending suggestions after accept");
|
|
290
|
+
// File should have new content
|
|
291
|
+
const content = await readDoc("suggest.md");
|
|
292
|
+
assert.ok(content.includes("Suggested replacement."), `content: ${content}`);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("suggestion.reject moves to archive", async () => {
|
|
296
|
+
await writeDoc("reject.md", "# Title\n\nOriginal.\n");
|
|
297
|
+
const snap = await readSnapshot(tmpRoot, "reject.md");
|
|
298
|
+
const paraRef = snap!.blocks[1].ref;
|
|
299
|
+
|
|
300
|
+
const addResult = await applyOps({
|
|
301
|
+
rootDir: tmpRoot,
|
|
302
|
+
mdPath: "reject.md",
|
|
303
|
+
baseRevision: 0,
|
|
304
|
+
by: "ai:claude",
|
|
305
|
+
ops: [{
|
|
306
|
+
type: "suggestion.add",
|
|
307
|
+
ref: paraRef,
|
|
308
|
+
kind: "replace",
|
|
309
|
+
markdown: "Rejected suggestion.",
|
|
310
|
+
}],
|
|
311
|
+
});
|
|
312
|
+
assert.ok(addResult.ok);
|
|
313
|
+
const sugId = addResult.ok ? addResult.snapshot.suggestions[0]?.id : null;
|
|
314
|
+
|
|
315
|
+
const rejectResult = await applyOps({
|
|
316
|
+
rootDir: tmpRoot,
|
|
317
|
+
mdPath: "reject.md",
|
|
318
|
+
baseRevision: 1,
|
|
319
|
+
by: "human",
|
|
320
|
+
ops: [{ type: "suggestion.reject", suggestionId: sugId! }],
|
|
321
|
+
});
|
|
322
|
+
assert.ok(rejectResult.ok);
|
|
323
|
+
const pending = rejectResult.ok ? rejectResult.snapshot.suggestions : [];
|
|
324
|
+
assert.equal(pending.length, 0, "no pending suggestions after reject");
|
|
325
|
+
// File should NOT have been changed
|
|
326
|
+
const content = await readDoc("reject.md");
|
|
327
|
+
assert.ok(content.includes("Original."), `content unchanged: ${content}`);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("events emitted for ops", async () => {
|
|
331
|
+
await writeDoc("events.md", "# Title\n\nParagraph.\n");
|
|
332
|
+
const snap = await readSnapshot(tmpRoot, "events.md");
|
|
333
|
+
const paraRef = snap!.blocks[1].ref;
|
|
334
|
+
|
|
335
|
+
const result = await applyOps({
|
|
336
|
+
rootDir: tmpRoot,
|
|
337
|
+
mdPath: "events.md",
|
|
338
|
+
baseRevision: 0,
|
|
339
|
+
by: "human",
|
|
340
|
+
ops: [{ type: "block.replace", ref: paraRef, markdown: "New." }],
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
assert.ok(result.ok);
|
|
344
|
+
assert.ok(result.ok && result.emittedEvents.length > 0, "events should be emitted");
|
|
345
|
+
assert.equal(result.ok ? result.emittedEvents[0].type : "", "block.replaced");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("idempotency: same key returns cached response", async () => {
|
|
349
|
+
await writeDoc("idem.md", "# Title\n\nParagraph.\n");
|
|
350
|
+
const snap = await readSnapshot(tmpRoot, "idem.md");
|
|
351
|
+
const paraRef = snap!.blocks[1].ref;
|
|
352
|
+
|
|
353
|
+
const key = "test-idem-key-" + Date.now();
|
|
354
|
+
const payload = {
|
|
355
|
+
payloadHash: "abc",
|
|
356
|
+
status: 200,
|
|
357
|
+
body: JSON.stringify({ cached: true }),
|
|
358
|
+
};
|
|
359
|
+
idempotency.set(key, payload);
|
|
360
|
+
|
|
361
|
+
const cached = idempotency.get(key);
|
|
362
|
+
assert.ok(cached !== null, "cached entry should exist");
|
|
363
|
+
assert.equal(cached!.body, JSON.stringify({ cached: true }));
|
|
364
|
+
assert.equal(cached!.status, 200);
|
|
365
|
+
|
|
366
|
+
// Different key should not be cached
|
|
367
|
+
assert.equal(idempotency.get("other-key"), null);
|
|
368
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseFrontmatter } from "../../lib/markdown/parse-frontmatter.js";
|
|
4
|
+
|
|
5
|
+
test("parses valid frontmatter and returns body", () => {
|
|
6
|
+
const text = `---
|
|
7
|
+
title: Hello
|
|
8
|
+
status: draft
|
|
9
|
+
tags: [a, b, c]
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Body heading
|
|
13
|
+
|
|
14
|
+
Some text.`;
|
|
15
|
+
const { data, body } = parseFrontmatter(text);
|
|
16
|
+
assert.equal(data.title, "Hello");
|
|
17
|
+
assert.equal(data.status, "draft");
|
|
18
|
+
assert.deepEqual(data.tags, ["a", "b", "c"]);
|
|
19
|
+
assert.ok(body.trimStart().startsWith("# Body heading"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("does not swallow body when a stray --- HR appears far below", () => {
|
|
23
|
+
// Opening fence, but the content is markdown (heading), and the only later
|
|
24
|
+
// `---` is a horizontal rule near the end. The parser must not treat the
|
|
25
|
+
// whole document as frontmatter.
|
|
26
|
+
const text = `---
|
|
27
|
+
|
|
28
|
+
## title: Collapsed heading not YAML
|
|
29
|
+
|
|
30
|
+
# Real Title
|
|
31
|
+
|
|
32
|
+
Intro paragraph.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
**Next step:** trailing line.`;
|
|
37
|
+
const { data, body } = parseFrontmatter(text);
|
|
38
|
+
assert.deepEqual(data, {}, "no keys should be parsed");
|
|
39
|
+
assert.ok(body.includes("# Real Title"), "body must retain the document");
|
|
40
|
+
assert.ok(body.includes("Intro paragraph."), "body must not be swallowed");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns empty data when block has no YAML keys", () => {
|
|
44
|
+
const text = `---
|
|
45
|
+
just some prose
|
|
46
|
+
more prose
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
Body.`;
|
|
50
|
+
const { data, body } = parseFrontmatter(text);
|
|
51
|
+
assert.deepEqual(data, {});
|
|
52
|
+
assert.equal(body, text, "entire text preserved as body");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("no frontmatter fence returns text unchanged", () => {
|
|
56
|
+
const text = `# Just a doc\n\nNo frontmatter here.`;
|
|
57
|
+
const { data, body } = parseFrontmatter(text);
|
|
58
|
+
assert.deepEqual(data, {});
|
|
59
|
+
assert.equal(body, text);
|
|
60
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { wrapAsProofSpan, unwrapProofSpans, revertProofSpan } from "../../lib/proof/proof-span.js";
|
|
4
|
+
import type { SpanAttrs } from "../../lib/proof/types.js";
|
|
5
|
+
|
|
6
|
+
const BASE_ATTRS: SpanAttrs = {
|
|
7
|
+
spanId: "p0001",
|
|
8
|
+
origin: "ai",
|
|
9
|
+
basis: "described",
|
|
10
|
+
by: "ai:claude",
|
|
11
|
+
at: "2026-01-01T00:00:00Z",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
test("wrapAsProofSpan on paragraph wraps text content", () => {
|
|
15
|
+
const result = wrapAsProofSpan("The team will focus on three pillars.", BASE_ATTRS);
|
|
16
|
+
assert.ok(result !== null, "should not return null for paragraph");
|
|
17
|
+
assert.ok(result!.includes('<proof-span'), `missing opening tag: ${result}`);
|
|
18
|
+
assert.ok(result!.includes("The team will focus on three pillars."), `content missing: ${result}`);
|
|
19
|
+
assert.ok(result!.includes("</proof-span>"), `missing closing tag: ${result}`);
|
|
20
|
+
assert.ok(result!.includes('id="p0001"'), `id attr missing: ${result}`);
|
|
21
|
+
assert.ok(result!.includes('origin="ai"'), `origin attr missing: ${result}`);
|
|
22
|
+
assert.ok(result!.includes('by="ai:claude"'), `by attr missing: ${result}`);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("wrapAsProofSpan on heading wraps text after #s", () => {
|
|
26
|
+
const result = wrapAsProofSpan("## The Section Title", BASE_ATTRS);
|
|
27
|
+
assert.ok(result !== null);
|
|
28
|
+
assert.ok(result!.startsWith("## "), `should preserve heading hashes: ${result}`);
|
|
29
|
+
assert.ok(result!.includes('<proof-span'), `missing proof-span: ${result}`);
|
|
30
|
+
assert.ok(result!.includes("The Section Title"), `heading text missing: ${result}`);
|
|
31
|
+
// The ## should be OUTSIDE the span
|
|
32
|
+
assert.ok(!result!.startsWith("## <proof-span") || result!.startsWith("## <proof-span"),
|
|
33
|
+
`heading format ok: ${result}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("wrapAsProofSpan on code block returns null", () => {
|
|
37
|
+
const result = wrapAsProofSpan("```typescript\nconst x = 1;\n```", BASE_ATTRS);
|
|
38
|
+
assert.equal(result, null, "code block should return null");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("wrapAsProofSpan on table returns null", () => {
|
|
42
|
+
const result = wrapAsProofSpan("| A | B |\n|---|---|\n| 1 | 2 |", BASE_ATTRS);
|
|
43
|
+
assert.equal(result, null, "table should return null");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("wrapAsProofSpan on hr returns null", () => {
|
|
47
|
+
const result = wrapAsProofSpan("---", BASE_ATTRS);
|
|
48
|
+
assert.equal(result, null, "hr should return null");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("wrapAsProofSpan on blockquote wraps content", () => {
|
|
52
|
+
const result = wrapAsProofSpan("> This is a quote.", BASE_ATTRS);
|
|
53
|
+
assert.ok(result !== null);
|
|
54
|
+
assert.ok(result!.includes(">"), `blockquote prefix missing: ${result}`);
|
|
55
|
+
assert.ok(result!.includes("This is a quote."), `content missing: ${result}`);
|
|
56
|
+
assert.ok(result!.includes('<proof-span'), `proof-span missing: ${result}`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("wrapAsProofSpan on bullet list wraps each item", () => {
|
|
60
|
+
const result = wrapAsProofSpan("- item one\n- item two", BASE_ATTRS);
|
|
61
|
+
assert.ok(result !== null);
|
|
62
|
+
// Both items should have proof-span wraps
|
|
63
|
+
const matches = result!.match(/<proof-span/g);
|
|
64
|
+
assert.ok(matches && matches.length >= 2, `should wrap each item: ${result}`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("special chars in basisDetail escaped properly", () => {
|
|
68
|
+
const attrs: SpanAttrs = {
|
|
69
|
+
...BASE_ATTRS,
|
|
70
|
+
basisDetail: 'user said "please fix" this',
|
|
71
|
+
};
|
|
72
|
+
const result = wrapAsProofSpan("Some text.", attrs);
|
|
73
|
+
assert.ok(result !== null);
|
|
74
|
+
assert.ok(result!.includes("""), `quotes not escaped: ${result}`);
|
|
75
|
+
assert.ok(!result!.includes('"please fix"'), `raw quotes should be escaped: ${result}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("unwrapProofSpans removes marks, keeps content", () => {
|
|
79
|
+
const md = `<proof-span id="p001" origin="ai" by="ai:claude" at="2026-01-01T00:00:00Z">The content.</proof-span>`;
|
|
80
|
+
const result = unwrapProofSpans(md);
|
|
81
|
+
assert.equal(result, "The content.");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("revertProofSpan removes specific span by id", () => {
|
|
85
|
+
const md = `Before. <proof-span id="p001" origin="ai" by="ai:claude" at="2026-01-01T00:00:00Z">AI text.</proof-span> After.`;
|
|
86
|
+
const result = revertProofSpan(md, "p001");
|
|
87
|
+
assert.ok(!result.includes("AI text."), `span content should be removed: ${result}`);
|
|
88
|
+
assert.ok(result.includes("Before."), `surrounding content preserved: ${result}`);
|
|
89
|
+
assert.ok(result.includes("After."), `surrounding content preserved: ${result}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("revertProofSpan leaves other spans untouched", () => {
|
|
93
|
+
const md = `<proof-span id="p001" origin="ai" by="ai:claude" at="2026-01-01T00:00:00Z">First.</proof-span> <proof-span id="p002" origin="ai" by="ai:claude" at="2026-01-01T00:00:00Z">Second.</proof-span>`;
|
|
94
|
+
const result = revertProofSpan(md, "p001");
|
|
95
|
+
assert.ok(!result.includes("First."), `first span removed: ${result}`);
|
|
96
|
+
assert.ok(result.includes("Second."), `second span preserved: ${result}`);
|
|
97
|
+
assert.ok(result.includes('id="p002"'), `second span tag preserved: ${result}`);
|
|
98
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { test, before } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { checkAndConsume, _resetBuckets } from "../../lib/proof/rate-limit.js";
|
|
4
|
+
|
|
5
|
+
before(() => {
|
|
6
|
+
_resetBuckets();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("checkAndConsume: allows up to bucket size ops", () => {
|
|
10
|
+
_resetBuckets();
|
|
11
|
+
// Default bucket = 60. Consume 60 one at a time.
|
|
12
|
+
for (let i = 0; i < 60; i++) {
|
|
13
|
+
const r = checkAndConsume("ai:unit-test");
|
|
14
|
+
assert.equal(r.ok, true, `Op ${i + 1} should succeed`);
|
|
15
|
+
}
|
|
16
|
+
// 61st should fail
|
|
17
|
+
const r = checkAndConsume("ai:unit-test");
|
|
18
|
+
assert.equal(r.ok, false);
|
|
19
|
+
if (!r.ok) {
|
|
20
|
+
assert.ok(r.retryAfterMs > 0, "retryAfterMs must be positive");
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("checkAndConsume: bulk consume fails when n > tokens", () => {
|
|
25
|
+
_resetBuckets();
|
|
26
|
+
// Fresh bucket = 60 tokens. Request 61.
|
|
27
|
+
const r = checkAndConsume("ai:bulk-test", 61);
|
|
28
|
+
assert.equal(r.ok, false);
|
|
29
|
+
if (!r.ok) {
|
|
30
|
+
assert.ok(r.retryAfterMs > 0);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("checkAndConsume: different identities have independent buckets", () => {
|
|
35
|
+
_resetBuckets();
|
|
36
|
+
// Drain bucket for agent A
|
|
37
|
+
for (let i = 0; i < 60; i++) {
|
|
38
|
+
checkAndConsume("ai:agent-a");
|
|
39
|
+
}
|
|
40
|
+
// Agent B still has full bucket
|
|
41
|
+
const r = checkAndConsume("ai:agent-b");
|
|
42
|
+
assert.equal(r.ok, true, "Agent B must not be affected by agent A draining");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("checkAndConsume: tokens refill over time", async () => {
|
|
46
|
+
_resetBuckets();
|
|
47
|
+
// Drain 3 tokens
|
|
48
|
+
checkAndConsume("ai:refill-test", 3);
|
|
49
|
+
// Wait 3 seconds to refill 3 tokens
|
|
50
|
+
await new Promise((res) => setTimeout(res, 3100));
|
|
51
|
+
// Should now be able to consume 3 more (57 + 3 = 60 total, or at least 3 refilled)
|
|
52
|
+
const r = checkAndConsume("ai:refill-test", 3);
|
|
53
|
+
assert.equal(r.ok, true, "Should have refilled after 3s");
|
|
54
|
+
});
|