kryptheon 0.1.8 → 0.1.10
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/bin/kryptheon.js +532 -22
- package/kryptheon-cleanup.js +245 -0
- package/kryptheon-recordings.js +149 -0
- package/kryptheon-reporter.js +21 -5
- package/kryptheon-selectors.js +301 -0
- package/kryptheon-signature.js +553 -530
- package/package.json +67 -56
- package/bin/kryptheon-Shantanu.js +0 -1672
package/kryptheon-signature.js
CHANGED
|
@@ -1,530 +1,553 @@
|
|
|
1
|
-
// A "DOM signature": a small, plain description of what a page ended up
|
|
2
|
-
// showing. Not a screenshot and not a pixel comparison - just the words a
|
|
3
|
-
// person would use to say what is on screen.
|
|
4
|
-
//
|
|
5
|
-
// Why it exists. Automatic baselines currently remember the final address and
|
|
6
|
-
// the page title. In a single-page app with a catch-all route those both stay
|
|
7
|
-
// the same when a flow breaks: a login that no longer logs anyone in still
|
|
8
|
-
// lands on the same URL with the same title, so the baseline sees no
|
|
9
|
-
// difference and the run is reported as fine. The signature is the thing that
|
|
10
|
-
// would have noticed - the headings, buttons, links and fields on the page
|
|
11
|
-
// change even when the address does not.
|
|
12
|
-
//
|
|
13
|
-
// STEP ONE ONLY. This module captures and prints. Nothing here is written to a
|
|
14
|
-
// baseline, compared against anything, or allowed to affect whether a test
|
|
15
|
-
// passes. Collection does not even run unless KRYPTHEON_DEBUG_SIGNATURE=1.
|
|
16
|
-
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
|
|
19
|
-
const MAX_TEXT = 80;
|
|
20
|
-
const MAX_ACTIONS = 40;
|
|
21
|
-
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// Pure helpers. No browser, no filesystem - so the unit checks can exercise
|
|
24
|
-
// them directly.
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* One
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
(el.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
'';
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (!
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
// A
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
1
|
+
// A "DOM signature": a small, plain description of what a page ended up
|
|
2
|
+
// showing. Not a screenshot and not a pixel comparison - just the words a
|
|
3
|
+
// person would use to say what is on screen.
|
|
4
|
+
//
|
|
5
|
+
// Why it exists. Automatic baselines currently remember the final address and
|
|
6
|
+
// the page title. In a single-page app with a catch-all route those both stay
|
|
7
|
+
// the same when a flow breaks: a login that no longer logs anyone in still
|
|
8
|
+
// lands on the same URL with the same title, so the baseline sees no
|
|
9
|
+
// difference and the run is reported as fine. The signature is the thing that
|
|
10
|
+
// would have noticed - the headings, buttons, links and fields on the page
|
|
11
|
+
// change even when the address does not.
|
|
12
|
+
//
|
|
13
|
+
// STEP ONE ONLY. This module captures and prints. Nothing here is written to a
|
|
14
|
+
// baseline, compared against anything, or allowed to affect whether a test
|
|
15
|
+
// passes. Collection does not even run unless KRYPTHEON_DEBUG_SIGNATURE=1.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
|
|
19
|
+
const MAX_TEXT = 80;
|
|
20
|
+
const MAX_ACTIONS = 40;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Pure helpers. No browser, no filesystem - so the unit checks can exercise
|
|
24
|
+
// them directly.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One whole numeric value, however it happens to be written.
|
|
29
|
+
*
|
|
30
|
+
* 0 007 -42 +3.14 100.0 1,234 1,234.56 1 234 1.2.3
|
|
31
|
+
*
|
|
32
|
+
* It has to be the whole value, not each run of digits, or two ways of writing
|
|
33
|
+
* the same quantity stop matching: "0" became "#" while "100.0" became "#.#",
|
|
34
|
+
* and the signature reported a page as changed because a rate went from 0% to
|
|
35
|
+
* 100.0%. The parts of a number are therefore all claimed by one match - the
|
|
36
|
+
* thousands groups, whether separated by a comma or a space, and every decimal
|
|
37
|
+
* or dotted section after them, which also takes "1.2.3" in one piece rather
|
|
38
|
+
* than leaving "#.#.#" behind.
|
|
39
|
+
*
|
|
40
|
+
* The sign belongs to the number only when nothing word-like precedes it, so
|
|
41
|
+
* "-42" is a single value while "COVID-19" keeps its hyphen and becomes
|
|
42
|
+
* "COVID-#".
|
|
43
|
+
*/
|
|
44
|
+
const NUMBER = /(?:(?<![A-Za-z0-9])[-+])?\d+(?: \d{3}|,\d{3})*(?:[.,]\d+)*/g;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One line, every number reduced to the same mark, capped.
|
|
48
|
+
*
|
|
49
|
+
* The digits are the important part. "Savings rate 0%" and "Savings rate 12%"
|
|
50
|
+
* are the same page as far as this is concerned, and a signature that treated
|
|
51
|
+
* them as different would report a regression every time a number moved.
|
|
52
|
+
*
|
|
53
|
+
* Only digits are touched. Everything else survives exactly as it was, because
|
|
54
|
+
* a normaliser aggressive enough to blur the words would hide the changes this
|
|
55
|
+
* is here to catch.
|
|
56
|
+
*/
|
|
57
|
+
function normaliseText(value) {
|
|
58
|
+
const line = String(value == null ? '' : value)
|
|
59
|
+
.replace(/\s+/g, ' ')
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(NUMBER, '#');
|
|
62
|
+
return line.length > MAX_TEXT ? line.slice(0, MAX_TEXT) : line;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function uniqueSorted(list) {
|
|
66
|
+
const seen = Object.create(null);
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const item of list || []) {
|
|
69
|
+
const text = normaliseText(item);
|
|
70
|
+
if (!text || seen[text]) continue;
|
|
71
|
+
seen[text] = true;
|
|
72
|
+
out.push(text);
|
|
73
|
+
}
|
|
74
|
+
return out.sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function uniqueInOrder(list) {
|
|
78
|
+
const seen = Object.create(null);
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const item of list || []) {
|
|
81
|
+
const text = normaliseText(item);
|
|
82
|
+
if (!text || seen[text]) continue;
|
|
83
|
+
seen[text] = true;
|
|
84
|
+
out.push(text);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Path only: no host, no query string, no fragment.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately not the reporter's requestPath, which keeps the query string so
|
|
93
|
+
* a person reading a failure can see it. A signature is stored and compared,
|
|
94
|
+
* and query strings routinely carry tokens, ids and search terms.
|
|
95
|
+
*/
|
|
96
|
+
function signaturePath(requestUrl) {
|
|
97
|
+
let s = String(requestUrl == null ? '' : requestUrl);
|
|
98
|
+
try {
|
|
99
|
+
s = new URL(s).pathname || '/';
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// Not absolute: strip any scheme://host by hand, then the query and hash.
|
|
102
|
+
s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*/, '');
|
|
103
|
+
const hash = s.indexOf('#');
|
|
104
|
+
if (hash !== -1) s = s.slice(0, hash);
|
|
105
|
+
const query = s.indexOf('?');
|
|
106
|
+
if (query !== -1) s = s.slice(0, query);
|
|
107
|
+
}
|
|
108
|
+
return s || '/';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Method, path and status. Never a query string, never a body. */
|
|
112
|
+
function toSignatureRequests(list) {
|
|
113
|
+
const seen = Object.create(null);
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const req of list || []) {
|
|
116
|
+
const status = Number(req && req.status);
|
|
117
|
+
if (!(status >= 400 && status <= 599)) continue;
|
|
118
|
+
const entry = {
|
|
119
|
+
method: (req && req.method) || 'GET',
|
|
120
|
+
path: signaturePath((req && req.url) != null ? req.url : req && req.path),
|
|
121
|
+
status: status,
|
|
122
|
+
};
|
|
123
|
+
const key = entry.method + ' ' + entry.path + ' ' + entry.status;
|
|
124
|
+
if (seen[key]) continue;
|
|
125
|
+
seen[key] = true;
|
|
126
|
+
out.push(entry);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Assembles the signature from raw, page-shaped input. */
|
|
132
|
+
function buildSignature(raw) {
|
|
133
|
+
const input = raw || {};
|
|
134
|
+
return {
|
|
135
|
+
headings: uniqueInOrder(input.headings),
|
|
136
|
+
actions: uniqueSorted(input.actions).slice(0, MAX_ACTIONS),
|
|
137
|
+
fields: uniqueSorted(input.fields),
|
|
138
|
+
failedRequests: toSignatureRequests(input.failedRequests),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One plain line saying what was on screen, for a failure report.
|
|
144
|
+
*
|
|
145
|
+
* Deliberately not the JSON. Somebody reading a failure wants "it was still on
|
|
146
|
+
* the sign-in page", not a structure to parse; the whole object stays behind
|
|
147
|
+
* the debug flag for when the detail is actually wanted.
|
|
148
|
+
*/
|
|
149
|
+
function describeSignature(signature) {
|
|
150
|
+
if (!signature || typeof signature !== 'object') return null;
|
|
151
|
+
const headings = (signature.headings || []).slice(0, 2);
|
|
152
|
+
const actions = (signature.actions || []).slice(0, 3);
|
|
153
|
+
const fields = (signature.fields || []).slice(0, 3);
|
|
154
|
+
|
|
155
|
+
let what;
|
|
156
|
+
if (headings.length) what = quoteList(headings);
|
|
157
|
+
else if (fields.length) what = 'a form';
|
|
158
|
+
else if (actions.length) what = 'no headings';
|
|
159
|
+
else return null; // nothing readable was on the page
|
|
160
|
+
|
|
161
|
+
const parts = [];
|
|
162
|
+
if (actions.length) parts.push(actions.join(', '));
|
|
163
|
+
if (!headings.length && fields.length) parts.push(fields.join(', '));
|
|
164
|
+
return parts.length ? what + ' with ' + parts.join('; ') : what;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Comparing two signatures.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What left and what arrived, as sets.
|
|
173
|
+
*
|
|
174
|
+
* Sets, not sequences: a page that moves its buttons around is the same page.
|
|
175
|
+
* Only something appearing or disappearing is worth stopping a run over.
|
|
176
|
+
*/
|
|
177
|
+
function setDifference(before, after) {
|
|
178
|
+
const had = new Set(before || []);
|
|
179
|
+
const has = new Set(after || []);
|
|
180
|
+
return {
|
|
181
|
+
gone: [...had].filter((item) => !has.has(item)),
|
|
182
|
+
added: [...has].filter((item) => !had.has(item)),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function requestLabel(req) {
|
|
187
|
+
return (req.method || 'GET') + ' ' + req.path + ' ' + req.status;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function quoteList(items) {
|
|
191
|
+
return items.map((item) => '"' + item + '"').join(', ');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compares a stored signature with what this run saw. Returns a message
|
|
196
|
+
* describing the change, or null when nothing worth failing over moved.
|
|
197
|
+
*
|
|
198
|
+
* Returns null when either side is missing. A baseline recorded before this
|
|
199
|
+
* feature existed has no signature and must keep working on address and title
|
|
200
|
+
* alone, and a run whose capture failed knows nothing - neither is evidence of
|
|
201
|
+
* a regression, and treating them as one would fail every old project on
|
|
202
|
+
* upgrade.
|
|
203
|
+
*/
|
|
204
|
+
function compareSignatures(previous, current) {
|
|
205
|
+
if (!previous || typeof previous !== 'object') return null;
|
|
206
|
+
if (!current || typeof current !== 'object') return null;
|
|
207
|
+
|
|
208
|
+
const headings = setDifference(previous.headings, current.headings);
|
|
209
|
+
const actions = setDifference(previous.actions, current.actions);
|
|
210
|
+
const fields = setDifference(previous.fields, current.fields);
|
|
211
|
+
|
|
212
|
+
// Only new failures matter. A 500 that has been fixed since the baseline was
|
|
213
|
+
// taken is an improvement, and failing the run for it would be perverse.
|
|
214
|
+
const knownRequests = new Set((previous.failedRequests || []).map(requestLabel));
|
|
215
|
+
const newRequests = (current.failedRequests || [])
|
|
216
|
+
.map(requestLabel)
|
|
217
|
+
.filter((label) => !knownRequests.has(label));
|
|
218
|
+
|
|
219
|
+
const moved =
|
|
220
|
+
headings.gone.length ||
|
|
221
|
+
headings.added.length ||
|
|
222
|
+
actions.gone.length ||
|
|
223
|
+
actions.added.length ||
|
|
224
|
+
fields.gone.length ||
|
|
225
|
+
fields.added.length ||
|
|
226
|
+
newRequests.length;
|
|
227
|
+
if (!moved) return null;
|
|
228
|
+
|
|
229
|
+
const lines = ['Baseline changed: the page ended up somewhere different than before.'];
|
|
230
|
+
if (headings.gone.length) lines.push('Headings that are gone: ' + quoteList(headings.gone));
|
|
231
|
+
if (headings.added.length) lines.push('Headings that are new: ' + quoteList(headings.added));
|
|
232
|
+
if (actions.gone.length) lines.push('Buttons and links that are gone: ' + quoteList(actions.gone));
|
|
233
|
+
if (actions.added.length) lines.push('Buttons and links that are new: ' + quoteList(actions.added));
|
|
234
|
+
if (fields.gone.length) lines.push('Form fields that are gone: ' + quoteList(fields.gone));
|
|
235
|
+
if (fields.added.length) lines.push('Form fields that are new: ' + quoteList(fields.added));
|
|
236
|
+
if (newRequests.length) lines.push('Requests that failed this time: ' + newRequests.join(', '));
|
|
237
|
+
return lines.join('\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// The browser side.
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
// Runs inside the page. Written as a single self-contained function because
|
|
245
|
+
// page.evaluate serialises it across the wire - it cannot close over anything
|
|
246
|
+
// in this file.
|
|
247
|
+
function readPage() {
|
|
248
|
+
const isVisible = (el) => {
|
|
249
|
+
if (!el) return false;
|
|
250
|
+
// checkVisibility covers display:none, visibility:hidden, content-visibility
|
|
251
|
+
// and the hidden attribute in one call where it exists.
|
|
252
|
+
if (typeof el.checkVisibility === 'function') {
|
|
253
|
+
if (!el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true })) return false;
|
|
254
|
+
} else {
|
|
255
|
+
const style = window.getComputedStyle(el);
|
|
256
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
257
|
+
}
|
|
258
|
+
const rect = el.getBoundingClientRect();
|
|
259
|
+
return rect.width > 0 || rect.height > 0;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const text = (el) => (el && el.innerText ? el.innerText : (el && el.textContent) || '');
|
|
263
|
+
|
|
264
|
+
const headings = [];
|
|
265
|
+
for (const el of document.querySelectorAll('h1, h2, h3')) {
|
|
266
|
+
if (isVisible(el)) headings.push(text(el));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const actions = [];
|
|
270
|
+
const actionSelector =
|
|
271
|
+
'button, a[href], [role="button"], [role="link"], input[type="submit"], input[type="button"]';
|
|
272
|
+
for (const el of document.querySelectorAll(actionSelector)) {
|
|
273
|
+
if (!isVisible(el)) continue;
|
|
274
|
+
const label =
|
|
275
|
+
el.getAttribute('aria-label') ||
|
|
276
|
+
(el.tagName === 'INPUT' ? el.value : '') ||
|
|
277
|
+
text(el) ||
|
|
278
|
+
el.getAttribute('title') ||
|
|
279
|
+
'';
|
|
280
|
+
if (label) actions.push(label);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const fields = [];
|
|
284
|
+
for (const el of document.querySelectorAll('input, select, textarea')) {
|
|
285
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
286
|
+
if (type === 'hidden' || type === 'submit' || type === 'button') continue;
|
|
287
|
+
if (!isVisible(el)) continue;
|
|
288
|
+
// What a person would call this field, in the order they would find it.
|
|
289
|
+
let label = '';
|
|
290
|
+
if (el.id) {
|
|
291
|
+
const tag = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
|
|
292
|
+
if (tag) label = text(tag);
|
|
293
|
+
}
|
|
294
|
+
if (!label && el.closest('label')) label = text(el.closest('label'));
|
|
295
|
+
if (!label) label = el.getAttribute('aria-label') || '';
|
|
296
|
+
if (!label) label = el.getAttribute('placeholder') || '';
|
|
297
|
+
if (!label) label = el.getAttribute('name') || '';
|
|
298
|
+
if (!label) label = el.id || '';
|
|
299
|
+
if (label) fields.push(label);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// A page that never declares its encoding is decoded by guesswork, and the
|
|
303
|
+
// text really does come out as mojibake - on screen and therefore in here.
|
|
304
|
+
// That is the app's bug, not this tool's, but it is worth saying once.
|
|
305
|
+
const declared = !!document.querySelector('meta[charset], meta[http-equiv="Content-Type" i]');
|
|
306
|
+
const guessedEncoding = !declared && String(document.characterSet || '').toUpperCase() !== 'UTF-8';
|
|
307
|
+
|
|
308
|
+
return { headings: headings, actions: actions, fields: fields, guessedEncoding: guessedEncoding };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** True only for an explicit opt-in. */
|
|
312
|
+
function signatureEnabled(env) {
|
|
313
|
+
const source = env || process.env;
|
|
314
|
+
return String(source.KRYPTHEON_DEBUG_SIGNATURE || '') === '1';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// How often the page is read while waiting for it to hold still.
|
|
318
|
+
const SETTLE_GAP = 200;
|
|
319
|
+
// How long it has to hold still before the reading is believed. Measured, not
|
|
320
|
+
// guessed: against a page that renders 1.5s after its request answers, 700ms
|
|
321
|
+
// still read the screen from before the click and 1200ms read the right one.
|
|
322
|
+
const SETTLE_QUIET = 1200;
|
|
323
|
+
// Never spend longer than this on one signature, however restless the page.
|
|
324
|
+
const SETTLE_BUDGET = 4000;
|
|
325
|
+
// A pending request is worth waiting out first, but not for the whole budget.
|
|
326
|
+
const NETWORK_BUDGET = 1500;
|
|
327
|
+
|
|
328
|
+
/** Two readings of the same page, exactly. */
|
|
329
|
+
function sameSignature(a, b) {
|
|
330
|
+
if (!a || !b) return false;
|
|
331
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function pause(page, ms) {
|
|
335
|
+
try {
|
|
336
|
+
await page.waitForTimeout(ms);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function readSignature(page, failedRequests) {
|
|
343
|
+
const raw = await page.evaluate(readPage);
|
|
344
|
+
const signature = buildSignature({
|
|
345
|
+
headings: raw.headings,
|
|
346
|
+
actions: raw.actions,
|
|
347
|
+
fields: raw.fields,
|
|
348
|
+
failedRequests: failedRequests,
|
|
349
|
+
});
|
|
350
|
+
// Not part of the signature itself - it describes the page's markup, not
|
|
351
|
+
// what the page showed, and it must never count as a difference.
|
|
352
|
+
Object.defineProperty(signature, 'guessedEncoding', {
|
|
353
|
+
value: !!raw.guessedEncoding,
|
|
354
|
+
enumerable: false,
|
|
355
|
+
});
|
|
356
|
+
return signature;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Reads the page once it has stopped changing, and returns a signature - or
|
|
361
|
+
* null if it could not be read at all. Never throws: this is a diagnostic and
|
|
362
|
+
* must not become a second failure.
|
|
363
|
+
*
|
|
364
|
+
* Why it is not just networkidle. In a single-page app a click causes no
|
|
365
|
+
* navigation: the request answers and the framework swaps the DOM a moment
|
|
366
|
+
* later. Playwright's networkidle means "no requests for 500ms", which is a
|
|
367
|
+
* fact about the network, not about the page - so a render that lands even
|
|
368
|
+
* 300ms after the response is missed entirely and the reading describes the
|
|
369
|
+
* screen the user was on *before* the click. That is exactly how a working
|
|
370
|
+
* login came back as the login form.
|
|
371
|
+
*
|
|
372
|
+
* Why it is not a fixed sleep either: whatever number is picked is too short
|
|
373
|
+
* for a slow machine and wasted on a fast one.
|
|
374
|
+
*
|
|
375
|
+
* So: wait out any pending request, then read the page repeatedly until it has
|
|
376
|
+
* held still for a while. The quiet window matters more than the gap between
|
|
377
|
+
* reads - two identical readings 200ms apart prove nothing on a page that is
|
|
378
|
+
* simply waiting, which is why stability is measured from the last change seen
|
|
379
|
+
* rather than from the first pair that happened to agree. A page that never
|
|
380
|
+
* settles is capped, and whatever it looked like at the cap is used; running
|
|
381
|
+
* out of patience is not a reason to fail somebody's test.
|
|
382
|
+
*/
|
|
383
|
+
function positiveNumber(value, fallback) {
|
|
384
|
+
const n = Number(value);
|
|
385
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The quiet window and the cap, either of which a slow app may need more of.
|
|
390
|
+
*
|
|
391
|
+
* No amount of waiting can predict a render that has not happened yet, so
|
|
392
|
+
* these are a judgement about how long a page is given to prove it has
|
|
393
|
+
* finished. An app that renders later than the window still reads early, and
|
|
394
|
+
* raising this is the way out of that.
|
|
395
|
+
*/
|
|
396
|
+
function settleSettings(env) {
|
|
397
|
+
const source = env || process.env;
|
|
398
|
+
return {
|
|
399
|
+
quiet: positiveNumber(source.KRYPTHEON_SIGNATURE_QUIET_MS, SETTLE_QUIET),
|
|
400
|
+
budget: positiveNumber(source.KRYPTHEON_SIGNATURE_BUDGET_MS, SETTLE_BUDGET),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function collectSignature(page, failedRequests, options) {
|
|
405
|
+
const opts = options || {};
|
|
406
|
+
const fromEnv = settleSettings(opts.env);
|
|
407
|
+
const gap = opts.settleGap == null ? SETTLE_GAP : opts.settleGap;
|
|
408
|
+
const quiet = opts.settleQuiet == null ? fromEnv.quiet : opts.settleQuiet;
|
|
409
|
+
const budget = opts.settleBudget == null ? fromEnv.budget : opts.settleBudget;
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const started = Date.now();
|
|
413
|
+
|
|
414
|
+
// An answer still in flight will change the page the moment it lands, so
|
|
415
|
+
// it is worth waiting out before deciding the page has stopped moving.
|
|
416
|
+
try {
|
|
417
|
+
await page.waitForLoadState('networkidle', {
|
|
418
|
+
timeout: Math.min(NETWORK_BUDGET, Math.max(0, budget - (Date.now() - started))),
|
|
419
|
+
});
|
|
420
|
+
} catch (e) {
|
|
421
|
+
/* still talking, or the state is unavailable - the loop below copes */
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let current = await readSignature(page, failedRequests);
|
|
425
|
+
let lastChange = Date.now();
|
|
426
|
+
|
|
427
|
+
for (;;) {
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
if (now - lastChange >= quiet) return current;
|
|
430
|
+
if (now - started >= budget) return current;
|
|
431
|
+
await pause(page, gap);
|
|
432
|
+
const next = await readSignature(page, failedRequests);
|
|
433
|
+
if (!sameSignature(current, next)) lastChange = Date.now();
|
|
434
|
+
current = next;
|
|
435
|
+
}
|
|
436
|
+
} catch (e) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// Getting the signature out of the worker and onto the terminal.
|
|
443
|
+
//
|
|
444
|
+
// Nothing written from inside a Playwright worker reaches the terminal here.
|
|
445
|
+
// Playwright pipes the worker's real file descriptors and hands whatever comes
|
|
446
|
+
// out to reporter.onStdOut / onStdErr; kryptheon's reporter implements neither
|
|
447
|
+
// hook and the config runs no other reporter, so it is collected and dropped.
|
|
448
|
+
// Measured, not assumed: console.log, process.stdout.write, process.stderr.write
|
|
449
|
+
// and fs.writeSync to both fd 1 and fd 2 all vanish.
|
|
450
|
+
//
|
|
451
|
+
// So the worker hands the signature over through a file, and the CLI - whose
|
|
452
|
+
// stderr really is the terminal - prints it once the run is over. The reporter
|
|
453
|
+
// and its output format are untouched.
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
const SINK_ENV = 'KRYPTHEON_SIGNATURE_FILE';
|
|
457
|
+
|
|
458
|
+
/** Where the worker should leave signatures, or null if nobody asked for them. */
|
|
459
|
+
function sinkPath(env) {
|
|
460
|
+
const source = env || process.env;
|
|
461
|
+
const file = source[SINK_ENV];
|
|
462
|
+
return file ? String(file) : null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** One JSON line per test. Append-only, so parallel workers cannot interleave. */
|
|
466
|
+
function recordSignature(label, signature, env) {
|
|
467
|
+
const file = sinkPath(env);
|
|
468
|
+
if (!file) return false;
|
|
469
|
+
try {
|
|
470
|
+
fs.appendFileSync(file, JSON.stringify({ test: label, signature: signature }) + '\n', 'utf8');
|
|
471
|
+
return true;
|
|
472
|
+
} catch (e) {
|
|
473
|
+
return false; // a diagnostic must never become a second failure
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Reads what the workers left, newest run last. Missing file means nothing ran. */
|
|
478
|
+
function readRecorded(file) {
|
|
479
|
+
let raw;
|
|
480
|
+
try {
|
|
481
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
482
|
+
} catch (e) {
|
|
483
|
+
return [];
|
|
484
|
+
}
|
|
485
|
+
const out = [];
|
|
486
|
+
for (const line of raw.split('\n')) {
|
|
487
|
+
if (!line.trim()) continue;
|
|
488
|
+
try {
|
|
489
|
+
out.push(JSON.parse(line));
|
|
490
|
+
} catch (e) {
|
|
491
|
+
/* a torn line is not worth failing over */
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Prints the signature. Step one does nothing else with it.
|
|
499
|
+
*
|
|
500
|
+
* Goes to stderr so the reporter's own output, which is stdout, stays exactly
|
|
501
|
+
* as it was - a person or a script reading the report sees no difference.
|
|
502
|
+
*/
|
|
503
|
+
function printSignature(signature, label, out) {
|
|
504
|
+
const write = out || ((line) => process.stderr.write(line + '\n'));
|
|
505
|
+
if (!signature) {
|
|
506
|
+
write('[kryptheon signature] ' + label + ': could not read the page');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
write('[kryptheon signature] ' + label);
|
|
510
|
+
write(JSON.stringify(signature, null, 2));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Prints everything the workers recorded, then clears the file. */
|
|
514
|
+
function drainSignatures(file, out) {
|
|
515
|
+
const records = readRecorded(file);
|
|
516
|
+
for (const record of records) {
|
|
517
|
+
printSignature(record.signature, record.test, out);
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
fs.rmSync(file, { force: true });
|
|
521
|
+
} catch (e) {
|
|
522
|
+
/* leftover temp file is not worth failing over */
|
|
523
|
+
}
|
|
524
|
+
return records.length;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
module.exports = {
|
|
528
|
+
SINK_ENV: SINK_ENV,
|
|
529
|
+
sinkPath: sinkPath,
|
|
530
|
+
recordSignature: recordSignature,
|
|
531
|
+
readRecorded: readRecorded,
|
|
532
|
+
drainSignatures: drainSignatures,
|
|
533
|
+
normaliseText: normaliseText,
|
|
534
|
+
uniqueSorted: uniqueSorted,
|
|
535
|
+
uniqueInOrder: uniqueInOrder,
|
|
536
|
+
signaturePath: signaturePath,
|
|
537
|
+
toSignatureRequests: toSignatureRequests,
|
|
538
|
+
buildSignature: buildSignature,
|
|
539
|
+
compareSignatures: compareSignatures,
|
|
540
|
+
describeSignature: describeSignature,
|
|
541
|
+
setDifference: setDifference,
|
|
542
|
+
signatureEnabled: signatureEnabled,
|
|
543
|
+
collectSignature: collectSignature,
|
|
544
|
+
printSignature: printSignature,
|
|
545
|
+
readPage: readPage,
|
|
546
|
+
sameSignature: sameSignature,
|
|
547
|
+
settleSettings: settleSettings,
|
|
548
|
+
MAX_TEXT: MAX_TEXT,
|
|
549
|
+
SETTLE_GAP: SETTLE_GAP,
|
|
550
|
+
SETTLE_QUIET: SETTLE_QUIET,
|
|
551
|
+
SETTLE_BUDGET: SETTLE_BUDGET,
|
|
552
|
+
MAX_ACTIONS: MAX_ACTIONS,
|
|
553
|
+
};
|