diff --git a/assets/package-lock.json b/assets/package-lock.json
index 6aa060196134..3854c4f71374 100644
--- a/assets/package-lock.json
+++ b/assets/package-lock.json
@@ -42,6 +42,7 @@
"visionscarto-world-atlas": "^1.0.0"
},
"devDependencies": {
+ "@playwright/test": "^1.48.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
@@ -49,6 +50,7 @@
"@types/classnames": "^2.3.1",
"@types/d3": "^7.4.3",
"@types/jest": "^29.5.12",
+ "@types/node": "^22.7.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-flatpickr": "^3.8.11",
@@ -1742,6 +1744,21 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.48.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz",
+ "integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==",
+ "dev": true,
+ "dependencies": {
+ "playwright": "1.48.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@popperjs/core": {
"version": "2.11.6",
"license": "MIT",
@@ -2498,12 +2515,12 @@
"dev": true
},
"node_modules/@types/node": {
- "version": "22.2.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.2.0.tgz",
- "integrity": "sha512-bm6EG6/pCpkxDf/0gDNDdtDILMOHgaQBVOJGdwsqClnxA3xL6jtMv76rLBc006RVMWbmaf0xbmom4Z/5o2nRkQ==",
+ "version": "22.7.9",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz",
+ "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==",
"dev": true,
"dependencies": {
- "undici-types": "~6.13.0"
+ "undici-types": "~6.19.2"
}
},
"node_modules/@types/prop-types": {
@@ -9413,6 +9430,36 @@
"node": ">=8"
}
},
+ "node_modules/playwright": {
+ "version": "1.48.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz",
+ "integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==",
+ "dev": true,
+ "dependencies": {
+ "playwright-core": "1.48.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.48.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz",
+ "integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==",
+ "dev": true,
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -11253,9 +11300,9 @@
}
},
"node_modules/undici-types": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz",
- "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==",
+ "version": "6.19.8",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true
},
"node_modules/universalify": {
diff --git a/assets/package.json b/assets/package.json
index 4e9078d5ee82..388e2191f4be 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -10,7 +10,10 @@
"stylelint": "stylelint css/**",
"lint": "npm run eslint && npm run stylelint",
"typecheck": "tsc --noEmit --pretty",
- "generate-types": "json2ts ../priv/json-schemas/query-api-schema.json ../assets/js/types/query-api.d.ts"
+ "generate-types": "json2ts ../priv/json-schemas/query-api-schema.json ../assets/js/types/query-api.d.ts",
+ "playwright": "playwright test",
+ "playwright:ui": "playwright test --ui",
+ "playwright:install": "playwright install chromium --with-deps"
},
"dependencies": {
"@headlessui/react": "^1.7.10",
@@ -46,6 +49,7 @@
"visionscarto-world-atlas": "^1.0.0"
},
"devDependencies": {
+ "@playwright/test": "^1.48.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
@@ -53,6 +57,7 @@
"@types/classnames": "^2.3.1",
"@types/d3": "^7.4.3",
"@types/jest": "^29.5.12",
+ "@types/node": "^22.7.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-flatpickr": "^3.8.11",
diff --git a/assets/playwright-tests/dashboard.spec.ts b/assets/playwright-tests/dashboard.spec.ts
new file mode 100644
index 000000000000..e82cf0e8a59c
--- /dev/null
+++ b/assets/playwright-tests/dashboard.spec.ts
@@ -0,0 +1,109 @@
+import { test, expect, Page, screenshot, TestInfo } from './playwright'
+
+const MODALS = [
+ { key: 'UTM Medium', sectionSelector: 'section::sources' },
+ { key: 'UTM Source', sectionSelector: 'section::sources' },
+ { key: 'UTM Campaign', sectionSelector: 'section::sources' },
+ { key: 'Top Pages', sectionSelector: 'section::pages' },
+ { key: 'Entry Pages', sectionSelector: 'section::pages' },
+ { key: 'Exit Pages', sectionSelector: 'section::pages' },
+ { key: 'Map', sectionSelector: 'section::locations' },
+ { key: 'Countries', sectionSelector: 'section::locations' },
+ { key: 'Regions', sectionSelector: 'section::locations' },
+ { key: 'Cities', sectionSelector: 'section::locations' },
+ { key: 'Browser', sectionSelector: 'section::devices' },
+ { key: 'OS', sectionSelector: 'section::devices' },
+ { key: 'Size', sectionSelector: 'section::devices' },
+ { key: 'Goals', sectionSelector: 'section::behaviors' },
+ { key: 'Properties', sectionSelector: 'section::behaviors' },
+]
+
+test.beforeEach(async ({page}) => {
+ await page.goto('/dummy.site')
+ await waitForData(page)
+})
+
+test('can navigate the dashboard via keyboard shortcuts', async ({ page }, testInfo) => {
+ const dateMenuButton = page.getByTestId('date-menu-button')
+
+ async function testShortcut(key: string, pattern: string | RegExp, options = { screenshot: true }) {
+ page.keyboard.down(key)
+ await waitForData(page)
+ await expect(dateMenuButton).toHaveText(pattern)
+ if (options.screenshot) {
+ await screenshot(page, testInfo)
+ }
+ }
+
+ await testShortcut("D", "Today")
+ await testShortcut("E", /(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/)
+ await testShortcut("W", "Last 7 days")
+ await testShortcut("T", "Last 30 days")
+ await testShortcut("M", "Month to Date")
+ await testShortcut("Y", "Year to Date")
+ await testShortcut("L", "Last 12 months")
+ await testShortcut("A", "All time")
+ await testShortcut("R", "Realtime", { screenshot: false })
+})
+
+MODALS.forEach(({ key, sectionSelector }) => {
+ test(`can open ${key} modal`, async ({ page }, testInfo) => {
+ await selectTabSection(page, sectionSelector, key)
+
+ await checkBreakdownModal(page, sectionSelector, testInfo)
+ })
+
+ test(`can open ${key} modal in comparison mode`, async ({ page }, testInfo) => {
+ page.keyboard.down("X")
+ await waitForData(page)
+
+ await selectTabSection(page, sectionSelector, key)
+ await checkBreakdownModal(page, sectionSelector, testInfo)
+ })
+})
+
+test('with revenue goal filter applied sees revenue metrics in top stats', async ({ page }, testInfo) => {
+ await expect(page.getByTestId("section::top-stats")).not.toHaveText(/Total revenue/)
+ await expect(page.getByTestId("section::top-stats")).not.toHaveText(/Average revenue/)
+
+ await page.getByText("North America Purchases").click()
+ await waitForData(page)
+
+ await expect(page.getByTestId("section::top-stats")).toHaveText(/Total revenue/)
+ await expect(page.getByTestId("section::top-stats")).toHaveText(/Average revenue/)
+
+ await screenshot(page.getByTestId("section::top-stats"), testInfo)
+})
+
+test('dashboard comparison with previous period', async ({ page }, testInfo) => {
+ await page.getByTestId('date-menu-button').click()
+ await page.getByTestId('datemenu').getByRole('link', { name: /Compare/ }).click()
+ await waitForData(page)
+
+ await screenshot(page, testInfo)
+})
+
+async function waitForData(page: Page) {
+ const loading = page.locator(".loading")
+
+ await page.waitForSelector("#main-graph-canvas")
+ await loading.waitFor({ state: "visible", timeout: 50 }).catch(() => {})
+ await expect(loading).toHaveCount(0)
+}
+
+async function selectTabSection(page: Page, sectionSelector: string, key: string) {
+ if (sectionSelector === 'section::sources') {
+ await page.getByTestId("campaign-menu").click()
+ await page.getByRole('menuitem', { name: key }).click()
+ } else {
+ await page.getByRole('button', { name: key }).click()
+ }
+}
+
+async function checkBreakdownModal(page: Page, listTestId: string, testInfo: TestInfo) {
+ await page.getByTestId(listTestId).getByTestId('details-link').click()
+ await waitForData(page)
+ await screenshot(page.locator(".modal__container"), testInfo)
+
+ await page.getByRole('button', { name: '✕' }).click()
+}
diff --git a/assets/playwright-tests/global.setup.ts b/assets/playwright-tests/global.setup.ts
new file mode 100644
index 000000000000..d02501809738
--- /dev/null
+++ b/assets/playwright-tests/global.setup.ts
@@ -0,0 +1,19 @@
+import { test as setup, expect } from './playwright'
+import path from 'path'
+
+const authFile = path.join(__dirname, '../playwright-tests/.auth/user.json')
+
+setup('login', 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.waitForURL("/sites")
+ await expect(page.locator("body")).toHaveText(/My Sites/)
+ await expect(page.locator("body")).toHaveText(/dummy.site/)
+
+ await page.context().storageState({ path: authFile })
+})
diff --git a/assets/playwright-tests/playwright.ts b/assets/playwright-tests/playwright.ts
new file mode 100644
index 000000000000..89de765a8482
--- /dev/null
+++ b/assets/playwright-tests/playwright.ts
@@ -0,0 +1,44 @@
+// Module to wrap playwright.
+
+import { test as playwrightTest, expect, Page, Locator, TestInfo } from '@playwright/test'
+
+export { expect, Page, Locator, TestInfo } from '@playwright/test'
+
+// Test wrapper which fails if any JS errors are logged.
+//
+// This is a catch-all error handling tool - all errors which
+// are not handled in JS trigger errors, such as 500s or other
+// frontend bugs.
+//
+// Idea from https://www.checklyhq.com/blog/track-frontend-javascript-exceptions-with-playwright/
+export const test = playwrightTest.extend<{ page: void }>({
+ page: async ({ page }: { page: any }, use: any) => {
+ // Track errors
+ const errors: Array = []
+ page.addListener("pageerror", (error: any) => {
+ errors.push(error)
+ })
+
+ // Run the test
+ await use(page)
+
+ // Check no errors
+ expect(errors).toHaveLength(0)
+ },
+})
+
+const snapshotsIndexes: Record = {}
+
+export async function screenshot(locator: Page | Locator, testInfo: TestInfo): Promise {
+ const root = testInfo.snapshotPath()
+ const snapshotIndex = (snapshotsIndexes[root] || 0) + 1
+ snapshotsIndexes[root] = snapshotIndex
+
+ const path = testInfo.snapshotPath(`${snapshotIndex}.png`)
+
+ await locator.screenshot({ path, animations: "disabled", fullPage: true })
+}
+
+test.beforeEach(async ({ context }) => {
+ await context.route(/changes.txt/, route => route.fulfill({ status: 200, body: '2020-01-01' }))
+})
diff --git a/assets/playwright-tests/sites.spec.ts b/assets/playwright-tests/sites.spec.ts
new file mode 100644
index 000000000000..bef25a4f4b9d
--- /dev/null
+++ b/assets/playwright-tests/sites.spec.ts
@@ -0,0 +1,14 @@
+import { test, expect } from './playwright'
+
+test('site card', async ({ page }) => {
+ await page.goto('/sites')
+
+ const siteCard = page.locator("li[data-domain='dummy.site']")
+
+ await expect(siteCard).toHaveText(/\d+\s+visitors in last 24h/)
+ await siteCard.click()
+
+ await page.waitForURL("/dummy.site")
+
+ await expect(page.locator("body")).toHaveText(/\d+\s+current visitors/)
+})
diff --git a/assets/playwright.config.ts b/assets/playwright.config.ts
new file mode 100644
index 000000000000..687856929505
--- /dev/null
+++ b/assets/playwright.config.ts
@@ -0,0 +1,59 @@
+import { defineConfig, devices } from '@playwright/test'
+import path from 'path'
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: './playwright-tests',
+
+ /* Location where snapshots and screenshots are stored */
+ snapshotPathTemplate: '{testDir}/snapshots/{testFileName}-{testName}-{arg}{ext}',
+ updateSnapshots: 'all',
+
+
+ /* 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: process.env.CI ? 'github' : '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: 'http://localhost:8000',
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ ...devices['Desktop Chrome']
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'setup',
+ testMatch: /global\.setup\.ts/,
+ },
+ {
+ name: 'chromium',
+ dependencies: ['setup'],
+ use: {
+ storageState: 'playwright-tests/.auth/user.json',
+ viewport: { width: 1280, height: 3000 },
+ }
+ },
+ ],
+
+ /* Run your local dev server before starting the tests */
+ webServer: {
+ command: 'mix phx.server',
+ url: 'http://localhost:8000',
+ cwd: path.resolve(__dirname, '..'),
+ reuseExistingServer: !process.env.CI,
+ stderr: 'ignore'
+ },
+});
diff --git a/lib/plausible/auth/api_key.ex b/lib/plausible/auth/api_key.ex
index e52810dcf986..5bbe9bd3810a 100644
--- a/lib/plausible/auth/api_key.ex
+++ b/lib/plausible/auth/api_key.ex
@@ -57,7 +57,7 @@ defmodule Plausible.Auth.ApiKey do
if get_change(changeset, :key) do
changeset
else
- key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64)
+ key = Plausible.Random.binary(64) |> Base.url_encode64() |> binary_part(0, 64)
put_change(changeset, :key, key)
end
end
diff --git a/lib/plausible/auth/totp.ex b/lib/plausible/auth/totp.ex
index e12f6ebc7194..37c9a32f8712 100644
--- a/lib/plausible/auth/totp.ex
+++ b/lib/plausible/auth/totp.ex
@@ -349,8 +349,7 @@ defmodule Plausible.Auth.TOTP do
end
defp generate_token() do
- 20
- |> :crypto.strong_rand_bytes()
+ Plausible.Random.binary(20)
|> Base.encode64(padding: false)
end
end
diff --git a/lib/plausible/auth/totp/recovery_code.ex b/lib/plausible/auth/totp/recovery_code.ex
index 0065c6825d36..133832662857 100644
--- a/lib/plausible/auth/totp/recovery_code.ex
+++ b/lib/plausible/auth/totp/recovery_code.ex
@@ -70,7 +70,7 @@ defmodule Plausible.Auth.TOTP.RecoveryCode do
end
defp generate_code() do
- Base.encode32(:crypto.strong_rand_bytes(6), padding: false)
+ Plausible.Random.binary(6) |> Base.encode32(padding: false)
end
defp hash(code) when byte_size(code) == @code_length do
diff --git a/lib/plausible/auth/user_session.ex b/lib/plausible/auth/user_session.ex
index 48d401808055..97c4376334dd 100644
--- a/lib/plausible/auth/user_session.ex
+++ b/lib/plausible/auth/user_session.ex
@@ -46,7 +46,7 @@ defmodule Plausible.Auth.UserSession do
end
defp generate_token(changeset) do
- token = :crypto.strong_rand_bytes(@rand_size)
+ token = Plausible.Random.binary(@rand_size)
put_change(changeset, :token, token)
end
end
diff --git a/lib/plausible/clickhouse_session_v2.ex b/lib/plausible/clickhouse_session_v2.ex
index edfbc233c6cb..d280df95d0ff 100644
--- a/lib/plausible/clickhouse_session_v2.ex
+++ b/lib/plausible/clickhouse_session_v2.ex
@@ -76,6 +76,6 @@ defmodule Plausible.ClickhouseSessionV2 do
end
def random_uint64() do
- :crypto.strong_rand_bytes(8) |> :binary.decode_unsigned()
+ Plausible.Random.byte_int(8)
end
end
diff --git a/lib/plausible/plugins/api/token.ex b/lib/plausible/plugins/api/token.ex
index 7fe3c2f2d206..8b6135615ef2 100644
--- a/lib/plausible/plugins/api/token.ex
+++ b/lib/plausible/plugins/api/token.ex
@@ -101,6 +101,6 @@ defmodule Plausible.Plugins.API.Token do
end
defp random_bytes() do
- 30 |> :crypto.strong_rand_bytes() |> Base.encode64()
+ Plausible.Random.binary(30) |> Base.encode64()
end
end
diff --git a/lib/plausible/random.ex b/lib/plausible/random.ex
new file mode 100644
index 000000000000..16df2694ca0e
--- /dev/null
+++ b/lib/plausible/random.ex
@@ -0,0 +1,27 @@
+defmodule Plausible.Random do
+ @moduledoc """
+ Methods for generating random numbers and strings.
+
+ Unlike crypto module, this respects RANDOM_SEED env variable when seeding dev data.
+ """
+
+ @spec byte_int(pos_integer()) :: non_neg_integer()
+ def byte_int(n) do
+ if System.get_env("RANDOM_SEED") do
+ limit = 2 ** (n * 8)
+ :rand.uniform(limit)
+ else
+ :crypto.strong_rand_bytes(n) |> :binary.decode_unsigned()
+ end
+ end
+
+ @spec binary(pos_integer()) :: binary()
+ def binary(n) do
+ if System.get_env("RANDOM_SEED") do
+ bit_count = 8 * n
+ <>
+ else
+ :crypto.strong_rand_bytes(n)
+ end
+ end
+end
diff --git a/lib/plausible/session/salts.ex b/lib/plausible/session/salts.ex
index ba2066424f45..309b67a84e34 100644
--- a/lib/plausible/session/salts.ex
+++ b/lib/plausible/session/salts.ex
@@ -42,7 +42,7 @@ defmodule Plausible.Session.Salts do
end
defp generate_and_persist_new_salt() do
- salt = :crypto.strong_rand_bytes(16)
+ salt = Plausible.Random.binary(16)
Repo.insert_all("salts", [%{salt: salt, inserted_at: DateTime.utc_now()}])
salt
diff --git a/lostpixel.config.js b/lostpixel.config.js
new file mode 100644
index 000000000000..b08cefba3229
--- /dev/null
+++ b/lostpixel.config.js
@@ -0,0 +1,8 @@
+export const config = {
+ customShots: {
+ currentShotsPath: "./assets/playwright-tests/snapshots",
+ },
+
+ lostPixelProjectId: 'cm2udoqav0kcl93vy727kha9g',
+ apiKey: process.env.LOST_PIXEL_API_KEY,
+}
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index b34c4076c5d2..cb1476a5177c 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -10,9 +10,17 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
-words =
- for i <- 0..(:erlang.system_info(:atom_count) - 1),
- do: :erlang.binary_to_term(<<131, 75, i::24>>)
+case System.get_env("RANDOM_SEED") do
+ seed_string when is_binary(seed_string) ->
+ seed = String.to_integer(seed_string)
+
+ :rand.seed(:exsplus, {seed, seed, seed})
+
+ _ ->
+ nil
+end
+
+words = File.read!("priv/repo/seeds_words.txt") |> String.split("\n", trim: true)
user = Plausible.Factory.insert(:user, email: "user@plausible.test", password: "plausible")
@@ -107,21 +115,6 @@ if Plausible.ee?() do
end
put_random_time = fn
- date, 0 ->
- current_hour = Time.utc_now().hour
- current_minute = Time.utc_now().minute
-
- random_time =
- Time.new!(
- Enum.random(0..current_hour),
- Enum.random(0..current_minute),
- 0
- )
-
- date
- |> NaiveDateTime.new!(random_time)
- |> NaiveDateTime.truncate(:second)
-
date, _ ->
random_time = Time.new!(:rand.uniform(23), :rand.uniform(59), 0)
@@ -308,6 +301,8 @@ native_stats_range
end)
|> Plausible.TestUtils.populate_stats()
+Plausible.Props.allow(site, ["url", "logged_in", "is_customer", "amount"])
+
site_import =
site
|> Plausible.Imported.SiteImport.create_changeset(user, %{
diff --git a/priv/repo/seeds_words.txt b/priv/repo/seeds_words.txt
new file mode 100644
index 000000000000..8ad6f06c9f77
--- /dev/null
+++ b/priv/repo/seeds_words.txt
@@ -0,0 +1,301 @@
+false
+true
+_
+nonode@nohost
+$end_of_table
+
+infinity
+timeout
+normal
+call
+return
+throw
+error
+exit
+undefined
+nocatch
+undefined_function
+undefined_lambda
+nil
+no
+none
+DOWN
+UP
+EXIT
+abandoned
+abort
+abs_path
+absoluteURI
+ac
+access
+active
+active_tasks
+active_tasks_all
+alias
+alive
+all
+all_but_first
+all_names
+alloc_info
+alloc_sizes
+allocated
+allocated_areas
+allocator
+allocator_sizes
+alloc_util_allocators
+allow_passive_connect
+already_exists
+already_loaded
+amd64
+anchored
+and
+andalso
+andthen
+any
+anycrlf
+apply
+args
+arg0
+arity
+asn1
+async
+async_dist
+asynchronous
+atom
+atom_used
+attributes
+auto_connect
+await_exit
+await_microstate_accounting_modifications
+await_port_send_result
+await_proc_exit
+await_result
+await_sched_wall_time_modifications
+awaiting_load
+awaiting_unload
+backtrace
+backtrace_depth
+badarg
+badarith
+badarity
+badfile
+badfun
+badkey
+badmap
+badmatch
+badrecord
+badsig
+badopt
+badtype
+bad_map_iterator
+bag
+band
+big
+bif_handle_signals_return
+bif_return_trap
+binary
+binary_copy_trap
+binary_find_trap
+binary_longest_prefix_trap
+binary_longest_suffix_trap
+binary_to_list_continue
+binary_to_term_trap
+block
+block_normal
+blocked
+blocked_normal
+bm
+bnot
+bor
+bxor
+break_ignored
+breakpoint
+bsl
+bsr
+bsr_anycrlf
+bsr_unicode
+build_flavor
+build_type
+busy
+busy_dist_port
+busy_limits_port
+busy_limits_msgq
+busy_port
+call_count
+call_error_handler
+call_memory
+call_time
+call_trace_return
+caller
+caller_line
+capture
+case_clause
+caseless
+catchlevel
+cause
+cd
+cdr
+cflags
+CHANGE
+characters_to_binary_int
+characters_to_list_int
+check_gc
+clear
+clock_service
+close
+closed
+code
+command
+commandv
+compact
+compat_rel
+compile
+complete
+compressed
+config_h
+convert_time_unit
+connect
+connected
+connection_closed
+connection_id
+const
+context_switches
+continue_exit
+control
+copy
+copy_literals
+counters
+count
+cpu
+cpu_timestamp
+cr
+crlf
+creation
+current_function
+current_location
+current_stacktrace
+data
+debug_flags
+decentralized_counters
+decimals
+default
+delay_trap
+demonitor
+deterministic
+dictionary
+dirty_bif_exception
+dirty_bif_result
+dirty_bif_trap
+dirty_cpu
+dirty_cpu_schedulers_online
+dirty_execution
+dirty_io
+dirty_nif_exception
+dirty_nif_finalizer
+disable_trace
+disabled
+discard
+dist
+dist_cmd
+dist_ctrl_put_data
+dist_ctrlr
+dist_data
+dist_spawn_init
+/
+div
+dmonitor_node
+$$
+$_
+dollar_endonly
+dotall
+driver
+driver_options
+dsend_continue_trap
+duplicate_bag
+duplicated
+dupnames
+dynamic_node_name
+einval
+emu_flavor
+emu_type
+emulator
+enable_trace
+enabled
+endian
+env
+ensure_at_least
+ensure_exactly
+eof
+eol
+=:=
+==
+erl_erts_errors
+erl_init
+erl_kernel_errors
+erl_stdlib_errors
+erl_tracer
+erlang
+erl_signal_server
+error_handler
+error_info
+error_logger
+error_only
+erts_code_purger
+erts_debug
+erts_dflags
+erts_internal
+ets
+ets_info_binary
+ETS-TRANSFER
+exact_reductions
+exception_from
+exception_trace
+exclusive
+exit_status
+exited
+existing
+existing_processes
+existing_ports
+exiting
+exports
+extended
+external
+extra
+fcgi
+fd
+first
+firstline
+flags
+flush
+flush_monitor_messages
+flush_timeout
+force
+format_bs_fail
+format_cpu_topology
+free
+fullsweep_after
+function
+function_counters
+functions
+function_clause
+garbage_collect
+garbage_collecting
+garbage_collection
+garbage_collection_info
+gc_major_end
+gc_major_start
+gc_max_heap_size
+gc_minor_end
+gc_minor_start
+>=
+generational
+get_all_trap
+get_internal_state_blocked
+get_seq_token
+get_size
+get_tail
+get_tcw
+gather_gc_info_result
+gather_io_bytes
+gather_microstate_accounting_result
+gather_sched_wall_time_result
diff --git a/test/support/factory.ex b/test/support/factory.ex
index aeb164b4f6e2..7559026951a4 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -246,7 +246,7 @@ defmodule Plausible.Factory do
end
def api_key_factory do
- key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64)
+ key = Plausible.Random.binary(64) |> Base.url_encode64() |> binary_part(0, 64)
%Plausible.Auth.ApiKey{
name: "api-key-name",