@cronn/playwright-utils 0.7.0 → 0.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.d.mts CHANGED
@@ -591,6 +591,33 @@ export declare class RouteInterceptorFixture {
591
591
  intercept(filter: RouteFilter): RouteInterceptor;
592
592
  }
593
593
  //#endregion
594
+ //#region src/security/csp-report.d.ts
595
+ /**
596
+ * Returns the `Content-Security-Policy` header of a response, or an empty
597
+ * string if the response or the header is missing.
598
+ */
599
+ export declare function getCspHeader(response: Response | null): Promise<string>;
600
+ /**
601
+ * Evaluates a `Content-Security-Policy` header against a set of recommended
602
+ * directives and returns the result as a Markdown report.
603
+ *
604
+ * The report lists all directives of the header, followed by recommendations
605
+ * for missing or weak directives. Missing fetch directives are evaluated using
606
+ * `default-src`, just like the browser falls back to it. Nonces are masked as
607
+ * `[NONCE_0]`, `[NONCE_1]`, … to keep the report deterministic for file snapshots.
608
+ *
609
+ * @example
610
+ * ```ts
611
+ * const response = await page.goto("/");
612
+ * const cspHeader = await getCspHeader(response);
613
+ *
614
+ * expect(cspReport(cspHeader)).toMatchTextFile({
615
+ * fileExtension: "md",
616
+ * });
617
+ * ```
618
+ */
619
+ export declare function cspReport(header: string): string;
620
+ //#endregion
594
621
  //#region src/file.d.ts
595
622
  /**
596
623
  * Resolves the given path segments against the root directory of the closest
@@ -606,7 +633,64 @@ export declare class RouteInterceptorFixture {
606
633
  */
607
634
  export declare function resolveFromPackageRoot(...paths: Array<string>): string;
608
635
  //#endregion
609
- //#region src/page-object-model/locator-extensions.d.ts
636
+ //#region src/page-object-model/form-filler.d.ts
637
+ interface Fillable<TValue> {
638
+ fill: Filler<TValue>;
639
+ }
640
+ type Filler<TValue> = (value: TValue) => Promise<void>;
641
+ type FormFields<TData> = { [K in keyof TData]-?: Fillable<Exclude<TData[K], undefined>> | Filler<Exclude<TData[K], undefined>>; };
642
+ /**
643
+ * Creates a filler function that fills a form declaratively from a data
644
+ * object, e.g. as the `fill` method of a page object.
645
+ *
646
+ * Each key of `fields` maps a key of the data to a form input: either an
647
+ * object with a `fill` method (such as a `Locator` or a nested page object) or
648
+ * a filler function receiving the value. Fields are filled one after another
649
+ * in the order of the keys in `fields`, and fields whose value is `undefined`
650
+ * are skipped.
651
+ *
652
+ * The type of the data is either given explicitly as type argument or inferred
653
+ * from `fields`. When inferred, filler functions need an explicit parameter
654
+ * type, otherwise their value is `unknown`. In both cases, the returned
655
+ * function accepts partial data.
656
+ *
657
+ * As the returned function is a filler itself, it can be used as a field of
658
+ * another form filler to fill nested data.
659
+ *
660
+ * @param fields - Mapping from each key of the data to the input to fill
661
+ * @returns A function filling the form with the given data
662
+ * @example
663
+ * ```ts
664
+ * const { checkbox, textbox } = roleLocators(page);
665
+ *
666
+ * const registrationForm = {
667
+ * fill: formFiller<Registration>({
668
+ * name: textbox("Name"),
669
+ * acceptTerms: (accepted) => checkbox("Accept terms").setChecked(accepted),
670
+ * }),
671
+ * };
672
+ * ```
673
+ */
674
+ export declare function formFiller<TData extends object>(fields: FormFields<TData>): Filler<Partial<TData>>;
675
+ //#endregion
676
+ //#region src/page-object-model/extend-locator.d.ts
677
+ type WithoutLocatorKeys = Partial<Record<Exclude<keyof Locator, "toString">, never>>;
678
+ type ExtendedLocator<TExtensions extends WithoutLocatorKeys> = Locator & TExtensions;
679
+ /**
680
+ * Extends a `Locator` with custom properties and functions while retaining the full `Locator` API.
681
+ *
682
+ * Extensions must not override members of `Locator`. Locators derived from the extended locator
683
+ * (e.g. via `first()`, `filter()` or `locator()`) are plain `Locator`s without the extensions.
684
+ *
685
+ * Methods of `Locator` are bound to the original locator on every access, so accessing the same method
686
+ * twice returns different function instances (e.g. `extended.click !== extended.click`).
687
+ *
688
+ * @param locator The locator to extend
689
+ * @param createExtensions Creates the extensions, receiving the original locator
690
+ */
691
+ export declare function extendLocator<TExtensions extends object & WithoutLocatorKeys>(locator: Locator, createExtensions: (locator: Locator) => TExtensions): ExtendedLocator<TExtensions>;
692
+ //#endregion
693
+ //#region src/page-object-model/role-locators.d.ts
610
694
  type RoleLocators = Record<Role, RoleLocator>;
