Repository navigation
Expand file tree
/
Copy pathinstall-skills.test.ts
More file actions
274 lines (243 loc) · 9.97 KB
/
Copy pathinstall-skills.test.ts
File metadata and controls
274 lines (243 loc) · 9.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Integration } from '../../types.js'
// The module's documented contract is "every filesystem step degrades to a
// warning" — spy on the warn channel so a refactor to a silent
// `catch { return ... }` fails here instead of shipping.
vi.mock('@clack/prompts', () => ({ log: { warn: vi.fn() } }))
import * as p from '@clack/prompts'
import {
availableSkills,
installSkills,
readBundledSkill,
SKILL_MAP,
skillsFor,
} from '../install-skills.js'
const warnings = () => vi.mocked(p.log.warn).mock.calls.map(String).join('\n')
// Every integration the init flow can resolve to. Kept in lockstep with the
// `Integration` union and the init provider registry — a new integration added
// without a SKILL_MAP entry crashes the skills install and the AGENTS.md builder
// (SKILL_MAP[integration] is undefined → "not iterable"), and `tsup` ships that
// without type-checking, so this list is the runtime guard tsc can't be.
const ALL_INTEGRATIONS: Integration[] = [
'drizzle',
'supabase',
'prisma-next',
'postgresql',
]
// Parity with the wizard's own SKILL_MAP is asserted in
// `e2e/tests/skill-map-parity.e2e.test.ts` — the only workspace that declares
// both `stash` and `@cipherstash/wizard`, and whose `typecheck` script compiles
// the cross-package import.
describe('SKILL_MAP', () => {
it('selects only skills that contain a bundled SKILL.md', () => {
for (const integration of ALL_INTEGRATIONS) {
const available = new Set(availableSkills(integration))
const skills = SKILL_MAP[integration]
for (const skill of skills) {
expect(available.has(skill), `${integration}: ${skill}/SKILL.md`).toBe(
true,
)
}
}
})
it('has a non-empty entry for every integration (no undefined → crash)', () => {
for (const integration of ALL_INTEGRATIONS) {
const skills = SKILL_MAP[integration]
expect(skills, integration).toBeDefined()
expect(skills.length, integration).toBeGreaterThan(0)
}
})
it('always includes stash-encryption and stash-cli for every integration', () => {
for (const [integration, skills] of Object.entries(SKILL_MAP)) {
expect(skills, integration).toContain('stash-encryption')
expect(skills, integration).toContain('stash-cli')
}
})
// #753: integrations shipped without a single CREATE INDEX example, so
// agent-built integrations left every encrypted predicate unindexed. The
// indexing skill is cross-cutting (Drizzle, Prisma Next, Supabase, and raw
// SQL all need it), so every integration must install it.
it('always includes stash-indexing for every integration', () => {
for (const [integration, skills] of Object.entries(SKILL_MAP)) {
expect(skills, integration).toContain('stash-indexing')
}
})
it('drizzle includes stash-drizzle', () => {
expect(SKILL_MAP.drizzle).toContain('stash-drizzle')
})
it('supabase includes stash-supabase', () => {
expect(SKILL_MAP.supabase).toContain('stash-supabase')
})
it('prisma-next includes stash-prisma-next', () => {
expect(SKILL_MAP['prisma-next']).toContain('stash-prisma-next')
})
it('postgresql skips ORM-specific skills', () => {
expect(SKILL_MAP.postgresql).not.toContain('stash-drizzle')
expect(SKILL_MAP.postgresql).not.toContain('stash-supabase')
expect(SKILL_MAP.postgresql).not.toContain('stash-prisma-next')
})
// #754: the no-ORM path had no source for the raw-SQL binding surface or
// the WASM entry — an integration on it had to reverse-engineer both from
// `dist/*.d.ts` and the Postgres catalog. `postgresql` is that path;
// Supabase shares it (Edge Functions are the flagship WASM-entry use, and
// its migrations/RPC are hand-written SQL).
it.each([
'postgresql',
'supabase',
] as const)('%s includes the raw-SQL and edge skills', (integration) => {
expect(SKILL_MAP[integration]).toContain('stash-postgres')
expect(SKILL_MAP[integration]).toContain('stash-edge')
})
// The ORM integrations emit correctly-typed operands themselves, so they
// get cross-links from their own skills rather than the full install.
it.each([
'drizzle',
'prisma-next',
] as const)('%s does not install the raw-SQL skill', (integration) => {
expect(SKILL_MAP[integration]).not.toContain('stash-postgres')
})
})
describe('skillsFor', () => {
it('returns the mapped skills for a known integration', () => {
expect(skillsFor('prisma-next')).toEqual([
'stash-encryption',
'stash-prisma-next',
'stash-indexing',
'stash-cli',
])
})
it('falls back to the base skills for an unmapped integration (never crashes)', () => {
// Simulate a future Integration variant with no SKILL_MAP entry.
const skills = skillsFor('mystery-orm' as Integration)
expect(skills).toEqual(['stash-encryption', 'stash-indexing', 'stash-cli'])
})
})
describe('installSkills', () => {
let tmp: string
beforeEach(() => {
vi.clearAllMocks()
tmp = mkdtempSync(join(tmpdir(), 'install-skills-test-'))
})
afterEach(() => {
rmSync(tmp, { recursive: true, force: true })
})
it('copies the per-integration skills into destDir', () => {
const { copied, failed } = installSkills(tmp, '.claude/skills', 'drizzle')
expect(copied).toEqual([
'stash-encryption',
'stash-drizzle',
'stash-indexing',
'stash-cli',
])
expect(failed).toEqual([])
for (const name of copied) {
expect(
existsSync(join(tmp, '.claude/skills', name, 'SKILL.md')),
`${name}/SKILL.md should be present`,
).toBe(true)
}
})
it('honours the destDir parameter (codex)', () => {
const { copied } = installSkills(tmp, '.codex/skills', 'supabase')
expect(copied).toContain('stash-supabase')
expect(existsSync(join(tmp, '.codex/skills/stash-supabase/SKILL.md'))).toBe(
true,
)
// Does not write to .claude/ when codex is the target.
expect(existsSync(join(tmp, '.claude'))).toBe(false)
})
// #736: a Codex sandbox denies writes under `.codex/`, and the unguarded
// `mkdirSync` threw PAST the per-skill fallback and past the caller — so the
// whole handoff step died and AGENTS.md / .cipherstash were never written.
// Degrading to `failed` (with a warning — the documented contract) is what
// lets the caller fall back to inlining exactly those skills.
it('degrades to failed-with-a-warning when destDir cannot be created', () => {
// A FILE where the skills directory needs to be: mkdirSync recursive
// fails with ENOTDIR/EEXIST rather than succeeding.
mkdirSync(join(tmp, '.codex'), { recursive: true })
writeFileSync(join(tmp, '.codex/skills'), 'not a directory', 'utf-8')
let result: ReturnType<typeof installSkills> | undefined
expect(() => {
result = installSkills(tmp, '.codex/skills', 'drizzle')
}).not.toThrow()
expect(result).toEqual({
copied: [],
failed: [
'stash-encryption',
'stash-drizzle',
'stash-indexing',
'stash-cli',
],
})
expect(warnings()).toContain('Could not create .codex/skills/')
})
// One skill's copy failing must fail only that skill — the result reports
// the split so the Codex fallback can inline exactly the missing ones
// instead of declaring success. A FILE where the skill's directory must go
// makes cpSync throw ERR_FS_CP_DIR_TO_NON_DIR — a type-mismatch error, so
// it triggers on every platform and as root, unlike permission tricks
// (a 0o555 directory blocks nothing on Linux, where cpSync overwrites the
// writable SKILL.md in place — this test's first version failed CI there).
it('reports a partial copy as copied plus failed', () => {
mkdirSync(join(tmp, '.codex/skills'), { recursive: true })
writeFileSync(
join(tmp, '.codex/skills/stash-drizzle'),
'not a dir',
'utf-8',
)
const { copied, failed } = installSkills(tmp, '.codex/skills', 'drizzle')
expect(copied).toEqual(['stash-encryption', 'stash-indexing', 'stash-cli'])
expect(failed).toEqual(['stash-drizzle'])
expect(warnings()).toContain('Failed to install skill stash-drizzle')
})
it('leaves the caller able to distinguish "unwritable" from "nothing to install"', () => {
// An unwritable destination reports the bundled skills as `failed`;
// a stripped build reports both lists empty. The two no longer look
// identical, so the Codex handoff's inline fallback stays honest.
mkdirSync(join(tmp, '.codex'), { recursive: true })
writeFileSync(join(tmp, '.codex/skills'), 'not a directory', 'utf-8')
const { copied, failed } = installSkills(tmp, '.codex/skills', 'drizzle')
expect(copied).toEqual([])
expect(failed).toEqual(availableSkills('drizzle'))
expect(failed).toEqual([
'stash-encryption',
'stash-drizzle',
'stash-indexing',
'stash-cli',
])
})
it('is idempotent — re-running does not throw and yields the same result', () => {
const first = installSkills(tmp, '.claude/skills', 'postgresql')
const second = installSkills(tmp, '.claude/skills', 'postgresql')
expect(second).toEqual(first)
})
it('writes SKILL.md content from the bundled source', () => {
installSkills(tmp, '.claude/skills', 'drizzle')
const content = readFileSync(
join(tmp, '.claude/skills/stash-encryption/SKILL.md'),
'utf-8',
)
expect(content).toMatch(/^---/)
expect(content).toContain('name: stash-encryption')
})
})
describe('readBundledSkill', () => {
it('returns the SKILL.md body for a bundled skill', () => {
const body = readBundledSkill('stash-encryption')
expect(body).toBeDefined()
expect(body).toContain('name: stash-encryption')
})
it('returns undefined for an unknown skill name', () => {
expect(readBundledSkill('does-not-exist')).toBeUndefined()
})
})