@boyingliu01/xp-gate 0.10.11 → 0.10.13
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__/doctor.test.js +155 -0
- package/lib/__tests__/install-skill.test.js +1 -1
- package/lib/doctor.js +126 -0
- package/lib/init.js +12 -0
- 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 +121 -1
- package/plugins/opencode/__tests__/xp-gate-e2e-upgrade.test.ts +562 -0
- package/plugins/opencode/index.ts +2 -0
- 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/opencode/tui-plugin.ts +24 -7
- 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
|
@@ -13,7 +13,6 @@ import { describe, it, before, after } from "node:test"
|
|
|
13
13
|
import assert from "node:assert/strict"
|
|
14
14
|
import { randomUUID } from "node:crypto"
|
|
15
15
|
import { mkdirSync, writeFileSync, rmSync } from "node:fs"
|
|
16
|
-
import { join } from "node:path"
|
|
17
16
|
import { tmpdir } from "node:os"
|
|
18
17
|
|
|
19
18
|
// Import the functions under test
|
|
@@ -421,3 +420,124 @@ void describe("multi-sprint rendering", () => {
|
|
|
421
420
|
assert.ok(output.includes("Minimal sprint"))
|
|
422
421
|
})
|
|
423
422
|
})
|
|
423
|
+
|
|
424
|
+
// ── Early-phase placeholder rendering ──
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Renders panel content with early-phase placeholder fallback.
|
|
428
|
+
* Mirrors the planned renderContent(dir, sprints, upgradeNotice) from tui-plugin.ts.
|
|
429
|
+
* Expected to be the actual implementation after TDD cycle.
|
|
430
|
+
*/
|
|
431
|
+
import { existsSync } from "node:fs"
|
|
432
|
+
import { dirname, resolve, parse, join } from "node:path"
|
|
433
|
+
|
|
434
|
+
function findGitRoot(startDir: string): string | null {
|
|
435
|
+
let current = resolve(startDir);
|
|
436
|
+
const root = parse(current).root;
|
|
437
|
+
const seen = new Set<string>();
|
|
438
|
+
while (current !== root) {
|
|
439
|
+
if (seen.has(current)) break;
|
|
440
|
+
seen.add(current);
|
|
441
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
442
|
+
const parent = dirname(current);
|
|
443
|
+
if (parent === current) break;
|
|
444
|
+
current = parent;
|
|
445
|
+
}
|
|
446
|
+
if (existsSync(join(root, '.git'))) return root;
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function renderContentWithPlaceholder(
|
|
451
|
+
dir: string,
|
|
452
|
+
sprints: { state: Record<string, unknown>; sourcePath: string; worktreeExists: boolean }[],
|
|
453
|
+
upgradeNotice: string | null,
|
|
454
|
+
): string | null {
|
|
455
|
+
if (sprints.length > 0) {
|
|
456
|
+
return upgradeNotice || null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const hasStateDir = existsSync(join(dir, ".sprint-state"))
|
|
460
|
+
const gitRoot = findGitRoot(dir)
|
|
461
|
+
const hasWorktreesRoot = gitRoot ? existsSync(join(gitRoot, ".worktrees")) : false
|
|
462
|
+
|
|
463
|
+
if (hasStateDir) {
|
|
464
|
+
const placeholder = "SPRINT FLOW\n → 初始化中..."
|
|
465
|
+
return [upgradeNotice, placeholder].filter(Boolean).join("\n---\n")
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (hasWorktreesRoot) {
|
|
469
|
+
const placeholder = "SPRINT FLOW\n · 准备中..."
|
|
470
|
+
return [upgradeNotice, placeholder].filter(Boolean).join("\n---\n")
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return upgradeNotice || null
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
void describe("early-phase placeholder rendering", () => {
|
|
477
|
+
const tmpDir = join(tmpdir(), "xp-gate-tui-placeholder-" + randomUUID())
|
|
478
|
+
|
|
479
|
+
before(() => {
|
|
480
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
after(() => {
|
|
484
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
void it("returns null when no sprints and no .sprint-state/ directory and no .worktrees/", () => {
|
|
488
|
+
const result = renderContentWithPlaceholder(tmpDir, [], null)
|
|
489
|
+
assert.equal(result, null, "Should return null when nothing exists")
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
void it("returns '初始化中...' placeholder when .sprint-state/ directory exists but no sprint data", () => {
|
|
493
|
+
mkdirSync(join(tmpDir, ".sprint-state"), { recursive: true })
|
|
494
|
+
const result = renderContentWithPlaceholder(tmpDir, [], null)
|
|
495
|
+
assert.ok(result !== null, "Should return a placeholder string")
|
|
496
|
+
assert.ok(result!.includes("SPRINT FLOW"), "Should include SPRINT FLOW header")
|
|
497
|
+
assert.ok(result!.includes("初始化中"), "Should include 初始化中...")
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
void it("returns '准备中...' placeholder when .worktrees/ directory exists but no sprint data", () => {
|
|
501
|
+
// Must create .git/ dir for findGitRoot() to succeed
|
|
502
|
+
// Note: test 2 (above) may have created .sprint-state/ — clean it first
|
|
503
|
+
rmSync(join(tmpDir, ".sprint-state"), { recursive: true, force: true })
|
|
504
|
+
mkdirSync(join(tmpDir, ".git"), { recursive: true })
|
|
505
|
+
mkdirSync(join(tmpDir, ".worktrees"), { recursive: true })
|
|
506
|
+
|
|
507
|
+
const result = renderContentWithPlaceholder(tmpDir, [], null)
|
|
508
|
+
assert.ok(result !== null, "Should return a placeholder string")
|
|
509
|
+
assert.ok(result!.includes("SPRINT FLOW"), "Should include SPRINT FLOW header")
|
|
510
|
+
assert.ok(result!.includes("准备中"), "Should include 准备中...")
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
void it("prefers .sprint-state/ placeholder over .worktrees/ when both exist", () => {
|
|
514
|
+
mkdirSync(join(tmpDir, ".sprint-state"), { recursive: true })
|
|
515
|
+
const result = renderContentWithPlaceholder(tmpDir, [], null)
|
|
516
|
+
assert.ok(result!.includes("初始化中"), "Should prefer 初始化中 when .sprint-state/ exists")
|
|
517
|
+
assert.ok(!result!.includes("准备中"), "Should NOT show 准备中 when .sprint-state/ exists")
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
void it("includes upgrade notice banner above placeholder when both present", () => {
|
|
521
|
+
const notice = "✓ Auto-upgraded from v0.10.12 to v0.10.13"
|
|
522
|
+
const result = renderContentWithPlaceholder(tmpDir, [], notice)
|
|
523
|
+
assert.ok(result !== null)
|
|
524
|
+
assert.ok(result!.includes("Auto-upgraded"), "Should include upgrade notice")
|
|
525
|
+
assert.ok(result!.includes("SPRINT FLOW"), "Should include placeholder below notice")
|
|
526
|
+
// Upgrade notice should appear before SPRINT FLOW
|
|
527
|
+
const noticePos = result!.indexOf("Auto-upgraded")
|
|
528
|
+
const sprintPos = result!.indexOf("SPRINT FLOW")
|
|
529
|
+
assert.ok(noticePos < sprintPos, "Upgrade notice should come before SPRINT FLOW")
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
void it("shows upgrade notice only when no sprints and no placeholder dirs", () => {
|
|
533
|
+
// Reset — remove .sprint-state/ and .worktrees/
|
|
534
|
+
rmSync(join(tmpDir, ".sprint-state"), { recursive: true, force: true })
|
|
535
|
+
rmSync(join(tmpDir, ".worktrees"), { recursive: true, force: true })
|
|
536
|
+
|
|
537
|
+
const notice = "↑ New version v1.0.0 available"
|
|
538
|
+
const result = renderContentWithPlaceholder(tmpDir, [], notice)
|
|
539
|
+
assert.ok(result !== null, "Should return upgrade notice even without sprint dirs")
|
|
540
|
+
assert.ok(result!.includes("New version"), "Should include upgrade notice text")
|
|
541
|
+
assert.ok(!result!.includes("SPRINT FLOW"), "Should NOT show placeholder when nothing exists")
|
|
542
|
+
})
|
|
543
|
+
})
|
|
@@ -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
|
+
})
|