From e6f6673dca14e8f116fcf454983b588f6583e426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Fri, 11 Apr 2025 13:10:31 +0200 Subject: [PATCH 001/618] escape team name in CRM (#5321) --- lib/plausible/auth/user_admin.ex | 8 +++++++- lib/plausible_web/controllers/admin_controller.ex | 14 +++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/plausible/auth/user_admin.ex b/lib/plausible/auth/user_admin.ex index 82437178defb..c71b5e65f059 100644 --- a/lib/plausible/auth/user_admin.ex +++ b/lib/plausible/auth/user_admin.ex @@ -62,7 +62,7 @@ defmodule Plausible.Auth.UserAdmin do teams |> Enum.map_join("
\n", fn team -> """ - #{team.name} + #{html_escape(team.name)} """ end) end @@ -72,4 +72,10 @@ defmodule Plausible.Auth.UserAdmin do defp format_date(date) do Calendar.strftime(date, "%b %-d, %Y") end + + def html_escape(string) do + string + |> Phoenix.HTML.html_escape() + |> Phoenix.HTML.safe_to_string() + end end diff --git a/lib/plausible_web/controllers/admin_controller.ex b/lib/plausible_web/controllers/admin_controller.ex index 01b353ac665c..2ae12033eff2 100644 --- a/lib/plausible_web/controllers/admin_controller.ex +++ b/lib/plausible_web/controllers/admin_controller.ex @@ -99,10 +99,10 @@ defmodule PlausibleWeb.AdminController do select: fragment( """ - case when ? = false then - string_agg(concat(?, ' (', ?, ')'), ',') - else - concat(?, ' [', string_agg(concat(?, ' (', ?, ')'), ','), ']') + case when ? = false then + string_agg(concat(?, ' (', ?, ')'), ',') + else + concat(?, ' [', string_agg(concat(?, ' (', ?, ')'), ','), ']') end """, t.setup_complete, @@ -155,9 +155,9 @@ defmodule PlausibleWeb.AdminController do select: [ fragment( """ - case when ? = false then + case when ? = false then concat(string_agg(concat(?, ' (', ?, ')'), ','), ' - ', ?) - else + else concat(concat(?, ' [', string_agg(concat(?, ' (', ?, ')'), ','), ']'), ' - ', ?) end """, @@ -187,7 +187,7 @@ defmodule PlausibleWeb.AdminController do defp usage_and_limits_html(team, usage, limits, embed?) do content = """ + +
+
+

How can you manage your preferences?

+

+ Tracking technologies that we enable are an essential part + of the frameworks we have adopted to securely enable our + digital ecosystem. Some of these trackers (which may include + third-party cookies) are strictly necessary and are enabled + for security purposes, to technically deliver the website or + service requested, and to facilitate the exercise of other + individual rights. Depending on your choice we may also + enable other categories of non-essential trackers to deliver + additional functionalities, such as personalisation, + application enhancement or improved website performance, + registering web traffic analysis and, assisting in our + marketing campaigns. The control panel provides detailed + information about each tracker and enables you to opt-in/out + of different purposes of processing. Our systems are able to + detect and honour Global Privacy Control. For purposes of + enabling our website and honouring user choice we process + user IP addresses. To learn more, read the section titled + "Content Delivery Network CDN" in our Cookie Notice. +
More information +

- -

- Functional cookies enable us to provide enhanced functionality - and personalisation, for example, by remembering that you - prefer vegetarian food when conducting searches for - restaurants. -

-
- +
+
+

Strictly Necessary Cookies

+
+
Always Active
+
+
+

+ These cookies are set to provide the service, application or + resource requested. Without these cookies, your request + cannot be properly delivered. They are usually set to manage + actions made by you, such as requesting website visual + elements, pages resources or due user login/logoff. We can + also use these cookies to set up essential functionalities + to guarantee the security and efficiency of the service + requested, like authentication and load balancer request. +

+
+
+

Vendor List

+
+
+
+ +
+

+ Folloze (Strictly necessary) +

+
Always Active
+
+ +
+
+ +
+
+
Parent Company
+
Folloze
+
+
+
Default Category
+
+ Strictly Necessary Cookies +
+
+
+
+ Data Protection Officer – Email +
+
legal@folloze.com
+
+
+
Privacy Policy Link
+ https://www.folloze.com/privacy-policy +
+
+
Cookie Policy Link
+ https://www.folloze.com/privacy-policy +
+
+
+
+ +
+

Greenhouse Software

+
Always Active
+
+ +
+
+ +
+
+
Parent Company
+
+ Greenhouse Software, Inc. +
+
+
+
Default Category
+
+ Strictly Necessary Cookies +
+
+
+
Default Description
+
+ Greenhouse Software is the leader in hiring + software. +
+
+
+
Privacy Policy Link
+ https://www.greenhouse.io/privacy-policy +
+
+
Cookie Policy Link
+ https://www.greenhouse.io/privacy-policy +
+
+
+
+ +
+

Everest Tech

+
Always Active
+
+ +
+
+ +
+
+
Parent Company
+
+ Everest Technologies, Inc. +
+
+
+
Default Category
+
+ Strictly Necessary Cookies +
+
+
+
Default Description
+
+ Everest Technologies is a Information Technology + solutions provider, delivery top quality systems + and software design, architecture, development, + testing and implementation services to clients in + a variety of industrial, commercial, and + governmental environments. +
+
+
+
Privacy Policy Link
+ https://everesttech.com/privacy-policy/ +
+
+
Cookie Policy Link
+ https://everesttech.com/privacy-policy/ +
+
+
+
+ +
+

OneTrust

+
Always Active
+
+ +
+
+ +
+
+
Parent Company
+
OneTrust
+
+
+
Default Category
+
+ Strictly Necessary Cookies +
+
+
+
Default Description
+
+ OneTrust LLC (OneTrust) is a provider of privacy + management software platform. The company's + platform supports organizations to adhere + compliance with the data privacy, governance and + security regulations across sectors and + jurisdictions. +
+
+
+
Privacy Policy Link
+ https://www.onetrust.com/privacy-notice/ +
+ +
+
+
+ +
+

CloudFlare

+
Always Active
+
+ +
+
+ +
+
+
Parent Company
+
Cloudflare Inc.
+
+
+
Default Category
+
+ Strictly Necessary Cookies +
+
+
+
Default Description
+
+ Cloudflare’s global cloud platform delivers a + range of network services to businesses of all + sizes around the world—making them more secure + while enhancing the performance and reliability of + their critical Internet properties. +
+
+
+
Privacy Policy Link
+ https://www.cloudflare.com/privacypolicy/ +
+ +
+
+
+
+
+
+
+

Functional Cookies

+
+ + +
+
+
+

+ These cookies are set by us or by third party service + providers we use to implement additional functionalities or + to enhance features and website performance, however they + are not directly related with the service you requested. + Services and functionalities implemented by these cookies + support features like automatic filled text box, live web + chat platform, non-necessary forms and optional security + parameters like a single sign-on (SSO). +

+
+
+

Vendor List

+
+
+
+ +
+

Wistia

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Wistia, Inc.
+
+
+
Default Category
+
Functional Cookies
+
+
+
Default Description
+
+ Wistia is a complete video hosting platform for + better marketing. +
+
+
+
Privacy Policy Link
+ https://wistia.com/privacy +
+
+
Cookie Policy Link
+ https://wistia.com/privacy +
+
+
+
+ +
+

Drift

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Drift.com, Inc.
+
+
+
Default Category
+
Functional Cookies
+
+
+
Default Description
+
+ Drift.com, Inc. is a conversational marketing and + sales technology company. +
+
+
+
Privacy Policy Link
+ https://www.drift.com/privacy-policy/ +
+ +
+
+
+ +
+

YouTube

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Google
+
+
+
Default Category
+
Functional Cookies
+
+
+
Default Description
+
+ YouTube is an American online video sharing and + social media platform owned by Google. +
+
+
+
Privacy Policy Link
+ https://policies.google.com/privacy +
+ +
+
+
+
+
+
+
+

Performance Cookies

+
+ + +
+
+
+

+ These cookies are set to provide quantitative measures of + website visitors. Information collected with these cookies + is used in operations to measure website or software KPIs, + such as performance. With the usage of these cookies we are + able to count visits and traffic sources to improve the + performance of our site and application. If you do not allow + these cookies, we will not know when you have visited our + site. +

+
+
+

Vendor List

+
+
+
+ +
+

Marketo

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Adobe
+
+
+
Default Category
+
Performance Cookies
+
+
+
Default Description
+
+ Marketo develops and sells marketing automation + software for account-based marketing and other + marketing services and products including SEO and + content creation. +
+
+ + +
+
+
+ +
+

Bizible

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Marketo/Adobe
+
+
+
Default Category
+
Performance Cookies
+
+
+
Default Description
+
+ Bizible unifies behavioral and ad data with sales + outcomes and machine learning, helping customers + make the right marketing decisions. +
+
+
+
Privacy Policy Link
+ https://www.adobe.com/privacy.html +
+ +
+
+
+ +
+

+ Adobe Dynamic Tag Manager +

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Adobe
+
+
+
Default Category
+
Performance Cookies
+
+
+
Default Description
+
+ Adobe Dynamic Tag Management (DTM) lets marketers + quickly and easily manage tags and provides + innovative tools for collecting and distributing + data across digital marketing systems. +
+
+
+
Privacy Policy Link
+ https://www.adobe.com/privacy.html +
+ +
+
+
+ +
+

Adobe Audience Manager

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Adobe
+
+
+
Default Category
+
Performance Cookies
+
+
+
Default Description
+
+ Adobe Audience Manager is Adobe's best-in-class + data management platform. +
+
+
+
Privacy Policy Link
+ https://www.adobe.com/privacy/policy.html +
+ +
+
+
+ +
+

New Relic

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
New Relic, Inc.
+
+
+
Default Category
+
Performance Cookies
+
+
+
Default Description
+
+ The world’s best engineering teams rely on New + Relic to visualize, analyze and troubleshoot their + software. New Relic One is the most powerful + cloud-based observability platform built to help + companies create more perfect software. Learn why + customers trust New Relic for improved uptime and + performance, greater scale and efficiency, and + accelerated time to market at newrelic.com. +
+
+ + +
+
+
+ +
+

Folloze (Performance)

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Folloze
+
+
+
Default Category
+
Performance Cookies
+
+
+
Privacy Policy Link
+ https://www.folloze.com/privacy-policy +
+
+
Cookie Policy Link
+ https://www.folloze.com/privacy-policy +
+
+
+
+ +
+

Crazy Egg

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Crazy Egg, Inc.
+
+
+
Default Category
+
Performance Cookies
+
+
+
Default Description
+
+ Crazy Egg is an analytics platform that tracks and + optimizes website visitor behavior so customers + can improve the user experience, increasing + conversion rates, and boosting the bottom line. +
+
+
+
Privacy Policy Link
+ https://www.crazyegg.com/privacy +
+
+
Cookie Policy Link
+ https://www.crazyegg.com/cookies +
+
+
+
+
+
+
+
+

Targeting Cookies

+
+ + +
+
+
+

+ These cookies are set by our advertising partners to provide + behavioral advertising and re-marketing analytical data. + They collect any type of browsing information necessary to + create profiles and to understand user habits in order to + develop an individual and specific advertising routine. The + profile created regarding your browsing interest and + behavior is used to customize the ads you see when you + access other websites. +

+
+
+

Vendor List

+
+
+
+ +
+

LinkedIn CDN

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Microsoft
+
+
+
Default Category
+
Targeting Cookies
+
+
+
Default Description
+
+ LinkedIn is an American business and + employment-oriented online service that operates + via websites and mobile apps. +
+
+ + +
+
+
+ +
+

LinkedIn Ads

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
Microsoft
+
+
+
Default Category
+
Targeting Cookies
+
+
+
Default Description
+
+ LinkedIn is an American business and + employment-oriented online service that operates + via websites and mobile apps. +
+
+ + +
+
+
+ +
+

6sense

+
+ + + label +
+
+ +
+
+ +
+
+
Parent Company
+
6Sense Insights, Inc.
+
+
+
Default Category
+
Targeting Cookies
+
+
+
Default Description
+
+ 6sense is an ABM platform that uses Revenue AI to + uncover anonymous buyers, understand their + behavior, and direct revenue teams to engage them. +
+
+
+
Privacy Policy Link
+ https://6sense.com/privacy-policy/ +
+
+
+
+
- - +
-
-
-
- -

Cookie List

-
-
-
-

- - -
-
+ +
+
+
+
+

Cookie List

-
-
-
- -
-
-
-
+
+
+

+ + +
+
+ +
+
+
+
+
+
+
+
+
+ Consent + Leg.Interest +
+
+
+ + + label +
+
- + label +
+
+ + label
-
- - -
-
-
+
+
-
-
-
-
- Consent - Leg.Interest -
-
-
- - - label -
-
- - - label -
-
- - - label +
+
+
+
+ +
+
+
+
+ + + label +
+
+ + +
-
-
    -
    + - Cookie List aria-hidden="true" >
    +
    +
    + +
    +
    + +
    +
    diff --git a/tracker/test/fixtures/cookies-quantcast.html b/tracker/test/fixtures/cookies-quantcast.html index 50e76c6ad367..31825cdf08de 100644 --- a/tracker/test/fixtures/cookies-quantcast.html +++ b/tracker/test/fixtures/cookies-quantcast.html @@ -5,6 +5,7 @@ Plausible Playwright tests + quantcast fixture
    diff --git a/tracker/test/installation_support/verifier-v2.spec.ts b/tracker/test/installation_support/verifier-v2.spec.ts index 1a6afce0e7c1..66736d74fcb8 100644 --- a/tracker/test/installation_support/verifier-v2.spec.ts +++ b/tracker/test/installation_support/verifier-v2.spec.ts @@ -11,7 +11,8 @@ const DEFAULT_VERIFICATION_OPTIONS = { timeoutMs: 1000, cspHostToCheck: 'plausible.io', maxAttempts: 2, - timeoutBetweenAttemptsMs: 500 + timeoutBetweenAttemptsMs: 500, + trackerScriptSelector: `script[src^="/tracker/js/plausible-web.js"]` } const incompleteCookiesConsentResult = { @@ -54,6 +55,7 @@ test.describe('installed plausible web variant', () => { data: { attempts: 1, completed: true, + trackerIsInHtml: true, plausibleIsInitialized: true, plausibleIsOnWindow: true, disallowedByCsp: false, @@ -115,6 +117,7 @@ test.describe('installed plausible web variant', () => { data: { attempts: 1, completed: true, + trackerIsInHtml: true, plausibleIsInitialized: true, plausibleIsOnWindow: true, disallowedByCsp: false, @@ -172,6 +175,7 @@ test.describe('installed plausible web variant', () => { data: { attempts: 1, completed: true, + trackerIsInHtml: true, plausibleIsInitialized: true, plausibleIsOnWindow: true, disallowedByCsp: false, @@ -218,6 +222,7 @@ test.describe('installed plausible web variant', () => { data: { attempts: 1, completed: true, + trackerIsInHtml: true, plausibleIsInitialized: true, plausibleIsOnWindow: true, disallowedByCsp: false, @@ -285,6 +290,7 @@ test.describe('installed plausible web variant', () => { data: { attempts: 2, completed: true, + trackerIsInHtml: true, plausibleIsInitialized: true, plausibleIsOnWindow: true, disallowedByCsp: false, @@ -426,6 +432,7 @@ test.describe('installed plausible web variant', () => { attempts: 1, completed: true, disallowedByCsp: true, + trackerIsInHtml: true, plausibleIsOnWindow: true, plausibleIsInitialized: undefined, plausibleVersion: undefined, @@ -487,6 +494,7 @@ test.describe('installed plausible web variant', () => { attempts: 1, completed: true, disallowedByCsp: false, // scripts from our domain are allowed, but the inline sourceless snippet can't run because 'unsafe-inline' is not present in the CSP + trackerIsInHtml: true, plausibleIsOnWindow: true, plausibleIsInitialized: undefined, plausibleVersion: undefined, @@ -547,6 +555,7 @@ test.describe('installed plausible web variant', () => { data: { attempts: 1, completed: true, + trackerIsInHtml: true, disallowedByCsp: false, plausibleIsOnWindow: true, plausibleIsInitialized: true, @@ -605,6 +614,7 @@ test.describe('installed plausible esm variant', () => { data: { attempts: 1, completed: true, + trackerIsInHtml: false, plausibleIsInitialized: true, plausibleIsOnWindow: true, disallowedByCsp: false, @@ -664,6 +674,7 @@ test.describe('installed plausible esm variant', () => { data: { attempts: 1, completed: true, + trackerIsInHtml: false, plausibleIsInitialized: true, plausibleIsOnWindow: true, disallowedByCsp: false, @@ -723,6 +734,7 @@ test.describe('installed plausible esm variant', () => { data: { attempts: 1, completed: true, + trackerIsInHtml: false, plausibleIsInitialized: true, plausibleIsOnWindow: true, disallowedByCsp: false, @@ -771,6 +783,7 @@ test.describe('installed plausible esm variant', () => { data: { attempts: 1, completed: true, + trackerIsInHtml: false, plausibleIsInitialized: true, plausibleIsOnWindow: true, disallowedByCsp: false, diff --git a/tracker/test/support/types.ts b/tracker/test/support/types.ts index 825858afcf9b..6d3ab86a6f71 100644 --- a/tracker/test/support/types.ts +++ b/tracker/test/support/types.ts @@ -22,6 +22,7 @@ export type VerifyV2Args = { responseHeaders: Record timeoutMs: number cspHostToCheck: string + trackerScriptSelector: string } type ConsentResult = From 6e9bc6e2f1e054e7fc82ff43719062df6cb850cc Mon Sep 17 00:00:00 2001 From: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:13:01 +0100 Subject: [PATCH 301/618] do not crash dashboard when externalLinkForPage fails (#5724) --- assets/js/dashboard/util/url.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/util/url.ts b/assets/js/dashboard/util/url.ts index fc69afaca28e..c428347a1d61 100644 --- a/assets/js/dashboard/util/url.ts +++ b/assets/js/dashboard/util/url.ts @@ -10,9 +10,13 @@ export function apiPath( export function externalLinkForPage( domain: PlausibleSite['domain'], page: string -): string { - const domainURL = new URL(`https://${domain}`) - return `https://${domainURL.host}${page}` +): string | null { + try { + const domainURL = new URL(`https://${domain}`) + return `https://${domainURL.host}${page}` + } catch (_error) { + return null + } } export function isValidHttpUrl(input: string): boolean { From cc5ca4b752ec3a7d51f1298656fe7949681e9869 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Mon, 15 Sep 2025 14:21:27 +0300 Subject: [PATCH 302/618] Script v2: Apply prettier (#5718) * Apply prettier * Fix issue with quotes in test setup * Fix other issues with customSubmitHandlerStub * Fix format with cookies fixtures --- .github/workflows/node.yml | 2 +- tracker/ARCHITECTURE.md | 3 +- tracker/README.md | 3 + tracker/compile.js | 28 +- tracker/compiler/analyze-sizes.js | 127 +++-- tracker/compiler/can-skip-compile.js | 12 +- tracker/compiler/generate-variants.js | 25 +- tracker/compiler/index.js | 37 +- tracker/compiler/worker-thread.js | 2 +- .../autoconsent/cookiebot.json | 26 +- .../check-cookie-banner.js | 8 +- .../check-data-domain-mismatch.js | 12 +- tracker/installation_support/check-gtm.js | 8 +- .../check-manual-extension.js | 4 +- .../check-proxy-likely.js | 4 +- .../check-unknown-attributes.js | 2 +- .../installation_support/check-wordpress.js | 24 +- tracker/installation_support/detector.js | 16 +- .../plausible-function-check.js | 22 +- tracker/installation_support/run-check.js | 10 +- .../installation_support/snippet-checks.js | 22 +- tracker/installation_support/verifier-v1.js | 32 +- tracker/npm_package/CHANGELOG.md | 2 + tracker/npm_package/README.md | 41 +- tracker/npm_package/plausible.d.ts | 30 +- tracker/package.json | 2 +- tracker/src/autocapture.js | 19 +- tracker/src/config.js | 16 +- tracker/src/custom-events.js | 229 ++++++--- tracker/src/engagement.js | 21 +- tracker/src/networking.js | 28 +- tracker/src/track.js | 59 ++- tracker/src/web-snippet.js | 16 +- tracker/test/callbacks.spec.ts | 2 +- tracker/test/custom-properties.spec.ts | 18 +- tracker/test/engagement.spec.js | 247 +++++++--- .../features-hierarchy-on-overlap.spec.ts | 25 +- tracker/test/file-downloads.spec.ts | 44 +- tracker/test/fixtures/cookies-cookiebot.html | 8 +- tracker/test/fixtures/cookies-iubenda.html | 4 +- tracker/test/fixtures/cookies-onetrust.html | 2 +- tracker/test/fixtures/cookies-quantcast.html | 2 +- .../fixtures/engagement-hash-exclusions.html | 34 +- .../test/fixtures/engagement-hash-manual.html | 66 +-- .../engagement-hash-pageview-props.html | 69 +-- tracker/test/fixtures/engagement-hash.html | 30 +- tracker/test/fixtures/engagement-manual.html | 64 +-- .../fixtures/engagement-pageview-props.html | 58 ++- tracker/test/fixtures/engagement.html | 40 +- .../fixtures/file-download-plausible-web.html | 51 +- .../fixtures/legacy-custom-properties.html | 35 +- .../fixtures/legacy-pageview-properties.html | 33 +- tracker/test/fixtures/manual.html | 82 ++-- tracker/test/fixtures/no-async.html | 24 +- tracker/test/fixtures/revenue.html | 42 +- .../scroll-depth-content-onscroll.html | 63 +-- .../scroll-depth-dynamic-content-load.html | 33 +- tracker/test/fixtures/scroll-depth-hash.html | 59 ++- .../scroll-depth-slow-window-load.html | 26 +- tracker/test/fixtures/scroll-depth.html | 29 +- tracker/test/form-submissions.spec.ts | 71 +-- tracker/test/hash-based-routing.spec.ts | 8 +- tracker/test/hash-exclusions.spec.ts | 6 +- .../check-data-domain-mismatch.spec.js | 13 +- .../check-disallowed-by-csp.spec.js | 30 +- .../installation_support/check-gtm.spec.js | 10 +- .../check-manual-extension.spec.js | 4 +- .../check-proxy-likely.spec.js | 16 +- .../installation_support/check-wp.spec.js | 48 +- .../installation_support/detector.spec.js | 105 +++- .../installation_support/verifier-v1.spec.js | 454 +++++++++++++----- .../installation_support/verifier-v2.spec.ts | 76 +-- tracker/test/legacy-custom-properties.spec.js | 13 +- tracker/test/logging.spec.ts | 5 +- tracker/test/manual.spec.js | 51 +- tracker/test/outbound-links.spec.ts | 20 +- tracker/test/pageview.spec.ts | 18 +- tracker/test/plausible-npm-init.spec.ts | 61 ++- tracker/test/plausible-web-init.spec.ts | 37 +- tracker/test/revenue.spec.js | 12 +- tracker/test/scroll-depth.spec.js | 112 +++-- tracker/test/support/html-fixtures.ts | 3 +- ...nstallation-support-playwright-wrappers.ts | 44 +- tracker/test/support/server.js | 23 +- tracker/test/support/test-utils.js | 102 ++-- tracker/test/tagged-events.spec.ts | 98 ++-- tracker/test/transform_request.spec.ts | 90 ++-- 87 files changed, 2313 insertions(+), 1299 deletions(-) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index ab0485ca5136..f6ec3b2dd401 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -32,5 +32,5 @@ jobs: - run: npm run check-format --prefix ./assets - run: npm run test --prefix ./assets - run: npm run lint --prefix ./tracker - # - run: npm run check-format --prefix ./tracker + - run: npm run check-format --prefix ./tracker - run: npm run deploy --prefix ./tracker diff --git a/tracker/ARCHITECTURE.md b/tracker/ARCHITECTURE.md index f8bda549be62..b1881d38a68d 100644 --- a/tracker/ARCHITECTURE.md +++ b/tracker/ARCHITECTURE.md @@ -32,6 +32,7 @@ Plausible provides a web 'snippet' users can include on their site, tooling for As Plausible doesn't have the workforce to maintain multiple code bases, everything is built from the same underlying source code. This is achieved by: + - Having a canonical list of compiled variants in `tracker/compiler/variants.json` - Using `COMPILE` globals to toggle certain functionality on/off depending on the variant. - Having the script minifier drop dead branches of the code from each variant. @@ -74,7 +75,7 @@ Contains source code to verify that the tracker script is installed correctly wi ### `tracker/compiler/variants.json` -Contains a list of variants and associated global `COMPILE` flags. +Contains a list of variants and associated global `COMPILE` flags. - `manualVariants` are the main variants we need to care for, containing both the web snippet and npm package. - `legacyVariants` is a list of all the legacy variants we support, generated by `tracker/compiler/generate-variants.js` diff --git a/tracker/README.md b/tracker/README.md index 37300740d964..d10f1f131c52 100644 --- a/tracker/README.md +++ b/tracker/README.md @@ -25,6 +25,7 @@ Use `node compile.js --watch` to watch for changes. Use `node compile.js --web-snippet` if you need to update web snippet code. ### Tests + Tests can be run in UI mode via `npm run playwright --ui`. This helps with debugging. ### NPM package @@ -40,10 +41,12 @@ More instructions can be found in [yalc repo](https://github.com/wclr/yalc). ## Cloud deployment Handled via PRs. When making tracker changes, it's required to: + - Tag your PR with a `tracker release:` label - Update `tracker/CHANGELOG.md` After merge github actions automatically: + - includes the updated tracker scripts in the next cloud deploy - updates npm package package.json and CHANGELOG.md with the new version - releases the new package version on NPM. diff --git a/tracker/compile.js b/tracker/compile.js index 591587f4850b..5c9a4527d799 100644 --- a/tracker/compile.js +++ b/tracker/compile.js @@ -4,19 +4,19 @@ import chokidar from 'chokidar' const { values } = parseArgs({ options: { - 'watch': { + watch: { type: 'boolean', short: 'w' }, - 'help': { - type: 'boolean', + help: { + type: 'boolean' }, - 'suffix': { + suffix: { type: 'string', default: '' }, 'web-snippet': { - type: 'boolean', + type: 'boolean' } } }) @@ -24,11 +24,19 @@ const { values } = parseArgs({ if (values.help) { console.log('Usage: node compile.js [flags]') console.log('Options:') - console.log(' --watch, -w Watch src/ directory for changes and recompile') - console.log(' --suffix, -s Suffix to add to the output file name. Used for testing script size changes') - console.log(' --help Show this help message') - console.log(' --web-snippet Compile and output the web snippet') - process.exit(0); + console.log( + ' --watch, -w Watch src/ directory for changes and recompile' + ) + console.log( + ' --suffix, -s Suffix to add to the output file name. Used for testing script size changes' + ) + console.log( + ' --help Show this help message' + ) + console.log( + ' --web-snippet Compile and output the web snippet' + ) + process.exit(0) } if (values['web-snippet']) { diff --git a/tracker/compiler/analyze-sizes.js b/tracker/compiler/analyze-sizes.js index 604a050925d6..8d214d66a49a 100644 --- a/tracker/compiler/analyze-sizes.js +++ b/tracker/compiler/analyze-sizes.js @@ -23,18 +23,18 @@ import variantsFile from './variants.json' with { type: 'json' } const { values } = parseArgs({ options: { - 'help': { - type: 'boolean', + help: { + type: 'boolean' }, - 'currentSuffix': { + currentSuffix: { type: 'string', default: 'current' }, - 'baselineSuffix': { + baselineSuffix: { type: 'string', default: 'master' }, - 'usePreviousData': { + usePreviousData: { type: 'boolean', default: false } @@ -43,8 +43,8 @@ const { values } = parseArgs({ const { currentSuffix, baselineSuffix } = values const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const TRACKER_FILES_DIR = path.join(__dirname, "../../priv/tracker/js/") -const NPM_PACKAGE_FILES_DIR = path.join(__dirname, "../npm_package") +const TRACKER_FILES_DIR = path.join(__dirname, '../../priv/tracker/js/') +const NPM_PACKAGE_FILES_DIR = path.join(__dirname, '../npm_package') const HEADER = ['', 'Brotli', 'Gzip', 'Uncompressed'] @@ -52,22 +52,34 @@ if (values.help) { console.log('Usage: node analyze-sizes.js master current') console.log('Options:') console.log(' --help Show this help message') - console.log(' --currentSuffix The suffix of the current script variants (see suffix flag for compile.js). Default: current') - console.log(' --baselineSuffix The suffix of the previous script variants (see suffix flag for compile.js). Default: master') - console.log(' --usePreviousData Use data from a previous run, speeding up the analysis.') - process.exit(0); + console.log( + ' --currentSuffix The suffix of the current script variants (see suffix flag for compile.js). Default: current' + ) + console.log( + ' --baselineSuffix The suffix of the previous script variants (see suffix flag for compile.js). Default: master' + ) + console.log( + ' --usePreviousData Use data from a previous run, speeding up the analysis.' + ) + process.exit(0) } - let fileData if (values.usePreviousData) { - fileData = JSON.parse(fs.readFileSync(path.join(__dirname, '.analyze-sizes.json'), 'utf8')) + fileData = JSON.parse( + fs.readFileSync(path.join(__dirname, '.analyze-sizes.json'), 'utf8') + ) } else { fileData = readPlausibleScriptSizes() - fs.writeFileSync(path.join(__dirname, '.analyze-sizes.json'), JSON.stringify(fileData)) + fs.writeFileSync( + path.join(__dirname, '.analyze-sizes.json'), + JSON.stringify(fileData) + ) } -const manualVariants = variantsFile.manualVariants.map((variant) => `'${variant.name}'`).join(', ') +const manualVariants = variantsFile.manualVariants + .map((variant) => `'${variant.name}'`) + .join(', ') const ctes = ` WITH array(${manualVariants}) as manual_variants, @@ -99,21 +111,27 @@ WITH ) ` -const mainVariantResults = clickhouseLocal(` +const mainVariantResults = clickhouseLocal( + ` ${ctes} SELECT * FROM data WHERE not is_legacy_variant ORDER BY variant -`, fileData) +`, + fileData +) -const legacyVariantResults = clickhouseLocal(` +const legacyVariantResults = clickhouseLocal( + ` ${ctes} SELECT * FROM data WHERE is_legacy_variant AND has(important_variants, variant) ORDER BY length(variant) -`, fileData) +`, + fileData +) const rowAsMap = ` map( @@ -133,7 +151,8 @@ map( ) ` -const [summary] = clickhouseLocal(` +const [summary] = clickhouseLocal( + ` ${ctes} SELECT count() AS total_variants, @@ -168,26 +187,45 @@ const [summary] = clickhouseLocal(` 'brotli_increase_percentage', toString(median(brotli_increase_percentage)) ) AS median_result FROM data -`, fileData) +`, + fileData +) -console.log(`Analyzed ${summary.total_variants} tracker script variants for size changes.`) -console.log(`The following tables summarize the results, with comparison with the baseline version in parentheses.\n`) +console.log( + `Analyzed ${summary.total_variants} tracker script variants for size changes.` +) +console.log( + `The following tables summarize the results, with comparison with the baseline version in parentheses.\n` +) -console.log("Main variants:") +console.log('Main variants:') console.log(createMarkdownTable(mainVariantResults)) -console.log("\nImportant legacy variants:") +console.log('\nImportant legacy variants:') console.log(createMarkdownTable(legacyVariantResults)) -console.log("\nSummary:") -console.log(createMarkdownTable([ - { ...summary.largest_variant, variant: `Largest variant (${summary.largest_variant.variant})`}, - { ...summary.max_increase_variant, variant: `Max change (${summary.max_increase_variant.variant})`}, - { ...summary.min_increase_variant, variant: `Min change (${summary.min_increase_variant.variant})`}, - summary.median_result -])) +console.log('\nSummary:') +console.log( + createMarkdownTable([ + { + ...summary.largest_variant, + variant: `Largest variant (${summary.largest_variant.variant})` + }, + { + ...summary.max_increase_variant, + variant: `Max change (${summary.max_increase_variant.variant})` + }, + { + ...summary.min_increase_variant, + variant: `Min change (${summary.min_increase_variant.variant})` + }, + summary.median_result + ]) +) -console.log(`\nIn total, ${summary.brotli_increase_percentaged_variants} variants brotli size increased and ${summary.brotli_decreased_variants} variants brotli size decreased.`) +console.log( + `\nIn total, ${summary.brotli_increase_percentaged_variants} variants brotli size increased and ${summary.brotli_decreased_variants} variants brotli size decreased.` +) function createMarkdownTable(rows) { return markdownTable([HEADER].concat(rows.map(markdownRow))) @@ -208,7 +246,6 @@ function markdownRow(row) { ] } - function sizeColumn(row, key) { const currentSize = row[`current_${key}`] const previousIncrease = row[`${key}_increase`] @@ -230,11 +267,13 @@ function addSign(value) { } function readPlausibleScriptSizes() { - const trackerFileSizes = fs.readdirSync(TRACKER_FILES_DIR) + const trackerFileSizes = fs + .readdirSync(TRACKER_FILES_DIR) .filter(isRelevantFile) .map((filename) => readFileSize(filename, TRACKER_FILES_DIR)) - const npmPackageFileSizes = fs.readdirSync(NPM_PACKAGE_FILES_DIR) + const npmPackageFileSizes = fs + .readdirSync(NPM_PACKAGE_FILES_DIR) .filter(isRelevantFile) .map((filename) => readFileSize(filename, NPM_PACKAGE_FILES_DIR)) @@ -246,7 +285,10 @@ function readFileSize(filename, basepath) { const [_, variant, suffix] = /(.*)[.]js(.*)/.exec(filename) return { - variant: (basepath === TRACKER_FILES_DIR ? `${variant}.js` : 'npm_package/plausible.js'), + variant: + basepath === TRACKER_FILES_DIR + ? `${variant}.js` + : 'npm_package/plausible.js', suffix, uncompressed: fs.statSync(filePath).size, gzip: execSync(`gzip -c -9 "${filePath}"`).length, @@ -255,18 +297,23 @@ function readFileSize(filename, basepath) { } function isRelevantFile(filename) { - return !['.gitkeep', 'p.js'].includes(filename) && + return ( + !['.gitkeep', 'p.js'].includes(filename) && filename.includes('.js') && (filename.includes(currentSuffix) || filename.includes(baselineSuffix)) + ) } function clickhouseLocal(sql, inputLines = null) { const options = {} if (inputLines) { - options.input = inputLines.map(JSON.stringify).join("\n") + options.input = inputLines.map(JSON.stringify).join('\n') } - const result = execSync(`clickhouse-local --query="${sql}" --format=JSON ${inputLines ? "--input-format=JSONLines" : ""}`, options) + const result = execSync( + `clickhouse-local --query="${sql}" --format=JSON ${inputLines ? '--input-format=JSONLines' : ''}`, + options + ) const json = JSON.parse(result.toString()) return json.data diff --git a/tracker/compiler/can-skip-compile.js b/tracker/compiler/can-skip-compile.js index b6f90b88850a..b2d47b7f359d 100644 --- a/tracker/compiler/can-skip-compile.js +++ b/tracker/compiler/can-skip-compile.js @@ -15,19 +15,19 @@ const COMPILE_DEPENDENCIES = [ ] function currentHash() { - const combinedHash = crypto.createHash('sha256'); + const combinedHash = crypto.createHash('sha256') for (const filePath of COMPILE_DEPENDENCIES) { try { - const fileContent = fs.readFileSync(filePath); - const fileHash = crypto.createHash('sha256').update(fileContent).digest(); - combinedHash.update(fileHash); + const fileContent = fs.readFileSync(filePath) + const fileHash = crypto.createHash('sha256').update(fileContent).digest() + combinedHash.update(fileHash) } catch (error) { - throw new Error(`Failed to read or hash ${filePath}: ${error.message}`); + throw new Error(`Failed to read or hash ${filePath}: ${error.message}`) } } - return combinedHash.digest('hex'); + return combinedHash.digest('hex') } function lastHash() { diff --git a/tracker/compiler/generate-variants.js b/tracker/compiler/generate-variants.js index b9e9a686ebc0..e68f7a8c7110 100644 --- a/tracker/compiler/generate-variants.js +++ b/tracker/compiler/generate-variants.js @@ -9,13 +9,25 @@ function idToGlobal(id) { return `COMPILE_${id.replace('-', '_').toUpperCase()}` } -const LEGACY_VARIANT_NAMES = ["hash", "outbound-links", "exclusions", "compat", "local", "manual", "file-downloads", "pageview-props", "tagged-events", "revenue"] +const LEGACY_VARIANT_NAMES = [ + 'hash', + 'outbound-links', + 'exclusions', + 'compat', + 'local', + 'manual', + 'file-downloads', + 'pageview-props', + 'tagged-events', + 'revenue' +] let legacyVariants = [...g.clone.powerSet(LEGACY_VARIANT_NAMES)] - .map(a => a.sort()) + .map((a) => a.sort()) .map((variant) => ({ - name: variant.length > 0 ? `plausible.${variant.join('.')}.js` : 'plausible.js', + name: + variant.length > 0 ? `plausible.${variant.join('.')}.js` : 'plausible.js', globals: { - ...Object.fromEntries(variant.map(id => [idToGlobal(id), true])), + ...Object.fromEntries(variant.map((id) => [idToGlobal(id), true])), COMPILE_PLAUSIBLE_LEGACY_VARIANT: true } })) @@ -23,4 +35,7 @@ let legacyVariants = [...g.clone.powerSet(LEGACY_VARIANT_NAMES)] const variantsFile = path.join(__dirname, 'variants.json') const existingData = JSON.parse(fs.readFileSync(variantsFile, 'utf8')) -fs.writeFileSync(variantsFile, JSON.stringify({ ...existingData, legacyVariants }, null, 2) + "\n") +fs.writeFileSync( + variantsFile, + JSON.stringify({ ...existingData, legacyVariants }, null, 2) + '\n' +) diff --git a/tracker/compiler/index.js b/tracker/compiler/index.js index 708b1ad135ed..7a7c67f53d9a 100644 --- a/tracker/compiler/index.js +++ b/tracker/compiler/index.js @@ -7,7 +7,7 @@ import variantsFile from './variants.json' with { type: 'json' } import { canSkipCompile } from './can-skip-compile.js' import packageJson from '../package.json' with { type: 'json' } import progress from 'cli-progress' -import { spawn, Worker, Pool } from "threads" +import { spawn, Worker, Pool } from 'threads' import json from '@rollup/plugin-json' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -28,27 +28,34 @@ export const DEFAULT_GLOBALS = { COMPILE_TAGGED_EVENTS: false, COMPILE_REVENUE: false, COMPILE_EXCLUSIONS: false, - COMPILE_TRACKER_SCRIPT_VERSION: packageJson.tracker_script_version, + COMPILE_TRACKER_SCRIPT_VERSION: packageJson.tracker_script_version } -const ALL_VARIANTS = variantsFile.legacyVariants.concat(variantsFile.manualVariants) +const ALL_VARIANTS = variantsFile.legacyVariants.concat( + variantsFile.manualVariants +) export async function compileAll(options = {}) { if (process.env.NODE_ENV === 'dev' && canSkipCompile()) { - console.info('COMPILATION SKIPPED: No changes detected in tracker dependencies') + console.info( + 'COMPILATION SKIPPED: No changes detected in tracker dependencies' + ) return } const bundledCode = await bundleCode() - const startTime = Date.now(); + const startTime = Date.now() console.log(`Starting compilation of ${ALL_VARIANTS.length} variants...`) - const bar = new progress.SingleBar({ clearOnComplete: true }, progress.Presets.shades_classic) + const bar = new progress.SingleBar( + { clearOnComplete: true }, + progress.Presets.shades_classic + ) bar.start(ALL_VARIANTS.length, 0) const workerPool = Pool(() => spawn(new Worker('./worker-thread.js'))) - ALL_VARIANTS.forEach(variant => { + ALL_VARIANTS.forEach((variant) => { workerPool.queue(async (worker) => { await worker.compileFile(variant, { ...options, bundledCode }) bar.increment() @@ -59,7 +66,9 @@ export async function compileAll(options = {}) { await workerPool.terminate() bar.stop() - console.log(`Completed compilation of ${ALL_VARIANTS.length} variants in ${((Date.now() - startTime) / 1000).toFixed(2)}s`); + console.log( + `Completed compilation of ${ALL_VARIANTS.length} variants in ${((Date.now() - startTime) / 1000).toFixed(2)}s` + ) } export async function compileFile(variant, options) { @@ -69,7 +78,7 @@ export async function compileFile(variant, options) { if (variant.entry_point) { code = await bundleCode(variant.entry_point) } else { - code = options.bundledCode || await bundleCode() + code = options.bundledCode || (await bundleCode()) } if (!variant.npm_package) { @@ -111,7 +120,7 @@ export function compileWebSnippet() { async function bundleCode(entryPoint = 'src/plausible.js') { const bundle = await rollup({ input: entryPoint, - plugins: [json({compact: true})] + plugins: [json({ compact: true })] }) const { output } = await bundle.generate({ format: 'esm' }) @@ -121,11 +130,13 @@ async function bundleCode(entryPoint = 'src/plausible.js') { function outputPath(variant, options) { if (variant.output_path) { - return relPath(`../../${variant.output_path}${options.suffix || ""}`) + return relPath(`../../${variant.output_path}${options.suffix || ''}`) } else if (variant.npm_package) { - return relPath(`../${variant.name}${options.suffix || ""}`) + return relPath(`../${variant.name}${options.suffix || ''}`) } else { - return relPath(`../../priv/tracker/js/${variant.name}${options.suffix || ""}`) + return relPath( + `../../priv/tracker/js/${variant.name}${options.suffix || ''}` + ) } } diff --git a/tracker/compiler/worker-thread.js b/tracker/compiler/worker-thread.js index a0a7c123c4c1..fdd339f867fd 100644 --- a/tracker/compiler/worker-thread.js +++ b/tracker/compiler/worker-thread.js @@ -1,4 +1,4 @@ import { compileFile } from './index.js' -import { expose } from "threads/worker" +import { expose } from 'threads/worker' expose({ compileFile }) diff --git a/tracker/installation_support/autoconsent-rules/autoconsent/cookiebot.json b/tracker/installation_support/autoconsent-rules/autoconsent/cookiebot.json index 8391511022e6..a6c714e9a2c5 100644 --- a/tracker/installation_support/autoconsent-rules/autoconsent/cookiebot.json +++ b/tracker/installation_support/autoconsent-rules/autoconsent/cookiebot.json @@ -1,9 +1,21 @@ { - "name": "cookiebot", - "prehideSelectors": [], - "detectCmp": [{ "exists": "#CybotCookiebotDialogBodyButtonAccept, #CybotCookiebotDialogBody, #CybotCookiebotDialogBodyLevelButtonPreferences, #cb-cookieoverlay, #CybotCookiebotDialog" }], - "detectPopup": [{ "visible": "#CybotCookiebotDialogBodyButtonAccept, #CybotCookiebotDialogBody, #CybotCookiebotDialogBodyLevelButtonPreferences, #cb-cookieoverlay, #CybotCookiebotDialog, #cookiebanner" }], - "optIn": [{ "waitForThenClick": "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll" }], - "optOut": [], - "test": [] + "name": "cookiebot", + "prehideSelectors": [], + "detectCmp": [ + { + "exists": "#CybotCookiebotDialogBodyButtonAccept, #CybotCookiebotDialogBody, #CybotCookiebotDialogBodyLevelButtonPreferences, #cb-cookieoverlay, #CybotCookiebotDialog" + } + ], + "detectPopup": [ + { + "visible": "#CybotCookiebotDialogBodyButtonAccept, #CybotCookiebotDialogBody, #CybotCookiebotDialogBodyLevelButtonPreferences, #cb-cookieoverlay, #CybotCookiebotDialog, #cookiebanner" + } + ], + "optIn": [ + { + "waitForThenClick": "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll" + } + ], + "optOut": [], + "test": [] } diff --git a/tracker/installation_support/check-cookie-banner.js b/tracker/installation_support/check-cookie-banner.js index bedf05425389..7510c1e95163 100644 --- a/tracker/installation_support/check-cookie-banner.js +++ b/tracker/installation_support/check-cookie-banner.js @@ -9,18 +9,18 @@ const SELECTORS = { '#CybotCookiebotDialogBodyLevelButtonPreferences', '#cb-cookieoverlay', '#CybotCookiebotDialog', - '#cookiebanner', + '#cookiebanner' ] } function isVisible(element) { - const style = window.getComputedStyle(element); + const style = window.getComputedStyle(element) return ( style.display !== 'none' && style.visibility !== 'hidden' && element.offsetParent !== null - ); + ) } export function checkCookieBanner() { @@ -35,4 +35,4 @@ export function checkCookieBanner() { } return false -} \ No newline at end of file +} diff --git a/tracker/installation_support/check-data-domain-mismatch.js b/tracker/installation_support/check-data-domain-mismatch.js index a370c55c4d54..f89cfb84b588 100644 --- a/tracker/installation_support/check-data-domain-mismatch.js +++ b/tracker/installation_support/check-data-domain-mismatch.js @@ -1,12 +1,14 @@ export function checkDataDomainMismatch(snippets, expectedDataDomain) { if (!snippets || snippets.length === 0) return false - return snippets.some(snippet => { + return snippets.some((snippet) => { const scriptDataDomain = snippet.getAttribute('data-domain') - const multiple = scriptDataDomain.split(',').map(d => d.trim()) - const dataDomainMismatch = !multiple.some((domain) => domain.replace(/^www\./, '') === expectedDataDomain) - + const multiple = scriptDataDomain.split(',').map((d) => d.trim()) + const dataDomainMismatch = !multiple.some( + (domain) => domain.replace(/^www\./, '') === expectedDataDomain + ) + return dataDomainMismatch }) -} \ No newline at end of file +} diff --git a/tracker/installation_support/check-gtm.js b/tracker/installation_support/check-gtm.js index cc91b00a78c5..b82e616d343a 100644 --- a/tracker/installation_support/check-gtm.js +++ b/tracker/installation_support/check-gtm.js @@ -1,10 +1,8 @@ -const GTM_SIGNATURES = [ - 'googletagmanager.com/gtm.js' -] +const GTM_SIGNATURES = ['googletagmanager.com/gtm.js'] function scanGTM(html) { if (typeof html === 'string') { - return GTM_SIGNATURES.some(signature => { + return GTM_SIGNATURES.some((signature) => { return html.includes(signature) }) } @@ -18,4 +16,4 @@ export function checkGTM(document) { } return false -} \ No newline at end of file +} diff --git a/tracker/installation_support/check-manual-extension.js b/tracker/installation_support/check-manual-extension.js index 8060e59d8f91..df1a945132aa 100644 --- a/tracker/installation_support/check-manual-extension.js +++ b/tracker/installation_support/check-manual-extension.js @@ -1,7 +1,7 @@ export function checkManualExtension(snippets) { if (!snippets || snippets.length === 0) return false - return snippets.some(snippet => { + return snippets.some((snippet) => { return snippet.getAttribute('src').includes('manual.') }) -} \ No newline at end of file +} diff --git a/tracker/installation_support/check-proxy-likely.js b/tracker/installation_support/check-proxy-likely.js index c086634f3347..df14e106e5df 100644 --- a/tracker/installation_support/check-proxy-likely.js +++ b/tracker/installation_support/check-proxy-likely.js @@ -1,8 +1,8 @@ export function checkProxyLikely(snippets) { if (!snippets || snippets.length === 0) return false - return snippets.some(snippet => { + return snippets.some((snippet) => { const src = snippet.getAttribute('src') return src && !/^https:\/\/plausible\.io\//.test(src) }) -} \ No newline at end of file +} diff --git a/tracker/installation_support/check-unknown-attributes.js b/tracker/installation_support/check-unknown-attributes.js index 3e5a4bb161ae..06a6ab6ef8c6 100644 --- a/tracker/installation_support/check-unknown-attributes.js +++ b/tracker/installation_support/check-unknown-attributes.js @@ -12,7 +12,7 @@ const KNOWN_ATTRIBUTES = [ export function checkUnknownAttributes(snippets) { if (!snippets || snippets.length === 0) return false - return snippets.some(snippet => { + return snippets.some((snippet) => { const attributes = snippet.attributes for (let i = 0; i < attributes.length; i++) { diff --git a/tracker/installation_support/check-wordpress.js b/tracker/installation_support/check-wordpress.js index bba815e7ec09..09da51a3d7e3 100644 --- a/tracker/installation_support/check-wordpress.js +++ b/tracker/installation_support/check-wordpress.js @@ -1,10 +1,7 @@ -export const WORDPRESS_PLUGIN_VERSION_SELECTOR = 'meta[name="plausible-analytics-version"]' +export const WORDPRESS_PLUGIN_VERSION_SELECTOR = + 'meta[name="plausible-analytics-version"]' -const WORDPRESS_SIGNATURES = [ - 'wp-content', - 'wp-includes', - 'wp-json' -] +const WORDPRESS_SIGNATURES = ['wp-content', 'wp-includes', 'wp-json'] function scanWpPlugin(document) { if (typeof document.querySelector === 'function') { @@ -17,9 +14,9 @@ function scanWpPlugin(document) { function scanWp(html) { if (typeof html === 'string') { - return WORDPRESS_SIGNATURES.some(signature => { + return WORDPRESS_SIGNATURES.some((signature) => { return html.includes(signature) - }) + }) } return false @@ -28,10 +25,11 @@ function scanWp(html) { export function checkWordPress(document) { if (typeof document === 'object') { const wordpressPlugin = scanWpPlugin(document) - const wordpressLikely = wordpressPlugin || scanWp(document.documentElement?.outerHTML) - - return {wordpressPlugin, wordpressLikely} + const wordpressLikely = + wordpressPlugin || scanWp(document.documentElement?.outerHTML) + + return { wordpressPlugin, wordpressLikely } } - return {wordpressPlugin: false, wordpressLikely: false} -} \ No newline at end of file + return { wordpressPlugin: false, wordpressLikely: false } +} diff --git a/tracker/installation_support/detector.js b/tracker/installation_support/detector.js index 570e1cc6e385..9fe7865ee149 100644 --- a/tracker/installation_support/detector.js +++ b/tracker/installation_support/detector.js @@ -1,9 +1,13 @@ -import { waitForPlausibleFunction } from "./plausible-function-check" -import { checkWordPress } from "./check-wordpress" -import { checkGTM } from "./check-gtm" -import { checkNPM } from "./check-npm" +import { waitForPlausibleFunction } from './plausible-function-check' +import { checkWordPress } from './check-wordpress' +import { checkGTM } from './check-gtm' +import { checkNPM } from './check-npm' -window.scanPageBeforePlausibleInstallation = async function({ detectV1, debug, timeoutMs }) { +window.scanPageBeforePlausibleInstallation = async function ({ + detectV1, + debug, + timeoutMs +}) { function log(message) { if (debug) console.log('[Plausible Verification]', message) } @@ -18,7 +22,7 @@ window.scanPageBeforePlausibleInstallation = async function({ detectV1, debug, t log(`v1Detected: ${v1Detected}`) } - const {wordpressPlugin, wordpressLikely} = checkWordPress(document) + const { wordpressPlugin, wordpressLikely } = checkWordPress(document) log(`wordpressPlugin: ${wordpressPlugin}`) log(`wordpressLikely: ${wordpressLikely}`) diff --git a/tracker/installation_support/plausible-function-check.js b/tracker/installation_support/plausible-function-check.js index 8bcba533da33..d7ae65a00ae8 100644 --- a/tracker/installation_support/plausible-function-check.js +++ b/tracker/installation_support/plausible-function-check.js @@ -1,9 +1,9 @@ -import { runThrottledCheck } from "./run-check" +import { runThrottledCheck } from './run-check' export async function plausibleFunctionCheck(log) { log('Checking for Plausible function...') const plausibleFound = await waitForPlausibleFunction() - + if (plausibleFound) { log('Plausible function found. Executing test event...') const callbackResult = await testPlausibleCallback(log) @@ -11,17 +11,21 @@ export async function plausibleFunctionCheck(log) { return { plausibleInstalled: true, callbackStatus: callbackResult.status } } else { log('Plausible function not found') - return { plausibleInstalled: false} + return { plausibleInstalled: false } } } export async function waitForPlausibleFunction(timeout = 5000) { const checkFn = (opts) => { - if (window.plausible?.l) { return true } - if (opts.timeout) { return false } + if (window.plausible?.l) { + return true + } + if (opts.timeout) { + return false + } return 'continue' } - return await runThrottledCheck(checkFn, {timeout: timeout, interval: 100}) + return await runThrottledCheck(checkFn, { timeout: timeout, interval: 100 }) } function testPlausibleCallback(log) { @@ -38,11 +42,11 @@ function testPlausibleCallback(log) { try { window.plausible('verification-agent-test', { - callback: function(options) { + callback: function (options) { if (!callbackResolved) { callbackResolved = true clearTimeout(callbackTimeout) - resolve({status: options && options.status ? options.status : -1 }) + resolve({ status: options && options.status ? options.status : -1 }) } } }) @@ -55,4 +59,4 @@ function testPlausibleCallback(log) { } } }) -} \ No newline at end of file +} diff --git a/tracker/installation_support/run-check.js b/tracker/installation_support/run-check.js index c9218e147d6d..eb2e89275007 100644 --- a/tracker/installation_support/run-check.js +++ b/tracker/installation_support/run-check.js @@ -1,17 +1,17 @@ -export async function runThrottledCheck(checkFn, {timeout, interval}) { +export async function runThrottledCheck(checkFn, { timeout, interval }) { return runCheckRecursive(checkFn, timeout, interval, 1) } async function runCheckRecursive(checkFn, timeout, interval, iteration) { return new Promise((resolve) => { if (iteration * interval >= timeout) { - resolve(checkFn({timeout: true})) - } else if (checkFn({timeout: false}) !== 'continue') { - resolve(checkFn({timeout: false})) + resolve(checkFn({ timeout: true })) + } else if (checkFn({ timeout: false }) !== 'continue') { + resolve(checkFn({ timeout: false })) } else { setTimeout(() => { resolve(runCheckRecursive(checkFn, timeout, interval, iteration + 1)) }, interval) } }) -} \ No newline at end of file +} diff --git a/tracker/installation_support/snippet-checks.js b/tracker/installation_support/snippet-checks.js index 8807f30d1c1e..c667b8f51d73 100644 --- a/tracker/installation_support/snippet-checks.js +++ b/tracker/installation_support/snippet-checks.js @@ -1,4 +1,4 @@ -import { runThrottledCheck } from "./run-check" +import { runThrottledCheck } from './run-check' export async function waitForSnippetsV1(log) { log('Starting snippet detection...') @@ -6,19 +6,23 @@ export async function waitForSnippetsV1(log) { let snippetCounts = await waitForFirstSnippet() if (snippetCounts.all > 0) { - log(`Found snippets: head=${snippetCounts.head}; body=${snippetCounts.body}`) + log( + `Found snippets: head=${snippetCounts.head}; body=${snippetCounts.body}` + ) log('Waiting for additional snippets to appear...') snippetCounts = await waitForAdditionalSnippets() - log(`Final snippet count: head=${snippetCounts.head}; body=${snippetCounts.body}`) + log( + `Final snippet count: head=${snippetCounts.head}; body=${snippetCounts.body}` + ) } else { - log('No snippets found after 5 seconds') + log('No snippets found after 5 seconds') } return { nodes: [...getHeadSnippets(), ...getBodySnippets()], - counts: snippetCounts, + counts: snippetCounts } } @@ -33,7 +37,7 @@ function getBodySnippets() { function countSnippets() { const headSnippets = getHeadSnippets() const bodySnippets = getBodySnippets() - + return { head: headSnippets.length, body: bodySnippets.length, @@ -44,7 +48,7 @@ function countSnippets() { async function waitForFirstSnippet() { const checkFn = (opts) => { const snippetsFound = countSnippets() - + if (snippetsFound.all > 0 || opts.timeout) { return snippetsFound } @@ -52,7 +56,7 @@ async function waitForFirstSnippet() { return 'continue' } - return await runThrottledCheck(checkFn, {timeout: 5000, interval: 100}) + return await runThrottledCheck(checkFn, { timeout: 5000, interval: 100 }) } async function waitForAdditionalSnippets() { @@ -61,4 +65,4 @@ async function waitForAdditionalSnippets() { resolve(countSnippets()) }, 1000) }) -} \ No newline at end of file +} diff --git a/tracker/installation_support/verifier-v1.js b/tracker/installation_support/verifier-v1.js index 2b59066dd6de..aa40a46f7592 100644 --- a/tracker/installation_support/verifier-v1.js +++ b/tracker/installation_support/verifier-v1.js @@ -1,14 +1,17 @@ -import { waitForSnippetsV1 } from "./snippet-checks" -import { plausibleFunctionCheck } from "./plausible-function-check" -import { checkDataDomainMismatch } from "./check-data-domain-mismatch" -import { checkProxyLikely } from "./check-proxy-likely" -import { checkWordPress } from "./check-wordpress" -import { checkGTM } from "./check-gtm" -import { checkCookieBanner } from "./check-cookie-banner" -import { checkManualExtension } from "./check-manual-extension" -import { checkUnknownAttributes } from "./check-unknown-attributes" +import { waitForSnippetsV1 } from './snippet-checks' +import { plausibleFunctionCheck } from './plausible-function-check' +import { checkDataDomainMismatch } from './check-data-domain-mismatch' +import { checkProxyLikely } from './check-proxy-likely' +import { checkWordPress } from './check-wordpress' +import { checkGTM } from './check-gtm' +import { checkCookieBanner } from './check-cookie-banner' +import { checkManualExtension } from './check-manual-extension' +import { checkUnknownAttributes } from './check-unknown-attributes' -window.verifyPlausibleInstallation = async function(expectedDataDomain, debug) { +window.verifyPlausibleInstallation = async function ( + expectedDataDomain, + debug +) { function log(message) { if (debug) console.log('[Plausible Verification]', message) } @@ -21,7 +24,10 @@ window.verifyPlausibleInstallation = async function(expectedDataDomain, debug) { const plausibleInstalled = plausibleFunctionDiagnostics.plausibleInstalled const callbackStatus = plausibleFunctionDiagnostics.callbackStatus || 0 - const dataDomainMismatch = checkDataDomainMismatch(snippetData.nodes, expectedDataDomain) + const dataDomainMismatch = checkDataDomainMismatch( + snippetData.nodes, + expectedDataDomain + ) log(`dataDomainMismatch: ${dataDomainMismatch}`) const manualScriptExtension = checkManualExtension(snippetData.nodes) @@ -33,7 +39,7 @@ window.verifyPlausibleInstallation = async function(expectedDataDomain, debug) { const proxyLikely = checkProxyLikely(snippetData.nodes) log(`proxyLikely: ${proxyLikely}`) - const {wordpressPlugin, wordpressLikely} = checkWordPress(document) + const { wordpressPlugin, wordpressLikely } = checkWordPress(document) log(`wordpressPlugin: ${wordpressPlugin}`) log(`wordpressLikely: ${wordpressLikely}`) @@ -67,4 +73,4 @@ window.verifyPlausibleInstallation = async function(expectedDataDomain, debug) { unknownAttributes: unknownAttributes } } -} \ No newline at end of file +} diff --git a/tracker/npm_package/CHANGELOG.md b/tracker/npm_package/CHANGELOG.md index a6c6d3cd4d60..8f4a87669d03 100644 --- a/tracker/npm_package/CHANGELOG.md +++ b/tracker/npm_package/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Fix formatting issues. + ## [0.4.2] - 2025-09-04 - Remove redeclared variables from the tracker. diff --git a/tracker/npm_package/README.md b/tracker/npm_package/README.md index 5077c2c228e3..86ead9162621 100644 --- a/tracker/npm_package/README.md +++ b/tracker/npm_package/README.md @@ -6,6 +6,7 @@ Add [Plausible Analytics](https://plausible.io/) to your website. ## Features + - Small package size - Same features and codebase as the official script, but as an NPM module - Automatically track page views in your SPA apps @@ -39,20 +40,20 @@ init({ See also [plausible.d.ts](https://github.com/plausible/analytics/blob/master/tracker/npm_package/plausible.d.ts) for typescript types. -| Option | Description | Default | -| --- | --- | --- | -| `domain` | **Required** Your site's domain, as declared by you in Plausible's settings. | | -| `endpoint` | The URL of the Plausible API endpoint. See proxying guide at https://plausible.io/docs/proxy/introduction | `"https://plausible.io/api/event"` | -| `autoCapturePageviews` | Whether to automatically capture pageviews. | `true` | -| `hashBasedRouting` | Whether the page uses hash based routing. Read more at https://plausible.io/docs/hash-based-routing | `false` | -| `outboundLinks` | Whether to track outbound link clicks. | `false` | -| `fileDownloads` | Whether to track file downloads. | `false` | -| `formSubmissions` | Whether to track form submissions. | `false` | -| `captureOnLocalhost` | Whether to capture events on localhost. | `false` | -| `logging` | Whether to log on ignored events. | `true` | -| `customProperties` | Object or function that returns custom properties for a given event. | `{}` | -| `transformRequest` | Function that allows transforming or ignoring requests | | -| `bindToWindow` | Binds `track` to `window.plausible` which is used by Plausible installation verification tool to detect whether Plausible has been installed correctly. If `bindToWindow` is set to false, the installation verification tool won't be able to automatically detect it on your site. | `true` | +| Option | Description | Default | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | +| `domain` | **Required** Your site's domain, as declared by you in Plausible's settings. | | +| `endpoint` | The URL of the Plausible API endpoint. See proxying guide at https://plausible.io/docs/proxy/introduction | `"https://plausible.io/api/event"` | +| `autoCapturePageviews` | Whether to automatically capture pageviews. | `true` | +| `hashBasedRouting` | Whether the page uses hash based routing. Read more at https://plausible.io/docs/hash-based-routing | `false` | +| `outboundLinks` | Whether to track outbound link clicks. | `false` | +| `fileDownloads` | Whether to track file downloads. | `false` | +| `formSubmissions` | Whether to track form submissions. | `false` | +| `captureOnLocalhost` | Whether to capture events on localhost. | `false` | +| `logging` | Whether to log on ignored events. | `true` | +| `customProperties` | Object or function that returns custom properties for a given event. | `{}` | +| `transformRequest` | Function that allows transforming or ignoring requests | | +| `bindToWindow` | Binds `track` to `window.plausible` which is used by Plausible installation verification tool to detect whether Plausible has been installed correctly. If `bindToWindow` is set to false, the installation verification tool won't be able to automatically detect it on your site. | `true` | #### Using `customProperties` @@ -61,7 +62,7 @@ To track a custom property with every page view, you can use the `customProperti ```javascript init({ domain: 'my-app.com', - customProperties: { "content_category": "news" } + customProperties: { content_category: 'news' } }) ``` @@ -70,7 +71,7 @@ init({ ```javascript init({ domain: 'my-app.com', - customProperties: (eventName) => ({ "title": document.title }) + customProperties: (eventName) => ({ title: document.title }) }) ``` @@ -81,7 +82,7 @@ To track a custom event, call `track` and give it the name of the event. Custom ```javascript import { track } from '@plausible-analytics/tracker' -track('signup', { props: { tier: "startup" } }) +track('signup', { props: { tier: 'startup' } }) ``` To mark an event as non-interactive so it would not be counted towards bounce rate calculations, set `interactive` option: @@ -112,11 +113,11 @@ import { track } from '@plausible-analytics/tracker' track('some-event', { callback: (result) => { if (result?.status) { - console.debug("Request to plausible done. Status:", result.status) + console.debug('Request to plausible done. Status:', result.status) } else if (result?.error) { - console.log("Error handling request:", result.error) + console.log('Error handling request:', result.error) } else { - console.log("Request was ignored") + console.log('Request was ignored') } } }) diff --git a/tracker/npm_package/plausible.d.ts b/tracker/npm_package/plausible.d.ts index 5277f971e8c1..440c6b993ed7 100644 --- a/tracker/npm_package/plausible.d.ts +++ b/tracker/npm_package/plausible.d.ts @@ -6,7 +6,7 @@ export function track(eventName: string, options: PlausibleEventOptions): void export interface PlausibleConfig { // Your site's domain, as declared by you in Plausible's settings. - domain: string, + domain: string // The URL of the Plausible API endpoint. Defaults to https://plausible.io/api/event // See proxying guide at https://plausible.io/docs/proxy/introduction @@ -36,14 +36,18 @@ export interface PlausibleConfig { // Custom properties to add to all events tracked. // If passed as a function, it will be called when `track` is called. - customProperties?: CustomProperties | ((eventName: string) => CustomProperties) + customProperties?: + | CustomProperties + | ((eventName: string) => CustomProperties) // A function that can be used to transform the payload before it is sent to the API. // If the function returns null or any other falsy value, the event will be ignored. // // This can be used to avoid sending certain types of events, or modifying any event // parameters, e.g. to clean URLs of values that should not be recorded. - transformRequest?: (payload: PlausibleRequestPayload) => PlausibleRequestPayload | null + transformRequest?: ( + payload: PlausibleRequestPayload + ) => PlausibleRequestPayload | null // If enabled (the default), the script will set `window.plausible` after `init` is called. // This is used by the verifier to detect if the script is loaded from npm package. @@ -67,7 +71,9 @@ export interface PlausibleEventOptions { // When request is ignored, the result will be undefined. // When request was delivered, the result will be an object with the response status code of the request. // When there was a network error, the result will be an object with the error object. - callback?: (result?: { status: number } | { error: unknown } | undefined) => void + callback?: ( + result?: { status: number } | { error: unknown } | undefined + ) => void // Overrides the URL of the page that the event is being tracked on. // If not provided, `location.href` will be used. @@ -78,26 +84,26 @@ export type CustomProperties = Record export type PlausibleEventRevenue = { // Revenue amount in `currency` - amount: number | string, + amount: number | string // Currency is an ISO 4217 string representing the currency code, e.g. "USD" or "EUR" currency: string } export type PlausibleRequestPayload = { // Event name - n: string, + n: string // URL of the event - u: string, + u: string // Domain of the event - d: string, + d: string // Referrer - r?: string | null, + r?: string | null // Custom properties - p?: CustomProperties, + p?: CustomProperties // Revenue information - $?: PlausibleEventRevenue, + $?: PlausibleEventRevenue // Whether the event is interactive - i?: boolean, + i?: boolean } & Record // Default file types that are tracked when `fileDownloads` is enabled. diff --git a/tracker/package.json b/tracker/package.json index 68e9a5768c25..7d071a14f746 100644 --- a/tracker/package.json +++ b/tracker/package.json @@ -1,5 +1,5 @@ { - "tracker_script_version": 31, + "tracker_script_version": 32, "type": "module", "scripts": { "lint": "eslint", diff --git a/tracker/src/autocapture.js b/tracker/src/autocapture.js index 6a7d2aee274f..2d69a9970448 100644 --- a/tracker/src/autocapture.js +++ b/tracker/src/autocapture.js @@ -1,18 +1,20 @@ import { config } from './config' export function init(track) { - var lastPage; + var lastPage function page(isSPANavigation) { if (!(COMPILE_HASH && (!COMPILE_CONFIG || config.hashBasedRouting))) { - if (isSPANavigation && lastPage === location.pathname) return; + if (isSPANavigation && lastPage === location.pathname) return } lastPage = location.pathname track('pageview') } - var onSPANavigation = function () { page(true) } + var onSPANavigation = function () { + page(true) + } if (COMPILE_HASH && (!COMPILE_CONFIG || config.hashBasedRouting)) { window.addEventListener('hashchange', onSPANavigation) @@ -22,7 +24,7 @@ export function init(track) { var originalPushState = his['pushState'] his.pushState = function () { originalPushState.apply(this, arguments) - onSPANavigation(); + onSPANavigation() } window.addEventListener('popstate', onSPANavigation) } @@ -34,8 +36,11 @@ export function init(track) { } } - if (document.visibilityState === 'hidden' || document.visibilityState === 'prerender') { - document.addEventListener('visibilitychange', handleVisibilityChange); + if ( + document.visibilityState === 'hidden' || + document.visibilityState === 'prerender' + ) { + document.addEventListener('visibilitychange', handleVisibilityChange) } else { page() } @@ -43,7 +48,7 @@ export function init(track) { window.addEventListener('pageshow', function (event) { if (event.persisted) { // Page was restored from bfcache - track a pageview - page(); + page() } }) } diff --git a/tracker/src/config.js b/tracker/src/config.js index 9e516c296811..3577e7291526 100644 --- a/tracker/src/config.js +++ b/tracker/src/config.js @@ -9,10 +9,10 @@ var config = {} function defaultEndpoint() { if (COMPILE_COMPAT) { - var pathArray = scriptEl.src.split('/'); - var protocol = pathArray[0]; - var host = pathArray[2]; - return protocol + '//' + host + '/api/event'; + var pathArray = scriptEl.src.split('/') + var protocol = pathArray[0] + var host = pathArray[2] + return protocol + '//' + host + '/api/event' } else { return new URL(scriptEl.src).origin + '/api/event' } @@ -23,14 +23,14 @@ export function getOptionsWithDefaults(initOptions) { return Object.assign(initOptions, { autoCapturePageviews: initOptions.autoCapturePageviews !== false, logging: initOptions.logging !== false, - lib: initOptions.lib || 'web', + lib: initOptions.lib || 'web' }) } if (COMPILE_PLAUSIBLE_NPM) { return Object.assign(initOptions, { autoCapturePageviews: initOptions.autoCapturePageviews !== false, logging: initOptions.logging !== false, - bindToWindow: initOptions.bindToWindow !== false, + bindToWindow: initOptions.bindToWindow !== false }) } } @@ -38,10 +38,10 @@ export function getOptionsWithDefaults(initOptions) { export function init(options) { if (COMPILE_PLAUSIBLE_WEB) { // This will be dynamically replaced by a config json object in the script serving endpoint - config = "<%= @config_js %>" + config = '<%= @config_js %>' Object.assign(config, options, { // Explicitly set domain after other options are applied as `plausible-web` does not support overriding it, except by transformRequest - domain: config.domain, + domain: config.domain }) } else if (COMPILE_PLAUSIBLE_NPM) { if (config.isInitialized) { diff --git a/tracker/src/custom-events.js b/tracker/src/custom-events.js index a9a67f9e28a9..e159dc23b99a 100644 --- a/tracker/src/custom-events.js +++ b/tracker/src/custom-events.js @@ -3,14 +3,44 @@ import { config, scriptEl } from './config' import { track } from './track' -export var DEFAULT_FILE_TYPES = ['pdf', 'xlsx', 'docx', 'txt', 'rtf', 'csv', 'exe', 'key', 'pps', 'ppt', 'pptx', '7z', 'pkg', 'rar', 'gz', 'zip', 'avi', 'mov', 'mp4', 'mpeg', 'wmv', 'midi', 'mp3', 'wav', 'wma', 'dmg'] +export var DEFAULT_FILE_TYPES = [ + 'pdf', + 'xlsx', + 'docx', + 'txt', + 'rtf', + 'csv', + 'exe', + 'key', + 'pps', + 'ppt', + 'pptx', + '7z', + 'pkg', + 'rar', + 'gz', + 'zip', + 'avi', + 'mov', + 'mp4', + 'mpeg', + 'wmv', + 'midi', + 'mp3', + 'wav', + 'wma', + 'dmg' +] var MIDDLE_MOUSE_BUTTON = 1 var PARENTS_TO_SEARCH_LIMIT = 3 var fileTypesToTrack = DEFAULT_FILE_TYPES function getLinkEl(link) { - while (link && (typeof link.tagName === 'undefined' || !isLink(link) || !link.href)) { + while ( + link && + (typeof link.tagName === 'undefined' || !isLink(link) || !link.href) + ) { link = link.parentNode } return link @@ -22,21 +52,34 @@ function isLink(element) { function shouldInterceptNavigation(event, link) { // If default has been prevented by an external script, Plausible should not intercept navigation. - if (event.defaultPrevented) return false; - var target = link.target; + if (event.defaultPrevented) return false + var target = link.target // If the link directs to open the link in a different context, or we're not sure, do not intercept navigation - if (target && (typeof target !== 'string' || !target.match(/^_(self|parent|top)$/i))) return false; + if ( + target && + (typeof target !== 'string' || !target.match(/^_(self|parent|top)$/i)) + ) + return false // If the click is not a regular click (e.g. ctrl, meta, shift, or not a click event), do not intercept navigation - if (event.ctrlKey || event.metaKey || event.shiftKey || event.type !== 'click') return false; - - return true; + if ( + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.type !== 'click' + ) + return false + + return true } function handleLinkClickEvent(event) { - if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { return } + if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { + return + } var link = getLinkEl(event.target) - var hrefWithoutQuery = link && typeof link.href === 'string' && link.href.split('?')[0] + var hrefWithoutQuery = + link && typeof link.href === 'string' && link.href.split('?')[0] if (COMPILE_TAGGED_EVENTS) { if (isElementOrParentTagged(link, 0)) { @@ -48,13 +91,19 @@ function handleLinkClickEvent(event) { if (COMPILE_OUTBOUND_LINKS && (!COMPILE_CONFIG || config.outboundLinks)) { if (isOutboundLink(link)) { - return sendLinkClickEvent(event, link, { name: 'Outbound Link: Click', props: { url: link.href } }) + return sendLinkClickEvent(event, link, { + name: 'Outbound Link: Click', + props: { url: link.href } + }) } } if (COMPILE_FILE_DOWNLOADS && (!COMPILE_CONFIG || config.fileDownloads)) { if (isDownloadToTrack(hrefWithoutQuery)) { - return sendLinkClickEvent(event, link, { name: 'File Download', props: { url: hrefWithoutQuery } }) + return sendLinkClickEvent(event, link, { + name: 'File Download', + props: { url: hrefWithoutQuery } + }) } } } @@ -64,22 +113,29 @@ function sendLinkClickEvent(event, link, eventAttrs) { // or until analytics request finishes, otherwise navigation could prevent the analytics event from being sent. var attrs if (COMPILE_COMPAT) { - var followedLink = false - function followLink() { - if (!followedLink) { - followedLink = true - window.location = link.href + var followedLink = false + function followLink() { + if (!followedLink) { + followedLink = true + window.location = link.href + } } - } - if (shouldInterceptNavigation(event, link)) { - attrs = { props: eventAttrs.props, callback: followLink } - if (COMPILE_REVENUE) { - attrs.revenue = eventAttrs.revenue + if (shouldInterceptNavigation(event, link)) { + attrs = { props: eventAttrs.props, callback: followLink } + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) + setTimeout(followLink, 5000) + event.preventDefault() + } else { + attrs = { props: eventAttrs.props } + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) } - track(eventAttrs.name, attrs) - setTimeout(followLink, 5000) - event.preventDefault() } else { attrs = { props: eventAttrs.props } if (COMPILE_REVENUE) { @@ -87,23 +143,23 @@ function sendLinkClickEvent(event, link, eventAttrs) { } track(eventAttrs.name, attrs) } - } else { - attrs = { props: eventAttrs.props } - if (COMPILE_REVENUE) { - attrs.revenue = eventAttrs.revenue - } - track(eventAttrs.name, attrs) - } } function isOutboundLink(link) { - return link && typeof link.href === 'string' && link.host && link.host !== location.host + return ( + link && + typeof link.href === 'string' && + link.host && + link.host !== location.host + ) } function isDownloadToTrack(url) { - if (!url) { return false } + if (!url) { + return false + } - var fileType = url.split('.').pop(); + var fileType = url.split('.').pop() return fileTypesToTrack.some(function (fileTypeToTrack) { return fileTypeToTrack === fileType }) @@ -113,29 +169,39 @@ function isTagged(element) { var classList = element && element.classList if (classList) { for (var i = 0; i < classList.length; i++) { - if (classList.item(i).match(/plausible-event-name(=|--)(.+)/)) { return true } + if (classList.item(i).match(/plausible-event-name(=|--)(.+)/)) { + return true + } } } return false } function isElementOrParentTagged(element, parentsChecked) { - if (!element || parentsChecked > PARENTS_TO_SEARCH_LIMIT) { return false } - if (isTagged(element)) { return true } + if (!element || parentsChecked > PARENTS_TO_SEARCH_LIMIT) { + return false + } + if (isTagged(element)) { + return true + } return isElementOrParentTagged(element.parentNode, parentsChecked + 1) } // Finds event attributes by iterating over the given element's (or its // parent's) classList. Returns an object with `name` and `props` keys. function getTaggedEventAttributes(htmlElement) { - var taggedElement = isTagged(htmlElement) ? htmlElement : htmlElement && htmlElement.parentNode + var taggedElement = isTagged(htmlElement) + ? htmlElement + : htmlElement && htmlElement.parentNode var eventAttrs = { name: null, props: {} } if (COMPILE_REVENUE) { eventAttrs.revenue = {} } var classList = taggedElement && taggedElement.classList - if (!classList) { return eventAttrs } + if (!classList) { + return eventAttrs + } for (var i = 0; i < classList.length; i++) { var className = classList.item(i) @@ -173,7 +239,10 @@ export function init() { if (COMPILE_FILE_DOWNLOADS && (!COMPILE_CONFIG || config.fileDownloads)) { if (COMPILE_CONFIG) { - if (typeof config.fileDownloads === 'object' && Array.isArray(config.fileDownloads.fileExtensions)) { + if ( + typeof config.fileDownloads === 'object' && + Array.isArray(config.fileDownloads.fileExtensions) + ) { fileTypesToTrack = config.fileDownloads.fileExtensions } } else { @@ -181,13 +250,14 @@ export function init() { var addFileTypesAttr = scriptEl.getAttribute('add-file-types') if (fileTypesAttr) { - fileTypesToTrack = fileTypesAttr.split(",") + fileTypesToTrack = fileTypesAttr.split(',') } if (addFileTypesAttr) { - fileTypesToTrack = addFileTypesAttr.split(",").concat(DEFAULT_FILE_TYPES) + fileTypesToTrack = addFileTypesAttr + .split(',') + .concat(DEFAULT_FILE_TYPES) } } - } if (COMPILE_CONFIG && config.formSubmissions) { @@ -197,55 +267,61 @@ export function init() { // If the form is tagged, we don't track it as a generic form submission. return } - track('Form: Submission'); + track('Form: Submission') } } - document.addEventListener('submit', trackFormSubmission, true); + document.addEventListener('submit', trackFormSubmission, true) } if (COMPILE_TAGGED_EVENTS) { function handleTaggedFormSubmitEvent(event) { var form = event.target var eventAttrs = getTaggedEventAttributes(form) - if (!eventAttrs.name) { return } - + if (!eventAttrs.name) { + return + } + var attrs // In some legacy variants, this block delays submitting the form for up to 5 seconds, // or until analytics request finishes, otherwise form-related navigation could prevent the analytics event from being sent. if (COMPILE_COMPAT) { - event.preventDefault() - var formSubmitted = false + event.preventDefault() + var formSubmitted = false - function submitForm() { - if (!formSubmitted) { - formSubmitted = true - form.submit() + function submitForm() { + if (!formSubmitted) { + formSubmitted = true + form.submit() + } } - } - setTimeout(submitForm, 5000) + setTimeout(submitForm, 5000) - attrs = { props: eventAttrs.props, callback: submitForm } - if (COMPILE_REVENUE) { - attrs.revenue = eventAttrs.revenue - } - track(eventAttrs.name, attrs) + attrs = { props: eventAttrs.props, callback: submitForm } + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) } else { - attrs = { props: eventAttrs.props } - if (COMPILE_REVENUE) { - attrs.revenue = eventAttrs.revenue - } - track(eventAttrs.name, attrs) + attrs = { props: eventAttrs.props } + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) } } function isForm(element) { - return element && element.tagName && element.tagName.toLowerCase() === 'form' + return ( + element && element.tagName && element.tagName.toLowerCase() === 'form' + ) } function handleTaggedElementClickEvent(event) { - if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { return } + if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { + return + } var clicked = event.target @@ -254,12 +330,20 @@ export function init() { // Iterate over parents to find the tagged element. Also search for // a link element to call for different tracking behavior if found. for (var i = 0; i <= PARENTS_TO_SEARCH_LIMIT; i++) { - if (!clicked) { break } + if (!clicked) { + break + } // Clicks inside forms are not tracked. Only form submits are. - if (isForm(clicked)) { return } - if (isLink(clicked)) { clickedLink = clicked } - if (isTagged(clicked)) { taggedElement = clicked } + if (isForm(clicked)) { + return + } + if (isLink(clicked)) { + clickedLink = clicked + } + if (isTagged(clicked)) { + taggedElement = clicked + } clicked = clicked.parentNode } @@ -281,7 +365,6 @@ export function init() { } } } - document.addEventListener('submit', handleTaggedFormSubmitEvent) document.addEventListener('click', handleTaggedElementClickEvent) diff --git a/tracker/src/engagement.js b/tracker/src/engagement.js index ea30f6ca9b3c..fcc302169e1a 100644 --- a/tracker/src/engagement.js +++ b/tracker/src/engagement.js @@ -57,7 +57,11 @@ function triggerEngagement() { Also, we don't send engagements if the current pageview is ignored (onIgnoredEvent) */ - if (!currentEngagementIgnored && (currentEngagementMaxScrollDepth < maxScrollDepthPx || engagementTime >= 3000)) { + if ( + !currentEngagementIgnored && + (currentEngagementMaxScrollDepth < maxScrollDepthPx || + engagementTime >= 3000) + ) { currentEngagementMaxScrollDepth = maxScrollDepthPx var payload = { @@ -83,7 +87,11 @@ function triggerEngagement() { } function onVisibilityChange() { - if (document.visibilityState === 'visible' && document.hasFocus() && runningEngagementStart === 0) { + if ( + document.visibilityState === 'visible' && + document.hasFocus() && + runningEngagementStart === 0 + ) { runningEngagementStart = Date.now() } else if (document.visibilityState === 'hidden' || !document.hasFocus()) { // Tab went back to background or lost focus. Save the engaged time so far @@ -112,7 +120,6 @@ function getEngagementTime() { } } - var currentDocumentHeight var maxScrollDepthPx @@ -135,7 +142,9 @@ function getCurrentScrollDepthPx() { var viewportHeight = window.innerHeight || el.clientHeight || 0 var scrollTop = window.scrollY || el.scrollTop || body.scrollTop || 0 - return currentDocumentHeight <= viewportHeight ? currentDocumentHeight : scrollTop + viewportHeight + return currentDocumentHeight <= viewportHeight + ? currentDocumentHeight + : scrollTop + viewportHeight } export function init() { @@ -151,7 +160,9 @@ export function init() { var count = 0 var interval = setInterval(function () { currentDocumentHeight = getDocumentHeight() - if (++count === 15) { clearInterval(interval) } + if (++count === 15) { + clearInterval(interval) + } }, 200) }) diff --git a/tracker/src/networking.js b/tracker/src/networking.js index 8bc4c0199fa4..51797b393b48 100644 --- a/tracker/src/networking.js +++ b/tracker/src/networking.js @@ -1,17 +1,21 @@ export function sendRequest(endpoint, payload, options) { if (COMPILE_COMPAT) { - var request = new XMLHttpRequest(); - request.open('POST', endpoint, true); - request.setRequestHeader('Content-Type', 'text/plain'); + var request = new XMLHttpRequest() + request.open('POST', endpoint, true) + request.setRequestHeader('Content-Type', 'text/plain') - request.send(JSON.stringify(payload)); + request.send(JSON.stringify(payload)) request.onreadystatechange = function () { if (request.readyState === 4) { if (request.status === 0) { - options && options.callback && options.callback({ error: new Error('Network error') }) + options && + options.callback && + options.callback({ error: new Error('Network error') }) } else { - options && options.callback && options.callback({ status: request.status }) + options && + options.callback && + options.callback({ status: request.status }) } } } @@ -24,11 +28,15 @@ export function sendRequest(endpoint, payload, options) { }, keepalive: true, body: JSON.stringify(payload) - }).then(function (response) { - options && options.callback && options.callback({ status: response.status }) - }).catch(function (error) { - options && options.callback && options.callback({ error }) }) + .then(function (response) { + options && + options.callback && + options.callback({ status: response.status }) + }) + .catch(function (error) { + options && options.callback && options.callback({ error }) + }) } } } diff --git a/tracker/src/track.js b/tracker/src/track.js index 464d7c232b73..1b2b0d4ed220 100644 --- a/tracker/src/track.js +++ b/tracker/src/track.js @@ -1,10 +1,16 @@ import { sendRequest } from './networking' -import { prePageviewTrack, postPageviewTrack, onPageviewIgnored } from './engagement' +import { + prePageviewTrack, + postPageviewTrack, + onPageviewIgnored +} from './engagement' import { config, scriptEl } from './config' export function track(eventName, options) { if (COMPILE_PLAUSIBLE_NPM && !config.isInitialized) { - throw new Error('plausible.track() can only be called after plausible.init()') + throw new Error( + 'plausible.track() can only be called after plausible.init()' + ) } var isPageview = eventName === 'pageview' @@ -14,10 +20,21 @@ export function track(eventName, options) { } if (!(COMPILE_LOCAL && (!COMPILE_CONFIG || config.captureOnLocalhost))) { - if (/^localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname) || location.protocol === 'file:') { + if ( + /^localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test( + location.hostname + ) || + location.protocol === 'file:' + ) { return onIgnoredEvent(eventName, options, 'localhost') } - if ((window._phantom || window.__nightmare || window.navigator.webdriver || window.Cypress) && !window.__plausible) { + if ( + (window._phantom || + window.__nightmare || + window.navigator.webdriver || + window.Cypress) && + !window.__plausible + ) { return onIgnoredEvent(eventName, options) } } @@ -40,15 +57,29 @@ export function track(eventName, options) { actualPath += location.hash } - // eslint-disable-next-line no-useless-escape - return actualPath.match(new RegExp('^' + wildcardPath.trim().replace(/\*\*/g, '.*').replace(/([^\.])\*/g, '$1[^\\s\/]*') + '\/?$')) + return actualPath.match( + new RegExp( + '^' + + wildcardPath + .trim() + .replace(/\*\*/g, '.*') + // eslint-disable-next-line no-useless-escape + .replace(/([^\.])\*/g, '$1[^\\s\/]*') + + // eslint-disable-next-line no-useless-escape + '\/?$' + ) + ) } if (isPageview) { - var isIncluded = !dataIncludeAttr || (dataIncludeAttr && dataIncludeAttr.split(',').some(pathMatches)) - var isExcluded = dataExcludeAttr && dataExcludeAttr.split(',').some(pathMatches) - - if (!isIncluded || isExcluded) return onIgnoredEvent(eventName, options, 'exclusion rule') + var isIncluded = + !dataIncludeAttr || + (dataIncludeAttr && dataIncludeAttr.split(',').some(pathMatches)) + var isExcluded = + dataExcludeAttr && dataExcludeAttr.split(',').some(pathMatches) + + if (!isIncluded || isExcluded) + return onIgnoredEvent(eventName, options, 'exclusion rule') } } @@ -114,7 +145,10 @@ export function track(eventName, options) { payload.h = 1 } - if ((COMPILE_PLAUSIBLE_WEB || COMPILE_PLAUSIBLE_NPM) && typeof config.transformRequest === 'function') { + if ( + (COMPILE_PLAUSIBLE_WEB || COMPILE_PLAUSIBLE_NPM) && + typeof config.transformRequest === 'function' + ) { payload = config.transformRequest(payload) if (!payload) { @@ -129,10 +163,9 @@ export function track(eventName, options) { sendRequest(config.endpoint, payload, options) } - function onIgnoredEvent(eventName, options, reason) { if (reason && config.logging) { - console.warn('Ignoring Event: ' + reason); + console.warn('Ignoring Event: ' + reason) } options && options.callback && options.callback() diff --git a/tracker/src/web-snippet.js b/tracker/src/web-snippet.js index 28a804c3f15b..1eb57011800d 100644 --- a/tracker/src/web-snippet.js +++ b/tracker/src/web-snippet.js @@ -1,12 +1,16 @@ // This snippet is shown to users to install plausible tracker script // diff --git a/tracker/test/callbacks.spec.ts b/tracker/test/callbacks.spec.ts index ffdaafdfc6c8..383e07865443 100644 --- a/tracker/test/callbacks.spec.ts +++ b/tracker/test/callbacks.spec.ts @@ -37,7 +37,7 @@ for (const mode of ['web', 'esm', 'legacy']) { apiPath: 'h://no-exist', mockPath: `/api/event`, fulfill: { status: 202 }, - expectedResult: { error: expect.any(Error)} + expectedResult: { error: expect.any(Error) } }, { name: 'on ignored request (because of not having capturing events on localhost)', diff --git a/tracker/test/custom-properties.spec.ts b/tracker/test/custom-properties.spec.ts index 60971e64fde5..1b49a0bbb0a9 100644 --- a/tracker/test/custom-properties.spec.ts +++ b/tracker/test/custom-properties.spec.ts @@ -163,7 +163,11 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `` + bodyContent: /* HTML */ `` }) await expectPlausibleInAction(page, { @@ -210,7 +214,11 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `` + bodyContent: /* HTML */ `` }) await expectPlausibleInAction(page, { @@ -253,7 +261,11 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `` + bodyContent: /* HTML */ `` }) await expectPlausibleInAction(page, { diff --git a/tracker/test/engagement.spec.js b/tracker/test/engagement.spec.js index 46cc4d949e7d..af325f7ac450 100644 --- a/tracker/test/engagement.spec.js +++ b/tracker/test/engagement.spec.js @@ -1,30 +1,47 @@ -import { expect } from "@playwright/test" -import { expectPlausibleInAction, hideAndShowCurrentTab, focus, blur, blurAndFocusPage, tracker_script_version } from './support/test-utils' +import { expect } from '@playwright/test' +import { + expectPlausibleInAction, + hideAndShowCurrentTab, + focus, + blur, + blurAndFocusPage, + tracker_script_version +} from './support/test-utils' import { test } from '@playwright/test' import { LOCAL_SERVER_ADDR } from './support/server' test.describe('engagement events', () => { - test('sends an engagement event with time measurement when navigating to the next page', async ({ page }) => { + test('sends an engagement event with time measurement when navigating to the next page', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) await page.waitForTimeout(1000) const [request] = await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html`, v: tracker_script_version}] + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement.html`, + v: tracker_script_version + } + ] }) expect(request.e).toBeGreaterThan(1000) expect(request.e).toBeLessThan(1500) }) - test('sends an event and a pageview on hash-based SPA navigation', async ({ page }) => { + test('sends an event and a pageview on hash-based SPA navigation', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement-hash.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) await page.waitForTimeout(1000) @@ -32,8 +49,11 @@ test.describe('engagement events', () => { const [request] = await expectPlausibleInAction(page, { action: () => page.click('#hash-nav'), expectedRequests: [ - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html`}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash`} + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html` }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash` + } ] }) @@ -41,10 +61,12 @@ test.describe('engagement events', () => { expect(request.e).toBeLessThan(1500) }) - test('sends an event and a pageview on history-based SPA navigation', async ({ page }) => { + test('sends an event and a pageview on history-based SPA navigation', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) await page.waitForTimeout(1000) @@ -52,8 +74,8 @@ test.describe('engagement events', () => { const [request] = await expectPlausibleInAction(page, { action: () => page.click('#history-nav'), expectedRequests: [ - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html`}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/another-page`} + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` }, + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/another-page` } ] }) @@ -61,10 +83,12 @@ test.describe('engagement events', () => { expect(request.e).toBeLessThan(1500) }) - test('sends engagements when pageviews are triggered manually on a SPA', async ({ page }) => { + test('sends engagements when pageviews are triggered manually on a SPA', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement-hash-manual.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) await page.waitForTimeout(1000) @@ -72,8 +96,8 @@ test.describe('engagement events', () => { const [request] = await expectPlausibleInAction(page, { action: () => page.click('#about-us-hash-link'), expectedRequests: [ - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/#home`}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/#about-us`} + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/#home` }, + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/#about-us` } ] }) @@ -86,45 +110,53 @@ test.describe('engagement events', () => { await expectPlausibleInAction(page, { action: () => page.click('#pageview-trigger-custom-url'), - expectedRequests: [{n: 'pageview', u: 'https://example.com/custom/location'}], + expectedRequests: [ + { n: 'pageview', u: 'https://example.com/custom/location' } + ] }) await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - expectedRequests: [{n: 'engagement', u: 'https://example.com/custom/location'}] + expectedRequests: [ + { n: 'engagement', u: 'https://example.com/custom/location' } + ] }) }) - test('does not send an event when pageview was not sent in manual mode', async ({ page }) => { + test('does not send an event when pageview was not sent in manual mode', async ({ + page + }) => { await page.goto('/engagement-manual.html') await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - refutedRequests: [{n: 'engagement'}] + refutedRequests: [{ n: 'engagement' }] }) }) - test('script.exclusions.hash.js sends an event only from URLs where a pageview was sent', async ({ page }) => { + test('script.exclusions.hash.js sends an event only from URLs where a pageview was sent', async ({ + page + }) => { const pageBaseURL = `${LOCAL_SERVER_ADDR}/engagement-hash-exclusions.html` await expectPlausibleInAction(page, { action: () => page.goto('/engagement-hash-exclusions.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) // After the initial pageview is sent, navigate to ignored page -> // engagement event is sent from the initial page URL await expectPlausibleInAction(page, { action: () => page.click('#ignored-hash-link'), - expectedRequests: [{n: 'engagement', u: pageBaseURL, h: 1}] + expectedRequests: [{ n: 'engagement', u: pageBaseURL, h: 1 }] }) // Navigate from ignored page to a tracked page -> // no engagement from the current page, pageview on the next page await expectPlausibleInAction(page, { action: () => page.click('#hash-link-1'), - expectedRequests: [{n: 'pageview', u: `${pageBaseURL}#hash1`, h: 1}], - refutedRequests: [{n: 'engagement'}] + expectedRequests: [{ n: 'pageview', u: `${pageBaseURL}#hash1`, h: 1 }], + refutedRequests: [{ n: 'engagement' }] }) // Navigate from a tracked page to another tracked page -> @@ -132,94 +164,109 @@ test.describe('engagement events', () => { await expectPlausibleInAction(page, { action: () => page.click('#hash-link-2'), expectedRequests: [ - {n: 'engagement', u: `${pageBaseURL}#hash1`, h: 1}, - {n: 'pageview', u: `${pageBaseURL}#hash2`, h: 1} + { n: 'engagement', u: `${pageBaseURL}#hash1`, h: 1 }, + { n: 'pageview', u: `${pageBaseURL}#hash2`, h: 1 } ] }) }) - test('sends an event with the same props as pageview (manual extension)', async ({ page }) => { + test('sends an event with the same props as pageview (manual extension)', async ({ + page + }) => { await page.goto('/engagement-manual.html') await expectPlausibleInAction(page, { action: () => page.click('#pageview-trigger-custom-props'), - expectedRequests: [{n: 'pageview', p: {author: 'John'}}], + expectedRequests: [{ n: 'pageview', p: { author: 'John' } }] }) await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - expectedRequests: [{n: 'engagement', p: {author: 'John'}}] + expectedRequests: [{ n: 'engagement', p: { author: 'John' } }] }) }) - test('sends an event with the same props as pageview (pageview-props extension)', async ({ page }) => { + test('sends an event with the same props as pageview (pageview-props extension)', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement-pageview-props.html'), - expectedRequests: [{n: 'pageview', p: {author: 'John', index: "0"}}], + expectedRequests: [{ n: 'pageview', p: { author: 'John', index: '0' } }] }) await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - expectedRequests: [{n: 'engagement', p: {author: 'John', index: "0"}}] + expectedRequests: [{ n: 'engagement', p: { author: 'John', index: '0' } }] }) }) - test('pageview props with custom events and values changing mid-view (pageview-props extension)', async ({ page }) => { + test('pageview props with custom events and values changing mid-view (pageview-props extension)', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement-pageview-props.html'), - expectedRequests: [{n: 'pageview', p: {author: 'John', index: "0"}}], + expectedRequests: [{ n: 'pageview', p: { author: 'John', index: '0' } }] }) await page.click('#increment-event-index') await expectPlausibleInAction(page, { action: () => page.click('#custom-event-button'), - expectedRequests: [{n: 'Custom event', p: {author: 'Karl', index: "1"}}] + expectedRequests: [ + { n: 'Custom event', p: { author: 'Karl', index: '1' } } + ] }) await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - expectedRequests: [{n: 'engagement', p: {author: 'John', index: "0"}}] + expectedRequests: [{ n: 'engagement', p: { author: 'John', index: '0' } }] }) }) - test('sends an event with the same props as pageview (hash navigation / pageview-props extension)', async ({ page }) => { + test('sends an event with the same props as pageview (hash navigation / pageview-props extension)', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement-hash-pageview-props.html'), - expectedRequests: [{n: 'pageview', p: {}}], + expectedRequests: [{ n: 'pageview', p: {} }] }) await expectPlausibleInAction(page, { action: () => page.click('#john-post'), expectedRequests: [ - {n: 'engagement', p: {}}, - {n: 'pageview', p: {author: 'john'}} + { n: 'engagement', p: {} }, + { n: 'pageview', p: { author: 'john' } } ] }) await expectPlausibleInAction(page, { action: () => page.click('#jane-post'), expectedRequests: [ - {n: 'engagement', p: {author: 'john'}}, - {n: 'pageview', p: {author: 'jane'}} + { n: 'engagement', p: { author: 'john' } }, + { n: 'pageview', p: { author: 'jane' } } ] }) await expectPlausibleInAction(page, { action: () => page.click('#home'), expectedRequests: [ - {n: 'engagement', p: {author: 'jane'}}, - {n: 'pageview', p: {}} + { n: 'engagement', p: { author: 'jane' } }, + { n: 'pageview', p: {} } ] }) }) - test('sends an event when plausible API is slow and user navigates away before response is received', async ({ page, browserName }) => { + test('sends an event when plausible API is slow and user navigates away before response is received', async ({ + page, + browserName + }) => { test.skip(browserName === 'chromium', 'flaky') await expectPlausibleInAction(page, { action: () => page.goto('/engagement.html'), - expectedRequests: [{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement.html`}], + expectedRequests: [ + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] }) await expectPlausibleInAction(page, { @@ -229,47 +276,65 @@ test.describe('engagement events', () => { await page.click('#back-button-trigger') }, expectedRequests: [ - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html`}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement-pageview-props.html`, p: {author: 'John', index: "0"}}, - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-pageview-props.html`, p: {author: 'John', index: "0"}}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement.html`} + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-pageview-props.html`, + p: { author: 'John', index: '0' } + }, + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement-pageview-props.html`, + p: { author: 'John', index: '0' } + }, + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement.html` } ], responseDelay: 1000 }) }) - test('sends engagement events when tab toggles between foreground and background', async ({ page }) => { + test('sends engagement events when tab toggles between foreground and background', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) const [request1] = await expectPlausibleInAction(page, { - action: () => hideAndShowCurrentTab(page, {delay: 2000}), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html`}], + action: () => hideAndShowCurrentTab(page, { delay: 2000 }), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] }) expect(request1.e).toBeLessThan(500) await page.waitForTimeout(3000) const [request2] = await expectPlausibleInAction(page, { - action: () => hideAndShowCurrentTab(page, {delay: 2000}), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html`}], + action: () => hideAndShowCurrentTab(page, { delay: 2000 }), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] }) expect(request2.e).toBeGreaterThan(3000) expect(request2.e).toBeLessThan(3500) }) - test('does not send engagement events when tab is only open for a short time until over 3000ms has passed', async ({ page }) => { + test('does not send engagement events when tab is only open for a short time until over 3000ms has passed', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) const [request1] = await expectPlausibleInAction(page, { action: () => hideAndShowCurrentTab(page), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html`}], + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] }) expect(request1.e).toBeLessThan(500) @@ -277,15 +342,17 @@ test.describe('engagement events', () => { await expectPlausibleInAction(page, { action: () => hideAndShowCurrentTab(page), - refutedRequests: [{n: 'engagement'}], + refutedRequests: [{ n: 'engagement' }], mockRequestTimeout: 100 }) await page.waitForTimeout(2500) const [request2] = await expectPlausibleInAction(page, { - action: () => hideAndShowCurrentTab(page, {delay: 3000}), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html`}], + action: () => hideAndShowCurrentTab(page, { delay: 3000 }), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] }) // Sum of both visibility times @@ -296,7 +363,7 @@ test.describe('engagement events', () => { test('tracks engagement time properly in a SPA', async ({ page }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement-hash.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) await page.waitForTimeout(1000) @@ -304,8 +371,11 @@ test.describe('engagement events', () => { const [request] = await expectPlausibleInAction(page, { action: () => page.click('#hash-nav'), expectedRequests: [ - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html`}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash`} + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html` }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash` + } ] }) @@ -315,8 +385,14 @@ test.describe('engagement events', () => { const [request2] = await expectPlausibleInAction(page, { action: () => page.click('#hash-nav-2'), expectedRequests: [ - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash`}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#another-hash`} + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash` + }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#another-hash` + } ] }) @@ -326,7 +402,12 @@ test.describe('engagement events', () => { const [request3] = await expectPlausibleInAction(page, { action: () => hideAndShowCurrentTab(page), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#another-hash`}], + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#another-hash` + } + ] }) expect(request3.e).toBeGreaterThan(3000) @@ -336,8 +417,14 @@ test.describe('engagement events', () => { const [request4] = await expectPlausibleInAction(page, { action: () => page.click('#hash-nav'), expectedRequests: [ - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#another-hash`}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash`} + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#another-hash` + }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash` + } ] }) @@ -345,15 +432,19 @@ test.describe('engagement events', () => { expect(request4.e).toBeLessThan(3500) }) - test('tracks engagement time whilst tab gains and loses focus', async ({ page }) => { + test('tracks engagement time whilst tab gains and loses focus', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/engagement.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) const [request1] = await expectPlausibleInAction(page, { action: () => blur(page), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html`}], + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] }) expect(request1.e).toBeLessThan(500) @@ -362,7 +453,7 @@ test.describe('engagement events', () => { await expectPlausibleInAction(page, { action: () => blurAndFocusPage(page, { delay: 3000 }), - refutedRequests: [{n: 'engagement'}], + refutedRequests: [{ n: 'engagement' }], mockRequestTimeout: 100 }) @@ -370,7 +461,9 @@ test.describe('engagement events', () => { const [request2] = await expectPlausibleInAction(page, { action: () => blur(page), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html`}], + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] }) expect(request2.e).toBeGreaterThan(3500) diff --git a/tracker/test/features-hierarchy-on-overlap.spec.ts b/tracker/test/features-hierarchy-on-overlap.spec.ts index 58f56becf4ee..63ea5b1c8a47 100644 --- a/tracker/test/features-hierarchy-on-overlap.spec.ts +++ b/tracker/test/features-hierarchy-on-overlap.spec.ts @@ -50,7 +50,11 @@ for (const mode of ['legacy', 'web'] as const) { }, mode ), - bodyContent: `Outbound Download` + bodyContent: /* HTML */ `Outbound Download` }) await page.goto(url) @@ -93,7 +97,7 @@ for (const mode of ['legacy', 'web'] as const) { }, mode ), - bodyContent: `Get file` + bodyContent: /* HTML */ `Get file` }) await page.goto(url) @@ -137,7 +141,7 @@ for (const mode of ['legacy', 'web'] as const) { }, mode ), - bodyContent: `Get file` + bodyContent: /* HTML */ `Get file` }) await page.goto(url) @@ -159,9 +163,9 @@ for (const mode of ['legacy', 'web'] as const) { for (const mode of ['web', 'esm'] as const) { test.describe(`form submissions and tagged events features hierarchy on overlap v2-specific (${mode})`, () => { - test('sends only tagged event if a form is tagged', async ({ - page - }, { testId }) => { + test('sends only tagged event if a form is tagged', async ({ page }, { + testId + }) => { const config = { ...DEFAULT_CONFIG, formSubmissions: true } const { url } = await initializePageDynamically(page, { testId, @@ -178,9 +182,12 @@ for (const mode of ['web', 'esm'] as const) { }, mode ), - bodyContent: ` + bodyContent: /* HTML */ `
    - +
    ` }) @@ -223,7 +230,7 @@ for (const mode of ['web', 'esm'] as const) { }, mode ), - bodyContent: ` + bodyContent: /* HTML */ `
    diff --git a/tracker/test/file-downloads.spec.ts b/tracker/test/file-downloads.spec.ts index dd6192e863b2..757f76757f85 100644 --- a/tracker/test/file-downloads.spec.ts +++ b/tracker/test/file-downloads.spec.ts @@ -45,7 +45,7 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `📥` + bodyContent: /* HTML */ `📥` }) await expectPlausibleInAction(page, { @@ -90,7 +90,7 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `📥` + bodyContent: /* HTML */ `📥` }) await expectPlausibleInAction(page, { @@ -149,7 +149,8 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `📥📥` + bodyContent: /* HTML */ `📥📥` }) await page.goto(url) await expectPlausibleInAction(page, { @@ -193,7 +194,7 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `📥` + bodyContent: /* HTML */ `📥` }) await page.goto(url) @@ -230,7 +231,7 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `📥` + bodyContent: /* HTML */ `📥` }) await page.goto(url) @@ -270,7 +271,7 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `📥` + bodyContent: /* HTML */ `📥` }) await page.goto(url) @@ -305,7 +306,9 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `
    📥
    ` + bodyContent: /* HTML */ `
    📥
    ` }) await page.goto(url) @@ -350,7 +353,7 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `Download` + bodyContent: /* HTML */ `Download` }) await page.goto(url) await page.click('a') @@ -396,7 +399,9 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `Download PDF` + bodyContent: /* HTML */ `Download PDF` }) await page.goto(url) @@ -430,7 +435,7 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `📥` + bodyContent: /* HTML */ `📥` }) await page.goto(url) @@ -476,7 +481,8 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `📥📥` + bodyContent: /* HTML */ `📥📥` }) await page.goto(url) await expectPlausibleInAction(page, { @@ -517,9 +523,11 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: ` - - ` + bodyContent: /* HTML */ ` + + + + ` }) const pageErrors: Error[] = [] @@ -603,7 +611,9 @@ test.describe('file downloads feature when using legacy .compat extension', () = testId, scriptConfig: '', - bodyContent: `

    📥

    ` + bodyContent: /* HTML */ `

    📥

    ` }) await page.goto(url) await page.click(click.element, { modifiers: click.modifiers }) @@ -668,7 +678,7 @@ test.describe('file downloads feature when using legacy .compat extension', () = testId, scriptConfig: '', - bodyContent: `📥` + bodyContent: /* HTML */ `📥` }) await page.goto(url) @@ -721,7 +731,7 @@ test.describe('file downloads feature when using legacy .compat extension', () = testId, scriptConfig: '', - bodyContent: `📥` + bodyContent: /* HTML */ `📥` }) await page.goto(url) const navigationPromise = page.waitForRequest(filePath, { diff --git a/tracker/test/fixtures/cookies-cookiebot.html b/tracker/test/fixtures/cookies-cookiebot.html index e1047d7b09ac..92c9080d8c69 100644 --- a/tracker/test/fixtures/cookies-cookiebot.html +++ b/tracker/test/fixtures/cookies-cookiebot.html @@ -1,11 +1,11 @@ - + Plausible Playwright tests - + cookiebot fixture
    Plausible Playwright tests - + iubenda fixture diff --git a/tracker/test/fixtures/cookies-onetrust.html b/tracker/test/fixtures/cookies-onetrust.html index dabc16b846d8..4ed7275e3ed4 100644 --- a/tracker/test/fixtures/cookies-onetrust.html +++ b/tracker/test/fixtures/cookies-onetrust.html @@ -1,4 +1,4 @@ - + diff --git a/tracker/test/fixtures/cookies-quantcast.html b/tracker/test/fixtures/cookies-quantcast.html index 31825cdf08de..77de8238c17c 100644 --- a/tracker/test/fixtures/cookies-quantcast.html +++ b/tracker/test/fixtures/cookies-quantcast.html @@ -1,4 +1,4 @@ - + diff --git a/tracker/test/fixtures/engagement-hash-exclusions.html b/tracker/test/fixtures/engagement-hash-exclusions.html index 5133f5ef45dc..38c15680064e 100644 --- a/tracker/test/fixtures/engagement-hash-exclusions.html +++ b/tracker/test/fixtures/engagement-hash-exclusions.html @@ -1,18 +1,22 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - - - - Ignored Hash Link - Hash Link 1 - Hash Link 2 - - + + Ignored Hash Link + Hash Link 1 + Hash Link 2 + diff --git a/tracker/test/fixtures/engagement-hash-manual.html b/tracker/test/fixtures/engagement-hash-manual.html index 4195a1296451..a0d0aa6a784f 100644 --- a/tracker/test/fixtures/engagement-hash-manual.html +++ b/tracker/test/fixtures/engagement-hash-manual.html @@ -1,39 +1,43 @@ - + + + + + + Plausible Playwright tests + + + - - - - - Plausible Playwright tests - - - + + About us + Home - - About us - Home +
    +

    Home

    +
    -
    -

    Home

    -
    + - + window.plausible('pageview', { u: 'http://localhost:3000/#home' }) + window.addEventListener('hashchange', updateContent) + + diff --git a/tracker/test/fixtures/engagement-hash-pageview-props.html b/tracker/test/fixtures/engagement-hash-pageview-props.html index 0953b8de31a0..b21777098ece 100644 --- a/tracker/test/fixtures/engagement-hash-pageview-props.html +++ b/tracker/test/fixtures/engagement-hash-pageview-props.html @@ -1,36 +1,47 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - + +

    Blog

    - -

    Blog

    + + + - - - +
    -
    - - - + + diff --git a/tracker/test/fixtures/engagement-hash.html b/tracker/test/fixtures/engagement-hash.html index a70812b38ac0..b6a3cf049e4a 100644 --- a/tracker/test/fixtures/engagement-hash.html +++ b/tracker/test/fixtures/engagement-hash.html @@ -1,20 +1,16 @@ - + + + + + + Plausible Playwright tests + + + - - - - - Plausible Playwright tests - - - - - - Hash link - Another hash link - - + + Hash link + Another hash link + diff --git a/tracker/test/fixtures/engagement-manual.html b/tracker/test/fixtures/engagement-manual.html index ca135d38a1e2..3d64b8ffcc38 100644 --- a/tracker/test/fixtures/engagement-manual.html +++ b/tracker/test/fixtures/engagement-manual.html @@ -1,34 +1,40 @@ - + + + + + + Plausible Playwright tests + + + - - - - - Plausible Playwright tests - - - + + Navigate away - - Navigate away - - - - - - + + + + diff --git a/tracker/test/fixtures/engagement-pageview-props.html b/tracker/test/fixtures/engagement-pageview-props.html index 90803a46f7ff..cdea2320ec3f 100644 --- a/tracker/test/fixtures/engagement-pageview-props.html +++ b/tracker/test/fixtures/engagement-pageview-props.html @@ -1,28 +1,42 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - + + Navigate away + + + + - - Navigate away - - - - + + increment() + diff --git a/tracker/test/fixtures/engagement.html b/tracker/test/fixtures/engagement.html index 94a0ed45a8e8..148af9cb9b96 100644 --- a/tracker/test/fixtures/engagement.html +++ b/tracker/test/fixtures/engagement.html @@ -1,25 +1,25 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - + + Navigate away + Navigate to pageleave-pageview props - - Navigate away - Navigate to pageleave-pageview props - - - - - + + + diff --git a/tracker/test/fixtures/file-download-plausible-web.html b/tracker/test/fixtures/file-download-plausible-web.html index 75b47d64c190..0e929c69a730 100644 --- a/tracker/test/fixtures/file-download-plausible-web.html +++ b/tracker/test/fixtures/file-download-plausible-web.html @@ -1,27 +1,36 @@ - + + + + + + plausible-web.js tests + - - - - - plausible-web.js tests - + + Download + Download ISO - - Download - Download ISO + - + const script = document.createElement('script') + script.src = `/tracker/js/plausible-web.js?script_config=${encodeURIComponent(JSON.stringify(config))}` + var r = document.getElementsByTagName('script')[0] + r.parentNode.insertBefore(script, r) + + diff --git a/tracker/test/fixtures/legacy-custom-properties.html b/tracker/test/fixtures/legacy-custom-properties.html index e4ea2376c513..479bf4a2a810 100644 --- a/tracker/test/fixtures/legacy-custom-properties.html +++ b/tracker/test/fixtures/legacy-custom-properties.html @@ -1,16 +1,25 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - - - - - - + + + + diff --git a/tracker/test/fixtures/legacy-pageview-properties.html b/tracker/test/fixtures/legacy-pageview-properties.html index cf0bdc6abcac..140168d25013 100644 --- a/tracker/test/fixtures/legacy-pageview-properties.html +++ b/tracker/test/fixtures/legacy-pageview-properties.html @@ -1,15 +1,24 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - - - - - + + + diff --git a/tracker/test/fixtures/manual.html b/tracker/test/fixtures/manual.html index 2c319337eafd..ae363402629b 100644 --- a/tracker/test/fixtures/manual.html +++ b/tracker/test/fixtures/manual.html @@ -1,44 +1,46 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - - - - - - - - - - + + + + + + + diff --git a/tracker/test/fixtures/no-async.html b/tracker/test/fixtures/no-async.html index d119866c0938..96c566410d8d 100644 --- a/tracker/test/fixtures/no-async.html +++ b/tracker/test/fixtures/no-async.html @@ -1,16 +1,14 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - - - - HELLO! - - + + HELLO! + diff --git a/tracker/test/fixtures/revenue.html b/tracker/test/fixtures/revenue.html index 2700c5c957fb..d586c815e08e 100644 --- a/tracker/test/fixtures/revenue.html +++ b/tracker/test/fixtures/revenue.html @@ -1,22 +1,30 @@ - + + + + + + Plausible Playwright tests - - - - - Plausible Playwright tests + + - - + + - - - - - + + diff --git a/tracker/test/fixtures/scroll-depth-content-onscroll.html b/tracker/test/fixtures/scroll-depth-content-onscroll.html index 733872b2d283..21f3a9066db3 100644 --- a/tracker/test/fixtures/scroll-depth-content-onscroll.html +++ b/tracker/test/fixtures/scroll-depth-content-onscroll.html @@ -1,35 +1,40 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - + + - - + - + const newDiv = document.createElement('div') + newDiv.setAttribute('id', 'more-content') + newDiv.setAttribute( + 'style', + 'height: 2000px; background-color: lightcoral;' + ) + document.body.appendChild(newDiv) + } + }) + }) + + diff --git a/tracker/test/fixtures/scroll-depth-dynamic-content-load.html b/tracker/test/fixtures/scroll-depth-dynamic-content-load.html index 708e1d96efbb..bd0f3651e446 100644 --- a/tracker/test/fixtures/scroll-depth-dynamic-content-load.html +++ b/tracker/test/fixtures/scroll-depth-dynamic-content-load.html @@ -1,25 +1,24 @@ - + - - - - Document - - - -
    -
    - + + +
    +
    + - + }, 500) + + diff --git a/tracker/test/fixtures/scroll-depth-hash.html b/tracker/test/fixtures/scroll-depth-hash.html index d364f302c16a..4762883df941 100644 --- a/tracker/test/fixtures/scroll-depth-hash.html +++ b/tracker/test/fixtures/scroll-depth-hash.html @@ -1,37 +1,36 @@ - + + + + + + Plausible Playwright tests + + - - - - - Plausible Playwright tests - - + + - - +
    -
    + - + function loadContent() { + const hash = window.location.hash || '#home' + const content = routes[hash] + document.getElementById('content').innerHTML = content + } + window.addEventListener('hashchange', loadContent) + window.addEventListener('DOMContentLoaded', loadContent) + + diff --git a/tracker/test/fixtures/scroll-depth-slow-window-load.html b/tracker/test/fixtures/scroll-depth-slow-window-load.html index e043c666cd25..ed81e705ba6f 100644 --- a/tracker/test/fixtures/scroll-depth-slow-window-load.html +++ b/tracker/test/fixtures/scroll-depth-slow-window-load.html @@ -1,16 +1,14 @@ - + - - - - Document - - - - - Navigate away - -
    - slow image - + + + + Document + + + + Navigate away +
    + slow image + diff --git a/tracker/test/fixtures/scroll-depth.html b/tracker/test/fixtures/scroll-depth.html index 5db9e9e587ee..ef6923abca35 100644 --- a/tracker/test/fixtures/scroll-depth.html +++ b/tracker/test/fixtures/scroll-depth.html @@ -1,14 +1,19 @@ - + - - - - - Document - - - - + + + + + Document + + + + diff --git a/tracker/test/form-submissions.spec.ts b/tracker/test/form-submissions.spec.ts index 4dd26ae58d80..24c5c66d7115 100644 --- a/tracker/test/form-submissions.spec.ts +++ b/tracker/test/form-submissions.spec.ts @@ -23,10 +23,8 @@ test('does not track form submissions when the feature is disabled', async ({ const { url } = await initializePageDynamically(page, { testId, scriptConfig: DEFAULT_CONFIG, - bodyContent: ` - - - + bodyContent: /* HTML */ ` +
    ` }) @@ -50,9 +48,12 @@ test.describe('form submissions feature is enabled', () => { const { url } = await initializePageDynamically(page, { testId, scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, - bodyContent: ` + bodyContent: /* HTML */ `
    - +
    ` }) @@ -81,8 +82,8 @@ test.describe('form submissions feature is enabled', () => { const { url } = await initializePageDynamically(page, { testId, scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, - bodyContent: ` -
    + bodyContent: /* HTML */ ` +
    ` @@ -109,21 +110,24 @@ test.describe('form submissions feature is enabled', () => { const { url } = await initializePageDynamically(page, { testId, scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, - bodyContent: ` -
    - - -
    + bodyContent: /* HTML */ ` +
    + + +
    ` }) @@ -151,8 +155,8 @@ test.describe('form submissions feature is enabled', () => { const { url } = await initializePageDynamically(page, { testId, scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, - bodyContent: ` -
    + bodyContent: /* HTML */ ` +
    @@ -184,7 +188,7 @@ test.describe('form submissions feature is enabled', () => { const { url } = await initializePageDynamically(page, { testId, scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, - bodyContent: ` + bodyContent: /* HTML */ `
    @@ -215,11 +219,16 @@ test.describe('form submissions feature is enabled', () => { const { url } = await initializePageDynamically(page, { testId, scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, - bodyContent: ` + bodyContent: /* HTML */ `
    - + ` }) @@ -246,12 +255,12 @@ test.describe('form submissions feature is enabled', () => { const { url } = await initializePageDynamically(page, { testId, scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, - bodyContent: ` -
    + bodyContent: /* HTML */ ` +

    Form 1

    -
    +

    Form 2

    diff --git a/tracker/test/hash-based-routing.spec.ts b/tracker/test/hash-based-routing.spec.ts index d9fda649ec9d..b6ab29a121d0 100644 --- a/tracker/test/hash-based-routing.spec.ts +++ b/tracker/test/hash-based-routing.spec.ts @@ -3,7 +3,7 @@ import { e, expectPlausibleInAction, hideAndShowCurrentTab, - switchByMode, + switchByMode } from './support/test-utils' import { test } from '@playwright/test' import { ScriptConfig } from './support/types' @@ -95,7 +95,11 @@ test.describe('hash-based routing (legacy)', () => { }, { testId }) => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: '' }) diff --git a/tracker/test/hash-exclusions.spec.ts b/tracker/test/hash-exclusions.spec.ts index e4d2c342f968..288f1253b7ea 100644 --- a/tracker/test/hash-exclusions.spec.ts +++ b/tracker/test/hash-exclusions.spec.ts @@ -6,7 +6,11 @@ test.describe('combination of hash and exclusions script extensions', () => { test('excludes by hash part of the URL', async ({ page }, { testId }) => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: '' }) await expectPlausibleInAction(page, { diff --git a/tracker/test/installation_support/check-data-domain-mismatch.spec.js b/tracker/test/installation_support/check-data-domain-mismatch.spec.js index bccb12a0fdd8..b7603dc1d27c 100644 --- a/tracker/test/installation_support/check-data-domain-mismatch.spec.js +++ b/tracker/test/installation_support/check-data-domain-mismatch.spec.js @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' import { checkDataDomainMismatch } from '../../installation_support/check-data-domain-mismatch' function mockSnippet(dataDomain) { - return { getAttribute: _ => dataDomain } + return { getAttribute: (_) => dataDomain } } test.describe('checkDataDomainMismatch', () => { @@ -43,7 +43,9 @@ test.describe('checkDataDomainMismatch', () => { }) test('handles multiple domains with www prefix', () => { - const snippets = [mockSnippet('www.example.org, www.example.com, www.example.net')] + const snippets = [ + mockSnippet('www.example.org, www.example.com, www.example.net') + ] expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(false) }) @@ -53,10 +55,7 @@ test.describe('checkDataDomainMismatch', () => { }) test('handles multiple snippets - returns true if any snippet has domain mismatch', () => { - const snippets = [ - mockSnippet('example.com'), - mockSnippet('wrong.com') - ] + const snippets = [mockSnippet('example.com'), mockSnippet('wrong.com')] expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(true) }) @@ -67,4 +66,4 @@ test.describe('checkDataDomainMismatch', () => { ] expect(checkDataDomainMismatch(snippets, 'example.com')).toBe(false) }) -}) \ No newline at end of file +}) diff --git a/tracker/test/installation_support/check-disallowed-by-csp.spec.js b/tracker/test/installation_support/check-disallowed-by-csp.spec.js index 963077d7f281..ac15b04aeff2 100644 --- a/tracker/test/installation_support/check-disallowed-by-csp.spec.js +++ b/tracker/test/installation_support/check-disallowed-by-csp.spec.js @@ -6,35 +6,49 @@ const HOST_TO_CHECK = 'plausible.io' test.describe('checkDisallowedByCSP', () => { test('returns false if no CSP header', () => { expect(checkDisallowedByCSP({}, HOST_TO_CHECK)).toBe(false) - expect(checkDisallowedByCSP({foo: 'bar'}, HOST_TO_CHECK)).toBe(false) + expect(checkDisallowedByCSP({ foo: 'bar' }, HOST_TO_CHECK)).toBe(false) }) test('returns false if CSP header is empty', () => { - expect(checkDisallowedByCSP({'content-security-policy': ''}, HOST_TO_CHECK)).toBe(false) + expect( + checkDisallowedByCSP({ 'content-security-policy': '' }, HOST_TO_CHECK) + ).toBe(false) }) test('returns true if plausible.io is not allowed', () => { - const headers = {'content-security-policy': "default-src 'self' foo.local; example.com"} + const headers = { + 'content-security-policy': "default-src 'self' foo.local; example.com" + } expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(true) }) test('returns false if plausible.io is allowed', () => { - const headers = {'content-security-policy': "default-src 'self' plausible.io; example.com"} + const headers = { + 'content-security-policy': "default-src 'self' plausible.io; example.com" + } expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false) }) test('returns false if plausible.io subdomain is allowed', () => { - const headers = {'content-security-policy': "default-src 'self' staging.plausible.io; example.com"} + const headers = { + 'content-security-policy': + "default-src 'self' staging.plausible.io; example.com" + } expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false) }) test('returns false if plausible.io is allowed with https', () => { - const headers = {'content-security-policy': "default-src 'self' https://plausible.io; example.com"} + const headers = { + 'content-security-policy': + "default-src 'self' https://plausible.io; example.com" + } expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false) }) test('returns true if plausible.io is not present in any directive', () => { - const headers = {'content-security-policy': "default-src 'self' foo.com; bar.com"} + const headers = { + 'content-security-policy': "default-src 'self' foo.com; bar.com" + } expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(true) }) -}) \ No newline at end of file +}) diff --git a/tracker/test/installation_support/check-gtm.spec.js b/tracker/test/installation_support/check-gtm.spec.js index 2bda508e4184..d684dd979615 100644 --- a/tracker/test/installation_support/check-gtm.spec.js +++ b/tracker/test/installation_support/check-gtm.spec.js @@ -3,7 +3,7 @@ import { checkGTM } from '../../installation_support/check-gtm' function mockDocument(html) { return { - documentElement: {outerHTML: `${html}`} + documentElement: { outerHTML: `${html}` } } } @@ -13,7 +13,7 @@ test.describe('checkGTM (gtmLikely)', () => { }) test('handles document.documentElement undefined', () => { - const document = {documentElement: undefined} + const document = { documentElement: undefined } expect(checkGTM(document)).toBe(false) }) @@ -23,7 +23,9 @@ test.describe('checkGTM (gtmLikely)', () => { }) test('detects gtmLikely by googletagmanager.com/gtm.js signature', () => { - const document = mockDocument('') + const document = mockDocument( + '' + ) expect(checkGTM(document)).toBe(true) }) -}) \ No newline at end of file +}) diff --git a/tracker/test/installation_support/check-manual-extension.spec.js b/tracker/test/installation_support/check-manual-extension.spec.js index 3683ef54fc05..798e20a20a01 100644 --- a/tracker/test/installation_support/check-manual-extension.spec.js +++ b/tracker/test/installation_support/check-manual-extension.spec.js @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' import { checkManualExtension } from '../../installation_support/check-manual-extension' function mockSnippet(dataDomain) { - return { getAttribute: _ => dataDomain } + return { getAttribute: (_) => dataDomain } } test.describe('checkManualExtension', () => { @@ -42,4 +42,4 @@ test.describe('checkManualExtension', () => { ] expect(checkManualExtension(snippets)).toBe(false) }) -}) \ No newline at end of file +}) diff --git a/tracker/test/installation_support/check-proxy-likely.spec.js b/tracker/test/installation_support/check-proxy-likely.spec.js index 6c620bc082c2..808c8e5fc4f0 100644 --- a/tracker/test/installation_support/check-proxy-likely.spec.js +++ b/tracker/test/installation_support/check-proxy-likely.spec.js @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' import { checkProxyLikely } from '../../installation_support/check-proxy-likely' function mockSnippet(src) { - return { getAttribute: _ => src } + return { getAttribute: (_) => src } } test.describe('checkProxyLikely', () => { @@ -23,12 +23,16 @@ test.describe('checkProxyLikely', () => { }) test('returns false when snippet src is official plausible.io URL with query params', () => { - const snippets = [mockSnippet('https://plausible.io/js/plausible.js?v=1.0.0')] + const snippets = [ + mockSnippet('https://plausible.io/js/plausible.js?v=1.0.0') + ] expect(checkProxyLikely(snippets)).toBe(false) }) test('handles similar domain names (should be true)', () => { - const snippets = [mockSnippet('https://plausible.io.example.com/js/plausible.js')] + const snippets = [ + mockSnippet('https://plausible.io.example.com/js/plausible.js') + ] expect(checkProxyLikely(snippets)).toBe(true) }) @@ -54,7 +58,9 @@ test.describe('checkProxyLikely', () => { }) test('handles plausible.io subdomain (should be true)', () => { - const snippets = [mockSnippet('https://staging.plausible.io/js/plausible.js')] + const snippets = [ + mockSnippet('https://staging.plausible.io/js/plausible.js') + ] expect(checkProxyLikely(snippets)).toBe(true) }) -}) \ No newline at end of file +}) diff --git a/tracker/test/installation_support/check-wp.spec.js b/tracker/test/installation_support/check-wp.spec.js index 8523fbd7b97e..2e555439ff6f 100644 --- a/tracker/test/installation_support/check-wp.spec.js +++ b/tracker/test/installation_support/check-wp.spec.js @@ -1,9 +1,12 @@ import { test, expect } from '@playwright/test' -import { checkWordPress, WORDPRESS_PLUGIN_VERSION_SELECTOR } from '../../installation_support/check-wordpress' +import { + checkWordPress, + WORDPRESS_PLUGIN_VERSION_SELECTOR +} from '../../installation_support/check-wordpress' function mockDocument(html, hasMetaTag) { return { - documentElement: {outerHTML: `${html}`}, + documentElement: { outerHTML: `${html}` }, querySelector: (selector) => { if (selector === WORDPRESS_PLUGIN_VERSION_SELECTOR) { return hasMetaTag ? {} : null @@ -16,23 +19,23 @@ function mockDocument(html, hasMetaTag) { test.describe('checkWordPress (wordpressPlugin, wordPressLikely)', () => { test('handles document undefined', () => { const result = checkWordPress(undefined) - + expect(result.wordpressPlugin).toBe(false) expect(result.wordpressLikely).toBe(false) }) test('handles document.querySelector undefined', () => { - const document = {documentElement: {outerHTML: ''}} + const document = { documentElement: { outerHTML: '' } } const result = checkWordPress(document) - + expect(result.wordpressPlugin).toBe(false) expect(result.wordpressLikely).toBe(false) }) test('handles document.documentElement undefined', () => { - const document = {documentElement: undefined, querySelector: (_) => null} + const document = { documentElement: undefined, querySelector: (_) => null } const result = checkWordPress(document) - + expect(result.wordpressPlugin).toBe(false) expect(result.wordpressLikely).toBe(false) }) @@ -40,7 +43,7 @@ test.describe('checkWordPress (wordpressPlugin, wordPressLikely)', () => { test('both false when no WordPress indicators present', () => { const document = mockDocument('', false) const result = checkWordPress(document) - + expect(result.wordpressPlugin).toBe(false) expect(result.wordpressLikely).toBe(false) }) @@ -48,31 +51,40 @@ test.describe('checkWordPress (wordpressPlugin, wordPressLikely)', () => { test('both true if WordPress plugin version meta tag detected', () => { const document = mockDocument('', true) const result = checkWordPress(document) - + expect(result.wordpressPlugin).toBe(true) expect(result.wordpressLikely).toBe(true) }) test('detects wordpressLikely by wp-content signature', () => { - const document = mockDocument('', false) + const document = mockDocument( + '', + false + ) const result = checkWordPress(document) - + expect(result.wordpressPlugin).toBe(false) expect(result.wordpressLikely).toBe(true) }) test('detects wordpressLikely by wp-includes signature', () => { - const document = mockDocument('', false) + const document = mockDocument( + '', + false + ) const result = checkWordPress(document) - + expect(result.wordpressPlugin).toBe(false) expect(result.wordpressLikely).toBe(true) }) test('detects wordpressLikely by wp-json signature', () => { - const document = mockDocument('', false) + const document = mockDocument( + '', + false + ) const result = checkWordPress(document) - + expect(result.wordpressPlugin).toBe(false) expect(result.wordpressLikely).toBe(true) }) @@ -89,10 +101,10 @@ test.describe('checkWordPress (wordpressPlugin, wordPressLikely)', () => { `, false ) - + const result = checkWordPress(document) - + expect(result.wordpressPlugin).toBe(false) expect(result.wordpressLikely).toBe(true) }) -}) \ No newline at end of file +}) diff --git a/tracker/test/installation_support/detector.spec.js b/tracker/test/installation_support/detector.spec.js index 0acdd9cb3388..d980be1bfd78 100644 --- a/tracker/test/installation_support/detector.spec.js +++ b/tracker/test/installation_support/detector.spec.js @@ -3,10 +3,12 @@ import { detect } from '../support/installation-support-playwright-wrappers' import { initializePageDynamically } from '../support/initialize-page-dynamically' test.describe('detector.js (tech recognition)', () => { - test('skips v1 snippet detection by default', async ({ page }, { testId }) => { + test('skips v1 snippet detection by default', async ({ page }, { + testId + }) => { const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` @@ -15,7 +17,11 @@ test.describe('detector.js (tech recognition)', () => { ` }) - const result = await detect(page, {url: url, detectV1: false, timeoutMs: 1000}) + const result = await detect(page, { + url: url, + detectV1: false, + timeoutMs: 1000 + }) expect(result.data.v1Detected).toBe(null) }) @@ -23,18 +29,29 @@ test.describe('detector.js (tech recognition)', () => { test('detects WP plugin, WP and GTM', async ({ page }, { testId }) => { const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` - - - + + + ` }) - const result = await detect(page, {url: url, detectV1: false, timeoutMs: 1000}) + const result = await detect(page, { + url: url, + detectV1: false, + timeoutMs: 1000 + }) expect(result.data.wordpressPlugin).toBe(true) expect(result.data.wordpressLikely).toBe(true) @@ -44,10 +61,16 @@ test.describe('detector.js (tech recognition)', () => { test('No WP plugin, WP or GTM', async ({ page }, { testId }) => { const { url } = await initializePageDynamically(page, { testId, - response: '' + response: /* HTML */ ` + + ` }) - const result = await detect(page, {url: url, detectV1: false, timeoutMs: 1000}) + const result = await detect(page, { + url: url, + detectV1: false, + timeoutMs: 1000 + }) expect(result.data.wordpressPlugin).toBe(false) expect(result.data.wordpressLikely).toBe(false) @@ -58,10 +81,13 @@ test.describe('detector.js (tech recognition)', () => { test('npm is reported correctly', async ({ page }, { testId }) => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `` + scriptConfig: /* HTML */ `` }) - const result = await detect(page, {url: url, detectV1: false}) + const result = await detect(page, { url: url, detectV1: false }) expect(result.data.wordpressPlugin).toBe(false) expect(result.data.wordpressLikely).toBe(false) @@ -71,22 +97,39 @@ test.describe('detector.js (tech recognition)', () => { }) test.describe('detector.js (v1 detection)', () => { - test('v1Detected is true when v1 plausible exists + detects WP plugin, WP and GTM', async ({ page }, { testId }) => { + test('v1Detected is true when v1 plausible exists + detects WP plugin, WP and GTM', async ({ + page + }, { testId }) => { const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` - - - - + + + + ` }) - const result = await detect(page, {url: url, detectV1: true, timeoutMs: 1000}) + const result = await detect(page, { + url: url, + detectV1: true, + timeoutMs: 1000 + }) expect(result.data.v1Detected).toBe(true) expect(result.data.wordpressPlugin).toBe(true) @@ -95,13 +138,21 @@ test.describe('detector.js (v1 detection)', () => { expect(result.data.npm).toBe(false) }) - test('v1Detected is false when plausible function does not exist', async ({ page }, { testId }) => { + test('v1Detected is false when plausible function does not exist', async ({ + page + }, { testId }) => { const { url } = await initializePageDynamically(page, { testId, - response: '' + response: /* HTML */ ` + + ` }) - const result = await detect(page, {url: url, detectV1: true, timeoutMs: 1000}) + const result = await detect(page, { + url: url, + detectV1: true, + timeoutMs: 1000 + }) expect(result.data.v1Detected).toBe(false) expect(result.data.wordpressPlugin).toBe(false) @@ -110,7 +161,9 @@ test.describe('detector.js (v1 detection)', () => { expect(result.data.npm).toBe(false) }) - test('v1Detected is false when v2 plausible installed', async ({ page }, { testId }) => { + test('v1Detected is false when v2 plausible installed', async ({ page }, { + testId + }) => { const { url } = await initializePageDynamically(page, { testId, scriptConfig: { @@ -119,7 +172,11 @@ test.describe('detector.js (v1 detection)', () => { } }) - const result = await detect(page, {url: url, detectV1: true, timeoutMs: 1000}) + const result = await detect(page, { + url: url, + detectV1: true, + timeoutMs: 1000 + }) expect(result.data.v1Detected).toBe(false) }) diff --git a/tracker/test/installation_support/verifier-v1.spec.js b/tracker/test/installation_support/verifier-v1.spec.js index 779f44c8a7f0..b68cd354c0ce 100644 --- a/tracker/test/installation_support/verifier-v1.spec.js +++ b/tracker/test/installation_support/verifier-v1.spec.js @@ -26,10 +26,17 @@ test.describe('v1 verifier (basic diagnostics)', () => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `` + scriptConfig: /* HTML */ `` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(1) @@ -52,27 +59,31 @@ test.describe('v1 verifier (basic diagnostics)', () => { const { url } = await initializePageDynamically(page, { testId, - response: ` - - - - - - + setTimeout(() => { + document.getElementsByTagName('head')[0].appendChild(script) + }, 500) + + + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN, debug: true}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN, + debug: true + }) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(1) @@ -87,7 +98,10 @@ test.describe('v1 verifier (basic diagnostics)', () => { scriptConfig: '' }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.plausibleInstalled).toBe(false) expect(result.data.callbackStatus).toBe(0) @@ -101,10 +115,19 @@ test.describe('v1 verifier (basic diagnostics)', () => { const { url } = await initializePageDynamically(page, { testId, - response: `` + response: /* HTML */ ` + + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(0) @@ -113,15 +136,26 @@ test.describe('v1 verifier (basic diagnostics)', () => { expect(result.data.dataDomainMismatch).toBe(false) }) - test('figures out well placed snippet in a multi-domain setup', async ({ page }, { testId }) => { + test('figures out well placed snippet in a multi-domain setup', async ({ + page + }, { testId }) => { await mockEventResponseSuccess(page) const { url } = await initializePageDynamically(page, { testId, - response: `` + response: /* HTML */ ` + + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: "example.com"}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: 'example.com' + }) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(1) @@ -130,15 +164,26 @@ test.describe('v1 verifier (basic diagnostics)', () => { expect(result.data.dataDomainMismatch).toBe(false) }) - test('figures out well placed snippet in a multi-domain mismatch', async ({ page }, { testId }) => { + test('figures out well placed snippet in a multi-domain mismatch', async ({ + page + }, { testId }) => { await mockEventResponseSuccess(page) const { url } = await initializePageDynamically(page, { testId, - response: `` + response: /* HTML */ ` + + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: "example.typo"}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: 'example.typo' + }) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(1) @@ -147,20 +192,25 @@ test.describe('v1 verifier (basic diagnostics)', () => { expect(result.data.dataDomainMismatch).toBe(true) }) - test('proxyLikely is false when every snippet starts with an official plausible.io URL', async ({ page }, { testId }) => { + test('proxyLikely is false when every snippet starts with an official plausible.io URL', async ({ + page + }, { testId }) => { const prodScriptLocation = 'https://plausible.io/js/' mockEventResponseSuccess(page) // We speed up the test by serving "just some script" // (avoiding the event callback delay in verifier) - const code = await compileFile({ - name: "plausible.local.js", - globals: { - "COMPILE_LOCAL": true, - "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true - } - }, { returnCode: true }) + const code = await compileFile( + { + name: 'plausible.local.js', + globals: { + COMPILE_LOCAL: true, + COMPILE_PLAUSIBLE_LEGACY_VARIANT: true + } + }, + { returnCode: true } + ) await page.context().route(`${prodScriptLocation}**`, async (route) => { await route.fulfill({ @@ -172,13 +222,28 @@ test.describe('v1 verifier (basic diagnostics)', () => { const { url } = await initializePageDynamically(page, { testId, - response: ` - - + response: /* HTML */ ` + + + + + + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.proxyLikely).toBe(false) }) @@ -188,20 +253,43 @@ test.describe('v1 verifier (basic diagnostics)', () => { const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` - - + + - - - + + + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: "example.com"}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: 'example.com' + }) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(2) @@ -215,71 +303,113 @@ test.describe('v1 verifier (basic diagnostics)', () => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `` + scriptConfig: /* HTML */ `` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: 'right.com'}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: 'right.com' + }) expect(result.data.dataDomainMismatch).toBe(true) }) - test('dataDomainMismatch is false when data-domain without "www." prefix matches', async ({ page }, { testId }) => { + test('dataDomainMismatch is false when data-domain without "www." prefix matches', async ({ + page + }, { testId }) => { await mockEventResponseSuccess(page) const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `` + scriptConfig: /* HTML */ `` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: 'right.com'}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: 'right.com' + }) expect(result.data.dataDomainMismatch).toBe(false) }) - }) test.describe('v1 verifier (window.plausible)', () => { - test('callbackStatus is 404 when /api/event not found', async ({ page }, { testId }) => { + test('callbackStatus is 404 when /api/event not found', async ({ page }, { + testId + }) => { await page.context().route('**/api/event', async (route) => { - await route.fulfill({status: 404}) + await route.fulfill({ status: 404 }) }) const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `` + scriptConfig: /* HTML */ `` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.callbackStatus).toBe(404) }) - test('callBackStatus is 0 when event request times out', async ({ page }, { testId }) => { + test('callBackStatus is 0 when event request times out', async ({ page }, { + testId + }) => { mockEventResponseSuccess(page, 20000) const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `` + scriptConfig: /* HTML */ `` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.callbackStatus).toBe(0) }) - test('callBackStatus is -1 when a network error occurs on sending event', async ({ page }, { testId }) => { + test('callBackStatus is -1 when a network error occurs on sending event', async ({ + page + }, { testId }) => { await page.context().route('**/api/event', async (route) => { await route.abort() }) const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `` + scriptConfig: /* HTML */ `` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.callbackStatus).toBe(-1) @@ -287,33 +417,48 @@ test.describe('v1 verifier (window.plausible)', () => { }) test.describe('v1 verifier (WordPress detection)', () => { - test('if wordpress plugin detected, wordpressLikely is also true', async ({ page }, { testId }) => { + test('if wordpress plugin detected, wordpressLikely is also true', async ({ + page + }, { testId }) => { await mockEventResponseSuccess(page) const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` - - + + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.wordpressPlugin).toBe(true) expect(result.data.wordpressLikely).toBe(true) }) - test('detects wordpressLikely by wp signatures', async ({ page }, { testId }) => { + test('detects wordpressLikely by wp signatures', async ({ page }, { + testId + }) => { await mockEventResponseSuccess(page) const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` - + @@ -321,7 +466,10 @@ test.describe('v1 verifier (WordPress detection)', () => { ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.wordpressPlugin).toBe(false) expect(result.data.wordpressLikely).toBe(true) @@ -334,33 +482,52 @@ test.describe('v1 verifier (GTM detection)', () => { const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` - - - - - - - Hello + + + + + + + + Hello + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.gtmLikely).toBe(true) }) }) test.describe('v1 verifier (cookieBanner detection)', () => { - test('detects a dynamically loaded cookiebot', async ({ page }, { testId }) => { + test('detects a dynamically loaded cookiebot', async ({ page }, { + testId + }) => { // While in real world the plausible script would be prevented // from loading when cookiebot is present, to speed up the test // we let it load, but mock a general network error. That is to @@ -374,10 +541,14 @@ test.describe('v1 verifier (cookieBanner detection)', () => { const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` - + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.cookieBannerLikely).toBe(true) }) }) test.describe('v1 verifier (manualScriptExtension detection)', () => { - test('manualScriptExtension is true when any snippet src has "manual." in it', async ({ page }, { testId }) => { + test('manualScriptExtension is true when any snippet src has "manual." in it', async ({ + page + }, { testId }) => { await mockEventResponseSuccess(page) const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` - - + + - + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.manualScriptExtension).toBe(true) }) }) test.describe('v1 verifier (unknownAttributes detection)', () => { - test('unknownAttributes is false when all attrs are known', async ({ page }, { testId }) => { + test('unknownAttributes is false when all attrs are known', async ({ page }, { + testId + }) => { await mockEventResponseSuccess(page) const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` + src="/tracker/js/plausible.manual.js" + > ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.unknownAttributes).toBe(false) }) - test('unknownAttributes is true when any unknown attributes are present', async ({ page }, { testId }) => { + test('unknownAttributes is true when any unknown attributes are present', async ({ + page + }, { testId }) => { await mockEventResponseSuccess(page) const { url } = await initializePageDynamically(page, { testId, - response: ` + response: /* HTML */ ` - + ` }) - const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN + }) expect(result.data.unknownAttributes).toBe(true) }) @@ -477,31 +683,51 @@ test.describe('v1 verifier (logging)', () => { await mockEventResponseSuccess(page) let logs = [] - page.context().on('console', msg => msg.type() === 'log' && logs.push(msg.text())) + page + .context() + .on('console', (msg) => msg.type() === 'log' && logs.push(msg.text())) const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `` + scriptConfig: /* HTML */ `` }) - await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN, debug: true}) + await verifyV1(page, { + url: url, + expectedDataDomain: SOME_DOMAIN, + debug: true + }) - expect(logs.find(str => str.includes('Starting snippet detection'))).toContain('[Plausible Verification] Starting snippet detection') - expect(logs.find(str => str.includes('Checking for Plausible function'))).toContain('[Plausible Verification] Checking for Plausible function') + expect( + logs.find((str) => str.includes('Starting snippet detection')) + ).toContain('[Plausible Verification] Starting snippet detection') + expect( + logs.find((str) => str.includes('Checking for Plausible function')) + ).toContain('[Plausible Verification] Checking for Plausible function') }) test('does not log by default', async ({ page }, { testId }) => { await mockEventResponseSuccess(page) let logs = [] - page.context().on('console', msg => msg.type() === 'log' && logs.push(msg.text())) + page + .context() + .on('console', (msg) => msg.type() === 'log' && logs.push(msg.text())) const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `` + scriptConfig: /* HTML */ `` }) - await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + await verifyV1(page, { url: url, expectedDataDomain: SOME_DOMAIN }) expect(logs.length).toBe(0) }) diff --git a/tracker/test/installation_support/verifier-v2.spec.ts b/tracker/test/installation_support/verifier-v2.spec.ts index 66736d74fcb8..a5812adf7e4f 100644 --- a/tracker/test/installation_support/verifier-v2.spec.ts +++ b/tracker/test/installation_support/verifier-v2.spec.ts @@ -592,13 +592,18 @@ test.describe('installed plausible esm variant', () => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: '' }) @@ -652,13 +657,18 @@ test.describe('installed plausible esm variant', () => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: '' }) @@ -701,13 +711,18 @@ test.describe('installed plausible esm variant', () => { }, { testId }) => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: '' }) @@ -761,13 +776,18 @@ test.describe('installed plausible esm variant', () => { }, { testId }) => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: '' }) diff --git a/tracker/test/legacy-custom-properties.spec.js b/tracker/test/legacy-custom-properties.spec.js index 65db5ed6b86c..9d5c4f18c8d1 100644 --- a/tracker/test/legacy-custom-properties.spec.js +++ b/tracker/test/legacy-custom-properties.spec.js @@ -1,7 +1,4 @@ -import { - expectPlausibleInAction, - isPageviewEvent -} from './support/test-utils' +import { expectPlausibleInAction, isPageviewEvent } from './support/test-utils' import { test } from '@playwright/test' test.describe('legacy custom properties support', () => { @@ -24,9 +21,7 @@ test.describe('legacy custom properties support', () => { await page.goto('/legacy-custom-properties.html') await page.click('#custom-props-button') }, - expectedRequests: [ - { n: 'Props event', p: { type: 'props' } } - ], + expectedRequests: [{ n: 'Props event', p: { type: 'props' } }], shouldIgnoreRequest: isPageviewEvent }) }) @@ -37,9 +32,7 @@ test.describe('legacy custom properties support', () => { await page.goto('/legacy-custom-properties.html') await page.click('#custom-meta-button') }, - expectedRequests: [ - { n: 'Meta event', m: '{"type":"meta"}' } - ], + expectedRequests: [{ n: 'Meta event', m: '{"type":"meta"}' }], shouldIgnoreRequest: isPageviewEvent }) }) diff --git a/tracker/test/logging.spec.ts b/tracker/test/logging.spec.ts index dcc89d4cf9bd..9696031772e6 100644 --- a/tracker/test/logging.spec.ts +++ b/tracker/test/logging.spec.ts @@ -1,8 +1,5 @@ import { initializePageDynamically } from './support/initialize-page-dynamically' -import { - expectPlausibleInAction, - switchByMode, -} from './support/test-utils' +import { expectPlausibleInAction, switchByMode } from './support/test-utils' import { expect, test } from '@playwright/test' import { ScriptConfig } from './support/types' import { LOCAL_SERVER_ADDR } from './support/server' diff --git a/tracker/test/manual.spec.js b/tracker/test/manual.spec.js index 7c2615b505ce..4e358c046e94 100644 --- a/tracker/test/manual.spec.js +++ b/tracker/test/manual.spec.js @@ -3,45 +3,74 @@ import { test } from '@playwright/test' import { LOCAL_SERVER_ADDR } from './support/server' test.describe('manual extension', () => { - test('can trigger custom events with and without a custom URL if pageview was sent with the default URL', async ({ page }) => { + test('can trigger custom events with and without a custom URL if pageview was sent with the default URL', async ({ + page + }) => { await page.goto('/manual.html') await expectPlausibleInAction(page, { action: () => page.click('#pageview-trigger'), - expectedRequests: [{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/manual.html`}] + expectedRequests: [ + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/manual.html` } + ] }) await expectPlausibleInAction(page, { action: () => page.click('#custom-event-trigger'), - expectedRequests: [{n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`}] + expectedRequests: [ + { n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html` } + ] }) await expectPlausibleInAction(page, { action: () => page.click('#custom-event-trigger-custom-url'), - expectedRequests: [{n: 'CustomEvent', u: `https://example.com/custom/location`}] + expectedRequests: [ + { n: 'CustomEvent', u: `https://example.com/custom/location` } + ] }) }) - test('can trigger custom events with and without a custom URL if pageview was sent with a custom URL', async ({ page }) => { + test('can trigger custom events with and without a custom URL if pageview was sent with a custom URL', async ({ + page + }) => { await page.goto('/manual.html') await expectPlausibleInAction(page, { action: () => page.click('#pageview-trigger-custom-url'), - expectedRequests: [{n: 'pageview', u: `https://example.com/custom/location`}] + expectedRequests: [ + { n: 'pageview', u: `https://example.com/custom/location` } + ] }) await expectPlausibleInAction(page, { action: () => page.click('#custom-event-trigger'), - expectedRequests: [{n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html`}] + expectedRequests: [ + { n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html` } + ] }) await expectPlausibleInAction(page, { action: () => page.click('#custom-event-trigger-custom-url'), - expectedRequests: [{n: 'CustomEvent', u: `https://example.com/custom/location`}] + expectedRequests: [ + { n: 'CustomEvent', u: `https://example.com/custom/location` } + ] }) }) - test('can trigger custom events with interactive: false', async ({ page }) => { + test('can trigger custom events with interactive: false', async ({ + page + }) => { await page.goto('/manual.html') await expectPlausibleInAction(page, { - action: () => page.evaluate(() => window.plausible('Non-Interactive Custom Event', { interactive: false })), - expectedRequests: [{n: 'Non-Interactive Custom Event', u: `${LOCAL_SERVER_ADDR}/manual.html`, i: false}] + action: () => + page.evaluate(() => + window.plausible('Non-Interactive Custom Event', { + interactive: false + }) + ), + expectedRequests: [ + { + n: 'Non-Interactive Custom Event', + u: `${LOCAL_SERVER_ADDR}/manual.html`, + i: false + } + ] }) }) }) diff --git a/tracker/test/outbound-links.spec.ts b/tracker/test/outbound-links.spec.ts index b9f3166700f0..632bad914272 100644 --- a/tracker/test/outbound-links.spec.ts +++ b/tracker/test/outbound-links.spec.ts @@ -47,7 +47,7 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `➡️` + bodyContent: /* HTML */ `➡️` }) await expectPlausibleInAction(page, { @@ -94,7 +94,7 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `➡️` + bodyContent: /* HTML */ `➡️` }) await expectPlausibleInAction(page, { @@ -169,7 +169,7 @@ for (const mode of ['legacy', 'web']) }, mode ), - bodyContent: `

    ➡️

    ` + bodyContent: /* HTML */ `

    ➡️

    ` }) await page.goto(url) @@ -219,7 +219,7 @@ for (const mode of ['legacy', 'web']) }, mode ), - bodyContent: `>➡️` + bodyContent: /* HTML */ `>➡️` }) await page.goto(url) await page.click('a') @@ -268,7 +268,7 @@ for (const mode of ['legacy', 'web']) }, mode ), - bodyContent: ` + bodyContent: /* HTML */ ` @@ -359,7 +359,9 @@ test.describe('outbound links feature when using legacy .compat extension', () = testId, scriptConfig: '', - bodyContent: `

    ➡️

    ` + bodyContent: /* HTML */ `

    ➡️

    ` }) await page.goto(url) @@ -402,7 +404,7 @@ test.describe('outbound links feature when using legacy .compat extension', () = testId, scriptConfig: '', - bodyContent: `📥` + bodyContent: /* HTML */ `📥` }) await page.goto(url) @@ -454,7 +456,7 @@ test.describe('outbound links feature when using legacy .compat extension', () = testId, scriptConfig: '', - bodyContent: `➡️` + bodyContent: /* HTML */ `➡️` }) await page.goto(url) const navigationPromise = page.waitForRequest(outboundUrl, { @@ -495,7 +497,7 @@ test.describe('outbound links feature when using legacy .compat extension', () = testId, scriptConfig: '', - bodyContent: ` + bodyContent: /* HTML */ ` diff --git a/tracker/test/pageview.spec.ts b/tracker/test/pageview.spec.ts index b3cb837a9b62..c7a4d63b5bea 100644 --- a/tracker/test/pageview.spec.ts +++ b/tracker/test/pageview.spec.ts @@ -76,9 +76,19 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: ` - A - B + bodyContent: /* HTML */ ` + A + B ` }) @@ -132,7 +142,7 @@ for (const mode of ['legacy', 'web', 'esm']) { d: DEFAULT_CONFIG.domain, u: `${LOCAL_SERVER_ADDR}${url}`, v: tracker_script_version, - h: e.toBeUndefined(), + h: e.toBeUndefined() } ], shouldIgnoreRequest: [isEngagementEvent] diff --git a/tracker/test/plausible-npm-init.spec.ts b/tracker/test/plausible-npm-init.spec.ts index 832835150eb2..9848863f768f 100644 --- a/tracker/test/plausible-npm-init.spec.ts +++ b/tracker/test/plausible-npm-init.spec.ts @@ -31,7 +31,11 @@ test('if `init` is called without domain, it throws', async ({ page }, { }) => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: 'body' }) await page.goto(url) @@ -46,7 +50,11 @@ test('if `init` is called with no configuration, it throws', async ({ page }, { }) => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: 'body' }) await page.goto(url) @@ -60,13 +68,15 @@ test('if `track` is called before `init`, it throws', async ({ page }, { }) => { const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: 'body' }) await page.goto(url) - await expect( - page.evaluate(() => window.track('purchase')) - ).rejects.toThrow( + await expect(page.evaluate(() => window.track('purchase'))).rejects.toThrow( 'plausible.track() can only be called after plausible.init()' ) }) @@ -77,9 +87,11 @@ test('if `init` is called twice, it throws, but tracking still works', async ({ const config = { ...DEFAULT_CONFIG } const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: 'body' }) @@ -109,9 +121,10 @@ test('`bindToWindow` is true by default, and plausible is attached to window', a const config = { ...DEFAULT_CONFIG } const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: 'body' }) @@ -144,9 +157,10 @@ test('if `bindToWindow` is false, plausible is not attached to window', async ({ const config = { ...DEFAULT_CONFIG, bindToWindow: false } const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: 'body' }) @@ -159,13 +173,9 @@ test('if `bindToWindow` is false, plausible is not attached to window', async ({ }) await expect( - page.waitForFunction( - () => window.plausible !== undefined, - undefined, - { - timeout: 1000 - } - ) + page.waitForFunction(() => window.plausible !== undefined, undefined, { + timeout: 1000 + }) ).rejects.toThrow('page.waitForFunction: Timeout 1000ms exceeded.') }) @@ -175,9 +185,10 @@ test('allows overriding `endpoint` with a custom URL via `init`', async ({ const config = { ...DEFAULT_CONFIG, endpoint: 'http://example.com/event' } const { url } = await initializePageDynamically(page, { testId, - scriptConfig: ``, + scriptConfig: /* HTML */ ``, bodyContent: 'body' }) await expectPlausibleInAction(page, { diff --git a/tracker/test/plausible-web-init.spec.ts b/tracker/test/plausible-web-init.spec.ts index e95c9b4cfbe2..adc745e8af7f 100644 --- a/tracker/test/plausible-web-init.spec.ts +++ b/tracker/test/plausible-web-init.spec.ts @@ -54,7 +54,9 @@ test('handles double-initialization of the script with a console.warn', async ({ const { url } = await initializePageDynamically(page, { testId, scriptConfig: config, - bodyContent: `` + bodyContent: /* HTML */ `` }) const messages: [string, string][] = [] page.on('console', (message) => { @@ -90,14 +92,21 @@ test('handles double-initialization of the script with a console.warn', async ({ }) }) -test('if there are two snippets on the page, one wins, no warning is emitted', async ({ page }, { - testId -}) => { +test('if there are two snippets on the page, one wins, no warning is emitted', async ({ + page +}, { testId }) => { const config = { ...DEFAULT_CONFIG } - const snippetAlfa = getConfiguredPlausibleWebSnippet({...config, customProperties: { alfa: true }}) - const initCallAlfa = 'plausible.init({"captureOnLocalhost":true,"customProperties":{"alfa":true}})' - expect(snippetAlfa).toEqual(expect.stringContaining(initCallAlfa)) - const snippetBeta = getConfiguredPlausibleWebSnippet({...config, customProperties: { beta: true }}) + const snippetAlfa = getConfiguredPlausibleWebSnippet({ + ...config, + customProperties: { alfa: true } + }) + const initCallAlfa = + 'plausible.init({"captureOnLocalhost":true,"customProperties":{"alfa":true}})' + expect(snippetAlfa).toEqual(expect.stringContaining(initCallAlfa)) + const snippetBeta = getConfiguredPlausibleWebSnippet({ + ...config, + customProperties: { beta: true } + }) const initCallBeta = `plausible.init({"captureOnLocalhost":true,"customProperties":{"beta":true}})` expect(snippetBeta).toEqual(expect.stringContaining(initCallBeta)) @@ -108,18 +117,22 @@ test('if there are two snippets on the page, one wins, no warning is emitted', a const { url } = await initializePageDynamically(page, { testId, - scriptConfig: `${snippetAlfa}${snippetBeta}`, + scriptConfig: /* HTML */ `${snippetAlfa}${snippetBeta}`, bodyContent: '' }) await expectPlausibleInAction(page, { action: () => page.goto(url), expectedRequests: [ - { n: 'pageview', d: config.domain, u: `${LOCAL_SERVER_ADDR}${url}`, p: { beta: true } }, + { + n: 'pageview', + d: config.domain, + u: `${LOCAL_SERVER_ADDR}${url}`, + p: { beta: true } + } ], shouldIgnoreRequest: isEngagementEvent }) expect(messages).toEqual([]) - }) test('if domain is provided in `init`, it is ignored', async ({ page }, { @@ -129,7 +142,7 @@ test('if domain is provided in `init`, it is ignored', async ({ page }, { const scriptConfig = getConfiguredPlausibleWebSnippet(config) const originalInitCall = 'plausible.init({"captureOnLocalhost":true})' // verify that the original snippet is what we expect it to be - expect(scriptConfig).toEqual(expect.stringContaining(originalInitCall)) + expect(scriptConfig).toEqual(expect.stringContaining(originalInitCall)) const initCallWithDomainOverride = `plausible.init({"captureOnLocalhost":true,"domain":"sub.${config.domain}"})` const updatedScriptConfig = scriptConfig.replace( originalInitCall, diff --git a/tracker/test/revenue.spec.js b/tracker/test/revenue.spec.js index cb98a082b602..f17a8888821a 100644 --- a/tracker/test/revenue.spec.js +++ b/tracker/test/revenue.spec.js @@ -7,16 +7,22 @@ test.describe('with revenue script extension', () => { await expectPlausibleInAction(page, { action: () => page.click('#manual-purchase'), - expectedRequests: [{n: "Purchase", $: {amount: 15.99, currency: "USD"}}] + expectedRequests: [ + { n: 'Purchase', $: { amount: 15.99, currency: 'USD' } } + ] }) }) - test('sends revenue currency and amount with tagged class name', async ({ page }) => { + test('sends revenue currency and amount with tagged class name', async ({ + page + }) => { await page.goto('/revenue.html') await expectPlausibleInAction(page, { action: () => page.click('#tagged-purchase'), - expectedRequests: [{n: "Purchase", $: {amount: "13.32", currency: "EUR"}}] + expectedRequests: [ + { n: 'Purchase', $: { amount: '13.32', currency: 'EUR' } } + ] }) }) }) diff --git a/tracker/test/scroll-depth.spec.js b/tracker/test/scroll-depth.spec.js index 1b648a28d2a4..daf9ed58c2bf 100644 --- a/tracker/test/scroll-depth.spec.js +++ b/tracker/test/scroll-depth.spec.js @@ -1,12 +1,18 @@ -import { expectPlausibleInAction, hideCurrentTab, hideAndShowCurrentTab } from './support/test-utils' +import { + expectPlausibleInAction, + hideCurrentTab, + hideAndShowCurrentTab +} from './support/test-utils' import { test, expect } from '@playwright/test' import { LOCAL_SERVER_ADDR } from './support/server' test.describe('scroll depth (engagement events)', () => { - test('sends scroll_depth in the pageleave payload when navigating to the next page', async ({ page }) => { + test('sends scroll_depth in the pageleave payload when navigating to the next page', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/scroll-depth.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) await page.evaluate(() => window.scrollBy(0, 300)) @@ -14,37 +20,52 @@ test.describe('scroll depth (engagement events)', () => { await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20}] + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20 } + ] }) }) test('sends scroll depth on hash navigation', async ({ page }) => { await expectPlausibleInAction(page, { action: () => page.goto('/scroll-depth-hash.html'), - expectedRequests: [{n: 'pageview'}] + expectedRequests: [{ n: 'pageview' }] }) await expectPlausibleInAction(page, { action: () => page.click('#about-link'), expectedRequests: [ - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html`, sd: 100}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#about`} + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html`, + sd: 100 + }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#about` + } ] }) await expectPlausibleInAction(page, { action: () => page.click('#home-link'), expectedRequests: [ - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#about`, sd: 34}, - {n: 'pageview', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#home`} + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#about`, + sd: 34 + }, + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#home` } ] }) }) - test('document height gets reevaluated after window load', async ({ page }) => { + test('document height gets reevaluated after window load', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/scroll-depth-slow-window-load.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) // Wait for the image to be loaded @@ -54,30 +75,41 @@ test.describe('scroll depth (engagement events)', () => { await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - expectedRequests: [{ - n: 'engagement', - u: `${LOCAL_SERVER_ADDR}/scroll-depth-slow-window-load.html`, sd: 24 - }] + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-slow-window-load.html`, + sd: 24 + } + ] }) }) - test('dynamically loaded content affects documentHeight', async ({ page }) => { + test('dynamically loaded content affects documentHeight', async ({ + page + }) => { await expectPlausibleInAction(page, { action: () => page.goto('/scroll-depth-dynamic-content-load.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) // The link appears dynamically after 500ms. await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth-dynamic-content-load.html`, sd: 14}] + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-dynamic-content-load.html`, + sd: 14 + } + ] }) }) test('document height gets reevaluated on scroll', async ({ page }) => { await expectPlausibleInAction(page, { action: () => page.goto('/scroll-depth-content-onscroll.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) // During the first 3 seconds, the script periodically updates document height @@ -95,14 +127,20 @@ test.describe('scroll depth (engagement events)', () => { await expectPlausibleInAction(page, { action: () => page.click('#navigate-away'), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth-content-onscroll.html`, sd: 80}] + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-content-onscroll.html`, + sd: 80 + } + ] }) }) test('sends scroll depth when minimizing the tab', async ({ page }) => { await expectPlausibleInAction(page, { action: () => page.goto('/scroll-depth.html'), - expectedRequests: [{n: 'pageview'}], + expectedRequests: [{ n: 'pageview' }] }) await page.evaluate(() => window.scrollBy(0, 300)) @@ -110,11 +148,16 @@ test.describe('scroll depth (engagement events)', () => { await expectPlausibleInAction(page, { action: () => hideCurrentTab(page), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20}], + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20 } + ] }) }) - test('re-sends engagement events only when user has scrolled in-between', async ({ page, browserName }) => { + test('re-sends engagement events only when user has scrolled in-between', async ({ + page, + browserName + }) => { test.skip(browserName === 'webkit', 'flaky') await expectPlausibleInAction(page, { @@ -123,36 +166,39 @@ test.describe('scroll depth (engagement events)', () => { await hideAndShowCurrentTab(page) }, expectedRequests: [ - {n: 'pageview'}, - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 14} - ], + { n: 'pageview' }, + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 14 } + ] }) await expectPlausibleInAction(page, { action: () => hideAndShowCurrentTab(page), expectedRequests: [], - refutedRequests: [{n: 'engagement'}] + refutedRequests: [{ n: 'engagement' }] }) await page.evaluate(() => window.scrollBy(0, 300)) await expectPlausibleInAction(page, { action: () => hideCurrentTab(page), - expectedRequests: [{n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20}], + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20 } + ] }) }) - - test('gets correct scroll depth when script has no async', async ({ page }) => { + test('gets correct scroll depth when script has no async', async ({ + page + }) => { await expectPlausibleInAction(page, { action: async () => { await page.goto('/no-async.html') await hideAndShowCurrentTab(page) }, expectedRequests: [ - {n: 'pageview'}, - {n: 'engagement', u: `${LOCAL_SERVER_ADDR}/no-async.html`, sd: 100} - ], + { n: 'pageview' }, + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/no-async.html`, sd: 100 } + ] }) await page.waitForTimeout(1000) diff --git a/tracker/test/support/html-fixtures.ts b/tracker/test/support/html-fixtures.ts index 1694d1f48d5f..8ea6f228c0da 100644 --- a/tracker/test/support/html-fixtures.ts +++ b/tracker/test/support/html-fixtures.ts @@ -3,4 +3,5 @@ * Overriding onsubmit with a custom handler is common practice in web development for a variety of reasons (mostly UX), * so this stub can be used in tests to simulate such behavior. */ -export const customSubmitHandlerStub = "event.preventDefault(); console.log('Submitted')" +export const customSubmitHandlerStub = + '"event.preventDefault(); console.log(\'Submitted\')"' diff --git a/tracker/test/support/installation-support-playwright-wrappers.ts b/tracker/test/support/installation-support-playwright-wrappers.ts index 788eacdc2de3..2350ebf1aafd 100644 --- a/tracker/test/support/installation-support-playwright-wrappers.ts +++ b/tracker/test/support/installation-support-playwright-wrappers.ts @@ -14,8 +14,13 @@ const DETECTOR_JS_VARIANT = variantsFile.manualVariants.find( ) export async function executeVerifyV2( - page: Page, - { responseHeaders, maxAttempts, timeoutBetweenAttemptsMs, ...functionContext }: VerifyV2Args & { maxAttempts: number, timeoutBetweenAttemptsMs: number } + page: Page, + { + responseHeaders, + maxAttempts, + timeoutBetweenAttemptsMs, + ...functionContext + }: VerifyV2Args & { maxAttempts: number; timeoutBetweenAttemptsMs: number } ): Promise { const verifierCode = (await compileFile(VERIFIER_V2_JS_VARIANT, { returnCode: true @@ -26,34 +31,38 @@ export async function executeVerifyV2( await page.evaluate(verifierCode) // injects window.verifyPlausibleInstallation return await page.evaluate( // @ts-expect-error - window.verifyPlausibleInstallation has been injected - (c) => {return window.verifyPlausibleInstallation(c)}, - {...functionContext, responseHeaders} - ); + (c) => { + return window.verifyPlausibleInstallation(c) + }, + { ...functionContext, responseHeaders } + ) } - let lastError; + let lastError for (let attempts = 1; attempts <= maxAttempts; attempts++) { try { - const output = await verify(); + const output = await verify() return { data: { ...output.data, attempts - }, - }; + } + } } catch (error) { - lastError = error; + lastError = error if ( - typeof error?.message === "string" && - error.message.toLowerCase().includes("execution context") + typeof error?.message === 'string' && + error.message.toLowerCase().includes('execution context') ) { - await new Promise((resolve) => setTimeout(resolve, timeoutBetweenAttemptsMs)); - continue; + await new Promise((resolve) => + setTimeout(resolve, timeoutBetweenAttemptsMs) + ) + continue } throw error } } - throw lastError; + throw lastError } catch (error) { return { data: { @@ -80,10 +89,7 @@ export async function verifyV1(page, context) { return await page.evaluate( async ({ expectedDataDomain, debug }) => { // @ts-expect-error - window.verifyPlausibleInstallation has been injected - return await window.verifyPlausibleInstallation( - expectedDataDomain, - debug - ) + return await window.verifyPlausibleInstallation(expectedDataDomain, debug) }, { expectedDataDomain, debug } ) diff --git a/tracker/test/support/server.js b/tracker/test/support/server.js index 8894b0605c11..39df1e4a86c8 100644 --- a/tracker/test/support/server.js +++ b/tracker/test/support/server.js @@ -5,9 +5,9 @@ import { compileFile } from '../../compiler/index.js' import variantsFile from '../../compiler/variants.json' with { type: 'json' } const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const isMainModule = fileURLToPath(import.meta.url) === process.argv[1]; +const isMainModule = fileURLToPath(import.meta.url) === process.argv[1] -const app = express(); +const app = express() const LOCAL_SERVER_PORT = 3000 const FIXTURES_PATH = path.join(__dirname, '/../fixtures') const VARIANTS = variantsFile.legacyVariants.concat(variantsFile.manualVariants) @@ -15,13 +15,16 @@ const VARIANTS = variantsFile.legacyVariants.concat(variantsFile.manualVariants) export const LOCAL_SERVER_ADDR = `http://localhost:${LOCAL_SERVER_PORT}` export function runLocalFileServer() { - app.use(express.static(FIXTURES_PATH)); + app.use(express.static(FIXTURES_PATH)) app.get('/tracker/js/*', async (req, res) => { const name = req.params[0] const variant = VARIANTS.find((variant) => variant.name === name) if (!variant) { - res.type('application/javascript').status(404).send({error: new Error(`Variant not found with name ${name}`)}) + res + .type('application/javascript') + .status(404) + .send({ error: new Error(`Variant not found with name ${name}`) }) } else { let code = await compileFile(variant, { returnCode: true }) @@ -30,19 +33,19 @@ export function runLocalFileServer() { } res.type('application/javascript').send(code) - } - }); + } + }) // A test utility - serve an image with an artificial delay app.get('/img/slow-image', (_req, res) => { setTimeout(() => { - res.sendFile(path.join(FIXTURES_PATH, '/img/black3x3000.png')); - }, 100); - }); + res.sendFile(path.join(FIXTURES_PATH, '/img/black3x3000.png')) + }, 100) + }) app.listen(LOCAL_SERVER_PORT, function () { console.log(`Local server listening on ${LOCAL_SERVER_ADDR}`) - }); + }) } if (isMainModule) { diff --git a/tracker/test/support/test-utils.js b/tracker/test/support/test-utils.js index c314bc965a0e..d2b1cd7b6817 100644 --- a/tracker/test/support/test-utils.js +++ b/tracker/test/support/test-utils.js @@ -1,6 +1,6 @@ -import { expect } from "@playwright/test" +import { expect } from '@playwright/test' import packageJson from '../../package.json' with { type: 'json' } -import { mockManyRequests } from "./mock-many-requests" +import { mockManyRequests } from './mock-many-requests' export const tracker_script_version = packageJson.tracker_script_version @@ -33,19 +33,26 @@ export const tracker_script_version = packageJson.tracker_script_version * API by the given number of milliseconds. * @param {number} [args.mockRequestTimeout] - How long to wait for the requests to be made */ -export const expectPlausibleInAction = async function (page, { - action, - expectedRequests = [], - refutedRequests = [], - pathToMock = '/api/event', - awaitedRequestCount, - expectedRequestCount, - responseDelay, - shouldIgnoreRequest, - mockRequestTimeout = 3000 -}) { - const requestsToExpect = expectedRequestCount ? expectedRequestCount : expectedRequests.length - const requestsToAwait = awaitedRequestCount ? awaitedRequestCount : requestsToExpect + refutedRequests.length +export const expectPlausibleInAction = async function ( + page, + { + action, + expectedRequests = [], + refutedRequests = [], + pathToMock = '/api/event', + awaitedRequestCount, + expectedRequestCount, + responseDelay, + shouldIgnoreRequest, + mockRequestTimeout = 3000 + } +) { + const requestsToExpect = expectedRequestCount + ? expectedRequestCount + : expectedRequests.length + const requestsToAwait = awaitedRequestCount + ? awaitedRequestCount + : requestsToExpect + refutedRequests.length const { getRequestList } = await mockManyRequests({ page, @@ -66,7 +73,9 @@ export const expectPlausibleInAction = async function (page, { return includesSubset(requestBody, bodySubset) }) - if (!wasFound) {expectedButNotFoundBodySubsets.push(bodySubset)} + if (!wasFound) { + expectedButNotFoundBodySubsets.push(bodySubset) + } }) const refutedButFoundRequestBodies = [] @@ -76,63 +85,79 @@ export const expectPlausibleInAction = async function (page, { return includesSubset(requestBody, bodySubset) }) - if (found) {refutedButFoundRequestBodies.push(found)} + if (found) { + refutedButFoundRequestBodies.push(found) + } }) const expectedBodySubsetsErrorMessage = `The following body subsets were not found from the requests that were made:\n\n${JSON.stringify(expectedButNotFoundBodySubsets, null, 4)}\n\nReceived requests with the following bodies:\n\n${JSON.stringify(requestBodies, null, 4)}` - expect(expectedButNotFoundBodySubsets, expectedBodySubsetsErrorMessage).toHaveLength(0) + expect( + expectedButNotFoundBodySubsets, + expectedBodySubsetsErrorMessage + ).toHaveLength(0) const refutedBodySubsetsErrorMessage = `The following requests were made, but were not expected:\n\n${JSON.stringify(refutedButFoundRequestBodies, null, 4)}` - expect(refutedButFoundRequestBodies, refutedBodySubsetsErrorMessage).toHaveLength(0) + expect( + refutedButFoundRequestBodies, + refutedBodySubsetsErrorMessage + ).toHaveLength(0) const unexpectedRequestBodiesErrorMessage = `Expected ${requestsToExpect} requests, but received ${requestBodies.length}:\n\n${JSON.stringify(requestBodies, null, 4)}` - expect(requestBodies.length, unexpectedRequestBodiesErrorMessage).toBe(requestsToExpect) + expect(requestBodies.length, unexpectedRequestBodiesErrorMessage).toBe( + requestsToExpect + ) return requestBodies } -export const isPageviewEvent = function(requestPostData) { +export const isPageviewEvent = function (requestPostData) { return requestPostData.n === 'pageview' } -export const isEngagementEvent = function(requestPostData) { +export const isEngagementEvent = function (requestPostData) { return requestPostData.n === 'engagement' } async function toggleTabVisibility(page, hide) { await page.evaluate((hide) => { - Object.defineProperty(document, 'visibilityState', { value: hide ? 'hidden' : 'visible', writable: true }) + Object.defineProperty(document, 'visibilityState', { + value: hide ? 'hidden' : 'visible', + writable: true + }) Object.defineProperty(document, 'hidden', { value: hide, writable: true }) document.dispatchEvent(new Event('visibilitychange')) }, hide) } -export const hideCurrentTab = async function(page) { +export const hideCurrentTab = async function (page) { return toggleTabVisibility(page, true) } -export const showCurrentTab = async function(page) { +export const showCurrentTab = async function (page) { return toggleTabVisibility(page, false) } async function setFocus(page, focus) { await page.evaluate((focus) => { - Object.defineProperty(document, 'hasFocus', { value: () => focus, writable: true }) + Object.defineProperty(document, 'hasFocus', { + value: () => focus, + writable: true + }) const eventName = focus ? 'focus' : 'blur' window.dispatchEvent(new Event(eventName)) }, focus) } -export const focus = async function(page) { +export const focus = async function (page) { return setFocus(page, true) } -export const blur = async function(page) { +export const blur = async function (page) { return setFocus(page, false) } -export const hideAndShowCurrentTab = async function(page, options = {}) { +export const hideAndShowCurrentTab = async function (page, options = {}) { await hideCurrentTab(page) if (options.delay > 0) { await delay(options.delay) @@ -140,7 +165,7 @@ export const hideAndShowCurrentTab = async function(page, options = {}) { await showCurrentTab(page) } -export const blurAndFocusPage = async function(page, options = {}) { +export const blurAndFocusPage = async function (page, options = {}) { await blur(page) if (options.delay > 0) { await delay(options.delay) @@ -163,7 +188,10 @@ export const e = { function includesSubset(body, subset) { return Object.keys(subset).every((key) => { if (typeof subset[key] === 'object' && !subset[key].__expectation__) { - return typeof body[key] === 'object' && areFlatObjectsEqual(body[key], subset[key]) + return ( + typeof body[key] === 'object' && + areFlatObjectsEqual(body[key], subset[key]) + ) } else { return checkEqual(body[key], subset[key]) } @@ -176,9 +204,9 @@ function areFlatObjectsEqual(obj1, obj2) { const keys1 = Object.keys(obj1) const keys2 = Object.keys(obj2) - if (keys1.length !== keys2.length) return false; + if (keys1.length !== keys2.length) return false - return keys1.every(key => checkEqual(obj2[key], obj1[key])) + return keys1.every((key) => checkEqual(obj2[key], obj1[key])) } function checkEqual(a, b) { @@ -189,14 +217,14 @@ function checkEqual(a, b) { } export function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) + return new Promise((resolve) => setTimeout(resolve, ms)) } export function switchByMode(cases, mode) { switch (mode) { case 'web': return cases.web - case 'esm': + case 'esm': return cases.esm case 'legacy': return cases.legacy @@ -208,10 +236,10 @@ export function switchByMode(cases, mode) { /** * This function ensures that the tracker script has attached the event listener before test is run. * Note that this race condition happens in the real world as well: - * Events from features like form submissions, file downloads, outbound links, tagged events + * Events from features like form submissions, file downloads, outbound links, tagged events * that work with event handlers registered on the document * will not be tracked if the event happens before the tracker script has attached the event listener. */ export function ensurePlausibleInitialized(page) { - return page.waitForFunction(() =>(window.plausible?.l === true)) + return page.waitForFunction(() => window.plausible?.l === true) } diff --git a/tracker/test/tagged-events.spec.ts b/tracker/test/tagged-events.spec.ts index e807616a38dc..ae60605568f3 100644 --- a/tracker/test/tagged-events.spec.ts +++ b/tracker/test/tagged-events.spec.ts @@ -47,7 +47,11 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `Purchase` + bodyContent: /* HTML */ `Purchase` }) await expectPlausibleInAction(page, { action: async () => { @@ -82,9 +86,11 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: ` - -

    + bodyContent: /* HTML */ ` +

    ` }) await page.goto(url) @@ -125,8 +131,12 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `
    - Reset password + bodyContent: /* HTML */ `` }) await page.goto(url) @@ -159,8 +169,11 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: ` -
    + bodyContent: /* HTML */ `

    Newsletter Signup

    @@ -208,10 +221,9 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: ` -
    + bodyContent: /* HTML */ `
    - +
    ` @@ -252,7 +264,10 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `anytext` + bodyContent: /* HTML */ `anytext` }) await page.goto(url) @@ -287,7 +302,11 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `` + bodyContent: /* HTML */ `` }) await page.goto(url) @@ -360,7 +379,7 @@ for (const mode of ['legacy', 'web']) { const targetPage = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: `

    Subscription successful

    `, + bodyContent: /* HTML */ `

    Subscription successful

    `, path: '/target' }) const { url } = await initializePageDynamically(page, { @@ -373,7 +392,11 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `Click to subscribe` + bodyContent: /* HTML */ `Click to subscribe` }) await page.goto(url) const navigationPromise = page.waitForRequest(targetPage.url, { @@ -403,7 +426,7 @@ for (const mode of ['legacy', 'web']) { const targetPage = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: `

    Navigation successful

    `, + bodyContent: /* HTML */ `

    Navigation successful

    `, path: '/target' }) const { url } = await initializePageDynamically(page, { @@ -416,7 +439,7 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: ` + bodyContent: /* HTML */ ` @@ -458,7 +481,7 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `` + bodyContent: /* HTML */ `` }) await page.goto(url) @@ -482,7 +505,9 @@ for (const mode of ['legacy', 'web']) { }, mode ), - bodyContent: `anything` + bodyContent: /* HTML */ `anything` }) await page.goto(url) @@ -562,7 +587,12 @@ test.describe('tagged events feature when using legacy .compat extension', () => testId, scriptConfig: '', - bodyContent: `

    ➡️

    ` + bodyContent: /* HTML */ `

    ➡️

    ` }) await page.goto(url) @@ -591,14 +621,18 @@ test.describe('tagged events feature when using legacy .compat extension', () => const targetPage = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: `

    Subscription successful

    `, + bodyContent: /* HTML */ `

    Subscription successful

    `, path: '/target' }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: `Click to subscribe` + bodyContent: /* HTML */ `Click to subscribe` }) await page.goto(url) const navigationPromise = page.waitForRequest(targetPage.url, { @@ -635,14 +669,18 @@ test.describe('tagged events feature when using legacy .compat extension', () => const targetPage = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: `

    Subscription successful

    `, + bodyContent: /* HTML */ `

    Subscription successful

    `, path: '/target' }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: `Click to subscribe` + bodyContent: /* HTML */ `Click to subscribe` }) await page.goto(url) const navigationPromise = page.waitForRequest(targetPage.url, { @@ -659,14 +697,18 @@ test.describe('tagged events feature when using legacy .compat extension', () => const targetPage = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: `

    Subscription successful

    `, + bodyContent: /* HTML */ `

    Subscription successful

    `, path: '/target' }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: `Click to subscribe` + bodyContent: /* HTML */ `Click to subscribe` }) await page.goto(url) @@ -684,14 +726,14 @@ test.describe('tagged events feature when using legacy .compat extension', () => const targetPage = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: `

    Subscription successful

    `, + bodyContent: /* HTML */ `

    Subscription successful

    `, path: '/target' }) const { url } = await initializePageDynamically(page, { testId, scriptConfig: '', - bodyContent: ` + bodyContent: /* HTML */ ` diff --git a/tracker/test/transform_request.spec.ts b/tracker/test/transform_request.spec.ts index 6bbf588d13aa..7823142e8cd2 100644 --- a/tracker/test/transform_request.spec.ts +++ b/tracker/test/transform_request.spec.ts @@ -80,7 +80,9 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `` + bodyContent: /* HTML */ `` }) await expectPlausibleInAction(page, { @@ -119,7 +121,9 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `` + bodyContent: /* HTML */ `` }) await expectPlausibleInAction(page, { @@ -137,45 +141,47 @@ for (const mode of ['web', 'esm']) { }) test('"transformRequest" does not allow making engagement event props different from pageview event props', async ({ - page - }, { testId }) => { - const config = { - ...DEFAULT_CONFIG, - transformRequest: (payload) => { - // @ts-expect-error - defines window.requestCount - window.requestCount = (window.requestCount ?? 0) + 1 - // @ts-expect-error - window.requestCount is defined - return { ...payload, p: { requestCount: window.requestCount } } - } + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: (payload) => { + // @ts-expect-error - defines window.requestCount + window.requestCount = (window.requestCount ?? 0) + 1 + // @ts-expect-error - window.requestCount is defined + return { ...payload, p: { requestCount: window.requestCount } } } - - const { url } = await initializePageDynamically(page, { - testId, - scriptConfig: switchByMode( - { - web: config, - esm: `` - }, - mode - ), - bodyContent: `` - }) - - await expectPlausibleInAction(page, { - action: async () => { - await page.goto(url) - await page.click('button') - await hideAndShowCurrentTab(page, { delay: 200 }) + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` }, - expectedRequests: [ - { n: 'pageview', p: { requestCount: 1 } }, - { n: 'Purchase', p: { requestCount: 2 } }, - { n: 'engagement', p: { requestCount: 1 } } - ] - }) - }) + mode + ), + bodyContent: /* HTML */ `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { n: 'pageview', p: { requestCount: 1 } }, + { n: 'Purchase', p: { requestCount: 2 } }, + { n: 'engagement', p: { requestCount: 1 } } + ] + }) + }) test('specificity: "transformRequest" runs after custom properties are determined', async ({ page @@ -239,7 +245,11 @@ for (const mode of ['web', 'esm']) { }, mode ), - bodyContent: `` + bodyContent: /* HTML */ `` }) await expectPlausibleInAction(page, { From aceef2d6d37bb1bcb7b530a2c79ca8f584272f67 Mon Sep 17 00:00:00 2001 From: apata Date: Mon, 15 Sep 2025 11:25:59 +0000 Subject: [PATCH 303/618] Released tracker script version 0.4.3 --- tracker/npm_package/CHANGELOG.md | 2 ++ tracker/npm_package/package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tracker/npm_package/CHANGELOG.md b/tracker/npm_package/CHANGELOG.md index 8f4a87669d03..c7e1cd5831ec 100644 --- a/tracker/npm_package/CHANGELOG.md +++ b/tracker/npm_package/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## [0.4.3] - 2025-09-15 + - Fix formatting issues. ## [0.4.2] - 2025-09-04 diff --git a/tracker/npm_package/package.json b/tracker/npm_package/package.json index 76a69253a9aa..237285c9152c 100644 --- a/tracker/npm_package/package.json +++ b/tracker/npm_package/package.json @@ -1,6 +1,6 @@ { "name": "@plausible-analytics/tracker", - "version": "0.4.2", + "version": "0.4.3", "description": "Plausible Analytics official frontend tracking library", "scripts": { "test": "echo \"Error: Testing done in the tracker folder\" && exit 1" From d02636dd07af7672abc528dc53ffa40d25e983af Mon Sep 17 00:00:00 2001 From: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:51:37 +0100 Subject: [PATCH 304/618] POC: Rollup view (#5719) * POC rollup dashboard * add rollups to stats api * remove the irrelevant new controller action * use team_identifier instead of team_id --- lib/plausible/site.ex | 14 ++++++++++ lib/plausible/stats/filters/query_parser.ex | 8 ++++++ .../stats/legacy/legacy_query_builder.ex | 8 ++++++ lib/plausible/stats/query.ex | 1 + lib/plausible/stats/sql/where_builder.ex | 28 +++++++++++++++++++ .../plugs/authorize_public_api.ex | 15 ++++++++-- .../plugs/authorize_site_access.ex | 9 ++++++ test/plausible/stats/query_parser_test.exs | 3 +- 8 files changed, 82 insertions(+), 4 deletions(-) diff --git a/lib/plausible/site.ex b/lib/plausible/site.ex index 7aec34d6d182..d69793a4215f 100644 --- a/lib/plausible/site.ex +++ b/lib/plausible/site.ex @@ -62,9 +62,23 @@ defmodule Plausible.Site do has_many :completed_imports, Plausible.Imported.SiteImport, where: [status: :completed] + field :rollup, :boolean, virtual: true, default: false + timestamps() end + def rollup(team) do + %Plausible.Site{ + id: 0, + native_stats_start_at: ~N[2018-01-01 00:00:00], + stats_start_date: ~D[2018-01-01], + rollup: true, + domain: "rollup:#{team.identifier}", + team: team, + team_id: team.id + } + end + def new_for_team(team, params) do params |> new() diff --git a/lib/plausible/stats/filters/query_parser.ex b/lib/plausible/stats/filters/query_parser.ex index d0eae7b1ee3f..7b64d45df7d9 100644 --- a/lib/plausible/stats/filters/query_parser.ex +++ b/lib/plausible/stats/filters/query_parser.ex @@ -47,8 +47,10 @@ defmodule Plausible.Stats.Filters.QueryParser do {:ok, pagination} <- parse_pagination(Map.get(params, "pagination", %{})), {preloaded_goals, revenue_warning, revenue_currencies} <- preload_goals_and_revenue(site, metrics, filters, dimensions), + rollup_site_ids = get_rollup_site_ids(site), query = %{ now: now, + rollup_site_ids: rollup_site_ids, input_date_range: Map.get(params, "date_range"), metrics: metrics, filters: filters, @@ -75,6 +77,12 @@ defmodule Plausible.Stats.Filters.QueryParser do end end + def get_rollup_site_ids(%Plausible.Site{rollup: true} = site) do + Plausible.Teams.owned_sites_ids(site.team) + end + + def get_rollup_site_ids(_site), do: nil + def parse_date_range_pair(site, [from, to]) when is_binary(from) and is_binary(to) do with {:ok, date_range} <- date_range_from_date_strings(site, from, to) do {:ok, date_range |> DateTimeRange.to_timezone("Etc/UTC")} diff --git a/lib/plausible/stats/legacy/legacy_query_builder.ex b/lib/plausible/stats/legacy/legacy_query_builder.ex index fdb643462f32..88e27d25c668 100644 --- a/lib/plausible/stats/legacy/legacy_query_builder.ex +++ b/lib/plausible/stats/legacy/legacy_query_builder.ex @@ -28,6 +28,7 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do |> put_parsed_filters(params) |> resolve_segments(site) |> preload_goals_and_revenue(site) + |> put_rollup_site_ids(site) |> put_order_by(params) |> put_include(site, params) |> Query.put_comparison_utc_time_range() @@ -41,6 +42,13 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do query end + defp put_rollup_site_ids(query, %Plausible.Site{rollup: true} = site) do + site_ids = Plausible.Teams.owned_sites_ids(site.team) + struct!(query, rollup_site_ids: site_ids) + end + + defp put_rollup_site_ids(query, _site), do: query + defp resolve_segments(query, site) do with {:ok, preloaded_segments} <- Plausible.Segments.Filters.preload_needed_segments(site, query.filters), diff --git a/lib/plausible/stats/query.ex b/lib/plausible/stats/query.ex index aabb61f26a49..5bbf7f44841d 100644 --- a/lib/plausible/stats/query.ex +++ b/lib/plausible/stats/query.ex @@ -26,6 +26,7 @@ defmodule Plausible.Stats.Query do revenue_warning: nil, remove_unavailable_revenue_metrics: false, site_id: nil, + rollup_site_ids: nil, site_native_stats_start_at: nil, # Contains information to determine how to combine legacy and new time on page metrics time_on_page_data: %{}, diff --git a/lib/plausible/stats/sql/where_builder.ex b/lib/plausible/stats/sql/where_builder.ex index a8c79fbd9c5e..3842e0e25a02 100644 --- a/lib/plausible/stats/sql/where_builder.ex +++ b/lib/plausible/stats/sql/where_builder.ex @@ -41,6 +41,19 @@ defmodule Plausible.Stats.SQL.WhereBuilder do end end + defp filter_site_time_range( + :events, + %Plausible.Stats.Query{rollup_site_ids: [_ | _] = site_ids} = query + ) do + {first_datetime, last_datetime} = utc_boundaries(query) + + dynamic( + [e], + e.site_id in ^site_ids and e.timestamp >= ^first_datetime and + e.timestamp <= ^last_datetime + ) + end + defp filter_site_time_range(:events, query) do {first_datetime, last_datetime} = utc_boundaries(query) @@ -51,6 +64,21 @@ defmodule Plausible.Stats.SQL.WhereBuilder do ) end + defp filter_site_time_range( + :sessions, + %Plausible.Stats.Query{rollup_site_ids: [_ | _] = site_ids} = query + ) do + {first_datetime, last_datetime} = utc_boundaries(query) + + dynamic( + [s], + s.site_id in ^site_ids and + s.start >= ^NaiveDateTime.add(first_datetime, -7, :day) and + s.timestamp >= ^first_datetime and + s.start <= ^last_datetime + ) + end + defp filter_site_time_range(:sessions, query) do {first_datetime, last_datetime} = utc_boundaries(query) diff --git a/lib/plausible_web/plugs/authorize_public_api.ex b/lib/plausible_web/plugs/authorize_public_api.ex index 5bbdfb287480..d18aa644d513 100644 --- a/lib/plausible_web/plugs/authorize_public_api.ex +++ b/lib/plausible_web/plugs/authorize_public_api.ex @@ -121,7 +121,7 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do defp verify_by_scope(conn, api_key, "stats:read:" <> _ = scope) do with :ok <- check_scope(api_key, scope), - {:ok, site} <- find_site(conn.params["site_id"]), + {:ok, site} <- find_site(conn.params["site_id"], api_key), :ok <- verify_site_access(api_key, site) do Plausible.OpenTelemetry.add_site_attributes(site) site = Plausible.Repo.preload(site, :completed_imports) @@ -173,9 +173,18 @@ defmodule PlausibleWeb.Plugs.AuthorizePublicAPI do end end - defp find_site(nil), do: {:error, :missing_site_id} + defp find_site(nil, _api_key), do: {:error, :missing_site_id} - defp find_site(site_id) do + defp find_site("rollup:" <> team_identifier, api_key) do + with true <- Plausible.Auth.is_super_admin?(api_key.user), + %Plausible.Teams.Team{} = team <- Plausible.Teams.get(team_identifier) do + {:ok, Plausible.Site.rollup(team)} + else + _ -> {:error, :invalid_api_key} + end + end + + defp find_site(site_id, _api_key) do domain_based_search = from s in Plausible.Site, where: s.domain == ^site_id or s.domain_changed_from == ^site_id diff --git a/lib/plausible_web/plugs/authorize_site_access.ex b/lib/plausible_web/plugs/authorize_site_access.ex index 589cac5ff7d9..8c8924976c46 100644 --- a/lib/plausible_web/plugs/authorize_site_access.ex +++ b/lib/plausible_web/plugs/authorize_site_access.ex @@ -172,6 +172,15 @@ defmodule PlausibleWeb.Plugs.AuthorizeSiteAccess do end end + defp get_site_with_role(conn, current_user, "rollup:" <> team_identifier) do + with true <- Plausible.Auth.is_super_admin?(current_user), + %Plausible.Teams.Team{} = team <- Plausible.Teams.get(team_identifier) do + {:ok, %{site: Plausible.Site.rollup(team), role: nil, member_type: nil}} + else + _ -> error_not_found(conn) + end + end + defp get_site_with_role(conn, current_user, domain) do site = Repo.get_by(Plausible.Site, domain: domain) diff --git a/test/plausible/stats/query_parser_test.exs b/test/plausible/stats/query_parser_test.exs index 80db0025b95b..6d67c6507537 100644 --- a/test/plausible/stats/query_parser_test.exs +++ b/test/plausible/stats/query_parser_test.exs @@ -77,7 +77,8 @@ defmodule Plausible.Stats.Filters.QueryParserTest do :input_date_range, :preloaded_goals, :revenue_warning, - :revenue_currencies + :revenue_currencies, + :rollup_site_ids ]) assert result == expected_result From 1531386b767c6827216ab63d87fa447c46d37bf7 Mon Sep 17 00:00:00 2001 From: Karl-Aksel Puulmann Date: Mon, 15 Sep 2025 17:14:59 +0300 Subject: [PATCH 305/618] FULL join for time:hour as well as time:minute (#5715) * FULL join for time:hour as well as time:minute Follow-up to https://github.com/plausible/analytics/pull/5694/files#r2321567271 A session might be active over multiple hours, but not (currently) reported as such when requesting only specific metrics per hour. This fixes that problem. * Handle full join logic correctly --------- Co-authored-by: Uku Taht --- CHANGELOG.md | 1 + lib/plausible/stats/query_optimizer.ex | 6 ++-- lib/plausible/stats/sql/query_builder.ex | 36 ++++++++++++------- test/plausible/stats/query_optimizer_test.exs | 6 ++-- .../external_stats_controller/query_test.exs | 23 ++++++++++++ 5 files changed, 55 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba3930eafa9d..324db5600add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file. - Realtime and hourly graphs of visit duration, views per visit no longer overcount due to long-lasting sessions, instead showing each visit when they occurred. - Fixed realtime and hourly graphs of visits overcounting +- When reporting only `visitors` and `visits` per hour, count visits in each hour they were active in. ## v3.0.0 - 2025-04-11 diff --git a/lib/plausible/stats/query_optimizer.ex b/lib/plausible/stats/query_optimizer.ex index 23ebae1baa4a..70e83643b54b 100644 --- a/lib/plausible/stats/query_optimizer.ex +++ b/lib/plausible/stats/query_optimizer.ex @@ -318,12 +318,12 @@ defmodule Plausible.Stats.QueryOptimizer do # Normally we can always LEFT JOIN as this is more performant and tables # are expected to contain the same dimensions. - # The only exception is using the "time:minute" dimension where the sessions + # The only exception is using the "time:minute"/"time:hour" dimension where the sessions # subquery might return more rows than the events one. That's because we're # counting sessions in all time buckets they were active in even if no event - # occurred during that particular minute. + # occurred during that particular bucket. defp set_sql_join_type(query) do - if "time:minute" in query.dimensions do + if "time:minute" in query.dimensions or "time:hour" in query.dimensions do Query.set(query, sql_join_type: :full) else query diff --git a/lib/plausible/stats/sql/query_builder.ex b/lib/plausible/stats/sql/query_builder.ex index ae8deb74f31d..51a538c172c3 100644 --- a/lib/plausible/stats/sql/query_builder.ex +++ b/lib/plausible/stats/sql/query_builder.ex @@ -185,7 +185,7 @@ defmodule Plausible.Stats.SQL.QueryBuilder do ) |> select_join_metrics(query, query.metrics -- [:sample_percent]) end) - |> select_dimensions(main_query) + |> select_dimensions(main_query, queries) end # NOTE: Old queries do their own pagination @@ -223,19 +223,31 @@ defmodule Plausible.Stats.SQL.QueryBuilder do end) end - defp select_dimensions(q, query) do + defp select_dimensions(q, query, queries) do Enum.reduce(query.dimensions, q, fn dimension, q -> - # We generally select dimensions from the left-most table. Only exception is time:minute where - # we use sessions table as sessions are considered on-going during the whole period. - if query.sql_join_type == :full and "time:minute" == dimension do - select_merge_as(q, [..., x], %{ - shortname(query, dimension) => field(x, ^shortname(query, dimension)) - }) - else - select_merge_as(q, [x], %{ - shortname(query, dimension) => field(x, ^shortname(query, dimension)) - }) + case select_from(dimension, query, queries) do + :leftmost_table -> + select_merge_as(q, [x], %{ + shortname(query, dimension) => field(x, ^shortname(query, dimension)) + }) + + :rightmost_table -> + select_merge_as(q, [..., x], %{ + shortname(query, dimension) => field(x, ^shortname(query, dimension)) + }) end end) end + + defp select_from(dimension, query, queries) do + smeared? = Enum.any?(queries, fn {_table_type, query, _q} -> query.smear_session_metrics end) + + cond do + query.sql_join_type == :left -> :leftmost_table + # We generally select dimensions from the left-most table. Only exception is time:minute/time:hour where + # we use sessions table as smeared sessions are considered on-going during the whole period. + dimension in ["time:minute", "time:hour"] and smeared? -> :rightmost_table + true -> :leftmost_table + end + end end diff --git a/test/plausible/stats/query_optimizer_test.exs b/test/plausible/stats/query_optimizer_test.exs index e7e5bd27fd17..036ae9fbc9e9 100644 --- a/test/plausible/stats/query_optimizer_test.exs +++ b/test/plausible/stats/query_optimizer_test.exs @@ -365,12 +365,14 @@ defmodule Plausible.Stats.QueryOptimizerTest do end describe "set_sql_join_type" do - test "updates sql_join_type to :full if time:minute dimension is present" do + test "updates sql_join_type to :full if time:minute or time:hour dimension is present" do assert perform(%{dimensions: ["time:minute"]}).sql_join_type == :full + assert perform(%{dimensions: ["time:hour"]}).sql_join_type == :full end test "keeps default sql_join_type otherwise" do - assert perform(%{dimensions: ["time:hour"]}).sql_join_type == :left + assert perform(%{dimensions: ["time:day"]}).sql_join_type == :left + assert perform(%{dimensions: []}).sql_join_type == :left end end end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_test.exs index 2f22cef28c43..e606eaceb15e 100644 --- a/test/plausible_web/controllers/api/external_stats_controller/query_test.exs +++ b/test/plausible_web/controllers/api/external_stats_controller/query_test.exs @@ -1372,6 +1372,29 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryTest do ] end + test "breakdown by time:hour (internal API), counts visitors and visits in all buckets their session was active in", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:20:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:40:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 01:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 01:20:00]) + ]) + + conn = + post(conn, "/api/v2/query-internal-test", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "visits", "visit_duration"], + "date_range" => ["2021-01-01", "2021-01-02"], + "dimensions" => ["time:hour"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [1, 1, 0]}, + %{"dimensions" => ["2021-01-01 01:00:00"], "metrics" => [1, 1, 3600]} + ] + end + test "shows hourly data for a certain date with time_labels", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), From a0e5f801ff414f61533bd14eb36552303842491b Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Tue, 16 Sep 2025 08:28:36 +0300 Subject: [PATCH 306/618] Script v2: Maybe load installation type from DB (#5717) * Maybe load installation type from DB * Swap get_or_create... with get_tracker_script_configuration * Fix getting saved_installation_type * Add utility to htmlize quotes * Update manual snippet not found error text * Prevent custom URL input if scriptv2 not true * Test verification v2 flows * Fix import for CE test --- .../detection/diagnostics.ex | 2 +- .../verification/checks.ex | 1 + .../verification/diagnostics.ex | 8 +- lib/plausible_web/live/installation.ex | 9 +- lib/plausible_web/live/installationv2.ex | 12 +- lib/plausible_web/live/verification.ex | 58 +++- lib/plausible_web/tracker.ex | 24 +- .../verification/checks_test.exs | 4 +- .../controllers/settings_controller_test.exs | 2 +- .../controllers/site_controller_test.exs | 4 +- .../live/change_domain_v2_test.exs | 2 +- .../live/components/verification_test.exs | 4 +- test/plausible_web/live/verification_test.exs | 28 +- .../live/verification_v2_test.exs | 322 ++++++++++++++++++ test/support/test_utils.ex | 4 + 15 files changed, 436 insertions(+), 48 deletions(-) create mode 100644 test/plausible_web/live/verification_v2_test.exs diff --git a/lib/plausible/installation_support/detection/diagnostics.ex b/lib/plausible/installation_support/detection/diagnostics.ex index 412ed6e73212..50966bf48cea 100644 --- a/lib/plausible/installation_support/detection/diagnostics.ex +++ b/lib/plausible/installation_support/detection/diagnostics.ex @@ -56,7 +56,7 @@ defmodule Plausible.InstallationSupport.Detection.Diagnostics do } = diagnostics, _url ) do - get_result("manual", diagnostics) + get_result(PlausibleWeb.Tracker.fallback_installation_type(), diagnostics) end def interpret( diff --git a/lib/plausible/installation_support/verification/checks.ex b/lib/plausible/installation_support/verification/checks.ex index 4c4ec4f41050..2479f011a763 100644 --- a/lib/plausible/installation_support/verification/checks.ex +++ b/lib/plausible/installation_support/verification/checks.ex @@ -15,6 +15,7 @@ defmodule Plausible.InstallationSupport.Verification.Checks do Checks.InstallationV2CacheBust ] + @spec run(String.t(), String.t(), String.t(), Keyword.t()) :: :ok def run(url, data_domain, installation_type, opts \\ []) do report_to = Keyword.get(opts, :report_to, self()) async? = Keyword.get(opts, :async?, true) diff --git a/lib/plausible/installation_support/verification/diagnostics.ex b/lib/plausible/installation_support/verification/diagnostics.ex index 8f9d4d635abc..d9989469f7ef 100644 --- a/lib/plausible/installation_support/verification/diagnostics.ex +++ b/lib/plausible/installation_support/verification/diagnostics.ex @@ -157,7 +157,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do def interpret( %__MODULE__{ tracker_is_in_html: false, - selected_installation_type: selected_installation_type, + selected_installation_type: "manual", plausible_is_on_window: plausible_is_on_window, plausible_is_initialized: plausible_is_initialized, service_error: nil @@ -165,9 +165,9 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do _expected_domain, _url ) - when selected_installation_type in ["manual", nil] and plausible_is_on_window != true and + when plausible_is_on_window != true and plausible_is_initialized != true, - do: error_plausible_not_found(selected_installation_type) + do: error_plausible_not_found("manual") @error_csp_disallowed Error.new!(%{ message: @@ -275,7 +275,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do @error_plausible_not_found_for_manual Error.new!(%{ message: @message_plausible_not_found, recommendation: - "Please make sure you've copied snippet to the head of your site, or verify your installation manually", + "Please make sure you've copied the snippet to the head of your site, or verify your installation manually", url: @verify_manually_url }) @error_plausible_not_found_for_npm Error.new!(%{ diff --git a/lib/plausible_web/live/installation.ex b/lib/plausible_web/live/installation.ex index 3a0eb7c6e773..583c67ef81f6 100644 --- a/lib/plausible_web/live/installation.ex +++ b/lib/plausible_web/live/installation.ex @@ -43,7 +43,14 @@ defmodule PlausibleWeb.Live.Installation do if PlausibleWeb.Tracker.scriptv2?(site) do {:ok, redirect(socket, - to: Routes.site_path(socket, :installation_v2, site.domain, flow: params["flow"]) + to: + Routes.site_path( + socket, + :installation_v2, + site.domain, + [flow: params["flow"], type: params["installation_type"]] + |> Keyword.filter(fn {_k, v} -> not is_nil(v) and v != "" end) + ) )} else flow = params["flow"] diff --git a/lib/plausible_web/live/installationv2.ex b/lib/plausible_web/live/installationv2.ex index e595f1f11358..78f1a7dee1f4 100644 --- a/lib/plausible_web/live/installationv2.ex +++ b/lib/plausible_web/live/installationv2.ex @@ -15,10 +15,6 @@ defmodule PlausibleWeb.Live.InstallationV2 do on_ee do alias Plausible.InstallationSupport.{Detection, Result} - - @installation_methods ["manual", "wordpress", "gtm", "npm"] - else - @installation_methods ["manual", "wordpress", "npm"] end def mount( @@ -93,7 +89,7 @@ defmodule PlausibleWeb.Live.InstallationV2 do def handle_params(params, _url, socket) do socket = if connected?(socket) && socket.assigns.recommended_installation_type.result && - params["type"] in @installation_methods do + params["type"] in PlausibleWeb.Tracker.supported_installation_types() do assign(socket, installation_type: %AsyncResult{result: params["type"]} ) @@ -217,12 +213,12 @@ defmodule PlausibleWeb.Live.InstallationV2 do Detection.Checks.interpret_diagnostics(detection_result) do {data.suggested_technology, data.v1_detected} else - _ -> {"manual", false} + _ -> {PlausibleWeb.Tracker.fallback_installation_type(), false} end end else defp detect_recommended_installation_type(_flow, _site) do - {"manual", false} + {PlausibleWeb.Tracker.fallback_installation_type(), false} end end @@ -326,7 +322,7 @@ defmodule PlausibleWeb.Live.InstallationV2 do selected_installation_type = cond do - params["type"] in @installation_methods -> + params["type"] in PlausibleWeb.Tracker.supported_installation_types() -> params["type"] flow == Flows.review() and diff --git a/lib/plausible_web/live/verification.ex b/lib/plausible_web/live/verification.ex index 7949a3487daf..4680bf4ab327 100644 --- a/lib/plausible_web/live/verification.ex +++ b/lib/plausible_web/live/verification.ex @@ -19,8 +19,10 @@ defmodule PlausibleWeb.Live.Verification do _session, socket ) do + current_user = socket.assigns.current_user + site = - Plausible.Sites.get_for_user!(socket.assigns.current_user, domain, [ + Plausible.Sites.get_for_user!(current_user, domain, [ :owner, :admin, :editor, @@ -30,9 +32,11 @@ defmodule PlausibleWeb.Live.Verification do private = Map.get(socket.private.connect_info, :private, %{}) - super_admin? = Plausible.Auth.is_super_admin?(socket.assigns.current_user) + super_admin? = Plausible.Auth.is_super_admin?(current_user) has_pageviews? = has_pageviews?(site) - custom_url_input? = params["custom_url"] == "true" + + custom_url_input? = + PlausibleWeb.Tracker.scriptv2?(site, current_user) and params["custom_url"] == "true" socket = assign(socket, @@ -42,7 +46,7 @@ defmodule PlausibleWeb.Live.Verification do domain: domain, has_pageviews?: has_pageviews?, component: @component, - installation_type: params["installation_type"], + installation_type: get_installation_type(params, site, current_user), report_to: self(), delay: private[:delay] || 500, slowdown: private[:slowdown] || 500, @@ -136,11 +140,14 @@ defmodule PlausibleWeb.Live.Verification do end def handle_info({:start, report_to}, socket) do - if is_pid(socket.assigns.checks_pid) and Process.alive?(socket.assigns.checks_pid) do + domain = socket.assigns.domain + checks_pid = socket.assigns.checks_pid + + if is_pid(checks_pid) and Process.alive?(checks_pid) do {:noreply, socket} else case Plausible.RateLimit.check_rate( - "site_verification:#{socket.assigns.domain}", + "site_verification:#{domain}", :timer.minutes(60), 3 ) do @@ -148,18 +155,18 @@ defmodule PlausibleWeb.Live.Verification do {:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking) end - domain = socket.assigns.domain - installation_type = socket.assigns.installation_type - {:ok, pid} = if PlausibleWeb.Tracker.scriptv2?(socket.assigns.site, socket.assigns.current_user) do - Verification.Checks.run(socket.assigns.url_to_verify, domain, installation_type, + Verification.Checks.run( + socket.assigns.url_to_verify, + domain, + socket.assigns.installation_type, report_to: report_to, slowdown: socket.assigns.slowdown ) else LegacyVerification.Checks.run( - "https://#{socket.assigns.domain}", + "https://#{domain}", domain, report_to: report_to, slowdown: socket.assigns.slowdown @@ -220,6 +227,35 @@ defmodule PlausibleWeb.Live.Verification do {:noreply, socket} end + @supported_installation_types_atoms PlausibleWeb.Tracker.supported_installation_types() + |> Enum.map(&String.to_atom/1) + defp get_installation_type(params, site, current_user) do + if PlausibleWeb.Tracker.scriptv2?(site, current_user) do + cond do + params["installation_type"] in PlausibleWeb.Tracker.supported_installation_types() -> + params["installation_type"] + + (saved_installation_type = get_saved_installation_type(site)) in @supported_installation_types_atoms -> + Atom.to_string(saved_installation_type) + + true -> + PlausibleWeb.Tracker.fallback_installation_type() + end + else + params["installation_type"] + end + end + + defp get_saved_installation_type(site) do + case PlausibleWeb.Tracker.get_tracker_script_configuration(site) do + %{installation_type: installation_type} -> + installation_type + + _ -> + nil + end + end + defp schedule_pageviews_check(socket) do if socket.assigns.polling_pageviews? do socket diff --git a/lib/plausible_web/tracker.ex b/lib/plausible_web/tracker.ex index 626a9c1ce9f5..53743dc53289 100644 --- a/lib/plausible_web/tracker.ex +++ b/lib/plausible_web/tracker.ex @@ -27,7 +27,7 @@ defmodule PlausibleWeb.Tracker do # # Note that EE is relying on CDN caching the script if PlausibleWeb.TrackerScriptCache.get(id, cache_opts) do - get_tracker_script_configuration(id) + get_tracker_script_configuration_by_id(id) |> build_script() end else @@ -109,13 +109,17 @@ defmodule PlausibleWeb.Tracker do def purge_tracker_script_cache!(_site), do: nil end + def get_tracker_script_configuration(site) do + Repo.get_by(TrackerScriptConfiguration, site_id: site.id) + end + def update_script_configuration!(site, config_update, changeset_type) do {:ok, updated_config} = update_script_configuration(site, config_update, changeset_type) updated_config end def get_or_create_tracker_script_configuration(site, params \\ %{}) do - configuration = Repo.get_by(TrackerScriptConfiguration, site_id: site.id) + configuration = get_tracker_script_configuration(site) if configuration do {:ok, configuration} @@ -140,10 +144,24 @@ defmodule PlausibleWeb.Tracker do config end + on_ee do + def supported_installation_types do + ["manual", "wordpress", "gtm", "npm"] + end + else + def supported_installation_types do + ["manual", "wordpress", "npm"] + end + end + + def fallback_installation_type do + "manual" + end + on_ee do import Ecto.Query - defp get_tracker_script_configuration(id) do + defp get_tracker_script_configuration_by_id(id) do from(t in TrackerScriptConfiguration, where: t.id == ^id, join: s in assoc(t, :site), diff --git a/test/plausible/installation_support/verification/checks_test.exs b/test/plausible/installation_support/verification/checks_test.exs index dabd83983869..c388a1532981 100644 --- a/test/plausible/installation_support/verification/checks_test.exs +++ b/test/plausible/installation_support/verification/checks_test.exs @@ -254,7 +254,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: - "Please make sure you've copied snippet to the head of your site, or verify your installation manually", + "Please make sure you've copied the snippet to the head of your site, or verify your installation manually", url: "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" } @@ -448,7 +448,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do {"npm", "Please make sure you've initialized Plausible on your site, or verify your installation manually"}, {"manual", - "Please make sure you've copied snippet to the head of your site, or verify your installation manually"} + "Please make sure you've copied the snippet to the head of your site, or verify your installation manually"} ] do test "returns error \"We couldn't detect Plausible on your site\" when plausible_is_on_window is false (with best guess recommendation for installation type: #{installation_type})" do expected_domain = "example.com" diff --git a/test/plausible_web/controllers/settings_controller_test.exs b/test/plausible_web/controllers/settings_controller_test.exs index e173b28460c0..95a3e1dc05cb 100644 --- a/test/plausible_web/controllers/settings_controller_test.exs +++ b/test/plausible_web/controllers/settings_controller_test.exs @@ -1008,7 +1008,7 @@ defmodule PlausibleWeb.SettingsControllerTest do "user" => %{"password" => password, "email" => user.email} }) - assert html_response(conn, 200) =~ "can't be the same" + assert html_response(conn, 200) =~ htmlize_quotes("can't be the same") end end diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs index ffc2ee503f26..52c3447b8877 100644 --- a/test/plausible_web/controllers/site_controller_test.exs +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -254,7 +254,7 @@ defmodule PlausibleWeb.SiteControllerTest do } }) - assert html_response(conn, 200) =~ "can't be blank" + assert html_response(conn, 200) =~ htmlize_quotes("can't be blank") end test "fails to create site when not allowed to in selected team", %{conn: conn, user: user} do @@ -404,7 +404,7 @@ defmodule PlausibleWeb.SiteControllerTest do } }) - assert html_response(conn, 200) =~ "can't be blank" + assert html_response(conn, 200) =~ htmlize_quotes("can't be blank") end test "only alphanumeric characters and slash allowed in domain", %{conn: conn} do diff --git a/test/plausible_web/live/change_domain_v2_test.exs b/test/plausible_web/live/change_domain_v2_test.exs index d394beaf5f43..d8bca48a8f32 100644 --- a/test/plausible_web/live/change_domain_v2_test.exs +++ b/test/plausible_web/live/change_domain_v2_test.exs @@ -133,7 +133,7 @@ defmodule PlausibleWeb.Live.ChangeDomainV2Test do |> element("form") |> render_submit(%{site: %{domain: ""}}) - assert html =~ "can't be blank" + assert html =~ htmlize_quotes("can't be blank") end test "form validation shows error for invalid domain format", %{conn: conn, site: site} do diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_test.exs index 5b615a2b89b5..327c5076b863 100644 --- a/test/plausible_web/live/components/verification_test.exs +++ b/test/plausible_web/live/components/verification_test.exs @@ -147,7 +147,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do domain: "example.com", success?: false, finished?: true, - installation_type: "WordPress", + installation_type: "wordpress", flow: PlausibleWeb.Flows.review() ) @@ -155,7 +155,7 @@ defmodule PlausibleWeb.Live.Components.VerificationTest do assert element_exists?( html, - ~s|a[href="/example.com/installation?flow=review&installation_type=WordPress"]| + ~s|a[href="/example.com/installation?flow=review&installation_type=wordpress"]| ) end end diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index fa2e535e3adb..3ad566db3c86 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -59,25 +59,29 @@ defmodule PlausibleWeb.Live.VerificationTest do end @tag :ee_only - test "from custom URL input form to verification", %{conn: conn, site: site} do + test "ignores v2 verification custom URL input", %{conn: conn, site: site} do + stub_fetch_body(200, source(site.domain)) + stub_installation() + # Get liveview with ?custom_url=true query param {:ok, lv, html} = conn |> no_slowdown() |> live("/#{site.domain}/verification?custom_url=true") - verifying_installation_text = "Verifying your installation" + refute html =~ "Enter Your Custom URL" - # Assert form is rendered instead of kicking off verification automatically - assert html =~ "Enter Your Custom URL" - assert html =~ ~s[value="https://#{site.domain}"] - assert html =~ ~s[placeholder="https://#{site.domain}"] - refute html =~ verifying_installation_text + assert eventually(fn -> + html = render(lv) - # Submit custom URL form - html = lv |> element("form") |> render_submit(%{"custom_url" => "https://abc.de"}) + { + text_of_element(html, @awaiting) =~ + "Awaiting your first pageview", + html + } + end) - # Should now show verification progress and hide custom URL form - assert html =~ verifying_installation_text - refute html =~ "Enter Your Custom URL" + html = render(lv) + assert html =~ "Success!" + assert html =~ "Awaiting your first pageview" end @tag :ee_only diff --git a/test/plausible_web/live/verification_v2_test.exs b/test/plausible_web/live/verification_v2_test.exs new file mode 100644 index 000000000000..d64e8df1401f --- /dev/null +++ b/test/plausible_web/live/verification_v2_test.exs @@ -0,0 +1,322 @@ +defmodule PlausibleWeb.Live.VerificationTest do + use PlausibleWeb.ConnCase, async: true + + use Plausible.Test.Support.DNS + + import Phoenix.LiveViewTest + import Plausible.Test.Support.HTML + + @moduletag :capture_log + + setup [:create_user, :log_in, :create_site] + + # @verify_button ~s|button#launch-verification-button[phx-click="launch-verification"]| + @retry_button ~s|a[phx-click="retry"]| + # @go_to_dashboard_button ~s|a[href$="?skip_to_dashboard=true"]| + @progress ~s|#verification-ui p#progress| + @awaiting ~s|#verification-ui span#awaiting| + @heading ~s|#verification-ui h2| + + setup %{site: site} do + FunWithFlags.enable(:scriptv2, for_actor: site) + + :ok + end + + describe "GET /:domain" do + @tag :ee_only + test "static verification screen renders", %{conn: conn, site: site} do + resp = + get(conn, conn |> no_slowdown() |> get("/#{site.domain}") |> redirected_to) + |> html_response(200) + + assert text_of_element(resp, @progress) =~ + "We're visiting your site to ensure that everything is working" + + assert resp =~ "Verifying your installation" + end + + @tag :ce_build_only + test "static verification screen renders (ce)", %{conn: conn, site: site} do + resp = + get(conn, conn |> no_slowdown() |> get("/#{site.domain}") |> redirected_to) + |> html_response(200) + + assert resp =~ "Awaiting your first pageview …" + end + end + + describe "LiveView" do + @tag :ee_only + test "LiveView mounts", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => false, + "error" => %{"message" => "Error"} + }) + + {_, html} = get_lv(conn, site) + + assert html =~ "Verifying your installation" + + assert text_of_element(html, @progress) =~ + "We're visiting your site to ensure that everything is working" + end + + @tag :ce_build_only + test "LiveView mounts (ce)", %{conn: conn, site: site} do + {_, html} = get_lv(conn, site) + assert html =~ "Awaiting your first pageview …" + end + + @tag :ee_only + test "from custom URL input form to verification", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => false, + "error" => %{"message" => "Error"} + }) + + # Get liveview with ?custom_url=true query param + {:ok, lv, html} = + conn |> no_slowdown() |> live("/#{site.domain}/verification?custom_url=true") + + verifying_installation_text = "Verifying your installation" + + # Assert form is rendered instead of kicking off verification automatically + assert html =~ "Enter Your Custom URL" + assert html =~ ~s[value="https://#{site.domain}"] + assert html =~ ~s[placeholder="https://#{site.domain}"] + refute html =~ verifying_installation_text + + # Submit custom URL form + html = lv |> element("form") |> render_submit(%{"custom_url" => "https://abc.de"}) + + # Should now show verification progress and hide custom URL form + assert html =~ verifying_installation_text + refute html =~ "Enter Your Custom URL" + end + + @tag :ee_only + test "eventually verifies installation", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + + { + text_of_element(html, @awaiting) =~ + "Awaiting your first pageview", + html + } + end) + + html = render(lv) + assert html =~ "Success!" + assert html =~ "Awaiting your first pageview" + end + + @tag :ee_only + test "won't await first pageview if site has pageviews", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview) + ]) + + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + + { + text(html) =~ "Success", + html + } + end) + + html = render(lv) + + refute text_of_element(html, @awaiting) =~ "Awaiting your first pageview" + refute_redirected(lv, "/#{URI.encode_www_form(site.domain)}/") + end + + test "will redirect when first pageview arrives", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + + { + text(html) =~ "Awaiting", + html + } + end) + + populate_stats(site, [ + build(:pageview) + ]) + + assert_redirect(lv, "/#{URI.encode_www_form(site.domain)}/") + end + + @tag :ce_build_only + test "will redirect when first pageview arrives (ce)", %{conn: conn, site: site} do + {:ok, lv} = kick_off_live_verification(conn, site) + + html = render(lv) + assert text(html) =~ "Awaiting your first pageview …" + + populate_stats(site, [build(:pageview)]) + + assert_redirect(lv, "/#{URI.encode_www_form(site.domain)}/") + end + + for {installation_type_param, expected_text, saved_installation_type} <- [ + {"manual", + "Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", + nil}, + {"npm", + "Please make sure you've initialized Plausible on your site, or verify your installation manually.", + nil}, + {"gtm", + "Please make sure you've configured the GTM template correctly, or verify your installation manually.", + nil}, + {"wordpress", + "Please make sure you've enabled the plugin, or verify your installation manually.", + nil}, + # trusts param over saved installation type + {"wordpress", + "Please make sure you've enabled the plugin, or verify your installation manually.", + "npm"}, + # falls back to saved installation type if no param + {"", + "Please make sure you've initialized Plausible on your site, or verify your installation manually.", + "npm"}, + # falls back to manual if no param and no saved installation type + {"", + "Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", + nil} + ] do + @tag :ee_only + test "eventually fails to verify installation (?installation_type=#{installation_type_param}) if saved installation type is #{inspect(saved_installation_type)}", + %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => false, + "plausibleIsOnWindow" => false, + "plausibleIsInitialized" => false + }) + + if unquote(saved_installation_type) do + PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site, %{ + "installation_type" => unquote(saved_installation_type) + }) + end + + {:ok, lv} = + kick_off_live_verification( + conn, + site, + "?installation_type=#{unquote(installation_type_param)}" + ) + + assert html = + eventually(fn -> + html = render(lv) + {html =~ "", html} + + { + text_of_element(html, @heading) =~ + "We couldn't detect Plausible on your site", + html + } + end) + + assert element_exists?(html, @retry_button) + + assert html =~ htmlize_quotes(unquote(expected_text)) + refute element_exists?(html, "#super-admin-report") + end + end + end + + defp get_lv(conn, site, qs \\ nil) do + {:ok, lv, html} = conn |> no_slowdown() |> live("/#{site.domain}/verification#{qs}") + + {lv, html} + end + + defp kick_off_live_verification(conn, site, qs \\ nil) do + {:ok, lv, _html} = + conn |> no_slowdown() |> no_delay() |> live("/#{site.domain}/verification#{qs}") + + {:ok, lv} + end + + defp no_slowdown(conn) do + Plug.Conn.put_private(conn, :slowdown, 0) + end + + defp no_delay(conn) do + Plug.Conn.put_private(conn, :delay, 0) + end + + defp stub_verification_result(js_data) do + Req.Test.stub(Plausible.InstallationSupport.Checks.InstallationV2, fn conn -> + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(%{"data" => js_data})) + end) + end +end diff --git a/test/support/test_utils.ex b/test/support/test_utils.ex index 2b136d43c141..bd47111161de 100644 --- a/test/support/test_utils.ex +++ b/test/support/test_utils.ex @@ -267,6 +267,10 @@ defmodule Plausible.TestUtils do Enum.map_join(1..4, ".", fn _ -> Enum.random(1..254) end) end + def htmlize_quotes(string) do + String.replace(string, "'", "'") + end + def minio_running? do %{host: host, port: port} = ExAws.Config.new(:s3) healthcheck_req = Finch.build(:head, "http://#{host}:#{port}") From b03569d7626f767e82b247444b4ad593bb361253 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Tue, 16 Sep 2025 08:39:17 +0300 Subject: [PATCH 307/618] Update tests transformRequest tests (#5687) --- tracker/.prettierignore | 1 + tracker/test/outbound-links.spec.ts | 28 ++- tracker/test/tagged-events.spec.ts | 30 ++- ...uest.spec.ts => transform-request.spec.ts} | 179 +++++++++++++++++- 4 files changed, 223 insertions(+), 15 deletions(-) rename tracker/test/{transform_request.spec.ts => transform-request.spec.ts} (59%) diff --git a/tracker/.prettierignore b/tracker/.prettierignore index 39b9daca0619..7a3a848c23fc 100644 --- a/tracker/.prettierignore +++ b/tracker/.prettierignore @@ -1,3 +1,4 @@ src/p.js src/plausible.js +npm_package/plausible.js node_modules/ diff --git a/tracker/test/outbound-links.spec.ts b/tracker/test/outbound-links.spec.ts index 632bad914272..cc733db1732b 100644 --- a/tracker/test/outbound-links.spec.ts +++ b/tracker/test/outbound-links.spec.ts @@ -31,7 +31,7 @@ for (const mode of ['web', 'esm']) { fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) @@ -78,7 +78,7 @@ for (const mode of ['web', 'esm']) { fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) @@ -151,7 +151,7 @@ for (const mode of ['legacy', 'web']) fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) @@ -201,7 +201,7 @@ for (const mode of ['legacy', 'web']) fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) @@ -253,7 +253,7 @@ for (const mode of ['legacy', 'web']) fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) @@ -340,7 +340,7 @@ test.describe('outbound links feature when using legacy .compat extension', () = fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: OTHER_PAGE_BODY }, awaitedRequestCount: 2, mockRequestTimeout: 2000 @@ -396,7 +396,7 @@ test.describe('outbound links feature when using legacy .compat extension', () = fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) @@ -448,7 +448,7 @@ test.describe('outbound links feature when using legacy .compat extension', () = fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: OTHER_PAGE_BODY }, awaitedRequestCount: 1 }) @@ -478,7 +478,7 @@ test.describe('outbound links feature when using legacy .compat extension', () = fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: OTHER_PAGE_BODY }, awaitedRequestCount: 2, mockRequestTimeout: 2000 @@ -522,3 +522,13 @@ test.describe('outbound links feature when using legacy .compat extension', () = }) }) }) + +const OTHER_PAGE_BODY = /* HTML */ ` + + + other page + + + other page + + ` diff --git a/tracker/test/tagged-events.spec.ts b/tracker/test/tagged-events.spec.ts index ae60605568f3..c1e2744f65ca 100644 --- a/tracker/test/tagged-events.spec.ts +++ b/tracker/test/tagged-events.spec.ts @@ -26,7 +26,15 @@ test.beforeEach(async ({ page }) => { await route.fulfill({ status: 200, contentType: 'text/html', - body: 'mocked pagemocked page' + body: /* HTML */ ` + + + mocked page + + + mocked page + + ` }) }) }) @@ -40,7 +48,7 @@ for (const mode of ['web', 'esm']) { testId, scriptConfig: switchByMode( { - web: { ...DEFAULT_CONFIG }, + web: config, esm: `` @@ -457,8 +465,12 @@ for (const mode of ['legacy', 'web']) { action: () => page.click('circle'), expectedRequests: [ { - n: 'link click' - // bug with p.url, can't assert + n: 'link click', + p: { + expected: { url: {} }, + __expectation__: (actual) => + actual && JSON.stringify(actual) === '{"url":{}}' + } } ], shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] @@ -568,7 +580,15 @@ test.describe('tagged events feature when using legacy .compat extension', () => fulfill: { status: 200, contentType: 'text/html', - body: 'other pageother page' + body: /* HTML */ ` + + + other page + + + other page + + ` }, awaitedRequestCount: 2, mockRequestTimeout: 2000 diff --git a/tracker/test/transform_request.spec.ts b/tracker/test/transform-request.spec.ts similarity index 59% rename from tracker/test/transform_request.spec.ts rename to tracker/test/transform-request.spec.ts index 7823142e8cd2..8e02b997dbac 100644 --- a/tracker/test/transform_request.spec.ts +++ b/tracker/test/transform-request.spec.ts @@ -6,12 +6,14 @@ import { e, expectPlausibleInAction, hideAndShowCurrentTab, + isPageviewEvent, isEngagementEvent, switchByMode } from './support/test-utils' -import { test } from '@playwright/test' +import { test, expect } from '@playwright/test' import { ScriptConfig } from './support/types' import { LOCAL_SERVER_ADDR } from './support/server' + const DEFAULT_CONFIG: ScriptConfig = { domain: 'example.com', endpoint: `${LOCAL_SERVER_ADDR}/api/event`, @@ -263,3 +265,178 @@ for (const mode of ['web', 'esm']) { }) }) } + +test.describe(`transformRequest examples from /docs work`, () => { + test.beforeEach(async ({ page }) => { + await page + .context() + .route(new RegExp('(http|https)://example\\.com.*'), async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: /* HTML */ ` + + + mocked page + + + mocked page + + ` + }) + }) + }) + + test('you can omit automatically tracked url property from tagged link clicks', async ({ + page + }, { testId }) => { + function omitAutomaticUrlProperty(payload) { + if (payload.p && payload.p.url) { + delete payload.p.url + } + return payload + } + const config = { + ...DEFAULT_CONFIG, + transformRequest: omitAutomaticUrlProperty + } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: config, + bodyContent: /* HTML */ `Purchase` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'Purchase', + p: { discounted: 'true' } // <-- no url property + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(page.getByText('mocked page')).toBeVisible() + }) + + for (const { hashBasedRouting, urlSuffix, expectedUrlSuffix } of [ + { + hashBasedRouting: true, + urlSuffix: + '?utm_source=example&utm_medium=referral&utm_campaign=test#fragment', + expectedUrlSuffix: '#fragment' + }, + { + hashBasedRouting: false, + urlSuffix: '?utm_source=example&utm_medium=referral&utm_campaign=test', + expectedUrlSuffix: '' + } + ]) { + test(`you can omit UTM properties from pageview urls (hashBasedRouting: ${hashBasedRouting})`, async ({ + page + }, { testId }) => { + function omitUTMProperties(payload) { + const parts = payload.u.split('?') + let urlWithoutQuery = parts.shift() + + if (payload.h) { + const fragment = parts.join('?').split('#')[1] + urlWithoutQuery = + typeof fragment === 'string' + ? urlWithoutQuery + '#' + fragment + : urlWithoutQuery + } + + payload.u = urlWithoutQuery + return payload + } + + const config = { + ...DEFAULT_CONFIG, + hashBasedRouting, + transformRequest: omitUTMProperties + } + + // the star path is needed for the dynamic page to load when accessing it with query params + const path = '*' + const { url } = await initializePageDynamically(page, { + testId, + path, + scriptConfig: config, + bodyContent: '' + }) + + const [actualUrl] = url.split('*') + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(`${actualUrl}${urlSuffix}`) + // await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}${actualUrl}${expectedUrlSuffix}` + } + ], + shouldIgnoreRequest: [isEngagementEvent] + }) + }) + } + + test('you can track pages using their canonical url', async ({ page }, { + testId + }) => { + function rewriteUrlToCanonicalUrl(payload) { + // Get the canonical URL element + const canonicalMeta = document.querySelector('link[rel="canonical"]') + // Use the canonical URL if it exists, falling back on the regular URL when it doesn't. + if (canonicalMeta) { + // @ts-expect-error - canonicalMeta definitely has the href attribute + payload.u = canonicalMeta.href + window.location.search + } + return payload + } + + // the star path is needed for the dynamic page to load when accessing it with query params + const nonCanonicalPath = '/products/clothes/shoes/banana-leather-shoe*' + const { url } = await initializePageDynamically(page, { + testId, + path: nonCanonicalPath, + scriptConfig: /* HTML */ ` + + + `, + bodyContent: '' + }) + const [actualUrl] = url.split('*') + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(`${actualUrl}?utm_source=example`) + }, + expectedRequests: [ + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/products/banana-leather-shoe?utm_source=example` + } + ], + shouldIgnoreRequest: [isEngagementEvent] + }) + }) +}) From ad46776f9f11fb05a87e7ff114d793a2b0366a06 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 16 Sep 2025 13:31:20 +0200 Subject: [PATCH 308/618] Use fragment to bind list of ids as a single param (#5730) * WIP * Alternative approach to OG approach (h/t @zoldar) * Revert "WIP" This reverts commit 7e3f0c1f8f667763b67f01b8e44315356b87eac0. --- lib/plausible/application.ex | 6 ++++++ lib/plausible/stats/filters/query_parser.ex | 4 +++- lib/plausible/stats/legacy/legacy_query_builder.ex | 6 +++++- lib/plausible/stats/sql/where_builder.ex | 5 +++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 8364c67f2276..218a1d51de64 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -42,6 +42,12 @@ defmodule Plausible.Application do n_lock_partitions: 1, ets_options: [read_concurrency: true, write_concurrency: true] ), + Plausible.Cache.Adapter.child_specs(:site_ids, :cache_site_ids, + ttl_check_interval: :timer.seconds(10), + global_ttl: :timer.minutes(30), + n_lock_partitions: 1, + ets_options: [read_concurrency: true, write_concurrency: true] + ), {Plausible.Session.Transfer, base_path: Application.get_env(:plausible, :session_transfer_dir)}, warmed_cache(Plausible.Site.Cache, diff --git a/lib/plausible/stats/filters/query_parser.ex b/lib/plausible/stats/filters/query_parser.ex index 7b64d45df7d9..c21cadba9014 100644 --- a/lib/plausible/stats/filters/query_parser.ex +++ b/lib/plausible/stats/filters/query_parser.ex @@ -78,7 +78,9 @@ defmodule Plausible.Stats.Filters.QueryParser do end def get_rollup_site_ids(%Plausible.Site{rollup: true} = site) do - Plausible.Teams.owned_sites_ids(site.team) + Plausible.Cache.Adapter.get(:site_ids, site.team_id, fn -> + Plausible.Teams.owned_sites_ids(site.team) + end) end def get_rollup_site_ids(_site), do: nil diff --git a/lib/plausible/stats/legacy/legacy_query_builder.ex b/lib/plausible/stats/legacy/legacy_query_builder.ex index 88e27d25c668..89c64374833a 100644 --- a/lib/plausible/stats/legacy/legacy_query_builder.ex +++ b/lib/plausible/stats/legacy/legacy_query_builder.ex @@ -43,7 +43,11 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do end defp put_rollup_site_ids(query, %Plausible.Site{rollup: true} = site) do - site_ids = Plausible.Teams.owned_sites_ids(site.team) + site_ids = + Plausible.Cache.Adapter.get(:site_ids, site.team_id, fn -> + Plausible.Teams.owned_sites_ids(site.team) + end) + struct!(query, rollup_site_ids: site_ids) end diff --git a/lib/plausible/stats/sql/where_builder.ex b/lib/plausible/stats/sql/where_builder.ex index 3842e0e25a02..b7af6aaee825 100644 --- a/lib/plausible/stats/sql/where_builder.ex +++ b/lib/plausible/stats/sql/where_builder.ex @@ -49,7 +49,8 @@ defmodule Plausible.Stats.SQL.WhereBuilder do dynamic( [e], - e.site_id in ^site_ids and e.timestamp >= ^first_datetime and + fragment("? in ?", e.site_id, ^site_ids) and + e.timestamp >= ^first_datetime and e.timestamp <= ^last_datetime ) end @@ -72,7 +73,7 @@ defmodule Plausible.Stats.SQL.WhereBuilder do dynamic( [s], - s.site_id in ^site_ids and + fragment("? in ?", s.site_id, ^site_ids) and s.start >= ^NaiveDateTime.add(first_datetime, -7, :day) and s.timestamp >= ^first_datetime and s.start <= ^last_datetime From 32fa20cfb1980b53cbe41808397c421eb3bdd425 Mon Sep 17 00:00:00 2001 From: Sanne de Vries <65487235+sanne-san@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:57:20 +0200 Subject: [PATCH 309/618] Make visual tweaks to the dashboard (#5726) * Make visual tweaks to the dashboard - Reduce shadow and increased border radius on cards, to be consistent with the rest of the app - Reduce logo size - Align horizontal and vertical spacing between cards - Reduce line width of the main graph - Fix horizontal alignment on tables - Reduce border radius on sites overview for consistency with dashboard - Add transition to hover effect on sites overview cards * Remove negative margin from logo to ensure vertical centering - The negative margin on the logo caused misalignment with the rest of the header --- assets/css/app.css | 27 ------------------- assets/js/dashboard/index.tsx | 4 +-- assets/js/dashboard/stats/behaviours/index.js | 2 +- assets/js/dashboard/stats/graph/graph-util.js | 4 +-- .../js/dashboard/stats/graph/visitor-graph.js | 2 +- assets/js/dashboard/stats/reports/list.tsx | 5 +++- assets/tailwind.config.js | 2 +- lib/plausible_web/live/sites.ex | 4 +-- .../templates/layout/_header.html.heex | 4 +-- 9 files changed, 15 insertions(+), 39 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 675bdd2cdc5e..9295bd0fee19 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -208,33 +208,6 @@ blockquote { background-color: rgb(26 32 44); } -.stats-item { - min-height: 436px; -} - -@screen md { - .stats-item { - margin-left: 6px; - margin-right: 6px; - width: calc(50% - 6px); - position: relative; - min-height: initial; - height: 27.25rem; - } - - .stats-item-header { - height: inherit; - } -} - -.stats-item:first-child { - margin-left: 0; -} - -.stats-item:last-child { - margin-right: 0; -} - .fade-enter { opacity: 0; } diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index f4c51038cff4..62493770fc83 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -17,7 +17,7 @@ function DashboardStats({ updateImportedDataInView?: (v: boolean) => void }) { const statsBoxClass = - 'stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded' + 'relative min-h-[436px] w-full mt-5 p-4 flex flex-col bg-white dark:bg-gray-825 shadow rounded-md md:min-h-initial md:h-27.25rem md:w-[calc(50%-10px)] md:ml-[10px] md:mr-[10px] first:ml-0 last:mr-0' return ( <> @@ -57,7 +57,7 @@ function Dashboard() { const [importedDataInView, setImportedDataInView] = useState(false) return ( -
    +
    -
    +

    diff --git a/assets/js/dashboard/stats/graph/graph-util.js b/assets/js/dashboard/stats/graph/graph-util.js index 35766defb985..bf7456f6da2f 100644 --- a/assets/js/dashboard/stats/graph/graph-util.js +++ b/assets/js/dashboard/stats/graph/graph-util.js @@ -86,9 +86,9 @@ export const buildDataSet = ( const defaultOptions = { label, - borderWidth: 3, + borderWidth: 2, pointBorderColor: 'transparent', - pointHoverRadius: 4, + pointHoverRadius: 3, backgroundColor: gradient, fill: true } diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js index ff967f548c88..bbe002297185 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.js +++ b/assets/js/dashboard/stats/graph/visitor-graph.js @@ -168,7 +168,7 @@ export default function VisitorGraph({ updateImportedDataInView }) { return (
    {(topStatsLoading || graphLoading) && renderLoader()} diff --git a/assets/js/dashboard/stats/reports/list.tsx b/assets/js/dashboard/stats/reports/list.tsx index b558de228568..a7c9c3148081 100644 --- a/assets/js/dashboard/stats/reports/list.tsx +++ b/assets/js/dashboard/stats/reports/list.tsx @@ -246,7 +246,10 @@ export default function ListReport< function renderRow(listItem: TListItem) { return (
    -
    +
    {renderBarFor(listItem)} {renderMetricValuesFor(listItem)}
    diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 297edc2fc67f..10fcb7c92faa 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -21,7 +21,7 @@ module.exports = { }, extend: { colors: { - yellow: colors.amber, // We started usign `yellow` in v2 but it was renamed to `amber` in v3 https://tailwindcss.com/docs/upgrade-guide#removed-color-aliases + yellow: colors.amber, // We started using `yellow` in v2 but it was renamed to `amber` in v3 https://tailwindcss.com/docs/upgrade-guide#removed-color-aliases gray: colors.slate, 'gray-950': 'rgb(13, 18, 30)', 'gray-850': 'rgb(26, 32, 44)', diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index fd363499b53e..16883df7ecae 100644 --- a/lib/plausible_web/live/sites.ex +++ b/lib/plausible_web/live/sites.ex @@ -186,7 +186,7 @@ defmodule PlausibleWeb.Live.Sites do data-domain={@site.domain} x-on:click={"invitationOpen = true; selectedInvitation = invitations['#{@invitation.invitation_id}']"} > -
    +
    <.unstyled_link href={"/#{URI.encode_www_form(@site.domain)}"}> -
    +
    <.favicon domain={@site.domain} />
    diff --git a/lib/plausible_web/templates/layout/_header.html.heex b/lib/plausible_web/templates/layout/_header.html.heex index 64fc90abbb0b..d8264f230b56 100644 --- a/lib/plausible_web/templates/layout/_header.html.heex +++ b/lib/plausible_web/templates/layout/_header.html.heex @@ -10,7 +10,7 @@ logo_path("logo_dark.svg") ) } - class="w-44 -mt-2 hidden dark:inline" + class="w-32 hidden dark:inline" alt="Plausible logo" loading="lazy" /> @@ -21,7 +21,7 @@ logo_path("logo_light.svg") ) } - class="w-44 -mt-2 inline dark:hidden" + class="w-32 inline dark:hidden" alt="Plausible logo" loading="lazy" /> From 4754e2a3e89bc7d87c80270eb31b84a84fc32354 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 16 Sep 2025 16:18:59 +0200 Subject: [PATCH 310/618] Remove SSO_ENABLED env flag and replace it with ee? checks where needed (#5728) * Remove `SSO_ENABLED` env flag and replace it with `ee?` checks where needed * Fix name of a test module to avoid clash * Remove unnecessary `ee?()` check from condition in `extra/` code --- config/.env.dev | 1 - config/.env.load | 1 - config/.env.test | 1 - config/runtime.exs | 3 - .../controllers/sso_controller.ex | 2 +- extra/lib/plausible_web/plugs/gate_sso.ex | 22 ----- lib/plausible.ex | 5 -- .../controllers/auth_controller.ex | 6 +- lib/plausible_web/router.ex | 4 +- .../templates/auth/login_form.html.heex | 2 +- lib/plausible_web/views/layout_view.ex | 5 +- .../controllers/sso_controller_sync_test.exs | 84 ------------------- .../live/verification_v2_test.exs | 2 +- 13 files changed, 8 insertions(+), 130 deletions(-) delete mode 100644 extra/lib/plausible_web/plugs/gate_sso.ex diff --git a/config/.env.dev b/config/.env.dev index 6295c57bdf8b..c1b5361dcecc 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -14,7 +14,6 @@ ADMIN_USER_IDS=1 SHOW_CITIES=true PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a PADDLE_VENDOR_ID=3942 -SSO_ENABLED=true SSO_VERIFICATION_NAMESERVERS=0.0.0.0:5354 GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com diff --git a/config/.env.load b/config/.env.load index 87cfab10312b..cb957fe12675 100644 --- a/config/.env.load +++ b/config/.env.load @@ -14,7 +14,6 @@ ADMIN_USER_IDS=1 SHOW_CITIES=true PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a PADDLE_VENDOR_ID=3942 -SSO_ENABLED=true GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=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 diff --git a/config/.env.test b/config/.env.test index a4b073b64bad..42a1e8552634 100644 --- a/config/.env.test +++ b/config/.env.test @@ -19,7 +19,6 @@ HELP_SCOUT_APP_ID=fake_app_id HELP_SCOUT_APP_SECRET=fake_app_secret HELP_SCOUT_SIGNATURE_KEY=fake_signature_key HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM= -SSO_ENABLED=true S3_DISABLED=false S3_ACCESS_KEY_ID=minioadmin diff --git a/config/runtime.exs b/config/runtime.exs index e1ccf94ff7f3..2e0ec97bbf35 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -318,8 +318,6 @@ secure_cookie = license_key = get_var_from_path_or_env(config_dir, "LICENSE_KEY", "") -sso_enabled = get_bool_from_path_or_env(config_dir, "SSO_ENABLED", false) - sso_saml_adapter = case get_var_from_path_or_env(config_dir, "SSO_SAML_ADAPTER", "fake") do "fake" -> PlausibleWeb.SSO.FakeSAMLAdapter @@ -353,7 +351,6 @@ config :plausible, license_key: license_key, data_dir: data_dir, session_transfer_dir: session_transfer_dir, - sso_enabled: sso_enabled, sso_saml_adapter: sso_saml_adapter, sso_verification_nameservers: sso_verification_nameservers diff --git a/extra/lib/plausible_web/controllers/sso_controller.ex b/extra/lib/plausible_web/controllers/sso_controller.ex index 033b64f552fb..dfad5d4f0b9c 100644 --- a/extra/lib/plausible_web/controllers/sso_controller.ex +++ b/extra/lib/plausible_web/controllers/sso_controller.ex @@ -95,7 +95,7 @@ defmodule PlausibleWeb.SSOController do end def sso_settings(conn, _params) do - if Plausible.Teams.setup?(conn.assigns.current_team) and Plausible.sso_enabled?() and + if Plausible.Teams.setup?(conn.assigns.current_team) and Plausible.Billing.Feature.SSO.check_availability(conn.assigns.current_team) == :ok do render(conn, :sso_settings, layout: {PlausibleWeb.LayoutView, :settings}, diff --git a/extra/lib/plausible_web/plugs/gate_sso.ex b/extra/lib/plausible_web/plugs/gate_sso.ex deleted file mode 100644 index 941858a390a4..000000000000 --- a/extra/lib/plausible_web/plugs/gate_sso.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule PlausibleWeb.Plugs.GateSSO do - @moduledoc """ - Plug for gating access to SSO routes with `SSO_ENABLED` env var. - """ - - @behaviour Plug - import Plug.Conn - - @impl true - def init(opts), do: opts - - @impl true - def call(conn, _) do - if Plausible.sso_enabled?() do - conn - else - conn - |> Phoenix.Controller.redirect(to: "/") - |> halt() - end - end -end diff --git a/lib/plausible.ex b/lib/plausible.ex index 4e62aaf276fe..39b39aa66aca 100644 --- a/lib/plausible.ex +++ b/lib/plausible.ex @@ -12,11 +12,6 @@ defmodule Plausible do end end - @spec sso_enabled?() :: boolean() - def sso_enabled?() do - Application.fetch_env!(:plausible, :sso_enabled) - end - defmacro on_ee(clauses) do do_on_ee(clauses) end diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex index 41f39bbfcc2a..650be1e0ba65 100644 --- a/lib/plausible_web/controllers/auth_controller.ex +++ b/lib/plausible_web/controllers/auth_controller.ex @@ -245,11 +245,7 @@ defmodule PlausibleWeb.AuthController do case {login_preference, params["prefer"], error} do {"sso", nil, nil} -> - if Plausible.sso_enabled?() do - redirect(conn, to: Routes.sso_path(conn, :login_form, return_to: params["return_to"])) - else - render(conn, "login_form.html") - end + redirect(conn, to: Routes.sso_path(conn, :login_form, return_to: params["return_to"])) _ -> render(conn, "login_form.html") diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 813f5a1d0564..555646e52f0a 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -176,14 +176,14 @@ defmodule PlausibleWeb.Router do end scope "/sso", PlausibleWeb do - pipe_through [PlausibleWeb.Plugs.GateSSO, :browser, :csrf] + pipe_through [:browser, :csrf] get "/login", SSOController, :login_form post "/login", SSOController, :login end scope "/sso/saml", PlausibleWeb do - pipe_through [PlausibleWeb.Plugs.GateSSO, :sso_saml] + pipe_through [:sso_saml] scope [] do pipe_through :sso_saml_auth diff --git a/lib/plausible_web/templates/auth/login_form.html.heex b/lib/plausible_web/templates/auth/login_form.html.heex index 1a5d459d2ef0..a22fff47dd68 100644 --- a/lib/plausible_web/templates/auth/login_form.html.heex +++ b/lib/plausible_web/templates/auth/login_form.html.heex @@ -49,7 +49,7 @@ instead. - <:item :if={ee?() and Plausible.sso_enabled?()}> + <:item :if={ee?()}> <%= on_ee do %> Have a Single Sign-on account? <.styled_link href={ diff --git a/lib/plausible_web/views/layout_view.ex b/lib/plausible_web/views/layout_view.ex index ff1fdbbba495..986894b41311 100644 --- a/lib/plausible_web/views/layout_view.ex +++ b/lib/plausible_web/views/layout_view.ex @@ -125,7 +125,7 @@ defmodule PlausibleWeb.LayoutView do do: %{key: "API Keys", value: "api-keys", icon: :key} ), if( - Plausible.sso_enabled?() and current_team_role == :owner and + ee?() and current_team_role == :owner and Plausible.Billing.Feature.SSO.check_availability(current_team) == :ok, do: %{ key: "Single Sign-On", @@ -137,8 +137,7 @@ defmodule PlausibleWeb.LayoutView do } ), if( - Plausible.sso_enabled?() and - Plausible.Billing.Feature.SSO.check_availability(current_team) != :ok, + ee?() and Plausible.Billing.Feature.SSO.check_availability(current_team) != :ok, do: %{ key: "Single Sign-On", value: "sso/info", diff --git a/test/plausible_web/controllers/sso_controller_sync_test.exs b/test/plausible_web/controllers/sso_controller_sync_test.exs index 09d04f7ffa50..48481f7c9417 100644 --- a/test/plausible_web/controllers/sso_controller_sync_test.exs +++ b/test/plausible_web/controllers/sso_controller_sync_test.exs @@ -10,90 +10,6 @@ defmodule PlausibleWeb.SSOControllerSyncTest do alias Plausible.Auth.SSO alias Plausible.Repo - describe "sso_enabled = false" do - setup do - patch_env(:sso_enabled, false) - end - - test "standard login form does not show link to SSO login", %{conn: conn} do - conn = get(conn, Routes.auth_path(conn, :login_form)) - - assert html = html_response(conn, 200) - - refute html =~ Routes.sso_path(conn, :login_form) - refute html =~ "Single Sign-on" - end - - test "sso_settings/2 are guarded by the env var", %{conn: conn} do - user = new_user() - team = new_site(owner: user).team |> Plausible.Teams.complete_setup() - {:ok, ctx} = log_in(%{conn: conn, user: user}) - conn = ctx[:conn] - conn = set_current_team(conn, team) - - conn = get(conn, Routes.sso_path(conn, :sso_settings)) - - assert redirected_to(conn, 302) == "/sites" - end - - test "sso team settings item is guarded by the env var", %{conn: conn} do - user = - new_user() |> subscribe_to_enterprise_plan(features: [Plausible.Billing.Feature.SSO]) - - team = new_site(owner: user).team |> Plausible.Teams.complete_setup() - {:ok, ctx} = log_in(%{conn: conn, user: user}) - conn = ctx[:conn] - conn = set_current_team(conn, team) - - conn = get(conn, Routes.settings_path(conn, :team_general)) - - assert html = html_response(conn, 200) - - refute html =~ "Single Sign-On" - end - - test "login_form/2 is guarded by the env var", %{conn: conn} do - conn = get(conn, Routes.sso_path(conn, :login_form)) - - assert redirected_to(conn, 302) == "/" - end - - test "login/2 is guarded by the env var", %{conn: conn} do - conn = post(conn, Routes.sso_path(conn, :login), %{"email" => "some@example.com"}) - - assert redirected_to(conn, 302) == "/" - end - - test "saml_signin/2 is guarded by the env var", %{conn: conn} do - conn = - get( - conn, - Routes.sso_path(conn, :saml_signin, Ecto.UUID.generate(), - email: "some@example.com", - return_to: "/sites" - ) - ) - - assert redirected_to(conn, 302) == "/" - end - - test "saml_consume/2 is guarded by the env var", %{conn: conn} do - conn = - post(conn, Routes.sso_path(conn, :saml_consume, Ecto.UUID.generate()), %{ - "email" => "some@example.com", - "return_to" => "/sites" - }) - - assert redirected_to(conn, 302) == "/" - end - - test "csp_report/2 is guarded by the env var", %{conn: conn} do - conn = post(conn, Routes.sso_path(conn, :csp_report), %{}) - - assert redirected_to(conn, 302) == "/" - end - end - @cert_pem """ -----BEGIN CERTIFICATE----- MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0 diff --git a/test/plausible_web/live/verification_v2_test.exs b/test/plausible_web/live/verification_v2_test.exs index d64e8df1401f..7a67c2604147 100644 --- a/test/plausible_web/live/verification_v2_test.exs +++ b/test/plausible_web/live/verification_v2_test.exs @@ -1,4 +1,4 @@ -defmodule PlausibleWeb.Live.VerificationTest do +defmodule PlausibleWeb.Live.VerificationV2Test do use PlausibleWeb.ConnCase, async: true use Plausible.Test.Support.DNS From 897d3c5044fbfe88463c04a4e7a42321dda06a40 Mon Sep 17 00:00:00 2001 From: Sanne de Vries <65487235+sanne-san@users.noreply.github.com> Date: Wed, 17 Sep 2025 09:40:40 +0200 Subject: [PATCH 311/618] Clean up number formatting util (#5725) * Clean up number formatting util - Deduplicate number_format util - Add tests for number_format util - Add number formatting to visitor change percentage in site overview * Move number_format to TextHelpers - Move number_format to a more generic place in TextHelpers - Revert the places where we use unlimited to use the inline util --- .../live/customer_support/team.ex | 8 ++-- .../team/components/billing.ex | 13 +++--- .../components/billing/billing.ex | 4 +- lib/plausible_web/live/sites.ex | 2 +- lib/plausible_web/views/text_helpers.ex | 6 +++ .../plausible_web/views/text_helpers_test.exs | 44 +++++++++++++++++++ 6 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 test/plausible_web/views/text_helpers_test.exs diff --git a/extra/lib/plausible_web/live/customer_support/team.ex b/extra/lib/plausible_web/live/customer_support/team.ex index bccbaecca001..546a6508a9c1 100644 --- a/extra/lib/plausible_web/live/customer_support/team.ex +++ b/extra/lib/plausible_web/live/customer_support/team.ex @@ -2,7 +2,7 @@ defmodule PlausibleWeb.Live.CustomerSupport.Team do @moduledoc """ Team coordinator LiveView for Customer Support interface. - Manages tab-based navigation and delegates rendering to specialized + Manages tab-based navigation and delegates rendering to specialized components: Overview, Members, Sites, Billing, SSO, and Audit. """ use PlausibleWeb.CustomerSupport.Live @@ -364,9 +364,7 @@ defmodule PlausibleWeb.Live.CustomerSupport.Team do "unlimited" end - defp number_format(number) when is_integer(number) do - Cldr.Number.to_string!(number) + defp number_format(input) do + PlausibleWeb.TextHelpers.number_format(input) end - - defp number_format(other), do: other end diff --git a/extra/lib/plausible_web/live/customer_support/team/components/billing.ex b/extra/lib/plausible_web/live/customer_support/team/components/billing.ex index 41971292fa60..7d008df6c854 100644 --- a/extra/lib/plausible_web/live/customer_support/team/components/billing.ex +++ b/extra/lib/plausible_web/live/customer_support/team/components/billing.ex @@ -71,7 +71,9 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do <.td>{cycle} <.td>{date} <.td> - limit, do: "text-red-600"}>{number_format(total)} + limit, do: "text-red-600"}> + {number_format(total)} + <.td>{number_format(limit)} @@ -360,12 +362,10 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do "unlimited" end - defp number_format(number) when is_integer(number) do - Cldr.Number.to_string!(number) + defp number_format(input) do + PlausibleWeb.TextHelpers.number_format(input) end - defp number_format(other), do: other - defp sanitize_params(params) do params |> Enum.map(&clear_param/1) @@ -403,7 +403,8 @@ defmodule PlausibleWeb.CustomerSupport.Team.Components.Billing do defp preview_number(n) do case Integer.parse("#{n}") do {n, ""} -> - number_format(n) <> " (#{PlausibleWeb.StatsView.large_number_format(n)})" + number_format(n) <> + " (#{PlausibleWeb.StatsView.large_number_format(n)})" _ -> "0" diff --git a/lib/plausible_web/components/billing/billing.ex b/lib/plausible_web/components/billing/billing.ex index 31431eaa8b37..0912d4193c62 100644 --- a/lib/plausible_web/components/billing/billing.ex +++ b/lib/plausible_web/components/billing/billing.ex @@ -198,8 +198,8 @@ defmodule PlausibleWeb.Components.Billing do {@title} - {Cldr.Number.to_string!(@usage)} - {if is_number(@limit), do: "/ #{Cldr.Number.to_string!(@limit)}"} + {PlausibleWeb.TextHelpers.number_format(@usage)} + {if is_number(@limit), do: "/ #{PlausibleWeb.TextHelpers.number_format(@limit)}"} """ diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index 16883df7ecae..263bfd256e1c 100644 --- a/lib/plausible_web/live/sites.ex +++ b/lib/plausible_web/live/sites.ex @@ -394,7 +394,7 @@ defmodule PlausibleWeb.Live.Sites do - {abs(@change)}% + {PlausibleWeb.TextHelpers.number_format(abs(@change))}%

    """ end diff --git a/lib/plausible_web/views/text_helpers.ex b/lib/plausible_web/views/text_helpers.ex index 2f8029d4170f..17c346948d48 100644 --- a/lib/plausible_web/views/text_helpers.ex +++ b/lib/plausible_web/views/text_helpers.ex @@ -44,4 +44,10 @@ defmodule PlausibleWeb.TextHelpers do def format_date(date) do Calendar.strftime(date, "%b %-d, %Y") end + + def number_format(number) when is_integer(number) do + Cldr.Number.to_string!(number) + end + + def number_format(other), do: other end diff --git a/test/plausible_web/views/text_helpers_test.exs b/test/plausible_web/views/text_helpers_test.exs new file mode 100644 index 000000000000..488ec321b8c8 --- /dev/null +++ b/test/plausible_web/views/text_helpers_test.exs @@ -0,0 +1,44 @@ +defmodule PlausibleWeb.TextHelpersTest do + use PlausibleWeb.ConnCase, async: true + alias PlausibleWeb.TextHelpers + doctest PlausibleWeb.TextHelpers + + describe "number_format" do + test "numbers under 1000 stay the same" do + assert TextHelpers.number_format(0) == "0" + assert TextHelpers.number_format(1) == "1" + assert TextHelpers.number_format(123) == "123" + assert TextHelpers.number_format(999) == "999" + end + + test "thousands get comma separator" do + assert TextHelpers.number_format(1_000) == "1,000" + assert TextHelpers.number_format(1_234) == "1,234" + assert TextHelpers.number_format(12_345) == "12,345" + assert TextHelpers.number_format(123_456) == "123,456" + end + + test "millions get multiple comma separators" do + assert TextHelpers.number_format(1_000_000) == "1,000,000" + assert TextHelpers.number_format(1_234_567) == "1,234,567" + assert TextHelpers.number_format(12_345_678) == "12,345,678" + assert TextHelpers.number_format(123_456_789) == "123,456,789" + end + + test "billions get multiple comma separators" do + assert TextHelpers.number_format(1_000_000_000) == "1,000,000,000" + assert TextHelpers.number_format(1_234_567_890) == "1,234,567,890" + assert TextHelpers.number_format(12_345_678_901) == "12,345,678,901" + end + + test "handles negative numbers" do + assert TextHelpers.number_format(-1234) == "-1,234" + assert TextHelpers.number_format(-1_234_567) == "-1,234,567" + end + + test "handles edge cases" do + assert TextHelpers.number_format(0) == "0" + assert TextHelpers.number_format(-0) == "0" + end + end +end From b879c2d30bfacd41c1cf347d7112f49ce60bce44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cenk=20K=C3=BCc=C3=BCk?= Date: Wed, 17 Sep 2025 21:13:17 +0200 Subject: [PATCH 312/618] Add kernel settings for non-selfhosted envs (#5734) --- rel/vm.args.eex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rel/vm.args.eex b/rel/vm.args.eex index c608ce8ea330..6bd4c435bc10 100644 --- a/rel/vm.args.eex +++ b/rel/vm.args.eex @@ -9,4 +9,10 @@ ## Tweak GC to run more often ##-env ERL_FULLSWEEP_AFTER 10 + +<%= if Application.get_env(:plausible, :is_selfhost) != true do %> +-kernel inet_dist_listen_min 9100 +-kernel inet_dist_listen_max 9200 +<% end %> + +Mdai max From 2be1220b703ef0edd3bd2514233c4747c8ddfa2f Mon Sep 17 00:00:00 2001 From: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:18:23 +0100 Subject: [PATCH 313/618] Migration: Consolidated views (#5735) * add migration * behind ee_only + index * no index yet --- ...250916154337_add_consolidated_field_to_sites.exs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 priv/repo/migrations/20250916154337_add_consolidated_field_to_sites.exs diff --git a/priv/repo/migrations/20250916154337_add_consolidated_field_to_sites.exs b/priv/repo/migrations/20250916154337_add_consolidated_field_to_sites.exs new file mode 100644 index 000000000000..fe97bb89277b --- /dev/null +++ b/priv/repo/migrations/20250916154337_add_consolidated_field_to_sites.exs @@ -0,0 +1,13 @@ +defmodule Plausible.Repo.Migrations.AddConsolidatedFieldToSites do + use Ecto.Migration + + import Plausible.MigrationUtils + + def change do + if enterprise_edition?() do + alter table(:sites) do + add :consolidated, :boolean, null: false, default: false + end + end + end +end From 5681c222833be17ffbc3431641c5c546122e6149 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 18 Sep 2025 12:41:21 +0200 Subject: [PATCH 314/618] Join cluster with .erlang.hosts on startup (#5736) --- lib/plausible/application.ex | 16 ++++++++++++++++ mix.exs | 3 ++- mix.lock | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 218a1d51de64..d38c54c3880d 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -13,8 +13,24 @@ defmodule Plausible.Application do # in CE we start the endpoint under site_encrypt for automatic https endpoint = on_ee(do: PlausibleWeb.Endpoint, else: maybe_https_endpoint()) + cluster = + on_ee( + do: + {Cluster.Supervisor, + [ + [ + default: [ + strategy: Cluster.Strategy.ErlangHosts, + config: [timeout: 30_000] + ] + ], + [name: Plausible.ClusterSupervisor] + ]} + ) + children = [ + cluster, {PartitionSupervisor, child_spec: Task.Supervisor, name: Plausible.UserAgentParseTaskSupervisor}, Plausible.Session.BalancerSupervisor, diff --git a/mix.exs b/mix.exs index 5256a6df1ecb..642e89e36b43 100644 --- a/mix.exs +++ b/mix.exs @@ -153,7 +153,8 @@ defmodule Plausible.MixProject do {:odgn_json_pointer, "~> 3.0.1"}, {:phoenix_bakery, "~> 0.1.2", only: [:ce, :ce_dev, :ce_test]}, {:site_encrypt, github: "sasa1977/site_encrypt", only: [:ce, :ce_dev, :ce_test]}, - {:phoenix_storybook, "~> 0.8"} + {:phoenix_storybook, "~> 0.8"}, + {:libcluster, "~> 3.5"} ] end diff --git a/mix.lock b/mix.lock index ac0c77415756..6e333f36ca91 100644 --- a/mix.lock +++ b/mix.lock @@ -73,6 +73,7 @@ "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.0", "b9dd9b6d52e3e6fcb6c65e151ad38bf4bc286382b5b6f97079c47ade6b1bcc6a", [:mix], [{:jose, "~> 1.11.5", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5a95b05a71cd0b54abd35378aeb1d487a23a52c324fa7efdffc512b655b5aaa7"}, "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, + "libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"}, "location": {:git, "https://github.com/plausible/location.git", "a89bf79985c3c3d0830477ae587001156a646ce8", []}, "locus": {:hex, :locus, "2.3.11", "ddfab230e3fb8b45f47416ed0fb8776c6d6d00f38687f6d37647ed7502c33d8e", [:rebar3], [{:tls_certificate_check, "~> 1.9", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "ad855e9b998adc6ec5c57b9d0e5130b0e40a927be7b50d8e104df245c60ede1a"}, "mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"}, From bf587b05fd364c341cb72c06886a46b77071d83c Mon Sep 17 00:00:00 2001 From: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:54:34 +0100 Subject: [PATCH 315/618] Change Site schema: Add consolidated (boolean) field (#5737) * remove Plausible.Site schema from BackfillTrackerScriptConfiguration * add schema field * ee_only site field --- .../backfill_tracker_script_configuration.ex | 7 ++++++- lib/plausible/site.ex | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/plausible/data_migration/backfill_tracker_script_configuration.ex b/lib/plausible/data_migration/backfill_tracker_script_configuration.ex index fcc28e1b137a..c7ef3693af86 100644 --- a/lib/plausible/data_migration/backfill_tracker_script_configuration.ex +++ b/lib/plausible/data_migration/backfill_tracker_script_configuration.ex @@ -44,7 +44,12 @@ defmodule Plausible.DataMigration.BackfillTrackerScriptConfiguration do def process_batch(offset, now) do sites = Repo.all( - from(s in Plausible.Site, order_by: [asc: :id], limit: @batch_size, offset: ^offset) + from(s in "sites", + order_by: [asc: :id], + limit: @batch_size, + offset: ^offset, + select: %{id: s.id, installation_meta: s.installation_meta} + ) ) if length(sites) > 0 do diff --git a/lib/plausible/site.ex b/lib/plausible/site.ex index d69793a4215f..55b85d8a4186 100644 --- a/lib/plausible/site.ex +++ b/lib/plausible/site.ex @@ -22,6 +22,10 @@ defmodule Plausible.Site do field :funnels_enabled, :boolean, default: true field :legacy_time_on_page_cutoff, :date, default: ~D[1970-01-01] + on_ee do + field :consolidated, :boolean + end + field :ingest_rate_limit_scale_seconds, :integer, default: 60 # default is set via changeset/2 field :ingest_rate_limit_threshold, :integer From 653aa65877192ca6ae211912385f36fabe229996 Mon Sep 17 00:00:00 2001 From: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:00:14 +0100 Subject: [PATCH 316/618] Bootstrap the interface for consolidated sites (#5739) * add schema field * ee_only site field * enable/1 and disable/1 with tests * add typespecs + eligible?/1 * site_ids/1 with tests * add Sites.regular/1 and Sites.consolidated/1 predicates * fix CE tests * move consolidated_view.ex to /extra * Update extra/lib/plausible/consolidated_view.ex Co-authored-by: Adam Rutkowski * review comments --------- Co-authored-by: Adam Rutkowski --- extra/lib/plausible/consolidated_view.ex | 83 +++++++++++++++++++++ lib/plausible/site.ex | 8 ++- lib/plausible/sites.ex | 14 ++++ test/plausible/consolidated_view_test.exs | 88 +++++++++++++++++++++++ test/plausible/site/sites_test.exs | 19 +++++ 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 extra/lib/plausible/consolidated_view.ex create mode 100644 test/plausible/consolidated_view_test.exs diff --git a/extra/lib/plausible/consolidated_view.ex b/extra/lib/plausible/consolidated_view.ex new file mode 100644 index 000000000000..ced2465b1642 --- /dev/null +++ b/extra/lib/plausible/consolidated_view.ex @@ -0,0 +1,83 @@ +defmodule Plausible.ConsolidatedView do + @moduledoc """ + Contextual interface for consolidated views, + each implemented as Site object serving as + pointers to team's regular sites. + """ + + use Plausible + + import Ecto.Query + + alias Plausible.Teams.Team + alias Plausible.{Repo, Site} + + @spec enable(Team.t()) :: {:ok, Site.t()} | {:error, :upgrade_required} + def enable(%Team{} = team) do + if eligible?(team) do + do_enable(team) + else + {:error, :upgrade_required} + end + end + + @spec disable(Team.t()) :: :ok + def disable(%Team{} = team) do + from(s in Site, where: s.consolidated and s.domain == ^make_id(team)) + |> Plausible.Repo.delete_all() + + :ok + end + + @spec site_ids(Team.t()) :: [pos_integer()] | {:error, :not_found} + def site_ids(%Team{} = team) do + case get(team) do + nil -> {:error, :not_found} + _found -> {:ok, owned_site_ids(team)} + end + end + + @spec get(Team.t() | String.t()) :: Site.t() | nil + def get(team_or_id) + + def get(%Team{} = team) do + team |> make_id() |> get() + end + + def get(id) when is_binary(id) do + Repo.get_by(Site, domain: id, consolidated: true) + end + + defp do_enable(%Team{} = team) do + case get(team) do + nil -> + Site.new_for_team(team, %{consolidated: true, domain: make_id(team)}) + |> Repo.insert() + + cv -> + {:ok, cv} + end + end + + defp make_id(%Team{} = team) do + team.identifier + end + + # TODO: Only active trials and business subscriptions should be eligible. + # This function should call a new underlying feature module. + defp eligible?(%Team{}), do: always(true) + + # TEMPORARY: Will be replaced with `Teams.owned_site_ids` once it starts + # filtering out consolidated sites. There are many other list/count all + # sites queries for which there will probably be a dedicated DB view that + # applies the where clause. + defp owned_site_ids(team) do + Repo.all( + from(s in Site, + where: s.team_id == ^team.id and not s.consolidated, + select: s.id, + order_by: [desc: s.id] + ) + ) + end +end diff --git a/lib/plausible/site.ex b/lib/plausible/site.ex index 55b85d8a4186..05470468a222 100644 --- a/lib/plausible/site.ex +++ b/lib/plausible/site.ex @@ -101,9 +101,15 @@ defmodule Plausible.Site do """ end + on_ee do + @changeset_cast_fields [:domain, :consolidated, :timezone, :legacy_time_on_page_cutoff] + else + @changeset_cast_fields [:domain, :timezone, :legacy_time_on_page_cutoff] + end + def changeset(site, attrs \\ %{}) do site - |> cast(attrs, [:domain, :timezone, :legacy_time_on_page_cutoff]) + |> cast(attrs, @changeset_cast_fields) |> clean_domain() |> validate_required([:domain, :timezone]) |> validate_timezone() diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index c97f37f24e69..0ffb65f0890b 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -12,6 +12,20 @@ defmodule Plausible.Sites do require Plausible.Site.UserPreference + on_ee do + @spec regular?(Site.t()) :: boolean() + def regular?(%Site{} = site), do: not site.consolidated + + @spec consolidated?(Site.t()) :: boolean() + def consolidated?(%Site{} = site), do: site.consolidated + else + @spec regular?(Site.t()) :: boolean() + def regular?(%Site{}), do: true + + @spec consolidated?(Site.t()) :: boolean() + def consolidated?(%Site{}), do: false + end + @shared_link_special_names ["WordPress - Shared Dashboard"] @doc """ Special shared link names are used to distinguish between those diff --git a/test/plausible/consolidated_view_test.exs b/test/plausible/consolidated_view_test.exs new file mode 100644 index 000000000000..3fa5153009f0 --- /dev/null +++ b/test/plausible/consolidated_view_test.exs @@ -0,0 +1,88 @@ +defmodule Plausible.ConsolidatedViewTest do + use Plausible + + on_ee do + use Plausible.DataCase, async: true + import Ecto.Query + alias Plausible.ConsolidatedView + + describe "enable/1" do + setup [:create_user, :create_team] + + test "creates and persists a new consolidated site instance", %{team: team} do + assert {:ok, %Plausible.Site{consolidated: true}} = ConsolidatedView.enable(team) + assert ConsolidatedView.get(team) + end + + test "is idempotent", %{team: team} do + assert {:ok, s1} = ConsolidatedView.enable(team) + assert {:ok, s2} = ConsolidatedView.enable(team) + + assert 1 = + from(s in Plausible.Site, where: s.team_id == ^team.id) + |> Plausible.Repo.aggregate(:count) + + assert s1.domain == s2.domain + end + + @tag :skip + test "returns {:error, :upgrade_required} when team ineligible for this feature" + end + + describe "disable/1" do + setup [:create_user, :create_team] + + setup %{team: team} do + ConsolidatedView.enable(team) + :ok + end + + test "deletes an existing consolidated site instance", %{team: team} do + assert ConsolidatedView.get(team) + + assert :ok = ConsolidatedView.disable(team) + + refute ConsolidatedView.get(team) + end + + test "is idempotent", %{team: team} do + assert :ok = ConsolidatedView.disable(team) + assert :ok = ConsolidatedView.disable(team) + + refute ConsolidatedView.get(team) + end + end + + describe "site_ids/1" do + setup [:create_user, :create_team, :create_site] + + test "returns {:error, :not_found} when no consolidated view exists", %{team: team} do + assert {:error, :not_found} = ConsolidatedView.site_ids(team) + end + + test "returns site_ids owned by the team when consolidated view exists", %{ + team: team, + site: site + } do + ConsolidatedView.enable(team) + assert ConsolidatedView.site_ids(team) == {:ok, [site.id]} + end + end + + describe "get/1" do + setup [:create_user, :create_team] + + test "can get by team", %{team: team} do + assert is_nil(ConsolidatedView.get(team)) + ConsolidatedView.enable(team) + assert %Plausible.Site{} = ConsolidatedView.get(team) + end + + test "can get by team.identifier", %{team: team} do + assert is_nil(ConsolidatedView.get(team.identifier)) + ConsolidatedView.enable(team) + assert %Plausible.Site{} = ConsolidatedView.get(team.identifier) + end + end + end +end diff --git a/test/plausible/site/sites_test.exs b/test/plausible/site/sites_test.exs index 75d5fb79f45a..6c7bdb5512fe 100644 --- a/test/plausible/site/sites_test.exs +++ b/test/plausible/site/sites_test.exs @@ -922,4 +922,23 @@ defmodule Plausible.SitesTest do assert {:error, :too_many_pins} = Sites.toggle_pin(user, site) end end + + describe "predicates for site.consolidated" do + on_ee do + test "regular?/1" do + assert Sites.regular?(%Plausible.Site{consolidated: false}) + refute Sites.regular?(%Plausible.Site{consolidated: true}) + end + + test "consolidated?/1" do + assert Sites.consolidated?(%Plausible.Site{consolidated: true}) + refute Sites.consolidated?(%Plausible.Site{consolidated: false}) + end + else + test "all sites are regular on CE" do + assert Sites.regular?(%Plausible.Site{}) + refute Sites.consolidated?(%Plausible.Site{}) + end + end + end end From 4635b4fe118140b6d649b28f0e27afe20897c1c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:32:57 +0200 Subject: [PATCH 317/618] Bump tj-actions/changed-files from 46.0.5 to 47.0.0 (#5722) Bumps [tj-actions/changed-files](https://github.com/tj-actions/changed-files) from 46.0.5 to 47.0.0. - [Release notes](https://github.com/tj-actions/changed-files/releases) - [Changelog](https://github.com/tj-actions/changed-files/blob/main/HISTORY.md) - [Commits](https://github.com/tj-actions/changed-files/compare/ed68ef82c095e0d48ec87eccea555d944a631a4c...24d32ffd492484c1d75e0c0b894501ddb9d30d62) --- updated-dependencies: - dependency-name: tj-actions/changed-files dependency-version: 47.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/tracker-script-update.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tracker-script-update.yml b/.github/workflows/tracker-script-update.yml index 5cb849ac312f..7bdba532f256 100644 --- a/.github/workflows/tracker-script-update.yml +++ b/.github/workflows/tracker-script-update.yml @@ -122,7 +122,7 @@ jobs: - name: Get changed files id: changelog_changed - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c + uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 with: files: | tracker/npm_package/CHANGELOG.md From da1733ace33aeef13914da2e0135d4e061c5f77d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:33:15 +0200 Subject: [PATCH 318/618] Bump actions/setup-node from 4 to 5 (#5703) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/node.yml | 2 +- .github/workflows/tracker-script-npm-release.yml | 2 +- .github/workflows/tracker.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index f6ec3b2dd401..b2ad8135cf9b 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -21,7 +21,7 @@ jobs: uses: marocchino/tool-versions-action@v1 id: versions - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{steps.versions.outputs.nodejs}} - run: npm install --prefix ./assets diff --git a/.github/workflows/tracker-script-npm-release.yml b/.github/workflows/tracker-script-npm-release.yml index 56915e45df28..39ba13a21ad7 100644 --- a/.github/workflows/tracker-script-npm-release.yml +++ b/.github/workflows/tracker-script-npm-release.yml @@ -23,7 +23,7 @@ jobs: with: token: ${{ secrets.PLAUSIBLE_BOT_GITHUB_TOKEN }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 23.2.0 registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/tracker.yml b/.github/workflows/tracker.yml index ce1e1227c696..375f983ba9db 100644 --- a/.github/workflows/tracker.yml +++ b/.github/workflows/tracker.yml @@ -21,7 +21,7 @@ jobs: shardTotal: [4] steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 23.2.0 cache: 'npm' @@ -61,7 +61,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 23.2.0 cache: 'npm' From bcd723e3a698644611630d1d3c4775baf6da2a59 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:33:24 +0200 Subject: [PATCH 319/618] Bump actions/github-script from 7 to 8 (#5704) Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/terraform-e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/terraform-e2e.yml b/.github/workflows/terraform-e2e.yml index 179e4cf0834f..41fe4018ee71 100644 --- a/.github/workflows/terraform-e2e.yml +++ b/.github/workflows/terraform-e2e.yml @@ -52,7 +52,7 @@ jobs: run: terraform plan -no-color continue-on-error: true - - uses: actions/github-script@v7 + - uses: actions/github-script@v8 if: github.event_name == 'pull_request' env: PLAN: "terraform\n${{ steps.plan.outputs.stdout }}" From 2dc48d40086c061bbce0ddb638788638c81ae8b9 Mon Sep 17 00:00:00 2001 From: Sanne de Vries <65487235+sanne-san@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:40:11 +0200 Subject: [PATCH 320/618] Improve account, team, and site settings areas visually (#5740) - Improve the spacing and typography of the settings sidebars - Improve the spacing between form components - Change UI copy from Title Case to Sentence case for a more natural reading flow - Improve subtitle and label copy --- assets/tailwind.config.js | 1 + .../lib/plausible_web/live/sso_management.ex | 4 +- .../components/billing/billing.ex | 6 ++- lib/plausible_web/components/generic.ex | 40 +++++++++++------- lib/plausible_web/components/layout.ex | 40 ++++++++---------- lib/plausible_web/components/site/feature.ex | 4 +- lib/plausible_web/components/team/notice.ex | 2 +- lib/plausible_web/live/choose_plan.ex | 8 ++-- lib/plausible_web/live/components/form.ex | 15 ++++--- lib/plausible_web/live/components/team.ex | 29 +++++++------ .../live/imports_exports_settings.ex | 18 ++++---- .../live/plugins/api/settings.ex | 2 +- .../live/shields/country_rules.ex | 8 ++-- .../live/shields/hostname_rules.ex | 8 ++-- lib/plausible_web/live/shields/ip_rules.ex | 10 ++--- lib/plausible_web/live/shields/page_rules.ex | 8 ++-- lib/plausible_web/live/team_management.ex | 8 ++-- lib/plausible_web/live/team_setup.ex | 2 +- .../templates/layout/settings.html.heex | 41 +++++++++++-------- .../templates/layout/site_settings.html.heex | 6 +-- .../templates/settings/api_keys.html.heex | 4 +- .../templates/settings/danger_zone.html.heex | 17 ++++---- .../templates/settings/invoices.html.heex | 6 +-- .../templates/settings/preferences.html.heex | 24 ++++------- .../templates/settings/security.html.heex | 37 +++++++++-------- .../templates/settings/subscription.html.heex | 17 ++++---- .../settings/team_danger_zone.html.heex | 7 ++-- .../templates/settings/team_general.html.heex | 18 ++++---- .../site/settings_danger_zone.html.heex | 17 ++++---- .../site/settings_email_reports.html.heex | 40 ++++++++---------- .../templates/site/settings_funnels.html.heex | 2 +- .../templates/site/settings_general.html.heex | 17 ++++---- .../templates/site/settings_goals.html.heex | 2 +- .../site/settings_imports_exports.html.heex | 6 +-- .../site/settings_integrations.html.heex | 11 ++--- .../templates/site/settings_people.html.heex | 10 ++--- .../site/settings_visibility.html.heex | 21 +++++----- lib/plausible_web/views/layout_view.ex | 25 ++++++----- .../controllers/settings_controller_test.exs | 34 ++++++++------- .../controllers/site_controller_test.exs | 38 ++++++++--------- test/plausible_web/live/choose_plan_test.exs | 2 +- .../live/funnel_settings_test.exs | 2 +- .../plausible_web/live/goal_settings_test.exs | 2 +- .../live/plugins_api_tokens_test.exs | 4 +- .../live/shields/countries_test.exs | 4 +- .../live/shields/hostnames_test.exs | 4 +- .../live/shields/ip_addresses_test.exs | 4 +- .../plausible_web/live/shields/pages_test.exs | 4 +- .../live/team_management_test.exs | 26 ++++++------ test/plausible_web/live/team_setup_test.exs | 15 ++++--- 50 files changed, 346 insertions(+), 334 deletions(-) diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 10fcb7c92faa..235be3667a83 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -23,6 +23,7 @@ module.exports = { colors: { yellow: colors.amber, // We started using `yellow` in v2 but it was renamed to `amber` in v3 https://tailwindcss.com/docs/upgrade-guide#removed-color-aliases gray: colors.slate, + 'gray-150': 'rgb(234, 238, 244)', 'gray-950': 'rgb(13, 18, 30)', 'gray-850': 'rgb(26, 32, 44)', 'gray-825': 'rgb(37, 47, 63)' diff --git a/extra/lib/plausible_web/live/sso_management.ex b/extra/lib/plausible_web/live/sso_management.ex index 83302f9aae64..424cae8194e6 100644 --- a/extra/lib/plausible_web/live/sso_management.ex +++ b/extra/lib/plausible_web/live/sso_management.ex @@ -34,7 +34,7 @@ defmodule PlausibleWeb.Live.SSOManagement do Single Sign-On <:subtitle> - Configure and manage Single Sign-On for your team + Configure and manage Single Sign-On for your team. <.init_view :if={@mode == :init} current_team={@current_team} /> @@ -244,7 +244,7 @@ defmodule PlausibleWeb.Live.SSOManagement do Single Sign-On <:subtitle> - Configure and manage Single Sign-On for your team + Configure and manage Single Sign-On for your team.
    diff --git a/lib/plausible_web/components/billing/billing.ex b/lib/plausible_web/components/billing/billing.ex index 0912d4193c62..1073adaff60b 100644 --- a/lib/plausible_web/components/billing/billing.ex +++ b/lib/plausible_web/components/billing/billing.ex @@ -330,7 +330,11 @@ defmodule PlausibleWeb.Components.Billing do def upgrade_link(assigns) do ~H""" - <.button_link id="upgrade-link-2" href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}> + <.button_link + id="upgrade-link-2" + href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} + mt?={false} + > Upgrade """ diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index a8f796f9c2bc..2b8916464a2c 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -6,10 +6,10 @@ defmodule PlausibleWeb.Components.Generic do @notice_themes %{ gray: %{ - bg: "bg-white dark:bg-gray-800", + bg: "bg-gray-100 dark:bg-gray-700/50", icon: "text-gray-400", - title_text: "text-gray-800 dark:text-gray-400", - body_text: "text-gray-700 dark:text-gray-500 leading-5" + title_text: "text-sm text-gray-800 dark:text-gray-300", + body_text: "text-sm text-gray-700 dark:text-gray-400 leading-5" }, yellow: %{ bg: "bg-yellow-50 dark:bg-yellow-100", @@ -138,7 +138,7 @@ defmodule PlausibleWeb.Components.Generic do ~H""" @@ -217,7 +217,7 @@ defmodule PlausibleWeb.Components.Generic do new_tab={@new_tab} href={@href} method={@method} - class={"text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600 " <> @class} + class={"text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-400 transition-colors duration-150 " <> @class} {@rest} > {render_slot(@inner_block)} @@ -444,7 +444,7 @@ defmodule PlausibleWeb.Components.Generic do attr :docs, :string, default: nil slot :inner_block, required: true slot :title, required: true - slot :subtitle, required: true + slot :subtitle, required: false attr :feature_mod, :atom, default: nil attr :feature_toggle?, :boolean, default: false attr :current_role, :atom, default: nil @@ -461,7 +461,7 @@ defmodule PlausibleWeb.Components.Generic do <.docs_info :if={@docs} slug={@docs} class="absolute top-4 right-4" /> -
    +
    {render_slot(@subtitle)}
    -
    +
    {render_slot(@inner_block)}
    <% else %> -
    +
    {render_slot(@inner_block)}
    <% end %> @@ -753,7 +753,7 @@ defmodule PlausibleWeb.Components.Generic do if assigns[:invisible] do "invisible" else - "px-6 first:pl-0 last:pr-0 py-3 text-left text-sm font-medium" + "px-6 first:pl-0 last:pr-0 py-3 text-left text-sm font-semibold" end assigns = assign(assigns, class: class) @@ -771,7 +771,7 @@ defmodule PlausibleWeb.Components.Generic do def toggle_submit(assigns) do ~H""" -
    +
    """ @@ -839,7 +839,7 @@ defmodule PlausibleWeb.Components.Generic do <.unstyled_link href={@href} {@rest}> <.dynamic_icon name={@icon} - class="w-5 h-5 text-red-800 hover:text-red-500 dark:text-red-500 dark:hover:text-red-400" + class="size-5 text-red-700 hover:text-red-500 dark:text-red-500 dark:hover:text-red-400" /> """ @@ -848,7 +848,7 @@ defmodule PlausibleWeb.Components.Generic do """ @@ -963,4 +963,14 @@ defmodule PlausibleWeb.Components.Generic do
    """ end + + def settings_badge(%{type: :new} = assigns) do + ~H""" + + NEW 🔥 + + """ + end + + def settings_badge(assigns), do: ~H"" end diff --git a/lib/plausible_web/components/layout.ex b/lib/plausible_web/components/layout.ex index e55d3567751e..386060e1fb1b 100644 --- a/lib/plausible_web/components/layout.ex +++ b/lib/plausible_web/components/layout.ex @@ -59,15 +59,17 @@ defmodule PlausibleWeb.Components.Layout do def settings_sidebar(assigns) do ~H""" - <.settings_top_tab - :for={%{key: key, value: value, icon: icon} = opts <- @options} - selected_fn={@selected_fn} - prefix={@prefix} - icon={icon} - text={key} - badge={opts[:badge]} - value={value} - /> +
    + <.settings_top_tab + :for={%{key: key, value: value, icon: icon} = opts <- @options} + selected_fn={@selected_fn} + prefix={@prefix} + icon={icon} + text={key} + badge={opts[:badge]} + value={value} + /> +
    """ end @@ -126,19 +128,19 @@ defmodule PlausibleWeb.Components.Layout do class={[ "text-sm flex items-center px-2 py-2 leading-5 font-medium rounded-md outline-none focus:outline-none transition ease-in-out duration-150", @current_tab? && - "text-gray-900 dark:text-gray-100 bg-gray-100 font-semibold dark:bg-gray-900 hover:text-gray-900 focus:bg-gray-200 dark:focus:bg-gray-800", + "text-gray-900 dark:text-gray-100 bg-gray-150 font-semibold dark:bg-gray-700/50 hover:text-gray-900 focus:bg-gray-200 dark:focus:bg-gray-800", @value && not @current_tab? && - "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 focus:text-gray-900 focus:bg-gray-50 dark:focus:text-gray-100 dark:focus:bg-gray-800", - !@value && "text-gray-600 dark:text-gray-400" + "text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 focus:text-gray-900 focus:bg-gray-50 dark:focus:text-gray-100 dark:focus:bg-gray-800", + !@value && "text-gray-600 dark:text-gray-300" ]} > {@text} - <.settings_badge type={@badge} /> + - NEW - - """ - end - - defp settings_badge(assigns), do: ~H"" - defp theme_preference(%{theme: theme}) when not is_nil(theme), do: theme defp theme_preference(%{current_user: %Plausible.Auth.User{theme: theme}}) diff --git a/lib/plausible_web/components/site/feature.ex b/lib/plausible_web/components/site/feature.ex index 9d147941ca9a..4976f9f93cec 100644 --- a/lib/plausible_web/components/site/feature.ex +++ b/lib/plausible_web/components/site/feature.ex @@ -18,7 +18,7 @@ defmodule PlausibleWeb.Components.Site.Feature do |> assign(:disabled?, assigns.feature_mod.check_availability(assigns.site.team) !== :ok) ~H""" -
    +
    <.form action={target(@site, @feature_mod.toggle_field(), @conn, !@current_setting)} method="put" @@ -26,7 +26,7 @@ defmodule PlausibleWeb.Components.Site.Feature do class={@class} > <.toggle_submit set_to={@current_setting} disabled?={@disabled?}> - Show {@feature_mod.display_name()} in the Dashboard + Show {String.downcase(@feature_mod.display_name())} in the dashboard diff --git a/lib/plausible_web/components/team/notice.ex b/lib/plausible_web/components/team/notice.ex index a0e813fc996a..6de07a46577c 100644 --- a/lib/plausible_web/components/team/notice.ex +++ b/lib/plausible_web/components/team/notice.ex @@ -43,7 +43,7 @@ defmodule PlausibleWeb.Team.Notice do def team_members_notice(assigns) do ~H"""