@cronn/playwright-utils 0.8.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
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
@@ -990,26 +1210,6 @@ function roleLocators(locator, defaultOptions = DEFAULT_OPTIONS) {
990
1210
  return Object.fromEntries(ROLES.map((role) => [role, getByRole(role)]));
991
1211
  }
992
1212
  //#endregion
993
- //#region src/normalizers/masked-value.ts
994
- /**
995
- * Formats a name as a masked placeholder, e.g. `maskedValue("base url")`
996
- * returns `[BASE_URL]`.
997
- *
998
- * @param name - Name of the masked value
999
- */
1000
- function maskedValue(name) {
1001
- return `[${name.toUpperCase().replace(/[ -.:,;]/g, "_")}]`;
1002
- }
1003
- /**
1004
- * Creates a factory for indexed masked placeholders, e.g. `[USER_1]`.
1005
- *
1006
- * @param name - Name of the masked value
1007
- * @returns A function mapping an index to a masked placeholder
1008
- */
1009
- function maskedValueWithIndex(name) {
1010
- return (index) => maskedValue(`${name}_${index}`);
1011
- }
1012
- //#endregion
1013
1213
  //#region src/normalizers/mask-base-url.ts
1014
1214
  /**
1015
1215
  * Creates a normalizer that replaces occurrences of the base URL with
@@ -1025,4 +1225,4 @@ function identity(value) {
1025
1225
  return value;
1026
1226
  }
1027
1227
  //#endregion
1028
- export { ConsoleCaptor, CspCaptor, RouteInterceptor, RouteInterceptorFixture, captureConsole, captureCspViolations, createFetchAdapter, cspFixtures, extendLocator, formFiller, 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.8.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
37
  "@changesets/cli": "3.0.3",
37
- "@cronn/element-snapshot": "0.30.0",
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.19.0",
41
+ "@types/node": "24.19.1",
41
42
  "@typescript/native": "npm:typescript@^7.0.2",
42
- "@vitest/coverage-v8": "5.0.2",
43
- "eslint": "10.11.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.38.0",
48
- "oxc-minify": "0.151.0",
49
- "oxfmt": "0.70.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.11.5",
54
+ "turbo": "2.11.7",
54
55
  "typescript": "npm:@typescript/typescript6@^6.0.2",
55
- "typescript-eslint": "8.70.1",
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.2"
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.9.1",
68
+ "version": "12.11.2",
68
69
  "onFail": "download"
69
70
  },
70
71
  "runtime": {