@suffolklitlab/alkiln 5.13.1 → 5.13.2-fix-da-dom-fieldset-1

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.
@@ -52,8 +52,8 @@ jobs:
52
52
  EMPTY_STRING: ""
53
53
  NORMAL_USER_EMAIL: alkiln@example.com
54
54
  NORMAL_USER_PASSWORD: User123^
55
- WRONG_EMAIL=wrong_email@example.com
56
- WRONG_PASSWORD=wrong_password
55
+ WRONG_EMAIL: wrong_email@example.com
56
+ WRONG_PASSWORD: wrong_password
57
57
 
58
58
  steps:
59
59
  # Place the root directory in this branch to access
@@ -160,7 +160,7 @@ jobs:
160
160
  #### Developer note: You can probably leave the rest out
161
161
  #### To learn more, see https://assemblyline.suffolklitlab.org/docs/alkiln/writing/#optional-inputs
162
162
  ALKILN_TAG_EXPRESSION: "${{ env.ALKILN_TAG_EXPRESSION }}"
163
- ALKILN_VERSION: delete
163
+ ALKILN_VERSION: da_DOM_fieldset
164
164
 
165
165
  #### Developer note: Example of making an issue when tests fail
166
166
  #### that includes the text of the failure output file
@@ -47,8 +47,8 @@ jobs:
47
47
  NORMAL_USER_PASSWORD: ${{ secrets.USER_NO_PERMISSIONS_PASSWORD }}
48
48
  NORMAL_USER_API_KEY: ${{ secrets.USER_NO_PERMISSIONS_API_KEY }}
49
49
  INVALID_API_KEY: invalidAPIkey
50
- WRONG_EMAIL=wrong_email@example.com
51
- WRONG_PASSWORD=wrong_password
50
+ WRONG_EMAIL: wrong_email@example.com
51
+ WRONG_PASSWORD: wrong_password
52
52
  EMPTY_STRING: ""
53
53
  SECRET_VAR1: secret-var1-value
54
54
  SECRET_VAR2: secret-var2-value
@@ -81,4 +81,4 @@ jobs:
81
81
  # want to check up on this.
82
82
  ALKILN_TAG_EXPRESSION: "${{ env.ALKILN_TAG_EXPRESSION }}"
83
83
  #### Developer note: You can probably leave this out
84
- ALKILN_VERSION: delete
84
+ ALKILN_VERSION: da_DOM_fieldset
package/CHANGELOG.md CHANGED
@@ -45,6 +45,18 @@ Format:
45
45
 
46
46
  ## [Unreleased]
47
47
 
