postgresai 0.16.0-dev.10 → 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/README.md +1 -84
- package/bin/postgres-ai.ts +140 -540
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +790 -2282
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -0
- package/lib/joe.ts +333 -391
- package/lib/mcp-server.ts +0 -625
- package/lib/util.ts +145 -29
- package/package.json +1 -1
- 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.test.ts +35 -0
- package/test/joe.cli.test.ts +355 -196
- package/test/joe.test.ts +565 -568
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
- package/test/util.test.ts +116 -0
- package/lib/dblab.ts +0 -449
- package/test/dblab.cli.test.ts +0 -373
- package/test/dblab.test.ts +0 -488
- package/test/e2e/pgai-e2e-smoke.sh +0 -192
package/test/joe.test.ts
CHANGED
|
@@ -1,28 +1,21 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach, spyOn } from "bun:test";
|
|
2
|
-
import { mkdtempSync, existsSync, readFileSync } from "fs";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
|
-
import { resolve } from "path";
|
|
5
2
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
getCommandResult,
|
|
3
|
+
startCommand,
|
|
4
|
+
getCommandOutput,
|
|
9
5
|
listProjects,
|
|
10
|
-
|
|
6
|
+
resolveJoeInstanceId,
|
|
11
7
|
isNumericProjectRef,
|
|
12
|
-
|
|
8
|
+
buildJoeCommandText,
|
|
13
9
|
runCommand,
|
|
14
10
|
executeJoeCommand,
|
|
15
|
-
readStoredSessionId,
|
|
16
|
-
writeStoredSessionId,
|
|
17
|
-
clearStoredSessionId,
|
|
18
11
|
clientSidePlanFlags,
|
|
19
12
|
formatProjectsTable,
|
|
20
|
-
|
|
13
|
+
formatJoeOutput,
|
|
21
14
|
JOE_COMMANDS,
|
|
15
|
+
DESCRIBE_VARIANTS,
|
|
22
16
|
type JoeCommand,
|
|
23
|
-
type
|
|
17
|
+
type JoeCommandOutput,
|
|
24
18
|
} from "../lib/joe";
|
|
25
|
-
import { handleToolCall, joeToolDefinitions, sanitizeBudgetMs, JOE_TOOL_TO_COMMAND, type McpToolRequest } from "../lib/mcp-server";
|
|
26
19
|
|
|
27
20
|
const BASE = "https://api.example.com";
|
|
28
21
|
const originalFetch = globalThis.fetch;
|
|
@@ -54,136 +47,203 @@ function json(obj: unknown, status = 200): Response {
|
|
|
54
47
|
return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } });
|
|
55
48
|
}
|
|
56
49
|
|
|
57
|
-
function tempDir(): string {
|
|
58
|
-
return mkdtempSync(resolve(tmpdir(), "joe-test-"));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
50
|
afterEach(() => {
|
|
62
51
|
globalThis.fetch = originalFetch;
|
|
63
52
|
});
|
|
64
53
|
|
|
65
|
-
describe("
|
|
54
|
+
describe("startCommand (joe_command_run)", () => {
|
|
66
55
|
test("throws when apiKey missing", async () => {
|
|
67
56
|
await expect(
|
|
68
|
-
|
|
57
|
+
startCommand({ apiKey: "", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
|
|
69
58
|
).rejects.toThrow("API key is required");
|
|
70
59
|
});
|
|
71
60
|
|
|
72
|
-
test("
|
|
73
|
-
const captured = installFetch({
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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({
|
|
77
72
|
apiKey: "k",
|
|
78
73
|
apiBaseUrl: BASE,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
sql: "select 1",
|
|
82
|
-
sessionId: "88",
|
|
83
|
-
idempotencyKey: "idem-1",
|
|
74
|
+
instanceId: 3,
|
|
75
|
+
command: "plan select 1",
|
|
84
76
|
});
|
|
85
|
-
expect(captured[0].url).toBe(`${BASE}/rpc/
|
|
77
|
+
expect(captured[0].url).toBe(`${BASE}/rpc/joe_command_run`);
|
|
86
78
|
expect(captured[0].headers["access-token"]).toBe("k");
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
sql: "select 1",
|
|
91
|
-
args: null,
|
|
92
|
-
session_id: "88",
|
|
93
|
-
idempotency_key: "idem-1",
|
|
94
|
-
});
|
|
95
|
-
expect(res).toEqual({ command_id: "4711", session_id: "88", status: "queued" });
|
|
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");
|
|
96
82
|
});
|
|
97
83
|
|
|
98
|
-
test("
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
});
|
|
102
|
-
await
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
command: "terminate",
|
|
106
|
-
projectId: 5,
|
|
107
|
-
args: { pid: 4711 },
|
|
108
|
-
idempotencyKey: "x",
|
|
109
|
-
});
|
|
110
|
-
expect(captured[0].body.sql).toBeNull();
|
|
111
|
-
expect(captured[0].body.session_id).toBeNull();
|
|
112
|
-
expect(captured[0].body.args).toEqual({ pid: 4711 });
|
|
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");
|
|
113
91
|
});
|
|
114
92
|
|
|
115
|
-
test("
|
|
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 () => {
|
|
116
131
|
installFetch({
|
|
117
|
-
|
|
118
|
-
json({
|
|
132
|
+
joe_command_run: () =>
|
|
133
|
+
json({ code: "PT403", message: "Forbidden", details: "Joe API v2 requires the All Features role." }, 403),
|
|
119
134
|
});
|
|
120
135
|
await expect(
|
|
121
|
-
|
|
122
|
-
).rejects.toThrow(/
|
|
136
|
+
startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
|
|
137
|
+
).rejects.toThrow(/Joe API v2 requires the All Features role/);
|
|
123
138
|
});
|
|
124
139
|
|
|
125
|
-
test("surfaces a
|
|
140
|
+
test("surfaces a PT403 reason phrase when PostgREST returns no JSON body", async () => {
|
|
126
141
|
installFetch({
|
|
127
|
-
|
|
142
|
+
joe_command_run: () => new Response(null, {
|
|
143
|
+
status: 403,
|
|
144
|
+
statusText: "Joe API v2 requires the All Features role.",
|
|
145
|
+
}),
|
|
128
146
|
});
|
|
129
147
|
await expect(
|
|
130
|
-
|
|
131
|
-
).rejects.toThrow(
|
|
148
|
+
startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
|
|
149
|
+
).rejects.toThrow("Joe API v2 requires the All Features role.");
|
|
132
150
|
});
|
|
133
|
-
});
|
|
134
151
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 }),
|
|
139
157
|
});
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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");
|
|
144
167
|
});
|
|
168
|
+
});
|
|
145
169
|
|
|
146
|
-
|
|
170
|
+
describe("getCommandOutput (joe_command_output)", () => {
|
|
171
|
+
test("maps to /rpc/joe_command_output with {command_id} and returns the FULL body", async () => {
|
|
147
172
|
const captured = installFetch({
|
|
148
|
-
|
|
173
|
+
joe_command_output: () =>
|
|
149
174
|
json({
|
|
150
175
|
command_id: "4711",
|
|
151
|
-
status: "
|
|
152
|
-
|
|
153
|
-
|
|
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,
|
|
154
182
|
plan_text: "Seq Scan on users",
|
|
155
|
-
plan_json: { Plan: { "Node Type": "Seq Scan", "Relation Name": "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",
|
|
156
188
|
error: null,
|
|
157
189
|
}),
|
|
158
190
|
});
|
|
159
|
-
const
|
|
160
|
-
expect(captured[0].url).toBe(`${BASE}/rpc/
|
|
161
|
-
expect(
|
|
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();
|
|
162
209
|
});
|
|
163
210
|
|
|
164
|
-
test("PT404 (not found /
|
|
211
|
+
test("PT404 (not found / other org) surfaces as an error", async () => {
|
|
165
212
|
installFetch({
|
|
166
|
-
|
|
213
|
+
joe_command_output: () => json({ code: "PT404", message: "Not found", details: "Specified command not found." }, 404),
|
|
167
214
|
});
|
|
168
215
|
await expect(
|
|
169
|
-
|
|
170
|
-
).rejects.toThrow(/Failed to fetch command
|
|
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/);
|
|
171
231
|
});
|
|
172
232
|
});
|
|
173
233
|
|
|
174
234
|
describe("listProjects", () => {
|
|
175
|
-
test("normalizes
|
|
235
|
+
test("normalizes rows and defaults missing optional fields", async () => {
|
|
176
236
|
installFetch({
|
|
177
237
|
projects_list: () =>
|
|
178
238
|
json([
|
|
179
|
-
{
|
|
239
|
+
{ project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3, dblab_instance_id: 7 },
|
|
180
240
|
{ project_id: 13, alias: "dw", name: "Warehouse", joe_ready: false, tunnel: false },
|
|
181
241
|
]),
|
|
182
242
|
});
|
|
183
243
|
const projects = await listProjects({ apiKey: "k", apiBaseUrl: BASE });
|
|
184
244
|
expect(projects).toEqual([
|
|
185
|
-
{ project_id: 12, alias: "main-db", name: "Main DB",
|
|
186
|
-
{ project_id: 13, alias: "dw", name: "Warehouse",
|
|
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 },
|
|
187
247
|
]);
|
|
188
248
|
});
|
|
189
249
|
|
|
@@ -192,9 +252,29 @@ describe("listProjects", () => {
|
|
|
192
252
|
await listProjects({ apiKey: "k", apiBaseUrl: BASE, orgId: 7 });
|
|
193
253
|
expect(captured[0].body).toEqual({ org_id: 7 });
|
|
194
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
|
+
});
|
|
195
270
|
});
|
|
196
271
|
|
|
197
|
-
describe("
|
|
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
|
+
|
|
198
278
|
test("isNumericProjectRef distinguishes ids from aliases", () => {
|
|
199
279
|
expect(isNumericProjectRef("12")).toBe(true);
|
|
200
280
|
expect(isNumericProjectRef(" 12 ")).toBe(true);
|
|
@@ -202,329 +282,459 @@ describe("resolveProjectId (id-or-alias)", () => {
|
|
|
202
282
|
expect(isNumericProjectRef("12a")).toBe(false);
|
|
203
283
|
});
|
|
204
284
|
|
|
205
|
-
test("numeric
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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);
|
|
210
292
|
});
|
|
211
293
|
|
|
212
|
-
test("alias
|
|
213
|
-
installFetch({
|
|
214
|
-
|
|
215
|
-
});
|
|
216
|
-
const id = await resolveProjectId({ apiKey: "k", apiBaseUrl: BASE, project: "MAIN-DB" });
|
|
217
|
-
expect(id).toBe(12);
|
|
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);
|
|
218
298
|
});
|
|
219
299
|
|
|
220
|
-
test("
|
|
300
|
+
test("a 64-bit instance id survives as a string", async () => {
|
|
221
301
|
installFetch({
|
|
222
|
-
projects_list: () => json([{
|
|
302
|
+
projects_list: () => json([{ project_id: 12, alias: "huge", instance_id: "9007199254740994" }]),
|
|
223
303
|
});
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
expect(byId).toBe(byAlias);
|
|
304
|
+
expect(await resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "huge" }))
|
|
305
|
+
.toBe("9007199254740994");
|
|
227
306
|
});
|
|
228
307
|
|
|
229
|
-
test("unknown
|
|
230
|
-
installFetch({ projects_list: () => json(
|
|
308
|
+
test("unknown ref throws with a 'pgai projects' hint", async () => {
|
|
309
|
+
installFetch({ projects_list: () => json(PROJECTS) });
|
|
231
310
|
await expect(
|
|
232
|
-
|
|
233
|
-
).rejects.toThrow(/Project not found for alias\/name 'nope'/);
|
|
311
|
+
resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "nope" })
|
|
312
|
+
).rejects.toThrow(/Project not found for id\/alias\/name 'nope'/);
|
|
234
313
|
});
|
|
235
314
|
|
|
236
|
-
test("
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
projects_list: () =>
|
|
242
|
-
json([{ id: 31, alias: "prod-db", name: "Prod", label: "Production EU", joe_ready: true }]),
|
|
243
|
-
});
|
|
244
|
-
const id = await resolveProjectId({ apiKey: "k", apiBaseUrl: BASE, project: "production eu" });
|
|
245
|
-
expect(id).toBe(31);
|
|
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/);
|
|
246
320
|
});
|
|
247
321
|
|
|
248
|
-
test("empty project ref throws", async () => {
|
|
322
|
+
test("empty project ref throws before any network call", async () => {
|
|
323
|
+
const captured = installFetch({ projects_list: () => json(PROJECTS) });
|
|
249
324
|
await expect(
|
|
250
|
-
|
|
325
|
+
resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "" })
|
|
251
326
|
).rejects.toThrow(/project is required/);
|
|
327
|
+
expect(captured.length).toBe(0);
|
|
252
328
|
});
|
|
253
329
|
});
|
|
254
330
|
|
|
255
|
-
describe("
|
|
256
|
-
test("
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
expect(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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");
|
|
263
355
|
});
|
|
264
356
|
|
|
265
|
-
test("
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
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);
|
|
269
375
|
});
|
|
270
376
|
});
|
|
271
377
|
|
|
272
|
-
describe("runCommand —
|
|
273
|
-
test("polls until terminal
|
|
274
|
-
let
|
|
275
|
-
installFetch({
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 });
|
|
280
389
|
},
|
|
281
|
-
joe_command_result: () =>
|
|
282
|
-
json({ command_id: "4711", status: "done", plan_text: "Index Scan", plan_json: {}, error: null }),
|
|
283
390
|
});
|
|
284
391
|
const outcome = await runCommand({
|
|
285
392
|
apiKey: "k",
|
|
286
393
|
apiBaseUrl: BASE,
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
sql: "select 1",
|
|
394
|
+
instanceId: 3,
|
|
395
|
+
command: "plan select 1",
|
|
290
396
|
pollIntervalMs: 0,
|
|
291
397
|
sleep: async () => {},
|
|
292
398
|
});
|
|
293
|
-
expect(outcome.status).toBe("
|
|
399
|
+
expect(outcome.status).toBe("ok");
|
|
294
400
|
expect(outcome.budgetExpired).toBe(false);
|
|
295
401
|
expect(outcome.commandId).toBe("4711");
|
|
296
|
-
expect(outcome.
|
|
297
|
-
expect(
|
|
298
|
-
|
|
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
|
+
}
|
|
299
408
|
});
|
|
300
409
|
|
|
301
|
-
test("budget expiry returns a resume handle without
|
|
410
|
+
test("budget expiry returns a resume handle without an output", async () => {
|
|
302
411
|
installFetch({
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
joe_command_result: () => json({ command_id: "4712", status: "done", error: null }),
|
|
412
|
+
joe_command_run: () => json("4712"),
|
|
413
|
+
joe_command_output: () => json({ command_id: "4712", status: "pending" }),
|
|
306
414
|
});
|
|
307
415
|
let clock = 1000;
|
|
308
416
|
const outcome = await runCommand({
|
|
309
417
|
apiKey: "k",
|
|
310
418
|
apiBaseUrl: BASE,
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
sql: "select 1",
|
|
419
|
+
instanceId: 3,
|
|
420
|
+
command: "plan select 1",
|
|
314
421
|
budgetMs: 5,
|
|
315
422
|
pollIntervalMs: 0,
|
|
316
423
|
now: () => (clock += 10),
|
|
317
424
|
sleep: async () => {},
|
|
318
425
|
});
|
|
319
426
|
expect(outcome.budgetExpired).toBe(true);
|
|
320
|
-
expect(outcome.status).toBe("
|
|
321
|
-
expect(outcome.
|
|
427
|
+
expect(outcome.status).toBe("pending");
|
|
428
|
+
expect(outcome.output).toBeNull();
|
|
322
429
|
expect(outcome.commandId).toBe("4712");
|
|
323
430
|
});
|
|
324
431
|
|
|
325
|
-
test("
|
|
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 () => {
|
|
326
462
|
installFetch({
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
joe_command_result: () => json({ command_id: "5", status: "error", error: "boom", plan_json: {} }),
|
|
463
|
+
joe_command_run: () => json("5"),
|
|
464
|
+
joe_command_output: () => json({ command_id: "5", status: "error", error: "ERROR: relation \"nope\" does not exist" }),
|
|
330
465
|
});
|
|
331
466
|
const outcome = await runCommand({
|
|
332
467
|
apiKey: "k",
|
|
333
468
|
apiBaseUrl: BASE,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
sql: "select 1",
|
|
469
|
+
instanceId: 3,
|
|
470
|
+
command: "explain select * from nope",
|
|
337
471
|
pollIntervalMs: 0,
|
|
338
472
|
sleep: async () => {},
|
|
339
473
|
});
|
|
340
474
|
expect(outcome.status).toBe("error");
|
|
341
|
-
expect(outcome.
|
|
475
|
+
expect(outcome.budgetExpired).toBe(false);
|
|
476
|
+
expect(outcome.output?.error).toContain("does not exist");
|
|
342
477
|
});
|
|
343
478
|
|
|
344
|
-
test("a
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
// a regression fails fast instead of hanging the test runner.
|
|
349
|
-
let statusCalls = 0;
|
|
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;
|
|
350
483
|
installFetch({
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (
|
|
355
|
-
|
|
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 });
|
|
356
489
|
}
|
|
357
|
-
return json({ command_id: "
|
|
490
|
+
return json({ command_id: "4714", status: "ok", plan_text: "Index Scan", error: null });
|
|
358
491
|
},
|
|
359
492
|
});
|
|
360
|
-
let clock = 0;
|
|
361
493
|
const outcome = await runCommand({
|
|
362
494
|
apiKey: "k",
|
|
363
495
|
apiBaseUrl: BASE,
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
sql: "select 1",
|
|
367
|
-
budgetMs: Number.NaN,
|
|
496
|
+
instanceId: 3,
|
|
497
|
+
command: "plan select 1",
|
|
368
498
|
pollIntervalMs: 0,
|
|
369
|
-
now: () => (clock += 1000), // 1 s per observation → passes the 25 s default budget in <30 polls
|
|
370
499
|
sleep: async () => {},
|
|
371
500
|
});
|
|
372
|
-
expect(outcome.
|
|
373
|
-
expect(
|
|
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);
|
|
374
505
|
});
|
|
375
506
|
|
|
376
|
-
test("
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
joe_command_result: () => json({ command_id: "6", status: "done", row_count: 0, notices: ["CREATE INDEX"], error: null }),
|
|
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 }),
|
|
381
511
|
});
|
|
382
|
-
|
|
512
|
+
let clock = 0;
|
|
513
|
+
const outcome = await runCommand({
|
|
383
514
|
apiKey: "k",
|
|
384
515
|
apiBaseUrl: BASE,
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
516
|
+
instanceId: 3,
|
|
517
|
+
command: "plan select 1",
|
|
518
|
+
budgetMs: 25,
|
|
388
519
|
pollIntervalMs: 0,
|
|
520
|
+
now: () => (clock += 10),
|
|
389
521
|
sleep: async () => {},
|
|
390
522
|
});
|
|
391
|
-
|
|
392
|
-
|
|
523
|
+
expect(outcome).toMatchObject({
|
|
524
|
+
commandId: "4715",
|
|
525
|
+
status: "pending",
|
|
526
|
+
output: null,
|
|
527
|
+
budgetExpired: true,
|
|
528
|
+
});
|
|
393
529
|
});
|
|
394
|
-
});
|
|
395
530
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const captured = installFetch({
|
|
401
|
-
joe_command_submit: () => json({ command_id: "1", session_id: "88", status: "queued" }),
|
|
402
|
-
joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
|
|
403
|
-
joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
|
|
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),
|
|
404
535
|
});
|
|
405
|
-
|
|
536
|
+
let clock = 0;
|
|
537
|
+
const outcome = await runCommand({
|
|
406
538
|
apiKey: "k",
|
|
407
539
|
apiBaseUrl: BASE,
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
sessionDir: dir,
|
|
540
|
+
instanceId: 3,
|
|
541
|
+
command: "plan select 1",
|
|
542
|
+
budgetMs: 25,
|
|
412
543
|
pollIntervalMs: 0,
|
|
544
|
+
now: () => (clock += 10),
|
|
413
545
|
sleep: async () => {},
|
|
414
546
|
});
|
|
415
|
-
expect(
|
|
547
|
+
expect(outcome).toMatchObject({
|
|
548
|
+
commandId: "4716",
|
|
549
|
+
status: "pending",
|
|
550
|
+
output: null,
|
|
551
|
+
budgetExpired: true,
|
|
552
|
+
});
|
|
416
553
|
});
|
|
417
554
|
|
|
418
|
-
test("
|
|
419
|
-
const dir = tempDir();
|
|
555
|
+
test("a terminal 404 mid-poll DOES abort (polling on cannot succeed)", async () => {
|
|
420
556
|
installFetch({
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
|
|
557
|
+
joe_command_run: () => json("4717"),
|
|
558
|
+
joe_command_output: () => json({ code: "PT404", message: "Not found", details: "Specified command not found." }, 404),
|
|
424
559
|
});
|
|
425
|
-
await
|
|
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({
|
|
426
590
|
apiKey: "k",
|
|
427
591
|
apiBaseUrl: BASE,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
sessionDir: dir,
|
|
592
|
+
instanceId: 3,
|
|
593
|
+
command: "plan select 1",
|
|
594
|
+
budgetMs: Number.NaN,
|
|
432
595
|
pollIntervalMs: 0,
|
|
596
|
+
now: () => (clock += 1000), // 1 s per observation → passes the 25 s default budget in <30 polls
|
|
433
597
|
sleep: async () => {},
|
|
434
598
|
});
|
|
435
|
-
expect(
|
|
599
|
+
expect(outcome.budgetExpired).toBe(true);
|
|
600
|
+
expect(outputCalls).toBeLessThanOrEqual(60);
|
|
436
601
|
});
|
|
602
|
+
});
|
|
437
603
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
writeStoredSessionId(12, "88", dir);
|
|
604
|
+
describe("executeJoeCommand — target → build → run", () => {
|
|
605
|
+
test("resolves an alias to the project's instance_id and sends the raw text", async () => {
|
|
441
606
|
const captured = installFetch({
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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 }),
|
|
445
610
|
});
|
|
446
|
-
await executeJoeCommand({
|
|
611
|
+
const outcome = await executeJoeCommand({
|
|
447
612
|
apiKey: "k",
|
|
448
613
|
apiBaseUrl: BASE,
|
|
449
614
|
command: "plan",
|
|
450
|
-
project: "
|
|
451
|
-
|
|
452
|
-
newSession: true,
|
|
453
|
-
sessionDir: dir,
|
|
615
|
+
project: "main-db",
|
|
616
|
+
input: { arg: "select 1" },
|
|
454
617
|
pollIntervalMs: 0,
|
|
455
618
|
sleep: async () => {},
|
|
456
619
|
});
|
|
457
|
-
expect(
|
|
458
|
-
|
|
459
|
-
expect(
|
|
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" });
|
|
460
625
|
});
|
|
461
626
|
|
|
462
|
-
test("
|
|
463
|
-
const dir = tempDir();
|
|
464
|
-
writeStoredSessionId(12, "88", dir);
|
|
627
|
+
test("a bad verb argument fails BEFORE any network call", async () => {
|
|
465
628
|
const captured = installFetch({
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
|
|
629
|
+
projects_list: () => json([]),
|
|
630
|
+
joe_command_run: () => json("1"),
|
|
469
631
|
});
|
|
470
|
-
await
|
|
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({
|
|
471
653
|
apiKey: "k",
|
|
472
654
|
apiBaseUrl: BASE,
|
|
473
|
-
command: "
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
session: "77",
|
|
477
|
-
sessionDir: dir,
|
|
655
|
+
command: "explain",
|
|
656
|
+
instanceId: "9007199254740994001",
|
|
657
|
+
input: { arg: "select 1" },
|
|
478
658
|
pollIntervalMs: 0,
|
|
479
659
|
sleep: async () => {},
|
|
480
660
|
});
|
|
481
|
-
expect(
|
|
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" });
|
|
482
666
|
});
|
|
483
667
|
|
|
484
|
-
test("
|
|
485
|
-
const dir = tempDir();
|
|
668
|
+
test("instanceId wins over project when both are given (no projects_list call)", async () => {
|
|
486
669
|
const captured = installFetch({
|
|
487
|
-
projects_list: () => json([{
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
|
|
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 }),
|
|
491
673
|
});
|
|
492
|
-
|
|
674
|
+
await executeJoeCommand({
|
|
493
675
|
apiKey: "k",
|
|
494
676
|
apiBaseUrl: BASE,
|
|
495
|
-
command: "
|
|
677
|
+
command: "reset",
|
|
496
678
|
project: "main-db",
|
|
497
|
-
|
|
498
|
-
sessionDir: dir,
|
|
679
|
+
instanceId: "7",
|
|
499
680
|
pollIntervalMs: 0,
|
|
500
681
|
sleep: async () => {},
|
|
501
682
|
});
|
|
502
|
-
expect(
|
|
503
|
-
const
|
|
504
|
-
expect(
|
|
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");
|
|
505
686
|
});
|
|
506
|
-
});
|
|
507
687
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
expect(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
+
});
|
|
528
738
|
});
|
|
529
739
|
});
|
|
530
740
|
|
|
@@ -540,319 +750,106 @@ describe("presentation helpers", () => {
|
|
|
540
750
|
expect(flags[0]).toContain("Seq Scan on users");
|
|
541
751
|
});
|
|
542
752
|
|
|
543
|
-
test("
|
|
544
|
-
|
|
545
|
-
|
|
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" } },
|
|
546
758
|
]);
|
|
547
|
-
|
|
548
|
-
expect(
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
expect(
|
|
553
|
-
expect(
|
|
554
|
-
expect(
|
|
555
|
-
expect(
|
|
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);
|
|
556
776
|
});
|
|
557
777
|
|
|
558
|
-
test("
|
|
559
|
-
const
|
|
778
|
+
test("formatJoeOutput prints every present section of the uniform row", () => {
|
|
779
|
+
const output: JoeCommandOutput = {
|
|
560
780
|
command_id: "1",
|
|
561
|
-
status: "
|
|
562
|
-
error: null,
|
|
781
|
+
status: "ok",
|
|
563
782
|
plan_text: "Seq Scan on users",
|
|
564
|
-
plan_json: { Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } },
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
expect(plan).toContain("⚑");
|
|
570
|
-
|
|
571
|
-
const exec = formatJoeResult("exec", {
|
|
572
|
-
command_id: "2",
|
|
573
|
-
status: "done",
|
|
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",
|
|
574
788
|
error: null,
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
expect(
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
pid: 4711,
|
|
586
|
-
});
|
|
587
|
-
expect(terminate).toContain("pid 4711");
|
|
588
|
-
});
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
describe("MCP tools", () => {
|
|
592
|
-
const ROOT = { apiKey: "k", apiBaseUrl: BASE };
|
|
593
|
-
const originalXdg = process.env.XDG_CONFIG_HOME;
|
|
594
|
-
|
|
595
|
-
function makeReq(name: string, args?: Record<string, unknown>): McpToolRequest {
|
|
596
|
-
return { params: { name, arguments: args } };
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
afterEach(() => {
|
|
600
|
-
globalThis.fetch = originalFetch;
|
|
601
|
-
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
602
|
-
else process.env.XDG_CONFIG_HOME = originalXdg;
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
test("joeToolDefinitions exposes every SPEC §5 name + list_projects", () => {
|
|
606
|
-
const names = joeToolDefinitions().map((t) => t.name);
|
|
607
|
-
expect(names).toEqual([
|
|
608
|
-
"plan_query",
|
|
609
|
-
"explain_query",
|
|
610
|
-
"exec_sql",
|
|
611
|
-
"hypo_index",
|
|
612
|
-
"activity",
|
|
613
|
-
"terminate_backend",
|
|
614
|
-
"reset_clone",
|
|
615
|
-
"describe",
|
|
616
|
-
"list_projects",
|
|
617
|
-
]);
|
|
618
|
-
// Tool-name -> command map covers the 8 command tools.
|
|
619
|
-
expect(Object.keys(JOE_TOOL_TO_COMMAND).length).toBe(8);
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
test("exec_sql carries the stronger raw-data warning; plan/explain note the plan egress", () => {
|
|
623
|
-
const defs = joeToolDefinitions();
|
|
624
|
-
const exec = defs.find((t) => t.name === "exec_sql");
|
|
625
|
-
expect(exec?.description).toMatch(/REAL .*TABLE ROWS/i);
|
|
626
|
-
expect(exec?.description).toContain("LLM context");
|
|
627
|
-
const plan = defs.find((t) => t.name === "plan_query");
|
|
628
|
-
expect(plan?.description).toMatch(/plan-only/i);
|
|
629
|
-
expect(plan?.description).toMatch(/NEVER executes/i);
|
|
630
|
-
const explain = defs.find((t) => t.name === "explain_query");
|
|
631
|
-
expect(explain?.description).toMatch(/EXECUTES/);
|
|
632
|
-
expect(explain?.description).toMatch(/NOT raw SELECT result rows/i);
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
test("plan_query calls executeJoeCommand and returns the plan result", () => {
|
|
636
|
-
process.env.XDG_CONFIG_HOME = tempDir();
|
|
637
|
-
const captured = installFetch({
|
|
638
|
-
joe_command_submit: () => json({ command_id: "4711", session_id: "88", status: "queued" }),
|
|
639
|
-
joe_command_status: () => json({ command_id: "4711", status: "done", error: null }),
|
|
640
|
-
joe_command_result: () =>
|
|
641
|
-
json({ command_id: "4711", status: "done", plan_text: "Seq Scan on users", plan_json: {}, error: null }),
|
|
642
|
-
});
|
|
643
|
-
return handleToolCall(makeReq("plan_query", { sql: "select 1", project_id: "12" }), ROOT).then((res) => {
|
|
644
|
-
expect(res.isError).toBeFalsy();
|
|
645
|
-
const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
|
|
646
|
-
expect(submit?.body.command).toBe("plan");
|
|
647
|
-
expect(submit?.body.project_id).toBe(12);
|
|
648
|
-
const payload = JSON.parse(res.content[0].text) as { status: string; result: { plan_text: string } };
|
|
649
|
-
expect(payload.status).toBe("done");
|
|
650
|
-
expect(payload.result.plan_text).toBe("Seq Scan on users");
|
|
651
|
-
});
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
test("exec_sql maps sql; requires it", async () => {
|
|
655
|
-
process.env.XDG_CONFIG_HOME = tempDir();
|
|
656
|
-
const captured = installFetch({
|
|
657
|
-
joe_command_submit: () => json({ command_id: "1", session_id: "88", status: "queued" }),
|
|
658
|
-
joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
|
|
659
|
-
joe_command_result: () => json({ command_id: "1", command: "exec", status: "done", row_count: 0, notices: ["CREATE INDEX"], error: null }),
|
|
660
|
-
});
|
|
661
|
-
const ok = await handleToolCall(makeReq("exec_sql", { sql: "create index on users (email)", project_id: "12" }), ROOT);
|
|
662
|
-
expect(ok.isError).toBeFalsy();
|
|
663
|
-
const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
|
|
664
|
-
expect(submit?.body.command).toBe("exec");
|
|
665
|
-
|
|
666
|
-
const missing = await handleToolCall(makeReq("exec_sql", { project_id: "12" }), ROOT);
|
|
667
|
-
expect(missing.isError).toBe(true);
|
|
668
|
-
expect(missing.content[0].text).toContain("sql is required");
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
test("hypo_index requires query and maps args.query", async () => {
|
|
672
|
-
process.env.XDG_CONFIG_HOME = tempDir();
|
|
673
|
-
const captured = installFetch({
|
|
674
|
-
joe_command_submit: () => json({ command_id: "1", session_id: "88", status: "queued" }),
|
|
675
|
-
joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
|
|
676
|
-
joe_command_result: () => json({ command_id: "1", status: "done", hypo_used: true, hypo_plan: {}, error: null }),
|
|
677
|
-
});
|
|
678
|
-
const missing = await handleToolCall(makeReq("hypo_index", { sql: "create index on orders (customer_id)", project_id: "12" }), ROOT);
|
|
679
|
-
expect(missing.isError).toBe(true);
|
|
680
|
-
expect(missing.content[0].text).toContain("query is required");
|
|
681
|
-
|
|
682
|
-
const ok = await handleToolCall(
|
|
683
|
-
makeReq("hypo_index", { sql: "create index on orders (customer_id)", query: "select 1", project_id: "12" }),
|
|
684
|
-
ROOT
|
|
685
|
-
);
|
|
686
|
-
expect(ok.isError).toBeFalsy();
|
|
687
|
-
const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
|
|
688
|
-
expect(submit?.body.command).toBe("hypo");
|
|
689
|
-
expect(submit?.body.args).toEqual({ query: "select 1" });
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
test("terminate_backend maps args.pid; describe maps args.object", async () => {
|
|
693
|
-
process.env.XDG_CONFIG_HOME = tempDir();
|
|
694
|
-
const captured = installFetch({
|
|
695
|
-
joe_command_submit: () => json({ command_id: "1", session_id: null, status: "queued" }),
|
|
696
|
-
joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
|
|
697
|
-
joe_command_result: () => json({ command_id: "1", status: "done", terminated: true, pid: 4711, snapshot: {}, error: null }),
|
|
698
|
-
});
|
|
699
|
-
await handleToolCall(makeReq("terminate_backend", { pid: 4711, project_id: "12" }), ROOT);
|
|
700
|
-
let submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
|
|
701
|
-
expect(submit?.body.command).toBe("terminate");
|
|
702
|
-
expect(submit?.body.args).toEqual({ pid: 4711 });
|
|
703
|
-
|
|
704
|
-
captured.length = 0;
|
|
705
|
-
await handleToolCall(makeReq("describe", { object: "users", variant: "\\d+", project_id: "12" }), ROOT);
|
|
706
|
-
submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
|
|
707
|
-
expect(submit?.body.command).toBe("describe");
|
|
708
|
-
expect(submit?.body.args).toEqual({ object: "users", variant: "\\d+" });
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
test("terminate_backend rejects a non-integer / non-positive pid (no submit)", async () => {
|
|
712
|
-
// The CLI requires a bare-digits pid (^[0-9]+$); the MCP path must be as
|
|
713
|
-
// strict — `Number.isFinite` alone lets -5 / 1.5 / 0 through to a
|
|
714
|
-
// pg_terminate_backend against the wrong (or no) backend.
|
|
715
|
-
process.env.XDG_CONFIG_HOME = tempDir();
|
|
716
|
-
const captured = installFetch({
|
|
717
|
-
joe_command_submit: () => json({ command_id: "1", session_id: null, status: "queued" }),
|
|
718
|
-
joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
|
|
719
|
-
joe_command_result: () => json({ command_id: "1", status: "done", terminated: true, pid: 4711, error: null }),
|
|
720
|
-
});
|
|
721
|
-
for (const bad of [-5, 1.5, 0, "12x", "1.5", "-5", "abc", Number.NaN]) {
|
|
722
|
-
const res = await handleToolCall(makeReq("terminate_backend", { pid: bad, project_id: "12" }), ROOT);
|
|
723
|
-
expect(res.isError).toBe(true);
|
|
724
|
-
expect(res.content[0].text).toContain("pid");
|
|
725
|
-
}
|
|
726
|
-
expect(captured.some((c) => c.url.endsWith("/rpc/joe_command_submit"))).toBe(false);
|
|
727
|
-
|
|
728
|
-
// A strict positive integer — as a number or a bare-digits string — still submits.
|
|
729
|
-
const ok = await handleToolCall(makeReq("terminate_backend", { pid: "4711", project_id: "12" }), ROOT);
|
|
730
|
-
expect(ok.isError).toBeFalsy();
|
|
731
|
-
const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
|
|
732
|
-
expect(submit?.body.args).toEqual({ pid: 4711 });
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
test("list_projects tool returns the normalized projects", async () => {
|
|
736
|
-
process.env.XDG_CONFIG_HOME = tempDir();
|
|
737
|
-
installFetch({
|
|
738
|
-
projects_list: () => json([{ id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true }]),
|
|
739
|
-
});
|
|
740
|
-
const res = await handleToolCall(makeReq("list_projects", {}), ROOT);
|
|
741
|
-
expect(res.isError).toBeFalsy();
|
|
742
|
-
const projects = JSON.parse(res.content[0].text) as Array<{ project_id: number; alias: string }>;
|
|
743
|
-
expect(projects[0].project_id).toBe(12);
|
|
744
|
-
expect(projects[0].alias).toBe("main-db");
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
test("resolves an alias project_id before submitting", async () => {
|
|
748
|
-
process.env.XDG_CONFIG_HOME = tempDir();
|
|
749
|
-
const captured = installFetch({
|
|
750
|
-
projects_list: () => json([{ id: 12, alias: "main-db", name: "Main DB", joe_ready: true }]),
|
|
751
|
-
joe_command_submit: () => json({ command_id: "1", session_id: "88", status: "queued" }),
|
|
752
|
-
joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
|
|
753
|
-
joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
|
|
754
|
-
});
|
|
755
|
-
await handleToolCall(makeReq("plan_query", { sql: "select 1", project_id: "main-db" }), ROOT);
|
|
756
|
-
const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
|
|
757
|
-
expect(submit?.body.project_id).toBe(12);
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
test("ai_enabled=false PT403 surfaces as an MCP error", async () => {
|
|
761
|
-
process.env.XDG_CONFIG_HOME = tempDir();
|
|
762
|
-
installFetch({
|
|
763
|
-
joe_command_submit: () =>
|
|
764
|
-
json({ message: "AI features disabled for this org — enable in AI Assistant Settings" }, 403),
|
|
765
|
-
});
|
|
766
|
-
const res = await handleToolCall(makeReq("plan_query", { sql: "select 1", project_id: "12" }), ROOT);
|
|
767
|
-
expect(res.isError).toBe(true);
|
|
768
|
-
expect(res.content[0].text).toContain("AI features disabled for this org");
|
|
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)");
|
|
769
799
|
});
|
|
770
800
|
|
|
771
|
-
test("
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
expect(res.isError).toBe(true);
|
|
775
|
-
expect(res.content[0].text).toContain("project_id is required");
|
|
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");
|
|
776
804
|
});
|
|
777
805
|
|
|
778
|
-
test("
|
|
779
|
-
|
|
780
|
-
// NaN would survive `?? DEFAULT_BUDGET_MS` and make the poll loop unbounded.
|
|
781
|
-
expect(sanitizeBudgetMs(undefined)).toBeUndefined();
|
|
782
|
-
expect(sanitizeBudgetMs(null)).toBeUndefined();
|
|
783
|
-
expect(sanitizeBudgetMs("abc")).toBeUndefined();
|
|
784
|
-
expect(sanitizeBudgetMs(Number.NaN)).toBeUndefined();
|
|
785
|
-
expect(sanitizeBudgetMs(Infinity)).toBeUndefined();
|
|
786
|
-
expect(sanitizeBudgetMs({})).toBeUndefined();
|
|
787
|
-
expect(sanitizeBudgetMs(5000)).toBe(5000);
|
|
788
|
-
expect(sanitizeBudgetMs("5000")).toBe(5000);
|
|
789
|
-
expect(sanitizeBudgetMs(0)).toBe(0);
|
|
806
|
+
test("formatJoeOutput renders nothing for an empty row", () => {
|
|
807
|
+
expect(formatJoeOutput({ command_id: "3", status: "ok", error: null })).toBe("");
|
|
790
808
|
});
|
|
791
809
|
|
|
792
|
-
test("
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
);
|
|
802
|
-
|
|
803
|
-
expect(
|
|
804
|
-
|
|
805
|
-
budget_expired: boolean;
|
|
806
|
-
resume?: string;
|
|
807
|
-
command_id: string;
|
|
808
|
-
};
|
|
809
|
-
expect(payload.budget_expired).toBe(true);
|
|
810
|
-
expect(payload.resume).toBe("pgai joe result 31");
|
|
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");
|
|
811
823
|
});
|
|
812
824
|
});
|
|
813
825
|
|
|
814
826
|
describe("--debug credential redaction (joe rpc surface)", () => {
|
|
815
827
|
test("response bodies have password-named fields redacted before logging", async () => {
|
|
816
|
-
// `--debug`
|
|
817
|
-
//
|
|
818
|
-
// same way the access-token header already is.
|
|
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.
|
|
819
830
|
const spy = spyOn(console, "error").mockImplementation(() => {});
|
|
820
831
|
try {
|
|
821
832
|
installFetch({
|
|
822
|
-
|
|
833
|
+
joe_command_output: () =>
|
|
823
834
|
json({
|
|
824
835
|
command_id: "1",
|
|
825
836
|
command: "exec",
|
|
826
|
-
status: "
|
|
837
|
+
status: "ok",
|
|
827
838
|
error: null,
|
|
828
|
-
|
|
839
|
+
response: "ok",
|
|
840
|
+
stats: null,
|
|
841
|
+
plan_text: null,
|
|
842
|
+
password: "row-secret-xyz",
|
|
829
843
|
}),
|
|
830
844
|
});
|
|
831
|
-
await
|
|
845
|
+
await getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "1", debug: true });
|
|
832
846
|
const logged = spy.mock.calls.map((c) => c.map(String).join(" ")).join("\n");
|
|
833
847
|
expect(logged).toContain("Debug: Response body");
|
|
834
848
|
expect(logged).not.toContain("row-secret-xyz");
|
|
835
849
|
// The rest of the body still logs (redaction, not suppression).
|
|
836
|
-
expect(logged).toContain("
|
|
850
|
+
expect(logged).toContain("command_id");
|
|
837
851
|
} finally {
|
|
838
852
|
spy.mockRestore();
|
|
839
853
|
}
|
|
840
854
|
});
|
|
841
855
|
});
|
|
842
|
-
|
|
843
|
-
describe("command set", () => {
|
|
844
|
-
test("JOE_COMMANDS is the SPEC command set", () => {
|
|
845
|
-
const expected: JoeCommand[] = ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe"];
|
|
846
|
-
expect([...JOE_COMMANDS]).toEqual(expected);
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
test("each command submits with its fixed command name", async () => {
|
|
850
|
-
for (const command of JOE_COMMANDS) {
|
|
851
|
-
const captured = installFetch({
|
|
852
|
-
joe_command_submit: () => json({ command_id: "1", session_id: null, status: "queued" as JoeStatus }),
|
|
853
|
-
});
|
|
854
|
-
await submitCommand({ apiKey: "k", apiBaseUrl: BASE, command, projectId: 12, idempotencyKey: "x" });
|
|
855
|
-
expect(captured[0].body.command).toBe(command);
|
|
856
|
-
}
|
|
857
|
-
});
|
|
858
|
-
});
|