@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 +27 -0
- package/dist/index.mjs +221 -21
- package/package.json +14 -13
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.
|
|
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.
|
|
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.
|
|
41
|
+
"@types/node": "24.19.1",
|
|
41
42
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
42
|
-
"@vitest/coverage-v8": "5.0.
|
|
43
|
-
"eslint": "10.
|
|
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.
|
|
48
|
-
"oxc-minify": "0.
|
|
49
|
-
"oxfmt": "0.
|
|
50
|
-
"publint": "0.3.
|
|
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.
|
|
54
|
+
"turbo": "2.11.7",
|
|
54
55
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
55
|
-
"typescript-eslint": "8.
|
|
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.
|
|
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.
|
|
68
|
+
"version": "12.11.2",
|
|
68
69
|
"onFail": "download"
|
|
69
70
|
},
|
|
70
71
|
"runtime": {
|