gloomberb 0.13.1 → 0.13.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.
Files changed (107) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/api-client/index.test.ts +34 -0
  4. package/src/api-client/request.ts +4 -1
  5. package/src/cli/desktop-pane-shot.ts +72 -2
  6. package/src/cli/pane-functions/cloud-session.test.ts +77 -0
  7. package/src/cli/pane-functions/cloud-session.ts +47 -0
  8. package/src/cli/pane-functions/index.ts +5 -1
  9. package/src/cli/pane-functions/screenshot.test.ts +27 -0
  10. package/src/cli/pane-functions/screenshot.ts +85 -20
  11. package/src/components/command-bar/assist/runtime.ts +38 -30
  12. package/src/components/command-bar/surface/assist.test.tsx +43 -0
  13. package/src/news/types.ts +1 -0
  14. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  15. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  16. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  17. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  18. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  19. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  20. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  21. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  22. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  23. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  24. package/src/plugins/builtin/cloud/askg/protocol.test.ts +3 -2
  25. package/src/plugins/builtin/cloud/askg/protocol.ts +16 -4
  26. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  27. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  28. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  29. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  30. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  31. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  32. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  33. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  34. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  35. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  36. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  37. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  38. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  39. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  40. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  41. package/src/plugins/builtin/earnings/headless.ts +162 -0
  42. package/src/plugins/builtin/earnings/index.tsx +35 -9
  43. package/src/plugins/builtin/earnings/model.test.ts +29 -1
  44. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  45. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  46. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  47. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  48. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  49. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +8 -9
  50. package/src/plugins/builtin/holders/client.ts +29 -0
  51. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  52. package/src/plugins/builtin/holders/headless.ts +162 -0
  53. package/src/plugins/builtin/holders/index.tsx +14 -8
  54. package/src/plugins/builtin/holders/pane.tsx +7 -1
  55. package/src/plugins/builtin/insider/client.ts +50 -0
  56. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  57. package/src/plugins/builtin/insider/headless.ts +117 -0
  58. package/src/plugins/builtin/insider/index.tsx +21 -50
  59. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  60. package/src/plugins/builtin/insider/model.ts +72 -0
  61. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  62. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  63. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  64. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  65. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  66. package/src/plugins/builtin/news/headless.test.ts +138 -0
  67. package/src/plugins/builtin/news/headless.ts +190 -0
  68. package/src/plugins/builtin/news/index.tsx +13 -9
  69. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  70. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  71. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  72. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  73. package/src/plugins/builtin/research/analyst-pane.test.tsx +145 -0
  74. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  75. package/src/plugins/builtin/research/client.ts +12 -0
  76. package/src/plugins/builtin/research/corporate-actions-pane.tsx +27 -1
  77. package/src/plugins/builtin/research/event-model.ts +56 -0
  78. package/src/plugins/builtin/research/headless.test.ts +93 -0
  79. package/src/plugins/builtin/research/headless.ts +161 -0
  80. package/src/plugins/builtin/research/index.test.ts +43 -0
  81. package/src/plugins/builtin/research/index.tsx +28 -18
  82. package/src/plugins/builtin/sec/client.ts +16 -0
  83. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  84. package/src/plugins/builtin/sec/headless.ts +69 -0
  85. package/src/plugins/builtin/sec/index.tsx +20 -75
  86. package/src/plugins/builtin/sec/model.ts +89 -0
  87. package/src/plugins/builtin/short-interest/client.ts +5 -2
  88. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  89. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  90. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  91. package/src/plugins/builtin/short-interest/model.ts +15 -4
  92. package/src/plugins/builtin/short-interest/pane.tsx +1 -1
  93. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  94. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  95. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  96. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  97. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  98. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  99. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  100. package/src/plugins/prediction-markets/headless.ts +255 -0
  101. package/src/plugins/prediction-markets/index.tsx +2 -0
  102. package/src/renderers/browser/config-host.test.ts +70 -0
  103. package/src/renderers/browser/config-host.ts +74 -10
  104. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +180 -19
  105. package/src/sources/gloomberb-cloud/news.ts +1 -0
  106. package/src/updater.test.ts +17 -1
  107. package/src/updater.ts +4 -2
