diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index ad045739159c..f230faed306d 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -1,7 +1,7 @@ name: Elixir CI on: - pull_request: + # pull_request: push: branches: [master, stable] merge_group: diff --git a/.github/workflows/playwright-e2e.yml b/.github/workflows/playwright-e2e.yml new file mode 100644 index 000000000000..a8fb8bf7f34d --- /dev/null +++ b/.github/workflows/playwright-e2e.yml @@ -0,0 +1,107 @@ +name: Playwright CI + +on: + pull_request: + push: + branches: [master, stable] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CACHE_VERSION: v1 + PERSISTENT_CACHE_DIR: cached + CI: "true" + MIX_ENV: dev + IP_GEOLOCATION_DB: test/priv/GeoLite2-City-Test.mmdb + RANDOM_SEED: "0" + +jobs: + e2e: + name: E2E playwright tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + 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:24.8.5.115-alpine + ports: + - 8123:8123 + env: + options: >- + --health-cmd nc -zw3 localhost 8124 + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + 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/setup-node@v4 + with: + node-version: ${{ steps.versions.outputs.nodejs }} + + - run: sudo apt-get install faketime + + - uses: actions/cache@v4 + id: cache + with: + path: | + deps + _build + tracker/node_modules + priv/tracker/js + assets/node_modules + ~/.cache/ms-playwright + ${{ env.PERSISTENT_CACHE_DIR }} + key: playwright-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + playwright-${{ env.CACHE_VERSION }}-${{ github.head_ref || github.ref }}- + playwright-${{ env.CACHE_VERSION }}-refs/heads/master- + playwright-${{ env.CACHE_VERSION }}- + + - name: Install frontend dependencies + run: npm ci --prefix assets + - name: Install Playwright Browsers + if: steps.cache.outputs.cache-hit != 'true' + run: npm run playwright:install --prefix assets + + - run: mix deps.get + + - name: Setup and seed database + run: | + mix ecto.create + mix ecto.migrate + faketime -f "@2024-10-01 00:00:00" mix ecto.setup + + - run: mix run -e "Tzdata.ReleaseUpdater.poll_for_update" + + - name: Run Playwright tests + run: | + faketime -f "@2024-10-01 00:00:00" npm run playwright --prefix assets + + - name: Lost Pixel + uses: lost-pixel/lost-pixel@v3.21.0 + env: + LOST_PIXEL_API_KEY: ${{ secrets.LOST_PIXEL_API_KEY }} diff --git a/.gitignore b/.gitignore index 2e11afb9084b..3263092e73e5 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,13 @@ npm-debug.log # test coverage directory /assets/coverage +# Playwright assets +/assets/playwright-tests/.auth +/assets/playwright-tests/snapshots +/assets/test-results/ +/assets/playwright-report/ +/assets/blob-report/ + # Since we are building assets from assets/, # we ignore priv/static. You may want to comment # this depending on your deployment strategy. diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 000000000000..8a6f1013f971 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,16 @@ +# Plausible frontend + +## Testing + +### 1. Jest component tests + +React component tests can be run via `npm run test` or `npx jest`. These tests test individual react components using +[@testing-library/react](https://testing-library.com/) + +### 2. Playwright tests + +Playwright tests test the application end-to-end. Used to test interaction-heavy parts of Plausible like the dashboard. + +Locally, the best way to run these tests is to: +1. Reset the database and re-seed: `mix ecto.reset` +2. Run tests in UI mode: `npm run --prefix assets playwright:ui` diff --git a/assets/js/dashboard/components/dropdown.tsx b/assets/js/dashboard/components/dropdown.tsx index 308720f52000..85eb18c5b756 100644 --- a/assets/js/dashboard/components/dropdown.tsx +++ b/assets/js/dashboard/components/dropdown.tsx @@ -21,7 +21,7 @@ export const ToggleDropdownButton = forwardRef< currentOption: ReactNode children: ReactNode onClick: () => void - dropdownContainerProps: AriaAttributes + dropdownContainerProps: AriaAttributes & { 'data-testid'?: string } } >(({ currentOption, children, onClick, dropdownContainerProps }, ref) => { return ( diff --git a/assets/js/dashboard/datepicker.tsx b/assets/js/dashboard/datepicker.tsx index dbed663dda7a..7e43b5acff1e 100644 --- a/assets/js/dashboard/datepicker.tsx +++ b/assets/js/dashboard/datepicker.tsx @@ -353,7 +353,8 @@ export default function QueryPeriodPicker() { onClick={toggleDateMenu} dropdownContainerProps={{ ['aria-controls']: 'datemenu', - ['aria-expanded']: menuVisible === 'datemenu' + ['aria-expanded']: menuVisible === 'datemenu', + ['data-testid']: 'date-menu-button' }} > {menuVisible === 'datemenu' && ( diff --git a/assets/js/dashboard/stats/behaviours/index.js b/assets/js/dashboard/stats/behaviours/index.js index 6f992e521709..5af4837b2433 100644 --- a/assets/js/dashboard/stats/behaviours/index.js +++ b/assets/js/dashboard/stats/behaviours/index.js @@ -168,7 +168,7 @@ export default function Behaviours({ importedDataInView }) { } return ( -
+
{displayName}
) @@ -353,7 +353,7 @@ export default function Behaviours({ importedDataInView }) { if (mode) { return ( -
+
diff --git a/assets/js/dashboard/stats/devices/index.js b/assets/js/dashboard/stats/devices/index.js index ab8ec720f5a9..a048a9c64b82 100644 --- a/assets/js/dashboard/stats/devices/index.js +++ b/assets/js/dashboard/stats/devices/index.js @@ -366,7 +366,7 @@ export default function Devices() { } return ( -
+

Devices

diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js index 4f74868f5c1c..6cfd62457830 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.js +++ b/assets/js/dashboard/stats/graph/visitor-graph.js @@ -157,7 +157,7 @@ export default function VisitorGraph({ updateImportedDataInView }) {
{(topStatsLoading || graphLoading) && renderLoader()} -
+
+

@@ -257,4 +257,4 @@ function LocationsWithContext() { const site = useSiteContext(); return } -export default LocationsWithContext \ No newline at end of file +export default LocationsWithContext diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.tsx index ef7206831eb9..e40d5db16910 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx @@ -166,7 +166,8 @@ export default function BreakdownModal({ orderByDictionary, toggleSortByMetric, renderIcon, - getExternalLinkURL + getExternalLinkURL, + meta ] ) diff --git a/assets/js/dashboard/stats/more-link.js b/assets/js/dashboard/stats/more-link.js index 9d7da72b0ad5..c2b32bc18203 100644 --- a/assets/js/dashboard/stats/more-link.js +++ b/assets/js/dashboard/stats/more-link.js @@ -27,6 +27,7 @@ export default function MoreLink({ linkProps, list, className, onClick }) { {...linkProps} className="leading-snug font-bold text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition tracking-wide" onClick={onClick} + data-testid="details-link" > {detailsIcon()} DETAILS diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js index 7430c702e643..3dcd24959e36 100644 --- a/assets/js/dashboard/stats/pages/index.js +++ b/assets/js/dashboard/stats/pages/index.js @@ -193,7 +193,7 @@ export default function Pages() { } return ( -
+
{/* Header Container */}
diff --git a/assets/js/dashboard/stats/reports/list.js b/assets/js/dashboard/stats/reports/list.js index 2908778e20f5..3e716c381fd1 100644 --- a/assets/js/dashboard/stats/reports/list.js +++ b/assets/js/dashboard/stats/reports/list.js @@ -367,7 +367,11 @@ export default function ListReport({ return ( -
+
{state.loading && renderLoading()} {!state.loading && ( diff --git a/assets/js/dashboard/stats/sources/referrer-list.js b/assets/js/dashboard/stats/sources/referrer-list.js index 7ed79484aae1..b7f53129adea 100644 --- a/assets/js/dashboard/stats/sources/referrer-list.js +++ b/assets/js/dashboard/stats/sources/referrer-list.js @@ -59,7 +59,7 @@ export default function Referrers({ source }) { } return ( -
+

Top Referrers

diff --git a/assets/js/dashboard/stats/sources/search-terms.js b/assets/js/dashboard/stats/sources/search-terms.js index cf38a5edb31c..d99aa0e7088b 100644 --- a/assets/js/dashboard/stats/sources/search-terms.js +++ b/assets/js/dashboard/stats/sources/search-terms.js @@ -121,7 +121,7 @@ export default class SearchTerms extends React.Component { render() { return ( -
+
{this.state.loading &&
} diff --git a/assets/js/dashboard/stats/sources/source-list.js b/assets/js/dashboard/stats/sources/source-list.js index 78e4dbc2a7a4..c3683b8d5d2f 100644 --- a/assets/js/dashboard/stats/sources/source-list.js +++ b/assets/js/dashboard/stats/sources/source-list.js @@ -179,7 +179,7 @@ export default function SourceList() {
- + {buttonText} @@ -198,7 +198,7 @@ export default function SourceList() {
{dropdownOptions.map((option) => { return ( - + {({ active }) => ( +
{/* Header Container */}
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",