ralph-cli-sandboxed 0.6.5 → 0.7.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/LICENSE +191 -0
- package/README.md +1 -1
- package/dist/commands/action.js +6 -8
- package/dist/commands/ask.d.ts +6 -0
- package/dist/commands/ask.js +140 -0
- package/dist/commands/branch.js +8 -4
- package/dist/commands/chat.js +11 -8
- package/dist/commands/docker.js +19 -4
- package/dist/commands/fix-config.js +0 -41
- package/dist/commands/help.js +10 -0
- package/dist/commands/run.js +9 -9
- package/dist/config/languages.json +5 -3
- package/dist/index.js +2 -0
- package/dist/providers/telegram.js +1 -1
- package/dist/responders/claude-code-responder.js +1 -0
- package/dist/responders/cli-responder.js +1 -0
- package/dist/responders/llm-responder.js +1 -1
- package/dist/templates/macos-scripts.js +18 -18
- package/dist/tui/components/JsonSnippetEditor.js +7 -7
- package/dist/tui/components/KeyValueEditor.js +5 -1
- package/dist/tui/components/LLMProvidersEditor.js +7 -9
- package/dist/tui/components/Preview.js +1 -1
- package/dist/tui/components/SectionNav.js +18 -2
- package/dist/utils/chat-client.js +1 -0
- package/dist/utils/config.d.ts +1 -0
- package/dist/utils/config.js +3 -1
- package/dist/utils/config.test.d.ts +1 -0
- package/dist/utils/config.test.js +424 -0
- package/dist/utils/notification.js +1 -1
- package/dist/utils/prd-validator.js +16 -4
- package/dist/utils/prd-validator.test.d.ts +1 -0
- package/dist/utils/prd-validator.test.js +1095 -0
- package/dist/utils/responder.js +4 -1
- package/dist/utils/stream-json.test.d.ts +1 -0
- package/dist/utils/stream-json.test.js +1007 -0
- package/docs/DOCKER.md +14 -0
- package/docs/PRD-GENERATOR.md +15 -0
- package/package.json +16 -13
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validatePrd, extractPassingItems, smartMerge, attemptRecovery, robustYamlParse, expandFileReferences, expandPrdFileReferences, createTemplatePrd, } from "./prd-validator.js";
|
|
3
|
+
// ─── validatePrd ────────────────────────────────────────────────────
|
|
4
|
+
describe("validatePrd", () => {
|
|
5
|
+
it("validates a correct PRD array", () => {
|
|
6
|
+
const prd = [
|
|
7
|
+
{
|
|
8
|
+
category: "feature",
|
|
9
|
+
description: "Add login page",
|
|
10
|
+
steps: ["Create form", "Add validation"],
|
|
11
|
+
passes: false,
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
const result = validatePrd(prd);
|
|
15
|
+
expect(result.valid).toBe(true);
|
|
16
|
+
expect(result.errors).toHaveLength(0);
|
|
17
|
+
expect(result.data).toHaveLength(1);
|
|
18
|
+
expect(result.data[0].category).toBe("feature");
|
|
19
|
+
});
|
|
20
|
+
it("rejects non-array input", () => {
|
|
21
|
+
const result = validatePrd({ not: "an array" });
|
|
22
|
+
expect(result.valid).toBe(false);
|
|
23
|
+
expect(result.errors).toContain("PRD must be a JSON array");
|
|
24
|
+
});
|
|
25
|
+
it("rejects items with missing fields", () => {
|
|
26
|
+
const result = validatePrd([{ category: "feature" }]);
|
|
27
|
+
expect(result.valid).toBe(false);
|
|
28
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
29
|
+
});
|
|
30
|
+
it("rejects invalid categories", () => {
|
|
31
|
+
const result = validatePrd([
|
|
32
|
+
{
|
|
33
|
+
category: "invalid-cat",
|
|
34
|
+
description: "test",
|
|
35
|
+
steps: ["step1"],
|
|
36
|
+
passes: false,
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
expect(result.valid).toBe(false);
|
|
40
|
+
expect(result.errors.some((e) => e.includes("invalid category"))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
it("rejects non-string steps", () => {
|
|
43
|
+
const result = validatePrd([
|
|
44
|
+
{
|
|
45
|
+
category: "feature",
|
|
46
|
+
description: "test",
|
|
47
|
+
steps: [123],
|
|
48
|
+
passes: false,
|
|
49
|
+
},
|
|
50
|
+
]);
|
|
51
|
+
expect(result.valid).toBe(false);
|
|
52
|
+
expect(result.errors.some((e) => e.includes("step 1 must be a string"))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it("rejects non-boolean passes", () => {
|
|
55
|
+
const result = validatePrd([
|
|
56
|
+
{
|
|
57
|
+
category: "feature",
|
|
58
|
+
description: "test",
|
|
59
|
+
steps: ["step"],
|
|
60
|
+
passes: "yes",
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
expect(result.valid).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
it("accepts optional branch field", () => {
|
|
66
|
+
const result = validatePrd([
|
|
67
|
+
{
|
|
68
|
+
category: "feature",
|
|
69
|
+
description: "test",
|
|
70
|
+
steps: ["step"],
|
|
71
|
+
passes: false,
|
|
72
|
+
branch: "feat/login",
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
expect(result.valid).toBe(true);
|
|
76
|
+
expect(result.data[0].branch).toBe("feat/login");
|
|
77
|
+
});
|
|
78
|
+
it("rejects non-string branch field", () => {
|
|
79
|
+
const result = validatePrd([
|
|
80
|
+
{
|
|
81
|
+
category: "feature",
|
|
82
|
+
description: "test",
|
|
83
|
+
steps: ["step"],
|
|
84
|
+
passes: false,
|
|
85
|
+
branch: 123,
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
expect(result.valid).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
it("rejects non-object items", () => {
|
|
91
|
+
const result = validatePrd(["not an object"]);
|
|
92
|
+
expect(result.valid).toBe(false);
|
|
93
|
+
expect(result.errors.some((e) => e.includes("must be an object"))).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
it("rejects empty description", () => {
|
|
96
|
+
const result = validatePrd([
|
|
97
|
+
{
|
|
98
|
+
category: "feature",
|
|
99
|
+
description: "",
|
|
100
|
+
steps: ["step"],
|
|
101
|
+
passes: false,
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
expect(result.valid).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
// --- new edge case tests ---
|
|
107
|
+
it("validates an empty array as valid", () => {
|
|
108
|
+
const result = validatePrd([]);
|
|
109
|
+
expect(result.valid).toBe(true);
|
|
110
|
+
expect(result.data).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
it("accepts all valid category values", () => {
|
|
113
|
+
const categories = ["ui", "feature", "bugfix", "setup", "development", "testing", "docs"];
|
|
114
|
+
for (const category of categories) {
|
|
115
|
+
const result = validatePrd([
|
|
116
|
+
{ category, description: `Test ${category}`, steps: ["step"], passes: false },
|
|
117
|
+
]);
|
|
118
|
+
expect(result.valid).toBe(true);
|
|
119
|
+
expect(result.data[0].category).toBe(category);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
it("validates multiple valid items", () => {
|
|
123
|
+
const prd = [
|
|
124
|
+
{ category: "feature", description: "First", steps: ["a"], passes: false },
|
|
125
|
+
{ category: "bugfix", description: "Second", steps: ["b", "c"], passes: true },
|
|
126
|
+
{ category: "docs", description: "Third", steps: ["d"], passes: false, branch: "docs/api" },
|
|
127
|
+
];
|
|
128
|
+
const result = validatePrd(prd);
|
|
129
|
+
expect(result.valid).toBe(true);
|
|
130
|
+
expect(result.data).toHaveLength(3);
|
|
131
|
+
expect(result.data[1].passes).toBe(true);
|
|
132
|
+
expect(result.data[2].branch).toBe("docs/api");
|
|
133
|
+
});
|
|
134
|
+
it("collects errors from multiple invalid items", () => {
|
|
135
|
+
const result = validatePrd([
|
|
136
|
+
{ category: "badcat", description: "a", steps: ["s"], passes: false },
|
|
137
|
+
{ category: "feature", description: "", steps: ["s"], passes: false },
|
|
138
|
+
{ category: "feature", description: "x", steps: "not-array", passes: false },
|
|
139
|
+
]);
|
|
140
|
+
expect(result.valid).toBe(false);
|
|
141
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(3);
|
|
142
|
+
});
|
|
143
|
+
it("rejects null items in array", () => {
|
|
144
|
+
const result = validatePrd([null]);
|
|
145
|
+
expect(result.valid).toBe(false);
|
|
146
|
+
expect(result.errors.some((e) => e.includes("must be an object"))).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
it("rejects number items in array", () => {
|
|
149
|
+
const result = validatePrd([42]);
|
|
150
|
+
expect(result.valid).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
it("rejects boolean items in array", () => {
|
|
153
|
+
const result = validatePrd([true]);
|
|
154
|
+
expect(result.valid).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
it("rejects steps that is not an array", () => {
|
|
157
|
+
const result = validatePrd([
|
|
158
|
+
{ category: "feature", description: "test", steps: "just a string", passes: false },
|
|
159
|
+
]);
|
|
160
|
+
expect(result.valid).toBe(false);
|
|
161
|
+
expect(result.errors.some((e) => e.includes("steps"))).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
it("rejects missing category entirely", () => {
|
|
164
|
+
const result = validatePrd([{ description: "test", steps: ["step"], passes: false }]);
|
|
165
|
+
expect(result.valid).toBe(false);
|
|
166
|
+
expect(result.errors.some((e) => e.includes("category"))).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
it("rejects passes as numeric", () => {
|
|
169
|
+
const result = validatePrd([
|
|
170
|
+
{ category: "feature", description: "test", steps: ["step"], passes: 1 },
|
|
171
|
+
]);
|
|
172
|
+
expect(result.valid).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
it("does not include branch in data when not provided", () => {
|
|
175
|
+
const result = validatePrd([
|
|
176
|
+
{ category: "feature", description: "test", steps: ["step"], passes: false },
|
|
177
|
+
]);
|
|
178
|
+
expect(result.valid).toBe(true);
|
|
179
|
+
expect(result.data[0].branch).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
it("rejects string input", () => {
|
|
182
|
+
const result = validatePrd("not an array");
|
|
183
|
+
expect(result.valid).toBe(false);
|
|
184
|
+
expect(result.errors).toContain("PRD must be a JSON array");
|
|
185
|
+
});
|
|
186
|
+
it("rejects null input", () => {
|
|
187
|
+
const result = validatePrd(null);
|
|
188
|
+
expect(result.valid).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
it("rejects undefined input", () => {
|
|
191
|
+
const result = validatePrd(undefined);
|
|
192
|
+
expect(result.valid).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
it("rejects numeric input", () => {
|
|
195
|
+
const result = validatePrd(123);
|
|
196
|
+
expect(result.valid).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
it("handles steps with mixed valid and invalid entries", () => {
|
|
199
|
+
const result = validatePrd([
|
|
200
|
+
{
|
|
201
|
+
category: "feature",
|
|
202
|
+
description: "test",
|
|
203
|
+
steps: ["valid", 123, "also valid"],
|
|
204
|
+
passes: false,
|
|
205
|
+
},
|
|
206
|
+
]);
|
|
207
|
+
expect(result.valid).toBe(false);
|
|
208
|
+
expect(result.errors.some((e) => e.includes("step 2 must be a string"))).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
it("allows items with passes: true", () => {
|
|
211
|
+
const result = validatePrd([
|
|
212
|
+
{ category: "feature", description: "Completed feature", steps: ["done"], passes: true },
|
|
213
|
+
]);
|
|
214
|
+
expect(result.valid).toBe(true);
|
|
215
|
+
expect(result.data[0].passes).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
it("accepts items with empty steps array", () => {
|
|
218
|
+
const result = validatePrd([
|
|
219
|
+
{ category: "feature", description: "test", steps: [], passes: false },
|
|
220
|
+
]);
|
|
221
|
+
expect(result.valid).toBe(true);
|
|
222
|
+
expect(result.data[0].steps).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
it("handles branch field with empty string", () => {
|
|
225
|
+
const result = validatePrd([
|
|
226
|
+
{ category: "feature", description: "test", steps: ["s"], passes: false, branch: "" },
|
|
227
|
+
]);
|
|
228
|
+
// empty string is still a string, should be valid
|
|
229
|
+
expect(result.valid).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
it("rejects branch as boolean", () => {
|
|
232
|
+
const result = validatePrd([
|
|
233
|
+
{ category: "feature", description: "test", steps: ["s"], passes: false, branch: true },
|
|
234
|
+
]);
|
|
235
|
+
expect(result.valid).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
it("rejects branch as array", () => {
|
|
238
|
+
const result = validatePrd([
|
|
239
|
+
{ category: "feature", description: "test", steps: ["s"], passes: false, branch: ["a", "b"] },
|
|
240
|
+
]);
|
|
241
|
+
expect(result.valid).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
// ─── extractPassingItems ────────────────────────────────────────────
|
|
245
|
+
describe("extractPassingItems", () => {
|
|
246
|
+
it("returns empty for null/undefined", () => {
|
|
247
|
+
expect(extractPassingItems(null)).toEqual([]);
|
|
248
|
+
expect(extractPassingItems(undefined)).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
it("extracts from a direct array", () => {
|
|
251
|
+
const items = extractPassingItems([
|
|
252
|
+
{ description: "Login page", passes: true },
|
|
253
|
+
{ description: "Signup page", passes: false },
|
|
254
|
+
]);
|
|
255
|
+
expect(items).toHaveLength(2);
|
|
256
|
+
expect(items[0].description).toBe("Login page");
|
|
257
|
+
expect(items[0].passes).toBe(true);
|
|
258
|
+
expect(items[1].passes).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
it("extracts from wrapped objects", () => {
|
|
261
|
+
const items = extractPassingItems({
|
|
262
|
+
tasks: [{ description: "Login page", passes: true }],
|
|
263
|
+
});
|
|
264
|
+
expect(items).toHaveLength(1);
|
|
265
|
+
expect(items[0].description).toBe("Login page");
|
|
266
|
+
});
|
|
267
|
+
it("handles alternative field names", () => {
|
|
268
|
+
const items = extractPassingItems([{ name: "Login page", done: true }]);
|
|
269
|
+
expect(items).toHaveLength(1);
|
|
270
|
+
expect(items[0].description).toBe("Login page");
|
|
271
|
+
expect(items[0].passes).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
it("handles string passes values", () => {
|
|
274
|
+
const items = extractPassingItems([
|
|
275
|
+
{ description: "Task A", status: "completed" },
|
|
276
|
+
{ description: "Task B", status: "pending" },
|
|
277
|
+
]);
|
|
278
|
+
expect(items[0].passes).toBe(true);
|
|
279
|
+
expect(items[1].passes).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
it("extracts from a single object (not array)", () => {
|
|
282
|
+
const items = extractPassingItems({ title: "Single task", passes: true });
|
|
283
|
+
expect(items).toHaveLength(1);
|
|
284
|
+
expect(items[0].description).toBe("Single task");
|
|
285
|
+
});
|
|
286
|
+
it("skips items without description", () => {
|
|
287
|
+
const items = extractPassingItems([{ passes: true }, { description: "Valid", passes: true }]);
|
|
288
|
+
expect(items).toHaveLength(1);
|
|
289
|
+
expect(items[0].description).toBe("Valid");
|
|
290
|
+
});
|
|
291
|
+
// --- new edge case tests ---
|
|
292
|
+
it("returns empty for empty array", () => {
|
|
293
|
+
expect(extractPassingItems([])).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
it("returns empty for empty object", () => {
|
|
296
|
+
expect(extractPassingItems({})).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
it("returns empty for primitive values", () => {
|
|
299
|
+
expect(extractPassingItems(42)).toEqual([]);
|
|
300
|
+
expect(extractPassingItems("string")).toEqual([]);
|
|
301
|
+
expect(extractPassingItems(true)).toEqual([]);
|
|
302
|
+
});
|
|
303
|
+
it("extracts from 'features' wrapper key", () => {
|
|
304
|
+
const items = extractPassingItems({
|
|
305
|
+
features: [{ description: "Feature 1", passes: true }],
|
|
306
|
+
});
|
|
307
|
+
expect(items).toHaveLength(1);
|
|
308
|
+
expect(items[0].description).toBe("Feature 1");
|
|
309
|
+
});
|
|
310
|
+
it("extracts from 'items' wrapper key", () => {
|
|
311
|
+
const items = extractPassingItems({
|
|
312
|
+
items: [{ description: "Item 1", passes: false }],
|
|
313
|
+
});
|
|
314
|
+
expect(items).toHaveLength(1);
|
|
315
|
+
expect(items[0].passes).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
it("extracts from 'entries' wrapper key", () => {
|
|
318
|
+
const items = extractPassingItems({
|
|
319
|
+
entries: [{ description: "Entry 1", done: true }],
|
|
320
|
+
});
|
|
321
|
+
expect(items).toHaveLength(1);
|
|
322
|
+
expect(items[0].passes).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
it("extracts from 'prd' wrapper key", () => {
|
|
325
|
+
const items = extractPassingItems({
|
|
326
|
+
prd: [{ description: "PRD item", passes: true }],
|
|
327
|
+
});
|
|
328
|
+
expect(items).toHaveLength(1);
|
|
329
|
+
});
|
|
330
|
+
it("extracts from 'requirements' wrapper key", () => {
|
|
331
|
+
const items = extractPassingItems({
|
|
332
|
+
requirements: [{ description: "Requirement 1", complete: true }],
|
|
333
|
+
});
|
|
334
|
+
expect(items).toHaveLength(1);
|
|
335
|
+
expect(items[0].passes).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
it("uses first matching wrapper key and ignores others", () => {
|
|
338
|
+
const items = extractPassingItems({
|
|
339
|
+
features: [{ description: "From features", passes: true }],
|
|
340
|
+
tasks: [{ description: "From tasks", passes: false }],
|
|
341
|
+
});
|
|
342
|
+
expect(items).toHaveLength(1);
|
|
343
|
+
expect(items[0].description).toBe("From features");
|
|
344
|
+
});
|
|
345
|
+
it("handles 'desc' as alternative description field", () => {
|
|
346
|
+
const items = extractPassingItems([{ desc: "Short desc", passes: true }]);
|
|
347
|
+
expect(items).toHaveLength(1);
|
|
348
|
+
expect(items[0].description).toBe("Short desc");
|
|
349
|
+
});
|
|
350
|
+
it("handles 'task' as alternative description field", () => {
|
|
351
|
+
const items = extractPassingItems([{ task: "My task", passes: false }]);
|
|
352
|
+
expect(items).toHaveLength(1);
|
|
353
|
+
expect(items[0].description).toBe("My task");
|
|
354
|
+
});
|
|
355
|
+
it("handles 'feature' as alternative description field", () => {
|
|
356
|
+
const items = extractPassingItems([{ feature: "My feature", passes: true }]);
|
|
357
|
+
expect(items).toHaveLength(1);
|
|
358
|
+
expect(items[0].description).toBe("My feature");
|
|
359
|
+
});
|
|
360
|
+
it("handles 'pass' as alternative passes field", () => {
|
|
361
|
+
const items = extractPassingItems([{ description: "test", pass: true }]);
|
|
362
|
+
expect(items).toHaveLength(1);
|
|
363
|
+
expect(items[0].passes).toBe(true);
|
|
364
|
+
});
|
|
365
|
+
it("handles 'passed' as alternative passes field", () => {
|
|
366
|
+
const items = extractPassingItems([{ description: "test", passed: true }]);
|
|
367
|
+
expect(items).toHaveLength(1);
|
|
368
|
+
expect(items[0].passes).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
it("handles 'finished' as alternative passes field", () => {
|
|
371
|
+
const items = extractPassingItems([{ description: "test", finished: true }]);
|
|
372
|
+
expect(items).toHaveLength(1);
|
|
373
|
+
expect(items[0].passes).toBe(true);
|
|
374
|
+
});
|
|
375
|
+
it("handles string status 'true'", () => {
|
|
376
|
+
const items = extractPassingItems([{ description: "test", passes: "true" }]);
|
|
377
|
+
expect(items).toHaveLength(1);
|
|
378
|
+
expect(items[0].passes).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
it("handles string status 'pass'", () => {
|
|
381
|
+
const items = extractPassingItems([{ description: "test", status: "pass" }]);
|
|
382
|
+
expect(items).toHaveLength(1);
|
|
383
|
+
expect(items[0].passes).toBe(true);
|
|
384
|
+
});
|
|
385
|
+
it("handles string status 'passed'", () => {
|
|
386
|
+
const items = extractPassingItems([{ description: "test", status: "passed" }]);
|
|
387
|
+
expect(items).toHaveLength(1);
|
|
388
|
+
expect(items[0].passes).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
it("handles string status 'done'", () => {
|
|
391
|
+
const items = extractPassingItems([{ description: "test", status: "done" }]);
|
|
392
|
+
expect(items).toHaveLength(1);
|
|
393
|
+
expect(items[0].passes).toBe(true);
|
|
394
|
+
});
|
|
395
|
+
it("handles string status 'finished'", () => {
|
|
396
|
+
const items = extractPassingItems([{ description: "test", status: "finished" }]);
|
|
397
|
+
expect(items).toHaveLength(1);
|
|
398
|
+
expect(items[0].passes).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
it("defaults passes to false when no passes field found", () => {
|
|
401
|
+
const items = extractPassingItems([{ description: "test" }]);
|
|
402
|
+
expect(items).toHaveLength(1);
|
|
403
|
+
expect(items[0].passes).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
it("prefers 'description' over alternative field names", () => {
|
|
406
|
+
const items = extractPassingItems([
|
|
407
|
+
{ description: "Primary", name: "Secondary", title: "Tertiary", passes: true },
|
|
408
|
+
]);
|
|
409
|
+
expect(items).toHaveLength(1);
|
|
410
|
+
expect(items[0].description).toBe("Primary");
|
|
411
|
+
});
|
|
412
|
+
it("skips non-object items in array", () => {
|
|
413
|
+
const items = extractPassingItems([
|
|
414
|
+
"string item",
|
|
415
|
+
42,
|
|
416
|
+
null,
|
|
417
|
+
{ description: "Valid item", passes: true },
|
|
418
|
+
]);
|
|
419
|
+
expect(items).toHaveLength(1);
|
|
420
|
+
expect(items[0].description).toBe("Valid item");
|
|
421
|
+
});
|
|
422
|
+
it("handles object with non-array wrapper value", () => {
|
|
423
|
+
const items = extractPassingItems({
|
|
424
|
+
tasks: "not an array",
|
|
425
|
+
description: "Fallback item",
|
|
426
|
+
passes: true,
|
|
427
|
+
});
|
|
428
|
+
// Falls through all wrapper keys, tries to extract from object directly
|
|
429
|
+
expect(items).toHaveLength(1);
|
|
430
|
+
expect(items[0].description).toBe("Fallback item");
|
|
431
|
+
});
|
|
432
|
+
it("handles large number of items", () => {
|
|
433
|
+
const bigArray = Array.from({ length: 100 }, (_, i) => ({
|
|
434
|
+
description: `Item ${i}`,
|
|
435
|
+
passes: i % 2 === 0,
|
|
436
|
+
}));
|
|
437
|
+
const items = extractPassingItems(bigArray);
|
|
438
|
+
expect(items).toHaveLength(100);
|
|
439
|
+
expect(items[0].passes).toBe(true);
|
|
440
|
+
expect(items[1].passes).toBe(false);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
// ─── smartMerge ─────────────────────────────────────────────────────
|
|
444
|
+
describe("smartMerge", () => {
|
|
445
|
+
const original = [
|
|
446
|
+
{
|
|
447
|
+
category: "feature",
|
|
448
|
+
description: "Add login page",
|
|
449
|
+
steps: ["Create form"],
|
|
450
|
+
passes: false,
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
category: "feature",
|
|
454
|
+
description: "Add signup page",
|
|
455
|
+
steps: ["Create form"],
|
|
456
|
+
passes: false,
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
it("updates passes for matching items", () => {
|
|
460
|
+
const corrupted = [
|
|
461
|
+
{ description: "Add login page", passes: true },
|
|
462
|
+
{ description: "Add signup page", passes: false },
|
|
463
|
+
];
|
|
464
|
+
const result = smartMerge(original, corrupted);
|
|
465
|
+
expect(result.itemsUpdated).toBe(1);
|
|
466
|
+
expect(result.merged[0].passes).toBe(true);
|
|
467
|
+
expect(result.merged[1].passes).toBe(false);
|
|
468
|
+
});
|
|
469
|
+
it("matches by similarity", () => {
|
|
470
|
+
const corrupted = [{ description: "Add login page feature", passes: true }];
|
|
471
|
+
const result = smartMerge(original, corrupted);
|
|
472
|
+
expect(result.itemsUpdated).toBe(1);
|
|
473
|
+
expect(result.merged[0].passes).toBe(true);
|
|
474
|
+
});
|
|
475
|
+
it("warns on unmatched items", () => {
|
|
476
|
+
const corrupted = [{ description: "Completely unrelated xyz abc", passes: true }];
|
|
477
|
+
const result = smartMerge(original, corrupted);
|
|
478
|
+
expect(result.itemsUpdated).toBe(0);
|
|
479
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
480
|
+
});
|
|
481
|
+
it("does not modify original array", () => {
|
|
482
|
+
const corrupted = [{ description: "Add login page", passes: true }];
|
|
483
|
+
smartMerge(original, corrupted);
|
|
484
|
+
expect(original[0].passes).toBe(false);
|
|
485
|
+
});
|
|
486
|
+
// --- new edge case tests ---
|
|
487
|
+
it("handles empty corrupted input", () => {
|
|
488
|
+
const result = smartMerge(original, []);
|
|
489
|
+
expect(result.itemsUpdated).toBe(0);
|
|
490
|
+
expect(result.merged).toHaveLength(2);
|
|
491
|
+
expect(result.merged[0].passes).toBe(false);
|
|
492
|
+
expect(result.merged[1].passes).toBe(false);
|
|
493
|
+
});
|
|
494
|
+
it("handles null corrupted input", () => {
|
|
495
|
+
const result = smartMerge(original, null);
|
|
496
|
+
expect(result.itemsUpdated).toBe(0);
|
|
497
|
+
expect(result.merged).toHaveLength(2);
|
|
498
|
+
});
|
|
499
|
+
it("handles undefined corrupted input", () => {
|
|
500
|
+
const result = smartMerge(original, undefined);
|
|
501
|
+
expect(result.itemsUpdated).toBe(0);
|
|
502
|
+
});
|
|
503
|
+
it("handles empty original array", () => {
|
|
504
|
+
const corrupted = [{ description: "Some item", passes: true }];
|
|
505
|
+
const result = smartMerge([], corrupted);
|
|
506
|
+
expect(result.itemsUpdated).toBe(0);
|
|
507
|
+
expect(result.merged).toHaveLength(0);
|
|
508
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
509
|
+
});
|
|
510
|
+
it("does not update items that are already passing", () => {
|
|
511
|
+
const alreadyPassing = [
|
|
512
|
+
{ category: "feature", description: "Add login page", steps: ["Create form"], passes: true },
|
|
513
|
+
];
|
|
514
|
+
const corrupted = [{ description: "Add login page", passes: true }];
|
|
515
|
+
const result = smartMerge(alreadyPassing, corrupted);
|
|
516
|
+
// Already passing, no update needed
|
|
517
|
+
expect(result.itemsUpdated).toBe(0);
|
|
518
|
+
expect(result.merged[0].passes).toBe(true);
|
|
519
|
+
});
|
|
520
|
+
it("skips corrupted items with passes: false", () => {
|
|
521
|
+
const corrupted = [
|
|
522
|
+
{ description: "Add login page", passes: false },
|
|
523
|
+
{ description: "Add signup page", passes: false },
|
|
524
|
+
];
|
|
525
|
+
const result = smartMerge(original, corrupted);
|
|
526
|
+
expect(result.itemsUpdated).toBe(0);
|
|
527
|
+
});
|
|
528
|
+
it("can update multiple items in one merge", () => {
|
|
529
|
+
const corrupted = [
|
|
530
|
+
{ description: "Add login page", passes: true },
|
|
531
|
+
{ description: "Add signup page", passes: true },
|
|
532
|
+
];
|
|
533
|
+
const result = smartMerge(original, corrupted);
|
|
534
|
+
expect(result.itemsUpdated).toBe(2);
|
|
535
|
+
expect(result.merged[0].passes).toBe(true);
|
|
536
|
+
expect(result.merged[1].passes).toBe(true);
|
|
537
|
+
});
|
|
538
|
+
it("matches by substring containment", () => {
|
|
539
|
+
const corrupted = [{ description: "login page", passes: true }];
|
|
540
|
+
const result = smartMerge(original, corrupted);
|
|
541
|
+
expect(result.itemsUpdated).toBe(1);
|
|
542
|
+
expect(result.merged[0].passes).toBe(true);
|
|
543
|
+
});
|
|
544
|
+
it("matches when corrupted description contains original description", () => {
|
|
545
|
+
const corrupted = [{ description: "We need to Add login page soon", passes: true }];
|
|
546
|
+
const result = smartMerge(original, corrupted);
|
|
547
|
+
expect(result.itemsUpdated).toBe(1);
|
|
548
|
+
});
|
|
549
|
+
it("preserves category, steps, and branch from original", () => {
|
|
550
|
+
const withBranch = [
|
|
551
|
+
{
|
|
552
|
+
category: "bugfix",
|
|
553
|
+
description: "Fix login bug",
|
|
554
|
+
steps: ["Step 1", "Step 2"],
|
|
555
|
+
passes: false,
|
|
556
|
+
branch: "fix/login",
|
|
557
|
+
},
|
|
558
|
+
];
|
|
559
|
+
const corrupted = [{ description: "Fix login bug", passes: true }];
|
|
560
|
+
const result = smartMerge(withBranch, corrupted);
|
|
561
|
+
expect(result.merged[0].category).toBe("bugfix");
|
|
562
|
+
expect(result.merged[0].steps).toEqual(["Step 1", "Step 2"]);
|
|
563
|
+
expect(result.merged[0].branch).toBe("fix/login");
|
|
564
|
+
});
|
|
565
|
+
it("handles corrupted input from wrapped object", () => {
|
|
566
|
+
const corrupted = {
|
|
567
|
+
tasks: [{ description: "Add login page", passes: true }],
|
|
568
|
+
};
|
|
569
|
+
const result = smartMerge(original, corrupted);
|
|
570
|
+
expect(result.itemsUpdated).toBe(1);
|
|
571
|
+
});
|
|
572
|
+
it("handles corrupted items with alternative field names", () => {
|
|
573
|
+
const corrupted = [{ name: "Add login page", done: true }];
|
|
574
|
+
const result = smartMerge(original, corrupted);
|
|
575
|
+
expect(result.itemsUpdated).toBe(1);
|
|
576
|
+
expect(result.merged[0].passes).toBe(true);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
// ─── attemptRecovery ────────────────────────────────────────────────
|
|
580
|
+
describe("attemptRecovery", () => {
|
|
581
|
+
it("recovers from wrapped objects", () => {
|
|
582
|
+
const corrupted = {
|
|
583
|
+
tasks: [
|
|
584
|
+
{
|
|
585
|
+
category: "feature",
|
|
586
|
+
description: "Add login page",
|
|
587
|
+
steps: ["Create form"],
|
|
588
|
+
passes: false,
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
};
|
|
592
|
+
const result = attemptRecovery(corrupted);
|
|
593
|
+
expect(result).toHaveLength(1);
|
|
594
|
+
expect(result[0].description).toBe("Add login page");
|
|
595
|
+
});
|
|
596
|
+
it("recovers with alternative field names", () => {
|
|
597
|
+
const corrupted = [
|
|
598
|
+
{
|
|
599
|
+
type: "feature",
|
|
600
|
+
name: "Add login page",
|
|
601
|
+
verification: ["Create form"],
|
|
602
|
+
done: false,
|
|
603
|
+
},
|
|
604
|
+
];
|
|
605
|
+
const result = attemptRecovery(corrupted);
|
|
606
|
+
expect(result).toHaveLength(1);
|
|
607
|
+
expect(result[0].category).toBe("feature");
|
|
608
|
+
expect(result[0].description).toBe("Add login page");
|
|
609
|
+
});
|
|
610
|
+
it("handles string passes values", () => {
|
|
611
|
+
const corrupted = [
|
|
612
|
+
{
|
|
613
|
+
category: "bugfix",
|
|
614
|
+
description: "Fix crash",
|
|
615
|
+
steps: ["Test it"],
|
|
616
|
+
status: "completed",
|
|
617
|
+
},
|
|
618
|
+
];
|
|
619
|
+
const result = attemptRecovery(corrupted);
|
|
620
|
+
expect(result).not.toBeNull();
|
|
621
|
+
expect(result[0].passes).toBe(true);
|
|
622
|
+
});
|
|
623
|
+
it("defaults missing steps", () => {
|
|
624
|
+
const corrupted = [
|
|
625
|
+
{
|
|
626
|
+
category: "feature",
|
|
627
|
+
description: "Add login page",
|
|
628
|
+
passes: false,
|
|
629
|
+
},
|
|
630
|
+
];
|
|
631
|
+
const result = attemptRecovery(corrupted);
|
|
632
|
+
expect(result).not.toBeNull();
|
|
633
|
+
expect(result[0].steps).toEqual(["Verify the feature works as expected"]);
|
|
634
|
+
});
|
|
635
|
+
it("returns null when recovery is impossible", () => {
|
|
636
|
+
expect(attemptRecovery("not an object")).toBeNull();
|
|
637
|
+
expect(attemptRecovery([{ noFields: true }])).toBeNull();
|
|
638
|
+
});
|
|
639
|
+
it("recovers branch field", () => {
|
|
640
|
+
const corrupted = [
|
|
641
|
+
{
|
|
642
|
+
category: "feature",
|
|
643
|
+
description: "Add login",
|
|
644
|
+
steps: ["test"],
|
|
645
|
+
passes: false,
|
|
646
|
+
branch: "feat/login",
|
|
647
|
+
},
|
|
648
|
+
];
|
|
649
|
+
const result = attemptRecovery(corrupted);
|
|
650
|
+
expect(result[0].branch).toBe("feat/login");
|
|
651
|
+
});
|
|
652
|
+
// --- new edge case tests ---
|
|
653
|
+
it("returns null for null input", () => {
|
|
654
|
+
expect(attemptRecovery(null)).toBeNull();
|
|
655
|
+
});
|
|
656
|
+
it("returns null for undefined input", () => {
|
|
657
|
+
expect(attemptRecovery(undefined)).toBeNull();
|
|
658
|
+
});
|
|
659
|
+
it("returns null for number input", () => {
|
|
660
|
+
expect(attemptRecovery(42)).toBeNull();
|
|
661
|
+
});
|
|
662
|
+
it("returns null for boolean input", () => {
|
|
663
|
+
expect(attemptRecovery(true)).toBeNull();
|
|
664
|
+
});
|
|
665
|
+
it("returns null for empty array", () => {
|
|
666
|
+
expect(attemptRecovery([])).toBeNull();
|
|
667
|
+
});
|
|
668
|
+
it("returns null for empty object (no wrapper keys)", () => {
|
|
669
|
+
expect(attemptRecovery({})).toBeNull();
|
|
670
|
+
});
|
|
671
|
+
it("recovers from 'features' wrapper", () => {
|
|
672
|
+
const corrupted = {
|
|
673
|
+
features: [{ category: "feature", description: "Test", steps: ["step"], passes: false }],
|
|
674
|
+
};
|
|
675
|
+
const result = attemptRecovery(corrupted);
|
|
676
|
+
expect(result).toHaveLength(1);
|
|
677
|
+
});
|
|
678
|
+
it("recovers from 'items' wrapper", () => {
|
|
679
|
+
const corrupted = {
|
|
680
|
+
items: [{ category: "bugfix", description: "Fix bug", steps: ["test"], passes: true }],
|
|
681
|
+
};
|
|
682
|
+
const result = attemptRecovery(corrupted);
|
|
683
|
+
expect(result).toHaveLength(1);
|
|
684
|
+
expect(result[0].passes).toBe(true);
|
|
685
|
+
});
|
|
686
|
+
it("recovers from 'entries' wrapper", () => {
|
|
687
|
+
const corrupted = {
|
|
688
|
+
entries: [
|
|
689
|
+
{ category: "setup", description: "Setup project", steps: ["init"], passes: false },
|
|
690
|
+
],
|
|
691
|
+
};
|
|
692
|
+
const result = attemptRecovery(corrupted);
|
|
693
|
+
expect(result).toHaveLength(1);
|
|
694
|
+
});
|
|
695
|
+
it("recovers from 'requirements' wrapper", () => {
|
|
696
|
+
const corrupted = {
|
|
697
|
+
requirements: [{ category: "feature", description: "A requirement", passes: false }],
|
|
698
|
+
};
|
|
699
|
+
const result = attemptRecovery(corrupted);
|
|
700
|
+
expect(result).toHaveLength(1);
|
|
701
|
+
expect(result[0].steps).toEqual(["Verify the feature works as expected"]);
|
|
702
|
+
});
|
|
703
|
+
it("uses 'cat' as alternative for category", () => {
|
|
704
|
+
const corrupted = [{ cat: "feature", description: "Test", passes: false }];
|
|
705
|
+
const result = attemptRecovery(corrupted);
|
|
706
|
+
expect(result).toHaveLength(1);
|
|
707
|
+
expect(result[0].category).toBe("feature");
|
|
708
|
+
});
|
|
709
|
+
it("uses 'id' as alternative for category", () => {
|
|
710
|
+
const corrupted = [{ id: "bugfix", description: "Fix it", passes: false }];
|
|
711
|
+
const result = attemptRecovery(corrupted);
|
|
712
|
+
expect(result).toHaveLength(1);
|
|
713
|
+
expect(result[0].category).toBe("bugfix");
|
|
714
|
+
});
|
|
715
|
+
it("uses 'title' as alternative for description", () => {
|
|
716
|
+
const corrupted = [{ category: "feature", title: "My title", passes: false }];
|
|
717
|
+
const result = attemptRecovery(corrupted);
|
|
718
|
+
expect(result).toHaveLength(1);
|
|
719
|
+
expect(result[0].description).toBe("My title");
|
|
720
|
+
});
|
|
721
|
+
it("uses 'checks' as alternative for steps", () => {
|
|
722
|
+
const corrupted = [
|
|
723
|
+
{ category: "feature", description: "Test", checks: ["check 1", "check 2"], passes: false },
|
|
724
|
+
];
|
|
725
|
+
const result = attemptRecovery(corrupted);
|
|
726
|
+
expect(result).toHaveLength(1);
|
|
727
|
+
expect(result[0].steps).toEqual(["check 1", "check 2"]);
|
|
728
|
+
});
|
|
729
|
+
it("uses 'tasks' as alternative for steps", () => {
|
|
730
|
+
const corrupted = [
|
|
731
|
+
{ category: "feature", description: "Test", tasks: ["task 1"], passes: false },
|
|
732
|
+
];
|
|
733
|
+
const result = attemptRecovery(corrupted);
|
|
734
|
+
expect(result).toHaveLength(1);
|
|
735
|
+
expect(result[0].steps).toEqual(["task 1"]);
|
|
736
|
+
});
|
|
737
|
+
it("handles string passes value 'false'", () => {
|
|
738
|
+
const corrupted = [
|
|
739
|
+
{ category: "feature", description: "Test", status: "false", passes: undefined },
|
|
740
|
+
];
|
|
741
|
+
const result = attemptRecovery(corrupted);
|
|
742
|
+
expect(result).not.toBeNull();
|
|
743
|
+
expect(result[0].passes).toBe(false);
|
|
744
|
+
});
|
|
745
|
+
it("handles string passes value 'fail'", () => {
|
|
746
|
+
const corrupted = [{ category: "feature", description: "Test", status: "fail" }];
|
|
747
|
+
const result = attemptRecovery(corrupted);
|
|
748
|
+
expect(result).not.toBeNull();
|
|
749
|
+
expect(result[0].passes).toBe(false);
|
|
750
|
+
});
|
|
751
|
+
it("handles string passes value 'failed'", () => {
|
|
752
|
+
const corrupted = [{ category: "feature", description: "Test", status: "failed" }];
|
|
753
|
+
const result = attemptRecovery(corrupted);
|
|
754
|
+
expect(result).not.toBeNull();
|
|
755
|
+
expect(result[0].passes).toBe(false);
|
|
756
|
+
});
|
|
757
|
+
it("handles string passes value 'pending'", () => {
|
|
758
|
+
const corrupted = [{ category: "feature", description: "Test", status: "pending" }];
|
|
759
|
+
const result = attemptRecovery(corrupted);
|
|
760
|
+
expect(result).not.toBeNull();
|
|
761
|
+
expect(result[0].passes).toBe(false);
|
|
762
|
+
});
|
|
763
|
+
it("handles string passes value 'incomplete'", () => {
|
|
764
|
+
const corrupted = [{ category: "feature", description: "Test", status: "incomplete" }];
|
|
765
|
+
const result = attemptRecovery(corrupted);
|
|
766
|
+
expect(result).not.toBeNull();
|
|
767
|
+
expect(result[0].passes).toBe(false);
|
|
768
|
+
});
|
|
769
|
+
it("defaults passes to false when status field is unrecognized string", () => {
|
|
770
|
+
const corrupted = [{ category: "feature", description: "Test", status: "in-progress" }];
|
|
771
|
+
const result = attemptRecovery(corrupted);
|
|
772
|
+
expect(result).not.toBeNull();
|
|
773
|
+
expect(result[0].passes).toBe(false);
|
|
774
|
+
});
|
|
775
|
+
it("recovers multiple items", () => {
|
|
776
|
+
const corrupted = [
|
|
777
|
+
{ category: "feature", description: "First", steps: ["s1"], passes: true },
|
|
778
|
+
{ category: "bugfix", description: "Second", steps: ["s2"], passes: false },
|
|
779
|
+
{ category: "docs", description: "Third", passes: false },
|
|
780
|
+
];
|
|
781
|
+
const result = attemptRecovery(corrupted);
|
|
782
|
+
expect(result).toHaveLength(3);
|
|
783
|
+
expect(result[0].passes).toBe(true);
|
|
784
|
+
expect(result[2].steps).toEqual(["Verify the feature works as expected"]);
|
|
785
|
+
});
|
|
786
|
+
it("returns null if any item is missing both category and description", () => {
|
|
787
|
+
const corrupted = [
|
|
788
|
+
{ category: "feature", description: "Valid" },
|
|
789
|
+
{ steps: ["step"], passes: false }, // missing category AND description
|
|
790
|
+
];
|
|
791
|
+
const result = attemptRecovery(corrupted);
|
|
792
|
+
expect(result).toBeNull();
|
|
793
|
+
});
|
|
794
|
+
it("returns null if array contains non-object items", () => {
|
|
795
|
+
const corrupted = [
|
|
796
|
+
{ category: "feature", description: "Valid", passes: false },
|
|
797
|
+
"not an object",
|
|
798
|
+
];
|
|
799
|
+
const result = attemptRecovery(corrupted);
|
|
800
|
+
expect(result).toBeNull();
|
|
801
|
+
});
|
|
802
|
+
it("skips invalid category values and returns null", () => {
|
|
803
|
+
const corrupted = [{ category: "invalid", description: "Test", passes: false }];
|
|
804
|
+
const result = attemptRecovery(corrupted);
|
|
805
|
+
// 'invalid' is not in VALID_CATEGORIES, so category won't be set -> null
|
|
806
|
+
expect(result).toBeNull();
|
|
807
|
+
});
|
|
808
|
+
it("recovers git_branch alternative field", () => {
|
|
809
|
+
const corrupted = [
|
|
810
|
+
{ category: "feature", description: "Test", git_branch: "feat/test", passes: false },
|
|
811
|
+
];
|
|
812
|
+
const result = attemptRecovery(corrupted);
|
|
813
|
+
expect(result).toHaveLength(1);
|
|
814
|
+
expect(result[0].branch).toBe("feat/test");
|
|
815
|
+
});
|
|
816
|
+
it("recovers gitBranch alternative field", () => {
|
|
817
|
+
const corrupted = [
|
|
818
|
+
{ category: "feature", description: "Test", gitBranch: "feat/camel", passes: false },
|
|
819
|
+
];
|
|
820
|
+
const result = attemptRecovery(corrupted);
|
|
821
|
+
expect(result).toHaveLength(1);
|
|
822
|
+
expect(result[0].branch).toBe("feat/camel");
|
|
823
|
+
});
|
|
824
|
+
it("filters non-string steps from alternative step fields", () => {
|
|
825
|
+
const corrupted = [
|
|
826
|
+
{
|
|
827
|
+
category: "feature",
|
|
828
|
+
description: "Test",
|
|
829
|
+
verification: ["valid", 123, null, "also valid"],
|
|
830
|
+
passes: false,
|
|
831
|
+
},
|
|
832
|
+
];
|
|
833
|
+
const result = attemptRecovery(corrupted);
|
|
834
|
+
expect(result).toHaveLength(1);
|
|
835
|
+
expect(result[0].steps).toEqual(["valid", "also valid"]);
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
// ─── robustYamlParse ────────────────────────────────────────────────
|
|
839
|
+
describe("robustYamlParse", () => {
|
|
840
|
+
it("parses valid YAML", () => {
|
|
841
|
+
const result = robustYamlParse("- name: test\n value: 1\n");
|
|
842
|
+
expect(result).toEqual([{ name: "test", value: 1 }]);
|
|
843
|
+
});
|
|
844
|
+
it("parses simple scalars", () => {
|
|
845
|
+
expect(robustYamlParse("hello")).toBe("hello");
|
|
846
|
+
});
|
|
847
|
+
it("handles multiline strings that would normally fail", () => {
|
|
848
|
+
const yaml = `- Implement each stage as its own class in
|
|
849
|
+
separate files
|
|
850
|
+
- Another item`;
|
|
851
|
+
const result = robustYamlParse(yaml);
|
|
852
|
+
expect(Array.isArray(result)).toBe(true);
|
|
853
|
+
});
|
|
854
|
+
// --- new edge case tests ---
|
|
855
|
+
it("parses empty string as null", () => {
|
|
856
|
+
expect(robustYamlParse("")).toBeNull();
|
|
857
|
+
});
|
|
858
|
+
it("parses YAML boolean values", () => {
|
|
859
|
+
expect(robustYamlParse("true")).toBe(true);
|
|
860
|
+
expect(robustYamlParse("false")).toBe(false);
|
|
861
|
+
});
|
|
862
|
+
it("parses YAML numbers", () => {
|
|
863
|
+
expect(robustYamlParse("42")).toBe(42);
|
|
864
|
+
expect(robustYamlParse("3.14")).toBeCloseTo(3.14);
|
|
865
|
+
});
|
|
866
|
+
it("parses nested YAML objects", () => {
|
|
867
|
+
const yaml = `
|
|
868
|
+
name: test
|
|
869
|
+
nested:
|
|
870
|
+
key: value
|
|
871
|
+
list:
|
|
872
|
+
- a
|
|
873
|
+
- b`;
|
|
874
|
+
const result = robustYamlParse(yaml);
|
|
875
|
+
expect(result.name).toBe("test");
|
|
876
|
+
expect(result.nested.key).toBe("value");
|
|
877
|
+
expect(result.nested.list).toEqual(["a", "b"]);
|
|
878
|
+
});
|
|
879
|
+
it("parses YAML with quoted strings", () => {
|
|
880
|
+
const yaml = `- "hello world"
|
|
881
|
+
- 'single quoted'
|
|
882
|
+
- plain text`;
|
|
883
|
+
const result = robustYamlParse(yaml);
|
|
884
|
+
expect(result).toEqual(["hello world", "single quoted", "plain text"]);
|
|
885
|
+
});
|
|
886
|
+
it("handles YAML with embedded colons in values", () => {
|
|
887
|
+
const yaml = `- category: feature
|
|
888
|
+
description: "Add server: port config"
|
|
889
|
+
passes: false`;
|
|
890
|
+
const result = robustYamlParse(yaml);
|
|
891
|
+
expect(Array.isArray(result)).toBe(true);
|
|
892
|
+
});
|
|
893
|
+
it("parses multi-item YAML PRD", () => {
|
|
894
|
+
const yaml = `- category: feature
|
|
895
|
+
description: Add login
|
|
896
|
+
steps:
|
|
897
|
+
- Create form
|
|
898
|
+
- Add validation
|
|
899
|
+
passes: false
|
|
900
|
+
- category: bugfix
|
|
901
|
+
description: Fix crash
|
|
902
|
+
steps:
|
|
903
|
+
- Debug issue
|
|
904
|
+
passes: true`;
|
|
905
|
+
const result = robustYamlParse(yaml);
|
|
906
|
+
expect(result).toHaveLength(2);
|
|
907
|
+
expect(result[0].category).toBe("feature");
|
|
908
|
+
expect(result[1].passes).toBe(true);
|
|
909
|
+
});
|
|
910
|
+
it("handles YAML null values", () => {
|
|
911
|
+
const yaml = "value: null";
|
|
912
|
+
const result = robustYamlParse(yaml);
|
|
913
|
+
expect(result.value).toBeNull();
|
|
914
|
+
});
|
|
915
|
+
it("handles YAML with block scalar (literal)", () => {
|
|
916
|
+
const yaml = `text: |
|
|
917
|
+
line one
|
|
918
|
+
line two`;
|
|
919
|
+
const result = robustYamlParse(yaml);
|
|
920
|
+
expect(result.text).toContain("line one");
|
|
921
|
+
expect(result.text).toContain("line two");
|
|
922
|
+
});
|
|
923
|
+
it("handles YAML with folded scalar", () => {
|
|
924
|
+
const yaml = `text: >
|
|
925
|
+
line one
|
|
926
|
+
line two`;
|
|
927
|
+
const result = robustYamlParse(yaml);
|
|
928
|
+
expect(typeof result.text).toBe("string");
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
// ─── expandFileReferences ───────────────────────────────────────────
|
|
932
|
+
describe("expandFileReferences", () => {
|
|
933
|
+
it("returns empty string for non-string input", () => {
|
|
934
|
+
expect(expandFileReferences(null, "/base")).toBe("");
|
|
935
|
+
expect(expandFileReferences(undefined, "/base")).toBe("");
|
|
936
|
+
});
|
|
937
|
+
it("returns text unchanged when no references", () => {
|
|
938
|
+
expect(expandFileReferences("Hello world", "/base")).toBe("Hello world");
|
|
939
|
+
});
|
|
940
|
+
it("replaces missing file references with error message", () => {
|
|
941
|
+
const result = expandFileReferences("Load @{/nonexistent/file.txt} here", "/base");
|
|
942
|
+
expect(result).toContain("[File not found:");
|
|
943
|
+
});
|
|
944
|
+
// --- new edge case tests ---
|
|
945
|
+
it("returns empty string for empty string input", () => {
|
|
946
|
+
expect(expandFileReferences("", "/base")).toBe("");
|
|
947
|
+
});
|
|
948
|
+
it("handles multiple file references in one string", () => {
|
|
949
|
+
const result = expandFileReferences("Load @{/nonexistent/a.txt} and @{/nonexistent/b.txt}", "/base");
|
|
950
|
+
expect(result).toContain("[File not found:");
|
|
951
|
+
// Both should be replaced
|
|
952
|
+
expect(result.match(/\[File not found:/g)).toHaveLength(2);
|
|
953
|
+
});
|
|
954
|
+
it("handles text with @{ but no closing brace", () => {
|
|
955
|
+
const result = expandFileReferences("Some text with @{ unclosed", "/base");
|
|
956
|
+
// No match for the pattern, returns text unchanged
|
|
957
|
+
expect(result).toBe("Some text with @{ unclosed");
|
|
958
|
+
});
|
|
959
|
+
it("handles empty file reference @{} (no match since pattern requires content)", () => {
|
|
960
|
+
const result = expandFileReferences("@{}", "/base");
|
|
961
|
+
// The regex requires at least one char inside braces, so @{} is left unchanged
|
|
962
|
+
expect(result).toBe("@{}");
|
|
963
|
+
});
|
|
964
|
+
it("preserves surrounding text around file references", () => {
|
|
965
|
+
const result = expandFileReferences("Before @{/nonexistent.txt} After", "/base");
|
|
966
|
+
expect(result).toMatch(/^Before .* After$/);
|
|
967
|
+
});
|
|
968
|
+
it("handles numeric input (non-string) via nullish coalescing", () => {
|
|
969
|
+
// Non-string input should still return a string per the function's contract
|
|
970
|
+
const result = expandFileReferences(42, "/base");
|
|
971
|
+
expect(typeof result).toBe("string");
|
|
972
|
+
expect(result).toBe(String(42));
|
|
973
|
+
});
|
|
974
|
+
it("handles boolean input (non-string) via nullish coalescing", () => {
|
|
975
|
+
// Non-string input should still return a string per the function's contract
|
|
976
|
+
const result = expandFileReferences(true, "/base");
|
|
977
|
+
expect(typeof result).toBe("string");
|
|
978
|
+
expect(result).toBe(String(true));
|
|
979
|
+
});
|
|
980
|
+
it("handles absolute file paths", () => {
|
|
981
|
+
const result = expandFileReferences("@{/absolute/path/file.txt}", "/base");
|
|
982
|
+
expect(result).toContain("[File not found: /absolute/path/file.txt]");
|
|
983
|
+
});
|
|
984
|
+
it("resolves relative file paths against base dir", () => {
|
|
985
|
+
const result = expandFileReferences("@{relative/path.txt}", "/base");
|
|
986
|
+
expect(result).toContain("[File not found: /base/relative/path.txt]");
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
// ─── expandPrdFileReferences ────────────────────────────────────────
|
|
990
|
+
describe("expandPrdFileReferences", () => {
|
|
991
|
+
it("expands file references in description and steps", () => {
|
|
992
|
+
const entries = [
|
|
993
|
+
{
|
|
994
|
+
category: "feature",
|
|
995
|
+
description: "Implement @{/nonexistent/spec.txt}",
|
|
996
|
+
steps: ["Check @{/nonexistent/check.txt}", "No reference here"],
|
|
997
|
+
passes: false,
|
|
998
|
+
},
|
|
999
|
+
];
|
|
1000
|
+
const result = expandPrdFileReferences(entries, "/base");
|
|
1001
|
+
expect(result[0].description).toContain("[File not found:");
|
|
1002
|
+
expect(result[0].steps[0]).toContain("[File not found:");
|
|
1003
|
+
expect(result[0].steps[1]).toBe("No reference here");
|
|
1004
|
+
});
|
|
1005
|
+
it("does not modify the original entries", () => {
|
|
1006
|
+
const entries = [
|
|
1007
|
+
{
|
|
1008
|
+
category: "feature",
|
|
1009
|
+
description: "Original @{/nonexistent.txt}",
|
|
1010
|
+
steps: ["Original step"],
|
|
1011
|
+
passes: false,
|
|
1012
|
+
},
|
|
1013
|
+
];
|
|
1014
|
+
const result = expandPrdFileReferences(entries, "/base");
|
|
1015
|
+
expect(entries[0].description).toBe("Original @{/nonexistent.txt}");
|
|
1016
|
+
expect(result[0].description).toContain("[File not found:");
|
|
1017
|
+
});
|
|
1018
|
+
it("preserves category, passes, and branch", () => {
|
|
1019
|
+
const entries = [
|
|
1020
|
+
{
|
|
1021
|
+
category: "bugfix",
|
|
1022
|
+
description: "Fix it",
|
|
1023
|
+
steps: ["step"],
|
|
1024
|
+
passes: true,
|
|
1025
|
+
branch: "fix/branch",
|
|
1026
|
+
},
|
|
1027
|
+
];
|
|
1028
|
+
const result = expandPrdFileReferences(entries, "/base");
|
|
1029
|
+
expect(result[0].category).toBe("bugfix");
|
|
1030
|
+
expect(result[0].passes).toBe(true);
|
|
1031
|
+
expect(result[0].branch).toBe("fix/branch");
|
|
1032
|
+
});
|
|
1033
|
+
it("handles empty entries array", () => {
|
|
1034
|
+
const result = expandPrdFileReferences([], "/base");
|
|
1035
|
+
expect(result).toEqual([]);
|
|
1036
|
+
});
|
|
1037
|
+
it("handles entries with no file references", () => {
|
|
1038
|
+
const entries = [
|
|
1039
|
+
{
|
|
1040
|
+
category: "feature",
|
|
1041
|
+
description: "Plain text",
|
|
1042
|
+
steps: ["Plain step"],
|
|
1043
|
+
passes: false,
|
|
1044
|
+
},
|
|
1045
|
+
];
|
|
1046
|
+
const result = expandPrdFileReferences(entries, "/base");
|
|
1047
|
+
expect(result[0].description).toBe("Plain text");
|
|
1048
|
+
expect(result[0].steps[0]).toBe("Plain step");
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
// ─── createTemplatePrd ──────────────────────────────────────────────
|
|
1052
|
+
describe("createTemplatePrd", () => {
|
|
1053
|
+
it("creates default template without backup", () => {
|
|
1054
|
+
const result = createTemplatePrd();
|
|
1055
|
+
expect(result).toHaveLength(1);
|
|
1056
|
+
expect(result[0].category).toBe("setup");
|
|
1057
|
+
expect(result[0].description).toBe("Add PRD entries");
|
|
1058
|
+
expect(result[0].passes).toBe(false);
|
|
1059
|
+
});
|
|
1060
|
+
it("creates recovery template with backup path", () => {
|
|
1061
|
+
const result = createTemplatePrd("/backup/prd.json");
|
|
1062
|
+
expect(result).toHaveLength(1);
|
|
1063
|
+
expect(result[0].description).toBe("Fix the PRD entries");
|
|
1064
|
+
expect(result[0].steps[0]).toContain("@{/backup/prd.json}");
|
|
1065
|
+
});
|
|
1066
|
+
// --- new edge case tests ---
|
|
1067
|
+
it("default template has two steps", () => {
|
|
1068
|
+
const result = createTemplatePrd();
|
|
1069
|
+
expect(result[0].steps).toHaveLength(2);
|
|
1070
|
+
expect(result[0].steps[0]).toContain("ralph add");
|
|
1071
|
+
expect(result[0].steps[1]).toContain("format");
|
|
1072
|
+
});
|
|
1073
|
+
it("recovery template has two steps", () => {
|
|
1074
|
+
const result = createTemplatePrd("/some/backup.yaml");
|
|
1075
|
+
expect(result[0].steps).toHaveLength(2);
|
|
1076
|
+
expect(result[0].steps[0]).toContain("corrupted backup");
|
|
1077
|
+
expect(result[0].steps[1]).toContain("valid entries");
|
|
1078
|
+
});
|
|
1079
|
+
it("uses absolute path for backup reference when path is already absolute", () => {
|
|
1080
|
+
const result = createTemplatePrd("/absolute/path/backup.json");
|
|
1081
|
+
expect(result[0].steps[0]).toContain("@{/absolute/path/backup.json}");
|
|
1082
|
+
});
|
|
1083
|
+
it("all template items have passes set to false", () => {
|
|
1084
|
+
const defaultResult = createTemplatePrd();
|
|
1085
|
+
const recoveryResult = createTemplatePrd("/backup.json");
|
|
1086
|
+
expect(defaultResult[0].passes).toBe(false);
|
|
1087
|
+
expect(recoveryResult[0].passes).toBe(false);
|
|
1088
|
+
});
|
|
1089
|
+
it("all template items have category 'setup'", () => {
|
|
1090
|
+
const defaultResult = createTemplatePrd();
|
|
1091
|
+
const recoveryResult = createTemplatePrd("/backup.json");
|
|
1092
|
+
expect(defaultResult[0].category).toBe("setup");
|
|
1093
|
+
expect(recoveryResult[0].category).toBe("setup");
|
|
1094
|
+
});
|
|
1095
|
+
});
|