@polderlabs/bizar-plugin 0.5.4
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/LICENSE +21 -0
- package/README.md +448 -0
- package/bun.lock +88 -0
- package/index.ts +1113 -0
- package/package.json +42 -0
- package/scripts/check-forbidden-imports.sh +33 -0
- package/src/background-state.ts +463 -0
- package/src/background.ts +964 -0
- package/src/commands-impl.ts +369 -0
- package/src/commands.ts +880 -0
- package/src/event-stream.ts +574 -0
- package/src/fingerprint.ts +120 -0
- package/src/handoff.ts +79 -0
- package/src/http-client.ts +467 -0
- package/src/logger.ts +144 -0
- package/src/loop.ts +176 -0
- package/src/options.ts +421 -0
- package/src/plan-fs.ts +323 -0
- package/src/report.ts +178 -0
- package/src/research-prompt.ts +35 -0
- package/src/serve.ts +476 -0
- package/src/settings.ts +349 -0
- package/src/state.ts +298 -0
- package/src/tools/bg-collect.ts +104 -0
- package/src/tools/bg-get-comments.ts +239 -0
- package/src/tools/bg-kill.ts +87 -0
- package/src/tools/bg-spawn.ts +263 -0
- package/src/tools/bg-status.ts +99 -0
- package/src/tools/plan-action.ts +767 -0
- package/src/tools/wait-for-feedback.ts +402 -0
- package/tests/attach-handler-bug.test.ts +166 -0
- package/tests/background-state.test.ts +277 -0
- package/tests/background.test.ts +402 -0
- package/tests/block.test.ts +193 -0
- package/tests/canonical-key-order.test.ts +71 -0
- package/tests/commands-impl.test.ts +442 -0
- package/tests/commands.test.ts +548 -0
- package/tests/config.test.ts +122 -0
- package/tests/dispose.test.ts +336 -0
- package/tests/event-stream.test.ts +409 -0
- package/tests/event.test.ts +262 -0
- package/tests/fingerprint.test.ts +161 -0
- package/tests/http-client.test.ts +403 -0
- package/tests/init-helpers.test.ts +203 -0
- package/tests/integration/slash-command.test.ts +348 -0
- package/tests/integration/tool-routing.test.ts +314 -0
- package/tests/loop.test.ts +397 -0
- package/tests/options.test.ts +274 -0
- package/tests/serve.test.ts +335 -0
- package/tests/settings.test.ts +351 -0
- package/tests/stall-think.test.ts +749 -0
- package/tests/state.test.ts +275 -0
- package/tests/tools/bg-collect.test.ts +337 -0
- package/tests/tools/bg-get-comments.test.ts +485 -0
- package/tests/tools/bg-kill.test.ts +231 -0
- package/tests/tools/bg-spawn.test.ts +311 -0
- package/tests/tools/bg-status.test.ts +216 -0
- package/tests/tools/plan-action.test.ts +599 -0
- package/tests/tools/wait-for-feedback.test.ts +390 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan-action.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests for the `planAction` core function in src/tools/plan-action.ts.
|
|
5
|
+
*
|
|
6
|
+
* The tool factory (`createPlanActionTool`) is a thin Zod schema + JSON
|
|
7
|
+
* wrapper around `planAction`. Testing the core function directly is
|
|
8
|
+
* faster and lets us drive edge cases that the framework would reject
|
|
9
|
+
* before reaching our code.
|
|
10
|
+
*
|
|
11
|
+
* Groups (13 tests):
|
|
12
|
+
* 1. get_canvas (success + missing + corrupt)
|
|
13
|
+
* 2. add_element (id generation + defaults)
|
|
14
|
+
* 3. update_element (patch + missing)
|
|
15
|
+
* 4. delete_element (cascade connections/comments)
|
|
16
|
+
* 5. add_connection (id generation + shorthand normalization)
|
|
17
|
+
* 6. delete_connection (success + missing)
|
|
18
|
+
* 7. add_comment (id + timestamp)
|
|
19
|
+
* 8. reply_to_comment (append to thread + missing)
|
|
20
|
+
* 9. set_status (update meta.json + touch plan.json)
|
|
21
|
+
* 10. invalid slug
|
|
22
|
+
* 11. corrupt plan.json does not throw
|
|
23
|
+
* 12. concurrent writes do not lose data
|
|
24
|
+
* 13. unknown action
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
28
|
+
import {
|
|
29
|
+
mkdtempSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
} from "node:fs";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
|
|
39
|
+
import { planAction } from "../../src/tools/plan-action.ts";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Fixtures
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
class MockLogger {
|
|
46
|
+
messages: Array<{ level: string; message: string }> = [];
|
|
47
|
+
log(opts: { level: string; message: string }) { this.messages.push(opts); }
|
|
48
|
+
debug(m: string) { this.messages.push({ level: "debug", message: m }); }
|
|
49
|
+
info(m: string) { this.messages.push({ level: "info", message: m }); }
|
|
50
|
+
warn(m: string) { this.messages.push({ level: "warn", message: m }); }
|
|
51
|
+
error(m: string) { this.messages.push({ level: "error", message: m }); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let worktree: string;
|
|
55
|
+
const logger = new MockLogger();
|
|
56
|
+
const locks = new Map<string, Promise<unknown>>();
|
|
57
|
+
|
|
58
|
+
beforeAll(() => {
|
|
59
|
+
worktree = mkdtempSync(join(tmpdir(), "bizar-plan-action-test-"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
if (worktree) rmSync(worktree, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function planDir(slug: string) {
|
|
67
|
+
return join(worktree, "plans", slug);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function planJsonPath(slug: string) {
|
|
71
|
+
return join(planDir(slug), "plan.json");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function metaJsonPath(slug: string) {
|
|
75
|
+
return join(planDir(slug), "meta.json");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function seedCanvas(slug: string, canvas: unknown) {
|
|
79
|
+
mkdirSync(planDir(slug), { recursive: true });
|
|
80
|
+
writeFileSync(planJsonPath(slug), JSON.stringify(canvas), "utf-8");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function seedMeta(slug: string, meta: unknown) {
|
|
84
|
+
mkdirSync(planDir(slug), { recursive: true });
|
|
85
|
+
writeFileSync(metaJsonPath(slug), JSON.stringify(meta), "utf-8");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readCanvas(slug: string): unknown {
|
|
89
|
+
return JSON.parse(readFileSync(planJsonPath(slug), "utf-8"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ===========================================================================
|
|
93
|
+
// Group 1 — get_canvas
|
|
94
|
+
// ===========================================================================
|
|
95
|
+
|
|
96
|
+
describe("planAction — get_canvas", () => {
|
|
97
|
+
test("returns the full plan.json when present", async () => {
|
|
98
|
+
const slug = "get-canvas-ok";
|
|
99
|
+
seedCanvas(slug, {
|
|
100
|
+
schemaVersion: 2,
|
|
101
|
+
title: "Test",
|
|
102
|
+
elements: [{ id: "el_a", title: "A" }],
|
|
103
|
+
comments: [],
|
|
104
|
+
});
|
|
105
|
+
const r = await planAction(worktree, logger, locks, {
|
|
106
|
+
action: "get_canvas",
|
|
107
|
+
planSlug: slug,
|
|
108
|
+
});
|
|
109
|
+
expect(r.ok).toBe(true);
|
|
110
|
+
if (r.ok) {
|
|
111
|
+
expect(r.action).toBe("get_canvas");
|
|
112
|
+
const canvas = r.canvas as { title?: string; elements?: unknown[] };
|
|
113
|
+
expect(canvas.title).toBe("Test");
|
|
114
|
+
expect(canvas.elements).toHaveLength(1);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("returns error when plan.json does not exist", async () => {
|
|
119
|
+
const r = await planAction(worktree, logger, locks, {
|
|
120
|
+
action: "get_canvas",
|
|
121
|
+
planSlug: "nonexistent-plan",
|
|
122
|
+
});
|
|
123
|
+
expect(r.ok).toBe(false);
|
|
124
|
+
if (!r.ok) {
|
|
125
|
+
expect(r.error).toMatch(/Plan not found/);
|
|
126
|
+
expect(r.planSlug).toBe("nonexistent-plan");
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ===========================================================================
|
|
132
|
+
// Group 2 — add_element
|
|
133
|
+
// ===========================================================================
|
|
134
|
+
|
|
135
|
+
describe("planAction — add_element", () => {
|
|
136
|
+
test("writes a new element with a generated id when none provided", async () => {
|
|
137
|
+
const slug = "add-element-gen";
|
|
138
|
+
await planAction(worktree, logger, locks, {
|
|
139
|
+
action: "add_element",
|
|
140
|
+
planSlug: slug,
|
|
141
|
+
element: { title: "Generated" },
|
|
142
|
+
});
|
|
143
|
+
const canvas = readCanvas(slug) as { elements: Array<{ id: string; title: string }> };
|
|
144
|
+
expect(canvas.elements).toHaveLength(1);
|
|
145
|
+
expect(canvas.elements[0]!.id).toMatch(/^el_/);
|
|
146
|
+
expect(canvas.elements[0]!.title).toBe("Generated");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("preserves a provided id when present", async () => {
|
|
150
|
+
const slug = "add-element-id";
|
|
151
|
+
await planAction(worktree, logger, locks, {
|
|
152
|
+
action: "add_element",
|
|
153
|
+
planSlug: slug,
|
|
154
|
+
element: { id: "el_custom", title: "Custom" },
|
|
155
|
+
});
|
|
156
|
+
const canvas = readCanvas(slug) as { elements: Array<{ id: string }> };
|
|
157
|
+
expect(canvas.elements[0]!.id).toBe("el_custom");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("applies default position/size when missing", async () => {
|
|
161
|
+
const slug = "add-element-defaults";
|
|
162
|
+
await planAction(worktree, logger, locks, {
|
|
163
|
+
action: "add_element",
|
|
164
|
+
planSlug: slug,
|
|
165
|
+
element: { title: "T" },
|
|
166
|
+
});
|
|
167
|
+
const canvas = readCanvas(slug) as {
|
|
168
|
+
elements: Array<{ x: number; y: number; width: number; height: number; type: string }>;
|
|
169
|
+
};
|
|
170
|
+
expect(canvas.elements[0]!.x).toBe(80);
|
|
171
|
+
expect(canvas.elements[0]!.y).toBe(80);
|
|
172
|
+
expect(canvas.elements[0]!.width).toBe(240);
|
|
173
|
+
expect(canvas.elements[0]!.height).toBe(160);
|
|
174
|
+
expect(canvas.elements[0]!.type).toBe("text");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("returns an error when 'element' arg is missing", async () => {
|
|
178
|
+
const r = await planAction(worktree, logger, locks, {
|
|
179
|
+
action: "add_element",
|
|
180
|
+
planSlug: "missing-element-arg",
|
|
181
|
+
});
|
|
182
|
+
expect(r.ok).toBe(false);
|
|
183
|
+
if (!r.ok) {
|
|
184
|
+
expect(r.error).toMatch(/Missing required argument: "element"/);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ===========================================================================
|
|
190
|
+
// Group 3 — update_element
|
|
191
|
+
// ===========================================================================
|
|
192
|
+
|
|
193
|
+
describe("planAction — update_element", () => {
|
|
194
|
+
test("patches an existing element", async () => {
|
|
195
|
+
const slug = "update-elem-ok";
|
|
196
|
+
seedCanvas(slug, {
|
|
197
|
+
schemaVersion: 2,
|
|
198
|
+
elements: [{ id: "el_x", title: "Old", x: 10 }],
|
|
199
|
+
});
|
|
200
|
+
const r = await planAction(worktree, logger, locks, {
|
|
201
|
+
action: "update_element",
|
|
202
|
+
planSlug: slug,
|
|
203
|
+
elementId: "el_x",
|
|
204
|
+
element: { title: "New", x: 20 },
|
|
205
|
+
});
|
|
206
|
+
expect(r.ok).toBe(true);
|
|
207
|
+
const canvas = readCanvas(slug) as { elements: Array<{ title: string; x: number; id: string }> };
|
|
208
|
+
expect(canvas.elements[0]!.title).toBe("New");
|
|
209
|
+
expect(canvas.elements[0]!.x).toBe(20);
|
|
210
|
+
expect(canvas.elements[0]!.id).toBe("el_x"); // id preserved
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("returns error when element not found", async () => {
|
|
214
|
+
const slug = "update-elem-missing";
|
|
215
|
+
seedCanvas(slug, { schemaVersion: 2, elements: [] });
|
|
216
|
+
const r = await planAction(worktree, logger, locks, {
|
|
217
|
+
action: "update_element",
|
|
218
|
+
planSlug: slug,
|
|
219
|
+
elementId: "el_nope",
|
|
220
|
+
element: { title: "X" },
|
|
221
|
+
});
|
|
222
|
+
expect(r.ok).toBe(false);
|
|
223
|
+
if (!r.ok) expect(r.error).toMatch(/Element not found/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("returns error when elementId is missing", async () => {
|
|
227
|
+
const r = await planAction(worktree, logger, locks, {
|
|
228
|
+
action: "update_element",
|
|
229
|
+
planSlug: "update-elem-no-id",
|
|
230
|
+
element: { title: "X" },
|
|
231
|
+
});
|
|
232
|
+
expect(r.ok).toBe(false);
|
|
233
|
+
if (!r.ok) expect(r.error).toMatch(/Missing required argument: "elementId"/);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ===========================================================================
|
|
238
|
+
// Group 4 — delete_element (cascade)
|
|
239
|
+
// ===========================================================================
|
|
240
|
+
|
|
241
|
+
describe("planAction — delete_element", () => {
|
|
242
|
+
test("removes the element and its connections + comments", async () => {
|
|
243
|
+
const slug = "delete-elem-cascade";
|
|
244
|
+
seedCanvas(slug, {
|
|
245
|
+
schemaVersion: 2,
|
|
246
|
+
elements: [
|
|
247
|
+
{ id: "el_a" },
|
|
248
|
+
{ id: "el_b" },
|
|
249
|
+
],
|
|
250
|
+
connections: [
|
|
251
|
+
{ id: "conn_1", fromElementId: "el_a", toElementId: "el_b" },
|
|
252
|
+
{ id: "conn_2", fromElementId: "el_b", toElementId: "el_a" },
|
|
253
|
+
],
|
|
254
|
+
comments: [
|
|
255
|
+
{ id: "c_1", elementId: "el_a", text: "Pinned to a" },
|
|
256
|
+
{ id: "c_2", elementId: "el_b", text: "Pinned to b" },
|
|
257
|
+
{ id: "c_3", elementId: null, text: "Canvas pinned" },
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
const r = await planAction(worktree, logger, locks, {
|
|
261
|
+
action: "delete_element",
|
|
262
|
+
planSlug: slug,
|
|
263
|
+
elementId: "el_a",
|
|
264
|
+
});
|
|
265
|
+
expect(r.ok).toBe(true);
|
|
266
|
+
if (r.ok) {
|
|
267
|
+
expect(r.removed).toBe(1);
|
|
268
|
+
expect(r.removedConnections).toBe(2);
|
|
269
|
+
expect(r.removedComments).toBe(1); // only c_1; canvas-pinned c_3 kept
|
|
270
|
+
}
|
|
271
|
+
const canvas = readCanvas(slug) as {
|
|
272
|
+
elements: unknown[];
|
|
273
|
+
connections: unknown[];
|
|
274
|
+
comments: Array<{ elementId: string | null }>;
|
|
275
|
+
};
|
|
276
|
+
expect(canvas.elements).toHaveLength(1);
|
|
277
|
+
expect(canvas.connections).toHaveLength(0);
|
|
278
|
+
expect(canvas.comments).toHaveLength(2);
|
|
279
|
+
expect(canvas.comments.find((c) => c.elementId === null)).toBeDefined();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ===========================================================================
|
|
284
|
+
// Group 5 — add_connection
|
|
285
|
+
// ===========================================================================
|
|
286
|
+
|
|
287
|
+
describe("planAction — add_connection", () => {
|
|
288
|
+
test("writes a new connection with a generated id", async () => {
|
|
289
|
+
const slug = "add-conn-gen";
|
|
290
|
+
await planAction(worktree, logger, locks, {
|
|
291
|
+
action: "add_connection",
|
|
292
|
+
planSlug: slug,
|
|
293
|
+
connection: { from: "el_a", to: "el_b" },
|
|
294
|
+
});
|
|
295
|
+
const canvas = readCanvas(slug) as { connections: Array<{ id: string; fromElementId: string; toElementId: string }> };
|
|
296
|
+
expect(canvas.connections).toHaveLength(1);
|
|
297
|
+
expect(canvas.connections[0]!.id).toMatch(/^conn_/);
|
|
298
|
+
expect(canvas.connections[0]!.fromElementId).toBe("el_a");
|
|
299
|
+
expect(canvas.connections[0]!.toElementId).toBe("el_b");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("preserves a provided id", async () => {
|
|
303
|
+
const slug = "add-conn-id";
|
|
304
|
+
await planAction(worktree, logger, locks, {
|
|
305
|
+
action: "add_connection",
|
|
306
|
+
planSlug: slug,
|
|
307
|
+
connection: { id: "conn_custom", fromElementId: "el_a", toElementId: "el_b" },
|
|
308
|
+
});
|
|
309
|
+
const canvas = readCanvas(slug) as { connections: Array<{ id: string }> };
|
|
310
|
+
expect(canvas.connections[0]!.id).toBe("conn_custom");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ===========================================================================
|
|
315
|
+
// Group 6 — delete_connection
|
|
316
|
+
// ===========================================================================
|
|
317
|
+
|
|
318
|
+
describe("planAction — delete_connection", () => {
|
|
319
|
+
test("removes the connection", async () => {
|
|
320
|
+
const slug = "del-conn-ok";
|
|
321
|
+
seedCanvas(slug, {
|
|
322
|
+
schemaVersion: 2,
|
|
323
|
+
connections: [
|
|
324
|
+
{ id: "conn_x", fromElementId: "el_a", toElementId: "el_b" },
|
|
325
|
+
],
|
|
326
|
+
});
|
|
327
|
+
const r = await planAction(worktree, logger, locks, {
|
|
328
|
+
action: "delete_connection",
|
|
329
|
+
planSlug: slug,
|
|
330
|
+
connectionId: "conn_x",
|
|
331
|
+
});
|
|
332
|
+
expect(r.ok).toBe(true);
|
|
333
|
+
const canvas = readCanvas(slug) as { connections: unknown[] };
|
|
334
|
+
expect(canvas.connections).toHaveLength(0);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("returns error when connection not found", async () => {
|
|
338
|
+
const slug = "del-conn-missing";
|
|
339
|
+
seedCanvas(slug, { schemaVersion: 2, connections: [] });
|
|
340
|
+
const r = await planAction(worktree, logger, locks, {
|
|
341
|
+
action: "delete_connection",
|
|
342
|
+
planSlug: slug,
|
|
343
|
+
connectionId: "conn_nope",
|
|
344
|
+
});
|
|
345
|
+
expect(r.ok).toBe(false);
|
|
346
|
+
if (!r.ok) expect(r.error).toMatch(/Connection not found/);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ===========================================================================
|
|
351
|
+
// Group 7 — add_comment
|
|
352
|
+
// ===========================================================================
|
|
353
|
+
|
|
354
|
+
describe("planAction — add_comment", () => {
|
|
355
|
+
test("writes a comment with generated id and timestamp", async () => {
|
|
356
|
+
const slug = "add-comment-gen";
|
|
357
|
+
await planAction(worktree, logger, locks, {
|
|
358
|
+
action: "add_comment",
|
|
359
|
+
planSlug: slug,
|
|
360
|
+
comment: { elementId: "el_a", text: "Hi", author: "DrB0rk" },
|
|
361
|
+
});
|
|
362
|
+
const canvas = readCanvas(slug) as { comments: Array<{ id: string; created: string; thread: unknown[] }> };
|
|
363
|
+
expect(canvas.comments).toHaveLength(1);
|
|
364
|
+
expect(canvas.comments[0]!.id).toMatch(/^c_/);
|
|
365
|
+
expect(canvas.comments[0]!.created).toMatch(/T/); // ISO-ish
|
|
366
|
+
expect(canvas.comments[0]!.thread).toEqual([]);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ===========================================================================
|
|
371
|
+
// Group 8 — reply_to_comment
|
|
372
|
+
// ===========================================================================
|
|
373
|
+
|
|
374
|
+
describe("planAction — reply_to_comment", () => {
|
|
375
|
+
test("appends a reply to an existing comment's thread", async () => {
|
|
376
|
+
const slug = "reply-comment-ok";
|
|
377
|
+
seedCanvas(slug, {
|
|
378
|
+
schemaVersion: 2,
|
|
379
|
+
comments: [{ id: "c_target", elementId: "el_a", text: "Hello" }],
|
|
380
|
+
});
|
|
381
|
+
const r = await planAction(worktree, logger, locks, {
|
|
382
|
+
action: "reply_to_comment",
|
|
383
|
+
planSlug: slug,
|
|
384
|
+
commentId: "c_target",
|
|
385
|
+
reply: { author: "ai", text: "Done" },
|
|
386
|
+
});
|
|
387
|
+
expect(r.ok).toBe(true);
|
|
388
|
+
if (r.ok) {
|
|
389
|
+
expect(r.commentId).toBe("c_target");
|
|
390
|
+
expect(r.replyId).toMatch(/^r_/);
|
|
391
|
+
}
|
|
392
|
+
const canvas = readCanvas(slug) as {
|
|
393
|
+
comments: Array<{ thread: Array<{ id: string; author: string; text: string }> }>;
|
|
394
|
+
};
|
|
395
|
+
expect(canvas.comments[0]!.thread).toHaveLength(1);
|
|
396
|
+
expect(canvas.comments[0]!.thread[0]!.author).toBe("ai");
|
|
397
|
+
expect(canvas.comments[0]!.thread[0]!.text).toBe("Done");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("returns error when comment not found", async () => {
|
|
401
|
+
const slug = "reply-comment-missing";
|
|
402
|
+
seedCanvas(slug, { schemaVersion: 2, comments: [] });
|
|
403
|
+
const r = await planAction(worktree, logger, locks, {
|
|
404
|
+
action: "reply_to_comment",
|
|
405
|
+
planSlug: slug,
|
|
406
|
+
commentId: "c_nope",
|
|
407
|
+
reply: { text: "Hi" },
|
|
408
|
+
});
|
|
409
|
+
expect(r.ok).toBe(false);
|
|
410
|
+
if (!r.ok) expect(r.error).toMatch(/Comment not found/);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ===========================================================================
|
|
415
|
+
// Group 9 — set_status
|
|
416
|
+
// ===========================================================================
|
|
417
|
+
|
|
418
|
+
describe("planAction — set_status", () => {
|
|
419
|
+
test("updates meta.json status", async () => {
|
|
420
|
+
const slug = "set-status-ok";
|
|
421
|
+
seedMeta(slug, { status: "draft" });
|
|
422
|
+
const r = await planAction(worktree, logger, locks, {
|
|
423
|
+
action: "set_status",
|
|
424
|
+
planSlug: slug,
|
|
425
|
+
status: "approved",
|
|
426
|
+
});
|
|
427
|
+
expect(r.ok).toBe(true);
|
|
428
|
+
if (r.ok) expect(r.status).toBe("approved");
|
|
429
|
+
const meta = JSON.parse(readFileSync(metaJsonPath(slug), "utf-8"));
|
|
430
|
+
expect(meta.status).toBe("approved");
|
|
431
|
+
expect(meta.lastEdited).toMatch(/T/);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("creates meta.json if missing", async () => {
|
|
435
|
+
const slug = "set-status-create";
|
|
436
|
+
mkdirSync(planDir(slug), { recursive: true });
|
|
437
|
+
const r = await planAction(worktree, logger, locks, {
|
|
438
|
+
action: "set_status",
|
|
439
|
+
planSlug: slug,
|
|
440
|
+
status: "rejected",
|
|
441
|
+
});
|
|
442
|
+
expect(r.ok).toBe(true);
|
|
443
|
+
expect(existsSync(metaJsonPath(slug))).toBe(true);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("rejects invalid status value", async () => {
|
|
447
|
+
const r = await planAction(worktree, logger, locks, {
|
|
448
|
+
action: "set_status",
|
|
449
|
+
planSlug: "set-status-bad",
|
|
450
|
+
status: "wat" as never,
|
|
451
|
+
});
|
|
452
|
+
expect(r.ok).toBe(false);
|
|
453
|
+
if (!r.ok) expect(r.error).toMatch(/Invalid status/);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ===========================================================================
|
|
458
|
+
// Group 10 — invalid slug
|
|
459
|
+
// ===========================================================================
|
|
460
|
+
|
|
461
|
+
describe("planAction — invalid slug", () => {
|
|
462
|
+
test("rejects UPPERCASE slug", async () => {
|
|
463
|
+
const r = await planAction(worktree, logger, locks, {
|
|
464
|
+
action: "get_canvas",
|
|
465
|
+
planSlug: "UPPERCASE",
|
|
466
|
+
});
|
|
467
|
+
expect(r.ok).toBe(false);
|
|
468
|
+
if (!r.ok) expect(r.error).toMatch(/Invalid planSlug/);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("rejects empty slug", async () => {
|
|
472
|
+
const r = await planAction(worktree, logger, locks, {
|
|
473
|
+
action: "get_canvas",
|
|
474
|
+
planSlug: "",
|
|
475
|
+
});
|
|
476
|
+
expect(r.ok).toBe(false);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("rejects path-traversal slug", async () => {
|
|
480
|
+
const r = await planAction(worktree, logger, locks, {
|
|
481
|
+
action: "get_canvas",
|
|
482
|
+
planSlug: "../etc",
|
|
483
|
+
});
|
|
484
|
+
expect(r.ok).toBe(false);
|
|
485
|
+
if (!r.ok) expect(r.error).toMatch(/Invalid planSlug/);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ===========================================================================
|
|
490
|
+
// Group 11 — corrupt plan.json does not throw
|
|
491
|
+
// ===========================================================================
|
|
492
|
+
|
|
493
|
+
describe("planAction — corrupt plan.json", () => {
|
|
494
|
+
test("does not throw and returns an error result on invalid JSON", async () => {
|
|
495
|
+
const slug = "corrupt-plan";
|
|
496
|
+
mkdirSync(planDir(slug), { recursive: true });
|
|
497
|
+
writeFileSync(planJsonPath(slug), "{ not valid json :: !!", "utf-8");
|
|
498
|
+
const r = await planAction(worktree, logger, locks, {
|
|
499
|
+
action: "add_element",
|
|
500
|
+
planSlug: slug,
|
|
501
|
+
element: { title: "Should fail" },
|
|
502
|
+
});
|
|
503
|
+
expect(r.ok).toBe(false);
|
|
504
|
+
if (!r.ok) {
|
|
505
|
+
expect(r.error).toMatch(/corrupt/i);
|
|
506
|
+
}
|
|
507
|
+
// Original corrupt file is preserved
|
|
508
|
+
expect(readFileSync(planJsonPath(slug), "utf-8")).toBe("{ not valid json :: !!");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("does not throw when plan.json is null", async () => {
|
|
512
|
+
const slug = "null-plan";
|
|
513
|
+
mkdirSync(planDir(slug), { recursive: true });
|
|
514
|
+
writeFileSync(planJsonPath(slug), "null", "utf-8");
|
|
515
|
+
const r = await planAction(worktree, logger, locks, {
|
|
516
|
+
action: "add_element",
|
|
517
|
+
planSlug: slug,
|
|
518
|
+
element: { title: "X" },
|
|
519
|
+
});
|
|
520
|
+
expect(r.ok).toBe(false);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// ===========================================================================
|
|
525
|
+
// Group 12 — concurrent writes do not lose data
|
|
526
|
+
// ===========================================================================
|
|
527
|
+
|
|
528
|
+
describe("planAction — concurrency", () => {
|
|
529
|
+
test("parallel add_element calls all land on disk", async () => {
|
|
530
|
+
const slug = "concurrent-add";
|
|
531
|
+
const N = 30;
|
|
532
|
+
const results = await Promise.all(
|
|
533
|
+
Array.from({ length: N }, (_, i) =>
|
|
534
|
+
planAction(worktree, logger, locks, {
|
|
535
|
+
action: "add_element",
|
|
536
|
+
planSlug: slug,
|
|
537
|
+
element: { title: `e-${i}` },
|
|
538
|
+
}),
|
|
539
|
+
),
|
|
540
|
+
);
|
|
541
|
+
const oks = results.filter((r) => r.ok);
|
|
542
|
+
expect(oks).toHaveLength(N);
|
|
543
|
+
const canvas = readCanvas(slug) as { elements: unknown[] };
|
|
544
|
+
expect(canvas.elements).toHaveLength(N);
|
|
545
|
+
const ids = new Set(
|
|
546
|
+
((canvas.elements as Array<{ id: string }>)).map((e) => e.id),
|
|
547
|
+
);
|
|
548
|
+
expect(ids.size).toBe(N); // all ids unique
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("parallel delete + add does not corrupt the file", async () => {
|
|
552
|
+
const slug = "concurrent-mixed";
|
|
553
|
+
// Seed 10 elements
|
|
554
|
+
seedCanvas(slug, {
|
|
555
|
+
schemaVersion: 2,
|
|
556
|
+
elements: Array.from({ length: 10 }, (_, i) => ({ id: `el_seed_${i}` })),
|
|
557
|
+
});
|
|
558
|
+
// Fire 10 deletes + 10 adds in parallel
|
|
559
|
+
const ops = [
|
|
560
|
+
...Array.from({ length: 10 }, (_, i) =>
|
|
561
|
+
planAction(worktree, logger, locks, {
|
|
562
|
+
action: "delete_element",
|
|
563
|
+
planSlug: slug,
|
|
564
|
+
elementId: `el_seed_${i}`,
|
|
565
|
+
}),
|
|
566
|
+
),
|
|
567
|
+
...Array.from({ length: 10 }, (_, i) =>
|
|
568
|
+
planAction(worktree, logger, locks, {
|
|
569
|
+
action: "add_element",
|
|
570
|
+
planSlug: slug,
|
|
571
|
+
element: { title: `new-${i}` },
|
|
572
|
+
}),
|
|
573
|
+
),
|
|
574
|
+
];
|
|
575
|
+
const results = await Promise.all(ops);
|
|
576
|
+
expect(results.every((r) => r.ok)).toBe(true);
|
|
577
|
+
// File is still valid JSON
|
|
578
|
+
const canvas = JSON.parse(readFileSync(planJsonPath(slug), "utf-8")) as {
|
|
579
|
+
elements: unknown[];
|
|
580
|
+
};
|
|
581
|
+
expect(Array.isArray(canvas.elements)).toBe(true);
|
|
582
|
+
expect(canvas.elements).toHaveLength(10); // 10 deletes + 10 adds = 10
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// ===========================================================================
|
|
587
|
+
// Group 13 — unknown action
|
|
588
|
+
// ===========================================================================
|
|
589
|
+
|
|
590
|
+
describe("planAction — unknown action", () => {
|
|
591
|
+
test("returns error for unknown action", async () => {
|
|
592
|
+
const r = await planAction(worktree, logger, locks, {
|
|
593
|
+
action: "nuke_plan",
|
|
594
|
+
planSlug: "unknown-action",
|
|
595
|
+
});
|
|
596
|
+
expect(r.ok).toBe(false);
|
|
597
|
+
if (!r.ok) expect(r.error).toMatch(/Unknown action/);
|
|
598
|
+
});
|
|
599
|
+
});
|