tdd-enforcer 0.3.2 → 0.3.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/adapters/pi/hooks.ts +1 -2
- package/adapters/pi/index.ts +7 -3
- package/adapters/pi/tools.test.ts +9 -4
- package/adapters/pi/tools.ts +42 -106
- package/engine/config.test.ts +69 -0
- package/engine/config.ts +9 -0
- package/engine/index.ts +6 -1
- package/engine/log.test.ts +171 -0
- package/engine/orchestrate.test.ts +445 -0
- package/engine/orchestrate.ts +159 -0
- package/engine/prompts.test.ts +90 -0
- package/{adapters/pi → engine}/prompts.ts +1 -1
- package/engine/state.test.ts +274 -2
- package/engine/state.ts +135 -1
- package/package.json +1 -2
- package/skills/tdd-enforcer/SKILL.md +4 -2
- package/DESIGN.md +0 -183
- package/adapters/pi/helpers.test.ts +0 -275
- package/adapters/pi/helpers.ts +0 -146
- package/adapters/pi/log.test.ts +0 -95
- package/adapters/pi/prompts.test.ts +0 -35
- package/behaviour.md +0 -175
- /package/{adapters/pi → engine}/log.ts +0 -0
package/adapters/pi/hooks.ts
CHANGED
|
@@ -13,9 +13,8 @@ import {
|
|
|
13
13
|
gitStashCreate,
|
|
14
14
|
restoreFilesTo,
|
|
15
15
|
} from "../../engine/git.js";
|
|
16
|
+
import { loadTddState, tddLog } from "../../engine/index.js";
|
|
16
17
|
import type { Config, Phase } from "../../engine/types.js";
|
|
17
|
-
import { loadTddState } from "./helpers.js";
|
|
18
|
-
import { tddLog } from "./log.js";
|
|
19
18
|
|
|
20
19
|
export async function handleToolCall(
|
|
21
20
|
event: any,
|
package/adapters/pi/index.ts
CHANGED
|
@@ -3,10 +3,14 @@ import type {
|
|
|
3
3
|
ExtensionAPI,
|
|
4
4
|
ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
loadTddState,
|
|
8
|
+
resetGit,
|
|
9
|
+
savePhaseState,
|
|
10
|
+
snapshot,
|
|
11
|
+
tddLog,
|
|
12
|
+
} from "../../engine/index.js";
|
|
8
13
|
import { registerHooks } from "./hooks.js";
|
|
9
|
-
import { tddLog } from "./log.js";
|
|
10
14
|
import { registerTools } from "./tools.js";
|
|
11
15
|
|
|
12
16
|
export async function handleTddOn(
|
|
@@ -382,15 +382,20 @@ describe("executeTddStatus", () => {
|
|
|
382
382
|
).rejects.toThrow("Missing .pi/tdd/");
|
|
383
383
|
});
|
|
384
384
|
|
|
385
|
-
it("
|
|
385
|
+
it("returns status details when TDD disabled", async () => {
|
|
386
386
|
mockLoadTddState.mockReturnValue({
|
|
387
387
|
ok: true,
|
|
388
388
|
state: { enabled: false, current: "red" },
|
|
389
389
|
config: CONFIG,
|
|
390
390
|
});
|
|
391
|
-
await
|
|
392
|
-
|
|
393
|
-
).
|
|
391
|
+
const result = await executeTddStatus({ cwd: "/test" } as any, makeDeps());
|
|
392
|
+
expect(result.content[0].text).toContain("disabled");
|
|
393
|
+
expect(result.content[0].text).toContain("RED");
|
|
394
|
+
expect(result.content[0].text).toContain("tests/**/*.test.ts");
|
|
395
|
+
expect(result.content[0].text).toContain("src/**/*.ts");
|
|
396
|
+
expect(result.details).toBeDefined();
|
|
397
|
+
expect(result.details?.enabled).toBe(false);
|
|
398
|
+
expect(result.details?.phase).toBe("red");
|
|
394
399
|
});
|
|
395
400
|
|
|
396
401
|
it("returns status details when TDD enabled", async () => {
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -11,19 +11,22 @@ import type {
|
|
|
11
11
|
} from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { Phase, TestRunner } from "../../engine/index.js";
|
|
13
13
|
import {
|
|
14
|
+
advancePhase,
|
|
14
15
|
checkGate,
|
|
15
16
|
getDisallowedChanges,
|
|
17
|
+
getNudgePrompt,
|
|
18
|
+
getStatusInfo,
|
|
16
19
|
hasParent,
|
|
17
20
|
headMessage,
|
|
21
|
+
loadTddState,
|
|
18
22
|
nextPhase,
|
|
19
23
|
resetHard,
|
|
24
|
+
revertPhase,
|
|
20
25
|
savePhaseState,
|
|
21
26
|
snapshot,
|
|
27
|
+
tddLog,
|
|
22
28
|
undoLastCommit,
|
|
23
29
|
} from "../../engine/index.js";
|
|
24
|
-
import { loadTddState } from "./helpers.js";
|
|
25
|
-
import { tddLog } from "./log.js";
|
|
26
|
-
import { getNudgePrompt } from "./prompts.js";
|
|
27
30
|
|
|
28
31
|
// ── De dependency types ─────────────────────────────────────────────────────
|
|
29
32
|
|
|
@@ -115,26 +118,14 @@ export async function executeNextPhase(
|
|
|
115
118
|
|
|
116
119
|
deps.tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
|
|
117
120
|
|
|
118
|
-
// 1. Allowlist check
|
|
119
|
-
const violations = deps.getDisallowedChanges(root, from, config);
|
|
120
|
-
if (violations.length > 0) {
|
|
121
|
-
deps.tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
|
|
122
|
-
from,
|
|
123
|
-
violations,
|
|
124
|
-
});
|
|
125
|
-
throw new Error(
|
|
126
|
-
`BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
|
|
127
|
-
violations.map((f) => ` - ${f}`).join("\n") +
|
|
128
|
-
`\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// 2. Gate check
|
|
133
121
|
const testRunner: TestRunner = async (commands, timeout) => {
|
|
134
122
|
const results = await Promise.all(
|
|
135
123
|
commands.map(async (cmd) => {
|
|
136
124
|
try {
|
|
137
|
-
await deps.asyncExec(cmd, {
|
|
125
|
+
await deps.asyncExec(cmd, {
|
|
126
|
+
cwd: root,
|
|
127
|
+
timeout: timeout * 1000,
|
|
128
|
+
});
|
|
138
129
|
return { command: cmd, passed: true };
|
|
139
130
|
} catch {
|
|
140
131
|
return { command: cmd, passed: false };
|
|
@@ -152,31 +143,30 @@ export async function executeNextPhase(
|
|
|
152
143
|
return { passed: true, message: "All tests passed." };
|
|
153
144
|
};
|
|
154
145
|
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
146
|
+
const result = await advancePhase(root, state, config, {
|
|
147
|
+
nextPhase: deps.nextPhase,
|
|
148
|
+
getDisallowedChanges: deps.getDisallowedChanges,
|
|
149
|
+
checkGate: deps.checkGate,
|
|
150
|
+
snapshot: deps.snapshot,
|
|
151
|
+
savePhaseState: deps.savePhaseState,
|
|
152
|
+
testRunner,
|
|
161
153
|
});
|
|
162
154
|
|
|
163
|
-
if (!
|
|
164
|
-
|
|
155
|
+
if (!result.ok) {
|
|
156
|
+
// advancePhase already returns the exact error message with revert hint
|
|
157
|
+
// Log the failure at adapter level
|
|
158
|
+
deps.tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
|
|
159
|
+
from,
|
|
160
|
+
violations: result.message,
|
|
161
|
+
});
|
|
162
|
+
throw new Error(result.message);
|
|
165
163
|
}
|
|
166
164
|
|
|
167
|
-
|
|
168
|
-
const hash = deps.snapshot(root, from);
|
|
169
|
-
deps.tddLog(tddDir, "INFO", "next_tdd_phase: snapshot created", {
|
|
165
|
+
deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", {
|
|
170
166
|
from,
|
|
171
167
|
to,
|
|
172
|
-
hash,
|
|
173
168
|
});
|
|
174
169
|
|
|
175
|
-
// 4. Save state
|
|
176
|
-
state.current = to;
|
|
177
|
-
deps.savePhaseState(root, state);
|
|
178
|
-
deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
|
|
179
|
-
|
|
180
170
|
return {
|
|
181
171
|
content: [{ type: "text", text: `\n${deps.getNudgePrompt(to, config)}` }],
|
|
182
172
|
details: {},
|
|
@@ -208,65 +198,28 @@ export async function executePreviousPhase(
|
|
|
208
198
|
|
|
209
199
|
const { state } = tdd;
|
|
210
200
|
|
|
211
|
-
|
|
212
|
-
deps.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// Read phase from HEAD snapshot commit message (source of truth).
|
|
219
|
-
// Snapshot is labeled with the phase the work was done in, so we use
|
|
220
|
-
// it directly — no hardcoded phase map needed.
|
|
221
|
-
const headMsg = deps.headMessage(root);
|
|
222
|
-
const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
|
|
223
|
-
if (!phaseMatch) {
|
|
224
|
-
deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
|
|
225
|
-
headMsg,
|
|
226
|
-
});
|
|
227
|
-
throw new Error(
|
|
228
|
-
`HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
|
|
229
|
-
`The private git repo at .pi/tdd must not be manually modified. ` +
|
|
230
|
-
`Tampering with it will cause TDD state corruption.`,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
const label = phaseMatch[1];
|
|
234
|
-
if (label !== "red" && label !== "green" && label !== "refactor") {
|
|
235
|
-
deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid phase label", {
|
|
236
|
-
headMsg,
|
|
237
|
-
label,
|
|
238
|
-
});
|
|
239
|
-
throw new Error(
|
|
240
|
-
`HEAD commit "${headMsg}" has unexpected label. Cannot determine previous phase.`,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
const prevPhase: Phase = label;
|
|
244
|
-
deps.tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
|
|
245
|
-
from: state.current,
|
|
246
|
-
to: prevPhase,
|
|
247
|
-
headMsg,
|
|
201
|
+
const result = await revertPhase(root, state, {
|
|
202
|
+
hasParent: deps.hasParent,
|
|
203
|
+
headMessage: deps.headMessage,
|
|
204
|
+
resetHard: deps.resetHard,
|
|
205
|
+
undoLastCommit: deps.undoLastCommit,
|
|
206
|
+
savePhaseState: deps.savePhaseState,
|
|
248
207
|
});
|
|
249
208
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
// 2. Pop last snapshot commit, keep its content as unstaged
|
|
255
|
-
deps.undoLastCommit(root);
|
|
256
|
-
deps.tddLog(tddDir, "DEBUG", "previous_tdd_phase: undoLastCommit done");
|
|
209
|
+
if (!result.ok) {
|
|
210
|
+
throw new Error(result.message);
|
|
211
|
+
}
|
|
257
212
|
|
|
258
|
-
// 3. Update phase label from the snapshot's own label
|
|
259
|
-
state.current = prevPhase;
|
|
260
|
-
deps.savePhaseState(root, state);
|
|
261
213
|
deps.tddLog(tddDir, "INFO", "previous_tdd_phase: complete", {
|
|
262
|
-
|
|
214
|
+
from: state.current,
|
|
215
|
+
to: result.newState?.current,
|
|
263
216
|
});
|
|
264
217
|
|
|
265
218
|
return {
|
|
266
219
|
content: [
|
|
267
220
|
{
|
|
268
221
|
type: "text",
|
|
269
|
-
text: `\
|
|
222
|
+
text: `\n${result.message} Working tree has the previous snapshot content as unstaged changes.`,
|
|
270
223
|
},
|
|
271
224
|
],
|
|
272
225
|
details: {},
|
|
@@ -292,35 +245,18 @@ export async function executeTddStatus(
|
|
|
292
245
|
});
|
|
293
246
|
throw new Error(`TDD: ${result.reason}`);
|
|
294
247
|
}
|
|
295
|
-
if (!result.state.enabled) {
|
|
296
|
-
deps.tddLog(tddDir, "WARN", "tdd_status: TDD disabled");
|
|
297
|
-
throw new Error("TDD is not enabled. Run /tdd:on to enable it.");
|
|
298
|
-
}
|
|
299
|
-
|
|
300
248
|
const { state, config } = result;
|
|
301
|
-
const
|
|
302
|
-
const redBlk = config.blockedInRed.join(", ") || "(none)";
|
|
303
|
-
const greenBlk = config.blockedInGreen.join(", ") || "(none)";
|
|
304
|
-
const commands = config.testCommands.join(", ") || "(none)";
|
|
249
|
+
const info = getStatusInfo(state, config);
|
|
305
250
|
|
|
306
251
|
deps.tddLog(tddDir, "INFO", "tdd_status: queried", {
|
|
252
|
+
enabled: state.enabled,
|
|
307
253
|
phase: state.current,
|
|
308
254
|
});
|
|
309
255
|
|
|
310
256
|
return {
|
|
311
|
-
content: [
|
|
312
|
-
{
|
|
313
|
-
type: "text",
|
|
314
|
-
text:
|
|
315
|
-
`\nTDD enforcer enabled\n` +
|
|
316
|
-
`Current phase: ${phaseStr}\n` +
|
|
317
|
-
`Blocked in RED: ${redBlk}\n` +
|
|
318
|
-
`Blocked in GREEN: ${greenBlk}\n` +
|
|
319
|
-
`Test commands: ${commands}`,
|
|
320
|
-
},
|
|
321
|
-
],
|
|
257
|
+
content: [{ type: "text", text: `\n${info}` }],
|
|
322
258
|
details: {
|
|
323
|
-
enabled:
|
|
259
|
+
enabled: state.enabled,
|
|
324
260
|
phase: state.current,
|
|
325
261
|
blockedInRed: config.blockedInRed,
|
|
326
262
|
blockedInGreen: config.blockedInGreen,
|
package/engine/config.test.ts
CHANGED
|
@@ -61,6 +61,24 @@ describe("loadConfig", () => {
|
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("defaults timeoutSeconds to 120 when omitted", () => {
|
|
65
|
+
withTempDir((dir) => {
|
|
66
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
67
|
+
mkdirSync(tddDir, { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(tddDir, "rules.json"),
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
72
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
73
|
+
testCommands: ["npm test"],
|
|
74
|
+
}),
|
|
75
|
+
"utf-8",
|
|
76
|
+
);
|
|
77
|
+
const config = loadConfig(dir);
|
|
78
|
+
expect(config.timeoutSeconds).toBe(120);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
64
82
|
describe("validation — throws on invalid content", () => {
|
|
65
83
|
it("throws when blockedInRed is not an array", () => {
|
|
66
84
|
withTempDir((dir) => {
|
|
@@ -172,5 +190,56 @@ describe("loadConfig", () => {
|
|
|
172
190
|
expect(() => loadConfig(dir)).toThrow();
|
|
173
191
|
});
|
|
174
192
|
});
|
|
193
|
+
|
|
194
|
+
it("throws when blockedInRed contains non-strings", () => {
|
|
195
|
+
withTempDir((dir) => {
|
|
196
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
197
|
+
mkdirSync(tddDir, { recursive: true });
|
|
198
|
+
writeFileSync(
|
|
199
|
+
join(tddDir, "rules.json"),
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
blockedInRed: [123],
|
|
202
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
203
|
+
testCommands: ["npm test"],
|
|
204
|
+
}),
|
|
205
|
+
"utf-8",
|
|
206
|
+
);
|
|
207
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("throws when blockedInGreen contains non-strings", () => {
|
|
212
|
+
withTempDir((dir) => {
|
|
213
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
214
|
+
mkdirSync(tddDir, { recursive: true });
|
|
215
|
+
writeFileSync(
|
|
216
|
+
join(tddDir, "rules.json"),
|
|
217
|
+
JSON.stringify({
|
|
218
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
219
|
+
blockedInGreen: [null],
|
|
220
|
+
testCommands: ["npm test"],
|
|
221
|
+
}),
|
|
222
|
+
"utf-8",
|
|
223
|
+
);
|
|
224
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("throws when testCommands contains non-strings", () => {
|
|
229
|
+
withTempDir((dir) => {
|
|
230
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
231
|
+
mkdirSync(tddDir, { recursive: true });
|
|
232
|
+
writeFileSync(
|
|
233
|
+
join(tddDir, "rules.json"),
|
|
234
|
+
JSON.stringify({
|
|
235
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
236
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
237
|
+
testCommands: ["npm test", 456],
|
|
238
|
+
}),
|
|
239
|
+
"utf-8",
|
|
240
|
+
);
|
|
241
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
175
244
|
});
|
|
176
245
|
});
|
package/engine/config.ts
CHANGED
|
@@ -16,15 +16,24 @@ export function loadConfig(projectRoot: string): Config {
|
|
|
16
16
|
if (!Array.isArray(parsed.blockedInRed) || parsed.blockedInRed.length === 0) {
|
|
17
17
|
throw new Error("rules.json: blockedInRed must be a non-empty array");
|
|
18
18
|
}
|
|
19
|
+
if (!parsed.blockedInRed.every((p: unknown) => typeof p === "string")) {
|
|
20
|
+
throw new Error("rules.json: blockedInRed must contain only strings");
|
|
21
|
+
}
|
|
19
22
|
if (
|
|
20
23
|
!Array.isArray(parsed.blockedInGreen) ||
|
|
21
24
|
parsed.blockedInGreen.length === 0
|
|
22
25
|
) {
|
|
23
26
|
throw new Error("rules.json: blockedInGreen must be a non-empty array");
|
|
24
27
|
}
|
|
28
|
+
if (!parsed.blockedInGreen.every((p: unknown) => typeof p === "string")) {
|
|
29
|
+
throw new Error("rules.json: blockedInGreen must contain only strings");
|
|
30
|
+
}
|
|
25
31
|
if (!Array.isArray(parsed.testCommands) || parsed.testCommands.length === 0) {
|
|
26
32
|
throw new Error("rules.json: testCommands must be a non-empty array");
|
|
27
33
|
}
|
|
34
|
+
if (!parsed.testCommands.every((p: unknown) => typeof p === "string")) {
|
|
35
|
+
throw new Error("rules.json: testCommands must contain only strings");
|
|
36
|
+
}
|
|
28
37
|
|
|
29
38
|
return {
|
|
30
39
|
blockedInRed: parsed.blockedInRed,
|
package/engine/index.ts
CHANGED
|
@@ -17,7 +17,12 @@ export {
|
|
|
17
17
|
undoLastCommit,
|
|
18
18
|
untrackedFiles,
|
|
19
19
|
} from "./git.js";
|
|
20
|
-
export {
|
|
20
|
+
export { tddLog } from "./log.js";
|
|
21
|
+
export type { AdvanceResult } from "./orchestrate.js";
|
|
22
|
+
export { advancePhase, getStatusInfo, revertPhase } from "./orchestrate.js";
|
|
23
|
+
export { getNudgePrompt } from "./prompts.js";
|
|
24
|
+
export type { TddLoadResult } from "./state.js";
|
|
25
|
+
export { loadPhaseState, loadTddState, savePhaseState } from "./state.js";
|
|
21
26
|
export { checkGate, getDisallowedChanges, nextPhase } from "./transition.js";
|
|
22
27
|
export type {
|
|
23
28
|
Config,
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { tddLog } from "./log.js";
|
|
3
|
+
|
|
4
|
+
describe("tddLog", () => {
|
|
5
|
+
let mockExistsSync: ReturnType<typeof vi.fn>;
|
|
6
|
+
let mockMkdirSync: ReturnType<typeof vi.fn>;
|
|
7
|
+
let mockAppendFileSync: ReturnType<typeof vi.fn>;
|
|
8
|
+
let mockWriteFileSync: ReturnType<typeof vi.fn>;
|
|
9
|
+
let mockReadFileSync: ReturnType<typeof vi.fn>;
|
|
10
|
+
let writtenContent: string;
|
|
11
|
+
|
|
12
|
+
function makeDeps() {
|
|
13
|
+
return {
|
|
14
|
+
existsSync: mockExistsSync,
|
|
15
|
+
mkdirSync: mockMkdirSync,
|
|
16
|
+
appendFileSync: mockAppendFileSync,
|
|
17
|
+
writeFileSync: mockWriteFileSync,
|
|
18
|
+
readFileSync: mockReadFileSync,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
writtenContent = "";
|
|
24
|
+
mockExistsSync = vi.fn().mockReturnValue(true);
|
|
25
|
+
mockMkdirSync = vi.fn();
|
|
26
|
+
mockAppendFileSync = vi.fn((_path: string, content: string) => {
|
|
27
|
+
writtenContent += content;
|
|
28
|
+
});
|
|
29
|
+
mockWriteFileSync = vi.fn((_path: string, content: string) => {
|
|
30
|
+
writtenContent = content;
|
|
31
|
+
});
|
|
32
|
+
mockReadFileSync = vi.fn(() => writtenContent);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("appends log line with timestamp and level", () => {
|
|
36
|
+
tddLog("/tdd", "INFO", "hello", undefined, makeDeps());
|
|
37
|
+
|
|
38
|
+
expect(mockAppendFileSync).toHaveBeenCalledOnce();
|
|
39
|
+
const written = mockAppendFileSync.mock.calls[0][1] as string;
|
|
40
|
+
expect(written).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
41
|
+
expect(written).toContain("[INFO]");
|
|
42
|
+
expect(written).toContain("hello");
|
|
43
|
+
expect(written.endsWith("\n")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("appends multiple lines", () => {
|
|
47
|
+
tddLog("/tdd", "INFO", "line one", undefined, makeDeps());
|
|
48
|
+
tddLog("/tdd", "DEBUG", "line two", undefined, makeDeps());
|
|
49
|
+
|
|
50
|
+
const lines = writtenContent.trim().split("\n");
|
|
51
|
+
expect(lines).toHaveLength(2);
|
|
52
|
+
expect(lines[0]).toContain("[INFO]");
|
|
53
|
+
expect(lines[0]).toContain("line one");
|
|
54
|
+
expect(lines[1]).toContain("[DEBUG]");
|
|
55
|
+
expect(lines[1]).toContain("line two");
|
|
56
|
+
expect(mockAppendFileSync).toHaveBeenCalledTimes(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("includes data as JSON when provided", () => {
|
|
60
|
+
tddLog("/tdd", "INFO", "with data", { key: "val", num: 42 }, makeDeps());
|
|
61
|
+
|
|
62
|
+
expect(writtenContent).toContain('{"key":"val","num":42}');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("includes data after message separated by a space", () => {
|
|
66
|
+
tddLog("/tdd", "INFO", "msg", { a: 1 }, makeDeps());
|
|
67
|
+
|
|
68
|
+
const line = writtenContent.trim();
|
|
69
|
+
expect(line).toContain('msg {"a":1}');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("trims to last 1000 lines when exceeded", () => {
|
|
73
|
+
for (let i = 0; i < 1005; i++) {
|
|
74
|
+
tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lines = writtenContent.trim().split("\n");
|
|
78
|
+
expect(lines).toHaveLength(1000);
|
|
79
|
+
expect(lines[0]).toContain("line 5");
|
|
80
|
+
expect(lines[999]).toContain("line 1004");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not throw when appendFileSync fails", () => {
|
|
84
|
+
mockAppendFileSync = vi.fn(() => {
|
|
85
|
+
throw new Error("ENOENT: no such file or directory");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(() =>
|
|
89
|
+
tddLog("/nonexistent/path/tdd", "INFO", "fail", undefined, makeDeps()),
|
|
90
|
+
).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not throw when readFileSync fails (first write)", () => {
|
|
94
|
+
mockReadFileSync = vi.fn(() => {
|
|
95
|
+
throw new Error("ENOENT: no such file or directory");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(() =>
|
|
99
|
+
tddLog("/tdd", "INFO", "hello", undefined, makeDeps()),
|
|
100
|
+
).not.toThrow();
|
|
101
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("handles undefined data gracefully", () => {
|
|
105
|
+
tddLog("/tdd", "WARN", "no data", undefined, makeDeps());
|
|
106
|
+
|
|
107
|
+
expect(writtenContent).toContain("[WARN]");
|
|
108
|
+
expect(writtenContent).toContain("no data");
|
|
109
|
+
expect(writtenContent).not.toContain("{}");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("logs at WARN level", () => {
|
|
113
|
+
tddLog("/tdd", "WARN", "warning message", undefined, makeDeps());
|
|
114
|
+
expect(writtenContent).toContain("[WARN]");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("logs at ERROR level", () => {
|
|
118
|
+
tddLog("/tdd", "ERROR", "error message", undefined, makeDeps());
|
|
119
|
+
expect(writtenContent).toContain("[ERROR]");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("logs at DEBUG level", () => {
|
|
123
|
+
tddLog("/tdd", "DEBUG", "debug message", undefined, makeDeps());
|
|
124
|
+
expect(writtenContent).toContain("[DEBUG]");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("writes to the path join(tddDir, 'tdd.log')", () => {
|
|
128
|
+
tddLog("/custom/tdd/path", "INFO", "test", undefined, makeDeps());
|
|
129
|
+
|
|
130
|
+
expect(mockAppendFileSync).toHaveBeenCalledWith(
|
|
131
|
+
"/custom/tdd/path/tdd.log",
|
|
132
|
+
expect.any(String),
|
|
133
|
+
"utf-8",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("trims to exactly 1000 lines keeping newest", () => {
|
|
138
|
+
for (let i = 0; i < 1001; i++) {
|
|
139
|
+
tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lines = writtenContent.trim().split("\n");
|
|
143
|
+
expect(lines).toHaveLength(1000);
|
|
144
|
+
expect(lines[0]).toContain("line 1");
|
|
145
|
+
expect(lines[999]).toContain("line 1000");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps all lines when exactly at MAX_LINES", () => {
|
|
149
|
+
for (let i = 0; i < 1000; i++) {
|
|
150
|
+
tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lines = writtenContent.trim().split("\n");
|
|
154
|
+
expect(lines).toHaveLength(1000);
|
|
155
|
+
expect(lines[0]).toContain("line 0");
|
|
156
|
+
expect(lines[999]).toContain("line 999");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("appends to existing content instead of overwriting", () => {
|
|
160
|
+
tddLog("/tdd", "INFO", "first", undefined, makeDeps());
|
|
161
|
+
tddLog("/tdd", "INFO", "second", undefined, makeDeps());
|
|
162
|
+
|
|
163
|
+
expect(writtenContent).toContain("first");
|
|
164
|
+
expect(writtenContent).toContain("second");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("uses the real filesystem deps by default", () => {
|
|
168
|
+
// Just verify the function exists and can be called without deps
|
|
169
|
+
expect(typeof tddLog).toBe("function");
|
|
170
|
+
});
|
|
171
|
+
});
|