@abarcenas/ai-workflow-template 1.44.0 → 1.46.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/.agents/config/README.md +43 -0
- package/.agents/config/domain.md +37 -0
- package/.agents/instructions/agent.instructions.md +10 -10
- package/.agents/instructions/learned-knowledge.instructions.md +170 -0
- package/.agents/instructions/memory-bank.instructions.md +11 -0
- package/.agents/skills/caveman-compress/README.md +2 -0
- package/.agents/skills/caveman-compress/SECURITY.md +3 -1
- package/.agents/skills/caveman-compress/SKILL.md +4 -0
- package/.agents/skills/caveman-compress/scripts/compress.py +9 -2
- package/.agents/skills/compress/SKILL.md +4 -0
- package/.agents/skills/compress/scripts/compress.py +9 -2
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +74 -0
- package/.agents/skills/grill-me/SKILL.md +7 -0
- package/.agents/skills/grill-with-docs/SKILL.md +7 -0
- package/.agents/skills/grilling/SKILL.md +28 -0
- package/.agents/skills/prototype/LOGIC.md +67 -0
- package/.agents/skills/prototype/SKILL.md +26 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.github/workflows/npm-publish.yml +10 -0
- package/.github/workflows/pr-test.yml +31 -0
- package/.opencode/agents/architect.agent.md +0 -1
- package/.opencode/agents/coder.agent.md +0 -1
- package/.opencode/agents/deployer.agent.md +0 -1
- package/.opencode/agents/designer.agent.md +0 -1
- package/.opencode/agents/e2e-tester.agent.md +0 -1
- package/.opencode/agents/implementer.agent.md +0 -1
- package/.opencode/agents/orchestrator/feature-pipeline.agent.md +0 -1
- package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -4
- package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +0 -1
- package/.opencode/agents/plan.agent.md +0 -1
- package/.opencode/agents/researcher.agent.md +0 -1
- package/.opencode/agents/reviewer.agent.md +0 -1
- package/.opencode/agents/scope.agent.md +299 -0
- package/.opencode/agents/tracker.agent.md +0 -1
- package/.opencode/agents/unit-tester.agent.md +0 -1
- package/AGENTS.md +44 -5
- package/README.md +6 -3
- package/package.json +1 -1
- package/scripts/bump-version.js +7 -0
- package/scripts/bump-version.test.js +238 -0
- package/scripts/mcp/playwright-mcp-launcher.js +55 -29
- package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
- package/scripts/mcp-memory-server.js +93 -76
- package/scripts/mcp-memory-server.test.js +398 -0
- package/scripts/memory-cli.test.js +297 -0
- package/scripts/memory-index.js +119 -31
- package/scripts/memory-index.test.js +1173 -0
- package/scripts/normalize-memory.js +26 -1
- package/scripts/normalize-memory.test.js +753 -0
- package/scripts/setup/discover.test.js +124 -2
- package/scripts/setup/hooks.test.js +188 -1
- package/scripts/setup/husky-init.test.js +311 -0
- package/scripts/setup/index.js +35 -5
- package/scripts/setup/index.test.js +279 -0
- package/scripts/setup/knowledgebase.test.js +100 -0
- package/scripts/setup/skills-phase.js +568 -0
- package/scripts/setup/skills-phase.test.js +1274 -0
- package/scripts/setup/sync-phase.js +6 -1
- package/scripts/setup/sync-phase.test.js +180 -2
- package/scripts/setup/ui.test.js +579 -0
- package/scripts/setup/utils.test.js +586 -0
- package/scripts/sync-unit.test.js +1342 -0
- package/scripts/sync.js +540 -246
- package/scripts/sync.test.js +283 -0
- package/scripts/vocab-sync.test.js +88 -0
- package/skills-lock.json +90 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// utils.test.js — Unit tests for the setup utility helpers
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Everything filesystem-related runs against real temp directories; nothing is
|
|
6
|
+
// written to the repository. Environment variables are saved/restored so the
|
|
7
|
+
// CI-detection tests cannot leak into other suites.
|
|
8
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
chmodSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from 'fs'
|
|
21
|
+
import { createHash } from 'crypto'
|
|
22
|
+
import { join, resolve } from 'path'
|
|
23
|
+
import { tmpdir } from 'os'
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
chmodX,
|
|
27
|
+
dirExists,
|
|
28
|
+
ensureDir,
|
|
29
|
+
fileExists,
|
|
30
|
+
getConsumerRoot,
|
|
31
|
+
getNodeVersion,
|
|
32
|
+
hashContent,
|
|
33
|
+
isCI,
|
|
34
|
+
isShellScript,
|
|
35
|
+
logError,
|
|
36
|
+
logInfo,
|
|
37
|
+
logWarn,
|
|
38
|
+
parseCliArgs,
|
|
39
|
+
resolveConsumerPath,
|
|
40
|
+
resolvePackageRoot,
|
|
41
|
+
safeAppendFile,
|
|
42
|
+
safeReadFile,
|
|
43
|
+
safeReadJson,
|
|
44
|
+
safeWriteFile,
|
|
45
|
+
safeWriteJson,
|
|
46
|
+
} from './utils.js'
|
|
47
|
+
import { CI_ENV_VARS } from './constants.js'
|
|
48
|
+
|
|
49
|
+
// ── Temp directory helpers ─────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const tempDirs = new Set()
|
|
52
|
+
|
|
53
|
+
function createTempDir() {
|
|
54
|
+
const dir = mkdtempSync(join(tmpdir(), 'utils-test-'))
|
|
55
|
+
tempDirs.add(dir)
|
|
56
|
+
return dir
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cleanupTempDirs() {
|
|
60
|
+
for (const dir of tempDirs) {
|
|
61
|
+
try {
|
|
62
|
+
rmSync(dir, { recursive: true, force: true })
|
|
63
|
+
} catch {
|
|
64
|
+
/* best-effort cleanup */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
tempDirs.clear()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Default flag shape ─────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const DEFAULT_FLAG_KEYS = [
|
|
73
|
+
'force',
|
|
74
|
+
'dryRun',
|
|
75
|
+
'yes',
|
|
76
|
+
'skipHooks',
|
|
77
|
+
'skipPrepare',
|
|
78
|
+
'skipSync',
|
|
79
|
+
'skipKnowledgebase',
|
|
80
|
+
'knowledgebase',
|
|
81
|
+
'help',
|
|
82
|
+
'version',
|
|
83
|
+
'quiet',
|
|
84
|
+
'verbose',
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
const allFlagsFalse = () =>
|
|
88
|
+
Object.fromEntries(DEFAULT_FLAG_KEYS.map((k) => [k, false]))
|
|
89
|
+
|
|
90
|
+
// ── Path resolution ────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('path resolution', () => {
|
|
93
|
+
const originalInitCwd = process.env.INIT_CWD
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
if (originalInitCwd === undefined) delete process.env.INIT_CWD
|
|
97
|
+
else process.env.INIT_CWD = originalInitCwd
|
|
98
|
+
cleanupTempDirs()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('getConsumerRoot() returns the absolute INIT_CWD when set', () => {
|
|
102
|
+
const dir = createTempDir()
|
|
103
|
+
process.env.INIT_CWD = dir
|
|
104
|
+
|
|
105
|
+
expect(getConsumerRoot()).toBe(dir)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('getConsumerRoot() resolves a relative INIT_CWD to an absolute path', () => {
|
|
109
|
+
process.env.INIT_CWD = 'some/relative/path'
|
|
110
|
+
|
|
111
|
+
expect(getConsumerRoot()).toBe(resolve('some/relative/path'))
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('getConsumerRoot() warns and falls back to process.cwd() without INIT_CWD', () => {
|
|
115
|
+
delete process.env.INIT_CWD
|
|
116
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
expect(getConsumerRoot()).toBe(process.cwd())
|
|
120
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
121
|
+
expect.stringContaining('INIT_CWD is not set'),
|
|
122
|
+
)
|
|
123
|
+
} finally {
|
|
124
|
+
warnSpy.mockRestore()
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('resolvePackageRoot() points at the directory containing package.json', () => {
|
|
129
|
+
const root = resolvePackageRoot()
|
|
130
|
+
|
|
131
|
+
expect(existsSync(join(root, 'package.json'))).toBe(true)
|
|
132
|
+
expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).name).toBe(
|
|
133
|
+
'@abarcenas/ai-workflow-template',
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('resolveConsumerPath() joins segments beneath the consumer root', () => {
|
|
138
|
+
const dir = createTempDir()
|
|
139
|
+
process.env.INIT_CWD = dir
|
|
140
|
+
|
|
141
|
+
expect(resolveConsumerPath('.husky', 'pre-commit')).toBe(
|
|
142
|
+
join(dir, '.husky', 'pre-commit'),
|
|
143
|
+
)
|
|
144
|
+
expect(resolveConsumerPath()).toBe(dir)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ── Directory helpers ──────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe('ensureDir()', () => {
|
|
151
|
+
afterEach(cleanupTempDirs)
|
|
152
|
+
|
|
153
|
+
it('creates nested directories and returns true', () => {
|
|
154
|
+
const dir = join(createTempDir(), 'a', 'b', 'c')
|
|
155
|
+
|
|
156
|
+
expect(ensureDir(dir)).toBe(true)
|
|
157
|
+
expect(statSync(dir).isDirectory()).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('returns true for a directory that already exists', () => {
|
|
161
|
+
const dir = createTempDir()
|
|
162
|
+
|
|
163
|
+
expect(ensureDir(dir)).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('returns true when the path already exists as a file (EEXIST)', () => {
|
|
167
|
+
const dir = createTempDir()
|
|
168
|
+
const file = join(dir, 'already-a-file')
|
|
169
|
+
writeFileSync(file, 'x')
|
|
170
|
+
|
|
171
|
+
expect(ensureDir(file)).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('rethrows unexpected filesystem errors', () => {
|
|
175
|
+
expect(() => ensureDir(join(createTempDir(), 'bad\u0000name'))).toThrow()
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('dirExists()', () => {
|
|
180
|
+
afterEach(cleanupTempDirs)
|
|
181
|
+
|
|
182
|
+
it('returns true for a directory', () => {
|
|
183
|
+
expect(dirExists(createTempDir())).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('returns false for a regular file', () => {
|
|
187
|
+
const file = join(createTempDir(), 'file.txt')
|
|
188
|
+
writeFileSync(file, 'x')
|
|
189
|
+
|
|
190
|
+
expect(dirExists(file)).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('returns false for a missing path', () => {
|
|
194
|
+
expect(dirExists(join(createTempDir(), 'missing'))).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe('fileExists()', () => {
|
|
199
|
+
afterEach(cleanupTempDirs)
|
|
200
|
+
|
|
201
|
+
it('returns true for files and directories', () => {
|
|
202
|
+
const dir = createTempDir()
|
|
203
|
+
const file = join(dir, 'present.txt')
|
|
204
|
+
writeFileSync(file, 'x')
|
|
205
|
+
|
|
206
|
+
expect(fileExists(file)).toBe(true)
|
|
207
|
+
expect(fileExists(dir)).toBe(true)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('returns false for a missing path', () => {
|
|
211
|
+
expect(fileExists(join(createTempDir(), 'nope.txt'))).toBe(false)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// ── File read/write helpers ────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
describe('safeReadFile()', () => {
|
|
218
|
+
afterEach(cleanupTempDirs)
|
|
219
|
+
|
|
220
|
+
it('returns the UTF-8 contents of an existing file', () => {
|
|
221
|
+
const file = join(createTempDir(), 'readme.txt')
|
|
222
|
+
writeFileSync(file, 'héllo wörld', 'utf-8')
|
|
223
|
+
|
|
224
|
+
expect(safeReadFile(file)).toBe('héllo wörld')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('returns null for a missing file (ENOENT)', () => {
|
|
228
|
+
expect(safeReadFile(join(createTempDir(), 'missing.txt'))).toBeNull()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('rethrows non-ENOENT errors such as EISDIR', () => {
|
|
232
|
+
const dir = createTempDir()
|
|
233
|
+
|
|
234
|
+
expect(() => safeReadFile(dir)).toThrow()
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('safeWriteFile()', () => {
|
|
239
|
+
afterEach(cleanupTempDirs)
|
|
240
|
+
|
|
241
|
+
it('writes content and creates parent directories', () => {
|
|
242
|
+
const file = join(createTempDir(), 'nested', 'deep', 'out.txt')
|
|
243
|
+
|
|
244
|
+
expect(safeWriteFile(file, 'content')).toBe(true)
|
|
245
|
+
expect(readFileSync(file, 'utf-8')).toBe('content')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('overwrites an existing file', () => {
|
|
249
|
+
const file = join(createTempDir(), 'out.txt')
|
|
250
|
+
writeFileSync(file, 'old')
|
|
251
|
+
|
|
252
|
+
expect(safeWriteFile(file, 'new')).toBe(true)
|
|
253
|
+
expect(readFileSync(file, 'utf-8')).toBe('new')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('returns false instead of throwing on EACCES', () => {
|
|
257
|
+
const file = join(createTempDir(), 'readonly.txt')
|
|
258
|
+
writeFileSync(file, 'locked')
|
|
259
|
+
chmodSync(file, 0o444)
|
|
260
|
+
|
|
261
|
+
expect(safeWriteFile(file, 'nope')).toBe(false)
|
|
262
|
+
expect(readFileSync(file, 'utf-8')).toBe('locked')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('rethrows unexpected errors (ENOTDIR)', () => {
|
|
266
|
+
const dir = createTempDir()
|
|
267
|
+
const blocker = join(dir, 'blocker')
|
|
268
|
+
writeFileSync(blocker, 'i am a file')
|
|
269
|
+
|
|
270
|
+
expect(() => safeWriteFile(join(blocker, 'child.txt'), 'x')).toThrow()
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('safeAppendFile()', () => {
|
|
275
|
+
afterEach(cleanupTempDirs)
|
|
276
|
+
|
|
277
|
+
it('creates the file and appends on repeated calls', () => {
|
|
278
|
+
const file = join(createTempDir(), 'nested', 'log.txt')
|
|
279
|
+
|
|
280
|
+
safeAppendFile(file, 'first\n')
|
|
281
|
+
safeAppendFile(file, 'second\n')
|
|
282
|
+
|
|
283
|
+
expect(readFileSync(file, 'utf-8')).toBe('first\nsecond\n')
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// ── Shell script inspection ───────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
describe('isShellScript()', () => {
|
|
290
|
+
afterEach(cleanupTempDirs)
|
|
291
|
+
|
|
292
|
+
it('returns true when the first line starts with a shebang', () => {
|
|
293
|
+
const file = join(createTempDir(), 'hook')
|
|
294
|
+
writeFileSync(file, '#!/bin/sh\necho hi\n')
|
|
295
|
+
|
|
296
|
+
expect(isShellScript(file)).toBe(true)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('returns false when the first line is not a shebang', () => {
|
|
300
|
+
const file = join(createTempDir(), 'hook')
|
|
301
|
+
writeFileSync(file, '# just a comment\n')
|
|
302
|
+
|
|
303
|
+
expect(isShellScript(file)).toBe(false)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('returns false for an empty file', () => {
|
|
307
|
+
const file = join(createTempDir(), 'empty')
|
|
308
|
+
writeFileSync(file, '')
|
|
309
|
+
|
|
310
|
+
expect(isShellScript(file)).toBe(false)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('returns false for a missing file', () => {
|
|
314
|
+
expect(isShellScript(join(createTempDir(), 'missing'))).toBe(false)
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
describe('chmodX()', () => {
|
|
319
|
+
afterEach(cleanupTempDirs)
|
|
320
|
+
|
|
321
|
+
it('sets 0755 permission bits', () => {
|
|
322
|
+
const file = join(createTempDir(), 'script')
|
|
323
|
+
writeFileSync(file, '#!/bin/sh\n')
|
|
324
|
+
|
|
325
|
+
chmodX(file)
|
|
326
|
+
|
|
327
|
+
expect(statSync(file).mode & 0o777).toBe(0o755)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('throws for a missing file', () => {
|
|
331
|
+
expect(() => chmodX(join(createTempDir(), 'missing'))).toThrow()
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// ── JSON helpers ───────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
describe('safeReadJson()', () => {
|
|
338
|
+
afterEach(cleanupTempDirs)
|
|
339
|
+
|
|
340
|
+
it('parses a valid JSON file', () => {
|
|
341
|
+
const file = join(createTempDir(), 'pkg.json')
|
|
342
|
+
writeFileSync(file, JSON.stringify({ name: 'x', nested: { ok: true } }))
|
|
343
|
+
|
|
344
|
+
expect(safeReadJson(file)).toEqual({ name: 'x', nested: { ok: true } })
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('returns undefined when the file is missing', () => {
|
|
348
|
+
expect(safeReadJson(join(createTempDir(), 'missing.json'))).toBeUndefined()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('returns undefined when the JSON is malformed', () => {
|
|
352
|
+
const file = join(createTempDir(), 'broken.json')
|
|
353
|
+
writeFileSync(file, '{ not json')
|
|
354
|
+
|
|
355
|
+
expect(safeReadJson(file)).toBeUndefined()
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe('safeWriteJson()', () => {
|
|
360
|
+
afterEach(cleanupTempDirs)
|
|
361
|
+
|
|
362
|
+
it('writes 2-space indented JSON with a trailing newline', () => {
|
|
363
|
+
const file = join(createTempDir(), 'nested', 'data.json')
|
|
364
|
+
|
|
365
|
+
safeWriteJson(file, { a: 1, b: [2, 3] })
|
|
366
|
+
|
|
367
|
+
expect(readFileSync(file, 'utf-8')).toBe(
|
|
368
|
+
'{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}\n',
|
|
369
|
+
)
|
|
370
|
+
expect(safeReadJson(file)).toEqual({ a: 1, b: [2, 3] })
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// ── Hashing ────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
describe('hashContent()', () => {
|
|
377
|
+
it('returns the SHA-256 hex digest', () => {
|
|
378
|
+
// Well-known vector: sha256('abc')
|
|
379
|
+
expect(hashContent('abc')).toBe(
|
|
380
|
+
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
|
|
381
|
+
)
|
|
382
|
+
expect(hashContent('abc')).toBe(
|
|
383
|
+
createHash('sha256').update('abc').digest('hex'),
|
|
384
|
+
)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('produces different digests for different content', () => {
|
|
388
|
+
expect(hashContent('a')).not.toBe(hashContent('b'))
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('handles the empty string', () => {
|
|
392
|
+
expect(hashContent('')).toHaveLength(64)
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// ── CI detection ───────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
describe('isCI()', () => {
|
|
399
|
+
const saved = new Map()
|
|
400
|
+
|
|
401
|
+
beforeAll(() => {
|
|
402
|
+
for (const name of CI_ENV_VARS) saved.set(name, process.env[name])
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
beforeEach(() => {
|
|
406
|
+
for (const name of CI_ENV_VARS) delete process.env[name]
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
afterAll(() => {
|
|
410
|
+
for (const [name, value] of saved) {
|
|
411
|
+
if (value === undefined) delete process.env[name]
|
|
412
|
+
else process.env[name] = value
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('returns false when no CI environment variable is set', () => {
|
|
417
|
+
expect(isCI()).toBe(false)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('returns true for a truthy generic CI variable', () => {
|
|
421
|
+
process.env.CI = 'true'
|
|
422
|
+
|
|
423
|
+
expect(isCI()).toBe(true)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('detects provider-specific variables', () => {
|
|
427
|
+
for (const name of ['GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'JENKINS_URL']) {
|
|
428
|
+
for (const other of CI_ENV_VARS) delete process.env[other]
|
|
429
|
+
process.env[name] = '1'
|
|
430
|
+
|
|
431
|
+
expect(isCI(), `${name} should be detected`).toBe(true)
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('treats NODE_ENV=production as CI', () => {
|
|
436
|
+
process.env.NODE_ENV = 'production'
|
|
437
|
+
|
|
438
|
+
expect(isCI()).toBe(true)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('does not treat other NODE_ENV values as CI', () => {
|
|
442
|
+
process.env.NODE_ENV = 'development'
|
|
443
|
+
|
|
444
|
+
expect(isCI()).toBe(false)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('ignores empty-string CI variables', () => {
|
|
448
|
+
process.env.CI = ''
|
|
449
|
+
process.env.GITHUB_ACTIONS = ''
|
|
450
|
+
|
|
451
|
+
expect(isCI()).toBe(false)
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
describe('getNodeVersion()', () => {
|
|
456
|
+
it('returns the major version parsed from process.version', () => {
|
|
457
|
+
expect(getNodeVersion()).toBe(
|
|
458
|
+
parseInt(process.version.slice(1).split('.')[0], 10),
|
|
459
|
+
)
|
|
460
|
+
expect(Number.isInteger(getNodeVersion())).toBe(true)
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
// ── CLI argument parsing ───────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
describe('parseCliArgs()', () => {
|
|
467
|
+
it('returns every supported flag set to false for empty argv', () => {
|
|
468
|
+
const flags = parseCliArgs([])
|
|
469
|
+
|
|
470
|
+
expect(flags).toEqual(allFlagsFalse())
|
|
471
|
+
expect(Object.keys(flags).sort()).toEqual([...DEFAULT_FLAG_KEYS].sort())
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('enables long-form boolean flags', () => {
|
|
475
|
+
const flags = parseCliArgs([
|
|
476
|
+
'--force',
|
|
477
|
+
'--dry-run',
|
|
478
|
+
'--yes',
|
|
479
|
+
'--skip-hooks',
|
|
480
|
+
'--skip-prepare',
|
|
481
|
+
'--skip-sync',
|
|
482
|
+
'--skip-knowledgebase',
|
|
483
|
+
'--knowledgebase',
|
|
484
|
+
'--help',
|
|
485
|
+
'--version',
|
|
486
|
+
'--quiet',
|
|
487
|
+
'--verbose',
|
|
488
|
+
])
|
|
489
|
+
|
|
490
|
+
expect(flags).toEqual(
|
|
491
|
+
Object.fromEntries(DEFAULT_FLAG_KEYS.map((k) => [k, true])),
|
|
492
|
+
)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('maps short-form flags to their canonical names', () => {
|
|
496
|
+
const flags = parseCliArgs(['-y', '-h', '-v', '-q', '-V'])
|
|
497
|
+
|
|
498
|
+
expect(flags.yes).toBe(true)
|
|
499
|
+
expect(flags.help).toBe(true)
|
|
500
|
+
expect(flags.version).toBe(true)
|
|
501
|
+
expect(flags.quiet).toBe(true)
|
|
502
|
+
expect(flags.verbose).toBe(true)
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('supports --flag=value for known flags', () => {
|
|
506
|
+
const flags = parseCliArgs(['--force=maybe', '--skip-sync=yes'])
|
|
507
|
+
|
|
508
|
+
expect(flags.force).toBe('maybe')
|
|
509
|
+
expect(flags.skipSync).toBe('yes')
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('supports --no-flag to explicitly disable a flag', () => {
|
|
513
|
+
const flags = parseCliArgs(['--force', '--no-force', '--skip-hooks', '--no-skip-hooks'])
|
|
514
|
+
|
|
515
|
+
expect(flags.force).toBe(false)
|
|
516
|
+
expect(flags.skipHooks).toBe(false)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('ignores unknown --no- flags', () => {
|
|
520
|
+
const flags = parseCliArgs(['--no-unknown-flag'])
|
|
521
|
+
|
|
522
|
+
expect(flags).toEqual(allFlagsFalse())
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('ignores unknown long flags, short flags and assignments', () => {
|
|
526
|
+
const flags = parseCliArgs(['--nope', '-x', '--what=ever', 'positional'])
|
|
527
|
+
|
|
528
|
+
expect(flags).toEqual(allFlagsFalse())
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('lets later arguments win over earlier ones', () => {
|
|
532
|
+
expect(parseCliArgs(['--force', '--no-force']).force).toBe(false)
|
|
533
|
+
expect(parseCliArgs(['--no-force', '--force']).force).toBe(true)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('leaves unrelated flags untouched', () => {
|
|
537
|
+
const flags = parseCliArgs(['--dry-run'])
|
|
538
|
+
|
|
539
|
+
expect(flags.dryRun).toBe(true)
|
|
540
|
+
expect(flags.force).toBe(false)
|
|
541
|
+
expect(flags.verbose).toBe(false)
|
|
542
|
+
})
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
// ── Logging wrappers ───────────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
describe('logging wrappers', () => {
|
|
548
|
+
it('logError() writes to stderr', () => {
|
|
549
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
550
|
+
|
|
551
|
+
logError('boom')
|
|
552
|
+
|
|
553
|
+
expect(spy).toHaveBeenCalledWith('boom')
|
|
554
|
+
spy.mockRestore()
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('logWarn() writes to stderr', () => {
|
|
558
|
+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
559
|
+
|
|
560
|
+
logWarn('careful')
|
|
561
|
+
|
|
562
|
+
expect(spy).toHaveBeenCalledWith('careful')
|
|
563
|
+
spy.mockRestore()
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('logInfo() writes to stdout', () => {
|
|
567
|
+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
568
|
+
|
|
569
|
+
logInfo('fyi')
|
|
570
|
+
|
|
571
|
+
expect(spy).toHaveBeenCalledWith('fyi')
|
|
572
|
+
spy.mockRestore()
|
|
573
|
+
})
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
// ── Cross-cutting: no repository writes ───────────────────────────────────
|
|
577
|
+
|
|
578
|
+
describe('temp-directory isolation', () => {
|
|
579
|
+
it('all fixtures live under the OS temp directory', () => {
|
|
580
|
+
const dir = createTempDir()
|
|
581
|
+
|
|
582
|
+
expect(dir.startsWith(tmpdir())).toBe(true)
|
|
583
|
+
mkdirSync(join(dir, 'sandbox'))
|
|
584
|
+
expect(existsSync(join(dir, 'sandbox'))).toBe(true)
|
|
585
|
+
})
|
|
586
|
+
})
|