48
+ ### Fixed
49
+
50
+ - Updates to latest docassemble HTML. See [#960](https://github.com/SuffolkLITLab/ALKiln/issues/960). This handles more than what is necessary, but it there's little downside.
51
+
52
+ ## [5.13.2] - 2024-11-23
53
+
54
+ ### Fixed
55
+
56
+ - Fixes download Step unable to use a partial filename match. The Step needed the whole name, including the extension. See [#725](https://github.com/SuffolkLITLab/ALKiln/issues/725).
57
+
58
+ ## [5.13.1] - 2024-10-23
59
+
48
60
  ### Changed
49
61
 
50
62
  - Moves "expected" status error to only be visible to internal test errors. Closes [#933](https://github.com/SuffolkLITLab/ALKiln/issues/933).
@@ -52,7 +64,6 @@ Format:
52
64
  ### Fixed
53
65
 
54
66
  - Detects failed sign in. Closes [#918](https://github.com/SuffolkLITLab/ALKiln/issues/918).
55
- - Updates pdfjs-dist for node v20 and v22. See [#952](https://github.com/SuffolkLITLab/ALKiln/issues/952).
56
67
 
57
68
  ### Internal
58
69
 
@@ -63,6 +74,7 @@ Format:
63
74
  - Adds decision docs
64
75
  - Updated CONTRIBUTING.md
65
76
  - Added example.env, closes [#374](https://github.com/SuffolkLITLab/ALKiln/issues/374)
77
+ - Updates pdfjs-dist for node v20 and v22. See [#952](https://github.com/SuffolkLITLab/ALKiln/issues/952).
66
78
 
67
79
  ## [5.13.0] - 2024-07-11
68
80
 
package/README.md CHANGED
@@ -41,25 +41,46 @@ Read about contributing in our [CONTRIBUTING.md document](CONTRIBUTING.md). Here
41
41
  ## Cheat sheet
42
42
 
43
43
  *Once you've already read the contributing documentation, you can use these as quick reminders for running our internal tests.*
44
- To set up for the integration tests, create the project on the server:
44
+
45
+ ### `setup` before starting development of a feature/fix
46
+
47
+ Set up for the cucumber integration tests.
48
+
49
+ Add the feature branch name to your `.env` file:
50
+
51
+ ```
52
+ BRANCH=42_feat_life_the_univers_and_everything
53
+ ```
54
+
55
+ Then create the project on the server:
45
56
 
46
57
  ```bash
47
58
  npm run setup
48
59
  ```
49
60
 
50
- Use the syntax below to trigger specific tags:
61
+ ### Run tests repeatedly
62
+
63
+ Run all cucumber tests that are not currently blocked by upstream changes:
64
+
65
+ ```bash
66
+ npm run pass
67
+ ```
68
+
69
+ Trigger cucumber tests with specific tags:
51
70
 
52
71
  ```bash
53
- npm run cucumber -- "--tags" "@tagname"
72
+ npm run cucumber "@tagname"
54
73
  ```
55
74
 
56
- To run the unit tests in isolation:
75
+ Run the unit tests:
57
76
 
58
77
  ```bash
59
78
  npm run unit
60
79
  ```
61
80
 
62
- If you or someone else changes the interview code in `./docassemble`, you have to clean up the old data on the server before running `setup` again:
81
+ ### Always run `takedown` before running `setup` again
82
+
83
+ If you or someone else changes the interview code in our `./docassemble` directory, you have to delete the code currently on the testing server before running `setup` again. Also do this when you're done with the feature/fix or starting a new feature/fix.
63
84
 
64
85
  ```bash
65
86
  npm run takedown
@@ -0,0 +1,27 @@
1
+ # Constrained random input tests architecture and decisions
2
+
3
+
4
+ ## What should throw an error in the generator parsing stage?
5
+
6
+ Nothing? Should it all get reported in the ALKiln tests?
7
+
8
+ ## Author wrote multiple generator Steps in one Scenario
9
+
10
+ Discuss: Warning or error
11
+
12
+ We think the easiest and clearest reaction is to refuse to generate any test and give a warning or error.
13
+
14
+ Other options:
15
+
16
+ - Use the last Step and delete the others. This may be hard to do. We'd need to mutate the Scenario's file text lines, maybe as we were looping through them. It would take some research
17
+ - Use the first Step and leave the others as they are. This would generate a broken test, but the test would at least work until it gets to the next generator Step line. We could add a Step in the core code for the generator Step and use it to give a meaningful error instead of just letting cucumber set a status of `undefined`. Maybe that would at least be a bit more clear, but I think not as clear as just refusing to generate and producing a warning or error.
18
+
19
+ Complications:
20
+
21
+ - What if there are other Steps between and after the generator Steps? Do we keep those and put them all at the bottom? Do we only keep the ones after the chosen generator Step?
22
+
23
+ If we do decide to pick a generator Step, maybe we can make sure to pick one that is otherwise valid. That is, it has a dataTable that has rows, is otherwise correctly formatted, etc.
24
+
25
+ ## Grammar
26
+
27
+ Post MVP, discuss: should generator Steps without a number, but with the word "tests" generate 2 Scenarios?
@@ -0,0 +1 @@
1
+ Moving the runtime config to the sources folder would make it very easy for each Project to have its own runtime config which would make ALKilnInThePlayground work more smoothly. On the other hand, it could be confusing to authors (though it's fine if they check it in now, it may not be fine later). On the other other hand, we could delete it after every test suite run and and "temp" and "alkiln" to the name. If there are multiple sources folders with config files then...? We'll just have to pick one?
@@ -0,0 +1,101 @@
1
+ # Constrained random input tests
2
+
3
+ I do 10 constrained random tests
4
+
5
+ ## Why do people want purely random input tests?
6
+
7
+ 1. They don't have time to write individual specific tests
8
+ 2. They are worried they haven't covered all eventualities
9
+
10
+ ## Problems for purely random tests
11
+
12
+ - Non-specific input. E.g. a name field might get a sentence. Can we do our best? That would be more work.
13
+
14
+ ## Why would people prefer constrained random input tests?
15
+
16
+ - They want to make the tests more focused on certain scenarios and avoid wasting time on unimportant tests. For example, sometimes several options actually give the same answer. A purely random test would give more preference to the repeated answer.
17
+ - **Some fields might require very specific answers, like a real lawyer's bar number or a specific file name for uploading exhibits.**
18
+ - Narrow down the paths at some points, like always choosing that you have minor children
19
+ - **Even if the user doesn't know it, this is the most flexible option**
20
+
21
+ ## Note
22
+
23
+ ~~If in the future we want to add the option to not specify, then we can and it won't be a breaking change.~~
24
+
25
+ We have to decide what it means to allow a person to not specify. E.g if excluding a row means don't give an answer to that variable at all, then it can't in future mean "give a random answer to this field". Options:
26
+
27
+ - (bad) Excluding a row means don't give an answer. Including a row with a blank answer means give a random answer.
28
+ - (bad) Including a row with a blank answer means don't give an answer. Give unlisted variables random answers.
29
+ - Or should we require a string (e.g. ALK_NO_ANSWER) for fields that should not get random inputs?
30
+ - (most easy purely random input method to implement without changing current code?) Or specify randomness (e.g. ALK_RANDOM_ANSWER)?
31
+ - **(backwards compatible because we can become more flexible later) Require both of the above?**
32
+
33
+ Which of these is worse? Mind reading can be bad. On the other hand, specifying everything may be somewhat antithetical to the 2 goals of people who want purely random input tests. (added to the interview an unrequired value, but forgot to add it to the test, they won't test all eventualities).
34
+
35
+ Possible solutions for people who want purely random tests or bits of tests:
36
+ - Add a Step for answering randomly for a whole test.
37
+ - Add a Step for answering randomly up to various page ids (list is allowed).
38
+ - Add Step to answer randomly for one page.
39
+
40
+ ## Flexible constraints?
41
+
42
+ Post MVP
43
+
44
+ - Allow number range "ALKiln: [1, 5)"? Or they have to do "1, 2, 3, 4"
45
+ - "ALKiln: year 1965 - 1999"
46
+ - "ALKiln: date [today, today - 10)"
47
+ - "ALKiln: max length 3" (of 5 checkbox answers)
48
+ - "ALKiln: name"
49
+ - "ALKiln: number" (and other types like "currency") (we can probably figure those out)
50
+
51
+ ## Note
52
+
53
+ MVP of random input tests
54
+
55
+ ## no seeds
56
+
57
+ seeds can break more easily. instead we save the spec of the test(s)
58
+
59
+ ## MVP
60
+
61
+ save the spec of the test...somewhere. Probably using special name to say
62
+ "rerun" and then change the name at the end of the run.
63
+ retry the test somehow (if needed)...
64
+ present the test and results to the user
65
+
66
+ ## Better
67
+
68
+ Avoid repeating previous random tests
69
+ Avoid repeating tests of users (which is much(?) harder)
70
+
71
+ ## Specifics
72
+
73
+ Create a file with a certain # of Scenarios and run it
74
+ - Does cucumber decide the tests it'll run at runtime or before? I think the former
75
+ - Is it better to split tests up into separate files if there are a lot?
76
+ - Is it better or worse to help the developer feel the magnitude of the #
77
+ of tests they're running (using Example) [Answer: worse]
78
+ - How to handle separate constrained random tables?
79
+
80
+ ## right now
81
+ [r1 opt 1, r1 opt 2, r1 opt 3, name input, date input, description input]
82
+
83
+ ## need for random
84
+
85
+ detect r1 while looping through the fields
86
+ [r1 opt 1, r1 opt 2, r1 opt 3, name input, date input, description input]
87
+ Con: as we loop through, we don't know the number of options at each particular instance
88
+
89
+ or make grouped fields into groups. Basically group r1:
90
+ [[r1 opt 1, r1 opt 2, r1 opt 3], name input, date input, description input]
91
+
92
+ ## why not generate a test that uses all the constraints and thus is fully random?
93
+
94
+ Because we'd have to parse docassembled *compiled* files, which is very hard
95
+
96
+ Alternative:
97
+
98
+ When the developer runs a test, they can use a Step to have it collect all possible fields that ALKiln sees during that test run.
99
+ Q: How effectively can we decode the fields? Or can we make a tool that will help a developer decode their fields? (Include an input that just decodes one encoded string (`atob()`))
100
+ Q: If the fields are multiple choice and the developer's test picks at least one choice from that group of fields, can we use that to properly decode all the names of the fields? Hesitant to do this because some values may be encoded differently from other values (I think).
101
+ Q: Is this more work than just refactoring how we group fields?
@@ -0,0 +1,11 @@
1
+ Pros for separate action:
2
+
3
+ - Reflects nature of process - generating files is inherently separate.
4
+ - Potentially cleaner separation conceptually for us.
5
+
6
+ Cons:
7
+
8
+ - Main beef: Developing and maintaining GitHub actions is hard
9
+ - Authors have more in their workflow file
10
+ - Need to update existing workflows
11
+ - Need to update ALKilnSetup interview
package/lib/scope.js CHANGED
@@ -514,13 +514,17 @@ module.exports = {
514
514
  },
515
515
 
516
516
  continue_button_selector: `fieldset.da-field-buttons button[type="submit"].btn-primary`,
517
+ continue_button_selector_backup: `.da-fieldset.da-field-buttons button[type="submit"].btn-primary`,
517
518
  signature_selector: `fieldset .dasigsave`,
519
+ signature_selector_backup: `.da-fieldset .dasigsave`,
518
520
  continue: async function ( scope ) {
519
521
  /* Presses whatever button it finds that might lead to the next page. */
520
522
  // Any selectors I find seem somewhat precarious.
521
523
  let elem = await Promise.race([
522
524
  scope.page.waitForSelector(scope.continue_button_selector), // other pages (this is the most consistent way)
525
+ scope.page.waitForSelector(scope.continue_button_selector_backup),
523
526
  scope.page.waitForSelector(scope.signature_selector), // signature page
527
+ scope.page.waitForSelector(scope.signature_selector_backup),
524
528
  ]);
525
529
  await elem.evaluate( el => { return el.className });
526
530
  // Waits for navigation or user error
@@ -540,9 +544,11 @@ module.exports = {
540
544
  // <button type="submit" class="btn btn-da btn-danger" name="X211bHRpcGxlX2Nob2ljZQ" value="0">exit</button>
541
545
  // `buttons:` can be used in question blocks as choices
542
546
  let regular = await scope.page.$(scope.continue_button_selector);
547
+ let regular_backup = await scope.page.$(scope.continue_button_selector_backup);
543
548
  let signature = await scope.page.$(scope.signature_selector);
549
+ let signature_backup = await scope.page.$(scope.signature_selector_backup);
544
550
 
545
- return regular !== null || signature !== null;
551
+ return regular !== null || regular_backup !== null || signature !== null || signature_backup !== null;
546
552
 
547
553
  }, // Ends scope.continue_exists()
548
554
 
@@ -916,7 +922,7 @@ module.exports = {
916
922
  // All the different types of fields
917
923
  // buttons, canvases, inputs of all kinds, selects (dropdowns), textareas. Are there more?
918
924
  // Will deal with `option` once inside `select`
919
- let all_nodes = $( `#dasigpage, fieldset button, .daquestionactionbutton, fieldset input, .da-form-group input, .da-form-group select, form select, .da-form-group textarea` );
925
+ let all_nodes = $( `#dasigpage, fieldset button, .da-fieldset button, .daquestionactionbutton, fieldset input, da-fieldset input, .da-form-group input, .da-form-group select, form select, .da-form-group textarea` );
920
926
  for ( let node of all_nodes ) {
921
927
  // Decision: Do not abstract the below. There's too much data to pass around for it to make sense
922
928
  let $node = $( node );
@@ -2635,6 +2641,7 @@ module.exports = {
2635
2641
 
2636
2642
  let fullPage = true;
2637
2643
  let signature_elem = await scope.page.$(scope.signature_selector);
2644
+ signature_elem = signature_elem || await scope.page.$(scope.signature_selector_backup);
2638
2645
  if ( signature_elem !== null ) {
2639
2646
  fullPage = false;
2640
2647
  }
@@ -2781,7 +2788,7 @@ module.exports = {
2781
2788
  await scope.afterStep(scope);
2782
2789
  }, // Ends scope.steps.sign()
2783
2790
 
2784
- download: async ( scope, filename ) => {
2791
+ download: async ( scope, full_or_partial_file_href ) => {
2785
2792
  /* Taps the link that leads to the given filename to trigger downloading.
2786
2793
  * and waits till the file has been downloaded before allowing the tests to continue.
2787
2794
  * WARNING: Cannot download the same file twice in a single scenario.
@@ -2791,47 +2798,86 @@ module.exports = {
2791
2798
  * TODO: Properly wait for download to complete. See notes in
2792
2799
  * scope.js scope.detectDownloadComplete()
2793
2800
  */
2794
- let [elem] = await scope.page.$$(`xpath/.//a[contains(@href, "${ filename }")]`);
2801
+ let [elem] = await scope.page.$$(`xpath/.//a[contains(@href, "${ full_or_partial_file_href }")]`);
2795
2802
 
2796
- let msg = `"${ filename }" seems to be missing. Cannot find a link to that document.`;
2803
+ let msg = `"${ full_or_partial_file_href }" seems to be missing on the page. Cannot find a link to that document.`;
2797
2804
  if ( !elem ) { reports.addToReport(scope, { type: `error`, code: `ALK0152`, value: msg }); }
2798
2805
  expect( elem, msg ).to.exist;
2799
2806
 
2800
2807
  let failed_to_download = false;
2801
2808
  let err_msg = "";
2802
2809
  try {
2803
- const binaryStr = await scope.page.evaluate(el => {
2810
+
2811
+ const { disposition, binaryStr } = await scope.page.evaluate(async function ( el ) {
2804
2812
  const url = el.getAttribute("href");
2805
2813
  return new Promise(async (resolve, reject) => {
2806
2814
  const response = await fetch(url, {method: "GET"});
2807
2815
  const reader = new FileReader();
2808
2816
  reader.readAsBinaryString(await response.blob());
2809
- reader.onload = () => resolve(reader.result);
2817
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
2818
+ reader.onload = () => resolve({
2819
+ disposition: response.headers.get(`Content-Disposition`),
2820
+ binaryStr: reader.result
2821
+ });
2810
2822
  reader.onerror = () => reject(`šŸ¤• ALK0153 ERROR: Error occurred on page when downloading ${ url }: ${ reader.error }`);
2811
2823
  });
2812
2824
  }, elem);
2825
+
2826
+ err_msg = `could not get the actual name of the file from the response headers.`;
2827
+ let actual_filename = await scope.steps.get_response_filename({
2828
+ disposition, default_name: full_or_partial_file_href
2829
+ });
2830
+
2813
2831
  if (binaryStr !== '') {
2832
+ err_msg = `binary data for download was empty`;
2814
2833
  const fileData = Buffer.from(binaryStr, 'binary');
2815
- fs.writeFileSync(`${ scope.paths.scenario }/${ filename }`, fileData);
2816
- reports.addToReport(scope, { type: `row info`, code: `ALK0154`, value: `Downloaded ${ filename } (${ fileData.length } bytes) to ${ scope.paths.scenario }`});
2834
+ fs.writeFileSync(`${ scope.paths.scenario }/${ actual_filename }`, fileData);
2835
+ reports.addToReport(scope, { type: `row info`, code: `ALK0154`, value: `Downloaded "${ actual_filename }" (${ fileData.length } bytes) to ${ scope.paths.scenario }`});
2817
2836
  } else {
2818
2837
  failed_to_download = true;
2819
- err_msg = `Couldn't download ${ filename }, binary data for download was empty`;
2820
2838
  }
2839
+
2821
2840
  } catch (error) {
2822
2841
  failed_to_download = true;
2823
2842
  err_msg = error;
2824
2843
  }
2825
2844
 
2826
2845
  if (failed_to_download) {
2827
- reports.addToReport(scope, { type: `warning`, code: `ALK0155`, value: `Could not download file using fetch (${ err_msg }). ALKiln will now fallback to the click download method.` });
2828
- scope.toDownload = filename;
2846
+ reports.addToReport(scope, { type: `warning`, code: `ALK0155`, value: `Could not download a file matching the name "${ full_or_partial_file_href }" using fetch (${ err_msg }). ALKiln will now fallback to the click download method.` });
2847
+ scope.toDownload = full_or_partial_file_href;
2829
2848
  // Should this be using `scope.tapElement`?
2830
2849
  await elem.evaluate( elem => { return elem.click(); });
2831
2850
  await scope.detectDownloadComplete( scope );
2832
2851
  }
2833
2852
  }, // Ends scope.steps.download()
2834
2853
 
2854
+ get_response_filename: async function({ disposition, default_name=`found_no_file_name.pdf` }) {
2855
+ /** Given a fetch response headers' Content-Disposition str, return the
2856
+ * filename of the response's file.
2857
+ *
2858
+ * @return { string | null } - Name of fetched file
2859
+ * */
2860
+ let filename = default_name;
2861
+ if ( disposition ) {
2862
+ filename = disposition.split(`;`)[1].split(`=`)[1];
2863
+ }
2864
+
2865
+ // Handle potential UTF-8 encoded filenames
2866
+ if ( filename.toLowerCase().startsWith( `utf-8''` )) {
2867
+ filename = decodeURIComponent( filename.replace( /utf-8''/i, `` ));
2868
+ } else {
2869
+ // Replace starting and ending quotes if they exist
2870
+ filename = filename.replace( /^['"]/, `` ).replace( /['"]$/, `` );
2871
+ }
2872
+
2873
+ // TODO: Add debug log here
2874
+ // console.log(`filename:`, filename);
2875
+
2876
+ // TODO: Add to the report if we had to use the default name
2877
+
2878
+ return filename;
2879
+ }, // Ends scope.steps.get_response_filename()
2880
+
2835
2881
  compare_pdfs: async function (scope, {existing_pdf_path, new_pdf_path}) {
2836
2882
  let existing_paths = await scope.findFiles(scope, {to_find_names: [existing_pdf_path]});
2837
2883
  if (existing_paths.length == 0) {
@@ -2852,7 +2898,7 @@ module.exports = {
2852
2898
  let removed_str = diffs.filter(part => part.removed).reduce((err_str, part) => {
2853
2899
  return err_str + `- ${ part.value }\n`
2854
2900
  }, 'The new PDF removed: \n');
2855
- let msg = `The PDFs were not the same.\n${ added_str }\n${removed_str}\n\n You can see the full PDFs at ${ full_existing_path } and ${ full_new_pdf_path}`;
2901
+ let msg = `The PDFs were different.\nAdded:\n${ added_str }\nRemoved:\n${removed_str}\n\nThere might be more information if you actually look at the files. You can see the full PDFs at ${ full_existing_path } and ${ full_new_pdf_path}`;
2856
2902
  reports.addToReport(scope, { type: `error`, code: `ALK0157`, value: msg });
2857
2903
  scope.failed_pdf_compares.push(msg);
2858
2904
  }
package/lib/steps.js CHANGED
@@ -1285,10 +1285,11 @@ After(async function(scenario) {
1285
1285
  if ( scope.failed_pdf_compares.length > 0) {
1286
1286
  let msg = scope.failed_pdf_compares.reduce((str, new_msg) => `${ str }\n―――\n${ new_msg }`)
1287
1287
  changeable_test_status = `FAILED`;
1288
+ // TODO: This may be redundant and therefore confusing
1288
1289
  reports.addToReport(scope, {
1289
1290
  type: `error`,
1290
1291
  code: `ALK0093`,
1291
- value: `PDF comparison failed ${ scope.failed_pdf_compares.length } time(s)\n―――\n${ msg }\n―――\n`
1292
+ value: `ALKiln ran into an error when it tried to compare ${ scope.failed_pdf_compares.length } PDF(s)\n―――\n${ msg }\n―――\n`
1292
1293
  });
1293
1294
  }
1294
1295
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suffolklitlab/alkiln",
3
- "version": "5.13.1",
3
+ "version": "5.13.2-fix-da-dom-fieldset-1",
4
4
  "description": "Integrated automated end-to-end testing with docassemble, puppeteer, and cucumber.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {