@aaroncql/pim-agent 0.0.1 → 0.1.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.
Files changed (60) hide show
  1. package/README.md +19 -8
  2. package/bin/pim.ts +55 -3
  3. package/package.json +20 -5
  4. package/src/extensions/_init/index.ts +3 -2
  5. package/src/extensions/bash/capture.test.ts +0 -126
  6. package/src/extensions/bash/format.test.ts +0 -240
  7. package/src/extensions/bash/run.test.ts +0 -262
  8. package/src/extensions/command-picker/ranker.test.ts +0 -46
  9. package/src/extensions/edit/edit.test.ts +0 -285
  10. package/src/extensions/file-picker/catalog.test.ts +0 -263
  11. package/src/extensions/file-picker/index.test.ts +0 -168
  12. package/src/extensions/file-picker/ranker.test.ts +0 -94
  13. package/src/extensions/footer/git.test.ts +0 -76
  14. package/src/extensions/footer/index.test.ts +0 -161
  15. package/src/extensions/footer/segments.test.ts +0 -164
  16. package/src/extensions/glob/glob.test.ts +0 -171
  17. package/src/extensions/glob/index.test.ts +0 -68
  18. package/src/extensions/glob/render.test.ts +0 -126
  19. package/src/extensions/grep/grep.test.ts +0 -387
  20. package/src/extensions/grep/index.test.ts +0 -68
  21. package/src/extensions/grep/render.test.ts +0 -269
  22. package/src/extensions/read/read.test.ts +0 -177
  23. package/src/extensions/read/render.test.ts +0 -61
  24. package/src/extensions/subagent/index.test.ts +0 -44
  25. package/src/extensions/subagent/render.test.ts +0 -292
  26. package/src/extensions/subagent/subagent.test.ts +0 -315
  27. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  28. package/src/extensions/todo/index.test.ts +0 -244
  29. package/src/extensions/todo/render.test.ts +0 -180
  30. package/src/extensions/todo/todo.test.ts +0 -222
  31. package/src/extensions/tps/index.test.ts +0 -254
  32. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  33. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  34. package/src/extensions/web-fetch/render.test.ts +0 -56
  35. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  36. package/src/extensions/web-search/render.test.ts +0 -21
  37. package/src/extensions/web-search/search.test.ts +0 -53
  38. package/src/extensions/working-indicator/index.test.ts +0 -21
  39. package/src/extensions/write/render.test.ts +0 -64
  40. package/src/extensions/write/write.test.ts +0 -108
  41. package/src/shared/DiffLines.test.ts +0 -193
  42. package/src/shared/DiffRenderer.test.ts +0 -206
  43. package/src/shared/EditMatcher.test.ts +0 -123
  44. package/src/shared/FileScanner.test.ts +0 -158
  45. package/src/shared/FuzzyMatcher.test.ts +0 -114
  46. package/src/shared/GitignoreFilter.test.ts +0 -64
  47. package/src/shared/Lines.test.ts +0 -25
  48. package/src/shared/McpClient.test.ts +0 -235
  49. package/src/shared/OutputBudget.test.ts +0 -99
  50. package/src/shared/Paths.test.ts +0 -51
  51. package/src/shared/PimSettings.test.ts +0 -90
  52. package/src/shared/Renderer.test.ts +0 -190
  53. package/src/shared/SpillCache.test.ts +0 -94
  54. package/src/shared/Tools.test.ts +0 -392
  55. package/src/telegram/Config.test.ts +0 -275
  56. package/src/telegram/Markdown.test.ts +0 -143
  57. package/src/telegram/Renderer.test.ts +0 -216
  58. package/src/telegram/SessionRegistry.test.ts +0 -89
  59. package/src/telegram/TaskScheduler.test.ts +0 -278
  60. package/src/telegram/TaskTool.test.ts +0 -179
