-
+
{topStatNumberShort(stat.graph_metric, stat.comparison_value)}
diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js
index 7490b48ae01b..1421f4d42198 100644
--- a/assets/js/dashboard/stats/locations/index.js
+++ b/assets/js/dashboard/stats/locations/index.js
@@ -270,6 +270,7 @@ class Locations extends React.Component {
render() {
return (
diff --git a/assets/js/dashboard/stats/modals/filter-modal-group.js b/assets/js/dashboard/stats/modals/filter-modal-group.js
index 2458e3575754..0e5c18069219 100644
--- a/assets/js/dashboard/stats/modals/filter-modal-group.js
+++ b/assets/js/dashboard/stats/modals/filter-modal-group.js
@@ -46,6 +46,7 @@ export default function FilterModalGroup({
{rows.map(({ id, filter }) =>
filterGroup === 'props' ? (
1}
@@ -55,6 +56,7 @@ export default function FilterModalGroup({
/>
) : (
+
-
+
{icon}
diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js
index d8e38b82cdac..962cb935a5ae 100644
--- a/assets/js/dashboard/stats/pages/index.js
+++ b/assets/js/dashboard/stats/pages/index.js
@@ -213,7 +213,7 @@ export default function Pages() {
}
return (
-
+
diff --git a/assets/js/dashboard/stats/reports/list.tsx b/assets/js/dashboard/stats/reports/list.tsx
index f3ed82437eca..43810bbdf4c2 100644
--- a/assets/js/dashboard/stats/reports/list.tsx
+++ b/assets/js/dashboard/stats/reports/list.tsx
@@ -249,6 +249,7 @@ export default function ListReport<
.map((metric) => {
return (
- {keyLabel}
+
+ {keyLabel}
+
{metricLabels}
)
@@ -280,6 +283,7 @@ export default function ListReport<
return (
+
diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js
index 3bdddaa1c87c..495a3b3b8ae1 100644
--- a/assets/js/dashboard/stats/sources/source-list.js
+++ b/assets/js/dashboard/stats/sources/source-list.js
@@ -271,7 +271,7 @@ export default function SourceList() {
}
return (
-
+
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index 8a2e740f9f6e..af526f444f1e 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -9,6 +9,8 @@
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.2",
+ "@js-joda/core": "^5.7.0",
+ "@js-joda/locale": "^4.15.3",
"@playwright/test": "^1.58.0",
"@types/node": "^25.1.0",
"eslint": "^9.39.2",
@@ -215,6 +217,68 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@js-joda/core": {
+ "version": "5.7.0",
+ "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz",
+ "integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@js-joda/locale": {
+ "version": "4.15.3",
+ "resolved": "https://registry.npmjs.org/@js-joda/locale/-/locale-4.15.3.tgz",
+ "integrity": "sha512-JsK0y/BXDdxpsiSZUh1fNjBGgkksp4oYNcjiDySmOUkPmbE9s/a/aZ2OlLHHmEOiPBppN2T5MHCaa/0Hf+z2fA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@js-joda/core": ">=3.2.0",
+ "@js-joda/timezone": "^2.3.0",
+ "cldr-data": "*",
+ "cldrjs": "^0.5.4"
+ }
+ },
+ "node_modules/@js-joda/timezone": {
+ "version": "2.23.0",
+ "resolved": "https://registry.npmjs.org/@js-joda/timezone/-/timezone-2.23.0.tgz",
+ "integrity": "sha512-33rPV8ORT66Httd/IHQaymTZ//MbjF0WRB58JOUT0G04/a9cB5Q0RFTV1+T4XjIjHr+nY5QkO6KppqgogsJs+Q==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "peer": true,
+ "peerDependencies": {
+ "@js-joda/core": ">=1.11.0"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@playwright/test": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
@@ -511,6 +575,14 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -551,6 +623,20 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -574,6 +660,27 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/axios": {
+ "version": "1.13.5",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
+ "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -592,6 +699,32 @@
"concat-map": "0.0.1"
}
},
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -619,6 +752,48 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/cldr-data": {
+ "version": "36.0.5",
+ "resolved": "https://registry.npmjs.org/cldr-data/-/cldr-data-36.0.5.tgz",
+ "integrity": "sha512-+SVRkiHCKQcd1Qp3XgIMrwR35TFuExw/BCeAVMmcN9wNH8gmgl+kGL2Pho8jaoNGgr9A4oNUAMJOG4cXQxr0Og==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": [
+ {
+ "type": "MIT",
+ "url": "https://github.com/rxaviers/cldr-data-npm/blob/master/LICENSE"
+ }
+ ],
+ "peer": true,
+ "dependencies": {
+ "cldr-data-downloader": "1.1.0",
+ "glob": "10.5.0"
+ }
+ },
+ "node_modules/cldr-data-downloader": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/cldr-data-downloader/-/cldr-data-downloader-1.1.0.tgz",
+ "integrity": "sha512-xg1GKFP4FOe4GEDkANb8ATz67e1tqJ6GGaRMTYJNNgRwr/9WL+qvlDU4nW9/Iw8gA6NISEfd/+XFNOFkuimaOQ==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "axios": "^1.7.2",
+ "mkdirp": "^1.0.4",
+ "nopt": "3.0.x",
+ "q": "1.0.1",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "cldr-data-downloader": "bin/download.sh"
+ }
+ },
+ "node_modules/cldrjs": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/cldrjs/-/cldrjs-0.5.5.tgz",
+ "integrity": "sha512-KDwzwbmLIPfCgd8JERVDpQKrUUM1U4KpFJJg2IROv89rF172lLufoJnqJ/Wea6fXL5bO6WjuLMzY8V52UWPvkA==",
+ "dev": true,
+ "peer": true
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -639,6 +814,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -686,6 +875,102 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -919,6 +1204,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -988,6 +1284,64 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -1003,6 +1357,81 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -1016,6 +1445,48 @@
"node": ">=10.13.0"
}
},
+ "node_modules/glob/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
+ "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
+ "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -1029,6 +1500,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -1039,6 +1524,51 @@
"node": ">=8"
}
},
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1086,6 +1616,17 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -1106,6 +1647,23 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "peer": true,
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -1187,6 +1745,50 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -1200,6 +1802,31 @@
"node": "*"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "peer": true,
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1214,6 +1841,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nopt": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
+ "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -1264,6 +1905,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "peer": true
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -1297,6 +1946,32 @@
"node": ">=8"
}
},
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "peer": true,
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
@@ -1368,6 +2043,14 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -1378,6 +2061,19 @@
"node": ">=6"
}
},
+ "node_modules/q": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/q/-/q-1.0.1.tgz",
+ "integrity": "sha512-18MnBaCeBX9sLRUdtxz/6onlb7wLzFxCylklyO8n27y5JxJYaGLPu4ccyc5zih58SpEzY8QmfwaWqguqXU6Y+A==",
+ "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.6.0",
+ "teleport": ">=0.2.0"
+ }
+ },
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -1424,6 +2120,132 @@
"node": ">=8"
}
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -1574,6 +2396,120 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/e2e/package.json b/e2e/package.json
index 73aaae00a2af..48c30be419c9 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -9,6 +9,8 @@
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.2",
+ "@js-joda/core": "^5.7.0",
+ "@js-joda/locale": "^4.15.3",
"@playwright/test": "^1.58.0",
"@types/node": "^25.1.0",
"eslint": "^9.39.2",
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
index cc8ac8c447b8..752024f03a62 100644
--- a/e2e/playwright.config.ts
+++ b/e2e/playwright.config.ts
@@ -13,6 +13,8 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
+ /* Make test timeout shorter when running tests in local dev env. */
+ timeout: process.env.CI ? 30_000 : 10_000,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'list',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
diff --git a/e2e/tests/dashboard/behaviours.spec.ts b/e2e/tests/dashboard/behaviours.spec.ts
new file mode 100644
index 000000000000..d781124d2d91
--- /dev/null
+++ b/e2e/tests/dashboard/behaviours.spec.ts
@@ -0,0 +1,470 @@
+import { test, expect } from '@playwright/test'
+import {
+ setupSite,
+ populateStats,
+ addCustomGoal,
+ addPageviewGoal,
+ addScrollDepthGoal,
+ addAllCustomProps,
+ addFunnel
+} from '../fixtures.ts'
+import {
+ tabButton,
+ expectHeaders,
+ expectRows,
+ rowLink,
+ expectMetricValues,
+ dropdown,
+ detailsLink,
+ modal,
+ closeModalButton,
+ searchInput
+} from '../test-utils.ts'
+
+const getReport = (page) => page.getByTestId('report-behaviours')
+
+test('goals breakdown', async ({ page, request }) => {
+ const report = getReport(page)
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ user_id: 123,
+ name: 'pageview',
+ pathname: '/page1',
+ timestamp: { minutesAgo: 60 }
+ },
+ {
+ user_id: 124,
+ name: 'pageview',
+ pathname: '/page1',
+ timestamp: { minutesAgo: 60 }
+ },
+ {
+ user_id: 125,
+ name: 'pageview',
+ pathname: '/page1',
+ timestamp: { minutesAgo: 60 }
+ },
+ {
+ user_id: 126,
+ name: 'pageview',
+ pathname: '/page1',
+ timestamp: { minutesAgo: 60 }
+ },
+ {
+ user_id: 123,
+ name: 'engagement',
+ pathname: '/page1',
+ scroll_depth: 80,
+ timestamp: { minutesAgo: 59 }
+ },
+ {
+ user_id: 124,
+ name: 'engagement',
+ pathname: '/page1',
+ scroll_depth: 80,
+ timestamp: { minutesAgo: 59 }
+ },
+ {
+ user_id: 125,
+ name: 'engagement',
+ pathname: '/page1',
+ scroll_depth: 80,
+ timestamp: { minutesAgo: 59 }
+ },
+ {
+ user_id: 123,
+ name: 'purchase',
+ pathname: '/buy',
+ revenue_reporting_amount: '23',
+ revenue_reporting_currency: 'EUR',
+ timestamp: { minutesAgo: 59 }
+ },
+ { user_id: 124, name: 'add_site', timestamp: { minutesAgo: 50 } },
+ { user_id: 125, name: 'add_site', timestamp: { minutesAgo: 50 } }
+ ]
+ })
+
+ await addCustomGoal({
+ page,
+ domain,
+ name: 'add_site',
+ displayName: 'Add a site'
+ })
+ await addCustomGoal({ page, domain, name: 'purchase', currency: 'EUR' })
+ await addPageviewGoal({ page, domain, pathname: '/page1' })
+ await addScrollDepthGoal({
+ page,
+ domain,
+ pathname: '/page1',
+ scrollPercentage: 75
+ })
+
+ await page.goto('/' + domain)
+
+ const goalsTabButton = tabButton(report, 'Goals')
+
+ await test.step('listing all goals', async () => {
+ await goalsTabButton.scrollIntoViewIfNeeded()
+ await expect(goalsTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, [
+ 'Goal',
+ 'Uniques',
+ 'Total',
+ 'CR',
+ 'Revenue',
+ 'Average'
+ ])
+
+ await expectRows(report, [
+ 'Visit /page1',
+ 'Scroll 75% on /page1',
+ 'Add a site',
+ 'purchase'
+ ])
+ await expectMetricValues(report, 'Visit /page1', [
+ '4',
+ '4',
+ '100%',
+ '-',
+ '-'
+ ])
+ await expectMetricValues(report, 'Scroll 75% on /page1', [
+ '3',
+ '-',
+ '75%',
+ '-',
+ '-'
+ ])
+ await expectMetricValues(report, 'Add a site', ['2', '2', '50%', '-', '-'])
+ await expectMetricValues(report, 'purchase', [
+ '1',
+ '1',
+ '25%',
+ '€23.0',
+ '€23.0'
+ ])
+ })
+
+ await test.step('goals modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Goal conversions' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Goal',
+ /Uniques/,
+ /Total/,
+ /CR/,
+ /Average/,
+ /Revenue/
+ ])
+
+ await expectRows(modal(page), [
+ 'Visit /page1',
+ 'Scroll 75% on /page1',
+ 'Add a site',
+ 'purchase'
+ ])
+
+ await expectMetricValues(modal(page), 'Visit /page1', [
+ '4',
+ '4',
+ '100%',
+ '-',
+ '-'
+ ])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('listing goals without revenue', async () => {
+ await page.goto('/' + domain + '?f=has_not_done,goal,purchase')
+
+ await goalsTabButton.scrollIntoViewIfNeeded()
+ await expect(goalsTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Goal', 'Uniques', 'Total', 'CR'])
+
+ await expectRows(report, [
+ 'Visit /page1',
+ 'Add a site',
+ 'Scroll 75% on /page1'
+ ])
+
+ await expectMetricValues(report, 'Visit /page1', ['3', '3', '100%'])
+ await expectMetricValues(report, 'Add a site', ['2', '2', '66.7%'])
+ await expectMetricValues(report, 'Scroll 75% on /page1', [
+ '2',
+ '-',
+ '66.7%'
+ ])
+ })
+
+ await test.step('goals modal without revenue', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Goal conversions' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), ['Goal', /Uniques/, /Total/, /CR/])
+
+ await expectRows(modal(page), [
+ 'Visit /page1',
+ 'Add a site',
+ 'Scroll 75% on /page1'
+ ])
+
+ await expectMetricValues(modal(page), 'Visit /page1', ['3', '3', '100%'])
+
+ await closeModalButton(page).click()
+ })
+})
+
+test('props breakdown', async ({ page, request }) => {
+ const report = getReport(page)
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ name: 'pageview',
+ pathname: '/page',
+ 'meta.key': [
+ 'logged_in',
+ 'browser_language',
+ 'prop3',
+ 'prop4',
+ 'prop5',
+ 'prop6',
+ 'prop7',
+ 'prop8',
+ 'prop9',
+ 'prop10',
+ 'prop11'
+ ],
+ 'meta.value': [
+ 'false',
+ 'en_US',
+ 'val3',
+ 'val4',
+ 'val5',
+ 'val6',
+ 'val7',
+ 'val8',
+ 'val9',
+ 'val10',
+ 'val11'
+ ]
+ },
+ {
+ name: 'pageview',
+ pathname: '/page',
+ 'meta.key': ['logged_in', 'browser_language'],
+ 'meta.value': ['false', 'en_US']
+ },
+ {
+ name: 'pageview',
+ pathname: '/page',
+ 'meta.key': ['logged_in', 'browser_language'],
+ 'meta.value': ['true', 'es']
+ }
+ ]
+ })
+
+ await addPageviewGoal({ page, domain, pathname: '/page' })
+
+ await addAllCustomProps({ page, domain })
+
+ await page.goto('/' + domain)
+
+ const propsTabButton = tabButton(report, 'Properties')
+
+ await test.step('listing props', async () => {
+ await propsTabButton.scrollIntoViewIfNeeded()
+ await propsTabButton.click()
+ await dropdown(report)
+ .getByRole('button', { name: 'browser_language' })
+ .click()
+
+ await expect(propsTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['browser_language', 'Visitors', 'Events', '%'])
+
+ await expectRows(report, ['en_US', 'es'])
+
+ await expectMetricValues(report, 'en_US', ['2', '2', '66.7%'])
+ await expectMetricValues(report, 'es', ['1', '1', '33.3%'])
+ })
+
+ await test.step('loading more', async () => {
+ await propsTabButton.click()
+ const showMoreButton = dropdown(report).getByRole('button', {
+ name: 'Show 1 more'
+ })
+ await showMoreButton.click()
+ await expect(showMoreButton).toBeHidden()
+ await expect(dropdown(report).getByRole('button')).toHaveCount(11)
+ })
+
+ await test.step('searching', async () => {
+ await searchInput(report).fill('prop1')
+ await expect(dropdown(report).getByRole('button')).toHaveCount(2)
+ })
+
+ await test.step('props modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Custom property breakdown' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'browser_language',
+ /Visitors/,
+ /Events/,
+ /%/
+ ])
+
+ await expectRows(modal(page), ['en_US', 'es'])
+
+ await expectMetricValues(modal(page), 'en_US', ['2', '2', '66.7%'])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('clicking goal opens props', async () => {
+ const goalsTabButton = tabButton(report, 'Goals')
+ goalsTabButton.click()
+
+ await expect(goalsTabButton).toHaveAttribute('data-active', 'true')
+
+ await rowLink(report, 'Visit /page').click()
+
+ await expect(propsTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, [
+ 'browser_language',
+ 'Visitors',
+ 'Events',
+ 'CR'
+ ])
+
+ await expectRows(report, ['en_US', 'es'])
+
+ await expectMetricValues(report, 'en_US', ['2', '2', '66.7%'])
+ await expectMetricValues(report, 'es', ['1', '1', '33.3%'])
+ })
+})
+
+test('funnels', async ({ page, request }) => {
+ const report = getReport(page)
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ user_id: 123,
+ name: 'pageview',
+ pathname: '/products',
+ timestamp: { minutesAgo: 60 }
+ },
+ {
+ user_id: 123,
+ name: 'pageview',
+ pathname: '/cart',
+ timestamp: { minutesAgo: 55 }
+ },
+ {
+ user_id: 123,
+ name: 'pageview',
+ pathname: '/checkout',
+ timestamp: { minutesAgo: 50 }
+ },
+ {
+ user_id: 124,
+ name: 'pageview',
+ pathname: '/products',
+ timestamp: { minutesAgo: 55 }
+ },
+ {
+ user_id: 124,
+ name: 'pageview',
+ pathname: '/cart',
+ timestamp: { minutesAgo: 50 }
+ },
+ {
+ user_id: 125,
+ name: 'pageview',
+ pathname: '/products',
+ timestamp: { minutesAgo: 50 }
+ }
+ ]
+ })
+
+ await addPageviewGoal({ page, domain, pathname: '/products' })
+ await addPageviewGoal({ page, domain, pathname: '/cart' })
+ await addPageviewGoal({ page, domain, pathname: '/checkout' })
+
+ for (let idx = 0; idx < 11; idx++) {
+ await addFunnel({
+ request,
+ domain,
+ name: `Shopping ${idx + 1} Funnel`,
+ steps: ['Visit /products', 'Visit /cart', 'Visit /checkout']
+ })
+ }
+
+ await page.goto('/' + domain)
+
+ const funnelsTabButton = tabButton(report, 'Funnels')
+
+ await test.step('rendering funnels', async () => {
+ await funnelsTabButton.scrollIntoViewIfNeeded()
+ await funnelsTabButton.click()
+ await dropdown(report)
+ .getByRole('button', { name: 'Shopping 11 Funnel' })
+ .click()
+
+ await expect(funnelsTabButton).toHaveAttribute('data-active', 'true')
+
+ await expect(report.getByRole('heading')).toHaveText('Shopping 11 Funnel')
+
+ await expect(report.getByText('3-step funnel')).toBeVisible()
+
+ await expect(report.getByText('33.33% conversion rate')).toBeVisible()
+ })
+
+ await test.step('loading more', async () => {
+ await funnelsTabButton.click()
+ await dropdown(report).getByRole('button', { name: 'Show 1 more' }).click()
+ await dropdown(report)
+ .getByRole('button', { name: 'Shopping 1 Funnel' })
+ .click()
+
+ await expect(report.getByRole('heading')).toHaveText('Shopping 1 Funnel')
+ })
+
+ await test.step('searching', async () => {
+ await funnelsTabButton.click()
+ await searchInput(report).fill('Shopping 1')
+
+ await expect(dropdown(report).getByRole('button')).toHaveText([
+ 'Shopping 11 Funnel',
+ 'Shopping 10 Funnel',
+ 'Shopping 1 Funnel'
+ ])
+ })
+})
diff --git a/e2e/tests/dashboard/breakdowns.spec.ts b/e2e/tests/dashboard/breakdowns.spec.ts
new file mode 100644
index 000000000000..efed61b51fec
--- /dev/null
+++ b/e2e/tests/dashboard/breakdowns.spec.ts
@@ -0,0 +1,1142 @@
+import { test, expect } from '@playwright/test'
+import { setupSite, populateStats, addCustomGoal } from '../fixtures.ts'
+import {
+ tabButton,
+ expectHeaders,
+ expectRows,
+ rowLink,
+ expectMetricValues,
+ dropdown,
+ modal,
+ detailsLink,
+ closeModalButton,
+ header,
+ searchInput
+} from '../test-utils.ts'
+
+test('sources breakdown', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ name: 'pageview',
+ referrer_source: 'DuckDuckGo',
+
+ referrer: 'https://duckduckgo.com/a1',
+ utm_medium: 'paid'
+ },
+ {
+ name: 'pageview',
+ referrer_source: 'DuckDuckGo',
+ referrer: 'https://duckduckgo.com/a2',
+ click_id_param: 'gclid'
+ },
+ { name: 'pageview', referrer_source: 'Facebook', utm_source: 'fb' },
+ { name: 'pageview', referrer_source: 'theguardian.com' },
+ { name: 'pageview', referrer_source: 'ablog.example.com' },
+ {
+ name: 'pageview',
+ utm_medium: 'SomeUTMMedium',
+ utm_source: 'SomeUTMSource',
+ utm_campaign: 'SomeUTMCampaign',
+ utm_content: 'SomeUTMContent',
+ utm_term: 'SomeUTMTerm'
+ }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const report = page.getByTestId('report-sources')
+
+ await test.step('sources tab', async () => {
+ const sourcesTabButton = tabButton(report, 'Sources')
+ await sourcesTabButton.scrollIntoViewIfNeeded()
+ await expect(sourcesTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Source', 'Visitors'])
+
+ await expectRows(report, [
+ 'DuckDuckGo',
+ 'Direct / None',
+ 'Facebook',
+ 'ablog.example.com',
+ 'theguardian.com'
+ ])
+
+ await expectMetricValues(report, 'DuckDuckGo', ['2', '33.3%'])
+ await expectMetricValues(report, 'Direct / None', ['1', '16.7%'])
+ await expectMetricValues(report, 'Facebook', ['1', '16.7%'])
+ await expectMetricValues(report, 'ablog.example.com', ['1', '16.7%'])
+ await expectMetricValues(report, 'theguardian.com', ['1', '16.7%'])
+ })
+
+ await test.step('sources modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top sources' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Source',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), [
+ 'DuckDuckGo',
+ 'Direct / None',
+ 'Facebook',
+ 'ablog.example.com',
+ 'theguardian.com'
+ ])
+
+ await expectMetricValues(modal(page), 'DuckDuckGo', ['2', '100%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+
+ const referrersReport = page.getByTestId('report-referrers')
+
+ await test.step('clicking sources entry shows referrers', async () => {
+ await rowLink(report, 'DuckDuckGo').click()
+ await expect(page).toHaveURL(/f=is,source,DuckDuckGo/)
+
+ await expect(tabButton(referrersReport, 'Top referrers')).toHaveAttribute(
+ 'data-active',
+ 'true'
+ )
+
+ // Move mouse away from report rows
+ await tabButton(referrersReport, 'Top referrers').hover()
+
+ await expectHeaders(referrersReport, ['Referrer', 'Visitors'])
+
+ await expectRows(referrersReport, [
+ 'https://duckduckgo.com/a1',
+ 'https://duckduckgo.com/a2'
+ ])
+ })
+
+ await test.step('referrers modal', async () => {
+ await detailsLink(referrersReport).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Referrer drilldown' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Referrer',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), [
+ 'https://duckduckgo.com/a1',
+ 'https://duckduckgo.com/a2'
+ ])
+
+ await closeModalButton(page).click()
+
+ await page
+ .getByRole('button', { name: 'Remove filter: Source is DuckDuckGo' })
+ .click()
+ })
+
+ await test.step('channels tab', async () => {
+ const channelsTabButton = tabButton(report, 'Channels')
+ await channelsTabButton.click()
+ await expect(channelsTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Channel', 'Visitors'])
+
+ await expectRows(report, [
+ 'Referral',
+ 'Direct',
+ 'Organic Search',
+ 'Organic Social',
+ 'Paid Search'
+ ])
+
+ await expectMetricValues(report, 'Referral', ['2', '33.3%'])
+ await expectMetricValues(report, 'Direct', ['1', '16.7%'])
+ await expectMetricValues(report, 'Organic Search', ['1', '16.7%'])
+ await expectMetricValues(report, 'Organic Social', ['1', '16.7%'])
+ await expectMetricValues(report, 'Paid Search', ['1', '16.7%'])
+ })
+
+ await test.step('channels modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top acquisition channels' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Channel',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), [
+ 'Referral',
+ 'Direct',
+ 'Organic Search',
+ 'Organic Social',
+ 'Paid Search'
+ ])
+
+ await expectMetricValues(modal(page), 'Referral', ['2', '100%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('campaigns > UTM mediums tab', async () => {
+ await tabButton(report, 'Campaigns').click()
+ await dropdown(report).getByRole('button', { name: 'UTM mediums' }).click()
+
+ await expect(tabButton(report, 'UTM mediums')).toHaveAttribute(
+ 'data-active',
+ 'true'
+ )
+
+ await expectHeaders(report, ['Medium', 'Visitors'])
+
+ await expectRows(report, ['SomeUTMMedium', 'paid'])
+
+ await expectMetricValues(report, 'SomeUTMMedium', ['1', '50%'])
+ await expectMetricValues(report, 'paid', ['1', '50%'])
+ })
+
+ await test.step('UTM mediums modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top UTM mediums' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'UTM medium',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['SomeUTMMedium', 'paid'])
+
+ await expectMetricValues(modal(page), 'SomeUTMMedium', ['1', '100%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('campaigns > UTM sources tab', async () => {
+ await tabButton(report, 'UTM mediums').click()
+ await dropdown(report).getByRole('button', { name: 'UTM sources' }).click()
+
+ await expect(tabButton(report, 'UTM sources')).toHaveAttribute(
+ 'data-active',
+ 'true'
+ )
+
+ await expectHeaders(report, ['Source', 'Visitors'])
+
+ await expectRows(report, ['SomeUTMSource', 'fb'])
+
+ await expectMetricValues(report, 'SomeUTMSource', ['1', '50%'])
+ await expectMetricValues(report, 'fb', ['1', '50%'])
+ })
+
+ await test.step('UTM sources modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top UTM sources' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'UTM source',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['SomeUTMSource', 'fb'])
+
+ await expectMetricValues(modal(page), 'SomeUTMSource', ['1', '100%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('campaigns > UTM campaigns tab', async () => {
+ await tabButton(report, 'UTM sources').click()
+ await dropdown(report)
+ .getByRole('button', { name: 'UTM campaigns' })
+ .click()
+
+ await expect(tabButton(report, 'UTM campaigns')).toHaveAttribute(
+ 'data-active',
+ 'true'
+ )
+
+ await expectHeaders(report, ['Campaign', 'Visitors'])
+
+ await expectRows(report, ['SomeUTMCampaign'])
+
+ await expectMetricValues(report, 'SomeUTMCampaign', ['1', '100%'])
+ })
+
+ await test.step('UTM campaigns modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top UTM campaigns' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'UTM campaign',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['SomeUTMCampaign'])
+
+ await expectMetricValues(modal(page), 'SomeUTMCampaign', [
+ '1',
+ '100%',
+ '0s'
+ ])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('campaigns > UTM contents tab', async () => {
+ await tabButton(report, 'UTM campaigns').click()
+ await dropdown(report).getByRole('button', { name: 'UTM contents' }).click()
+
+ await expect(tabButton(report, 'UTM contents')).toHaveAttribute(
+ 'data-active',
+ 'true'
+ )
+
+ await expectHeaders(report, ['Content', 'Visitors'])
+
+ await expectRows(report, ['SomeUTMContent'])
+
+ await expectMetricValues(report, 'SomeUTMContent', ['1', '100%'])
+ })
+
+ await test.step('UTM contents modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top UTM contents' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'UTM content',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['SomeUTMContent'])
+
+ await expectMetricValues(modal(page), 'SomeUTMContent', ['1', '100%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('campaigns > UTM terms tab', async () => {
+ await tabButton(report, 'UTM contents').click()
+ await dropdown(report).getByRole('button', { name: 'UTM terms' }).click()
+
+ await expect(tabButton(report, 'UTM terms')).toHaveAttribute(
+ 'data-active',
+ 'true'
+ )
+
+ await expectHeaders(report, ['Term', 'Visitors'])
+
+ await expectRows(report, ['SomeUTMTerm'])
+
+ await expectMetricValues(report, 'SomeUTMTerm', ['1', '100%'])
+ })
+
+ await test.step('UTM terms modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top UTM terms' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'UTM term',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['SomeUTMTerm'])
+
+ await expectMetricValues(modal(page), 'SomeUTMTerm', ['1', '100%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+})
+
+test('pages breakdown', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { user_id: 123, name: 'pageview', pathname: '/page1' },
+ { user_id: 123, name: 'pageview', pathname: '/page2' },
+ { user_id: 123, name: 'pageview', pathname: '/page3' },
+ { user_id: 124, name: 'pageview', pathname: '/page1' },
+ { user_id: 124, name: 'pageview', pathname: '/page2' },
+ { name: 'pageview', pathname: '/page1' },
+ { name: 'pageview', pathname: '/other' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const report = page.getByTestId('report-pages')
+
+ await test.step('top pages tab', async () => {
+ const pagesTabButton = tabButton(report, 'Top pages')
+ await pagesTabButton.scrollIntoViewIfNeeded()
+ await expect(pagesTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Page', 'Visitors'])
+
+ await expectRows(report, ['/page1', '/page2', '/other', '/page3'])
+
+ await expectMetricValues(report, '/page1', ['3', '75%'])
+ await expectMetricValues(report, '/page2', ['2', '50%'])
+ await expectMetricValues(report, '/other', ['1', '25%'])
+ await expectMetricValues(report, '/page3', ['1', '25%'])
+ })
+
+ await test.step('entry pages tab', async () => {
+ const entryPagesTabButton = tabButton(report, 'Entry pages')
+ entryPagesTabButton.click()
+ await expect(entryPagesTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Entry page', 'Unique entrances'])
+
+ await expectRows(report, ['/page1', '/other'])
+
+ await expectMetricValues(report, '/page1', ['3', '75%'])
+ await expectMetricValues(report, '/other', ['1', '25%'])
+ })
+
+ await test.step('Entry pages modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Entry pages' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Entry page',
+ /Visitors/,
+ /Total entrances/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['/page1', '/other'])
+
+ await expectMetricValues(modal(page), '/page1', ['3', '3', '33%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('exit pages tab', async () => {
+ const exitPagesTabButton = tabButton(report, 'Exit pages')
+ exitPagesTabButton.click()
+ await expect(exitPagesTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Exit page', 'Unique exits'])
+
+ await expectRows(report, ['/other', '/page1', '/page2', '/page3'])
+
+ await expectMetricValues(report, '/other', ['1', '25%'])
+ await expectMetricValues(report, '/page1', ['1', '25%'])
+ await expectMetricValues(report, '/page2', ['1', '25%'])
+ await expectMetricValues(report, '/page3', ['1', '25%'])
+ })
+
+ await test.step('Exit pages modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Exit pages' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Page url',
+ /Visitors/,
+ /Total exits/,
+ /Exit rate/
+ ])
+
+ await expectRows(modal(page), ['/other', '/page1', '/page2', '/page3'])
+
+ await expectMetricValues(modal(page), '/other', ['1', '1', '100%'])
+
+ await closeModalButton(page).click()
+ })
+})
+
+test('pages breakdown modal', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ const pagesCount = 110
+
+ // We generate unique page entries, each with a different number of visits
+ const pageEvents = Array(pagesCount)
+ .fill()
+ .map((_, idx) => {
+ return Array(idx + 1)
+ .fill()
+ .map(() => {
+ return { name: 'pageview', pathname: `/page${idx + 1}/foo` }
+ })
+ })
+ .flat()
+
+ await populateStats({
+ request,
+ domain,
+ events: pageEvents
+ })
+
+ await page.goto('/' + domain)
+
+ const report = page.getByTestId('report-pages')
+
+ const pagesTabButton = tabButton(report, 'Top pages')
+ await pagesTabButton.scrollIntoViewIfNeeded()
+ await expect(pagesTabButton).toHaveAttribute('data-active', 'true')
+
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top pages' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Page url',
+ /Visitors/,
+ /Pageviews/,
+ /Bounce rate/,
+ /Time on page/,
+ /Scroll depth/
+ ])
+
+ await test.step('displays 100 entries on a single page', async () => {
+ const pageRows = Array(100)
+ .fill()
+ .map((_, idx) => {
+ return `/page${pagesCount - idx}/foo`
+ })
+
+ await expectRows(modal(page), pageRows)
+
+ await expectMetricValues(modal(page), '/page110/foo', [
+ '110',
+ '110',
+ '100%',
+ '-',
+ '-'
+ ])
+
+ await expectMetricValues(modal(page), '/page11/foo', [
+ '11',
+ '11',
+ '100%',
+ '-',
+ '-'
+ ])
+ })
+
+ await test.step('loads more when requested', async () => {
+ const loadMoreButton = modal(page).getByRole('button', {
+ name: 'Load more'
+ })
+
+ await loadMoreButton.scrollIntoViewIfNeeded()
+ await loadMoreButton.click()
+
+ await expectMetricValues(modal(page), '/page10/foo', [
+ '10',
+ '10',
+ '100%',
+ '-',
+ '-'
+ ])
+
+ await expectMetricValues(modal(page), '/page1/foo', [
+ '1',
+ '1',
+ '100%',
+ '-',
+ '-'
+ ])
+ })
+
+ await test.step('sorts when clicking on column header', async () => {
+ await header(modal(page), 'Visitors').click()
+
+ const pageRows = Array(100)
+ .fill()
+ .map((_, idx) => {
+ return `/page${idx + 1}/foo`
+ })
+
+ await expectRows(modal(page), pageRows)
+ })
+
+ await test.step('filters when using search', async () => {
+ await searchInput(modal(page)).fill('page9')
+
+ await expectRows(modal(page), [
+ '/page9/foo',
+ '/page90/foo',
+ '/page91/foo',
+ '/page92/foo',
+ '/page93/foo',
+ '/page94/foo',
+ '/page95/foo',
+ '/page96/foo',
+ '/page97/foo',
+ '/page98/foo',
+ '/page99/foo'
+ ])
+ })
+
+ await test.step('close button closes the modal', async () => {
+ await closeModalButton(page).click()
+
+ await expect(modal(page)).toBeHidden()
+ })
+
+ await test.step('reopening the modal resets the search state but preserves', async () => {
+ await detailsLink(report).click()
+
+ await expect(modal(page)).toContainClass('is-open')
+
+ await expect(searchInput(modal(page))).toHaveValue('')
+
+ const pageRows = Array(100)
+ .fill()
+ .map((_, idx) => {
+ return `/page${idx + 1}/foo`
+ })
+
+ await expectRows(modal(page), pageRows)
+ })
+})
+
+test('pages breakdown with a pageview goal filter applied', async ({
+ page,
+ request
+}) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { user_id: 123, name: 'pageview', pathname: '/page1' },
+ { user_id: 123, name: 'pageview', pathname: '/page2' },
+ { user_id: 123, name: 'pageview', pathname: '/page3' },
+ {
+ user_id: 123,
+ name: 'purchase',
+ revenue_reporting_amount: '23',
+ revenue_reporting_currency: 'EUR'
+ },
+ { user_id: 124, name: 'pageview', pathname: '/page1' },
+ { user_id: 124, name: 'pageview', pathname: '/page2' },
+ { user_id: 124, name: 'create_site' },
+ { name: 'pageview', pathname: '/page1' },
+ { name: 'pageview', pathname: '/other' }
+ ]
+ })
+
+ await addCustomGoal({ page, domain, name: 'create_site' })
+ await addCustomGoal({ page, domain, name: 'purchase', currency: 'EUR' })
+
+ const report = page.getByTestId('report-pages')
+
+ await test.step('custom goal filter applied', async () => {
+ await page.goto('/' + domain + '?f=is,goal,create_site')
+
+ const pagesTabButton = tabButton(report, 'Conversion pages')
+ await pagesTabButton.scrollIntoViewIfNeeded()
+ await expect(pagesTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Page', 'Conversions', 'CR'])
+
+ await expectRows(report, ['/'])
+
+ await expectMetricValues(report, '/', ['1', '50%'])
+ })
+
+ await test.step('details modal after custom goal filter applied', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top pages' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Page url',
+ /Total visitors/,
+ /Conversions/,
+ /CR/
+ ])
+
+ await expectRows(modal(page), ['/'])
+
+ await expectMetricValues(modal(page), '/', ['2', '1', '50%'])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('revenue goal filter applied', async () => {
+ await page.goto('/' + domain + '?f=is,goal,purchase')
+
+ const pagesTabButton = tabButton(report, 'Conversion pages')
+ await pagesTabButton.scrollIntoViewIfNeeded()
+ await expect(pagesTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Page', 'Conversions', 'CR'])
+
+ await expectRows(report, ['/'])
+
+ await expectMetricValues(report, '/', ['1', '50%'])
+ })
+
+ await test.step('details modal after revenue goal filter applied', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top pages' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Page url',
+ /Total visitors/,
+ /Conversions/,
+ /CR/,
+ /Revenue/,
+ /Average/
+ ])
+
+ await expectRows(modal(page), ['/'])
+
+ await expectMetricValues(modal(page), '/', [
+ '2',
+ '1',
+ '50%',
+ '€23.0',
+ '€23.0'
+ ])
+
+ await closeModalButton(page).click()
+ })
+})
+
+test('locations breakdown', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ name: 'pageview',
+ country_code: 'EE',
+ subdivision1_code: 'EE-37',
+ city_geoname_id: 588_409
+ },
+ {
+ name: 'pageview',
+ country_code: 'EE',
+ subdivision1_code: 'EE-79',
+ city_geoname_id: 588_335
+ },
+ {
+ name: 'pageview',
+ country_code: 'PL',
+ subdivision1_code: 'PL-14',
+ city_geoname_id: 756_135
+ }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const report = page.getByTestId('report-locations')
+
+ await test.step('map tab', async () => {
+ const mapTabButton = tabButton(report, 'Map')
+ await mapTabButton.scrollIntoViewIfNeeded()
+ await expect(mapTabButton).toHaveAttribute('data-active', 'true')
+
+ // NOTE: We only check that the map is there
+ await expect(report.locator('svg path.country').first()).toBeVisible()
+ })
+
+ await test.step('countries tab', async () => {
+ const countriesTabButton = tabButton(report, 'Countries')
+ await countriesTabButton.click()
+ await expect(countriesTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Country', 'Visitors'])
+
+ await expectRows(report, [/Estonia/, /Poland/])
+
+ await expectMetricValues(report, 'Estonia', ['2', '66.7%'])
+ await expectMetricValues(report, 'Poland', ['1', '33.3%'])
+ })
+
+ await test.step('countries modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top countries' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), ['Country', /Visitors/])
+
+ await expectRows(modal(page), [/Estonia/, /Poland/])
+
+ await expectMetricValues(modal(page), 'Estonia', ['2'])
+
+ await closeModalButton(page).click()
+ })
+
+ const regionsTabButton = tabButton(report, 'Regions')
+
+ await test.step('clicking country entry shows regions', async () => {
+ await rowLink(report, 'Estonia').click()
+ await expect(page).toHaveURL(/f=is,country,EE/)
+
+ await expect(regionsTabButton).toHaveAttribute('data-active', 'true')
+
+ await page
+ .getByRole('button', { name: 'Remove filter: Country is Estonia' })
+ .click()
+ })
+
+ await test.step('regions tab', async () => {
+ await regionsTabButton.click()
+ await expect(regionsTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Region', 'Visitors'])
+
+ await expectRows(report, [/Harjumaa/, /Tartumaa/, /Mazovia/])
+
+ await expectMetricValues(report, 'Harjumaa', ['1', '33.3%'])
+ await expectMetricValues(report, 'Tartumaa', ['1', '33.3%'])
+ await expectMetricValues(report, 'Mazovia', ['1', '33.3%'])
+ })
+
+ await test.step('regions modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top regions' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), ['Region', /Visitors/])
+
+ await expectRows(modal(page), [/Harjumaa/, /Tartumaa/, /Mazovia/])
+
+ await expectMetricValues(modal(page), 'Harjumaa', ['1'])
+
+ await closeModalButton(page).click()
+ })
+
+ const citiesTabButton = tabButton(report, 'Cities')
+
+ await test.step('clicking region entry shows cities', async () => {
+ await rowLink(report, 'Harjumaa').click()
+ await expect(page).toHaveURL(/f=is,region,EE-37/)
+
+ await expect(citiesTabButton).toHaveAttribute('data-active', 'true')
+
+ await page
+ .getByRole('button', { name: 'Remove filter: Region is Harjumaa' })
+ .click()
+ })
+
+ await test.step('cities tab', async () => {
+ await citiesTabButton.click()
+ await expect(citiesTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['City', 'Visitors'])
+
+ await expectRows(report, [/Tartu/, /Tallinn/, /Warsaw/])
+
+ await expectMetricValues(report, 'Tartu', ['1', '33.3%'])
+ await expectMetricValues(report, 'Tallinn', ['1', '33.3%'])
+ await expectMetricValues(report, 'Warsaw', ['1', '33.3%'])
+ })
+
+ await test.step('cities modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Top cities' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), ['City', /Visitors/])
+
+ await expectRows(modal(page), [/Tartu/, /Tallinn/, /Warsaw/])
+
+ await expectMetricValues(modal(page), 'Tartu', ['1'])
+
+ await closeModalButton(page).click()
+ })
+})
+
+test('devices breakdown', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ name: 'pageview',
+ screen_size: 'Desktop',
+ browser: 'Chrome',
+ browser_version: '14.0.7',
+ operating_system: 'Windows',
+ operating_system_version: '11'
+ },
+ {
+ name: 'pageview',
+ screen_size: 'Desktop',
+ browser: 'Firefox',
+ browser_version: '98',
+ operating_system: 'MacOS',
+ operating_system_version: '10.15'
+ },
+ {
+ name: 'pageview',
+ screen_size: 'Mobile',
+ browser: 'Safari',
+ browser_version: '123',
+ operating_system: 'iOS',
+ operating_system_version: '16.15'
+ }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const report = page.getByTestId('report-devices')
+
+ const browsersTabButton = tabButton(report, 'Browsers')
+
+ await test.step('browsers tab', async () => {
+ await browsersTabButton.scrollIntoViewIfNeeded()
+ await expect(browsersTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Browser', 'Visitors'])
+
+ await expectRows(report, ['Chrome', 'Firefox', 'Safari'])
+
+ await expectMetricValues(report, 'Chrome', ['1', '33.3%'])
+ await expectMetricValues(report, 'Firefox', ['1', '33.3%'])
+ await expectMetricValues(report, 'Safari', ['1', '33.3%'])
+ })
+
+ await test.step('browsers modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Browsers' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Browser',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['Chrome', 'Firefox', 'Safari'])
+
+ await expectMetricValues(modal(page), 'Chrome', ['1', '100%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('browser versions', async () => {
+ await rowLink(report, 'Firefox').click()
+
+ await expect(page).toHaveURL(/f=is,browser,Firefox/)
+
+ await expect(browsersTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Browser version', 'Visitors'])
+
+ await expectRows(report, ['Firefox 98'])
+
+ await expectMetricValues(report, 'Firefox 98', ['1', '100%'])
+ })
+
+ await test.step('browser versions modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Browser versions' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Browser version',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['98'])
+
+ await expectMetricValues(modal(page), '98', ['1', '100%', '0s'])
+
+ await closeModalButton(page).click()
+
+ await page
+ .getByRole('button', { name: 'Remove filter: Browser is Firefox' })
+ .click()
+ })
+
+ const osTabButton = tabButton(report, 'Operating systems')
+
+ await test.step('operating systems tab', async () => {
+ await osTabButton.click()
+ await expect(osTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Operating system', 'Visitors'])
+
+ await expectRows(report, ['MacOS', 'Windows', 'iOS'])
+
+ await expectMetricValues(report, 'MacOS', ['1', '33.3%'])
+ await expectMetricValues(report, 'Windows', ['1', '33.3%'])
+ await expectMetricValues(report, 'iOS', ['1', '33.3%'])
+ })
+
+ await test.step('operating systems modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Operating systems' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Operating system',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['MacOS', 'Windows', 'iOS'])
+
+ await expectMetricValues(modal(page), 'MacOS', ['1', '100%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+
+ await test.step('operating system versions', async () => {
+ await rowLink(report, 'Windows').click()
+
+ await expect(page).toHaveURL(/f=is,os,Windows/)
+
+ await expect(osTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Operating system version', 'Visitors'])
+
+ await expectRows(report, ['Windows 11'])
+
+ await expectMetricValues(report, 'Windows 11', ['1', '100%'])
+ })
+
+ await test.step('operating system versions modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Operating system versions' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Operating system version',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['11'])
+
+ await expectMetricValues(modal(page), '11', ['1', '100%', '0s'])
+
+ await closeModalButton(page).click()
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: Operating system is Windows'
+ })
+ .click()
+ })
+
+ await test.step('devices tab', async () => {
+ const devicesTabButton = tabButton(report, 'Devices')
+ await devicesTabButton.click()
+ await expect(devicesTabButton).toHaveAttribute('data-active', 'true')
+
+ await expectHeaders(report, ['Device', 'Visitors'])
+
+ await expectRows(report, ['Desktop', 'Mobile'])
+
+ await expectMetricValues(report, 'Desktop', ['2', '66.7%'])
+ await expectMetricValues(report, 'Mobile', ['1', '33.3%'])
+ })
+
+ await test.step('devices modal', async () => {
+ await detailsLink(report).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Devices' })
+ ).toBeVisible()
+
+ await expectHeaders(modal(page), [
+ 'Device',
+ /Visitors/,
+ /Bounce rate/,
+ /Visit duration/
+ ])
+
+ await expectRows(modal(page), ['Desktop', 'Mobile'])
+
+ await expectMetricValues(modal(page), 'Desktop', ['2', '100%', '0s'])
+
+ await closeModalButton(page).click()
+ })
+})
diff --git a/e2e/tests/dashboard/filtering.spec.ts b/e2e/tests/dashboard/filtering.spec.ts
new file mode 100644
index 000000000000..179a9a6e4664
--- /dev/null
+++ b/e2e/tests/dashboard/filtering.spec.ts
@@ -0,0 +1,979 @@
+import { test, expect } from '@playwright/test'
+import { setupSite, populateStats, addPageviewGoal } from '../fixtures.ts'
+import {
+ filterButton,
+ filterItemButton,
+ applyFilterButton,
+ filterRow,
+ suggestedItem,
+ filterOperator,
+ filterOperatorOption
+} from '../test-utils.ts'
+
+test.describe('page filtering tests', () => {
+ const pageFilterButton = (page) => filterItemButton(page, 'Page')
+
+ test('filtering by page with detailed behavior test', async ({
+ page,
+ request
+ }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', pathname: '/page1' },
+ { name: 'pageview', pathname: '/page2' },
+ { name: 'pageview', pathname: '/page3' },
+ { name: 'pageview', pathname: '/other' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const pageFilterRow = filterRow(page, 'page')
+ const pageInput = page.getByPlaceholder('Select a Page')
+
+ await filterButton(page).click()
+ await pageFilterButton(page).click()
+
+ await expect(
+ page.getByRole('heading', { name: 'Filter by Page' })
+ ).toBeVisible()
+
+ await expect(applyFilterButton(page, { disabled: true })).toBeVisible()
+ await pageInput.fill('page')
+
+ await expect(suggestedItem(pageFilterRow, '/page1')).toBeVisible()
+ await expect(suggestedItem(pageFilterRow, '/page2')).toBeVisible()
+ await expect(suggestedItem(pageFilterRow, '/page3')).toBeVisible()
+ await expect(applyFilterButton(page, { disabled: true })).toBeVisible()
+
+ await pageInput.fill('/page1')
+
+ await expect(suggestedItem(pageFilterRow, '/page1')).toBeVisible()
+ await expect(applyFilterButton(page, { disabled: true })).toBeVisible()
+
+ await suggestedItem(pageFilterRow, '/page1').click()
+ await expect(applyFilterButton(page)).toBeVisible()
+
+ await applyFilterButton(page).click()
+
+ await expect(page).toHaveURL(/f=is,page,\/page1/)
+
+ await expect(
+ page.getByRole('link', { name: 'Page is /page1' })
+ ).toHaveAttribute('title', 'Edit filter: Page is /page1')
+ })
+
+ test('filtering by page using different operators', async ({
+ page,
+ request
+ }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', pathname: '/page1' },
+ { name: 'pageview', pathname: '/page2' },
+ { name: 'pageview', pathname: '/page3' },
+ { name: 'pageview', pathname: '/other' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const pageFilterRow = filterRow(page, 'page')
+ const pageInput = page.getByPlaceholder('Select a Page')
+
+ await test.step("'is not' operator", async () => {
+ await filterButton(page).click()
+ await pageFilterButton(page).click()
+
+ await filterOperator(pageFilterRow).click()
+ await filterOperatorOption(pageFilterRow, 'is not').click()
+ await pageInput.fill('page')
+ await suggestedItem(pageFilterRow, '/page1').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Page is not /page1' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is_not,page,\/page1/)
+
+ await page
+ .getByRole('button', { name: 'Remove filter: Page is not /page1' })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=is_not,page,\/page1/)
+ })
+
+ await test.step("'contains' operator", async () => {
+ await filterButton(page).click()
+ await pageFilterButton(page).click()
+
+ await filterOperator(pageFilterRow).click()
+ await filterOperatorOption(pageFilterRow, 'contains').click()
+ await pageInput.fill('page1')
+ await suggestedItem(pageFilterRow, "Filter by 'page1'").click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Page contains page1' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=contains,page,page1/)
+
+ await page
+ .getByRole('button', { name: 'Remove filter: Page contains page1' })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=contains,page,page1/)
+ })
+
+ await test.step("'does not contain' operator", async () => {
+ await filterButton(page).click()
+ await pageFilterButton(page).click()
+
+ await filterOperator(pageFilterRow).click()
+ await filterOperatorOption(pageFilterRow, 'does not contain').click()
+ await pageInput.fill('page1')
+ await suggestedItem(pageFilterRow, "Filter by 'page1'").click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Page does not contain page1' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=contains_not,page,page1/)
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: Page does not contain page1'
+ })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=contains_not,page,page1/)
+ })
+
+ await test.step("'is' operator with multiple choices", async () => {
+ await filterButton(page).click()
+ await pageFilterButton(page).click()
+
+ await pageInput.fill('page')
+ await suggestedItem(pageFilterRow, '/page2').click()
+ await pageInput.fill('page')
+ await suggestedItem(pageFilterRow, '/page3').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Page is /page2 or /page3' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,page,\/page2,\/page3/)
+ })
+ })
+
+ test('filtering by entry page', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { user_id: 123, name: 'pageview', pathname: '/page1' },
+ { user_id: 123, name: 'pageview', pathname: '/page2' },
+ { user_id: 123, name: 'pageview', pathname: '/page3' },
+ { user_id: 124, name: 'pageview', pathname: '/page1' },
+ { user_id: 124, name: 'pageview', pathname: '/page2' },
+ { name: 'pageview', pathname: '/page1' },
+ { name: 'pageview', pathname: '/other' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const entryPageFilterRow = filterRow(page, 'entry_page')
+ const entryPageInput = page.getByPlaceholder('Select an Entry Page')
+
+ await filterButton(page).click()
+ await pageFilterButton(page).click()
+
+ await entryPageInput.fill('page')
+ await suggestedItem(entryPageFilterRow, '/page1').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Entry page is /page1' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,entry_page,\/page1/)
+ })
+
+ test('filtering by exit page', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { user_id: 123, name: 'pageview', pathname: '/page1' },
+ { user_id: 123, name: 'pageview', pathname: '/page2' },
+ { user_id: 123, name: 'pageview', pathname: '/page3' },
+ { user_id: 124, name: 'pageview', pathname: '/page1' },
+ { user_id: 124, name: 'pageview', pathname: '/page2' },
+ { name: 'pageview', pathname: '/page1' },
+ { name: 'pageview', pathname: '/other' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const exitPageFilterRow = filterRow(page, 'exit_page')
+ const exitPageInput = page.getByPlaceholder('Select an Exit Page')
+
+ await filterButton(page).click()
+ await pageFilterButton(page).click()
+
+ await exitPageInput.fill('page')
+ await suggestedItem(exitPageFilterRow, '/page3').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Exit page is /page3' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,exit_page,\/page3/)
+ })
+})
+
+test.describe('hostname filtering tests', () => {
+ const hostnameFilterButton = (page) => filterItemButton(page, 'Hostname')
+
+ test('filtering by hostname', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', hostname: 'one.example.com' },
+ { name: 'pageview', hostname: 'two.example.com' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const hostnameFilterRow = filterRow(page, 'hostname')
+ const hostnameInput = page.getByPlaceholder('Select a Hostname')
+
+ await filterButton(page).click()
+ await hostnameFilterButton(page).click()
+
+ await hostnameInput.fill('one')
+ await suggestedItem(hostnameFilterRow, 'one.example.com').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Hostname is one.example.com' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,hostname,one.example.com/)
+ })
+})
+
+test.describe('acquisition filtering tests', () => {
+ const sourceFilterButton = (page) => filterItemButton(page, 'Source')
+
+ test('filtering by source information', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', referrer_source: 'Google', utm_source: 'Adwords' },
+ { name: 'pageview', referrer_source: 'Facebook', utm_source: 'fb' },
+ { name: 'pageview', referrer: 'https://theguardian.com' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ await test.step('filtering by source', async () => {
+ const sourceFilterRow = filterRow(page, 'source')
+ const sourceInput = page.getByPlaceholder('Select a Source')
+
+ await filterButton(page).click()
+ await sourceFilterButton(page).click()
+
+ await sourceInput.fill('goog')
+ await suggestedItem(sourceFilterRow, 'Google').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Source is Google' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,source,Google/)
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: Source is Google'
+ })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=is,source,Google/)
+ })
+
+ await test.step('filtering by channel', async () => {
+ const channelFilterRow = filterRow(page, 'channel')
+ const channelInput = page.getByPlaceholder('Select a Channel')
+
+ await filterButton(page).click()
+ await sourceFilterButton(page).click()
+
+ await channelInput.fill('paid')
+ await suggestedItem(channelFilterRow, 'Paid Search').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Channel is Paid Search' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,channel,Paid%20Search/)
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: Channel is Paid Search'
+ })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=is,channel,Paid%20Search/)
+ })
+
+ await test.step('filtering by referrer URL', async () => {
+ const referrerFilterRow = filterRow(page, 'referrer')
+ const referrerInput = page.getByPlaceholder('Select a Referrer URL')
+
+ await filterButton(page).click()
+ await sourceFilterButton(page).click()
+
+ await referrerInput.fill('guard')
+ await suggestedItem(referrerFilterRow, 'https://theguardian.com').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', {
+ name: 'Referrer URL is https://theguardian.com'
+ })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,referrer,https:\/\/theguardian\.com/)
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: Referrer URL is https://theguardian.com'
+ })
+ .click()
+
+ await expect(page).not.toHaveURL(
+ /f=is,referrer,https:\/\/theguardian\.com/
+ )
+ })
+ })
+
+ const utmTagsFilterButton = (page) => filterItemButton(page, 'UTM tags')
+
+ test('filtering by UTM tags', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', utm_medium: 'social' },
+ { name: 'pageview', utm_source: 'producthunt' },
+ { name: 'pageview', utm_campaign: 'ads' },
+ { name: 'pageview', utm_term: 'post' },
+ { name: 'pageview', utm_content: 'website' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ await test.step('filtering by UTM medium', async () => {
+ const utmMediumFilterRow = filterRow(page, 'utm_medium')
+ const utmMediumInput = page.getByPlaceholder('Select a UTM Medium')
+
+ await filterButton(page).click()
+ await utmTagsFilterButton(page).click()
+
+ await utmMediumInput.fill('soc')
+ await suggestedItem(utmMediumFilterRow, 'social').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'UTM Medium is social' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,utm_medium,social/)
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: UTM Medium is social'
+ })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=is,utm_medium,social/)
+ })
+
+ await test.step('filtering by UTM source', async () => {
+ const utmSourceFilterRow = filterRow(page, 'utm_source')
+ const utmSourceInput = page.getByPlaceholder('Select a UTM Source')
+
+ await filterButton(page).click()
+ await utmTagsFilterButton(page).click()
+
+ await utmSourceInput.fill('hunt')
+ await suggestedItem(utmSourceFilterRow, 'producthunt').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'UTM Source is producthunt' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,utm_source,producthunt/)
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: UTM Source is producthunt'
+ })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=is,utm_source,producthunt/)
+ })
+
+ await test.step('filtering by UTM campaign', async () => {
+ const utmCampaignFilterRow = filterRow(page, 'utm_campaign')
+ const utmCampaignInput = page.getByPlaceholder('Select a UTM Campaign')
+
+ await filterButton(page).click()
+ await utmTagsFilterButton(page).click()
+
+ await utmCampaignInput.fill('ads')
+ await suggestedItem(utmCampaignFilterRow, 'ads').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'UTM Campaign is ads' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,utm_campaign,ads/)
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: UTM Campaign is ads'
+ })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=is,utm_campaign,ads/)
+ })
+
+ await test.step('filtering by UTM term', async () => {
+ const utmTermFilterRow = filterRow(page, 'utm_term')
+ const utmTermInput = page.getByPlaceholder('Select a UTM Term')
+
+ await filterButton(page).click()
+ await utmTagsFilterButton(page).click()
+
+ await utmTermInput.fill('pos')
+ await suggestedItem(utmTermFilterRow, 'post').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'UTM Term is post' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,utm_term,post/)
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: UTM Term is post'
+ })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=is,utm_term,post/)
+ })
+
+ await test.step('filtering by UTM content', async () => {
+ const utmContentFilterRow = filterRow(page, 'utm_content')
+ const utmContentInput = page.getByPlaceholder('Select a UTM Content')
+
+ await filterButton(page).click()
+ await utmTagsFilterButton(page).click()
+
+ await utmContentInput.fill('web')
+ await suggestedItem(utmContentFilterRow, 'website').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'UTM Content is website' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,utm_content,website/)
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: UTM Content is website'
+ })
+ .click()
+
+ await expect(page).not.toHaveURL(/f=is,utm_content,website/)
+ })
+ })
+})
+
+test.describe('location filtering tests', () => {
+ const locationFilterButton = (page) => filterItemButton(page, 'Location')
+
+ test('filtering by location', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ name: 'pageview',
+ country_code: 'EE',
+ subdivision1_code: 'EE-37',
+ city_geoname_id: 588_409
+ }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ await test.step('filtering by country', async () => {
+ const countryFilterRow = filterRow(page, 'country')
+ const countryInput = page.getByPlaceholder('Select a Country')
+
+ await filterButton(page).click()
+ await locationFilterButton(page).click()
+
+ await countryInput.fill('est')
+ await suggestedItem(countryFilterRow, 'Estonia').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Country is Estonia' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,country,EE/)
+ })
+
+ await test.step('filtering by region', async () => {
+ const regionFilterRow = filterRow(page, 'region')
+ const regionInput = page.getByPlaceholder('Select a Region')
+
+ await filterButton(page).click()
+ await locationFilterButton(page).click()
+
+ await regionInput.fill('har')
+ await suggestedItem(regionFilterRow, 'Harjumaa').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Region is Harjumaa' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,region,EE-37/)
+ await expect(page).toHaveURL(/f=is,country,EE/)
+ })
+
+ await test.step('filtering by city', async () => {
+ const cityFilterRow = filterRow(page, 'city')
+ const cityInput = page.getByPlaceholder('Select a City')
+
+ await filterButton(page).click()
+ await locationFilterButton(page).click()
+
+ await cityInput.click()
+ await suggestedItem(cityFilterRow, 'Tallinn').click()
+
+ await applyFilterButton(page).click()
+
+ await page
+ .getByRole('button', { name: 'See 1 more filter and actions' })
+ .click()
+
+ await expect(
+ page.getByRole('link', { name: 'City is Tallinn' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,city,588409/)
+ await expect(page).toHaveURL(/f=is,region,EE-37/)
+ await expect(page).toHaveURL(/f=is,country,EE/)
+ })
+ })
+})
+
+test.describe('screen size filtering tests', () => {
+ const screenSizeFilterButton = (page) => filterItemButton(page, 'Screen size')
+
+ test('filtering by screen size', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', screen_size: 'Desktop' },
+ { name: 'pageview', screen_size: 'Mobile' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const screenSizeFilterRow = filterRow(page, 'screen')
+ const screenSizeInput = page.getByPlaceholder('Select a Screen size')
+
+ await filterButton(page).click()
+ await screenSizeFilterButton(page).click()
+
+ // When testing via test.e2e.ui, it shows there are no
+ // suggestions found but there are 2 pageview in the top stats.
+ // When navigating live via `MIX_ENV=e2e_test iex -S mix`,
+ // all works fine. Puzzling.
+ await screenSizeInput.fill('mob')
+ await suggestedItem(screenSizeFilterRow, 'Mobile').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Screen size is Mobile' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,screen,Mobile/)
+ })
+})
+
+test.describe('browser filtering tests', () => {
+ const browserFilterButton = (page) => filterItemButton(page, 'Browser')
+
+ test('filtering by browser', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', browser: 'Chrome', browser_version: '14.0.7' },
+ { name: 'pageview', browser: 'Firefox', browser_version: '98' }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ await test.step('filtering by browser type', async () => {
+ const browserFilterRow = filterRow(page, 'browser')
+ const browserInput = page.getByPlaceholder('Select a Browser', {
+ exact: true
+ })
+
+ await filterButton(page).click()
+ await browserFilterButton(page).click()
+
+ await browserInput.fill('chrom')
+ await suggestedItem(browserFilterRow, 'Chrome').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Browser is Chrome' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,browser,Chrome/)
+ })
+
+ await test.step('filtering by browser version', async () => {
+ const browserVersionFilterRow = filterRow(page, 'browser_version')
+ const browserVersionInput = page.getByPlaceholder(
+ 'Select a Browser Version'
+ )
+
+ await filterButton(page).click()
+ await browserFilterButton(page).click()
+
+ await browserVersionInput.fill('14')
+ await suggestedItem(browserVersionFilterRow, '14.0.7').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Browser version is 14.0.7' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,browser_version,14\.0\.7/)
+ await expect(page).toHaveURL(/f=is,browser,Chrome/)
+ })
+ })
+})
+
+test.describe('operating system filtering tests', () => {
+ const operatingSystemFilterButton = (page) =>
+ filterItemButton(page, 'Operating system')
+
+ test('filtering by operating system', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ name: 'pageview',
+ operating_system: 'Windows',
+ operating_system_version: '11'
+ },
+ {
+ name: 'pageview',
+ operating_system: 'MacOS',
+ operating_system_version: '10.15'
+ }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ await test.step('filtering by operating system type', async () => {
+ const operatingSystemFilterRow = filterRow(page, 'os')
+ const operatingSystemInput = page.getByPlaceholder(
+ 'Select an Operating system',
+ { exact: true }
+ )
+
+ await filterButton(page).click()
+ await operatingSystemFilterButton(page).click()
+
+ // The same problem as in the case of screen size filter test.
+ await operatingSystemInput.click()
+ await suggestedItem(operatingSystemFilterRow, 'Windows').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Operating System is Windows' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,os,Windows/)
+ })
+
+ await test.step('filtering by operating system version', async () => {
+ const operatingSystemVersionFilterRow = filterRow(page, 'os_version')
+ const operatingSystemVersionInput = page.getByPlaceholder(
+ 'Select an Operating system version'
+ )
+
+ await filterButton(page).click()
+ await operatingSystemFilterButton(page).click()
+
+ await operatingSystemVersionInput.click()
+ await suggestedItem(operatingSystemVersionFilterRow, '11').click()
+
+ await applyFilterButton(page).click()
+
+ await page
+ .getByRole('button', { name: 'See 1 more filter and actions' })
+ .click()
+
+ await expect(
+ page.getByRole('link', { name: 'Operating system version is 11' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,os_version,11/)
+ await expect(page).toHaveURL(/f=is,os,Windows/)
+ })
+ })
+})
+
+test.describe('goal filtering tests', () => {
+ const goalFilterButton = (page) => filterItemButton(page, 'Goal')
+
+ test('filtering by goals', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', pathname: '/page1' },
+ { name: 'pageview', pathname: '/page2' }
+ ]
+ })
+
+ await addPageviewGoal({ page, domain, pathname: '/page1' })
+ await addPageviewGoal({ page, domain, pathname: '/page2' })
+
+ await page.goto('/' + domain)
+
+ const goalFilterRow = filterRow(page, 'goal')
+ const goalInput = goalFilterRow.getByPlaceholder('Select a Goal')
+
+ await test.step('single goal filter', async () => {
+ await filterButton(page).click()
+ await goalFilterButton(page).click()
+
+ await goalInput.fill('page1')
+ await suggestedItem(goalFilterRow, 'Visit /page1').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Goal is Visit /page1' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,goal,Visit%20\/page1/)
+ })
+
+ const goalFilterRow2 = filterRow(page, 'goal1')
+ const goalInput2 = goalFilterRow2.getByPlaceholder('Select a Goal')
+
+ await test.step('multiple goal filters', async () => {
+ await page.getByRole('link', { name: 'Goal is Visit /page1' }).click()
+
+ await page.getByText('+ Add another').click()
+
+ await filterOperator(goalFilterRow2).click()
+ await filterOperatorOption(goalFilterRow2, 'is not').click()
+
+ await goalInput2.fill('page2')
+ await suggestedItem(goalFilterRow2, 'Visit /page2').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Goal is Visit /page1' })
+ ).toBeVisible()
+
+ await expect(
+ page.getByRole('link', { name: 'Goal is not Visit /page2' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,goal,Visit%20\/page1/)
+ await expect(page).toHaveURL(/f=has_not_done,goal,Visit%20\/page2/)
+ })
+ })
+})
+
+test.describe('property filtering tests', () => {
+ const propFilterButton = (page) =>
+ page.getByTestId('filtermenu').getByRole('link', { name: 'Property' })
+
+ test('filtering by properties', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ name: 'pageview',
+ 'meta.key': ['logged_in', 'browser_language'],
+ 'meta.value': ['false', 'en_US']
+ },
+ {
+ name: 'pageview',
+ 'meta.key': ['logged_in', 'browser_language'],
+ 'meta.value': ['true', 'es']
+ }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const propFilterRow = filterRow(page, 'props')
+ const propNameInput = propFilterRow.getByPlaceholder('Property')
+ const propValueInput = propFilterRow.getByPlaceholder('Value')
+
+ await test.step('single property filter', async () => {
+ await filterButton(page).click()
+ await propFilterButton(page).click()
+
+ await propNameInput.fill('logged')
+ await suggestedItem(propFilterRow, 'logged_in').click()
+ await propValueInput.fill('false')
+ await suggestedItem(propFilterRow, 'false').click()
+
+ await applyFilterButton(page).click()
+
+ await expect(
+ page.getByRole('link', { name: 'Property logged_in is false' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,props:logged_in,false/)
+ })
+
+ const propFilterRow2 = filterRow(page, 'props1')
+ const propNameInput2 = propFilterRow2.getByPlaceholder('Property')
+ const propValueInput2 = propFilterRow2.getByPlaceholder('Value')
+
+ await test.step('multiple property filters', async () => {
+ await page
+ .getByRole('link', { name: 'Property logged_in is false' })
+ .click()
+
+ await page.getByText('+ Add another').click()
+
+ await propNameInput2.fill('browser')
+ await suggestedItem(propFilterRow2, 'browser_language').click()
+ await filterOperator(propFilterRow2).click()
+ await filterOperatorOption(propFilterRow2, 'is not').click()
+ await propValueInput2.fill('US')
+ await suggestedItem(propFilterRow2, 'en_US').click()
+
+ await applyFilterButton(page).click()
+
+ await page
+ .getByRole('button', { name: 'See 1 more filter and actions' })
+ .click()
+
+ await expect(
+ page.getByRole('link', {
+ name: 'Property logged_in is false'
+ })
+ ).toBeVisible()
+
+ await expect(
+ page.getByRole('link', {
+ name: 'Property browser_language is not en_US'
+ })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,props:logged_in,false/)
+ await expect(page).toHaveURL(/f=is_not,props:browser_language,en_US/)
+ })
+ })
+})
diff --git a/e2e/tests/dashboard/general.spec.ts b/e2e/tests/dashboard/general.spec.ts
new file mode 100644
index 000000000000..db8a800c1628
--- /dev/null
+++ b/e2e/tests/dashboard/general.spec.ts
@@ -0,0 +1,79 @@
+import { test, expect } from '@playwright/test'
+import { setupSite, logout, makeSitePublic, populateStats } from '../fixtures.ts'
+
+test('dashboard renders for logged in user', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+ await populateStats({ request, domain, events: [{ name: 'pageview' }] })
+
+ await page.goto('/' + domain)
+
+ await expect(page).toHaveTitle(/Plausible/)
+
+ await expect(page.getByRole('button', { name: domain })).toBeVisible()
+})
+
+test('dashboard renders for anonymous viewer', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+ await makeSitePublic({ page, domain })
+ await populateStats({ request, domain, events: [{ name: 'pageview' }] })
+ await logout(page)
+
+ await page.goto('/' + domain)
+
+ await expect(page).toHaveTitle(/Plausible/)
+
+ await expect(page.getByRole('button', { name: domain })).toBeVisible()
+})
+
+test('tab selection user preferences are preserved across reloads', async ({
+ page,
+ request
+}) => {
+ const { domain } = await setupSite({ page, request })
+ await populateStats({ request, domain, events: [{ name: 'pageview' }] })
+
+ await page.goto('/' + domain)
+
+ await page.getByRole('button', { name: 'Entry pages' }).click()
+
+ await page.goto('/' + domain)
+
+ let currentTab = await page.evaluate(
+ (domain) => localStorage.getItem('pageTab__' + domain),
+ domain
+ )
+
+ expect(currentTab).toEqual('entry-pages')
+
+ await page.getByRole('button', { name: 'Exit pages' }).click()
+
+ await page.goto('/' + domain)
+
+ currentTab = await page.evaluate(
+ (domain) => localStorage.getItem('pageTab__' + domain),
+ domain
+ )
+
+ expect(currentTab).toEqual('exit-pages')
+})
+
+test('back navigation closes the modal', async ({ page, request, baseURL }) => {
+ const { domain } = await setupSite({ page, request })
+ await populateStats({
+ request,
+ domain,
+ events: [{ name: 'pageview' }]
+ })
+
+ await page.goto('/' + domain)
+
+ await page.getByRole('button', { name: 'Filter' }).click()
+
+ await page.getByRole('link', { name: 'Page' }).click()
+
+ await expect(page).toHaveURL(baseURL + '/' + domain + '/filter/page')
+
+ await page.goBack()
+
+ await expect(page).toHaveURL(baseURL + '/' + domain)
+})
diff --git a/e2e/tests/dashboard/segments.spec.ts b/e2e/tests/dashboard/segments.spec.ts
new file mode 100644
index 000000000000..55d59bf2f213
--- /dev/null
+++ b/e2e/tests/dashboard/segments.spec.ts
@@ -0,0 +1,404 @@
+import { test, expect } from '@playwright/test'
+import { setupSite, populateStats } from '../fixtures.ts'
+import {
+ filterButton,
+ filterItemButton,
+ applyFilterButton,
+ filterRow,
+ suggestedItem,
+ modal
+} from '../test-utils.ts'
+
+const setupSiteAndStats = async ({ page, request }) => {
+ const context = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain: context.domain,
+ events: [
+ { name: 'pageview', referrer_source: 'Google', utm_source: 'Adwords' },
+ { name: 'pageview', referrer_source: 'Facebook', utm_source: 'fb' },
+ { name: 'pageview', referrer: 'https://theguardian.com' }
+ ]
+ })
+
+ return context
+}
+
+const segmentMenu = (page) => page.getByTestId('segmentmenu')
+
+const sourceFilterButton = (page) => filterItemButton(page, 'Source')
+const utmTagsFilterButton = (page) => filterItemButton(page, 'UTM tags')
+
+const addSourceFilter = async (page, sourceLabel) => {
+ const sourceFilterRow = filterRow(page, 'source')
+ const sourceInput = page.getByPlaceholder('Select a Source')
+
+ await filterButton(page).click()
+ await sourceFilterButton(page).click()
+
+ await sourceInput.click()
+ await suggestedItem(sourceFilterRow, sourceLabel).click()
+
+ await applyFilterButton(page).click()
+
+ const url = new RegExp(`f=is,source,${sourceLabel}`)
+ await expect(page).toHaveURL(url)
+}
+
+const addUtmSourceFilter = async (page, utmSource) => {
+ const utmSourceFilterRow = filterRow(page, 'utm_source')
+ const utmSourceInput = page.getByPlaceholder('Select a UTM Source')
+
+ await filterButton(page).click()
+ await utmTagsFilterButton(page).click()
+
+ await utmSourceInput.click()
+ await suggestedItem(utmSourceFilterRow, utmSource).click()
+
+ await applyFilterButton(page).click()
+
+ const url = new RegExp(`f=is,utm_source,${utmSource}`)
+ await expect(page).toHaveURL(url)
+}
+
+const createPersonalSegment = async (page, name) => {
+ await page.getByRole('button', { name: 'See actions' }).click()
+
+ await page.getByRole('link', { name: 'Save as segment' }).click()
+
+ await modal(page).getByLabel('Segment name').fill(name)
+
+ await modal(page).getByRole('button', { name: 'Save' }).click()
+
+ await expect(page).toHaveURL(/f=is,segment,[0-9]+/)
+}
+
+test('saving a segment', async ({ page, request }) => {
+ const { domain } = await setupSiteAndStats({ page, request })
+
+ await page.goto('/' + domain)
+
+ await test.step('creating personal segment using defaults', async () => {
+ await addSourceFilter(page, 'Facebook')
+
+ await page.getByRole('button', { name: 'See actions' }).click()
+
+ await page.getByRole('link', { name: 'Save as segment' }).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Create segment' })
+ ).toBeVisible()
+
+ await expect(
+ modal(page).getByPlaceholder('Source is Facebook')
+ ).toHaveAccessibleName('Segment name')
+
+ await expect(
+ modal(page).getByRole('radio', { name: 'Personal segment' })
+ ).toBeChecked()
+
+ await modal(page).getByRole('button', { name: 'Save' }).click()
+
+ await expect(page).toHaveURL(/f=is,segment,[0-9]+/)
+
+ await expect(
+ page.getByRole('link', { name: 'Segment is Source is Facebook' })
+ ).toBeVisible()
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: Segment is Source is Facebook'
+ })
+ .click()
+
+ await filterButton(page).click()
+
+ await expect(filterItemButton(page, 'Source is Facebook')).toBeVisible()
+
+ await filterButton(page).click()
+ })
+
+ await test.step('creating a personal segment with a custom name', async () => {
+ await addSourceFilter(page, 'Google')
+
+ await page.getByRole('button', { name: 'See actions' }).click()
+
+ await page.getByRole('link', { name: 'Save as segment' }).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Create segment' })
+ ).toBeVisible()
+
+ await modal(page).getByLabel('Segment name').fill('Traffic from Google')
+
+ await expect(
+ modal(page).getByRole('radio', { name: 'Personal segment' })
+ ).toBeChecked()
+
+ await modal(page).getByRole('button', { name: 'Save' }).click()
+
+ await expect(page).toHaveURL(/f=is,segment,[0-9]+/)
+
+ await expect(
+ page.getByRole('link', { name: 'Segment is Traffic from Google' })
+ ).toBeVisible()
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: Segment is Traffic from Google'
+ })
+ .click()
+
+ await filterButton(page).click()
+
+ await expect(filterItemButton(page, 'Traffic from Google')).toBeVisible()
+ await expect(filterItemButton(page, 'Source is Facebook')).toBeVisible()
+
+ await filterButton(page).click()
+ })
+
+ await test.step('creating a site segment from more than one filter', async () => {
+ await addSourceFilter(page, 'Google')
+ await addUtmSourceFilter(page, 'Adwords')
+
+ await page.getByRole('button', { name: 'See actions' }).click()
+
+ await page.getByRole('link', { name: 'Save as segment' }).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Create segment' })
+ ).toBeVisible()
+
+ await expect(
+ modal(page).getByPlaceholder('UTM source is Adwords and Source is Google')
+ ).toHaveAccessibleName('Segment name')
+
+ await modal(page).getByLabel('Segment name').fill('Ads from Google')
+
+ const siteSegmentRadio = modal(page).getByRole('radio', {
+ name: 'Site segment'
+ })
+
+ await siteSegmentRadio.click()
+
+ await expect(siteSegmentRadio).toBeChecked()
+
+ await modal(page).getByRole('button', { name: 'Save' }).click()
+
+ await expect(page).toHaveURL(/f=is,segment,[0-9]+/)
+
+ await expect(
+ page.getByRole('link', { name: 'Segment is Ads from Google' })
+ ).toBeVisible()
+
+ await page
+ .getByRole('button', {
+ name: 'Remove filter: Segment is Ads from Google'
+ })
+ .click()
+
+ await filterButton(page).click()
+
+ await expect(filterItemButton(page, 'Ads from Google')).toBeVisible()
+ await expect(filterItemButton(page, 'Traffic from Google')).toBeVisible()
+ await expect(filterItemButton(page, 'Source is Facebook')).toBeVisible()
+
+ await filterButton(page).click()
+ })
+})
+
+test('creating a segment from a combination of segment and a filter is not allowed', async ({
+ page,
+ request
+}) => {
+ const { domain } = await setupSiteAndStats({ page, request })
+
+ await page.goto('/' + domain)
+
+ await addSourceFilter(page, 'Google')
+ await createPersonalSegment(page, 'Traffic from Google')
+ await addUtmSourceFilter(page, 'Adwords')
+
+ await expect(
+ page.getByRole('link', { name: 'UTM source is Adwords' })
+ ).toBeVisible()
+
+ await page
+ .getByRole('button', { name: 'See 1 more filter and actions' })
+ .click()
+
+ await expect(
+ page.getByRole('link', { name: 'Segment is Traffic from Google' })
+ ).toBeVisible()
+
+ await expect(page).toHaveURL(/f=is,segment,[0-9]+/)
+ await expect(page).toHaveURL(/f=is,utm_source,Adwords/)
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Create segment' })
+ ).toBeHidden()
+})
+
+test('editing an existing segment', async ({ page, request }) => {
+ const { domain } = await setupSiteAndStats({ page, request })
+
+ await page.goto('/' + domain)
+
+ await addSourceFilter(page, 'Google')
+ await createPersonalSegment(page, 'Traffic from Google')
+
+ await page
+ .getByRole('link', { name: 'Segment is Traffic from Google' })
+ .click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Traffic from Google' })
+ ).toBeVisible()
+
+ await modal(page).getByRole('link', { name: 'Edit segment' }).click()
+
+ await addUtmSourceFilter(page, 'Adwords')
+
+ await page.getByRole('link', { name: 'Update segment' }).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Update segment' })
+ ).toBeVisible()
+
+ await modal(page).getByLabel('Segment name').fill('Ads from Google')
+
+ await modal(page).getByRole('button', { name: 'Save' }).click()
+
+ await page.getByRole('link', { name: 'Segment is Ads from Google' }).click()
+
+ await expect(modal(page)).toContainText('UTM source is Adwords')
+ await expect(modal(page)).toContainText('Source is Google')
+
+ await modal(page).getByRole('button', { name: 'Remove filter' }).click()
+
+ await expect(page).not.toHaveURL(/f=is,segment,[0-9]+/)
+
+ await filterButton(page).click()
+
+ await expect(filterItemButton(page, 'Ads from Google')).toBeVisible()
+ await expect(filterItemButton(page, 'Traffic from Google')).toBeHidden()
+})
+
+test('saving edited segment as new', async ({ page, request }) => {
+ const { domain } = await setupSiteAndStats({ page, request })
+
+ await page.goto('/' + domain)
+
+ await addSourceFilter(page, 'Google')
+ await createPersonalSegment(page, 'Traffic from Google')
+
+ await page
+ .getByRole('link', { name: 'Segment is Traffic from Google' })
+ .click()
+
+ await modal(page).getByRole('link', { name: 'Edit segment' }).click()
+
+ await addUtmSourceFilter(page, 'Adwords')
+
+ await segmentMenu(page).click()
+
+ await page.getByRole('link', { name: 'Save as a new segment' }).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Create segment' })
+ ).toBeVisible()
+
+ await expect(modal(page).getByLabel('Segment name')).toHaveValue(
+ 'Copy of Traffic from Google'
+ )
+
+ await modal(page).getByLabel('Segment name').fill('Ads from Google')
+
+ await modal(page).getByRole('button', { name: 'Save' }).click()
+
+ await page.getByRole('link', { name: 'Segment is Ads from Google' }).click()
+
+ await expect(modal(page)).toContainText('UTM source is Adwords')
+ await expect(modal(page)).toContainText('Source is Google')
+
+ await modal(page).getByRole('button', { name: 'Remove filter' }).click()
+
+ await filterButton(page).click()
+
+ await expect(filterItemButton(page, 'Ads from Google')).toBeVisible()
+ await expect(filterItemButton(page, 'Traffic from Google')).toBeVisible()
+
+ await filterItemButton(page, 'Traffic from Google').click()
+
+ await page
+ .getByRole('link', { name: 'Segment is Traffic from Google' })
+ .click()
+
+ await expect(modal(page)).not.toContainText('UTM source is Adwords')
+ await expect(modal(page)).toContainText('Source is Google')
+})
+
+test('deleting segment', async ({ page, request }) => {
+ const { domain } = await setupSiteAndStats({ page, request })
+
+ await page.goto('/' + domain)
+
+ await addSourceFilter(page, 'Google')
+ await createPersonalSegment(page, 'Traffic from Google')
+
+ await page
+ .getByRole('link', { name: 'Segment is Traffic from Google' })
+ .click()
+
+ await modal(page).getByRole('link', { name: 'Edit segment' }).click()
+
+ await segmentMenu(page).click()
+
+ await page.getByRole('link', { name: 'Delete segment' }).click()
+
+ await expect(
+ modal(page).getByRole('heading', { name: 'Delete personal segment' })
+ ).toBeVisible()
+
+ await modal(page).getByRole('button', { name: 'Delete' }).click()
+
+ await filterButton(page).click()
+
+ await expect(filterItemButton(page, 'Traffic from Google')).toBeHidden()
+})
+
+test('closing edited segment without saving', async ({
+ page,
+ request
+}) => {
+ const { domain } = await setupSiteAndStats({ page, request })
+
+ await page.goto('/' + domain)
+
+ await addSourceFilter(page, 'Google')
+ await createPersonalSegment(page, 'Traffic from Google')
+
+ await page
+ .getByRole('link', { name: 'Segment is Traffic from Google' })
+ .click()
+
+ await modal(page).getByRole('link', { name: 'Edit segment' }).click()
+
+ await addUtmSourceFilter(page, 'Adwords')
+
+ await segmentMenu(page).click()
+
+ await page.getByRole('link', { name: 'Close without saving' }).click()
+
+ await filterButton(page).click()
+
+ await filterItemButton(page, 'Traffic from Google').click()
+
+ await page
+ .getByRole('link', { name: 'Segment is Traffic from Google' })
+ .click()
+
+ await expect(modal(page)).not.toContainText('UTM source is Adwords')
+ await expect(modal(page)).toContainText('Source is Google')
+})
diff --git a/e2e/tests/dashboard/top-stats.spec.ts b/e2e/tests/dashboard/top-stats.spec.ts
new file mode 100644
index 000000000000..97431320310c
--- /dev/null
+++ b/e2e/tests/dashboard/top-stats.spec.ts
@@ -0,0 +1,438 @@
+import { test, expect } from '@playwright/test'
+import {
+ ZonedDateTime,
+ ZoneOffset,
+ ChronoUnit,
+ DateTimeFormatter
+} from '@js-joda/core'
+import { Locale } from '@js-joda/locale'
+import { setupSite, populateStats } from '../fixtures.ts'
+
+function timeToISO(ts: ZonedDateTime): string {
+ return ts.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
+}
+
+test('site switcher allows switching between different sites', async ({
+ page,
+ request
+}) => {
+ const { domain: domain1, user } = await setupSite({ page, request })
+ const { domain: domain2 } = await setupSite({ page, request, user })
+ const { domain: domain3 } = await setupSite({ page, request, user })
+
+ await populateStats({
+ request,
+ domain: domain1,
+ events: [{ name: 'pageview' }]
+ })
+ await populateStats({
+ request,
+ domain: domain2,
+ events: [{ name: 'pageview' }]
+ })
+ await populateStats({
+ request,
+ domain: domain3,
+ events: [{ name: 'pageview' }]
+ })
+
+ await page.goto('/' + domain1)
+
+ const switcherButton = page.getByTestId('site-switcher-current-site')
+
+ await expect(switcherButton).toHaveText(domain1)
+
+ await switcherButton.click()
+
+ await expect(page.getByRole('link', { name: domain1 })).toBeVisible()
+ await expect(page.getByRole('link', { name: domain2 })).toBeVisible()
+ await expect(page.getByRole('link', { name: domain3 })).toBeVisible()
+
+ await page.getByRole('link', { name: domain2 }).click()
+
+ await expect(page).toHaveURL(`/${domain2}`)
+ await expect(switcherButton).toHaveText(domain2)
+
+ const sortedDomains = [domain1, domain2, domain3].sort()
+
+ await page.keyboard.press('3')
+
+ await expect(page).toHaveURL(`/${sortedDomains[2]}`)
+ await expect(switcherButton).toHaveText(sortedDomains[2])
+
+ await page.keyboard.press('1')
+
+ await expect(page).toHaveURL(`/${sortedDomains[0]}`)
+ await expect(switcherButton).toHaveText(sortedDomains[0])
+})
+
+test('current visitors counter shows number of active visitors', async ({
+ page,
+ request
+}) => {
+ const { domain } = await setupSite({ page, request })
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', timestamp: { minutesAgo: 2 } },
+ { name: 'pageview', timestamp: { minutesAgo: 3 } },
+ { name: 'pageview', timestamp: { minutesAgo: 4 } },
+ { name: 'pageview', timestamp: { minutesAgo: 4 } },
+ { name: 'pageview', timestamp: { minutesAgo: 20 } },
+ { name: 'pageview', timestamp: { minutesAgo: 50 } }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ await expect(page.getByText('4 current visitors')).toBeVisible()
+})
+
+test('top stats show relevant metrics', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+ await populateStats({
+ request,
+ domain,
+ events: [
+ {
+ user_id: 123,
+ name: 'pageview',
+ pathname: '/',
+ timestamp: { minutesAgo: 120 }
+ },
+ {
+ user_id: 123,
+ name: 'pageview',
+ pathname: '/',
+ timestamp: { minutesAgo: 60 }
+ },
+ {
+ user_id: 123,
+ name: 'pageview',
+ pathname: '/page1',
+ timestamp: { minutesAgo: 50 }
+ },
+ {
+ user_id: 456,
+ name: 'pageview',
+ pathname: '/',
+ timestamp: { minutesAgo: 80 }
+ }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ await expect(page).toHaveTitle(/Plausible/)
+
+ await expect(page.getByRole('button', { name: domain })).toBeVisible()
+
+ await expect(page.locator('#visitors')).toHaveText('2')
+ await expect(page.locator('#visits')).toHaveText('3')
+ await expect(page.locator('#pageviews')).toHaveText('4')
+ await expect(page.locator('#views_per_visit')).toHaveText('1.33')
+ await expect(page.locator('#bounce_rate')).toHaveText('67%')
+ await expect(page.locator('#visit_duration')).toHaveText('3m 20s')
+})
+
+test('different time ranges are supported', async ({ page, request }) => {
+ const now = ZonedDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS)
+ const startOfDay = now.truncatedTo(ChronoUnit.DAYS)
+ const startOfYesterday = startOfDay.minusDays(1)
+ const startOfMonth = startOfDay.withDayOfMonth(1)
+ const startOfLastMonth = startOfMonth.minusMonths(1)
+ const startOfYear = now.withDayOfYear(1)
+
+ const expectedCounts = [
+ { from: startOfDay, to: now, key: 'd', value: 0 },
+ { from: startOfYesterday, to: startOfDay, key: 'e', value: 0 },
+ { from: now.minusMinutes(30), to: now, key: 'r', value: 0 },
+ { from: now.minusHours(24), to: now, key: 'h', value: 0 },
+ { from: startOfDay.minusDays(7), to: startOfDay, key: 'w', value: 0 },
+ { from: startOfDay.minusDays(28), to: startOfDay, key: 'f', value: 0 },
+ { from: startOfDay.minusDays(91), to: startOfDay, key: 'n', value: 0 },
+ { from: startOfMonth, to: now, key: 'm', value: 0 },
+ { from: startOfLastMonth, to: startOfMonth, key: 'p', value: 0 },
+ { from: startOfYear, to: now, key: 'y', value: 0 },
+ { from: startOfMonth.minusMonths(12), to: startOfMonth, key: 'l', value: 0 }
+ ]
+
+ const eventTimes = [
+ now.minusMinutes(20),
+ now.minusHours(12),
+ now.minusHours(26),
+ now.minusHours(30),
+ now.minusHours(35),
+ now.minusDays(5),
+ now.minusDays(17),
+ now.minusDays(54),
+ now.minusDays(120),
+ now.minusDays(720)
+ ]
+
+ const events = []
+
+ eventTimes.forEach((ts, idx) => {
+ expectedCounts.forEach((expected) => {
+ if (ts.compareTo(expected.from) >= 0 && ts.compareTo(expected.to) < 0) {
+ expected.value += 1
+ }
+ })
+
+ events.push({
+ user_id: idx + 1,
+ name: 'pageview',
+ timestamp: timeToISO(ts)
+ })
+ })
+
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({ request, domain, events })
+
+ await page.goto('/' + domain)
+ await expect(page.getByRole('button', { name: domain })).toBeVisible()
+
+ await expect(page.getByTestId('current-query-period')).toHaveText(
+ 'Last 28 days'
+ )
+
+ const visitors = page.locator('#visitors')
+
+ for (const expected of expectedCounts) {
+ await page.keyboard.press(expected.key)
+ await expect(visitors).toHaveText(`${expected.value}`)
+ }
+
+ // Realtime
+ await page.keyboard.press('r')
+ await expect(visitors).toHaveText('1')
+
+ // All time
+ await page.keyboard.press('a')
+ await expect(visitors).toHaveText(`${events.length}`)
+})
+
+test('different graph time intervals are available', async ({
+ page,
+ request
+}) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', timestamp: { minutesAgo: 60 } },
+ { name: 'pageview', timestamp: { daysAgo: 5 } }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ await expect(page.getByTestId('current-query-period')).toHaveText(
+ 'Last 28 days'
+ )
+
+ const intervalButton = page.getByTestId('current-graph-interval')
+ const intervalOptions = page.getByTestId('graph-interval')
+ await expect(intervalButton).toHaveText('Days')
+ await intervalButton.click()
+ const intervalOptions28Days = await intervalOptions.allTextContents()
+
+ expect(intervalOptions28Days.indexOf('Days') > -1).toBeTruthy()
+ expect(intervalOptions28Days.indexOf('Weeks') > -1).toBeTruthy()
+
+ await page.getByTestId('current-query-period').click()
+ await page
+ .getByTestId('query-period-picker')
+ .getByRole('link', { name: 'Today' })
+ .click()
+
+ await expect(intervalButton).toHaveText('Hours')
+ await intervalButton.click()
+ // The popover does not appear right away
+ await expect(intervalOptions).toHaveCount(2)
+ const intervalOptionsToday = await intervalOptions.allTextContents()
+
+ expect(intervalOptionsToday.indexOf('Hours') > -1).toBeTruthy()
+ expect(intervalOptionsToday.indexOf('Minutes') > -1).toBeTruthy()
+})
+
+test('navigating dates previous next time periods', async ({
+ page,
+ request
+}) => {
+ const { domain } = await setupSite({ page, request })
+
+ const now = ZonedDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS)
+ const startOfDay = now.truncatedTo(ChronoUnit.DAYS)
+ const startOfYesterday = startOfDay.minusDays(1)
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', timestamp: timeToISO(now) },
+ { name: 'pageview', timestamp: timeToISO(startOfDay.minusHours(3)) },
+ { name: 'pageview', timestamp: timeToISO(startOfDay.minusHours(4)) },
+ {
+ name: 'pageview',
+ timestamp: timeToISO(startOfYesterday.minusHours(3))
+ },
+ {
+ name: 'pageview',
+ timestamp: timeToISO(startOfYesterday.minusHours(4))
+ },
+ {
+ name: 'pageview',
+ timestamp: timeToISO(startOfYesterday.minusHours(5))
+ }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const currentQueryPeriod = page.getByTestId('current-query-period')
+ const queryPeriodPicker = page.getByTestId('query-period-picker')
+ const backButton = queryPeriodPicker.getByTestId('period-move-back')
+ const forwardButton = queryPeriodPicker.getByTestId('period-move-forward')
+ const visitors = page.locator('#visitors')
+
+ await currentQueryPeriod.click()
+ await queryPeriodPicker.getByRole('link', { name: 'Today' }).click()
+
+ await expect(currentQueryPeriod).toHaveText('Today')
+ await expect(visitors).toHaveText('1')
+ await expect(backButton).not.toHaveCSS('cursor', 'not-allowed')
+ await expect(forwardButton).toHaveCSS('cursor', 'not-allowed')
+
+ await backButton.click()
+
+ const yesterdayLabel = startOfYesterday.format(
+ DateTimeFormatter.ofPattern('EEE, dd MMM').withLocale(Locale.ENGLISH)
+ )
+
+ await expect(currentQueryPeriod).toHaveText(yesterdayLabel)
+ await expect(backButton).not.toHaveCSS('cursor', 'not-allowed')
+ await expect(forwardButton).not.toHaveCSS('cursor', 'not-allowed')
+ await expect(visitors).toHaveText('2')
+
+ await backButton.click()
+
+ const beforeYesterdayLabel = startOfYesterday
+ .minusDays(1)
+ .format(
+ DateTimeFormatter.ofPattern('EEE, dd MMM').withLocale(Locale.ENGLISH)
+ )
+
+ await expect(currentQueryPeriod).toHaveText(beforeYesterdayLabel)
+ await expect(backButton).toHaveCSS('cursor', 'not-allowed')
+ await expect(forwardButton).not.toHaveCSS('cursor', 'not-allowed')
+ await expect(visitors).toHaveText('3')
+
+ await forwardButton.click()
+
+ await expect(currentQueryPeriod).toHaveText(yesterdayLabel)
+ await expect(backButton).not.toHaveCSS('cursor', 'not-allowed')
+ await expect(forwardButton).not.toHaveCSS('cursor', 'not-allowed')
+ await expect(visitors).toHaveText('2')
+
+ await forwardButton.click()
+
+ await expect(currentQueryPeriod).toHaveText('Today')
+ await expect(backButton).not.toHaveCSS('cursor', 'not-allowed')
+ await expect(forwardButton).toHaveCSS('cursor', 'not-allowed')
+ await expect(visitors).toHaveText('1')
+})
+
+test('selecting a custom date range', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ // NOTE: As the calendar renders contents dynamically, we cannot tell for sure
+ // whether the day before today will be visible without switching month.
+ // To make things simpler, we only test a single-day range of today.
+
+ const now = ZonedDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS)
+ const startOfDay = now.truncatedTo(ChronoUnit.DAYS)
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', timestamp: timeToISO(now) },
+ { name: 'pageview', timestamp: timeToISO(startOfDay.minusDays(3)) }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ const currentQueryPeriod = page.getByTestId('current-query-period')
+ const queryPeriodPicker = page.getByTestId('query-period-picker')
+ const visitors = page.locator('#visitors')
+
+ currentQueryPeriod.click()
+ await queryPeriodPicker.getByRole('link', { name: 'Custom range' }).click()
+
+ const todayLabel = startOfDay.format(
+ DateTimeFormatter.ofPattern('MMMM d, YYYY').withLocale(Locale.ENGLISH)
+ )
+
+ await page.getByLabel(todayLabel).click()
+ await page.getByLabel(todayLabel).click()
+
+ await expect(currentQueryPeriod).toHaveText('Today')
+ await expect(visitors).toHaveText('1')
+})
+
+test('comparing stats over time is supported', async ({ page, request }) => {
+ const { domain } = await setupSite({ page, request })
+
+ await populateStats({
+ request,
+ domain,
+ events: [
+ { name: 'pageview', timestamp: { daysAgo: 2 } },
+ { name: 'pageview', timestamp: { daysAgo: 4 } },
+ { name: 'pageview', timestamp: { daysAgo: 30 } },
+ { name: 'pageview', timestamp: { daysAgo: 30 } },
+ { name: 'pageview', timestamp: { daysAgo: 31 } },
+ { name: 'pageview', timestamp: { daysAgo: 370 } }
+ ]
+ })
+
+ await page.goto('/' + domain)
+
+ await expect(page.getByTestId('current-query-period')).toHaveText(
+ 'Last 28 days'
+ )
+
+ await page.getByTestId('query-period-picker').click()
+ await page
+ .getByTestId('query-period-picker')
+ .getByRole('link', { name: 'Compare' })
+ .click()
+
+ const previousPeriodButton = page.getByRole('button', {
+ name: 'Previous period'
+ })
+
+ await expect(previousPeriodButton).toBeVisible()
+
+ const visitors = page.locator('#visitors')
+ const previousVisitors = page.locator('#previous-visitors')
+
+ await expect(visitors).toHaveText('2')
+ await expect(previousVisitors).toHaveText('3')
+
+ await previousPeriodButton.click()
+ await page.getByRole('link', { name: 'Year over year' }).click()
+
+ await expect(
+ page.getByRole('button', { name: 'Year over year' })
+ ).toBeVisible()
+
+ await expect(visitors).toHaveText('2')
+ await expect(previousVisitors).toHaveText('1')
+})
diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts
index c1c23ddf93d8..82ae54fab1b4 100644
--- a/e2e/tests/fixtures.ts
+++ b/e2e/tests/fixtures.ts
@@ -15,7 +15,26 @@ type EventTimestamp =
type Event = {
name: string
+ user_id?: number
+ scroll_depth?: number
+ revenue_reporting_amount?: string
+ revenue_reporting_currency?: string
pathname?: string
+ hostname?: string
+ referrer_source?: string
+ referrer?: string
+ utm_medium?: string
+ utm_source?: string
+ utm_campaign?: string
+ utm_term?: string
+ utm_content?: string
+ screen_size?: string
+ browser?: string
+ browser_version?: string
+ operating_system?: string
+ operating_system_version?: string
+ 'meta.key'?: string[]
+ 'meta.value'?: string[]
timestamp?: EventTimestamp
}
@@ -152,24 +171,268 @@ export async function populateStats({
expect(response.ok()).toBeTruthy()
}
+export async function addCustomGoal({
+ page,
+ domain,
+ name,
+ displayName,
+ currency,
+ // Useful when adding a goal for which there's no matching stat yet
+ clickManually = true
+}: {
+ page: Page
+ domain: string
+ name: string
+ displayName?: string
+ currency?: string
+ clickManually?: boolean
+}) {
+ await page.goto(`/${domain}/settings/goals`)
+
+ await expectLiveViewConnected(page)
+
+ await page.getByRole('button', { name: 'Add goal' }).click()
+ const customEventButton = page.locator(
+ 'button[phx-value-goal-type="custom_events"]'
+ )
+
+ await customEventButton.click()
+
+ if (clickManually) {
+ await page.getByRole('button', { name: 'Add manually' }).click()
+ }
+
+ await expect(
+ page.getByRole('heading', { name: `Add goal for ${domain}` })
+ ).toBeVisible()
+ // NOTE: Locating inputs by role and label does not work in this case
+ // for some reason.
+ const nameInput = page.locator('input[placeholder="e.g. Signup"]')
+ await nameInput.fill(name)
+ await page.locator(`a[data-display-value="${name}"]`).click()
+ await expect(nameInput).toHaveAttribute('value', name)
+
+ if (displayName) {
+ await page
+ .locator('input#custom_event_display_name_input')
+ .fill(displayName)
+ }
+
+ if (currency) {
+ page.locator('button[aria-labelledby="enable-revenue-tracking"]').click()
+ const currencyInput = page.locator('input[id^=currency_input_]')
+ await currencyInput.fill(currency)
+ await page.locator(`a[phx-value-submit-value="${currency}"]`).click()
+ await expect(page.locator('input[name="goal[currency]"]')).toHaveAttribute(
+ 'value',
+ currency
+ )
+ }
+
+ await page
+ .locator('form[phx-submit="save-goal"]')
+ .getByRole('button', { name: 'Add goal' })
+ .click()
+
+ await expect(page.locator('body')).toContainText('Goal saved successfully')
+}
+
+export async function addPageviewGoal({
+ page,
+ domain,
+ pathname,
+ displayName
+}: {
+ page: Page
+ domain: string
+ pathname: string
+ displayName?: string
+}) {
+ await page.goto(`/${domain}/settings/goals`)
+
+ await expectLiveViewConnected(page)
+
+ await page.getByRole('button', { name: 'Add goal' }).click()
+ const pageviewEventButton = page.locator(
+ 'button[phx-value-goal-type="pageviews"]'
+ )
+
+ await pageviewEventButton.click()
+
+ await expect(
+ page.getByRole('heading', { name: `Add goal for ${domain}` })
+ ).toBeVisible()
+
+ const pathnameInput = page.locator('input[id^="page_path_input"]')
+ await pathnameInput.fill(pathname)
+ await page.locator(`a[data-display-value="${pathname}"]`).click()
+ await expect(pathnameInput).toHaveAttribute('value', pathname)
+ if (displayName) {
+ await page.locator('input#pageview_display_name_input').fill(displayName)
+ }
+
+ await page
+ .locator('form[phx-submit="save-goal"]')
+ .getByRole('button', { name: 'Add goal' })
+ .click()
+
+ await expect(page.locator('body')).toContainText('Goal saved successfully')
+}
+
+export async function addScrollDepthGoal({
+ page,
+ domain,
+ pathname,
+ displayName,
+ scrollPercentage
+}: {
+ page: Page
+ domain: string
+ pathname: string
+ displayName?: string
+ scrollPercentage?: number
+}) {
+ await page.goto(`/${domain}/settings/goals`)
+
+ await expectLiveViewConnected(page)
+
+ await page.getByRole('button', { name: 'Add goal' }).click()
+ const scrollDepthEventButton = page.locator(
+ 'button[phx-value-goal-type="scroll"]'
+ )
+
+ await scrollDepthEventButton.click()
+
+ await expect(
+ page.getByRole('heading', { name: `Add goal for ${domain}` })
+ ).toBeVisible()
+
+ if (scrollPercentage) {
+ await page
+ .locator('input[name="goal[scroll_threshold]"]')
+ .fill(scrollPercentage.toString())
+ }
+
+ const pathnameInput = page.locator('input[id^="scroll_page_path_input"]')
+ await pathnameInput.fill(pathname)
+ await page.locator(`a[data-display-value="${pathname}"]`).click()
+ await expect(pathnameInput).toHaveAttribute('value', pathname)
+
+ if (displayName) {
+ await page.locator('input#scroll_display_name_input').fill(displayName)
+ }
+
+ await page
+ .locator('form[phx-submit="save-goal"]')
+ .getByRole('button', { name: 'Add goal' })
+ .click()
+
+ await expect(page.locator('body')).toContainText('Goal saved successfully')
+}
+
+export async function addCustomProp({
+ page,
+ domain,
+ name
+}: {
+ page: Page
+ domain: string
+ name: string
+}) {
+ await page.goto(`/${domain}/settings/properties`)
+
+ await expectLiveViewConnected(page)
+
+ await page.getByRole('button', { name: 'Add property' }).click()
+
+ await expect(
+ page.getByRole('heading', { name: `Add property for ${domain}` })
+ ).toBeVisible()
+
+ const propInput = page.locator('input#prop_input')
+ propInput.fill(name)
+ await page.locator(`a[data-display-value="${name}"]`).click()
+ await expect(propInput).toHaveAttribute('value', name)
+
+ await page
+ .locator('form[phx-submit="allow-prop"]')
+ .getByRole('button', { name: 'Add property' })
+ .click()
+
+ await expect(page.locator('body')).toContainText(
+ 'Property added successfully'
+ )
+}
+
+export async function addAllCustomProps({
+ page,
+ domain
+}: {
+ page: Page
+ domain: string
+}) {
+ await page.goto(`/${domain}/settings/properties`)
+
+ await expectLiveViewConnected(page)
+
+ await page.getByRole('button', { name: 'Add property' }).click()
+
+ await expect(
+ page.getByRole('heading', { name: `Add property for ${domain}` })
+ ).toBeVisible()
+
+ await page.getByText(/Click to add [0-9]+ existing properties/).click()
+
+ await expect(page.locator('body')).toContainText(
+ 'Properties added successfully'
+ )
+}
+
+export async function addFunnel({
+ request,
+ domain,
+ name,
+ steps
+}: {
+ request: Request
+ domain: string
+ name: string
+ steps: string[]
+}) {
+ const response = await request.post('/e2e-tests/funnel', {
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json'
+ },
+ data: { domain: domain, name: name, steps: steps }
+ })
+
+ expect(response.ok()).toBeTruthy()
+}
+
export async function setupSite({
+ user,
page,
request
}: {
+ user?: User
page: Page
request: Request
}): { domain: string; user: user } {
const domain = `${randomID()}.example.com`
- const userID = randomID()
+ if (!user) {
+ const userID = randomID()
+
+ user = {
+ name: `User ${userID}`,
+ email: `email-${userID}@example.com`,
+ password: 'VeryStrongVerySecret'
+ }
- const user: User = {
- name: `User ${userID}`,
- email: `email-${userID}@example.com`,
- password: 'VeryStrongVerySecret'
+ await register({ page, request, user })
}
- await register({ page, request, user })
await addSite({ page, domain })
return { domain, user }
diff --git a/e2e/tests/general.spec.ts b/e2e/tests/general.spec.ts
deleted file mode 100644
index 7aee23d46b1d..000000000000
--- a/e2e/tests/general.spec.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import { test, expect } from '@playwright/test'
-import { setupSite, logout, makeSitePublic, populateStats } from './fixtures.ts'
-
-test('dashboard renders for logged in user', async ({ page, request }) => {
- const { domain } = await setupSite({ page, request })
- await populateStats({ request, domain, events: [{ name: 'pageview' }] })
-
- await page.goto('/' + domain)
-
- await expect(page).toHaveTitle(/Plausible/)
-
- await expect(page.getByRole('button', { name: domain })).toBeVisible()
-})
-
-test('dashboard renders for anonymous viewer', async ({ page, request }) => {
- const { domain } = await setupSite({ page, request })
- await makeSitePublic({ page, domain })
- await populateStats({ request, domain, events: [{ name: 'pageview' }] })
- await logout(page)
-
- await page.goto('/' + domain)
-
- await expect(page).toHaveTitle(/Plausible/)
-
- await expect(page.getByRole('button', { name: domain })).toBeVisible()
-})
-
-test('filter is applied', async ({ page, request, baseURL }) => {
- const { domain } = await setupSite({ page, request })
- await populateStats({
- request,
- domain,
- events: [
- { name: 'pageview', pathname: '/page1' },
- { name: 'pageview', pathname: '/page2' },
- { name: 'pageview', pathname: '/page3' },
- { name: 'pageview', pathname: '/other' }
- ]
- })
-
- await page.goto('/' + domain)
-
- await expect(page.getByRole('link', { name: 'Page' })).toBeHidden()
-
- await page.getByRole('button', { name: 'Filter' }).click()
-
- await expect(page.getByRole('link', { name: 'Page' })).toHaveCount(1)
-
- await page.getByRole('link', { name: 'Page' }).click()
-
- await expect(page).toHaveURL(baseURL + '/' + domain + '/filter/page')
-
- await expect(
- page.getByRole('heading', { name: 'Filter by Page' })
- ).toBeVisible()
-
- await expect(
- page.getByRole('button', { name: 'Apply filter', disabled: true })
- ).toHaveCount(1)
-
- await page.getByPlaceholder('Select a Page').click()
-
- await expect(
- page.getByRole('button', { name: 'Apply filter', disabled: true })
- ).toHaveCount(1)
-
- await expect(
- page.getByRole('listitem').filter({ hasText: '/page1' })
- ).toBeVisible()
-
- await expect(
- page.getByRole('listitem').filter({ hasText: '/page2' })
- ).toBeVisible()
-
- await expect(
- page.getByRole('listitem').filter({ hasText: '/page3' })
- ).toBeVisible()
-
- await expect(
- page.getByRole('listitem').filter({ hasText: '/other' })
- ).toBeVisible()
-
- await page.getByPlaceholder('Select a Page').fill('pag')
-
- await expect(
- page.getByRole('listitem').filter({ hasText: '/page1' })
- ).toBeVisible()
-
- await expect(
- page.getByRole('listitem').filter({ hasText: '/page2' })
- ).toBeVisible()
-
- await expect(
- page.getByRole('listitem').filter({ hasText: '/page3' })
- ).toBeVisible()
-
- await expect(
- page.getByRole('listitem').filter({ hasText: '/other' })
- ).toBeHidden()
-
- await page.getByRole('listitem').filter({ hasText: '/page1' }).click()
-
- await expect(
- page.getByRole('button', { name: 'Apply filter', disabled: false })
- ).toHaveCount(1)
-
- await page.getByRole('button', { name: 'Apply filter' }).click()
-
- await expect(page).toHaveURL(baseURL + '/' + domain + '?f=is,page,/page1')
-
- await expect(
- page.getByRole('link', { name: 'Page is /page1' })
- ).toHaveAttribute('title', 'Edit filter: Page is /page1')
-})
-
-test('tab selection user preferences are preserved across reloads', async ({
- page,
- request
-}) => {
- const { domain } = await setupSite({ page, request })
- await populateStats({ request, domain, events: [{ name: 'pageview' }] })
-
- await page.goto('/' + domain)
-
- await page.getByRole('button', { name: 'Entry pages' }).click()
-
- await page.goto('/' + domain)
-
- let currentTab = await page.evaluate(
- (domain) => localStorage.getItem('pageTab__' + domain),
- domain
- )
-
- expect(currentTab).toEqual('entry-pages')
-
- await page.getByRole('button', { name: 'Exit pages' }).click()
-
- await page.goto('/' + domain)
-
- currentTab = await page.evaluate(
- (domain) => localStorage.getItem('pageTab__' + domain),
- domain
- )
-
- expect(currentTab).toEqual('exit-pages')
-})
-
-test('back navigation closes the modal', async ({ page, request, baseURL }) => {
- const { domain } = await setupSite({ page, request })
- await populateStats({
- request,
- domain,
- events: [{ name: 'pageview' }]
- })
-
- await page.goto('/' + domain)
-
- await page.getByRole('button', { name: 'Filter' }).click()
-
- await page.getByRole('link', { name: 'Page' }).click()
-
- await expect(page).toHaveURL(baseURL + '/' + domain + '/filter/page')
-
- await page.goBack()
-
- await expect(page).toHaveURL(baseURL + '/' + domain)
-})
diff --git a/e2e/tests/test-utils.ts b/e2e/tests/test-utils.ts
index 8f889def216f..347283d2e7a0 100644
--- a/e2e/tests/test-utils.ts
+++ b/e2e/tests/test-utils.ts
@@ -8,3 +8,63 @@ export async function expectLiveViewConnected(page: Page) {
export function randomID() {
return Math.random().toString(16).slice(2)
}
+
+export const tabButton = (page, label) =>
+ page.getByTestId('tab-button').filter({ hasText: label })
+
+export const header = (report, label) =>
+ report
+ .getByTestId('report-header')
+ .filter({ hasText: label })
+ .getByRole('button')
+
+export const expectHeaders = async (report, headers) =>
+ expect(report.getByTestId('report-header')).toHaveText(headers)
+
+export const expectRows = async (report, labels) =>
+ expect(report.getByTestId('report-row').getByRole('link')).toHaveText(labels)
+
+export const rowLink = (report, label) =>
+ report.getByTestId('report-row').filter({ hasText: label }).getByRole('link')
+
+export const expectMetricValues = async (report, label, values) =>
+ expect(
+ report
+ .getByTestId('report-row')
+ .filter({ hasText: label })
+ .getByTestId('metric-value')
+ ).toHaveText(values)
+
+export const dropdown = (report) => report.getByTestId('dropdown-items')
+
+export const searchInput = (report) => report.getByTestId('search-input')
+
+export const modal = (page) => page.locator('.modal')
+
+export const detailsLink = (report) =>
+ report.getByRole('link', { name: 'View details' })
+
+export const closeModalButton = (page) =>
+ page.getByRole('button', { name: 'Close modal' })
+
+export const filterButton = (page) =>
+ page.getByRole('button', { name: 'Filter', exact: true })
+
+export const filterItemButton = (page, label) =>
+ page.getByTestId('filtermenu').getByRole('link', { name: label, exact: true })
+
+export const applyFilterButton = (page, { disabled = false } = {}) =>
+ page.getByRole('button', {
+ name: 'Apply filter',
+ disabled
+ })
+
+export const filterRow = (page, key) => page.getByTestId(`filter-row-${key}`)
+
+export const suggestedItem = (scoped, url) =>
+ scoped.getByRole('listitem').filter({ hasText: url })
+
+export const filterOperator = (scoped) => scoped.getByTestId('filter-operator')
+
+export const filterOperatorOption = (scoped, option) =>
+ scoped.getByTestId('filter-operator-option').filter({ hasText: option })
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index a57f4fb77dc6..d3274434dab3 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -174,11 +174,14 @@ defmodule PlausibleWeb.Router do
end
# Routes for E2E testing
- if Mix.env() in [:test, :ce_test, :e2e_test] do
- scope "/e2e-tests", PlausibleWeb do
- pipe_through :api
+ on_ee do
+ if Mix.env() == :e2e_test do
+ scope "/e2e-tests", PlausibleWeb do
+ pipe_through :api
- post "/stats", E2EController, :populate_stats
+ post "/stats", E2EController, :populate_stats
+ post "/funnel", E2EController, :create_funnel
+ end
end
end
diff --git a/test/support/dev/controllers/e2e_controller.ex b/test/support/dev/controllers/e2e_controller.ex
index b0246f4ab8b6..0424fd2feaba 100644
--- a/test/support/dev/controllers/e2e_controller.ex
+++ b/test/support/dev/controllers/e2e_controller.ex
@@ -1,65 +1,92 @@
defmodule PlausibleWeb.E2EController do
use Plausible
- use PlausibleWeb, :controller
+ on_ee do
+ use PlausibleWeb, :controller
- def populate_stats(conn, %{"domain" => domain, "events" => events}) do
- site = Plausible.Repo.get_by!(Plausible.Site, domain: domain)
+ def populate_stats(conn, %{"domain" => domain, "events" => events}) do
+ site = Plausible.Repo.get_by!(Plausible.Site, domain: domain)
- events =
- events
- |> Enum.map(&deserialize/1)
- |> Enum.map(&build/1)
+ events =
+ events
+ |> Enum.map(&deserialize/1)
+ |> Enum.map(&build/1)
- stats_start_time = Enum.min_by(events, & &1.timestamp).timestamp
- stats_start_date = NaiveDateTime.to_date(stats_start_time)
+ stats_start_time = Enum.min_by(events, & &1.timestamp, NaiveDateTime).timestamp
+ stats_start_date = NaiveDateTime.to_date(stats_start_time)
- site
- |> Plausible.Site.set_native_stats_start_at(stats_start_time)
- |> Plausible.Site.set_stats_start_date(stats_start_date)
- |> Plausible.Repo.update!()
+ site
+ |> Plausible.Site.set_native_stats_start_at(stats_start_time)
+ |> Plausible.Site.set_stats_start_date(stats_start_date)
+ |> Plausible.Repo.update!()
- populate(events, site)
+ populate(events, site)
- send_resp(conn, 200, Jason.encode!(%{"ok" => true}))
- end
+ send_resp(conn, 200, Jason.encode!(%{"ok" => true}))
+ end
- defp deserialize(event) do
- Enum.map(event, fn
- {"timestamp", value} ->
- {:timestamp, to_timestamp(value)}
+ def create_funnel(conn, %{"domain" => domain, "name" => name, "steps" => steps}) do
+ site = Plausible.Repo.get_by!(Plausible.Site, domain: domain)
- {key, value} ->
- {String.to_existing_atom(key), value}
- end)
- end
+ steps =
+ Enum.map(steps, fn step ->
+ goal = get_goal(site, step)
+ %{"goal_id" => goal.id}
+ end)
- defp build(attrs) do
- timestamp = NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-48, :hour)
+ {:ok, _} = Plausible.Funnels.create(site, name, steps)
- attrs =
- if attrs[:timestamp] do
- attrs
- else
- Keyword.put(attrs, :timestamp, timestamp)
- end
+ send_resp(conn, 200, Jason.encode!(%{"ok" => true}))
+ end
- Plausible.Factory.build(:event, attrs)
- end
+ defp get_goal(site, name) do
+ Plausible.Repo.get_by!(Plausible.Goal, site_id: site.id, display_name: name)
+ end
- defp populate(events, site) do
- Plausible.TestUtils.populate_stats(site, events)
- end
+ defp deserialize(event) do
+ Enum.map(event, fn
+ {"timestamp", value} ->
+ {:timestamp, to_timestamp(value)}
- defp to_timestamp(%{"daysAgo" => offset}) do
- NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-offset, :day)
- end
+ {"revenue_reporting_amount", value} ->
+ {:revenue_reporting_amount, Decimal.new(value)}
- defp to_timestamp(%{"hoursAgo" => offset}) do
- NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-offset, :hour)
- end
+ {key, value} ->
+ {String.to_existing_atom(key), value}
+ end)
+ end
+
+ defp build(attrs) do
+ timestamp = NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-48, :hour)
+
+ attrs =
+ if attrs[:timestamp] do
+ attrs
+ else
+ Keyword.put(attrs, :timestamp, timestamp)
+ end
+
+ Plausible.Factory.build(:event, attrs)
+ end
+
+ defp populate(events, site) do
+ Plausible.TestUtils.populate_stats(site, events)
+ end
+
+ defp to_timestamp(%{"daysAgo" => offset}) do
+ NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-offset, :day)
+ end
+
+ defp to_timestamp(%{"hoursAgo" => offset}) do
+ NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-offset, :hour)
+ end
+
+ defp to_timestamp(%{"minutesAgo" => offset}) do
+ NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-offset, :minute)
+ end
- defp to_timestamp(%{"minutesAgo" => offset}) do
- NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-offset, :minute)
+ defp to_timestamp(ts) when is_binary(ts) do
+ NaiveDateTime.from_iso8601!(ts)
+ end
end
end