@abarcenas/ai-workflow-template 1.28.0 → 1.32.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/instructions/learned-knowledge.instructions.md +23 -0
- package/.github/workflows/npm-publish.yml +3 -0
- package/.github/workflows/pr-test.yml +23 -0
- package/.husky/post-merge +1 -0
- package/.husky/pre-commit +2 -0
- package/.opencode/agents/architect.agent.md +2 -2
- package/.opencode/agents/designer.agent.md +13 -13
- package/.opencode/agents/e2e-tester.agent.md +33 -32
- package/.opencode/agents/orchestrator/feature-pipeline.agent.md +46 -56
- package/.opencode/agents/orchestrator/orchestrator.agent.md +55 -67
- package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +47 -59
- package/.opencode/agents/researcher.agent.md +15 -14
- package/.opencode/agents/unit-tester.agent.md +2 -1
- package/README.md +48 -1
- package/bin/setup.js +23 -0
- package/package.json +7 -2
- package/scripts/setup/constants.js +226 -0
- package/scripts/setup/discover.js +236 -0
- package/scripts/setup/discover.test.js +388 -0
- package/scripts/setup/hooks.js +261 -0
- package/scripts/setup/hooks.test.js +527 -0
- package/scripts/setup/husky-init.js +102 -0
- package/scripts/setup/index.js +354 -0
- package/scripts/setup/index.test.js +639 -0
- package/scripts/setup/prepare.js +127 -0
- package/scripts/setup/prepare.test.js +718 -0
- package/scripts/setup/sync-phase.js +172 -0
- package/scripts/setup/ui.js +503 -0
- package/scripts/setup/utils.js +371 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// prepare.test.js — Unit tests for the prepare script handling module
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Tests the 6-case prepare script algorithm implemented in prepare.js using
|
|
6
|
+
// real temp directories. The ui module is mocked to keep test output clean.
|
|
7
|
+
//
|
|
8
|
+
// Covered scenarios (≥12, per plan T16 + task description):
|
|
9
|
+
// 1. No package.json → action='skipped', message includes 'No package.json'
|
|
10
|
+
// 2. prepare already has "husky" → action='skipped', message includes 'already'
|
|
11
|
+
// 3. No prepare script → action='added', package.json gets "prepare": "husky"
|
|
12
|
+
// 4. Simple prepare (merge) → action='merged', value becomes "npm run build && husky"
|
|
13
|
+
// 5. Complex prepare (multi-line) → action='skipped', manual merge required
|
|
14
|
+
// 6. Complex prepare with || → action='skipped', manual merge required
|
|
15
|
+
// 7. Complex prepare with ; → action='skipped', manual merge required
|
|
16
|
+
// 8. Complex prepare + --force → action='added', value becomes "husky"
|
|
17
|
+
// 9. dryRun=true → action='dry-run', package.json NOT modified
|
|
18
|
+
// 10. Empty string prepare → treated as no prepare, action='added'
|
|
19
|
+
// 11. Whitespace-only prepare → treated as empty, action='added'
|
|
20
|
+
// 12. "husky && npm run build" → action='skipped' (substring detection)
|
|
21
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
import { vi, describe, it, expect, afterEach } from 'vitest'
|
|
24
|
+
import {
|
|
25
|
+
existsSync,
|
|
26
|
+
mkdtempSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
realpathSync,
|
|
31
|
+
} from 'fs'
|
|
32
|
+
import { join } from 'path'
|
|
33
|
+
import { tmpdir } from 'os'
|
|
34
|
+
|
|
35
|
+
// ── Mock ui module to keep test output clean ──────────────────────────────
|
|
36
|
+
|
|
37
|
+
vi.mock('./ui.js', () => ({
|
|
38
|
+
step: vi.fn(),
|
|
39
|
+
stepSuccess: vi.fn(),
|
|
40
|
+
stepWarn: vi.fn(),
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
import { handlePrepare } from './prepare.js'
|
|
44
|
+
|
|
45
|
+
// ── Test helpers ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const tempDirs = new Set()
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates and registers a temporary directory for a single test case.
|
|
51
|
+
* Automatically cleaned up in `afterEach`.
|
|
52
|
+
*
|
|
53
|
+
* @returns {string} Absolute path to the new temp directory.
|
|
54
|
+
*/
|
|
55
|
+
function createTempDir() {
|
|
56
|
+
const dir = mkdtempSync(join(realpathSync(tmpdir()), 'prepare-test-'))
|
|
57
|
+
tempDirs.add(dir)
|
|
58
|
+
return dir
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cleanupTempDirs() {
|
|
62
|
+
for (const dir of tempDirs) {
|
|
63
|
+
try {
|
|
64
|
+
rmSync(dir, { recursive: true, force: true })
|
|
65
|
+
} catch {
|
|
66
|
+
/* best-effort cleanup */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
tempDirs.clear()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Writes a package.json fixture into the given directory.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} dir - Directory to write into.
|
|
76
|
+
* @param {object} [overrides] - Properties to merge into the default package.
|
|
77
|
+
* @returns {object} The written package object.
|
|
78
|
+
*/
|
|
79
|
+
function writePkg(dir, overrides = {}) {
|
|
80
|
+
const pkg = { name: 'test-pkg', version: '1.0.0', ...overrides }
|
|
81
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
|
|
82
|
+
return pkg
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reads and parses the package.json in the given directory.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} dir - Directory containing package.json.
|
|
89
|
+
* @returns {object} Parsed package object.
|
|
90
|
+
*/
|
|
91
|
+
function readPkg(dir) {
|
|
92
|
+
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('handlePrepare', () => {
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
cleanupTempDirs()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// ── 1. No package.json ─────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
it('scenario 1: no consumerPackageJson → action=skipped, message includes "No package.json"', async () => {
|
|
105
|
+
const dir = createTempDir()
|
|
106
|
+
// No package.json file is written — consumerPackageJson is undefined
|
|
107
|
+
|
|
108
|
+
const result = await handlePrepare({
|
|
109
|
+
consumerRoot: dir,
|
|
110
|
+
consumerPackageJson: undefined,
|
|
111
|
+
existingPrepare: undefined,
|
|
112
|
+
prepareIsSimple: false,
|
|
113
|
+
dryRun: false,
|
|
114
|
+
force: false,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(result.action).toBe('skipped')
|
|
118
|
+
expect(result.message).toContain('No package.json')
|
|
119
|
+
// Nothing should have been created
|
|
120
|
+
expect(existsSync(join(dir, 'package.json'))).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// ── 2. Prepare already has "husky" ─────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
it('scenario 2: prepare already set to exactly "husky" → action=skipped, message includes "already"', async () => {
|
|
126
|
+
const dir = createTempDir()
|
|
127
|
+
writePkg(dir, { scripts: { prepare: 'husky' } })
|
|
128
|
+
|
|
129
|
+
const result = await handlePrepare({
|
|
130
|
+
consumerRoot: dir,
|
|
131
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: 'husky' } },
|
|
132
|
+
existingPrepare: 'husky',
|
|
133
|
+
prepareIsSimple: true,
|
|
134
|
+
dryRun: false,
|
|
135
|
+
force: false,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
expect(result.action).toBe('skipped')
|
|
139
|
+
expect(result.message).toContain('already')
|
|
140
|
+
// package.json must be unchanged
|
|
141
|
+
expect(readPkg(dir).scripts.prepare).toBe('husky')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('scenario 2b: prepare with "husky && lint" (substring) → action=skipped', async () => {
|
|
145
|
+
const dir = createTempDir()
|
|
146
|
+
writePkg(dir, { scripts: { prepare: 'husky && lint' } })
|
|
147
|
+
|
|
148
|
+
const result = await handlePrepare({
|
|
149
|
+
consumerRoot: dir,
|
|
150
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: 'husky && lint' } },
|
|
151
|
+
existingPrepare: 'husky && lint',
|
|
152
|
+
prepareIsSimple: true,
|
|
153
|
+
dryRun: false,
|
|
154
|
+
force: false,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
expect(result.action).toBe('skipped')
|
|
158
|
+
expect(result.message).toContain('already')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('scenario 2c: prepare with "build && husky" (suffix) → action=skipped', async () => {
|
|
162
|
+
const dir = createTempDir()
|
|
163
|
+
writePkg(dir, { scripts: { prepare: 'build && husky' } })
|
|
164
|
+
|
|
165
|
+
const result = await handlePrepare({
|
|
166
|
+
consumerRoot: dir,
|
|
167
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: 'build && husky' } },
|
|
168
|
+
existingPrepare: 'build && husky',
|
|
169
|
+
prepareIsSimple: true,
|
|
170
|
+
dryRun: false,
|
|
171
|
+
force: false,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
expect(result.action).toBe('skipped')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('scenario 2d: prepare with "build && husky && lint" (middle) → action=skipped', async () => {
|
|
178
|
+
const dir = createTempDir()
|
|
179
|
+
writePkg(dir, { scripts: { prepare: 'build && husky && lint' } })
|
|
180
|
+
|
|
181
|
+
const result = await handlePrepare({
|
|
182
|
+
consumerRoot: dir,
|
|
183
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: 'build && husky && lint' } },
|
|
184
|
+
existingPrepare: 'build && husky && lint',
|
|
185
|
+
prepareIsSimple: false,
|
|
186
|
+
dryRun: false,
|
|
187
|
+
force: false,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
expect(result.action).toBe('skipped')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// ── 3. No prepare script ───────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
it('scenario 3: no prepare script → action=added, package.json gets "prepare": "husky"', async () => {
|
|
196
|
+
const dir = createTempDir()
|
|
197
|
+
writePkg(dir, { scripts: { test: 'vitest' } }) // scripts block present but no prepare
|
|
198
|
+
|
|
199
|
+
const result = await handlePrepare({
|
|
200
|
+
consumerRoot: dir,
|
|
201
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { test: 'vitest' } },
|
|
202
|
+
existingPrepare: undefined,
|
|
203
|
+
prepareIsSimple: false,
|
|
204
|
+
dryRun: false,
|
|
205
|
+
force: false,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
expect(result.action).toBe('added')
|
|
209
|
+
expect(result.message).toContain('Added')
|
|
210
|
+
const pkg = readPkg(dir)
|
|
211
|
+
expect(pkg.scripts.prepare).toBe('husky')
|
|
212
|
+
// Existing scripts are preserved
|
|
213
|
+
expect(pkg.scripts.test).toBe('vitest')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('scenario 3b: no scripts block at all → creates scripts block with prepare', async () => {
|
|
217
|
+
const dir = createTempDir()
|
|
218
|
+
writePkg(dir, {}) // no scripts block
|
|
219
|
+
|
|
220
|
+
const result = await handlePrepare({
|
|
221
|
+
consumerRoot: dir,
|
|
222
|
+
consumerPackageJson: { name: 'test-pkg' },
|
|
223
|
+
existingPrepare: undefined,
|
|
224
|
+
prepareIsSimple: false,
|
|
225
|
+
dryRun: false,
|
|
226
|
+
force: false,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
expect(result.action).toBe('added')
|
|
230
|
+
const pkg = readPkg(dir)
|
|
231
|
+
expect(pkg.scripts).toEqual({ prepare: 'husky' })
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ── 4. Simple prepare (merge) ──────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
it('scenario 4: simple prepare "npm run build" → action=merged, value becomes "npm run build && husky"', async () => {
|
|
237
|
+
const dir = createTempDir()
|
|
238
|
+
writePkg(dir, { scripts: { prepare: 'npm run build' } })
|
|
239
|
+
|
|
240
|
+
const result = await handlePrepare({
|
|
241
|
+
consumerRoot: dir,
|
|
242
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: 'npm run build' } },
|
|
243
|
+
existingPrepare: 'npm run build',
|
|
244
|
+
prepareIsSimple: true,
|
|
245
|
+
dryRun: false,
|
|
246
|
+
force: false,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
expect(result.action).toBe('merged')
|
|
250
|
+
expect(result.message).toContain('Merged')
|
|
251
|
+
expect(readPkg(dir).scripts.prepare).toBe('npm run build && husky')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('scenario 4b: simple prepare "echo hello" → action=merged', async () => {
|
|
255
|
+
const dir = createTempDir()
|
|
256
|
+
writePkg(dir, { scripts: { prepare: 'echo hello' } })
|
|
257
|
+
|
|
258
|
+
const result = await handlePrepare({
|
|
259
|
+
consumerRoot: dir,
|
|
260
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: 'echo hello' } },
|
|
261
|
+
existingPrepare: 'echo hello',
|
|
262
|
+
prepareIsSimple: true,
|
|
263
|
+
dryRun: false,
|
|
264
|
+
force: false,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
expect(result.action).toBe('merged')
|
|
268
|
+
expect(readPkg(dir).scripts.prepare).toBe('echo hello && husky')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// ── 5. Complex prepare (multi-line) ────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
it('scenario 5: multi-line prepare → action=skipped, manual merge required', async () => {
|
|
274
|
+
const dir = createTempDir()
|
|
275
|
+
const script = 'echo "line1"\necho "line2"\necho "line3"'
|
|
276
|
+
writePkg(dir, { scripts: { prepare: script } })
|
|
277
|
+
|
|
278
|
+
const result = await handlePrepare({
|
|
279
|
+
consumerRoot: dir,
|
|
280
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: script } },
|
|
281
|
+
existingPrepare: script,
|
|
282
|
+
prepareIsSimple: false,
|
|
283
|
+
dryRun: false,
|
|
284
|
+
force: false,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
expect(result.action).toBe('skipped')
|
|
288
|
+
expect(result.message).toContain('manual merge')
|
|
289
|
+
// File must not be modified
|
|
290
|
+
expect(readPkg(dir).scripts.prepare).toBe(script)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// ── 6. Complex prepare with || ─────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
it('scenario 6: prepare with || operator → action=skipped, manual merge required', async () => {
|
|
296
|
+
const dir = createTempDir()
|
|
297
|
+
const script = 'cmd1 || cmd2'
|
|
298
|
+
writePkg(dir, { scripts: { prepare: script } })
|
|
299
|
+
|
|
300
|
+
const result = await handlePrepare({
|
|
301
|
+
consumerRoot: dir,
|
|
302
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: script } },
|
|
303
|
+
existingPrepare: script,
|
|
304
|
+
prepareIsSimple: false,
|
|
305
|
+
dryRun: false,
|
|
306
|
+
force: false,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
expect(result.action).toBe('skipped')
|
|
310
|
+
expect(result.message).toContain('manual merge')
|
|
311
|
+
expect(readPkg(dir).scripts.prepare).toBe(script)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// ── 7. Complex prepare with ; ──────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
it('scenario 7: prepare with ; separator → action=skipped, manual merge required', async () => {
|
|
317
|
+
const dir = createTempDir()
|
|
318
|
+
const script = 'cmd1; cmd2'
|
|
319
|
+
writePkg(dir, { scripts: { prepare: script } })
|
|
320
|
+
|
|
321
|
+
const result = await handlePrepare({
|
|
322
|
+
consumerRoot: dir,
|
|
323
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: script } },
|
|
324
|
+
existingPrepare: script,
|
|
325
|
+
prepareIsSimple: false,
|
|
326
|
+
dryRun: false,
|
|
327
|
+
force: false,
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
expect(result.action).toBe('skipped')
|
|
331
|
+
expect(result.message).toContain('manual merge')
|
|
332
|
+
expect(readPkg(dir).scripts.prepare).toBe(script)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// ── 8. Complex prepare + --force ───────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
it('scenario 8: complex prepare + --force → action=added, value becomes "husky"', async () => {
|
|
338
|
+
const dir = createTempDir()
|
|
339
|
+
const script = 'cmd1 && cmd2 && cmd3 && cmd4'
|
|
340
|
+
writePkg(dir, { scripts: { prepare: script } })
|
|
341
|
+
|
|
342
|
+
const result = await handlePrepare({
|
|
343
|
+
consumerRoot: dir,
|
|
344
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: script } },
|
|
345
|
+
existingPrepare: script,
|
|
346
|
+
prepareIsSimple: false,
|
|
347
|
+
dryRun: false,
|
|
348
|
+
force: true,
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
expect(result.action).toBe('added')
|
|
352
|
+
expect(result.message).toContain('Replaced')
|
|
353
|
+
expect(readPkg(dir).scripts.prepare).toBe('husky')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('scenario 8b: multi-line prepare + --force → value replaced with "husky"', async () => {
|
|
357
|
+
const dir = createTempDir()
|
|
358
|
+
const script = 'echo "a"\necho "b"\necho "c"'
|
|
359
|
+
writePkg(dir, { scripts: { prepare: script } })
|
|
360
|
+
|
|
361
|
+
const result = await handlePrepare({
|
|
362
|
+
consumerRoot: dir,
|
|
363
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: script } },
|
|
364
|
+
existingPrepare: script,
|
|
365
|
+
prepareIsSimple: false,
|
|
366
|
+
dryRun: false,
|
|
367
|
+
force: true,
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
expect(result.action).toBe('added')
|
|
371
|
+
expect(readPkg(dir).scripts.prepare).toBe('husky')
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// ── 9. dryRun=true ─────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
it('scenario 9a: dryRun + no prepare → action=dry-run, package.json NOT modified', async () => {
|
|
377
|
+
const dir = createTempDir()
|
|
378
|
+
writePkg(dir, { scripts: { test: 'vitest' } })
|
|
379
|
+
|
|
380
|
+
const result = await handlePrepare({
|
|
381
|
+
consumerRoot: dir,
|
|
382
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { test: 'vitest' } },
|
|
383
|
+
existingPrepare: undefined,
|
|
384
|
+
prepareIsSimple: false,
|
|
385
|
+
dryRun: true,
|
|
386
|
+
force: false,
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
expect(result.action).toBe('dry-run')
|
|
390
|
+
expect(result.message).toContain('Would add')
|
|
391
|
+
// Verify no change
|
|
392
|
+
const pkg = readPkg(dir)
|
|
393
|
+
expect(pkg.scripts.prepare).toBeUndefined()
|
|
394
|
+
expect(pkg.scripts.test).toBe('vitest')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('scenario 9b: dryRun + simple prepare → action=dry-run, package.json NOT modified', async () => {
|
|
398
|
+
const dir = createTempDir()
|
|
399
|
+
writePkg(dir, { scripts: { prepare: 'npm run build' } })
|
|
400
|
+
|
|
401
|
+
const result = await handlePrepare({
|
|
402
|
+
consumerRoot: dir,
|
|
403
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: 'npm run build' } },
|
|
404
|
+
existingPrepare: 'npm run build',
|
|
405
|
+
prepareIsSimple: true,
|
|
406
|
+
dryRun: true,
|
|
407
|
+
force: false,
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
expect(result.action).toBe('dry-run')
|
|
411
|
+
expect(result.message).toContain('Would merge')
|
|
412
|
+
expect(readPkg(dir).scripts.prepare).toBe('npm run build') // unchanged
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('scenario 9c: dryRun + complex prepare → action=dry-run, package.json NOT modified', async () => {
|
|
416
|
+
const dir = createTempDir()
|
|
417
|
+
const script = 'cmd1 && cmd2 && cmd3'
|
|
418
|
+
writePkg(dir, { scripts: { prepare: script } })
|
|
419
|
+
|
|
420
|
+
const result = await handlePrepare({
|
|
421
|
+
consumerRoot: dir,
|
|
422
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: script } },
|
|
423
|
+
existingPrepare: script,
|
|
424
|
+
prepareIsSimple: false,
|
|
425
|
+
dryRun: true,
|
|
426
|
+
force: false,
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
expect(result.action).toBe('dry-run')
|
|
430
|
+
expect(result.message).toContain('would skip')
|
|
431
|
+
expect(readPkg(dir).scripts.prepare).toBe(script) // unchanged
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('scenario 9d: dryRun + complex prepare + --force → action=dry-run, package.json NOT modified', async () => {
|
|
435
|
+
const dir = createTempDir()
|
|
436
|
+
const script = 'cmd1 && cmd2 && cmd3'
|
|
437
|
+
writePkg(dir, { scripts: { prepare: script } })
|
|
438
|
+
|
|
439
|
+
const result = await handlePrepare({
|
|
440
|
+
consumerRoot: dir,
|
|
441
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: script } },
|
|
442
|
+
existingPrepare: script,
|
|
443
|
+
prepareIsSimple: false,
|
|
444
|
+
dryRun: true,
|
|
445
|
+
force: true,
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
expect(result.action).toBe('dry-run')
|
|
449
|
+
expect(result.message).toContain('Would replace')
|
|
450
|
+
expect(readPkg(dir).scripts.prepare).toBe(script) // unchanged
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
// ── 10. Empty string prepare ───────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
it('scenario 10: empty string prepare → treated as no prepare, action=added', async () => {
|
|
456
|
+
const dir = createTempDir()
|
|
457
|
+
writePkg(dir, { scripts: { prepare: '' } })
|
|
458
|
+
|
|
459
|
+
const result = await handlePrepare({
|
|
460
|
+
consumerRoot: dir,
|
|
461
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: '' } },
|
|
462
|
+
existingPrepare: '',
|
|
463
|
+
prepareIsSimple: false,
|
|
464
|
+
dryRun: false,
|
|
465
|
+
force: false,
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
expect(result.action).toBe('added')
|
|
469
|
+
expect(readPkg(dir).scripts.prepare).toBe('husky')
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
// ── 11. Whitespace-only prepare ────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
it('scenario 11: whitespace-only prepare → treated as empty, action=added', async () => {
|
|
475
|
+
const dir = createTempDir()
|
|
476
|
+
writePkg(dir, { scripts: { prepare: ' ' } })
|
|
477
|
+
|
|
478
|
+
const result = await handlePrepare({
|
|
479
|
+
consumerRoot: dir,
|
|
480
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: ' ' } },
|
|
481
|
+
existingPrepare: ' ',
|
|
482
|
+
prepareIsSimple: false,
|
|
483
|
+
dryRun: false,
|
|
484
|
+
force: false,
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
expect(result.action).toBe('added')
|
|
488
|
+
expect(readPkg(dir).scripts.prepare).toBe('husky')
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('scenario 11b: whitespace-only prepare with tabs and newlines → treated as empty, action=added', async () => {
|
|
492
|
+
const dir = createTempDir()
|
|
493
|
+
const whitespace = ' \t\n '
|
|
494
|
+
writePkg(dir, { scripts: { prepare: whitespace } })
|
|
495
|
+
|
|
496
|
+
const result = await handlePrepare({
|
|
497
|
+
consumerRoot: dir,
|
|
498
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: whitespace } },
|
|
499
|
+
existingPrepare: whitespace,
|
|
500
|
+
prepareIsSimple: false,
|
|
501
|
+
dryRun: false,
|
|
502
|
+
force: false,
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
expect(result.action).toBe('added')
|
|
506
|
+
expect(readPkg(dir).scripts.prepare).toBe('husky')
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
// ── 12. "husky && ..." substring detection ─────────────────────────────
|
|
510
|
+
|
|
511
|
+
it('scenario 12: "husky && npm run build" → action=skipped (substring detects husky)', async () => {
|
|
512
|
+
const dir = createTempDir()
|
|
513
|
+
const script = 'husky && npm run build'
|
|
514
|
+
writePkg(dir, { scripts: { prepare: script } })
|
|
515
|
+
|
|
516
|
+
const result = await handlePrepare({
|
|
517
|
+
consumerRoot: dir,
|
|
518
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: script } },
|
|
519
|
+
existingPrepare: script,
|
|
520
|
+
prepareIsSimple: true,
|
|
521
|
+
dryRun: false,
|
|
522
|
+
force: false,
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
expect(result.action).toBe('skipped')
|
|
526
|
+
expect(result.message).toContain('already')
|
|
527
|
+
expect(readPkg(dir).scripts.prepare).toBe(script) // unchanged
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
// ── Additional edge cases ──────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
it('preserves other package.json fields when adding prepare script', async () => {
|
|
533
|
+
const dir = createTempDir()
|
|
534
|
+
writePkg(dir, {
|
|
535
|
+
version: '2.0.0',
|
|
536
|
+
description: 'test',
|
|
537
|
+
scripts: { test: 'vitest', lint: 'eslint' },
|
|
538
|
+
dependencies: { lodash: '^4.0.0' },
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
const result = await handlePrepare({
|
|
542
|
+
consumerRoot: dir,
|
|
543
|
+
consumerPackageJson: {
|
|
544
|
+
name: 'test-pkg',
|
|
545
|
+
version: '2.0.0',
|
|
546
|
+
description: 'test',
|
|
547
|
+
scripts: { test: 'vitest', lint: 'eslint' },
|
|
548
|
+
dependencies: { lodash: '^4.0.0' },
|
|
549
|
+
},
|
|
550
|
+
existingPrepare: undefined,
|
|
551
|
+
prepareIsSimple: false,
|
|
552
|
+
dryRun: false,
|
|
553
|
+
force: false,
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
expect(result.action).toBe('added')
|
|
557
|
+
const pkg = readPkg(dir)
|
|
558
|
+
expect(pkg.scripts.prepare).toBe('husky')
|
|
559
|
+
expect(pkg.scripts.test).toBe('vitest')
|
|
560
|
+
expect(pkg.scripts.lint).toBe('eslint')
|
|
561
|
+
expect(pkg.version).toBe('2.0.0')
|
|
562
|
+
expect(pkg.dependencies.lodash).toBe('^4.0.0')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('preserves other package.json fields when merging prepare script', async () => {
|
|
566
|
+
const dir = createTempDir()
|
|
567
|
+
writePkg(dir, {
|
|
568
|
+
version: '3.0.0',
|
|
569
|
+
scripts: { prepare: 'npm run build', test: 'vitest' },
|
|
570
|
+
devDependencies: { husky: '^9.0.0' },
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
const result = await handlePrepare({
|
|
574
|
+
consumerRoot: dir,
|
|
575
|
+
consumerPackageJson: {
|
|
576
|
+
name: 'test-pkg',
|
|
577
|
+
version: '3.0.0',
|
|
578
|
+
scripts: { prepare: 'npm run build', test: 'vitest' },
|
|
579
|
+
devDependencies: { husky: '^9.0.0' },
|
|
580
|
+
},
|
|
581
|
+
existingPrepare: 'npm run build',
|
|
582
|
+
prepareIsSimple: true,
|
|
583
|
+
dryRun: false,
|
|
584
|
+
force: false,
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
expect(result.action).toBe('merged')
|
|
588
|
+
const pkg = readPkg(dir)
|
|
589
|
+
expect(pkg.scripts.prepare).toBe('npm run build && husky')
|
|
590
|
+
expect(pkg.scripts.test).toBe('vitest')
|
|
591
|
+
expect(pkg.version).toBe('3.0.0')
|
|
592
|
+
expect(pkg.devDependencies.husky).toBe('^9.0.0')
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it('preserves other package.json fields when force-replacing prepare script', async () => {
|
|
596
|
+
const dir = createTempDir()
|
|
597
|
+
writePkg(dir, {
|
|
598
|
+
scripts: { prepare: 'cmd1 && cmd2 && cmd3', test: 'vitest' },
|
|
599
|
+
keywords: ['test'],
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
const result = await handlePrepare({
|
|
603
|
+
consumerRoot: dir,
|
|
604
|
+
consumerPackageJson: {
|
|
605
|
+
name: 'test-pkg',
|
|
606
|
+
scripts: { prepare: 'cmd1 && cmd2 && cmd3', test: 'vitest' },
|
|
607
|
+
keywords: ['test'],
|
|
608
|
+
},
|
|
609
|
+
existingPrepare: 'cmd1 && cmd2 && cmd3',
|
|
610
|
+
prepareIsSimple: false,
|
|
611
|
+
dryRun: false,
|
|
612
|
+
force: true,
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
expect(result.action).toBe('added')
|
|
616
|
+
const pkg = readPkg(dir)
|
|
617
|
+
expect(pkg.scripts.prepare).toBe('husky')
|
|
618
|
+
expect(pkg.scripts.test).toBe('vitest')
|
|
619
|
+
expect(pkg.keywords).toEqual(['test'])
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('handles prepare script using "husky" in a non-substring context (edge: "npx husky")', async () => {
|
|
623
|
+
// "npx husky" contains "husky" substring → should be skipped by case 2
|
|
624
|
+
const dir = createTempDir()
|
|
625
|
+
const script = 'npx husky'
|
|
626
|
+
writePkg(dir, { scripts: { prepare: script } })
|
|
627
|
+
|
|
628
|
+
const result = await handlePrepare({
|
|
629
|
+
consumerRoot: dir,
|
|
630
|
+
consumerPackageJson: { name: 'test-pkg', scripts: { prepare: script } },
|
|
631
|
+
existingPrepare: script,
|
|
632
|
+
prepareIsSimple: true,
|
|
633
|
+
dryRun: false,
|
|
634
|
+
force: false,
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
expect(result.action).toBe('skipped')
|
|
638
|
+
expect(result.message).toContain('already')
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('result object always contains action and message string fields', async () => {
|
|
642
|
+
const dir = createTempDir()
|
|
643
|
+
writePkg(dir)
|
|
644
|
+
|
|
645
|
+
const scenarios = [
|
|
646
|
+
// No package.json
|
|
647
|
+
await handlePrepare({
|
|
648
|
+
consumerRoot: dir,
|
|
649
|
+
consumerPackageJson: undefined,
|
|
650
|
+
existingPrepare: undefined,
|
|
651
|
+
prepareIsSimple: false,
|
|
652
|
+
dryRun: false,
|
|
653
|
+
force: false,
|
|
654
|
+
}),
|
|
655
|
+
// Already has husky
|
|
656
|
+
await handlePrepare({
|
|
657
|
+
consumerRoot: dir,
|
|
658
|
+
consumerPackageJson: { name: 'test', scripts: { prepare: 'husky' } },
|
|
659
|
+
existingPrepare: 'husky',
|
|
660
|
+
prepareIsSimple: true,
|
|
661
|
+
dryRun: false,
|
|
662
|
+
force: false,
|
|
663
|
+
}),
|
|
664
|
+
// No prepare script
|
|
665
|
+
await handlePrepare({
|
|
666
|
+
consumerRoot: dir,
|
|
667
|
+
consumerPackageJson: { name: 'test' },
|
|
668
|
+
existingPrepare: undefined,
|
|
669
|
+
prepareIsSimple: false,
|
|
670
|
+
dryRun: false,
|
|
671
|
+
force: false,
|
|
672
|
+
}),
|
|
673
|
+
// Simple prepare
|
|
674
|
+
await handlePrepare({
|
|
675
|
+
consumerRoot: dir,
|
|
676
|
+
consumerPackageJson: { name: 'test', scripts: { prepare: 'npm run build' } },
|
|
677
|
+
existingPrepare: 'npm run build',
|
|
678
|
+
prepareIsSimple: true,
|
|
679
|
+
dryRun: false,
|
|
680
|
+
force: false,
|
|
681
|
+
}),
|
|
682
|
+
// Complex prepare - no force
|
|
683
|
+
await handlePrepare({
|
|
684
|
+
consumerRoot: dir,
|
|
685
|
+
consumerPackageJson: { name: 'test', scripts: { prepare: 'cmd1 || cmd2' } },
|
|
686
|
+
existingPrepare: 'cmd1 || cmd2',
|
|
687
|
+
prepareIsSimple: false,
|
|
688
|
+
dryRun: false,
|
|
689
|
+
force: false,
|
|
690
|
+
}),
|
|
691
|
+
// Complex prepare + force
|
|
692
|
+
await handlePrepare({
|
|
693
|
+
consumerRoot: dir,
|
|
694
|
+
consumerPackageJson: { name: 'test', scripts: { prepare: 'cmd1 || cmd2' } },
|
|
695
|
+
existingPrepare: 'cmd1 || cmd2',
|
|
696
|
+
prepareIsSimple: false,
|
|
697
|
+
dryRun: false,
|
|
698
|
+
force: true,
|
|
699
|
+
}),
|
|
700
|
+
// Dry-run
|
|
701
|
+
await handlePrepare({
|
|
702
|
+
consumerRoot: dir,
|
|
703
|
+
consumerPackageJson: { name: 'test', scripts: { prepare: 'npm run build' } },
|
|
704
|
+
existingPrepare: 'npm run build',
|
|
705
|
+
prepareIsSimple: true,
|
|
706
|
+
dryRun: true,
|
|
707
|
+
force: false,
|
|
708
|
+
}),
|
|
709
|
+
]
|
|
710
|
+
|
|
711
|
+
for (const r of scenarios) {
|
|
712
|
+
expect(r).toHaveProperty('action')
|
|
713
|
+
expect(r).toHaveProperty('message')
|
|
714
|
+
expect(typeof r.action).toBe('string')
|
|
715
|
+
expect(typeof r.message).toBe('string')
|
|
716
|
+
}
|
|
717
|
+
})
|
|
718
|
+
})
|