611
695
  interface RoleLocator {
612
696
  (options?: RoleLocatorOptions): Locator;
@@ -673,4 +757,4 @@ export declare function maskedValueWithIndex(name: string): (index: number) => s
673
757
  */
674
758
  type PlaywrightTarget = Page | Locator;
675
759
  //#endregion
676
- export type { ConsoleMessageFilter, ConsoleMessageLevel, CspFixtures, CspViolation, HttpMethod, PlaywrightTarget, ResponseHandler, RoleLocators, RouteFilter };
760
+ export type { ConsoleMessageFilter, ConsoleMessageLevel, CspFixtures, CspViolation, ExtendedLocator, FormFields, HttpMethod, PlaywrightTarget, ResponseHandler, RoleLocators, RouteFilter };
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { expect } from "@playwright/test";
2
+ import { markdownTable } from "markdown-table";
2
3
  import path from "node:path";
3
4
  import { packageDirectorySync } from "package-directory";
4
5
  import { maskString, stringNormalizer } from "@cronn/playwright-file-snapshots";
@@ -770,6 +771,225 @@ function modifyTextBody(modifier) {
770
771
  });
771
772
  }
772
773
  //#endregion
774
+ //#region src/normalizers/masked-value.ts
775
+ /**
776
+ * Formats a name as a masked placeholder, e.g. `maskedValue("base url")`
777
+ * returns `[BASE_URL]`.
778
+ *
779
+ * @param name - Name of the masked value
780
+ */
781
+ function maskedValue(name) {
782
+ return `[${name.toUpperCase().replace(/[ -.:,;]/g, "_")}]`;
783
+ }
784
+ /**
785
+ * Creates a factory for indexed masked placeholders, e.g. `[USER_1]`.
786
+ *
787
+ * @param name - Name of the masked value
788
+ * @returns A function mapping an index to a masked placeholder
789
+ */
790
+ function maskedValueWithIndex(name) {
791
+ return (index) => maskedValue(`${name}_${index}`);
792
+ }
793
+ //#endregion
794
+ //#region src/security/csp-report.ts
795
+ const RECOMMENDED_DIRECTIVES = [
796
+ { name: "upgrade-insecure-requests" },
797
+ { name: "trusted-types" },
798
+ {
799
+ name: "require-trusted-types-for",
800
+ values: ["'script'"]
801
+ },
802
+ {
803
+ name: "default-src",
804
+ values: ["'self'"]
805
+ },
806
+ {
807
+ name: "script-src",
808
+ requireNonce: true,
809
+ tolerated: ["'strict-dynamic'"],
810
+ fallback: "default-src",
811
+ optional: ["script-src-elem", "script-src-attr"]
812
+ },
813
+ {
814
+ name: "script-src-elem",
815
+ requireNonce: true,
816
+ tolerated: ["'strict-dynamic'"],
817
+ optional: true
818
+ },
819
+ {
820
+ name: "script-src-attr",
821
+ values: ["'none'"],
822
+ optional: true
823
+ },
824
+ {
825
+ name: "style-src",
826
+ requireNonce: true,
827
+ fallback: "default-src",
828
+ optional: ["style-src-elem", "style-src-attr"]
829
+ },
830
+ {
831
+ name: "style-src-elem",
832
+ requireNonce: true,
833
+ optional: true
834
+ },
835
+ {
836
+ name: "style-src-attr",
837
+ values: ["'none'"],
838
+ optional: true
839
+ },
840
+ {
841
+ name: "img-src",
842
+ values: ["'self'", "data:"],
843
+ fallback: "default-src"
844
+ },
845
+ {
846
+ name: "font-src",
847
+ values: ["'self'"],
848
+ fallback: "default-src"
849
+ },
850
+ {
851
+ name: "connect-src",
852
+ values: ["'self'"],
853
+ fallback: "default-src"
854
+ },
855
+ {
856
+ name: "object-src",
857
+ values: ["'none'"]
858
+ },
859
+ {
860
+ name: "base-uri",
861
+ values: ["'self'"]
862
+ },
863
+ {
864
+ name: "form-action",
865
+ values: ["'self'"]
866
+ },
867
+ {
868
+ name: "frame-ancestors",
869
+ values: ["'none'"]
870
+ }
871
+ ];
872
+ const NONCE_PLACEHOLDER = "'nonce-[RANDOM]'";
873
+ const MASKED_NONCE = maskedValueWithIndex("NONCE");
874
+ /**
875
+ * Returns the `Content-Security-Policy` header of a response, or an empty
876
+ * string if the response or the header is missing.
877
+ */
878
+ async function getCspHeader(response) {
879
+ return await response?.headerValue("content-security-policy") ?? "";
880
+ }
881
+ /**
882
+ * Evaluates a `Content-Security-Policy` header against a set of recommended
883
+ * directives and returns the result as a Markdown report.
884
+ *
885
+ * The report lists all directives of the header, followed by recommendations
886
+ * for missing or weak directives. Missing fetch directives are evaluated using
887
+ * `default-src`, just like the browser falls back to it. Nonces are masked as
888
+ * `[NONCE_0]`, `[NONCE_1]`, … to keep the report deterministic for file snapshots.
889
+ *
890
+ * @example
891
+ * ```ts
892
+ * const response = await page.goto("/");
893
+ * const cspHeader = await getCspHeader(response);
894
+ *
895
+ * expect(cspReport(cspHeader)).toMatchTextFile({
896
+ * fileExtension: "md",
897
+ * });
898
+ * ```
899
+ */
900
+ function cspReport(header) {
901
+ const { directives, recommendations } = evaluateCsp(parseCsp(header));
902
+ return [
903
+ "# CSP Report",
904
+ "## Detected Directives",
905
+ directives.length === 0 ? "None" : markdownTable([["Directive", "Value"], ...maskNonces(directives).map((directive) => [code(directive.name), formatValues(directive.values)])]),
906
+ "## Recommendations",
907
+ recommendations.length === 0 ? "None" : recommendations.map((recommendation) => `- ${code(recommendation.directive)}: ${recommendation.message}`).join("\n")
908
+ ].join("\n\n").concat("\n");
909
+ }
910
+ function parseCsp(header) {
911
+ return header.split(";").map((directive) => directive.trim()).filter((directive) => directive !== "").map((directive) => {
912
+ const [name = "", ...values] = directive.split(/\s+/);
913
+ return {
914
+ name: name.toLowerCase(),
915
+ values
916
+ };
917
+ });
918
+ }
919
+ function evaluateCsp(directives) {
920
+ const directivesByName = /* @__PURE__ */ new Map();
921
+ for (const directive of directives) if (!directivesByName.has(directive.name)) directivesByName.set(directive.name, directive);
922
+ return {
923
+ directives,
924
+ recommendations: RECOMMENDED_DIRECTIVES.flatMap((recommended) => evaluateDirective(recommended, directivesByName).map((message) => ({
925
+ directive: recommended.name,
926
+ message
927
+ })))
928
+ };
929
+ }
930
+ function evaluateDirective(recommended, directivesByName) {
931
+ const directive = directivesByName.get(recommended.name);
932
+ if (directive === void 0 && isOptional(recommended, directivesByName)) return [];
933
+ const fallback = recommended.fallback !== void 0 ? directivesByName.get(recommended.fallback) : void 0;
934
+ const actual = directive ?? fallback;
935
+ if (actual === void 0) return [`Missing, add ${code([recommended.name, ...formatRecommendedValues(recommended)].join(" "))}`];
936
+ const inherited = directive === void 0 ? ` (inherited from ${code(actual.name)})` : "";
937
+ const messages = [];
938
+ const recommendedValues = recommended.values;
939
+ const extraValues = findExtraValues(recommended, actual.values);
940
+ const hasRecommendedValue = extraValues.length < actual.values.length;
941
+ if (recommendedValues !== void 0 && extraValues.length > 0 && !hasRecommendedValue) messages.push(`Add ${formatValues(recommendedValues)}`);
942
+ if (recommended.requireNonce === true && !actual.values.some(isNonce)) messages.push(`Add a nonce source`);
943
+ for (const value of extraValues) messages.push(`Remove ${code(value)}${inherited}`);
944
+ return messages;
945
+ }
946
+ function isOptional(recommended, directivesByName) {
947
+ if (Array.isArray(recommended.optional)) return recommended.optional.every((name) => directivesByName.has(name));
948
+ return recommended.optional === true;
949
+ }
950
+ /**
951
+ * Returns all sources which are neither recommended nor tolerated. Directives
952
+ * without recommended sources accept any value, and `'none'` is always
953
+ * accepted as it is stricter than any recommendation.
954
+ */
955
+ function findExtraValues(recommended, values) {
956
+ if (recommended.values === void 0 && recommended.requireNonce !== true || isNone(values)) return [];
957
+ const acceptedValues = [...recommended.values ?? [], ...recommended.tolerated ?? []];
958
+ return values.filter((value) => !acceptedValues.includes(value) && !(recommended.requireNonce === true && isNonce(value)));
959
+ }
960
+ function formatRecommendedValues(recommended) {
961
+ return [...recommended.requireNonce === true ? [NONCE_PLACEHOLDER] : [], ...recommended.values ?? []];
962
+ }
963
+ function maskNonces(directives) {
964
+ const nonceIndices = /* @__PURE__ */ new Map();
965
+ function maskNonce(value) {
966
+ if (!isNonce(value)) return value;
967
+ let index = nonceIndices.get(value);
968
+ if (index === void 0) {
969
+ index = nonceIndices.size;
970
+ nonceIndices.set(value, index);
971
+ }
972
+ return `'nonce-${MASKED_NONCE(index)}'`;
973
+ }
974
+ return directives.map((directive) => ({
975
+ ...directive,
976
+ values: directive.values.map(maskNonce)
977
+ }));
978
+ }
979
+ function isNone(values) {
980
+ return values.length === 1 && values[0] === "'none'";
981
+ }
982
+ function isNonce(value) {
983
+ return value.startsWith("'nonce-");
984
+ }
985
+ function formatValues(values) {
986
+ if (values.length === 0) return "_none_";
987
+ return code(values.join(" "));
988
+ }
989
+ function code(text) {
990
+ return `\`${text.replaceAll("|", "\\|")}\``;
991
+ }
992
+ //#endregion
773
993
  //#region src/file.ts
