@zerotal/testing 1.0.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/package.json +56 -0
- package/src/TestApp.ts +573 -0
- package/src/TestExceptionHandler.ts +54 -0
- package/src/TestResponse.ts +953 -0
- package/src/assertions.ts +84 -0
- package/src/data.ts +4433 -0
- package/src/factory.ts +288 -0
- package/src/fake.ts +462 -0
- package/src/fakeFile.ts +229 -0
- package/src/global.d.ts +20 -0
- package/src/index.ts +30 -0
- package/src/migrateDatabase.ts +66 -0
- package/src/preload.ts +33 -0
- package/src/refreshDatabase.ts +115 -0
- package/src/resetTestState.ts +12 -0
- package/src/storageAssertions.ts +52 -0
- package/src/withDatabase.ts +52 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around a Fetch Response that adds assertion helpers.
|
|
3
|
+
* Returned by TestApp.request().
|
|
4
|
+
*
|
|
5
|
+
* The body is read once, at construction, and every assertion works off that
|
|
6
|
+
* buffered string. That is what lets `assertSee`/`assertJson` be synchronous
|
|
7
|
+
* like the status assertions beside them: an async assertion that reads
|
|
8
|
+
* identically to a sync one is a trap, because a forgotten `await` turns a
|
|
9
|
+
* failure into an unhandled rejection and the test passes green.
|
|
10
|
+
*
|
|
11
|
+
* Session data is decoded the same way: {@link TestApp} hands {@link TestResponse.of}
|
|
12
|
+
* a decoder bound to the application's own `session.driver`, which runs once
|
|
13
|
+
* while the response is being read. Decoding through the real driver is the
|
|
14
|
+
* only way the session assertions can be correct — the cookie format is the
|
|
15
|
+
* driver's business (the shipped one is authenticated encryption), so anything
|
|
16
|
+
* that parses the cookie itself is guessing, and a guess that fails silently
|
|
17
|
+
* turns `assertSessionMissing` into an assertion that always passes.
|
|
18
|
+
*
|
|
19
|
+
* Use {@link TestResponse.of} to build one from a `Response`; the constructor
|
|
20
|
+
* takes the already-read body so it can stay synchronous.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Decodes the session carried by a response, using the application's own
|
|
25
|
+
* session driver. Returns `null` when the response sets no session cookie.
|
|
26
|
+
*/
|
|
27
|
+
export type SessionDecoder = (response: Response) => Promise<Record<string, unknown> | null>;
|
|
28
|
+
|
|
29
|
+
/** Optional extras {@link TestApp} attaches while building a response. */
|
|
30
|
+
export interface TestResponseContext {
|
|
31
|
+
/** Decoder bound to the app's `session.driver`. */
|
|
32
|
+
session?: SessionDecoder | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The exception the request raised, captured by
|
|
35
|
+
* {@link TestApp.withoutExceptionHandling}. Included in assertion failures so
|
|
36
|
+
* a 500 reports the bug rather than the error page rendered from it.
|
|
37
|
+
*/
|
|
38
|
+
exception?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How much of the body to quote back in an assertion failure. */
|
|
42
|
+
const EXCERPT_LIMIT = 400;
|
|
43
|
+
|
|
44
|
+
export class TestResponse {
|
|
45
|
+
/**
|
|
46
|
+
* The decoded session: a data record, `null` when the response set no session
|
|
47
|
+
* cookie, or `undefined` when no decoder was available (a bare `TestResponse`
|
|
48
|
+
* built outside {@link TestApp}).
|
|
49
|
+
*/
|
|
50
|
+
private readonly _session: Record<string, unknown> | null | undefined;
|
|
51
|
+
private readonly _exception: unknown;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly _res: Response,
|
|
55
|
+
private readonly _body: string,
|
|
56
|
+
context: TestResponseContext & {
|
|
57
|
+
decodedSession?: Record<string, unknown> | null | undefined;
|
|
58
|
+
} = {},
|
|
59
|
+
) {
|
|
60
|
+
this._session = context.decodedSession;
|
|
61
|
+
this._exception = context.exception;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read a `Response`'s body (and session) and wrap it. The only async step in the class. */
|
|
65
|
+
static async of(res: Response, context: TestResponseContext = {}): Promise<TestResponse> {
|
|
66
|
+
const body = await res.clone().text();
|
|
67
|
+
let decodedSession: Record<string, unknown> | null | undefined = undefined;
|
|
68
|
+
if (context.session) {
|
|
69
|
+
// A driver that cannot decode (a rotated secret, an unreachable Redis) must
|
|
70
|
+
// not fail the request itself — it leaves the session `undefined`, which the
|
|
71
|
+
// session assertions report as unavailable rather than as absent.
|
|
72
|
+
try {
|
|
73
|
+
decodedSession = await context.session(res);
|
|
74
|
+
} catch {
|
|
75
|
+
decodedSession = undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return new TestResponse(res, body, { ...context, decodedSession });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The parsed JSON body. Throws with the raw text when it is not JSON. */
|
|
82
|
+
private _json<T = unknown>(): T {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(this._body) as T;
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error(
|
|
87
|
+
this._decorate(`Expected a JSON body but could not parse it.`, { body: true }),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get status(): number {
|
|
93
|
+
return this._res.status;
|
|
94
|
+
}
|
|
95
|
+
get ok(): boolean {
|
|
96
|
+
return this._res.ok;
|
|
97
|
+
}
|
|
98
|
+
get headers(): Headers {
|
|
99
|
+
return this._res.headers;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The exception the request raised, when the suite called
|
|
104
|
+
* {@link TestApp.withoutExceptionHandling}. `undefined` otherwise, and when
|
|
105
|
+
* the request completed without throwing.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* const res = await app.withoutExceptionHandling().get('/boom');
|
|
109
|
+
* expect(res.exception()).toBeInstanceOf(PaymentDeclinedError);
|
|
110
|
+
*/
|
|
111
|
+
exception(): unknown {
|
|
112
|
+
return this._exception;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Status assertions ─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/** Assert the HTTP status code. Chainable. */
|
|
118
|
+
assertStatus(expected: number): this {
|
|
119
|
+
if (this._res.status !== expected) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
this._decorate(`Expected HTTP ${expected} but got ${this._res.status}.`, { body: true }),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Assert HTTP 200 OK. */
|
|
128
|
+
assertOk(): this {
|
|
129
|
+
return this.assertStatus(200);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Assert HTTP 201 Created. */
|
|
133
|
+
assertCreated(): this {
|
|
134
|
+
return this.assertStatus(201);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Assert HTTP 204 No Content. */
|
|
138
|
+
assertNoContent(): this {
|
|
139
|
+
return this.assertStatus(204);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Assert HTTP 301 Moved Permanently. */
|
|
143
|
+
assertMovedPermanently(): this {
|
|
144
|
+
return this.assertStatus(301);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Assert HTTP 401 Unauthorized. */
|
|
148
|
+
assertUnauthorized(): this {
|
|
149
|
+
return this.assertStatus(401);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Assert HTTP 403 Forbidden. */
|
|
153
|
+
assertForbidden(): this {
|
|
154
|
+
return this.assertStatus(403);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Assert HTTP 404 Not Found. */
|
|
158
|
+
assertNotFound(): this {
|
|
159
|
+
return this.assertStatus(404);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Assert HTTP 422 Unprocessable Entity. */
|
|
163
|
+
assertUnprocessable(): this {
|
|
164
|
+
return this.assertStatus(422);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Assert HTTP 500 Internal Server Error. */
|
|
168
|
+
assertServerError(): this {
|
|
169
|
+
return this.assertStatus(500);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Assert the status is in the 2xx range. */
|
|
173
|
+
assertSuccessful(): this {
|
|
174
|
+
if (this._res.status < 200 || this._res.status > 299) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
this._decorate(`Expected a 2xx status but got HTTP ${this._res.status}.`, { body: true }),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Assert the response is a redirect to `url`. */
|
|
183
|
+
assertRedirect(url: string): this {
|
|
184
|
+
if (this._res.status < 300 || this._res.status > 399) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
this._decorate(`Expected a redirect but got HTTP ${this._res.status}.`, { body: true }),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const location = this._res.headers.get("Location") ?? "";
|
|
190
|
+
if (!location.includes(url)) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
this._decorate(`Expected redirect to "${url}" but Location was "${location}".`),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return this;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Header assertions ─────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Assert a response header is present and optionally matches a value.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* res.assertHeader('Content-Type');
|
|
205
|
+
* res.assertHeader('Content-Type', 'application/json');
|
|
206
|
+
*/
|
|
207
|
+
assertHeader(name: string, value?: string): this {
|
|
208
|
+
const actual = this._res.headers.get(name);
|
|
209
|
+
if (actual === null) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
this._decorate(`Expected response header "${name}" to be present, but it was absent.`, {
|
|
212
|
+
headers: true,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (value !== undefined && !actual.includes(value)) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
this._decorate(`Expected header "${name}" to contain "${value}" but got "${actual}".`),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return this;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Assert a response header is absent. */
|
|
225
|
+
assertHeaderMissing(name: string): this {
|
|
226
|
+
if (this._res.headers.get(name) !== null) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
this._decorate(`Expected header "${name}" to be absent, but it was present.`),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return this;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Body assertions ───────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parse the body as JSON and assert that every key in `expected` matches.
|
|
238
|
+
* Extra keys in the actual response are allowed.
|
|
239
|
+
*/
|
|
240
|
+
assertJson(expected: Record<string, unknown>): this {
|
|
241
|
+
const body = this._json<Record<string, unknown>>();
|
|
242
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
243
|
+
const actual = body[key];
|
|
244
|
+
if (JSON.stringify(actual) !== JSON.stringify(value)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
this._decorate(
|
|
247
|
+
`assertJson: expected body["${key}"] to equal ${JSON.stringify(value)} ` +
|
|
248
|
+
`but got ${JSON.stringify(actual)}.`,
|
|
249
|
+
{ body: true },
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Assert a value at a dot-notation path in the JSON body.
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* res.assertJsonPath('user.name', 'Alice');
|
|
262
|
+
* res.assertJsonPath('data.0.id', 1);
|
|
263
|
+
*/
|
|
264
|
+
assertJsonPath(path: string, expected: unknown): this {
|
|
265
|
+
const body = this._json<Record<string, unknown>>();
|
|
266
|
+
const actual = _getPath(body, path);
|
|
267
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
this._decorate(
|
|
270
|
+
`assertJsonPath("${path}"): expected ${JSON.stringify(expected)} ` +
|
|
271
|
+
`but got ${JSON.stringify(actual)}.`,
|
|
272
|
+
{ body: true },
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return this;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Assert the JSON body (or a key within it) is an array of the given length.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* res.assertJsonCount(3); // top-level array
|
|
284
|
+
* res.assertJsonCount(3, 'data'); // array at body.data
|
|
285
|
+
*/
|
|
286
|
+
assertJsonCount(count: number, key?: string): this {
|
|
287
|
+
const body = this._json();
|
|
288
|
+
const target = key ? _getPath(body as Record<string, unknown>, key) : body;
|
|
289
|
+
if (!Array.isArray(target)) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
this._decorate(
|
|
292
|
+
`assertJsonCount: expected ${key ? `"${key}"` : "body"} to be an array, ` +
|
|
293
|
+
`but got ${typeof target}.`,
|
|
294
|
+
{ body: true },
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (target.length !== count) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
this._decorate(
|
|
301
|
+
`assertJsonCount: expected ${count} item(s) ` +
|
|
302
|
+
`${key ? `at "${key}"` : ""} but got ${target.length}.`,
|
|
303
|
+
{ body: true },
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return this;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Assert the response body contains the given string.
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* res.assertSee('Welcome, Alice');
|
|
315
|
+
*/
|
|
316
|
+
assertSee(needle: string): this {
|
|
317
|
+
if (!this._body.includes(needle)) {
|
|
318
|
+
throw new Error(this._decorate(`Expected body to contain "${needle}".`, { body: true }));
|
|
319
|
+
}
|
|
320
|
+
return this;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Assert the response body does NOT contain the given string.
|
|
325
|
+
*
|
|
326
|
+
* @example
|
|
327
|
+
* res.assertDontSee('Error');
|
|
328
|
+
*/
|
|
329
|
+
assertDontSee(needle: string): this {
|
|
330
|
+
if (this._body.includes(needle)) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
this._decorate(`Expected body NOT to contain "${needle}", but it does.`, { body: true }),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return this;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Assert the response body contains the given string. Alias of assertSee. */
|
|
339
|
+
assertBodyContains(needle: string): this {
|
|
340
|
+
return this.assertSee(needle);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Assert the body contains `needle` once tags are stripped — the text a user
|
|
345
|
+
* would actually read. Use it when markup sits between the words you expect,
|
|
346
|
+
* which is what makes a plain {@link assertSee} on rendered HTML brittle.
|
|
347
|
+
*
|
|
348
|
+
* @example
|
|
349
|
+
* // Passes against `<strong>Welcome</strong>, <em>Alice</em>`
|
|
350
|
+
* res.assertSeeText('Welcome, Alice');
|
|
351
|
+
*/
|
|
352
|
+
assertSeeText(needle: string): this {
|
|
353
|
+
const text = _stripTags(this._body);
|
|
354
|
+
if (!text.includes(needle)) {
|
|
355
|
+
throw new Error(this._decorate(`Expected body text to contain "${needle}".`, { body: true }));
|
|
356
|
+
}
|
|
357
|
+
return this;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Assert the tag-stripped body does NOT contain `needle`. */
|
|
361
|
+
assertDontSeeText(needle: string): this {
|
|
362
|
+
const text = _stripTags(this._body);
|
|
363
|
+
if (text.includes(needle)) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
this._decorate(`Expected body text NOT to contain "${needle}", but it does.`, {
|
|
366
|
+
body: true,
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return this;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Parse and return the full JSON body. */
|
|
374
|
+
json<T = unknown>(): T {
|
|
375
|
+
return this._json<T>();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Return the full response body as text. */
|
|
379
|
+
text(): string {
|
|
380
|
+
return this._body;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Validation assertions ─────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The validation errors this response carries, from whichever place the
|
|
387
|
+
* response put them: a JSON API gets `422 { errors: {...} }`, while a form
|
|
388
|
+
* submit gets a redirect with the errors flashed to the session. Returns
|
|
389
|
+
* `null` when the response carries none.
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* const errors = res.validationErrors();
|
|
393
|
+
*/
|
|
394
|
+
validationErrors(): Record<string, string[]> | null {
|
|
395
|
+
// JSON clients: `{ message, errors }` with a 422.
|
|
396
|
+
try {
|
|
397
|
+
const body = JSON.parse(this._body) as { errors?: unknown };
|
|
398
|
+
const normalised = _normaliseErrors(body?.errors);
|
|
399
|
+
if (normalised) return normalised;
|
|
400
|
+
} catch {
|
|
401
|
+
// Not JSON — fall through to the session.
|
|
402
|
+
}
|
|
403
|
+
// Form submits: flashed to the session and redirected back.
|
|
404
|
+
if (this._session) return _normaliseErrors(this._session["errors"]);
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Assert the request failed validation, optionally naming the fields.
|
|
410
|
+
*
|
|
411
|
+
* Covers both shapes a failed validation takes: the `422` JSON body an API
|
|
412
|
+
* client receives, and the errors a form submit flashes to the session before
|
|
413
|
+
* redirecting back. You assert the same way for either.
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* res.assertInvalid(); // failed on something
|
|
417
|
+
* res.assertInvalid('email'); // failed on email
|
|
418
|
+
* res.assertInvalid(['email', 'password']); // failed on both
|
|
419
|
+
* res.assertInvalid({ email: 'required' }); // and the message contains "required"
|
|
420
|
+
*/
|
|
421
|
+
assertInvalid(fields?: string | string[] | Record<string, string>): this {
|
|
422
|
+
const errors = this.validationErrors();
|
|
423
|
+
if (errors === null || Object.keys(errors).length === 0) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
this._decorate("assertInvalid: expected validation errors but the response had none.", {
|
|
426
|
+
body: true,
|
|
427
|
+
session: true,
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (fields === undefined) return this;
|
|
432
|
+
|
|
433
|
+
const expected: Record<string, string | null> =
|
|
434
|
+
typeof fields === "string"
|
|
435
|
+
? { [fields]: null }
|
|
436
|
+
: Array.isArray(fields)
|
|
437
|
+
? Object.fromEntries(fields.map((f) => [f, null]))
|
|
438
|
+
: fields;
|
|
439
|
+
|
|
440
|
+
for (const [field, message] of Object.entries(expected)) {
|
|
441
|
+
const messages = errors[field];
|
|
442
|
+
if (!messages) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
this._decorate(
|
|
445
|
+
`assertInvalid: expected a validation error for "${field}" but the failing ` +
|
|
446
|
+
`fields were [${Object.keys(errors).join(", ")}].`,
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (message !== null && !messages.some((m) => m.includes(message))) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
this._decorate(
|
|
453
|
+
`assertInvalid: expected the "${field}" error to contain "${message}" but ` +
|
|
454
|
+
`got ${JSON.stringify(messages)}.`,
|
|
455
|
+
),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return this;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Assert the response carries no validation errors — for the named fields
|
|
464
|
+
* when given, or for any field at all when called bare.
|
|
465
|
+
*
|
|
466
|
+
* @example
|
|
467
|
+
* res.assertValid(); // nothing failed
|
|
468
|
+
* res.assertValid('email'); // email in particular did not fail
|
|
469
|
+
*/
|
|
470
|
+
assertValid(fields?: string | string[]): this {
|
|
471
|
+
const errors = this.validationErrors();
|
|
472
|
+
if (errors === null) return this;
|
|
473
|
+
|
|
474
|
+
if (fields === undefined) {
|
|
475
|
+
const failing = Object.keys(errors);
|
|
476
|
+
if (failing.length > 0) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
this._decorate(
|
|
479
|
+
`assertValid: expected no validation errors but [${failing.join(", ")}] failed: ` +
|
|
480
|
+
`${JSON.stringify(errors)}.`,
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return this;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
for (const field of Array.isArray(fields) ? fields : [fields]) {
|
|
488
|
+
if (errors[field]) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
this._decorate(
|
|
491
|
+
`assertValid: expected "${field}" to pass validation but it failed with ` +
|
|
492
|
+
`${JSON.stringify(errors[field])}.`,
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return this;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Cookie assertions ─────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Assert that the response sets a cookie with the given name.
|
|
504
|
+
* Optionally assert its value.
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* res.assertCookie('zerotal_session');
|
|
508
|
+
* res.assertCookie('theme', 'dark');
|
|
509
|
+
*/
|
|
510
|
+
assertCookie(name: string, value?: string): this {
|
|
511
|
+
const cookies = _parseCookies(this._res.headers);
|
|
512
|
+
if (!(name in cookies)) {
|
|
513
|
+
const set = Object.keys(cookies);
|
|
514
|
+
throw new Error(
|
|
515
|
+
this._decorate(
|
|
516
|
+
`Expected response to set cookie "${name}" but it was not found. ` +
|
|
517
|
+
(set.length ? `Cookies set: [${set.join(", ")}].` : "No cookies were set."),
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
if (value !== undefined && cookies[name] !== value) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
this._decorate(`Expected cookie "${name}" to equal "${value}" but got "${cookies[name]}".`),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
return this;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Assert that the response does NOT set a cookie with the given name.
|
|
531
|
+
*
|
|
532
|
+
* @example
|
|
533
|
+
* res.assertCookieMissing('remember_me');
|
|
534
|
+
*/
|
|
535
|
+
assertCookieMissing(name: string): this {
|
|
536
|
+
const cookies = _parseCookies(this._res.headers);
|
|
537
|
+
if (name in cookies) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
this._decorate(`Expected response NOT to set cookie "${name}" but it was found.`),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
return this;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── Session assertions ────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The session the response carries, decoded through the app's own session
|
|
549
|
+
* driver. `null` when the response set no session cookie.
|
|
550
|
+
*
|
|
551
|
+
* @throws When the response was not produced by a {@link TestApp} with a
|
|
552
|
+
* resolvable `session.driver` — there is nothing to decode with.
|
|
553
|
+
*
|
|
554
|
+
* @example
|
|
555
|
+
* expect(res.session()?.['cart_id']).toBe(7);
|
|
556
|
+
*/
|
|
557
|
+
session(): Record<string, unknown> | null {
|
|
558
|
+
if (this._session === undefined) throw new Error(_SESSION_UNAVAILABLE);
|
|
559
|
+
return this._session;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Assert that the session contains the given key (optionally matching value).
|
|
564
|
+
*
|
|
565
|
+
* @example
|
|
566
|
+
* res.assertSessionHas('status', 'saved');
|
|
567
|
+
* res.assertSessionHas('user_id');
|
|
568
|
+
*/
|
|
569
|
+
assertSessionHas(key: string, value?: unknown): this {
|
|
570
|
+
const data = this.session();
|
|
571
|
+
if (data === null) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
this._decorate("assertSessionHas: the response set no session cookie.", { headers: true }),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (!(key in data)) {
|
|
577
|
+
throw new Error(
|
|
578
|
+
this._decorate(
|
|
579
|
+
`Expected session to contain key "${key}" but the session held ` +
|
|
580
|
+
`[${Object.keys(data).join(", ")}].`,
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
if (value !== undefined) {
|
|
585
|
+
const actual = data[key];
|
|
586
|
+
if (JSON.stringify(actual) !== JSON.stringify(value)) {
|
|
587
|
+
throw new Error(
|
|
588
|
+
this._decorate(
|
|
589
|
+
`Expected session["${key}"] to equal ${JSON.stringify(value)} but got ${JSON.stringify(actual)}.`,
|
|
590
|
+
),
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return this;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Assert that the session does NOT contain the given key.
|
|
599
|
+
*
|
|
600
|
+
* A session that cannot be decoded throws rather than passing: "I could not
|
|
601
|
+
* read the session" is not evidence that the key is absent, and treating it
|
|
602
|
+
* as such is an assertion that can never fail.
|
|
603
|
+
*
|
|
604
|
+
* @example
|
|
605
|
+
* res.assertSessionMissing('errors');
|
|
606
|
+
*/
|
|
607
|
+
assertSessionMissing(key: string): this {
|
|
608
|
+
const data = this.session();
|
|
609
|
+
if (data === null) return this; // no session cookie at all — the key is genuinely absent
|
|
610
|
+
if (key in data) {
|
|
611
|
+
throw new Error(
|
|
612
|
+
this._decorate(
|
|
613
|
+
`Expected session NOT to contain key "${key}" but it held ` +
|
|
614
|
+
`${JSON.stringify(data[key])}.`,
|
|
615
|
+
),
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
return this;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Assert the session carries flashed validation errors, optionally for the
|
|
623
|
+
* named fields. The form-submit counterpart of {@link assertInvalid}.
|
|
624
|
+
*
|
|
625
|
+
* @example
|
|
626
|
+
* res.assertSessionHasErrors(['email']);
|
|
627
|
+
*/
|
|
628
|
+
assertSessionHasErrors(fields?: string | string[]): this {
|
|
629
|
+
this.assertSessionHas("errors");
|
|
630
|
+
if (fields === undefined) return this;
|
|
631
|
+
return this.assertInvalid(fields);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Assert the session carries no flashed validation errors. */
|
|
635
|
+
assertSessionHasNoErrors(): this {
|
|
636
|
+
return this.assertSessionMissing("errors");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ── Auth assertions ───────────────────────────────────────────────────
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Assert the response leaves someone signed in — the session carries the
|
|
643
|
+
* `user_id` that {@link AuthSessionMiddleware} hydrates `ctx.user` from.
|
|
644
|
+
*
|
|
645
|
+
* @example
|
|
646
|
+
* const res = await app.followingRedirects().post('/login', creds);
|
|
647
|
+
* res.assertAuthenticated();
|
|
648
|
+
*/
|
|
649
|
+
assertAuthenticated(): this {
|
|
650
|
+
const data = this.session();
|
|
651
|
+
if (data === null || data["user_id"] === undefined || data["user_id"] === null) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
this._decorate(
|
|
654
|
+
"assertAuthenticated: expected the session to hold a user_id, but the request " +
|
|
655
|
+
"ended as a guest.",
|
|
656
|
+
{ session: true },
|
|
657
|
+
),
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
return this;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Assert the response leaves the given user signed in.
|
|
665
|
+
*
|
|
666
|
+
* Accepts the user or a bare id, and compares loosely across the number/string
|
|
667
|
+
* divide — a session round-trips through JSON, so an integer key can come back
|
|
668
|
+
* either way depending on the driver.
|
|
669
|
+
*
|
|
670
|
+
* @example
|
|
671
|
+
* res.assertAuthenticatedAs(user);
|
|
672
|
+
* res.assertAuthenticatedAs(42);
|
|
673
|
+
*/
|
|
674
|
+
assertAuthenticatedAs(user: { id: number | string } | number | string): this {
|
|
675
|
+
this.assertAuthenticated();
|
|
676
|
+
const expected = typeof user === "object" ? user.id : user;
|
|
677
|
+
const actual = this.session()?.["user_id"];
|
|
678
|
+
if (String(actual) !== String(expected)) {
|
|
679
|
+
throw new Error(
|
|
680
|
+
this._decorate(
|
|
681
|
+
`assertAuthenticatedAs: expected user ${JSON.stringify(expected)} to be signed in ` +
|
|
682
|
+
`but the session held ${JSON.stringify(actual)}.`,
|
|
683
|
+
),
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
return this;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Assert the response leaves nobody signed in. */
|
|
690
|
+
assertGuest(): this {
|
|
691
|
+
const data = this.session();
|
|
692
|
+
const userId = data?.["user_id"];
|
|
693
|
+
if (userId !== undefined && userId !== null) {
|
|
694
|
+
throw new Error(
|
|
695
|
+
this._decorate(
|
|
696
|
+
`assertGuest: expected nobody to be signed in but the session held ` +
|
|
697
|
+
`user_id ${JSON.stringify(userId)}.`,
|
|
698
|
+
),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
return this;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── Inertia assertions ────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The Inertia page object this response carries, from either shape the
|
|
708
|
+
* protocol uses: the JSON body of an `X-Inertia` visit, or the
|
|
709
|
+
* `<script data-page>` payload embedded in a full page load. `null` when the
|
|
710
|
+
* response is not an Inertia response.
|
|
711
|
+
*/
|
|
712
|
+
inertia(): InertiaPage | null {
|
|
713
|
+
if (this._res.headers.get("X-Inertia") === "true") {
|
|
714
|
+
try {
|
|
715
|
+
return JSON.parse(this._body) as InertiaPage;
|
|
716
|
+
} catch {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const match = /<script[^>]*data-page="app"[^>]*>([\s\S]*?)<\/script>/.exec(this._body);
|
|
721
|
+
if (!match?.[1]) return null;
|
|
722
|
+
try {
|
|
723
|
+
return JSON.parse(match[1]) as InertiaPage;
|
|
724
|
+
} catch {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Assert the response rendered an Inertia page — optionally the named
|
|
731
|
+
* component, optionally carrying the given props.
|
|
732
|
+
*
|
|
733
|
+
* Props are matched partially, so you assert the ones the test is about and
|
|
734
|
+
* ignore the shared props riding along with every page.
|
|
735
|
+
*
|
|
736
|
+
* @example
|
|
737
|
+
* res.assertInertia('Posts/Index');
|
|
738
|
+
* res.assertInertia('Posts/Show', { post: { id: 1, title: 'Hello' } });
|
|
739
|
+
*/
|
|
740
|
+
assertInertia(component?: string, props?: Record<string, unknown>): this {
|
|
741
|
+
const page = this.inertia();
|
|
742
|
+
if (page === null) {
|
|
743
|
+
throw new Error(
|
|
744
|
+
this._decorate("assertInertia: the response carries no Inertia page object.", {
|
|
745
|
+
body: true,
|
|
746
|
+
}),
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
if (component !== undefined && page.component !== component) {
|
|
750
|
+
throw new Error(
|
|
751
|
+
this._decorate(
|
|
752
|
+
`assertInertia: expected component "${component}" but rendered "${page.component}".`,
|
|
753
|
+
),
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
if (props !== undefined) {
|
|
757
|
+
for (const [key, value] of Object.entries(props)) {
|
|
758
|
+
const actual = page.props?.[key];
|
|
759
|
+
if (JSON.stringify(actual) !== JSON.stringify(value)) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
this._decorate(
|
|
762
|
+
`assertInertia: expected prop "${key}" to equal ${JSON.stringify(value)} ` +
|
|
763
|
+
`but got ${JSON.stringify(actual)}. Props present: ` +
|
|
764
|
+
`[${Object.keys(page.props ?? {}).join(", ")}].`,
|
|
765
|
+
),
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return this;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Assert the Inertia page carries the named prop, optionally matching a value. */
|
|
774
|
+
assertInertiaProp(key: string, value?: unknown): this {
|
|
775
|
+
const page = this.inertia();
|
|
776
|
+
if (page === null) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
this._decorate("assertInertiaProp: the response carries no Inertia page object.", {
|
|
779
|
+
body: true,
|
|
780
|
+
}),
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
const props = page.props ?? {};
|
|
784
|
+
if (!(key in props)) {
|
|
785
|
+
throw new Error(
|
|
786
|
+
this._decorate(
|
|
787
|
+
`assertInertiaProp: expected prop "${key}" but the page carried ` +
|
|
788
|
+
`[${Object.keys(props).join(", ")}].`,
|
|
789
|
+
),
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
if (value !== undefined && JSON.stringify(props[key]) !== JSON.stringify(value)) {
|
|
793
|
+
throw new Error(
|
|
794
|
+
this._decorate(
|
|
795
|
+
`assertInertiaProp: expected "${key}" to equal ${JSON.stringify(value)} ` +
|
|
796
|
+
`but got ${JSON.stringify(props[key])}.`,
|
|
797
|
+
),
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
return this;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ── Private helpers ───────────────────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Attach the context that turns a bare expectation mismatch into something
|
|
807
|
+
* you can act on: the exception the request actually raised, an excerpt of
|
|
808
|
+
* the body, the headers, the session. An assertion that says only
|
|
809
|
+
* "expected 200, got 500" makes you re-run the test by hand to learn why.
|
|
810
|
+
*/
|
|
811
|
+
private _decorate(
|
|
812
|
+
message: string,
|
|
813
|
+
include: { body?: boolean; headers?: boolean; session?: boolean } = {},
|
|
814
|
+
): string {
|
|
815
|
+
const parts = [message];
|
|
816
|
+
|
|
817
|
+
if (this._exception !== undefined) {
|
|
818
|
+
const error = this._exception;
|
|
819
|
+
const detail =
|
|
820
|
+
error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error);
|
|
821
|
+
parts.push(`\nThe request raised:\n${_indent(detail)}`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (include.body) {
|
|
825
|
+
parts.push(`\nResponse body (${this._res.status}):\n${_indent(this._excerpt())}`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (include.headers) {
|
|
829
|
+
const headers = [...this._res.headers.entries()]
|
|
830
|
+
.map(([name, value]) => `${name}: ${value}`)
|
|
831
|
+
.join("\n");
|
|
832
|
+
parts.push(`\nResponse headers:\n${_indent(headers || "<none>")}`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (include.session && this._session) {
|
|
836
|
+
parts.push(`\nSession:\n${_indent(JSON.stringify(this._session, null, 2))}`);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return parts.join("\n");
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* A readable excerpt of the body. JSON is pretty-printed and HTML is reduced
|
|
844
|
+
* to its text, because the useful part of a failing error page is the message
|
|
845
|
+
* buried in it, not the markup around it.
|
|
846
|
+
*/
|
|
847
|
+
private _excerpt(limit = EXCERPT_LIMIT): string {
|
|
848
|
+
const body = this._body.trim();
|
|
849
|
+
if (!body) return "<empty body>";
|
|
850
|
+
|
|
851
|
+
const type = this._res.headers.get("Content-Type") ?? "";
|
|
852
|
+
|
|
853
|
+
if (type.includes("json") || body.startsWith("{") || body.startsWith("[")) {
|
|
854
|
+
try {
|
|
855
|
+
return _truncate(JSON.stringify(JSON.parse(body), null, 2), limit);
|
|
856
|
+
} catch {
|
|
857
|
+
// Not actually JSON — fall through.
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (type.includes("html") || body.startsWith("<")) {
|
|
862
|
+
const text = _stripTags(body);
|
|
863
|
+
return _truncate(text || "<no text content>", limit);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return _truncate(body, limit);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** The page object the Inertia protocol puts on the wire. */
|
|
871
|
+
export interface InertiaPage {
|
|
872
|
+
component: string;
|
|
873
|
+
props: Record<string, unknown>;
|
|
874
|
+
url: string;
|
|
875
|
+
version: string | null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const _SESSION_UNAVAILABLE =
|
|
879
|
+
"Session assertions need a response produced by a TestApp whose container can " +
|
|
880
|
+
"resolve `session.driver` — the session is decoded through the app's own driver. " +
|
|
881
|
+
"Register SessionProvider (or bind `session.driver`) in the app you pass to createTestApp().";
|
|
882
|
+
|
|
883
|
+
/** Resolve a dot-notation path inside a nested object/array. */
|
|
884
|
+
function _getPath(obj: unknown, path: string): unknown {
|
|
885
|
+
return path.split(".").reduce<unknown>((cur, key) => {
|
|
886
|
+
if (cur === null || cur === undefined) return undefined;
|
|
887
|
+
if (Array.isArray(cur)) {
|
|
888
|
+
const i = parseInt(key, 10);
|
|
889
|
+
return isNaN(i) ? undefined : cur[i];
|
|
890
|
+
}
|
|
891
|
+
return (cur as Record<string, unknown>)[key];
|
|
892
|
+
}, obj);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Parse all Set-Cookie headers from a response into a name→value map.
|
|
897
|
+
* Only extracts the cookie name=value pair (ignores Path, Secure, HttpOnly etc.).
|
|
898
|
+
*/
|
|
899
|
+
function _parseCookies(headers: Headers): Record<string, string> {
|
|
900
|
+
const result: Record<string, string> = {};
|
|
901
|
+
headers.forEach((value, name) => {
|
|
902
|
+
if (name.toLowerCase() !== "set-cookie") return;
|
|
903
|
+
// A single Set-Cookie header may contain one cookie directive
|
|
904
|
+
const pair = value.split(";")[0]?.trim() ?? "";
|
|
905
|
+
const eqIdx = pair.indexOf("=");
|
|
906
|
+
if (eqIdx === -1) return;
|
|
907
|
+
const cookieName = pair.slice(0, eqIdx).trim();
|
|
908
|
+
const cookieValue = pair.slice(eqIdx + 1).trim();
|
|
909
|
+
result[cookieName] = cookieValue;
|
|
910
|
+
});
|
|
911
|
+
return result;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Coerce the several shapes validation errors arrive in — `{ field: "msg" }`,
|
|
916
|
+
* `{ field: ["msg"] }` — into one `{ field: string[] }`, so the assertions
|
|
917
|
+
* above have a single thing to reason about.
|
|
918
|
+
*/
|
|
919
|
+
function _normaliseErrors(raw: unknown): Record<string, string[]> | null {
|
|
920
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
921
|
+
const out: Record<string, string[]> = {};
|
|
922
|
+
for (const [field, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
923
|
+
if (Array.isArray(value)) out[field] = value.map((v) => String(v));
|
|
924
|
+
else if (value !== null && value !== undefined) out[field] = [String(value)];
|
|
925
|
+
}
|
|
926
|
+
return out;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Reduce markup to the text a reader would see. */
|
|
930
|
+
function _stripTags(html: string): string {
|
|
931
|
+
return html
|
|
932
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
|
|
933
|
+
.replace(/<[^>]*>/g, " ")
|
|
934
|
+
.replace(/ /g, " ")
|
|
935
|
+
.replace(/&/g, "&")
|
|
936
|
+
.replace(/</g, "<")
|
|
937
|
+
.replace(/>/g, ">")
|
|
938
|
+
.replace(/"/g, '"')
|
|
939
|
+
.replace(/'/g, "'")
|
|
940
|
+
.replace(/\s+/g, " ")
|
|
941
|
+
.trim();
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function _truncate(text: string, limit: number): string {
|
|
945
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}… (${text.length} chars total)`;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function _indent(text: string): string {
|
|
949
|
+
return text
|
|
950
|
+
.split("\n")
|
|
951
|
+
.map((line) => ` ${line}`)
|
|
952
|
+
.join("\n");
|
|
953
|
+
}
|