pi-minimal 0.5.0 → 0.6.0

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.
@@ -0,0 +1,272 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
4
+ import { stripTerminalSequences } from "@earendil-works/pi-tui"
5
+ import { filterPrs, parsePrFilter, prsToolResult } from "../src/prs-command.js"
6
+ import {
7
+ ciFailing,
8
+ createPrSource,
9
+ describePr,
10
+ findPrUrls,
11
+ isPrCreateCommand,
12
+ parsePrList,
13
+ parsePrView,
14
+ parseRepoView,
15
+ PR_LIST_FIELDS,
16
+ prChipLabel,
17
+ prColor,
18
+ prSegment,
19
+ prUrlRepo,
20
+ type PrRepo,
21
+ type PullRequest,
22
+ } from "../src/prs.js"
23
+
24
+ const repo: PrRepo = { nameWithOwner: "raviy06/pi-minimal", host: "github.com", url: "https://github.com/raviy06/pi-minimal" }
25
+
26
+ // Shapes from `gh pr list --json …` on github.com and on a GitHub Enterprise host.
27
+ const ghList = JSON.stringify([
28
+ {
29
+ number: 23, title: "feat: background processes", url: "https://github.com/raviy06/pi-minimal/pull/23", isDraft: false,
30
+ author: { login: "raviy06" }, reviewRequests: [], reviewDecision: "",
31
+ statusCheckRollup: [{ state: null, conclusion: "", status: "IN_PROGRESS" }], updatedAt: "2026-10-28T13:40:00Z",
32
+ },
33
+ {
34
+ number: 22, title: "feat: Rex siblings", url: "https://github.com/raviy06/pi-minimal/pull/22", isDraft: true,
35
+ author: { login: "raviy06" }, reviewRequests: [], reviewDecision: "",
36
+ statusCheckRollup: [{ state: null, conclusion: "SUCCESS", status: "COMPLETED" }], updatedAt: "2026-10-28T13:30:00Z",
37
+ },
38
+ {
39
+ number: 21, title: "docs: glossary", url: "https://github.com/raviy06/pi-minimal/pull/21", isDraft: false,
40
+ author: { login: "raviy06" }, reviewRequests: [], reviewDecision: "CHANGES_REQUESTED",
41
+ statusCheckRollup: [], updatedAt: "2026-10-27T10:00:00Z",
42
+ },
43
+ {
44
+ number: 20, title: "fix: review me", url: "https://github.com/raviy06/pi-minimal/pull/20", isDraft: false,
45
+ author: { login: "someone" }, reviewRequests: [{ __typename: "User", login: "RaviY06" }], reviewDecision: "REVIEW_REQUIRED",
46
+ statusCheckRollup: [{ state: "PENDING", conclusion: null, status: null }, { state: "FAILURE", conclusion: null, status: null }], updatedAt: "2026-10-28T09:00:00Z",
47
+ },
48
+ {
49
+ number: 19, title: "team review", url: "https://github.com/raviy06/pi-minimal/pull/19", isDraft: false,
50
+ author: { login: "someone" }, reviewRequests: [{ __typename: "Team", name: "HotelTonight", slug: "hoteltonight" }], reviewDecision: "",
51
+ statusCheckRollup: [], updatedAt: "2026-10-28T08:00:00Z",
52
+ },
53
+ {
54
+ number: 18, title: "just mentioned", url: "https://github.com/raviy06/pi-minimal/pull/18", isDraft: false,
55
+ author: { login: "someone" }, reviewRequests: [], reviewDecision: "", statusCheckRollup: [], updatedAt: "2026-10-28T07:00:00Z",
56
+ },
57
+ ])
58
+
59
+ test.describe("prs parsing", () => {
60
+ test("parseRepoView derives the host from the URL", () => {
61
+ assert.deepEqual(parseRepoView(JSON.stringify({ nameWithOwner: "airbnb/ht-mainapp", url: "https://git.musta.ch/airbnb/ht-mainapp" })), {
62
+ nameWithOwner: "airbnb/ht-mainapp", host: "git.musta.ch", url: "https://git.musta.ch/airbnb/ht-mainapp",
63
+ })
64
+ assert.throws(() => parseRepoView("{}"))
65
+ })
66
+
67
+ test("ciFailing reads both status contexts and check runs", () => {
68
+ assert.equal(ciFailing([{ state: "SUCCESS" }, { conclusion: "SUCCESS", status: "COMPLETED" }]), false)
69
+ assert.equal(ciFailing([{ state: "PENDING" }]), false)
70
+ assert.equal(ciFailing([{ state: "FAILURE" }]), true)
71
+ assert.equal(ciFailing([{ conclusion: "TIMED_OUT", status: "COMPLETED" }]), true)
72
+ assert.equal(ciFailing(null), false)
73
+ })
74
+
75
+ test("parsePrList keeps mine and review-requested (case-insensitive), drops mentions, sorts reviews first", () => {
76
+ const prs = parsePrList(ghList, repo, "raviy06")
77
+ assert.deepEqual(prs.map((pr) => [pr.number, pr.kind]), [[20, "review"], [23, "mine"], [22, "mine"], [21, "mine"]])
78
+ assert.equal(prs.find((pr) => pr.number === 20)?.ciFailing, true)
79
+ assert.equal(prs.find((pr) => pr.number === 22)?.isDraft, true)
80
+ assert.equal(prs[0]?.repo, "github.com/raviy06/pi-minimal")
81
+ })
82
+
83
+ test("team review requests count when my teams are known", () => {
84
+ const prs = parsePrList(ghList, repo, "raviy06", ["HotelTonight"])
85
+ assert.ok(prs.some((pr) => pr.number === 19 && pr.kind === "review"))
86
+ })
87
+ })
88
+
89
+ test.describe("prs display", () => {
90
+ const theme = { fg: (c: string, t: string) => `[${c}]${t}[/${c}]` }
91
+ const prs = parsePrList(ghList, repo, "raviy06")
92
+ const by = (n: number) => prs.find((pr) => pr.number === n) as PullRequest
93
+
94
+ test("prColor: trouble > review > draft > mine", () => {
95
+ assert.equal(prColor(by(20)), "error", "failing CI on a review request")
96
+ assert.equal(prColor(by(21)), "error", "changes requested")
97
+ assert.equal(prColor({ ...by(20), ciFailing: false }), "warning")
98
+ assert.equal(prColor(by(22)), "dim")
99
+ assert.equal(prColor(by(23)), "accent")
100
+ })
101
+
102
+ test("prSegment links each chip, colours it, and collapses the overflow", () => {
103
+ const segment = prSegment(prs, theme, 2, "github.com/raviy06/pi-minimal") as string
104
+ const esc = "\u001b"
105
+ assert.ok(segment.includes(`[error]${esc}]8;;https://github.com/raviy06/pi-minimal/pull/20${esc}\\#20${esc}]8;;${esc}\\[/error]`), "chip #20 is a red OSC 8 link")
106
+ assert.match(segment, /\[accent\].*#23.*\[\/accent\]/)
107
+ assert.match(segment, /\[dim\]\+2\[\/dim\]$/)
108
+ assert.equal(stripTerminalSequences(segment.replace(/\[\/?[a-z]+\]/g, "")), "#20 #23 +2")
109
+ assert.equal(prSegment([], theme), undefined)
110
+ assert.equal(stripTerminalSequences((prSegment(prs.slice(0, 1), theme, 4) as string).replace(/\[\/?[a-z]+\]/g, "")), "pi-minimal#20", "outside any repo the chip names its repo")
111
+ })
112
+
113
+ test("describePr summarises kind and trouble", () => {
114
+ assert.equal(describePr(by(20), "github.com/raviy06/pi-minimal"), "#20 fix: review me · review requested · CI failing · @someone")
115
+ assert.equal(describePr(by(22), "github.com/raviy06/pi-minimal"), "#22 feat: Rex siblings · draft")
116
+ assert.equal(describePr(by(21), "github.com/raviy06/pi-minimal"), "#21 docs: glossary · mine · changes requested")
117
+ })
118
+
119
+ test("filter parsing and filtering", () => {
120
+ assert.equal(parsePrFilter(""), "all")
121
+ assert.equal(parsePrFilter("mine"), "mine")
122
+ assert.equal(parsePrFilter("Review extra"), "review")
123
+ assert.deepEqual(filterPrs(prs, "review").map((pr) => pr.number), [20])
124
+ assert.deepEqual(filterPrs(prs, "mine").map((pr) => pr.number), [23, 22, 21])
125
+ })
126
+ })
127
+
128
+ test.describe("createPrSource", () => {
129
+ const tick = () => new Promise((resolve) => setImmediate(resolve))
130
+ const settle = async () => { for (let i = 0; i < 12; i++) await tick() }
131
+
132
+ const sessionPrUrl = "https://git.musta.ch/airbnb/ht-mainapp/pull/22741"
133
+ const ghView = (state = "OPEN") => JSON.stringify({
134
+ number: 22741, title: "Make the Rails session cookie host-only", url: sessionPrUrl, isDraft: false, state,
135
+ author: { login: "ravi-yadav" }, reviewRequests: [], reviewDecision: "REVIEW_REQUIRED",
136
+ statusCheckRollup: [{ state: "FAILURE" }], updatedAt: "2026-10-28T12:00:00Z",
137
+ })
138
+
139
+ function fakePi(options: { repoOk?: boolean; login?: string; viewState?: string } = {}) {
140
+ const execCalls: string[][] = []
141
+ const pi = {
142
+ async exec(command: string, args: string[]) {
143
+ execCalls.push([command, ...args])
144
+ if (command !== "gh") return { stdout: "", stderr: "", code: 1, killed: false }
145
+ if (args[0] === "pr" && args[1] === "view") return { stdout: ghView(options.viewState), stderr: "", code: 0, killed: false }
146
+ if (args[0] === "repo") {
147
+ return options.repoOk === false
148
+ ? { stdout: "", stderr: "none of the git remotes configured for this repository point to a known GitHub host", code: 1, killed: false }
149
+ : { stdout: JSON.stringify({ nameWithOwner: repo.nameWithOwner, url: repo.url }), stderr: "", code: 0, killed: false }
150
+ }
151
+ if (args[0] === "api") return { stdout: `${options.login ?? "raviy06"}\n`, stderr: "", code: 0, killed: false }
152
+ if (args[0] === "pr") return { stdout: ghList, stderr: "", code: 0, killed: false }
153
+ return { stdout: "", stderr: "", code: 1, killed: false }
154
+ },
155
+ } as unknown as ExtensionAPI
156
+ return { pi, execCalls }
157
+ }
158
+
159
+ test("fetches repo, viewer login (per host) and the PR list once; later refreshes reuse the repo and login", async (t) => {
160
+ const { pi, execCalls } = fakePi()
161
+ let now = 1_000_000
162
+ const source = createPrSource({ pi, cwd: () => "/repo", now: () => now, minIntervalMs: 60_000, pollMs: 10 * 60_000 })
163
+ t.after(() => source.dispose())
164
+ let changes = 0
165
+ source.onChange(() => { changes += 1 })
166
+ await settle()
167
+ assert.deepEqual(execCalls.map((c) => c.slice(0, 3)), [
168
+ ["gh", "repo", "view"],
169
+ ["gh", "api", "--hostname"],
170
+ ["gh", "pr", "list"],
171
+ ])
172
+ const hostnameIdx = execCalls[1]?.indexOf("--hostname") ?? -1
173
+ assert.ok(hostnameIdx >= 0)
174
+ assert.equal(execCalls[1]?.[hostnameIdx + 1], "github.com")
175
+ assert.ok(execCalls[2]?.includes("github.com/raviy06/pi-minimal") && execCalls[2]?.includes(PR_LIST_FIELDS))
176
+ assert.equal(changes, 1)
177
+ assert.deepEqual(source.get().prs.map((pr) => pr.number), [20, 23, 22, 21])
178
+ assert.equal(source.get().fetchedAt, now)
179
+
180
+ // Throttled: an event-driven refresh inside the window does nothing.
181
+ await source.refresh()
182
+ assert.equal(execCalls.length, 3)
183
+ // Forced: only the list is fetched again; nothing changed so no notification.
184
+ now += 1000
185
+ await source.refresh(true)
186
+ assert.equal(execCalls.length, 4)
187
+ assert.equal(execCalls[3]?.[1], "pr")
188
+ assert.equal(changes, 1)
189
+
190
+ const tool = prsToolResult(source, "review")
191
+ assert.equal(tool.repo, "raviy06/pi-minimal")
192
+ assert.deepEqual(tool.prs.map((pr) => pr.number), [20])
193
+ })
194
+
195
+ test("outside a GitHub repo the snapshot is empty with the error, and no PR list is requested", async (t) => {
196
+ const { pi, execCalls } = fakePi({ repoOk: false })
197
+ const source = createPrSource({ pi, cwd: () => "/elsewhere", pollMs: 10 * 60_000 })
198
+ t.after(() => source.dispose())
199
+ await settle()
200
+ assert.equal(execCalls.length, 1)
201
+ assert.equal(source.get().repo, undefined)
202
+ assert.deepEqual(source.get().prs, [])
203
+ assert.match(source.get().error ?? "", /known GitHub host/)
204
+ })
205
+
206
+ test("a PR created in this session shows up even outside any repo, is persisted via sessionPrs(), and is forgotten once merged", async (t) => {
207
+ const { pi, execCalls } = fakePi({ repoOk: false })
208
+ const source = createPrSource({ pi, cwd: () => "/elsewhere", pollMs: 10 * 60_000 })
209
+ t.after(() => source.dispose())
210
+ await settle()
211
+ assert.deepEqual(source.get().prs, [])
212
+
213
+ source.addSessionPr("Creating pull request…\nhttps://git.musta.ch/airbnb/ht-mainapp/pull/22741\n")
214
+ await settle()
215
+ assert.deepEqual(source.sessionPrs(), [sessionPrUrl])
216
+ assert.ok(execCalls.some((c) => c[1] === "pr" && c[2] === "view" && c[3] === sessionPrUrl))
217
+ assert.deepEqual(source.get().prs.map((pr) => [pr.number, pr.kind, pr.repo, pr.ciFailing]), [[22741, "mine", "git.musta.ch/airbnb/ht-mainapp", true]])
218
+ assert.equal(source.get().repo, undefined, "still not a repo here")
219
+
220
+ // Same URL again is a no-op.
221
+ const calls = execCalls.length
222
+ source.addSessionPr(sessionPrUrl)
223
+ await settle()
224
+ assert.equal(execCalls.length, calls)
225
+
226
+ // A restored session starts with the URL and fetches it on the first refresh.
227
+ const restored = createPrSource({ pi, cwd: () => "/elsewhere", pollMs: 10 * 60_000, sessionPrs: [sessionPrUrl] })
228
+ t.after(() => restored.dispose())
229
+ await settle()
230
+ assert.deepEqual(restored.get().prs.map((pr) => pr.number), [22741])
231
+ })
232
+
233
+ test("a session PR in the cwd repo is not listed twice; a merged one disappears", async (t) => {
234
+ const merged = fakePi({ viewState: "MERGED" })
235
+ const source = createPrSource({ pi: merged.pi, cwd: () => "/repo", pollMs: 10 * 60_000, sessionPrs: [sessionPrUrl, "https://github.com/raviy06/pi-minimal/pull/23"] })
236
+ t.after(() => source.dispose())
237
+ await settle()
238
+ // #23 comes from the list (so no `pr view` for it); the GHE one was merged, so it is dropped and forgotten.
239
+ assert.equal(merged.execCalls.filter((c) => c[1] === "pr" && c[2] === "view").length, 1)
240
+ assert.deepEqual(source.get().prs.map((pr) => pr.number), [20, 23, 22, 21])
241
+ assert.deepEqual(source.sessionPrs(), ["https://github.com/raviy06/pi-minimal/pull/23"])
242
+ })
243
+ })
244
+
245
+ test.describe("session PRs", () => {
246
+ test("PR URLs are found in gh pr create output and mapped to their repo", () => {
247
+ const out = "Creating pull request for ry-prs-status into main in raviy06/pi-minimal\n\nhttps://github.com/raviy06/pi-minimal/pull/24\n"
248
+ assert.deepEqual(findPrUrls(out), ["https://github.com/raviy06/pi-minimal/pull/24"])
249
+ assert.deepEqual(findPrUrls("see https://git.musta.ch/airbnb/ht-mainapp/pull/22741 and https://git.musta.ch/airbnb/ht-mainapp/pull/22741"), ["https://git.musta.ch/airbnb/ht-mainapp/pull/22741"])
250
+ assert.deepEqual(findPrUrls("https://github.com/raviy06/pi-minimal/issues/5"), [])
251
+ assert.equal(prUrlRepo("https://git.musta.ch/airbnb/ht-mainapp/pull/22741"), "git.musta.ch/airbnb/ht-mainapp")
252
+ assert.equal(isPrCreateCommand("cd x && gh pr create --fill"), true)
253
+ assert.equal(isPrCreateCommand("gt submit --stack"), true)
254
+ assert.equal(isPrCreateCommand("gh pr view 24"), false)
255
+ })
256
+
257
+ test("parsePrView keeps open PRs as mine and drops merged/closed ones", () => {
258
+ const url = "https://git.musta.ch/airbnb/ht-mainapp/pull/22741"
259
+ const raw = { number: 22741, title: "t", url, isDraft: false, author: { login: "x" }, statusCheckRollup: [], state: "OPEN" }
260
+ assert.equal(parsePrView(JSON.stringify(raw), url)?.kind, "mine")
261
+ assert.equal(parsePrView(JSON.stringify(raw), url)?.repo, "git.musta.ch/airbnb/ht-mainapp")
262
+ assert.equal(parsePrView(JSON.stringify({ ...raw, state: "MERGED" }), url), undefined)
263
+ })
264
+
265
+ test("chips outside the cwd repo carry the repo name", () => {
266
+ const pr = parsePrList(ghList, repo, "raviy06")[0] as PullRequest
267
+ assert.equal(prChipLabel(pr, "github.com/raviy06/pi-minimal"), "#20")
268
+ assert.equal(prChipLabel(pr, "git.musta.ch/airbnb/ht-mainapp"), "pi-minimal#20")
269
+ assert.equal(prChipLabel(pr, undefined), "pi-minimal#20", "no cwd repo: name the repo")
270
+ assert.match(describePr(pr, "git.musta.ch/airbnb/other"), /^pi-minimal#20 {2}/)
271
+ })
272
+ })
@@ -0,0 +1,249 @@
1
+ import assert from "node:assert/strict"
2
+ import { EventEmitter } from "node:events"
3
+ import { PassThrough } from "node:stream"
4
+ import test from "node:test"
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
6
+ import {
7
+ applyRexEvent,
8
+ createRexSiblings,
9
+ createRexState,
10
+ listRexSiblings,
11
+ parseProgramStatus,
12
+ parseWindowList,
13
+ type RexSpawn,
14
+ } from "../src/rex.js"
15
+
16
+ const SELF = "block:self"
17
+ const OTHER = "block:other"
18
+ const WIN_SELF = "window:self"
19
+ const WIN_OTHER = "window:other"
20
+
21
+ // Shapes captured from `rex window ls --json`, `rex block call … program_status` and `rex events --json`.
22
+ const windowList = JSON.stringify({
23
+ revision: 14,
24
+ session_id: "session:s",
25
+ label: "pi-dev",
26
+ active_window_id: WIN_SELF,
27
+ windows: [
28
+ {
29
+ window_id: WIN_SELF,
30
+ label: "Window 395a96cb",
31
+ focused_block_id: SELF,
32
+ layers: [{ kind: "tiled", blocks: [{ block_id: SELF, label: "shell", creator_name: "com.superlogical.terminal" }] }],
33
+ },
34
+ {
35
+ window_id: WIN_OTHER,
36
+ label: "ry-fix-login",
37
+ focused_block_id: OTHER,
38
+ layers: [
39
+ {
40
+ kind: "tiled",
41
+ blocks: [
42
+ { block_id: OTHER, label: "shell", creator_name: "com.superlogical.terminal" },
43
+ { block_id: "block:browser", label: "web", creator_name: "com.superlogical.browser" },
44
+ ],
45
+ },
46
+ ],
47
+ },
48
+ ],
49
+ })
50
+
51
+ function sessionEvent(event: unknown): string {
52
+ return JSON.stringify({ type: "session_event", payload: { session_id: "session:s", event } })
53
+ }
54
+
55
+ function statusChanged(blockId: string, record: Record<string, unknown>, id = ""): string {
56
+ return sessionEvent({
57
+ type: "block_event",
58
+ payload: { block_id: blockId, creator_name: "com.superlogical.terminal", name: "program_status_changed", payload: { id, record, reason: "report" } },
59
+ })
60
+ }
61
+
62
+ function statusRemoved(blockId: string, ids: string[], reason = "child_exited"): string {
63
+ return sessionEvent({
64
+ type: "block_event",
65
+ payload: { block_id: blockId, creator_name: "com.superlogical.terminal", name: "program_status_removed", payload: { ids, reason } },
66
+ })
67
+ }
68
+
69
+ test.describe("rex parsing", () => {
70
+ test("parseWindowList maps terminal blocks to tabs and skips non-terminal blocks", () => {
71
+ const layout = parseWindowList(windowList)
72
+ assert.deepEqual([...layout.tabs], [[WIN_SELF, "Window 395a96cb"], [WIN_OTHER, "ry-fix-login"]])
73
+ assert.deepEqual([...layout.blocks], [[SELF, WIN_SELF], [OTHER, WIN_OTHER]])
74
+ })
75
+
76
+ test("parseWindowList rejects output without windows", () => {
77
+ assert.throws(() => parseWindowList("{}"))
78
+ })
79
+
80
+ test("parseProgramStatus returns the root record and prefers effective_app", () => {
81
+ const out = JSON.stringify({
82
+ records: [
83
+ { state: "working", app: "pi", effective_app: "pi", updated_at: "2026-10-28T12:58:11Z", owner: { pid: 1, name: "node" } },
84
+ { id: "build", state: "blocked", updated_at: "2026-10-28T12:58:11Z" },
85
+ ],
86
+ })
87
+ assert.deepEqual(parseProgramStatus(out), { state: "working", app: "pi" })
88
+ assert.equal(parseProgramStatus(JSON.stringify({ records: [] })), undefined)
89
+ assert.equal(parseProgramStatus(JSON.stringify({ records: [{ id: "child", state: "working", updated_at: "x" }] })), undefined)
90
+ })
91
+ })
92
+
93
+ test.describe("rex events", () => {
94
+ test("root status reports create, replace and remove records", () => {
95
+ const state = createRexState()
96
+ state.layout = parseWindowList(windowList)
97
+
98
+ assert.equal(applyRexEvent(state, statusChanged(OTHER, { state: "working", app: "pi", effective_app: "pi", updated_at: "x" })), "status")
99
+ assert.deepEqual(listRexSiblings(state, SELF), [{ name: "ry-fix-login", status: "working" }])
100
+
101
+ assert.equal(applyRexEvent(state, statusChanged(OTHER, { state: "blocked", kind: "question", app: "pi", msg: "Approve edit?", updated_at: "x" })), "status")
102
+ assert.deepEqual(listRexSiblings(state, SELF), [{ name: "ry-fix-login", status: "blocked" }])
103
+
104
+ assert.equal(applyRexEvent(state, statusRemoved(OTHER, [""])), "status")
105
+ assert.equal(listRexSiblings(state, SELF), undefined)
106
+ })
107
+
108
+ test("child records, other ids and non-session lines are ignored", () => {
109
+ const state = createRexState()
110
+ assert.equal(applyRexEvent(state, statusChanged(OTHER, { state: "working", updated_at: "x" }, "build/test")), undefined)
111
+ assert.equal(applyRexEvent(state, statusRemoved(OTHER, ["build"])), undefined)
112
+ assert.equal(applyRexEvent(state, sessionEvent({ type: "session_view_changed", payload: { revision: 3 } })), undefined)
113
+ assert.equal(applyRexEvent(state, JSON.stringify({ type: "server_event" })), undefined)
114
+ assert.equal(applyRexEvent(state, "not json"), undefined)
115
+ assert.equal(state.status.size, 0)
116
+ })
117
+
118
+ test("block_closed drops the record; label and block changes request a layout refresh", () => {
119
+ const state = createRexState()
120
+ state.layout = parseWindowList(windowList)
121
+ applyRexEvent(state, statusChanged(OTHER, { state: "working", app: "pi", updated_at: "x" }))
122
+ assert.equal(applyRexEvent(state, sessionEvent({ type: "block_closed", payload: { block_id: OTHER } })), "layout")
123
+ assert.equal(state.status.has(OTHER), false)
124
+ assert.equal(state.layout.blocks.has(OTHER), false)
125
+ assert.equal(applyRexEvent(state, sessionEvent({ type: "window_label_changed", payload: { window_id: WIN_OTHER, label: "x" } })), "layout")
126
+ assert.equal(applyRexEvent(state, sessionEvent({ type: "block_created", payload: { block_id: "block:new" } })), "layout")
127
+ })
128
+ })
129
+
130
+ test.describe("listRexSiblings", () => {
131
+ test("excludes self, falls back from auto labels to the app, and needs a self block", () => {
132
+ const state = createRexState()
133
+ state.layout = parseWindowList(windowList)
134
+ state.status.set(SELF, { state: "working", app: "pi" })
135
+ state.status.set(OTHER, { state: "idle", app: "pi" })
136
+ assert.deepEqual(listRexSiblings(state, SELF), [{ name: "ry-fix-login", status: "idle" }])
137
+ // From the other pane's point of view, our auto-labelled tab is named after the program.
138
+ assert.deepEqual(listRexSiblings(state, OTHER), [{ name: "pi", status: "working" }])
139
+ assert.equal(listRexSiblings(state, undefined), undefined)
140
+ })
141
+
142
+ test("a pane in the same tab as self is named after its program, not the shared tab", () => {
143
+ const state = createRexState()
144
+ state.layout = parseWindowList(windowList)
145
+ state.layout.blocks.set("block:split", WIN_OTHER)
146
+ state.status.set("block:split", { state: "blocked", app: "terraform" })
147
+ state.status.set(SELF, { state: "working", app: "pi" })
148
+ assert.deepEqual(listRexSiblings(state, OTHER), [
149
+ { name: "terraform", status: "blocked" },
150
+ { name: "pi", status: "working" },
151
+ ])
152
+ })
153
+
154
+ test("a record with neither a real label nor an app is skipped", () => {
155
+ const state = createRexState()
156
+ state.layout = parseWindowList(windowList)
157
+ state.status.set(SELF, { state: "working" })
158
+ assert.equal(listRexSiblings(state, OTHER), undefined)
159
+ })
160
+ })
161
+
162
+ type FakeChild = EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: () => boolean; killed: boolean }
163
+
164
+ function fakeSpawn() {
165
+ const children: FakeChild[] = []
166
+ const calls: string[][] = []
167
+ const spawn: RexSpawn = (command, args) => {
168
+ calls.push([command, ...args])
169
+ const child = new EventEmitter() as FakeChild
170
+ child.stdout = new PassThrough()
171
+ child.stderr = new PassThrough()
172
+ child.killed = false
173
+ child.kill = () => { child.killed = true; return true }
174
+ children.push(child)
175
+ return child as unknown as ReturnType<RexSpawn>
176
+ }
177
+ return { spawn, children, calls }
178
+ }
179
+
180
+ function fakePi(statusByBlock: Record<string, string>) {
181
+ const execCalls: string[][] = []
182
+ const pi = {
183
+ async exec(command: string, args: string[]) {
184
+ execCalls.push([command, ...args])
185
+ if (command === "rex" && args[0] === "window") return { stdout: windowList, stderr: "", code: 0, killed: false }
186
+ if (command === "rex" && args[0] === "block") {
187
+ const block = args[args.indexOf("-b") + 1]
188
+ const stdout = statusByBlock[block] ?? JSON.stringify({ records: [] })
189
+ return { stdout, stderr: "", code: 0, killed: false }
190
+ }
191
+ return { stdout: "", stderr: "", code: 1, killed: false }
192
+ },
193
+ } as unknown as ExtensionAPI
194
+ return { pi, execCalls }
195
+ }
196
+
197
+ const tick = () => new Promise((resolve) => setImmediate(resolve))
198
+
199
+ test.describe("createRexSiblings", () => {
200
+ test("seeds from window ls + program_status (skipping self), then follows the event stream", async () => {
201
+ const { spawn, children, calls } = fakeSpawn()
202
+ const { pi, execCalls } = fakePi({
203
+ [OTHER]: JSON.stringify({ records: [{ state: "working", app: "pi", effective_app: "pi", updated_at: "x" }] }),
204
+ })
205
+ const source = createRexSiblings({ pi, spawn, env: { REX_SESSION: "session:s", REX_BLOCK: SELF } })
206
+ let changes = 0
207
+ source.onChange(() => { changes += 1 })
208
+
209
+ assert.deepEqual(calls, [["rex", "events", "-s", "session:s", "--json"]])
210
+ for (let i = 0; i < 5; i++) await tick()
211
+ assert.deepEqual(source.get(), [{ name: "ry-fix-login", status: "working" }])
212
+ assert.equal(changes, 1)
213
+ assert.ok(execCalls.some((c) => c[1] === "window"))
214
+ assert.ok(execCalls.some((c) => c[1] === "block" && c.includes(OTHER)))
215
+ assert.equal(execCalls.some((c) => c[1] === "block" && c.includes(SELF)), false, "self is not probed")
216
+
217
+ children[0].stdout.write(statusChanged(OTHER, { state: "blocked", kind: "permission", app: "pi", updated_at: "y" }) + "\n")
218
+ for (let i = 0; i < 3; i++) await tick()
219
+ assert.deepEqual(source.get(), [{ name: "ry-fix-login", status: "blocked" }])
220
+ assert.equal(changes, 2)
221
+
222
+ // Same state again: no spurious notification.
223
+ children[0].stdout.write(statusChanged(OTHER, { state: "blocked", kind: "permission", app: "pi", updated_at: "z" }) + "\n")
224
+ for (let i = 0; i < 3; i++) await tick()
225
+ assert.equal(changes, 2)
226
+
227
+ source.dispose()
228
+ assert.equal(children[0].killed, true)
229
+ assert.equal(source.get(), undefined)
230
+ })
231
+
232
+ test("a stream exit clears siblings and a disposed source ignores late events", async () => {
233
+ const { spawn, children } = fakeSpawn()
234
+ const { pi } = fakePi({})
235
+ const source = createRexSiblings({ pi, spawn, env: { REX_SESSION: "session:s", REX_BLOCK: SELF } })
236
+ for (let i = 0; i < 5; i++) await tick()
237
+ children[0].stdout.write(statusChanged(OTHER, { state: "working", app: "pi", updated_at: "x" }) + "\n")
238
+ for (let i = 0; i < 3; i++) await tick()
239
+ assert.deepEqual(source.get(), [{ name: "ry-fix-login", status: "working" }])
240
+
241
+ children[0].emit("exit", 1)
242
+ assert.equal(source.get(), undefined)
243
+
244
+ source.dispose()
245
+ children[0].stdout.write(statusChanged(OTHER, { state: "working", app: "pi", updated_at: "x" }) + "\n")
246
+ for (let i = 0; i < 3; i++) await tick()
247
+ assert.equal(source.get(), undefined)
248
+ })
249
+ })