@specific.dev/spectest 0.128.0 → 0.129.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/README.md +54 -0
- package/dist/browser.d.ts +12 -5
- package/dist/browser.js +13 -13
- package/dist/index.d.ts +2 -2
- package/dist/recorder.d.ts +29 -2
- package/dist/terminal.d.ts +31 -17
- package/dist/terminal.js +261 -105
- package/package.json +1 -1
- package/src/browser-navigation.test.ts +36 -0
- package/src/browser.ts +26 -18
- package/src/index.ts +2 -2
- package/src/recorder.ts +22 -2
- package/src/terminal.test.ts +324 -0
- package/src/terminal.ts +269 -119
package/src/recorder.ts
CHANGED
|
@@ -467,6 +467,9 @@ export interface RecordedBrowserLocatorPart {
|
|
|
467
467
|
|
|
468
468
|
export interface BrowserEvent extends BaseEvent {
|
|
469
469
|
kind: "browser";
|
|
470
|
+
/** Explicit navigation options, when supplied by the test. */
|
|
471
|
+
timeout?: number;
|
|
472
|
+
waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
|
|
470
473
|
action: BrowserAction;
|
|
471
474
|
target?: RecordedBrowserLocatorPart[];
|
|
472
475
|
destinationTarget?: RecordedBrowserLocatorPart[];
|
|
@@ -599,6 +602,14 @@ export type TerminalStepAction =
|
|
|
599
602
|
| "exit"
|
|
600
603
|
| "close";
|
|
601
604
|
|
|
605
|
+
export type TerminalWaitMatcher =
|
|
606
|
+
| { kind: "text"; value: string }
|
|
607
|
+
| { kind: "regex"; value: string; flags: string }
|
|
608
|
+
| { kind: "custom"; description?: string };
|
|
609
|
+
|
|
610
|
+
export type TerminalWaitOutcome = "matched" | "timed_out" | "failure_matched"
|
|
611
|
+
| "predicate_error" | "process_exited" | "cancelled";
|
|
612
|
+
|
|
602
613
|
/**
|
|
603
614
|
* One operation on an open interactive terminal — analogous to
|
|
604
615
|
* `BrowserEvent`. The session's heavy asciicast frames live on the
|
|
@@ -622,9 +633,18 @@ export interface TerminalStepEvent extends BaseEvent {
|
|
|
622
633
|
description?: string;
|
|
623
634
|
/** How many polls `waitFor` made. */
|
|
624
635
|
attempts?: number;
|
|
625
|
-
/** Whether `waitFor` matched
|
|
636
|
+
/** Whether `waitFor` matched; see outcome for failure reasons. */
|
|
626
637
|
matched?: boolean;
|
|
627
|
-
/**
|
|
638
|
+
/** The condition being observed, including an optional custom-predicate description. */
|
|
639
|
+
matcher?: TerminalWaitMatcher;
|
|
640
|
+
outcome?: TerminalWaitOutcome;
|
|
641
|
+
failureMatchers?: { description: string; matcher: TerminalWaitMatcher }[];
|
|
642
|
+
intervalMs?: number;
|
|
643
|
+
timeoutMs?: number;
|
|
644
|
+
/** Wait boundaries on the session clock, including time with no output. */
|
|
645
|
+
castStartTimeSec?: number;
|
|
646
|
+
castEndTimeSec?: number;
|
|
647
|
+
/** Exit code observed on exit, close, or a failed wait. */
|
|
628
648
|
exitCode?: number;
|
|
629
649
|
/** Rendered screen at the moment of the op (post-action), truncated. */
|
|
630
650
|
screenPreview: string;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { afterEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { openTerminal } from "./terminal.js";
|
|
3
|
+
import { startRecording, stopRecording } from "./recorder.js";
|
|
4
|
+
|
|
5
|
+
afterEach(() => stopRecording());
|
|
6
|
+
|
|
7
|
+
async function session(opts: { killExits?: boolean; eofExits?: boolean } = {}) {
|
|
8
|
+
let stream!: ReadableStreamDefaultController<Uint8Array>;
|
|
9
|
+
let resolveExit!: (code: number) => void;
|
|
10
|
+
let streamClosed = false;
|
|
11
|
+
let processExited = false;
|
|
12
|
+
const output = (text: string) => { if (!streamClosed) stream.enqueue(new TextEncoder().encode(text)); };
|
|
13
|
+
const endOutput = () => { if (!streamClosed) { streamClosed = true; stream.close(); } };
|
|
14
|
+
const exit = (code = 0, drain = true) => {
|
|
15
|
+
if (drain) endOutput();
|
|
16
|
+
if (!processExited) { processExited = true; resolveExit(code); }
|
|
17
|
+
};
|
|
18
|
+
const writes: string[] = [];
|
|
19
|
+
const kills: unknown[] = [];
|
|
20
|
+
const fake = {
|
|
21
|
+
stdout: new ReadableStream<Uint8Array>({ start(c) { stream = c; }, cancel() { streamClosed = true; } }),
|
|
22
|
+
stderr: new ReadableStream<Uint8Array>({ start(c) { c.close(); } }),
|
|
23
|
+
stdin: { write: (data: string) => {
|
|
24
|
+
writes.push(data);
|
|
25
|
+
if (data === "\x03") setTimeout(() => output("\r\n^C"), 0);
|
|
26
|
+
if (data === "\x04" && opts.eofExits) exit();
|
|
27
|
+
} },
|
|
28
|
+
exited: new Promise<number>((resolve) => { resolveExit = resolve; }),
|
|
29
|
+
kill: (signal: unknown) => { kills.push(signal); if (opts.killExits !== false) exit(137); },
|
|
30
|
+
};
|
|
31
|
+
const spawn = spyOn(Bun, "spawn").mockReturnValue(fake as unknown as ReturnType<typeof Bun.spawn>);
|
|
32
|
+
try {
|
|
33
|
+
const term = await openTerminal({
|
|
34
|
+
service: "rack", sessionId: "shell", recordEvents: true,
|
|
35
|
+
sink: { pushFrame() {}, markClosed() {} },
|
|
36
|
+
});
|
|
37
|
+
return { term, output, exit, endOutput, writes, kills, async close() {
|
|
38
|
+
exit();
|
|
39
|
+
try { await term.close(); } catch { /* a tested close failure remains memoized */ }
|
|
40
|
+
spawn.mockRestore();
|
|
41
|
+
} };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
exit(); spawn.mockRestore(); throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function waitEvents() {
|
|
48
|
+
return stopRecording().filter(e => e.kind === "terminal-step" && e.action === "waitFor");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test.each([
|
|
52
|
+
["Initialized", { kind: "text", value: "Initialized" }],
|
|
53
|
+
[/Initialized/i, { kind: "regex", value: "Initialized", flags: "i" }],
|
|
54
|
+
[(screen: string) => screen.includes("Initialized"), { kind: "custom" }],
|
|
55
|
+
] as const)("waitFor records its condition and bounds without an outer wait: %p", async (matcher, expected) => {
|
|
56
|
+
startRecording();
|
|
57
|
+
const s = await session();
|
|
58
|
+
try {
|
|
59
|
+
s.output("Booting\r\n");
|
|
60
|
+
const timer = setTimeout(() => s.output("Initialized\r\n"), 40);
|
|
61
|
+
try {
|
|
62
|
+
await s.term.waitFor("Rack is ready", matcher as string, { timeoutMs: 1000, intervalMs: 5 });
|
|
63
|
+
} finally { clearTimeout(timer); }
|
|
64
|
+
await s.term.press("Ctrl+C");
|
|
65
|
+
const events = stopRecording();
|
|
66
|
+
const wait = events.find(e => e.kind === "terminal-step" && e.action === "waitFor");
|
|
67
|
+
if (wait?.kind !== "terminal-step") throw new Error("Missing wait event");
|
|
68
|
+
expect(wait.matcher).toEqual(expected);
|
|
69
|
+
expect(wait.description).toBe("Rack is ready");
|
|
70
|
+
expect(wait.matched).toBe(true);
|
|
71
|
+
expect(wait.attempts).toBeGreaterThan(1);
|
|
72
|
+
expect(wait.timeoutMs).toBe(1000);
|
|
73
|
+
expect(wait.castEndTimeSec!).toBeGreaterThan(wait.castStartTimeSec!);
|
|
74
|
+
expect(wait.screenPreview).toContain("Initialized");
|
|
75
|
+
expect(wait.screenPreview).not.toContain("^C");
|
|
76
|
+
const press = events.find(e => e.kind === "terminal-step" && e.action === "press");
|
|
77
|
+
expect(press?.seq).toBeGreaterThan(wait.seq);
|
|
78
|
+
expect(events.filter(e => e.kind === "wait")).toHaveLength(0);
|
|
79
|
+
} finally { await s.close(); }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a quiet timeout records elapsed time even after the last terminal frame", async () => {
|
|
83
|
+
startRecording();
|
|
84
|
+
const s = await session();
|
|
85
|
+
try {
|
|
86
|
+
s.output("Still booting");
|
|
87
|
+
await expect(s.term.waitFor("Rack is ready", "Initialized", { timeoutMs: 60, intervalMs: 5 }))
|
|
88
|
+
.rejects.toThrow("timed out");
|
|
89
|
+
const wait = stopRecording().find(e => e.kind === "terminal-step" && e.action === "waitFor");
|
|
90
|
+
if (wait?.kind !== "terminal-step") throw new Error("Missing wait event");
|
|
91
|
+
expect(wait.matched).toBe(false);
|
|
92
|
+
expect(wait.durationMs).toBeGreaterThanOrEqual(60);
|
|
93
|
+
expect(wait.castEndTimeSec!).toBeGreaterThan(wait.castTimeSec);
|
|
94
|
+
expect(wait.screenPreview).toBe("Still booting");
|
|
95
|
+
} finally { await s.close(); }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
test("records a thrown predicate error once, preserving its identity and observed screen", async () => {
|
|
100
|
+
const s = await session();
|
|
101
|
+
startRecording();
|
|
102
|
+
try {
|
|
103
|
+
s.output("Initialization Failed");
|
|
104
|
+
const failure = new Error("Rack initialization failed");
|
|
105
|
+
await expect(s.term.waitFor("Initialized", async screen => {
|
|
106
|
+
if (screen.includes("Failed")) {
|
|
107
|
+
// The screen can change while an async predicate is running. Keep
|
|
108
|
+
// the frame that was actually checked as the failure evidence.
|
|
109
|
+
s.output("\r\nLater unrelated output");
|
|
110
|
+
await Bun.sleep(5);
|
|
111
|
+
throw failure;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}, { intervalMs: 2 })).rejects.toBe(failure);
|
|
115
|
+
const events = waitEvents();
|
|
116
|
+
expect(events).toHaveLength(1);
|
|
117
|
+
expect(events[0]).toMatchObject({ outcome:"predicate_error", matched:false,
|
|
118
|
+
error:failure.message, screenPreview:"Initialization Failed" });
|
|
119
|
+
} finally { await s.close(); }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("records declarative failure conditions and their outcome before accepting success", async () => {
|
|
123
|
+
const s = await session();
|
|
124
|
+
startRecording();
|
|
125
|
+
try {
|
|
126
|
+
s.output("Initialized\r\nInitialization Panicked");
|
|
127
|
+
await expect(s.term.waitFor("Rack initialized", "Initialized", { intervalMs:2,
|
|
128
|
+
failOn:[{description:"Rack initialization panicked",matcher:/Initialization Panicked/}],
|
|
129
|
+
})).rejects.toThrow("Rack initialization panicked");
|
|
130
|
+
expect(waitEvents()[0]).toMatchObject({outcome:"failure_matched", matched:false,
|
|
131
|
+
failureMatchers:[{description:"Rack initialization panicked",matcher:{kind:"regex",value:"Initialization Panicked",flags:""}}]});
|
|
132
|
+
} finally { await s.close(); }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("checks final drained output before accepting process exit, without waiting a poll interval", async () => {
|
|
136
|
+
const s = await session();
|
|
137
|
+
startRecording();
|
|
138
|
+
try {
|
|
139
|
+
const pending = s.term.waitFor("Ready", "Initialized", {intervalMs:10000,timeoutMs:1000});
|
|
140
|
+
s.exit(0, false);
|
|
141
|
+
s.output("Initialized");
|
|
142
|
+
s.endOutput();
|
|
143
|
+
await pending;
|
|
144
|
+
expect(waitEvents()[0]).toMatchObject({outcome:"matched",screenPreview:"Initialized"});
|
|
145
|
+
} finally { await s.close(); }
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a disconnect wakes a quiet wait and records the exit code", async () => {
|
|
149
|
+
const s = await session();
|
|
150
|
+
startRecording();
|
|
151
|
+
try {
|
|
152
|
+
const pending = s.term.waitFor("Ready", "Initialized", {intervalMs:10000,timeoutMs:1000});
|
|
153
|
+
s.output("Disconnected");
|
|
154
|
+
s.exit(255);
|
|
155
|
+
await expect(pending).rejects.toThrow("exited with code 255");
|
|
156
|
+
expect(waitEvents()[0]).toMatchObject({outcome:"process_exited",exitCode:255,screenPreview:"Disconnected"});
|
|
157
|
+
} finally { await s.close(); }
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("reading exited does not reserve an event before the process actually exits", async () => {
|
|
161
|
+
const s = await session();
|
|
162
|
+
startRecording();
|
|
163
|
+
try {
|
|
164
|
+
const exiting = s.term.exited;
|
|
165
|
+
expect(s.term.exited).toBe(exiting);
|
|
166
|
+
s.output("Ready");
|
|
167
|
+
await s.term.waitFor("Ready", "Ready", {intervalMs:2});
|
|
168
|
+
await s.term.press("Ctrl+C");
|
|
169
|
+
s.exit();
|
|
170
|
+
await exiting;
|
|
171
|
+
const events = stopRecording().filter(e => e.kind === "terminal-step");
|
|
172
|
+
expect(events.map(e => e.action)).toEqual(["waitFor", "press", "exit"]);
|
|
173
|
+
expect(events[2].tOffsetMs).toBeGreaterThanOrEqual(events[1].tOffsetMs);
|
|
174
|
+
} finally { await s.close(); }
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("abort stops polling, records cancellation once, and leaves the terminal usable", async () => {
|
|
178
|
+
const s = await session();
|
|
179
|
+
startRecording();
|
|
180
|
+
try {
|
|
181
|
+
const control = new AbortController();
|
|
182
|
+
let checks = 0;
|
|
183
|
+
const pending = s.term.waitFor("Ready", () => { checks++; return false; }, {signal:control.signal,intervalMs:5});
|
|
184
|
+
await Bun.sleep(10);
|
|
185
|
+
const failure = new Error("User cancelled");
|
|
186
|
+
control.abort(failure);
|
|
187
|
+
await expect(pending).rejects.toBe(failure);
|
|
188
|
+
const stopped = checks;
|
|
189
|
+
await Bun.sleep(15);
|
|
190
|
+
expect(checks).toBe(stopped);
|
|
191
|
+
expect(waitEvents()).toMatchObject([{outcome:"cancelled",error:"User cancelled"}]);
|
|
192
|
+
s.output("Ready");
|
|
193
|
+
await s.term.waitFor("Still usable", "Ready", {intervalMs:2});
|
|
194
|
+
} finally { await s.close(); }
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("async predicates are awaited and a hung predicate respects the deadline", async () => {
|
|
198
|
+
const s = await session();
|
|
199
|
+
startRecording();
|
|
200
|
+
try {
|
|
201
|
+
let checks = 0;
|
|
202
|
+
await s.term.waitFor("Async readiness", async () => ++checks === 2 ? "ready" : false,
|
|
203
|
+
{intervalMs:2,condition:"The async readiness check returns ready"});
|
|
204
|
+
expect(checks).toBe(2);
|
|
205
|
+
let finish!: (result: string) => void;
|
|
206
|
+
await expect(s.term.waitFor("Hung", () => new Promise<string>(resolve => {finish = resolve;}),
|
|
207
|
+
{timeoutMs:20})).rejects.toThrow("timed out");
|
|
208
|
+
finish("too late");
|
|
209
|
+
await Bun.sleep(5);
|
|
210
|
+
const events = waitEvents();
|
|
211
|
+
expect(events).toHaveLength(2);
|
|
212
|
+
expect(events[0]).toMatchObject({outcome:"matched",matcher:{kind:"custom",description:"The async readiness check returns ready"}});
|
|
213
|
+
expect(events[1]).toMatchObject({outcome:"timed_out",matched:false});
|
|
214
|
+
} finally { await s.close(); }
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("close sends EOF, cancels active waits, and is idempotent", async () => {
|
|
218
|
+
const s = await session({eofExits:true});
|
|
219
|
+
startRecording();
|
|
220
|
+
try {
|
|
221
|
+
const pending = s.term.waitFor("Ready", "Ready", {intervalMs:10000});
|
|
222
|
+
const rejected = pending.catch(error => error);
|
|
223
|
+
const closing = s.term.close();
|
|
224
|
+
expect(s.term.close()).toBe(closing);
|
|
225
|
+
await closing;
|
|
226
|
+
expect((await rejected).message).toContain("cancelled");
|
|
227
|
+
expect(s.writes).toContain("\x04");
|
|
228
|
+
expect(s.kills).toHaveLength(0);
|
|
229
|
+
const events = stopRecording().filter(e => e.kind === "terminal-step");
|
|
230
|
+
expect(events.filter(e => e.action === "close")).toHaveLength(1);
|
|
231
|
+
expect(events.find(e => e.action === "waitFor")).toMatchObject({outcome:"cancelled"});
|
|
232
|
+
} finally { await s.close(); }
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test.each(["process", "pipe"])("close has a deadline when the %s never finishes", async (stuck) => {
|
|
236
|
+
const s = await session({killExits:false});
|
|
237
|
+
startRecording();
|
|
238
|
+
try {
|
|
239
|
+
if (stuck === "pipe") s.exit(0, false);
|
|
240
|
+
const closing = s.term.close({timeoutMs:40});
|
|
241
|
+
await expect(closing).rejects.toThrow("close timed out");
|
|
242
|
+
expect(s.term.close()).toBe(closing);
|
|
243
|
+
if (stuck === "process") expect(s.kills).toContain("SIGKILL");
|
|
244
|
+
expect(stopRecording().filter(e => e.kind === "terminal-step" && e.action === "close"))
|
|
245
|
+
.toMatchObject([{error:"Terminal close timed out after 40ms"}]);
|
|
246
|
+
} finally { await s.close(); }
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test.skipIf(!Bun.which("script"))("real PTY drains its last screen and preserves the child exit status", async () => {
|
|
250
|
+
const spawn = Bun.spawn.bind(Bun);
|
|
251
|
+
const intercepted = spyOn(Bun, "spawn").mockImplementation(((args: string[], opts: unknown) => {
|
|
252
|
+
// Exercise the production PTY flags and streams, replacing only Docker.
|
|
253
|
+
return spawn([args[0], args[1], "printf 'Final status: Initialized\\n'; exit 7", args[3]], opts as never);
|
|
254
|
+
}) as typeof Bun.spawn);
|
|
255
|
+
startRecording();
|
|
256
|
+
let term: Awaited<ReturnType<typeof openTerminal>> | undefined;
|
|
257
|
+
try {
|
|
258
|
+
term = await openTerminal({service:"rack",sessionId:"real-pty",recordEvents:true,
|
|
259
|
+
sink:{pushFrame() {},markClosed() {}}});
|
|
260
|
+
await term.waitFor("Final status", "Final status: Initialized", {intervalMs:10000,timeoutMs:2000});
|
|
261
|
+
const result = await term.exited;
|
|
262
|
+
const { readRaw } = await import("./inspect.js");
|
|
263
|
+
expect(readRaw(result.exitCode)).toBe(7);
|
|
264
|
+
const events = stopRecording().filter(e => e.kind === "terminal-step");
|
|
265
|
+
expect(events.find(e => e.action === "waitFor")).toMatchObject({outcome:"matched",screenPreview:"Final status: Initialized"});
|
|
266
|
+
expect(events.find(e => e.action === "exit")).toMatchObject({exitCode:7});
|
|
267
|
+
} finally {
|
|
268
|
+
await term?.close();
|
|
269
|
+
intercepted.mockRestore();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("an already-aborted wait never calls its predicate", async () => {
|
|
274
|
+
const s = await session();
|
|
275
|
+
startRecording();
|
|
276
|
+
try {
|
|
277
|
+
let checks = 0;
|
|
278
|
+
const signal = AbortSignal.abort(new Error("Already cancelled"));
|
|
279
|
+
await expect(s.term.waitFor("Ready", () => { checks++; return true; }, {signal}))
|
|
280
|
+
.rejects.toThrow("Already cancelled");
|
|
281
|
+
expect(checks).toBe(0);
|
|
282
|
+
expect(waitEvents()).toMatchObject([{outcome:"cancelled",attempts:0}]);
|
|
283
|
+
} finally { await s.close(); }
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("an async check interrupted by exit rechecks the final screen", async () => {
|
|
287
|
+
const s = await session();
|
|
288
|
+
startRecording();
|
|
289
|
+
try {
|
|
290
|
+
let release!: (value: boolean) => void;
|
|
291
|
+
const pending = s.term.waitFor("Ready", screen => screen.includes("Ready") ||
|
|
292
|
+
new Promise<boolean>(resolve => { release = resolve; }), {timeoutMs:1000});
|
|
293
|
+
await Bun.sleep(5);
|
|
294
|
+
s.output("Ready");
|
|
295
|
+
s.exit();
|
|
296
|
+
await Bun.sleep(5);
|
|
297
|
+
release(false);
|
|
298
|
+
await pending;
|
|
299
|
+
expect(waitEvents()).toMatchObject([{outcome:"matched",screenPreview:"Ready",attempts:2}]);
|
|
300
|
+
} finally { await s.close(); }
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("close escalates after EOF and returns the real killed status", async () => {
|
|
304
|
+
const s = await session();
|
|
305
|
+
try {
|
|
306
|
+
const result = await s.term.close({timeoutMs:100});
|
|
307
|
+
const { readRaw } = await import("./inspect.js");
|
|
308
|
+
expect(readRaw(result.exitCode)).toBe(137);
|
|
309
|
+
expect(s.writes).toEqual(["\x04"]);
|
|
310
|
+
expect(s.kills).toEqual(["SIGKILL"]);
|
|
311
|
+
} finally { await s.close(); }
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("exit cannot wait forever for an inherited output pipe", async () => {
|
|
315
|
+
const s = await session();
|
|
316
|
+
startRecording();
|
|
317
|
+
try {
|
|
318
|
+
const waiting = s.term.waitFor("Ready", "Ready", {timeoutMs:8000,intervalMs:10000});
|
|
319
|
+
s.exit(0, false);
|
|
320
|
+
await expect(waiting).rejects.toThrow("did not finish draining");
|
|
321
|
+
expect(waitEvents()).toMatchObject([{outcome:"process_exited",exitCode:0}]);
|
|
322
|
+
await expect(s.term.exited).rejects.toThrow("did not finish draining");
|
|
323
|
+
} finally { await s.close(); }
|
|
324
|
+
}, 10000);
|