@boyingliu01/xp-gate 0.10.10 → 0.10.12
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/lib/__tests__/check-version.test.js +19 -5
- package/lib/__tests__/install-skill.test.js +1 -1
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +6 -9
- package/plugins/claude-code/skills/sprint-flow/references/orchestration-rules.md +8 -6
- package/plugins/claude-code/skills/sprint-flow/references/phase-1-plan.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +3 -1
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/__tests__/tui-plugin.test.ts +9 -9
- package/plugins/opencode/__tests__/xp-gate-e2e-upgrade.test.ts +562 -0
- package/plugins/opencode/__tests__/xp-gate-update.test.ts +117 -11
- package/plugins/opencode/index.ts +14 -14
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/SKILL.md +6 -9
- package/plugins/opencode/skills/sprint-flow/references/orchestration-rules.md +8 -6
- package/plugins/opencode/skills/sprint-flow/references/phase-1-plan.md +2 -2
- package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +3 -1
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/SKILL.md +191 -1088
- package/plugins/qoder/skills/sprint-flow/references/orchestration-rules.md +365 -0
- package/plugins/qoder/skills/sprint-flow/references/phase-1-plan.md +2 -2
- package/plugins/qoder/skills/sprint-flow/references/phase-2-build.md +25 -180
- package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +3 -1
- package/plugins/qoder/skills/sprint-flow/references/phase-6-ship.md +4 -188
- package/plugins/qoder/skills/sprint-flow/references/phase-7-land.md +4 -135
- package/plugins/qoder/skills/sprint-flow/references/phase-8-cleanup.md +4 -187
- package/plugins/qoder/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +6 -0
- package/plugins/qoder/skills/sprint-flow/references/phase-minus-1-isolate.md +58 -0
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/sprint-flow/SKILL.md +6 -9
- package/skills/sprint-flow/references/orchestration-rules.md +8 -6
- package/skills/sprint-flow/references/phase-1-plan.md +2 -2
- package/skills/sprint-flow/references/phase-3-review.md +3 -1
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XP-Gate E2E Upgrade Test
|
|
3
|
+
*
|
|
4
|
+
* End-to-end tests for the OpenCode plugin's auto-upgrade flow:
|
|
5
|
+
* checkXpGateUpdate → writeUpgradeNotice → chat.message hook
|
|
6
|
+
*
|
|
7
|
+
* These tests validate the FULL code path that was broken before the
|
|
8
|
+
* fire-and-forget fix (commit 9bad3c1):
|
|
9
|
+
* - spawn must be awaited (not discarded when hook returns)
|
|
10
|
+
* - cache must be written with status:current after spawn completes
|
|
11
|
+
* - upgrade-notice.json must be written for TUI display
|
|
12
|
+
* - runBackgroundUpdates must return the upgrade message
|
|
13
|
+
*
|
|
14
|
+
* All tests use REAL `npm install -g` (skipped in CI).
|
|
15
|
+
*
|
|
16
|
+
* Function implementations are copied from plugins/opencode/index.ts
|
|
17
|
+
* because importing from index.ts requires @opencode-ai/plugin runtime.
|
|
18
|
+
* This mirrors the existing pattern in xp-gate-update.test.ts.
|
|
19
|
+
*
|
|
20
|
+
* @test E2E-001 Full runBackgroundUpdates flow
|
|
21
|
+
* @test E2E-002 Await timing verification
|
|
22
|
+
* @test E2E-003 Upgrade notice file content
|
|
23
|
+
* @test E2E-004 Chat.message hook simulation
|
|
24
|
+
* @test E2E-005 Cache integrity after spawn
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, before, after } from "node:test"
|
|
28
|
+
import assert from "node:assert/strict"
|
|
29
|
+
import { randomUUID } from "node:crypto"
|
|
30
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"
|
|
31
|
+
import { join } from "node:path"
|
|
32
|
+
import { tmpdir, homedir } from "node:os"
|
|
33
|
+
import { execSync, spawn } from "node:child_process"
|
|
34
|
+
|
|
35
|
+
// ── Constants (must match index.ts) ──
|
|
36
|
+
|
|
37
|
+
const CACHE_TTL_MS = 86_400_000 // 24h
|
|
38
|
+
const FETCH_TIMEOUT_MS = 5_000
|
|
39
|
+
|
|
40
|
+
const XP_GATE_NPM_PKG = "@boyingliu01/xp-gate"
|
|
41
|
+
const XP_GATE_CACHE_FILE = join(homedir(), ".xp-gate", "xp-gate-version-check.json")
|
|
42
|
+
const XP_GATE_REGISTRY_URL = `https://registry.npmjs.org/-/package/${encodeURIComponent(XP_GATE_NPM_PKG)}/dist-tags`
|
|
43
|
+
const UPGRADE_NOTICE_FILE = join(homedir(), ".xp-gate", "upgrade-notice.json")
|
|
44
|
+
|
|
45
|
+
// ── Types (must match index.ts) ──
|
|
46
|
+
|
|
47
|
+
type UpgradeResult = {
|
|
48
|
+
action: "noop" | "upgraded" | "error"
|
|
49
|
+
localVersion: string | null
|
|
50
|
+
remoteVersion: string | null
|
|
51
|
+
error?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type UpgradeNotice = {
|
|
55
|
+
kind: "upgraded" | "outdated" | "error"
|
|
56
|
+
localVersion: string | null
|
|
57
|
+
remoteVersion: string | null
|
|
58
|
+
message: string
|
|
59
|
+
ts: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type CacheEntry = {
|
|
63
|
+
ts: number
|
|
64
|
+
remoteVersion: string
|
|
65
|
+
localVersion?: string
|
|
66
|
+
status?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Utilities (must match index.ts) ──
|
|
70
|
+
|
|
71
|
+
function semverLt(a: string, b: string): boolean {
|
|
72
|
+
const pa = a.replace(/^v/, "").split(".").map(Number)
|
|
73
|
+
const pb = b.replace(/^v/, "").split(".").map(Number)
|
|
74
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
75
|
+
const na = pa[i] ?? 0
|
|
76
|
+
const nb = pb[i] ?? 0
|
|
77
|
+
if (na !== nb) return na < nb
|
|
78
|
+
}
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function fetchNpmLatestVersion(url: string): Promise<string | null> {
|
|
83
|
+
const controller = new AbortController()
|
|
84
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(url, { signal: controller.signal })
|
|
87
|
+
if (!response.ok) return null
|
|
88
|
+
const data: Record<string, unknown> = await response.json()
|
|
89
|
+
return typeof data.latest === "string" ? data.latest : null
|
|
90
|
+
} catch {
|
|
91
|
+
return null
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readCache(file: string): CacheEntry | null {
|
|
98
|
+
try {
|
|
99
|
+
if (!existsSync(file)) return null
|
|
100
|
+
const raw = readFileSync(file, "utf8")
|
|
101
|
+
const data = JSON.parse(raw)
|
|
102
|
+
if (Date.now() - data.ts < CACHE_TTL_MS && data.remoteVersion) return data
|
|
103
|
+
return null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeCache(file: string, data: object): void {
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(join(homedir(), ".xp-gate"), { recursive: true })
|
|
112
|
+
writeFileSync(file + ".tmp", JSON.stringify(data), "utf8")
|
|
113
|
+
try { rmSync(file) } catch {}
|
|
114
|
+
writeFileSync(file, readFileSync(file + ".tmp", "utf8"), "utf8")
|
|
115
|
+
try { rmSync(file + ".tmp") } catch {}
|
|
116
|
+
} catch {
|
|
117
|
+
// silent
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readXpGateConfig(): { autoUpgrade?: boolean } | null {
|
|
122
|
+
const cfgPath = join(homedir(), ".xp-gate", "config.json")
|
|
123
|
+
try {
|
|
124
|
+
if (!existsSync(cfgPath)) return null
|
|
125
|
+
return JSON.parse(readFileSync(cfgPath, "utf8"))
|
|
126
|
+
} catch {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Local version override (same pattern as xp-gate-update.test.ts) ──
|
|
132
|
+
|
|
133
|
+
let getLocalVersionOverride: (() => string | null) | null = null
|
|
134
|
+
|
|
135
|
+
function getLocalXpGateVersion(): string | null {
|
|
136
|
+
if (getLocalVersionOverride) return getLocalVersionOverride()
|
|
137
|
+
try {
|
|
138
|
+
const globalRoot = execSync("npm root -g", { encoding: "utf8" }).trim()
|
|
139
|
+
const pkg = JSON.parse(readFileSync(join(globalRoot, XP_GATE_NPM_PKG, "package.json"), "utf8"))
|
|
140
|
+
return pkg.version || null
|
|
141
|
+
} catch {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Core functions (MUST match index.ts EXACTLY) ──
|
|
147
|
+
|
|
148
|
+
async function checkXpGateUpdate(): Promise<UpgradeResult> {
|
|
149
|
+
const cached = readCache(XP_GATE_CACHE_FILE)
|
|
150
|
+
const localVersion = getLocalXpGateVersion()
|
|
151
|
+
|
|
152
|
+
if (cached?.status === "current" && cached.remoteVersion && localVersion && cached.localVersion === localVersion) {
|
|
153
|
+
return { action: "noop", localVersion, remoteVersion: cached.remoteVersion }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!localVersion) return { action: "noop", localVersion: null, remoteVersion: null }
|
|
157
|
+
|
|
158
|
+
const remoteVersion = await fetchNpmLatestVersion(XP_GATE_REGISTRY_URL)
|
|
159
|
+
if (!remoteVersion) return { action: "noop", localVersion, remoteVersion: null }
|
|
160
|
+
|
|
161
|
+
if (!semverLt(localVersion, remoteVersion)) {
|
|
162
|
+
writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion, status: "current" })
|
|
163
|
+
return { action: "noop", localVersion, remoteVersion }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const config = readXpGateConfig()
|
|
167
|
+
if (config?.autoUpgrade === false) {
|
|
168
|
+
writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion, status: "current" })
|
|
169
|
+
return { action: "noop", localVersion, remoteVersion }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion })
|
|
173
|
+
try {
|
|
174
|
+
const exitCode = await new Promise<number | null>((resolve, reject) => {
|
|
175
|
+
const child = spawn("npm", ["install", "-g", `${XP_GATE_NPM_PKG}@${remoteVersion}`], {
|
|
176
|
+
stdio: "pipe",
|
|
177
|
+
timeout: 120_000,
|
|
178
|
+
})
|
|
179
|
+
child.on("close", (code) => resolve(code))
|
|
180
|
+
child.on("error", (err) => reject(err))
|
|
181
|
+
})
|
|
182
|
+
if (exitCode === 0) {
|
|
183
|
+
writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion: remoteVersion, remoteVersion, status: "current" })
|
|
184
|
+
return { action: "upgraded", localVersion, remoteVersion }
|
|
185
|
+
}
|
|
186
|
+
return { action: "error", localVersion, remoteVersion, error: `npm install exited with code ${exitCode}` }
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
189
|
+
return { action: "error", localVersion, remoteVersion, error: msg }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function writeUpgradeNotice(notice: UpgradeNotice): void {
|
|
194
|
+
writeCache(UPGRADE_NOTICE_FILE, notice)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Full background check — the function called by chat.message hook.
|
|
199
|
+
* MUST match index.ts exactly.
|
|
200
|
+
*/
|
|
201
|
+
async function runBackgroundUpdates(pluginDir: string): Promise<string | null> {
|
|
202
|
+
void pluginDir // unused in this function, kept for signature match
|
|
203
|
+
const result = await checkXpGateUpdate()
|
|
204
|
+
// checkPluginUpdate is skipped here — it's not part of the xp-gate upgrade flow
|
|
205
|
+
|
|
206
|
+
if (result.action === "upgraded") {
|
|
207
|
+
const msg = `[XP-Gate] Auto-upgraded from v${result.localVersion} to v${result.remoteVersion}`
|
|
208
|
+
writeUpgradeNotice({ kind: "upgraded", localVersion: result.localVersion, remoteVersion: result.remoteVersion, message: msg, ts: Date.now() })
|
|
209
|
+
return msg
|
|
210
|
+
}
|
|
211
|
+
if (result.action === "error") {
|
|
212
|
+
const msg = `[XP-Gate] Upgrade check: v${result.remoteVersion} available (auto-upgrade failed: ${result.error})`
|
|
213
|
+
writeUpgradeNotice({ kind: "error", localVersion: result.localVersion, remoteVersion: result.remoteVersion, message: msg, ts: Date.now() })
|
|
214
|
+
return msg
|
|
215
|
+
}
|
|
216
|
+
if (result.remoteVersion && result.localVersion && semverLt(result.localVersion, result.remoteVersion)) {
|
|
217
|
+
const msg = `[XP-Gate] New version v${result.remoteVersion} available (you have v${result.localVersion})`
|
|
218
|
+
writeUpgradeNotice({ kind: "outdated", localVersion: result.localVersion, remoteVersion: result.remoteVersion, message: msg, ts: Date.now() })
|
|
219
|
+
return msg
|
|
220
|
+
}
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Helpers ──
|
|
225
|
+
|
|
226
|
+
function readUpgradeNotice(): UpgradeNotice | null {
|
|
227
|
+
try {
|
|
228
|
+
if (!existsSync(UPGRADE_NOTICE_FILE)) return null
|
|
229
|
+
return JSON.parse(readFileSync(UPGRADE_NOTICE_FILE, "utf8"))
|
|
230
|
+
} catch {
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertInCI(): boolean {
|
|
236
|
+
if (process.env.CI) {
|
|
237
|
+
console.log(" [SKIP] E2E spawn test disabled in CI (real npm install required)")
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ═══════════════════════════════════════════════════
|
|
244
|
+
// E2E TESTS
|
|
245
|
+
// ═══════════════════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
// ── Suite 1: Full runBackgroundUpdates E2E ──
|
|
248
|
+
|
|
249
|
+
void describe("E2E: runBackgroundUpdates → upgrade + notice + cache (E2E-001)", () => {
|
|
250
|
+
const fakeHome = join(tmpdir(), "xp-gate-e2e-001-" + randomUUID())
|
|
251
|
+
const origHome = process.env.HOME
|
|
252
|
+
|
|
253
|
+
before(() => {
|
|
254
|
+
process.env.HOME = fakeHome
|
|
255
|
+
mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
after(() => {
|
|
259
|
+
process.env.HOME = origHome
|
|
260
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
void it("runBackgroundUpdates returns upgrade message when local < remote", async () => {
|
|
264
|
+
if (assertInCI()) return
|
|
265
|
+
|
|
266
|
+
// Seed a stale cache that will force a network check
|
|
267
|
+
const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
|
|
268
|
+
writeFileSync(cachePath, JSON.stringify({
|
|
269
|
+
ts: Date.now() - 86_400_000 - 3600_000, // 25h stale
|
|
270
|
+
localVersion: "0.8.0",
|
|
271
|
+
remoteVersion: "0.8.1",
|
|
272
|
+
}))
|
|
273
|
+
|
|
274
|
+
// Override local version to be older than latest on npm
|
|
275
|
+
getLocalVersionOverride = () => "0.8.0"
|
|
276
|
+
|
|
277
|
+
const msg = await runBackgroundUpdates("/fake/plugin/dir")
|
|
278
|
+
getLocalVersionOverride = null
|
|
279
|
+
|
|
280
|
+
assert.ok(msg !== null, "E2E-001 FAIL: runBackgroundUpdates returned null — expected upgrade message")
|
|
281
|
+
if (msg) {
|
|
282
|
+
assert.ok(msg.includes("Auto-upgraded"), `E2E-001 FAIL: message missing 'Auto-upgraded': ${msg}`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Verify upgrade notice was written
|
|
286
|
+
const notice = readUpgradeNotice()
|
|
287
|
+
assert.ok(notice !== null, "E2E-001 FAIL: upgrade-notice.json was not written")
|
|
288
|
+
if (notice) {
|
|
289
|
+
assert.equal(notice.kind, "upgraded", `E2E-001 FAIL: notice kind=${notice.kind}, expected 'upgraded'`)
|
|
290
|
+
assert.equal(notice.localVersion, "0.8.0")
|
|
291
|
+
assert.ok(notice.remoteVersion !== null)
|
|
292
|
+
assert.ok(notice.message.includes("Auto-upgraded"))
|
|
293
|
+
assert.ok(notice.ts > 0)
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
void it("cache is written with status:current immediately after runBackgroundUpdates returns", async () => {
|
|
298
|
+
if (assertInCI()) return
|
|
299
|
+
|
|
300
|
+
// Re-seed with stale cache to force another upgrade
|
|
301
|
+
rmSync(join(fakeHome, ".xp-gate", "xp-gate-version-check.json"), { force: true })
|
|
302
|
+
const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
|
|
303
|
+
writeFileSync(cachePath, JSON.stringify({
|
|
304
|
+
ts: Date.now() - 86_400_000 - 3600_000,
|
|
305
|
+
localVersion: "0.8.0",
|
|
306
|
+
remoteVersion: "0.8.1",
|
|
307
|
+
}))
|
|
308
|
+
|
|
309
|
+
getLocalVersionOverride = () => "0.8.0"
|
|
310
|
+
|
|
311
|
+
await runBackgroundUpdates("/fake/plugin/dir")
|
|
312
|
+
getLocalVersionOverride = null
|
|
313
|
+
|
|
314
|
+
// After function returns, cache MUST have status:current
|
|
315
|
+
// This is the KEY assertion — fire-and-forget would fail here
|
|
316
|
+
const finalCache = readCache(XP_GATE_CACHE_FILE)
|
|
317
|
+
if (finalCache) {
|
|
318
|
+
assert.equal(finalCache.status, "current",
|
|
319
|
+
"E2E-001 FAIL: cache status ≠ 'current' after runBackgroundUpdates returned. " +
|
|
320
|
+
"If this fails, the spawn is NOT being awaited — the same bug as before the fix.")
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// ── Suite 2: Await timing verification ──
|
|
326
|
+
|
|
327
|
+
void describe("E2E: Await timing — resolve AFTER spawn (E2E-002)", () => {
|
|
328
|
+
const fakeHome = join(tmpdir(), "xp-gate-e2e-002-" + randomUUID())
|
|
329
|
+
const origHome = process.env.HOME
|
|
330
|
+
|
|
331
|
+
before(() => {
|
|
332
|
+
process.env.HOME = fakeHome
|
|
333
|
+
mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
after(() => {
|
|
337
|
+
process.env.HOME = origHome
|
|
338
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
void it("runBackgroundUpdates resolve time > 1000ms (spawn is awaited)", async () => {
|
|
342
|
+
if (assertInCI()) return
|
|
343
|
+
|
|
344
|
+
const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
|
|
345
|
+
writeFileSync(cachePath, JSON.stringify({
|
|
346
|
+
ts: Date.now() - 86_400_000 - 3600_000,
|
|
347
|
+
localVersion: "0.8.0",
|
|
348
|
+
remoteVersion: "0.8.1",
|
|
349
|
+
}))
|
|
350
|
+
|
|
351
|
+
getLocalVersionOverride = () => "0.8.0"
|
|
352
|
+
|
|
353
|
+
const startTime = Date.now()
|
|
354
|
+
const msg = await runBackgroundUpdates("/fake/plugin/dir")
|
|
355
|
+
const elapsed = Date.now() - startTime
|
|
356
|
+
getLocalVersionOverride = null
|
|
357
|
+
|
|
358
|
+
console.log(` E2E-002: runBackgroundUpdates() returned in ${elapsed}ms`)
|
|
359
|
+
|
|
360
|
+
if (msg?.includes("Auto-upgraded")) {
|
|
361
|
+
// Only assert timing if upgrade actually happened (network call succeeded)
|
|
362
|
+
assert.ok(elapsed > 1000,
|
|
363
|
+
`E2E-002 FAIL: runBackgroundUpdates resolved in ${elapsed}ms — ` +
|
|
364
|
+
"spawn was NOT awaited. The chat.message hook would lose the upgrade. " +
|
|
365
|
+
"This is the fire-and-forget bug.")
|
|
366
|
+
} else if (elapsed < 200) {
|
|
367
|
+
// If no upgrade happened (network failure, etc.), elapsed should still be
|
|
368
|
+
// reasonable for a network call (5s timeout for npm registry fetch)
|
|
369
|
+
assert.ok(elapsed > 200,
|
|
370
|
+
`E2E-002 FAIL: resolved in ${elapsed}ms with no upgrade — network check too fast?`)
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// ── Suite 3: Upgrade notice file verification ──
|
|
376
|
+
|
|
377
|
+
void describe("E2E: writeUpgradeNotice file content (E2E-003)", () => {
|
|
378
|
+
const fakeHome = join(tmpdir(), "xp-gate-e2e-003-" + randomUUID())
|
|
379
|
+
const origHome = process.env.HOME
|
|
380
|
+
|
|
381
|
+
before(() => {
|
|
382
|
+
process.env.HOME = fakeHome
|
|
383
|
+
mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
after(() => {
|
|
387
|
+
process.env.HOME = origHome
|
|
388
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
void it("notice file has correct schema (kind, localVersion, remoteVersion, message, ts)", async () => {
|
|
392
|
+
if (assertInCI()) return
|
|
393
|
+
|
|
394
|
+
const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
|
|
395
|
+
writeFileSync(cachePath, JSON.stringify({
|
|
396
|
+
ts: Date.now() - 86_400_000 - 3600_000,
|
|
397
|
+
localVersion: "0.8.0",
|
|
398
|
+
remoteVersion: "0.8.1",
|
|
399
|
+
}))
|
|
400
|
+
|
|
401
|
+
getLocalVersionOverride = () => "0.8.0"
|
|
402
|
+
await runBackgroundUpdates("/fake/plugin/dir")
|
|
403
|
+
getLocalVersionOverride = null
|
|
404
|
+
|
|
405
|
+
const notice = readUpgradeNotice()
|
|
406
|
+
assert.ok(notice !== null, "E2E-003 FAIL: notice file not written")
|
|
407
|
+
|
|
408
|
+
if (notice) {
|
|
409
|
+
// Schema validation
|
|
410
|
+
assert.ok(["upgraded", "outdated", "error"].includes(notice.kind),
|
|
411
|
+
`E2E-003 FAIL: invalid kind: ${notice.kind}`)
|
|
412
|
+
assert.ok(typeof notice.localVersion === "string" || notice.localVersion === null,
|
|
413
|
+
"E2E-003 FAIL: localVersion must be string or null")
|
|
414
|
+
assert.ok(typeof notice.remoteVersion === "string" || notice.remoteVersion === null,
|
|
415
|
+
"E2E-003 FAIL: remoteVersion must be string or null")
|
|
416
|
+
assert.ok(typeof notice.message === "string" && notice.message.length > 0,
|
|
417
|
+
"E2E-003 FAIL: message must be non-empty string")
|
|
418
|
+
assert.ok(typeof notice.ts === "number" && notice.ts > 0,
|
|
419
|
+
"E2E-003 FAIL: ts must be positive number")
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
void it("notice file can be read back and parsed (no corruption)", async () => {
|
|
424
|
+
if (assertInCI()) return
|
|
425
|
+
|
|
426
|
+
// Verify the file is valid JSON (not corrupted by tmpfile write)
|
|
427
|
+
const raw = readFileSync(UPGRADE_NOTICE_FILE, "utf8")
|
|
428
|
+
let parsed: unknown = null
|
|
429
|
+
assert.doesNotThrow(() => {
|
|
430
|
+
parsed = JSON.parse(raw)
|
|
431
|
+
}, "E2E-003 FAIL: notice file contains invalid JSON")
|
|
432
|
+
assert.ok(parsed !== null)
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// ── Suite 4: Chat.message hook simulation (E2E-004) ──
|
|
437
|
+
|
|
438
|
+
void describe("E2E: chat.message hook simulation (E2E-004)", () => {
|
|
439
|
+
const fakeHome = join(tmpdir(), "xp-gate-e2e-004-" + randomUUID())
|
|
440
|
+
const origHome = process.env.HOME
|
|
441
|
+
|
|
442
|
+
before(() => {
|
|
443
|
+
process.env.HOME = fakeHome
|
|
444
|
+
mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
after(() => {
|
|
448
|
+
process.env.HOME = origHome
|
|
449
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
void it("simulated chat.message hook: upgrade completes before hook returns", async () => {
|
|
453
|
+
if (assertInCI()) return
|
|
454
|
+
|
|
455
|
+
// This simulates the EXACT code path from index.ts lines 321-327:
|
|
456
|
+
// "chat.message": async (_input: { message: string }) => {
|
|
457
|
+
// if (!checked) {
|
|
458
|
+
// checked = true
|
|
459
|
+
// const msg = await runBackgroundUpdates(directory).catch(() => null)
|
|
460
|
+
// if (msg) process.stderr.write(`${msg}\n`)
|
|
461
|
+
// }
|
|
462
|
+
// }
|
|
463
|
+
|
|
464
|
+
let checked = false
|
|
465
|
+
const stderrOutput: string[] = []
|
|
466
|
+
|
|
467
|
+
// Simulate the hook callback (first chat.message invocation)
|
|
468
|
+
const simulateHook = async (pluginDir: string): Promise<void> => {
|
|
469
|
+
if (!checked) {
|
|
470
|
+
checked = true
|
|
471
|
+
const msg = await runBackgroundUpdates(pluginDir).catch(() => null)
|
|
472
|
+
if (msg) stderrOutput.push(msg)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Setup: stale cache forces upgrade
|
|
477
|
+
const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
|
|
478
|
+
writeFileSync(cachePath, JSON.stringify({
|
|
479
|
+
ts: Date.now() - 86_400_000 - 3600_000,
|
|
480
|
+
localVersion: "0.8.0",
|
|
481
|
+
remoteVersion: "0.8.1",
|
|
482
|
+
}))
|
|
483
|
+
|
|
484
|
+
getLocalVersionOverride = () => "0.8.0"
|
|
485
|
+
|
|
486
|
+
const startTime = Date.now()
|
|
487
|
+
await simulateHook("/fake/plugin/dir")
|
|
488
|
+
const elapsed = Date.now() - startTime
|
|
489
|
+
getLocalVersionOverride = null
|
|
490
|
+
|
|
491
|
+
console.log(` E2E-004: simulateHook returned in ${elapsed}ms`)
|
|
492
|
+
|
|
493
|
+
// The hook should have upgrade output written to stderr
|
|
494
|
+
assert.ok(stderrOutput.length > 0,
|
|
495
|
+
"E2E-004 FAIL: no stderr output from upgrade — chat.message hook would be silent")
|
|
496
|
+
|
|
497
|
+
// If upgrade happened, check the content
|
|
498
|
+
if (stderrOutput.length > 0) {
|
|
499
|
+
const output = stderrOutput.join("\n")
|
|
500
|
+
assert.ok(output.includes("Auto-upgraded") || output.includes("New version"),
|
|
501
|
+
`E2E-004 FAIL: unexpected stderr output: ${output}`)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Second hook call should NOT re-run (checked flag)
|
|
505
|
+
const preCount = stderrOutput.length
|
|
506
|
+
await simulateHook("/fake/plugin/dir")
|
|
507
|
+
assert.equal(stderrOutput.length, preCount,
|
|
508
|
+
"E2E-004 FAIL: second hook call re-ran upgrade check (checked flag broken)")
|
|
509
|
+
|
|
510
|
+
// After hook returns, cache must be written
|
|
511
|
+
const finalCache = readCache(XP_GATE_CACHE_FILE)
|
|
512
|
+
if (finalCache && stderrOutput.some(s => s.includes("Auto-upgraded"))) {
|
|
513
|
+
assert.equal(finalCache.status, "current",
|
|
514
|
+
"E2E-004 FAIL: after chat.message hook, cache has no status:current. " +
|
|
515
|
+
"The fire-and-forget bug would cause this.")
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
// ── Suite 5: Cache integrity after upgrade (E2E-005) ──
|
|
521
|
+
|
|
522
|
+
void describe("E2E: Cache integrity after real npm install (E2E-005)", () => {
|
|
523
|
+
const fakeHome = join(tmpdir(), "xp-gate-e2e-005-" + randomUUID())
|
|
524
|
+
const origHome = process.env.HOME
|
|
525
|
+
|
|
526
|
+
before(() => {
|
|
527
|
+
process.env.HOME = fakeHome
|
|
528
|
+
mkdirSync(join(fakeHome, ".xp-gate"), { recursive: true })
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
after(() => {
|
|
532
|
+
process.env.HOME = origHome
|
|
533
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
void it("cache is NOT corrupted after spawn (atomic write via tmpfile)", async () => {
|
|
537
|
+
if (assertInCI()) return
|
|
538
|
+
|
|
539
|
+
const cachePath = join(fakeHome, ".xp-gate", "xp-gate-version-check.json")
|
|
540
|
+
writeFileSync(cachePath, JSON.stringify({
|
|
541
|
+
ts: Date.now() - 86_400_000 - 3600_000,
|
|
542
|
+
localVersion: "0.8.0",
|
|
543
|
+
remoteVersion: "0.8.1",
|
|
544
|
+
}))
|
|
545
|
+
|
|
546
|
+
getLocalVersionOverride = () => "0.8.0"
|
|
547
|
+
await runBackgroundUpdates("/fake/plugin/dir")
|
|
548
|
+
getLocalVersionOverride = null
|
|
549
|
+
|
|
550
|
+
// Cache file must be valid JSON
|
|
551
|
+
const raw = readFileSync(XP_GATE_CACHE_FILE, "utf8")
|
|
552
|
+
let cache: unknown = null
|
|
553
|
+
assert.doesNotThrow(() => {
|
|
554
|
+
cache = JSON.parse(raw)
|
|
555
|
+
}, "E2E-005 FAIL: cache file is not valid JSON — tmpfile write may be corrupted")
|
|
556
|
+
|
|
557
|
+
// Cache must have required fields
|
|
558
|
+
const c = cache as Record<string, unknown>
|
|
559
|
+
assert.ok(typeof c.ts === "number", "E2E-005 FAIL: cache missing ts")
|
|
560
|
+
assert.ok(typeof c.remoteVersion === "string", "E2E-005 FAIL: cache missing remoteVersion")
|
|
561
|
+
})
|
|
562
|
+
})
|