opencode-snippets 1.1.2 → 1.2.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/README.md +43 -71
- package/index.ts +47 -17
- package/package.json +10 -5
- package/src/constants.ts +8 -8
- package/src/expander.test.ts +456 -0
- package/src/expander.ts +47 -37
- package/src/loader.test.ts +261 -0
- package/src/loader.ts +99 -45
- package/src/logger.test.ts +136 -0
- package/src/logger.ts +95 -95
- package/src/shell.ts +30 -24
- package/src/types.ts +6 -6
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadSnippets } from "../src/loader.js";
|
|
4
|
+
|
|
5
|
+
describe("loadSnippets - Dual Path Support", () => {
|
|
6
|
+
let tempDir: string;
|
|
7
|
+
let globalSnippetDir: string;
|
|
8
|
+
let projectSnippetDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
// Create temporary directories for testing
|
|
12
|
+
tempDir = join(process.cwd(), ".test-temp");
|
|
13
|
+
globalSnippetDir = join(tempDir, "global-snippet");
|
|
14
|
+
projectSnippetDir = join(tempDir, "project", ".opencode", "snippet");
|
|
15
|
+
|
|
16
|
+
await mkdir(globalSnippetDir, { recursive: true });
|
|
17
|
+
await mkdir(projectSnippetDir, { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
// Clean up temporary directories
|
|
22
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("Global snippets only", () => {
|
|
26
|
+
it("should load snippets with aliases", async () => {
|
|
27
|
+
await writeFile(
|
|
28
|
+
join(globalSnippetDir, "careful.md"),
|
|
29
|
+
`---
|
|
30
|
+
aliases: safe
|
|
31
|
+
---
|
|
32
|
+
Think step by step. Double-check your work.`,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
36
|
+
|
|
37
|
+
expect(snippets.size).toBe(2);
|
|
38
|
+
expect(snippets.get("careful")).toBe("Think step by step. Double-check your work.");
|
|
39
|
+
expect(snippets.get("safe")).toBe("Think step by step. Double-check your work.");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should load multiple snippets from global directory", async () => {
|
|
43
|
+
await writeFile(join(globalSnippetDir, "snippet1.md"), "Content of snippet 1");
|
|
44
|
+
await writeFile(join(globalSnippetDir, "snippet2.md"), "Content of snippet 2");
|
|
45
|
+
|
|
46
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
47
|
+
|
|
48
|
+
expect(snippets.size).toBe(2);
|
|
49
|
+
expect(snippets.get("snippet1")).toBe("Content of snippet 1");
|
|
50
|
+
expect(snippets.get("snippet2")).toBe("Content of snippet 2");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("Project snippets only", () => {
|
|
55
|
+
it("should load snippets from project directory", async () => {
|
|
56
|
+
await writeFile(
|
|
57
|
+
join(projectSnippetDir, "project-specific.md"),
|
|
58
|
+
"This is a project-specific snippet",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Load with project directory
|
|
62
|
+
const projectDir = join(tempDir, "project");
|
|
63
|
+
const snippets = await loadSnippets(projectDir, globalSnippetDir);
|
|
64
|
+
|
|
65
|
+
expect(snippets.size).toBe(1);
|
|
66
|
+
expect(snippets.get("project-specific")).toBe("This is a project-specific snippet");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should handle missing global directory when project exists", async () => {
|
|
70
|
+
await writeFile(join(projectSnippetDir, "team-rule.md"), "Team rule 1");
|
|
71
|
+
await writeFile(join(projectSnippetDir, "domain-knowledge.md"), "Domain knowledge");
|
|
72
|
+
|
|
73
|
+
const projectDir = join(tempDir, "project");
|
|
74
|
+
const snippets = await loadSnippets(projectDir, globalSnippetDir);
|
|
75
|
+
|
|
76
|
+
expect(snippets.size).toBe(2);
|
|
77
|
+
expect(snippets.get("team-rule")).toBe("Team rule 1");
|
|
78
|
+
expect(snippets.get("domain-knowledge")).toBe("Domain knowledge");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("Both global and project snippets", () => {
|
|
83
|
+
it("should merge global and project snippets", async () => {
|
|
84
|
+
// Create global snippet
|
|
85
|
+
await writeFile(join(globalSnippetDir, "global.md"), "Global snippet content");
|
|
86
|
+
|
|
87
|
+
// Create project snippet
|
|
88
|
+
await writeFile(join(projectSnippetDir, "project.md"), "Project snippet content");
|
|
89
|
+
|
|
90
|
+
const projectDir = join(tempDir, "project");
|
|
91
|
+
const snippets = await loadSnippets(projectDir, globalSnippetDir);
|
|
92
|
+
|
|
93
|
+
expect(snippets.size).toBe(2);
|
|
94
|
+
expect(snippets.get("global")).toBe("Global snippet content");
|
|
95
|
+
expect(snippets.get("project")).toBe("Project snippet content");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should allow project snippets to override global snippets", async () => {
|
|
99
|
+
// Create global snippet
|
|
100
|
+
await writeFile(join(globalSnippetDir, "careful.md"), "Global careful content");
|
|
101
|
+
|
|
102
|
+
// Create project snippet with same name
|
|
103
|
+
await writeFile(join(projectSnippetDir, "careful.md"), "Project-specific careful content");
|
|
104
|
+
|
|
105
|
+
const projectDir = join(tempDir, "project");
|
|
106
|
+
const snippets = await loadSnippets(projectDir, globalSnippetDir);
|
|
107
|
+
|
|
108
|
+
// Project snippet should override global
|
|
109
|
+
expect(snippets.get("careful")).toBe("Project-specific careful content");
|
|
110
|
+
expect(snippets.size).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("Alias handling", () => {
|
|
115
|
+
it("should handle multiple aliases from different sources", async () => {
|
|
116
|
+
// Global snippet with aliases
|
|
117
|
+
await writeFile(
|
|
118
|
+
join(globalSnippetDir, "review.md"),
|
|
119
|
+
`---
|
|
120
|
+
aliases:
|
|
121
|
+
- pr
|
|
122
|
+
- check
|
|
123
|
+
---
|
|
124
|
+
Global review guidelines`,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Project snippet with aliases
|
|
128
|
+
await writeFile(
|
|
129
|
+
join(projectSnippetDir, "test.md"),
|
|
130
|
+
`---
|
|
131
|
+
aliases:
|
|
132
|
+
- tdd
|
|
133
|
+
- testing
|
|
134
|
+
---
|
|
135
|
+
Project test guidelines`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const projectDir = join(tempDir, "project");
|
|
139
|
+
const snippets = await loadSnippets(projectDir, globalSnippetDir);
|
|
140
|
+
|
|
141
|
+
expect(snippets.size).toBe(6); // review, pr, check, test, tdd, testing
|
|
142
|
+
expect(snippets.get("review")).toBe("Global review guidelines");
|
|
143
|
+
expect(snippets.get("pr")).toBe("Global review guidelines");
|
|
144
|
+
expect(snippets.get("check")).toBe("Global review guidelines");
|
|
145
|
+
expect(snippets.get("test")).toBe("Project test guidelines");
|
|
146
|
+
expect(snippets.get("tdd")).toBe("Project test guidelines");
|
|
147
|
+
expect(snippets.get("testing")).toBe("Project test guidelines");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should allow project to override global aliases", async () => {
|
|
151
|
+
// Global snippet with aliases
|
|
152
|
+
await writeFile(
|
|
153
|
+
join(globalSnippetDir, "careful.md"),
|
|
154
|
+
`---
|
|
155
|
+
aliases:
|
|
156
|
+
- safe
|
|
157
|
+
- cautious
|
|
158
|
+
---
|
|
159
|
+
Global careful`,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Project snippet with same name but different aliases
|
|
163
|
+
await writeFile(
|
|
164
|
+
join(projectSnippetDir, "careful.md"),
|
|
165
|
+
`---
|
|
166
|
+
aliases: safe
|
|
167
|
+
---
|
|
168
|
+
Project careful`,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const projectDir = join(tempDir, "project");
|
|
172
|
+
const snippets = await loadSnippets(projectDir, globalSnippetDir);
|
|
173
|
+
|
|
174
|
+
// Project should override with its aliases
|
|
175
|
+
expect(snippets.get("careful")).toBe("Project careful");
|
|
176
|
+
expect(snippets.get("safe")).toBe("Project careful");
|
|
177
|
+
expect(snippets.get("cautious")).toBeUndefined();
|
|
178
|
+
expect(snippets.size).toBe(2);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("Edge cases", () => {
|
|
183
|
+
it("should handle empty snippet content", async () => {
|
|
184
|
+
await writeFile(join(globalSnippetDir, "empty.md"), "");
|
|
185
|
+
|
|
186
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
187
|
+
expect(snippets.size).toBe(1);
|
|
188
|
+
expect(snippets.get("empty")).toBe("");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should handle snippet with only metadata", async () => {
|
|
192
|
+
await writeFile(
|
|
193
|
+
join(globalSnippetDir, "metadata-only.md"),
|
|
194
|
+
`---
|
|
195
|
+
description: "A snippet with only metadata"
|
|
196
|
+
aliases: meta
|
|
197
|
+
---`,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
201
|
+
expect(snippets.size).toBe(2);
|
|
202
|
+
expect(snippets.get("metadata-only")).toBe("");
|
|
203
|
+
expect(snippets.get("meta")).toBe("");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("should handle multiline content", async () => {
|
|
207
|
+
await writeFile(
|
|
208
|
+
join(globalSnippetDir, "multiline.md"),
|
|
209
|
+
`Line 1
|
|
210
|
+
Line 2
|
|
211
|
+
Line 3`,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
215
|
+
expect(snippets.get("multiline")).toBe("Line 1\nLine 2\nLine 3");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("should ignore non-.md files", async () => {
|
|
219
|
+
await writeFile(join(globalSnippetDir, "not-a-snippet.txt"), "This should be ignored");
|
|
220
|
+
await writeFile(join(globalSnippetDir, "valid.md"), "This should be loaded");
|
|
221
|
+
|
|
222
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
223
|
+
expect(snippets.size).toBe(1);
|
|
224
|
+
expect(snippets.get("valid")).toBe("This should be loaded");
|
|
225
|
+
expect(snippets.has("not-a-snippet")).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should handle invalid frontmatter", async () => {
|
|
229
|
+
await writeFile(
|
|
230
|
+
join(globalSnippetDir, "bad-frontmatter.md"),
|
|
231
|
+
`---
|
|
232
|
+
invalid yaml
|
|
233
|
+
---
|
|
234
|
+
Content`,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await writeFile(join(globalSnippetDir, "special-chars.md"), "Special content");
|
|
238
|
+
|
|
239
|
+
const snippets = await loadSnippets(undefined, globalSnippetDir);
|
|
240
|
+
// Should load valid snippet, skip invalid one
|
|
241
|
+
expect(snippets.get("special-chars")).toBe("Special content");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("should handle non-existent directories gracefully", async () => {
|
|
245
|
+
const snippets = await loadSnippets(undefined, "/nonexistent/path");
|
|
246
|
+
expect(snippets.size).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("Smoke test - real global snippets", () => {
|
|
251
|
+
it("should load real global snippets without crashing", async () => {
|
|
252
|
+
// This is a smoke test - just verify it doesn't crash
|
|
253
|
+
const snippets = await loadSnippets();
|
|
254
|
+
|
|
255
|
+
// We don't assert specific content since we don't control user's global snippets
|
|
256
|
+
// Just verify it returns a Map
|
|
257
|
+
expect(snippets).toBeInstanceOf(Map);
|
|
258
|
+
expect(Array.isArray(Array.from(snippets.keys()))).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
package/src/loader.ts
CHANGED
|
@@ -1,72 +1,114 @@
|
|
|
1
|
-
import { readdir, readFile } from "node:fs/promises"
|
|
2
|
-
import {
|
|
3
|
-
import matter from "gray-matter"
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import { CONFIG, PATHS } from "./constants.js";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
import type { SnippetFrontmatter, SnippetRegistry } from "./types.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Loads all snippets from
|
|
10
|
-
*
|
|
9
|
+
* Loads all snippets from global and project directories
|
|
10
|
+
*
|
|
11
|
+
* @param projectDir - Optional project directory path (from ctx.directory)
|
|
12
|
+
* @param globalDir - Optional global snippets directory (for testing)
|
|
11
13
|
* @returns A map of snippet keys (lowercase) to their content
|
|
12
14
|
*/
|
|
13
|
-
export async function loadSnippets(
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
export async function loadSnippets(
|
|
16
|
+
projectDir?: string,
|
|
17
|
+
globalDir?: string,
|
|
18
|
+
): Promise<SnippetRegistry> {
|
|
19
|
+
const snippets: SnippetRegistry = new Map();
|
|
20
|
+
|
|
21
|
+
// Load from global directory first (use provided or default)
|
|
22
|
+
const globalSnippetsDir = globalDir ?? PATHS.SNIPPETS_DIR;
|
|
23
|
+
await loadFromDirectory(globalSnippetsDir, snippets, "global");
|
|
24
|
+
|
|
25
|
+
// Load from project directory if provided (overrides global)
|
|
26
|
+
if (projectDir) {
|
|
27
|
+
const projectSnippetsDir = join(projectDir, ".opencode", "snippet");
|
|
28
|
+
await loadFromDirectory(projectSnippetsDir, snippets, "project");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return snippets;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Loads snippets from a specific directory
|
|
36
|
+
*
|
|
37
|
+
* @param dir - Directory to load snippets from
|
|
38
|
+
* @param registry - Registry to populate
|
|
39
|
+
* @param source - Source label for logging
|
|
40
|
+
*/
|
|
41
|
+
async function loadFromDirectory(
|
|
42
|
+
dir: string,
|
|
43
|
+
registry: SnippetRegistry,
|
|
44
|
+
source: string,
|
|
45
|
+
): Promise<void> {
|
|
16
46
|
try {
|
|
17
|
-
const files = await readdir(
|
|
18
|
-
|
|
47
|
+
const files = await readdir(dir);
|
|
48
|
+
|
|
19
49
|
for (const file of files) {
|
|
20
|
-
if (!file.endsWith(CONFIG.SNIPPET_EXTENSION)) continue
|
|
21
|
-
|
|
22
|
-
const snippet = await loadSnippetFile(file)
|
|
50
|
+
if (!file.endsWith(CONFIG.SNIPPET_EXTENSION)) continue;
|
|
51
|
+
|
|
52
|
+
const snippet = await loadSnippetFile(dir, file);
|
|
23
53
|
if (snippet) {
|
|
24
|
-
registerSnippet(
|
|
54
|
+
registerSnippet(registry, snippet.name, snippet.content, snippet.aliases);
|
|
25
55
|
}
|
|
26
56
|
}
|
|
57
|
+
|
|
58
|
+
logger.debug(`Loaded snippets from ${source} directory`, {
|
|
59
|
+
path: dir,
|
|
60
|
+
fileCount: files.length,
|
|
61
|
+
});
|
|
27
62
|
} catch (error) {
|
|
28
63
|
// Snippets directory doesn't exist or can't be read - that's fine
|
|
29
|
-
logger.
|
|
30
|
-
path:
|
|
31
|
-
error: error instanceof Error ? error.message : String(error)
|
|
32
|
-
})
|
|
33
|
-
// Return empty registry
|
|
64
|
+
logger.debug(`${source} snippets directory not found or unreadable`, {
|
|
65
|
+
path: dir,
|
|
66
|
+
error: error instanceof Error ? error.message : String(error),
|
|
67
|
+
});
|
|
34
68
|
}
|
|
35
|
-
|
|
36
|
-
return snippets
|
|
37
69
|
}
|
|
38
70
|
|
|
39
71
|
/**
|
|
40
72
|
* Loads and parses a single snippet file
|
|
41
|
-
*
|
|
73
|
+
*
|
|
74
|
+
* @param dir - Directory containing the snippet file
|
|
42
75
|
* @param filename - The filename to load (e.g., "my-snippet.md")
|
|
43
76
|
* @returns The parsed snippet data, or null if parsing failed
|
|
44
77
|
*/
|
|
45
|
-
async function loadSnippetFile(filename: string) {
|
|
78
|
+
async function loadSnippetFile(dir: string, filename: string) {
|
|
46
79
|
try {
|
|
47
|
-
const name = basename(filename, CONFIG.SNIPPET_EXTENSION)
|
|
48
|
-
const filePath = join(
|
|
49
|
-
const fileContent = await readFile(filePath, "utf-8")
|
|
50
|
-
const parsed = matter(fileContent)
|
|
51
|
-
|
|
52
|
-
const content = parsed.content.trim()
|
|
53
|
-
const frontmatter = parsed.data as SnippetFrontmatter
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
80
|
+
const name = basename(filename, CONFIG.SNIPPET_EXTENSION);
|
|
81
|
+
const filePath = join(dir, filename);
|
|
82
|
+
const fileContent = await readFile(filePath, "utf-8");
|
|
83
|
+
const parsed = matter(fileContent);
|
|
84
|
+
|
|
85
|
+
const content = parsed.content.trim();
|
|
86
|
+
const frontmatter = parsed.data as SnippetFrontmatter;
|
|
87
|
+
|
|
88
|
+
// Handle aliases as string or array
|
|
89
|
+
let aliases: string[] = [];
|
|
90
|
+
if (frontmatter.aliases) {
|
|
91
|
+
if (Array.isArray(frontmatter.aliases)) {
|
|
92
|
+
aliases = frontmatter.aliases;
|
|
93
|
+
} else {
|
|
94
|
+
aliases = [frontmatter.aliases];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { name, content, aliases };
|
|
57
99
|
} catch (error) {
|
|
58
100
|
// Failed to read or parse this snippet - skip it
|
|
59
|
-
logger.warn("Failed to load snippet file", {
|
|
101
|
+
logger.warn("Failed to load snippet file", {
|
|
60
102
|
filename,
|
|
61
|
-
error: error instanceof Error ? error.message : String(error)
|
|
62
|
-
})
|
|
63
|
-
return null
|
|
103
|
+
error: error instanceof Error ? error.message : String(error),
|
|
104
|
+
});
|
|
105
|
+
return null;
|
|
64
106
|
}
|
|
65
107
|
}
|
|
66
108
|
|
|
67
109
|
/**
|
|
68
110
|
* Registers a snippet and its aliases in the registry
|
|
69
|
-
*
|
|
111
|
+
*
|
|
70
112
|
* @param registry - The snippet registry to update
|
|
71
113
|
* @param name - The primary name of the snippet
|
|
72
114
|
* @param content - The snippet content
|
|
@@ -76,13 +118,25 @@ function registerSnippet(
|
|
|
76
118
|
registry: SnippetRegistry,
|
|
77
119
|
name: string,
|
|
78
120
|
content: string,
|
|
79
|
-
aliases: string[]
|
|
121
|
+
aliases: string[],
|
|
80
122
|
) {
|
|
123
|
+
const key = name.toLowerCase();
|
|
124
|
+
|
|
125
|
+
// If snippet already exists, remove all entries with the old content
|
|
126
|
+
const oldContent = registry.get(key);
|
|
127
|
+
if (oldContent !== undefined) {
|
|
128
|
+
for (const [k, v] of registry.entries()) {
|
|
129
|
+
if (v === oldContent) {
|
|
130
|
+
registry.delete(k);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
81
135
|
// Register with primary name (lowercase)
|
|
82
|
-
registry.set(
|
|
83
|
-
|
|
136
|
+
registry.set(key, content);
|
|
137
|
+
|
|
84
138
|
// Register all aliases (lowercase)
|
|
85
139
|
for (const alias of aliases) {
|
|
86
|
-
registry.set(alias.toLowerCase(), content)
|
|
140
|
+
registry.set(alias.toLowerCase(), content);
|
|
87
141
|
}
|
|
88
142
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
describe("Logger", () => {
|
|
7
|
+
let tempDir: string;
|
|
8
|
+
let originalEnv: NodeJS.ProcessEnv;
|
|
9
|
+
let Logger: typeof import("../src/logger.js").Logger;
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
originalEnv = { ...process.env };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tempDir = mkdtempSync(join(tmpdir(), "snippets-logger-test-"));
|
|
17
|
+
Logger = require("../src/logger.js").Logger;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
process.env = originalEnv;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("isDebugEnabled", () => {
|
|
27
|
+
it("should return false when DEBUG_SNIPPETS is not set", () => {
|
|
28
|
+
delete process.env.DEBUG_SNIPPETS;
|
|
29
|
+
const testLogger = new Logger(join(tempDir, "logs"));
|
|
30
|
+
expect(testLogger.enabled).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should return true when DEBUG_SNIPPETS=1", () => {
|
|
34
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
35
|
+
const testLogger = new Logger(join(tempDir, "logs"));
|
|
36
|
+
expect(testLogger.enabled).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should return true when DEBUG_SNIPPETS=true", () => {
|
|
40
|
+
process.env.DEBUG_SNIPPETS = "true";
|
|
41
|
+
const testLogger = new Logger(join(tempDir, "logs"));
|
|
42
|
+
expect(testLogger.enabled).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should return false for other values", () => {
|
|
46
|
+
process.env.DEBUG_SNIPPETS = "yes";
|
|
47
|
+
const testLogger = new Logger(join(tempDir, "logs"));
|
|
48
|
+
expect(testLogger.enabled).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("log writing", () => {
|
|
53
|
+
it("should not write logs when debug is disabled", () => {
|
|
54
|
+
delete process.env.DEBUG_SNIPPETS;
|
|
55
|
+
const writeSpy = vi.spyOn(require("fs"), "writeFileSync");
|
|
56
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
57
|
+
logger.debug("test message", { key: "value" });
|
|
58
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should write logs when debug is enabled", () => {
|
|
62
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
63
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
64
|
+
logger.debug("test debug message", { test: true });
|
|
65
|
+
logger.info("test info message");
|
|
66
|
+
logger.warn("test warn message");
|
|
67
|
+
logger.error("test error message");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should format data correctly", () => {
|
|
71
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
72
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
73
|
+
logger.debug("with data", {
|
|
74
|
+
string: "hello",
|
|
75
|
+
number: 42,
|
|
76
|
+
bool: true,
|
|
77
|
+
nullVal: null,
|
|
78
|
+
undefinedVal: undefined,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should handle empty data", () => {
|
|
83
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
84
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
85
|
+
logger.debug("no data");
|
|
86
|
+
logger.debug("empty object", {});
|
|
87
|
+
logger.debug("empty array", { items: [] as unknown[] });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should format arrays compactly", () => {
|
|
91
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
92
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
93
|
+
logger.debug("small array", { items: [1, 2, 3] });
|
|
94
|
+
logger.debug("large array", { items: [1, 2, 3, 4, 5, 6] });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should handle objects in data", () => {
|
|
98
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
99
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
100
|
+
logger.debug("nested object", {
|
|
101
|
+
nested: { a: 1, b: 2 },
|
|
102
|
+
long: "this is a very long string that should be truncated if over 50 characters",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("file output", () => {
|
|
108
|
+
it("should create log file in daily directory", () => {
|
|
109
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
110
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
111
|
+
logger.info("file output test", { test: true });
|
|
112
|
+
|
|
113
|
+
const today = new Date().toISOString().split("T")[0];
|
|
114
|
+
const logFile = join(tempDir, "logs", "daily", `${today}.log`);
|
|
115
|
+
|
|
116
|
+
expect(existsSync(logFile)).toBe(true);
|
|
117
|
+
const content = readFileSync(logFile, "utf-8");
|
|
118
|
+
expect(content).toContain("file output test");
|
|
119
|
+
expect(content).toContain("test=true");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should append to existing log file", () => {
|
|
123
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
124
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
125
|
+
logger.info("first message");
|
|
126
|
+
logger.info("second message");
|
|
127
|
+
|
|
128
|
+
const today = new Date().toISOString().split("T")[0];
|
|
129
|
+
const logFile = join(tempDir, "logs", "daily", `${today}.log`);
|
|
130
|
+
const content = readFileSync(logFile, "utf-8");
|
|
131
|
+
|
|
132
|
+
expect(content).toContain("first message");
|
|
133
|
+
expect(content).toContain("second message");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|