postgresai 0.16.0-dev.1 → 0.16.0-dev.11
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/CHANGELOG.md +11 -0
- package/bin/postgres-ai.ts +408 -25
- package/dist/bin/postgres-ai.js +940 -90
- package/dist/sql/06.helpers.sql +0 -122
- package/dist/sql/sql/06.helpers.sql +0 -122
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -8
- package/lib/joe.ts +703 -0
- package/lib/supabase.ts +0 -18
- package/lib/util.ts +241 -18
- package/package.json +1 -1
- package/sql/06.helpers.sql +0 -122
- package/test/auth.test.ts +30 -1
- package/test/checkup.integration.test.ts +31 -21
- package/test/checkup.test.ts +48 -3
- package/test/init.integration.test.ts +9 -79
- package/test/init.test.ts +35 -0
- package/test/joe.cli.test.ts +628 -0
- package/test/joe.test.ts +855 -0
- package/test/monitoring.test.ts +54 -3
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
- package/test/util.test.ts +227 -1
package/test/joe.test.ts
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import { describe, test, expect, mock, afterEach, spyOn } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
startCommand,
|
|
4
|
+
getCommandOutput,
|
|
5
|
+
listProjects,
|
|
6
|
+
resolveJoeInstanceId,
|
|
7
|
+
isNumericProjectRef,
|
|
8
|
+
buildJoeCommandText,
|
|
9
|
+
runCommand,
|
|
10
|
+
executeJoeCommand,
|
|
11
|
+
clientSidePlanFlags,
|
|
12
|
+
formatProjectsTable,
|
|
13
|
+
formatJoeOutput,
|
|
14
|
+
JOE_COMMANDS,
|
|
15
|
+
DESCRIBE_VARIANTS,
|
|
16
|
+
type JoeCommand,
|
|
17
|
+
type JoeCommandOutput,
|
|
18
|
+
} from "../lib/joe";
|
|
19
|
+
|
|
20
|
+
const BASE = "https://api.example.com";
|
|
21
|
+
const originalFetch = globalThis.fetch;
|
|
22
|
+
|
|
23
|
+
interface Captured {
|
|
24
|
+
url: string;
|
|
25
|
+
body: Record<string, unknown>;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Install a fetch mock that routes on the /rpc/<fn> suffix and records requests. */
|
|
30
|
+
function installFetch(routes: Record<string, (body: Record<string, unknown>) => Response>): Captured[] {
|
|
31
|
+
const captured: Captured[] = [];
|
|
32
|
+
globalThis.fetch = mock((url: string, options: RequestInit) => {
|
|
33
|
+
const body = options.body ? (JSON.parse(options.body as string) as Record<string, unknown>) : {};
|
|
34
|
+
const headers = (options.headers as Record<string, string>) || {};
|
|
35
|
+
captured.push({ url, body, headers });
|
|
36
|
+
const fn = new URL(url).pathname.split("/rpc/")[1] ?? "";
|
|
37
|
+
const handler = routes[fn];
|
|
38
|
+
if (!handler) {
|
|
39
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
40
|
+
}
|
|
41
|
+
return Promise.resolve(handler(body));
|
|
42
|
+
}) as unknown as typeof fetch;
|
|
43
|
+
return captured;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function json(obj: unknown, status = 200): Response {
|
|
47
|
+
return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
globalThis.fetch = originalFetch;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("startCommand (joe_command_run)", () => {
|
|
55
|
+
test("throws when apiKey missing", async () => {
|
|
56
|
+
await expect(
|
|
57
|
+
startCommand({ apiKey: "", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
|
|
58
|
+
).rejects.toThrow("API key is required");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("throws on empty command text before any network call", async () => {
|
|
62
|
+
const captured = installFetch({ joe_command_run: () => json("1") });
|
|
63
|
+
await expect(
|
|
64
|
+
startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: " " })
|
|
65
|
+
).rejects.toThrow("command text is required");
|
|
66
|
+
expect(captured.length).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("maps to /rpc/joe_command_run with {instance_id, command} and the access-token header", async () => {
|
|
70
|
+
const captured = installFetch({ joe_command_run: () => json("4711") });
|
|
71
|
+
const id = await startCommand({
|
|
72
|
+
apiKey: "k",
|
|
73
|
+
apiBaseUrl: BASE,
|
|
74
|
+
instanceId: 3,
|
|
75
|
+
command: "plan select 1",
|
|
76
|
+
});
|
|
77
|
+
expect(captured[0].url).toBe(`${BASE}/rpc/joe_command_run`);
|
|
78
|
+
expect(captured[0].headers["access-token"]).toBe("k");
|
|
79
|
+
// The RAW command text goes on the wire — no structured body, no prefixing.
|
|
80
|
+
expect(captured[0].body).toEqual({ instance_id: 3, command: "plan select 1" });
|
|
81
|
+
expect(id).toBe("4711");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the command id STAYS a string — a >2^53 id survives verbatim", async () => {
|
|
85
|
+
// joe_command_run returns to_json(id::text). Any parseInt/Number round-trip
|
|
86
|
+
// would corrupt 9007199254740993 to 9007199254740992.
|
|
87
|
+
installFetch({ joe_command_run: () => json("9007199254740993") });
|
|
88
|
+
const id = await startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "reset" });
|
|
89
|
+
expect(id).toBe("9007199254740993");
|
|
90
|
+
expect(typeof id).toBe("string");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a non-string rpc reply is rejected (contract violation)", async () => {
|
|
94
|
+
installFetch({ joe_command_run: () => json({ command_id: "1" }) });
|
|
95
|
+
await expect(
|
|
96
|
+
startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "reset" })
|
|
97
|
+
).rejects.toThrow(/expected a command id string/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a bare-number id is rejected — the whole point of the precision guard", async () => {
|
|
101
|
+
// If the backend ever returned to_json(id) instead of to_json(id::text),
|
|
102
|
+
// JSON.parse would have ALREADY rounded 9007199254740993 → ...992; a
|
|
103
|
+
// number must never be silently String()ed back.
|
|
104
|
+
installFetch({ joe_command_run: () => json(9007199254740993) });
|
|
105
|
+
await expect(
|
|
106
|
+
startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "reset" })
|
|
107
|
+
).rejects.toThrow(/expected a command id string/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a 401 rejects with the auth remediation hint", async () => {
|
|
111
|
+
installFetch({ joe_command_run: () => json({ message: "JWT expired" }, 401) });
|
|
112
|
+
let thrown: Error | null = null;
|
|
113
|
+
try {
|
|
114
|
+
await startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
thrown = err as Error;
|
|
117
|
+
}
|
|
118
|
+
expect(thrown?.message).toContain("HTTP 401");
|
|
119
|
+
expect(thrown?.message).toContain("JWT expired");
|
|
120
|
+
expect(thrown?.message).toContain("postgresai auth");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a 502 on run rejects legibly (no id exists yet — nothing to resume)", async () => {
|
|
124
|
+
installFetch({ joe_command_run: () => new Response("Bad Gateway", { status: 502 }) });
|
|
125
|
+
await expect(
|
|
126
|
+
startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
|
|
127
|
+
).rejects.toThrow(/Failed to run Joe command: HTTP 502/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("surfaces the missing-role PT403 detail from the JSON body", async () => {
|
|
131
|
+
installFetch({
|
|
132
|
+
joe_command_run: () =>
|
|
133
|
+
json({ code: "PT403", message: "Forbidden", details: "Joe API v2 requires the All Features role." }, 403),
|
|
134
|
+
});
|
|
135
|
+
await expect(
|
|
136
|
+
startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
|
|
137
|
+
).rejects.toThrow(/Joe API v2 requires the All Features role/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("surfaces a PT403 reason phrase when PostgREST returns no JSON body", async () => {
|
|
141
|
+
installFetch({
|
|
142
|
+
joe_command_run: () => new Response(null, {
|
|
143
|
+
status: 403,
|
|
144
|
+
statusText: "Joe API v2 requires the All Features role.",
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
await expect(
|
|
148
|
+
startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
|
|
149
|
+
).rejects.toThrow("Joe API v2 requires the All Features role.");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a non-JSON body embedded in the parse-failure error is redacted", async () => {
|
|
153
|
+
// The thrown Error flows into CLI stderr — a body echoing credentials must
|
|
154
|
+
// not bypass redaction on this path.
|
|
155
|
+
installFetch({
|
|
156
|
+
joe_command_run: () => new Response("oops password=hunter2 dsn=postgresql://joe:pw-abc@h/db", { status: 200 }),
|
|
157
|
+
});
|
|
158
|
+
let thrown: Error | null = null;
|
|
159
|
+
try {
|
|
160
|
+
await startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
thrown = err as Error;
|
|
163
|
+
}
|
|
164
|
+
expect(thrown?.message).toContain("failed to parse response");
|
|
165
|
+
expect(thrown?.message).not.toContain("hunter2");
|
|
166
|
+
expect(thrown?.message).not.toContain("pw-abc");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("getCommandOutput (joe_command_output)", () => {
|
|
171
|
+
test("maps to /rpc/joe_command_output with {command_id} and returns the FULL body", async () => {
|
|
172
|
+
const captured = installFetch({
|
|
173
|
+
joe_command_output: () =>
|
|
174
|
+
json({
|
|
175
|
+
command_id: "4711",
|
|
176
|
+
status: "ok",
|
|
177
|
+
created_at: "2026-07-22T10:00:00",
|
|
178
|
+
command: "explain",
|
|
179
|
+
query: "select * from users",
|
|
180
|
+
queryid: "7712349901234567890",
|
|
181
|
+
response: null,
|
|
182
|
+
plan_text: "Seq Scan on users",
|
|
183
|
+
plan_json: [{ Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } }],
|
|
184
|
+
plan_execution_text: "Seq Scan on users (actual time=0.1..12.3)",
|
|
185
|
+
plan_execution_json: [{ Plan: {} }],
|
|
186
|
+
stats: "Time: 12.4 ms",
|
|
187
|
+
recommendations: ":warning: Seq Scan",
|
|
188
|
+
error: null,
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
const output = await getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "4711" });
|
|
192
|
+
expect(captured[0].url).toBe(`${BASE}/rpc/joe_command_output`);
|
|
193
|
+
expect(captured[0].body).toEqual({ command_id: "4711" });
|
|
194
|
+
expect(output.status).toBe("ok");
|
|
195
|
+
expect(output.plan_text).toBe("Seq Scan on users");
|
|
196
|
+
expect(output.plan_execution_text).toContain("actual time");
|
|
197
|
+
expect(output.stats).toBe("Time: 12.4 ms");
|
|
198
|
+
// plan_json arrives structured (the rpc unwraps the stored jsonb string).
|
|
199
|
+
expect(Array.isArray(output.plan_json)).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a 'pending' body (no result columns yet) passes through", async () => {
|
|
203
|
+
installFetch({
|
|
204
|
+
joe_command_output: () => json({ command_id: "4711", status: "pending", created_at: "2026-07-22T10:00:00" }),
|
|
205
|
+
});
|
|
206
|
+
const output = await getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "4711" });
|
|
207
|
+
expect(output.status).toBe("pending");
|
|
208
|
+
expect(output.plan_text).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("PT404 (not found / other org) surfaces as an error", async () => {
|
|
212
|
+
installFetch({
|
|
213
|
+
joe_command_output: () => json({ code: "PT404", message: "Not found", details: "Specified command not found." }, 404),
|
|
214
|
+
});
|
|
215
|
+
await expect(
|
|
216
|
+
getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "9999" })
|
|
217
|
+
).rejects.toThrow(/Failed to fetch command output/);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("requires a commandId", async () => {
|
|
221
|
+
await expect(
|
|
222
|
+
getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "" })
|
|
223
|
+
).rejects.toThrow("commandId is required");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a 502 rejects legibly (never mistaken for success)", async () => {
|
|
227
|
+
installFetch({ joe_command_output: () => new Response("<html>Bad Gateway</html>", { status: 502 }) });
|
|
228
|
+
await expect(
|
|
229
|
+
getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "1" })
|
|
230
|
+
).rejects.toThrow(/Failed to fetch command output: HTTP 502/);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("listProjects", () => {
|
|
235
|
+
test("normalizes rows and defaults missing optional fields", async () => {
|
|
236
|
+
installFetch({
|
|
237
|
+
projects_list: () =>
|
|
238
|
+
json([
|
|
239
|
+
{ project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3, dblab_instance_id: 7 },
|
|
240
|
+
{ project_id: 13, alias: "dw", name: "Warehouse", joe_ready: false, tunnel: false },
|
|
241
|
+
]),
|
|
242
|
+
});
|
|
243
|
+
const projects = await listProjects({ apiKey: "k", apiBaseUrl: BASE });
|
|
244
|
+
expect(projects).toEqual([
|
|
245
|
+
{ project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3, dblab_instance_id: 7 },
|
|
246
|
+
{ project_id: 13, alias: "dw", name: "Warehouse", joe_ready: false, tunnel: false, instance_id: null, dblab_instance_id: null },
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("passes org_id when provided", async () => {
|
|
251
|
+
const captured = installFetch({ projects_list: () => json([]) });
|
|
252
|
+
await listProjects({ apiKey: "k", apiBaseUrl: BASE, orgId: 7 });
|
|
253
|
+
expect(captured[0].body).toEqual({ org_id: 7 });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("preserves 64-bit project and instance ids without Number precision loss", async () => {
|
|
257
|
+
installFetch({
|
|
258
|
+
projects_list: () => json([{
|
|
259
|
+
project_id: "9007199254740993",
|
|
260
|
+
alias: "huge",
|
|
261
|
+
instance_id: "9007199254740994",
|
|
262
|
+
dblab_instance_id: "9007199254740995",
|
|
263
|
+
}]),
|
|
264
|
+
});
|
|
265
|
+
const [project] = await listProjects({ apiKey: "k", apiBaseUrl: BASE });
|
|
266
|
+
expect(project.project_id).toBe("9007199254740993");
|
|
267
|
+
expect(project.instance_id).toBe("9007199254740994");
|
|
268
|
+
expect(project.dblab_instance_id).toBe("9007199254740995");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("resolveJoeInstanceId (project id-or-alias → Joe instance)", () => {
|
|
273
|
+
const PROJECTS = [
|
|
274
|
+
{ project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, instance_id: 3 },
|
|
275
|
+
{ project_id: 13, alias: "no-joe", name: "No Joe", joe_ready: false, instance_id: null },
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
test("isNumericProjectRef distinguishes ids from aliases", () => {
|
|
279
|
+
expect(isNumericProjectRef("12")).toBe(true);
|
|
280
|
+
expect(isNumericProjectRef(" 12 ")).toBe(true);
|
|
281
|
+
expect(isNumericProjectRef("main-db")).toBe(false);
|
|
282
|
+
expect(isNumericProjectRef("12a")).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a numeric project id resolves to the project's instance_id (lookup required)", async () => {
|
|
286
|
+
// Unlike a pure project-id resolver, the instance id only lives in the
|
|
287
|
+
// projects listing — a numeric ref must still hit projects_list.
|
|
288
|
+
const captured = installFetch({ projects_list: () => json(PROJECTS) });
|
|
289
|
+
const id = await resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "12" });
|
|
290
|
+
expect(id).toBe(3);
|
|
291
|
+
expect(captured.length).toBe(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("alias / name resolve case-insensitively to the same instance", async () => {
|
|
295
|
+
installFetch({ projects_list: () => json(PROJECTS) });
|
|
296
|
+
expect(await resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "MAIN-DB" })).toBe(3);
|
|
297
|
+
expect(await resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "main db" })).toBe(3);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("a 64-bit instance id survives as a string", async () => {
|
|
301
|
+
installFetch({
|
|
302
|
+
projects_list: () => json([{ project_id: 12, alias: "huge", instance_id: "9007199254740994" }]),
|
|
303
|
+
});
|
|
304
|
+
expect(await resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "huge" }))
|
|
305
|
+
.toBe("9007199254740994");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("unknown ref throws with a 'pgai projects' hint", async () => {
|
|
309
|
+
installFetch({ projects_list: () => json(PROJECTS) });
|
|
310
|
+
await expect(
|
|
311
|
+
resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "nope" })
|
|
312
|
+
).rejects.toThrow(/Project not found for id\/alias\/name 'nope'/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("a project without a Joe instance throws", async () => {
|
|
316
|
+
installFetch({ projects_list: () => json(PROJECTS) });
|
|
317
|
+
await expect(
|
|
318
|
+
resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "no-joe" })
|
|
319
|
+
).rejects.toThrow(/has no Joe instance/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("empty project ref throws before any network call", async () => {
|
|
323
|
+
const captured = installFetch({ projects_list: () => json(PROJECTS) });
|
|
324
|
+
await expect(
|
|
325
|
+
resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "" })
|
|
326
|
+
).rejects.toThrow(/project is required/);
|
|
327
|
+
expect(captured.length).toBe(0);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("buildJoeCommandText — the raw wire text per verb", () => {
|
|
332
|
+
test("SQL-carrying verbs prefix the verb, nothing else", () => {
|
|
333
|
+
expect(buildJoeCommandText("plan", { arg: "select * from users" })).toBe("plan select * from users");
|
|
334
|
+
expect(buildJoeCommandText("explain", { arg: "select 1" })).toBe("explain select 1");
|
|
335
|
+
expect(buildJoeCommandText("exec", { arg: "create index on users (email)" })).toBe("exec create index on users (email)");
|
|
336
|
+
expect(buildJoeCommandText("hypo", { arg: "create index on users (email)" })).toBe("hypo create index on users (email)");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("bare verbs render as the verb alone", () => {
|
|
340
|
+
expect(buildJoeCommandText("activity")).toBe("activity");
|
|
341
|
+
expect(buildJoeCommandText("reset")).toBe("reset");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("terminate renders `terminate <pid>` and rejects non-bare-digit pids", () => {
|
|
345
|
+
expect(buildJoeCommandText("terminate", { arg: "4711" })).toBe("terminate 4711");
|
|
346
|
+
for (const bad of ["0", "12x", "abc", "1.5", "0x10", "-5", "", " "]) {
|
|
347
|
+
expect(() => buildJoeCommandText("terminate", { arg: bad })).toThrow("pid must be a positive integer");
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("describe defaults to \\d and honors an allowlisted --variant", () => {
|
|
352
|
+
expect(buildJoeCommandText("describe", { arg: "users" })).toBe("\\d users");
|
|
353
|
+
expect(buildJoeCommandText("describe", { arg: "users", variant: "\\d+" })).toBe("\\d+ users");
|
|
354
|
+
expect(buildJoeCommandText("describe", { arg: "users_pkey", variant: "\\di+" })).toBe("\\di+ users_pkey");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("describe rejects a variant outside Joe's psql allowlist", () => {
|
|
358
|
+
for (const bad of ["\\dx", "\\copy", "d", "\\du"]) {
|
|
359
|
+
expect(() => buildJoeCommandText("describe", { arg: "users", variant: bad })).toThrow(/Unsupported describe variant/);
|
|
360
|
+
}
|
|
361
|
+
// The allowlist mirrors Joe's own dispatcher table.
|
|
362
|
+
expect([...DESCRIBE_VARIANTS]).toEqual(["\\d", "\\d+", "\\dt", "\\dt+", "\\di", "\\di+", "\\l", "\\l+", "\\dv", "\\dv+", "\\dm", "\\dm+"]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("empty required arguments throw", () => {
|
|
366
|
+
for (const verb of ["plan", "explain", "exec", "hypo"] as const) {
|
|
367
|
+
expect(() => buildJoeCommandText(verb, { arg: " " })).toThrow(`${verb} requires an argument`);
|
|
368
|
+
}
|
|
369
|
+
expect(() => buildJoeCommandText("describe", { arg: "" })).toThrow("describe requires an object name");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("JOE_COMMANDS is the Joe dispatcher verb set", () => {
|
|
373
|
+
const expected: JoeCommand[] = ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe"];
|
|
374
|
+
expect([...JOE_COMMANDS]).toEqual(expected);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("runCommand — run-then-poll", () => {
|
|
379
|
+
test("polls the output until terminal (pending → pending → ok)", async () => {
|
|
380
|
+
let outputCalls = 0;
|
|
381
|
+
const captured = installFetch({
|
|
382
|
+
joe_command_run: () => json("4711"),
|
|
383
|
+
joe_command_output: () => {
|
|
384
|
+
outputCalls += 1;
|
|
385
|
+
if (outputCalls < 3) {
|
|
386
|
+
return json({ command_id: "4711", status: "pending" });
|
|
387
|
+
}
|
|
388
|
+
return json({ command_id: "4711", status: "ok", plan_text: "Index Scan", error: null });
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
const outcome = await runCommand({
|
|
392
|
+
apiKey: "k",
|
|
393
|
+
apiBaseUrl: BASE,
|
|
394
|
+
instanceId: 3,
|
|
395
|
+
command: "plan select 1",
|
|
396
|
+
pollIntervalMs: 0,
|
|
397
|
+
sleep: async () => {},
|
|
398
|
+
});
|
|
399
|
+
expect(outcome.status).toBe("ok");
|
|
400
|
+
expect(outcome.budgetExpired).toBe(false);
|
|
401
|
+
expect(outcome.commandId).toBe("4711");
|
|
402
|
+
expect(outcome.output?.plan_text).toBe("Index Scan");
|
|
403
|
+
expect(outputCalls).toBe(3);
|
|
404
|
+
// Every poll carried the id as the string the run rpc returned.
|
|
405
|
+
for (const call of captured.slice(1)) {
|
|
406
|
+
expect(call.body).toEqual({ command_id: "4711" });
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("budget expiry returns a resume handle without an output", async () => {
|
|
411
|
+
installFetch({
|
|
412
|
+
joe_command_run: () => json("4712"),
|
|
413
|
+
joe_command_output: () => json({ command_id: "4712", status: "pending" }),
|
|
414
|
+
});
|
|
415
|
+
let clock = 1000;
|
|
416
|
+
const outcome = await runCommand({
|
|
417
|
+
apiKey: "k",
|
|
418
|
+
apiBaseUrl: BASE,
|
|
419
|
+
instanceId: 3,
|
|
420
|
+
command: "plan select 1",
|
|
421
|
+
budgetMs: 5,
|
|
422
|
+
pollIntervalMs: 0,
|
|
423
|
+
now: () => (clock += 10),
|
|
424
|
+
sleep: async () => {},
|
|
425
|
+
});
|
|
426
|
+
expect(outcome.budgetExpired).toBe(true);
|
|
427
|
+
expect(outcome.status).toBe("pending");
|
|
428
|
+
expect(outcome.output).toBeNull();
|
|
429
|
+
expect(outcome.commandId).toBe("4712");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("a stalled output request is aborted and returns a resume handle", async () => {
|
|
433
|
+
let calls = 0;
|
|
434
|
+
globalThis.fetch = mock((_url: string, options: RequestInit) => {
|
|
435
|
+
calls += 1;
|
|
436
|
+
if (calls === 1) {
|
|
437
|
+
return Promise.resolve(json("4713"));
|
|
438
|
+
}
|
|
439
|
+
return new Promise<Response>((_resolve, reject) => {
|
|
440
|
+
options.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true });
|
|
441
|
+
});
|
|
442
|
+
}) as unknown as typeof fetch;
|
|
443
|
+
|
|
444
|
+
const outcome = await runCommand({
|
|
445
|
+
apiKey: "k",
|
|
446
|
+
apiBaseUrl: BASE,
|
|
447
|
+
instanceId: 3,
|
|
448
|
+
command: "plan select 1",
|
|
449
|
+
budgetMs: 10,
|
|
450
|
+
pollIntervalMs: 0,
|
|
451
|
+
sleep: async () => {},
|
|
452
|
+
});
|
|
453
|
+
expect(outcome).toMatchObject({
|
|
454
|
+
commandId: "4713",
|
|
455
|
+
status: "pending",
|
|
456
|
+
output: null,
|
|
457
|
+
budgetExpired: true,
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("a terminal error output is returned in full", async () => {
|
|
462
|
+
installFetch({
|
|
463
|
+
joe_command_run: () => json("5"),
|
|
464
|
+
joe_command_output: () => json({ command_id: "5", status: "error", error: "ERROR: relation \"nope\" does not exist" }),
|
|
465
|
+
});
|
|
466
|
+
const outcome = await runCommand({
|
|
467
|
+
apiKey: "k",
|
|
468
|
+
apiBaseUrl: BASE,
|
|
469
|
+
instanceId: 3,
|
|
470
|
+
command: "explain select * from nope",
|
|
471
|
+
pollIntervalMs: 0,
|
|
472
|
+
sleep: async () => {},
|
|
473
|
+
});
|
|
474
|
+
expect(outcome.status).toBe("error");
|
|
475
|
+
expect(outcome.budgetExpired).toBe(false);
|
|
476
|
+
expect(outcome.output?.error).toContain("does not exist");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("a transient 502 mid-poll never discards the id: keeps polling and recovers", async () => {
|
|
480
|
+
// startCommand already returned a valid command id; a proxy hiccup on ONE
|
|
481
|
+
// output poll must not throw it away — the loop retries within the budget.
|
|
482
|
+
let outputCalls = 0;
|
|
483
|
+
installFetch({
|
|
484
|
+
joe_command_run: () => json("4714"),
|
|
485
|
+
joe_command_output: () => {
|
|
486
|
+
outputCalls += 1;
|
|
487
|
+
if (outputCalls === 1) {
|
|
488
|
+
return new Response("Bad Gateway", { status: 502 });
|
|
489
|
+
}
|
|
490
|
+
return json({ command_id: "4714", status: "ok", plan_text: "Index Scan", error: null });
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
const outcome = await runCommand({
|
|
494
|
+
apiKey: "k",
|
|
495
|
+
apiBaseUrl: BASE,
|
|
496
|
+
instanceId: 3,
|
|
497
|
+
command: "plan select 1",
|
|
498
|
+
pollIntervalMs: 0,
|
|
499
|
+
sleep: async () => {},
|
|
500
|
+
});
|
|
501
|
+
expect(outcome.status).toBe("ok");
|
|
502
|
+
expect(outcome.commandId).toBe("4714");
|
|
503
|
+
expect(outcome.output?.plan_text).toBe("Index Scan");
|
|
504
|
+
expect(outputCalls).toBe(2);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("a persistent 502 surfaces the id as a resume handle instead of throwing", async () => {
|
|
508
|
+
installFetch({
|
|
509
|
+
joe_command_run: () => json("4715"),
|
|
510
|
+
joe_command_output: () => new Response("Bad Gateway", { status: 502 }),
|
|
511
|
+
});
|
|
512
|
+
let clock = 0;
|
|
513
|
+
const outcome = await runCommand({
|
|
514
|
+
apiKey: "k",
|
|
515
|
+
apiBaseUrl: BASE,
|
|
516
|
+
instanceId: 3,
|
|
517
|
+
command: "plan select 1",
|
|
518
|
+
budgetMs: 25,
|
|
519
|
+
pollIntervalMs: 0,
|
|
520
|
+
now: () => (clock += 10),
|
|
521
|
+
sleep: async () => {},
|
|
522
|
+
});
|
|
523
|
+
expect(outcome).toMatchObject({
|
|
524
|
+
commandId: "4715",
|
|
525
|
+
status: "pending",
|
|
526
|
+
output: null,
|
|
527
|
+
budgetExpired: true,
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("a persistent 429 (rate limit) also surfaces the id as a resume handle", async () => {
|
|
532
|
+
installFetch({
|
|
533
|
+
joe_command_run: () => json("4716"),
|
|
534
|
+
joe_command_output: () => json({ message: "rate limited" }, 429),
|
|
535
|
+
});
|
|
536
|
+
let clock = 0;
|
|
537
|
+
const outcome = await runCommand({
|
|
538
|
+
apiKey: "k",
|
|
539
|
+
apiBaseUrl: BASE,
|
|
540
|
+
instanceId: 3,
|
|
541
|
+
command: "plan select 1",
|
|
542
|
+
budgetMs: 25,
|
|
543
|
+
pollIntervalMs: 0,
|
|
544
|
+
now: () => (clock += 10),
|
|
545
|
+
sleep: async () => {},
|
|
546
|
+
});
|
|
547
|
+
expect(outcome).toMatchObject({
|
|
548
|
+
commandId: "4716",
|
|
549
|
+
status: "pending",
|
|
550
|
+
output: null,
|
|
551
|
+
budgetExpired: true,
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("a terminal 404 mid-poll DOES abort (polling on cannot succeed)", async () => {
|
|
556
|
+
installFetch({
|
|
557
|
+
joe_command_run: () => json("4717"),
|
|
558
|
+
joe_command_output: () => json({ code: "PT404", message: "Not found", details: "Specified command not found." }, 404),
|
|
559
|
+
});
|
|
560
|
+
await expect(
|
|
561
|
+
runCommand({
|
|
562
|
+
apiKey: "k",
|
|
563
|
+
apiBaseUrl: BASE,
|
|
564
|
+
instanceId: 3,
|
|
565
|
+
command: "plan select 1",
|
|
566
|
+
pollIntervalMs: 0,
|
|
567
|
+
sleep: async () => {},
|
|
568
|
+
})
|
|
569
|
+
).rejects.toThrow(/Failed to fetch command output: HTTP 404/);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("a NaN budget is clamped to the default budget (never an unbounded poll)", async () => {
|
|
573
|
+
// NaN survives `?? DEFAULT_BUDGET_MS` (it is neither null nor undefined), and
|
|
574
|
+
// `now() >= NaN` is always false — without a clamp the poll loop never exits.
|
|
575
|
+
// The output route trips a breaker well past the default-budget poll count so
|
|
576
|
+
// a regression fails fast instead of hanging the test runner.
|
|
577
|
+
let outputCalls = 0;
|
|
578
|
+
installFetch({
|
|
579
|
+
joe_command_run: () => json("9"),
|
|
580
|
+
joe_command_output: () => {
|
|
581
|
+
outputCalls += 1;
|
|
582
|
+
if (outputCalls > 60) {
|
|
583
|
+
throw new Error("unbounded poll loop: NaN budget never expired");
|
|
584
|
+
}
|
|
585
|
+
return json({ command_id: "9", status: "pending" });
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
let clock = 0;
|
|
589
|
+
const outcome = await runCommand({
|
|
590
|
+
apiKey: "k",
|
|
591
|
+
apiBaseUrl: BASE,
|
|
592
|
+
instanceId: 3,
|
|
593
|
+
command: "plan select 1",
|
|
594
|
+
budgetMs: Number.NaN,
|
|
595
|
+
pollIntervalMs: 0,
|
|
596
|
+
now: () => (clock += 1000), // 1 s per observation → passes the 25 s default budget in <30 polls
|
|
597
|
+
sleep: async () => {},
|
|
598
|
+
});
|
|
599
|
+
expect(outcome.budgetExpired).toBe(true);
|
|
600
|
+
expect(outputCalls).toBeLessThanOrEqual(60);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe("executeJoeCommand — target → build → run", () => {
|
|
605
|
+
test("resolves an alias to the project's instance_id and sends the raw text", async () => {
|
|
606
|
+
const captured = installFetch({
|
|
607
|
+
projects_list: () => json([{ project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, instance_id: 3 }]),
|
|
608
|
+
joe_command_run: () => json("77"),
|
|
609
|
+
joe_command_output: () => json({ command_id: "77", status: "ok", plan_text: "ok", error: null }),
|
|
610
|
+
});
|
|
611
|
+
const outcome = await executeJoeCommand({
|
|
612
|
+
apiKey: "k",
|
|
613
|
+
apiBaseUrl: BASE,
|
|
614
|
+
command: "plan",
|
|
615
|
+
project: "main-db",
|
|
616
|
+
input: { arg: "select 1" },
|
|
617
|
+
pollIntervalMs: 0,
|
|
618
|
+
sleep: async () => {},
|
|
619
|
+
});
|
|
620
|
+
expect(outcome.instanceId).toBe(3);
|
|
621
|
+
expect(outcome.commandText).toBe("plan select 1");
|
|
622
|
+
expect(outcome.status).toBe("ok");
|
|
623
|
+
const run = captured.find((c) => c.url.endsWith("/rpc/joe_command_run"));
|
|
624
|
+
expect(run?.body).toEqual({ instance_id: 3, command: "plan select 1" });
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("a bad verb argument fails BEFORE any network call", async () => {
|
|
628
|
+
const captured = installFetch({
|
|
629
|
+
projects_list: () => json([]),
|
|
630
|
+
joe_command_run: () => json("1"),
|
|
631
|
+
});
|
|
632
|
+
await expect(
|
|
633
|
+
executeJoeCommand({
|
|
634
|
+
apiKey: "k",
|
|
635
|
+
apiBaseUrl: BASE,
|
|
636
|
+
command: "terminate",
|
|
637
|
+
project: "12",
|
|
638
|
+
input: { arg: "12x" },
|
|
639
|
+
})
|
|
640
|
+
).rejects.toThrow("pid must be a positive integer");
|
|
641
|
+
expect(captured.length).toBe(0);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("a direct instanceId skips project resolution entirely", async () => {
|
|
645
|
+
// The v1 path: projects_list is not deployed, so the instance id is given
|
|
646
|
+
// directly and must go straight to joe_command_run — as the exact string,
|
|
647
|
+
// never resolved, never Number()ed.
|
|
648
|
+
const captured = installFetch({
|
|
649
|
+
joe_command_run: () => json("80"),
|
|
650
|
+
joe_command_output: () => json({ command_id: "80", status: "ok", response: "ok", error: null }),
|
|
651
|
+
});
|
|
652
|
+
const outcome = await executeJoeCommand({
|
|
653
|
+
apiKey: "k",
|
|
654
|
+
apiBaseUrl: BASE,
|
|
655
|
+
command: "explain",
|
|
656
|
+
instanceId: "9007199254740994001",
|
|
657
|
+
input: { arg: "select 1" },
|
|
658
|
+
pollIntervalMs: 0,
|
|
659
|
+
sleep: async () => {},
|
|
660
|
+
});
|
|
661
|
+
expect(outcome.status).toBe("ok");
|
|
662
|
+
expect(outcome.instanceId).toBe("9007199254740994001");
|
|
663
|
+
expect(captured.some((c) => c.url.endsWith("/rpc/projects_list"))).toBe(false);
|
|
664
|
+
const run = captured.find((c) => c.url.endsWith("/rpc/joe_command_run"));
|
|
665
|
+
expect(run?.body).toEqual({ instance_id: "9007199254740994001", command: "explain select 1" });
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("instanceId wins over project when both are given (no projects_list call)", async () => {
|
|
669
|
+
const captured = installFetch({
|
|
670
|
+
projects_list: () => json([{ project_id: 12, alias: "main-db", joe_ready: true, instance_id: 3 }]),
|
|
671
|
+
joe_command_run: () => json("81"),
|
|
672
|
+
joe_command_output: () => json({ command_id: "81", status: "ok", response: "ok", error: null }),
|
|
673
|
+
});
|
|
674
|
+
await executeJoeCommand({
|
|
675
|
+
apiKey: "k",
|
|
676
|
+
apiBaseUrl: BASE,
|
|
677
|
+
command: "reset",
|
|
678
|
+
project: "main-db",
|
|
679
|
+
instanceId: "7",
|
|
680
|
+
pollIntervalMs: 0,
|
|
681
|
+
sleep: async () => {},
|
|
682
|
+
});
|
|
683
|
+
expect(captured.some((c) => c.url.endsWith("/rpc/projects_list"))).toBe(false);
|
|
684
|
+
const run = captured.find((c) => c.url.endsWith("/rpc/joe_command_run"));
|
|
685
|
+
expect(run?.body.instance_id).toBe("7");
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("a malformed instanceId is rejected before any network call", async () => {
|
|
689
|
+
const captured = installFetch({ joe_command_run: () => json("1") });
|
|
690
|
+
await expect(
|
|
691
|
+
executeJoeCommand({ apiKey: "k", apiBaseUrl: BASE, command: "reset", instanceId: "1; drop" })
|
|
692
|
+
).rejects.toThrow("instanceId must be a numeric Joe instance id");
|
|
693
|
+
expect(captured.length).toBe(0);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("neither instanceId nor project is a clear error before any network call", async () => {
|
|
697
|
+
const captured = installFetch({ joe_command_run: () => json("1") });
|
|
698
|
+
await expect(
|
|
699
|
+
executeJoeCommand({ apiKey: "k", apiBaseUrl: BASE, command: "reset" })
|
|
700
|
+
).rejects.toThrow("either instanceId or project is required");
|
|
701
|
+
expect(captured.length).toBe(0);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("the full output (plan_execution_*, stats, recommendations) reaches the outcome", async () => {
|
|
705
|
+
installFetch({
|
|
706
|
+
projects_list: () => json([{ project_id: 12, alias: "main-db", joe_ready: true, instance_id: 3 }]),
|
|
707
|
+
joe_command_run: () => json("78"),
|
|
708
|
+
joe_command_output: () =>
|
|
709
|
+
json({
|
|
710
|
+
command_id: "78",
|
|
711
|
+
status: "ok",
|
|
712
|
+
command: "explain",
|
|
713
|
+
query: "select 1",
|
|
714
|
+
queryid: "991",
|
|
715
|
+
plan_text: "Result",
|
|
716
|
+
plan_json: [{ Plan: { "Node Type": "Result" } }],
|
|
717
|
+
plan_execution_text: "Result (actual time=0.001..0.002)",
|
|
718
|
+
stats: "Time: 0.5 ms",
|
|
719
|
+
recommendations: "looks good",
|
|
720
|
+
error: null,
|
|
721
|
+
}),
|
|
722
|
+
});
|
|
723
|
+
const outcome = await executeJoeCommand({
|
|
724
|
+
apiKey: "k",
|
|
725
|
+
apiBaseUrl: BASE,
|
|
726
|
+
command: "explain",
|
|
727
|
+
project: "12",
|
|
728
|
+
input: { arg: "select 1" },
|
|
729
|
+
pollIntervalMs: 0,
|
|
730
|
+
sleep: async () => {},
|
|
731
|
+
});
|
|
732
|
+
expect(outcome.output).toMatchObject({
|
|
733
|
+
plan_execution_text: "Result (actual time=0.001..0.002)",
|
|
734
|
+
stats: "Time: 0.5 ms",
|
|
735
|
+
recommendations: "looks good",
|
|
736
|
+
queryid: "991",
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
describe("presentation helpers", () => {
|
|
742
|
+
test("clientSidePlanFlags flags Seq Scans in nested plans", () => {
|
|
743
|
+
const flags = clientSidePlanFlags({
|
|
744
|
+
Plan: {
|
|
745
|
+
"Node Type": "Nested Loop",
|
|
746
|
+
Plans: [{ "Node Type": "Seq Scan", "Relation Name": "users" }],
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
expect(flags.length).toBe(1);
|
|
750
|
+
expect(flags[0]).toContain("Seq Scan on users");
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("clientSidePlanFlags handles the EXPLAIN json array form the rpc returns", () => {
|
|
754
|
+
// plan_json is unwrapped server-side into EXPLAIN's native array shape:
|
|
755
|
+
// [{ "Plan": { … } }].
|
|
756
|
+
const flags = clientSidePlanFlags([
|
|
757
|
+
{ Plan: { "Node Type": "Seq Scan", "Relation Name": "orders" } },
|
|
758
|
+
]);
|
|
759
|
+
expect(flags.length).toBe(1);
|
|
760
|
+
expect(flags[0]).toContain("Seq Scan on orders");
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("clientSidePlanFlags handles empty, root, and multiple nested plans", () => {
|
|
764
|
+
expect(clientSidePlanFlags(null)).toEqual([]);
|
|
765
|
+
expect(clientSidePlanFlags({})).toEqual([]);
|
|
766
|
+
expect(clientSidePlanFlags({ "Node Type": "Seq Scan", "Relation Name": "root" })[0]).toContain("root");
|
|
767
|
+
expect(clientSidePlanFlags({
|
|
768
|
+
Plan: {
|
|
769
|
+
"Node Type": "Append",
|
|
770
|
+
Plans: [
|
|
771
|
+
{ "Node Type": "Seq Scan", "Relation Name": "a" },
|
|
772
|
+
{ "Node Type": "Seq Scan", "Relation Name": "b" },
|
|
773
|
+
],
|
|
774
|
+
},
|
|
775
|
+
})).toHaveLength(2);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("formatJoeOutput prints every present section of the uniform row", () => {
|
|
779
|
+
const output: JoeCommandOutput = {
|
|
780
|
+
command_id: "1",
|
|
781
|
+
status: "ok",
|
|
782
|
+
plan_text: "Seq Scan on users",
|
|
783
|
+
plan_json: [{ Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } }],
|
|
784
|
+
plan_execution_text: "Seq Scan on users (actual time=0.1..12.3)",
|
|
785
|
+
stats: "Time: 12.4 ms",
|
|
786
|
+
recommendations: ":warning: Seq Scan detected",
|
|
787
|
+
queryid: "7712349901234567890",
|
|
788
|
+
error: null,
|
|
789
|
+
};
|
|
790
|
+
const text = formatJoeOutput(output);
|
|
791
|
+
expect(text).toContain("plan:");
|
|
792
|
+
expect(text).toContain("Seq Scan on users");
|
|
793
|
+
expect(text).toContain("⚑");
|
|
794
|
+
expect(text).toContain("execution plan (EXPLAIN ANALYZE):");
|
|
795
|
+
expect(text).toContain("stats:");
|
|
796
|
+
expect(text).toContain("Time: 12.4 ms");
|
|
797
|
+
expect(text).toContain("recommendations:");
|
|
798
|
+
expect(text).toContain("(queryid 7712349901234567890)");
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("formatJoeOutput prints a bare response (exec/describe/activity) without labels", () => {
|
|
802
|
+
const text = formatJoeOutput({ command_id: "2", status: "ok", response: "CREATE INDEX", error: null });
|
|
803
|
+
expect(text).toBe("CREATE INDEX");
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("formatJoeOutput renders nothing for an empty row", () => {
|
|
807
|
+
expect(formatJoeOutput({ command_id: "3", status: "ok", error: null })).toBe("");
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("formatProjectsTable renders the fixed-width columns", () => {
|
|
811
|
+
const table = formatProjectsTable([
|
|
812
|
+
{ project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3, dblab_instance_id: 7 },
|
|
813
|
+
]);
|
|
814
|
+
const [header, row] = table.split("\n");
|
|
815
|
+
expect(header).toContain("PROJECT_ID");
|
|
816
|
+
expect(header).toContain("ALIAS");
|
|
817
|
+
expect(header).toContain("JOE");
|
|
818
|
+
expect(header).toContain("TUNNEL");
|
|
819
|
+
expect(row).toContain("12");
|
|
820
|
+
expect(row).toContain("main-db");
|
|
821
|
+
expect(row).toContain("ready");
|
|
822
|
+
expect(row).toContain("yes");
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
describe("--debug credential redaction (joe rpc surface)", () => {
|
|
827
|
+
test("response bodies have password-named fields redacted before logging", async () => {
|
|
828
|
+
// `--debug` writes the raw response body to stderr; any credential-shaped
|
|
829
|
+
// field must be masked the same way the access-token header already is.
|
|
830
|
+
const spy = spyOn(console, "error").mockImplementation(() => {});
|
|
831
|
+
try {
|
|
832
|
+
installFetch({
|
|
833
|
+
joe_command_output: () =>
|
|
834
|
+
json({
|
|
835
|
+
command_id: "1",
|
|
836
|
+
command: "exec",
|
|
837
|
+
status: "ok",
|
|
838
|
+
error: null,
|
|
839
|
+
response: "ok",
|
|
840
|
+
stats: null,
|
|
841
|
+
plan_text: null,
|
|
842
|
+
password: "row-secret-xyz",
|
|
843
|
+
}),
|
|
844
|
+
});
|
|
845
|
+
await getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "1", debug: true });
|
|
846
|
+
const logged = spy.mock.calls.map((c) => c.map(String).join(" ")).join("\n");
|
|
847
|
+
expect(logged).toContain("Debug: Response body");
|
|
848
|
+
expect(logged).not.toContain("row-secret-xyz");
|
|
849
|
+
// The rest of the body still logs (redaction, not suppression).
|
|
850
|
+
expect(logged).toContain("command_id");
|
|
851
|
+
} finally {
|
|
852
|
+
spy.mockRestore();
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
});
|