@proteinjs/db-driver-knex 1.13.9 → 1.14.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -0
  3. package/dist/generated/index.js +1 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/generated/test/index.js +1 -1
  6. package/dist/generated/test/index.js.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/src/KnexDriver.d.ts +66 -0
  12. package/dist/src/KnexDriver.d.ts.map +1 -1
  13. package/dist/src/KnexDriver.js +181 -1
  14. package/dist/src/KnexDriver.js.map +1 -1
  15. package/dist/src/KnexLogValues.d.ts +38 -0
  16. package/dist/src/KnexLogValues.d.ts.map +1 -0
  17. package/dist/src/KnexLogValues.js +47 -0
  18. package/dist/src/KnexLogValues.js.map +1 -0
  19. package/dist/test/LogValuesSwitch.test.d.ts +2 -0
  20. package/dist/test/LogValuesSwitch.test.d.ts.map +1 -0
  21. package/dist/test/LogValuesSwitch.test.js +255 -0
  22. package/dist/test/LogValuesSwitch.test.js.map +1 -0
  23. package/dist/test/StatementLogNeverThrows.test.d.ts +2 -0
  24. package/dist/test/StatementLogNeverThrows.test.d.ts.map +1 -0
  25. package/dist/test/StatementLogNeverThrows.test.js +541 -0
  26. package/dist/test/StatementLogNeverThrows.test.js.map +1 -0
  27. package/dist/test/StatementParamLogging.test.d.ts +2 -0
  28. package/dist/test/StatementParamLogging.test.d.ts.map +1 -0
  29. package/dist/test/StatementParamLogging.test.js +403 -0
  30. package/dist/test/StatementParamLogging.test.js.map +1 -0
  31. package/dist/test/util/printedLine.d.ts +20 -0
  32. package/dist/test/util/printedLine.d.ts.map +1 -0
  33. package/dist/test/util/printedLine.js +48 -0
  34. package/dist/test/util/printedLine.js.map +1 -0
  35. package/generated/index.ts +1 -1
  36. package/generated/test/index.ts +1 -1
  37. package/index.ts +1 -0
  38. package/package.json +4 -4
  39. package/src/KnexDriver.ts +191 -2
  40. package/src/KnexLogValues.ts +42 -0
  41. package/test/LogValuesSwitch.test.ts +201 -0
  42. package/test/StatementLogNeverThrows.test.ts +466 -0
  43. package/test/StatementParamLogging.test.ts +313 -0
  44. package/test/util/printedLine.ts +43 -0
