@boyingliu01/xp-gate 0.12.3 → 0.12.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.
- package/adapters/cpp.sh +0 -0
- package/adapters/dart.sh +0 -0
- package/adapters/flutter.sh +0 -0
- package/adapters/go.sh +0 -0
- package/adapters/java.sh +0 -0
- package/adapters/kotlin.sh +0 -0
- package/adapters/objectivec.sh +0 -0
- package/adapters/powershell.sh +0 -0
- package/adapters/python.sh +0 -0
- package/adapters/shell.sh +0 -0
- package/adapters/swift.sh +0 -0
- package/adapters/typescript.sh +0 -0
- package/bin/xp-gate.js +0 -8
- package/gate-3.sh +0 -0
- package/gate-4.sh +0 -0
- package/gate-7.sh +0 -0
- package/gate-8.sh +0 -0
- package/gate-9.sh +0 -0
- package/lib/init.js +9 -6
- package/lib/sprint-status.js +35 -1
- package/lib/update-hooks.js +46 -46
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
- package/lib/__tests__/next-sprint.test.js +0 -231
- package/lib/next-sprint.js +0 -129
- package/lib/shared-phase-constants.d.ts +0 -17
- package/lib/shared-phase-constants.js +0 -77
- package/plugins/opencode/__tests__/tui-plugin.test.ts +0 -543
- package/plugins/opencode/__tests__/xp-gate-e2e-upgrade.test.ts +0 -562
- package/plugins/opencode/__tests__/xp-gate-update.test.ts +0 -518
- package/plugins/opencode/tui-plugin.ts +0 -414
- package/plugins/opencode/tui-plugin.tsx +0 -306
|
@@ -1,543 +0,0 @@
|
|
|
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, rmSync } from "node:fs"
|
|
16
|
-
import { tmpdir } from "node:os"
|
|
17
|
-
|
|
18
|
-
// Import the functions under test
|
|
19
|
-
// Since the file uses type-only imports from @opencode-ai/plugin/tui,
|
|
20
|
-
// test runner provides a separate copy of the pure functions
|
|
21
|
-
import { readSprintState, renderSprintSidebar } from "../tui-plugin.ts"
|
|
22
|
-
|
|
23
|
-
// Inline single-sprint rendering logic for multi-sprint comparison
|
|
24
|
-
// (The module-level renderMultiSprintSidebar is not directly importable
|
|
25
|
-
// since it uses module-level state. We test the composition here.)
|
|
26
|
-
function buildMultiSprintBlock(state: Record<string, unknown>, index: number): string {
|
|
27
|
-
const lines: string[] = []
|
|
28
|
-
const id = (state as Record<string, unknown>).id || `sprint-${index}`
|
|
29
|
-
const desc = (state as Record<string, unknown>).task_description || id
|
|
30
|
-
lines.push(`SPRINT: ${desc}`)
|
|
31
|
-
if ((state as Record<string, unknown>).isolation?.branch) {
|
|
32
|
-
lines.push(` ${(state as Record<string, unknown>).isolation.branch}`)
|
|
33
|
-
}
|
|
34
|
-
const historyByPhase: Record<string, { status?: string; phase_name?: string }> = {}
|
|
35
|
-
if (Array.isArray((state as Record<string, unknown>).phase_history)) {
|
|
36
|
-
for (const ph of (state as Record<string, unknown>).phase_history) {
|
|
37
|
-
historyByPhase[String(ph.phase)] = ph
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
for (const key of ['-1', '-0.5', '0', '1', '2', '3', '4', '5', '6', '7', '8']) {
|
|
41
|
-
const history = historyByPhase[key]
|
|
42
|
-
if (!history && String((state as Record<string, unknown>).phase) !== key) continue
|
|
43
|
-
const name = history?.phase_name || ({
|
|
44
|
-
'-1': 'ISOLATE', '-0.5': 'AUTO-ESTIMATE', '0': 'THINK', '1': 'PLAN', '2': 'BUILD',
|
|
45
|
-
'3': 'REVIEW', '4': 'USER ACCEPT', '5': 'FEEDBACK', '6': 'SHIP', '7': 'LAND', '8': 'CLEANUP',
|
|
46
|
-
})[key] || key
|
|
47
|
-
const sym = history?.status === 'completed' ? '✓' :
|
|
48
|
-
history?.status === 'in_progress' ? '→' :
|
|
49
|
-
(String((state as Record<string, unknown>).phase) === key ? '·' : '○')
|
|
50
|
-
lines.push(`${sym} ${name.padEnd(14)} ${history?.status === 'completed' ? 'done' : history?.status === 'in_progress' ? 'active' : ''}`.replace(/\s+$/, ''))
|
|
51
|
-
}
|
|
52
|
-
return lines.join('\n')
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function buildMultiSprintOutput(sprints: Record<string, unknown>[]): string | null {
|
|
56
|
-
if (sprints.length === 0) return null
|
|
57
|
-
const blocks = sprints.slice(0, 3).map((s, i) => buildMultiSprintBlock(s, i))
|
|
58
|
-
if (sprints.length > 3) blocks.push(`… +${sprints.length - 3} more`)
|
|
59
|
-
return blocks.join('\n---\n')
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Duplicate helpers here to test in isolation
|
|
63
|
-
function isStale(state: { started_at?: string; phase_history?: Array<{ started_at?: string; completed_at?: string }> }): boolean {
|
|
64
|
-
if (!state || !state.started_at) return false
|
|
65
|
-
const started = new Date(state.started_at).getTime()
|
|
66
|
-
if (isNaN(started)) return false
|
|
67
|
-
let latest = started
|
|
68
|
-
if (Array.isArray(state.phase_history)) {
|
|
69
|
-
for (const ph of state.phase_history) {
|
|
70
|
-
if (ph.completed_at) {
|
|
71
|
-
const t = new Date(ph.completed_at).getTime()
|
|
72
|
-
if (!isNaN(t) && t > latest) latest = t
|
|
73
|
-
}
|
|
74
|
-
if (ph.started_at) {
|
|
75
|
-
const t = new Date(ph.started_at).getTime()
|
|
76
|
-
if (!isNaN(t) && t > latest) latest = t
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return Date.now() - latest > 3_600_000
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function statusSymbol(status: string | undefined, key: string, currentPhase: string | number | undefined): string {
|
|
84
|
-
if (status === "completed") return "✓"
|
|
85
|
-
if (status === "in_progress") return "→"
|
|
86
|
-
if (String(currentPhase) === key) return "·"
|
|
87
|
-
return "○"
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const PHASE_NAMES: Record<string, string> = {
|
|
91
|
-
"-1": "ISOLATE",
|
|
92
|
-
"-0.5": "AUTO-ESTIMATE",
|
|
93
|
-
"0": "THINK",
|
|
94
|
-
"1": "PLAN",
|
|
95
|
-
"2": "BUILD",
|
|
96
|
-
"3": "REVIEW",
|
|
97
|
-
"4": "USER ACCEPT",
|
|
98
|
-
"5": "FEEDBACK",
|
|
99
|
-
"6": "SHIP",
|
|
100
|
-
"7": "LAND",
|
|
101
|
-
"8": "CLEANUP",
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function renderPhaseLine(key: string, history: { status?: string; phase_name?: string } | undefined, currentPhase: string | number | undefined): string {
|
|
105
|
-
const name = history?.phase_name || PHASE_NAMES[key] || key
|
|
106
|
-
const status = history?.status || (String(currentPhase) === key ? "in_progress" : "pending")
|
|
107
|
-
const sym = statusSymbol(status, key, currentPhase)
|
|
108
|
-
return `${sym} ${name.padEnd(14)} ${status === "completed" ? "done" : status === "in_progress" ? "active" : ""}`
|
|
109
|
-
.replace(/\s+$/, "")
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ── Test fixtures ──
|
|
113
|
-
|
|
114
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
115
|
-
function makeSprintState(overrides: Record<string, unknown> = {}): any {
|
|
116
|
-
const base = {
|
|
117
|
-
id: "sprint-001",
|
|
118
|
-
phase: "2",
|
|
119
|
-
status: "in_progress",
|
|
120
|
-
started_at: new Date(Date.now() - 3_600_000).toISOString(),
|
|
121
|
-
task_description: "Implement user login with OAuth2",
|
|
122
|
-
metrics: { tests_passed: 42, coverage_pct: 87 },
|
|
123
|
-
phase_history: [
|
|
124
|
-
{ 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() },
|
|
125
|
-
{ 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() },
|
|
126
|
-
{ 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() },
|
|
127
|
-
{ 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 } } },
|
|
128
|
-
],
|
|
129
|
-
}
|
|
130
|
-
return { ...base, ...overrides }
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ── isStale ──
|
|
134
|
-
|
|
135
|
-
void describe("isStale", () => {
|
|
136
|
-
void it("returns false when state has no started_at", () => {
|
|
137
|
-
assert.equal(isStale({}), false)
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
void it("returns false when started_at is recent", () => {
|
|
141
|
-
const state = { started_at: new Date().toISOString() }
|
|
142
|
-
assert.equal(isStale(state), false)
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
void it("returns true when started_at is >1h ago and no phase_history", () => {
|
|
146
|
-
const state = { started_at: new Date(Date.now() - 7_200_000).toISOString() }
|
|
147
|
-
assert.equal(isStale(state), true)
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
void it("returns false when latest phase_history is recent", () => {
|
|
151
|
-
const state = {
|
|
152
|
-
started_at: new Date(Date.now() - 7_200_000).toISOString(),
|
|
153
|
-
phase_history: [
|
|
154
|
-
{ phase: "0", started_at: new Date(Date.now() - 3_600_000).toISOString(), completed_at: new Date(Date.now() - 60_000).toISOString() },
|
|
155
|
-
],
|
|
156
|
-
}
|
|
157
|
-
assert.equal(isStale(state), false)
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
void it("returns true when all activity is >1h ago", () => {
|
|
161
|
-
const state = {
|
|
162
|
-
started_at: new Date(Date.now() - 86_400_000).toISOString(),
|
|
163
|
-
phase_history: [
|
|
164
|
-
{ phase: "-1", started_at: new Date(Date.now() - 86_000_000).toISOString(), completed_at: new Date(Date.now() - 85_000_000).toISOString() },
|
|
165
|
-
],
|
|
166
|
-
}
|
|
167
|
-
assert.equal(isStale(state), true)
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
void it("returns false when started_at is invalid date", () => {
|
|
171
|
-
const state = { started_at: "not-a-date" }
|
|
172
|
-
assert.equal(isStale(state), false)
|
|
173
|
-
})
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
// ── statusSymbol ──
|
|
177
|
-
|
|
178
|
-
void describe("statusSymbol", () => {
|
|
179
|
-
void it("returns ✓ for completed", () => {
|
|
180
|
-
assert.equal(statusSymbol("completed", "0", undefined), "✓")
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
void it("returns → for in_progress", () => {
|
|
184
|
-
assert.equal(statusSymbol("in_progress", "1", undefined), "→")
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
void it("returns · when key matches currentPhase", () => {
|
|
188
|
-
assert.equal(statusSymbol(undefined, "2", "2"), "·")
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
void it("returns ○ for pending (no match)", () => {
|
|
192
|
-
assert.equal(statusSymbol("pending", "3", "2"), "○")
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
void it("returns ✓ for completed even when key matches", () => {
|
|
196
|
-
// completed takes priority over currentPhase match
|
|
197
|
-
assert.equal(statusSymbol("completed", "2", "2"), "✓")
|
|
198
|
-
})
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
// ── renderPhaseLine ──
|
|
202
|
-
|
|
203
|
-
void describe("renderPhaseLine", () => {
|
|
204
|
-
void it("renders completed phase", () => {
|
|
205
|
-
const line = renderPhaseLine("0", { status: "completed" }, "2")
|
|
206
|
-
assert.ok(line.includes("✓"))
|
|
207
|
-
assert.ok(line.includes("THINK"))
|
|
208
|
-
assert.ok(line.includes("done"))
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
void it("renders in_progress phase", () => {
|
|
212
|
-
const line = renderPhaseLine("2", { status: "in_progress" }, "2")
|
|
213
|
-
assert.ok(line.includes("→"))
|
|
214
|
-
assert.ok(line.includes("BUILD"))
|
|
215
|
-
assert.ok(line.includes("active"))
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
void it("renders pending phase (current phase match)", () => {
|
|
219
|
-
const line = renderPhaseLine("3", undefined, "2")
|
|
220
|
-
assert.ok(line.includes("○"))
|
|
221
|
-
assert.ok(line.includes("REVIEW"))
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
void it("renders current phase with arrow when no history entry (current assigned in_progress)", () => {
|
|
225
|
-
const line = renderPhaseLine("2", undefined, "2")
|
|
226
|
-
assert.ok(line.includes("→"))
|
|
227
|
-
assert.ok(line.includes("BUILD"))
|
|
228
|
-
assert.ok(line.includes("active"))
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
void it("uses custom phase_name from history", () => {
|
|
232
|
-
const line = renderPhaseLine("2", { status: "completed", phase_name: "MY BUILD" }, "2")
|
|
233
|
-
assert.ok(line.includes("MY BUILD"))
|
|
234
|
-
})
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
// ── readSprintState ──
|
|
238
|
-
|
|
239
|
-
void describe("readSprintState", () => {
|
|
240
|
-
const tmpDir = join(tmpdir(), "xp-gate-tui-test-" + randomUUID())
|
|
241
|
-
|
|
242
|
-
before(() => {
|
|
243
|
-
mkdirSync(join(tmpDir, ".sprint-state"), { recursive: true })
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
after(() => {
|
|
247
|
-
rmSync(tmpDir, { recursive: true, force: true })
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
void it("returns null when no sprint-state.json", () => {
|
|
251
|
-
assert.equal(readSprintState(tmpDir), null)
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
void it("returns parsed state when file exists", () => {
|
|
255
|
-
const state = { id: "test", phase: "1", task_description: "Test" }
|
|
256
|
-
writeFileSync(join(tmpDir, ".sprint-state", "sprint-state.json"), JSON.stringify(state))
|
|
257
|
-
const result = readSprintState(tmpDir)
|
|
258
|
-
assert.notEqual(result, null)
|
|
259
|
-
assert.equal(result?.id, "test")
|
|
260
|
-
assert.equal(result?.phase, "1")
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
void it("returns null on malformed JSON", () => {
|
|
264
|
-
writeFileSync(join(tmpDir, ".sprint-state", "sprint-state.json"), "{not-json")
|
|
265
|
-
assert.equal(readSprintState(tmpDir), null)
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
void it("returns null when directory does not exist", () => {
|
|
269
|
-
assert.equal(readSprintState("/nonexistent/path"), null)
|
|
270
|
-
})
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
// ── renderSprintSidebar ──
|
|
274
|
-
|
|
275
|
-
void describe("renderSprintSidebar", () => {
|
|
276
|
-
void it("returns empty string for null state", () => {
|
|
277
|
-
assert.equal(renderSprintSidebar(null as unknown as Parameters<typeof renderSprintSidebar>[0]), "")
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
void it("returns empty string when no task_description", () => {
|
|
281
|
-
assert.equal(renderSprintSidebar({} as Parameters<typeof renderSprintSidebar>[0]), "")
|
|
282
|
-
})
|
|
283
|
-
|
|
284
|
-
void it("includes sprint title on first line", () => {
|
|
285
|
-
const output = renderSprintSidebar(makeSprintState() as unknown as Parameters<typeof renderSprintSidebar>[0])
|
|
286
|
-
const lines = output.split("\n")
|
|
287
|
-
assert.ok(lines[0].includes("SPRINT:"))
|
|
288
|
-
assert.ok(lines[0].includes("OAuth2"))
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
void it("includes metrics when available", () => {
|
|
292
|
-
const output = renderSprintSidebar(makeSprintState() as unknown as Parameters<typeof renderSprintSidebar>[0])
|
|
293
|
-
assert.ok(output.includes("tests:42"))
|
|
294
|
-
assert.ok(output.includes("cov:87%"))
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
void it("omits metrics section when none present", () => {
|
|
298
|
-
const output = renderSprintSidebar(makeSprintState({ metrics: {} }) as unknown as Parameters<typeof renderSprintSidebar>[0])
|
|
299
|
-
assert.ok(!output.includes("tests:"))
|
|
300
|
-
assert.ok(!output.includes("cov:"))
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
void it("shows stale warning when >1h idle", () => {
|
|
304
|
-
const state = makeSprintState({ started_at: new Date(Date.now() - 7_200_000).toISOString(), phase_history: [] })
|
|
305
|
-
const output = renderSprintSidebar(state)
|
|
306
|
-
assert.ok(output.includes("idle"))
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
void it("does not show stale warning when recent activity", () => {
|
|
310
|
-
const state = makeSprintState({
|
|
311
|
-
started_at: new Date(Date.now() - 7_200_000).toISOString(),
|
|
312
|
-
phase_history: [
|
|
313
|
-
{ phase: "0", started_at: new Date().toISOString(), completed_at: new Date().toISOString() },
|
|
314
|
-
],
|
|
315
|
-
})
|
|
316
|
-
const output = renderSprintSidebar(state)
|
|
317
|
-
assert.ok(!output.includes("idle"))
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
void it("shows phases with activity or current", () => {
|
|
321
|
-
const output = renderSprintSidebar(makeSprintState() as unknown as Parameters<typeof renderSprintSidebar>[0])
|
|
322
|
-
// Should show ISOLATE (completed in history), THINK, PLAN, BUILD (current)
|
|
323
|
-
assert.ok(output.includes("ISOLATE"))
|
|
324
|
-
assert.ok(output.includes("THINK"))
|
|
325
|
-
assert.ok(output.includes("PLAN"))
|
|
326
|
-
assert.ok(output.includes("BUILD"))
|
|
327
|
-
// Should NOT show REVIEW, FEEDBACK, etc. (no history, not current)
|
|
328
|
-
assert.ok(!output.includes("REVIEW"))
|
|
329
|
-
assert.ok(!output.includes("FEEDBACK"))
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
void it("shows REQ-level progress for BUILD phase", () => {
|
|
333
|
-
const output = renderSprintSidebar(makeSprintState() as unknown as Parameters<typeof renderSprintSidebar>[0])
|
|
334
|
-
assert.ok(output.includes("JWT"))
|
|
335
|
-
assert.ok(output.includes("OAuth2"))
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
void it("renders correct status symbols per phase", () => {
|
|
339
|
-
const output = renderSprintSidebar(makeSprintState() as unknown as Parameters<typeof renderSprintSidebar>[0])
|
|
340
|
-
const lines = output.split("\n")
|
|
341
|
-
const isolateLine = lines.find((l: string) => l.includes("ISOLATE"))
|
|
342
|
-
assert.ok(isolateLine && isolateLine.startsWith("✓"), `Expected ISOLATE line to start with ✓, got: ${isolateLine}`)
|
|
343
|
-
})
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
// ── Multi-Sprint Rendering ──
|
|
347
|
-
|
|
348
|
-
void describe("multi-sprint rendering", () => {
|
|
349
|
-
void it("returns null for empty sprints array", () => {
|
|
350
|
-
assert.equal(buildMultiSprintOutput([]), null)
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
void it("renders single sprint without separator", () => {
|
|
354
|
-
const output = buildMultiSprintOutput([makeSprintState()])
|
|
355
|
-
assert.ok(output !== null)
|
|
356
|
-
assert.ok(!output!.includes("---"), "Single sprint should not have separator")
|
|
357
|
-
assert.ok(output!.includes("SPRINT:"))
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
void it("renders two sprints separated by ---", () => {
|
|
361
|
-
const sprint2 = makeSprintState({
|
|
362
|
-
id: "sprint-002",
|
|
363
|
-
task_description: "Second sprint",
|
|
364
|
-
started_at: new Date(Date.now() - 7_200_000).toISOString(),
|
|
365
|
-
phase: "1",
|
|
366
|
-
})
|
|
367
|
-
const output = buildMultiSprintOutput([makeSprintState(), sprint2])
|
|
368
|
-
assert.ok(output !== null)
|
|
369
|
-
assert.ok(output!.includes("---"), "Two sprints should be separated by ---")
|
|
370
|
-
assert.ok(output!.includes("OAuth2"))
|
|
371
|
-
assert.ok(output!.includes("Second sprint"))
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
void it("shows only first 3 sprints with overflow message for 4+", () => {
|
|
375
|
-
const sprints = [1, 2, 3, 4, 5].map(i =>
|
|
376
|
-
makeSprintState({
|
|
377
|
-
id: `sprint-00${i}`,
|
|
378
|
-
task_description: `Sprint ${i}`,
|
|
379
|
-
})
|
|
380
|
-
)
|
|
381
|
-
const output = buildMultiSprintOutput(sprints)
|
|
382
|
-
assert.ok(output !== null)
|
|
383
|
-
assert.ok(output!.includes("+2 more"), "Should show overflow for 5 sprints")
|
|
384
|
-
assert.ok(!output!.includes("Sprint 4"), "4th sprint should be collapsed")
|
|
385
|
-
assert.ok(!output!.includes("Sprint 5"), "5th sprint should be collapsed")
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
void it("renders 3 sprints without overflow", () => {
|
|
389
|
-
const sprints = [1, 2, 3].map(i =>
|
|
390
|
-
makeSprintState({
|
|
391
|
-
id: `sprint-00${i}`,
|
|
392
|
-
task_description: `Sprint ${i}`,
|
|
393
|
-
})
|
|
394
|
-
)
|
|
395
|
-
const output = buildMultiSprintOutput(sprints)
|
|
396
|
-
assert.ok(output !== null)
|
|
397
|
-
assert.ok(!output!.includes("more"), "3 sprints should not show overflow")
|
|
398
|
-
})
|
|
399
|
-
|
|
400
|
-
void it("uses sprint ID as fallback when task_description missing", () => {
|
|
401
|
-
const sprint = makeSprintState({ task_description: undefined, id: "sprint-2026-06-23-01" })
|
|
402
|
-
const output = buildMultiSprintBlock(sprint, 0)
|
|
403
|
-
assert.ok(output.includes("sprint-2026-06-23-01"), "Should use sprint ID as fallback")
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
void it("shows branch info when isolation.branch present", () => {
|
|
407
|
-
const sprint = makeSprintState({
|
|
408
|
-
isolation: { branch: "sprint/my-feature" },
|
|
409
|
-
})
|
|
410
|
-
const output = buildMultiSprintBlock(sprint, 0)
|
|
411
|
-
assert.ok(output.includes("sprint/my-feature"))
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
void it("does not crash on sprint with no started_at or phase_history", () => {
|
|
415
|
-
const sprint = {
|
|
416
|
-
id: "sprint-minimal",
|
|
417
|
-
task_description: "Minimal sprint",
|
|
418
|
-
}
|
|
419
|
-
const output = buildMultiSprintBlock(sprint, 0)
|
|
420
|
-
assert.ok(output.includes("Minimal sprint"))
|
|
421
|
-
})
|
|
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
|
-
})
|