-
Notifications
You must be signed in to change notification settings - Fork 224
Expand file tree
/
Copy pathe2e-kit-flows.test.mjs
More file actions
378 lines (339 loc) · 14.2 KB
/
Copy pathe2e-kit-flows.test.mjs
File metadata and controls
378 lines (339 loc) · 14.2 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
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
#!/usr/bin/env node
/**
* e2e-kit-flows.test.mjs
* Four end-to-end trials for vibecode-pro-max-kit v3.0.0.
* Uses Node built-in node:test — no external test dependencies.
* ALL writes go to /tmp — never touches the target/source repo.
*
* Run: node e2e-kit-flows.test.mjs
* Or: node --test e2e-kit-flows.test.mjs
*
* VC_KIT_SOURCE must point to the local kit for all trials.
* Default: KIT_PATH = the kit repo root (this file's directory)
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
const KIT_PATH = process.env.VC_KIT_SOURCE
?? path.dirname(new URL(import.meta.url).pathname);
const INSTALL_SH = path.join(KIT_PATH, 'install.sh');
/**
* Create a fresh /tmp project directory, optionally seeded with fixtures.
* CRITICAL: every project is git-initialized so discover-skills.mjs (git rev-parse) works.
* Returns the project path. Caller is responsible for cleanup.
*
* @param {object} opts
* @param {'empty'|'with-user-skill'|'v2x-snapshot'} opts.seed
* @returns {string} projectPath
*/
function seedProject(opts) {
const projectPath = fs.mkdtempSync('/tmp/vc-e2e-');
// Every project needs git init — discover-skills.mjs runs git rev-parse
execSync('git init -q', { cwd: projectPath });
execSync('git commit --allow-empty -m "init" -q', {
cwd: projectPath,
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test.com',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test.com',
},
});
if (opts.seed === 'with-user-skill') {
// Seed: user has a non-vc custom skill that must survive install
const userSkillDir = path.join(projectPath, '.claude/skills/my-custom-skill');
fs.mkdirSync(userSkillDir, { recursive: true });
fs.writeFileSync(
path.join(userSkillDir, 'SKILL.md'),
'# my-custom-skill\nThis is user-owned. Must survive install.'
);
// Seed: user has a non-kit root file
fs.writeFileSync(path.join(projectPath, 'MY_NOTES.txt'), 'user note file');
// Seed: user has .claude/settings.json (merge-preserved)
fs.mkdirSync(path.join(projectPath, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(projectPath, '.claude/settings.json'),
JSON.stringify(
{
userCustomSetting: true,
permissions: { allow: ['Bash(**/my-scripts/**:*)'] },
},
null,
2
)
);
}
if (opts.seed === 'v2x-snapshot') {
// Seed: simulate v2.4.1 install state
// Has deprecated vc-team skill (in legacyDeletions) present on disk
const vcTeamDir = path.join(projectPath, '.claude/skills/vc-team');
fs.mkdirSync(vcTeamDir, { recursive: true });
fs.writeFileSync(path.join(vcTeamDir, 'SKILL.md'), '# vc-team (deprecated v2.x skill)');
// Has a user skill that must survive
const userSkillDir = path.join(projectPath, '.claude/skills/my-tool');
fs.mkdirSync(userSkillDir, { recursive: true });
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), '# my-tool — user owned');
// Write a prior .vc-installed-files snapshot (v2.x state)
// Lists vc-team/SKILL.md as kit-installed (it was, in v2.x)
const v2Snapshot = [
'.claude/skills/vc-team/SKILL.md',
'CLAUDE.md',
'AGENTS.md',
].join('\n');
fs.writeFileSync(path.join(projectPath, '.vc-installed-files'), v2Snapshot);
fs.writeFileSync(path.join(projectPath, '.vc-version'), '2.4.1');
}
return projectPath;
}
// ──────────────────────────────────────────────────────────────
// Trial A: New install on empty project
// ──────────────────────────────────────────────────────────────
test('Trial A: new install on empty project', async () => {
const projectPath = seedProject({ seed: 'empty' });
try {
execSync(`bash ${INSTALL_SH}`, {
cwd: projectPath,
env: { ...process.env, VC_KIT_SOURCE: KIT_PATH },
stdio: 'pipe',
});
// AC-3: VC_KIT_SOURCE honored — installed version matches kit manifest version
const kitVersion = JSON.parse(
fs.readFileSync(path.join(KIT_PATH, 'vc-manifest.json'), 'utf8')
).version;
const installedVersion = fs.readFileSync(
path.join(projectPath, '.vc-version'),
'utf8'
).trim();
assert.equal(installedVersion, kitVersion, 'AC-3: installed version matches kit version');
// AC-2: no rm -rf wipe — .vc-installed-files written
assert.ok(
fs.existsSync(path.join(projectPath, '.vc-installed-files')),
'AC-2: .vc-installed-files written'
);
// AC-2 (code audit): install.sh does not contain the wipe line
const installShContent = fs.readFileSync(INSTALL_SH, 'utf8');
assert.ok(
!installShContent.includes('rm -rf .claude .codex .agents'),
'AC-2: install.sh does not contain rm -rf .claude .codex .agents wipe'
);
// vc-* skills directory present
assert.ok(
fs.existsSync(path.join(projectPath, '.claude/skills')),
'kit skills directory present'
);
// .agents/skills symlink or directory present
assert.ok(
fs.existsSync(path.join(projectPath, '.agents/skills')),
'.agents/skills exists'
);
// discover-skills exits 0 (tests git rev-parse + skill discovery)
execSync(
`node ${path.join(KIT_PATH, '.claude/skills/vc-context-discovery/scripts/discover-skills.mjs')}`,
{ cwd: projectPath, stdio: 'pipe' }
);
// No assertion needed — execSync throws on non-zero exit
} finally {
fs.rmSync(projectPath, { recursive: true, force: true });
}
});
// ──────────────────────────────────────────────────────────────
// Trial B: Coexistence — user-owned skills survive install
// ──────────────────────────────────────────────────────────────
test('Trial B: coexistence — user skills survive install', async () => {
const projectPath = seedProject({ seed: 'with-user-skill' });
// Capture user skill content before install
const userSkillPath = path.join(
projectPath,
'.claude/skills/my-custom-skill/SKILL.md'
);
const userSkillBefore = fs.readFileSync(userSkillPath);
// Capture user settings.json before install
const settingsPath = path.join(projectPath, '.claude/settings.json');
const settingsBefore = fs.readFileSync(settingsPath);
try {
execSync(`bash ${INSTALL_SH}`, {
cwd: projectPath,
env: { ...process.env, VC_KIT_SOURCE: KIT_PATH },
stdio: 'pipe',
});
// AC-1: user's custom skill is byte-identical after install
const userSkillAfter = fs.readFileSync(userSkillPath);
assert.equal(
Buffer.compare(userSkillBefore, userSkillAfter),
0,
'AC-1: user custom skill byte-identical after install'
);
// AC-1: user's non-kit root file untouched
assert.ok(
fs.existsSync(path.join(projectPath, 'MY_NOTES.txt')),
'AC-1: user MY_NOTES.txt untouched'
);
// AC-1: settings.json preserved (merge semantics — user version kept)
const settingsAfter = fs.readFileSync(settingsPath);
assert.equal(
Buffer.compare(settingsBefore, settingsAfter),
0,
'AC-1: .claude/settings.json merge preserved (user version kept)'
);
// AC-17: backup present (HAS_EXISTING=true because we seeded .claude/)
assert.ok(
fs.existsSync(path.join(projectPath, '.vibecode-backup')),
'AC-17: .vibecode-backup directory present'
);
// AC-2: kit vc-* skills installed alongside user skill
const skillDirs = fs.readdirSync(path.join(projectPath, '.claude/skills'));
const hasVcSkills = skillDirs.some((d) => d.startsWith('vc-'));
assert.ok(hasVcSkills, 'AC-2: vc-* skills installed');
assert.ok(
skillDirs.includes('my-custom-skill'),
'AC-1: my-custom-skill still present'
);
} finally {
fs.rmSync(projectPath, { recursive: true, force: true });
}
});
// ──────────────────────────────────────────────────────────────
// Trial C: v2.x → v3 migration via install
// ──────────────────────────────────────────────────────────────
test('Trial C: v2.x to v3 migration via install', async () => {
const projectPath = seedProject({ seed: 'v2x-snapshot' });
// Capture user tool before install
const userToolPath = path.join(
projectPath,
'.claude/skills/my-tool/SKILL.md'
);
const userToolBefore = fs.readFileSync(userToolPath);
try {
execSync(`bash ${INSTALL_SH}`, {
cwd: projectPath,
env: { ...process.env, VC_KIT_SOURCE: KIT_PATH },
stdio: 'pipe',
});
// AC-7: deprecated vc-team removed (legacyDeletion — full dir)
assert.ok(
!fs.existsSync(path.join(projectPath, '.claude/skills/vc-team')),
'AC-7: .claude/skills/vc-team removed (legacyDeletion)'
);
// AC-7: .vc-version updated to current kit version
const kitVersion = JSON.parse(
fs.readFileSync(path.join(KIT_PATH, 'vc-manifest.json'), 'utf8')
).version;
const installedVersion = fs.readFileSync(
path.join(projectPath, '.vc-version'),
'utf8'
).trim();
assert.equal(installedVersion, kitVersion, 'AC-7: .vc-version updated to v3.0.0');
// AC-7 / AC-1: user-owned my-tool untouched
const userToolAfter = fs.readFileSync(userToolPath);
assert.equal(
Buffer.compare(userToolBefore, userToolAfter),
0,
'AC-7: user tool byte-identical after migration install'
);
// AC-5: legacyDeletions audit — every path must start with a kit-owned prefix
const manifest = JSON.parse(
fs.readFileSync(path.join(KIT_PATH, 'vc-manifest.json'), 'utf8')
);
const legacyDeletions = manifest.legacyDeletions || [];
for (const p of legacyDeletions) {
const isKitOwned =
p.startsWith('.claude/skills/vc-') ||
p.startsWith('.claude/agents/vc-') ||
p.startsWith('process/development-protocols/');
assert.ok(
isKitOwned,
`AC-5: legacyDeletion '${p}' must start with .claude/skills/vc-, .claude/agents/vc-, or process/development-protocols/`
);
}
} finally {
fs.rmSync(projectPath, { recursive: true, force: true });
}
});
// ──────────────────────────────────────────────────────────────
// Trial D: Publish dry-run — no git push occurs without explicit approval
// ──────────────────────────────────────────────────────────────
test('Trial D: publish dry-run — no git push occurs without explicit approval', async () => {
// This trial is a proxy for "agent paused at push gate."
// We cannot automate the full vc-publish prose flow (it requires Claude Code).
// The proxy: assert SKILL.md contains the required gate language, then run
// compute-sync-plan.mjs in publish direction and assert git state unchanged.
const publishSkillPath = path.join(
KIT_PATH,
'.claude/skills/vc-publish/SKILL.md'
);
const skillContent = fs.readFileSync(publishSkillPath, 'utf8');
// AC-14: SKILL.md has explicit separate push gate (Step 9b)
assert.ok(
skillContent.includes('Explicit Push Approval') ||
(skillContent.includes('explicit') &&
skillContent.includes('push') &&
skillContent.includes('Step 9')),
'AC-14: vc-publish SKILL.md contains explicit push gate step'
);
// AC-14: NEVER auto-push rule present in SKILL.md Rules section
assert.ok(
skillContent.includes('NEVER auto-push') ||
skillContent.includes('never auto-push'),
'AC-14: vc-publish SKILL.md Rules section has NEVER auto-push rule'
);
// AC-15: leak detection step precedes commit/push step in the flow
const leakPos = skillContent.indexOf('Leak Detection');
const commitPos = skillContent.indexOf('Commit and Tag');
assert.ok(leakPos !== -1, 'AC-15: Leak Detection step present in vc-publish SKILL.md');
assert.ok(
commitPos !== -1,
'AC-15: Commit and Tag step present in vc-publish SKILL.md'
);
assert.ok(
leakPos < commitPos,
'AC-15: Leak Detection appears before Commit and Tag in the flow'
);
// Proxy: run compute-sync-plan.mjs in publish direction and assert git state unchanged
const tmpKitClone = fs.mkdtempSync('/tmp/vc-e2e-publish-');
try {
// Clone the local kit to a tmpdir (simulates kitRepoPath in vc-publish)
execSync(`git clone --local --quiet "${KIT_PATH}" "${tmpKitClone}"`, {
stdio: 'pipe',
});
// Capture git log before
const logBefore = execSync('git log --oneline', {
cwd: tmpKitClone,
encoding: 'utf8',
});
const tagsBefore = execSync('git tag -l', {
cwd: tmpKitClone,
encoding: 'utf8',
});
// Run compute-sync-plan.mjs (publish direction: project=tmpKitClone, kit=KIT_PATH)
// Pure computation — no git ops
execSync(
`node "${path.join(KIT_PATH, 'compute-sync-plan.mjs')}" --root "${tmpKitClone}" --kit-root "${KIT_PATH}" --json`,
{ stdio: 'pipe' }
);
// Capture git log after
const logAfter = execSync('git log --oneline', {
cwd: tmpKitClone,
encoding: 'utf8',
});
const tagsAfter = execSync('git tag -l', {
cwd: tmpKitClone,
encoding: 'utf8',
});
// AC-14: no commit or tag happened during computation
assert.equal(
logBefore,
logAfter,
'AC-14: git log unchanged after compute-sync-plan.mjs run'
);
assert.equal(
tagsBefore,
tagsAfter,
'AC-14: git tags unchanged after compute-sync-plan.mjs run'
);
} finally {
fs.rmSync(tmpKitClone, { recursive: true, force: true });
}
});