@abarcenas/ai-workflow-template 1.45.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/learned-knowledge.instructions.md +110 -0
- package/.agents/instructions/memory-bank.instructions.md +11 -0
- 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/orchestrator/orchestrator.agent.md +68 -3
- package/.opencode/agents/scope.agent.md +299 -0
- package/AGENTS.md +42 -3
- package/README.md +5 -2
- package/package.json +1 -1
- 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,1274 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// skills-phase.test.js — Unit tests for the pinned-skills setup phase
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Tests runSkillsPhase() from skills-phase.js with:
|
|
6
|
+
// - real temp directories for the consumer project and the package root
|
|
7
|
+
// - an injected fake `fetch` (no network access)
|
|
8
|
+
// - mocked ui helpers (no console output)
|
|
9
|
+
//
|
|
10
|
+
// Covered scenarios:
|
|
11
|
+
// 1. Happy path — fetch N files, verify hashes, write them
|
|
12
|
+
// 2. Default fetch / no AbortSignal.timeout support
|
|
13
|
+
// 3. Non-200 response → fallback, one notice (TEST-07)
|
|
14
|
+
// 4. Null response / missing status → fallback
|
|
15
|
+
// 5. Network rejection → fallback for all remaining skills (TEST-07)
|
|
16
|
+
// 6. Timeout → fallback naming "timed out"
|
|
17
|
+
// 7. Non-Error rejection / undefined rejection → fallback
|
|
18
|
+
// 8. Upstream hash mismatch → fallback (TEST-07)
|
|
19
|
+
// 9. Local edit → warning naming the file, file overwritten
|
|
20
|
+
// 10. No pinned entries → no-op, zero fetch calls
|
|
21
|
+
// 11. Unusable lock shapes → no-op
|
|
22
|
+
// 12. --dry-run → zero writes, zero fetch calls
|
|
23
|
+
// 13. --dry-run with local edit → warning, file untouched
|
|
24
|
+
// 14. Destination already matching → no fetch, no write
|
|
25
|
+
// 15. Mixed upstream + fallback skills
|
|
26
|
+
// 16. --skip-sync flag → skipped (no fetch)
|
|
27
|
+
// 17. Missing consumer root → no-op
|
|
28
|
+
// 18. Path traversal → refused
|
|
29
|
+
// 19. Fallback: missing shipped copy / shipped hash mismatch
|
|
30
|
+
// 20. Default upstream source
|
|
31
|
+
// 21. Unreadable response body → fallback
|
|
32
|
+
// 22. Fallback is all-or-nothing (missing/unreadable/unwritable shipped copies)
|
|
33
|
+
// 23. Malformed lock file records ignored
|
|
34
|
+
// 24. Fallback overwrites local edits with a warning
|
|
35
|
+
// 25. index.js wiring: runs / skipped by --skip-sync / errors contained
|
|
36
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
39
|
+
import { createHash } from 'crypto'
|
|
40
|
+
import {
|
|
41
|
+
existsSync,
|
|
42
|
+
mkdirSync,
|
|
43
|
+
mkdtempSync,
|
|
44
|
+
readFileSync,
|
|
45
|
+
realpathSync,
|
|
46
|
+
rmSync,
|
|
47
|
+
writeFileSync,
|
|
48
|
+
} from 'fs'
|
|
49
|
+
import { dirname, join } from 'path'
|
|
50
|
+
import { tmpdir } from 'os'
|
|
51
|
+
|
|
52
|
+
// ── Mocks ────────────────────────────────────────────────────────────────
|
|
53
|
+
//
|
|
54
|
+
// index.js imports every phase module; they are mocked here so the wiring
|
|
55
|
+
// tests never touch the filesystem. The skills module is wrapped (not
|
|
56
|
+
// replaced) so the unit tests below still exercise the real implementation
|
|
57
|
+
// while the wiring tests can assert whether it was invoked.
|
|
58
|
+
|
|
59
|
+
vi.mock('./ui.js', () => ({
|
|
60
|
+
ciModeBanner: vi.fn(),
|
|
61
|
+
header: vi.fn(),
|
|
62
|
+
help: vi.fn(),
|
|
63
|
+
section: vi.fn(),
|
|
64
|
+
step: vi.fn(),
|
|
65
|
+
stepError: vi.fn(),
|
|
66
|
+
stepSuccess: vi.fn(),
|
|
67
|
+
stepWarn: vi.fn(),
|
|
68
|
+
summary: vi.fn(),
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
vi.mock('./utils.js', () => ({
|
|
72
|
+
parseCliArgs: vi.fn(),
|
|
73
|
+
resolvePackageRoot: vi.fn(),
|
|
74
|
+
}))
|
|
75
|
+
|
|
76
|
+
vi.mock('./discover.js', () => ({ discover: vi.fn() }))
|
|
77
|
+
vi.mock('./hooks.js', () => ({ installHooks: vi.fn() }))
|
|
78
|
+
vi.mock('./prepare.js', () => ({ handlePrepare: vi.fn() }))
|
|
79
|
+
vi.mock('./husky-init.js', () => ({ initHusky: vi.fn() }))
|
|
80
|
+
vi.mock('./sync-phase.js', () => ({ runSyncPhase: vi.fn() }))
|
|
81
|
+
vi.mock('./knowledgebase.js', () => ({ registerKnowledgebase: vi.fn() }))
|
|
82
|
+
|
|
83
|
+
vi.mock('./skills-phase.js', async (importOriginal) => {
|
|
84
|
+
const actual = await importOriginal()
|
|
85
|
+
return { ...actual, runSkillsPhase: vi.fn(actual.runSkillsPhase) }
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// ── Imports (resolved to the mocks above) ────────────────────────────────
|
|
89
|
+
|
|
90
|
+
import { runSkillsPhase } from './skills-phase.js'
|
|
91
|
+
import { main } from './index.js'
|
|
92
|
+
import { stepSuccess, stepWarn } from './ui.js'
|
|
93
|
+
import { parseCliArgs } from './utils.js'
|
|
94
|
+
import { discover } from './discover.js'
|
|
95
|
+
import { installHooks } from './hooks.js'
|
|
96
|
+
import { handlePrepare } from './prepare.js'
|
|
97
|
+
import { initHusky } from './husky-init.js'
|
|
98
|
+
import { runSyncPhase } from './sync-phase.js'
|
|
99
|
+
import { registerKnowledgebase } from './knowledgebase.js'
|
|
100
|
+
import { EXIT_CODES } from './constants.js'
|
|
101
|
+
|
|
102
|
+
// ── Fixtures ─────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const tempDirs = new Set()
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Creates and registers a temporary directory for a single test case.
|
|
108
|
+
*
|
|
109
|
+
* @returns {string} Absolute path to the new temp directory.
|
|
110
|
+
*/
|
|
111
|
+
function createTempDir() {
|
|
112
|
+
const dir = mkdtempSync(join(realpathSync(tmpdir()), 'skills-phase-test-'))
|
|
113
|
+
tempDirs.add(dir)
|
|
114
|
+
return dir
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Removes every temp directory created during a test. */
|
|
118
|
+
function cleanupTempDirs() {
|
|
119
|
+
for (const dir of tempDirs) {
|
|
120
|
+
try {
|
|
121
|
+
rmSync(dir, { recursive: true, force: true })
|
|
122
|
+
} catch {
|
|
123
|
+
/* best-effort cleanup */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
tempDirs.clear()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Computes the SHA-256 hex digest of a string.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} content - Content to hash.
|
|
133
|
+
* @returns {string} Hex digest.
|
|
134
|
+
*/
|
|
135
|
+
function sha(content) {
|
|
136
|
+
return createHash('sha256').update(Buffer.from(content)).digest('hex')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Writes a list of `{ path, content }` files beneath a root directory.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} root - Base directory.
|
|
143
|
+
* @param {Array<{ path: string, content: string }>} files - Files to write.
|
|
144
|
+
*/
|
|
145
|
+
function writeFiles(root, files) {
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const filePath = join(root, file.path)
|
|
148
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
149
|
+
writeFileSync(filePath, file.content)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Builds the pinned raw URL used by the phase.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} name - Skill name.
|
|
157
|
+
* @param {string} filePath - File path inside the skill.
|
|
158
|
+
* @param {object} [spec] - Skill spec (may override ref/source/upstreamPath).
|
|
159
|
+
* @returns {string} Raw GitHub URL.
|
|
160
|
+
*/
|
|
161
|
+
function urlFor(name, filePath, spec = {}) {
|
|
162
|
+
const source = spec.source ?? 'mattpocock/skills'
|
|
163
|
+
const ref = spec.ref ?? 'v1.3.1'
|
|
164
|
+
const upstreamPath =
|
|
165
|
+
spec.upstreamPath ?? `skills/engineering/${name}/${filePath}`
|
|
166
|
+
return `https://raw.githubusercontent.com/${source}/${ref}/${upstreamPath}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Builds a package root containing `skills-lock.json` and the shipped copies.
|
|
171
|
+
*
|
|
172
|
+
* A spec with `pinned: false` produces a legacy entry (no `ref`/`files`).
|
|
173
|
+
* `shipped` overrides the contents of the package's copy for fallback tests.
|
|
174
|
+
*
|
|
175
|
+
* @param {Array<object>} specs - Skill specs.
|
|
176
|
+
* @returns {{ pkgRoot: string, consumerRoot: string, routes: object }}
|
|
177
|
+
*/
|
|
178
|
+
function workspace(specs) {
|
|
179
|
+
const root = createTempDir()
|
|
180
|
+
const pkgRoot = join(root, 'package')
|
|
181
|
+
const consumerRoot = join(root, 'consumer')
|
|
182
|
+
mkdirSync(pkgRoot, { recursive: true })
|
|
183
|
+
mkdirSync(consumerRoot, { recursive: true })
|
|
184
|
+
|
|
185
|
+
const skills = {}
|
|
186
|
+
const routes = {}
|
|
187
|
+
|
|
188
|
+
for (const spec of specs) {
|
|
189
|
+
if (!spec.pinned) {
|
|
190
|
+
skills[spec.name] = {
|
|
191
|
+
source: 'JuliusBrussee/caveman',
|
|
192
|
+
sourceType: 'github',
|
|
193
|
+
skillPath: `${spec.name}/SKILL.md`,
|
|
194
|
+
computedHash: 'legacy-hash',
|
|
195
|
+
}
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const files = spec.files.map((file) => ({
|
|
200
|
+
path: file.path,
|
|
201
|
+
sha256: file.sha256 ?? sha(file.content),
|
|
202
|
+
upstreamPath:
|
|
203
|
+
file.upstreamPath ?? `skills/engineering/${spec.name}/${file.path}`,
|
|
204
|
+
}))
|
|
205
|
+
|
|
206
|
+
skills[spec.name] = {
|
|
207
|
+
sourceType: 'github',
|
|
208
|
+
skillPath: `skills/engineering/${spec.name}/SKILL.md`,
|
|
209
|
+
ref: spec.ref ?? 'v1.3.1',
|
|
210
|
+
computedHash: files[0].sha256,
|
|
211
|
+
files,
|
|
212
|
+
}
|
|
213
|
+
if (spec.source !== null) {
|
|
214
|
+
skills[spec.name].source = spec.source ?? 'mattpocock/skills'
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
writeFiles(
|
|
218
|
+
join(pkgRoot, '.agents', 'skills', spec.name),
|
|
219
|
+
spec.shipped ?? spec.files,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
for (const file of spec.files) {
|
|
223
|
+
routes[urlFor(spec.name, file.path, spec)] = { content: file.content }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
writeFileSync(
|
|
228
|
+
join(pkgRoot, 'skills-lock.json'),
|
|
229
|
+
JSON.stringify({ version: 1, skills }, null, 2),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return { pkgRoot, consumerRoot, routes }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Builds a minimal setup context.
|
|
237
|
+
*
|
|
238
|
+
* @param {string|undefined} consumerRoot - Consumer project root.
|
|
239
|
+
* @param {object} [overrides] - Context overrides.
|
|
240
|
+
* @returns {object} Context.
|
|
241
|
+
*/
|
|
242
|
+
function makeContext(consumerRoot, overrides = {}) {
|
|
243
|
+
return { consumerRoot, dryRun: false, flags: {}, ...overrides }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Builds a fake `fetch` from a URL → response map.
|
|
248
|
+
*
|
|
249
|
+
* Route values:
|
|
250
|
+
* - string → 200 with that body
|
|
251
|
+
* - `{ content }` → 200 with that body
|
|
252
|
+
* - `{ ok, status }` → response with the given flags
|
|
253
|
+
* - `{ reject }` → rejects with that value
|
|
254
|
+
* - null → resolves to `null`
|
|
255
|
+
* - `{ arrayBuffer: false }` → 200 whose body read throws
|
|
256
|
+
*
|
|
257
|
+
* Unknown URLs resolve to a 404 response.
|
|
258
|
+
*
|
|
259
|
+
* @param {Record<string, *>} routes - URL → route definition.
|
|
260
|
+
* @returns {ReturnType<typeof vi.fn>} Fake fetch.
|
|
261
|
+
*/
|
|
262
|
+
function makeFetch(routes) {
|
|
263
|
+
return vi.fn(async (requestUrl) => {
|
|
264
|
+
const route = routes[requestUrl]
|
|
265
|
+
if (route === undefined) return { ok: false, status: 404 }
|
|
266
|
+
if (route === null) return null
|
|
267
|
+
if (route instanceof Error) throw route
|
|
268
|
+
if (typeof route === 'string') {
|
|
269
|
+
return { ok: true, status: 200, arrayBuffer: async () => Buffer.from(route) }
|
|
270
|
+
}
|
|
271
|
+
if ('reject' in route) throw route.reject
|
|
272
|
+
if (route.arrayBuffer === false) {
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
status: 200,
|
|
276
|
+
arrayBuffer: async () => {
|
|
277
|
+
throw new Error('read failed')
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
ok: route.ok ?? true,
|
|
283
|
+
status: route.status,
|
|
284
|
+
arrayBuffer: async () => Buffer.from(route.content ?? ''),
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Reads a destination file inside the consumer project.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} consumerRoot - Consumer project root.
|
|
293
|
+
* @param {string} name - Skill name.
|
|
294
|
+
* @param {string} filePath - File path inside the skill.
|
|
295
|
+
* @returns {string} File content.
|
|
296
|
+
*/
|
|
297
|
+
function readDest(consumerRoot, name, filePath) {
|
|
298
|
+
return readFileSync(
|
|
299
|
+
join(consumerRoot, '.agents', 'skills', name, filePath),
|
|
300
|
+
'utf-8',
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Returns the stepWarn messages that mention the fallback notice. */
|
|
305
|
+
function fallbackNotices() {
|
|
306
|
+
return vi
|
|
307
|
+
.mocked(stepWarn)
|
|
308
|
+
.mock.calls.map((call) => call[0])
|
|
309
|
+
.filter((msg) => String(msg).includes('upstream fetch unavailable'))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Unit tests: runSkillsPhase ────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
describe('runSkillsPhase', () => {
|
|
315
|
+
beforeEach(() => {
|
|
316
|
+
vi.clearAllMocks()
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
cleanupTempDirs()
|
|
321
|
+
vi.unstubAllGlobals()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// ── 1. Happy path ─────────────────────────────────────────────────────
|
|
325
|
+
it('fetches every recorded file, verifies hashes, and writes them', async () => {
|
|
326
|
+
const spec = {
|
|
327
|
+
name: 'grilling',
|
|
328
|
+
pinned: true,
|
|
329
|
+
files: [
|
|
330
|
+
{ path: 'SKILL.md', content: 'upstream-skill' },
|
|
331
|
+
{ path: 'EXTRA.md', content: 'upstream-extra' },
|
|
332
|
+
],
|
|
333
|
+
}
|
|
334
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
335
|
+
const fetchImpl = makeFetch(routes)
|
|
336
|
+
|
|
337
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
338
|
+
fetchImpl,
|
|
339
|
+
packageRoot: pkgRoot,
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
expect(result.action).toBe('completed')
|
|
343
|
+
expect(result.pinned).toBe(1)
|
|
344
|
+
expect(result.fetched).toEqual(['grilling'])
|
|
345
|
+
expect(result.fallback).toEqual([])
|
|
346
|
+
expect(result.skipped).toEqual([])
|
|
347
|
+
expect(result.warnings).toEqual([])
|
|
348
|
+
expect(readDest(consumerRoot, 'grilling', 'SKILL.md')).toBe('upstream-skill')
|
|
349
|
+
expect(readDest(consumerRoot, 'grilling', 'EXTRA.md')).toBe('upstream-extra')
|
|
350
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
|
351
|
+
expect(fetchImpl).toHaveBeenCalledWith(
|
|
352
|
+
urlFor('grilling', 'SKILL.md'),
|
|
353
|
+
expect.objectContaining({ signal: expect.anything() }),
|
|
354
|
+
)
|
|
355
|
+
expect(stepSuccess).toHaveBeenCalledWith(
|
|
356
|
+
expect.stringContaining('Fetched 1 pinned skill(s) from upstream'),
|
|
357
|
+
)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
// ── 2. Default fetch + missing AbortSignal.timeout ────────────────────
|
|
361
|
+
it('uses global fetch when no fetchImpl is injected', async () => {
|
|
362
|
+
const spec = {
|
|
363
|
+
name: 'grilling',
|
|
364
|
+
pinned: true,
|
|
365
|
+
files: [{ path: 'SKILL.md', content: 'via-global' }],
|
|
366
|
+
}
|
|
367
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
368
|
+
const fetchImpl = makeFetch(routes)
|
|
369
|
+
vi.stubGlobal('fetch', fetchImpl)
|
|
370
|
+
|
|
371
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
372
|
+
packageRoot: pkgRoot,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
expect(result.fetched).toEqual(['grilling'])
|
|
376
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('sends no abort signal when AbortSignal.timeout is unavailable', async () => {
|
|
380
|
+
const spec = {
|
|
381
|
+
name: 'grilling',
|
|
382
|
+
pinned: true,
|
|
383
|
+
files: [{ path: 'SKILL.md', content: 'no-abort' }],
|
|
384
|
+
}
|
|
385
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
386
|
+
const fetchImpl = makeFetch(routes)
|
|
387
|
+
vi.stubGlobal('AbortSignal', undefined)
|
|
388
|
+
|
|
389
|
+
await runSkillsPhase(makeContext(consumerRoot), {
|
|
390
|
+
fetchImpl,
|
|
391
|
+
packageRoot: pkgRoot,
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
expect(fetchImpl).toHaveBeenCalledWith(expect.any(String), {})
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
// ── 3. Non-200 → fallback (TEST-07) ───────────────────────────────────
|
|
398
|
+
it('falls back to shipped copies on a non-200 response', async () => {
|
|
399
|
+
const spec = {
|
|
400
|
+
name: 'grilling',
|
|
401
|
+
pinned: true,
|
|
402
|
+
files: [{ path: 'SKILL.md', content: 'upstream' }],
|
|
403
|
+
shipped: [{ path: 'SKILL.md', content: 'shipped' }],
|
|
404
|
+
}
|
|
405
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
406
|
+
routes[urlFor('grilling', 'SKILL.md')] = { ok: false, status: 500 }
|
|
407
|
+
const fetchImpl = makeFetch(routes)
|
|
408
|
+
|
|
409
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
410
|
+
fetchImpl,
|
|
411
|
+
packageRoot: pkgRoot,
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
expect(result.fetched).toEqual([])
|
|
415
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
416
|
+
expect(result.message).toContain(
|
|
417
|
+
'1 restored from the copies shipped with the package',
|
|
418
|
+
)
|
|
419
|
+
expect(readDest(consumerRoot, 'grilling', 'SKILL.md')).toBe('shipped')
|
|
420
|
+
expect(fallbackNotices()).toHaveLength(1)
|
|
421
|
+
expect(fallbackNotices()[0]).toContain('HTTP 500')
|
|
422
|
+
expect(fallbackNotices()[0]).toContain(
|
|
423
|
+
'using the copies shipped with the package',
|
|
424
|
+
)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// ── 4. Null response / unknown status ─────────────────────────────────
|
|
428
|
+
it('falls back when fetch resolves to null', async () => {
|
|
429
|
+
const spec = {
|
|
430
|
+
name: 'grilling',
|
|
431
|
+
pinned: true,
|
|
432
|
+
files: [{ path: 'SKILL.md', content: 'upstream' }],
|
|
433
|
+
}
|
|
434
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
435
|
+
routes[urlFor('grilling', 'SKILL.md')] = null
|
|
436
|
+
|
|
437
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
438
|
+
fetchImpl: makeFetch(routes),
|
|
439
|
+
packageRoot: pkgRoot,
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
443
|
+
expect(fallbackNotices()[0]).toContain('HTTP unknown')
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('falls back when a response has no status', async () => {
|
|
447
|
+
const spec = {
|
|
448
|
+
name: 'grilling',
|
|
449
|
+
pinned: true,
|
|
450
|
+
files: [{ path: 'SKILL.md', content: 'upstream' }],
|
|
451
|
+
}
|
|
452
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
453
|
+
routes[urlFor('grilling', 'SKILL.md')] = { ok: false }
|
|
454
|
+
|
|
455
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
456
|
+
fetchImpl: makeFetch(routes),
|
|
457
|
+
packageRoot: pkgRoot,
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
461
|
+
expect(fallbackNotices()[0]).toContain('HTTP unknown')
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
// ── 5. Network rejection short-circuits the remaining skills ──────────
|
|
465
|
+
it('falls back for every skill after a network rejection, with one notice', async () => {
|
|
466
|
+
const specs = [
|
|
467
|
+
{
|
|
468
|
+
name: 'grilling',
|
|
469
|
+
pinned: true,
|
|
470
|
+
files: [{ path: 'SKILL.md', content: 'a-up' }],
|
|
471
|
+
shipped: [{ path: 'SKILL.md', content: 'a-shipped' }],
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
name: 'prototype',
|
|
475
|
+
pinned: true,
|
|
476
|
+
files: [{ path: 'SKILL.md', content: 'b-up' }],
|
|
477
|
+
shipped: [{ path: 'SKILL.md', content: 'b-shipped' }],
|
|
478
|
+
},
|
|
479
|
+
]
|
|
480
|
+
const { pkgRoot, consumerRoot, routes } = workspace(specs)
|
|
481
|
+
routes[urlFor('grilling', 'SKILL.md')] = {
|
|
482
|
+
reject: new Error('ECONNREFUSED'),
|
|
483
|
+
}
|
|
484
|
+
const fetchImpl = makeFetch(routes)
|
|
485
|
+
|
|
486
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
487
|
+
fetchImpl,
|
|
488
|
+
packageRoot: pkgRoot,
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
expect(result.fetched).toEqual([])
|
|
492
|
+
expect(result.fallback).toEqual(['grilling', 'prototype'])
|
|
493
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
494
|
+
expect(fallbackNotices()).toHaveLength(1)
|
|
495
|
+
expect(fallbackNotices()[0]).toContain('ECONNREFUSED')
|
|
496
|
+
expect(readDest(consumerRoot, 'grilling', 'SKILL.md')).toBe('a-shipped')
|
|
497
|
+
expect(readDest(consumerRoot, 'prototype', 'SKILL.md')).toBe('b-shipped')
|
|
498
|
+
expect(result.message).toContain(
|
|
499
|
+
'2 restored from the copies shipped with the package',
|
|
500
|
+
)
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// ── 6. Timeout ────────────────────────────────────────────────────────
|
|
504
|
+
it('reports a timeout when the fetch aborts', async () => {
|
|
505
|
+
const spec = {
|
|
506
|
+
name: 'grilling',
|
|
507
|
+
pinned: true,
|
|
508
|
+
files: [{ path: 'SKILL.md', content: 'upstream' }],
|
|
509
|
+
}
|
|
510
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
511
|
+
const timeout = new Error('The operation was aborted due to timeout')
|
|
512
|
+
timeout.name = 'TimeoutError'
|
|
513
|
+
routes[urlFor('grilling', 'SKILL.md')] = { reject: timeout }
|
|
514
|
+
|
|
515
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
516
|
+
fetchImpl: makeFetch(routes),
|
|
517
|
+
packageRoot: pkgRoot,
|
|
518
|
+
fetchTimeoutMs: 10,
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
522
|
+
expect(fallbackNotices()[0]).toContain('timed out')
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
// ── 7. Non-Error / undefined rejections ───────────────────────────────
|
|
526
|
+
it('describes a string rejection without throwing', async () => {
|
|
527
|
+
const spec = {
|
|
528
|
+
name: 'grilling',
|
|
529
|
+
pinned: true,
|
|
530
|
+
files: [{ path: 'SKILL.md', content: 'upstream' }],
|
|
531
|
+
}
|
|
532
|
+
const { pkgRoot, consumerRoot } = workspace([spec])
|
|
533
|
+
|
|
534
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
535
|
+
fetchImpl: vi.fn(async () => {
|
|
536
|
+
throw 'offline'
|
|
537
|
+
}),
|
|
538
|
+
packageRoot: pkgRoot,
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
542
|
+
expect(fallbackNotices()[0]).toContain('offline')
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('describes an undefined rejection as an unknown error', async () => {
|
|
546
|
+
const spec = {
|
|
547
|
+
name: 'grilling',
|
|
548
|
+
pinned: true,
|
|
549
|
+
files: [{ path: 'SKILL.md', content: 'upstream' }],
|
|
550
|
+
}
|
|
551
|
+
const { pkgRoot, consumerRoot } = workspace([spec])
|
|
552
|
+
|
|
553
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
554
|
+
fetchImpl: vi.fn(async () => {
|
|
555
|
+
throw undefined
|
|
556
|
+
}),
|
|
557
|
+
packageRoot: pkgRoot,
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
561
|
+
expect(fallbackNotices()[0]).toContain('unknown error')
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
// ── 8. Hash mismatch → fallback (TEST-07) ─────────────────────────────
|
|
565
|
+
it('falls back when an upstream file does not match the lock hash', async () => {
|
|
566
|
+
const spec = {
|
|
567
|
+
name: 'grilling',
|
|
568
|
+
pinned: true,
|
|
569
|
+
files: [{ path: 'SKILL.md', content: 'pinned-content' }],
|
|
570
|
+
shipped: [{ path: 'SKILL.md', content: 'pinned-content' }],
|
|
571
|
+
}
|
|
572
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
573
|
+
routes[urlFor('grilling', 'SKILL.md')] = { content: 'tampered-content' }
|
|
574
|
+
|
|
575
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
576
|
+
fetchImpl: makeFetch(routes),
|
|
577
|
+
packageRoot: pkgRoot,
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
expect(result.fetched).toEqual([])
|
|
581
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
582
|
+
expect(readDest(consumerRoot, 'grilling', 'SKILL.md')).toBe('pinned-content')
|
|
583
|
+
expect(fallbackNotices()[0]).toContain('hash mismatch')
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
// ── 9. Local edit → warning + overwrite ───────────────────────────────
|
|
587
|
+
it('warns naming the file before overwriting local edits', async () => {
|
|
588
|
+
const spec = {
|
|
589
|
+
name: 'grilling',
|
|
590
|
+
pinned: true,
|
|
591
|
+
files: [{ path: 'SKILL.md', content: 'pinned-content' }],
|
|
592
|
+
}
|
|
593
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
594
|
+
writeFiles(join(consumerRoot, '.agents', 'skills', 'grilling'), [
|
|
595
|
+
{ path: 'SKILL.md', content: 'my local edit' },
|
|
596
|
+
])
|
|
597
|
+
|
|
598
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
599
|
+
fetchImpl: makeFetch(routes),
|
|
600
|
+
packageRoot: pkgRoot,
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
const dest = join(consumerRoot, '.agents', 'skills', 'grilling', 'SKILL.md')
|
|
604
|
+
expect(readDest(consumerRoot, 'grilling', 'SKILL.md')).toBe('pinned-content')
|
|
605
|
+
expect(result.fetched).toEqual(['grilling'])
|
|
606
|
+
expect(result.warnings).toHaveLength(1)
|
|
607
|
+
expect(result.warnings[0]).toContain(dest)
|
|
608
|
+
expect(stepWarn).toHaveBeenCalledWith(
|
|
609
|
+
expect.stringContaining('overwriting with the pinned version'),
|
|
610
|
+
)
|
|
611
|
+
expect(vi.mocked(stepWarn).mock.calls[0][0]).toContain(dest)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('warns naming the file when the fallback overwrites local edits', async () => {
|
|
615
|
+
const spec = {
|
|
616
|
+
name: 'grilling',
|
|
617
|
+
pinned: true,
|
|
618
|
+
files: [{ path: 'SKILL.md', content: 'pinned-content' }],
|
|
619
|
+
shipped: [{ path: 'SKILL.md', content: 'pinned-content' }],
|
|
620
|
+
}
|
|
621
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
622
|
+
writeFiles(join(consumerRoot, '.agents', 'skills', 'grilling'), [
|
|
623
|
+
{ path: 'SKILL.md', content: 'my local edit' },
|
|
624
|
+
])
|
|
625
|
+
routes[urlFor('grilling', 'SKILL.md')] = { ok: false, status: 500 }
|
|
626
|
+
|
|
627
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
628
|
+
fetchImpl: makeFetch(routes),
|
|
629
|
+
packageRoot: pkgRoot,
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
const dest = join(consumerRoot, '.agents', 'skills', 'grilling', 'SKILL.md')
|
|
633
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
634
|
+
expect(readDest(consumerRoot, 'grilling', 'SKILL.md')).toBe('pinned-content')
|
|
635
|
+
expect(
|
|
636
|
+
result.warnings.some(
|
|
637
|
+
(w) => w.includes(dest) && w.includes('overwriting with the pinned version'),
|
|
638
|
+
),
|
|
639
|
+
).toBe(true)
|
|
640
|
+
expect(stepWarn).toHaveBeenCalledWith(
|
|
641
|
+
expect.stringContaining('overwriting with the pinned version'),
|
|
642
|
+
)
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// ── 10. No pinned entries → no-op ─────────────────────────────────────
|
|
646
|
+
it('is a no-op with zero fetch calls when there are no pinned entries', async () => {
|
|
647
|
+
const { pkgRoot, consumerRoot } = workspace([
|
|
648
|
+
{ name: 'caveman', pinned: false },
|
|
649
|
+
{ name: 'compress', pinned: false },
|
|
650
|
+
])
|
|
651
|
+
const fetchImpl = vi.fn()
|
|
652
|
+
|
|
653
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
654
|
+
fetchImpl,
|
|
655
|
+
packageRoot: pkgRoot,
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
expect(result.action).toBe('completed')
|
|
659
|
+
expect(result.pinned).toBe(0)
|
|
660
|
+
expect(result.message).toBe('0 pinned skills, nothing to fetch')
|
|
661
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
662
|
+
expect(existsSync(join(consumerRoot, '.agents'))).toBe(false)
|
|
663
|
+
expect(stepSuccess).toHaveBeenCalledWith(
|
|
664
|
+
expect.stringContaining('0 pinned skills'),
|
|
665
|
+
)
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
// ── 11. Unusable lock shapes ──────────────────────────────────────────
|
|
669
|
+
it('treats a missing lock file as zero pinned skills', async () => {
|
|
670
|
+
const root = createTempDir()
|
|
671
|
+
const pkgRoot = join(root, 'package')
|
|
672
|
+
const consumerRoot = join(root, 'consumer')
|
|
673
|
+
mkdirSync(pkgRoot, { recursive: true })
|
|
674
|
+
mkdirSync(consumerRoot, { recursive: true })
|
|
675
|
+
const fetchImpl = vi.fn()
|
|
676
|
+
|
|
677
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
678
|
+
fetchImpl,
|
|
679
|
+
packageRoot: pkgRoot,
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
expect(result.pinned).toBe(0)
|
|
683
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
it('treats invalid JSON as zero pinned skills', async () => {
|
|
687
|
+
const root = createTempDir()
|
|
688
|
+
const pkgRoot = join(root, 'package')
|
|
689
|
+
const consumerRoot = join(root, 'consumer')
|
|
690
|
+
mkdirSync(pkgRoot, { recursive: true })
|
|
691
|
+
mkdirSync(consumerRoot, { recursive: true })
|
|
692
|
+
writeFileSync(join(pkgRoot, 'skills-lock.json'), 'not json')
|
|
693
|
+
|
|
694
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
695
|
+
fetchImpl: vi.fn(),
|
|
696
|
+
packageRoot: pkgRoot,
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
expect(result.pinned).toBe(0)
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('ignores a malformed skills collection', async () => {
|
|
703
|
+
const root = createTempDir()
|
|
704
|
+
const pkgRoot = join(root, 'package')
|
|
705
|
+
const consumerRoot = join(root, 'consumer')
|
|
706
|
+
mkdirSync(pkgRoot, { recursive: true })
|
|
707
|
+
mkdirSync(consumerRoot, { recursive: true })
|
|
708
|
+
writeFileSync(
|
|
709
|
+
join(pkgRoot, 'skills-lock.json'),
|
|
710
|
+
JSON.stringify({ version: 1, skills: 'nope' }),
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
714
|
+
fetchImpl: vi.fn(),
|
|
715
|
+
packageRoot: pkgRoot,
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
expect(result.pinned).toBe(0)
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
it('ignores entries with an empty ref, no ref, or no files', async () => {
|
|
722
|
+
const root = createTempDir()
|
|
723
|
+
const pkgRoot = join(root, 'package')
|
|
724
|
+
const consumerRoot = join(root, 'consumer')
|
|
725
|
+
mkdirSync(pkgRoot, { recursive: true })
|
|
726
|
+
mkdirSync(consumerRoot, { recursive: true })
|
|
727
|
+
writeFileSync(
|
|
728
|
+
join(pkgRoot, 'skills-lock.json'),
|
|
729
|
+
JSON.stringify({
|
|
730
|
+
version: 1,
|
|
731
|
+
skills: {
|
|
732
|
+
emptyRef: { ref: '', files: [{ path: 'SKILL.md', sha256: 'x' }] },
|
|
733
|
+
noRef: { files: [{ path: 'SKILL.md', sha256: 'x' }] },
|
|
734
|
+
noFiles: { ref: 'v1.3.1', files: [] },
|
|
735
|
+
filesNotArray: { ref: 'v1.3.1', files: 'SKILL.md' },
|
|
736
|
+
},
|
|
737
|
+
}),
|
|
738
|
+
)
|
|
739
|
+
const fetchImpl = vi.fn()
|
|
740
|
+
|
|
741
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
742
|
+
fetchImpl,
|
|
743
|
+
packageRoot: pkgRoot,
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
expect(result.pinned).toBe(0)
|
|
747
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
// ── 12. Dry run ───────────────────────────────────────────────────────
|
|
751
|
+
it('performs no fetch and no writes in dry-run mode', async () => {
|
|
752
|
+
const specs = [
|
|
753
|
+
{
|
|
754
|
+
name: 'grilling',
|
|
755
|
+
pinned: true,
|
|
756
|
+
files: [{ path: 'SKILL.md', content: 'pending-upstream' }],
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
name: 'prototype',
|
|
760
|
+
pinned: true,
|
|
761
|
+
files: [{ path: 'SKILL.md', content: 'current-content' }],
|
|
762
|
+
},
|
|
763
|
+
]
|
|
764
|
+
const { pkgRoot, consumerRoot } = workspace(specs)
|
|
765
|
+
writeFiles(join(consumerRoot, '.agents', 'skills', 'prototype'), [
|
|
766
|
+
{ path: 'SKILL.md', content: 'current-content' },
|
|
767
|
+
])
|
|
768
|
+
const fetchImpl = vi.fn()
|
|
769
|
+
|
|
770
|
+
const result = await runSkillsPhase(
|
|
771
|
+
makeContext(consumerRoot, { dryRun: true }),
|
|
772
|
+
{ fetchImpl, packageRoot: pkgRoot },
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
expect(result.action).toBe('dry-run')
|
|
776
|
+
expect(result.fetched).toEqual(['grilling'])
|
|
777
|
+
expect(result.skipped).toEqual(['prototype'])
|
|
778
|
+
expect(result.message).toBe(
|
|
779
|
+
'Would fetch 1 pinned skill(s) from upstream; 1 already up to date',
|
|
780
|
+
)
|
|
781
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
782
|
+
expect(
|
|
783
|
+
existsSync(join(consumerRoot, '.agents', 'skills', 'grilling', 'SKILL.md')),
|
|
784
|
+
).toBe(false)
|
|
785
|
+
expect(stepSuccess).toHaveBeenCalledWith(
|
|
786
|
+
expect.stringContaining('Would fetch 1 pinned skill(s)'),
|
|
787
|
+
)
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
it('reports (but does not perform) local-edit overwrites in dry-run mode', async () => {
|
|
791
|
+
const spec = {
|
|
792
|
+
name: 'grilling',
|
|
793
|
+
pinned: true,
|
|
794
|
+
files: [{ path: 'SKILL.md', content: 'pinned-content' }],
|
|
795
|
+
}
|
|
796
|
+
const { pkgRoot, consumerRoot } = workspace([spec])
|
|
797
|
+
writeFiles(join(consumerRoot, '.agents', 'skills', 'grilling'), [
|
|
798
|
+
{ path: 'SKILL.md', content: 'my local edit' },
|
|
799
|
+
])
|
|
800
|
+
|
|
801
|
+
const result = await runSkillsPhase(
|
|
802
|
+
makeContext(consumerRoot, { dryRun: true }),
|
|
803
|
+
{ fetchImpl: vi.fn(), packageRoot: pkgRoot },
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
expect(result.action).toBe('dry-run')
|
|
807
|
+
expect(result.warnings).toHaveLength(1)
|
|
808
|
+
expect(result.warnings[0]).toContain('would be overwritten')
|
|
809
|
+
expect(readDest(consumerRoot, 'grilling', 'SKILL.md')).toBe('my local edit')
|
|
810
|
+
expect(stepWarn).toHaveBeenCalledWith(
|
|
811
|
+
expect.stringContaining('would be overwritten'),
|
|
812
|
+
)
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
// ── 13. Idempotency ───────────────────────────────────────────────────
|
|
816
|
+
it('skips the fetch when every destination file already matches the lock', async () => {
|
|
817
|
+
const spec = {
|
|
818
|
+
name: 'grilling',
|
|
819
|
+
pinned: true,
|
|
820
|
+
files: [{ path: 'SKILL.md', content: 'current-content' }],
|
|
821
|
+
}
|
|
822
|
+
const { pkgRoot, consumerRoot } = workspace([spec])
|
|
823
|
+
writeFiles(join(consumerRoot, '.agents', 'skills', 'grilling'), [
|
|
824
|
+
{ path: 'SKILL.md', content: 'current-content' },
|
|
825
|
+
])
|
|
826
|
+
const fetchImpl = vi.fn()
|
|
827
|
+
|
|
828
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
829
|
+
fetchImpl,
|
|
830
|
+
packageRoot: pkgRoot,
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
expect(result.action).toBe('completed')
|
|
834
|
+
expect(result.fetched).toEqual([])
|
|
835
|
+
expect(result.skipped).toEqual(['grilling'])
|
|
836
|
+
expect(result.message).toBe('1 already up to date')
|
|
837
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
// ── 14. Mixed upstream + fallback ─────────────────────────────────────
|
|
841
|
+
it('mixes upstream and fallback skills in one run', async () => {
|
|
842
|
+
const specs = [
|
|
843
|
+
{
|
|
844
|
+
name: 'grilling',
|
|
845
|
+
pinned: true,
|
|
846
|
+
files: [{ path: 'SKILL.md', content: 'a-up' }],
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
name: 'prototype',
|
|
850
|
+
pinned: true,
|
|
851
|
+
files: [{ path: 'SKILL.md', content: 'b-pinned' }],
|
|
852
|
+
shipped: [{ path: 'SKILL.md', content: 'b-shipped' }],
|
|
853
|
+
},
|
|
854
|
+
]
|
|
855
|
+
const { pkgRoot, consumerRoot, routes } = workspace(specs)
|
|
856
|
+
routes[urlFor('prototype', 'SKILL.md')] = { content: 'b-tampered' }
|
|
857
|
+
const fetchImpl = makeFetch(routes)
|
|
858
|
+
|
|
859
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
860
|
+
fetchImpl,
|
|
861
|
+
packageRoot: pkgRoot,
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
expect(result.fetched).toEqual(['grilling'])
|
|
865
|
+
expect(result.fallback).toEqual(['prototype'])
|
|
866
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
|
867
|
+
expect(result.message).toBe(
|
|
868
|
+
'Fetched 1 pinned skill(s) from upstream; ' +
|
|
869
|
+
'1 restored from the copies shipped with the package',
|
|
870
|
+
)
|
|
871
|
+
expect(readDest(consumerRoot, 'prototype', 'SKILL.md')).toBe('b-shipped')
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
// ── 15. Skip flag ─────────────────────────────────────────────────────
|
|
875
|
+
it('returns action="skipped" when flags.skipSync is set', async () => {
|
|
876
|
+
const fetchImpl = vi.fn()
|
|
877
|
+
|
|
878
|
+
const result = await runSkillsPhase(
|
|
879
|
+
makeContext('/tmp/never-used', { flags: { skipSync: true } }),
|
|
880
|
+
{ fetchImpl },
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
expect(result.action).toBe('skipped')
|
|
884
|
+
expect(result.message).toContain('--skip-sync')
|
|
885
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
886
|
+
expect(stepWarn).toHaveBeenCalledWith(
|
|
887
|
+
expect.stringContaining('--skip-sync'),
|
|
888
|
+
)
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
// ── 16. Missing consumer root ─────────────────────────────────────────
|
|
892
|
+
it('is a no-op when the consumer root does not exist', async () => {
|
|
893
|
+
const fetchImpl = vi.fn()
|
|
894
|
+
const missing = join(realpathSync(tmpdir()), 'skills-phase-missing-root')
|
|
895
|
+
|
|
896
|
+
const result = await runSkillsPhase(makeContext(missing), { fetchImpl })
|
|
897
|
+
const resultNoRoot = await runSkillsPhase({ flags: {} }, { fetchImpl })
|
|
898
|
+
|
|
899
|
+
expect(result.action).toBe('completed')
|
|
900
|
+
expect(result.message).toContain('consumer root not found')
|
|
901
|
+
expect(resultNoRoot.message).toContain('consumer root not found')
|
|
902
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
// ── 17. Path traversal ────────────────────────────────────────────────
|
|
906
|
+
it('refuses recorded paths that escape the skill directory', async () => {
|
|
907
|
+
const spec = {
|
|
908
|
+
name: 'grilling',
|
|
909
|
+
pinned: true,
|
|
910
|
+
files: [{ path: '../../escape.md', content: 'evil' }],
|
|
911
|
+
}
|
|
912
|
+
const { pkgRoot, consumerRoot } = workspace([spec])
|
|
913
|
+
const fetchImpl = vi.fn()
|
|
914
|
+
|
|
915
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
916
|
+
fetchImpl,
|
|
917
|
+
packageRoot: pkgRoot,
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
expect(result.warnings[0]).toContain('Refusing to write outside')
|
|
921
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
922
|
+
expect(
|
|
923
|
+
existsSync(join(consumerRoot, '.agents', 'escape.md')),
|
|
924
|
+
).toBe(false)
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
it('skips traversal files during fallback but still copies valid ones', async () => {
|
|
928
|
+
const spec = {
|
|
929
|
+
name: 'grilling',
|
|
930
|
+
pinned: true,
|
|
931
|
+
files: [
|
|
932
|
+
{ path: 'SKILL.md', content: 'valid-pinned' },
|
|
933
|
+
{ path: '../../escape.md', content: 'evil' },
|
|
934
|
+
],
|
|
935
|
+
}
|
|
936
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
937
|
+
routes[urlFor('grilling', 'SKILL.md')] = { ok: false, status: 503 }
|
|
938
|
+
const fetchImpl = makeFetch(routes)
|
|
939
|
+
|
|
940
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
941
|
+
fetchImpl,
|
|
942
|
+
packageRoot: pkgRoot,
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
946
|
+
expect(readDest(consumerRoot, 'grilling', 'SKILL.md')).toBe('valid-pinned')
|
|
947
|
+
expect(existsSync(join(consumerRoot, '.agents', 'escape.md'))).toBe(false)
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
// ── 18. Fallback copy problems ────────────────────────────────────────
|
|
951
|
+
it('warns but does not throw when a shipped copy is missing', async () => {
|
|
952
|
+
const spec = {
|
|
953
|
+
name: 'grilling',
|
|
954
|
+
pinned: true,
|
|
955
|
+
files: [{ path: 'SKILL.md', content: 'upstream' }],
|
|
956
|
+
shipped: [],
|
|
957
|
+
}
|
|
958
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
959
|
+
routes[urlFor('grilling', 'SKILL.md')] = { ok: false, status: 404 }
|
|
960
|
+
|
|
961
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
962
|
+
fetchImpl: makeFetch(routes),
|
|
963
|
+
packageRoot: pkgRoot,
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
967
|
+
expect(result.warnings.some((w) => w.includes('Shipped copy missing'))).toBe(
|
|
968
|
+
true,
|
|
969
|
+
)
|
|
970
|
+
expect(
|
|
971
|
+
existsSync(join(consumerRoot, '.agents', 'skills', 'grilling', 'SKILL.md')),
|
|
972
|
+
).toBe(false)
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
it('warns but still copies a shipped file whose hash differs', async () => {
|
|
976
|
+
const spec = {
|
|
977
|
+
name: 'grilling',
|
|
978
|
+
pinned: true,
|
|
979
|
+
files: [{ path: 'SKILL.md', content: 'locked-content' }],
|
|
980
|
+
shipped: [{ path: 'SKILL.md', content: 'stale-shipped-content' }],
|
|
981
|
+
}
|
|
982
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
983
|
+
routes[urlFor('grilling', 'SKILL.md')] = { ok: false, status: 404 }
|
|
984
|
+
|
|
985
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
986
|
+
fetchImpl: makeFetch(routes),
|
|
987
|
+
packageRoot: pkgRoot,
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
expect(
|
|
991
|
+
result.warnings.some((w) => w.includes('does not match the lock hash')),
|
|
992
|
+
).toBe(true)
|
|
993
|
+
expect(readDest(consumerRoot, 'grilling', 'SKILL.md')).toBe(
|
|
994
|
+
'stale-shipped-content',
|
|
995
|
+
)
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
it('writes no files when one shipped file of a multi-file skill is missing', async () => {
|
|
999
|
+
const spec = {
|
|
1000
|
+
name: 'prototype',
|
|
1001
|
+
pinned: true,
|
|
1002
|
+
files: [
|
|
1003
|
+
{ path: 'SKILL.md', content: 'pinned-skill' },
|
|
1004
|
+
{ path: 'LOGIC.md', content: 'pinned-logic' },
|
|
1005
|
+
],
|
|
1006
|
+
shipped: [{ path: 'SKILL.md', content: 'pinned-skill' }],
|
|
1007
|
+
}
|
|
1008
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
1009
|
+
routes[urlFor('prototype', 'SKILL.md')] = { ok: false, status: 500 }
|
|
1010
|
+
|
|
1011
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
1012
|
+
fetchImpl: makeFetch(routes),
|
|
1013
|
+
packageRoot: pkgRoot,
|
|
1014
|
+
})
|
|
1015
|
+
|
|
1016
|
+
expect(result.fallback).toEqual(['prototype'])
|
|
1017
|
+
expect(
|
|
1018
|
+
result.warnings.some((w) => w.includes('Shipped copy missing')),
|
|
1019
|
+
).toBe(true)
|
|
1020
|
+
// Stage-then-commit: the available file is NOT written on its own.
|
|
1021
|
+
expect(
|
|
1022
|
+
existsSync(
|
|
1023
|
+
join(consumerRoot, '.agents', 'skills', 'prototype', 'SKILL.md'),
|
|
1024
|
+
),
|
|
1025
|
+
).toBe(false)
|
|
1026
|
+
expect(
|
|
1027
|
+
existsSync(
|
|
1028
|
+
join(consumerRoot, '.agents', 'skills', 'prototype', 'LOGIC.md'),
|
|
1029
|
+
),
|
|
1030
|
+
).toBe(false)
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('warns but does not throw when a shipped copy cannot be read', async () => {
|
|
1034
|
+
const spec = {
|
|
1035
|
+
name: 'grilling',
|
|
1036
|
+
pinned: true,
|
|
1037
|
+
files: [{ path: 'SKILL.md', content: 'upstream' }],
|
|
1038
|
+
}
|
|
1039
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
1040
|
+
routes[urlFor('grilling', 'SKILL.md')] = { ok: false, status: 404 }
|
|
1041
|
+
|
|
1042
|
+
// Replace the shipped file with a directory so readFileSync throws EISDIR.
|
|
1043
|
+
const shippedFile = join(
|
|
1044
|
+
pkgRoot,
|
|
1045
|
+
'.agents',
|
|
1046
|
+
'skills',
|
|
1047
|
+
'grilling',
|
|
1048
|
+
'SKILL.md',
|
|
1049
|
+
)
|
|
1050
|
+
rmSync(shippedFile)
|
|
1051
|
+
mkdirSync(shippedFile)
|
|
1052
|
+
|
|
1053
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
1054
|
+
fetchImpl: makeFetch(routes),
|
|
1055
|
+
packageRoot: pkgRoot,
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
1059
|
+
expect(
|
|
1060
|
+
result.warnings.some((w) => w.includes('Shipped copy unreadable')),
|
|
1061
|
+
).toBe(true)
|
|
1062
|
+
expect(
|
|
1063
|
+
existsSync(join(consumerRoot, '.agents', 'skills', 'grilling', 'SKILL.md')),
|
|
1064
|
+
).toBe(false)
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
it('ignores malformed file records instead of throwing', async () => {
|
|
1068
|
+
const root = createTempDir()
|
|
1069
|
+
const pkgRoot = join(root, 'package')
|
|
1070
|
+
const consumerRoot = join(root, 'consumer')
|
|
1071
|
+
mkdirSync(pkgRoot, { recursive: true })
|
|
1072
|
+
mkdirSync(consumerRoot, { recursive: true })
|
|
1073
|
+
const content = 'usable'
|
|
1074
|
+
writeFileSync(
|
|
1075
|
+
join(pkgRoot, 'skills-lock.json'),
|
|
1076
|
+
JSON.stringify({
|
|
1077
|
+
version: 1,
|
|
1078
|
+
skills: {
|
|
1079
|
+
broken: { ref: 'v1.3.1', files: [{ path: 'SKILL.md' }] },
|
|
1080
|
+
onlyJunk: { ref: 'v1.3.1', files: [{ sha256: 'x' }] },
|
|
1081
|
+
mixed: {
|
|
1082
|
+
ref: 'v1.3.1',
|
|
1083
|
+
source: ' ',
|
|
1084
|
+
files: [
|
|
1085
|
+
{
|
|
1086
|
+
path: 'SKILL.md',
|
|
1087
|
+
sha256: sha(content),
|
|
1088
|
+
upstreamPath: 'skills/engineering/mixed/SKILL.md',
|
|
1089
|
+
},
|
|
1090
|
+
{ path: 'MISSING.md' },
|
|
1091
|
+
],
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
}),
|
|
1095
|
+
)
|
|
1096
|
+
const upstreamUrl =
|
|
1097
|
+
'https://raw.githubusercontent.com/mattpocock/skills/v1.3.1/' +
|
|
1098
|
+
'skills/engineering/mixed/SKILL.md'
|
|
1099
|
+
const fetchImpl = makeFetch({ [upstreamUrl]: { content } })
|
|
1100
|
+
|
|
1101
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
1102
|
+
fetchImpl,
|
|
1103
|
+
packageRoot: pkgRoot,
|
|
1104
|
+
})
|
|
1105
|
+
|
|
1106
|
+
expect(result.pinned).toBe(1)
|
|
1107
|
+
expect(result.fetched).toEqual(['mixed'])
|
|
1108
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
1109
|
+
expect(readDest(consumerRoot, 'mixed', 'SKILL.md')).toBe(content)
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
it('warns but does not throw when the fallback cannot write', async () => {
|
|
1113
|
+
const spec = {
|
|
1114
|
+
name: 'grilling',
|
|
1115
|
+
pinned: true,
|
|
1116
|
+
files: [{ path: 'SKILL.md', content: 'pinned-content' }],
|
|
1117
|
+
shipped: [{ path: 'SKILL.md', content: 'pinned-content' }],
|
|
1118
|
+
}
|
|
1119
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
1120
|
+
routes[urlFor('grilling', 'SKILL.md')] = { ok: false, status: 500 }
|
|
1121
|
+
|
|
1122
|
+
// A file where `.agents/` should be a directory makes every write fail.
|
|
1123
|
+
writeFileSync(join(consumerRoot, '.agents'), 'not a directory')
|
|
1124
|
+
|
|
1125
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
1126
|
+
fetchImpl: makeFetch(routes),
|
|
1127
|
+
packageRoot: pkgRoot,
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
1131
|
+
expect(
|
|
1132
|
+
result.warnings.some((w) => w.includes('Could not write the shipped copy')),
|
|
1133
|
+
).toBe(true)
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
// ── 19. Default upstream source ───────────────────────────────────────
|
|
1137
|
+
it('defaults to mattpocock/skills when the lock omits source', async () => {
|
|
1138
|
+
const spec = {
|
|
1139
|
+
name: 'grilling',
|
|
1140
|
+
pinned: true,
|
|
1141
|
+
source: null,
|
|
1142
|
+
files: [{ path: 'SKILL.md', content: 'no-source' }],
|
|
1143
|
+
}
|
|
1144
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
1145
|
+
const fetchImpl = makeFetch(routes)
|
|
1146
|
+
|
|
1147
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
1148
|
+
fetchImpl,
|
|
1149
|
+
packageRoot: pkgRoot,
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
expect(result.fetched).toEqual(['grilling'])
|
|
1153
|
+
expect(fetchImpl).toHaveBeenCalledWith(
|
|
1154
|
+
'https://raw.githubusercontent.com/mattpocock/skills/v1.3.1/' +
|
|
1155
|
+
'skills/engineering/grilling/SKILL.md',
|
|
1156
|
+
expect.anything(),
|
|
1157
|
+
)
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
// ── 20. Unreadable response body ──────────────────────────────────────
|
|
1161
|
+
it('falls back when the response body cannot be read', async () => {
|
|
1162
|
+
const spec = {
|
|
1163
|
+
name: 'grilling',
|
|
1164
|
+
pinned: true,
|
|
1165
|
+
files: [{ path: 'SKILL.md', content: 'upstream' }],
|
|
1166
|
+
}
|
|
1167
|
+
const { pkgRoot, consumerRoot, routes } = workspace([spec])
|
|
1168
|
+
routes[urlFor('grilling', 'SKILL.md')] = { arrayBuffer: false }
|
|
1169
|
+
|
|
1170
|
+
const result = await runSkillsPhase(makeContext(consumerRoot), {
|
|
1171
|
+
fetchImpl: makeFetch(routes),
|
|
1172
|
+
packageRoot: pkgRoot,
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
expect(result.fallback).toEqual(['grilling'])
|
|
1176
|
+
expect(fallbackNotices()[0]).toContain('read failed')
|
|
1177
|
+
})
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
// ── Wiring tests: index.js gating ────────────────────────────────────────
|
|
1181
|
+
|
|
1182
|
+
describe('index.js skills phase wiring', () => {
|
|
1183
|
+
const defaultFlags = {
|
|
1184
|
+
force: false,
|
|
1185
|
+
dryRun: false,
|
|
1186
|
+
yes: false,
|
|
1187
|
+
skipHooks: false,
|
|
1188
|
+
skipPrepare: false,
|
|
1189
|
+
skipSync: false,
|
|
1190
|
+
skipKnowledgebase: false,
|
|
1191
|
+
knowledgebase: false,
|
|
1192
|
+
help: false,
|
|
1193
|
+
version: false,
|
|
1194
|
+
quiet: false,
|
|
1195
|
+
verbose: false,
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const baseContext = {
|
|
1199
|
+
consumerRoot: join(realpathSync(tmpdir()), 'skills-wiring-no-such-dir'),
|
|
1200
|
+
hasGit: true,
|
|
1201
|
+
hasHuskyDir: false,
|
|
1202
|
+
hasPackageJson: true,
|
|
1203
|
+
consumerPackageJson: { name: 'test-consumer' },
|
|
1204
|
+
existingHooks: {},
|
|
1205
|
+
existingPrepare: null,
|
|
1206
|
+
prepareIsSimple: false,
|
|
1207
|
+
isCI: false,
|
|
1208
|
+
nodeVersion: 20,
|
|
1209
|
+
dryRun: false,
|
|
1210
|
+
verbose: false,
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
beforeEach(() => {
|
|
1214
|
+
vi.clearAllMocks()
|
|
1215
|
+
parseCliArgs.mockReturnValue({ ...defaultFlags })
|
|
1216
|
+
discover.mockResolvedValue({ ...baseContext })
|
|
1217
|
+
installHooks.mockResolvedValue([])
|
|
1218
|
+
handlePrepare.mockResolvedValue({ action: 'added', message: 'prepared' })
|
|
1219
|
+
initHusky.mockResolvedValue({ action: 'initialized', message: 'husky' })
|
|
1220
|
+
runSyncPhase.mockResolvedValue({ action: 'completed', message: 'synced' })
|
|
1221
|
+
registerKnowledgebase.mockResolvedValue({ action: 'indexed', message: 'kb' })
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
afterEach(() => {
|
|
1225
|
+
cleanupTempDirs()
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1228
|
+
it('invokes the skills phase as part of the normal pipeline', async () => {
|
|
1229
|
+
const code = await main([])
|
|
1230
|
+
|
|
1231
|
+
expect(code).toBe(EXIT_CODES.SUCCESS)
|
|
1232
|
+
expect(runSkillsPhase).toHaveBeenCalledTimes(1)
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
it('does not invoke the skills phase under --skip-sync and reports it', async () => {
|
|
1236
|
+
parseCliArgs.mockReturnValue({ ...defaultFlags, skipSync: true })
|
|
1237
|
+
|
|
1238
|
+
const code = await main(['--skip-sync'])
|
|
1239
|
+
|
|
1240
|
+
expect(code).toBe(EXIT_CODES.SUCCESS)
|
|
1241
|
+
expect(runSkillsPhase).not.toHaveBeenCalled()
|
|
1242
|
+
expect(stepWarn).toHaveBeenCalledWith(
|
|
1243
|
+
expect.stringContaining('--skip-sync'),
|
|
1244
|
+
)
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
it('suppresses the skip status in quiet mode', async () => {
|
|
1248
|
+
parseCliArgs.mockReturnValue({
|
|
1249
|
+
...defaultFlags,
|
|
1250
|
+
skipSync: true,
|
|
1251
|
+
quiet: true,
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
await main(['--skip-sync', '--quiet'])
|
|
1255
|
+
|
|
1256
|
+
expect(runSkillsPhase).not.toHaveBeenCalled()
|
|
1257
|
+
expect(stepWarn).not.toHaveBeenCalledWith(
|
|
1258
|
+
expect.stringContaining('--skip-sync'),
|
|
1259
|
+
)
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
it('contains a skills phase error as a warning instead of throwing', async () => {
|
|
1263
|
+
vi.mocked(runSkillsPhase).mockImplementationOnce(async () => {
|
|
1264
|
+
throw new Error('boom')
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
const code = await main([])
|
|
1268
|
+
|
|
1269
|
+
expect(code).toBe(EXIT_CODES.WARNINGS)
|
|
1270
|
+
expect(stepWarn).toHaveBeenCalledWith(
|
|
1271
|
+
expect.stringContaining('Skills phase skipped: boom'),
|
|
1272
|
+
)
|
|
1273
|
+
})
|
|
1274
|
+
})
|