774
994
  /**
775
995
  * Resolves the given path segments against the root directory of the closest
@@ -789,7 +1009,81 @@ function resolveFromPackageRoot(...paths) {
789
1009
  return path.resolve(packageDir, ...paths);
790
1010
  }
791
1011
  //#endregion
792
- //#region src/page-object-model/locator-extensions.ts
1012
+ //#region src/page-object-model/form-filler.ts
1013
+ /**
1014
+ * Creates a filler function that fills a form declaratively from a data
1015
+ * object, e.g. as the `fill` method of a page object.
1016
+ *
1017
+ * Each key of `fields` maps a key of the data to a form input: either an
1018
+ * object with a `fill` method (such as a `Locator` or a nested page object) or
1019
+ * a filler function receiving the value. Fields are filled one after another
1020
+ * in the order of the keys in `fields`, and fields whose value is `undefined`
1021
+ * are skipped.
1022
+ *
1023
+ * The type of the data is either given explicitly as type argument or inferred
1024
+ * from `fields`. When inferred, filler functions need an explicit parameter
1025
+ * type, otherwise their value is `unknown`. In both cases, the returned
1026
+ * function accepts partial data.
1027
+ *
1028
+ * As the returned function is a filler itself, it can be used as a field of
1029
+ * another form filler to fill nested data.
1030
+ *
1031
+ * @param fields - Mapping from each key of the data to the input to fill
1032
+ * @returns A function filling the form with the given data
1033
+ * @example
1034
+ * ```ts
1035
+ * const { checkbox, textbox } = roleLocators(page);
1036
+ *
1037
+ * const registrationForm = {
1038
+ * fill: formFiller<Registration>({
1039
+ * name: textbox("Name"),
1040
+ * acceptTerms: (accepted) => checkbox("Accept terms").setChecked(accepted),
1041
+ * }),
1042
+ * };
1043
+ * ```
1044
+ */
1045
+ function formFiller(fields) {
1046
+ return async (data) => {
1047
+ for (const [key, filler] of Object.entries(fields)) {
1048
+ const value = data[key];
1049
+ if (value === void 0) continue;
1050
+ if (typeof filler === "function") await filler(value);
1051
+ else await filler.fill(value);
1052
+ }
1053
+ };
1054
+ }
1055
+ //#endregion
1056
+ //#region src/page-object-model/extend-locator.ts
1057
+ /**
1058
+ * Extends a `Locator` with custom properties and functions while retaining the full `Locator` API.
1059
+ *
1060
+ * Extensions must not override members of `Locator`. Locators derived from the extended locator
1061
+ * (e.g. via `first()`, `filter()` or `locator()`) are plain `Locator`s without the extensions.
1062
+ *
1063
+ * Methods of `Locator` are bound to the original locator on every access, so accessing the same method
1064
+ * twice returns different function instances (e.g. `extended.click !== extended.click`).
1065
+ *
1066
+ * @param locator The locator to extend
1067
+ * @param createExtensions Creates the extensions, receiving the original locator
1068
+ */
1069
+ function extendLocator(locator, createExtensions) {
1070
+ const extensions = createExtensions(locator);
1071
+ return new Proxy(locator, {
1072
+ get(target, property) {
1073
+ if (isOwnKeyOf(extensions, property)) return extensions[property];
1074
+ const value = Reflect.get(target, property, target);
1075
+ return typeof value === "function" && property !== "constructor" ? value.bind(target) : value;
1076
+ },
1077
+ has(target, property) {
1078
+ return isOwnKeyOf(extensions, property) || Reflect.has(target, property);
1079
+ }
1080
+ });
1081
+ }
1082
+ function isOwnKeyOf(object, key) {
1083
+ return Object.hasOwn(object, key);
1084
+ }
1085
+ //#endregion
1086
+ //#region src/page-object-model/role-locators.ts
793
1087
  const ROLES = [
794
1088
  "alert",
795
1089
  "alertdialog",
@@ -916,26 +1210,6 @@ function roleLocators(locator, defaultOptions = DEFAULT_OPTIONS) {
916
1210
  return Object.fromEntries(ROLES.map((role) => [role, getByRole(role)]));
917
1211
  }
918
1212
  //#endregion
919
- //#region src/normalizers/masked-value.ts
920
- /**
921
- * Formats a name as a masked placeholder, e.g. `maskedValue("base url")`
922
- * returns `[BASE_URL]`.
923
- *
924
- * @param name - Name of the masked value
925
- */
926
- function maskedValue(name) {
927
- return `[${name.toUpperCase().replace(/[ -.:,;]/g, "_")}]`;
928
- }
929
- /**
930
- * Creates a factory for indexed masked placeholders, e.g. `[USER_1]`.
931
- *
932
- * @param name - Name of the masked value
933
- * @returns A function mapping an index to a masked placeholder
934
- */
935
- function maskedValueWithIndex(name) {
936
- return (index) => maskedValue(`${name}_${index}`);
937
- }
938
- //#endregion
939
1213
  //#region src/normalizers/mask-base-url.ts
940
1214
  /**
941
1215
  * Creates a normalizer that replaces occurrences of the base URL with
@@ -951,4 +1225,4 @@ function identity(value) {
951
1225
  return value;
952
1226
  }
953
1227
  //#endregion
954
- export { ConsoleCaptor, CspCaptor, RouteInterceptor, RouteInterceptorFixture, captureConsole, captureCspViolations, createFetchAdapter, cspFixtures, hasLogLevel, interceptRoute, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, matchPath, modifyJsonBody, modifyTextBody, resolveFromPackageRoot, roleLocators };
1228
+ export { ConsoleCaptor, CspCaptor, RouteInterceptor, RouteInterceptorFixture, captureConsole, captureCspViolations, createFetchAdapter, cspFixtures, cspReport, extendLocator, formFiller, getCspHeader, hasLogLevel, interceptRoute, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, matchPath, modifyJsonBody, modifyTextBody, resolveFromPackageRoot, roleLocators };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cronn/playwright-utils",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Utilities for writing tests with Playwright",
5
5
  "keywords": [
6
6
  "playwright",
@@ -29,33 +29,34 @@
29
29
  }
30
30
  },
31
31
  "dependencies": {
32
+ "markdown-table": "^3.0.4",
32
33
  "package-directory": "^8.2.0"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@arethetypeswrong/core": "0.18.5",
36
- "@changesets/cli": "3.0.2",
37
- "@cronn/element-snapshot": "0.30.0",
37
+ "@changesets/cli": "3.0.3",
38
+ "@cronn/element-snapshot": "0.32.1",
38
39
  "@cronn/playwright-file-snapshots": "2.2.2",
39
40
  "@playwright/test": "1.63.0",
40
- "@types/node": "24.13.4",
41
+ "@types/node": "24.19.1",
41
42
  "@typescript/native": "npm:typescript@^7.0.2",
42
- "@vitest/coverage-v8": "5.0.0",
43
- "eslint": "10.10.0",
43
+ "@vitest/coverage-v8": "5.0.3",
44
+ "eslint": "10.12.0",
44
45
  "eslint-config-prettier": "10.1.8",
45
46
  "eslint-plugin-check-file": "3.3.2",
46
47
  "eslint-plugin-unused-imports": "4.4.1",
47
- "knip": "6.35.1",
48
- "oxc-minify": "0.149.0",
49
- "oxfmt": "0.67.0",
50
- "publint": "0.3.24",
48
+ "knip": "6.39.0",
49
+ "oxc-minify": "0.153.0",
50
+ "oxfmt": "0.72.0",
51
+ "publint": "0.3.25",
51
52
  "rimraf": "6.1.3",
52
53
  "tsdown": "0.23.0",
53
- "turbo": "2.10.12",
54
+ "turbo": "2.11.7",
54
55
  "typescript": "npm:@typescript/typescript6@^6.0.2",
55
- "typescript-eslint": "8.70.0",
56
+ "typescript-eslint": "8.71.1",
56
57
  "vitepress": "2.0.0-alpha.20",
57
58
  "vitepress-plugin-group-icons": "1.7.6",
58
- "vitest": "5.0.0"
59
+ "vitest": "5.0.3"
59
60
  },
60
61
  "peerDependencies": {
61
62
  "@cronn/playwright-file-snapshots": "^2",
@@ -64,7 +65,7 @@
64
65
  "devEngines": {
65
66
  "packageManager": {
66
67
  "name": "pnpm",
67
- "version": "12.5.1",
68
+ "version": "12.11.2",
68
69
  "onFail": "download"
69
70
  },
70
71
  "runtime": {
@@ -90,9 +91,7 @@
90
91
  "lint:check": "eslint . --max-warnings=0",
91
92
  "lint:fix": "eslint . --max-warnings=0 --fix",
92
93
  "playwright:install": "playwright install chromium",
93
- "pnpm:check": "pnpm dedupe --check",
94
94
  "pnpm:clean": "pnpm pm clean && pnpm store prune",
95
- "pnpm:fix": "pnpm dedupe",
96
95
  "release": "pnpm turbo run build && pnpm changeset publish",
97
96
  "test:integration": "playwright test",
98
97
  "test:unit": "vitest run",