withruntime 0.12.0 → 0.16.0
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/BLAXEL.md +97 -32
- package/COMPATIBILITY.md +120 -5
- package/DAYTONA.md +34 -3
- package/E2B.md +34 -5
- package/README.md +36 -3
- package/README.src.md +35 -2
- package/VERCEL.md +52 -5
- package/compatibility-lock.json +71 -111
- package/dist/billing-cli.js +8 -4
- package/dist/blaxel/client.d.ts +11 -0
- package/dist/blaxel/client.js +28 -0
- package/dist/blaxel/errors.d.ts +65 -0
- package/dist/blaxel/errors.js +114 -0
- package/dist/blaxel/index.d.ts +10 -3
- package/dist/blaxel/index.js +23 -4
- package/dist/blaxel/preview.d.ts +29 -4
- package/dist/blaxel/preview.js +79 -5
- package/dist/blaxel/sandbox.d.ts +1 -1
- package/dist/blaxel/sandbox.js +2 -1
- package/dist/blaxel/session.js +4 -2
- package/dist/cli-extras.js +2 -1
- package/dist/cli-words.js +1 -0
- package/dist/cli.d.ts +17 -2
- package/dist/cli.js +291 -85
- package/dist/client.d.ts +1 -1
- package/dist/credentials.d.ts +3 -0
- package/dist/credentials.js +15 -0
- package/dist/daytona/client.d.ts +4 -0
- package/dist/daytona/client.js +8 -3
- package/dist/daytona/daytona.d.ts +4 -1
- package/dist/daytona/daytona.js +3 -2
- package/dist/e2b/sandbox.d.ts +7 -6
- package/dist/e2b/sandbox.js +9 -12
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/freestyle/index.d.ts +77 -5
- package/dist/freestyle/index.js +380 -48
- package/dist/identity.js +36 -18
- package/dist/index.d.ts +4 -3
- package/dist/machine.d.ts +39 -13
- package/dist/machine.js +72 -25
- package/dist/mcp.js +157 -20
- package/dist/modal/index.d.ts +15 -0
- package/dist/modal/index.js +35 -7
- package/dist/morph/index.d.ts +255 -0
- package/dist/morph/index.js +641 -0
- package/dist/network-cli.js +2 -2
- package/dist/product-names.d.ts +2 -2
- package/dist/product-names.js +2 -2
- package/dist/products/api-keys.d.ts +73 -0
- package/dist/products/api-keys.js +23 -0
- package/dist/products/apps.d.ts +12 -31
- package/dist/products/apps.js +8 -113
- package/dist/products/balancers.d.ts +87 -0
- package/dist/products/balancers.js +8 -0
- package/dist/products/builds.d.ts +30 -0
- package/dist/products/builds.js +6 -0
- package/dist/products/index.d.ts +2 -0
- package/dist/products/index.js +2 -0
- package/dist/products/jobs.d.ts +2 -2
- package/dist/products/jobs.js +1 -1
- package/dist/products/limits.d.ts +16 -2
- package/dist/products/limits.js +2 -2
- package/dist/products/mcp.d.ts +5 -0
- package/dist/products/network-products.d.ts +21 -3
- package/dist/products/network-products.js +3 -1
- package/dist/products/observability.d.ts +7 -2
- package/dist/products/previews.d.ts +6 -3
- package/dist/products/previews.js +1 -1
- package/dist/products/resources.d.ts +54 -0
- package/dist/products/resources.js +122 -0
- package/dist/products/secrets.d.ts +22 -0
- package/dist/products/secrets.js +8 -0
- package/dist/products/volumes.d.ts +3 -0
- package/dist/sandbox.d.ts +4 -0
- package/dist/sandbox.js +27 -2
- package/dist/snapshots.d.ts +4 -0
- package/dist/ssh.d.ts +35 -0
- package/dist/ssh.js +93 -1
- package/dist/transport.d.ts +14 -8
- package/dist/transport.js +70 -15
- package/dist/types.d.ts +33 -5
- package/dist/vercel/command.d.ts +48 -1
- package/dist/vercel/command.js +237 -6
- package/dist/vercel/sandbox-user.js +2 -1
- package/dist/vercel/sandbox.d.ts +17 -3
- package/dist/vercel/sandbox.js +46 -3
- package/dist/wallet.d.ts +4 -1
- package/dist/wallet.js +2 -1
- package/package.json +7 -14
- package/dist/cli/logs.test.d.ts +0 -1
- package/dist/cli/logs.test.js +0 -272
- package/dist/products/logs.test.d.ts +0 -1
- package/dist/products/logs.test.js +0 -343
package/dist/cli/logs.test.js
DELETED
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
import { logsProductCommand } from "./logs.js";
|
|
3
|
-
import { terminalSafe } from "../terminal.js";
|
|
4
|
-
import { logs, traces, alerts } from "../products/logs.js";
|
|
5
|
-
import { Transport } from "../transport.js";
|
|
6
|
-
import { InvalidRequestError } from "../errors.js";
|
|
7
|
-
const input = {
|
|
8
|
-
name: "errors",
|
|
9
|
-
labels: {},
|
|
10
|
-
query: '{app="api"}',
|
|
11
|
-
condition: "above 5",
|
|
12
|
-
for: "5m",
|
|
13
|
-
actions: [],
|
|
14
|
-
};
|
|
15
|
-
const cases = [
|
|
16
|
-
["logs", ["query", "{}"], { from: "1", to: "2", limit: "5" }, "GET", "/v1/logs/query_range"],
|
|
17
|
-
["logs", ["labels"], {}, "GET", "/v1/logs/labels"],
|
|
18
|
-
["logs", ["values", "app"], {}, "GET", "/v1/logs/labels/app/values"],
|
|
19
|
-
["logs", ["ingest"], { body: '{"resourceLogs":[]}' }, "POST", "/v1/logs"],
|
|
20
|
-
["traces", ["ingest"], { body: '{"resourceSpans":[]}' }, "POST", "/v1/traces"],
|
|
21
|
-
["traces", ["get", "id"], {}, "GET", "/v1/traces/id"],
|
|
22
|
-
["traces", ["search", "{}"], {}, "GET", "/v1/traces:search"],
|
|
23
|
-
["traces", ["tags"], {}, "GET", "/v1/traces/tags"],
|
|
24
|
-
["alerts", ["test", "id"], {}, "POST", "/v1/alerts/id/test"],
|
|
25
|
-
[
|
|
26
|
-
"alerts",
|
|
27
|
-
["silence", "id"],
|
|
28
|
-
{ until: "2026-10-07T00:00:00Z", reason: "maintenance" },
|
|
29
|
-
"POST",
|
|
30
|
-
"/v1/alerts/id/silence",
|
|
31
|
-
],
|
|
32
|
-
];
|
|
33
|
-
for (const product of ["logs", "traces", "alerts"])
|
|
34
|
-
for (const verb of ["ls", "get", "create", "update", "delete"]) {
|
|
35
|
-
const collection = `/v1/${product}${product === "alerts" ? "" : "/views"}`;
|
|
36
|
-
cases.push([
|
|
37
|
-
product,
|
|
38
|
-
[
|
|
39
|
-
...(product === "alerts" ? [] : ["views"]),
|
|
40
|
-
verb,
|
|
41
|
-
...(["get", "update", "delete"].includes(verb) ? ["id"] : []),
|
|
42
|
-
],
|
|
43
|
-
["create", "update"].includes(verb) ? { body: JSON.stringify(input) } : {},
|
|
44
|
-
{ ls: "GET", get: "GET", create: "POST", update: "PUT", delete: "DELETE" }[verb],
|
|
45
|
-
collection + (["get", "update", "delete"].includes(verb) ? "/id" : ""),
|
|
46
|
-
]);
|
|
47
|
-
}
|
|
48
|
-
function setup() {
|
|
49
|
-
const requests = [];
|
|
50
|
-
const transport = new Transport({
|
|
51
|
-
apiKey: "fake",
|
|
52
|
-
baseUrl: "https://fake.test",
|
|
53
|
-
maxRetries: 0,
|
|
54
|
-
fetch: (async (url, init) => {
|
|
55
|
-
requests.push({ url: new URL(url instanceof Request ? url.url : url), init: init ?? {} });
|
|
56
|
-
return Response.json({ ...input, id: "id", data: [], nextCursor: null, marker: "ok" });
|
|
57
|
-
}),
|
|
58
|
-
});
|
|
59
|
-
return {
|
|
60
|
-
client: { logs: logs(transport), traces: traces(transport), alerts: alerts(transport) },
|
|
61
|
-
requests,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
for (const [product, positional, flags, method, path] of cases)
|
|
65
|
-
for (const mode of ["tty", "pipe", "json"])
|
|
66
|
-
test(`${product} ${positional.join(" ")} ${mode}`, async () => {
|
|
67
|
-
const { client, requests } = setup(), output = [];
|
|
68
|
-
const result = await logsProductCommand(product, positional, {
|
|
69
|
-
positional,
|
|
70
|
-
flags: new Map(Object.entries({ ...flags, "idempotency-key": "retry-key" }).map(([key, value]) => [
|
|
71
|
-
key,
|
|
72
|
-
[value],
|
|
73
|
-
])),
|
|
74
|
-
}, {
|
|
75
|
-
json: mode === "json",
|
|
76
|
-
tty: mode === "tty",
|
|
77
|
-
write: (text) => output.push(text),
|
|
78
|
-
error: () => {
|
|
79
|
-
throw new Error("unexpected stderr");
|
|
80
|
-
},
|
|
81
|
-
}, async () => client);
|
|
82
|
-
expect(result).toBe(0);
|
|
83
|
-
expect(requests).toHaveLength(1);
|
|
84
|
-
expect(requests[0].url.pathname).toBe(path);
|
|
85
|
-
expect(requests[0].init.method).toBe(method);
|
|
86
|
-
expect(output).toHaveLength(1);
|
|
87
|
-
expect(output[0]).not.toContain("\x1b");
|
|
88
|
-
const printed = JSON.parse(output[0]);
|
|
89
|
-
if (positional.includes("delete"))
|
|
90
|
-
expect(printed).toEqual({ deleted: "id" });
|
|
91
|
-
else if (positional.includes("ls"))
|
|
92
|
-
expect(printed).toEqual({ data: [], nextCursor: null });
|
|
93
|
-
else
|
|
94
|
-
expect(printed.marker).toBe("ok");
|
|
95
|
-
if (method !== "GET")
|
|
96
|
-
expect(new Headers(requests[0].init.headers).get("idempotency-key")).toBe("retry-key");
|
|
97
|
-
if (positional[0] === "query")
|
|
98
|
-
expect(Object.fromEntries(requests[0].url.searchParams)).toEqual({
|
|
99
|
-
query: "{}",
|
|
100
|
-
start: "1",
|
|
101
|
-
end: "2",
|
|
102
|
-
limit: "5",
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
for (const mode of ["tty", "pipe", "json"])
|
|
106
|
-
test(`tail ${mode}, selector escaping, gap and backpressure`, async () => {
|
|
107
|
-
const { client } = setup();
|
|
108
|
-
const output = [];
|
|
109
|
-
let drained = 0;
|
|
110
|
-
const events = [
|
|
111
|
-
{
|
|
112
|
-
time: "12:00",
|
|
113
|
-
timeUnixNano: "1",
|
|
114
|
-
severity: 9,
|
|
115
|
-
severityText: "INFO",
|
|
116
|
-
labels: {},
|
|
117
|
-
attributes: {},
|
|
118
|
-
traceId: null,
|
|
119
|
-
spanId: null,
|
|
120
|
-
body: "line\x1b[2J\nnext",
|
|
121
|
-
cursor: "one",
|
|
122
|
-
},
|
|
123
|
-
{ gap: true, from: "old", to: "kept" },
|
|
124
|
-
];
|
|
125
|
-
let received;
|
|
126
|
-
client.logs.tail = async function* (query, options) {
|
|
127
|
-
received = { query, options };
|
|
128
|
-
yield* events;
|
|
129
|
-
};
|
|
130
|
-
const positional = ["tail", 'app"name'];
|
|
131
|
-
await logsProductCommand("logs", positional, {
|
|
132
|
-
positional,
|
|
133
|
-
flags: new Map([
|
|
134
|
-
["since", ["1h"]],
|
|
135
|
-
["grep", ['a"b']],
|
|
136
|
-
]),
|
|
137
|
-
}, {
|
|
138
|
-
json: mode === "json",
|
|
139
|
-
tty: mode === "tty",
|
|
140
|
-
write: (text) => output.push(text),
|
|
141
|
-
error() { },
|
|
142
|
-
async drain() {
|
|
143
|
-
drained++;
|
|
144
|
-
},
|
|
145
|
-
}, async () => client, { now: Date.parse("2026-10-06T12:00:00Z") });
|
|
146
|
-
expect(received).toEqual({
|
|
147
|
-
query: '{app="app\\"name"} |= "a\\"b"',
|
|
148
|
-
options: { start: "2026-10-06T11:00:00.000Z" },
|
|
149
|
-
});
|
|
150
|
-
expect(drained).toBe(2);
|
|
151
|
-
if (mode === "json")
|
|
152
|
-
expect(output.map((text) => JSON.parse(text))).toEqual(events);
|
|
153
|
-
else {
|
|
154
|
-
if (mode === "tty")
|
|
155
|
-
expect(output[0]).toContain("line\\u001b[2J\\u000anext");
|
|
156
|
-
else
|
|
157
|
-
expect(output[0]).toContain("line\u001b[2J\nnext");
|
|
158
|
-
expect(output[1]).toContain("Logs expired");
|
|
159
|
-
expect(output[1].includes("\x1b[2m")).toBe(mode === "tty");
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
test("tail survives an actual fake-fetch disconnect through the SDK", async () => {
|
|
163
|
-
let calls = 0;
|
|
164
|
-
const cursors = [], output = [];
|
|
165
|
-
const abort = new AbortController();
|
|
166
|
-
const transport = new Transport({
|
|
167
|
-
apiKey: "fake",
|
|
168
|
-
baseUrl: "https://fake.test",
|
|
169
|
-
fetch: (async (input) => {
|
|
170
|
-
cursors.push(new URL(input instanceof Request ? input.url : input).searchParams.get("cursor"));
|
|
171
|
-
const cursor = String(++calls);
|
|
172
|
-
return new Response(`id: ${cursor}\ndata: ${JSON.stringify({ time: "now", timeUnixNano: "1", labels: {}, body: "same", severityText: "INFO" })}\n\n`, { headers: { "content-type": "text/event-stream" } });
|
|
173
|
-
}),
|
|
174
|
-
});
|
|
175
|
-
const client = { logs: logs(transport), traces: traces(transport), alerts: alerts(transport) };
|
|
176
|
-
const run = logsProductCommand("logs", ["tail", "{}"], { positional: ["tail", "{}"], flags: new Map() }, {
|
|
177
|
-
json: true,
|
|
178
|
-
write(text) {
|
|
179
|
-
output.push(text);
|
|
180
|
-
if (output.length === 2)
|
|
181
|
-
abort.abort(new Error("done"));
|
|
182
|
-
},
|
|
183
|
-
error() { },
|
|
184
|
-
}, async () => client, { signal: abort.signal });
|
|
185
|
-
await expect(run).rejects.toThrow("done");
|
|
186
|
-
expect(cursors).toEqual([null, "1"]);
|
|
187
|
-
expect(output.map((text) => JSON.parse(text).cursor)).toEqual(["1", "2"]);
|
|
188
|
-
});
|
|
189
|
-
for (const [product, positional, flags] of [
|
|
190
|
-
["logs", ["query", "{}"], { limit: "0" }],
|
|
191
|
-
["logs", ["query"], {}],
|
|
192
|
-
["logs", ["tail", "api"], { since: "forever" }],
|
|
193
|
-
["alerts", ["create"], { body: "[1]" }],
|
|
194
|
-
["alerts", ["create"], { body: "broken" }],
|
|
195
|
-
["alerts", ["silence", "id"], { until: "tomorrow" }],
|
|
196
|
-
["logs", ["ingest"], { body: "{}" }],
|
|
197
|
-
["traces", ["ingest"], { body: "{}" }],
|
|
198
|
-
["alerts", ["oops", "id"], {}],
|
|
199
|
-
])
|
|
200
|
-
test(`refuses ${positional.join(" ")} ${JSON.stringify(flags)}`, async () => {
|
|
201
|
-
const { client, requests } = setup();
|
|
202
|
-
await expect(logsProductCommand(product, positional, { positional, flags: new Map(Object.entries(flags).map(([key, value]) => [key, [value]])) }, { json: true, write() { }, error() { } }, async () => client)).rejects.toMatchObject({ code: "usage", hint: expect.any(String) });
|
|
203
|
-
expect(requests).toHaveLength(0);
|
|
204
|
-
});
|
|
205
|
-
test("help needs no credentials", async () => {
|
|
206
|
-
const output = [];
|
|
207
|
-
await logsProductCommand("logs", ["help"], { positional: ["help"], flags: new Map() }, { json: false, write: (text) => output.push(text), error() { } }, async () => {
|
|
208
|
-
throw new Error("credentials requested");
|
|
209
|
-
});
|
|
210
|
-
expect(output[0]).toContain("\nruntime logs query");
|
|
211
|
-
});
|
|
212
|
-
test("typed failures reach the main CLI's JSON error renderer unchanged", async () => {
|
|
213
|
-
const { client } = setup();
|
|
214
|
-
const failure = new InvalidRequestError({
|
|
215
|
-
code: "query_limit",
|
|
216
|
-
message: "Too broad",
|
|
217
|
-
status: 400,
|
|
218
|
-
hint: "Narrow the time range.",
|
|
219
|
-
});
|
|
220
|
-
client.logs.queryRange = async () => {
|
|
221
|
-
throw failure;
|
|
222
|
-
};
|
|
223
|
-
await expect(logsProductCommand("logs", ["query", "{}"], { positional: ["query", "{}"], flags: new Map() }, {
|
|
224
|
-
json: true,
|
|
225
|
-
write() {
|
|
226
|
-
throw new Error("Do not print success on failure");
|
|
227
|
-
},
|
|
228
|
-
error() { },
|
|
229
|
-
}, async () => client)).rejects.toBe(failure);
|
|
230
|
-
});
|
|
231
|
-
test("human JSON keeps formatting but escapes C1 terminal controls", async () => {
|
|
232
|
-
const { client } = setup();
|
|
233
|
-
const output = [];
|
|
234
|
-
client.traces.get = async () => ({ resourceSpans: [], marker: "\u009b2J" });
|
|
235
|
-
await logsProductCommand("traces", ["get", "id"], { positional: ["get", "id"], flags: new Map() }, { json: false, tty: true, write: (text) => output.push(text), error() { } }, async () => client);
|
|
236
|
-
expect(output[0]).not.toContain("\u009b");
|
|
237
|
-
expect(output[0]).toContain("\\u009b");
|
|
238
|
-
expect(output[0]).toContain("\n");
|
|
239
|
-
expect(JSON.parse(output[0]).marker).toBe("\u009b2J");
|
|
240
|
-
});
|
|
241
|
-
test("a terminal shows what customers' programs wrote, escaped, and nothing acts", async () => {
|
|
242
|
-
// Clipboard (OSC 52), a forged link (OSC 8), screen clear, the one-byte C1
|
|
243
|
-
// CSI and a right-to-left override; tab stays.
|
|
244
|
-
const hostile = "a\tb \x1b]52;c;c2VjcmV0\x07 \x1b]8;;https://evil.test\x07ok\x1b]8;;\x07 \x1b[2J \u009b2J \u202eevil";
|
|
245
|
-
const shown = terminalSafe(hostile);
|
|
246
|
-
// eslint-disable-next-line no-control-regex -- the test looks for control characters
|
|
247
|
-
expect(shown).not.toMatch(/[\x00-\x08\x0a-\x1f\x7f-\x9f\u202e]/);
|
|
248
|
-
expect(shown).toContain("a\tb");
|
|
249
|
-
expect(shown).toContain("\\u001b]52;c;c2VjcmV0\\u0007");
|
|
250
|
-
expect(shown).toContain("\\u009b2J \\u202eevil");
|
|
251
|
-
const run = async (tty, raw, json) => {
|
|
252
|
-
const { client } = setup();
|
|
253
|
-
client.logs.tail = async function* () {
|
|
254
|
-
yield { cursor: "c", time: "t", severityText: "INFO", body: hostile, labels: {} };
|
|
255
|
-
};
|
|
256
|
-
const output = [];
|
|
257
|
-
const flags = new Map(raw ? [["raw", ["true"]]] : []);
|
|
258
|
-
await logsProductCommand("logs", ["tail", "{}"], { positional: ["tail", "{}"], flags }, { json, tty, write: (text) => output.push(text), error() { } }, async () => client);
|
|
259
|
-
return output.join("\n");
|
|
260
|
-
};
|
|
261
|
-
for (const json of [false, true]) {
|
|
262
|
-
const onTerminal = await run(true, false, json);
|
|
263
|
-
expect(onTerminal).not.toContain("\x1b]");
|
|
264
|
-
expect(onTerminal).not.toContain("\u009b");
|
|
265
|
-
expect(onTerminal).not.toContain("\u202e");
|
|
266
|
-
if (json)
|
|
267
|
-
expect(JSON.parse(onTerminal).body).toBe(hostile);
|
|
268
|
-
// A pipe, or --raw on a terminal, gets exactly what was written.
|
|
269
|
-
expect(await run(false, false, json)).toContain(json ? JSON.stringify(hostile) : hostile);
|
|
270
|
-
expect(await run(true, true, json)).toContain(json ? JSON.stringify(hostile) : hostile);
|
|
271
|
-
}
|
|
272
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { Transport } from "../transport.js";
|
|
3
|
-
import { InvalidRequestError, NotFoundError, RuntimeError } from "../errors.js";
|
|
4
|
-
import { alerts, logs, traces, } from "./logs.js";
|
|
5
|
-
const line = {
|
|
6
|
-
time: "2026-10-06T12:00:00.000000001Z",
|
|
7
|
-
timeUnixNano: "1791288000000000001",
|
|
8
|
-
severity: 9,
|
|
9
|
-
severityText: "INFO",
|
|
10
|
-
body: "same α",
|
|
11
|
-
labels: { app: "api" },
|
|
12
|
-
attributes: {},
|
|
13
|
-
traceId: null,
|
|
14
|
-
spanId: null,
|
|
15
|
-
};
|
|
16
|
-
const view = { name: "errors", labels: {}, query: '{app="api"}' };
|
|
17
|
-
const rule = { ...view, condition: "above 5", for: "5m", actions: [] };
|
|
18
|
-
const options = { idempotencyKey: "repeat-this-write" };
|
|
19
|
-
function fixture(handler) {
|
|
20
|
-
const requests = [];
|
|
21
|
-
const transport = new Transport({
|
|
22
|
-
apiKey: "test-only",
|
|
23
|
-
baseUrl: "https://fake.test",
|
|
24
|
-
maxRetries: 0,
|
|
25
|
-
fetch: (async (input, init) => {
|
|
26
|
-
const url = new URL(input instanceof Request ? input.url : input);
|
|
27
|
-
requests.push({ url, init: init ?? {} });
|
|
28
|
-
return handler(url, init ?? {});
|
|
29
|
-
}),
|
|
30
|
-
});
|
|
31
|
-
return {
|
|
32
|
-
client: { logs: logs(transport), traces: traces(transport), alerts: alerts(transport) },
|
|
33
|
-
requests,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
const calls = [
|
|
37
|
-
{
|
|
38
|
-
name: "search",
|
|
39
|
-
method: "POST",
|
|
40
|
-
path: "/v1/logs:search",
|
|
41
|
-
body: { query: "{}", cursor: "c", limit: 2 },
|
|
42
|
-
run: (c) => c.logs.search({ query: "{}", cursor: "c", limit: 2 }, options),
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
name: "instant query",
|
|
46
|
-
method: "GET",
|
|
47
|
-
path: "/v1/logs/query",
|
|
48
|
-
query: { query: "{}", time: "42", direction: "forward", limit: "2" },
|
|
49
|
-
run: (c) => c.logs.query({ query: "{}", time: "42", direction: "forward", limit: 2 }),
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
name: "range query",
|
|
53
|
-
method: "GET",
|
|
54
|
-
path: "/v1/logs/query_range",
|
|
55
|
-
query: { query: "{}", start: "1", end: "2", step: "1s" },
|
|
56
|
-
run: (c) => c.logs.queryRange({ query: "{}", start: "1", end: "2", step: "1s" }),
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: "labels",
|
|
60
|
-
method: "GET",
|
|
61
|
-
path: "/v1/logs/labels",
|
|
62
|
-
query: { start: "1", end: "2" },
|
|
63
|
-
run: (c) => c.logs.labels({ start: "1", end: "2" }),
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
name: "label values",
|
|
67
|
-
method: "GET",
|
|
68
|
-
path: "/v1/logs/labels/a%2Fb/values",
|
|
69
|
-
run: (c) => c.logs.labelValues("a/b"),
|
|
70
|
-
},
|
|
71
|
-
{ name: "trace get", method: "GET", path: "/v1/traces/a%2Fb", run: (c) => c.traces.get("a/b") },
|
|
72
|
-
{
|
|
73
|
-
name: "trace search",
|
|
74
|
-
method: "GET",
|
|
75
|
-
path: "/v1/traces:search",
|
|
76
|
-
query: { q: "{duration > 1s}" },
|
|
77
|
-
run: (c) => c.traces.search("{duration > 1s}"),
|
|
78
|
-
},
|
|
79
|
-
{ name: "trace tags", method: "GET", path: "/v1/traces/tags", run: (c) => c.traces.tags() },
|
|
80
|
-
{
|
|
81
|
-
name: "log ingest",
|
|
82
|
-
method: "POST",
|
|
83
|
-
path: "/v1/logs",
|
|
84
|
-
body: { resourceLogs: [] },
|
|
85
|
-
run: (c) => c.logs.ingest({ resourceLogs: [] }, options),
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
name: "trace ingest",
|
|
89
|
-
method: "POST",
|
|
90
|
-
path: "/v1/traces",
|
|
91
|
-
body: { resourceSpans: [] },
|
|
92
|
-
run: (c) => c.traces.ingest({ resourceSpans: [] }, options),
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
name: "test rule",
|
|
96
|
-
method: "POST",
|
|
97
|
-
path: "/v1/alerts/a%2Fb/test",
|
|
98
|
-
body: {},
|
|
99
|
-
run: (c) => c.alerts.test("a/b", options),
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
name: "silence rule",
|
|
103
|
-
method: "POST",
|
|
104
|
-
path: "/v1/alerts/a%2Fb/silence",
|
|
105
|
-
body: { until: "2026-10-07T00:00:00Z" },
|
|
106
|
-
run: (c) => c.alerts.silence("a/b", { until: "2026-10-07T00:00:00Z" }, options),
|
|
107
|
-
},
|
|
108
|
-
];
|
|
109
|
-
for (const product of ["logs", "traces", "alerts"]) {
|
|
110
|
-
const path = `/v1/${product}${product === "alerts" ? "" : "/views"}`;
|
|
111
|
-
const resource = (c) => (product === "alerts" ? c.alerts : c[product].views);
|
|
112
|
-
const input = product === "alerts" ? rule : view;
|
|
113
|
-
calls.push({
|
|
114
|
-
name: `${product} create`,
|
|
115
|
-
method: "POST",
|
|
116
|
-
path,
|
|
117
|
-
body: input,
|
|
118
|
-
run: (c) => resource(c).create(input, options),
|
|
119
|
-
}, {
|
|
120
|
-
name: `${product} get`,
|
|
121
|
-
method: "GET",
|
|
122
|
-
path: `${path}/a%2Fb`,
|
|
123
|
-
run: (c) => resource(c).get("a/b"),
|
|
124
|
-
}, {
|
|
125
|
-
name: `${product} list`,
|
|
126
|
-
method: "GET",
|
|
127
|
-
path,
|
|
128
|
-
query: { limit: "2", cursor: "next" },
|
|
129
|
-
run: (c) => resource(c).list({ limit: 2, cursor: "next" }),
|
|
130
|
-
}, {
|
|
131
|
-
name: `${product} update`,
|
|
132
|
-
method: "PUT",
|
|
133
|
-
path: `${path}/a%2Fb`,
|
|
134
|
-
body: { name: "changed" },
|
|
135
|
-
run: (c) => resource(c).update("a/b", { name: "changed" }, options),
|
|
136
|
-
}, {
|
|
137
|
-
name: `${product} delete`,
|
|
138
|
-
method: "DELETE",
|
|
139
|
-
path: `${path}/a%2Fb`,
|
|
140
|
-
run: (c) => resource(c).delete("a/b", options),
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
for (const entry of calls)
|
|
144
|
-
describe(entry.name, () => {
|
|
145
|
-
test("request, encoded path and idempotency", async () => {
|
|
146
|
-
const { client, requests } = fixture(() => entry.method === "DELETE"
|
|
147
|
-
? new Response(null, { status: 204 })
|
|
148
|
-
: Response.json({ data: [], nextCursor: null, marker: "answer" }));
|
|
149
|
-
const answer = await entry.run(client);
|
|
150
|
-
if (entry.method === "DELETE")
|
|
151
|
-
expect(answer).toBeNull();
|
|
152
|
-
else if (!entry.name.endsWith(" list"))
|
|
153
|
-
expect(answer).toEqual({ data: [], nextCursor: null, marker: "answer" });
|
|
154
|
-
expect(requests).toHaveLength(1);
|
|
155
|
-
const request = requests[0];
|
|
156
|
-
expect(request.url.pathname).toBe(entry.path);
|
|
157
|
-
expect(request.init.method).toBe(entry.method);
|
|
158
|
-
expect(Object.fromEntries(request.url.searchParams)).toEqual(entry.query ?? {});
|
|
159
|
-
if (entry.body !== undefined)
|
|
160
|
-
expect(JSON.parse(request.init.body)).toEqual(entry.body);
|
|
161
|
-
if (entry.method !== "GET")
|
|
162
|
-
expect(new Headers(request.init.headers).get("idempotency-key")).toBe(options.idempotencyKey);
|
|
163
|
-
});
|
|
164
|
-
test("typed server error preserves code and hint", async () => {
|
|
165
|
-
const { client } = fixture(() => Response.json({ error: { code: "not_found", message: "Missing", hint: "List resources first." } }, { status: 404 }));
|
|
166
|
-
try {
|
|
167
|
-
await entry.run(client);
|
|
168
|
-
throw new Error("Accepted failure");
|
|
169
|
-
}
|
|
170
|
-
catch (error) {
|
|
171
|
-
expect(error).toBeInstanceOf(NotFoundError);
|
|
172
|
-
expect(error).toMatchObject({ code: "not_found", hint: "List resources first." });
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
test("search paging follows opaque cursors and preserves equal timestamp lines", async () => {
|
|
177
|
-
const { client, requests } = fixture((_url, init) => Response.json({
|
|
178
|
-
lines: [line],
|
|
179
|
-
next: JSON.parse(init.body).cursor ? null : "opaque/+",
|
|
180
|
-
stats: {},
|
|
181
|
-
}));
|
|
182
|
-
const pages = [];
|
|
183
|
-
for await (const page of client.logs.pages({ query: "{}", limit: 1 }))
|
|
184
|
-
pages.push(page);
|
|
185
|
-
expect(pages.flatMap((page) => page.lines)).toEqual([line, line]);
|
|
186
|
-
expect(JSON.parse(requests[1].init.body).cursor).toBe("opaque/+");
|
|
187
|
-
});
|
|
188
|
-
test("search detects a cursor loop", async () => {
|
|
189
|
-
const { client } = fixture(() => Response.json({ lines: [], next: "same" }));
|
|
190
|
-
const iterator = client.logs.pages({ query: "{}", cursor: "same" });
|
|
191
|
-
await iterator.next();
|
|
192
|
-
await expect(iterator.next()).rejects.toMatchObject({ code: "invalid_stream" });
|
|
193
|
-
});
|
|
194
|
-
test("resource lists can follow pages", async () => {
|
|
195
|
-
const { client } = fixture((url) => Response.json({
|
|
196
|
-
data: [{ id: url.searchParams.get("cursor") ?? "first" }],
|
|
197
|
-
nextCursor: url.searchParams.has("cursor") ? null : "second",
|
|
198
|
-
}));
|
|
199
|
-
expect(await (await client.logs.views.list()).toArray()).toEqual([
|
|
200
|
-
{ id: "first" },
|
|
201
|
-
{ id: "second" },
|
|
202
|
-
]);
|
|
203
|
-
});
|
|
204
|
-
test("UTF-8 bytes enforce the 1 MiB ingest bound before fetch", () => {
|
|
205
|
-
const { client, requests } = fixture(() => {
|
|
206
|
-
throw new Error("No fetch allowed");
|
|
207
|
-
});
|
|
208
|
-
expect(() => client.logs.ingest({ resourceLogs: [{ body: "α".repeat(530_000) }] })).toThrow(InvalidRequestError);
|
|
209
|
-
expect(() => client.traces.ingest({ resourceSpans: [{ body: "α".repeat(530_000) }] })).toThrow(InvalidRequestError);
|
|
210
|
-
expect(requests).toHaveLength(0);
|
|
211
|
-
});
|
|
212
|
-
const event = (id, value = line, kind = "") => `id: ${id}\r\n${kind ? `event: ${kind}\r\n` : ""}data: ${JSON.stringify(value)}\r\n\r\n`;
|
|
213
|
-
function sse(text, bytes = 7) {
|
|
214
|
-
const encoded = new TextEncoder().encode(text);
|
|
215
|
-
let at = 0;
|
|
216
|
-
return new Response(new ReadableStream({
|
|
217
|
-
pull(controller) {
|
|
218
|
-
if (at === encoded.length)
|
|
219
|
-
controller.close();
|
|
220
|
-
else {
|
|
221
|
-
controller.enqueue(encoded.slice(at, at + bytes));
|
|
222
|
-
at = Math.min(at + bytes, encoded.length);
|
|
223
|
-
}
|
|
224
|
-
},
|
|
225
|
-
}), { headers: { "content-type": "text/event-stream" } });
|
|
226
|
-
}
|
|
227
|
-
test("SSE reconnects by cursor, discards torn events, keeps identical records and decodes split UTF-8", async () => {
|
|
228
|
-
let count = 0;
|
|
229
|
-
const { client, requests } = fixture(() => ++count === 1
|
|
230
|
-
? sse(`: heartbeat\r\n\r\n${event("one")}id: torn\ndata: {`)
|
|
231
|
-
: sse(event("one") + event("two")));
|
|
232
|
-
const iterator = client.logs.tail("{}", { reconnectMs: 0 });
|
|
233
|
-
expect((await iterator.next()).value).toEqual({ ...line, cursor: "one" });
|
|
234
|
-
expect((await iterator.next()).value).toEqual({ ...line, cursor: "two" });
|
|
235
|
-
expect(requests[1].url.searchParams.get("cursor")).toBe("one");
|
|
236
|
-
expect(new Headers(requests[0].init.headers).get("accept")).toBe("text/event-stream");
|
|
237
|
-
await iterator.return(undefined);
|
|
238
|
-
});
|
|
239
|
-
test("a drop before the first line resumes from the start event's cursor", async () => {
|
|
240
|
-
let count = 0;
|
|
241
|
-
const { client, requests } = fixture(() => ++count === 1
|
|
242
|
-
? sse(event("began", { cursor: "began" }, "start"))
|
|
243
|
-
: sse(event("began", { cursor: "began" }, "start") + event("first")));
|
|
244
|
-
const iterator = client.logs.tail("{}", { reconnectMs: 0 });
|
|
245
|
-
expect((await iterator.next()).value).toEqual({ ...line, cursor: "first" });
|
|
246
|
-
expect(requests[0].url.searchParams.get("cursor")).toBeNull();
|
|
247
|
-
expect(requests[1].url.searchParams.get("cursor")).toBe("began");
|
|
248
|
-
await iterator.return(undefined);
|
|
249
|
-
});
|
|
250
|
-
test("server retention gap becomes typed from/to and resumes after it", async () => {
|
|
251
|
-
const { client } = fixture(() => sse(event("kept", { cursor: "kept" }, "gap") + event("later")));
|
|
252
|
-
const iterator = client.logs.tail("{}", { cursor: "expired", reconnectMs: 0 });
|
|
253
|
-
expect((await iterator.next()).value).toEqual({ gap: true, from: "expired", to: "kept" });
|
|
254
|
-
expect((await iterator.next()).value).toMatchObject({ cursor: "later" });
|
|
255
|
-
await iterator.return(undefined);
|
|
256
|
-
});
|
|
257
|
-
test("reader error, including an echoed cursor, reconnects without moving the cursor", async () => {
|
|
258
|
-
let calls = 0;
|
|
259
|
-
const { client, requests } = fixture(() => sse(++calls === 1 ? event("old", { message: "reader gone" }, "error") : event("new")));
|
|
260
|
-
const iterator = client.logs.tail("{}", { cursor: "old", reconnectMs: 0 });
|
|
261
|
-
expect((await iterator.next()).value).toMatchObject({ cursor: "new" });
|
|
262
|
-
expect(requests[1].url.searchParams.get("cursor")).toBe("old");
|
|
263
|
-
await iterator.return(undefined);
|
|
264
|
-
});
|
|
265
|
-
for (const [name, wire] of [
|
|
266
|
-
["JSON", "id: a\ndata: nope\n\n"],
|
|
267
|
-
["cursor", `data: ${JSON.stringify(line)}\n\n`],
|
|
268
|
-
["line", event("a", {})],
|
|
269
|
-
["gap", event("a", { cursor: "wrong" }, "gap")],
|
|
270
|
-
["oversized event", `id: a\ndata: ${"x".repeat(1_048_577)}\n\n`],
|
|
271
|
-
])
|
|
272
|
-
test(`invalid ${name} fails once with a code and hint`, async () => {
|
|
273
|
-
const { client, requests } = fixture(() => sse(wire, wire.length));
|
|
274
|
-
await expect(client.logs.tail("{}", { cursor: "before", reconnectMs: 0 }).next()).rejects.toMatchObject({ code: "invalid_stream", hint: expect.any(String) });
|
|
275
|
-
expect(requests).toHaveLength(1);
|
|
276
|
-
});
|
|
277
|
-
test("tail authentication errors do not reconnect", async () => {
|
|
278
|
-
const { client, requests } = fixture(() => Response.json({ error: { code: "unauthorized", hint: "Sign in." } }, { status: 401 }));
|
|
279
|
-
await expect(client.logs.tail("{}").next()).rejects.toMatchObject({
|
|
280
|
-
code: "unauthorized",
|
|
281
|
-
hint: "Sign in.",
|
|
282
|
-
});
|
|
283
|
-
expect(requests).toHaveLength(1);
|
|
284
|
-
});
|
|
285
|
-
test("aborting an idle connection closes its reader", async () => {
|
|
286
|
-
let cancelled = false;
|
|
287
|
-
const { client } = fixture(() => new Response(new ReadableStream({
|
|
288
|
-
cancel() {
|
|
289
|
-
cancelled = true;
|
|
290
|
-
},
|
|
291
|
-
}), { headers: { "content-type": "text/event-stream" } }));
|
|
292
|
-
const abort = new AbortController();
|
|
293
|
-
const iterator = client.logs.tail("{}", { signal: abort.signal });
|
|
294
|
-
const pending = iterator.next();
|
|
295
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
296
|
-
abort.abort(new Error("test stop"));
|
|
297
|
-
await expect(pending).rejects.toThrow("test stop");
|
|
298
|
-
expect(cancelled).toBe(true);
|
|
299
|
-
});
|
|
300
|
-
test("non-SSE success is refused", async () => {
|
|
301
|
-
const { client } = fixture(() => Response.json({}));
|
|
302
|
-
await expect(client.logs.tail("{}").next()).rejects.toBeInstanceOf(RuntimeError);
|
|
303
|
-
});
|
|
304
|
-
test("an abrupt read failure reconnects, rather than only handling clean EOF", async () => {
|
|
305
|
-
let attempt = 0;
|
|
306
|
-
const { client, requests } = fixture(() => {
|
|
307
|
-
if (++attempt > 1)
|
|
308
|
-
return sse(event("second"));
|
|
309
|
-
let pulled = false;
|
|
310
|
-
return new Response(new ReadableStream({
|
|
311
|
-
pull(controller) {
|
|
312
|
-
if (!pulled) {
|
|
313
|
-
pulled = true;
|
|
314
|
-
controller.enqueue(new TextEncoder().encode(event("first")));
|
|
315
|
-
}
|
|
316
|
-
else
|
|
317
|
-
controller.error(new Error("connection reset"));
|
|
318
|
-
},
|
|
319
|
-
}), { headers: { "content-type": "text/event-stream" } });
|
|
320
|
-
});
|
|
321
|
-
const iterator = client.logs.tail("{}", { reconnectMs: 0 });
|
|
322
|
-
expect((await iterator.next()).value).toMatchObject({ cursor: "first" });
|
|
323
|
-
expect((await iterator.next()).value).toMatchObject({ cursor: "second" });
|
|
324
|
-
expect(requests[1].url.searchParams.get("cursor")).toBe("first");
|
|
325
|
-
await iterator.return(undefined);
|
|
326
|
-
});
|
|
327
|
-
test("a multline SSE event and the agreed from/to gap shape are accepted", async () => {
|
|
328
|
-
const { client } = fixture(() => sse('event: gap\nid: kept\ndata: {"from":"old",\ndata: "to":"kept"}\n\n'));
|
|
329
|
-
const iterator = client.logs.tail("{}", { cursor: "old" });
|
|
330
|
-
expect((await iterator.next()).value).toEqual({ gap: true, from: "old", to: "kept" });
|
|
331
|
-
await iterator.return(undefined);
|
|
332
|
-
});
|
|
333
|
-
test("abort during reconnect delay stops without another request", async () => {
|
|
334
|
-
const abort = new AbortController();
|
|
335
|
-
const { client, requests } = fixture(() => sse(event("first")));
|
|
336
|
-
const iterator = client.logs.tail("{}", { signal: abort.signal, reconnectMs: 1000 });
|
|
337
|
-
await iterator.next();
|
|
338
|
-
const pending = iterator.next();
|
|
339
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
340
|
-
abort.abort(new Error("stop reconnect"));
|
|
341
|
-
await expect(pending).rejects.toThrow("stop reconnect");
|
|
342
|
-
expect(requests).toHaveLength(1);
|
|
343
|
-
});
|