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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/api-client/index.test.ts +34 -0
- package/src/api-client/request.ts +4 -1
- package/src/cli/desktop-pane-shot.ts +72 -2
- package/src/cli/pane-functions/cloud-session.test.ts +77 -0
- package/src/cli/pane-functions/cloud-session.ts +47 -0
- package/src/cli/pane-functions/index.ts +5 -1
- package/src/cli/pane-functions/screenshot.test.ts +27 -0
- package/src/cli/pane-functions/screenshot.ts +85 -20
- package/src/components/command-bar/assist/runtime.ts +38 -30
- package/src/components/command-bar/surface/assist.test.tsx +43 -0
- package/src/news/types.ts +1 -0
- package/src/plugins/builtin/chat/controller/channels.ts +2 -1
- package/src/plugins/builtin/chat/controller/index.ts +13 -5
- package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
- package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
- package/src/plugins/builtin/chat/controller/state.ts +1 -0
- package/src/plugins/builtin/chat/controller.test.ts +260 -4
- package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
- package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
- package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
- package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
- package/src/plugins/builtin/cloud/askg/protocol.test.ts +3 -2
- package/src/plugins/builtin/cloud/askg/protocol.ts +16 -4
- package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
- package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
- package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
- package/src/plugins/builtin/cloud/plugin.tsx +2 -0
- package/src/plugins/builtin/congress-trades/client.ts +11 -0
- package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
- package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
- package/src/plugins/builtin/congress-trades/headless.ts +145 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
- package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
- package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
- package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
- package/src/plugins/builtin/dividend-yield/model.ts +4 -22
- package/src/plugins/builtin/dividend-yield/view.ts +17 -0
- package/src/plugins/builtin/earnings/headless.test.ts +82 -0
- package/src/plugins/builtin/earnings/headless.ts +162 -0
- package/src/plugins/builtin/earnings/index.tsx +35 -9
- package/src/plugins/builtin/earnings/model.test.ts +29 -1
- package/src/plugins/builtin/earnings-calls/data.ts +29 -8
- package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
- package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
- package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
- package/src/plugins/builtin/earnings-calls/model.ts +238 -0
- package/src/plugins/builtin/earnings-calls/transcript-view.tsx +8 -9
- package/src/plugins/builtin/holders/client.ts +29 -0
- package/src/plugins/builtin/holders/headless.test.ts +58 -0
- package/src/plugins/builtin/holders/headless.ts +162 -0
- package/src/plugins/builtin/holders/index.tsx +14 -8
- package/src/plugins/builtin/holders/pane.tsx +7 -1
- package/src/plugins/builtin/insider/client.ts +50 -0
- package/src/plugins/builtin/insider/headless.test.ts +122 -0
- package/src/plugins/builtin/insider/headless.ts +117 -0
- package/src/plugins/builtin/insider/index.tsx +21 -50
- package/src/plugins/builtin/insider/insider-data.ts +1 -1
- package/src/plugins/builtin/insider/model.ts +72 -0
- package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
- package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
- package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
- package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
- package/src/plugins/builtin/news/headless.test.ts +138 -0
- package/src/plugins/builtin/news/headless.ts +190 -0
- package/src/plugins/builtin/news/index.tsx +13 -9
- package/src/plugins/builtin/news/wire/index.tsx +2 -1
- package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
- package/src/plugins/builtin/research/analyst-headless.ts +172 -0
- package/src/plugins/builtin/research/analyst-model.ts +219 -0
- package/src/plugins/builtin/research/analyst-pane.test.tsx +145 -0
- package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
- package/src/plugins/builtin/research/client.ts +12 -0
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +27 -1
- package/src/plugins/builtin/research/event-model.ts +56 -0
- package/src/plugins/builtin/research/headless.test.ts +93 -0
- package/src/plugins/builtin/research/headless.ts +161 -0
- package/src/plugins/builtin/research/index.test.ts +43 -0
- package/src/plugins/builtin/research/index.tsx +28 -18
- package/src/plugins/builtin/sec/client.ts +16 -0
- package/src/plugins/builtin/sec/headless.test.ts +72 -0
- package/src/plugins/builtin/sec/headless.ts +69 -0
- package/src/plugins/builtin/sec/index.tsx +20 -75
- package/src/plugins/builtin/sec/model.ts +89 -0
- package/src/plugins/builtin/short-interest/client.ts +5 -2
- package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
- package/src/plugins/builtin/short-interest/headless.ts +111 -0
- package/src/plugins/builtin/short-interest/index.tsx +15 -9
- package/src/plugins/builtin/short-interest/model.ts +15 -4
- package/src/plugins/builtin/short-interest/pane.tsx +1 -1
- package/src/plugins/builtin/thirteenf/data.ts +13 -11
- package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
- package/src/plugins/builtin/thirteenf/headless.ts +286 -0
- package/src/plugins/builtin/thirteenf/index.tsx +4 -0
- package/src/plugins/builtin/thirteenf/model.ts +13 -0
- package/src/plugins/builtin/thirteenf/table.ts +3 -15
- package/src/plugins/prediction-markets/headless.test.ts +119 -0
- package/src/plugins/prediction-markets/headless.ts +255 -0
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/renderers/browser/config-host.test.ts +70 -0
- package/src/renderers/browser/config-host.ts +74 -10
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +180 -19
- package/src/sources/gloomberb-cloud/news.ts +1 -0
- package/src/updater.test.ts +17 -1
- 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
|
@@ -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.
|
|
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
|
|
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, {
|
|
699
|
+
render = await renderDesktopPaneScreenshot(payload, outputPath, apiProxy, {
|
|
700
|
+
captureImage,
|
|
701
|
+
bridge: createDesktopShotBridge(context),
|
|
702
|
+
});
|
|
638
703
|
} finally {
|
|
639
704
|
apiClient.setSessionToken(previousSessionToken);
|
|
640
705
|
}
|