buninu 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { canonicalHeaderName, curlEscape, normalizeUrl, parseCurlArguments } from "../src/curl.js";
6
+
7
+ const root = new URL("..", import.meta.url).pathname;
8
+ const bunmsh = [process.execPath, join(root, "src/main.js"), "-cc", "builtin", "curl"];
9
+
10
+ let server;
11
+ let origin;
12
+
13
+ // Everything the builtin talks to is local, so the suite stays honest
14
+ // without a network and without a service that could change under it.
15
+ beforeAll(() => {
16
+ server = Bun.serve({
17
+ port: 0,
18
+ async fetch(request) {
19
+ const url = new URL(request.url);
20
+ switch (url.pathname) {
21
+ case "/text":
22
+ return new Response("hello\n");
23
+ case "/query":
24
+ return new Response(`${url.search}\n`);
25
+ case "/method":
26
+ return new Response(`${request.method}\n`);
27
+ case "/body":
28
+ return new Response(await request.text());
29
+ case "/type":
30
+ return new Response(`${request.headers.get("content-type") ?? "none"}\n`);
31
+ case "/auth":
32
+ return new Response(`${request.headers.get("authorization") ?? "none"}\n`);
33
+ case "/pick":
34
+ return new Response(`${request.headers.get(url.searchParams.get("h")) ?? "none"}\n`);
35
+ case "/missing":
36
+ return new Response("missing\n", { status: 404 });
37
+ case "/redirect":
38
+ return new Response(null, { status: 302, headers: { Location: "/method" } });
39
+ case "/permanent":
40
+ return new Response(null, { status: 308, headers: { Location: "/method" } });
41
+ case "/loop":
42
+ return new Response(null, { status: 302, headers: { Location: "/loop" } });
43
+ case "/attachment":
44
+ return new Response("attached\n", {
45
+ headers: { "Content-Disposition": 'attachment; filename="named.txt"' },
46
+ });
47
+ case "/chunks": {
48
+ const stream = new ReadableStream({
49
+ start(controller) {
50
+ for (let i = 0; i < 4; i++) controller.enqueue(Buffer.from(`chunk${i}\n`));
51
+ controller.close();
52
+ },
53
+ });
54
+ return new Response(stream);
55
+ }
56
+ case "/blob": {
57
+ const body = Buffer.alloc(65536, "b");
58
+ const range = request.headers.get("range");
59
+ if (!range) return new Response(body);
60
+ const start = Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0);
61
+ if (start >= body.length) return new Response(null, { status: 416 });
62
+ return new Response(body.subarray(start), {
63
+ status: 206,
64
+ headers: { "Content-Range": `bytes ${start}-${body.length - 1}/${body.length}` },
65
+ });
66
+ }
67
+ default:
68
+ return new Response("not found\n", { status: 404 });
69
+ }
70
+ },
71
+ });
72
+ origin = `localhost:${server.port}`;
73
+ });
74
+
75
+ afterAll(() => server?.stop(true));
76
+
77
+ async function curl(...args) {
78
+ const proc = Bun.spawn({
79
+ cmd: [...bunmsh, ...args],
80
+ cwd: root,
81
+ stdin: "ignore",
82
+ stdout: "pipe",
83
+ stderr: "pipe",
84
+ });
85
+ const [status, stdout, stderr] = await Promise.all([
86
+ proc.exited,
87
+ new Response(proc.stdout).text(),
88
+ new Response(proc.stderr).text(),
89
+ ]);
90
+ return { status, stdout, stderr };
91
+ }
92
+
93
+ describe("curl argument parsing", () => {
94
+ test("splits the short clusters the tmpk scripts use", () => {
95
+ const { options } = parseCurlArguments(["curl", "-kLO", "https://example.com/a.tgz"]);
96
+ expect(options.insecure).toBe(true);
97
+ expect(options.location).toBe(true);
98
+ expect(options.outputs).toEqual([{ kind: "remote" }]);
99
+ expect(options.urls).toEqual(["https://example.com/a.tgz"]);
100
+
101
+ const quiet = parseCurlArguments(["curl", "-fsSL", "https://example.com/i.sh"]).options;
102
+ expect([quiet.fail, quiet.silent, quiet.showError, quiet.location]).toEqual([true, true, true, true]);
103
+
104
+ const probe = parseCurlArguments(["curl", "-kfsS", "https://example.com/m"]).options;
105
+ expect([probe.insecure, probe.fail, probe.silent, probe.showError]).toEqual([true, true, true, true]);
106
+
107
+ const bar = parseCurlArguments(["curl", "-#k", "https://example.com/s"]).options;
108
+ expect([bar.progressBar, bar.insecure]).toEqual([true, true]);
109
+ });
110
+
111
+ test("takes a value from the next argument or the tail of a cluster", () => {
112
+ const resume = parseCurlArguments(["curl", "-C", "-", "-kLO", "https://example.com/a"]).options;
113
+ expect(resume.continueAt).toBe("-");
114
+ expect(resume.urls).toEqual(["https://example.com/a"]);
115
+ expect(parseCurlArguments(["curl", "-oout.txt", "u"]).options.outputs)
116
+ .toEqual([{ kind: "file", path: "out.txt" }]);
117
+ expect(parseCurlArguments(["curl", "--output=out.txt", "u"]).options.outputs)
118
+ .toEqual([{ kind: "file", path: "out.txt" }]);
119
+ });
120
+
121
+ test("rejects unknown options and reports missing parameters", () => {
122
+ expect(parseCurlArguments(["curl", "--bogus", "u"]).error)
123
+ .toBe("curl: option --bogus: is unknown");
124
+ expect(parseCurlArguments(["curl", "-B", "u"]).error)
125
+ .toBe("curl: option -B: is unknown");
126
+ expect(parseCurlArguments(["curl", "u", "-o"]).error)
127
+ .toBe("curl: option -o: requires parameter");
128
+ });
129
+
130
+ test("understands --no- prefixes and -- end of options", () => {
131
+ const options = parseCurlArguments(["curl", "--location", "--no-location", "--", "-weird"]).options;
132
+ expect(options.location).toBe(false);
133
+ expect(options.urls).toEqual(["-weird"]);
134
+ });
135
+ });
136
+
137
+ describe("curl url and escaping helpers", () => {
138
+ test("adds the scheme a bare host is missing", () => {
139
+ expect(normalizeUrl("localhost:8080")).toBe("http://localhost:8080");
140
+ expect(normalizeUrl("example.com/page")).toBe("http://example.com/page");
141
+ expect(normalizeUrl("example.com:443/page")).toBe("https://example.com:443/page");
142
+ expect(normalizeUrl("example.com", "https")).toBe("https://example.com");
143
+ expect(normalizeUrl("https://example.com")).toBe("https://example.com");
144
+ });
145
+
146
+ test("escapes like curl: unreserved kept, space as plus", () => {
147
+ expect(curlEscape("a b&c")).toBe("a+b%26c");
148
+ expect(curlEscape("-._~AZaz09")).toBe("-._~AZaz09");
149
+ expect(curlEscape("ü")).toBe("%C3%BC");
150
+ });
151
+
152
+ test("restores the conventional casing fetch lower-cases away", () => {
153
+ expect(canonicalHeaderName("content-type")).toBe("Content-Type");
154
+ expect(canonicalHeaderName("etag")).toBe("ETag");
155
+ expect(canonicalHeaderName("www-authenticate")).toBe("WWW-Authenticate");
156
+ });
157
+ });
158
+
159
+ describe("curl transfers", () => {
160
+ test("fetches a body and adds http:// to a bare host", async () => {
161
+ expect(await curl("-s", `${origin}/text`)).toEqual({ status: 0, stdout: "hello\n", stderr: "" });
162
+ });
163
+
164
+ test("reports usage without a URL", async () => {
165
+ const output = await curl();
166
+ expect(output.status).toBe(2);
167
+ expect(output.stderr).toBe("curl: try 'curl --help' or 'curl --manual' for more information\n");
168
+ });
169
+
170
+ test("keeps the body on an HTTP error unless -f is given", async () => {
171
+ expect(await curl("-s", `${origin}/missing`))
172
+ .toEqual({ status: 0, stdout: "missing\n", stderr: "" });
173
+ expect(await curl("-sf", `${origin}/missing`))
174
+ .toEqual({ status: 22, stdout: "", stderr: "" });
175
+ expect(await curl("-sSf", `${origin}/missing`)).toEqual({
176
+ status: 22,
177
+ stdout: "",
178
+ stderr: "curl: (22) The requested URL returned error: 404\n",
179
+ });
180
+ expect(await curl("-s", "--fail-with-body", `${origin}/missing`))
181
+ .toEqual({ status: 22, stdout: "missing\n", stderr: "" });
182
+ });
183
+
184
+ test("follows redirects only with -L and downgrades POST to GET", async () => {
185
+ expect(await curl("-s", `${origin}/redirect`)).toMatchObject({ status: 0, stdout: "" });
186
+ expect(await curl("-sL", `${origin}/redirect`)).toMatchObject({ stdout: "GET\n" });
187
+ expect(await curl("-sL", "-d", "a=1", `${origin}/redirect`)).toMatchObject({ stdout: "GET\n" });
188
+ // 308 keeps the method, which is the whole point of the status.
189
+ expect(await curl("-sL", "-d", "a=1", `${origin}/permanent`)).toMatchObject({ stdout: "POST\n" });
190
+ });
191
+
192
+ test("stops a redirect loop at --max-redirs", async () => {
193
+ const output = await curl("-sS", "-L", "--max-redirs", "3", `${origin}/loop`);
194
+ expect(output.status).toBe(47);
195
+ expect(output.stderr).toBe("curl: (47) Maximum (3) redirects followed\n");
196
+ });
197
+
198
+ test("sends data, headers, and auth the way an API expects", async () => {
199
+ expect(await curl("-s", "-d", '{"a":1}', `${origin}/method`)).toMatchObject({ stdout: "POST\n" });
200
+ expect(await curl("-s", "-d", "a=1", "-d", "b=2", `${origin}/body`)).toMatchObject({ stdout: "a=1&b=2" });
201
+ expect(await curl("-s", "-d", "a=1", `${origin}/type`))
202
+ .toMatchObject({ stdout: "application/x-www-form-urlencoded\n" });
203
+ expect(await curl("-s", "--json", '{"a":1}', `${origin}/type`))
204
+ .toMatchObject({ stdout: "application/json\n" });
205
+ expect(await curl("-s", "-H", "Content-Type: text/plain", "-d", "x", `${origin}/type`))
206
+ .toMatchObject({ stdout: "text/plain\n" });
207
+ expect(await curl("-s", "-u", "user:pass", `${origin}/auth`))
208
+ .toMatchObject({ stdout: "Basic dXNlcjpwYXNz\n" });
209
+ expect(await curl("-s", "--oauth2-bearer", "sk-test", `${origin}/auth`))
210
+ .toMatchObject({ stdout: "Bearer sk-test\n" });
211
+ expect(await curl("-s", "-X", "DELETE", `${origin}/method`)).toMatchObject({ stdout: "DELETE\n" });
212
+ });
213
+
214
+ test("moves data onto the query string with -G", async () => {
215
+ expect(await curl("-s", "-G", "-d", "a=1", "-d", "b=2", `${origin}/query`))
216
+ .toMatchObject({ stdout: "?a=1&b=2\n" });
217
+ expect(await curl("-s", "-G", "--data-urlencode", "q=a b", `${origin}/query`))
218
+ .toMatchObject({ stdout: "?q=a+b\n" });
219
+ });
220
+
221
+ test("prints response headers for -i and -I", async () => {
222
+ const include = await curl("-si", `${origin}/text`);
223
+ expect(include.stdout.startsWith("HTTP/1.1 200 OK\r\n")).toBe(true);
224
+ expect(include.stdout.endsWith("\r\n\r\nhello\n")).toBe(true);
225
+ expect(include.stdout).toContain("Content-Type: text/plain;charset=utf-8\r\n");
226
+
227
+ const head = await curl("-sI", `${origin}/text`);
228
+ expect(head.stdout.startsWith("HTTP/1.1 200 OK\r\n")).toBe(true);
229
+ expect(head.stdout).not.toContain("hello");
230
+ });
231
+
232
+ test("reports transfer facts through --write-out", async () => {
233
+ expect(await curl("-s", "-o", "/dev/null", "-w", "%{http_code} %{size_download}\\n", `${origin}/text`))
234
+ .toMatchObject({ status: 0, stdout: "200 6\n" });
235
+ // A failed transfer still knows what the server said.
236
+ expect(await curl("-sf", "-w", "%{http_code} %{exitcode}\\n", `${origin}/missing`))
237
+ .toMatchObject({ status: 22, stdout: "404 22\n" });
238
+ expect(await curl("-s", "-o", "/dev/null", "-w", "%{url_effective} %{num_redirects}\\n", "-L", `${origin}/redirect`))
239
+ .toMatchObject({ stdout: `http://${origin}/method 1\n` });
240
+ });
241
+
242
+ test("maps connection failures onto curl's exit codes", async () => {
243
+ const refused = await curl("-sS", "http://localhost:1/");
244
+ expect(refused.status).toBe(7);
245
+ expect(refused.stderr).toContain("curl: (7) Failed to connect to localhost port 1");
246
+
247
+ const unresolved = await curl("-sS", "http://no-such-host-zzz.invalid/");
248
+ expect(unresolved.status).toBe(6);
249
+ expect(unresolved.stderr).toBe("curl: (6) Could not resolve host: no-such-host-zzz.invalid\n");
250
+
251
+ const unsupported = await curl("-sS", "ftp://example.com/file");
252
+ expect(unsupported.status).toBe(1);
253
+ expect(unsupported.stderr)
254
+ .toBe('curl: (1) Protocol "ftp" not supported or disabled in libcurl\n');
255
+ });
256
+ });
257
+
258
+ describe("curl file output", () => {
259
+ let directory;
260
+
261
+ beforeAll(() => { directory = mkdtempSync(join(tmpdir(), "bunmsh-curl-")); });
262
+ afterAll(() => rmSync(directory, { recursive: true, force: true }));
263
+
264
+ async function curlIn(...args) {
265
+ const proc = Bun.spawn({
266
+ cmd: [...bunmsh, ...args],
267
+ cwd: directory,
268
+ stdin: "ignore",
269
+ stdout: "pipe",
270
+ stderr: "pipe",
271
+ });
272
+ const [status, stdout, stderr] = await Promise.all([
273
+ proc.exited,
274
+ new Response(proc.stdout).text(),
275
+ new Response(proc.stderr).text(),
276
+ ]);
277
+ return { status, stdout, stderr };
278
+ }
279
+
280
+ test("-O names the file after the URL path", async () => {
281
+ expect(await curlIn("-s", "-O", `${origin}/text`)).toMatchObject({ status: 0, stdout: "" });
282
+ expect(readFileSync(join(directory, "text"), "utf8")).toBe("hello\n");
283
+ });
284
+
285
+ test("-J prefers the Content-Disposition filename", async () => {
286
+ expect(await curlIn("-sOJ", `${origin}/attachment`)).toMatchObject({ status: 0 });
287
+ expect(readFileSync(join(directory, "named.txt"), "utf8")).toBe("attached\n");
288
+ });
289
+
290
+ test("-o writes where it is told, and --create-dirs makes the way", async () => {
291
+ await curlIn("-s", "--create-dirs", "-o", "nested/deep/out.txt", `${origin}/text`);
292
+ expect(readFileSync(join(directory, "nested/deep/out.txt"), "utf8")).toBe("hello\n");
293
+ });
294
+
295
+ test("-C - resumes a partial file and stops when it is complete", async () => {
296
+ const path = join(directory, "blob");
297
+ writeFileSync(path, Buffer.alloc(1024, "b"));
298
+ const resumed = await curlIn("-s", "-C", "-", "-O", `${origin}/blob`);
299
+ expect(resumed.status).toBe(0);
300
+ expect(readFileSync(path).length).toBe(65536);
301
+
302
+ // Second pass: the server answers 416 and nothing is rewritten.
303
+ const complete = await curlIn("-s", "-C", "-", "-O", `${origin}/blob`);
304
+ expect(complete.status).toBe(0);
305
+ expect(readFileSync(path).length).toBe(65536);
306
+ });
307
+
308
+ test("-d @file reads the body from disk and @- from stdin", async () => {
309
+ writeFileSync(join(directory, "payload.json"), '{"model":"test"}\n');
310
+ expect(await curlIn("-s", "-d", "@payload.json", `${origin}/body`))
311
+ .toMatchObject({ stdout: '{"model":"test"}' });
312
+
313
+ const proc = Bun.spawn({
314
+ cmd: [...bunmsh, "-s", "--data-binary", "@-", `${origin}/body`],
315
+ cwd: directory,
316
+ stdin: Buffer.from("streamed body\n"),
317
+ stdout: "pipe",
318
+ stderr: "pipe",
319
+ });
320
+ const [status, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]);
321
+ expect({ status, stdout }).toEqual({ status: 0, stdout: "streamed body\n" });
322
+ });
323
+
324
+ test("writes the response headers to -D", async () => {
325
+ await curlIn("-s", "-o", "/dev/null", "-D", "head.txt", `${origin}/text`);
326
+ expect(readFileSync(join(directory, "head.txt"), "utf8").startsWith("HTTP/1.1 200 OK\r\n")).toBe(true);
327
+ });
328
+
329
+ test("shows a progress meter for a file download and none for -s", async () => {
330
+ const meter = await curlIn("-o", "meter.bin", `${origin}/blob`);
331
+ expect(meter.stderr).toContain("% Total % Received % Xferd");
332
+ const silent = await curlIn("-s", "-o", "silent.bin", `${origin}/blob`);
333
+ expect(silent.stderr).toBe("");
334
+ });
335
+ });
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
@@ -675,3 +675,129 @@ describe("concurrent streaming pipelines", () => {
675
675
  expect(output).toEqual({ status: 0, stdout: "1048576\n", stderr: "" });
676
676
  });