package/README.md CHANGED
@@ -62,6 +62,8 @@ curl -fsSL gloom.sh/install | bash
62
62
 
63
63
  Both install `Gloomberb.app` and a `gloomberb` command that runs the TUI through the app bundle, so the bundled runtime is stored once.
64
64
 
65
+ `Gloomberb.app` is Apple Silicon (arm64) only. On an Intel Mac the install script installs the standalone `gloomberb` terminal app instead, and the Homebrew cask refuses to install rather than leaving an app that cannot launch.
66
+
65
67
  Prefer a direct download?
66
68
 
67
69
  - [Download Gloomberb for Mac](https://gloom.sh/download/desktop)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/index.tsx",
@@ -264,6 +264,40 @@ describe("apiClient auth cookies", () => {
264
264
  ]);
265
265
  });
266
266
 
267
+ test("ignores a stale response cookie after the session credential changes", async () => {
268
+ let releaseResponse: (() => void) | null = null;
269
+ let markRequestStarted: (() => void) | null = null;
270
+ const requestStarted = new Promise<void>((resolve) => {
271
+ markRequestStarted = resolve;
272
+ });
273
+
274
+ apiClient.setSessionToken("old-session.value");
275
+ setCloudApiFetchTransport(async () => {
276
+ markRequestStarted?.();
277
+ await new Promise<void>((resolve) => {
278
+ releaseResponse = resolve;
279
+ });
280
+ return createResponse({
281
+ channels: [],
282
+ onlineCount: 0,
283
+ channelStates: [],
284
+ notifications: [],
285
+ }, {
286
+ cookies: [
287
+ "__Secure-gloomberb.session_token=old-session.value; Path=/; HttpOnly; Secure; SameSite=None",
288
+ ],
289
+ });
290
+ });
291
+
292
+ const staleRequest = apiClient.getChatState();
293
+ await requestStarted;
294
+ apiClient.setSessionToken("new-session.value");
295
+ releaseResponse?.();
296
+ await staleRequest;
297
+
298
+ expect(apiClient.getSessionToken()).toBe("new-session.value");
299
+ });
300
+
267
301
  test("uses an installed cloud API fetch transport for auth cookie capture", async () => {
268
302
  const seenCookies: Array<string | null> = [];
269
303
  globalThis.fetch = mockFetch(async () => {
@@ -141,6 +141,7 @@ export class CloudApiRequestTransport {
141
141
  headers.set("Content-Type", "application/json");
142
142
  }
143
143
  this.setSessionCookieHeader(headers);
144
+ const sessionTokenAtRequestStart = this.sessionToken;
144
145
  headers.set("Origin", this.baseUrl);
145
146
 
146
147
  const operation = `${options?.method ?? "GET"} ${path.split("?")[0]}`;
@@ -151,7 +152,9 @@ export class CloudApiRequestTransport {
151
152
  credentials: "include",
152
153
  });
153
154
  throwIfRequestAborted(options?.signal);
154
- this.extractSessionCookie(res);
155
+ if (this.sessionToken === sessionTokenAtRequestStart) {
156
+ this.extractSessionCookie(res);
157
+ }
155
158
  const text = await res.text();
156
159
  throwIfRequestAborted(options?.signal);
157
160
 
@@ -56,6 +56,33 @@ export interface DesktopPaneShotApiProxy {
56
56
  sessionToken: string | null;
57
57
  }
58
58
 
59
+ /**
60
+ * The page renders panes that fetch their own data. Left alone it can only
61
+ * reach the cloud API, so a pane whose values come from a provider the router
62
+ * merges in (analyst price targets, Yahoo dividends) rendered thinner than the
63
+ * same pane in the terminal. The bridge lets the page ask the Bun process to
64
+ * run those requests through the real provider router instead.
65
+ */
66
+ export interface DesktopPaneShotBridge {
67
+ marketData(operation: string, args: unknown[]): Promise<unknown>;
68
+ httpFetch(request: DesktopPaneShotHttpRequest): Promise<DesktopPaneShotHttpResponse>;
69
+ }
70
+
71
+ export interface DesktopPaneShotHttpRequest {
72
+ url: string;
73
+ method?: string;
74
+ headers?: Record<string, string>;
75
+ body?: string;
76
+ }
77
+
78
+ export interface DesktopPaneShotHttpResponse {
79
+ status: number;
80
+ statusText: string;
81
+ headers: Record<string, string>;
82
+ setCookie: string[];
83
+ body: string;
84
+ }
85
+
59
86
  export interface DesktopPaneShotRenderedCell {
60
87
  columnId?: string;
61
88
  columnLabel: string;
@@ -109,13 +136,15 @@ const SHOT_READY_TIMEOUT_MS = 45_000;
109
136
  const CDP_CALL_TIMEOUT_MS = 10_000;
110
137
  const DEFAULT_DEVICE_SCALE_FACTOR = 2;
111
138
  const SHOT_API_PROXY_PREFIX = "/__gloom_cli_api__";
139
+ export const SHOT_MARKET_BRIDGE_PATH = "/__gloom_cli_market__";
140
+ export const SHOT_HTTP_BRIDGE_PATH = "/__gloom_cli_http__";
112
141
  const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
113
142
 
114
143
  export async function renderDesktopPaneScreenshot(
115
144
  payload: DesktopPaneShotPayload,
116
145
  outputPath: string,
117
146
  apiProxy: DesktopPaneShotApiProxy,
118
- options: { captureImage?: boolean } = {},
147
+ options: { captureImage?: boolean; bridge?: DesktopPaneShotBridge } = {},
119
148
  ): Promise<DesktopPaneShotRenderResult> {
120
149
  const tempDir = await mkdtemp(join(tmpdir(), "gloom-pane-shot-"));
121
150
  let server: ReturnType<typeof Bun.serve> | null = null;
@@ -123,7 +152,7 @@ export async function renderDesktopPaneScreenshot(
123
152
  const outdir = join(tempDir, "assets");
124
153
  await mkdir(outdir, { recursive: true });
125
154
  await buildShotPage(outdir, payload);
126
- server = serveShotPage(outdir, apiProxy);
155
+ server = serveShotPage(outdir, apiProxy, options.bridge ?? null);
127
156
  const chrome = await findChromeExecutable();
128
157
  return await capturePageScreenshot({
129
158
  chrome,
@@ -183,6 +212,7 @@ async function buildShotPage(outdir: string, payload: DesktopPaneShotPayload): P
183
212
  function serveShotPage(
184
213
  outdir: string,
185
214
  apiProxy: DesktopPaneShotApiProxy,
215
+ bridge: DesktopPaneShotBridge | null,
186
216
  ): ReturnType<typeof Bun.serve> {
187
217
  const staticRoot = resolve(outdir);
188
218
  return Bun.serve({
@@ -194,11 +224,51 @@ function serveShotPage(
194
224
  if (url.pathname.startsWith(`${SHOT_API_PROXY_PREFIX}/`)) {
195
225
  return proxyShotApiRequest(request, url, apiProxy);
196
226
  }
227
+ if (url.pathname === SHOT_MARKET_BRIDGE_PATH) {
228
+ return runShotMarketBridge(request, bridge);
229
+ }
230
+ if (url.pathname === SHOT_HTTP_BRIDGE_PATH) {
231
+ return runShotHttpBridge(request, bridge);
232
+ }
197
233
  return serveShotAsset(url, staticRoot);
198
234
  },
199
235
  });
200
236
  }
201
237
 
238
+ async function runShotMarketBridge(
239
+ request: Request,
240
+ bridge: DesktopPaneShotBridge | null,
241
+ ): Promise<Response> {
242
+ if (!bridge) return Response.json({ ok: false, error: "No market bridge" }, { status: 404 });
243
+ try {
244
+ const payload = await request.json() as { operation?: unknown; args?: unknown };
245
+ const operation = typeof payload.operation === "string" ? payload.operation : "";
246
+ const args = Array.isArray(payload.args) ? payload.args : [];
247
+ const data = await bridge.marketData(operation, args);
248
+ return Response.json({ ok: true, data });
249
+ } catch (error) {
250
+ return Response.json({ ok: false, error: errorMessage(error) });
251
+ }
252
+ }
253
+
254
+ async function runShotHttpBridge(
255
+ request: Request,
256
+ bridge: DesktopPaneShotBridge | null,
257
+ ): Promise<Response> {
258
+ if (!bridge) return Response.json({ ok: false, error: "No HTTP bridge" }, { status: 404 });
259
+ try {
260
+ const payload = await request.json() as DesktopPaneShotHttpRequest;
261
+ const data = await bridge.httpFetch(payload);
262
+ return Response.json({ ok: true, data });
263
+ } catch (error) {
264
+ return Response.json({ ok: false, error: errorMessage(error) });
265
+ }
266
+ }
267
+
268
+ function errorMessage(error: unknown): string {
269
+ return error instanceof Error ? error.message : String(error);
270
+ }
271
+
202
272
  async function serveShotAsset(url: URL, staticRoot: string): Promise<Response> {
203
273
  let pathname: string;
204
274
  try {
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { apiClient } from "../../api-client";
3
+ import type { MarketContext } from "../types";
4
+ import {
5
+ resolvePersistedCloudSessionToken,
6
+ withPersistedCloudSession,
7
+ } from "./cloud-session";
8
+
9
+ function contextWithSession(
10
+ key: string,
11
+ sessionToken: string,
12
+ ): Pick<MarketContext, "persistence"> {
13
+ return {
14
+ persistence: {
15
+ pluginState: {
16
+ get: (_pluginId: string, candidate: string) => candidate === key
17
+ ? { value: { sessionToken }, schemaVersion: 1, updatedAt: 1 }
18
+ : null,
19
+ },
20
+ },
21
+ } as Pick<MarketContext, "persistence">;
22
+ }
23
+
24
+ describe("pane function Cloud session", () => {
25
+ test("restores the current and legacy persisted session keys", () => {
26
+ expect(resolvePersistedCloudSessionToken(
27
+ contextWithSession("resume:session", "current-session"),
28
+ )).toBe("current-session");
29
+ expect(resolvePersistedCloudSessionToken(
30
+ contextWithSession("session", "legacy-session"),
31
+ )).toBe("legacy-session");
32
+ });
33
+
34
+ test("scopes the persisted session to one report load", async () => {
35
+ const initial = apiClient.getSessionToken();
36
+ apiClient.setSessionToken("previous-session");
37
+ try {
38
+ let activeSession: string | null = null;
39
+ const result = await withPersistedCloudSession(
40
+ contextWithSession("resume:session", "report-session"),
41
+ async () => {
42
+ activeSession = apiClient.getSessionToken();
43
+ return 42;
44
+ },
45
+ );
46
+
47
+ expect(result).toBe(42);
48
+ expect(activeSession).toBe("report-session");
49
+ expect(apiClient.getSessionToken()).toBe("previous-session");
50
+ } finally {
51
+ apiClient.setSessionToken(initial);
52
+ }
53
+ });
54
+ });
55
+
56
+ describe("cloud session token from the environment", () => {
57
+ test("prefers GLOOMBERB_SESSION_TOKEN over persisted state so containers can authenticate", () => {
58
+ const context = {
59
+ persistence: {
60
+ pluginState: {
61
+ get: () => ({ value: { sessionToken: "persisted-token" } }),
62
+ },
63
+ },
64
+ } as unknown as Parameters<typeof resolvePersistedCloudSessionToken>[0];
65
+
66
+ const previous = process.env.GLOOMBERB_SESSION_TOKEN;
67
+ try {
68
+ process.env.GLOOMBERB_SESSION_TOKEN = "env-token";
69
+ expect(resolvePersistedCloudSessionToken(context)).toBe("env-token");
70
+ delete process.env.GLOOMBERB_SESSION_TOKEN;
71
+ expect(resolvePersistedCloudSessionToken(context)).toBe("persisted-token");
72
+ } finally {
73
+ if (previous === undefined) delete process.env.GLOOMBERB_SESSION_TOKEN;
74
+ else process.env.GLOOMBERB_SESSION_TOKEN = previous;
75
+ }
76
+ });
77
+ });
@@ -0,0 +1,47 @@
1
+ import { apiClient } from "../../api-client";
2
+ import type { MarketContext } from "../types";
3
+
4
+ const CLOUD_PLUGIN_ID = "gloomberb-cloud";
5
+ const CLOUD_SESSION_KEYS = ["resume:session", "session"] as const;
6
+
7
+ interface PersistedCloudSession {
8
+ sessionToken?: unknown;
9
+ }
10
+
11
+ /**
12
+ * A container has no local plugin state, so a bot or a CI job has no way to
13
+ * reach Pro gated panes. `GLOOMBERB_SESSION_TOKEN` gives headless callers the
14
+ * same session the desktop app persists after signing in, and it wins over the
15
+ * stored one so a service account can be pointed somewhere else without
16
+ * touching the user's own state.
17
+ */
18
+ export function resolvePersistedCloudSessionToken(
19
+ context: Pick<MarketContext, "persistence">,
20
+ ): string | null {
21
+ const fromEnv = process.env.GLOOMBERB_SESSION_TOKEN?.trim();
22
+ if (fromEnv) return fromEnv;
23
+ for (const key of CLOUD_SESSION_KEYS) {
24
+ const value = context.persistence.pluginState.get<PersistedCloudSession>(
25
+ CLOUD_PLUGIN_ID,
26
+ key,
27
+ 1,
28
+ )?.value;
29
+ if (typeof value?.sessionToken === "string" && value.sessionToken.length > 0) {
30
+ return value.sessionToken;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export async function withPersistedCloudSession<T>(
37
+ context: Pick<MarketContext, "persistence">,
38
+ run: () => Promise<T>,
39
+ ): Promise<T> {
40
+ const previousSessionToken = apiClient.getSessionToken();
41
+ apiClient.setSessionToken(resolvePersistedCloudSessionToken(context));
42
+ try {
43
+ return await run();
44
+ } finally {
45
+ apiClient.setSessionToken(previousSessionToken);
46
+ }
47
+ }
@@ -28,6 +28,7 @@ import {
28
28
  isDataPaneForDomFallback,
29
29
  normalizeCapabilityOptions,
30
30
  } from "./capabilities";
31
+ import { withPersistedCloudSession } from "./cloud-session";
31
32
 
32
33
  async function withPaneRuntime<T>(
33
34
  ctx: CliCommandContext,
@@ -63,7 +64,10 @@ export async function runPaneFunction(args: string[], ctx: CliCommandContext) {
63
64
  + `Use "gloomberb catalog ${resolved.token}" to inspect readiness.`,
64
65
  );
65
66
  }
66
- const report = await buildFunctionReport(resolved, context, parsed.arg);
67
+ const report = await withPersistedCloudSession(
68
+ context,
69
+ () => buildFunctionReport(resolved, context, parsed.arg),
70
+ );
67
71
  if (parsed.requireBotSafe && (report.data.empty || !report.data.complete)) {
68
72
  const unavailable = report.data.unavailableSymbols.length > 0
69
73
  ? ` Missing data for ${report.data.unavailableSymbols.join(", ")}.`
@@ -3,6 +3,7 @@ import type { RemoteUiNodeSnapshot } from "../../remote/types";
3
3
  import {
4
4
  chartSeriesEvidenceWithinRange,
5
5
  chartEvidenceMismatchesFor,
6
+ createDesktopShotBridge,
6
7
  isPaneScreenshotUsable,
7
8
  missingActiveTabSelections,
8
9
  resolveDesktopShotApiProxy,
@@ -94,6 +95,32 @@ describe("pane screenshot payload credentials", () => {
94
95
  });
95
96
  });
96
97
 
98
+ describe("pane screenshot market bridge", () => {
99
+ /**
100
+ * The page can only reach the cloud API on its own, and the cloud carries no
101
+ * per-rating price targets. Serving research requests from the router is what
102
+ * keeps a screenshot showing the same values as `gloomberb fn`.
103
+ */
104
+ test("answers research requests from the routed provider and refuses anything else", async () => {
105
+ const calls: Array<[string, unknown[]]> = [];
106
+ const bridge = createDesktopShotBridge({
107
+ dataProvider: {
108
+ getAnalystResearch: (...args: unknown[]) => {
109
+ calls.push(["getAnalystResearch", args]);
110
+ return Promise.resolve({ symbol: "NKE", ratings: [{ currentPriceTarget: 42 }] });
111
+ },
112
+ } as any,
113
+ });
114
+
115
+ expect(await bridge.marketData("getAnalystResearch", ["NKE", "NYSE"])).toEqual({
116
+ symbol: "NKE",
117
+ ratings: [{ currentPriceTarget: 42 }],
118
+ });
119
+ expect(calls).toEqual([["getAnalystResearch", ["NKE", "NYSE"]]]);
120
+ await expect(bridge.marketData("getOptionsChain", ["NKE"])).rejects.toThrow(/does not serve/);
121
+ });
122
+ });
123
+
97
124
  describe("pane screenshot active-state verification", () => {
98
125
  const expected: PaneScreenshotExpectedSelection[] = [
99
126
  { control: "statement", label: "Cash Flow" },
@@ -11,6 +11,9 @@ const DEFAULT_SHOT_DEVICE_SCALE_FACTOR = 2;
11
11
  import {
12
12
  renderDesktopPaneScreenshot,
13
13
  type DesktopPaneShotApiProxy,
14
+ type DesktopPaneShotBridge,
15
+ type DesktopPaneShotHttpRequest,
16
+ type DesktopPaneShotHttpResponse,
14
17
  type DesktopPaneShotIntradayHistory,
15
18
  type DesktopPaneShotPayload,
16
19
  type DesktopPaneShotRenderResult,
@@ -19,6 +22,7 @@ import { optionPaneState } from "./options";
19
22
  import type { ResolvedPaneFunction } from "./resolver";
20
23
  import type { MarketContext } from "../types";
21
24
  import { capabilityPluginState } from "./capabilities";
25
+ import { resolvePersistedCloudSessionToken } from "./cloud-session";
22
26
  import type { RemoteUiNodeSnapshot } from "../../remote/types";
23
27
  import {
24
28
  graphRowsForFinancials,
@@ -76,8 +80,6 @@ const DESKTOP_CELL_HEIGHT_PX = 18;
76
80
  const OPTIONS_PANE_ID = "options";
77
81
  const MARKET_VALUATION_PANE_ID = "market-valuation";
78
82
  const ECON_STATISTICS_PANE_ID = "econ-statistics";
79
- const CLOUD_PLUGIN_ID = "gloomberb-cloud";
80
- const CLOUD_SESSION_KEYS = ["resume:session", "session"] as const;
81
83
  const CREDENTIAL_FIELD_NAMES = new Set([
82
84
  "accesstoken",
83
85
  "accessurl",
@@ -96,10 +98,6 @@ const CREDENTIAL_FIELD_NAMES = new Set([
96
98
  "token",
97
99
  ]);
98
100
 
99
- interface PersistedShotCloudSession {
100
- sessionToken?: unknown;
101
- }
102
-
103
101
  /**
104
102
  * The proxy receives the desktop session out of band. Keeping this separate
105
103
  * from DesktopPaneShotPayload prevents the token from entering the page,
@@ -138,21 +136,85 @@ export function stripDesktopShotCredentials<T>(value: T): T {
138
136
  export function resolveDesktopShotApiProxy(
139
137
  context: Pick<MarketContext, "persistence">,
140
138
  ): DesktopPaneShotApiProxy {
141
- let sessionToken: string | null = null;
142
- for (const key of CLOUD_SESSION_KEYS) {
143
- const value = context.persistence.pluginState.get<PersistedShotCloudSession>(
144
- CLOUD_PLUGIN_ID,
145
- key,
146
- 1,
147
- )?.value;
148
- if (typeof value?.sessionToken === "string" && value.sessionToken.length > 0) {
149
- sessionToken = value.sessionToken;
150
- break;
151
- }
152
- }
153
139
  return {
154
140
  baseUrl: getCloudApiBaseUrl(),
155
- sessionToken,
141
+ sessionToken: resolvePersistedCloudSessionToken(context),
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Data the page may request while it renders. The cloud API is only one of the
147
+ * router's sources, so calling it directly from the page dropped whatever
148
+ * another provider contributes: analyst rating price targets came back empty
149
+ * and corporate actions vanished whenever the cloud leg failed. Quotes are
150
+ * here for board panes that list symbols the payload was never built for.
151
+ * These run on the Bun side through the same router `gloomberb fn` uses.
152
+ */
153
+ const SHOT_BRIDGE_MARKET_OPERATIONS = new Set([
154
+ "getAnalystResearch",
155
+ "getCorporateActions",
156
+ "getEarningsCalendar",
157
+ "getHolders",
158
+ "getPriceHistory",
159
+ "getQuote",
160
+ "getQuotesBatch",
161
+ "getSecFilings",
162
+ ]);
163
+
164
+ const SHOT_BRIDGE_HTTP_TIMEOUT_MS = 20_000;
165
+
166
+ export function createDesktopShotBridge(
167
+ context: Pick<MarketContext, "dataProvider">,
168
+ ): DesktopPaneShotBridge {
169
+ return {
170
+ async marketData(operation, args) {
171
+ if (!SHOT_BRIDGE_MARKET_OPERATIONS.has(operation)) {
172
+ throw new Error(`Screenshot market bridge does not serve "${operation}".`);
173
+ }
174
+ const provider = context.dataProvider as unknown as Record<string, unknown>;
175
+ const handler = provider[operation];
176
+ if (typeof handler !== "function") {
177
+ throw new Error(`No provider available for ${operation}.`);
178
+ }
179
+ return await (handler as (...values: unknown[]) => Promise<unknown>).apply(
180
+ context.dataProvider,
181
+ args,
182
+ );
183
+ },
184
+ httpFetch: (request) => runDesktopShotHttpFetch(request),
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Panes such as DVD fetch a third-party API directly. A browser cannot: the
190
+ * request is cross-origin and dies in CORS, so the pane reported that the
191
+ * ticker pays no dividend. The desktop renderer already proxies these through
192
+ * its native half; the screenshot page gets the same treatment.
193
+ */
194
+ async function runDesktopShotHttpFetch(
195
+ request: DesktopPaneShotHttpRequest,
196
+ ): Promise<DesktopPaneShotHttpResponse> {
197
+ const target = new URL(request.url);
198
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
199
+ throw new Error(`Screenshot HTTP bridge refuses ${target.protocol} requests.`);
200
+ }
201
+ const response = await fetch(request.url, {
202
+ method: request.method ?? "GET",
203
+ headers: request.headers,
204
+ body: request.body,
205
+ redirect: "follow",
206
+ signal: AbortSignal.timeout(SHOT_BRIDGE_HTTP_TIMEOUT_MS),
207
+ });
208
+ const headers: Record<string, string> = {};
209
+ response.headers.forEach((value, name) => {
210
+ headers[name] = value;
211
+ });
212
+ return {
213
+ status: response.status,
214
+ statusText: response.statusText,
215
+ headers,
216
+ setCookie: response.headers.getSetCookie?.() ?? [],
217
+ body: await response.text(),
156
218
  };
157
219
  }
158
220
 
@@ -634,7 +696,10 @@ export async function renderDesktopShot({
634
696
  scale ?? 1,
635
697
  watermark ?? null,
636
698
  );
637
- render = await renderDesktopPaneScreenshot(payload, outputPath, apiProxy, { captureImage });
699
+ render = await renderDesktopPaneScreenshot(payload, outputPath, apiProxy, {
700
+ captureImage,
701
+ bridge: createDesktopShotBridge(context),
702
+ });
638
703
  } finally {
639
704
  apiClient.setSessionToken(previousSessionToken);
640
705
  }