diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 9d3db71650e3..90b3ad2448fc 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -113,6 +113,147 @@ jobs: env: MIX_TEST_PARTITION: ${{ matrix.mix_test_partition }} + e2e: + name: End-to-end tests + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + MIX_ENV: e2e_test + BASE_URL: "http://localhost:8000" + services: + postgres: + image: "postgres:18" + ports: + - 5432:5432 + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + clickhouse: + image: clickhouse/clickhouse-server:25.11.5.8-alpine + ports: + - 8123:8123 + env: + CLICKHOUSE_SKIP_USER_SETUP: 1 + options: >- + --health-cmd nc -zw3 localhost 8124 + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2] + shardTotal: [2] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: marocchino/tool-versions-action@v1 + id: versions + + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ steps.versions.outputs.elixir }} + otp-version: ${{ steps.versions.outputs.erlang }} + + - uses: actions/cache@v5 + with: + path: | + deps + _build + assets/node_modules + tracker/node_modules + priv/tracker/js + priv/tracker/installation_support + ${{ env.PERSISTENT_CACHE_DIR }} + key: e2e-${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + e2e-${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}- + e2e-${{ env.MIX_ENV }}-${{ env.CACHE_VERSION }}-refs/heads/master- + + - name: Cache E2E dependencies and Playwright browsers + uses: actions/cache@v5 + id: playwright-cache + with: + path: | + e2e/node_modules + ~/.cache/ms-playwright + ~/.cache/ms-playwright-github + key: playwright-${{ runner.os }}-${{ hashFiles('e2e/package-lock.json') }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Check for changes in tracker/** + uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + tracker: + - 'tracker/**' + + - run: npm install --prefix ./tracker + - run: npm run deploy --prefix ./tracker + - run: mix deps.get --only $MIX_ENV + - run: mix compile --warnings-as-errors --all-warnings + - run: npm install --prefix ./assets + - run: mix assets.deploy + - run: mix do ecto.create, ecto.migrate + - run: mix download_country_database + - run: mix run -e "Tzdata.ReleaseUpdater.poll_for_update" + + - name: Setup E2E seeds + run: mix run priv/repo/e2e_seeds.exs + + - name: Install E2E dependencies + run: npm --prefix ./e2e ci + + - name: Install E2E Playwright Browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: ./e2e + run: npx playwright install --with-deps chromium + + - name: Run E2E Playwright tests + run: npm --prefix ./e2e test -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob + + - name: Upload E2E blob report to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v6 + with: + name: e2e-blob-report-${{ matrix.shardIndex }} + path: e2e/blob-report + retention-days: 1 + + merge-sharded-e2e-test-report: + if: ${{ !cancelled() }} + needs: [e2e] + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 23.2.0 + cache: 'npm' + cache-dependency-path: e2e/package-lock.json + - name: Install dependencies + run: npm --prefix ./e2e ci + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@v7 + with: + path: all-e2e-blob-reports + pattern: e2e-blob-report-* + merge-multiple: true + + - name: Merge into list report + working-directory: ./e2e + run: npx playwright merge-reports --reporter list ../all-e2e-blob-reports + static: name: Static checks (format, credo, dialyzer) env: @@ -125,6 +266,7 @@ jobs: - uses: marocchino/tool-versions-action@v1 id: versions + - uses: erlef/setup-beam@v1 with: elixir-version: ${{ steps.versions.outputs.elixir }} diff --git a/config/.env.e2e_test b/config/.env.e2e_test new file mode 100644 index 000000000000..052f02eab8ae --- /dev/null +++ b/config/.env.e2e_test @@ -0,0 +1,38 @@ +BASE_URL=http://localhost:8000 +SECURE_COOKIE=false +DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/plausible_e2e +CLICKHOUSE_DATABASE_URL=http://127.0.0.1:8123/plausible_e2e +CLICKHOUSE_MAX_BUFFER_SIZE_BYTES=1000000 +SECRET_KEY_BASE=/njrhntbycvastyvtk1zycwfm981vpo/0xrvwjjvemdakc/vsvbrevlwsc6u8rcg +TOTP_VAULT_KEY=Q3BD4nddbkVJIPXgHuo5NthGKSIH0yesRfG05J88HIo= +ENVIRONMENT=dev +MAILER_ADAPTER=Bamboo.LocalAdapter +LOG_LEVEL=warning +SELFHOST=false +DISABLE_CRON=true +ADMIN_USER_IDS=1 +SHOW_CITIES=true +PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a +PADDLE_VENDOR_ID=3942 +SSO_VERIFICATION_NAMESERVERS=0.0.0.0:5354 + +GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + +PROMEX_DISABLED=false +SITE_DEFAULT_INGEST_THRESHOLD=1000000 + +S3_DISABLED=false +S3_ACCESS_KEY_ID=minioadmin +S3_SECRET_ACCESS_KEY=minioadmin +S3_REGION=us-east-1 +S3_ENDPOINT=http://localhost:10000 +S3_EXPORTS_BUCKET=dev-exports +S3_IMPORTS_BUCKET=dev-imports + +HELP_SCOUT_APP_ID=fake_app_id +HELP_SCOUT_APP_SECRET=fake_app_secret +HELP_SCOUT_SIGNATURE_KEY=fake_signature_key +HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM= + +VERIFICATION_ENABLED=true diff --git a/config/e2e_test.exs b/config/e2e_test.exs new file mode 100644 index 000000000000..697727ec3a2c --- /dev/null +++ b/config/e2e_test.exs @@ -0,0 +1,18 @@ +import Config + +config :plausible, PlausibleWeb.Endpoint, + server: true, + check_origin: false + +config :plausible, paddle_api: Plausible.Billing.DevPaddleApiMock + +config :phoenix, :stacktrace_depth, 20 +config :phoenix, :plug_init_mode, :runtime + +config :bcrypt_elixir, :log_rounds, 4 + +config :plausible, Plausible.Ingestion.Counters, enabled: false + +config :plausible, Oban, testing: :manual + +config :plausible, Plausible.Session.Salts, interval: :timer.hours(1) diff --git a/config/runtime.exs b/config/runtime.exs index 0d2e62860151..0ecd2d04d558 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -14,6 +14,10 @@ if config_env() == :ce_test do Envy.load(["config/.env.test"]) end +if config_env() == :e2e_test do + Envy.load(["config/.env.e2e_test"]) +end + config_dir = System.get_env("CONFIG_DIR", "/run/secrets") log_format = diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000000..99a51372c2ed --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,9 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/output/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 000000000000..f76d66a0eace --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,97 @@ +{ + "name": "e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "e2e", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.58.0", + "@types/node": "^25.1.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", + "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", + "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", + "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000000..20338fef761b --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "e2e", + "scripts": { + "test": "npx playwright test", + "test:ui": "npx playwright test --ui" + }, + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.58.0", + "@types/node": "^25.1.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000000..0cada02b4edc --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,72 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: process.env.BASE_URL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + cwd: '..', + command: 'mix phx.server', + url: process.env.BASE_URL, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/e2e/tests/general.spec.ts b/e2e/tests/general.spec.ts new file mode 100644 index 000000000000..fac75819d60f --- /dev/null +++ b/e2e/tests/general.spec.ts @@ -0,0 +1,147 @@ +import { test, expect } from "@playwright/test"; + +const baseUrl = process.env.BASE_URL; + +test("dashboard renders", async ({ page }) => { + await page.goto("/public.example.com"); + + await expect(page).toHaveTitle(/Plausible/); + + await expect( + page.getByRole("button", { name: "public.example.com" }), + ).toBeVisible(); +}); + +test("filter is applied", async ({ page }) => { + await page.goto("/public.example.com"); + + await expect(page.getByRole("link", { name: "Page" })).toBeHidden(); + + await page.getByRole("button", { name: "Filter" }).click(); + + await expect(page.getByRole("link", { name: "Page" })).toHaveCount(1); + + await page.getByRole("link", { name: "Page" }).click(); + + await expect(page).toHaveURL(baseUrl + "/public.example.com/filter/page"); + + await expect( + page.getByRole("heading", { name: "Filter by Page" }), + ).toBeVisible(); + + await expect( + page.getByRole("button", { name: "Apply filter", disabled: true }), + ).toHaveCount(1); + + await page.getByPlaceholder("Select a Page").click(); + + await expect( + page.getByRole("button", { name: "Apply filter", disabled: true }), + ).toHaveCount(1); + + await expect( + page.getByRole("listitem").filter({ hasText: "/page1" }), + ).toBeVisible(); + + await expect( + page.getByRole("listitem").filter({ hasText: "/page2" }), + ).toBeVisible(); + + await expect( + page.getByRole("listitem").filter({ hasText: "/page3" }), + ).toBeVisible(); + + await expect( + page.getByRole("listitem").filter({ hasText: "/other" }), + ).toBeVisible(); + + await page.getByPlaceholder("Select a Page").fill("pag"); + + await expect( + page.getByRole("listitem").filter({ hasText: "/page1" }), + ).toBeVisible(); + + await expect( + page.getByRole("listitem").filter({ hasText: "/page2" }), + ).toBeVisible(); + + await expect( + page.getByRole("listitem").filter({ hasText: "/page3" }), + ).toBeVisible(); + + await expect( + page.getByRole("listitem").filter({ hasText: "/other" }), + ).toBeHidden(); + + await page.getByRole("listitem").filter({ hasText: "/page1" }).click(); + + await expect( + page.getByRole("button", { name: "Apply filter", disabled: false }), + ).toHaveCount(1); + + await page.getByRole("button", { name: "Apply filter" }).click(); + + await expect(page).toHaveURL( + baseUrl + "/public.example.com?f=is,page,/page1", + ); + + await expect( + page.getByRole("link", { name: "Page is /page1" }), + ).toHaveAttribute("title", "Edit filter: Page is /page1"); +}); + +test("tab selection user preferences are preserved across reloads", async ({ + page, +}) => { + await page.goto("/public.example.com"); + + await page.getByRole("button", { name: "Entry pages" }).click(); + + await page.goto("/public.example.com"); + + let currentTab = await page.evaluate(() => + localStorage.getItem("pageTab__public.example.com"), + ); + + await expect(currentTab).toEqual("entry-pages"); + + await page.getByRole("button", { name: "Exit pages" }).click(); + + await page.goto("/public.example.com"); + + currentTab = await page.evaluate(() => + localStorage.getItem("pageTab__public.example.com"), + ); + + await expect(currentTab).toEqual("exit-pages"); +}); + +test("back navigation closes the modal", async ({ page }) => { + await page.goto("/public.example.com"); + + await page.getByRole("button", { name: "Filter" }).click(); + + await page.getByRole("link", { name: "Page" }).click(); + + await expect(page).toHaveURL(baseUrl + "/public.example.com/filter/page"); + + await page.goBack(); + + await expect(page).toHaveURL(baseUrl + "/public.example.com"); +}); + +test("opens for logged in user", async ({ page }) => { + await page.goto("/login"); + + await page.getByLabel("Email").fill("user@plausible.test"); + + await page.getByLabel("Password").fill("plausible"); + + await page.getByRole("button", { name: "Log in" }).click(); + + await page.goto("/private.example.com"); + + await expect( + page.getByRole("button", { name: "private.example.com" }), + ).toBeVisible(); +}); diff --git a/extra/lib/plausible_web/dogfood.ex b/extra/lib/plausible_web/dogfood.ex index ad72dbab72d3..6881e4d060a1 100644 --- a/extra/lib/plausible_web/dogfood.ex +++ b/extra/lib/plausible_web/dogfood.ex @@ -43,7 +43,7 @@ defmodule PlausibleWeb.Dogfood do "pa-invalid-script-id" - env in ["test", "ce_test"] -> + env in ["test", "ce_test", "e2e_test"] -> "" end diff --git a/lib/mix/tasks/clean_clickhouse.ex b/lib/mix/tasks/clean_clickhouse.ex index 2430272e494e..071f139be251 100644 --- a/lib/mix/tasks/clean_clickhouse.ex +++ b/lib/mix/tasks/clean_clickhouse.ex @@ -1,9 +1,17 @@ defmodule Mix.Tasks.CleanClickhouse do + @moduledoc false + use Mix.Task alias Plausible.IngestRepo def run(_) do + case Plausible.IngestRepo.start_link(pool_size: 1, log: false) do + {:ok, _} -> :pass + {:error, {:already_started, _pid}} -> :pass + {:error, _} = error -> throw(error) + end + %{rows: rows} = IngestRepo.query!("show tables") tables = Enum.map(rows, fn [table] -> table end) @@ -22,5 +30,7 @@ defmodule Mix.Tasks.CleanClickhouse do Enum.each(to_truncate, fn table -> IngestRepo.query!("truncate #{table}") end) + after + Plausible.IngestRepo.stop(500) end end diff --git a/lib/mix/tasks/clean_postgres.ex b/lib/mix/tasks/clean_postgres.ex new file mode 100644 index 000000000000..29cc7fc70af4 --- /dev/null +++ b/lib/mix/tasks/clean_postgres.ex @@ -0,0 +1,31 @@ +defmodule Mix.Tasks.CleanPostgres do + @moduledoc false + + use Mix.Task + + alias Plausible.Repo + + def run(_) do + case Plausible.Repo.start_link(pool_size: 1, log: false) do + {:ok, _} -> :pass + {:error, {:already_started, _pid}} -> :pass + {:error, _} = error -> throw(error) + end + + query = """ + SELECT tablename + FROM pg_catalog.pg_tables + WHERE schemaname != 'pg_catalog' AND + schemaname != 'information_schema'; + """ + + %{rows: rows} = Repo.query!(query) + tables = Enum.map(rows, fn [table] -> table end) + + Enum.each(tables -- ["schema_migrations"], fn table -> + Repo.query!("truncate #{table} cascade") + end) + after + Plausible.Repo.stop(500) + end +end diff --git a/lib/plausible/auth/auth.ex b/lib/plausible/auth/auth.ex index 7831aa3752bf..91606dcde5da 100644 --- a/lib/plausible/auth/auth.ex +++ b/lib/plausible/auth/auth.ex @@ -15,15 +15,23 @@ defmodule Plausible.Auth do require Logger + if Mix.env() == :e2e_test do + @ip_rate_limit 100_000 + @user_rate_limit 100_000 + else + @ip_rate_limit 5 + @user_rate_limit 5 + end + @rate_limits %{ login_ip: %{ prefix: "login:ip", - limit: 5, + limit: @ip_rate_limit, interval: :timer.seconds(60) }, login_user: %{ prefix: "login:user", - limit: 5, + limit: @user_rate_limit, interval: :timer.seconds(60) }, email_change_user: %{ diff --git a/lib/plausible/prom_ex.ex b/lib/plausible/prom_ex.ex index 8e22a4dc4309..f2186e0206a6 100644 --- a/lib/plausible/prom_ex.ex +++ b/lib/plausible/prom_ex.ex @@ -19,7 +19,7 @@ defmodule Plausible.PromEx do ] @impl true - if Mix.env() in [:test, :ce_test] do + if Mix.env() in [:test, :ce_test, :e2e_test] do # PromEx tries to query Oban's DB tables in order to retrieve metrics. # During tests, however, this is pointless as Oban is in manual mode, # and that leads to connection ownership clashes. diff --git a/lib/plausible/s3.ex b/lib/plausible/s3.ex index eca8d3c18a41..1d471a43fd24 100644 --- a/lib/plausible/s3.ex +++ b/lib/plausible/s3.ex @@ -58,7 +58,7 @@ defmodule Plausible.S3 do # to make ClickHouse see MinIO in dev and test envs we replace # the host in the S3 URL with host.docker.internal or whatever's set in $MINIO_HOST_FOR_CLICKHOUSE - if Mix.env() in [:dev, :test, :ce_dev, :ce_test] do + if Mix.env() in [:dev, :test, :ce_dev, :ce_test, :e2e_test] do defp extract_s3_url(presigned_url) do [s3_url, _] = String.split(presigned_url, "?") default_ch_host = unless System.get_env("CI"), do: "host.docker.internal" diff --git a/lib/plausible/session/balancer_supervisor.ex b/lib/plausible/session/balancer_supervisor.ex index e35969841d80..2c456b7d1ae0 100644 --- a/lib/plausible/session/balancer_supervisor.ex +++ b/lib/plausible/session/balancer_supervisor.ex @@ -2,7 +2,7 @@ defmodule Plausible.Session.BalancerSupervisor do @moduledoc "Serialize session processing to avoid explicit locks" use Supervisor - if Mix.env() in [:test, :ce_test] do + if Mix.env() in [:test, :ce_test, :e2e_test] do def size(), do: 10 else diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 587c2e3c0899..04b4f55b07bd 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -137,7 +137,7 @@ defmodule PlausibleWeb.Router do end on_ee do - if Mix.env() in [:dev, :test] do + if Mix.env() in [:dev, :test, :e2e_test] do scope "/dev", PlausibleWeb do pipe_through :browser @@ -153,7 +153,7 @@ defmodule PlausibleWeb.Router do end # Routes for plug integration testing - if Mix.env() in [:test, :ce_test] do + if Mix.env() in [:test, :ce_test, :e2e_test] do scope "/plug-tests", PlausibleWeb do scope [] do pipe_through :browser diff --git a/lib/workers/clickhouse_clean_sites.ex b/lib/workers/clickhouse_clean_sites.ex index 3d14ec0c7ae3..a425fdfa5864 100644 --- a/lib/workers/clickhouse_clean_sites.ex +++ b/lib/workers/clickhouse_clean_sites.ex @@ -31,7 +31,7 @@ defmodule Plausible.Workers.ClickhouseCleanSites do "imported_visitors" ] - @settings if Mix.env() in [:test, :ce_test], do: [mutations_sync: 2], else: [] + @settings if Mix.env() in [:test, :ce_test, :e2e_test], do: [mutations_sync: 2], else: [] def perform(_job) do deleted_sites = get_deleted_sites_with_clickhouse_data() diff --git a/mix.exs b/mix.exs index a70666b655a6..a68bb8e0058d 100644 --- a/mix.exs +++ b/mix.exs @@ -49,8 +49,17 @@ defmodule Plausible.MixProject do ] end + def cli do + [ + preferred_envs: [ + "test.e2e": :e2e_test, + "test.e2e.ui": :e2e_test + ] + ] + end + # Specifies which paths to compile per environment. - defp elixirc_paths(env) when env in [:test, :dev], + defp elixirc_paths(env) when env in [:test, :e2e_test, :dev], do: ["lib", "test/support", "extra/lib"] defp elixirc_paths(env) when env in [:ce_test, :ce_dev], @@ -69,7 +78,7 @@ defmodule Plausible.MixProject do {:bamboo_smtp, "~> 4.1"}, {:bamboo_mua, "~> 0.2.0"}, {:bcrypt_elixir, "~> 3.3"}, - {:bypass, "~> 2.1", only: [:dev, :test, :ce_test]}, + {:bypass, "~> 2.1", only: [:dev, :test, :ce_test, :e2e_test]}, {:ecto_ch, "~> 0.8.4"}, {:cloak, "~> 1.1"}, {:cloak_ecto, "~> 1.2"}, @@ -77,12 +86,12 @@ defmodule Plausible.MixProject do {:cors_plug, "~> 3.0"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, - {:double, "~> 0.8.0", only: [:dev, :test, :ce_test, :ce_dev]}, + {:double, "~> 0.8.0", only: [:dev, :test, :ce_test, :ce_dev, :e2e_test]}, {:ecto, "~> 3.13.5"}, {:ecto_sql, "~> 3.13.2"}, {:envy, "~> 1.1.1"}, {:eqrcode, "~> 0.2.1"}, - {:ex_machina, "~> 2.3", only: [:dev, :test, :ce_dev, :ce_test]}, + {:ex_machina, "~> 2.3", only: [:dev, :test, :ce_dev, :ce_test, :e2e_test]}, {:excoveralls, "~> 0.10", only: :test}, {:finch, "~> 0.20.0"}, {:floki, "~> 0.36"}, @@ -94,7 +103,7 @@ defmodule Plausible.MixProject do {:hackney, "~> 1.8"}, {:jason, "~> 1.3"}, {:location, git: "https://github.com/plausible/location.git"}, - {:mox, "~> 1.0", only: [:test, :ce_test]}, + {:mox, "~> 1.0", only: [:test, :ce_test, :e2e_test]}, {:nanoid, "~> 2.1.0"}, {:nimble_totp, "~> 1.0"}, {:oban, "~> 2.20.1"}, @@ -152,11 +161,11 @@ defmodule Plausible.MixProject do {:con_cache, git: "https://github.com/aerosol/con_cache", branch: "ensure-dirty-ops-emit-telemetry"}, {:req, "~> 0.5.16"}, - {:happy_tcp, github: "ruslandoga/happy_tcp", only: [:ce, :ce_dev, :ce_test]}, + {:happy_tcp, github: "ruslandoga/happy_tcp", only: [:ce, :ce_dev, :ce_test, :e2e_test]}, {:ex_json_schema, "~> 0.11.1"}, {:odgn_json_pointer, "~> 3.1.0"}, - {:phoenix_bakery, "~> 0.1.2", only: [:ce, :ce_dev, :ce_test]}, - {:site_encrypt, github: "sasa1977/site_encrypt", only: [:ce, :ce_dev, :ce_test]}, + {:phoenix_bakery, "~> 0.1.2", only: [:ce, :ce_dev, :ce_test, :e2e_test]}, + {:site_encrypt, github: "sasa1977/site_encrypt", only: [:ce, :ce_dev, :ce_test, :e2e_test]}, {:phoenix_storybook, "~> 0.9"}, {:libcluster, "~> 3.5"} ] @@ -168,6 +177,28 @@ defmodule Plausible.MixProject do "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate", "test", "clean_clickhouse"], + "test.e2e": [ + "esbuild default", + "ecto.create --quiet", + "ecto.migrate", + "clean_postgres", + "clean_clickhouse", + "run priv/repo/e2e_seeds.exs", + "cmd npm run --prefix ./e2e test", + "clean_postgres", + "clean_clickhouse" + ], + "test.e2e.ui": [ + "esbuild default", + "ecto.create --quiet", + "ecto.migrate", + "clean_postgres", + "clean_clickhouse", + "run priv/repo/e2e_seeds.exs", + "cmd npm run --prefix ./e2e test:ui", + "clean_postgres", + "clean_clickhouse" + ], "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], "assets.typecheck": ["cmd npm --prefix assets run typecheck"], "assets.build": [ diff --git a/priv/repo/e2e_seeds.exs b/priv/repo/e2e_seeds.exs new file mode 100644 index 000000000000..03f52bb864f6 --- /dev/null +++ b/priv/repo/e2e_seeds.exs @@ -0,0 +1,24 @@ +use Plausible + +import Plausible.Teams.Test + +hours_ago = fn hr -> + NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-hr, :hour) +end + +user = new_user(email: "user@plausible.test", password: "plausible") + +public_site = new_site(domain: "public.example.com", public: true) + +Plausible.TestUtils.populate_stats(public_site, [ + Plausible.Factory.build(:pageview, pathname: "/page1", timestamp: hours_ago.(48)), + Plausible.Factory.build(:pageview, pathname: "/page2", timestamp: hours_ago.(48)), + Plausible.Factory.build(:pageview, pathname: "/page3", timestamp: hours_ago.(48)), + Plausible.Factory.build(:pageview, pathname: "/other", timestamp: hours_ago.(48)) +]) + +private_site = new_site(domain: "private.example.com", owner: user) + +Plausible.TestUtils.populate_stats(private_site, [ + Plausible.Factory.build(:pageview, pathname: "/", timestamp: hours_ago.(48)) +]) diff --git a/test/test_helper.exs b/test/test_helper.exs index e11ea3df954d..bd6ac0e85ed4 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -3,15 +3,18 @@ if not Enum.empty?(Path.wildcard("lib/**/*_test.exs")) do end {:ok, _} = Application.ensure_all_started(:ex_machina) -Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface) -Mox.defmock(Plausible.DnsLookup.Mock, - for: Plausible.DnsLookupInterface -) +if Mix.env() != :e2e_test do + Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface) -Application.ensure_all_started(:double) + Mox.defmock(Plausible.DnsLookup.Mock, + for: Plausible.DnsLookupInterface + ) -Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual) + Application.ensure_all_started(:double) + + Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual) +end # warn about minio if it's included in tests but not running if :minio in Keyword.fetch!(ExUnit.configuration(), :include) do @@ -27,10 +30,16 @@ for {app, _, _} <- Application.loaded_applications() do end end -if Mix.env() == :ce_test do - IO.puts("Test mode: Community Edition") - ExUnit.configure(exclude: [:ee_only | default_exclude]) -else - IO.puts("Test mode: Enterprise Edition") - ExUnit.configure(exclude: [:ce_build_only | default_exclude]) +case Mix.env() do + :ce_test -> + IO.puts("Test mode: Community Edition") + ExUnit.configure(exclude: [:ee_only, :e2e | default_exclude]) + + :e2e_test -> + IO.puts("Test mode: End-to-End Tests") + ExUnit.configure(exclude: [:test], include: [:e2e]) + + _ -> + IO.puts("Test mode: Enterprise Edition") + ExUnit.configure(exclude: [:ce_build_only, :e2e | default_exclude]) end diff --git a/tracker/README.md b/tracker/README.md index d10f1f131c52..df3b99311d8f 100644 --- a/tracker/README.md +++ b/tracker/README.md @@ -26,7 +26,7 @@ Use `node compile.js --web-snippet` if you need to update web snippet code. ### Tests -Tests can be run in UI mode via `npm run playwright --ui`. This helps with debugging. +Tests can be run in UI mode via `npx playwright test --ui`. This helps with debugging. ### NPM package