677
677
  });
678
+
679
+ // The fallback `curl` is only worth having if a script cannot tell it apart
680
+ // from the real one, so the parts that can be compared byte for byte are:
681
+ // the body, the exit code, and whatever --write-out reports. Response header
682
+ // order and casing come from fetch rather than the wire, so `-i` output is
683
+ // compared after normalisation instead.
684
+ describe("system curl reference", () => {
685
+ const systemCurl = Bun.which("curl");
686
+ let server;
687
+ let origin;
688
+
689
+ beforeAll(() => {
690
+ server = Bun.serve({
691
+ port: 0,
692
+ async fetch(request) {
693
+ const url = new URL(request.url);
694
+ switch (url.pathname) {
695
+ case "/text": return new Response("hello\n");
696
+ case "/query": return new Response(`${url.search}\n`);
697
+ case "/method": return new Response(`${request.method}\n`);
698
+ case "/body": return new Response(await request.text());
699
+ case "/missing": return new Response("missing\n", { status: 404 });
700
+ case "/redirect":
701
+ return new Response(null, { status: 302, headers: { Location: "/method" } });
702
+ case "/blob": return new Response(Buffer.alloc(65536, "b"));
703
+ default: return new Response("not found\n", { status: 404 });
704
+ }
705
+ },
706
+ });
707
+ origin = `localhost:${server.port}`;
708
+ });
709
+
710
+ afterAll(() => server?.stop(true));
711
+
712
+ async function expectLikeCurl(args, transform = (output) => output) {
713
+ const [actual, reference] = await Promise.all([
714
+ invokeArgvWithInput(
715
+ [process.execPath, join(root, "src/main.js"), "-cc", "builtin", "curl", ...args],
716
+ "",
717
+ ),
718
+ invokeArgvWithInput([systemCurl, ...args], ""),
719
+ ]);
720
+ expect(transform(actual)).toEqual(transform(reference));
721
+ }
722
+
723
+ test.skipIf(!systemCurl)("matches bodies, statuses, and the guessed scheme", async () => {
724
+ await expectLikeCurl(["-s", `${origin}/text`]);
725
+ await expectLikeCurl(["-s", `http://${origin}/text`]);
726
+ await expectLikeCurl(["-s", `${origin}/missing`]);
727
+ await expectLikeCurl(["-s", `${origin}/nowhere`]);
728
+ });
729
+
730
+ test.skipIf(!systemCurl)("matches the tmpk download and probe invocations", async () => {
731
+ await expectLikeCurl(["-fsSL", `${origin}/text`]);
732
+ await expectLikeCurl(["-kfsS", `${origin}/text`]);
733
+ await expectLikeCurl(["-sk", `${origin}/blob`]);
734
+ // `-#` paints a live bar whose bytes depend on timing; only the body
735
+ // and the status are comparable.
736
+ await expectLikeCurl(["-#k", `${origin}/text`], ({ status, stdout }) => ({ status, stdout }));
737
+ });
738
+
739
+ test.skipIf(!systemCurl)("matches -f and error exit codes", async () => {
740
+ await expectLikeCurl(["-sf", `${origin}/missing`]);
741
+ await expectLikeCurl(["-sSf", `${origin}/missing`]);
742
+ await expectLikeCurl(["-s", "--fail-with-body", `${origin}/missing`]);
743
+ await expectLikeCurl(["-s", "http://localhost:1/"]);
744
+ await expectLikeCurl(["-s", "http://no-such-host-zzz.invalid/"]);
745
+ await expectLikeCurl(["-sS", "http://no-such-host-zzz.invalid/"]);
746
+ await expectLikeCurl(["--bogus", `${origin}/text`]);
747
+ await expectLikeCurl([]);
748
+ });
749
+
750
+ test.skipIf(!systemCurl)("matches redirect following and the method it lands on", async () => {
751
+ await expectLikeCurl(["-s", `${origin}/redirect`]);
752
+ await expectLikeCurl(["-sL", `${origin}/redirect`]);
753
+ await expectLikeCurl(["-sL", "-d", "a=1", `${origin}/redirect`]);
754
+ });
755
+
756
+ test.skipIf(!systemCurl)("matches the request bodies an API call sends", async () => {
757
+ await expectLikeCurl(["-s", "-d", "a=1", "-d", "b=2", `${origin}/body`]);
758
+ await expectLikeCurl(["-s", "--data-raw", '{"a":1}', `${origin}/body`]);
759
+ await expectLikeCurl(["-s", "--json", '{"a":1}', `${origin}/body`]);
760
+ await expectLikeCurl(["-s", "--data-urlencode", "q=a b&c", `${origin}/body`]);
761
+ await expectLikeCurl(["-s", "-X", "PUT", "-d", "x", `${origin}/method`]);
762
+ await expectLikeCurl(["-s", "-G", "-d", "a=1", "-d", "b=2", `${origin}/query`]);
763
+ await expectLikeCurl(["-s", "-G", "--data-urlencode", "q=a b", `${origin}/query`]);
764
+ });
765
+
766
+ test.skipIf(!systemCurl)("matches --write-out reporting", async () => {
767
+ await expectLikeCurl([
768
+ "-s", "-o", "/dev/null", "-w", "%{http_code} %{size_download} %{content_type}\\n",
769
+ `${origin}/text`,
770
+ ]);
771
+ await expectLikeCurl([
772
+ "-sf", "-w", "%{http_code} %{exitcode}\\n", `${origin}/missing`,
773
+ ]);
774
+ await expectLikeCurl([
775
+ "-s", "-o", "/dev/null", "-L", "-w", "%{url_effective} %{num_redirects} %{method}\\n",
776
+ `${origin}/redirect`,
777
+ ]);
778
+ });
779
+
780
+ test.skipIf(!systemCurl)("matches -i once header order and casing are normalised", async () => {
781
+ // fetch hands back lower-cased headers in its own order and hides the
782
+ // negotiated HTTP version, so compare the status line plus a sorted,
783
+ // lower-cased header set — the values themselves still have to agree.
784
+ const normalize = ({ status, stdout, stderr }) => {
785
+ const split = stdout.indexOf("\r\n\r\n");
786
+ const head = stdout.slice(0, split).split("\r\n");
787
+ const body = stdout.slice(split + 4);
788
+ return {
789
+ status,
790
+ stderr,
791
+ body,
792
+ statusLine: head[0],
793
+ headers: head.slice(1)
794
+ .map((line) => line.toLowerCase())
795
+ // Date moves on between the two requests.
796
+ .filter((line) => !line.startsWith("date:"))
797
+ .sort(),
798
+ };
799
+ };
800
+ await expectLikeCurl(["-si", `${origin}/text`], normalize);
801
+ await expectLikeCurl(["-sI", `${origin}/text`], normalize);
802
+ });
803
+ });