forked from garrytan/gstack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmigration-checkpoint-ownership.test.ts
More file actions
147 lines (126 loc) · 6.18 KB
/
Copy pathmigration-checkpoint-ownership.test.ts
File metadata and controls
147 lines (126 loc) · 6.18 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
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..');
const MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v1.1.3.0.sh');
function runMigration(tmpHome: string): { exitCode: number; stdout: string; stderr: string } {
const result = spawnSync('bash', [MIGRATION], {
env: { ...process.env, HOME: tmpHome },
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10_000,
});
return {
exitCode: result.status ?? 1,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
};
}
function setupFakeGstackRoot(tmpHome: string): string {
// A real target that the gstack symlink can resolve into.
const gstackDir = path.join(tmpHome, '.claude', 'skills', 'gstack');
fs.mkdirSync(path.join(gstackDir, 'checkpoint'), { recursive: true });
fs.writeFileSync(path.join(gstackDir, 'checkpoint', 'SKILL.md'), '# fake gstack checkpoint\n');
return gstackDir;
}
describe('migration v1.1.3.0 — checkpoint ownership guard', () => {
let tmpHome: string;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-migration-ownership-'));
});
afterEach(() => {
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
});
test('scenario A: directory symlink into gstack → removed', () => {
setupFakeGstackRoot(tmpHome);
const skillsDir = path.join(tmpHome, '.claude', 'skills');
const gstackCheckpoint = path.join(skillsDir, 'gstack', 'checkpoint');
const topLevel = path.join(skillsDir, 'checkpoint');
fs.symlinkSync(gstackCheckpoint, topLevel);
const result = runMigration(tmpHome);
expect(result.exitCode).toBe(0);
expect(fs.existsSync(topLevel)).toBe(false);
// Also removes the gstack-owned inner copy (Shape 2 cleanup).
expect(fs.existsSync(gstackCheckpoint)).toBe(false);
expect(result.stdout).toContain('Removed stale /checkpoint symlink');
});
test('scenario B: directory with SKILL.md symlinked into gstack → removed', () => {
setupFakeGstackRoot(tmpHome);
const skillsDir = path.join(tmpHome, '.claude', 'skills');
const gstackSKILL = path.join(skillsDir, 'gstack', 'checkpoint', 'SKILL.md');
const topLevel = path.join(skillsDir, 'checkpoint');
fs.mkdirSync(topLevel, { recursive: true });
fs.symlinkSync(gstackSKILL, path.join(topLevel, 'SKILL.md'));
const result = runMigration(tmpHome);
expect(result.exitCode).toBe(0);
expect(fs.existsSync(topLevel)).toBe(false);
expect(result.stdout).toContain('Removed stale /checkpoint install directory');
});
test('scenario C: user-owned regular directory with custom content → preserved', () => {
setupFakeGstackRoot(tmpHome);
const skillsDir = path.join(tmpHome, '.claude', 'skills');
const topLevel = path.join(skillsDir, 'checkpoint');
fs.mkdirSync(topLevel, { recursive: true });
// User's own custom skill: regular file, not a symlink.
fs.writeFileSync(path.join(topLevel, 'SKILL.md'), '# my custom /checkpoint\n');
fs.writeFileSync(path.join(topLevel, 'extra.txt'), 'user content\n');
const result = runMigration(tmpHome);
expect(result.exitCode).toBe(0);
expect(fs.existsSync(topLevel)).toBe(true);
expect(fs.existsSync(path.join(topLevel, 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(topLevel, 'extra.txt'))).toBe(true);
expect(result.stdout).toContain('Leaving');
expect(result.stdout).toContain('not a gstack-owned install');
});
test('scenario D: symlink pointing outside gstack → preserved', () => {
setupFakeGstackRoot(tmpHome);
const skillsDir = path.join(tmpHome, '.claude', 'skills');
const topLevel = path.join(skillsDir, 'checkpoint');
// User's own skill elsewhere on the filesystem.
const userSkillDir = path.join(tmpHome, 'my-own-skill');
fs.mkdirSync(userSkillDir, { recursive: true });
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), '# my custom /checkpoint\n');
fs.symlinkSync(userSkillDir, topLevel);
const result = runMigration(tmpHome);
expect(result.exitCode).toBe(0);
expect(fs.existsSync(topLevel)).toBe(true);
// The user's underlying dir is untouched.
expect(fs.existsSync(path.join(userSkillDir, 'SKILL.md'))).toBe(true);
expect(result.stdout).toContain('Leaving');
expect(result.stdout).toContain('outside gstack');
});
test('scenario E: nothing to do → no-op exit 0 (idempotent)', () => {
// No checkpoint install at all. First run: nothing removed.
setupFakeGstackRoot(tmpHome);
// Delete the inner gstack/checkpoint to simulate post-upgrade state.
fs.rmSync(path.join(tmpHome, '.claude', 'skills', 'gstack', 'checkpoint'), { recursive: true, force: true });
const result1 = runMigration(tmpHome);
expect(result1.exitCode).toBe(0);
// Second run: still exit 0, still no-op.
const result2 = runMigration(tmpHome);
expect(result2.exitCode).toBe(0);
});
test('scenario F: gstack not installed → no-op exit 0', () => {
// No ~/.claude/skills/gstack/ at all. Also no checkpoint install.
fs.mkdirSync(path.join(tmpHome, '.claude', 'skills'), { recursive: true });
const result = runMigration(tmpHome);
expect(result.exitCode).toBe(0);
});
test('scenario G: SKILL.md is a symlink pointing outside gstack → preserved', () => {
setupFakeGstackRoot(tmpHome);
const skillsDir = path.join(tmpHome, '.claude', 'skills');
const topLevel = path.join(skillsDir, 'checkpoint');
fs.mkdirSync(topLevel, { recursive: true });
// A directory containing SKILL.md that's a symlink pointing outside gstack.
const externalSkill = path.join(tmpHome, 'external', 'SKILL.md');
fs.mkdirSync(path.dirname(externalSkill), { recursive: true });
fs.writeFileSync(externalSkill, '# external skill\n');
fs.symlinkSync(externalSkill, path.join(topLevel, 'SKILL.md'));
const result = runMigration(tmpHome);
expect(result.exitCode).toBe(0);
expect(fs.existsSync(topLevel)).toBe(true);
expect(fs.existsSync(path.join(topLevel, 'SKILL.md'))).toBe(true);
expect(result.stdout).toContain('Leaving');
});
});