-
Notifications
You must be signed in to change notification settings - Fork 0
/
test.js
1756 lines (1576 loc) · 64.5 KB
/
test.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
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
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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
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
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
'use strict';
import { Screenshot } from './ui/screenshot.js';
import { brimstone, progressIndicator } from './utilities.js';
import * as Errors from './error.js';
import * as BDS from './ui/brimstoneDataService.js';
import { clone, getComparableVersion } from './utilities.js';
import { infobar } from './ui/infobar/infobar.js';
import { Tab } from './tab.js';
import { uuidv4, pngDiff } from './utilities.js';
import { options } from './options.js';
import {
Correction,
BoundingBox,
UnpredictableCorrection,
ActualCorrection,
AntiAliasCorrection,
} from './rectangle.js';
import * as extensionInfo from './ui/extensionInfo.js';
const arrowsSvg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M32 176h370.8l-57.38 57.38c-12.5 12.5-12.5 32.75 0 45.25C351.6 284.9 359.8 288 368 288s16.38-3.125 22.62-9.375l112-112c12.5-12.5 12.5-32.75 0-45.25l-112-112c-12.5-12.5-32.75-12.5-45.25 0s-12.5 32.75 0 45.25L402.8 112H32c-17.69 0-32 14.31-32 32S14.31 176 32 176zM480 336H109.3l57.38-57.38c12.5-12.5 12.5-32.75 0-45.25s-32.75-12.5-45.25 0l-112 112c-12.5 12.5-12.5 32.75 0 45.25l112 112C127.6 508.9 135.8 512 144 512s16.38-3.125 22.62-9.375c12.5-12.5 12.5-32.75 0-45.25L109.3 400H480c17.69 0 32-14.31 32-32S497.7 336 480 336z"/></svg>';
const pencilSvg =
'<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="pencil-alt" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-pencil-alt fa-w-16 fa-9x"> <path fill="currentColor" d="M491.609 73.625l-53.861-53.839c-26.378-26.379-69.075-26.383-95.46-.001L24.91 335.089.329 484.085c-2.675 16.215 11.368 30.261 27.587 27.587l148.995-24.582 315.326-317.378c26.33-26.331 26.581-68.879-.628-96.087zM200.443 311.557C204.739 315.853 210.37 318 216 318s11.261-2.147 15.557-6.443l119.029-119.03 28.569 28.569L210 391.355V350h-48v-48h-41.356l170.259-169.155 28.569 28.569-119.03 119.029c-8.589 8.592-8.589 22.522.001 31.114zM82.132 458.132l-28.263-28.263 12.14-73.587L84.409 338H126v48h48v41.59l-18.282 18.401-73.586 12.141zm378.985-319.533l-.051.051-.051.051-48.03 48.344-88.03-88.03 48.344-48.03.05-.05.05-.05c9.147-9.146 23.978-9.259 33.236-.001l53.854 53.854c9.878 9.877 9.939 24.549.628 33.861z" class=""></path></svg>';
const leftArrow =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M224 480c-8.188 0-16.38-3.125-22.62-9.375l-192-192c-12.5-12.5-12.5-32.75 0-45.25l192-192c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L77.25 256l169.4 169.4c12.5 12.5 12.5 32.75 0 45.25C240.4 476.9 232.2 480 224 480z"/></svg>';
const noImageAvailableDataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKAQMAAAC3/F3+AAAABlBMVEX///+AgIBizNOVAAAAAXRSTlMAQObYZgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAAtJREFUCJljYMAHAAAeAAHu9nAhAAAAAElFTkSuQmCC';
/**
* A ziptest instance is a recording of user actions that can be played back
* and verified.
*/
export class Test {
get dirty() {
for (let i = 0; i < this.steps.length; ++i) {
if (this.steps[i].dirty) {
return true;
}
}
return false;
}
/**
* reset state
* @param {Test} test
*/
_reset() {
/**
* Like dirty, but only because the version is older.
*/
this.oldVersion = false;
/**
* These are the individual actions of the test.
* @type {TestAction[]}
*/
this.steps = [];
/** Should we hide the cursor for this test for performance? */
this.hideCursor = true;
/** Was this test recorded in (and hence should be played back in) incognito? */
this.incognito = true;
/** If this test is persisted to disk, this records the name used */
this.filename = 'untitled';
/**
* The zipfile this instance was loaded from or saved into.
*/
this.zip = undefined;
/**
* This is the default index of the next recorded action.
* If the action comes in with an index already that is used.
*/
this.recordIndex = 0;
/** Statistics about the last run of this zipfile test */
this.lastRun = new BDS.Test();
/**
* The server this test starts on. Normall this would come from the first
* action. The first action normally is a goto <URL>. But in the case of
* a multizip test, later zips might be internal parts of the workflow.
* in that case we still need to propagate the url into the DB.
*/
this.startingServer = null;
/** The PlayTree node for this test.
* @type {PlayTree}
*/
this._playTree = new PlayTree();
this._playTree._zipTest = this;
/**
* The version of brimstone that this test format corresponds to.
* @type {string}
*/
this.brimstoneVersion = undefined;
}
/**
* Hydrates the dataurl for expected and acceptable screenshots in all steps in this
* test, that are not currently hydrated. Dirty steps should always remain hydrated
* so they should not be overwritten by this.
* */
hydrateStepsDataUrls() {
console.debug('hydrating step dataurls');
return progressIndicator({
progressCallback: infobar.setProgress.bind(
infobar,
'hydrate',
'hydrated'
),
items: this.steps,
itemProcessor: async (action) => {
if (action.expectedScreenshot && !action.expectedScreenshot.dataUrl) {
if (action.expectedScreenshot?.fileName) {
// protect against possible bad save
await action.expectedScreenshot.loadDataUrlFromZip();
}
}
if (
action.acceptablePixelDifferences &&
!action.acceptablePixelDifferences.dataUrl
) {
if (action.acceptablePixelDifferences?.fileName) {
// protect against possible bad save
await action.acceptablePixelDifferences.loadDataUrlFromZip();
}
}
},
});
}
/**
* default constructor
*/
constructor() {
this._reset();
}
/**
* Insert or append the action to the test. If the action does
* not have an index it will be assigned an index 1 past the last.
* Then the action will be inserted there.
*
* @param {TestAction} action The action to push onto the end.
*/
updateOrAppendAction(action) {
// make sure it has a step number
if (action.index === undefined) {
// when recording actions they (may!) come in without an index, so use the running one.
action.setIndex(this.recordIndex);
}
// pollscreen actions only update the UI they don't actually get recorded
if (action.type !== 'pollscreen') {
this.recordIndex = action.index + 1;
}
this.steps[action.index] = action;
action.test = this; // each action knows what test it is in
}
/**
* Delete the specified action from the test. This changes the indexes of all subsequent actions, but that isn't
* persisted until a save.
* @param {TestAction} action */
async deleteAction(action) {
let abort = false;
if (options.confirmToDelete) {
abort = !(await brimstone.window.confirm(
'This will delete the current action from memory, not from disk. There is no undo (yet).\n\nContinue?'
));
}
if (abort) {
return false;
}
await this.hydrateStepsDataUrls(); // this is required to save correctly now
let removeIndex = action.index;
for (let i = action.index + 1; i < this.steps.length; ++i) {
let action = this.steps[i];
action.setIndex(i - 1);
action.dirty = true;
}
this.steps.splice(removeIndex, 1);
return true;
}
/**
* Delete all the actions before the passed in one.
* The passed in one becomes index .
* @param {TestAction} action
*/
async deleteActionsBefore(action) {
let abort = false;
if (options.confirmToDelete) {
abort = !(await brimstone.window.confirm(
'This will delete all actions before the current one from memory, not from disk. There is no undo (yet).\n\nContinue?'
));
}
if (abort) {
return false;
}
await this.hydrateStepsDataUrls(); // this is required to save correctly now
this.steps.splice(0, action.index);
this.reindex({ changedAsDirty: false });
return true;
}
/**
* Put the real index of the action within the
* steps property into the action index property.
*
* Will mark actions that are updated as dirty.
*/
reindex({ changedAsDirty = true } = {}) {
for (let i = 0; i < this.steps.length; ++i) {
let action = this.steps[i];
let oldIndex = action.index;
action.setIndex(i);
if (changedAsDirty && oldIndex !== i) {
action.dirty = true;
}
}
}
/**
* Delete all the actions after the passed in one.
* The passed in one becomes one before the last.
* Update the last to just contain the expected screenshot.
* @param {TestAction} action
*/
async deleteActionsAfter(action) {
let abort = false;
if (options.confirmToDelete) {
abort = !(await brimstone.window.confirm(
'This will delete all actions after the current one from memory, not from disk. There is no undo (yet).\n\nContinue?'
));
}
if (abort) {
return false;
}
await this.hydrateStepsDataUrls(); // this is required to save correctly now
this.steps.splice(action.index + 2);
this.reindex();
return true;
}
/**
* insert (splice in) the action at the index specified in the action
* @param {TestAction} newAction The action to insert
*/
async insertAction(newAction) {
await this.hydrateStepsDataUrls(); // this is required to save correctly now
newAction.test = this;
newAction.tab = clone(this.steps[newAction.index].tab);
this.steps.splice(newAction.index, 0, newAction);
this.reindex();
}
/**
* Insert the given actions into this test
* at the given index.
*
* @param {number} index the index to insert the actions
* @param {TestAction[]} actions the actions to insert
*/
insertActions(index, actions) {
this.steps.splice(index, 0, ...actions);
for (let i = 0; i < actions.length; ++i) {
actions[i].inserted = true;
actions[i].test = this;
}
this.reindex({ changedAsDirty: false });
}
toJSON() {
return {
steps: this.steps,
brimstoneVersion: extensionInfo.version,
hideCursor: this.hideCursor,
incognito: this.incognito,
};
}
/**
* create a blob in memory that
* can be wrtten to disk as the zipfile.
* @returns {Blob}
*/
async createZipBlob() {
console.debug('create zip');
const blobWriter = new zip.BlobWriter('application/zip');
const writer = new zip.ZipWriter(blobWriter);
await writer.add(
'test.json',
new zip.TextReader(JSON.stringify(this, null, 2))
); // add the test.json file to archive
await writer.add('screenshots', null, { directory: true }); // directory
await this.hydrateStepsDataUrls();
// write the dataUrl for expected and acceptable screenshots in all steps of this test into the zip.
await progressIndicator({
progressCallback: infobar.setProgress.bind(
infobar,
'build save data',
'built'
),
items: this.steps,
itemProcessor: async (card) => {
if (card.expectedScreenshot?.dataUrl) {
await writer.add(
`screenshots/${card.expectedScreenshot.fileName}`,
new zip.Data64URIReader(card.expectedScreenshot.dataUrl)
);
}
if (card.acceptablePixelDifferences?.dataUrl) {
await writer.add(
`screenshots/${card.acceptablePixelDifferences.fileName}`,
new zip.Data64URIReader(card.acceptablePixelDifferences.dataUrl)
);
}
},
});
await writer.close();
return blobWriter.getData();
}
/**
* Pop the showSaveFilePicker dialog to the user. If the user picks
* a handle, write the passed in blob to the handle. The moment
* that the showSaveFilePicker dialog picks a handle that file
* is **truncated**. This is why the blob must be precalculated,
* we don't want the user to be able to lose data.
* @param {Blob} the blob of the file to write to the zip
*/
async saveZipFile(blob) {
if (!blob) {
throw new Errors.TestSaveError('no blob was provided to saveZipFile');
}
let handle;
try {
handle = await window.showSaveFilePicker({
suggestedName: this.filename,
types: [
{
description: 'A ZIP archive that can be run by Brimstone',
accept: { 'application/zip': ['.zip'] },
},
],
});
// get the zip file as a Blob, if the promise rejects the wait throws the rejected value.
const writable = await handle.createWritable();
infobar.setText(`saving ${handle.name} `, {
html: `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="save"
class="svg-inline--fa fa-save fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 448 512">
<path fill="currentColor"
d="M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM224 416c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64s64 28.654 64 64c0 35.346-28.654 64-64 64zm96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A11.996 11.996 0 0 1 320 111.48z">
</path>
</svg>`,
});
await writable.write(blob); // Write the contents of the file to the stream.
await writable.close(); // Close the file and write the contents to disk.
this.filename = handle.name;
for (let i = 0; i < this.steps.length; ++i) {
this.steps[i].dirty = false;
this.steps[i].inserted = false;
}
infobar.setText(`saved ${handle.name}`);
return handle;
} catch (e) {
if (
e instanceof DOMException &&
e.message === 'The user aborted a request.'
) {
return; // fine
}
throw new Errors.TestSaveError(e.stack);
}
}
/**
* async constructor from a zip filehandle in playtree.
* loads all the expected screenshots into data urls as fast as possible from the zip.
* @param {PlayTree} playTree
* @returns
*/
async fromPlayTree(playTree) {
await this.fromFileHandle(playTree._fileHandle);
this._playTree = playTree;
this._playTree._zipTest = this;
return this;
}
/**
* async constructor from a filehandle of the zip.
* loads all the expected screenshots into data urls as fast as possible from the zip.
*
* no feedback.
*/
async fromFileHandle(fileHandle) {
if (!fileHandle) {
return this;
}
this._reset();
const blob = await fileHandle.getFile();
let blobReader = new zip.BlobReader(blob); // construct a blob reader
let zipReader = new zip.ZipReader(blobReader); // construct a zip reader
let entries = await zipReader.getEntries(); // get the entries
let testJsonEntry = entries.find((e) => e.filename === 'test.json');
let testJson = await testJsonEntry.getData(new zip.TextWriter()); // The type of Writer determines the return type.
let testPojo = JSON.parse(testJson);
let actions = testPojo.steps;
// convert older tests
if (testPojo.meta) {
Object.assign(testPojo, testPojo.meta);
delete testPojo.meta;
}
this.hideCursor = testPojo.hideCursor;
this.incognito = testPojo.incognito;
this.filename = fileHandle.name;
this.brimstoneVersion = testPojo.brimstoneVersion;
if (this.brimstoneVersion === undefined) {
this.brimstoneVersion = 'v1.0.0';
}
if (options.warnOnVersionMismatch) {
let extensionVersion = getComparableVersion(extensionInfo.version);
let testVersion = getComparableVersion(this.brimstoneVersion);
if (testVersion > extensionVersion) {
let tryAnyway = await brimstone.window
.confirm(`You are trying to load test '${this.filename}' which was saved with a newer version of Brimstone than you are currently using. This test might misbehave, but probably not. Your call.
Continue to load this test with (your possibly) incompatible version of Brimstone?`);
if (!tryAnyway) {
throw new Errors.InvalidVersion();
}
}
}
let screenshotPromises = [];
for (let i = 0; i < actions.length; ++i) {
let _action = actions[i];
if (this.brimstoneVersion < extensionInfo.version) {
this.oldVersion = true;
// convert old tests
if (_action.type === 'start') {
_action.type = 'goto';
}
if (_action.sender) {
_action.tab = _action.sender;
} else if (!_action.tab) {
_action.tab = {};
}
if (_action.tabWidth) {
_action.tab.width = _action.tabWidth;
_action.tab.height = _action.tabHeight;
delete _action.tabWidth;
delete _action.tabHeight;
}
if (_action.tab.virtualId === undefined) {
_action.tab.virtualId = 0;
}
if ('v1.18.0' <= extensionInfo.version) {
if (
_action.type === 'wait' &&
_action?.event?.milliseconds === undefined
) {
_action.type = 'pollscreen';
}
}
}
let action = new TestAction(_action);
this.updateOrAppendAction(action);
if (action.expectedScreenshot?.fileName) {
console.debug(`attach expected zipEntry for step ${i}`);
action.expectedScreenshot = new Screenshot(action.expectedScreenshot);
action.expectedScreenshot.zipEntry = entries.find(
(e) =>
e.filename === `screenshots/${action.expectedScreenshot.fileName}`
);
action._view = constants.view.EXPECTED;
if (!action.expectedScreenshot.zipEntry) {
throw new Error("can't find entry");
}
} else {
action.expectedScreenshot = undefined; // whack any bad data
}
// create the container for the other screenshots to be hydrated,
// thus, if these props exist on the action, they def have a fileName
// but may not be hydrated. if they don't exist, they weren't in the zip.
// These can be hydrated later
if (action.acceptablePixelDifferences?.fileName) {
console.debug(`attach acceptable zipEntry for step ${i}`);
action._match = constants.match.ALLOW;
action.acceptablePixelDifferences = new Screenshot(
action.acceptablePixelDifferences
);
action.acceptablePixelDifferences.zipEntry = entries.find(
(e) =>
e.filename ===
`screenshots/${action.acceptablePixelDifferences.fileName}`
);
if (!action.acceptablePixelDifferences.zipEntry) {
throw new Error("can't find entry");
}
} else {
action.acceptablePixelDifferences = undefined; // whack any bad data
}
if (action.actualScreenshot?.fileName) {
action._match = constants.match.FAIL; // if it failed, do I really care to know there are allowed differences too?
// if you have an actual one to load it means that the last time this was run it failed.
// I only store these in old tests. Newer tests will not store these.
action.actualScreenshot = new Screenshot(action.actualScreenshot);
action.actualScreenshot.zipEntry = entries.find(
(e) =>
e.filename === `screenshots/${action.actualScreenshot.fileName}`
);
if (!action.actualScreenshot.zipEntry) {
action.actualScreenshot = undefined; // whack any bad data
}
} else {
action.actualScreenshot = undefined; // whack any bad data
}
}
return this;
}
/**
* A hack to reduce the memory footprint.
* A better approach is to refactor the PlayTree, Test, TestAction, BDS.Test BDS.step classes.
*/
removeScreenshots() {
delete this.steps;
}
}
/**
* An array of file handles to the zipfiles
* @type {FileHandle[]}
*/
let fileHandles = [];
/**
* Let the user pick one or more tests to load.
* @returns {FileHandle[]} An array of filehandles
*
*/
Test.loadFileHandles = async function loadFileHandles() {
fileHandles = [];
try {
fileHandles = await window.showOpenFilePicker({
suggestedName: `test.zip`,
types: [
{
description: 'ZIP archive(s) that can be run by Brimstone',
accept: {
'application/zip': ['.zip'],
'application/json': ['.json'],
},
},
],
multiple: true,
});
} catch (e) {
if (
e instanceof DOMException &&
e.message === 'The user aborted a request.'
) {
return; // fine
}
throw e;
}
return fileHandles;
};
/**
* A global to pass around easily that contains the current test
* @type {Test}
*/
Test.current = null;
/**
* A precalculated blob of the zipfile to save.
* @type {Blob}
*/
Test._saveBlob;
export class PlayTree {
/** json identifier for this filetype */
type = 'brimstone playtree';
/** @type {string} */
description;
/** @type {string} */
author;
/** is this playtree to be considered one big flat test, or as a (linear vis DFT) suite of tests?
* If true, each item is a test, else we conceptually flatten the list of items into one big test.
*/
suite = true;
/**
* Defined only for non-leaf nodes.
* @type {PlayTree[]}
*/
children;
/**
* The filehandle of this node (zip or playlist file).
* @type {FileHandle}*/
_fileHandle;
/**
* If this node is for a ziptest the test will be stored in here.
* @type {Test}
* */
_zipTest;
/**
* The keys provide a "list" of the unique filenames of the ziptests under this tree.
* Used by the root node to clear allowed pixel differences of a suite.
*/
uniqueZipFilenames = {};
/**
* If this node is for a ziptest the number of steps in this
* zip test will be stored in here. Used for ETA.
* @type {number}
* */
_stepsInZipTest;
/**
* If this node is for a ziptest then this is the base index of
* this zipnodes steps in the context of the larger set.
* @type {number}
*/
_stepBaseIndex;
/** @type {PlayTree} */
_parent;
constructor(args) {
this._parent = args?.parent;
}
/**
* A set of run reports for this node.
* If this node is is zipnode, or a flat (suite:false) playlist
* then there will only be one entry. If this node
* is a suite (suite:true) then there will be one or more entries.
* @type {BDS.Test[]}
* */
reports;
toJSON() {
return {
type: this.type,
description: this.description,
author: this.author,
play: this.play.map((p) => ({ name: p.name })),
};
}
/**
* async constructor
* @param {FileHandle[]} fileHandles
* @returns this
*/
async fromFileHandles(...fileHandles) {
if (fileHandles.length > 1) {
this.children = [];
// we want to create this node with many filehandles, so it has children
for (let i = 0; i < fileHandles.length; ++i) {
let fileHandle = fileHandles[i];
let child = await new PlayTree({ parent: this }).fromFileHandles(
fileHandle
);
this.children.push(child);
}
} else {
// we want to create a node from one file handle
this._fileHandle = fileHandles[0];
if (this._fileHandle.name.endsWith('.json')) {
// the filehandle is to a json file. so we we need to get it's file handles and recurse back to previous case.
if (!PlayTree.directoryHandle) {
await brimstone.window.alert(
'You must specify a (base) directory that will contain all your tests before you can use playlists.'
);
if (!(await PlayTree.loadLibrary())) {
throw new Errors.TestLoadError(
'Base test directory access must be specified in order to load playlists.',
this._fileHandle.name
);
}
}
let blob = await this._fileHandle.getFile();
blob = await blob.text();
let pojo;
try {
pojo = JSON.parse(blob);
} catch (e) {
if (e instanceof SyntaxError) {
throw new Errors.TestLoadError(
`Syntax error: ${e.stack}`,
this._fileHandle.name
);
}
}
this.description = pojo.description;
this.author = pojo.author;
this.suite = pojo.suite === undefined ? true : pojo.suite;
/* build a map from filename to filehandle */
let directoryEntries = {};
for await (let [key, value] of PlayTree.directoryHandle.entries()) {
directoryEntries[key] = value;
}
// get the filehandles for this playlist
let fileHandles = pojo.play.map(
(playNode) =>
directoryEntries[playNode.name] ??
(() => {
throw new Errors.TestLoadError(
`playlist item file '${playNode.name}' not found`,
this._fileHandle.name
);
})()
);
// recurse
await this.fromFileHandles(...fileHandles);
} else {
// it's a zip, which terminates recursion
// get the nmber of steps in the zip so we can
// let the user know how long playing takes etc.
const blob = await this._fileHandle.getFile();
let blobReader = new zip.BlobReader(blob); // construct a blob reader
let zipReader = new zip.ZipReader(blobReader); // construct a zip reader
let entries = await zipReader.getEntries(); // get the entries
let testJsonEntry = entries.find((e) => e.filename === 'test.json');
let testJson = await testJsonEntry.getData(new zip.TextWriter()); // The type of Writer determines the return type.
let testPojo = JSON.parse(testJson);
this._stepsInZipTest = testPojo.steps.length;
}
}
return this;
}
/** Give us the depth first traversal of the tree leaf nodes.
* i.e. the linear sequence of zip files to play.
*/
depthFirstTraversal(array) {
if (!this.children) {
array.push(this);
}
this.children?.forEach((child) => child.depthFirstTraversal(array));
}
/** return the path to the parent */
path() {
let p = '';
for (
let node = this;
node?._fileHandle?.name || node?._zipTest?.filename;
node = node._parent
) {
let old = p;
p = node?._fileHandle?.name || node?._zipTest?.filename;
if (old) {
p += '/' + old;
}
}
return p;
}
/**
* Build the report(s) for this node.
* @returns {BDS.Test[]} A set of run reports for this node.
* If this node is is zipnode, or a flat (suite:false) playlist
* then there will only be one entry. If this node
* is a suite (suite:true) then there will be one or more entries.
*/
buildReports() {
this.reports = [];
let reports = this.reports; // shorter alias
// if I am a ziptest node return me
if (this._zipTest) {
this._zipTest.lastRun.path = this.path();
return (this.reports = [this._zipTest.lastRun]);
}
if (!this.children && this._fileHandle.name.endsWith('.zip')) {
// we haven't loaded this zipfile into a zipTest yet, meaning
// we have not run it.
return (this.reports = [new BDS.Test()]); // returns status "not run"
}
// you should either be a _zipTest or have children but not both.
for (let i = 0; i < this.children.length; ++i) {
let child = this.children[i];
/** @type {BDS.Test[]} */
let childReports;
childReports = child.buildReports();
// playing this child has returned either [report], or [report1, report2, ...],
// either way keep on appending them into a flat array.
reports.push(...childReports);
}
// now all children are processed
if (!this.suite) {
// i need to return a single report, i.e. [report]
let flatReport = new BDS.Test();
flatReport.startDate = reports[0].startDate;
flatReport.wallTime = 0;
flatReport.userTime = 0;
flatReport.name = this._fileHandle.name;
flatReport.startingServer = reports[0].startingServer;
var baseIndex = 0;
for (let i = 0; i < reports.length; ++i) {
let report = reports[i];
flatReport.status = report.status === 'allow' ? 'pass' : report.status; // an allow is a pass
flatReport.userTime += report.userTime;
flatReport.wallTime += report.wallTime;
flatReport.endDate = report.endDate;
flatReport.errorMessage = report.errorMessage;
let lastStep = report.failingStep || report.steps.length;
for (let j = 0; j < lastStep; ++j) {
let step = clone(report.steps[j]);
step.baseIndex = baseIndex;
step.index += baseIndex;
step.path = report.path;
flatReport.steps.push(step);
}
if (report.failingStep) {
flatReport.errorMessage = report.errorMessage;
break;
}
baseIndex += report.steps.length;
}
this.reports = [flatReport];
}
// else it's a suite so we process all the child results as individual tests
return this.reports;
}
}
/**
* @type {FileSystemDirectoryHandle}
*/
PlayTree.directoryHandle;
/**
* The complete playtree, i.e the root node;
* @type {PlayTree}
*/
PlayTree.complete;
PlayTree.loadLibrary = async function loadLibrary() {
try {
PlayTree.directoryHandle = await window.showDirectoryPicker();
return true;
} catch (e) {
return false;
}
};
/** The aggregate number of steps over all zipnodes loaded. */
PlayTree.stepsInZipNodes = 0;
const PNG = png.PNG;
export const constants = {
/** properties of the instance. it can have more than one set, these are converted to classes.*/
view: {
/** 2nd card - result doesn't match. (here is what we expected) */
EXPECTED: 'expected',
/** 2nd card during recording - screenshot is constantly being refreshed */
DYNAMIC: 'dynamic',
/** 2nd card - it doesn't match. (here is what we got) */
ACTUAL: 'actual',
/** 2nd card - it doesn't match. (let's make it okay to have some differences between expected and actual) */
EDIT: 'edit',
/** 1st card - just show the action */
ACTION: 'action',
},
/** the status of a testrun/step */
match: {
/** the last time this action ws played it passed */
PASS: 'pass',
/** this action is currently being played */
PLAY: 'play',
/** the last time this action was played it passed with allowed pixel differences */
ALLOW: 'allow',
/** the last time this action was played it mismatched screenshots */
FAIL: 'fail',
/** the last time this action was played it was canceled by the user before */
CANCEL: 'cancel',
/** this action has not been played yet */
NOTRUN: 'notrun',
/** play stopped just prior to this action actully playing because of a breakpoint */
BREAKPOINT: 'breakpoint',
/** action was not played because the wrong element would recieve the there was an overlay present (timeout) */
WRONG_ELEMENT: 'wrongElement',
},
};
const pointer = `
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="arrow-pointer"
class="svg-inline--fa fa-arrow-pointer" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path fill="currentColor"
d="M318.4 304.5c-3.531 9.344-12.47 15.52-22.45 15.52h-105l45.15 94.82c9.496 19.94 1.031 43.8-18.91 53.31c-19.95 9.504-43.82 1.035-53.32-18.91L117.3 351.3l-75 88.25c-4.641 5.469-11.37 8.453-18.28 8.453c-2.781 0-5.578-.4844-8.281-1.469C6.281 443.1 0 434.1 0 423.1V56.02c0-9.438 5.531-18.03 14.12-21.91C22.75 30.26 32.83 31.77 39.87 37.99l271.1 240C319.4 284.6 321.1 295.1 318.4 304.5z">
</path>
</svg>`;
export class TestAction {
/**
* a string that identifies the action type.
* FIXME: i think it would make sense to refactor these as a subclass of
* TestAction?
* @type {string}
*/
type;
/**
* @type {object}
* @property {number} frameId frame in the tab that generated this action */
sender;
/**
* @type {Tab} info about the tab this action was recorded on.
*/
tab = null;
/**
* object that describes the boundingClientRect in percentages
* so that it can render when the UI is resized.
*/
overlay;
/** how long the mouse hovered over this element before it was clicked.
* helps replay wait long enough to trigger (custom) tooltips.
*/
hoverTime;
/** text to display in UI about this action */
description;
/** the index of this action within the full test */
index;
/**
* used to distinguish 1st from 2nd click for single double clicks
*/
detail;
/**
* the element that is the target of this action
*/
boundingClientRect;
/** x coordinate of the action. for mouse events, this is the pixel location of the mouse. for type events it is the middle of the element that gets the key */
x;
/** y coordinate of the action. for mouse events, this is the pixel location of the mouse. for type events it is the middle of the element that gets the key */
y;