@@ -1,292 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type {
3
- AgentToolResult,
4
- Theme,
5
- ThemeColor,
6
- } from "@earendil-works/pi-coding-agent";
7
- import type { SubagentDetails } from "./subagent";
8
- import {
9
- formatCallTitle,
10
- formatTopLine,
11
- renderCall,
12
- renderResult,
13
- } from "./render";
14
-
15
- const stubTheme = {
16
- bold: (text: string) => text,
17
- italic: (text: string) => text,
18
- strikethrough: (text: string) => text,
19
- underline: (text: string) => text,
20
- fg: (_color: string, text: string) => text,
21
- } as unknown as Theme;
22
-
23
- type ColorCall = {
24
- readonly color: ThemeColor;
25
- readonly text: string;
26
- };
27
-
28
- function tracingTheme(): {
29
- readonly theme: Theme;
30
- readonly calls: ColorCall[];
31
- } {
32
- const calls: ColorCall[] = [];
33
- return {
34
- calls,
35
- theme: {
36
- bold: (text: string) => text,
37
- italic: (text: string) => text,
38
- strikethrough: (text: string) => text,
39
- underline: (text: string) => text,
40
- fg: (color: ThemeColor, text: string) => {
41
- calls.push({ color, text });
42
- return text;
43
- },
44
- } as unknown as Theme,
45
- };
46
- }
47
-
48
- const baseDetails: SubagentDetails = {
49
- returnedOutput: "body",
50
- fullOutput: "body",
51
- outputTruncated: false,
52
- omittedBytes: 0,
53
- usage: {
54
- input: 10,
55
- output: 5,
56
- cacheRead: 2,
57
- cacheWrite: 0,
58
- cost: 0.23,
59
- turns: 3,
60
- contextTokens: 4000,
61
- },
62
- toolCalls: [{ name: "read", isError: false }],
63
- activeToolNames: [],
64
- lastToolName: "read",
65
- stopReason: "stop",
66
- errorMessage: undefined,
67
- model: "deepseek-v4-flash",
68
- contextWindow: 1_000_000,
69
- topLine: "$0.23 ⬝ 0.4%/1.0M ⬝ deepseek-v4-flash ⬝ 3 turns ⬝ 1 tool",
70
- };
71
-
72
- function result(text: string): AgentToolResult<SubagentDetails> {
73
- return { content: [{ type: "text", text }], details: baseDetails };
74
- }
75
-
76
- describe("subagent render formatting", () => {
77
- test("call title uses the first line without truncating", () => {
78
- const long = `${"x".repeat(140)}\nsecond`;
79
-
80
- expect(formatCallTitle(long)).toBe("x".repeat(140));
81
- });
82
-
83
- test("top line includes cost, context, model, and activity", () => {
84
- expect(formatTopLine(baseDetails)).toBe(
85
- "$0.23 ⬝ 0.4%/1.0M ⬝ deepseek-v4-flash ⬝ 3 turns ⬝ 1 tool"
86
- );
87
- });
88
-
89
- test("call title renders prompt markdown", () => {
90
- const component = renderCall(
91
- { prompt: "Review **bold** and `code`" },
92
- stubTheme,
93
- {
94
- lastComponent: undefined,
95
- isPartial: false,
96
- isError: false,
97
- }
98
- );
99
-
100
- expect(component.render(80)[0]?.trimEnd()).toBe(
101
- " ▪ Subagent: Review bold and code"
102
- );
103
- });
104
-
105
- test("call title uses the default color for prompt text", () => {
106
- const rendered = tracingTheme();
107
- renderCall({ prompt: "plain prompt" }, rendered.theme, {
108
- lastComponent: undefined,
109
- isPartial: false,
110
- isError: false,
111
- }).render(80);
112
-
113
- expect(rendered.calls).not.toContainEqual({
114
- color: "toolTitle",
115
- text: "plain prompt",
116
- });
117
- });
118
-
119
- test("call title colors the Subagent label by running status", () => {
120
- const pending = tracingTheme();
121
- renderCall({ prompt: "investigate" }, pending.theme, {
122
- lastComponent: undefined,
123
- isPartial: true,
124
- isError: false,
125
- }).render(80);
126
-
127
- expect(pending.calls).toContainEqual({
128
- color: "warning",
129
- text: "Subagent",
130
- });
131
-
132
- const done = tracingTheme();
133
- renderCall({ prompt: "investigate" }, done.theme, {
134
- lastComponent: undefined,
135
- isPartial: false,
136
- isError: false,
137
- }).render(80);
138
-
139
- expect(done.calls).toContainEqual({ color: "accent", text: "Subagent" });
140
- });
141
-
142
- test("top line uses muted dots with accent or warning content", () => {
143
- const done = tracingTheme();
144
- renderResult(
145
- result("body"),
146
- { expanded: false, isPartial: false },
147
- done.theme,
148
- { lastComponent: undefined, isPartial: false, isError: false }
149
- ).render(80);
150
-
151
- expect(done.calls).toContainEqual({ color: "accent", text: "$0.23 " });
152
- expect(done.calls).toContainEqual({ color: "muted", text: "⬝" });
153
-
154
- const running = tracingTheme();
155
- renderResult(
156
- {
157
- content: [{ type: "text", text: "ignored body" }],
158
- details: { ...baseDetails, stopReason: undefined },
159
- },
160
- { expanded: false, isPartial: true },
161
- running.theme,
162
- { lastComponent: undefined, isPartial: true, isError: false }
163
- ).render(80);
164
-
165
- expect(running.calls).toContainEqual({ color: "warning", text: "$0.23 " });
166
- expect(running.calls).toContainEqual({ color: "muted", text: "⬝" });
167
- });
168
-
169
- test("partial render displays only the running top line", () => {
170
- const runningDetails: SubagentDetails = {
171
- ...baseDetails,
172
- toolCalls: [],
173
- activeToolNames: ["grep"],
174
- stopReason: undefined,
175
- topLine: "$0.23 ⬝ 0.4%/1.0M ⬝ deepseek-v4-flash ⬝ 3 turns ⬝ grep",
176
- };
177
- const component = renderResult(
178
- {
179
- content: [{ type: "text", text: "ignored body" }],
180
- details: runningDetails,
181
- },
182
- { expanded: false, isPartial: true },
183
- stubTheme,
184
- { lastComponent: undefined, isPartial: true, isError: false }
185
- );
186
-
187
- expect(component.render(80)).toEqual([` │ ${runningDetails.topLine}`]);
188
- });
189
-
190
- test("collapsed done render hides the final message", () => {
191
- const body = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join(
192
- "\n"
193
- );
194
- const component = renderResult(
195
- result(body),
196
- { expanded: false, isPartial: false },
197
- stubTheme,
198
- { lastComponent: undefined, isPartial: false, isError: false }
199
- );
200
-
201
- expect(component.render(80)).toEqual([` │ ${baseDetails.topLine}`]);
202
- });
203
-
204
- test("expanded done render keeps the top line above the final message", () => {
205
- const component = renderResult(
206
- result("line 1\nline 2"),
207
- { expanded: true, isPartial: false },
208
- stubTheme,
209
- { lastComponent: undefined, isPartial: false, isError: false }
210
- );
211
-
212
- expect(component.render(80)).toEqual([
213
- ` │ ${baseDetails.topLine}`,
214
- " │ line 1",
215
- " │ line 2",
216
- ]);
217
- });
218
-
219
- test("expanded done render renders final message markdown", () => {
220
- const component = renderResult(
221
- result("Final **answer** and `code`"),
222
- { expanded: true, isPartial: false },
223
- stubTheme,
224
- { lastComponent: undefined, isPartial: false, isError: false }
225
- );
226
-
227
- expect(component.render(80)).toEqual([
228
- ` │ ${baseDetails.topLine}`,
229
- " │ Final answer and code",
230
- ]);
231
- });
232
-
233
- test("expanded done render uses configured markdown theme tokens", () => {
234
- const rendered = tracingTheme();
235
- renderResult(
236
- result(
237
- [
238
- "# Heading",
239
- "",
240
- "[docs](https://example.test)",
241
- "",
242
- "`inline`",
243
- "",
244
- "> quoted",
245
- "",
246
- "- item",
247
- "",
248
- "```",
249
- "plain code",
250
- "```",
251
- "",
252
- "---",
253
- ].join("\n")
254
- ),
255
- { expanded: true, isPartial: false },
256
- rendered.theme,
257
- { lastComponent: undefined, isPartial: false, isError: false }
258
- ).render(120);
259
-
260
- const colors = new Set(rendered.calls.map((call) => call.color));
261
- const expectedColors = [
262
- "mdHeading",
263
- "mdLink",
264
- "mdCode",
265
- "mdQuote",
266
- "mdQuoteBorder",
267
- "mdListBullet",
268
- "mdCodeBlock",
269
- "mdCodeBlockBorder",
270
- "mdHr",
271
- ] satisfies readonly ThemeColor[];
272
-
273
- for (const color of expectedColors) {
274
- expect(colors.has(color)).toBe(true);
275
- }
276
- });
277
-
278
- test("expanded done render uses the default color for final message text", () => {
279
- const rendered = tracingTheme();
280
- renderResult(
281
- result("plain final"),
282
- { expanded: true, isPartial: false },
283
- rendered.theme,
284
- { lastComponent: undefined, isPartial: false, isError: false }
285
- ).render(80);
286
-
287
- expect(rendered.calls).not.toContainEqual({
288
- color: "toolOutput",
289
- text: "plain final",
290
- });
291
- });
292
- });
@@ -1,315 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
4
- import {
5
- applyOutputCap,
6
- childToolNames,
7
- runSubagent,
8
- SubagentEventCapture,
9
- type SubagentSession,
10
- } from "./subagent";
11
-
12
- type UsageOverrides = Omit<Partial<Usage>, "cost"> & {
13
- readonly cost?: Partial<Usage["cost"]>;
14
- };
15
-
16
- const usage = (overrides: UsageOverrides = {}): Usage => {
17
- const { cost, ...rest } = overrides;
18
- return {
19
- input: 0,
20
- output: 0,
21
- cacheRead: 0,
22
- cacheWrite: 0,
23
- totalTokens: 0,
24
- cost: {
25
- input: 0,
26
- output: 0,
27
- cacheRead: 0,
28
- cacheWrite: 0,
29
- total: 0,
30
- ...cost,
31
- },
32
- ...rest,
33
- };
34
- };
35
-
36
- function assistant(
37
- textParts: readonly string[],
38
- overrides: Partial<AssistantMessage> = {}
39
- ): AssistantMessage {
40
- return {
41
- role: "assistant",
42
- content: textParts.map((text) => ({ type: "text", text })),
43
- api: "anthropic-messages",
44
- provider: "anthropic",
45
- model: "claude-test",
46
- usage: usage(),
47
- stopReason: "stop",
48
- timestamp: 1,
49
- ...overrides,
50
- };
51
- }
52
-
53
- const ctx = { cwd: "/work" } as ExtensionContext;
54
-
55
- class FakeSession implements SubagentSession {
56
- public promptCalls = 0;
57
- public abortCalls = 0;
58
- public disposeCalls = 0;
59
- private listener: ((event: never) => void) | undefined;
60
-
61
- public constructor(
62
- private readonly onPrompt: (
63
- session: FakeSession,
64
- prompt: string
65
- ) => Promise<void>
66
- ) {}
67
-
68
- public subscribe(listener: (event: never) => void): () => void {
69
- this.listener = listener;
70
- return () => {
71
- this.listener = undefined;
72
- };
73
- }
74
-
75
- public emit(event: unknown): void {
76
- this.listener?.(event as never);
77
- }
78
-
79
- public async prompt(prompt: string): Promise<void> {
80
- this.promptCalls += 1;
81
- await this.onPrompt(this, prompt);
82
- }
83
-
84
- public async abort(): Promise<void> {
85
- this.abortCalls += 1;
86
- }
87
-
88
- public dispose(): void {
89
- this.disposeCalls += 1;
90
- }
91
- }
92
-
93
- describe("childToolNames", () => {
94
- test("removes the subagent tool from a child's inherited allowlist", () => {
95
- expect(childToolNames(["read", "subagent", "bash"])).toEqual([
96
- "read",
97
- "bash",
98
- ]);
99
- });
100
- });
101
-
102
- describe("SubagentEventCapture", () => {
103
- test("concatenates multi-part text, resets for each assistant message, and records usage/tools", () => {
104
- const updates: string[] = [];
105
- const capture = new SubagentEventCapture((partial) => {
106
- updates.push(
107
- partial.content[0]?.type === "text" ? partial.content[0].text : ""
108
- );
109
- });
110
-
111
- capture.handle({ type: "message_start", message: assistant([]) } as never);
112
- capture.handle({
113
- type: "message_end",
114
- message: assistant(["first ", "turn"], {
115
- usage: usage({ input: 10, output: 4, cost: { total: 0.01 } }),
116
- }),
117
- } as never);
118
- capture.handle({
119
- type: "tool_execution_end",
120
- toolCallId: "1",
121
- toolName: "read",
122
- result: {},
123
- isError: false,
124
- } as never);
125
- capture.handle({ type: "message_start", message: assistant([]) } as never);
126
- capture.handle({
127
- type: "message_end",
128
- message: assistant(["final", " answer"], {
129
- usage: usage({
130
- input: 2,
131
- output: 8,
132
- cacheRead: 3,
133
- cost: { total: 0.02 },
134
- }),
135
- }),
136
- } as never);
137
-
138
- const snapshot = capture.snapshot();
139
- expect(snapshot.finalOutput).toBe("final answer");
140
- expect(snapshot.usage).toEqual({
141
- input: 12,
142
- output: 12,
143
- cacheRead: 3,
144
- cacheWrite: 0,
145
- cost: 0.03,
146
- turns: 2,
147
- contextTokens: undefined,
148
- });
149
- expect(snapshot.toolCalls).toEqual([{ name: "read", isError: false }]);
150
- expect(snapshot.lastToolName).toBe("read");
151
- expect(updates.at(-1)).toBe("$0.03 ⬝ ?/? ⬝ claude-test ⬝ 2 turns ⬝ 1 tool");
152
- });
153
-
154
- test("a later message with no text discards the prior message's text", () => {
155
- const capture = new SubagentEventCapture();
156
-
157
- capture.handle({ type: "message_start", message: assistant([]) } as never);
158
- capture.handle({
159
- type: "message_end",
160
- message: assistant(["intro"], { stopReason: "toolUse" }),
161
- } as never);
162
- capture.handle({ type: "message_start", message: assistant([]) } as never);
163
- capture.handle({
164
- type: "message_end",
165
- message: assistant([], { stopReason: "stop" }),
166
- } as never);
167
-
168
- expect(capture.snapshot().finalOutput).toBe("");
169
- });
170
-
171
- test("message_update content is materialized lazily on snapshot read", () => {
172
- const capture = new SubagentEventCapture();
173
-
174
- capture.handle({ type: "message_start", message: assistant([]) } as never);
175
- capture.handle({
176
- type: "message_update",
177
- message: assistant(["partial"]),
178
- } as never);
179
-
180
- expect(capture.snapshot().finalOutput).toBe("partial");
181
- });
182
- });
183
-
184
- describe("applyOutputCap", () => {
185
- test("truncates on a UTF-8 boundary and reports omitted bytes", () => {
186
- const capped = applyOutputCap("😀😀😀", 5);
187
-
188
- expect(capped.text).toContain(
189
- "😀\n[subagent: output truncated, 8 bytes omitted"
190
- );
191
- expect(capped.text).not.toContain("�");
192
- expect(capped.truncated).toBe(true);
193
- expect(capped.omittedBytes).toBe(8);
194
- });
195
- });
196
-
197
- describe("runSubagent", () => {
198
- test("returns bare text on normal completion", async () => {
199
- const fake = new FakeSession(async (session) => {
200
- session.emit({ type: "message_start", message: assistant([]) });
201
- session.emit({
202
- type: "message_end",
203
- message: assistant(["hello"]),
204
- });
205
- });
206
-
207
- const result = await runSubagent(
208
- "say hi",
209
- ctx,
210
- undefined,
211
- undefined,
212
- async () => fake
213
- );
214
-
215
- expect(result.content).toEqual([{ type: "text", text: "hello" }]);
216
- expect(result.details.fullOutput).toBe("hello");
217
- expect(fake.promptCalls).toBe(1);
218
- expect(fake.abortCalls).toBe(1);
219
- expect(fake.disposeCalls).toBe(1);
220
- });
221
-
222
- test("returns a hint, not an error, for normal empty output", async () => {
223
- const fake = new FakeSession(async (session) => {
224
- session.emit({ type: "message_start", message: assistant([]) });
225
- session.emit({ type: "message_end", message: assistant([]) });
226
- });
227
-
228
- const result = await runSubagent(
229
- "empty",
230
- ctx,
231
- undefined,
232
- undefined,
233
- async () => fake
234
- );
235
-
236
- expect(
237
- result.content[0]?.type === "text" ? result.content[0].text : ""
238
- ).toBe("[subagent tool: completed with no text output.]");
239
- });
240
-
241
- test("throws on model error with partial output", async () => {
242
- const fake = new FakeSession(async (session) => {
243
- session.emit({ type: "message_start", message: assistant([]) });
244
- session.emit({
245
- type: "message_end",
246
- message: assistant(["partial"], {
247
- stopReason: "error",
248
- errorMessage: "provider exploded",
249
- }),
250
- });
251
- });
252
-
253
- await expect(
254
- runSubagent("fail", ctx, undefined, undefined, async () => fake)
255
- ).rejects.toThrow(
256
- "Subagent failed: error. Error: provider exploded.\nPartial output before failure:\npartial"
257
- );
258
- });
259
-
260
- test("rejects pre-aborted signals before prompt", async () => {
261
- const controller = new AbortController();
262
- controller.abort();
263
- const fake = new FakeSession(async () => {});
264
-
265
- await expect(
266
- runSubagent("abort", ctx, controller.signal, undefined, async () => fake)
267
- ).rejects.toThrow("Subagent failed: subagent aborted before start");
268
-
269
- expect(fake.promptCalls).toBe(0);
270
- expect(fake.abortCalls).toBe(1);
271
- expect(fake.disposeCalls).toBe(1);
272
- });
273
-
274
- test("mid-run abort aborts once, tears down, and rejects", async () => {
275
- let finishPrompt: (() => void) | undefined;
276
- const fake = new (class extends FakeSession {
277
- public override async abort(): Promise<void> {
278
- await super.abort();
279
- finishPrompt?.();
280
- }
281
- })(
282
- () =>
283
- new Promise<void>((resolve) => {
284
- finishPrompt = resolve;
285
- })
286
- );
287
- const controller = new AbortController();
288
- const promise = runSubagent(
289
- "long",
290
- ctx,
291
- controller.signal,
292
- undefined,
293
- async () => fake
294
- );
295
-
296
- await Promise.resolve();
297
- controller.abort();
298
-
299
- await expect(promise).rejects.toThrow("Subagent failed: aborted");
300
- expect(fake.promptCalls).toBe(1);
301
- expect(fake.abortCalls).toBe(1);
302
- expect(fake.disposeCalls).toBe(1);
303
- });
304
-
305
- test("nested subagent calls are rejected by the async-local recursion ban", async () => {
306
- const outer = new FakeSession(async () => {
307
- const inner = new FakeSession(async () => {});
308
- await runSubagent("inner", ctx, undefined, undefined, async () => inner);
309
- });
310
-
311
- await expect(
312
- runSubagent("outer", ctx, undefined, undefined, async () => outer)
313
- ).rejects.toThrow("subagents cannot call subagent tool");
314
- });
315
- });
@@ -1,64 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { buildSystemPrompt, describeOs } from "./prompt";
3
-
4
- describe("buildSystemPrompt", () => {
5
- test("emits a best-effort os field instead of process.platform", () => {
6
- const prompt = buildSystemPrompt({
7
- cwd: "/repo",
8
- contextFiles: [],
9
- skillsBlock: "",
10
- toolGuidelines: [],
11
- os: "Ubuntu 24.04.2 LTS",
12
- });
13
-
14
- expect(prompt).toContain("- os: Ubuntu 24.04.2 LTS");
15
- expect(prompt).not.toContain("- platform:");
16
- });
17
- });
18
-
19
- describe("describeOs", () => {
20
- test("uses PRETTY_NAME from /etc/os-release on Linux", () => {
21
- const os = describeOs({
22
- platform: "linux",
23
- runCommand: (cmd) =>
24
- cmd.join(" ") === "cat /etc/os-release"
25
- ? 'NAME="Ubuntu"\nVERSION="24.04.2 LTS"\nPRETTY_NAME="Ubuntu 24.04.2 LTS"\n'
26
- : undefined,
27
- });
28
-
29
- expect(os).toBe("Ubuntu 24.04.2 LTS");
30
- });
31
-
32
- test("formats macOS from sw_vers", () => {
33
- const os = describeOs({
34
- platform: "darwin",
35
- runCommand: (cmd) =>
36
- cmd.join(" ") === "sw_vers"
37
- ? "ProductName:\t\tmacOS\nProductVersion:\t15.5\nBuildVersion:\t\t24F74\n"
38
- : undefined,
39
- });
40
-
41
- expect(os).toBe("macOS 15.5");
42
- });
43
-
44
- test("falls back to process platform when no probe succeeds", () => {
45
- const os = describeOs({
46
- platform: "linux",
47
- runCommand: () => undefined,
48
- });
49
-
50
- expect(os).toBe("linux");
51
- });
52
-
53
- test("falls back to NAME and VERSION when PRETTY_NAME is absent", () => {
54
- const os = describeOs({
55
- platform: "linux",
56
- runCommand: (cmd) =>
57
- cmd.join(" ") === "cat /etc/os-release"
58
- ? 'NAME=Fedora\nVERSION="40 (Workstation Edition)"'
59
- : undefined,
60
- });
61
-
62
- expect(os).toBe("Fedora 40 (Workstation Edition)");
63
- });
64
- });