diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc3e1820877..471a0501c812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file. ### Removed +- Removed the intermediate "Awaiting your first pageview" screen after site created. User is now taken straight to the dashboard from the installation instructions page. - Removed the standalone team switcher page; team switching is now done from the topbar dropdown only - Removed `Bamboo.SMTPAdapter` from supported e-mail adapters; the library is no longer in active developments and does not compile under Elixir 1.20+ diff --git a/assets/js/dashboard/email-reports-cta-banner.test.tsx b/assets/js/dashboard/email-reports-cta-banner.test.tsx new file mode 100644 index 000000000000..e4f707c785ea --- /dev/null +++ b/assets/js/dashboard/email-reports-cta-banner.test.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TestContextProviders } from '../../test-utils/app-context-providers' +import { MockAPI } from '../../test-utils/mock-api' +import { EmailReportsCTABanner } from './email-reports-cta-banner' + +const domain = 'dummy.site' + +let mockAPI: MockAPI + +beforeAll(() => { + mockAPI = new MockAPI().start() +}) + +afterAll(() => { + mockAPI.stop() +}) + +beforeEach(() => { + mockAPI.clear() +}) + +function renderBanner(showEmailReportsCta: boolean) { + render(, { + wrapper: (props) => ( + + ) + }) +} + +test('renders nothing when showEmailReportsCta is false', () => { + renderBanner(false) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) + +test('renders the banner when showEmailReportsCta is true', () => { + renderBanner(true) + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Your first pageview has landed!' + ) +}) + +test('dismissing fires the mutation and hides the banner', async () => { + const putHandler = mockAPI.put(`/api/${domain}/complete-onboarding`, {}) + + renderBanner(true) + + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + await waitFor(() => expect(putHandler).toHaveBeenCalledTimes(1)) +}) + +test('the email reports link points at the settings page with cta_clicked=true', async () => { + renderBanner(true) + + const link = screen.getByRole('link', { + name: /Get weekly traffic reports by email/ + }) + + expect(link).toHaveAttribute( + 'href', + `/${domain}/settings/email-reports?cta_clicked=true` + ) +}) + +test('still hides the banner (and does not throw) when the mutation fails', async () => { + mockAPI.put(`/api/${domain}/complete-onboarding`, () => + Promise.resolve({ + ok: false, + status: 401, + json: async () => ({ error: 'unauthorized' }) + } as Response) + ) + + renderBanner(true) + + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +}) diff --git a/assets/js/dashboard/email-reports-cta-banner.tsx b/assets/js/dashboard/email-reports-cta-banner.tsx new file mode 100644 index 000000000000..14d6895db989 --- /dev/null +++ b/assets/js/dashboard/email-reports-cta-banner.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react' +import { XMarkIcon } from '@heroicons/react/24/outline' +import { useSiteContext } from './site-context' +import * as api from './api' + +// CTA for configuring weekly email reports. It displays when the +// dashboard is loaded for the very first time, having actual data +// (i.e. site.onboarding_status == "first_pageview"). The CTA will +// remain visible until either: +// +// 1) it's dismissed by any site member that sees it -- +// Notifies the backend to advance the site onboarding status via +// a HTTP request. +// +// 2) the CTA is clicked by any site member that sees it -- +// The link to /:domain/settings/email-reports includes a query +// parameter telling the controller action to advance the site's +// onboarding status. +export function EmailReportsCTABanner() { + const site = useSiteContext() + const [visible, setVisible] = useState(site.showEmailReportsCta) + + if (!visible) { + return null + } + + function dismiss() { + setVisible(false) + + api + .mutation(`/api/${encodeURIComponent(site.domain)}/complete-onboarding`, { + method: 'PUT', + body: {} + }) + .catch((error) => { + if (!(error instanceof api.ApiError)) { + throw error + } + }) + } + + return ( +
+ + 🎉 + + Your first pageview has landed! + {' '} + setVisible(false)} + > + Get weekly traffic reports by email → + +
+ ) +} diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index 1736f963d227..bb2f7ac6be1f 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -11,6 +11,8 @@ import { isRealTimeDashboard } from './util/filters' import { GraphIntervalProvider } from './stats/graph/graph-interval-context' import { ImportsIncludedProvider } from './stats/graph/imports-included-context' import { CurrentVisitorsProvider } from './current-visitors-context' +import { VerificationLiveViewPortal } from './verification/portal' +import { EmailReportsCTABanner } from './email-reports-cta-banner' function DashboardStats({ importedDataInView, @@ -21,7 +23,11 @@ function DashboardStats({ }) { return ( <> - +
+ + + +
diff --git a/assets/js/dashboard/nav-menu/top-bar.test.tsx b/assets/js/dashboard/nav-menu/top-bar.test.tsx index d2a1d645d709..be3cacf1b2a7 100644 --- a/assets/js/dashboard/nav-menu/top-bar.test.tsx +++ b/assets/js/dashboard/nav-menu/top-bar.test.tsx @@ -75,6 +75,29 @@ test('user can open and close site switcher', async () => { expect(screen.queryAllByRole('menuitem')).toEqual([]) }) +test('site switcher links to a site needing verification with verify_installation and flow params', async () => { + mockAPI.get('/api/sites', { + data: [ + { domain, needs_verification: false }, + { domain: 'example.com', needs_verification: true } + ] + }) + + render(, { + wrapper: (props) => ( + + ) + }) + + const toggleSiteSwitcher = screen.getByRole('button', { name: domain }) + await userEvent.click(toggleSiteSwitcher) + + expect(screen.getByRole('link', { name: /example\.com/ })).toHaveAttribute( + 'href', + '/example.com?verify_installation=true&flow=provisioning' + ) +}) + test('user can open and close filters dropdown', async () => { render(, { wrapper: (props) => ( diff --git a/assets/js/dashboard/site-context.test.tsx b/assets/js/dashboard/site-context.test.tsx index 44f67dc1ff55..d4668180756a 100644 --- a/assets/js/dashboard/site-context.test.tsx +++ b/assets/js/dashboard/site-context.test.tsx @@ -62,7 +62,8 @@ describe('parseSiteFromDataset', () => { isDbip: false, flags: {}, shared: false, - isConsolidatedView: false + isConsolidatedView: false, + showEmailReportsCta: false } it('parses from dom string map correctly', () => { diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx index c812686a0ab6..e2f2d67556b8 100644 --- a/assets/js/dashboard/site-context.tsx +++ b/assets/js/dashboard/site-context.tsx @@ -28,7 +28,8 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite { isDbip: dataset.isDbip === 'true', flags: JSON.parse(dataset.flags!), shared: !!dataset.sharedLinkAuth, - isConsolidatedView: dataset.isConsolidatedView === 'true' + isConsolidatedView: dataset.isConsolidatedView === 'true', + showEmailReportsCta: dataset.showEmailReportsCta === 'true' } } @@ -62,7 +63,8 @@ export const siteContextDefaultValue = { isDbip: false, flags: {} as FeatureFlags, shared: false, - isConsolidatedView: false + isConsolidatedView: false, + showEmailReportsCta: false } export type PlausibleSite = typeof siteContextDefaultValue diff --git a/assets/js/dashboard/site-switcher.tsx b/assets/js/dashboard/site-switcher.tsx index 637dd5aa3d98..8dd48bacbc55 100644 --- a/assets/js/dashboard/site-switcher.tsx +++ b/assets/js/dashboard/site-switcher.tsx @@ -58,13 +58,17 @@ const buttonLinkClassName = classNames( const getSwitchToSiteURL = ( currentSite: PlausibleSite, - site: { domain: string } + site: { domain: string; needsVerification?: boolean } ): string | null => { // Prevents reloading the page when the current site is selected if (currentSite.domain === site.domain) { return null } - return `/${encodeURIComponent(site.domain)}` + const url = `/${encodeURIComponent(site.domain)}` + + return site.needsVerification + ? `${url}?verify_installation=true&flow=provisioning` + : url } const SiteSwitcherStatic = () => { @@ -96,7 +100,9 @@ export const SiteSwitcher = () => { const sitesQuery = useQuery({ enabled: user.loggedIn, queryKey: ['sites'], - queryFn: async (): Promise<{ data: Array<{ domain: string }> }> => { + queryFn: async (): Promise<{ + data: Array<{ domain: string; needs_verification: boolean }> + }> => { const response = await get('/api/sites') return response }, @@ -121,24 +127,29 @@ export const SiteSwitcher = () => { <> {!!dashboardRouteMatch && !modal && - sitesQuery.data?.data.slice(0, 8).map(({ domain }, index) => ( - { - const url = getSwitchToSiteURL(currentSite, { domain }) - if (!url) { - closePopover() - } else { - closePopover() - window.location.assign(url) - } - }} - shouldIgnoreWhen={[isModifierPressed, isTyping]} - targetRef="document" - /> - ))} + sitesQuery.data?.data + .slice(0, 8) + .map(({ domain, needs_verification }, index) => ( + { + const url = getSwitchToSiteURL(currentSite, { + domain, + needsVerification: needs_verification + }) + if (!url) { + closePopover() + } else { + closePopover() + window.location.assign(url) + } + }} + shouldIgnoreWhen={[isModifierPressed, isTyping]} + targetRef="document" + /> + ))} {!!dashboardRouteMatch && !modal && @@ -253,12 +264,17 @@ export const SiteSwitcher = () => { )} {!!sitesInDropdown && - sitesInDropdown.map(({ domain }, index) => ( + sitesInDropdown.map(({ domain, needs_verification }, index) => ( closePopover() diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx index e16a9d955eef..1483c712ad7e 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.tsx +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -113,7 +113,7 @@ export default function VisitorGraph({ !showFullLoader return ( -
+
<>
{location.pathname + location.search}
+} + +function renderWithInitialEntry(initialEntry: string) { + render( + <> + + + , + { + wrapper: (props) => ( + + ) + } + ) +} + +function dispatchVerificationFinished(queryParams: string[]) { + act(() => { + window.dispatchEvent( + new CustomEvent(VERIFICATION_FINISHED_EVENT, { + detail: { queryParams } + }) + ) + }) +} + +test('drops exactly the query params named in the event detail, leaving every other param untouched', () => { + renderWithInitialEntry( + '/some-domain?f=contains,os,a&f=contains,page,/&verify_installation=true&flow=provisioning&comparison=year_over_year' + ) + + dispatchVerificationFinished(['verify_installation', 'flow']) + + expect(screen.getByTestId('location').textContent).toBe( + '/?f=contains,os,a&f=contains,page,/&comparison=year_over_year' + ) +}) + +test('does nothing when none of the named params are present', () => { + renderWithInitialEntry('/some-domain?comparison=year_over_year') + + dispatchVerificationFinished(['verify_installation', 'flow']) + + expect(screen.getByTestId('location').textContent).toBe( + '/?comparison=year_over_year' + ) +}) + +test('drops only verify_installation, keeping a real param that happens to be a prefix of it', () => { + renderWithInitialEntry( + '/some-domain?verify_installation=true&verify_installation_extra=keep-me' + ) + + dispatchVerificationFinished(['verify_installation']) + + expect(screen.getByTestId('location').textContent).toBe( + '/?verify_installation_extra=keep-me' + ) +}) diff --git a/assets/js/dashboard/verification/portal.tsx b/assets/js/dashboard/verification/portal.tsx new file mode 100644 index 000000000000..19d482ecb111 --- /dev/null +++ b/assets/js/dashboard/verification/portal.tsx @@ -0,0 +1,50 @@ +import React, { useEffect } from 'react' +import { useAppNavigate } from '../navigation/use-app-navigate' + +type VerificationFinishedDetail = { + /** + * Exact query param names to drop from the URL when verification banner + * disappears. See: PlausibleWeb.Live.Components.VerificationBanner.query_params/0 + */ + queryParams: string[] +} + +export const VERIFICATION_FINISHED_EVENT = 'verification-finished' + +/** + * Renders the portal target into which the verification LiveView (see + * lib/plausible_web/live/components/verification.ex) gets teleported. + * Also helps that LiveView out with cleaning up after itself: clearing + * its one-time query params through React Router. + */ +export const VerificationLiveViewPortal = React.memo(() => { + const navigate = useAppNavigate() + + useEffect(() => { + function handleVerificationFinished(event: Event) { + const { queryParams } = (event as CustomEvent) + .detail + + navigate({ + search: (search) => { + const nextSearch = { ...search } + queryParams.forEach((param) => delete nextSearch[param]) + return nextSearch + } + }) + } + + window.addEventListener( + VERIFICATION_FINISHED_EVENT, + handleVerificationFinished + ) + + return () => + window.removeEventListener( + VERIFICATION_FINISHED_EVENT, + handleVerificationFinished + ) + }, [navigate]) + + return
+}) diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index 2379bb596e6d..225913d12aac 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -17,6 +17,23 @@ let csrfToken = document.querySelector("meta[name='csrf-token']") let websocketUrl = document.querySelector("meta[name='websocket-url']") if (csrfToken && websocketUrl) { let Hooks = { Modal, Dropdown } + + Hooks.VerificationLifecycle = { + mounted() { + // Lets a LiveView tell the client to tear down the websocket connection + // once it's done with it (e.g. PlausibleWeb.Live.Verification, once its + // banner has been dismissed) - the server-side process then terminates + // gracefully. + this.handleEvent('disconnect-liveview', () => liveSocket.disconnect()) + + this.handleEvent('verification-succeeded', ({ queryParams }) => { + window.dispatchEvent( + new CustomEvent('verification-finished', { detail: { queryParams } }) + ) + }) + } + } + let Uploaders = {} Uploaders.S3 = function (entries, onViewError) { entries.forEach((entry) => { diff --git a/assets/test-utils/app-context-providers.tsx b/assets/test-utils/app-context-providers.tsx index 062aac19f3e5..8d25852950e1 100644 --- a/assets/test-utils/app-context-providers.tsx +++ b/assets/test-utils/app-context-providers.tsx @@ -50,7 +50,8 @@ export const DEFAULT_SITE: PlausibleSite = { isDbip: false, flags: {}, shared: false, - isConsolidatedView: false + isConsolidatedView: false, + showEmailReportsCta: false } export const TestContextProviders = ({ diff --git a/assets/test-utils/mock-api.ts b/assets/test-utils/mock-api.ts index 91a597eb2eff..b68b631a5ae4 100644 --- a/assets/test-utils/mock-api.ts +++ b/assets/test-utils/mock-api.ts @@ -35,6 +35,14 @@ export class MockAPI { return this.register('post', urlWithoutQueryString, responseHandler) } + // sets put handler + public put( + urlWithoutQueryString: string, + responseHandler: typeof fetch | Record + ): jest.Mock { + return this.register('put', urlWithoutQueryString, responseHandler) + } + private register( method: string, urlWithoutQueryString: string, diff --git a/config/config.exs b/config/config.exs index 0b6efb948791..1c2199c0c1a8 100644 --- a/config/config.exs +++ b/config/config.exs @@ -44,7 +44,8 @@ config :ref_inspector, config :plausible, paddle_api: Plausible.Billing.PaddleApi, - google_api: Plausible.Google.API + google_api: Plausible.Google.API, + verification_checks_mod: Plausible.InstallationSupport.Verification.Checks config :plausible, # 30 minutes diff --git a/config/dev.exs b/config/dev.exs index 7201157eea5b..f0851f421c41 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -25,7 +25,9 @@ config :plausible, PlausibleWeb.Endpoint, ] ] -config :plausible, paddle_api: Plausible.Billing.DevPaddleApiMock +config :plausible, + paddle_api: Plausible.Billing.DevPaddleApiMock, + verification_checks_mod: Plausible.InstallationSupport.Verification.ChecksMock config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime diff --git a/config/e2e_test.exs b/config/e2e_test.exs index c881cdec49cf..dada94766e68 100644 --- a/config/e2e_test.exs +++ b/config/e2e_test.exs @@ -6,7 +6,8 @@ config :plausible, PlausibleWeb.Endpoint, config :plausible, paddle_api: Plausible.Billing.DevPaddleApiMock, - google_api: Plausible.Google.API.Mock + google_api: Plausible.Google.API.Mock, + verification_checks_mod: Plausible.InstallationSupport.Verification.ChecksMock config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime diff --git a/e2e/tests/dashboard/breakdowns.spec.ts b/e2e/tests/dashboard/breakdowns.spec.ts index eeb7b3413077..93e5bc2065f0 100644 --- a/e2e/tests/dashboard/breakdowns.spec.ts +++ b/e2e/tests/dashboard/breakdowns.spec.ts @@ -614,6 +614,7 @@ test('pages breakdown', async ({ page, request }) => { await test.step('exit pages modal with an event filter applied', async () => { await page.goto('/' + domain + '?f=is,page,/page1', { waitUntil: 'commit' }) + await report.getByTestId('report-end').scrollIntoViewIfNeeded() await detailsLink(report).click() await expect( diff --git a/e2e/tests/dashboard/verification.spec.ts b/e2e/tests/dashboard/verification.spec.ts new file mode 100644 index 000000000000..a2df3947cff9 --- /dev/null +++ b/e2e/tests/dashboard/verification.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from '@playwright/test' +import { addSite, setupSite, setVerificationScenario } from '../fixtures' +import { expectLiveViewConnected } from '../test-utils' + +const VERIFICATION_BANNER_SELECTOR = '#verification-ui' +const PROGRESS_MSG_SELECTOR = '#progress' +const HEADING_SELECTOR = 'h3' + +const SUCCESS_MESSAGE = 'Tracking is active on your site' +const FAILURE_HEADING = "We couldn't detect Plausible on your site" +const LOADING_STATE_TITLE = 'Verifying your installation' +const LOADING_STATE_CYCLED_MESSAGES = [ + /We're visiting your site to ensure that everything is working/, + /We're trying to reach your website/, + /We're verifying that your visitors are being counted correctly/ +] + +test('installation verification', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + const otherDomain = `other.verification.com` + await addSite({ page, domain: otherDomain }) + + const banner = page.locator(VERIFICATION_BANNER_SELECTOR) + const progress = banner.locator(PROGRESS_MSG_SELECTOR) + const heading = banner.locator(HEADING_SELECTOR) + + await test.step('restarts when the page is refreshed while verification is ongoing', async () => { + await setVerificationScenario({ + request, + domain, + scenario: 'plausible_not_found', + options: { slowdown: 500, launch_delay: 500 } + }) + + await page.goto(`/${domain}?verify_installation=true`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + await expect(banner).toContainText(LOADING_STATE_TITLE) + await expect(progress).toHaveText(LOADING_STATE_CYCLED_MESSAGES[0]!) + await expect(progress).toHaveText(LOADING_STATE_CYCLED_MESSAGES[1]!) + + await page.reload({ waitUntil: 'commit' }) + await expectLiveViewConnected(page) + + await expect(banner).toContainText(LOADING_STATE_TITLE) + + for (const msg of LOADING_STATE_CYCLED_MESSAGES) { + await expect(progress).toHaveText(msg) + } + }) + + await test.step('when finished with failure, refreshing kicks off verification again', async () => { + await expect(heading).toHaveText(FAILURE_HEADING) + + await page.reload({ waitUntil: 'commit' }) + await expectLiveViewConnected(page) + + await expect(banner).toContainText(LOADING_STATE_TITLE) + }) + + await test.step('navigating to the dashboard via the site switcher kicks off verification again', async () => { + await setVerificationScenario({ request, domain, scenario: 'success' }) + + await page.goto(`/${otherDomain}`, { waitUntil: 'commit' }) + + await page.getByRole('button', { name: otherDomain }).click() + await page + .getByTestId('sitemenu') + .getByRole('link', { name: new RegExp(domain) }) + .click() + + await expect(page).toHaveURL( + new RegExp(`${domain}\\?verify_installation=true&flow=provisioning`) + ) + await expectLiveViewConnected(page) + + await expect(banner).toContainText(SUCCESS_MESSAGE) + }) + + await test.step("when finished with success, a page refresh won't bring it back", async () => { + await page.reload({ waitUntil: 'commit' }) + + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) + }) + + await test.step("flow=review, dismissing while in progress: refresh won't bring it back", async () => { + await setVerificationScenario({ + request, + domain, + scenario: 'success', + options: { slowdown: 3000 } + }) + + await page.goto(`/${domain}?verify_installation=true&flow=review`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + await expect(banner).toContainText(LOADING_STATE_TITLE) + + await banner.getByRole('button', { name: 'Dismiss' }).click() + + await expect(banner).toBeHidden() + await expect(page).not.toHaveURL(/verify_installation/) + await expect(page).not.toHaveURL(/flow=/) + + await page.reload({ waitUntil: 'commit' }) + + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) + }) + + await test.step('flow=review, finished with failure: refresh kicks off verification again', async () => { + await setVerificationScenario({ + request, + domain, + scenario: 'plausible_not_found' + }) + + await page.goto(`/${domain}?verify_installation=true&flow=review`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + await expect(heading).toHaveText(FAILURE_HEADING) + + await page.reload({ waitUntil: 'commit' }) + await expectLiveViewConnected(page) + + await expect(heading).toHaveText(FAILURE_HEADING) + }) + + await test.step("flow=domain_change, dismissing after finished with failure: refresh won't bring it back", async () => { + await page.goto(`/${domain}?verify_installation=true&flow=domain_change`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + await expect(heading).toHaveText(FAILURE_HEADING) + + await banner.getByRole('button', { name: 'Dismiss' }).click() + + await expect(banner).toBeHidden() + await expect(page).not.toHaveURL(/verify_installation/) + await expect(page).not.toHaveURL(/flow=/) + + await page.reload({ waitUntil: 'commit' }) + + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) + }) + + await test.step("flow=domain_change, finished with success: refresh won't bring it back", async () => { + await setVerificationScenario({ request, domain, scenario: 'success' }) + + await page.goto(`/${domain}?verify_installation=true&flow=domain_change`, { + waitUntil: 'commit' + }) + await expectLiveViewConnected(page) + + await expect(banner).toContainText(SUCCESS_MESSAGE) + + await page.reload({ waitUntil: 'commit' }) + + await expect(page.locator(VERIFICATION_BANNER_SELECTOR)).toHaveCount(0) + }) +}) diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts index 55bb6a99f2c8..34eeabc2c59c 100644 --- a/e2e/tests/fixtures.ts +++ b/e2e/tests/fixtures.ts @@ -241,6 +241,28 @@ export async function populateStats({ expect(response.ok()).toBeTruthy() } +export async function setVerificationScenario({ + request, + domain, + scenario, + options +}: { + request: APIRequestContext + domain: string + scenario: string + options?: { slowdown?: number; launch_delay?: number } +}) { + const response = await request.put('/e2e-tests/verification', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + data: { domain: domain, scenario: scenario, options: options } + }) + + expect(response.ok()).toBeTruthy() +} + export async function addGoal({ request, domain, diff --git a/extra/lib/plausible/installation_support/check_runner.ex b/extra/lib/plausible/installation_support/check_runner.ex index 73aa5f84555b..f847ebf4b875 100644 --- a/extra/lib/plausible/installation_support/check_runner.ex +++ b/extra/lib/plausible/installation_support/check_runner.ex @@ -12,20 +12,26 @@ defmodule Plausible.InstallationSupport.CheckRunner do Checks are normally run asynchronously, except when synchronous execution is optionally required for tests. Slowdowns can be optionally added, the user doesn't benefit from running the checks too quickly. + An optional `:launch_delay` is awaited once, before the first check + starts - callers that don't care about it (e.g. detection checks) can + simply omit the opt, since it defaults to 0 here. """ def run(state, checks, opts) do async? = Keyword.get(opts, :async?, true) slowdown = Keyword.get(opts, :slowdown, 500) + launch_delay = Keyword.get(opts, :launch_delay, 0) if async? do - Task.start_link(fn -> do_run(state, checks, slowdown) end) + Task.start_link(fn -> do_run(state, checks, slowdown, launch_delay) end) else - do_run(state, checks, slowdown) + do_run(state, checks, slowdown, launch_delay) end end - defp do_run(state, checks, slowdown) do + defp do_run(state, checks, slowdown, launch_delay) do + if is_integer(launch_delay) and launch_delay > 0, do: :timer.sleep(launch_delay) + state = Enum.reduce_while( checks, diff --git a/extra/lib/plausible/installation_support/installation_support.ex b/extra/lib/plausible/installation_support/installation_support.ex index f724a96d28ea..0452fc5503c5 100644 --- a/extra/lib/plausible/installation_support/installation_support.ex +++ b/extra/lib/plausible/installation_support/installation_support.ex @@ -12,6 +12,8 @@ defmodule Plausible.InstallationSupport do def user_agent() do "Plausible Verification Agent - if abused, contact support@plausible.io" end + + def verification_checks_mod, do: Application.fetch_env!(:plausible, :verification_checks_mod) else def user_agent() do "Plausible Community Edition" diff --git a/extra/lib/plausible/installation_support/result.ex b/extra/lib/plausible/installation_support/result.ex index baa08d645ddd..a1fad54ca56b 100644 --- a/extra/lib/plausible/installation_support/result.ex +++ b/extra/lib/plausible/installation_support/result.ex @@ -6,7 +6,7 @@ defmodule Plausible.InstallationSupport.Result do ok?: false, data: nil, errors: [error.message], - recommendations: [%{text: error.recommendation, url: error.url}] + recommendations: [%{text: error.recommendation, inline_links: error.inline_links}] ok?: true, data: %{}, diff --git a/extra/lib/plausible/installation_support/verification/checks.ex b/extra/lib/plausible/installation_support/verification/checks.ex index 2a3328d21bcb..35f42b87a71a 100644 --- a/extra/lib/plausible/installation_support/verification/checks.ex +++ b/extra/lib/plausible/installation_support/verification/checks.ex @@ -23,6 +23,7 @@ defmodule Plausible.InstallationSupport.Verification.Checks do report_to = Keyword.get(opts, :report_to, self()) async? = Keyword.get(opts, :async?, true) slowdown = Keyword.get(opts, :slowdown, 500) + launch_delay = Keyword.get(opts, :launch_delay, 500) init_state = %State{ @@ -43,7 +44,8 @@ defmodule Plausible.InstallationSupport.Verification.Checks do CheckRunner.run(init_state, checks, async?: async?, report_to: report_to, - slowdown: slowdown + slowdown: slowdown, + launch_delay: launch_delay ) end diff --git a/extra/lib/plausible/installation_support/verification/diagnostics.ex b/extra/lib/plausible/installation_support/verification/diagnostics.ex index 0773c07550f8..68ce77220305 100644 --- a/extra/lib/plausible/installation_support/verification/diagnostics.ex +++ b/extra/lib/plausible/installation_support/verification/diagnostics.ex @@ -33,35 +33,56 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do """ @enforce_keys [:message, :recommendation] - defstruct [:message, :recommendation, :url] + defstruct [:message, :recommendation, inline_links: []] + + @required_link_prefix "https://plausible.io/" def new!(attrs) do message = Map.fetch!(attrs, :message) + recommendation = Map.fetch!(attrs, :recommendation) + inline_links = Map.get(attrs, :inline_links, []) if String.ends_with?(message, ".") do raise ArgumentError, "Error message must not end with a period: #{inspect(message)}" end - if String.ends_with?(attrs[:recommendation], ".") do + if String.ends_with?(recommendation, ".") do raise ArgumentError, - "Error recommendation must not end with a period: #{inspect(attrs[:recommendation])}" + "Error recommendation must not end with a period: #{inspect(recommendation)}" end - if is_binary(attrs[:url]) and not String.starts_with?(attrs[:url], "https://plausible.io") do - raise ArgumentError, - "Recommendation url must start with 'https://plausible.io': #{inspect(attrs[:url])}" + for %{text: text, href: href} <- inline_links do + if length(String.split(recommendation, text)) - 1 != 1 do + raise ArgumentError, + "Recommendation inline_links text #{inspect(text)} must appear exactly once in: #{inspect(recommendation)}" + end + + if not String.starts_with?(href, @required_link_prefix) do + raise ArgumentError, + "Recommendation inline_links href must start with '#{@required_link_prefix}': #{inspect(href)}" + end end struct!(__MODULE__, attrs) end end + @verify_manually_inline_link %{ + text: "verify your installation manually", + href: @verify_manually_url + } + @error_succeeds_only_after_cache_bust Error.new!(%{ message: "We detected an issue with your site's cache", recommendation: - "Please clear the cache for your site to ensure that your visitors will load the latest version of your site that has Plausible correctly installed", - url: - "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + "Clear the cache for your site to ensure your visitors load the latest version of your site with Plausible correctly installed. Learn more", + inline_links: [ + %{ + text: "Learn more", + href: + "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + } + ] }) @spec interpret(t(), String.t(), String.t()) :: Result.t() @@ -81,7 +102,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ) when response_status in [200, 202] and domain == expected_domain, - do: handled_error(@error_succeeds_only_after_cache_bust) + do: named_result!(:succeeds_only_after_cache_bust) def interpret( %__MODULE__{ @@ -98,7 +119,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ) when response_status in [200, 202] and domain == expected_domain, - do: success() + do: named_result!(:success) def interpret( %__MODULE__{ @@ -116,22 +137,25 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ) when response_status in [200, 202] and domain != expected_domain do - error_unexpected_domain(selected_installation_type) - |> handled_error() + named_result!(:unexpected_domain, installation_type: selected_installation_type) end @error_proxy_network_error Error.new!(%{ - message: - "We got an unexpected response from the proxy you are using for Plausible", + message: "We couldn't verify your proxied installation", recommendation: - "Please check that you've configured the proxied /event route correctly", - url: "https://plausible.io/docs/proxy/introduction" + "We received an unexpected response from your proxy. Check that you've configured the proxied /event route correctly. Learn more", + inline_links: [ + %{ + text: "Learn more", + href: "https://plausible.io/docs/proxy/introduction" + } + ] }) @error_plausible_network_error Error.new!(%{ message: "We couldn't verify your website", recommendation: "Please try verifying again in a few minutes, or verify your installation manually", - url: @verify_manually_url + inline_links: [@verify_manually_inline_link] }) def interpret( @@ -149,9 +173,9 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do proxying? = not String.starts_with?(request_url, PlausibleWeb.Endpoint.url()) if proxying? do - handled_error(@error_proxy_network_error) + named_result!(:proxy_network_error) else - handled_error(@error_plausible_network_error) + named_result!(:plausible_network_error) end end @@ -168,17 +192,21 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ) when plausible_is_on_window != true and plausible_is_initialized != true do - error_plausible_not_found("manual") - |> handled_error() + named_result!(:plausible_not_found, installation_type: "manual") end @error_csp_disallowed Error.new!(%{ message: - "We encountered an issue with your site's Content Security Policy (CSP)", + "Your site's Content Security Policy (CSP) is blocking Plausible", recommendation: - "Please add plausible.io domain specifically to the allowed list of domains in your site's CSP", - url: - "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + "Add plausible.io to the list of allowed domains in your site's Content Security Policy to allow Plausible to collect analytics. Learn more", + inline_links: [ + %{ + text: "Learn more", + href: + "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + } + ] }) def interpret( %__MODULE__{ @@ -188,30 +216,27 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do _expected_domain, _url ), - do: handled_error(@error_csp_disallowed) + do: named_result!(:csp_disallowed) @error_domain_not_found Error.new!(%{ - message: "We couldn't find your website at <%= @attempted_url %>", + message: "We couldn't reach <%= @attempted_url %>", recommendation: - "Please check that the domain you entered is correct and reachable publicly. If it's intentionally private, you'll need to verify that Plausible works manually", - url: @verify_manually_url + "Check that the URL is correct and publicly accessible. If your site is intentionally private, you'll need to verify your installation manually", + inline_links: [@verify_manually_inline_link] }) def interpret(%__MODULE__{service_error: %{code: code}}, expected_domain, url) when code in [:domain_not_found, :invalid_url] do attempted_url = if url, do: url, else: "https://#{expected_domain}" - @error_domain_not_found - |> handled_error(attempted_url: attempted_url) - |> struct!(data: %{offer_custom_url_input: true}) + named_result!(:domain_not_found, attempted_url: attempted_url) end @error_browserless_network Error.new!(%{ - message: - "We couldn't verify your website at <%= @attempted_url %>", + message: "We couldn't verify <%= @attempted_url %>", recommendation: - "Accessing the website resulted in a network error. Please verify your installation manually", - url: @verify_manually_url + "We encountered a network error while trying to access your website. You can verify your installation manually", + inline_links: [@verify_manually_inline_link] }) def interpret( @@ -222,30 +247,26 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do when is_binary(url) do attempted_url = shorten_url(url) - @error_browserless_network - |> handled_error(attempted_url: attempted_url) - |> struct!(data: %{offer_custom_url_input: true}) + named_result!(:browserless_network_error, attempted_url: attempted_url) end @error_browserless_temporary Error.new!(%{ - message: - "Our verification tool encountered a temporary service error", + message: "Our verification service is temporarily unavailable", recommendation: "Please try again in a few minutes or verify your installation manually", - url: @verify_manually_url + inline_links: [@verify_manually_inline_link] }) def interpret(%__MODULE__{service_error: %{code: code}}, _expected_domain, _url) when code in [:bad_browserless_response, :browserless_timeout, :internal_check_timeout] do - unhandled_error(@error_browserless_temporary, browserless_issue: true) + named_result!(:browserless_temporary) end @error_unexpected_page_response Error.new!(%{ - message: - "We couldn't verify your website at <%= @attempted_url %>", + message: "We couldn't verify <%= @attempted_url %>", recommendation: - "Accessing the website resulted in an unexpected status code <%= @page_response_status %>. Please check for anything that might be blocking us from reaching your site, like a firewall, authentication requirements, or CDN rules. If you'd prefer, you can skip this and verify your installation manually", - url: @verify_manually_url + "Accessing your website returned an unexpected status code (<%= @page_response_status %>). Check for anything that might be blocking our access to your site, such as a firewall, authentication requirements, or CDN rules. You can also verify your installation manually", + inline_links: [@verify_manually_inline_link] }) def interpret( @@ -263,9 +284,10 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do plausible_is_initialized != true do attempted_url = shorten_url(url) - @error_unexpected_page_response - |> handled_error(attempted_url: attempted_url, page_response_status: page_response_status) - |> struct!(data: %{offer_custom_url_input: true}) + named_result!(:unexpected_page_response, + attempted_url: attempted_url, + page_response_status: page_response_status + ) end def interpret( @@ -277,39 +299,39 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do _expected_domain, _url ) do - error_plausible_not_found(selected_installation_type) - |> handled_error() + named_result!(:plausible_not_found, installation_type: selected_installation_type) end def interpret(%__MODULE__{} = diagnostics, _expected_domain, _url) do - error_plausible_not_found(diagnostics.selected_installation_type) - |> unhandled_error() + named_result!(:plausible_not_found_unhandled, + installation_type: diagnostics.selected_installation_type + ) end @message_plausible_not_found "We couldn't detect Plausible on your site" @error_plausible_not_found_for_manual Error.new!(%{ message: @message_plausible_not_found, recommendation: - "Please make sure you've copied the snippet to the head of your site, or verify your installation manually", - url: @verify_manually_url + "Make sure you've copied the snippet to the head of your site, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_plausible_not_found_for_npm Error.new!(%{ message: @message_plausible_not_found, recommendation: - "Please make sure you've initialized Plausible on your site, or verify your installation manually", - url: @verify_manually_url + "Make sure you've initialized Plausible on your site, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_plausible_not_found_for_gtm Error.new!(%{ message: @message_plausible_not_found, recommendation: - "Please make sure you've configured the GTM template correctly, or verify your installation manually", - url: @verify_manually_url + "Make sure you've configured the GTM template correctly, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_plausible_not_found_for_wordpress Error.new!(%{ message: @message_plausible_not_found, recommendation: - "Please make sure you've enabled the plugin, or verify your installation manually", - url: @verify_manually_url + "Make sure you've enabled the WordPress plugin, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) defp error_plausible_not_found(selected_installation_type) do case selected_installation_type do @@ -320,33 +342,33 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do end end - @unexpected_domain_message "Plausible test event is not for this site" + @unexpected_domain_message "Your Plausible snippet is configured for a different domain" @error_unexpected_domain_for_manual Error.new!(%{ message: @unexpected_domain_message, recommendation: - "Please check that the snippet on your site matches the installation instructions exactly, or verify your installation manually", - url: @verify_manually_url + "Check that the snippet on your site matches the one shown in the installation instructions, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_unexpected_domain_for_npm Error.new!(%{ message: @unexpected_domain_message, recommendation: - "Please check that you've initialized Plausible with the correct domain, or verify your installation manually", - url: @verify_manually_url + "Check you've initialized Plausible with the correct domain, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_unexpected_domain_for_gtm Error.new!(%{ message: @unexpected_domain_message, recommendation: - "Please check that you've entered the ID in the GTM template correctly, or verify your installation manually", - url: @verify_manually_url + "Check you've entered the ID in the GTM template correctly, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) @error_unexpected_domain_for_wordpress Error.new!(%{ message: @unexpected_domain_message, recommendation: - "Please check that you've installed the WordPress plugin correctly, or verify your installation manually", - url: @verify_manually_url + "Check you've installed the WordPress plugin correctly, or verify your installation manually", + inline_links: [@verify_manually_inline_link] }) defp error_unexpected_domain(selected_installation_type) do case selected_installation_type do @@ -372,7 +394,7 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do %Result{ ok?: false, errors: [message], - recommendations: [%{text: recommendation, url: error.url}] + recommendations: [%{text: recommendation, inline_links: error.inline_links}] } end @@ -383,7 +405,90 @@ defmodule Plausible.InstallationSupport.Verification.Diagnostics do ok?: false, data: %{unhandled: true, browserless_issue: browserless_issue}, errors: [error.message], - recommendations: [%{text: error.recommendation, url: error.url}] + recommendations: [%{text: error.recommendation, inline_links: error.inline_links}] + } + end + + # Every result `interpret/3` can produce is named here, so that verification + # can be mocked (see `Plausible.InstallationSupport.Verification.ChecksMock`) + # by referring to the exact same result-construction code `interpret/3` + # itself uses - a scenario name can never silently drift from what real + # verification would have interpreted. + @spec named_results() :: %{atom() => (Keyword.t() -> Result.t())} + defp named_results do + %{ + success: fn _assigns -> success() end, + succeeds_only_after_cache_bust: fn _assigns -> + handled_error(@error_succeeds_only_after_cache_bust) + end, + csp_disallowed: fn _assigns -> handled_error(@error_csp_disallowed) end, + proxy_network_error: fn _assigns -> handled_error(@error_proxy_network_error) end, + plausible_network_error: fn _assigns -> handled_error(@error_plausible_network_error) end, + browserless_temporary: fn _assigns -> + unhandled_error(@error_browserless_temporary, browserless_issue: true) + end, + unexpected_domain: fn assigns -> + assigns + |> Keyword.fetch!(:installation_type) + |> error_unexpected_domain() + |> handled_error() + end, + plausible_not_found: fn assigns -> + assigns + |> Keyword.fetch!(:installation_type) + |> error_plausible_not_found() + |> handled_error() + end, + plausible_not_found_unhandled: fn assigns -> + assigns + |> Keyword.fetch!(:installation_type) + |> error_plausible_not_found() + |> unhandled_error() + end, + domain_not_found: fn assigns -> + @error_domain_not_found + |> handled_error(attempted_url: Keyword.fetch!(assigns, :attempted_url)) + |> struct!(data: %{offer_custom_url_input: true}) + end, + browserless_network_error: fn assigns -> + @error_browserless_network + |> handled_error(attempted_url: Keyword.fetch!(assigns, :attempted_url)) + |> struct!(data: %{offer_custom_url_input: true}) + end, + unexpected_page_response: fn assigns -> + @error_unexpected_page_response + |> handled_error( + attempted_url: Keyword.fetch!(assigns, :attempted_url), + page_response_status: Keyword.fetch!(assigns, :page_response_status) + ) + |> struct!(data: %{offer_custom_url_input: true}) + end } end + + @doc """ + Looks up a named interpretation result, optionally built from the given + assigns (e.g. `attempted_url`, `installation_type`) - keys that don't need + any just ignore them. + """ + @spec named_result!(atom()) :: Result.t() + def named_result!(key), do: named_result!(key, []) + + @spec named_result!(atom(), Keyword.t()) :: Result.t() + def named_result!(key, assigns) do + case Map.fetch(named_results(), key) do + {:ok, build_result} -> build_result.(assigns) + :error -> raise ArgumentError, "No interpretation result named #{inspect(key)}" + end + end + + @doc "Returns every valid `named_result!/2` scenario key." + @spec named_scenario_keys() :: [atom()] + def named_scenario_keys, do: Map.keys(named_results()) + + @spec named_scenario_from_string(String.t()) :: {:ok, atom()} | :error + def named_scenario_from_string(string) when is_binary(string) do + named_scenario_keys() + |> Enum.find_value(:error, fn key -> if Atom.to_string(key) == string, do: {:ok, key} end) + end end diff --git a/extra/lib/plausible_web/live/verification.ex b/extra/lib/plausible_web/live/verification.ex index 0fc148d3a2b0..80b95de3f05c 100644 --- a/extra/lib/plausible_web/live/verification.ex +++ b/extra/lib/plausible_web/live/verification.ex @@ -1,21 +1,22 @@ defmodule PlausibleWeb.Live.Verification do @moduledoc """ - LiveView coordinating the site verification process. - Onboarding new sites, renders a standalone component. - Embedded modal variant is available for general site settings. + LiveView coordinating the site verification process. Rendered as a banner + on top of the React dashboard. """ use PlausibleWeb, :live_view - import PlausibleWeb.Components.Generic + alias Plausible.InstallationSupport + alias Plausible.InstallationSupport.State + alias Plausible.Repo + alias Plausible.Site - alias Plausible.InstallationSupport.{State, Verification} - - @component PlausibleWeb.Live.Components.Verification + @component PlausibleWeb.Live.Components.VerificationBanner @slowdown_for_frequent_checking :timer.seconds(5) + @use_portal? Mix.env() not in [:test, :ce_test] def mount( - %{"domain" => domain} = params, - _session, + _params, + %{"domain" => domain} = session, socket ) do current_user = socket.assigns.current_user @@ -36,9 +37,9 @@ defmodule PlausibleWeb.Live.Verification do private = Map.get(socket.private.connect_info, :private, %{}) super_admin? = Plausible.Auth.super_admin?(current_user) - has_pageviews? = has_pageviews?(site) - custom_url_input? = params["custom_url"] == "true" + tracker_script_configuration = + PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site) socket = assign(socket, @@ -46,52 +47,78 @@ defmodule PlausibleWeb.Live.Verification do site: site, super_admin?: super_admin?, domain: domain, - has_pageviews?: has_pageviews?, component: @component, - installation_type: get_installation_type(params, site), + tracker_script_configuration: tracker_script_configuration, report_to: self(), - delay: private[:delay] || 500, + launch_delay: private[:launch_delay] || 500, slowdown: private[:slowdown] || 500, - flow: params["flow"] || "", + flow: session["flow"] || "", checks_pid: nil, attempts: 0, - polling_pageviews?: false, - custom_url_input?: custom_url_input? + custom_url_input?: false, + dismissed?: false ) - if connected?(socket) and not custom_url_input? do - launch_delayed(socket) + if connected?(socket) do + start_verification(socket) end {:ok, socket} end def render(assigns) do + assigns = assign(assigns, :use_portal?, @use_portal?) + + ~H""" +
+ <%= if @use_portal? do %> + <.portal id="verification-portal-source" target="#verification-portal-target"> + <.verification_content {assigns} /> + + <% else %> + <.verification_content {assigns} /> + <% end %> +
+ """ + end + + defp verification_content(assigns) do ~H""" - - <.custom_url_form :if={@custom_url_input?} domain={@domain} /> <.live_component - :if={not @custom_url_input?} module={@component} - installation_type={@installation_type} domain={@domain} id="verification-standalone" attempts={@attempts} flow={@flow} - awaiting_first_pageview?={not @has_pageviews?} super_admin?={@super_admin?} + custom_url_input?={@custom_url_input?} + dismissed?={@dismissed?} /> """ end def handle_event("launch-verification", _, socket) do - launch_delayed(socket) - {:noreply, reset_component(socket)} + reset_component(socket) + start_verification(socket) + {:noreply, socket} end def handle_event("retry", _, socket) do - launch_delayed(socket) - {:noreply, reset_component(socket)} + reset_component(socket) + start_verification(socket) + {:noreply, socket} + end + + def handle_event("show-custom-url-form", _, socket) do + {:noreply, assign(socket, custom_url_input?: true)} + end + + # Once dismissed, this LiveView has nothing left to do - and since it's + # the only LiveView on the dashboard page, there's no reason to keep + # the websocket connection open for the rest of the browsing session. + def handle_event("dismiss", _, socket) do + update_component(socket, dismissed?: true) + {:noreply, socket |> assign(dismissed?: true) |> push_event("disconnect-liveview", %{})} end def handle_event("verify-custom-url", %{"custom_url" => custom_url}, socket) do @@ -100,8 +127,9 @@ defmodule PlausibleWeb.Live.Verification do |> assign(url_to_verify: custom_url) |> assign(custom_url_input?: false) - launch_delayed(socket) - {:noreply, reset_component(socket)} + reset_component(socket) + start_verification(socket) + {:noreply, socket} end def handle_info({:start, report_to}, socket) do @@ -121,12 +149,13 @@ defmodule PlausibleWeb.Live.Verification do end {:ok, pid} = - Verification.Checks.run( + InstallationSupport.verification_checks_mod().run( socket.assigns.url_to_verify, domain, - socket.assigns.installation_type, + get_installation_type(socket.assigns.tracker_script_configuration), report_to: report_to, - slowdown: socket.assigns.slowdown + slowdown: socket.assigns.slowdown, + launch_delay: socket.assigns.launch_delay ) {:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)} @@ -149,11 +178,20 @@ defmodule PlausibleWeb.Live.Verification do end def handle_info({:all_checks_done, %State{} = state}, socket) do - interpretation = Verification.Checks.interpret_diagnostics(state) + interpretation = InstallationSupport.verification_checks_mod().interpret_diagnostics(state) - if not socket.assigns.has_pageviews? do - schedule_pageviews_check(socket) - end + socket = + if interpretation.ok? do + socket.assigns.site + |> Site.put_onboarding_status_advance(:verification_succeeded) + |> Repo.update!() + + # When verification succeeds, we'll want to strip the URL params that + # trigger verification, so it doesn't kick off again on page refresh. + push_event(socket, "verification-succeeded", %{queryParams: @component.query_params()}) + else + socket + end update_component(socket, finished?: true, @@ -165,58 +203,18 @@ defmodule PlausibleWeb.Live.Verification do {:noreply, assign(socket, checks_pid: nil)} end - def handle_info(:check_pageviews, socket) do - socket = - if has_pageviews?(socket.assigns.site) do - redirect_to_stats(socket) - else - socket - |> assign(polling_pageviews?: false) - |> schedule_pageviews_check() - end - - {:noreply, socket} - end - @supported_installation_types_atoms PlausibleWeb.Tracker.supported_installation_types() |> Enum.map(&String.to_atom/1) - defp get_installation_type(params, site) 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 - end - - defp get_saved_installation_type(site) do - case PlausibleWeb.Tracker.get_tracker_script_configuration(site) do - %{installation_type: installation_type} -> - installation_type + defp get_installation_type(tracker_script_configuration) do + case tracker_script_configuration.installation_type do + type when type in @supported_installation_types_atoms -> + Atom.to_string(type) _ -> - nil - end - end - - defp schedule_pageviews_check(socket) do - if socket.assigns.polling_pageviews? do - socket - else - Process.send_after(self(), :check_pageviews, socket.assigns.delay * 2) - assign(socket, polling_pageviews?: true) + PlausibleWeb.Tracker.fallback_installation_type() end end - defp redirect_to_stats(socket) do - stats_url = Routes.stats_path(PlausibleWeb.Endpoint, :stats, socket.assigns.domain, []) - redirect(socket, to: stats_url) - end - defp reset_component(socket) do update_component(socket, message: "We're visiting your site to ensure that everything is working", @@ -235,54 +233,7 @@ defmodule PlausibleWeb.Live.Verification do ) end - defp launch_delayed(socket) do - Process.send_after(self(), {:start, socket.assigns.report_to}, socket.assigns.delay) - end - - defp has_pageviews?(site) do - Plausible.Stats.Clickhouse.has_pageviews?(site) - end - - defp custom_url_form(assigns) do - ~H""" - <.focus_box> -
- -
-
-

- Enter Your Custom URL -

-

- Please enter the URL where your website with the Plausible script is located. -

-
-
- - -
- -
-
- - """ + defp start_verification(socket) do + send(self(), {:start, socket.assigns.report_to}) end end diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 20191fc70189..86de147f3b6a 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -194,6 +194,11 @@ defmodule Plausible.Application do end, Plausible.Ingestion.Counters, Plausible.Session.Salts, + on_ee do + if Mix.env() in [:dev, :e2e_test, :test] do + Plausible.InstallationSupport.Verification.MockScenarios + end + end, Supervisor.child_spec(Plausible.Event.WriteBuffer, id: Plausible.Event.WriteBuffer), Supervisor.child_spec(Plausible.Session.WriteBuffer, id: Plausible.Session.WriteBuffer), ReferrerBlocklist, diff --git a/lib/plausible/purge.ex b/lib/plausible/purge.ex index aa072bade88a..6e4b56f159de 100644 --- a/lib/plausible/purge.ex +++ b/lib/plausible/purge.ex @@ -40,7 +40,7 @@ defmodule Plausible.Purge do Deletes imported stats from and clears the `stats_start_date` field. The `stats_start_date` is expected to get repopulated the next time - `Plausible.Sites.stats_start_date/1` is called. + `Plausible.Sites.ensure_stats_start_date/1` is called. If the input argument is a site, all imported stats are deleted. If it's a site import, only imported stats for that import are deleted. diff --git a/lib/plausible/site.ex b/lib/plausible/site.ex index f7c76f589bad..b4c5aaa2daca 100644 --- a/lib/plausible/site.ex +++ b/lib/plausible/site.ex @@ -10,6 +10,8 @@ defmodule Plausible.Site do @type t() :: %__MODULE__{} + @onboarding_statuses [:new_site, :verification_succeeded, :first_pageview, :completed] + @derive {Jason.Encoder, only: [:domain, :timezone]} schema "sites" do field :domain, :string @@ -17,6 +19,7 @@ defmodule Plausible.Site do field :public, :boolean field :stats_start_date, :date field :native_stats_start_at, :naive_datetime + field :onboarding_status, Ecto.Enum, values: @onboarding_statuses, default: :completed field :allowed_event_props, {:array, :string} field :conversions_enabled, :boolean, default: true field :props_enabled, :boolean, default: true @@ -78,7 +81,20 @@ defmodule Plausible.Site do |> put_assoc(:team, team) end - def new(params), do: changeset(%__MODULE__{}, params) + def new(params) do + changeset(%__MODULE__{}, params) + |> put_change(:onboarding_status, initial_onboarding_status(params)) + end + + # Consolidated sites never receive direct ingestion or run per-site + # verification, so they'd otherwise get stuck at :new_site forever. + defp initial_onboarding_status(params) do + if Map.get(params, :consolidated) || Map.get(params, "consolidated") do + :completed + else + :new_site + end + end on_ee do @domain_unique_error """ @@ -135,7 +151,8 @@ defmodule Plausible.Site do :public, :native_stats_start_at, :ingest_rate_limit_threshold, - :ingest_rate_limit_scale_seconds + :ingest_rate_limit_scale_seconds, + :onboarding_status ]) |> validate_required([:timezone, :public]) |> validate_number(:ingest_rate_limit_scale_seconds, @@ -178,6 +195,32 @@ defmodule Plausible.Site do change(site, native_stats_start_at: val) end + def onboarding_statuses, do: @onboarding_statuses + + @doc """ + Advances `onboarding_status` to `new_status`, unless the site is already at + or past that point in the `onboarding_statuses/0` progression - onboarding + status only ever moves forward, though a transition can skip an + intermediate value. Composable with other changes to the same changeset + (accepts either a site or an existing changeset). + """ + def put_onboarding_status_advance(site_or_changeset, new_status) do + current_status = + case site_or_changeset do + %Ecto.Changeset{} = changeset -> changeset.data.onboarding_status + %__MODULE__{} = site -> site.onboarding_status + end + + new_index = Enum.find_index(@onboarding_statuses, &(&1 == new_status)) + current_index = Enum.find_index(@onboarding_statuses, &(&1 == current_status)) + + if new_index > current_index do + change(site_or_changeset, onboarding_status: new_status) + else + change(site_or_changeset) + end + end + defp clean_domain(changeset) do clean_domain = (get_field(changeset, :domain) || "") diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index 3b9e16dd8b84..d936a883a802 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -366,36 +366,38 @@ defmodule Plausible.Sites do |> Plausible.Repo.update!() end - @spec stats_start_date(Site.t()) :: Date.t() | nil + @spec ensure_stats_start_date(Site.t()) :: Site.t() @doc """ - Returns the date of the first event of the given site, or `nil` if the site - does not have stats yet. + Ensures `stats_start_date` is set on the given site, returning the + (possibly updated) site. `stats_start_date` stays `nil` if the site does + not have stats yet. If this is the first time the function is called for the site, it queries imported stats and Clickhouse, choosing the earliest start date and saves - it in the sites table. + it in the sites table - at the same time advancing `onboarding_status` to + `:first_pageview`, so callers see that reflected immediately rather than + needing to reload the site to notice it. """ - def stats_start_date(site) + def ensure_stats_start_date(site) on_ee do # for now, we're going to always update consolidated views, # though Repo.update! runs the actual update query only when # the value has changed - def stats_start_date(%Site{consolidated: true} = site) do + def ensure_stats_start_date(%Site{consolidated: true} = site) do team = Repo.preload(site, :team).team site |> Plausible.ConsolidatedView.change_stats_dates(team) |> Repo.update!() - |> Map.fetch!(:stats_start_date) end end - def stats_start_date(%Site{stats_start_date: %Date{} = date}) do - date + def ensure_stats_start_date(%Site{stats_start_date: %Date{}} = site) do + site end - def stats_start_date(%Site{} = site) do + def ensure_stats_start_date(%Site{} = site) do start_date = [ Plausible.Imported.earliest_import_start_date(site), @@ -405,12 +407,12 @@ defmodule Plausible.Sites do |> Enum.min(Date, fn -> nil end) if start_date do - updated_site = - site - |> Site.set_stats_start_date(start_date) - |> Repo.update!() - - updated_site.stats_start_date + site + |> Site.set_stats_start_date(start_date) + |> Site.put_onboarding_status_advance(:first_pageview) + |> Repo.update!() + else + site end end @@ -420,7 +422,7 @@ defmodule Plausible.Sites do end def has_stats?(site) do - !!stats_start_date(site) + !!ensure_stats_start_date(site).stats_start_date end def create_shared_link(site, name, opts \\ []) do diff --git a/lib/plausible/stats/clickhouse.ex b/lib/plausible/stats/clickhouse.ex index 375c9e9f47fc..fadd473f867d 100644 --- a/lib/plausible/stats/clickhouse.ex +++ b/lib/plausible/stats/clickhouse.ex @@ -191,20 +191,4 @@ defmodule Plausible.Stats.Clickhouse do def current_visitors_12h(site) do Stats.current_visitors(site, Duration.new!(hour: -12)) end - - def has_pageviews?(site) do - # This function is currently only used in installation verification - # which is not accessible for consolidated views. - true = Plausible.Sites.regular?(site) - - ClickhouseRepo.exists?( - from(e in "events_v2", - where: - e.site_id == ^site.id and - e.name == "pageview" and - e.timestamp >= - ^site.native_stats_start_at - ) - ) - end end diff --git a/lib/plausible/stats/legacy/legacy_query_builder.ex b/lib/plausible/stats/legacy/legacy_query_builder.ex index f942d27d1bcd..e79e3c4f7340 100644 --- a/lib/plausible/stats/legacy/legacy_query_builder.ex +++ b/lib/plausible/stats/legacy/legacy_query_builder.ex @@ -195,7 +195,7 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do defp put_input_date_range(query, site, %{"period" => "all"}) do today = today(query) - start_date = Plausible.Sites.stats_start_date(site) || today + start_date = Plausible.Sites.ensure_stats_start_date(site).stats_start_date || today datetime_range = DateTimeRange.new!(start_date, today, site.timezone) |> DateTimeRange.to_timezone("Etc/UTC") diff --git a/lib/plausible/stats/query_period.ex b/lib/plausible/stats/query_period.ex index 4b06b10ba5ab..6becdc0bd78d 100644 --- a/lib/plausible/stats/query_period.ex +++ b/lib/plausible/stats/query_period.ex @@ -61,7 +61,7 @@ defmodule Plausible.Stats.QueryPeriod do from a timezone alone. Other shapes pass through unchanged. """ def resolve_input_date_range(:all, %Plausible.Site{} = site, relative_date) do - start_date = Plausible.Sites.stats_start_date(site) || relative_date + start_date = Plausible.Sites.ensure_stats_start_date(site).stats_start_date || relative_date {:date_range, start_date, relative_date} end diff --git a/lib/plausible_web/components/first_dashboard_launch_banner.ex b/lib/plausible_web/components/first_dashboard_launch_banner.ex deleted file mode 100644 index 7646368a6fdd..000000000000 --- a/lib/plausible_web/components/first_dashboard_launch_banner.ex +++ /dev/null @@ -1,52 +0,0 @@ -defmodule PlausibleWeb.Components.FirstDashboardLaunchBanner do - @moduledoc """ - A banner that appears on the first dashboard launch - """ - - use PlausibleWeb, :component - - attr(:site, Plausible.Site, required: true) - - def set(assigns) do - ~H""" - - """ - end - - attr(:site, Plausible.Site, required: true) - - def render(assigns) do - ~H""" - - """ - end - - defp x_data(site) do - "{show: !!sessionStorage.getItem('#{storage_key(site)}')}" - end - - defp x_init(site) do - "setTimeout(() => sessionStorage.removeItem('#{storage_key(site)}'), 3000)" - end - - defp storage_key(site) do - "dashboard_seen_#{site.domain}" - end -end diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index ffdf5277c06a..173e6a6a6f88 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -12,25 +12,37 @@ defmodule PlausibleWeb.Components.Generic do gray: %{ bg: "bg-gray-100 dark:bg-gray-800", icon: "text-gray-600 dark:text-gray-300", - title_text: "text-sm text-gray-900 dark:text-gray-100", + title_text: "text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-800 dark:text-gray-200 leading-5" }, + indigo: %{ + bg: "bg-indigo-100/60 dark:bg-indigo-900/40", + icon: "text-indigo-500", + title_text: "text-gray-900 dark:text-gray-100", + body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" + }, + green: %{ + bg: "bg-green-100/60 dark:bg-green-900/40", + icon: "text-green-500", + title_text: "text-gray-900 dark:text-gray-100", + body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" + }, yellow: %{ bg: "bg-yellow-100/60 dark:bg-yellow-900/40", icon: "text-yellow-500", - title_text: "text-sm text-gray-900 dark:text-gray-100", + title_text: "text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" }, red: %{ bg: "bg-red-100 dark:bg-red-900/30", icon: "text-red-600 dark:text-red-500", - title_text: "text-sm text-gray-900 dark:text-gray-100", + title_text: "text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-600 dark:text-gray-100/60 leading-5" }, white: %{ bg: "bg-white dark:bg-gray-900 shadow-sm dark:shadow-none", icon: "text-gray-600 dark:text-gray-400", - title_text: "text-sm text-gray-900 dark:text-gray-100", + title_text: "text-gray-900 dark:text-gray-100", body_text: "text-sm text-gray-600 dark:text-gray-300 leading-5" } } @@ -222,6 +234,7 @@ defmodule PlausibleWeb.Components.Generic do attr(:show_icon, :boolean, default: true) attr(:class, :string, default: "") attr(:icon_class, :string, default: "") + attr(:title_class, :string, default: "") attr(:rest, :global) slot(:inner_block) slot(:actions) @@ -253,7 +266,14 @@ defmodule PlausibleWeb.Components.Generic do <% end %>
-

+

{@title}

@@ -850,6 +870,38 @@ defmodule PlausibleWeb.Components.Generic do """ end + attr :id, :string, required: true + attr :text, :string, required: true + attr :rows, :integer, required: true + attr :resizable, :boolean, default: false + + def copyable_readonly_text_area(assigns) do + ~H""" + + """ + end + slot :title slot :subtitle slot :inner_block, required: true diff --git a/lib/plausible_web/controllers/api/internal_controller.ex b/lib/plausible_web/controllers/api/internal_controller.ex index 821fe58e58b6..05032e951d92 100644 --- a/lib/plausible_web/controllers/api/internal_controller.ex +++ b/lib/plausible_web/controllers/api/internal_controller.ex @@ -1,4 +1,5 @@ defmodule PlausibleWeb.Api.InternalController do + use Plausible use PlausibleWeb, :controller use Plausible.Repo import Ecto.Query @@ -57,13 +58,34 @@ defmodule PlausibleWeb.Api.InternalController do end end + def complete_onboarding(conn, %{"domain" => domain}) do + with %User{} = user <- conn.assigns[:current_user], + site <- Sites.get_by_domain(domain), + true <- Teams.Memberships.has_editor_access?(site, user) do + site + |> Plausible.Site.put_onboarding_status_advance(:completed) + |> Repo.update!() + + json(conn, "ok") + else + _ -> + PlausibleWeb.Api.Helpers.unauthorized( + conn, + "You need to be logged in as the owner, admin, or editor of this site" + ) + end + end + defp sites_for(user, team) do from(u in subquery(Teams.Sites.accessible_by(user, team)), inner_join: s in ^Plausible.Site.regular(), on: u.site_id == s.id, left_join: up in Plausible.Site.UserPreference, on: up.site_id == s.id and up.user_id == ^user.id, - select: %{domain: s.domain}, + select: %{ + domain: s.domain, + needs_verification: ^ee?() and s.onboarding_status == :new_site + }, order_by: [ asc: fragment( diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex index 7413111898eb..791105cdab43 100644 --- a/lib/plausible_web/controllers/site_controller.ex +++ b/lib/plausible_web/controllers/site_controller.ex @@ -50,11 +50,7 @@ defmodule PlausibleWeb.SiteController do end redirect(conn, - to: - Routes.site_path(conn, :installation, site.domain, - site_created: true, - flow: flow - ) + to: Routes.site_path(conn, :installation, site.domain, flow: flow) ) {:error, _, :permission_denied, _} -> @@ -169,9 +165,15 @@ defmodule PlausibleWeb.SiteController do ) end - def settings_email_reports(conn, _params) do + def settings_email_reports(conn, params) do site = conn.assigns[:site] + if params["cta_clicked"] == "true" do + site + |> Plausible.Site.put_onboarding_status_advance(:completed) + |> Repo.update!() + end + conn |> render("settings_email_reports.html", site: site, diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index 38e9169f66f9..3ff0d0125c4c 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -47,10 +47,14 @@ defmodule PlausibleWeb.StatsController do plug(PlausibleWeb.Plugs.AuthorizeSiteAccess when action in [:stats]) def stats(%{assigns: %{site: site}} = conn, _params) do - site = Plausible.Repo.preload(site, :owners) + site = + site + |> Plausible.Repo.preload(:owners) + |> Plausible.Sites.ensure_stats_start_date() + site_role = conn.assigns[:site_role] current_user = conn.assigns[:current_user] - stats_start_date = Plausible.Sites.stats_start_date(site) + stats_start_date = site.stats_start_date can_see_stats? = not Teams.locked?(site.team) or site_role == :super_admin demo = site.domain == "plausible.io" dogfood_page_path = if demo, do: "/#{site.domain}", else: "/:dashboard" @@ -70,9 +74,6 @@ defmodule PlausibleWeb.StatsController do team_identifier = site.team.identifier - skip_to_dashboard? = - conn.params["skip_to_dashboard"] == "true" or consolidated_view? - {:ok, segments} = Plausible.Segments.get_all_for_site(site, site_role) segments = Enum.map(segments, &Plausible.Segments.to_response_map(&1, site)) @@ -80,9 +81,28 @@ defmodule PlausibleWeb.StatsController do consolidated_view? and not consolidated_view_available? and site_role != :super_admin -> redirect(conn, to: Routes.site_path(conn, :index)) - (stats_start_date && can_see_stats?) || (can_see_stats? && skip_to_dashboard?) -> + not can_see_stats? -> + site = Plausible.Repo.preload(site, :owners) + render(conn, "site_locked.html", site: site, dogfood_page_path: dogfood_page_path) + + true -> flags = get_flags(current_user, site) + verify_installation? = + ee?() and + not is_nil(current_user) and + not consolidated_view? and + conn.params["verify_installation"] == "true" and + (conn.params["flow"] in [ + PlausibleWeb.Flows.review(), + PlausibleWeb.Flows.domain_change() + ] or site.onboarding_status == :new_site) + + show_email_reports_cta? = + not consolidated_view? and + site_role in [:owner, :admin, :editor] and + site.onboarding_status == :first_pageview + conn |> put_resp_header("x-robots-tag", "noindex, nofollow") |> render("stats.html", @@ -106,15 +126,15 @@ defmodule PlausibleWeb.StatsController do exploration_journey_end_event: exploration_journey_end_event, exploration_max_journey_steps: exploration_max_journey_steps, team_identifier: team_identifier, - limited_to_segment_id: nil + limited_to_segment_id: nil, + connect_live_socket: verify_installation?, + verify_installation?: verify_installation?, + show_email_reports_cta?: show_email_reports_cta?, + verification_session: + PlausibleWeb.Live.Components.VerificationBanner.query_params() + |> Map.new(&{&1, conn.params[&1]}) + |> Map.put("domain", site.domain) ) - - !stats_start_date && can_see_stats? -> - redirect(conn, to: Routes.site_path(conn, :verification, site.domain)) - - Teams.locked?(site.team) -> - site = Plausible.Repo.preload(site, :owners) - render(conn, "site_locked.html", site: site, dogfood_page_path: dogfood_page_path) end end @@ -293,12 +313,10 @@ defmodule PlausibleWeb.StatsController do defp serialize_star_path_as_query_string_fragment(conn) do star_path = conn.path_params["path"] - if length(star_path) > 0 do + if star_path != [] do # make the path start with a / # to be able to reject values that don't start with a / %{"return_to" => "/#{Enum.join(star_path, "/")}"} |> URI.encode_query() - else - nil end end @@ -358,7 +376,13 @@ defmodule PlausibleWeb.StatsController do current_user = conn.assigns[:current_user] site_role = get_fallback_site_role(conn) shared_link = Plausible.Repo.preload(shared_link, :segment, site: [:owners]) - stats_start_date = Plausible.Sites.stats_start_date(shared_link.site) + + shared_link = %{ + shared_link + | site: Plausible.Sites.ensure_stats_start_date(shared_link.site) + } + + stats_start_date = shared_link.site.stats_start_date flags = get_flags(current_user, shared_link.site) @@ -422,7 +446,10 @@ defmodule PlausibleWeb.StatsController do exploration_journey_end_event: exploration_journey_end_event, exploration_max_journey_steps: exploration_max_journey_steps, team_identifier: team_identifier, - limited_to_segment_id: limited_to_segment_id + limited_to_segment_id: limited_to_segment_id, + verify_installation?: false, + show_email_reports_cta?: false, + verification_session: %{} ) end end diff --git a/lib/plausible_web/live/awaiting_pageviews.ex b/lib/plausible_web/live/awaiting_pageviews.ex deleted file mode 100644 index 29c4f3e731d4..000000000000 --- a/lib/plausible_web/live/awaiting_pageviews.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule PlausibleWeb.Live.AwaitingPageviews do - @moduledoc """ - A replacement for installation verification on Community Edition. - """ - use PlausibleWeb, :live_view - - import PlausibleWeb.Components.Generic - - def mount( - %{"domain" => domain} = params, - _session, - socket - ) do - current_user = socket.assigns.current_user - - site = - Plausible.Sites.get_for_user!(current_user, domain, - roles: [ - :owner, - :admin, - :editor, - :super_admin, - :viewer - ] - ) - - private = Map.get(socket.private.connect_info, :private, %{}) - - has_pageviews? = has_pageviews?(site) - - socket = - assign(socket, - site: site, - domain: domain, - has_pageviews?: has_pageviews?, - delay: private[:delay] || 500, - flow: params["flow"] || "", - polling_pageviews?: false - ) - - socket = - if has_pageviews? do - redirect_to_stats(socket) - else - schedule_pageviews_check(socket) - end - - {:ok, socket} - end - - def render(assigns) do - ~H""" - - <.awaiting_pageviews /> - """ - end - - defp awaiting_pageviews(assigns) do - ~H""" - <.focus_box> -
-
-

Awaiting your first pageview …

-
- - """ - end - - def handle_info(:check_pageviews, socket) do - socket = - if has_pageviews?(socket.assigns.site) do - redirect_to_stats(socket) - else - socket - |> assign(polling_pageviews?: false) - |> schedule_pageviews_check() - end - - {:noreply, socket} - end - - defp schedule_pageviews_check(socket) do - if socket.assigns.polling_pageviews? do - socket - else - Process.send_after(self(), :check_pageviews, socket.assigns.delay * 2) - assign(socket, polling_pageviews?: true) - end - end - - defp redirect_to_stats(socket) do - stats_url = Routes.stats_path(PlausibleWeb.Endpoint, :stats, socket.assigns.domain, []) - redirect(socket, to: stats_url) - end - - defp has_pageviews?(site) do - Plausible.Stats.Clickhouse.has_pageviews?(site) - end -end diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex deleted file mode 100644 index ef8113d0a0a8..000000000000 --- a/lib/plausible_web/live/components/verification.ex +++ /dev/null @@ -1,205 +0,0 @@ -defmodule PlausibleWeb.Live.Components.Verification do - @moduledoc """ - This component is responsible for rendering the verification progress - and diagnostics. - """ - use Phoenix.LiveComponent - use Plausible - - alias PlausibleWeb.Router.Helpers, as: Routes - alias Plausible.InstallationSupport.{State, Result} - - import PlausibleWeb.Components.Generic - - attr(:domain, :string, required: true) - - attr(:message, :string, - default: "We're visiting your site to ensure that everything is working" - ) - - attr(:super_admin?, :boolean, default: false) - attr(:finished?, :boolean, default: false) - attr(:success?, :boolean, default: false) - attr(:verification_state, State, default: nil) - attr(:interpretation, Result, default: nil) - attr(:attempts, :integer, default: 0) - attr(:flow, :string, default: "") - attr(:installation_type, :string, default: nil) - attr(:awaiting_first_pageview?, :boolean, default: false) - - def render(assigns) do - ~H""" -
- <.render_progress :if={not @finished?} message={@message} /> - <.render_success - :if={@finished? and @success?} - awaiting_first_pageview?={@awaiting_first_pageview?} - domain={@domain} - /> - <.render_failed - :if={@finished? and not @success?} - interpretation={@interpretation} - attempts={@attempts} - domain={@domain} - flow={@flow} - installation_type={@installation_type} - /> - <.render_super_admin_diagnostics - :if={not is_nil(@verification_state) && @super_admin? && @finished?} - verification_state={@verification_state} - /> -
- """ - end - - defp render_progress(assigns) do - ~H""" - <.focus_box> -
-
-
-
- <.title>Verifying your installation -

{@message}

-
- - """ - end - - defp render_success(assigns) do - ~H""" - <.focus_box> -
- -
- -
- <.title>Success! -

- Your installation is working and visitors are being counted accurately. - - Awaiting your first pageview... - -

-
- <.button_link - href={"/#{URI.encode_www_form(@domain)}?skip_to_dashboard=true"} - class="w-full font-bold mb-4" - > - Go to the dashboard - - - """ - end - - defp render_failed(assigns) do - ~H""" - <.focus_box> -
- -
- -
- <.title>{List.first(@interpretation.errors)} -

- {List.first(@interpretation.recommendations).text}.  - <.styled_link href={List.first(@interpretation.recommendations).url} new_tab={true}> - Learn more - -

-
- -
- <.button_link mt?={false} href="#" phx-click="retry" class="w-full"> - Verify installation again - -
- <:footer> - <.focus_list> - <:item :if={ - @interpretation && is_map(@interpretation.data) && - @interpretation.data[:offer_custom_url_input] - }> - - Is your website located at a different URL? - <.styled_link href={ - Routes.site_path(PlausibleWeb.Endpoint, :verification, @domain, - flow: @flow, - installation_type: @installation_type, - custom_url: true - ) - }> - Click here - - - - <:item :if={ee?() and @attempts >= 3}> - Need further help with your installation? - <.styled_link href="https://plausible.io/contact"> - Contact us - - - <:item> - Need to see installation instructions again? - <.styled_link href={ - Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, - flow: @flow, - installation_type: @installation_type - ) - }> - Click here - - - <:item> - Run verification later and go to site settings? - <.styled_link href={"/#{URI.encode_www_form(@domain)}/settings/general"}> - Click here - - - - - - """ - end - - defp render_super_admin_diagnostics(assigns) do - ~H""" - <.focus_box> -
-

- - As a super-admin, you're eligible to see diagnostics details. Click to expand. - -

-
- <.focus_list> - <:item :for={{diag, value} <- Map.from_struct(@verification_state.diagnostics)}> - - {Phoenix.Naming.humanize(diag)}: - {to_string_value(value)} - - - -
-
- - """ - end - - defp to_string_value(value) when is_binary(value), do: value - defp to_string_value(value), do: inspect(value) -end diff --git a/lib/plausible_web/live/components/verification_banner.ex b/lib/plausible_web/live/components/verification_banner.ex new file mode 100644 index 000000000000..dfb09f50bc77 --- /dev/null +++ b/lib/plausible_web/live/components/verification_banner.ex @@ -0,0 +1,349 @@ +defmodule PlausibleWeb.Live.Components.VerificationBanner do + @moduledoc """ + This component is responsible for rendering the verification progress + and diagnostics as a compact banner on top of the dashboard. + """ + use Phoenix.LiveComponent + use Plausible + + alias PlausibleWeb.Router.Helpers, as: Routes + alias Plausible.InstallationSupport.{State, Result} + + import PlausibleWeb.Components.Generic + import PlausibleWeb.Live.Components.Form + + @container_id "verification-ui" + + # All query params the verification LiveView needs must be listed here, so + # they can be cleaned up from the URL once verification finishes. + @query_params ~w(verify_installation flow) + def query_params, do: @query_params + + attr(:domain, :string, required: true) + + attr(:message, :string, + default: "We're visiting your site to ensure that everything is working" + ) + + attr(:super_admin?, :boolean, default: false) + attr(:finished?, :boolean, default: false) + attr(:success?, :boolean, default: false) + attr(:verification_state, State, default: nil) + attr(:interpretation, Result, default: nil) + attr(:attempts, :integer, default: 0) + attr(:flow, :string, default: "") + attr(:custom_url_input?, :boolean, default: false) + attr(:dismissed?, :boolean, default: false) + + def render(assigns) do + assigns = + assigns + |> assign(:container_id, @container_id) + |> assign(:query_params, @query_params) + + ~H""" +
+ <.dismiss_button container_id={@container_id} query_params={@query_params} /> + <.render_progress :if={not @finished?} message={@message} /> + <.render_success + :if={@finished? and @success?} + domain={@domain} + flow={@flow} + super_admin?={@super_admin?} + verification_state={@verification_state} + /> + <.render_failed + :if={@finished? and not @success?} + interpretation={@interpretation} + attempts={@attempts} + domain={@domain} + flow={@flow} + super_admin?={@super_admin?} + verification_state={@verification_state} + custom_url_input?={@custom_url_input?} + /> +
+ """ + end + + # The action of dismissing the verification banner consists of 4 + # independent things: + # + # 1. Client-side: the inlined `onclick` instantly adds the `hidden` + # class straight to the container div. + # + # 2. Client-side: instantly dispatches a `verification-finished` + # window event so React router can clean up query params that are + # no longer needed (see assets/js/dashboard/verification/portal.tsx). + # Also makes sure that a refresh won't bring verification back. + # + # 3. Server-side (phx-click="dismiss"): sets `dismissed?` on this + # component's assigns, so it stays hidden even if a later + # `send_update` (e.g. :all_checks_done) re-renders it. + # + # 4. Server-side, same handler: tells the client to close the websocket + # connection, since the LiveView has nothing left to do. + defp dismiss_button(assigns) do + ~H""" + + """ + end + + defp dismiss_onclick(container_id, query_params) do + "document.getElementById('#{container_id}').classList.add('hidden');" <> + "window.dispatchEvent(new CustomEvent('verification-finished', { detail: { queryParams: #{Jason.encode!(query_params)} } }));" + end + + defp render_progress(assigns) do + ~H""" + <.notice + title="Verifying your installation" + theme={:indigo} + title_class="text-base font-semibold text-gray-900 dark:text-gray-100" + > + <:icon> + <.spinner class="mt-0.5 size-4.5" /> + +

+ {@message}... +

+ + """ + end + + defp render_success(assigns) do + assigns = assign(assigns, :success_message, success_message(assigns.flow)) + + ~H""" + <.notice + title="Tracking is active on your site" + theme={:green} + title_class="text-base font-semibold text-green-800 dark:text-green-300" + icon_class="!mt-0.5 size-5 text-green-800 dark:text-green-300" + > + <:icon> + + +

+ {@success_message} +

+ <.super_admin_diagnostics + :if={@super_admin? and not is_nil(@verification_state)} + verification_state={@verification_state} + /> + + """ + end + + defp render_failed(assigns) do + assigns = + assign( + assigns, + :offer_custom_url_input?, + offer_custom_url_input?(assigns.interpretation) + ) + + ~H""" + <.notice + title={ + if @interpretation, + do: List.first(@interpretation.errors), + else: "We couldn't verify your installation" + } + theme={:yellow} + show_icon={false} + title_class="text-base font-semibold text-yellow-800 dark:text-yellow-400" + > + <.recommendation + :if={@interpretation} + interpretation={@interpretation} + offer_custom_url_input?={@offer_custom_url_input?} + domain={@domain} + flow={@flow} + /> +
+ <.retry_form_or_button custom_url_input?={@custom_url_input?} domain={@domain} /> + <.button_link + :if={not @custom_url_input? and @offer_custom_url_input?} + mt?={false} + href="#" + phx-click="show-custom-url-form" + id="verify-custom-url-link" + theme="ghost" + size="sm" + class="hover:bg-gray-600/10 dark:hover:bg-white/10 hover:border-transparent dark:hover:border-transparent" + > + Try another URL + + <.button_link + :if={not @custom_url_input? and not @offer_custom_url_input?} + mt?={false} + href={Routes.site_path(PlausibleWeb.Endpoint, :installation, @domain, flow: @flow)} + theme="ghost" + size="sm" + class="hover:bg-gray-600/10 dark:hover:bg-white/10 hover:border-transparent dark:hover:border-transparent" + > + Review installation + +
+ <.contact_us_link :if={ee?() and @attempts >= 3} /> + <.super_admin_diagnostics + :if={@super_admin? and not is_nil(@verification_state)} + verification_state={@verification_state} + /> + + """ + end + + defp success_message(flow) do + cond do + flow == PlausibleWeb.Flows.review() -> + "Visitors are being counted correctly." + + flow == PlausibleWeb.Flows.domain_change() -> + "Visitors are being counted correctly on your new domain." + + true -> + "Your dashboard is ready. Data will appear here as soon as visitors start arriving." + end + end + + defp offer_custom_url_input?(interpretation) do + match?(%{data: %{offer_custom_url_input: true}}, interpretation) + end + + defp recommendation(assigns) do + recommendation = List.first(assigns.interpretation.recommendations) + main = render_recommendation(recommendation.text, recommendation.inline_links) + + body = + if assigns.offer_custom_url_input? do + [main, ". ", review_installation_link_sentence(assigns), "."] + else + [main, "."] + end + + assigns = assign(assigns, :body, body) + + ~H""" +

+ {@body} +

+ """ + end + + defp render_recommendation(text, inline_links) do + html = + Enum.reduce(inline_links, html_escape(text), fn %{text: link_text, href: href}, acc -> + String.replace(acc, link_text, link_markup(link_text, href), global: false) + end) + + Phoenix.HTML.raw(html) + end + + defp review_installation_link_sentence(assigns) do + review_installation_url = + Routes.site_path(PlausibleWeb.Endpoint, :installation, assigns.domain, flow: assigns.flow) + + render_recommendation("See your installation instructions again here", [ + %{text: "here", href: review_installation_url} + ]) + end + + defp link_markup(text, href) do + external_attrs = + if String.starts_with?(href, "http"), + do: ~s| target="_blank" rel="noopener noreferrer"|, + else: "" + + ~s|#{html_escape(text)}| + end + + defp html_escape(value) do + value |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + end + + defp retry_form_or_button(%{custom_url_input?: true} = assigns) do + ~H""" +
+ <.input + type="url" + name="custom_url" + id="custom_url" + aria-label="Website URL" + required + mt?={false} + width="w-64 h-[38px] dark:bg-white/15 dark:border-transparent" + placeholder={"https://#{@domain}"} + value={"https://#{@domain}"} + /> + <.button type="submit" mt?={false} theme="primary" size="sm"> + Verify URL + +
+ """ + end + + defp retry_form_or_button(assigns) do + ~H""" + <.button_link + mt?={false} + href="#" + phx-click="retry" + theme="secondary" + size="sm" + class="dark:bg-white/15 dark:hover:bg-white/20 dark:border-transparent" + > + Check again + + """ + end + + defp contact_us_link(assigns) do + ~H""" +

+ Need help? {Phoenix.HTML.raw(link_markup("Contact us", "https://plausible.io/contact"))} +

+ """ + end + + defp super_admin_diagnostics(assigns) do + ~H""" +
+

+ + As a super-admin, you're eligible to see diagnostics details. Click to expand. + +

+
+ <.focus_list> + <:item :for={{diag, value} <- Map.from_struct(@verification_state.diagnostics)}> + + {Phoenix.Naming.humanize(diag)}: {to_string_value(value)} + + + +
+
+ """ + end + + defp to_string_value(value) when is_binary(value), do: value + defp to_string_value(value), do: inspect(value) +end diff --git a/lib/plausible_web/live/installation.ex b/lib/plausible_web/live/installation.ex index 88a16ae2e742..83ffac86e35b 100644 --- a/lib/plausible_web/live/installation.ex +++ b/lib/plausible_web/live/installation.ex @@ -82,7 +82,6 @@ defmodule PlausibleWeb.Live.Installation do {:ok, assign(socket, site: site, - site_created?: params["site_created"] == "true", flow: flow )} end @@ -104,7 +103,6 @@ defmodule PlausibleWeb.Live.Installation do def render(assigns) do ~H"""
- <.focus_box> @@ -186,7 +184,7 @@ defmodule PlausibleWeb.Live.Installation do type="submit" class="w-full mt-8" > - {verify_cta(@installation_type.result)} + {submit_button_text(@installation_type.result)} @@ -205,10 +203,14 @@ defmodule PlausibleWeb.Live.Installation do """ end - defp verify_cta("manual"), do: "Verify Script installation" - defp verify_cta("wordpress"), do: "Verify WordPress installation" - defp verify_cta("gtm"), do: "Verify Tag Manager installation" - defp verify_cta("npm"), do: "Verify NPM installation" + on_ee do + defp submit_button_text("manual"), do: "Verify Script installation" + defp submit_button_text("wordpress"), do: "Verify WordPress installation" + defp submit_button_text("gtm"), do: "Verify Tag Manager installation" + defp submit_button_text("npm"), do: "Verify NPM installation" + else + defp submit_button_text(_), do: "Proceed to dashboard" + end on_ee do defp install_method_event(installation_type, recommended) do @@ -321,21 +323,21 @@ defmodule PlausibleWeb.Live.Installation do end def handle_event("submit", %{"tracker_script_configuration" => params}, socket) do - config = - PlausibleWeb.Tracker.update_script_configuration!( - socket.assigns.site, - params, - :installation - ) + PlausibleWeb.Tracker.update_script_configuration!(socket.assigns.site, params, :installation) - {:noreply, - push_navigate(socket, - to: - Routes.site_path(socket, :verification, socket.assigns.site.domain, - flow: socket.assigns.flow, - installation_type: config.installation_type - ) - )} + domain = socket.assigns.site.domain + + destination = + on_ee do + Routes.stats_path(socket, :stats, domain, + verify_installation: true, + flow: socket.assigns.flow + ) + else + Routes.stats_path(socket, :stats, domain, []) + end + + {:noreply, redirect(socket, to: destination)} end defp initialize_installation_data(flow, site, params) do diff --git a/lib/plausible_web/live/installation/instructions.ex b/lib/plausible_web/live/installation/instructions.ex index 31d7292c301c..9716d01ea8e7 100644 --- a/lib/plausible_web/live/installation/instructions.ex +++ b/lib/plausible_web/live/installation/instructions.ex @@ -3,6 +3,8 @@ defmodule PlausibleWeb.Live.Installation.Instructions do Instruction forms and components for the Installation module """ use PlausibleWeb, :component + import PlausibleWeb.Components.Generic + alias Plausible.Site.TrackerScriptConfiguration attr :tracker_script_configuration_form, :map, required: true @@ -21,11 +23,7 @@ defmodule PlausibleWeb.Live.Installation.Instructions do Once done, click the button below to verify your installation.
- <.snippet_form - text={render_snippet(@tracker_script_configuration_form.data)} - rows={6} - resizable={true} - /> + <.copy_snippet_box tracker_script_configuration={@tracker_script_configuration_form.data} /> <.h2 class="mt-8 text-sm font-medium">Optional measurements <.script_config_control field={@tracker_script_configuration_form[:outbound_links]} @@ -129,38 +127,56 @@ defmodule PlausibleWeb.Live.Installation.Instructions do Tag Manager installation
- - We've detected your website is using Google Tag Manager. Here's how to integrate Plausible: - - - Using Google Tag Manager? Here's how to integrate Plausible: - -
- <.focus_list> - <:item> - Copy your site's Script ID: - <.snippet_form - text={@tracker_script_configuration_form.data.id} - rows={1} - resizable={false} - /> - + <.gtm_instructions_content + recommended_installation_type={@recommended_installation_type} + tracker_script_configuration={@tracker_script_configuration_form.data} + /> +
+ """ + end - <:item> - <.styled_link href="https://plausible.io/gtm-template" new_tab={true}> - Install the Plausible template in GTM - - + attr :recommended_installation_type, :string, required: true + attr :tracker_script_configuration, TrackerScriptConfiguration, required: true - <:item> - Paste your Script ID into the template and click the button below to verify your installation. - - -
+ def gtm_instructions_content(assigns) do + ~H""" + + We've detected your website is using Google Tag Manager. Here's how to integrate Plausible: + + + Using Google Tag Manager? Here's how to integrate Plausible: + +
+ <.gtm_instructions_content_inner tracker_script_configuration={@tracker_script_configuration} />
""" end + def gtm_instructions_content_inner(assigns) do + ~H""" + <.copyable_readonly_text_area + id="script-config-id" + text={@tracker_script_configuration.id} + rows={1} + /> + <.focus_list> + <:item> + Copy your site's Script ID from above + + + <:item> + <.styled_link href="https://plausible.io/gtm-template" new_tab={true}> + Install the Plausible template in GTM + + + + <:item> + Paste your Script ID into the template + + + """ + end + def npm_instructions(assigns) do ~H""" <.title class="my-4"> @@ -236,27 +252,11 @@ defmodule PlausibleWeb.Live.Installation.Instructions do """ end - defp snippet_form(assigns) do - ~H""" -
- + def copy_snippet_box(assigns) do + assigns = assign(assigns, :text, render_snippet(assigns.tracker_script_configuration)) - - - - COPY - - -
+ ~H""" + <.copyable_readonly_text_area id="snippet" text={@text} rows={6} resizable={true} /> """ end diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index 11217bd370a1..e4d0c5ee07eb 100644 --- a/lib/plausible_web/live/sites.ex +++ b/lib/plausible_web/live/sites.ex @@ -7,6 +7,7 @@ defmodule PlausibleWeb.Live.Sites do import PlausibleWeb.Live.Components.Pagination import PlausibleWeb.StatsView, only: [large_number_format: 1] + alias Plausible.Repo alias Plausible.Sites alias Plausible.Sites.Index alias Plausible.Teams @@ -533,6 +534,13 @@ defmodule PlausibleWeb.Live.Sites do attr(:sparkline, :map, required: true) def site(assigns) do + assigns = + assign( + assigns, + :needs_verification?, + ee?() and assigns.site.onboarding_status == :new_site + ) + ~H"""
  • <.unstyled_link - href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, @site.domain, [])} + href={ + Routes.stats_path( + PlausibleWeb.Endpoint, + :stats, + @site.domain, + if(@needs_verification?, + do: [verify_installation: true, flow: PlausibleWeb.Flows.provisioning()], + else: [] + ) + ) + } class="block group-has-[.phx-click-loading]/sort:animate-pulse group-has-[.phx-click-loading]/sort:pointer-events-none" >
    @@ -567,7 +585,7 @@ defmodule PlausibleWeb.Live.Sites do
  • - <.site_stats sparkline={@sparkline} /> + <.site_stats sparkline={@sparkline} needs_verification?={@needs_verification?} />
    @@ -657,6 +675,7 @@ defmodule PlausibleWeb.Live.Sites do end attr(:sparkline, :any, required: true) + attr(:needs_verification?, :boolean, default: false) def site_stats(assigns) do ~H""" @@ -685,7 +704,10 @@ defmodule PlausibleWeb.Live.Sites do

    - <.percentage_change change={@sparkline.visitors_change} /> + <.pill :if={@needs_verification?} color={:yellow}> + Setup pending + + <.percentage_change :if={not @needs_verification?} change={@sparkline.visitors_change} /> @@ -946,8 +968,6 @@ defmodule PlausibleWeb.Live.Sites do site_entries = Sites.get_for_user_by_ids(assigns.current_user, page.entries, team: assigns.current_team) - sites = %{page | entries: site_entries} - sparklines = if connected?(socket) do Plausible.Stats.Sparkline.parallel_overview(site_entries) @@ -955,6 +975,10 @@ defmodule PlausibleWeb.Live.Sites do %{} end + site_entries = Enum.map(site_entries, &advance_onboarding_status_if_needed(&1, sparklines)) + + sites = %{page | entries: site_entries} + consolidated_sparkline = if connected?(socket), do: load_consolidated_sparkline(assigns.consolidated_view), @@ -968,6 +992,23 @@ defmodule PlausibleWeb.Live.Sites do ) end + defp advance_onboarding_status_if_needed( + %Plausible.Site{onboarding_status: :new_site} = site, + sparklines + ) do + case Map.get(sparklines, site.domain) do + %{visitors: visitors} when visitors > 0 -> + site + |> Plausible.Site.put_onboarding_status_advance(:first_pageview) + |> Repo.update!() + + _ -> + site + end + end + + defp advance_onboarding_status_if_needed(site, _sparklines), do: site + defp refresh_index_pins(socket) do assign(socket, :index_state, Index.refresh_pins(socket.assigns.index_state)) end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 6c1439f81f25..f9c16cf88faa 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -176,6 +176,7 @@ defmodule PlausibleWeb.Router do post "/stats", E2EController, :populate_stats post "/funnel", E2EController, :create_funnel post "/goal", E2EController, :create_goal + put "/verification", E2EController, :put_verification_scenario end end end @@ -411,6 +412,7 @@ defmodule PlausibleWeb.Router do get "/paddle/currency", Api.PaddleController, :currency put "/:domain/disable-feature", Api.InternalController, :disable_feature + put "/:domain/complete-onboarding", Api.InternalController, :complete_onboarding get "/sites", Api.InternalController, :sites end @@ -602,15 +604,6 @@ defmodule PlausibleWeb.Router do live "/:domain/installation", Installation, :installation, as: :site end - scope assigns: %{ - dogfood_page_path: "/:website/verification" - } do - live "/:domain/verification", - on_ee(do: Verification, else: AwaitingPageviews), - :verification, - as: :site - end - scope assigns: %{ dogfood_page_path: "/:website/change-domain" } do diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index 6a8058bb7d73..2a308338f388 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -1,6 +1,4 @@
    - - <%= if Plausible.Teams.locked?(@site.team) do %>
    + <%= if @verify_installation? do %> + {live_render(@conn, PlausibleWeb.Live.Verification, + id: "live-verification", + session: @verification_session + )} + <% end %> <%= if ee?() && !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
    diff --git a/priv/repo/migrations/20260727120000_add_onboarding_status_to_sites.exs b/priv/repo/migrations/20260727120000_add_onboarding_status_to_sites.exs new file mode 100644 index 000000000000..c2421fdbc363 --- /dev/null +++ b/priv/repo/migrations/20260727120000_add_onboarding_status_to_sites.exs @@ -0,0 +1,9 @@ +defmodule Plausible.Repo.Migrations.AddOnboardingStatusToSites do + use Ecto.Migration + + def change do + alter table(:sites) do + add :onboarding_status, :string, null: false, default: "completed" + end + end +end diff --git a/test/plausible/installation_support/check_runner_test.exs b/test/plausible/installation_support/check_runner_test.exs new file mode 100644 index 000000000000..752391bdc23f --- /dev/null +++ b/test/plausible/installation_support/check_runner_test.exs @@ -0,0 +1,58 @@ +defmodule Plausible.InstallationSupport.CheckRunnerTest do + use Plausible.DataCase, async: true + + on_ee do + alias Plausible.InstallationSupport.{CheckRunner, State} + + defmodule NoopCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: "noop" + + @impl true + def perform(state, _opts), do: state + end + + defp init_state do + %State{url: "https://example.com", data_domain: "example.com", report_to: self()} + end + + describe "run/3" do + test "defaults launch_delay to 0" do + started_at = System.monotonic_time(:millisecond) + + CheckRunner.run(init_state(), [{NoopCheck, []}], async?: false, slowdown: 0) + + assert System.monotonic_time(:millisecond) - started_at < 100 + end + + test "awaits :launch_delay before the first check starts" do + started_at = System.monotonic_time(:millisecond) + + {:ok, _pid} = + CheckRunner.run(init_state(), [{NoopCheck, []}], + slowdown: 0, + launch_delay: 100, + report_to: self() + ) + + assert_receive {:check_start, {NoopCheck, _state}}, 1000 + + assert System.monotonic_time(:millisecond) - started_at >= 100 + end + + test "runs the first check immediately when :launch_delay is 0" do + {:ok, _pid} = + CheckRunner.run(init_state(), [{NoopCheck, []}], + slowdown: 0, + launch_delay: 0, + report_to: self() + ) + + assert_receive {:check_start, {NoopCheck, _state}}, 100 + end + end + end +end diff --git a/test/plausible/installation_support/verification/checks_mock_test.exs b/test/plausible/installation_support/verification/checks_mock_test.exs new file mode 100644 index 000000000000..c4d2bf0c7c62 --- /dev/null +++ b/test/plausible/installation_support/verification/checks_mock_test.exs @@ -0,0 +1,152 @@ +defmodule Plausible.InstallationSupport.Verification.ChecksMockTest do + use Plausible.DataCase, async: true + + on_ee do + alias Plausible.InstallationSupport.{Checks, Result} + alias Plausible.InstallationSupport.Verification.{ChecksMock, Diagnostics, MockScenarios} + + @url "https://example.com" + + describe "run/4" do + test "raises when no scenario is registered for the domain" do + domain = insert(:site).domain + + assert_raise RuntimeError, ~r/no scenario is registered/, fn -> + ChecksMock.run(@url, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) + end + end + + test "runs synchronously, keeping the given installation_type in the resulting state" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :success) + + state = + ChecksMock.run(@url, domain, "wordpress", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) + + assert state.url == @url + assert state.data_domain == domain + assert state.diagnostics.selected_installation_type == "wordpress" + end + + test "notifies check_start for all 3 checks with the same messages as real verification, then all_checks_done" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :success) + + ChecksMock.run(@url, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: self() + ) + + assert_received {:check_start, {ChecksMock.FakeUrlCheck, _state}} + assert_received {:check_start, {ChecksMock.FakeVerifyInstallationCheck, _state}} + assert_received {:check_start, {ChecksMock.FakeVerifyInstallationCacheBustCheck, _state}} + assert_received {:all_checks_done, %{data_domain: ^domain}} + + assert ChecksMock.FakeUrlCheck.report_progress_as() == + Checks.Url.report_progress_as() + + assert ChecksMock.FakeVerifyInstallationCheck.report_progress_as() == + Checks.VerifyInstallation.report_progress_as() + + assert ChecksMock.FakeVerifyInstallationCacheBustCheck.report_progress_as() == + Checks.VerifyInstallationCacheBust.report_progress_as() + end + + test "defaults state.url from data_domain when called with url: nil, mirroring the real Url check" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :success) + + state = + ChecksMock.run(nil, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) + + assert state.url == "https://#{domain}" + end + end + + describe "interpret_diagnostics/1" do + test "returns the named result for the registered scenario" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :success) + + state = + ChecksMock.run(@url, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) + + assert %Result{ok?: true} = ChecksMock.interpret_diagnostics(state) + end + + test "returns interpretation based on installation type" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :plausible_not_found) + + state = + ChecksMock.run(@url, domain, "wordpress", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) + + assert %Result{ + ok?: false, + recommendations: [%{text: recommendation}] + } = ChecksMock.interpret_diagnostics(state) + + assert recommendation =~ "WordPress plugin" + end + + test "uses state.url (e.g. a custom retry URL) as attempted_url, not just the bare domain" do + domain = insert(:site).domain + :ok = MockScenarios.put(domain, :domain_not_found) + + custom_url = "https://abc.de" + + state = + ChecksMock.run(custom_url, domain, "manual", + async?: false, + slowdown: 0, + launch_delay: 0, + report_to: nil + ) + + assert %Result{errors: [error]} = ChecksMock.interpret_diagnostics(state) + assert error =~ custom_url + end + + test "raises when no scenario is registered for the domain" do + domain = insert(:site).domain + + state = %Plausible.InstallationSupport.State{ + url: @url, + data_domain: domain, + diagnostics: %Diagnostics{selected_installation_type: "manual"} + } + + assert_raise RuntimeError, ~r/no scenario is registered/, fn -> + ChecksMock.interpret_diagnostics(state) + end + end + end + end +end diff --git a/test/plausible/installation_support/verification/checks_observability_test.exs b/test/plausible/installation_support/verification/checks_observability_test.exs index 5464c08c019a..eda236fcc05e 100644 --- a/test/plausible/installation_support/verification/checks_observability_test.exs +++ b/test/plausible/installation_support/verification/checks_observability_test.exs @@ -136,7 +136,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksObservabilityTest do verify_installation_check_timeout: 100, report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) log = capture_log(fn -> Checks.interpret_diagnostics(state) end) @@ -167,7 +168,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksObservabilityTest do Checks.run(@url_to_verify, @expected_domain, "manual", report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) end diff --git a/test/plausible/installation_support/verification/checks_test.exs b/test/plausible/installation_support/verification/checks_test.exs index 01e01e4d7910..f893a490e604 100644 --- a/test/plausible/installation_support/verification/checks_test.exs +++ b/test/plausible/installation_support/verification/checks_test.exs @@ -13,6 +13,11 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do @expected_domain "example.com" @url_to_verify "https://#{@expected_domain}" + @verify_manually_url "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + @verify_manually_inline_link %{ + text: "verify your installation manually", + href: @verify_manually_url + } describe "URL check" do test "returns error when DNS check fails with domain not found error, offers custom URL input" do @@ -22,21 +27,21 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do ok?: false, data: %{offer_custom_url_input: true}, errors: [ - ^any(:string, ~r/We couldn't find your website at #{@url_to_verify}$/) + ^any(:string, ~r/We couldn't reach #{@url_to_verify}$/) ], recommendations: [ %{ text: - "Please check that the domain you entered is correct and reachable publicly. If it's intentionally private, you'll need to verify that Plausible works manually", - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + "Check that the URL is correct and publicly accessible. If your site is intentionally private, you'll need to verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = Checks.run(@url_to_verify, @expected_domain, "manual", report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) |> Checks.interpret_diagnostics() end @@ -49,21 +54,21 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do ok?: false, data: %{offer_custom_url_input: true}, errors: [ - ^any(:string, ~r/We couldn't find your website at #{url_to_verify}$/) + ^any(:string, ~r/We couldn't reach #{url_to_verify}$/) ], recommendations: [ %{ text: - "Please check that the domain you entered is correct and reachable publicly. If it's intentionally private, you'll need to verify that Plausible works manually", - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + "Check that the URL is correct and publicly accessible. If your site is intentionally private, you'll need to verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = Checks.run(url_to_verify, @expected_domain, "manual", report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) |> Checks.interpret_diagnostics() end @@ -94,13 +99,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do for {installation_type, expected_recommendation} <- [ {"wordpress", - "Please check that you've installed the WordPress plugin correctly, or verify your installation manually"}, + "Check you've installed the WordPress plugin correctly, or verify your installation manually"}, {"gtm", - "Please check that you've entered the ID in the GTM template correctly, or verify your installation manually"}, + "Check you've entered the ID in the GTM template correctly, or verify your installation manually"}, {"npm", - "Please check that you've initialized Plausible with the correct domain, or verify your installation manually"}, + "Check you've initialized Plausible with the correct domain, or verify your installation manually"}, {"manual", - "Please check that the snippet on your site matches the installation instructions exactly, or verify your installation manually"} + "Check that the snippet on your site matches the one shown in the installation instructions, or verify your installation manually"} ] do test "returns error when test event domain doesn't match the expected domain, with recommendation for installation type: #{installation_type}" do verification_stub = @@ -119,12 +124,11 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, - errors: ["Plausible test event is not for this site"], + errors: ["Your Plausible snippet is configured for a different domain"], recommendations: [ %{ text: unquote(expected_recommendation), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + inline_links: [^@verify_manually_inline_link] } ] } = @@ -154,11 +158,16 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, - errors: [^any(:string, ~r/.*proxy.*/)], + errors: [^any(:string, ~r/.*proxied.*/)], recommendations: [ %{ text: ^any(:string, ~r/.*proxied.*/), - url: "https://plausible.io/docs/proxy/introduction" + inline_links: [ + %{ + text: "Learn more", + href: "https://plausible.io/docs/proxy/introduction" + } + ] } ] } = @@ -187,9 +196,9 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do errors: [^any(:string, ~r/.*couldn't verify.*/)], recommendations: [ %{ - text: ^any(:string, ~r/.*try verifying again.*/), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + text: + "Please try verifying again in a few minutes, or verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -213,9 +222,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: - "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" + "Make sure you've copied the snippet to the head of your site, or verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -236,14 +244,19 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, errors: [ - "We encountered an issue with your site's Content Security Policy (CSP)" + "Your site's Content Security Policy (CSP) is blocking Plausible" ], recommendations: [ %{ text: - "Please add plausible.io domain specifically to the allowed list of domains in your site's CSP", - url: - "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + "Add plausible.io to the list of allowed domains in your site's Content Security Policy to allow Plausible to collect analytics. Learn more", + inline_links: [ + %{ + text: "Learn more", + href: + "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + } + ] } ] } = @@ -261,15 +274,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, data: %{offer_custom_url_input: true}, - errors: [ - "We couldn't verify your website at https://example.com" - ], + errors: ["We couldn't verify https://example.com"], recommendations: [ %{ text: - "Accessing the website resulted in a network error. Please verify your installation manually", - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + "We encountered a network error while trying to access your website. You can verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -291,15 +301,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, data: %{offer_custom_url_input: true}, - errors: [ - "We couldn't verify your website at https://example.com" - ], + errors: ["We couldn't verify https://example.com"], recommendations: [ %{ text: - "Accessing the website resulted in an unexpected status code 403. Please check for anything that might be blocking us from reaching your site, like a firewall, authentication requirements, or CDN rules. If you'd prefer, you can skip this and verify your installation manually", - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + "Accessing your website returned an unexpected status code (403). Check for anything that might be blocking our access to your site, such as a firewall, authentication requirements, or CDN rules. You can also verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = @@ -309,13 +316,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do for {installation_type, expected_recommendation} <- [ {"wordpress", - "Please make sure you've enabled the plugin, or verify your installation manually"}, + "Make sure you've enabled the WordPress plugin, or verify your installation manually"}, {"gtm", - "Please make sure you've configured the GTM template correctly, or verify your installation manually"}, + "Make sure you've configured the GTM template correctly, or verify your installation manually"}, {"npm", - "Please make sure you've initialized Plausible on your site, or verify your installation manually"}, + "Make sure you've initialized Plausible on your site, or verify your installation manually"}, {"manual", - "Please make sure you've copied the snippet to the head of your site, or verify your installation manually"} + "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 verification_stub = @@ -334,8 +341,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: unquote(expected_recommendation), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + inline_links: [^@verify_manually_inline_link] } ] } = @@ -363,8 +369,7 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: unquote(expected_recommendation), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + inline_links: [^@verify_manually_inline_link] } ] } = @@ -426,8 +431,13 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do recommendations: [ %{ text: ^any(:string, ~r/.*cache.*/), - url: - "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + inline_links: [ + %{ + text: "Learn more", + href: + "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + } + ] } ] } = run_checks(verification_stub) |> Checks.interpret_diagnostics() @@ -484,12 +494,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, - errors: [^any(:string, ~r/.*temporary service error.*/)], + errors: [^any(:string, ~r/.*temporarily unavailable.*/)], recommendations: [ %{ - text: ^any(:string, ~r/.*in a few minutes.*/), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + text: + "Please try again in a few minutes or verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = Checks.interpret_diagnostics(state) @@ -514,12 +524,12 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do assert_matches %Result{ ok?: false, - errors: [^any(:string, ~r/.*temporary service error.*/)], + errors: [^any(:string, ~r/.*temporarily unavailable.*/)], recommendations: [ %{ - text: ^any(:string, ~r/.*in a few minutes.*/), - url: - "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + text: + "Please try again in a few minutes or verify your installation manually", + inline_links: [^@verify_manually_inline_link] } ] } = Checks.interpret_diagnostics(state) @@ -553,7 +563,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do Checks.run(@url_to_verify, @expected_domain, installation_type, report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) assert expected_req_count == :atomics.get(counter, 1) @@ -565,7 +576,8 @@ defmodule Plausible.InstallationSupport.Verification.ChecksTest do Checks.run(@url_to_verify, @expected_domain, installation_type, report_to: nil, async?: false, - slowdown: 0 + slowdown: 0, + launch_delay: 0 ) end end diff --git a/test/plausible/installation_support/verification/diagnostics_test.exs b/test/plausible/installation_support/verification/diagnostics_test.exs new file mode 100644 index 000000000000..21a065654faa --- /dev/null +++ b/test/plausible/installation_support/verification/diagnostics_test.exs @@ -0,0 +1,49 @@ +defmodule Plausible.InstallationSupport.Verification.DiagnosticsTest do + use ExUnit.Case, async: true + use Plausible + + on_ee do + alias Plausible.InstallationSupport.Verification.Diagnostics.Error + + describe "Error.new!/1" do + test "accepts a recommendation whose inline_links text appears exactly once" do + assert %Error{} = + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the docs for more info", + inline_links: [%{text: "docs", href: "https://plausible.io/docs"}] + }) + end + + test "raises when inline_links text isn't found in the recommendation" do + assert_raise ArgumentError, ~r/must appear exactly once/, fn -> + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the manual for more info", + inline_links: [%{text: "docs", href: "https://plausible.io/docs"}] + }) + end + end + + test "raises when inline_links text appears more than once in the recommendation" do + assert_raise ArgumentError, ~r/must appear exactly once/, fn -> + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the docs, or check the docs again", + inline_links: [%{text: "the docs", href: "https://plausible.io/docs"}] + }) + end + end + + test "raises when inline_links href doesn't point at plausible.io" do + assert_raise ArgumentError, ~r/must start with/, fn -> + Error.new!(%{ + message: "Something went wrong", + recommendation: "Check the docs for more info", + inline_links: [%{text: "docs", href: "https://example.com/docs"}] + }) + end + end + end + end +end diff --git a/test/plausible/installation_support/verification/mock_scenarios_test.exs b/test/plausible/installation_support/verification/mock_scenarios_test.exs new file mode 100644 index 000000000000..ed191e6cc410 --- /dev/null +++ b/test/plausible/installation_support/verification/mock_scenarios_test.exs @@ -0,0 +1,90 @@ +defmodule Plausible.InstallationSupport.Verification.MockScenariosTest do + use Plausible.DataCase, async: true + + on_ee do + alias Plausible.InstallationSupport.Verification.MockScenarios + + test "get/1 returns nil for a domain with no registered scenario" do + site = insert(:site) + + assert MockScenarios.get(site.domain) == nil + end + + test "put/3 registers a scenario, get/1 returns it" do + site = insert(:site) + + :ok = MockScenarios.put(site.domain, :success, []) + + assert MockScenarios.get(site.domain) == %{ + interpretation_result: :success, + slowdown: nil, + launch_delay: nil + } + end + + test "put/3 stores a slowdown opt alongside the interpretation result" do + site = insert(:site) + + :ok = MockScenarios.put(site.domain, :domain_not_found, slowdown: 2000) + + assert MockScenarios.get(site.domain) == %{ + interpretation_result: :domain_not_found, + slowdown: 2000, + launch_delay: nil + } + end + + test "put/3 stores a launch_delay opt alongside the interpretation result" do + site = insert(:site) + + :ok = MockScenarios.put(site.domain, :domain_not_found, launch_delay: 2000) + + assert MockScenarios.get(site.domain) == %{ + interpretation_result: :domain_not_found, + slowdown: nil, + launch_delay: 2000 + } + end + + test "put/3 overwrites a previously registered scenario for the same domain" do + site = insert(:site) + + :ok = MockScenarios.put(site.domain, :success, []) + :ok = MockScenarios.put(site.domain, :csp_disallowed, []) + + assert MockScenarios.get(site.domain) == %{ + interpretation_result: :csp_disallowed, + slowdown: nil, + launch_delay: nil + } + end + + test "scenarios registered for one domain never leak into another domain on the same registry" do + site_a = insert(:site) + site_b = insert(:site) + + :ok = MockScenarios.put(site_a.domain, :success, []) + :ok = MockScenarios.put(site_b.domain, :domain_not_found, slowdown: 500, launch_delay: 100) + + assert MockScenarios.get(site_a.domain) == %{ + interpretation_result: :success, + slowdown: nil, + launch_delay: nil + } + + assert MockScenarios.get(site_b.domain) == %{ + interpretation_result: :domain_not_found, + slowdown: 500, + launch_delay: 100 + } + + :ok = MockScenarios.put(site_a.domain, :csp_disallowed, []) + + assert MockScenarios.get(site_b.domain) == %{ + interpretation_result: :domain_not_found, + slowdown: 500, + launch_delay: 100 + } + end + end +end diff --git a/test/plausible/site/schema_test.exs b/test/plausible/site/schema_test.exs index 91a19761f268..aa1cf24212a2 100644 --- a/test/plausible/site/schema_test.exs +++ b/test/plausible/site/schema_test.exs @@ -4,6 +4,25 @@ defmodule Plausible.SiteTest do doctest Plausible.Site + describe "new/1" do + test "sets onboarding_status to :new_site by default" do + changeset = Site.new(%{"domain" => "example.com", "timezone" => "Europe/London"}) + + assert Ecto.Changeset.get_change(changeset, :onboarding_status) == :new_site + end + + test "sets onboarding_status to :completed for a consolidated site" do + changeset = + Site.new(%{ + "domain" => "example.com", + "timezone" => "Europe/London", + "consolidated" => true + }) + + assert Ecto.Changeset.apply_changes(changeset).onboarding_status == :completed + end + end + describe "tz_offset/2" do test "returns offset from utc in seconds" do site = build(:site, timezone: "US/Eastern") @@ -31,4 +50,62 @@ defmodule Plausible.SiteTest do assert Site.tz_offset(site, ~U[2023-11-05 06:00:00Z]) == -18_000 end end + + describe "put_onboarding_status_advance/2" do + test "advances onboarding_status forward" do + site = insert(:site, onboarding_status: :new_site) + + changeset = Site.put_onboarding_status_advance(site, :verification_succeeded) + + assert Ecto.Changeset.get_change(changeset, :onboarding_status) == :verification_succeeded + end + + test "can skip an intermediate status" do + site = insert(:site, onboarding_status: :new_site) + + changeset = Site.put_onboarding_status_advance(site, :first_pageview) + + assert Ecto.Changeset.get_change(changeset, :onboarding_status) == :first_pageview + end + + test "is a no-op when the site is already at the given status" do + site = insert(:site, onboarding_status: :verification_succeeded) + + changeset = Site.put_onboarding_status_advance(site, :verification_succeeded) + + refute Ecto.Changeset.get_change(changeset, :onboarding_status) + end + + test "is a no-op when the site is already past the given status" do + site = insert(:site, onboarding_status: :completed) + + changeset = Site.put_onboarding_status_advance(site, :verification_succeeded) + + refute Ecto.Changeset.get_change(changeset, :onboarding_status) + end + + test "composes with other changes on the same changeset" do + site = insert(:site, onboarding_status: :new_site) + + changeset = + site + |> Site.set_stats_start_date(~D[2024-01-01]) + |> Site.put_onboarding_status_advance(:first_pageview) + + assert Ecto.Changeset.get_change(changeset, :stats_start_date) == ~D[2024-01-01] + assert Ecto.Changeset.get_change(changeset, :onboarding_status) == :first_pageview + end + + test "persists via Repo.update!" do + site = insert(:site, onboarding_status: :new_site) + + updated_site = + site + |> Site.put_onboarding_status_advance(:verification_succeeded) + |> Repo.update!() + + assert updated_site.onboarding_status == :verification_succeeded + assert Repo.reload!(site).onboarding_status == :verification_succeeded + end + end end diff --git a/test/plausible/sites_test.exs b/test/plausible/sites_test.exs index 5e9ea8db3cb6..8007f9f63867 100644 --- a/test/plausible/sites_test.exs +++ b/test/plausible/sites_test.exs @@ -143,11 +143,11 @@ defmodule Plausible.SitesTest do end end - describe "stats_start_date" do + describe "ensure_stats_start_date" do test "is nil if site has no stats" do site = insert(:site) - assert Sites.stats_start_date(site) == nil + assert Sites.ensure_stats_start_date(site).stats_start_date == nil end test "is date if site does have stats" do @@ -157,7 +157,8 @@ defmodule Plausible.SitesTest do build(:pageview) ]) - assert Sites.stats_start_date(site) == Plausible.Times.today(site.timezone) + assert Sites.ensure_stats_start_date(site).stats_start_date == + Plausible.Times.today(site.timezone) end test "memoizes value of start date" do @@ -169,10 +170,33 @@ defmodule Plausible.SitesTest do build(:pageview) ]) - assert Sites.stats_start_date(site) == Plausible.Times.today(site.timezone) + assert Sites.ensure_stats_start_date(site).stats_start_date == + Plausible.Times.today(site.timezone) + assert Repo.reload!(site).stats_start_date == Plausible.Times.today(site.timezone) end + test "advances onboarding_status to :first_pageview when stats are first discovered, in the returned site" do + site = insert(:site, onboarding_status: :new_site) + + populate_stats(site, [build(:pageview)]) + + updated_site = Sites.ensure_stats_start_date(site) + + assert updated_site.onboarding_status == :first_pageview + assert Repo.reload!(site).onboarding_status == :first_pageview + end + + test "does not regress :completed onboarding_status" do + site = insert(:site, onboarding_status: :completed, stats_start_date: nil) + + populate_stats(site, [build(:pageview)]) + + Sites.ensure_stats_start_date(site) + + assert Repo.reload!(site).onboarding_status == :completed + end + on_ee do test "resets consolidated view stats dates every time" do owner = new_user() @@ -183,11 +207,12 @@ defmodule Plausible.SitesTest do consolidated_view = new_consolidated_view(team) assert consolidated_view.stats_start_date == ~D[2000-01-01] - assert Sites.stats_start_date(consolidated_view) == ~D[2000-01-01] + assert Sites.ensure_stats_start_date(consolidated_view).stats_start_date == ~D[2000-01-01] new_site(team: team, native_stats_start_at: ~N[1999-01-01 12:00:00]) - assert Sites.stats_start_date(consolidated_view) == ~D[1999-01-01] + assert Sites.ensure_stats_start_date(consolidated_view).stats_start_date == + ~D[1999-01-01] end end end diff --git a/test/plausible_web/controllers/api/internal_controller_test.exs b/test/plausible_web/controllers/api/internal_controller_test.exs index 1039882381e3..b8e640a5aeb4 100644 --- a/test/plausible_web/controllers/api/internal_controller_test.exs +++ b/test/plausible_web/controllers/api/internal_controller_test.exs @@ -11,9 +11,22 @@ defmodule PlausibleWeb.Api.InternalControllerTest do conn = get(conn, "/api/sites") %{"data" => sites} = json_response(conn, 200) + domains = Enum.map(sites, & &1["domain"]) - assert %{"domain" => site.domain} in sites - assert %{"domain" => site2.domain} in sites + assert site.domain in domains + assert site2.domain in domains + end + + @tag :ee_only + test "needs_verification reflects the site's onboarding status", %{conn: conn, user: user} do + new_site_ = new_site(owner: user, onboarding_status: :new_site) + onboarded_site = new_site(owner: user, onboarding_status: :completed) + + conn = get(conn, "/api/sites") + %{"data" => sites} = json_response(conn, 200) + + assert %{"domain" => new_site_.domain, "needs_verification" => true} in sites + assert %{"domain" => onboarded_site.domain, "needs_verification" => false} in sites end test "returns a list of max 9 site domains for the current user, putting pinned first", %{ @@ -44,16 +57,14 @@ defmodule PlausibleWeb.Api.InternalControllerTest do %{"data" => sites} = json_response(conn, 200) + domains = Enum.map(sites, & &1["domain"]) + assert Enum.count(sites) == 9 - assert [ - %{"domain" => "site05.example.com"}, - %{"domain" => "site07.example.com"}, - %{"domain" => "site01.example.com"} | _ - ] = sites + assert ["site05.example.com", "site07.example.com", "site01.example.com" | _] = domains - assert %{"domain" => "site09.example.com"} in sites - refute %{"domain" => "sites10.example.com"} in sites + assert "site09.example.com" in domains + refute "sites10.example.com" in domains end end @@ -142,4 +153,78 @@ defmodule PlausibleWeb.Api.InternalControllerTest do assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain) end end + + describe "PUT /api/:domain/complete-onboarding" do + setup [:create_user, :log_in] + + test "when the logged-in user is the owner of the site", %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :first_pageview) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 200) == "ok" + assert %{onboarding_status: :completed} = Plausible.Sites.get_by_domain(site.domain) + end + + test "when the logged-in user is an editor guest of the site", %{conn: conn, user: user} do + site = new_site(onboarding_status: :first_pageview) + add_guest(site, user: user, role: :editor) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 200) == "ok" + assert %{onboarding_status: :completed} = Plausible.Sites.get_by_domain(site.domain) + end + + test "returns 401 when the logged-in user is a viewer of the site", %{conn: conn, user: user} do + site = new_site(onboarding_status: :first_pageview) + add_guest(site, user: user, role: :viewer) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in as the owner, admin, or editor of this site" + } + + assert %{onboarding_status: :first_pageview} = Plausible.Sites.get_by_domain(site.domain) + end + + test "returns 401 when the logged-in user doesn't have site access at all", %{conn: conn} do + site = new_site(onboarding_status: :first_pageview) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in as the owner, admin, or editor of this site" + } + + assert %{onboarding_status: :first_pageview} = Plausible.Sites.get_by_domain(site.domain) + end + + test "is idempotent - calling it again on an already-completed site is still a 200", %{ + conn: conn, + user: user + } do + site = new_site(owner: user, onboarding_status: :completed) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 200) == "ok" + assert %{onboarding_status: :completed} = Plausible.Sites.get_by_domain(site.domain) + end + end + + describe "PUT /api/:domain/complete-onboarding - user not logged in" do + test "returns 401 unauthorized", %{conn: conn} do + site = insert(:site, onboarding_status: :first_pageview) + + conn = put(conn, "/api/#{site.domain}/complete-onboarding") + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in as the owner, admin, or editor of this site" + } + + assert %{onboarding_status: :first_pageview} = Plausible.Sites.get_by_domain(site.domain) + end + end end diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs index bb76d271a853..8c66b55e5e8a 100644 --- a/test/plausible_web/controllers/site_controller_test.exs +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -362,7 +362,7 @@ defmodule PlausibleWeb.SiteControllerTest do }) assert redirected_to(conn) == - "/#{URI.encode_www_form("éxample.com")}/installation?site_created=true&flow=" + "/#{URI.encode_www_form("éxample.com")}/installation?flow=" assert site = Repo.get_by(Plausible.Site, domain: "éxample.com") assert site.timezone == "Europe/London" @@ -480,7 +480,7 @@ defmodule PlausibleWeb.SiteControllerTest do } }) - assert redirected_to(conn) == "/example.com/installation?site_created=true&flow=" + assert redirected_to(conn) == "/example.com/installation?flow=" assert Repo.get_by(Plausible.Site, domain: "example.com") end @@ -501,7 +501,7 @@ defmodule PlausibleWeb.SiteControllerTest do } }) - assert redirected_to(conn) == "/example.com/installation?site_created=true&flow=" + assert redirected_to(conn) == "/example.com/installation?flow=" assert Plausible.Teams.Billing.site_usage(team) == 3 end @@ -515,7 +515,7 @@ defmodule PlausibleWeb.SiteControllerTest do } }) - assert redirected_to(conn) == "/example.com/installation?site_created=true&flow=" + assert redirected_to(conn) == "/example.com/installation?flow=" assert Repo.get_by(Plausible.Site, domain: "example.com") end end @@ -555,7 +555,7 @@ defmodule PlausibleWeb.SiteControllerTest do }) assert redirected_to(conn) == - "/example.com%2Fsome_blog_site/installation?site_created=true&flow=" + "/example.com%2Fsome_blog_site/installation?flow=" end test "renders form again when it is a duplicate domain", %{conn: conn} do @@ -618,7 +618,7 @@ defmodule PlausibleWeb.SiteControllerTest do }) assert redirected_to(conn) == - "/example.com/installation?site_created=true&flow=" + "/example.com/installation?flow=" end for role <- [:owner, :admin, :editor] do @@ -917,6 +917,46 @@ defmodule PlausibleWeb.SiteControllerTest do end end + describe "GET /:domain/settings/email-reports" do + setup [:create_user, :log_in, :create_site] + + test "renders the page without advancing onboarding_status by default", %{ + conn: conn, + site: site + } do + site = site |> Ecto.Changeset.change(onboarding_status: :first_pageview) |> Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/email-reports") + + assert html_response(conn, 200) =~ "Weekly email reports" + assert Repo.reload!(site).onboarding_status == :first_pageview + end + + test "advances onboarding_status to :completed when cta_clicked=true", %{ + conn: conn, + site: site + } do + site = site |> Ecto.Changeset.change(onboarding_status: :first_pageview) |> Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/email-reports?cta_clicked=true") + + assert html_response(conn, 200) + assert Repo.reload!(site).onboarding_status == :completed + end + + test "cta_clicked=true does not regress :completed onboarding_status", %{ + conn: conn, + site: site + } do + site = site |> Ecto.Changeset.change(onboarding_status: :completed) |> Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/email-reports?cta_clicked=true") + + assert html_response(conn, 200) + assert Repo.reload!(site).onboarding_status == :completed + end + end + describe "GET /:domain/settings/visibility" do setup [:create_user, :log_in, :create_site] diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index 38155954a91d..d6c249a5165f 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -3,6 +3,7 @@ defmodule PlausibleWeb.StatsControllerTest do use Plausible.Repo @react_container "div#stats-react-container" + @verification_banner "#verification-ui" describe "GET /:domain - anonymous user" do test "public site - shows site stats", %{conn: conn} do @@ -104,28 +105,42 @@ defmodule PlausibleWeb.StatsControllerTest do assert resp =~ "Getting started" end - test "public site - redirect to /login when no stats because verification requires it", %{ - conn: conn - } do + test "public site - shows an empty dashboard without stats (no verification banner)", + %{ + conn: conn + } do new_site(domain: "some-other-public-site.io", public: true) - conn = get(conn, conn |> get("/some-other-public-site.io") |> redirected_to()) + resp = get(conn, "/some-other-public-site.io") |> html_response(200) - assert redirected_to(conn) == - Routes.auth_path(conn, :login_form, - return_to: "/some-other-public-site.io/verification" - ) + refute element_exists?(resp, @verification_banner) end - test "public site - no stats with skip_to_dashboard", %{ - conn: conn - } do + test "public site - anonymous visitors never see the verification banner, even with the param", + %{ + conn: conn + } do new_site(domain: "some-other-public-site.io", public: true) - conn = get(conn, "/some-other-public-site.io?skip_to_dashboard=true") - resp = html_response(conn, 200) + resp = + get(conn, "/some-other-public-site.io?verify_installation=true") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-logged-in") == "false" + refute element_exists?(resp, @verification_banner) + end + + test "public site - anonymous visitors never see the email reports CTA", %{conn: conn} do + public_site = + new_site( + domain: "some-other-public-site.io", + public: true, + onboarding_status: :first_pageview + ) + + resp = get(conn, "/#{public_site.domain}") |> html_response(200) assert text_of_attr(resp, @react_container, "data-logged-in") == "false" + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" end test "can not view stats of a private website", %{conn: conn} do @@ -147,15 +162,130 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" end - test "can view stats of a website I've created, enforcing pageviews check skip", %{ + on_ee do + test "verification banner showing in the provisioning flow", + %{ + conn: conn, + user: user, + site: site + } do + get_dashboard_resp = fn conn, site, q -> + get(conn, "/#{site.domain}#{q}") |> html_response(200) + end + + q = "?verify_installation=true&flow=#{PlausibleWeb.Flows.provisioning()}" + + # No `?verify_installation=true` query parameter -> doesn't show + resp = get_dashboard_resp.(conn, site, "") + refute element_exists?(resp, @verification_banner) + + # site.onboarding_status != :new_site -> doesn't show + for status <- [:verification_succeeded, :first_pageview, :completed] do + site = new_site(owner: user, onboarding_status: status) + resp = get_dashboard_resp.(conn, site, q) + refute element_exists?(resp, @verification_banner) + end + + # site.onboarding_status != :new_site & flow param not provided -> doesn't show + for status <- [:verification_succeeded, :first_pageview, :completed] do + site = new_site(owner: user, onboarding_status: status) + resp = get_dashboard_resp.(conn, site, "?verify_installation=true") + refute element_exists?(resp, @verification_banner) + end + + # both conditions met -> shows + resp = get_dashboard_resp.(conn, site, q) + assert element_exists?(resp, @verification_banner) + end + + for flow <- [PlausibleWeb.Flows.review(), PlausibleWeb.Flows.domain_change()] do + test "verification banner in #{flow} flow shows when verify_installation query param is present", + %{ + conn: conn, + site: site + } do + site + |> Plausible.Site.put_onboarding_status_advance(:completed) + |> Plausible.Repo.update!() + + resp = + get(conn, "/#{site.domain}?verify_installation=true&flow=#{unquote(flow)}") + |> html_response(200) + + assert element_exists?(resp, @verification_banner) + end + end + end + + test "shows email reports CTA when onboarding_status is :first_pageview", %{ conn: conn, - site: site + user: user } do - resp = conn |> get(conn |> get("/" <> site.domain) |> redirected_to()) |> html_response(200) - refute text_of_attr(resp, @react_container, "data-logged-in") == "true" + site = new_site(owner: user, onboarding_status: :first_pageview) - resp = conn |> get("/" <> site.domain <> "?skip_to_dashboard=true") |> html_response(200) - assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + resp = get(conn, "/#{site.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "true" + end + + test "shows email reports CTA on the very first load that discovers a pageview, without needing a second refresh", + %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :verification_succeeded) + populate_stats(site, [build(:pageview)]) + + assert Repo.reload!(site).onboarding_status == :verification_succeeded + + resp = get(conn, "/#{site.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "true" + assert Repo.reload!(site).onboarding_status == :first_pageview + end + + for status <- [:new_site, :verification_succeeded, :completed] do + test "does not show email reports CTA when onboarding_status is #{status}", %{ + conn: conn, + user: user + } do + site = new_site(owner: user, onboarding_status: unquote(status)) + + resp = get(conn, "/#{site.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + end + + test "does not show email reports CTA for a viewer, since they can't reach the settings page it links to", + %{conn: conn, user: user} do + site = new_site(onboarding_status: :first_pageview) + add_guest(site, user: user, role: :viewer) + + resp = get(conn, "/#{site.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-current-user-role") == "viewer" + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + + on_ee do + test "does not show email reports CTA for consolidated views", %{ + conn: conn, + user: user + } do + new_site(owner: user) + new_site(owner: user) + cv = user |> team_of() |> new_consolidated_view() + + # `onboarding_status` should always be :completed for + # consolidated views anyway but this test makes sure that + # stats_controller explicitly excludes email reports CTA + # for consolidated views too. + cv + |> Ecto.Changeset.change(%{onboarding_status: :first_pageview}) + |> Plausible.Repo.update!() + + resp = get(conn, "/#{cv.domain}") |> html_response(200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end end on_ee do @@ -229,21 +359,22 @@ defmodule PlausibleWeb.StatsControllerTest do assert cv.native_stats_start_at == twenty_days_ago end - test "does not redirect consolidated views to verification", %{ - conn: conn, - user: user - } do + test "does not show verification banner for consolidated views even with the explicit param", + %{ + conn: conn, + user: user + } do new_site(owner: user) new_site(owner: user) cv = user |> team_of() |> new_consolidated_view() - conn = get(conn, "/" <> cv.domain) - resp = html_response(conn, 200) + resp = get(conn, "/#{cv.domain}?verify_installation=true") |> html_response(200) assert text_of_attr(resp, @react_container, "data-domain") == cv.domain assert text_of_attr(resp, @react_container, "data-logged-in") == "true" assert text_of_attr(resp, @react_container, "data-current-user-role") == "owner" assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" + refute element_exists?(resp, @verification_banner) end test "redirects to /sites if for some reason ineligible anymore", %{ @@ -334,9 +465,9 @@ defmodule PlausibleWeb.StatsControllerTest do end test "does not show CRM link to the site", %{conn: conn, site: site} do - conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) + resp = get(conn, "/" <> site.domain) |> html_response(200) - refute html_response(conn, 200) =~ "/cs/sites" + refute resp =~ "/cs/sites" end test "all segments (personal or site) are stuffed into dataset, with their associated owner_id and owner_name", @@ -387,11 +518,21 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" end - test "can enter verification when site is without stats", %{conn: conn} do - site = new_site() + test "can enter verification regardless of whether the site has stats or not", %{conn: conn} do + site_without_stats = new_site() + site_with_stats = new_site() + populate_stats(site_with_stats, [build(:pageview)]) + + for site <- [site_without_stats, site_with_stats] do + resp = + get( + conn, + "/#{site.domain}?verify_installation=true&flow=#{PlausibleWeb.Flows.review()}" + ) + |> html_response(200) - conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) - assert html_response(conn, 200) =~ "Verifying your installation" + assert element_exists?(resp, @verification_banner) + end end test "can view a private locked dashboard with stats", %{conn: conn} do @@ -405,13 +546,25 @@ defmodule PlausibleWeb.StatsControllerTest do assert resp =~ "This dashboard is actually locked" end - test "can view private locked verification without stats", %{conn: conn} do - user = new_user() - site = new_site(owner: user) - site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + test "can trigger verification on a locked private dashboard regardless of whether the site has stats or not", + %{conn: conn} do + site_without_stats = new_site(owner: new_user()) + site_without_stats.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + + site_with_stats = new_site(owner: new_user()) + populate_stats(site_with_stats, [build(:pageview)]) + site_with_stats.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + + for site <- [site_without_stats, site_with_stats] do + resp = + get( + conn, + "/#{site.domain}?verify_installation=true&flow=#{PlausibleWeb.Flows.review()}" + ) + |> html_response(200) - conn = get(conn, conn |> get("/#{site.domain}") |> redirected_to()) - assert html_response(conn, 200) =~ "Verifying your installation" + assert element_exists?(resp, @verification_banner) + end end test "can view a locked public dashboard", %{conn: conn} do @@ -424,12 +577,34 @@ defmodule PlausibleWeb.StatsControllerTest do assert resp =~ "This dashboard is actually locked" end + test "does not show email reports CTA when viewing as a super admin without site membership", + %{conn: conn} do + site = new_site(onboarding_status: :first_pageview) + + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-current-user-role") == "super_admin" + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + + test "still shows email reports CTA for a super admin who is also a real site member", + %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :first_pageview) + + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-current-user-role") == "owner" + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "true" + end + on_ee do test "shows CRM link to the site", %{conn: conn} do site = new_site() - conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) + resp = get(conn, "/" <> site.domain) |> html_response(200) - assert html_response(conn, 200) =~ + assert resp =~ Routes.customer_support_site_path(PlausibleWeb.Endpoint, :show, site.id) end end @@ -465,6 +640,18 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-current-user-role") == "public" end + test "never shows the email reports CTA, regardless of the site's onboarding_status", %{ + conn: conn + } do + site = new_site(onboarding_status: :first_pageview) + link = insert(:shared_link, site: site) + + conn = get(conn, "/share/#{site.domain}/?auth=#{link.slug}") + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "false" + end + test "if the shared link is limited to a segment, only that segment is stuffed into data-segments", %{ conn: conn diff --git a/test/plausible_web/live/components/verification_banner_test.exs b/test/plausible_web/live/components/verification_banner_test.exs new file mode 100644 index 000000000000..22d638c9d794 --- /dev/null +++ b/test/plausible_web/live/components/verification_banner_test.exs @@ -0,0 +1,244 @@ +defmodule PlausibleWeb.Live.Components.VerificationBannerTest do + use PlausibleWeb.ConnCase, async: true + + on_ee do + import Phoenix.LiveViewTest, only: [render_component: 2] + + alias Plausible.InstallationSupport.{State, Verification} + + @moduletag :capture_log + + @component PlausibleWeb.Live.Components.VerificationBanner + @banner "#verification-ui" + @progress ~s|#verification-ui p#progress| + + @loading_spinner ~s|#{@banner} svg.animate-spin| + @check_circle ~s|#{@banner} #check-circle| + @recommendations ~s|#recommendation| + @super_admin_report ~s|#super-admin-report| + + test "renders initial state" do + html = render_component(@component, domain: "example.com") + assert element_exists?(html, @progress) + + assert text_of_element(html, @progress) == + "We're visiting your site to ensure that everything is working..." + + assert element_exists?(html, @loading_spinner) + refute element_exists?(html, @recommendations) + refute element_exists?(html, @check_circle) + refute element_exists?(html, @super_admin_report) + end + + test "renders failed state without progress spinner" do + html = render_component(@component, domain: "example.com", success?: false, finished?: true) + refute element_exists?(html, @loading_spinner) + refute element_exists?(html, @check_circle) + refute element_exists?(html, @recommendations) + assert text_of_element(html, @banner) =~ "We couldn't verify your installation" + end + + test "renders diagnostic interpretation with inline verify link and standalone review-installation sentence" do + interpretation = + Verification.Checks.interpret_diagnostics(%State{ + url: "https://example.com", + data_domain: "example.com", + diagnostics: %Verification.Diagnostics{service_error: %{code: :domain_not_found}} + }) + + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + interpretation: interpretation + ) + + assert [recommendation] = html |> find(@recommendations) |> Enum.map(&text/1) + assert recommendation =~ "Check that the URL is correct and publicly accessible" + assert recommendation =~ "verify your installation manually" + refute recommendation =~ "review your installation" + assert recommendation =~ "See your installation instructions again here" + + assert element_exists?( + html, + ~s|#recommendation a[href="https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration"]| + ) + + assert element_exists?( + html, + ~s|#recommendation a[href="/example.com/installation?flow="]| + ) + + refute element_exists?(html, @super_admin_report) + end + + test "renders inline verify-manually link when the recommendation mentions it (no custom URL retry)" do + interpretation = + Verification.Checks.interpret_diagnostics(%State{ + url: "https://example.com", + data_domain: "example.com", + diagnostics: %Verification.Diagnostics{ + plausible_is_on_window: false, + selected_installation_type: "manual" + } + }) + + refute Map.get(interpretation.data || %{}, :offer_custom_url_input) == true + + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + interpretation: interpretation + ) + + assert [recommendation] = html |> find(@recommendations) |> Enum.map(&text/1) + assert recommendation =~ "Make sure you've copied the snippet" + assert recommendation =~ "verify your installation manually" + refute recommendation =~ "review your installation" + refute recommendation =~ "Learn more" + refute recommendation =~ "See your installation instructions again here" + + assert element_exists?( + html, + ~s|#recommendation a[href="https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration"]| + ) + + refute element_exists?(html, ~s|#recommendation a[href^="/example.com/installation"]|) + end + + test "renders super-admin report" do + state = %State{ + url: "https://example.com", + data_domain: "example.com", + diagnostics: %Verification.Diagnostics{} + } + + interpretation = Verification.Checks.interpret_diagnostics(state) + + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + interpretation: interpretation, + verification_state: state, + super_admin?: true + ) + + assert element_exists?(html, @super_admin_report) + assert text_of_element(html, @super_admin_report) =~ "Plausible is on window: nil" + end + + test "hides pulsating circle when finished, shows check circle" do + html = + render_component(@component, + domain: "example.com", + success?: true, + finished?: true + ) + + refute element_exists?(html, @loading_spinner) + assert element_exists?(html, @check_circle) + end + + test "renders a progress message" do + html = render_component(@component, domain: "example.com", message: "Arbitrary message") + + assert text_of_element(html, @progress) == "Arbitrary message..." + end + + test "renders contact link on >=3 attempts" do + html = render_component(@component, domain: "example.com", attempts: 2, finished?: true) + refute html =~ "Need help?" + refute element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) + + html = render_component(@component, domain: "example.com", attempts: 3, finished?: true) + assert html =~ "Need help?" + assert element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) + end + + test "renders a Try another URL ghost button when a custom URL retry is offered" do + interpretation = + Verification.Checks.interpret_diagnostics(%State{ + url: "example.com", + diagnostics: %Verification.Diagnostics{ + plausible_is_on_window: false, + plausible_is_initialized: false, + service_error: %{code: :domain_not_found} + } + }) + + assert interpretation.data.offer_custom_url_input == true + + html = + render_component(@component, + domain: "example.com", + finished?: true, + success?: false, + interpretation: interpretation + ) + + assert text_of_element(html, "#verify-custom-url-link") =~ "Try another URL" + assert element_exists?(html, ~s|a#verify-custom-url-link[phx-click="show-custom-url-form"]|) + refute html =~ "Review installation" + end + + test "renders the custom URL input inline, replacing Check again with the Verify URL submit button, and hides the secondary action" do + interpretation = + Verification.Checks.interpret_diagnostics(%State{ + url: "example.com", + diagnostics: %Verification.Diagnostics{ + plausible_is_on_window: false, + plausible_is_initialized: false, + service_error: %{code: :domain_not_found} + } + }) + + html = + render_component(@component, + domain: "example.com", + finished?: true, + success?: false, + interpretation: interpretation, + custom_url_input?: true + ) + + refute element_exists?(html, "#verify-custom-url-link") + refute element_exists?(html, ~s|a[phx-click="retry"]|) + refute html =~ "Review installation" + + assert text_of_element(html, ~s|form[phx-submit="verify-custom-url"] button[type="submit"]|) =~ + "Verify URL" + + assert element_exists?( + html, + ~s|form[phx-submit="verify-custom-url"] input[name="custom_url"]| + ) + + assert text_of_attr(html, ~s|form[phx-submit="verify-custom-url"] input|, "value") =~ + "https://example.com" + end + + test "offers a Review installation ghost button on failure by default" do + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + flow: PlausibleWeb.Flows.review() + ) + + refute element_exists?(html, ~s|a[href="/example.com/settings/general"]|) + + assert element_exists?( + html, + ~s|a[href="/example.com/installation?flow=review"]| + ) + + assert html =~ "Review installation" + end + end +end diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_test.exs deleted file mode 100644 index 2645de32cc4e..000000000000 --- a/test/plausible_web/live/components/verification_test.exs +++ /dev/null @@ -1,162 +0,0 @@ -defmodule PlausibleWeb.Live.Components.VerificationTest do - use PlausibleWeb.ConnCase, async: true - - on_ee do - import Phoenix.LiveViewTest, only: [render_component: 2] - - alias Plausible.InstallationSupport.{State, Verification} - - @moduletag :capture_log - - @component PlausibleWeb.Live.Components.Verification - @progress ~s|#verification-ui p#progress| - - @pulsating_circle ~s|div#verification-ui div.pulsating-circle| - @check_circle ~s|div#verification-ui #check-circle| - @error_circle ~s|div#verification-ui #error-circle| - @recommendations ~s|#recommendation| - @super_admin_report ~s|#super-admin-report| - - test "renders initial state" do - html = render_component(@component, domain: "example.com") - assert element_exists?(html, @progress) - - assert text_of_element(html, @progress) == - "We're visiting your site to ensure that everything is working" - - assert element_exists?(html, @pulsating_circle) - refute class_of_element(html, @pulsating_circle) =~ "hidden" - refute element_exists?(html, @recommendations) - refute element_exists?(html, @check_circle) - refute element_exists?(html, @super_admin_report) - end - - test "renders error badge on error" do - html = render_component(@component, domain: "example.com", success?: false, finished?: true) - refute element_exists?(html, @pulsating_circle) - refute element_exists?(html, @check_circle) - refute element_exists?(html, @recommendations) - assert element_exists?(html, @error_circle) - end - - test "renders diagnostic interpretation" do - interpretation = - Verification.Checks.interpret_diagnostics(%State{ - url: "https://example.com", - data_domain: "example.com", - diagnostics: %Verification.Diagnostics{service_error: %{code: :domain_not_found}} - }) - - html = - render_component(@component, - domain: "example.com", - success?: false, - finished?: true, - interpretation: interpretation - ) - - assert [recommendation] = html |> find(@recommendations) |> Enum.map(&text/1) - assert recommendation =~ "check that the domain you entered is correct" - - refute element_exists?(html, @super_admin_report) - end - - test "renders super-admin report" do - state = %State{ - url: "https://example.com", - data_domain: "example.com", - diagnostics: %Verification.Diagnostics{} - } - - interpretation = Verification.Checks.interpret_diagnostics(state) - - html = - render_component(@component, - domain: "example.com", - success?: false, - finished?: true, - interpretation: interpretation, - verification_state: state, - super_admin?: true - ) - - assert element_exists?(html, @super_admin_report) - assert text_of_element(html, @super_admin_report) =~ "Plausible is on window: nil" - end - - test "hides pulsating circle when finished, shows check circle" do - html = - render_component(@component, - domain: "example.com", - success?: true, - finished?: true - ) - - refute element_exists?(html, @pulsating_circle) - assert element_exists?(html, @check_circle) - end - - test "renders a progress message" do - html = render_component(@component, domain: "example.com", message: "Arbitrary message") - - assert text_of_element(html, @progress) == "Arbitrary message" - end - - test "renders contact link on >3 attempts" do - html = render_component(@component, domain: "example.com", attempts: 2, finished?: true) - refute html =~ "Need further help with your installation?" - refute element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) - - html = render_component(@component, domain: "example.com", attempts: 3, finished?: true) - assert html =~ "Need further help with your installation?" - assert element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) - end - - test "renders link to verify installation at a different URL" do - interpretation = - Verification.Checks.interpret_diagnostics(%State{ - url: "example.com", - diagnostics: %Verification.Diagnostics{ - plausible_is_on_window: false, - plausible_is_initialized: false, - service_error: %{code: :domain_not_found} - } - }) - - assert interpretation.data.offer_custom_url_input == true - - expected_link_href = - PlausibleWeb.Router.Helpers.site_path(PlausibleWeb.Endpoint, :verification, "example.com") - - html = - render_component(@component, - domain: "example.com", - finished?: true, - success?: false, - interpretation: interpretation - ) - - assert text_of_element(html, "#verify-custom-url-link") =~ "different URL?" - assert text_of_attr(html, "#verify-custom-url-link a", "href") =~ expected_link_href - assert text_of_attr(html, "#verify-custom-url-link a", "href") =~ "custom_url=true" - end - - test "offers escape paths: settings and installation instructions on failure" do - html = - render_component(@component, - domain: "example.com", - success?: false, - finished?: true, - installation_type: "wordpress", - flow: PlausibleWeb.Flows.review() - ) - - assert element_exists?(html, ~s|a[href="/example.com/settings/general"]|) - - assert element_exists?( - html, - ~s|a[href="/example.com/installation?flow=review&installation_type=wordpress"]| - ) - end - end -end diff --git a/test/plausible_web/live/installation_test.exs b/test/plausible_web/live/installation_test.exs index 84e379b321fa..794b6ec09f4d 100644 --- a/test/plausible_web/live/installation_test.exs +++ b/test/plausible_web/live/installation_test.exs @@ -8,6 +8,20 @@ defmodule PlausibleWeb.Live.InstallationTest do @migration_guide_link "https://plausible.io/docs/script-update-guide" + on_ee do + @manual_button_text "Verify Script installation" + @wordpress_button_text "Verify WordPress installation" + @gtm_button_text "Verify Tag Manager installation" + @npm_button_text "Verify NPM installation" + else + @shared_button_text "Proceed to dashboard" + + @manual_button_text @shared_button_text + @wordpress_button_text @shared_button_text + @gtm_button_text @shared_button_text + @npm_button_text @shared_button_text + end + setup [:create_user, :log_in, :create_site] describe "GET /:domain/installation" do @@ -43,7 +57,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert text(html) =~ "Verify WordPress installation" + assert text(html) =~ @wordpress_button_text end @tag :ee_only @@ -57,7 +71,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=wordpress") html = render_async(lv, 500) - assert text(html) =~ "Verify WordPress installation" + assert text(html) =~ @wordpress_button_text end @tag :ee_only @@ -71,7 +85,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=gtm") html = render_async(lv, 500) - assert text(html) =~ "Verify Tag Manager installation" + assert text(html) =~ @gtm_button_text end @tag :ee_only @@ -85,7 +99,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=npm") html = render_async(lv, 500) - assert text(html) =~ "Verify NPM installation" + assert text(html) =~ @npm_button_text end @tag :ee_only @@ -99,54 +113,64 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=manual") html = render_async(lv, 500) - assert text(html) =~ "Verify Script installation" + assert text(html) =~ @manual_button_text end - @tag :ee_only - test "allows switching between installation tabs (EE)", %{conn: conn, site: site} do - stub_dns() - stub_detection_manual() + on_ee do + test "allows switching between installation tabs (EE)", %{conn: conn, site: site} do + stub_dns() + stub_detection_manual() - {lv, _html} = get_lv(conn, site, "?type=manual") + {lv, _html} = get_lv(conn, site, "?type=manual") - html = render_async(lv, 500) - assert html =~ "Verify Script installation" + html = render_async(lv, 500) + assert html =~ @manual_button_text - lv - |> element("a[href*=\"type=wordpress\"]") - |> render_click() + lv + |> element("a[href*=\"type=wordpress\"]") + |> render_click() - html = render(lv) - assert html =~ "Verify WordPress installation" + html = render(lv) + assert html =~ @wordpress_button_text - lv - |> element("a[href*=\"type=gtm\"]") - |> render_click() + lv + |> element("a[href*=\"type=gtm\"]") + |> render_click() - html = render(lv) - assert html =~ "Verify Tag Manager installation" + html = render(lv) + assert html =~ @gtm_button_text - lv - |> element("a[href*=\"type=npm\"]") - |> render_click() + lv + |> element("a[href*=\"type=npm\"]") + |> render_click() - html = render(lv) - assert html =~ "Verify NPM installation" - end + html = render(lv) + assert html =~ @npm_button_text + end + else + test "allows switching between installation tabs (CE)", %{conn: conn, site: site} do + {lv, _html} = get_lv(conn, site) - @tag :ce_build_only - test "allows switching between installation tabs (CE)", %{conn: conn, site: site} do - {lv, _html} = get_lv(conn, site) + html = render_async(lv, 500) + assert html =~ "window.plausible" + assert html =~ @shared_button_text - html = render_async(lv, 500) - assert html =~ "Verify Script installation" + lv + |> element("a[href*=\"type=wordpress\"]") + |> render_click() - lv - |> element("a[href*=\"type=wordpress\"]") - |> render_click() + html = render(lv) + assert html =~ "https://plausible.io/wordpress-analytics-plugin" + assert html =~ @shared_button_text - html = render(lv) - assert html =~ "Verify WordPress installation" + lv + |> element("a[href*=\"type=npm\"]") + |> render_click() + + html = render(lv) + assert html =~ "@plausible-analytics/tracker" + assert html =~ @shared_button_text + end end test "manual installations has script snippet with expected ID", %{conn: conn, site: site} do @@ -159,7 +183,7 @@ defmodule PlausibleWeb.Live.InstallationTest do assert eventually(fn -> html = render(lv) - {html =~ "Verify Script installation", html} + {html =~ @manual_button_text, html} end) html = render(lv) @@ -178,7 +202,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text assert html =~ "Optional measurements" assert html =~ "Outbound links" assert html =~ "File downloads" @@ -194,7 +218,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text assert html =~ "Advanced options" assert html =~ "Manual tagging" assert html =~ "404 error pages" @@ -215,7 +239,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text config = TrackerScriptConfiguration |> Plausible.Repo.get_by!(site_id: site.id) assert config.outbound_links == true @@ -241,16 +265,18 @@ defmodule PlausibleWeb.Live.InstallationTest do on_ee do for {type, expected_text} <- [ - {"manual", "Verify Script installation"}, - {"wordpress", "Verify WordPress installation"}, - {"gtm", "Verify Tag Manager installation"}, - {"npm", "Verify NPM installation"} + {"manual", @manual_button_text}, + {"wordpress", @wordpress_button_text}, + {"gtm", @gtm_button_text}, + {"npm", @npm_button_text} ] do - test "submitting form with #{type} redirects to verification (EE)", %{ - conn: conn, - site: site - } do + test "submitting form with #{type} redirects to the dashboard with the verification banner (EE)", + %{ + conn: conn, + site: site + } do stub_dns() + stub_detection_manual() {lv, _html} = get_lv(conn, site, "?type=#{unquote(type)}") @@ -270,9 +296,9 @@ defmodule PlausibleWeb.Live.InstallationTest do assert_redirect( lv, - Routes.site_path(conn, :verification, site.domain, - flow: "provisioning", - installation_type: unquote(type) + Routes.stats_path(conn, :stats, site.domain, + verify_installation: true, + flow: "provisioning" ) ) end @@ -280,7 +306,10 @@ defmodule PlausibleWeb.Live.InstallationTest do end @tag :ce_build_only - test "submitting the form redirects to verification (CE)", %{conn: conn, site: site} do + test "submitting the form redirects straight to the dashboard, no banner (CE)", %{ + conn: conn, + site: site + } do {lv, _html} = get_lv(conn, site) lv @@ -294,13 +323,7 @@ defmodule PlausibleWeb.Live.InstallationTest do } }) - assert_redirect( - lv, - Routes.site_path(conn, :verification, site.domain, - flow: "provisioning", - installation_type: "manual" - ) - ) + assert_redirect(lv, Routes.stats_path(conn, :stats, site.domain)) end test "404 goal gets created regardless of user options", %{conn: conn, site: site} do @@ -312,7 +335,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text # Test with all options disabled lv @@ -331,10 +354,11 @@ defmodule PlausibleWeb.Live.InstallationTest do assert Enum.any?(goals, &(&1.event_name == "404")) end - test "submitting form with review flow redirects to verification with flow param", %{ - conn: conn, - site: site - } do + test "submitting form with review flow redirects to the dashboard with the flow param preserved", + %{ + conn: conn, + site: site + } do on_ee do stub_dns() stub_detection_manual() @@ -343,7 +367,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text lv |> element("form[phx-submit='submit']") @@ -356,13 +380,19 @@ defmodule PlausibleWeb.Live.InstallationTest do } }) - assert_redirect( - lv, - Routes.site_path(conn, :verification, site.domain, - flow: "review", - installation_type: "manual" + on_ee do + assert_redirect( + lv, + Routes.stats_path(conn, :stats, site.domain, + verify_installation: true, + flow: "review" + ) ) - ) + end + + on_ce do + assert_redirect(lv, Routes.stats_path(conn, :stats, site.domain)) + end end @tag :ee_only @@ -398,7 +428,7 @@ defmodule PlausibleWeb.Live.InstallationTest do html = render_async(lv, 500) refute text(html) =~ "We've detected your website is using WordPress" - assert text(html) =~ "Verify Script installation" + assert text(html) =~ @manual_button_text end @tag :ee_only @@ -409,7 +439,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify Tag Manager installation" + assert html =~ @gtm_button_text assert text(html) =~ "We've detected your website is using Google Tag Manager" end @@ -429,7 +459,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify NPM installation" + assert html =~ @npm_button_text end @tag :ee_only @@ -468,7 +498,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=wordpress") html = render_async(lv, 500) - assert html =~ "Verify WordPress installation" + assert html =~ @wordpress_button_text refute element_exists?(html, "a[href='#{@migration_guide_link}']") end @@ -485,7 +515,7 @@ defmodule PlausibleWeb.Live.InstallationTest do assert eventually(fn -> html = render(lv) # Should default to manual installation when detection returns {:error, _} - {html =~ "Verify Script installation", html} + {html =~ @manual_button_text, html} end) end) end @@ -503,7 +533,7 @@ defmodule PlausibleWeb.Live.InstallationTest do html = render_async(lv, 500) # Should default to manual installation when detection returns {:error, _} - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end) end end @@ -530,7 +560,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end test "allows editor access to installation page", %{conn: conn, user: user} do @@ -545,7 +575,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end end @@ -563,7 +593,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?type=invalid") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end test "falls back to provisioning flow when invalid flow parameter supplied", %{ @@ -578,7 +608,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?flow=invalid") html = render_async(lv, 500) - assert html =~ "Verify Script installation" + assert html =~ @manual_button_text end end @@ -598,7 +628,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site) html = render_async(lv, 500) - assert html =~ "Verify Tag Manager installation" + assert html =~ @gtm_button_text end end @@ -622,7 +652,7 @@ defmodule PlausibleWeb.Live.InstallationTest do {lv, _} = get_lv(conn, site, "?flow=review") html = render_async(lv, 500) - assert html =~ "Verify WordPress installation" + assert html =~ @wordpress_button_text end end diff --git a/test/plausible_web/live/sites_test.exs b/test/plausible_web/live/sites_test.exs index add5590f8443..e78e9fbd5834 100644 --- a/test/plausible_web/live/sites_test.exs +++ b/test/plausible_web/live/sites_test.exs @@ -152,6 +152,71 @@ defmodule PlausibleWeb.Live.SitesTest do end on_ee do + describe "pending setup badge and verification query parameter" do + @tag :ee_only + test "shows for a site with onboarding_status :new_site", %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :new_site) + + {:ok, _lv, html} = live(conn, "/sites") + + site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]") + assert site_card =~ "Setup pending" + + dashboard_link_href = text_of_attr(html, "li[data-domain=\"#{site.domain}\"] > a", "href") + assert dashboard_link_href =~ "verify_installation=true" + + assert Repo.reload!(site).onboarding_status == :new_site + end + + @tag :ee_only + test "advances onboarding_status (and hides the badge) when the site already has visitors, before its dashboard has ever been loaded", + %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :new_site) + populate_stats(site, [build(:pageview)]) + + {:ok, _lv, html} = live(conn, "/sites") + + site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]") + refute site_card =~ "Setup pending" + + dashboard_link_href = text_of_attr(html, "li[data-domain=\"#{site.domain}\"] > a", "href") + refute dashboard_link_href =~ "verify_installation=true" + + assert Repo.reload!(site).onboarding_status == :first_pageview + end + + for status <- [:verification_succeeded, :first_pageview, :completed] do + @tag :ee_only + test "does not show once onboarding_status has moved to #{status} (even with stats_start_date reset)", + %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: unquote(status), stats_start_date: nil) + + {:ok, _lv, html} = live(conn, "/sites") + + site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]") + refute site_card =~ "Setup pending" + + dashboard_link_href = + text_of_attr(html, "li[data-domain=\"#{site.domain}\"] > a", "href") + + refute dashboard_link_href =~ "verify_installation=true" + end + end + + @tag :ce_build_only + test "never shows on CE", %{conn: conn, user: user} do + site = new_site(owner: user, onboarding_status: :new_site) + + {:ok, _lv, html} = live(conn, "/sites") + + site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]") + refute site_card =~ "Setup pending" + + dashboard_link_href = text_of_attr(html, "li[data-domain=\"#{site.domain}\"] > a", "href") + refute dashboard_link_href =~ "verify_installation=true" + end + end + describe "consolidated views appearance" do test "consolidated view shows up", %{conn: conn, user: user} do new_site(owner: user) diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs index 1e2928306459..61c15140b354 100644 --- a/test/plausible_web/live/verification_test.exs +++ b/test/plausible_web/live/verification_test.exs @@ -5,37 +5,43 @@ defmodule PlausibleWeb.Live.VerificationTest do import Phoenix.LiveViewTest + alias Plausible.Repo + alias Plausible.Site + @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| + @heading ~s|#verification-ui h3| + @banner ~s|#verification-ui| + + @in_progress_text "Verifying your installation" describe "GET /:domain" do @tag :ee_only - test "static verification screen renders", %{conn: conn, site: site} do + test "static verification banner renders on a freshly provisioned site", %{ + conn: conn, + site: site + } do resp = - get(conn, conn |> no_slowdown() |> get("/#{site.domain}") |> redirected_to) + conn + |> no_slowdown() + |> get("/#{site.domain}?verify_installation=true") |> 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" + assert resp =~ @in_progress_text 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) + test "no verification banner renders on CE", %{conn: conn, site: site} do + resp = get(conn, "/#{site.domain}") |> html_response(200) - assert resp =~ "Awaiting your first pageview …" + refute resp =~ "verification-ui" end end @@ -51,45 +57,61 @@ defmodule PlausibleWeb.Live.VerificationTest do {_, html} = get_lv(conn, site) - assert html =~ "Verifying your installation" + assert html =~ @in_progress_text 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 + test "clicking the custom URL link reveals an inline form next to the retry button, submitting kicks off a new run", + %{ + conn: conn, + site: site + } do stub_dns() stub_verification_result(%{ - "completed" => false, - "error" => %{"message" => "Error"} + "completed" => true, + "trackerIsInHtml" => false, + "plausibleIsOnWindow" => false, + "plausibleIsInitialized" => false }) - # Get liveview with ?custom_url=true query param - {:ok, lv, html} = - conn |> no_slowdown() |> live("/#{site.domain}/verification?custom_url=true") + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + + { + text_of_element(html, @heading) =~ "We couldn't detect Plausible on your site", + html + } + end) + + html = lv |> render_click("show-custom-url-form") + + refute html =~ @in_progress_text + + refute element_exists?(html, @retry_button) + refute element_exists?(html, "#verify-custom-url-link") - verifying_installation_text = "Verifying your installation" + assert element_exists?( + html, + ~s|form[phx-submit="verify-custom-url"] input[name="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 - # Submit custom URL form - html = lv |> element("form") |> render_submit(%{"custom_url" => "https://abc.de"}) + lv + |> element("form[phx-submit='verify-custom-url']") + |> 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" + assert eventually(fn -> + html = render(lv) + {html =~ @in_progress_text, html} + end) end @tag :ee_only @@ -113,25 +135,46 @@ defmodule PlausibleWeb.Live.VerificationTest do assert eventually(fn -> html = render(lv) - - { - text_of_element(html, @awaiting) =~ - "Awaiting your first pageview", - html - } + {html =~ "Tracking is active on your site", html} end) + end - html = render(lv) - assert html =~ "Success!" - assert html =~ "Awaiting your first pageview" + for {flow, message} <- %{ + PlausibleWeb.Flows.review() => "Visitors are being counted correctly.", + PlausibleWeb.Flows.domain_change() => + "Visitors are being counted correctly on your new domain." + } do + @tag :ee_only + test "shows a flow-specific success message for flow=#{flow}", %{conn: conn, site: site} do + stub_dns() + + 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, unquote(flow)) + + assert eventually(fn -> + html = render(lv) + {html =~ unquote(message), html} + end) + end end @tag :ee_only - test "won't await first pageview if site has pageviews", %{conn: conn, site: site} do - populate_stats(site, [ - build(:pageview) - ]) - + test "advances onboarding_status to :verification_succeeded on success", %{ + conn: conn, + site: site + } do stub_dns() stub_verification_result(%{ @@ -151,20 +194,53 @@ defmodule PlausibleWeb.Live.VerificationTest do assert eventually(fn -> html = render(lv) - - { - text(html) =~ "Success", - html - } + {html =~ "Tracking is active on your site", html} end) - html = render(lv) + assert Repo.reload!(site).onboarding_status == :verification_succeeded + end + + for flow <- [PlausibleWeb.Flows.review(), PlausibleWeb.Flows.domain_change()] do + @tag :ee_only + test "advances onboarding_status to :verification_succeeded on first success via flow=#{flow}", + %{conn: conn, site: site} do + assert site.onboarding_status == :new_site + + stub_dns() - refute text_of_element(html, @awaiting) =~ "Awaiting your first pageview" - refute_redirected(lv, "/#{URI.encode_www_form(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, unquote(flow)) + + assert eventually(fn -> + html = render(lv) + {html =~ "Tracking is active on your site", html} + end) + + assert Repo.reload!(site).onboarding_status == :verification_succeeded + end end - test "will redirect when first pageview arrives", %{conn: conn, site: site} do + @tag :ee_only + test "does not regress onboarding_status if already past :verification_succeeded", %{ + conn: conn, + site: site + } do + site + |> Site.put_onboarding_status_advance(:completed) + |> Repo.update!() + stub_dns() stub_verification_result(%{ @@ -180,64 +256,121 @@ defmodule PlausibleWeb.Live.VerificationTest do } }) + {:ok, lv} = kick_off_live_verification(conn, site, PlausibleWeb.Flows.review()) + + assert eventually(fn -> + html = render(lv) + {html =~ "Tracking is active on your site", html} + end) + + assert Repo.reload!(site).onboarding_status == :completed + end + + @tag :ee_only + test "does not advance onboarding_status when verification fails", %{ + conn: conn, + site: site + } do + stub_dns() + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => false, + "plausibleIsOnWindow" => false, + "plausibleIsInitialized" => false + }) + {:ok, lv} = kick_off_live_verification(conn, site) assert eventually(fn -> html = render(lv) { - text(html) =~ "Awaiting", + text_of_element(html, @heading) =~ "We couldn't detect Plausible on your site", html } end) - populate_stats(site, [ - build(:pageview) - ]) - - assert_redirect(lv, Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain)) + assert Repo.reload!(site).onboarding_status == :new_site end - @tag :ce_build_only - test "will redirect when first pageview arrives (ce)", %{conn: conn, site: site} do + @tag :ee_only + test "the dismissed flag keeps the banner hidden even if a late update arrives while still connected", + %{conn: conn, site: site} do + stub_dns() + + 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) html = render(lv) - assert text(html) =~ "Awaiting your first pageview …" + assert html =~ @in_progress_text + refute class_of_element(html, @banner) =~ "hidden" + + html = render_click(lv, "dismiss") + assert class_of_element(html, @banner) =~ "hidden" - populate_stats(site, [build(:pageview)]) + # This might look a bit counter-intuitive -- dismissing the banner + # closes the websocket connection and the LV process would normally + # die before the component gets notified of success. + + # However, `Phoenix.LiveViewTest` can't simulate a real socket closing, + # so the process here just stays alive regardless. What this guards is + # the defensive `dismissed?` gate itself: if this process is ever still + # around when a late update arrives, for whatever reason, the banner + # must stay hidden. + assert eventually(fn -> + html = render(lv) + {html =~ "Tracking is active on your site", html} + end) - assert_redirect(lv, Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain)) + html = render(lv) + assert class_of_element(html, @banner) =~ "hidden" 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.", + @tag :ee_only + test "dismissing tells the client to close the websocket connection", + %{conn: conn, site: site} do + stub_dns() + + stub_verification_result(%{ + "completed" => false, + "error" => %{"message" => "Error"} + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + render_click(lv, "dismiss") + + assert_push_event(lv, "disconnect-liveview", %{}) + end + + for {expected_text, saved_installation_type} <- [ + {"Make sure you've copied the snippet to the head of your site, or verify your installation manually.", + "manual"}, + {"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.", + {"Make sure you've configured the GTM template correctly, or verify your installation manually.", + "gtm"}, + {"Make sure you've enabled the WordPress plugin, or verify your installation manually.", + "wordpress"}, + # falls back to manual when there's no saved installation type + {"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)}", + test "eventually fails to verify installation if saved installation type is #{inspect(saved_installation_type)}", %{ conn: conn, site: site @@ -257,51 +390,58 @@ defmodule PlausibleWeb.Live.VerificationTest do }) 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) + {:ok, lv} = kick_off_live_verification(conn, site) + + html = + eventually(fn -> + html = render(lv) + + { + 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)) + assert text_of_element(html, "#recommendation") =~ 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}") + defp get_lv(conn, site) do + {:ok, lv, html} = + conn |> no_slowdown() |> as_live() |> live(verification_path(site)) + {lv, html} end - defp kick_off_live_verification(conn, site, qs \\ nil) do + defp kick_off_live_verification(conn, site, flow \\ nil) do {:ok, lv, _html} = - conn |> no_slowdown() |> no_delay() |> live("/#{site.domain}/verification#{qs}") + conn |> no_slowdown() |> no_delay() |> as_live() |> live(verification_path(site, flow)) {:ok, lv} end + # `PlausibleWeb.Live.Verification` is rendered via `live_render/3` from a + # plain controller-rendered page rather than a live router route, so it + # never carries the `:live_module` assign `Phoenix.LiveViewTest.live/2` + # looks for. Mirrors the same workaround used in other live_render-embedded + # LiveView tests (e.g. props_settings_test.exs). + defp as_live(conn), do: assign(conn, :live_module, PlausibleWeb.Live.Verification) + + defp verification_path(site, flow \\ nil) do + base = "/#{site.domain}?verify_installation=true" + if flow, do: base <> "&flow=#{flow}", else: base + 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) + Plug.Conn.put_private(conn, :launch_delay, 0) end defp stub_verification_result(js_data) do diff --git a/test/support/dev/controllers/e2e_controller.ex b/test/support/dev/controllers/e2e_controller.ex index bd61fdfbf2e1..165992b82ee7 100644 --- a/test/support/dev/controllers/e2e_controller.ex +++ b/test/support/dev/controllers/e2e_controller.ex @@ -51,6 +51,7 @@ defmodule PlausibleWeb.E2EController do site |> Plausible.Site.set_native_stats_start_at(stats_start_time) |> Plausible.Site.set_stats_start_date(stats_start_date) + |> Plausible.Site.put_onboarding_status_advance(:first_pageview) |> Plausible.Repo.update!() populate(events, site) @@ -96,6 +97,28 @@ defmodule PlausibleWeb.E2EController do send_resp(conn, 200, Jason.encode!(%{"ok" => true})) end + def put_verification_scenario(conn, %{"domain" => domain, "scenario" => scenario} = params) do + opts = [ + slowdown: params["options"]["slowdown"] || 0, + launch_delay: params["options"]["launch_delay"] || 0 + ] + + # Registering a new scenario is treated as the start of a fresh + # verification "phase" in e2e specs - reset the rate limit so a spec + # exercising multiple scenarios/flows against the same domain doesn't + # hit the real per-domain verification rate limit. + rate_limit_key = "site_verification:#{domain}" + :ets.select_delete(Plausible.RateLimit, [{{{rate_limit_key, :_}, :_, :_}, [], [true]}]) + + case Plausible.InstallationSupport.Verification.MockScenarios.put(domain, scenario, opts) do + :ok -> + send_resp(conn, 200, Jason.encode!(%{"ok" => true})) + + {:error, :unknown_scenario} -> + send_resp(conn, 422, Jason.encode!(%{"error" => "Unknown scenario: #{scenario}"})) + end + end + defp get_goal(site, name) do Plausible.Repo.get_by!(Plausible.Goal, site_id: site.id, display_name: name) end diff --git a/test/support/factory.ex b/test/support/factory.ex index 6e2fa3a49032..e534bb72514b 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -85,7 +85,8 @@ defmodule Plausible.Factory do site = %Plausible.Site{ native_stats_start_at: ~N[2000-01-01 00:00:00], domain: domain, - timezone: "Etc/UTC" + timezone: "Etc/UTC", + onboarding_status: :new_site } merge_attributes(site, attrs) diff --git a/test/support/installation_support/verification/checks_mock.ex b/test/support/installation_support/verification/checks_mock.ex new file mode 100644 index 000000000000..b25a3ed2951f --- /dev/null +++ b/test/support/installation_support/verification/checks_mock.ex @@ -0,0 +1,212 @@ +# This file lives under `test/support` (rather than `extra/lib`, alongside +# the rest of the EE-only installation-support code it depends on - +# `Diagnostics`, `State`, `CheckRunner`, `Checks`, `Check`) so it's available +# in the `:dev` env too - see `Plausible.InstallationSupport.MockScenarios` +# and `ChecksMock`'s moduledocs. `test/support` also compiles under +# `:ce_test`/`:ce_dev`, where none of those EE-only dependencies exist, so +# both modules are wrapped in a single `on_ee` block - under CE builds, +# neither is defined at all, matching the fact that this whole feature +# (installation verification) doesn't exist there. +use Plausible + +on_ee do + defmodule Plausible.InstallationSupport.Verification.MockScenarios do + @moduledoc """ + Per-domain registry of forced verification outcomes. It's a public ETS + table owned by a simple GenServer process. Used to bypass the real DNS + lookup and browserless checks when iterating on verification banner UI + locally, or when driving it from Playwright e2e specs. + """ + + use GenServer + + alias Plausible.InstallationSupport.Verification.Diagnostics + + @table __MODULE__ + + @type scenario :: %{ + interpretation_result: atom(), + slowdown: non_neg_integer() | nil, + launch_delay: non_neg_integer() | nil + } + + def start_link(_opts) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl true + def init(nil) do + :ets.new(@table, [:set, :public, :named_table, read_concurrency: true]) + {:ok, nil} + end + + @doc """ + Registers a mock verification for `domain`. + + `key` (an atom or a string) must name a scenario recognized by + `Diagnostics.named_result!/2` - see `Diagnostics.named_scenario_keys/0`. + Returns `{:error, :unknown_scenario}` otherwise. + + ### Opts + + * `:slowdown` - overrides the check pipeline's default per-check delay + * `:launch_delay` - overrides the delay before the first check starts + """ + @spec put(String.t(), atom() | String.t(), Keyword.t()) :: :ok | {:error, :unknown_scenario} + def put(domain, key, opts \\ []) when is_binary(domain) do + with {:ok, key} <- resolve_key(key) do + scenario = %{ + interpretation_result: key, + slowdown: Keyword.get(opts, :slowdown), + launch_delay: Keyword.get(opts, :launch_delay) + } + + :ets.insert(@table, {domain, scenario}) + :ok + end + end + + defp resolve_key(key) when is_atom(key) do + if key in Diagnostics.named_scenario_keys(), + do: {:ok, key}, + else: {:error, :unknown_scenario} + end + + defp resolve_key(key) when is_binary(key) do + case Diagnostics.named_scenario_from_string(key) do + {:ok, key} -> {:ok, key} + :error -> {:error, :unknown_scenario} + end + end + + @doc "Returns the scenario registered for `domain`, or `nil` if none was set." + @spec get(String.t()) :: scenario() | nil + def get(domain) when is_binary(domain) do + case :ets.lookup(@table, domain) do + [{^domain, scenario}] -> scenario + [] -> nil + end + end + end + + defmodule Plausible.InstallationSupport.Verification.ChecksMock do + @moduledoc """ + Drop-in replacement for `Plausible.InstallationSupport.Verification.Checks` + that never performs a real DNS lookup or browserless check for a domain + with a registered mock scenario. Used locally (`:dev`) and in Playwright + e2e specs (`:e2e_test`) to deterministically drive Verification banner UI. + + When no scenario is registered for a domain: + + * in `:dev`, falls back to the real `Checks` module - casually loading a + site with `?verify_installation=true` still verifies for real unless + you've deliberately mocked that domain. + + * everywhere else (`:e2e_test`, and `:test` for this module's own + tests), raises - every e2e spec that drives verification is expected + to register a scenario before triggering it, and it shouldn't + silently fall back to a real, slow, non-deterministic check. + """ + + alias Plausible.InstallationSupport.{State, CheckRunner, Checks} + alias Plausible.InstallationSupport.Verification.{Diagnostics, MockScenarios} + alias Plausible.InstallationSupport.Verification.Checks, as: RealChecks + + defmodule FakeUrlCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: Checks.Url.report_progress_as() + + @impl true + def perform(state, _opts), do: state + end + + defmodule FakeVerifyInstallationCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: Checks.VerifyInstallation.report_progress_as() + + @impl true + def perform(state, _opts), do: state + end + + defmodule FakeVerifyInstallationCacheBustCheck do + @moduledoc false + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: Checks.VerifyInstallationCacheBust.report_progress_as() + + @impl true + def perform(state, _opts), do: state + end + + @spec run(String.t(), String.t(), String.t(), Keyword.t()) :: {:ok, pid()} | State.t() + def run(url, data_domain, installation_type, opts \\ []) do + case MockScenarios.get(data_domain) do + nil -> + raise_unless_dev_env!(data_domain) + RealChecks.run(url, data_domain, installation_type, opts) + + scenario -> + run_mocked(url, data_domain, installation_type, opts, scenario) + end + end + + defp run_mocked(url, data_domain, installation_type, opts, scenario) do + report_to = Keyword.get(opts, :report_to, self()) + async? = Keyword.get(opts, :async?, true) + slowdown = scenario.slowdown || Keyword.get(opts, :slowdown, 500) + launch_delay = scenario.launch_delay || Keyword.get(opts, :launch_delay, 500) + + init_state = %State{ + url: url || "https://#{data_domain}", + data_domain: data_domain, + report_to: report_to, + diagnostics: %Diagnostics{selected_installation_type: installation_type} + } + + checks = [ + {FakeUrlCheck, []}, + {FakeVerifyInstallationCheck, []}, + {FakeVerifyInstallationCacheBustCheck, []} + ] + + CheckRunner.run(init_state, checks, + async?: async?, + report_to: report_to, + slowdown: slowdown, + launch_delay: launch_delay + ) + end + + @spec interpret_diagnostics(State.t()) :: Plausible.InstallationSupport.Result.t() + def interpret_diagnostics(%State{data_domain: data_domain} = state) do + case MockScenarios.get(data_domain) do + nil -> + raise_unless_dev_env!(data_domain) + RealChecks.interpret_diagnostics(state) + + scenario -> + Diagnostics.named_result!(scenario.interpretation_result, + installation_type: state.diagnostics.selected_installation_type, + attempted_url: state.url, + page_response_status: 500 + ) + end + end + + defp raise_unless_dev_env!(data_domain) do + if Mix.env() != :dev do + raise """ + ChecksMock was used to verify #{inspect(data_domain)}, but no scenario \ + is registered for it. Call MockScenarios.put/3 first. + """ + end + end + end +end diff --git a/test/workers/import_analytics_test.exs b/test/workers/import_analytics_test.exs index 42613cbaa4a6..4af13ff2b850 100644 --- a/test/workers/import_analytics_test.exs +++ b/test/workers/import_analytics_test.exs @@ -59,7 +59,10 @@ defmodule Plausible.Workers.ImportAnalyticsTest do site = Repo.reload!(site) assert site.stats_start_date == nil - assert Plausible.Sites.stats_start_date(site) == import_opts[:start_date] + + assert Plausible.Sites.ensure_stats_start_date(site).stats_start_date == + import_opts[:start_date] + assert Repo.reload!(site).stats_start_date == import_opts[:start_date] end