@abarcenas/ai-workflow-template 1.48.0 → 2.0.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/knowledgebase.instructions.md +2 -2
- package/.agents/instructions/learned-knowledge.instructions.md +47 -2
- package/.agents/instructions/memory-schema.instructions.md +1 -1
- package/.github/workflows/npm-publish.yml +69 -1
- package/.opencode/agents/implementer.md +3 -1
- package/.opencode/agents/unit-tester.md +21 -2
- package/.opencode/plugins/graphify.js +22 -0
- package/LICENSE +15 -0
- package/README.md +56 -22
- package/memory-bank/.vocabulary.json +3 -0
- package/opencode.mcp.example.json +13 -0
- package/package.json +20 -10
- package/scripts/bump-version.js +18 -2
- package/scripts/mcp/playwright-mcp-launcher.js +45 -2
- package/scripts/bump-version.test.js +0 -238
- package/scripts/knowledgebase-cli.test.js +0 -576
- package/scripts/knowledgebase-index.test.js +0 -759
- package/scripts/mcp/playwright-mcp-launcher.test.js +0 -217
- package/scripts/mcp-knowledgebase-server.test.js +0 -745
- package/scripts/mcp-memory-server.test.js +0 -398
- package/scripts/memory-cli.test.js +0 -297
- package/scripts/memory-index.test.js +0 -1173
- package/scripts/normalize-memory.test.js +0 -753
- package/scripts/setup/discover.test.js +0 -530
- package/scripts/setup/hooks.test.js +0 -722
- package/scripts/setup/husky-init.test.js +0 -311
- package/scripts/setup/index.test.js +0 -1021
- package/scripts/setup/knowledgebase.test.js +0 -352
- package/scripts/setup/prepare.test.js +0 -718
- package/scripts/setup/skills-phase.test.js +0 -1274
- package/scripts/setup/sync-phase.test.js +0 -373
- package/scripts/setup/ui.test.js +0 -579
- package/scripts/setup/utils.test.js +0 -586
- package/scripts/sync-unit.test.js +0 -1881
- package/scripts/sync.test.js +0 -472
- package/scripts/validate-memory-schema.test.js +0 -328
- package/scripts/vocab-sync.test.js +0 -902
package/scripts/setup/ui.test.js
DELETED
|
@@ -1,579 +0,0 @@
|
|
|
1
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// ui.test.js — Unit tests for the setup output/formatting module
|
|
3
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
-
//
|
|
5
|
-
// ui.js captures `process.stdout.isTTY` at module-evaluation time, so every
|
|
6
|
-
// test loads a fresh module instance after arranging the fake terminal:
|
|
7
|
-
//
|
|
8
|
-
// loadUi({ isTTY, columns }) → vi.resetModules() + dynamic import
|
|
9
|
-
//
|
|
10
|
-
// Output is captured by spying on console.log / console.error (the two writers
|
|
11
|
-
// ui.js uses) so the suite stays silent.
|
|
12
|
-
// ═════════════════════════════════════════════════════════════════════════════
|
|
13
|
-
|
|
14
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
15
|
-
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs'
|
|
16
|
-
import { join } from 'path'
|
|
17
|
-
import { tmpdir } from 'os'
|
|
18
|
-
|
|
19
|
-
import { PACKAGE_NAME, BIN_NAME, UNICODE_CHARS } from './constants.js'
|
|
20
|
-
|
|
21
|
-
// ── Terminal + module loading helpers ──────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')
|
|
24
|
-
const originalColumns = Object.getOwnPropertyDescriptor(process.stdout, 'columns')
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Overrides `process.stdout.isTTY` / `.columns` for the next module import.
|
|
28
|
-
*
|
|
29
|
-
* @param {object} opts
|
|
30
|
-
* @param {boolean} [opts.isTTY=false] - Whether colour codes should be emitted.
|
|
31
|
-
* @param {number|undefined} [opts.columns] - Simulated terminal width.
|
|
32
|
-
*/
|
|
33
|
-
function arrangeTerminal({ isTTY = false, columns } = {}) {
|
|
34
|
-
Object.defineProperty(process.stdout, 'isTTY', {
|
|
35
|
-
value: isTTY,
|
|
36
|
-
configurable: true,
|
|
37
|
-
writable: true,
|
|
38
|
-
})
|
|
39
|
-
Object.defineProperty(process.stdout, 'columns', {
|
|
40
|
-
value: columns,
|
|
41
|
-
configurable: true,
|
|
42
|
-
writable: true,
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Restores the real stdout descriptors between tests. */
|
|
47
|
-
function restoreTerminal() {
|
|
48
|
-
if (originalIsTTY) Object.defineProperty(process.stdout, 'isTTY', originalIsTTY)
|
|
49
|
-
else delete process.stdout.isTTY
|
|
50
|
-
if (originalColumns) Object.defineProperty(process.stdout, 'columns', originalColumns)
|
|
51
|
-
else delete process.stdout.columns
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Imports a fresh copy of ui.js with the requested fake terminal attached.
|
|
56
|
-
*
|
|
57
|
-
* @param {object} [terminal] - See arrangeTerminal().
|
|
58
|
-
* @returns {Promise<typeof import('./ui.js')>} Fresh module instance.
|
|
59
|
-
*/
|
|
60
|
-
async function loadUi(terminal = {}) {
|
|
61
|
-
arrangeTerminal(terminal)
|
|
62
|
-
vi.resetModules()
|
|
63
|
-
return import('./ui.js')
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const tempDirs = new Set()
|
|
67
|
-
|
|
68
|
-
/** Creates a temp dir containing the given package.json object. */
|
|
69
|
-
function packageDir(pkg) {
|
|
70
|
-
const dir = mkdtempSync(join(tmpdir(), 'ui-test-'))
|
|
71
|
-
tempDirs.add(dir)
|
|
72
|
-
writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg), 'utf-8')
|
|
73
|
-
return dir
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── Spies ──────────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
let logSpy
|
|
79
|
-
let errSpy
|
|
80
|
-
|
|
81
|
-
/** All lines written to stdout by the module under test. */
|
|
82
|
-
const out = () => logSpy.mock.calls.map((c) => c[0])
|
|
83
|
-
/** All lines written to stderr by the module under test. */
|
|
84
|
-
const err = () => errSpy.mock.calls.map((c) => c[0])
|
|
85
|
-
|
|
86
|
-
beforeEach(() => {
|
|
87
|
-
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
88
|
-
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
afterEach(() => {
|
|
92
|
-
logSpy.mockRestore()
|
|
93
|
-
errSpy.mockRestore()
|
|
94
|
-
restoreTerminal()
|
|
95
|
-
vi.doUnmock('./utils.js')
|
|
96
|
-
vi.resetModules()
|
|
97
|
-
for (const dir of tempDirs) {
|
|
98
|
-
try {
|
|
99
|
-
rmSync(dir, { recursive: true, force: true })
|
|
100
|
-
} catch {
|
|
101
|
-
/* best-effort cleanup */
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
tempDirs.clear()
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
// ── Colour support ─────────────────────────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
describe('COLORS / ANSI support', () => {
|
|
110
|
-
it('exposes empty escape strings when stdout is not a TTY', async () => {
|
|
111
|
-
const ui = await loadUi({ isTTY: false })
|
|
112
|
-
|
|
113
|
-
expect(ui.COLORS).toEqual({
|
|
114
|
-
reset: '',
|
|
115
|
-
bold: '',
|
|
116
|
-
dim: '',
|
|
117
|
-
green: '',
|
|
118
|
-
yellow: '',
|
|
119
|
-
red: '',
|
|
120
|
-
cyan: '',
|
|
121
|
-
})
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('exposes ANSI SGR sequences when stdout is a TTY', async () => {
|
|
125
|
-
const ui = await loadUi({ isTTY: true })
|
|
126
|
-
|
|
127
|
-
expect(ui.COLORS.bold).toBe('\x1b[1m')
|
|
128
|
-
expect(ui.COLORS.dim).toBe('\x1b[2m')
|
|
129
|
-
expect(ui.COLORS.green).toBe('\x1b[32m')
|
|
130
|
-
expect(ui.COLORS.yellow).toBe('\x1b[33m')
|
|
131
|
-
expect(ui.COLORS.red).toBe('\x1b[31m')
|
|
132
|
-
expect(ui.COLORS.cyan).toBe('\x1b[36m')
|
|
133
|
-
expect(ui.COLORS.reset).toBe('\x1b[0m')
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
it('freezes the palette so consumers cannot mutate shared output state', async () => {
|
|
137
|
-
const ui = await loadUi({ isTTY: true })
|
|
138
|
-
|
|
139
|
-
expect(Object.isFrozen(ui.COLORS)).toBe(true)
|
|
140
|
-
})
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
// ── Basic line writers ─────────────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
describe('line writers (no colour)', () => {
|
|
146
|
-
it('step() writes an arrow-prefixed line', async () => {
|
|
147
|
-
const ui = await loadUi()
|
|
148
|
-
ui.step('Checking environment…')
|
|
149
|
-
|
|
150
|
-
expect(out()).toEqual([` ${UNICODE_CHARS.ARROW} Checking environment…`])
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
it('stepSuccess() writes a check-prefixed line', async () => {
|
|
154
|
-
const ui = await loadUi()
|
|
155
|
-
ui.stepSuccess('Environment ready')
|
|
156
|
-
|
|
157
|
-
expect(out()).toEqual([` ${UNICODE_CHARS.CHECK} Environment ready`])
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('stepWarn() writes a warning-prefixed line', async () => {
|
|
161
|
-
const ui = await loadUi()
|
|
162
|
-
ui.stepWarn('Husky is newer than expected')
|
|
163
|
-
|
|
164
|
-
expect(out()).toEqual([` ${UNICODE_CHARS.WARN} Husky is newer than expected`])
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
it('stepError() writes a cross-prefixed line', async () => {
|
|
168
|
-
const ui = await loadUi()
|
|
169
|
-
ui.stepError('Git repository not found')
|
|
170
|
-
|
|
171
|
-
expect(out()).toEqual([` ${UNICODE_CHARS.CROSS} Git repository not found`])
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
it('success() writes a check-prefixed line', async () => {
|
|
175
|
-
const ui = await loadUi()
|
|
176
|
-
ui.success('Done')
|
|
177
|
-
|
|
178
|
-
expect(out()).toEqual([`${UNICODE_CHARS.CHECK} Done`])
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('section() writes the title unchanged', async () => {
|
|
182
|
-
const ui = await loadUi()
|
|
183
|
-
ui.section('Environment Discovery')
|
|
184
|
-
|
|
185
|
-
expect(out()).toEqual(['Environment Discovery'])
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
it('info() writes the message unchanged', async () => {
|
|
189
|
-
const ui = await loadUi()
|
|
190
|
-
ui.info('plain information')
|
|
191
|
-
|
|
192
|
-
expect(out()).toEqual(['plain information'])
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
it('warn() writes to stderr with a namespace prefix', async () => {
|
|
196
|
-
const ui = await loadUi()
|
|
197
|
-
ui.warn('hooks were not installed')
|
|
198
|
-
|
|
199
|
-
expect(out()).toEqual([])
|
|
200
|
-
expect(err()).toEqual(['warning hooks were not installed'])
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
it('error() writes to stderr with a namespace prefix', async () => {
|
|
204
|
-
const ui = await loadUi()
|
|
205
|
-
ui.error('permission denied')
|
|
206
|
-
|
|
207
|
-
expect(out()).toEqual([])
|
|
208
|
-
expect(err()).toEqual(['error permission denied'])
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
it('verbose() is a no-op when disabled', async () => {
|
|
212
|
-
const ui = await loadUi()
|
|
213
|
-
|
|
214
|
-
ui.verbose(false, 'hidden')
|
|
215
|
-
ui.verbose(0, 'hidden')
|
|
216
|
-
ui.verbose(undefined, 'hidden')
|
|
217
|
-
|
|
218
|
-
expect(out()).toEqual([])
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
it('verbose() writes a dimmed ellipsis line when enabled', async () => {
|
|
222
|
-
const ui = await loadUi()
|
|
223
|
-
ui.verbose(true, 'Resolving consumer root…')
|
|
224
|
-
|
|
225
|
-
expect(out()).toEqual([' … Resolving consumer root…'])
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
it('verbose() emits ANSI dim codes when colour is on', async () => {
|
|
229
|
-
const ui = await loadUi({ isTTY: true })
|
|
230
|
-
ui.verbose(1, 'spawning')
|
|
231
|
-
|
|
232
|
-
expect(out()).toEqual([' \x1b[2m… spawning\x1b[0m'])
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
it('verbose() accepts any truthy value, not just booleans', async () => {
|
|
236
|
-
const ui = await loadUi()
|
|
237
|
-
ui.verbose('yes', 'truthy string')
|
|
238
|
-
|
|
239
|
-
expect(out()).toEqual([' … truthy string'])
|
|
240
|
-
})
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
// ── header / divider ───────────────────────────────────────────────────────
|
|
244
|
-
|
|
245
|
-
describe('header()', () => {
|
|
246
|
-
it('prints a banner with package name, version and setup label', async () => {
|
|
247
|
-
const pkg = JSON.parse(
|
|
248
|
-
readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'),
|
|
249
|
-
)
|
|
250
|
-
const ui = await loadUi()
|
|
251
|
-
ui.header()
|
|
252
|
-
|
|
253
|
-
// write() with no arguments is a no-op, so only three lines are emitted.
|
|
254
|
-
const lines = out()
|
|
255
|
-
expect(lines).toHaveLength(3)
|
|
256
|
-
expect(lines[0]).toBe('─'.repeat(72))
|
|
257
|
-
expect(lines[1]).toContain(PACKAGE_NAME)
|
|
258
|
-
expect(lines[1]).toContain(`v${pkg.version}`)
|
|
259
|
-
expect(lines[1]).toContain('Setup')
|
|
260
|
-
expect(lines[2]).toBe('─'.repeat(72))
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
it('respects a narrower terminal width', async () => {
|
|
264
|
-
const ui = await loadUi({ columns: 40 })
|
|
265
|
-
ui.header()
|
|
266
|
-
|
|
267
|
-
expect(out()[0]).toBe('─'.repeat(40))
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
it('caps the rule at 72 columns on very wide terminals', async () => {
|
|
271
|
-
const ui = await loadUi({ columns: 200 })
|
|
272
|
-
ui.header()
|
|
273
|
-
|
|
274
|
-
expect(out()[0]).toBe('─'.repeat(72))
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
it('emits colour codes around the title when stdout is a TTY', async () => {
|
|
278
|
-
const ui = await loadUi({ isTTY: true })
|
|
279
|
-
ui.header()
|
|
280
|
-
|
|
281
|
-
const title = out()[1]
|
|
282
|
-
expect(title).toContain('\x1b[1m')
|
|
283
|
-
expect(title).toContain('\x1b[32m')
|
|
284
|
-
expect(title.endsWith('\x1b[0m')).toBe(true)
|
|
285
|
-
})
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
describe('divider()', () => {
|
|
289
|
-
it('prints a dimmed rule', async () => {
|
|
290
|
-
const ui = await loadUi()
|
|
291
|
-
ui.divider()
|
|
292
|
-
|
|
293
|
-
expect(out()).toEqual([` ${'─'.repeat(72)}`])
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
it('honours the terminal width and colour', async () => {
|
|
297
|
-
const ui = await loadUi({ isTTY: true, columns: 20 })
|
|
298
|
-
ui.divider()
|
|
299
|
-
|
|
300
|
-
expect(out()).toEqual([` \x1b[2m${'─'.repeat(20)}\x1b[0m`])
|
|
301
|
-
})
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
// ── Banners ────────────────────────────────────────────────────────────────
|
|
305
|
-
|
|
306
|
-
describe('dryRunBanner()', () => {
|
|
307
|
-
it('prints a bordered dry-run warning', async () => {
|
|
308
|
-
const ui = await loadUi({ columns: 10 })
|
|
309
|
-
ui.dryRunBanner()
|
|
310
|
-
|
|
311
|
-
const lines = out()
|
|
312
|
-
expect(lines).toHaveLength(3)
|
|
313
|
-
expect(lines[0]).toBe('─'.repeat(10))
|
|
314
|
-
expect(lines[1]).toContain('DRY RUN MODE')
|
|
315
|
-
expect(lines[1]).toContain('No files will be modified.')
|
|
316
|
-
expect(lines[2]).toBe('─'.repeat(10))
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
it('uses yellow escapes around the rule in colour mode', async () => {
|
|
320
|
-
const ui = await loadUi({ isTTY: true, columns: 8 })
|
|
321
|
-
ui.dryRunBanner()
|
|
322
|
-
|
|
323
|
-
expect(out()[0]).toBe(`\x1b[33m${'─'.repeat(8)}\x1b[0m`)
|
|
324
|
-
expect(out()[1]).toContain('\x1b[33m')
|
|
325
|
-
})
|
|
326
|
-
|
|
327
|
-
it('falls back to 72 columns when the terminal reports no width', async () => {
|
|
328
|
-
const ui = await loadUi()
|
|
329
|
-
ui.dryRunBanner()
|
|
330
|
-
|
|
331
|
-
expect(out()[0]).toBe('─'.repeat(72))
|
|
332
|
-
})
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
describe('ciModeBanner()', () => {
|
|
336
|
-
it('prints a bordered CI-mode notice', async () => {
|
|
337
|
-
const ui = await loadUi({ columns: 12 })
|
|
338
|
-
ui.ciModeBanner()
|
|
339
|
-
|
|
340
|
-
const lines = out()
|
|
341
|
-
expect(lines).toHaveLength(3)
|
|
342
|
-
expect(lines[0]).toBe('─'.repeat(12))
|
|
343
|
-
expect(lines[1]).toContain('CI MODE DETECTED')
|
|
344
|
-
expect(lines[1]).toContain('Git hooks will be skipped.')
|
|
345
|
-
expect(lines[1]).toContain('--skip-hooks')
|
|
346
|
-
expect(lines[2]).toBe('─'.repeat(12))
|
|
347
|
-
})
|
|
348
|
-
|
|
349
|
-
it('uses cyan escapes around the rule in colour mode', async () => {
|
|
350
|
-
const ui = await loadUi({ isTTY: true, columns: 6 })
|
|
351
|
-
ui.ciModeBanner()
|
|
352
|
-
|
|
353
|
-
expect(out()[0]).toBe(`\x1b[36m${'─'.repeat(6)}\x1b[0m`)
|
|
354
|
-
expect(out()[1]).toContain(UNICODE_CHARS.BULLET)
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
it('falls back to 72 columns when the terminal reports no width', async () => {
|
|
358
|
-
const ui = await loadUi()
|
|
359
|
-
ui.ciModeBanner()
|
|
360
|
-
|
|
361
|
-
expect(out()[0]).toBe('─'.repeat(72))
|
|
362
|
-
})
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
// ── help() ─────────────────────────────────────────────────────────────────
|
|
366
|
-
|
|
367
|
-
describe('help()', () => {
|
|
368
|
-
it('prints usage, description, options and examples', async () => {
|
|
369
|
-
const ui = await loadUi()
|
|
370
|
-
ui.help()
|
|
371
|
-
|
|
372
|
-
const text = out().join('\n')
|
|
373
|
-
expect(text).toContain(`${PACKAGE_NAME}`)
|
|
374
|
-
expect(text).toContain('Usage:')
|
|
375
|
-
expect(text).toContain(`npx ${PACKAGE_NAME} [options]`)
|
|
376
|
-
expect(text).toContain(`${BIN_NAME} [options]`)
|
|
377
|
-
expect(text).toContain('Description:')
|
|
378
|
-
expect(text).toContain('Options:')
|
|
379
|
-
expect(text).toContain('Examples:')
|
|
380
|
-
expect(text).toContain(`${BIN_NAME} --help`)
|
|
381
|
-
})
|
|
382
|
-
|
|
383
|
-
it('documents every canonical flag with its description', async () => {
|
|
384
|
-
const ui = await loadUi()
|
|
385
|
-
ui.help()
|
|
386
|
-
|
|
387
|
-
const text = out().join('\n')
|
|
388
|
-
const expected = [
|
|
389
|
-
['--force', 'Overwrite existing hooks'],
|
|
390
|
-
['--dry-run', 'Show what would be done'],
|
|
391
|
-
['--yes', 'Skip confirmation prompts'],
|
|
392
|
-
['--skip-hooks', 'Skip git hook installation phase'],
|
|
393
|
-
['--skip-prepare', 'Skip prepare script modification phase'],
|
|
394
|
-
['--skip-sync', 'Skip file sync phase'],
|
|
395
|
-
['--skip-knowledgebase', 'Skip knowledgebase registration phase'],
|
|
396
|
-
['--knowledgebase', 'Run ONLY the knowledgebase registration phase'],
|
|
397
|
-
['--help', 'Show this help message and exit'],
|
|
398
|
-
['--version', 'Show the version number and exit'],
|
|
399
|
-
['--quiet', 'Suppress non-error output'],
|
|
400
|
-
['--verbose', 'Show detailed debug information'],
|
|
401
|
-
]
|
|
402
|
-
for (const [flag, description] of expected) {
|
|
403
|
-
expect(text).toContain(flag)
|
|
404
|
-
expect(text).toContain(description)
|
|
405
|
-
}
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
it('lists short flags before their long equivalents', async () => {
|
|
409
|
-
const ui = await loadUi()
|
|
410
|
-
ui.help()
|
|
411
|
-
|
|
412
|
-
const text = out().join('\n')
|
|
413
|
-
expect(text).toContain('-y, --yes')
|
|
414
|
-
expect(text).toContain('-h, --help')
|
|
415
|
-
expect(text).toContain('-v, --version')
|
|
416
|
-
expect(text).toContain('-q, --quiet')
|
|
417
|
-
expect(text).toContain('-V, --verbose')
|
|
418
|
-
expect(text).toContain('--force')
|
|
419
|
-
})
|
|
420
|
-
|
|
421
|
-
it('aligns descriptions to the widest flag group', async () => {
|
|
422
|
-
const ui = await loadUi()
|
|
423
|
-
ui.help()
|
|
424
|
-
|
|
425
|
-
const optionLines = out().filter((l) => l.startsWith(' -'))
|
|
426
|
-
expect(optionLines).toHaveLength(12)
|
|
427
|
-
// 4-space indent + widest flag group (20) + 2-space gap = column 26
|
|
428
|
-
for (const line of optionLines) {
|
|
429
|
-
expect(line[26]).not.toBe(' ')
|
|
430
|
-
expect(line.slice(4, 26).trimEnd()).toBe(line.slice(4, 26).trimEnd().trimStart())
|
|
431
|
-
}
|
|
432
|
-
})
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
// ── summary() ──────────────────────────────────────────────────────────────
|
|
436
|
-
|
|
437
|
-
describe('summary()', () => {
|
|
438
|
-
it('prints nothing for an empty result list', async () => {
|
|
439
|
-
const ui = await loadUi()
|
|
440
|
-
|
|
441
|
-
ui.summary([])
|
|
442
|
-
ui.summary(undefined)
|
|
443
|
-
|
|
444
|
-
expect(out()).toEqual([])
|
|
445
|
-
expect(err()).toEqual([])
|
|
446
|
-
})
|
|
447
|
-
|
|
448
|
-
it('renders one row per phase with aligned columns', async () => {
|
|
449
|
-
const ui = await loadUi()
|
|
450
|
-
ui.summary([
|
|
451
|
-
{ phase: 'discover', status: 'success', message: 'ok' },
|
|
452
|
-
{ phase: 'hooks', status: 'warn', message: 'skipped' },
|
|
453
|
-
])
|
|
454
|
-
|
|
455
|
-
const lines = out()
|
|
456
|
-
expect(lines[0]).toBe('Setup Summary')
|
|
457
|
-
expect(lines[1]).toBe(` ${'─'.repeat(72)}`)
|
|
458
|
-
expect(lines[2]).toBe(' Phase Status Message')
|
|
459
|
-
expect(lines[4]).toBe(' discover ✓ success ok')
|
|
460
|
-
expect(lines[5]).toBe(' hooks ⚠ warn skipped')
|
|
461
|
-
expect(err()).toEqual(['warning Setup completed with warnings.'])
|
|
462
|
-
})
|
|
463
|
-
|
|
464
|
-
it('renders success status with a check symbol', async () => {
|
|
465
|
-
const ui = await loadUi()
|
|
466
|
-
ui.summary([{ phase: 'sync', status: 'success', message: 'done' }])
|
|
467
|
-
|
|
468
|
-
expect(out().some((l) => l.includes(`${UNICODE_CHARS.CHECK} success`))).toBe(true)
|
|
469
|
-
})
|
|
470
|
-
|
|
471
|
-
it('renders error status with a cross symbol', async () => {
|
|
472
|
-
const ui = await loadUi()
|
|
473
|
-
ui.summary([{ phase: 'hooks', status: 'error', message: 'denied' }])
|
|
474
|
-
|
|
475
|
-
expect(out().some((l) => l.includes(`${UNICODE_CHARS.CROSS} error`))).toBe(true)
|
|
476
|
-
expect(err()).toEqual(['error Setup completed with errors.'])
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
it('passes custom status strings through unchanged', async () => {
|
|
480
|
-
const ui = await loadUi()
|
|
481
|
-
ui.summary([{ phase: 'custom', status: 'pending-review', message: 'later' }])
|
|
482
|
-
|
|
483
|
-
expect(out().some((l) => l.includes('pending-review'))).toBe(true)
|
|
484
|
-
})
|
|
485
|
-
|
|
486
|
-
it('treats a missing message as an empty string', async () => {
|
|
487
|
-
const ui = await loadUi()
|
|
488
|
-
ui.summary([
|
|
489
|
-
{ phase: 'sync', status: 'success' },
|
|
490
|
-
{ phase: 'hooks', status: 'success', message: null },
|
|
491
|
-
])
|
|
492
|
-
|
|
493
|
-
// colMsg falls back to the width of the "Message" header (7)
|
|
494
|
-
expect(out()[2]).toBe(' Phase Status Message')
|
|
495
|
-
})
|
|
496
|
-
|
|
497
|
-
it('reports errors as fatal even when warnings are also present', async () => {
|
|
498
|
-
const ui = await loadUi()
|
|
499
|
-
ui.summary([
|
|
500
|
-
{ phase: 'a', status: 'warn', message: 'w' },
|
|
501
|
-
{ phase: 'b', status: 'error', message: 'e' },
|
|
502
|
-
])
|
|
503
|
-
|
|
504
|
-
expect(err()).toEqual(['error Setup completed with errors.'])
|
|
505
|
-
expect(out().every((l) => !l.includes('completed with warnings'))).toBe(true)
|
|
506
|
-
})
|
|
507
|
-
|
|
508
|
-
it('uses ANSI-aware widths when colour output is enabled', async () => {
|
|
509
|
-
const ui = await loadUi({ isTTY: true })
|
|
510
|
-
ui.summary([{ phase: 'hooks', status: 'success', message: 'ok' }])
|
|
511
|
-
|
|
512
|
-
const lines = out()
|
|
513
|
-
// The coloured status wrapper is 19 chars; statusAnsiExtra keeps the
|
|
514
|
-
// header and rows aligned with a 10-char pad.
|
|
515
|
-
expect(lines[2]).toContain('Phase')
|
|
516
|
-
expect(lines[4]).toContain('\x1b[32m')
|
|
517
|
-
expect(lines[4]).toContain('success')
|
|
518
|
-
expect(err()).toEqual([])
|
|
519
|
-
})
|
|
520
|
-
|
|
521
|
-
it('keeps the status column wide enough for the header on short statuses', async () => {
|
|
522
|
-
const ui = await loadUi()
|
|
523
|
-
ui.summary([{ phase: 'x', status: 'e', message: 'm' }])
|
|
524
|
-
|
|
525
|
-
// Status header is 6 chars → separator uses max(colStatus, 6)
|
|
526
|
-
const separator = out()[3]
|
|
527
|
-
expect(separator).toContain('─'.repeat(6))
|
|
528
|
-
})
|
|
529
|
-
|
|
530
|
-
it('accounts for wide unicode messages when sizing the message column', async () => {
|
|
531
|
-
const ui = await loadUi()
|
|
532
|
-
ui.summary([{ phase: 'utf', status: 'success', message: '✓⚠→•✓' }])
|
|
533
|
-
|
|
534
|
-
// Message header (7) is narrower than the 5-symbol message? no — 7 wins;
|
|
535
|
-
// the row must still contain the untouched message.
|
|
536
|
-
expect(out().some((l) => l.includes('✓⚠→•✓'))).toBe(true)
|
|
537
|
-
})
|
|
538
|
-
})
|
|
539
|
-
|
|
540
|
-
// ── Version resolution ─────────────────────────────────────────────────────
|
|
541
|
-
|
|
542
|
-
describe('version resolution', () => {
|
|
543
|
-
it('falls back to 0.0.0 when package.json cannot be read', async () => {
|
|
544
|
-
vi.resetModules()
|
|
545
|
-
vi.doMock('./utils.js', () => ({
|
|
546
|
-
resolvePackageRoot: () => '/definitely/not/a/real/package/root',
|
|
547
|
-
}))
|
|
548
|
-
|
|
549
|
-
const ui = await import('./ui.js')
|
|
550
|
-
ui.header()
|
|
551
|
-
|
|
552
|
-
expect(out().join('\n')).toContain('v0.0.0')
|
|
553
|
-
})
|
|
554
|
-
|
|
555
|
-
it('falls back to 0.0.0 when the version field is missing', async () => {
|
|
556
|
-
const dir = packageDir({ name: 'no-version-here' })
|
|
557
|
-
vi.resetModules()
|
|
558
|
-
vi.doMock('./utils.js', () => ({
|
|
559
|
-
resolvePackageRoot: () => dir,
|
|
560
|
-
}))
|
|
561
|
-
|
|
562
|
-
const ui = await import('./ui.js')
|
|
563
|
-
ui.header()
|
|
564
|
-
|
|
565
|
-
expect(out().join('\n')).toContain('v0.0.0')
|
|
566
|
-
})
|
|
567
|
-
|
|
568
|
-
it('caches the version across header() and help() calls', async () => {
|
|
569
|
-
const ui = await loadUi()
|
|
570
|
-
ui.header()
|
|
571
|
-
ui.help()
|
|
572
|
-
|
|
573
|
-
const versions = out()
|
|
574
|
-
.filter((l) => l.includes('v'))
|
|
575
|
-
.map((l) => (l.match(/v(\d+\.\d+\.\d+)/) || [])[1])
|
|
576
|
-
.filter(Boolean)
|
|
577
|
-
expect(new Set(versions).size).toBe(1)
|
|
578
|
-
})
|
|
579
|
-
})
|