quickpickle 1.7.1 → 1.9.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/dist/index.esm.js CHANGED
@@ -3,6 +3,9 @@ import { intersection, isFunction, isString, concat, fromPairs, escapeRegExp, de
3
3
  import parse from '@cucumber/tag-expressions';
4
4
  import * as Gherkin from '@cucumber/gherkin';
5
5
  import * as Messages from '@cucumber/messages';
6
+ import pixelmatch from 'pixelmatch';
7
+ import imageSize from '@coderosh/image-size';
8
+ import { Buffer } from 'buffer';
6
9
 
7
10
  const steps = [];
8
11
  const parameterTypeRegistry = new ParameterTypeRegistry();
@@ -80,10 +83,12 @@ function normalizeTags(tags) {
80
83
  return tags.filter(Boolean).map(tag => tag.startsWith('@') ? tag : `@${tag}`);
81
84
  }
82
85
  /**
86
+ * Compares two lists of tags and returns the ones that are shared by both,
87
+ * or null if there are no shared tags.
83
88
  *
84
89
  * @param confTags string[]
85
90
  * @param testTags string[]
86
- * @returns boolean
91
+ * @returns string[]|null
87
92
  */
88
93
  function tagsMatch(confTags, testTags) {
89
94
  let tags = intersection(confTags.map(t => t.toLowerCase()), testTags.map(t => t.toLowerCase()));
@@ -610,10 +615,130 @@ class DocString extends String {
610
615
  }
611
616
  }
612
617
 
618
+ /**
619
+ * Shim for node:path.normalize
620
+ * @param path string
621
+ * @returns string
622
+ */
623
+ function normalize(path) {
624
+ // Simple path normalization
625
+ const isAbsolute = path.startsWith('/');
626
+ const parts = path.split(/[/\\]+/).filter(Boolean);
627
+ const normalizedParts = [];
628
+ for (const part of parts) {
629
+ if (part === '.')
630
+ continue;
631
+ if (part === '..') {
632
+ normalizedParts.pop();
633
+ }
634
+ else {
635
+ normalizedParts.push(part);
636
+ }
637
+ }
638
+ return (isAbsolute ? '/' : '') + normalizedParts.join('/');
639
+ }
640
+ /**
641
+ * Shim for node:path.join
642
+ * @param paths string[]
643
+ * @returns string
644
+ */
645
+ function join(...paths) {
646
+ return normalize(paths.join('/'));
647
+ }
648
+
649
+ const DEFAULT_OPTIONS = {
650
+ decode: [
651
+ {
652
+ regex: /%2e/g,
653
+ replacement: '.'
654
+ },
655
+ {
656
+ regex: /%2f/g,
657
+ replacement: '/'
658
+ },
659
+ {
660
+ regex: /%5c/g,
661
+ replacement: '\\'
662
+ }
663
+ ],
664
+ parentDirectoryRegEx: /[\/\\]\.\.[\/\\]/g,
665
+ notAllowedRegEx: /:|\$|!|'|"|@|\+|`|\||=/g
666
+ };
667
+ /**
668
+ * Sanitizes a portion of a path to avoid Path Traversal
669
+ */
670
+ function sanitize(pathstr, options = DEFAULT_OPTIONS) {
671
+ if (!options)
672
+ options = DEFAULT_OPTIONS;
673
+ if (typeof options !== 'object')
674
+ throw new Error('options must be an object');
675
+ if (!Array.isArray(options.decode))
676
+ options.decode = DEFAULT_OPTIONS.decode;
677
+ if (!options.parentDirectoryRegEx)
678
+ options.parentDirectoryRegEx = DEFAULT_OPTIONS.parentDirectoryRegEx;
679
+ if (!options.notAllowedRegEx)
680
+ options.notAllowedRegEx = DEFAULT_OPTIONS.notAllowedRegEx;
681
+ if (typeof pathstr !== 'string') {
682
+ // Stringify the path
683
+ pathstr = `${pathstr}`;
684
+ }
685
+ let sanitizedPath = pathstr;
686
+ // ################################################################################################################
687
+ // Decode
688
+ options.decode.forEach(decode => {
689
+ sanitizedPath = sanitizedPath.replace(decode.regex, decode.replacement);
690
+ });
691
+ // Remove not allowed characters
692
+ sanitizedPath = sanitizedPath.replace(options.notAllowedRegEx, '');
693
+ // Replace backslashes with normal slashes
694
+ sanitizedPath = sanitizedPath.replace(/[\\]/g, '/');
695
+ // Replace /../ with /
696
+ sanitizedPath = sanitizedPath.replace(options.parentDirectoryRegEx, '/');
697
+ // Remove ../ at pos 0 and /.. at end
698
+ sanitizedPath = sanitizedPath.replace(/^\.\.[\/\\]/g, '/');
699
+ sanitizedPath = sanitizedPath.replace(/[\/\\]\.\.$/g, '/');
700
+ // Replace double (back)slashes with a single slash
701
+ sanitizedPath = sanitizedPath.replace(/[\/\\]+/g, '/');
702
+ // Normalize path
703
+ sanitizedPath = normalize(sanitizedPath);
704
+ // Remove / or \ in the end
705
+ while (sanitizedPath.endsWith('/') || sanitizedPath.endsWith('\\')) {
706
+ sanitizedPath = sanitizedPath.slice(0, -1);
707
+ }
708
+ // Remove / or \ in the beginning
709
+ while (sanitizedPath.startsWith('/') || sanitizedPath.startsWith('\\')) {
710
+ sanitizedPath = sanitizedPath.slice(1);
711
+ }
712
+ // Validate path
713
+ sanitizedPath = join('', sanitizedPath);
714
+ // Remove not allowed characters
715
+ sanitizedPath = sanitizedPath.replace(options.notAllowedRegEx, '');
716
+ // Again change all \ to /
717
+ sanitizedPath = sanitizedPath.replace(/[\\]/g, '/');
718
+ // Replace double (back)slashes with a single slash
719
+ sanitizedPath = sanitizedPath.replace(/[\/\\]+/g, '/');
720
+ // Replace /../ with /
721
+ sanitizedPath = sanitizedPath.replace(options.parentDirectoryRegEx, '/');
722
+ // Remove ./ or / at start
723
+ while (sanitizedPath.startsWith('/') || sanitizedPath.startsWith('./') || sanitizedPath.endsWith('/..') || sanitizedPath.endsWith('/../') || sanitizedPath.startsWith('../') || sanitizedPath.startsWith('/../')) {
724
+ sanitizedPath = sanitizedPath.replace(/^\.\//g, ''); // ^./
725
+ sanitizedPath = sanitizedPath.replace(/^\//g, ''); // ^/
726
+ // Remove ../ | /../ at pos 0 and /.. | /../ at end
727
+ sanitizedPath = sanitizedPath.replace(/^[\/\\]\.\.[\/\\]/g, '/');
728
+ sanitizedPath = sanitizedPath.replace(/^\.\.[\/\\]/g, '/');
729
+ sanitizedPath = sanitizedPath.replace(/[\/\\]\.\.$/g, '/');
730
+ sanitizedPath = sanitizedPath.replace(/[\/\\]\.\.\/$/g, '/');
731
+ }
732
+ // Make sure out is not "."
733
+ sanitizedPath = sanitizedPath.trim() === '.' ? '' : sanitizedPath;
734
+ return sanitizedPath.trim();
735
+ }
736
+
613
737
  class QuickPickleWorld {
614
738
  constructor(context, info) {
615
739
  this._projectRoot = '';
616
740
  this.data = {};
741
+ this.sanitizePath = sanitize;
617
742
  this.context = context;
618
743
  this.common = info.common;
619
744
  this.info = { ...info, errors: [] };
@@ -623,10 +748,44 @@ class QuickPickleWorld {
623
748
  get config() { return this.info.config; }
624
749
  get worldConfig() { return this.info.config.worldConfig; }
625
750
  get isComplete() { return this.info.stepIdx === this.info.steps.length; }
626
- get projectRoot() { return this._projectRoot; }
751
+ /**
752
+ * Checks the tags of the Scenario against a provided list of tags,
753
+ * and returns the shared tags, with the "@" prefix character.
754
+ *
755
+ * @param tags tags to check
756
+ * @returns string[]|null
757
+ */
627
758
  tagsMatch(tags) {
628
759
  return tagsMatch(tags, this.info.tags);
629
760
  }
761
+ /**
762
+ * Given a provided path-like string, returns a full path that:
763
+ *
764
+ * 1. contains no invalid characters.
765
+ * 2. is a subdirectory of the project root.
766
+ *
767
+ * This is intended for security when retrieving and saving files;
768
+ * it does not slugify filenames or check for a file's existence.
769
+ *
770
+ * @param path string the path to sanitize
771
+ * @return string the sanitized path, including the project root
772
+ */
773
+ fullPath(path) {
774
+ return `${this._projectRoot}/${this.sanitizePath(path)}`;
775
+ }
776
+ /**
777
+ * A helper function for when you really just need to wait.
778
+ *
779
+ * @deprecated Waiting for arbitrary amounts of time makes your tests flaky! There are
780
+ * usually better ways to wait for something to happen, and this functionality will be
781
+ * removed from the API as soon we're sure nobody will **EVER** want to use it again.
782
+ * (That may be a long time.)
783
+ *
784
+ * @param ms milliseconds to wait
785
+ */
786
+ async wait(ms) {
787
+ await new Promise(r => setTimeout(r, ms));
788
+ }
630
789
  toString() {
631
790
  let parts = [
632
791
  this.constructor.name,
@@ -644,6 +803,48 @@ function getWorldConstructor() {
644
803
  function setWorldConstructor(constructor) {
645
804
  worldConstructor = constructor;
646
805
  }
806
+ const defaultScreenshotComparisonOptions = {
807
+ maxDiffPercentage: 0,
808
+ threshold: 0.1,
809
+ alpha: 0.6
810
+ };
811
+ class VisualWorld extends QuickPickleWorld {
812
+ constructor(context, info) {
813
+ super(context, info);
814
+ }
815
+ async init() { }
816
+ get screenshotDir() {
817
+ return this.sanitizePath(this.worldConfig.screenshotDir);
818
+ }
819
+ get screenshotFilename() {
820
+ return `${this.toString().replace(/^.+?Feature: /, 'Feature: ').replace(' ' + this.info.step, '')}.png`;
821
+ }
822
+ get screenshotPath() {
823
+ return this.fullPath(`${this.screenshotDir}/${this.screenshotFilename}`);
824
+ }
825
+ getScreenshotPath(name) {
826
+ if (!name)
827
+ return this.screenshotPath;
828
+ let explodedTags = this.info.explodedIdx ? `_(${this.info.tags.join(',')})` : '';
829
+ return this.fullPath(`${this.screenshotDir}/${name}${explodedTags}.png`);
830
+ }
831
+ async screenshotDiff(actual, expected, opts) {
832
+ // Convert Buffer to ArrayBuffer if needed
833
+ const actualBuffer = actual instanceof Buffer ?
834
+ actual.buffer.slice(actual.byteOffset, actual.byteOffset + actual.byteLength) :
835
+ actual;
836
+ const expectedBuffer = expected instanceof Buffer ?
837
+ expected.buffer.slice(expected.byteOffset, expected.byteOffset + expected.byteLength) :
838
+ expected;
839
+ const { width, height } = await imageSize(actualBuffer);
840
+ const diff = Buffer.from([]);
841
+ const mismatchedPixels = pixelmatch(new Uint8Array(actualBuffer), new Uint8Array(expectedBuffer), diff, width, height, opts);
842
+ const totalPixels = width * height;
843
+ const diffPercentage = (mismatchedPixels / totalPixels) * 100;
844
+ const pass = diffPercentage <= opts.maxDiffPercentage;
845
+ return { pass, diff, diffPercentage };
846
+ }
847
+ }
647
848
 
648
849
  const featureRegex = /\.feature(?:\.md)?$/;
649
850
  const Given = addStepDefinition;
@@ -811,5 +1012,5 @@ const quickpickle = (conf = {}) => {
811
1012
  };
812
1013
  };
813
1014
 
814
- export { After, AfterAll, AfterStep, Before, BeforeAll, BeforeStep, DataTable, DocString, Given, QuickPickleWorld, Then, When, applyHooks, quickpickle as default, defaultConfig, defineParameterType, explodeTags, formatStack, getWorldConstructor, gherkinStep, normalizeTags, quickpickle, setWorldConstructor, tagsMatch };
1015
+ export { After, AfterAll, AfterStep, Before, BeforeAll, BeforeStep, DataTable, DocString, Given, QuickPickleWorld, Then, VisualWorld, When, applyHooks, quickpickle as default, defaultConfig, defaultScreenshotComparisonOptions, defineParameterType, explodeTags, formatStack, getWorldConstructor, gherkinStep, normalizeTags, quickpickle, setWorldConstructor, tagsMatch };
815
1016
  //# sourceMappingURL=index.esm.js.map