@suffolklitlab/alkiln 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -44,6 +44,14 @@ Format:
44
44
  -->
45
45
  <!-- ## [Unreleased] -->
46
46
 
47
+ ## [5.4.0] - 2023-10-14
48
+
49
+ ### Added
50
+
51
+ - New step to compare PDFs!
52
+ - An example: `I expect the baseline PDF "something-in-sources.pdf" and the new PDF "downloaded.pdf" to be the same`
53
+ - See [#774](https://github.com/SuffolkLITLab/ALKiln/pull/774) for more details.
54
+
47
55
  ## [5.3.0] - 2023-10-14
48
56
 
49
57
  ### Added
package/lib/scope.js CHANGED
@@ -9,6 +9,7 @@ const safe_filename = require("sanitize-filename");
9
9
  const faker = require('@faker-js/faker/locale/en').faker;
10
10
 
11
11
  // Ours
12
+ const pdf_reader = require('./utils/pdf_reader');
12
13
  const session_vars = require('./utils/session_vars');
13
14
  const get_base_interview_url = require(`./docassemble/get_base_interview_url`);
14
15
  const time = require('./utils/time');
@@ -1679,22 +1680,15 @@ module.exports = {
1679
1680
  return valid_date;
1680
1681
  }, // Ends scope.today_to_date()
1681
1682
 
1682
- uploadFiles: async function ( scope, { handle, answer }) {
1683
- /* Try to upload one or more files to a file-type input field.
1684
- * Does not submit, so no danger of timeout with server reload.
1685
- *
1686
- * Returns whether the row was used or not.
1687
- */
1688
-
1683
+ findFiles: async function( scope, {to_find_names}) {
1689
1684
  // First make sure the list of files was defined. Don't remove spaces yet -
1690
1685
  // allow a typo where someone forgot to include a space after a comma.
1691
- let uploads_names = answer.split(`,`);
1692
- if ( uploads_names.length === 0 ) {
1686
+ if ( to_find_names.length === 0 ) {
1693
1687
  reports.addToReport( scope, {
1694
1688
  type: `error`,
1695
- value: `Your list of files to upload is empty.`
1689
+ value: `Your list of files to find is empty.`
1696
1690
  });
1697
- return false; // The row wasn't used
1691
+ return [];
1698
1692
  }
1699
1693
 
1700
1694
  let dirs = [];
@@ -1717,7 +1711,7 @@ module.exports = {
1717
1711
  }
1718
1712
 
1719
1713
  if ( session_vars.get_debug() ) {
1720
- console.log( `All discovered dirs to upload from:`, dirs );
1714
+ console.log( `All discovered dirs to find files from:`, dirs );
1721
1715
  }
1722
1716
 
1723
1717
  // Make sure sources directory exists and has files in it
@@ -1727,14 +1721,13 @@ module.exports = {
1727
1721
  value: `The test files need to be in the "sources" directory, `
1728
1722
  + `but ALKiln couldn't find the directory or it was empty.`
1729
1723
  });
1730
- return false; // The row wasn't used
1724
+ return [];
1731
1725
  }
1732
1726
 
1733
1727
  // Get the full paths of all the files
1734
- let dir = null;
1735
1728
  let paths = [];
1736
1729
  let missing_files = []; // For any user warnings
1737
- for ( let name of uploads_names ) {
1730
+ for ( let name of to_find_names) {
1738
1731
 
1739
1732
  let trimmed_name = name.trim();
1740
1733
  let matching_paths = [];
@@ -1760,9 +1753,27 @@ module.exports = {
1760
1753
  });
1761
1754
  }
1762
1755
 
1763
- // If all files missing, return that the row wasn't used
1764
- if ( missing_files.length === uploads_names.length ) {
1765
- return false; // The row wasn't used
1756
+ // If all files missing, return an empty array
1757
+ if ( missing_files.length === to_find_names.length ) {
1758
+ return [];
1759
+ }
1760
+ return paths;
1761
+ },
1762
+
1763
+ uploadFiles: async function ( scope, { handle, answer }) {
1764
+ /* Try to upload one or more files to a file-type input field.
1765
+ * Does not submit, so no danger of timeout with server reload.
1766
+ *
1767
+ * Returns whether the row was used or not.
1768
+ */
1769
+
1770
+ // First make sure the list of files was defined. Don't remove spaces yet -
1771
+ // allow a typo where someone forgot to include a space after a comma.
1772
+ let to_find_names = answer.split(`,`);
1773
+ let paths = await scope.findFiles(scope, {to_find_names});
1774
+ if (paths.length == 0) {
1775
+ // The row wasn't used
1776
+ return false;
1766
1777
  }
1767
1778
 
1768
1779
  // Prepare to wait for upload to complete
@@ -2503,6 +2514,32 @@ module.exports = {
2503
2514
  await scope.detectDownloadComplete( scope );
2504
2515
  }, // Ends scope.steps.download()
2505
2516
 
2517
+ compare_pdfs: async function (scope, {existing_pdf_path, new_pdf_path}) {
2518
+ let existing_paths = await scope.findFiles(scope, {to_find_names: [existing_pdf_path]});
2519
+ if (existing_paths.length == 0) {
2520
+ // The row wasn't used
2521
+ let msg = `Could not find the existing PDF at ${ existing_pdf_path }`;
2522
+ reports.addToReport(scope, { type: `error`, value: msg });
2523
+ scope.failed_pdf_compares.push(msg);
2524
+ // Return early since we couldn't find the PDF
2525
+ return;
2526
+ }
2527
+ let full_existing_path = existing_paths[0];
2528
+ let full_new_pdf_path = `${scope.paths.scenario}/${new_pdf_path}`;
2529
+ let diffs = await pdf_reader.compare_pdfs(full_existing_path, full_new_pdf_path);
2530
+ if (diffs) {
2531
+ let added_str = diffs.filter(part => part.added).reduce((err_str, part) => {
2532
+ return err_str + `- ${ part.value }\n`
2533
+ }, 'The new PDF added: \n');
2534
+ let removed_str = diffs.filter(part => part.removed).reduce((err_str, part) => {
2535
+ return err_str + `- ${ part.value }\n`
2536
+ }, 'The new PDF removed: \n');
2537
+ 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}`;
2538
+ reports.addToReport(scope, { type: `error`, value: msg });
2539
+ scope.failed_pdf_compares.push(msg);
2540
+ }
2541
+ },
2542
+
2506
2543
  set_al_name: async ( scope, var_base, name_str ) => {
2507
2544
  /** Given a variable name and a space-separated string, fills the
2508
2545
  * appropriate fields on an Assembly Line name question.
@@ -2930,4 +2967,5 @@ module.exports = {
2930
2967
 
2931
2968
  return none_included;
2932
2969
  },
2970
+
2933
2971
  };
package/lib/steps.js CHANGED
@@ -147,6 +147,7 @@ Before(async (scenario) => {
147
147
  scope.page_id = null;
148
148
  scope.check_all_for_a11y = false;
149
149
  scope.passed_all_for_a11y = true;
150
+ scope.failed_pdf_compares = [];
150
151
 
151
152
  // Reset default timeout
152
153
  scope.timeout = default_timeout;
@@ -705,6 +706,19 @@ Then(/I get the(?: page's)?(?: JSON)? var(?:iable)?s and val(?:ue)?s/i, { timeou
705
706
  );
706
707
  });
707
708
 
709
+ Then(/I expect the baseline PDF "([^"]+)" and the new PDF "([^"]+)" to be the same/i, async (existing_pdf_path, new_pdf_path) => {
710
+ /** Compare the text or fields of a baseline PDF and the newly downloaded PDF.
711
+ *
712
+ * Variations:
713
+ * Then I expect the baseline PDF "simple-doc-Baseline.pdf" and the new PDF "simple-doc.pdf" to be the same
714
+ */
715
+
716
+ return await wrapPromiseWithTimeout(
717
+ scope.steps.compare_pdfs(scope, {existing_pdf_path, new_pdf_path}),
718
+ scope.timeout
719
+ );
720
+ });
721
+
708
722
  //#####################################
709
723
  //#####################################
710
724
  // Actions
@@ -1104,12 +1118,21 @@ After(async function(scenario) {
1104
1118
  if ( scope.check_all_for_a11y && !scope.passed_all_for_a11y) {
1105
1119
  // The fact that the scenario failed has already been noted earlier in the report
1106
1120
  changeable_test_status = `FAILED`;
1107
- scope.addToReport(scope, {
1121
+ reports.addToReport(scope, {
1108
1122
  type: `error`,
1109
1123
  value: `Accessibility standards weren't met. See information above.`
1110
1124
  });
1111
1125
  }
1112
1126
 
1127
+ if ( scope.failed_pdf_compares.length > 0) {
1128
+ let msg = scope.failed_pdf_compares.reduce((str, new_msg) => `${ str }\n---\n${ new_msg }`)
1129
+ changeable_test_status = `FAILED`;
1130
+ reports.addToReport(scope, {
1131
+ type: `error`,
1132
+ value: `PDF comparison failed ${ scope.failed_pdf_compares.length } time(s)\n---\n${ msg }\n---\n`
1133
+ });
1134
+ }
1135
+
1113
1136
  // Record status before internal report tests can interfere with values
1114
1137
  reports.setReportScenarioStatus( scope, { status: changeable_test_status });
1115
1138
 
@@ -0,0 +1,113 @@
1
+ // All functions dealing with getting information from PDFs
2
+
3
+ const pdfjs_lib = require("pdfjs-dist/legacy/build/pdf.js");
4
+ const diff_lib = require("diff");
5
+
6
+ let _load_pdf = async function(pdf_path) {
7
+ // Simple wrapper to get the document
8
+ return await pdfjs_lib.getDocument({
9
+ url: pdf_path,
10
+ // https://github.com/mozilla/pdf.js/issues/4244#issuecomment-1479534301
11
+ useSystemFonts: true
12
+ }).promise;
13
+ };
14
+
15
+ let _get_content = async function(pdf_doc) {
16
+ let num_pages = pdf_doc.numPages;
17
+ let contents = "";
18
+ for (var i = 1; i <= num_pages; i++) {
19
+ const page_contents = await (await pdf_doc.getPage(i)).getTextContent();
20
+ contents += "\n" + page_contents.items.map((item) => item.str).join(" ");
21
+ }
22
+ return contents;
23
+ };
24
+
25
+ let pdf_reader = {};
26
+
27
+ pdf_reader.get_content = async function(pdf_path) {
28
+ /* Gets the full text content from the PDF.
29
+ *
30
+ * @returns str
31
+ */
32
+
33
+ let doc = await _load_pdf(pdf_path);
34
+ return await _get_content(doc);
35
+ }; // Ends get_content()
36
+
37
+ pdf_reader.get_fields = async function(pdf_path) {
38
+ /* Gets all of the fields in the PDF
39
+ *
40
+ * Example:
41
+ * {
42
+ * "users1_name_first": [
43
+ * {
44
+ * "id": "6R",
45
+ * "value": "aeu",
46
+ * "defaultValue": "",
47
+ * "multiline": false,
48
+ * "password": false,
49
+ * "charLimit": 0,
50
+ * "comb": false,
51
+ * "editable": true,
52
+ * "hidden": false,
53
+ * "name": "users1_name_first",
54
+ * "rect": [
55
+ * 143.52,
56
+ * 660.72,
57
+ * 315.11,
58
+ * 674.88
59
+ * ],
60
+ * "actions": null,
61
+ * "page": 0,
62
+ * "strokeColor": null,
63
+ * "fillColor": null,
64
+ * "rotation": 0,
65
+ * "type": "text"
66
+ * }
67
+ * ],
68
+ */
69
+ let doc = await _load_pdf(pdf_path);
70
+ const field_objects = await doc.getFieldObjects();
71
+ return field_objects;
72
+ }; // Ends get_fields
73
+
74
+ pdf_reader.get_field_value = async function(pdf_path, field_name) {
75
+ let field_objects = await pdf_reader.get_fields(pdf_path);
76
+ if (field_name in field_objects) {
77
+ for (let inst of field_objects[field_name]) {
78
+ if ("value" in inst) {
79
+ return inst["value"];
80
+ }
81
+ }
82
+ }
83
+ return null;
84
+ }; // Ends get_field
85
+
86
+ pdf_reader.compare_pdfs = async function(existing_pdf_path, new_pdf_path) {
87
+ /**
88
+ * Returns the change objects between the PDF fields, if there's any diff
89
+ */
90
+ let existing_doc = await _load_pdf(existing_pdf_path);
91
+ let new_doc = await _load_pdf(new_pdf_path);
92
+ let existing_content = await _get_content(existing_doc);
93
+ let new_content = await _get_content(new_doc);
94
+
95
+ // Assumption: authors won't be changing their PDF templates programmatically;
96
+ // so if the text of the PDFs are different, then it's actually the fields that are different,
97
+ // but they aren't editable / are flattened into the PDF.
98
+ let change = diff_lib.diffWords(existing_content, new_content);
99
+ if (change.some(diff => diff.added || diff.removed)) {
100
+ return change;
101
+ }
102
+
103
+ const existing_fields = await existing_doc.getFieldObjects();
104
+ const new_fields = await new_doc.getFieldObjects();
105
+
106
+ let changeObjs = diff_lib.diffJson(existing_fields, new_fields);
107
+ if (changeObjs.some(diff => diff.added || diff.removed)) {
108
+ return changeObjs;
109
+ }
110
+ return null;
111
+ }; // End compare_pdfs
112
+
113
+ module.exports = pdf_reader;