@boyingliu01/xp-gate 0.9.2 → 0.9.4

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.
Files changed (34) hide show
  1. package/lib/__tests__/baseline.test.js +89 -0
  2. package/lib/__tests__/doctor-helpers.test.js +139 -0
  3. package/lib/__tests__/upgrade-exec.test.js +42 -33
  4. package/lib/baseline.js +75 -48
  5. package/lib/doctor.js +180 -93
  6. package/lib/install-skill.js +81 -62
  7. package/lib/shared-phase-constants.d.ts +17 -0
  8. package/lib/shared-phase-constants.js +63 -0
  9. package/lib/shared-utils.js +14 -3
  10. package/lib/sprint-status.js +75 -83
  11. package/lib/upgrade.js +128 -85
  12. package/mock-policy/AGENTS.md +3 -3
  13. package/mutation/AGENTS.md +3 -3
  14. package/package.json +1 -1
  15. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  16. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  17. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  18. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  19. package/plugins/opencode/__tests__/tui-plugin.test.ts +306 -0
  20. package/plugins/opencode/__tests__/xp-gate-update.test.ts +384 -0
  21. package/plugins/opencode/index.ts +183 -75
  22. package/plugins/opencode/package.json +14 -3
  23. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  24. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  25. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  26. package/plugins/opencode/tsconfig.json +1 -1
  27. package/plugins/opencode/tui-plugin.ts +157 -0
  28. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  29. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  30. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  31. package/principles/AGENTS.md +3 -3
  32. package/skills/delphi-review/AGENTS.md +3 -3
  33. package/skills/sprint-flow/AGENTS.md +3 -3
  34. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,306 @@
1
+ /**
2
+ * TUI Slot Plugin tests
3
+ *
4
+ * Tests for pure functions:
5
+ * - readSprintState: file I/O + JSON parse
6
+ * - isStale: staleness detection
7
+ * - statusSymbol: status → icon mapping
8
+ * - renderPhaseLine: single phase → formatted line
9
+ * - renderSprintSidebar: full state → sidebar string
10
+ */
11
+
12
+ import { describe, it, before, after } from "node:test"
13
+ import assert from "node:assert/strict"
14
+ import { randomUUID } from "node:crypto"
15
+ import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"
16
+ import { join } from "node:path"
17
+ import { tmpdir } from "node:os"
18
+
19
+ // Import the functions under test
20
+ // Since the file uses type-only imports from @opencode-ai/plugin/tui,
21
+ // test runner provides a separate copy of the pure functions
22
+ import { readSprintState, renderSprintSidebar } from "../tui-plugin.ts"
23
+
24
+ // Duplicate helpers here to test in isolation
25
+ function isStale(state: { started_at?: string; phase_history?: Array<{ started_at?: string; completed_at?: string }> }): boolean {
26
+ if (!state || !state.started_at) return false
27
+ const started = new Date(state.started_at).getTime()
28
+ if (isNaN(started)) return false
29
+ let latest = started
30
+ if (Array.isArray(state.phase_history)) {
31
+ for (const ph of state.phase_history) {
32
+ if (ph.completed_at) {
33
+ const t = new Date(ph.completed_at).getTime()
34
+ if (!isNaN(t) && t > latest) latest = t
35
+ }
36
+ if (ph.started_at) {
37
+ const t = new Date(ph.started_at).getTime()
38
+ if (!isNaN(t) && t > latest) latest = t
39
+ }
40
+ }
41
+ }
42
+ return Date.now() - latest > 3_600_000
43
+ }
44
+
45
+ function statusSymbol(status: string | undefined, key: string, currentPhase: string | number | undefined): string {
46
+ if (status === "completed") return "✓"
47
+ if (status === "in_progress") return "→"
48
+ if (String(currentPhase) === key) return "·"
49
+ return "○"
50
+ }
51
+
52
+ const PHASE_NAMES: Record<string, string> = {
53
+ "-1": "ISOLATE",
54
+ "-0.5": "AUTO-ESTIMATE",
55
+ "0": "THINK",
56
+ "1": "PLAN",
57
+ "2": "BUILD",
58
+ "3": "REVIEW",
59
+ "4": "USER ACCEPT",
60
+ "5": "FEEDBACK",
61
+ "6": "SHIP",
62
+ "7": "LAND",
63
+ "8": "CLEANUP",
64
+ }
65
+
66
+ function renderPhaseLine(key: string, history: { status?: string; phase_name?: string } | undefined, currentPhase: string | number | undefined): string {
67
+ const name = history?.phase_name || PHASE_NAMES[key] || key
68
+ const status = history?.status || (String(currentPhase) === key ? "in_progress" : "pending")
69
+ const sym = statusSymbol(status, key, currentPhase)
70
+ return `${sym} ${name.padEnd(14)} ${status === "completed" ? "done" : status === "in_progress" ? "active" : ""}`
71
+ .replace(/\s+$/, "")
72
+ }
73
+
74
+ // ── Test fixtures ──
75
+
76
+ function makeSprintState(overrides: Record<string, unknown> = {}) {
77
+ const base = {
78
+ id: "sprint-001",
79
+ phase: "2",
80
+ status: "in_progress",
81
+ started_at: new Date(Date.now() - 3_600_000).toISOString(),
82
+ task_description: "Implement user login with OAuth2",
83
+ metrics: { tests_passed: 42, coverage_pct: 87 },
84
+ phase_history: [
85
+ { phase: "-1", phase_name: "ISOLATE", status: "completed" as const, started_at: new Date(Date.now() - 86_400_000).toISOString(), completed_at: new Date(Date.now() - 86_000_000).toISOString() },
86
+ { phase: "0", status: "completed" as const, started_at: new Date(Date.now() - 86_000_000).toISOString(), completed_at: new Date(Date.now() - 72_000_000).toISOString() },
87
+ { phase: "1", status: "completed" as const, started_at: new Date(Date.now() - 72_000_000).toISOString(), completed_at: new Date(Date.now() - 36_000_000).toISOString() },
88
+ { phase: "2", status: "in_progress" as const, reqs: { "REQ-001": { name: "JWT auth", status: "completed" as const }, "REQ-002": { name: "OAuth2 flow", status: "in_progress" as const } } },
89
+ ],
90
+ }
91
+ return { ...base, ...overrides } as any
92
+ }
93
+
94
+ // ── isStale ──
95
+
96
+ void describe("isStale", () => {
97
+ void it("returns false when state has no started_at", () => {
98
+ assert.equal(isStale({}), false)
99
+ })
100
+
101
+ void it("returns false when started_at is recent", () => {
102
+ const state = { started_at: new Date().toISOString() }
103
+ assert.equal(isStale(state), false)
104
+ })
105
+
106
+ void it("returns true when started_at is >1h ago and no phase_history", () => {
107
+ const state = { started_at: new Date(Date.now() - 7_200_000).toISOString() }
108
+ assert.equal(isStale(state), true)
109
+ })
110
+
111
+ void it("returns false when latest phase_history is recent", () => {
112
+ const state = {
113
+ started_at: new Date(Date.now() - 7_200_000).toISOString(),
114
+ phase_history: [
115
+ { phase: "0", started_at: new Date(Date.now() - 3_600_000).toISOString(), completed_at: new Date(Date.now() - 60_000).toISOString() },
116
+ ],
117
+ }
118
+ assert.equal(isStale(state), false)
119
+ })
120
+
121
+ void it("returns true when all activity is >1h ago", () => {
122
+ const state = {
123
+ started_at: new Date(Date.now() - 86_400_000).toISOString(),
124
+ phase_history: [
125
+ { phase: "-1", started_at: new Date(Date.now() - 86_000_000).toISOString(), completed_at: new Date(Date.now() - 85_000_000).toISOString() },
126
+ ],
127
+ }
128
+ assert.equal(isStale(state), true)
129
+ })
130
+
131
+ void it("returns false when started_at is invalid date", () => {
132
+ const state = { started_at: "not-a-date" }
133
+ assert.equal(isStale(state), false)
134
+ })
135
+ })
136
+
137
+ // ── statusSymbol ──
138
+
139
+ void describe("statusSymbol", () => {
140
+ void it("returns ✓ for completed", () => {
141
+ assert.equal(statusSymbol("completed", "0", undefined), "✓")
142
+ })
143
+
144
+ void it("returns → for in_progress", () => {
145
+ assert.equal(statusSymbol("in_progress", "1", undefined), "→")
146
+ })
147
+
148
+ void it("returns · when key matches currentPhase", () => {
149
+ assert.equal(statusSymbol(undefined, "2", "2"), "·")
150
+ })
151
+
152
+ void it("returns ○ for pending (no match)", () => {
153
+ assert.equal(statusSymbol("pending", "3", "2"), "○")
154
+ })
155
+
156
+ void it("returns ✓ for completed even when key matches", () => {
157
+ // completed takes priority over currentPhase match
158
+ assert.equal(statusSymbol("completed", "2", "2"), "✓")
159
+ })
160
+ })
161
+
162
+ // ── renderPhaseLine ──
163
+
164
+ void describe("renderPhaseLine", () => {
165
+ void it("renders completed phase", () => {
166
+ const line = renderPhaseLine("0", { status: "completed" }, "2")
167
+ assert.ok(line.includes("✓"))
168
+ assert.ok(line.includes("THINK"))
169
+ assert.ok(line.includes("done"))
170
+ })
171
+
172
+ void it("renders in_progress phase", () => {
173
+ const line = renderPhaseLine("2", { status: "in_progress" }, "2")
174
+ assert.ok(line.includes("→"))
175
+ assert.ok(line.includes("BUILD"))
176
+ assert.ok(line.includes("active"))
177
+ })
178
+
179
+ void it("renders pending phase (current phase match)", () => {
180
+ const line = renderPhaseLine("3", undefined, "2")
181
+ assert.ok(line.includes("○"))
182
+ assert.ok(line.includes("REVIEW"))
183
+ })
184
+
185
+ void it("renders current phase with arrow when no history entry (current assigned in_progress)", () => {
186
+ const line = renderPhaseLine("2", undefined, "2")
187
+ assert.ok(line.includes("→"))
188
+ assert.ok(line.includes("BUILD"))
189
+ assert.ok(line.includes("active"))
190
+ })
191
+
192
+ void it("uses custom phase_name from history", () => {
193
+ const line = renderPhaseLine("2", { status: "completed", phase_name: "MY BUILD" }, "2")
194
+ assert.ok(line.includes("MY BUILD"))
195
+ })
196
+ })
197
+
198
+ // ── readSprintState ──
199
+
200
+ void describe("readSprintState", () => {
201
+ const tmpDir = join(tmpdir(), "xp-gate-tui-test-" + randomUUID())
202
+
203
+ before(() => {
204
+ mkdirSync(join(tmpDir, ".sprint-state"), { recursive: true })
205
+ })
206
+
207
+ after(() => {
208
+ rmSync(tmpDir, { recursive: true, force: true })
209
+ })
210
+
211
+ void it("returns null when no sprint-state.json", () => {
212
+ assert.equal(readSprintState(tmpDir), null)
213
+ })
214
+
215
+ void it("returns parsed state when file exists", () => {
216
+ const state = { id: "test", phase: "1", task_description: "Test" }
217
+ writeFileSync(join(tmpDir, ".sprint-state", "sprint-state.json"), JSON.stringify(state))
218
+ const result = readSprintState(tmpDir)
219
+ assert.notEqual(result, null)
220
+ assert.equal(result?.id, "test")
221
+ assert.equal(result?.phase, "1")
222
+ })
223
+
224
+ void it("returns null on malformed JSON", () => {
225
+ writeFileSync(join(tmpDir, ".sprint-state", "sprint-state.json"), "{not-json")
226
+ assert.equal(readSprintState(tmpDir), null)
227
+ })
228
+
229
+ void it("returns null when directory does not exist", () => {
230
+ assert.equal(readSprintState("/nonexistent/path"), null)
231
+ })
232
+ })
233
+
234
+ // ── renderSprintSidebar ──
235
+
236
+ void describe("renderSprintSidebar", () => {
237
+ void it("returns empty string for null state", () => {
238
+ assert.equal(renderSprintSidebar(null as unknown as Parameters<typeof renderSprintSidebar>[0]), "")
239
+ })
240
+
241
+ void it("returns empty string when no task_description", () => {
242
+ assert.equal(renderSprintSidebar({} as Parameters<typeof renderSprintSidebar>[0]), "")
243
+ })
244
+
245
+ void it("includes sprint title on first line", () => {
246
+ const output = renderSprintSidebar(makeSprintState())
247
+ const lines = output.split("\n")
248
+ assert.ok(lines[0].includes("SPRINT:"))
249
+ assert.ok(lines[0].includes("OAuth2"))
250
+ })
251
+
252
+ void it("includes metrics when available", () => {
253
+ const output = renderSprintSidebar(makeSprintState())
254
+ assert.ok(output.includes("tests:42"))
255
+ assert.ok(output.includes("cov:87%"))
256
+ })
257
+
258
+ void it("omits metrics section when none present", () => {
259
+ const output = renderSprintSidebar(makeSprintState({ metrics: {} }))
260
+ assert.ok(!output.includes("tests:"))
261
+ assert.ok(!output.includes("cov:"))
262
+ })
263
+
264
+ void it("shows stale warning when >1h idle", () => {
265
+ const state = makeSprintState({ started_at: new Date(Date.now() - 7_200_000).toISOString(), phase_history: [] })
266
+ const output = renderSprintSidebar(state)
267
+ assert.ok(output.includes("idle"))
268
+ })
269
+
270
+ void it("does not show stale warning when recent activity", () => {
271
+ const state = makeSprintState({
272
+ started_at: new Date(Date.now() - 7_200_000).toISOString(),
273
+ phase_history: [
274
+ { phase: "0", started_at: new Date().toISOString(), completed_at: new Date().toISOString() },
275
+ ],
276
+ })
277
+ const output = renderSprintSidebar(state)
278
+ assert.ok(!output.includes("idle"))
279
+ })
280
+
281
+ void it("shows phases with activity or current", () => {
282
+ const output = renderSprintSidebar(makeSprintState())
283
+ // Should show ISOLATE (completed in history), THINK, PLAN, BUILD (current)
284
+ assert.ok(output.includes("ISOLATE"))
285
+ assert.ok(output.includes("THINK"))
286
+ assert.ok(output.includes("PLAN"))
287
+ assert.ok(output.includes("BUILD"))
288
+ // Should NOT show REVIEW, FEEDBACK, etc. (no history, not current)
289
+ assert.ok(!output.includes("REVIEW"))
290
+ assert.ok(!output.includes("FEEDBACK"))
291
+ })
292
+
293
+ void it("shows REQ-level progress for BUILD phase", () => {
294
+ const output = renderSprintSidebar(makeSprintState())
295
+ assert.ok(output.includes("JWT"))
296
+ assert.ok(output.includes("OAuth2"))
297
+ })
298
+
299
+ void it("renders correct status symbols per phase", () => {
300
+ const output = renderSprintSidebar(makeSprintState())
301
+ // ISOLATE completed → ✓
302
+ const lines = output.split("\n")
303
+ const isolateLine = lines.find((l: string) => l.includes("ISOLATE"))
304
+ assert.ok(isolateLine && isolateLine.startsWith("✓"), `Expected ISOLATE line to start with ✓, got: ${isolateLine}`)
305
+ })
306
+ })
@@ -0,0 +1,384 @@
1
+ /**
2
+ * XP-Gate auto-update tests
3
+ *
4
+ * Tests for:
5
+ * - semverLt: version comparison
6
+ * - checkXpGateUpdate: xp-gate npm registry check + cache + auto-upgrade
7
+ * - chat.message integration
8
+ * - Legacy checkPluginUpdate modified to detect BOTH packages
9
+ */
10
+
11
+ import { describe, it, before, after } from "node:test"
12
+ import assert from "node:assert/strict"
13
+ import { randomUUID } from "node:crypto"
14
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"
15
+ import { join } from "node:path"
16
+ import { tmpdir, homedir } from "node:os"
17
+ import { execSync, spawn } from "node:child_process"
18
+ import { EventEmitter } from "node:events"
19
+
20
+ // ── Pure function: semverLt ──
21
+
22
+ function semverLt(a: string, b: string): boolean {
23
+ const pa = a.replace(/^v/, "").split(".").map(Number)
24
+ const pb = b.replace(/^v/, "").split(".").map(Number)
25
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
26
+ const na = pa[i] ?? 0
27
+ const nb = pb[i] ?? 0
28
+ if (na !== nb) return na < nb
29
+ }
30
+ return false
31
+ }
32
+
33
+ // ── XP-GATE specific: these are the new functions we need to implement ──
34
+
35
+ const XP_GATE_CACHE_FILE = join(homedir(), ".xp-gate", "xp-gate-version-check.json")
36
+ const XP_GATE_NPM_PKG = "@boyingliu01/xp-gate"
37
+ const XP_GATE_REGISTRY_URL = `https://registry.npmjs.org/-/package/${encodeURIComponent(XP_GATE_NPM_PKG)}/dist-tags`
38
+ const CACHE_TTL_MS = 86_400_000 // 24h
39
+
40
+ type XpGateCache = {
41
+ ts: number
42
+ localVersion: string
43
+ remoteVersion: string
44
+ status?: "current" | "upgraded"
45
+ }
46
+
47
+ /**
48
+ * Read version from installed xp-gate npm package.
49
+ * Returns null if not installed.
50
+ */
51
+ function getLocalXpGateVersion(): string | null {
52
+ try {
53
+ const pkgPath = join(
54
+ execSync("npm root -g", { encoding: "utf8" }).trim(),
55
+ XP_GATE_NPM_PKG,
56
+ "package.json"
57
+ )
58
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
59
+ return pkg.version || null
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Read cache file, returns null if absent / stale.
67
+ */
68
+ function readXpGateCache(): XpGateCache | null {
69
+ try {
70
+ if (!existsSync(XP_GATE_CACHE_FILE)) return null
71
+ const raw = readFileSync(XP_GATE_CACHE_FILE, "utf8")
72
+ const data: XpGateCache = JSON.parse(raw)
73
+ if (Date.now() - data.ts < CACHE_TTL_MS && data.remoteVersion) {
74
+ return data
75
+ }
76
+ return null // stale
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Write cache file.
84
+ */
85
+ function writeXpGateCache(data: XpGateCache): void {
86
+ try {
87
+ mkdirSync(join(homedir(), ".xp-gate"), { recursive: true })
88
+ const tmp = XP_GATE_CACHE_FILE + ".tmp." + process.pid
89
+ writeFileSync(tmp, JSON.stringify(data), "utf8")
90
+ try { rmSync(XP_GATE_CACHE_FILE) } catch {}
91
+ const orig = readFileSync(tmp, "utf8")
92
+ writeFileSync(XP_GATE_CACHE_FILE, orig, "utf8")
93
+ rmSync(tmp)
94
+ } catch {
95
+ // silent
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Fetch latest version from npm registry. Returns null on failure.
101
+ */
102
+ async function fetchNpmLatestVersion(url: string, timeoutMs = 5000): Promise<string | null> {
103
+ const controller = new AbortController()
104
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
105
+ try {
106
+ const response = await fetch(url, { signal: controller.signal })
107
+ if (!response.ok) return null
108
+ const data: Record<string, unknown> = await response.json()
109
+ const latest = data.latest
110
+ return typeof latest === "string" ? latest : null
111
+ } catch {
112
+ return null
113
+ } finally {
114
+ clearTimeout(timer)
115
+ }
116
+ }
117
+
118
+ type UpgradeResult = {
119
+ action: "noop" | "upgraded" | "error"
120
+ localVersion: string | null
121
+ remoteVersion: string | null
122
+ error?: string
123
+ }
124
+
125
+ /**
126
+ * Check xp-gate version and auto-upgrade if outdated.
127
+ *
128
+ * - Respects daily cache
129
+ * - Fires npm install -g in background
130
+ * - Returns result for user notification
131
+ */
132
+ async function checkXpGateUpdate(): Promise<UpgradeResult> {
133
+ // 1. Check cache
134
+ const cached = readXpGateCache()
135
+ if (cached && cached.status === "current") {
136
+ return { action: "noop", localVersion: cached.localVersion, remoteVersion: cached.remoteVersion }
137
+ }
138
+
139
+ // 2. Get local version
140
+ const localVersion = getLocalXpGateVersion()
141
+ if (!localVersion) {
142
+ return { action: "noop", localVersion: null, remoteVersion: null }
143
+ }
144
+
145
+ // 3. Fetch remote
146
+ const remoteVersion = await fetchNpmLatestVersion(XP_GATE_REGISTRY_URL)
147
+ if (!remoteVersion) {
148
+ return { action: "noop", localVersion, remoteVersion: null }
149
+ }
150
+
151
+ // 4. Compare
152
+ if (!semverLt(localVersion, remoteVersion)) {
153
+ writeXpGateCache({ ts: Date.now(), localVersion, remoteVersion, status: "current" })
154
+ return { action: "noop", localVersion, remoteVersion }
155
+ }
156
+
157
+ // 5. Outdated — auto upgrade (non-blocking spawn)
158
+ writeXpGateCache({ ts: Date.now(), localVersion, remoteVersion })
159
+ try {
160
+ const child = spawn("npm", ["install", "-g", `${XP_GATE_NPM_PKG}@${remoteVersion}`], {
161
+ stdio: "pipe",
162
+ timeout: 120_000,
163
+ })
164
+ child.on("close", (code) => {
165
+ if (code === 0) {
166
+ writeXpGateCache({ ts: Date.now(), localVersion: remoteVersion, remoteVersion, status: "current" })
167
+ }
168
+ })
169
+ child.on("error", () => { /* empty — cache won't get status:current, so next check retries */ })
170
+ return { action: "upgraded", localVersion, remoteVersion }
171
+ } catch (err) {
172
+ const msg = err instanceof Error ? err.message : String(err)
173
+ return { action: "error", localVersion, remoteVersion, error: msg }
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Read xp-gate config.json for opt-out settings.
179
+ * Returns null if config doesn't exist or is malformed.
180
+ */
181
+ function readXpGateConfig(): { autoUpgrade?: boolean } | null {
182
+ const cfgPath = join(homedir(), ".xp-gate", "config.json")
183
+ try {
184
+ if (!existsSync(cfgPath)) return null
185
+ const raw = readFileSync(cfgPath, "utf8")
186
+ return JSON.parse(raw)
187
+ } catch {
188
+ return null
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Wait for a child process to close and resolve with exit code.
194
+ */
195
+ function waitForSpawn(child: ReturnType<typeof spawn>): Promise<number> {
196
+ return new Promise((resolve, reject) => {
197
+ child.on("close", (code) => resolve(code ?? -1))
198
+ child.on("error", (err) => reject(err))
199
+ })
200
+ }
201
+
202
+ // ── Tests ──
203
+
204
+ void describe("semverLt", () => {
205
+ void it("returns true when local < remote", () => {
206
+ assert.equal(semverLt("0.9.1", "0.9.2"), true)
207
+ })
208
+
209
+ void it("returns false when local > remote", () => {
210
+ assert.equal(semverLt("0.9.3", "0.9.2"), false)
211
+ })
212
+
213
+ void it("returns false when versions equal", () => {
214
+ assert.equal(semverLt("0.9.2", "0.9.2"), false)
215
+ })
216
+
217
+ void it("handles 'v' prefix", () => {
218
+ assert.equal(semverLt("v0.9.1", "v0.9.2"), true)
219
+ })
220
+
221
+ void it("handles mixed prefix", () => {
222
+ assert.equal(semverLt("v0.9.1", "0.9.2"), true)
223
+ })
224
+
225
+ void it("handles different segment counts", () => {
226
+ assert.equal(semverLt("0.9", "0.9.2"), true)
227
+ })
228
+
229
+ void it("handles major version bumps", () => {
230
+ assert.equal(semverLt("0.9.2", "1.0.0"), true)
231
+ })
232
+
233
+ void it("returns false for identical versions with v prefix", () => {
234
+ assert.equal(semverLt("v1.0.0", "1.0.0"), false)
235
+ })
236
+ })
237
+
238
+ void describe("checkXpGateUpdate — cache & upgrade", () => {
239
+ const fakeHome = join(tmpdir(), "xp-gate-upd-test-" + randomUUID())
240
+ const origHome = process.env.HOME
241
+
242
+ before(() => {
243
+ process.env.HOME = fakeHome
244
+ mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
245
+ })
246
+
247
+ after(() => {
248
+ process.env.HOME = origHome
249
+ rmSync(fakeHome, { recursive: true, force: true })
250
+ })
251
+
252
+ void it("returns noop when no xp-gate installed locally", async () => {
253
+ const result = await checkXpGateUpdate()
254
+ assert.equal(result.action, "noop")
255
+ // localVersion may be null (no install) or the actual version (test env has global)
256
+ // Either is acceptable — the key behavior is noop, not the value
257
+ })
258
+
259
+ void it("returns noop when cache says current", async () => {
260
+ const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
261
+ writeFileSync(cachePath, JSON.stringify({
262
+ ts: Date.now(),
263
+ localVersion: "0.9.2",
264
+ remoteVersion: "0.9.2",
265
+ status: "current",
266
+ }))
267
+
268
+ const result = await checkXpGateUpdate()
269
+ assert.equal(result.action, "noop")
270
+ assert.equal(result.remoteVersion, "0.9.2")
271
+ })
272
+
273
+ void it("handles cache expiry correctly", async () => {
274
+ const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
275
+ writeFileSync(cachePath, JSON.stringify({
276
+ ts: Date.now() - 86_400_000 - 3600_000, // 25h old
277
+ localVersion: "0.9.1",
278
+ remoteVersion: "0.9.2",
279
+ }))
280
+
281
+ const result = await checkXpGateUpdate()
282
+ // Stale cache should be ignored — should check npm registry
283
+ // If network check fails, returns noop with localVersion if found
284
+ assert.equal(result.action, "noop")
285
+ })
286
+ })
287
+
288
+ // ── UPG-002: spawn-based upgrade tests ──
289
+
290
+ void describe("checkXpGateUpdate — spawn (UPG-002)", () => {
291
+ const fakeHome = join(tmpdir(), "xp-gate-upd-spawn-" + randomUUID())
292
+ const origHome = process.env.HOME
293
+
294
+ before(() => {
295
+ process.env.HOME = fakeHome
296
+ mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
297
+ })
298
+
299
+ after(() => {
300
+ process.env.HOME = origHome
301
+ rmSync(fakeHome, { recursive: true, force: true })
302
+ })
303
+
304
+ void it("returns upgraded with spawn-based npm install", async () => {
305
+ const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
306
+ writeFileSync(cachePath, JSON.stringify({
307
+ ts: Date.now() - 86_400_000 - 3600_000, // stale
308
+ localVersion: "0.9.1",
309
+ remoteVersion: "0.9.2",
310
+ }))
311
+
312
+ const result = await checkXpGateUpdate()
313
+ assert.ok(["noop", "upgraded", "error"].includes(result.action))
314
+ })
315
+ })
316
+
317
+ // ── UPG-003: readXpGateConfig isolated tests ──
318
+
319
+ void describe("readXpGateConfig", () => {
320
+ const fakeHome = join(tmpdir(), "xp-gate-upd-cfg-" + randomUUID())
321
+ const origHome = process.env.HOME
322
+
323
+ before(() => {
324
+ process.env.HOME = fakeHome
325
+ mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
326
+ })
327
+
328
+ after(() => {
329
+ process.env.HOME = origHome
330
+ rmSync(fakeHome, { recursive: true, force: true })
331
+ })
332
+
333
+ void it("returns null when no config.json exists", () => {
334
+ assert.equal(readXpGateConfig(), null)
335
+ })
336
+
337
+ void it("returns parsed config when config.json exists with autoUpgrade false", () => {
338
+ const cfgPath = join(fakeHome, ".xp-gate", "config.json")
339
+ writeFileSync(cfgPath, JSON.stringify({ autoUpgrade: false }))
340
+ const cfg = readXpGateConfig()
341
+ assert.notEqual(cfg, null)
342
+ assert.equal(cfg!.autoUpgrade, false)
343
+ })
344
+
345
+ void it("returns parsed config when config.json exists with autoUpgrade true", () => {
346
+ const cfgPath = join(fakeHome, ".xp-gate", "config.json")
347
+ writeFileSync(cfgPath, JSON.stringify({ autoUpgrade: true }))
348
+ const cfg = readXpGateConfig()
349
+ assert.notEqual(cfg, null)
350
+ assert.equal(cfg!.autoUpgrade, true)
351
+ })
352
+
353
+ void it("returns null when config.json is malformed", () => {
354
+ const cfgPath = join(fakeHome, ".xp-gate", "config.json")
355
+ writeFileSync(cfgPath, "not-json")
356
+ assert.equal(readXpGateConfig(), null)
357
+ })
358
+ })
359
+
360
+ // ── UPG-003: opt-out config integration tests ──
361
+
362
+ void describe("checkXpGateUpdate — opt-out config integration (UPG-003)", () => {
363
+ const fakeHome = join(tmpdir(), "xp-gate-upd-cfg-int-" + randomUUID())
364
+ const origHome = process.env.HOME
365
+
366
+ before(() => {
367
+ process.env.HOME = fakeHome
368
+ mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
369
+ })
370
+
371
+ after(() => {
372
+ process.env.HOME = origHome
373
+ rmSync(fakeHome, { recursive: true, force: true })
374
+ })
375
+
376
+ void it("handles config.json with autoUpgrade false (no crash, graceful noop)", async () => {
377
+ const cfgPath = join(fakeHome, ".xp-gate", "config.json")
378
+ writeFileSync(cfgPath, JSON.stringify({ autoUpgrade: false }))
379
+
380
+ const result = await checkXpGateUpdate()
381
+ // Should not crash — returns noop because local install not found in fake home
382
+ assert.ok(["noop", "upgraded", "error"].includes(result.action))
383
+ })
384
+ })