gloomberb 0.3.1 → 0.3.3

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.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { cloneLayout, createDefaultConfig, createPaneInstance, type LayoutConfig } from "./types/config";
2
+ import { cloneLayout, createDefaultConfig, createPaneInstance, DEFAULT_PORTFOLIO_COLUMN_IDS, type LayoutConfig } from "./types/config";
3
3
  import { deletePaneSetting, getPaneSettingValue, setPaneSetting } from "./pane-settings";
4
4
 
5
5
  describe("pane settings helpers", () => {
@@ -13,15 +13,7 @@ describe("pane settings helpers", () => {
13
13
  hideTabs: false,
14
14
  lockedCollectionId: "main",
15
15
  });
16
- expect(portfolioPane?.settings?.columnIds).toEqual([
17
- "ticker",
18
- "price",
19
- "change_pct",
20
- "market_cap",
21
- "pe",
22
- "forward_pe",
23
- "latency",
24
- ]);
16
+ expect(portfolioPane?.settings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
25
17
  expect(detailPane?.settings).toEqual({
26
18
  hideTabs: false,
27
19
  lockedTabId: "overview",
@@ -38,15 +30,7 @@ describe("pane settings helpers", () => {
38
30
  expect(clonedPortfolioSettings).not.toBe(originalPortfolioSettings);
39
31
 
40
32
  (clonedPortfolioSettings?.columnIds as string[]).push("change");
41
- expect(originalPortfolioSettings?.columnIds).toEqual([
42
- "ticker",
43
- "price",
44
- "change_pct",
45
- "market_cap",
46
- "pe",
47
- "forward_pe",
48
- "latency",
49
- ]);
33
+ expect(originalPortfolioSettings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
50
34
  });
51
35
 
52
36
  test("setPaneSetting and deletePaneSetting update pane-scoped settings immutably", () => {
@@ -1,13 +1,13 @@
1
1
  import { useRef, useEffect, useState, useCallback } from "react";
2
2
  import { useKeyboard } from "@opentui/react";
3
3
  import { TextAttributes } from "@opentui/core";
4
- import type { InputRenderable } from "@opentui/core";
4
+ import type { InputRenderable, ScrollBoxRenderable } from "@opentui/core";
5
5
  import type { DetailTabProps } from "../../../types/plugin";
6
6
  import { useAppState, usePaneTicker } from "../../../state/app-context";
7
7
  import { useFxRatesMap } from "../../../market-data/hooks";
8
8
  import { usePluginState } from "../../../plugins/plugin-runtime";
9
9
  import { useInlineTickers } from "../../../state/use-inline-tickers";
10
- import { TickerBadgeText } from "../../../components/ticker-badge-text";
10
+ import { MarkdownText } from "../../../components/markdown-text";
11
11
  import { colors } from "../../../theme/colors";
12
12
  import { Spinner } from "../../../components/spinner";
13
13
  import { buildTickerAiContext } from "./ticker-context";
@@ -80,6 +80,7 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
80
80
  const [inputFocused, setInputFocused] = useState(false);
81
81
  const [inputValue, setInputValue] = useState("");
82
82
  const inputRef = useRef<InputRenderable>(null);
83
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
83
84
  const runRef = useRef<ReturnType<typeof runAiPrompt> | null>(null);
84
85
  const availableProviders = getAvailableProviders(providers);
85
86
  const currentProvider = providers.find((provider) => provider.id === providerId && provider.available)
@@ -116,7 +117,12 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
116
117
 
117
118
  useEffect(() => {
118
119
  if (!tickerSymbol || !currentProvider || messages.length === 0) return;
119
- const trimmed = messages.filter((message) => !message.loading).slice(-ASK_AI_HISTORY_LIMIT);
120
+ const hasLoading = messages.some((message) => message.loading);
121
+ if (hasLoading) {
122
+ chatHistories.set(tickerSymbol, messages);
123
+ return;
124
+ }
125
+ const trimmed = messages.slice(-ASK_AI_HISTORY_LIMIT);
120
126
  chatHistories.set(tickerSymbol, trimmed);
121
127
  setPersistedConversation({
122
128
  updatedAt: Date.now(),
@@ -124,6 +130,12 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
124
130
  });
125
131
  }, [currentProvider, messages, setPersistedConversation, tickerSymbol]);
126
132
 
133
+ useEffect(() => {
134
+ const sb = scrollRef.current;
135
+ if (!sb || messages.length === 0) return;
136
+ sb.scrollTo({ x: 0, y: Math.max(0, sb.scrollHeight - sb.viewport.height) });
137
+ }, [messages, focused]);
138
+
127
139
  const cycleProvider = useCallback(() => {
128
140
  if (availableProviders.length <= 1) return;
129
141
  setProviderId((current) => {
@@ -267,7 +279,7 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
267
279
  </box>
268
280
  </box>
269
281
 
270
- <scrollbox height={chatHeight} scrollY>
282
+ <scrollbox ref={scrollRef} height={chatHeight} scrollY>
271
283
  <box flexDirection="column">
272
284
  {messages.length === 0 ? (
273
285
  <box paddingTop={1}>
@@ -289,7 +301,7 @@ export function AskAiDetailTab({ width, height, focused, onCapture }: DetailTabP
289
301
  </box>
290
302
  <box>
291
303
  {message.content ? (
292
- <TickerBadgeText
304
+ <MarkdownText
293
305
  text={message.content}
294
306
  lineWidth={contentWidth}
295
307
  catalog={catalog}
@@ -57,6 +57,31 @@ function getPositionCurrency(
57
57
  return positions.find((position) => position.currency)?.currency || fallbackCurrency;
58
58
  }
59
59
 
60
+ function normalizePositionMultiplier(multiplier: number | undefined): number {
61
+ return typeof multiplier === "number" && Number.isFinite(multiplier) && multiplier > 0
62
+ ? multiplier
63
+ : 1;
64
+ }
65
+
66
+ function resolvePositionCostMultiplier(
67
+ position: TickerRecord["metadata"]["positions"][number],
68
+ ): number {
69
+ const priceMultiplier = normalizePositionMultiplier(position.multiplier);
70
+ if (priceMultiplier === 1) return 1;
71
+
72
+ if (position.marketValue == null || position.unrealizedPnl == null) {
73
+ return priceMultiplier;
74
+ }
75
+
76
+ const costWithoutMultiplier = position.shares * position.avgCost;
77
+ const costWithMultiplier = costWithoutMultiplier * priceMultiplier;
78
+ const withoutMultiplierError = Math.abs((position.marketValue - costWithoutMultiplier) - position.unrealizedPnl);
79
+ const withMultiplierError = Math.abs((position.marketValue - costWithMultiplier) - position.unrealizedPnl);
80
+
81
+ // IBKR gateway derivatives can report avgCost already scaled to the contract.
82
+ return withoutMultiplierError < withMultiplierError ? 1 : priceMultiplier;
83
+ }
84
+
60
85
  function getPortfolioPositionMetrics(
61
86
  ticker: TickerRecord,
62
87
  activeTab: string | undefined,
@@ -66,25 +91,24 @@ function getPortfolioPositionMetrics(
66
91
  ? ticker.metadata.positions.filter((position) => position.portfolio === activeTab)
67
92
  : ticker.metadata.positions;
68
93
  const positionCurrency = getPositionCurrency(tabPositions, fallbackCurrency);
69
- const totalShares = tabPositions.reduce((sum, position) => sum + position.shares * (position.side === "short" ? -1 : 1), 0);
70
- const totalCost = tabPositions.reduce(
71
- (sum, position) => sum + position.shares * position.avgCost * (position.multiplier || 1),
72
- 0,
73
- );
74
- const totalCostUnits = tabPositions.reduce(
75
- (sum, position) => sum + position.shares * (position.multiplier || 1),
76
- 0,
77
- );
78
- const totalPriceUnits = tabPositions.reduce(
79
- (sum, position) => sum + position.shares * (position.multiplier || 1) * (position.side === "short" ? -1 : 1),
80
- 0,
81
- );
82
-
94
+ let totalShares = 0;
95
+ let totalCost = 0;
96
+ let totalCostUnits = 0;
97
+ let totalPriceUnits = 0;
83
98
  let brokerMktValue = 0;
84
99
  let hasBrokerMktValue = false;
85
100
  let brokerPnl = 0;
86
101
  let hasBrokerPnl = false;
87
102
  for (const position of tabPositions) {
103
+ const direction = position.side === "short" ? -1 : 1;
104
+ const priceMultiplier = normalizePositionMultiplier(position.multiplier);
105
+ const costMultiplier = resolvePositionCostMultiplier(position);
106
+
107
+ totalShares += position.shares * direction;
108
+ totalCost += position.shares * position.avgCost * costMultiplier;
109
+ totalCostUnits += position.shares * costMultiplier;
110
+ totalPriceUnits += position.shares * priceMultiplier * direction;
111
+
88
112
  if (position.marketValue != null) {
89
113
  brokerMktValue += position.marketValue;
90
114
  hasBrokerMktValue = true;
@@ -1,5 +1,5 @@
1
1
  import type { PaneSettingOption, PaneSettingsDef, PaneTemplateContext } from "../../../types/plugin";
2
- import { DEFAULT_COLUMNS, type AppConfig, type ColumnConfig } from "../../../types/config";
2
+ import { DEFAULT_COLUMNS, DEFAULT_PORTFOLIO_COLUMN_IDS, type AppConfig, type ColumnConfig } from "../../../types/config";
3
3
 
4
4
  export type CollectionScope = "all" | "portfolios" | "watchlists" | "custom";
5
5
 
@@ -35,16 +35,6 @@ export const PORTFOLIO_COLUMN_DEFS: ColumnConfig[] = [
35
35
  { id: "pnl_pct", label: "P&L%", width: 8, align: "right", format: "percent" },
36
36
  ];
37
37
 
38
- export const DEFAULT_PORTFOLIO_COLUMN_IDS = [
39
- ...DEFAULT_COLUMNS.map((column) => column.id),
40
- "shares",
41
- "avg_cost",
42
- "cost_basis",
43
- "mkt_value",
44
- "pnl",
45
- "pnl_pct",
46
- ];
47
-
48
38
  const PORTFOLIO_COLUMNS_BY_ID = new Map(PORTFOLIO_COLUMN_DEFS.map((column) => [column.id, column]));
49
39
  const PORTFOLIO_ONLY_COLUMN_IDS = new Set([
50
40
  "shares",
@@ -133,6 +133,77 @@ describe("portfolio-metrics", () => {
133
133
  });
134
134
  });
135
135
 
136
+ test("does not double-apply IBKR option multipliers when avgCost is already contract-scaled", () => {
137
+ const ticker = createTicker({
138
+ ticker: "AMD 270917C00230000",
139
+ assetCategory: "OPT",
140
+ positions: [{
141
+ portfolio: "main",
142
+ shares: 10,
143
+ avgCost: 5095.07295,
144
+ broker: "ibkr",
145
+ currency: "USD",
146
+ marketValue: 58803.06,
147
+ unrealizedPnl: 7852.33,
148
+ multiplier: 100,
149
+ markPrice: 58.8030586,
150
+ }],
151
+ });
152
+ const financialsMap = new Map<string, TickerFinancials>();
153
+
154
+ const totals = calculatePortfolioSummaryTotals(
155
+ [ticker],
156
+ financialsMap,
157
+ "USD",
158
+ new Map([["USD", 1]]),
159
+ true,
160
+ "main",
161
+ );
162
+
163
+ expect(totals.totalMktValue).toBeCloseTo(58803.06, 2);
164
+ expect(totals.totalCostBasis).toBeCloseTo(50950.7295, 4);
165
+ expect(totals.unrealizedPnl).toBeCloseTo(7852.3305, 4);
166
+ expect(totals.hasPositions).toBe(true);
167
+ });
168
+
169
+ test("keeps option avg cost display contract-scaled while using the correct cost basis", () => {
170
+ const ticker = createTicker({
171
+ ticker: "AMD 270917C00230000",
172
+ assetCategory: "OPT",
173
+ positions: [{
174
+ portfolio: "main",
175
+ shares: 10,
176
+ avgCost: 5095.07295,
177
+ broker: "ibkr",
178
+ currency: "USD",
179
+ marketValue: 58803.06,
180
+ unrealizedPnl: 7852.33,
181
+ multiplier: 100,
182
+ markPrice: 58.8030586,
183
+ }],
184
+ });
185
+ const financials = createFinancials({
186
+ quote: {
187
+ symbol: "AMD 270917C00230000",
188
+ price: 58.8030586,
189
+ currency: "USD",
190
+ change: 0,
191
+ changePercent: 0,
192
+ previousClose: 58.8030586,
193
+ },
194
+ });
195
+ const avgCostColumn: ColumnConfig = { id: "avg_cost", label: "AVG COST", width: 10, align: "right", format: "currency" };
196
+ const pnlColumn: ColumnConfig = { id: "pnl", label: "P&L", width: 10, align: "right", format: "compact" };
197
+
198
+ expect(getColumnValue(avgCostColumn, ticker, financials, defaultColumnContext)).toEqual({
199
+ text: "5,095.07",
200
+ });
201
+ expect(getColumnValue(pnlColumn, ticker, financials, defaultColumnContext)).toEqual({
202
+ text: "+7.9k",
203
+ color: expect.any(String),
204
+ });
205
+ });
206
+
136
207
  test("formats portfolio-only column values and sort keys consistently", () => {
137
208
  const ticker = createTicker({
138
209
  positions: [{ portfolio: "main", shares: 10, avgCost: 100, broker: "manual" }],
@@ -129,4 +129,34 @@ describe("appReducer command bar state", () => {
129
129
  ? secondUndone.config.layout.dockRoot.ratio
130
130
  : null).toBe(0.4);
131
131
  });
132
+
133
+ test("tracks manual update-check feedback", () => {
134
+ const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
135
+ const checking = appReducer(initial, { type: "SET_UPDATE_CHECK_IN_PROGRESS", checking: true });
136
+ const noticed = appReducer(checking, { type: "SET_UPDATE_NOTICE", notice: "Already on v0.3.1" });
137
+
138
+ expect(checking.updateCheckInProgress).toBe(true);
139
+ expect(noticed.updateNotice).toBe("Already on v0.3.1");
140
+ });
141
+
142
+ test("clears stale update notices when an update becomes available", () => {
143
+ const initial = {
144
+ ...createInitialState(createDefaultConfig("/tmp/gloomberb-test")),
145
+ updateNotice: "Already on v0.3.1",
146
+ };
147
+ const next = appReducer(initial, {
148
+ type: "SET_UPDATE_AVAILABLE",
149
+ release: {
150
+ version: "0.3.2",
151
+ tagName: "v0.3.2",
152
+ downloadUrl: "https://example.com/gloomberb.gz",
153
+ publishedAt: "2026-04-03T00:00:00Z",
154
+ updateAction: { kind: "self" },
155
+ compressed: true,
156
+ },
157
+ });
158
+
159
+ expect(next.updateAvailable?.version).toBe("0.3.2");
160
+ expect(next.updateNotice).toBeNull();
161
+ });
132
162
  });
@@ -78,6 +78,8 @@ export interface AppState {
78
78
  inputCaptured: boolean;
79
79
  updateAvailable: ReleaseInfo | null;
80
80
  updateProgress: UpdateProgress | null;
81
+ updateCheckInProgress: boolean;
82
+ updateNotice: string | null;
81
83
  layoutHistory: Record<number, LayoutHistoryEntry>;
82
84
  }
83
85
 
@@ -101,8 +103,10 @@ export type AppAction =
101
103
  | { type: "SHOW_GRIDLOCK_TIP" }
102
104
  | { type: "DISMISS_GRIDLOCK_TIP" }
103
105
  | { type: "SET_THEME"; theme: string }
104
- | { type: "SET_UPDATE_AVAILABLE"; release: ReleaseInfo }
106
+ | { type: "SET_UPDATE_AVAILABLE"; release: ReleaseInfo | null }
105
107
  | { type: "SET_UPDATE_PROGRESS"; progress: UpdateProgress | null }
108
+ | { type: "SET_UPDATE_CHECK_IN_PROGRESS"; checking: boolean }
109
+ | { type: "SET_UPDATE_NOTICE"; notice: string | null }
106
110
  | { type: "TOGGLE_PLUGIN"; pluginId: string }
107
111
  | { type: "SET_INPUT_CAPTURED"; captured: boolean }
108
112
  | { type: "SET_EXCHANGE_RATE"; currency: string; rate: number }
@@ -548,10 +552,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
548
552
  return { ...state, config: { ...state.config, theme: action.theme } };
549
553
 
550
554
  case "SET_UPDATE_AVAILABLE":
551
- return { ...state, updateAvailable: action.release };
555
+ return { ...state, updateAvailable: action.release, updateNotice: action.release ? null : state.updateNotice };
552
556
 
553
557
  case "SET_UPDATE_PROGRESS":
554
- return { ...state, updateProgress: action.progress };
558
+ return { ...state, updateProgress: action.progress, updateNotice: action.progress ? null : state.updateNotice };
559
+
560
+ case "SET_UPDATE_CHECK_IN_PROGRESS":
561
+ return { ...state, updateCheckInProgress: action.checking };
562
+
563
+ case "SET_UPDATE_NOTICE":
564
+ return { ...state, updateNotice: action.notice };
555
565
 
556
566
  case "TOGGLE_PLUGIN": {
557
567
  const disabledPlugins = state.config.disabledPlugins.includes(action.pluginId)
@@ -936,6 +946,8 @@ export function createInitialState(config: AppConfig, sessionSnapshot: AppSessio
936
946
  inputCaptured: false,
937
947
  updateAvailable: null,
938
948
  updateProgress: null,
949
+ updateCheckInProgress: false,
950
+ updateNotice: null,
939
951
  layoutHistory: {},
940
952
  };
941
953
  }
@@ -1,6 +1,6 @@
1
1
  import type { Portfolio, Watchlist } from "./ticker";
2
2
 
3
- export const CURRENT_CONFIG_VERSION = 10;
3
+ export const CURRENT_CONFIG_VERSION = 11;
4
4
 
5
5
  export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc";
6
6
  export type ChartRendererPreference = "auto" | "kitty" | "braille";
@@ -134,6 +134,16 @@ export const DEFAULT_COLUMNS: ColumnConfig[] = [
134
134
  { id: "latency", label: "AGE", width: 6, align: "right" },
135
135
  ];
136
136
 
137
+ export const DEFAULT_PORTFOLIO_COLUMN_IDS = [
138
+ ...DEFAULT_COLUMNS.map((column) => column.id),
139
+ "shares",
140
+ "avg_cost",
141
+ "cost_basis",
142
+ "mkt_value",
143
+ "pnl",
144
+ "pnl_pct",
145
+ ];
146
+
137
147
  export const DEFAULT_LAYOUT: LayoutConfig = {
138
148
  dockRoot: {
139
149
  kind: "split",
@@ -148,7 +158,7 @@ export const DEFAULT_LAYOUT: LayoutConfig = {
148
158
  paneId: "portfolio-list",
149
159
  params: { collectionId: "main" },
150
160
  settings: {
151
- columnIds: DEFAULT_COLUMNS.map((column) => column.id),
161
+ columnIds: [...DEFAULT_PORTFOLIO_COLUMN_IDS],
152
162
  collectionScope: "all",
153
163
  visibleCollectionIds: [],
154
164
  hideTabs: false,
@@ -1,7 +1,12 @@
1
- import { describe, expect, it, test } from "bun:test";
1
+ import { afterEach, describe, expect, it, test } from "bun:test";
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { gzipSync } from "zlib";
2
6
  import {
3
7
  canSelfUpdate,
4
8
  checkForUpdate,
9
+ checkForUpdateDetailed,
5
10
  detectUpdateAction,
6
11
  performUpdate,
7
12
  resolveSelfUpdateTargetPath,
@@ -9,6 +14,18 @@ import {
9
14
  type UpdateProgress,
10
15
  } from "./updater";
11
16
 
17
+ const originalFetch = globalThis.fetch;
18
+
19
+ afterEach(() => {
20
+ globalThis.fetch = originalFetch;
21
+ });
22
+
23
+ function expectedAssetName(compressed = false): string {
24
+ const os = process.platform === "darwin" ? "darwin" : "linux";
25
+ const arch = os === "darwin" || process.arch === "arm64" ? "arm64" : "x64";
26
+ return compressed ? `gloomberb-${os}-${arch}.gz` : `gloomberb-${os}-${arch}`;
27
+ }
28
+
12
29
  describe("detectUpdateAction", () => {
13
30
  test("uses self-update for standalone binaries", () => {
14
31
  expect(detectUpdateAction(
@@ -100,6 +117,66 @@ describe("checkForUpdate", () => {
100
117
  Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
101
118
  }
102
119
  });
120
+
121
+ it("finds gzipped release assets published on GitHub", async () => {
122
+ const originalExecPath = process.execPath;
123
+ const originalArgv = process.argv;
124
+ globalThis.fetch = (async () => new Response(JSON.stringify({
125
+ tag_name: "v0.3.2",
126
+ published_at: "2026-04-03T00:00:00Z",
127
+ assets: [{
128
+ name: expectedAssetName(true),
129
+ browser_download_url: "https://example.com/gloomberb.gz",
130
+ }],
131
+ }), {
132
+ status: 200,
133
+ headers: { "content-type": "application/json" },
134
+ })) as typeof fetch;
135
+
136
+ try {
137
+ Object.defineProperty(process, "execPath", { value: "/Applications/gloomberb", configurable: true });
138
+ Object.defineProperty(process, "argv", {
139
+ value: ["/Applications/gloomberb"],
140
+ configurable: true,
141
+ });
142
+
143
+ await expect(checkForUpdate("0.3.1")).resolves.toEqual({
144
+ version: "0.3.2",
145
+ tagName: "v0.3.2",
146
+ downloadUrl: "https://example.com/gloomberb.gz",
147
+ publishedAt: "2026-04-03T00:00:00Z",
148
+ updateAction: { kind: "self" },
149
+ compressed: true,
150
+ });
151
+ } finally {
152
+ Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
153
+ Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
154
+ }
155
+ });
156
+ });
157
+
158
+ describe("checkForUpdateDetailed", () => {
159
+ it("returns a useful error when GitHub rejects the request", async () => {
160
+ const originalExecPath = process.execPath;
161
+ const originalArgv = process.argv;
162
+ globalThis.fetch = (async () => new Response("busy", { status: 503 })) as typeof fetch;
163
+
164
+ try {
165
+ Object.defineProperty(process, "execPath", { value: "/Applications/gloomberb", configurable: true });
166
+ Object.defineProperty(process, "argv", {
167
+ value: ["/Applications/gloomberb"],
168
+ configurable: true,
169
+ });
170
+
171
+ await expect(checkForUpdateDetailed("0.3.1")).resolves.toEqual({
172
+ kind: "error",
173
+ error: "GitHub returned 503",
174
+ });
175
+ } finally {
176
+ Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
177
+ Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
178
+ }
179
+ });
103
180
  });
104
181
 
105
182
  describe("performUpdate", () => {
@@ -159,4 +236,47 @@ describe("performUpdate", () => {
159
236
  Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
160
237
  }
161
238
  });
239
+
240
+ it("decompresses gzipped release assets before replacing the binary", async () => {
241
+ const originalExecPath = process.execPath;
242
+ const originalArgv = process.argv;
243
+ const tempDir = mkdtempSync(join(tmpdir(), "gloomberb-update-"));
244
+ const execPath = join(tempDir, "gloomberb");
245
+ const nextBinary = Buffer.from("new-binary");
246
+ const payload = gzipSync(nextBinary);
247
+ const progress: UpdateProgress[] = [];
248
+
249
+ writeFileSync(execPath, Buffer.from("old-binary"));
250
+ chmodSync(execPath, 0o755);
251
+ globalThis.fetch = (async () => new Response(payload, {
252
+ status: 200,
253
+ headers: { "content-length": String(payload.length) },
254
+ })) as typeof fetch;
255
+
256
+ try {
257
+ Object.defineProperty(process, "execPath", { value: execPath, configurable: true });
258
+ Object.defineProperty(process, "argv", {
259
+ value: [execPath],
260
+ configurable: true,
261
+ });
262
+
263
+ await performUpdate({
264
+ version: "9.9.9",
265
+ tagName: "v9.9.9",
266
+ downloadUrl: "https://example.com/gloomberb.gz",
267
+ publishedAt: "2026-04-03T00:00:00Z",
268
+ updateAction: { kind: "self" },
269
+ compressed: true,
270
+ }, (entry) => {
271
+ progress.push(entry);
272
+ });
273
+
274
+ expect(readFileSync(execPath)).toEqual(nextBinary);
275
+ expect(progress.at(-1)).toEqual({ phase: "done" });
276
+ } finally {
277
+ Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
278
+ Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
279
+ rmSync(tempDir, { recursive: true, force: true });
280
+ }
281
+ });
162
282
  });