@@ -0,0 +1,466 @@
1
+ import knex from 'knex';
2
+ import { inspect } from 'util';
3
+ import { KnexDriver } from '@proteinjs/db-driver-knex';
4
+ import { Logger } from '@proteinjs/logger';
5
+ import { CapturedLog, lineOf } from './util/printedLine';
6
+
7
+ /**
8
+ * Writing a log line can NEVER change what the driver throws. The failure line is written on the
9
+ * way to `throw error`, so anything that goes wrong while building or writing it would replace the
10
+ * vendor's error with its own — a caller that matches on the vendor's code would stop matching, and
11
+ * the failure line itself would be lost.
12
+ *
13
+ * Two layers hold that, and this suite holds both:
14
+ *
15
+ * - the helpers that build the line (`describeParams`, `causeSummary`) are TOTAL: whatever they
16
+ * are handed — positional bindings, a bindings dictionary, nothing, null, a scalar, a Map, a
17
+ * value whose accessor or `toJSON` throws, a proxy that refuses everything — they return a
18
+ * description and never a value;
19
+ * - the line is written through ONE guarded door (`writeStatementLine`): if building or writing it
20
+ * throws anyway — a log writer that is down, a serializer meeting a value it cannot print — the
21
+ * failure is reported as one FIXED line that carries nothing of the statement, and the driver
22
+ * goes on to throw exactly what it was going to throw.
23
+ *
24
+ * No server is needed: the REAL query layer and dialect run over a stub connection that rejects
25
+ * the way the client library does. The stub server's message quotes no bound value (what the
26
+ * SERVER echoes is not this suite's subject; see StatementParamLogging.test.ts's header).
27
+ */
28
+
29
+ // A fixture value shaped like row content (not a real credential).
30
+ const CANARY = 'rst_5d41402abc4b2a76b9719d911017c592';
31
+
32
+ type ParamDescription = { type: string; length?: number; null?: true };
33
+ type ParamsDescription = ParamDescription[] | { [name: string]: ParamDescription } | 'unreadable';
34
+ type DriverInternals = {
35
+ logger: Logger;
36
+ describeParams: (params?: unknown) => ParamsDescription | undefined;
37
+ causeSummary: (error: unknown) => { [fact: string]: unknown };
38
+ };
39
+ type DriverStatics = { KNEX?: unknown };
40
+
41
+ /** What a throwing accessor throws: one object, so a test can tell it from anything else. */
42
+ const ACCESSOR_ERROR = new Error('accessor refused');
43
+ const throwingEntry = (target: object, name: string | number): any =>
44
+ Object.defineProperty(target, name, {
45
+ enumerable: true,
46
+ get: () => {
47
+ throw ACCESSOR_ERROR;
48
+ },
49
+ });
50
+ const refusesEverything = (target: object): any =>
51
+ new Proxy(target, {
52
+ get: () => {
53
+ throw ACCESSOR_ERROR;
54
+ },
55
+ has: () => {
56
+ throw ACCESSOR_ERROR;
57
+ },
58
+ ownKeys: () => {
59
+ throw ACCESSOR_ERROR;
60
+ },
61
+ getPrototypeOf: () => {
62
+ throw ACCESSOR_ERROR;
63
+ },
64
+ getOwnPropertyDescriptor: () => {
65
+ throw ACCESSOR_ERROR;
66
+ },
67
+ });
68
+ const revoked = (target: object): any => {
69
+ const { proxy, revoke } = Proxy.revocable(target, {});
70
+ revoke();
71
+ return proxy;
72
+ };
73
+ /** An array that answers every read — its `length` included — with the value itself. */
74
+ const lyingArray = (): any => new Proxy([CANARY], { get: () => CANARY });
75
+ class Credentials {
76
+ token = CANARY;
77
+ }
78
+
79
+ /** Every shape the driver can be handed as a statement's bindings, and the description each one gets. */
80
+ const BINDINGS: [string, () => unknown, ParamsDescription | undefined][] = [
81
+ ['positional bindings', () => [CANARY, 3], [{ type: 'string', length: CANARY.length }, { type: 'number' }]],
82
+ [
83
+ 'a bindings dictionary',
84
+ () => ({ token: CANARY, n: 3 }),
85
+ { token: { type: 'string', length: 36 }, n: { type: 'number' } },
86
+ ],
87
+ [
88
+ 'a dictionary with no prototype',
89
+ () => Object.assign(Object.create(null), { token: CANARY }),
90
+ { token: { type: 'string', length: CANARY.length } },
91
+ ],
92
+ ['nothing', () => undefined, undefined],
93
+ ['null — the query layer binds it as one parameter', () => null, [{ type: 'null', null: true }]],
94
+ ['a scalar string — one parameter', () => CANARY, [{ type: 'string', length: CANARY.length }]],
95
+ ['an empty string', () => '', [{ type: 'string', length: 0 }]],
96
+ ['zero', () => 0, [{ type: 'number' }]],
97
+ ['false', () => false, [{ type: 'boolean' }]],
98
+ ['a bigint', () => BigInt(CANARY.length), [{ type: 'bigint' }]],
99
+ ['a symbol', () => Symbol(CANARY), [{ type: 'symbol' }]],
100
+ ['a function', () => () => CANARY, [{ type: 'function' }]],
101
+ ['a Map — not a dictionary: one parameter', () => new Map([['token', CANARY]]), [{ type: 'object' }]],
102
+ ['a Set', () => new Set([CANARY]), [{ type: 'object' }]],
103
+ ['a class instance — not a dictionary: one parameter', () => new Credentials(), [{ type: 'object' }]],
104
+ ['a Date', () => new Date(0), [{ type: 'date' }]],
105
+ ['bytes', () => Buffer.from(CANARY), [{ type: 'bytes', length: Buffer.from(CANARY).length }]],
106
+ [
107
+ 'a dictionary with an entry whose accessor throws',
108
+ () => throwingEntry({ id: CANARY }, 'token'),
109
+ { id: { type: 'string', length: CANARY.length }, token: { type: 'unreadable' } },
110
+ ],
111
+ [
112
+ 'positional bindings with a position whose accessor throws',
113
+ () => throwingEntry([CANARY, undefined], 1),
114
+ [{ type: 'string', length: CANARY.length }, { type: 'unreadable' }],
115
+ ],
116
+ [
117
+ 'a value whose toJSON throws',
118
+ () => [
119
+ {
120
+ token: CANARY,
121
+ toJSON: () => {
122
+ throw ACCESSOR_ERROR;
123
+ },
124
+ },
125
+ ],
126
+ [{ type: 'object' }],
127
+ ],
128
+ [
129
+ 'a positional value that refuses every read',
130
+ () => [refusesEverything({ token: CANARY })],
131
+ [{ type: 'unreadable' }],
132
+ ],
133
+ ['an array that answers every read with the value', () => [lyingArray()], [{ type: 'array' }]],
134
+ ['bindings that refuse every read', () => refusesEverything({ token: CANARY }), 'unreadable'],
135
+ ['revoked bindings', () => revoked([CANARY]), 'unreadable'],
136
+ [
137
+ 'a sparse array',
138
+ () => new Array(2),
139
+ [
140
+ { type: 'null', null: true },
141
+ { type: 'null', null: true },
142
+ ],
143
+ ],
144
+ ];
145
+
146
+ describe('describeParams is total: any bindings, a description, never a value', () => {
147
+ const internals = new KnexDriver({
148
+ host: 'localhost',
149
+ user: 'root',
150
+ password: '',
151
+ dbName: 'test',
152
+ }) as unknown as DriverInternals;
153
+
154
+ test.each(BINDINGS)('%s', (_shape, bindings, expected) => {
155
+ let described: ParamsDescription | undefined;
156
+
157
+ expect(() => {
158
+ described = internals.describeParams(bindings());
159
+ }).not.toThrow();
160
+
161
+ expect(described).toEqual(expected);
162
+ expect(inspect(described, { depth: 10, showHidden: true })).not.toContain(CANARY);
163
+ expect(JSON.stringify(described) ?? '').not.toContain(CANARY);
164
+ });
165
+ });
166
+
167
+ describe('causeSummary is total: anything thrown, a summary of codes, never the rewritten message', () => {
168
+ const internals = new KnexDriver({
169
+ host: 'localhost',
170
+ user: 'root',
171
+ password: '',
172
+ dbName: 'test',
173
+ }) as unknown as DriverInternals;
174
+ const vendorFacts = {
175
+ code: 'ER_NO_SUCH_TABLE',
176
+ errno: 1146,
177
+ sqlState: '42S02',
178
+ sqlMessage: `Table 't' doesn't exist`,
179
+ };
180
+
181
+ const THROWN: [string, () => unknown, { [fact: string]: unknown }][] = [
182
+ [
183
+ 'a vendor error whose message and sql carry the bindings',
184
+ () => Object.assign(new Error(`insert ... '${CANARY}'`), vendorFacts, { sql: `insert ... '${CANARY}'` }),
185
+ { name: 'Error', ...vendorFacts },
186
+ ],
187
+ ['nothing', () => undefined, {}],
188
+ ['null', () => null, {}],
189
+ ['a thrown string', () => CANARY, {}],
190
+ ['a thrown number', () => 1146, {}],
191
+ ['facts of the wrong kind', () => ({ name: 5, code: 1146, errno: '1146', sqlState: null, sqlMessage: {} }), {}],
192
+ [
193
+ 'an error with a fact whose accessor throws',
194
+ () => throwingEntry(Object.assign(new Error(CANARY), vendorFacts), 'code'),
195
+ { name: 'Error', errno: 1146, sqlState: '42S02', sqlMessage: vendorFacts.sqlMessage },
196
+ ],
197
+ ['an error that refuses every read', () => refusesEverything(new Error(CANARY)), {}],
198
+ ['a revoked error', () => revoked(new Error(CANARY)), {}],
199
+ ];
200
+
201
+ test.each(THROWN)('%s', (_shape, thrown, expected) => {
202
+ let summary: { [fact: string]: unknown } | undefined;
203
+
204
+ expect(() => {
205
+ summary = internals.causeSummary(thrown());
206
+ }).not.toThrow();
207
+
208
+ expect(summary).toEqual(expected);
209
+ expect(inspect(summary, { depth: 10, showHidden: true })).not.toContain(CANARY);
210
+ });
211
+ });
212
+
213
+ /** The errors the stub connection rejected with, as they left the client library's callback. */
214
+ const rejected: Error[] = [];
215
+
216
+ /** A connection that rejects every statement like a missing table — a server message that quotes no binding. */
217
+ const rejectingConnection = () => {
218
+ const mysql = require('mysql');
219
+ return {
220
+ state: 'authenticated',
221
+ on: () => undefined,
222
+ removeAllListeners: () => undefined,
223
+ connect: (done: (error?: Error) => void) => done(),
224
+ end: (done: (error?: Error) => void) => done(),
225
+ query: (options: { sql: string }, bindings: any[], done: (error: Error | null, rows?: unknown[]) => void) => {
226
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(options.sql)) {
227
+ done(null, []);
228
+ return;
229
+ }
230
+ const sqlMessage = `Table 'test.credential' doesn't exist`;
231
+ const error = Object.assign(new Error(`ER_NO_SUCH_TABLE: ${sqlMessage}`), {
232
+ code: 'ER_NO_SUCH_TABLE',
233
+ errno: 1146,
234
+ sqlState: '42S02',
235
+ sqlMessage,
236
+ sql: mysql.format(options.sql, bindings),
237
+ });
238
+ rejected.push(error);
239
+ done(error);
240
+ },
241
+ };
242
+ };
243
+
244
+ const settle = <T>(run: () => Promise<T>): Promise<any> =>
245
+ Promise.resolve()
246
+ .then(run)
247
+ .then(() => 'resolved' as const)
248
+ .catch((error) => error);
249
+
250
+ describe('writing the failure line never changes what the driver throws', () => {
251
+ const driver = new KnexDriver({ host: 'localhost', user: 'root', password: '', dbName: 'test' });
252
+ const internals = driver as unknown as DriverInternals;
253
+ const statics = KnexDriver as unknown as DriverStatics;
254
+ const ENV = ['DEVELOPMENT', 'DB_LOG_PARAM_VALUES'];
255
+ const envBefore: { [name: string]: string | undefined } = {};
256
+ let written: CapturedLog[] = [];
257
+ let instance: ReturnType<typeof knex>;
258
+ let previousInstance: unknown;
259
+
260
+ /** A logger whose writer records every line it is handed and then throws for the ones `refuses` names. */
261
+ const loggerThatThrows = (refuses: (log: CapturedLog) => boolean) =>
262
+ new Logger({
263
+ name: 'KnexDriver',
264
+ logLevel: 'debug',
265
+ logWriter: {
266
+ write: (log: CapturedLog) => {
267
+ written.push(log);
268
+ if (refuses(log)) {
269
+ throw new Error('the log writer is down');
270
+ }
271
+ },
272
+ } as any,
273
+ });
274
+
275
+ beforeAll(() => {
276
+ instance = knex({ client: 'mysql', connection: { host: 'localhost', user: 'root', password: '' } });
277
+ (instance as any).client.driver = { createConnection: rejectingConnection };
278
+ previousInstance = statics.KNEX;
279
+ statics.KNEX = instance;
280
+ });
281
+
282
+ afterAll(async () => {
283
+ statics.KNEX = previousInstance;
284
+ await instance.destroy();
285
+ });
286
+
287
+ beforeEach(() => {
288
+ written = [];
289
+ rejected.length = 0;
290
+ internals.logger = loggerThatThrows(() => false);
291
+ for (const name of ENV) {
292
+ envBefore[name] = process.env[name];
293
+ delete process.env[name];
294
+ }
295
+ });
296
+
297
+ afterEach(() => {
298
+ for (const name of ENV) {
299
+ if (envBefore[name] === undefined) {
300
+ delete process.env[name];
301
+ } else {
302
+ process.env[name] = envBefore[name];
303
+ }
304
+ }
305
+ });
306
+
307
+ const INSERT = 'INSERT INTO `credential` (`id`, `token`) VALUES (?, ?)';
308
+ const insertStatement = () => ({ sql: INSERT, params: ['c0ffee00-0000-4000-8000-000000000001', CANARY] });
309
+ /** Everything a caller can read off a caught error. */
310
+ const facts = (error: any) => ({
311
+ class: error?.constructor?.name,
312
+ message: error?.message,
313
+ own: Object.getOwnPropertyNames(Object(error))
314
+ .sort()
315
+ .map((name) => [name, name === 'stack' ? '(stack)' : error[name]]),
316
+ });
317
+
318
+ test('the premise: with a working writer the statement fails with the vendor`s error and writes its one line', async () => {
319
+ const outcome = await settle(() => driver.runDml(insertStatement));
320
+
321
+ expect(rejected).toHaveLength(1);
322
+ expect(outcome).toBe(rejected[0]);
323
+ expect(written.map((log) => log.message)).toEqual(['Failed when executing sql']);
324
+ });
325
+
326
+ test('a writer that throws on the failure line: the throw is still the vendor`s error itself, and the lost line is reported as one fixed line', async () => {
327
+ internals.logger = loggerThatThrows((log) => log.message === 'Failed when executing sql');
328
+
329
+ const outcome = await settle(() => driver.runDml(insertStatement));
330
+
331
+ expect(rejected).toHaveLength(1);
332
+ expect(outcome).toBe(rejected[0]);
333
+ expect(outcome.code).toBe('ER_NO_SUCH_TABLE');
334
+ expect(written.map((log) => log.message)).toEqual([
335
+ 'Failed when executing sql',
336
+ 'Failed to write a statement log line',
337
+ ]);
338
+ // The fixed line carries nothing of the statement: no sql, no params, no error.
339
+ const [, fixed] = written;
340
+ expect(fixed.logLevel).toBe('error');
341
+ expect(fixed.obj).toBeUndefined();
342
+ expect(fixed.error).toBeUndefined();
343
+ expect(lineOf(fixed)).not.toContain(CANARY);
344
+ });
345
+
346
+ test('a writer that throws on EVERY line: the throw is still the vendor`s error itself', async () => {
347
+ internals.logger = loggerThatThrows(() => true);
348
+
349
+ const outcome = await settle(() => driver.runDml(insertStatement));
350
+
351
+ expect(rejected).toHaveLength(1);
352
+ expect(outcome).toBe(rejected[0]);
353
+ expect(written.map((log) => log.message)).toEqual([
354
+ 'Failed when executing sql',
355
+ 'Failed to write a statement log line',
356
+ ]);
357
+ });
358
+
359
+ test('a writer that throws, inside a transaction: the transaction rejects with the vendor`s error itself', async () => {
360
+ internals.logger = loggerThatThrows(() => true);
361
+
362
+ const outcome = await settle(() =>
363
+ driver.runTransaction((transaction) => driver.runDml(insertStatement, transaction))
364
+ );
365
+
366
+ expect(rejected).toHaveLength(1);
367
+ expect(outcome).toBe(rejected[0]);
368
+ });
369
+
370
+ test('the values switch open and a writer that cannot serialize a bound value: the throw is still the vendor`s error itself', async () => {
371
+ Object.assign(process.env, { DEVELOPMENT: 'true', DB_LOG_PARAM_VALUES: '1' });
372
+ // A structured writer: it serializes what it is handed, and a bigint cannot be serialized.
373
+ internals.logger = new Logger({
374
+ name: 'KnexDriver',
375
+ logLevel: 'debug',
376
+ logWriter: {
377
+ write: (log: CapturedLog) => {
378
+ written.push({ logLevel: log.logLevel, message: log.message });
379
+ JSON.stringify(log.obj);
380
+ },
381
+ } as any,
382
+ });
383
+
384
+ const outcome = await settle(() => driver.runDml(() => ({ sql: INSERT, params: [BigInt(7), CANARY] })));
385
+
386
+ expect(rejected).toHaveLength(1);
387
+ expect(outcome).toBe(rejected[0]);
388
+ expect(written.map((log) => log.message)).toEqual([
389
+ 'Failed when executing sql',
390
+ 'Failed to write a statement log line',
391
+ ]);
392
+ });
393
+
394
+ /** Bindings of every shape, at the door: what the driver throws is what a bare query-layer call throws. */
395
+ const DOORS: [string, string, () => unknown][] = [
396
+ ['positional bindings', 'SELECT 1 FROM `credential` WHERE `id` = ? AND `token` = ?', () => [1, CANARY]],
397
+ [
398
+ 'a bindings dictionary',
399
+ 'SELECT 1 FROM `credential` WHERE `id` = :id AND `token` = :token',
400
+ () => ({ id: 1, token: CANARY }),
401
+ ],
402
+ [
403
+ 'a dictionary with no prototype',
404
+ 'SELECT 1 FROM `credential` WHERE `token` = :token',
405
+ () => Object.assign(Object.create(null), { token: CANARY }),
406
+ ],
407
+ ['nothing', 'SELECT 1 FROM `credential`', () => undefined],
408
+ ['null', 'SELECT 1 FROM `credential` WHERE `token` = ?', () => null],
409
+ ['a scalar string', 'SELECT 1 FROM `credential` WHERE `token` = ?', () => CANARY],
410
+ ['zero', 'SELECT 1 FROM `credential` WHERE `n` = ?', () => 0],
411
+ ['a Map', 'SELECT 1 FROM `credential` WHERE `token` = ?', () => new Map([['token', CANARY]])],
412
+ ['a Date', 'SELECT 1 FROM `credential` WHERE `at` = ?', () => new Date(0)],
413
+ ['bytes', 'SELECT 1 FROM `credential` WHERE `token` = ?', () => Buffer.from(CANARY)],
414
+ [
415
+ 'fewer bindings than placeholders — the query layer refuses the statement itself',
416
+ 'SELECT 1 FROM `credential` WHERE `id` = ? AND `token` = ?',
417
+ () => [CANARY],
418
+ ],
419
+ [
420
+ 'a dictionary with an entry whose accessor throws',
421
+ 'SELECT 1 FROM `credential` WHERE `id` = :id AND `token` = :token',
422
+ () => throwingEntry({ id: 1 }, 'token'),
423
+ ],
424
+ [
425
+ 'a value whose toJSON throws',
426
+ 'SELECT 1 FROM `credential` WHERE `token` = ?',
427
+ () => [
428
+ {
429
+ token: CANARY,
430
+ toJSON: () => {
431
+ throw ACCESSOR_ERROR;
432
+ },
433
+ },
434
+ ],
435
+ ],
436
+ ['revoked bindings', 'SELECT 1 FROM `credential` WHERE `token` = ?', () => revoked([CANARY])],
437
+ ];
438
+
439
+ test.each(DOORS)(
440
+ 'at the door — %s: the throw reads as a bare query-layer call`s, and the line carries no value',
441
+ async (_shape, sql, bindings) => {
442
+ const bare = await settle(() => instance.raw(sql, bindings() as any) as any);
443
+ // The premise: a bare query-layer call with these bindings fails.
444
+ expect(bare).not.toBe('resolved');
445
+ const bareRejections = rejected.length;
446
+ const bareThrewTheVendorError = bare === rejected[0];
447
+ rejected.length = 0;
448
+
449
+ const outcome = await settle(() => driver.runQuery(() => ({ sql, params: bindings() as any })));
450
+
451
+ expect(facts(outcome)).toEqual(facts(bare));
452
+ expect(rejected).toHaveLength(bareRejections);
453
+ // The very object, wherever the test can name it: the one the client library rejected with
454
+ // (when that is what a bare call throws), or the one a throwing accessor throws.
455
+ if (bareThrewTheVendorError) {
456
+ expect(outcome).toBe(rejected[0]);
457
+ }
458
+ if (bare === ACCESSOR_ERROR) {
459
+ expect(outcome).toBe(ACCESSOR_ERROR);
460
+ }
461
+ expect(written.map((log) => log.message)).toEqual(['Failed when executing sql']);
462
+ expect(written[0].obj.sql).toBe(sql);
463
+ expect(lineOf(written[0])).not.toContain(CANARY);
464
+ }
465
+ );
466
+ });