mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
chore: introduce playwright e2e tests (#28178)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
2
.github/workflows/ci-backend.yml
vendored
2
.github/workflows/ci-backend.yml
vendored
@@ -416,4 +416,4 @@ jobs:
|
||||
with:
|
||||
posthog-token: ${{secrets.POSTHOG_API_TOKEN}}
|
||||
event: 'posthog-ci-running-time'
|
||||
properties: '{"runner": "depot","duration_seconds": ${{ env.running_time_duration_seconds }}, "run_url": "${{ env.running_time_run_url }}", "run_attempt": "${{ env.running_time_run_attempt }}", "run_id": "${{ env.running_time_run_id }}", "run_started_at": "${{ env.running_time_run_started_at }}"}'
|
||||
properties: '{"runner": "depot", "duration_seconds": ${{ env.running_time_duration_seconds }}, "run_url": "${{ env.running_time_run_url }}", "run_attempt": "${{ env.running_time_run_attempt }}", "run_id": "${{ env.running_time_run_id }}", "run_started_at": "${{ env.running_time_run_started_at }}"}'
|
||||
|
||||
251
.github/workflows/ci-e2e-playwright.yml
vendored
Normal file
251
.github/workflows/ci-e2e-playwright.yml
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
#
|
||||
# This workflow runs CI E2E tests with Playwright.
|
||||
#
|
||||
# It relies on the container image built by 'container-images-ci.yml'.
|
||||
#
|
||||
name: E2E CI Playwright
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
if: github.repository == 'PostHog/posthog'
|
||||
name: Determine need to run E2E checks
|
||||
# Set job outputs to values from filter step
|
||||
outputs:
|
||||
shouldRun: ${{ steps.changes.outputs.shouldRun }}
|
||||
steps:
|
||||
# For pull requests it's not necessary to check out the code
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
shouldRun:
|
||||
# Avoid running E2E tests for irrelevant changes
|
||||
# NOTE: we are at risk of missing a dependency here. We could make
|
||||
# the dependencies more clear if we separated the backend/frontend
|
||||
# code completely
|
||||
- 'ee/**'
|
||||
- 'posthog/**'
|
||||
- 'bin/*'
|
||||
- frontend/**/*
|
||||
- requirements.txt
|
||||
- requirements-dev.txt
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
# Make sure we run if someone is explicitly changes the workflow
|
||||
- .github/workflows/ci-e2e-playwright.yml
|
||||
- .github/actions/build-n-cache-image/action.yml
|
||||
# We use docker compose for tests, make sure we rerun on
|
||||
# changes to docker-compose.dev.yml e.g. dependency
|
||||
# version changes
|
||||
- docker-compose.dev.yml
|
||||
- Dockerfile
|
||||
- playwright/**
|
||||
|
||||
container:
|
||||
name: Build and cache container image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [changes]
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # allow issuing OIDC tokens for this workflow run
|
||||
outputs:
|
||||
tag: ${{ steps.build.outputs.tag }}
|
||||
build-id: ${{ steps.build.outputs.build-id }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
uses: actions/checkout@v3
|
||||
- name: Build the Docker image with Depot
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
# Build the container image in preparation for the E2E tests
|
||||
uses: ./.github/actions/build-n-cache-image
|
||||
id: build
|
||||
with:
|
||||
save: true
|
||||
actions-id-token-request-url: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}
|
||||
|
||||
playwright:
|
||||
name: Playwright E2E tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [changes, container]
|
||||
permissions:
|
||||
id-token: write # allow issuing OIDC tokens for this workflow run
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.12.1
|
||||
|
||||
- name: Get pnpm cache directory path
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
id: pnpm-cache-dir
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
id: pnpm-cache
|
||||
with:
|
||||
path: |
|
||||
${{ steps.pnpm-cache-dir.outputs.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-playwright-
|
||||
|
||||
- name: Install package.json dependencies with pnpm
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install playwright and dependencies
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Stop/Start stack with Docker Compose
|
||||
# these are required checks so, we can't skip entire sections
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
run: |
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
- name: Wait for ClickHouse
|
||||
# these are required checks so, we can't skip entire sections
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
run: ./bin/check_kafka_clickhouse_up
|
||||
|
||||
- name: Install Depot CLI
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
- name: Get Docker image cached in Depot
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
uses: depot/pull-action@v1
|
||||
with:
|
||||
build-id: ${{ needs.container.outputs.build-id }}
|
||||
tags: ${{ needs.container.outputs.tag }}
|
||||
|
||||
- name: Write .env # This step intentionally has no if, so that GH always considers the action as having run
|
||||
run: |
|
||||
cat <<EOT >> .env
|
||||
SECRET_KEY=6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da
|
||||
REDIS_URL=redis://localhost
|
||||
DATABASE_URL=postgres://posthog:posthog@localhost:5432/posthog
|
||||
KAFKA_HOSTS=kafka:9092
|
||||
DISABLE_SECURE_SSL_REDIRECT=1
|
||||
SECURE_COOKIES=0
|
||||
OPT_OUT_CAPTURE=0
|
||||
E2E_TESTING=1
|
||||
SKIP_SERVICE_VERSION_REQUIREMENTS=1
|
||||
EMAIL_HOST=email.test.posthog.net
|
||||
SITE_URL=http://localhost:8000
|
||||
NO_RESTART_LOOP=1
|
||||
CLICKHOUSE_SECURE=0
|
||||
OBJECT_STORAGE_ENABLED=1
|
||||
OBJECT_STORAGE_ENDPOINT=http://localhost:19000
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID=object_storage_root_user
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY=object_storage_root_password
|
||||
GITHUB_ACTION_RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
CELERY_METRICS_PORT=8999
|
||||
CLOUD_DEPLOYMENT=1
|
||||
EOT
|
||||
|
||||
- name: Start PostHog
|
||||
# these are required checks so, we can't skip entire sections
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
run: |
|
||||
mkdir -p /tmp/logs
|
||||
|
||||
echo "Starting PostHog using the container image ${{ needs.container.outputs.tag }}"
|
||||
DOCKER_RUN="docker run --rm --network host --add-host kafka:127.0.0.1 --env-file .env ${{ needs.container.outputs.tag }}"
|
||||
|
||||
$DOCKER_RUN ./bin/migrate
|
||||
$DOCKER_RUN python manage.py setup_dev
|
||||
|
||||
# only starts the plugin server so that the "wait for PostHog" step passes
|
||||
$DOCKER_RUN ./bin/docker-worker &> /tmp/logs/worker.txt &
|
||||
$DOCKER_RUN ./bin/docker-server &> /tmp/logs/server.txt &
|
||||
|
||||
- name: Wait for PostHog
|
||||
# these are required checks so, we can't skip entire sections
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
# this action might be abandoned - but v1 doesn't point to latest of v1 (which it should)
|
||||
# so pointing to v1.1.0 to remove warnings about node version with v1
|
||||
# todo check https://github.com/iFaxity/wait-on-action/releases for new releases
|
||||
uses: iFaxity/wait-on-action@v1.2.1
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
verbose: true
|
||||
log: true
|
||||
resource: http://localhost:8000
|
||||
|
||||
- name: Playwright run
|
||||
# these are required checks so, we can't skip entire sections
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
run: pnpm exec playwright test
|
||||
env:
|
||||
E2E_TESTING: 1
|
||||
OPT_OUT_CAPTURE: 0
|
||||
GITHUB_ACTION_RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
|
||||
- name: Archive report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
if: ${{ failure() }}
|
||||
|
||||
capture-run-time:
|
||||
name: Capture run time
|
||||
runs-on: ubuntu-latest
|
||||
needs: [playwright]
|
||||
if: needs.changes.outputs.shouldRun == 'true'
|
||||
steps:
|
||||
- name: Calculate run time and send to PostHog
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token
|
||||
run_id=${GITHUB_RUN_ID}
|
||||
repo=${GITHUB_REPOSITORY}
|
||||
run_info=$(gh api repos/${repo}/actions/runs/${run_id})
|
||||
echo run_info: ${run_info}
|
||||
# name is the name of the workflow file
|
||||
# run_started_at is the start time of the workflow
|
||||
# we want to get the number of seconds between the start time and now
|
||||
name=$(echo ${run_info} | jq -r '.name')
|
||||
run_url=$(echo ${run_info} | jq -r '.url')
|
||||
run_started_at=$(echo ${run_info} | jq -r '.run_started_at')
|
||||
run_attempt=$(echo ${run_info} | jq -r '.run_attempt')
|
||||
start_seconds=$(date -d "${run_started_at}" +%s)
|
||||
now_seconds=$(date +%s)
|
||||
duration=$((now_seconds-start_seconds))
|
||||
echo running_time_duration_seconds=${duration} >> $GITHUB_ENV
|
||||
echo running_time_run_url=${run_url} >> $GITHUB_ENV
|
||||
echo running_time_run_attempt=${run_attempt} >> $GITHUB_ENV
|
||||
echo running_time_run_id=${run_id} >> $GITHUB_ENV
|
||||
echo running_time_run_started_at=${run_started_at} >> $GITHUB_ENV
|
||||
- name: Capture running time to PostHog
|
||||
if: github.repository == 'PostHog/posthog'
|
||||
uses: PostHog/posthog-github-action@v0.1
|
||||
with:
|
||||
posthog-token: ${{secrets.POSTHOG_API_TOKEN}}
|
||||
event: 'posthog-playwright-e2e-running-time'
|
||||
properties: '{"duration_seconds": ${{ env.running_time_duration_seconds }}, "run_url": "${{ env.running_time_run_url }}", "run_attempt": "${{ env.running_time_run_attempt }}", "run_id": "${{ env.running_time_run_id }}", "run_started_at": "${{ env.running_time_run_started_at }}"}'
|
||||
@@ -65,6 +65,7 @@ export PGPASSWORD="${PGPASSWORD:=posthog}"
|
||||
export PGPORT="${PGPORT:=5432}"
|
||||
export DATABASE_URL="postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/${DATABASE}"
|
||||
export CLOUD_DEPLOYMENT=E2E
|
||||
export START_CYPRESS="${START_CYPRESS:1}"
|
||||
|
||||
source ./bin/celery-queues.env
|
||||
|
||||
@@ -98,10 +99,16 @@ $SKIP_RECREATE_DATABASE || recreateDatabases
|
||||
$SKIP_MIGRATE || migrateDatabases
|
||||
$SKIP_SETUP_DEV || setupDev
|
||||
|
||||
# parallel block
|
||||
# Only start webpack if not already running
|
||||
nc -vz 127.0.0.1 8234 2> /dev/null || ./bin/start-frontend &
|
||||
pnpm cypress open --config-file cypress.e2e.config.ts &
|
||||
uv pip install -r requirements.txt -r requirements-dev.txt
|
||||
python manage.py run_autoreload_celery --type=worker &
|
||||
python manage.py runserver 8080
|
||||
if [ "$START_CYPRESS" ]; then
|
||||
# parallel block
|
||||
# Only start webpack if not already running
|
||||
nc -vz 127.0.0.1 8234 2> /dev/null || ./bin/start-frontend &
|
||||
pnpm cypress open --config-file cypress.e2e.config.ts &
|
||||
python manage.py run_autoreload_celery --type=worker &
|
||||
python manage.py runserver 8080
|
||||
else
|
||||
# parallel block
|
||||
# Only start webpack if not already running
|
||||
nc -vz 127.0.0.1 8234 2> /dev/null || ./bin/start-frontend &
|
||||
python manage.py runserver 8080
|
||||
fi
|
||||
@@ -18,14 +18,14 @@ export default defineConfig({
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 5000,
|
||||
timeout: 30 * 1000,
|
||||
},
|
||||
/* 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,
|
||||
retries: process.env.CI ? 4 : 2,
|
||||
/* Run one worker per core in GitHub Actions. */
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
@@ -35,18 +35,26 @@ export default defineConfig({
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
baseURL: process.env.CI ? 'http://localhost:8000' : 'http://localhost:8080',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Locate elements defined by `data-attr-something` with `page.getByTestId('something')` */
|
||||
testIdAttribute: 'data-attr',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
// setup
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
// use auth state generated by setup
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
launchOptions: {
|
||||
// https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md
|
||||
args: [
|
||||
@@ -58,7 +66,7 @@ export default defineConfig({
|
||||
'--disable-features=PaintHolding',
|
||||
'--disable-partial-raster',
|
||||
'--disable-skia-runtime-opt',
|
||||
'--in-process-gpu',
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
'--force-color-profile=srgb',
|
||||
],
|
||||
|
||||
93
playwright/.auth/user.json
Normal file
93
playwright/.auth/user.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "sessionid",
|
||||
"value": "fxdw2cqaodz9wdy1m4j3yuwgvyqjnrfv",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1739630048.015045,
|
||||
"httpOnly": true,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "posthog_csrftoken",
|
||||
"value": "U3I9p02YuUlCBanYKIPYVY9Z74y3uVXW",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1739630048.015089,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "ph_phc_ex7Mnvi4DqeB6xSQoXU1UVPzAmUIpiciRKQQXGGTYQO_posthog",
|
||||
"value": "%7B%22distinct_id%22%3A%220194c1ee-d2ae-7416-905b-abeb41e3f9d6%22%2C%22%24sesid%22%3A%5B1738420448079%2C%220194c1ee-d2ab-70ca-b3f9-a90e0ab37f8b%22%2C1738420441771%5D%2C%22%24epp%22%3Atrue%2C%22%24initial_person_info%22%3A%7B%22r%22%3A%22%24direct%22%2C%22u%22%3A%22http%3A%2F%2Flocalhost%3A8080%2Flogin%22%7D%7D",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": 1769956448,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "http://localhost:8080",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "ph_phc_ex7Mnvi4DqeB6xSQoXU1UVPzAmUIpiciRKQQXGGTYQO_posthog",
|
||||
"value": "{\"$configured_session_timeout_ms\":1800000,\"$sesid\":[1738420448079,\"0194c1ee-d2ab-70ca-b3f9-a90e0ab37f8b\",1738420441771],\"$client_session_props\":{\"sessionId\":\"0194c1ee-d2ab-70ca-b3f9-a90e0ab37f8b\",\"props\":{\"initialPathName\":\"/login\",\"referringDomain\":\"$direct\",\"utm_source\":null,\"utm_medium\":null,\"utm_campaign\":null,\"utm_content\":null,\"utm_term\":null,\"gad_source\":null,\"mc_cid\":null,\"gclid\":null,\"gclsrc\":null,\"dclid\":null,\"gbraid\":null,\"wbraid\":null,\"fbclid\":null,\"msclkid\":null,\"twclid\":null,\"li_fat_id\":null,\"igshid\":null,\"ttclid\":null,\"rdt_cid\":null,\"irclid\":null,\"_kx\":null}},\"distinct_id\":\"0194c1ee-d2ae-7416-905b-abeb41e3f9d6\",\"$device_id\":\"0194c1ee-d2ae-7416-905b-abeb41e3f9d6\",\"$user_state\":\"anonymous\",\"$capture_rate_limit\":{\"tokens\":99,\"last\":1738420448077},\"$initial_person_info\":{\"r\":\"$direct\",\"u\":\"http://localhost:8080/login\"},\"$epp\":true,\"realm\":\"cloud\",\"email_service_available\":true,\"slack_service_available\":false,\"commit_sha\":\"66206e76e6\",\"$session_recording_enabled_server_side\":true,\"$console_log_recording_enabled_server_side\":true,\"$session_recording_network_payload_capture\":{\"capturePerformance\":{\"network_timing\":true,\"web_vitals\":false,\"web_vitals_allowed_metrics\":null},\"recordBody\":true,\"recordHeaders\":true},\"$session_recording_canvas_recording\":{\"enabled\":true,\"fps\":3,\"quality\":\"0.4\"},\"$replay_sample_rate\":null,\"$replay_minimum_duration\":null,\"$replay_script_config\":null,\"$autocapture_disabled_server_side\":false,\"$heatmaps_enabled_server_side\":false,\"$web_vitals_enabled_server_side\":false,\"$web_vitals_allowed_metrics\":null,\"$exception_capture_enabled_server_side\":false,\"$dead_clicks_enabled_server_side\":false,\"$active_feature_flags\":[],\"$enabled_feature_flags\":{\"surveys-actions\":false},\"$feature_flag_payloads\":{},\"$surveys\":[]}"
|
||||
},
|
||||
{
|
||||
"name": "scenes.navigation.sidepanel.sidePanelStateLogic.selectedTab",
|
||||
"value": "null"
|
||||
},
|
||||
{
|
||||
"name": "components.lemon-banner.lemonBannerLogic.usage-limit-approaching.isDismissed",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "__ph_opt_in_out_phc_ex7Mnvi4DqeB6xSQoXU1UVPzAmUIpiciRKQQXGGTYQO",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"name": "layout.navigation-3000.themeLogic.selectedTheme",
|
||||
"value": "null"
|
||||
},
|
||||
{
|
||||
"name": "scenes.billing.billingLogic.isCreditCTAHeroDismissed",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "components.lemon-banner.lemonBannerLogic.usage-limit-exceeded.isDismissed",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "layout.navigation-3000.themeLogic.persistedCustomCss",
|
||||
"value": "null"
|
||||
},
|
||||
{
|
||||
"name": "layout.navigation-3000.themeLogic.previewingCustomCss",
|
||||
"value": "null"
|
||||
},
|
||||
{
|
||||
"name": "lib.logic.featureFlagLogic.featureFlags",
|
||||
"value": "{\"simplify-actions\":true,\"historical-exports-v2\":true,\"ingestion-warnings-enabled\":true,\"persons-hogql-query\":true,\"datanode-concurrency-limit\":true,\"session-table-property-filters\":true,\"query-async\":true}"
|
||||
},
|
||||
{
|
||||
"name": "scenes.navigation.sidepanel.sidePanelStateLogic.sidePanelOpen",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "scenes.notebooks.Notebook.notebookPanelLogic.selectedNotebook",
|
||||
"value": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "scenes.sceneLogic.lastReloadAt",
|
||||
"value": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
27
playwright/auth.setup.ts
Normal file
27
playwright/auth.setup.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {mkdirSync} from "node:fs";
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
import { test as setup } from '@playwright/test'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
const authFile = resolve('playwright/.auth/user.json')
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
mkdirSync(dirname(authFile), { recursive: true }) // Ensure directory exists
|
||||
|
||||
// perform authentication steps
|
||||
await page.goto(urls.login())
|
||||
await page.getByPlaceholder('email@yourcompany.com').fill('test@posthog.com')
|
||||
await page.getByPlaceholder('••••••••••').fill('12345678')
|
||||
await page.getByRole('button', { name: 'Log in' }).click()
|
||||
|
||||
// wait for login to succeed / cookies
|
||||
await page.waitForURL(urls.projectHomepage())
|
||||
|
||||
// Fetch storage state before saving
|
||||
const storageState = await page.context().storageState()
|
||||
console.log('✅ Storage state captured:', JSON.stringify(storageState, null, 2))
|
||||
|
||||
// store auth state
|
||||
await page.context().storageState({ path: authFile })
|
||||
})
|
||||
82
playwright/e2e/product-analytics/insight-navigation.spec.ts
Normal file
82
playwright/e2e/product-analytics/insight-navigation.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { InsightType } from '~/types'
|
||||
|
||||
import { InsightPage } from '../../page-models/insightPage'
|
||||
import {Navigation} from "../../utils/navigation";
|
||||
|
||||
const typeTestCases: { type: InsightType; selector: string }[] = [
|
||||
{ type: InsightType.TRENDS, selector: '.TrendsInsight canvas' },
|
||||
{ type: InsightType.FUNNELS, selector: '.funnels-empty-state__title' },
|
||||
{ type: InsightType.RETENTION, selector: '.RetentionContainer canvas' },
|
||||
{ type: InsightType.PATHS, selector: '.Paths' },
|
||||
{ type: InsightType.STICKINESS, selector: '.TrendsInsight canvas' },
|
||||
{ type: InsightType.LIFECYCLE, selector: '.TrendsInsight canvas' },
|
||||
{ type: InsightType.SQL, selector: '[data-attr="hogql-query-editor"]' },
|
||||
]
|
||||
|
||||
typeTestCases.forEach(({ type, selector }) => {
|
||||
// skipping things because we want to get a single passing test in
|
||||
test.skip(`can navigate to ${type} insight from saved insights page`, async ({ page }) => {
|
||||
await new InsightPage(page).goToNew(type)
|
||||
// have to use contains to make paths match user paths
|
||||
await expect(page.locator('.LemonTabs__tab--active')).toContainText(type, {ignoreCase: true})
|
||||
|
||||
// we don't need to wait for the insight to load, just that it or its loading state is visible
|
||||
const insightStillLoading = await page.locator('.insight-empty-state.warning').isVisible()
|
||||
const insightDidLoad = await page.locator(selector).isVisible()
|
||||
expect(insightStillLoading || insightDidLoad).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// skipping things because we want to get a single passing test in
|
||||
// commented out because the query spec is incorrect
|
||||
// test.skip('can navigate to insight by query', async ({ page }) => {
|
||||
// const insight = new InsightPage(page)
|
||||
// const url = urls.insightNew(undefined, undefined, {
|
||||
// kind: NodeKind.InsightVizNode,
|
||||
// source: {
|
||||
// kind: 'TrendsQuery',
|
||||
// series: [
|
||||
// {
|
||||
// kind: 'EventsNode',
|
||||
// event: '$autocapture',
|
||||
// name: 'Autocapture',
|
||||
// math: 'total',
|
||||
// },
|
||||
// ],
|
||||
// interval: 'day',
|
||||
// trendsFilter: {
|
||||
// display: 'ActionsLineGraph',
|
||||
// },
|
||||
// },
|
||||
// full: true,
|
||||
// })
|
||||
//
|
||||
// await page.goto(url)
|
||||
//
|
||||
// // test series labels
|
||||
// await insight.waitForDetailsTable()
|
||||
// const labels = await insight.detailsLabels.allInnerTexts()
|
||||
// expect(labels).toEqual(['Autocapture'])
|
||||
// })
|
||||
|
||||
test('can open event explorer as an insight', async ({ page }) => {
|
||||
const navigation = new Navigation(page)
|
||||
await navigation.openHome()
|
||||
|
||||
await navigation.openMenuItem('activity')
|
||||
await page.getByTestId('data-table-export-menu').click()
|
||||
await page.getByTestId('open-json-editor-button').click()
|
||||
|
||||
await expect(page.getByTestId('insight-json-tab')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('does not show the json tab usually', async ({ page }) => {
|
||||
const navigation = new Navigation(page)
|
||||
await navigation.openHome()
|
||||
|
||||
await navigation.openMenuItem('savedinsights')
|
||||
|
||||
await expect(page.getByTestId('insight-json-tab')).toHaveCount(0)
|
||||
})
|
||||
58
playwright/page-models/dashboardPage.ts
Normal file
58
playwright/page-models/dashboardPage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect, Locator, Page } from '@playwright/test'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { randomString } from '../utils'
|
||||
|
||||
export class DashboardPage {
|
||||
readonly page: Page
|
||||
|
||||
readonly topBarName: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
|
||||
this.topBarName = page.getByTestId('top-bar-name')
|
||||
}
|
||||
|
||||
async createNew(dashboardName: string = randomString('dashboard')): Promise<DashboardPage> {
|
||||
await this.page.goto(urls.dashboards())
|
||||
await this.page.getByTestId('new-dashboard').click()
|
||||
await this.page.getByTestId('create-dashboard-blank').click()
|
||||
await expect(this.page.locator('.dashboard')).toBeVisible()
|
||||
|
||||
await this.editName(dashboardName)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Checks assertions, reloads and checks again. This is useful for asserting both the local state
|
||||
* and the backend side state are persisted correctly. */
|
||||
async withReload(callback: () => Promise<void>, beforeFn?: () => Promise<void>): Promise<void> {
|
||||
await beforeFn?.()
|
||||
await callback()
|
||||
await this.page.reload({ waitUntil: 'networkidle' })
|
||||
await callback()
|
||||
}
|
||||
|
||||
async editName(dashboardName: string = randomString('dashboard')): Promise<void> {
|
||||
await this.topBarName.getByRole('button').click()
|
||||
await this.topBarName.getByRole('textbox').fill(dashboardName)
|
||||
await this.topBarName.getByRole('button').getByText('Save').click()
|
||||
}
|
||||
|
||||
async renameFirstTile(newTileName: string): Promise<void> {
|
||||
await this.page.locator('.CardMeta').getByTestId('more-button').click()
|
||||
await this.page.locator('.Popover').getByText('Rename').click()
|
||||
await this.page.locator('.LemonModal').getByTestId('insight-name').fill(newTileName)
|
||||
await this.page.locator('.LemonModal').getByText('Submit').click()
|
||||
}
|
||||
|
||||
async removeFirstTile(): Promise<void> {
|
||||
await this.page.locator('.CardMeta').getByTestId('more-button').click()
|
||||
await this.page.locator('.Popover').getByText('Remove from dashboard').click()
|
||||
}
|
||||
|
||||
async duplicateFirstTile(): Promise<void> {
|
||||
await this.page.locator('.CardMeta').getByTestId('more-button').click()
|
||||
await this.page.locator('.Popover').getByText('Duplicate').click()
|
||||
}
|
||||
}
|
||||
185
playwright/page-models/insightPage.ts
Normal file
185
playwright/page-models/insightPage.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { expect, Locator, Page } from '@playwright/test'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { InsightType } from '~/types'
|
||||
|
||||
import { randomString } from '../utils'
|
||||
import { DashboardPage } from './dashboardPage'
|
||||
|
||||
export class InsightPage {
|
||||
readonly page: Page
|
||||
|
||||
readonly saveButton: Locator
|
||||
readonly editButton: Locator
|
||||
readonly topBarName: Locator
|
||||
|
||||
// series
|
||||
readonly addEntityButton: Locator
|
||||
readonly firstEntity: Locator
|
||||
readonly secondEntity: Locator
|
||||
|
||||
// details table
|
||||
readonly detailsLabels: Locator
|
||||
readonly detailsLoader: Locator
|
||||
|
||||
// menu
|
||||
readonly moreButton: Locator
|
||||
readonly toggleEditorButton: Locator
|
||||
|
||||
// dashboard
|
||||
readonly dashboardButton: Locator
|
||||
|
||||
// source editor
|
||||
readonly editor: Locator
|
||||
readonly updateSourceButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
|
||||
this.saveButton = page.getByTestId('insight-save-button')
|
||||
this.editButton = page.getByTestId('insight-edit-button')
|
||||
this.topBarName = page.getByTestId('top-bar-name')
|
||||
|
||||
this.addEntityButton = page.getByTestId('add-action-event-button')
|
||||
this.firstEntity = page.getByTestId('trend-element-subject-0')
|
||||
this.secondEntity = page.getByTestId('trend-element-subject-1')
|
||||
|
||||
this.detailsLabels = page.getByTestId('insights-table-graph').locator('.insights-label')
|
||||
this.detailsLoader = page.locator('.LemonTableLoader')
|
||||
|
||||
this.moreButton = page.getByTestId('more-button')
|
||||
this.toggleEditorButton = page.getByTestId('show-insight-source')
|
||||
|
||||
this.dashboardButton = page.getByTestId('save-to-dashboard-button')
|
||||
|
||||
this.editor = this.page.getByTestId('query-editor').locator('.monaco-editor')
|
||||
this.updateSourceButton = page.getByRole('button', { name: 'Update and run' })
|
||||
}
|
||||
|
||||
async goToNew(insightType?: InsightType): Promise<InsightPage> {
|
||||
await this.page.goto(urls.savedInsights())
|
||||
await this.page.getByTestId('saved-insights-new-insight-dropdown').click()
|
||||
|
||||
const insightQuery = this.page.waitForRequest((req) => {
|
||||
return !!(req.url().match(/api\/environments\/\d+\/query/) && req.method() === 'POST')
|
||||
})
|
||||
await this.page.locator(`[data-attr-insight-type="${insightType || 'TRENDS'}"]`).click()
|
||||
await insightQuery
|
||||
|
||||
await this.page.waitForSelector('.LemonTabs__tab--active')
|
||||
return this
|
||||
}
|
||||
|
||||
async createNew(insightName?: string, insightType?: InsightType): Promise<InsightPage> {
|
||||
await this.goToNew(insightType)
|
||||
await this.editName(insightName)
|
||||
await this.save()
|
||||
return this
|
||||
}
|
||||
|
||||
/*
|
||||
* Filters
|
||||
*/
|
||||
async save(): Promise<void> {
|
||||
await this.saveButton.click()
|
||||
// wait for save to complete and URL to change and include short id
|
||||
await this.page.waitForURL(/^(?!.*\/new$).+$/)
|
||||
}
|
||||
|
||||
async edit(): Promise<void> {
|
||||
await this.editButton.click()
|
||||
}
|
||||
|
||||
/** Enables edit mode, performs actions and saves. */
|
||||
async withEdit(callback: () => Promise<void>): Promise<void> {
|
||||
await this.edit()
|
||||
await callback()
|
||||
await this.save()
|
||||
}
|
||||
|
||||
/** Checks assertions, reloads and checks again. This is useful for asserting both the local state
|
||||
* and the backend side state are persisted correctly. */
|
||||
async withReload(callback: () => Promise<void>, beforeFn?: () => Promise<void>): Promise<void> {
|
||||
await beforeFn?.()
|
||||
await callback()
|
||||
await this.page.reload({ waitUntil: 'networkidle' })
|
||||
await callback()
|
||||
}
|
||||
|
||||
async waitForDetailsTable(): Promise<void> {
|
||||
await this.detailsLabels.first().waitFor()
|
||||
await expect(this.detailsLoader).toHaveCount(0)
|
||||
}
|
||||
|
||||
/*
|
||||
* Metadata
|
||||
*/
|
||||
async editName(insightName: string = randomString('insight')): Promise<void> {
|
||||
await this.topBarName.getByRole('button').click()
|
||||
await this.topBarName.getByRole('textbox').fill(insightName)
|
||||
await this.topBarName.getByRole('button').getByText('Save').click()
|
||||
}
|
||||
|
||||
/*
|
||||
* Query editor
|
||||
*/
|
||||
async openSourceEditor(): Promise<void> {
|
||||
await this.moreButton.click()
|
||||
await this.toggleEditorButton.click()
|
||||
}
|
||||
|
||||
async changeQuerySource(code: string): Promise<void> {
|
||||
await this.editor.click()
|
||||
|
||||
// clear text
|
||||
await this.page.keyboard.press('Control+KeyA')
|
||||
await this.page.keyboard.press('Backspace')
|
||||
|
||||
// insert text
|
||||
await this.page.keyboard.insertText(code)
|
||||
|
||||
await this.updateSourceButton.click()
|
||||
}
|
||||
|
||||
/*
|
||||
* More menu
|
||||
*/
|
||||
async delete(): Promise<void> {
|
||||
await this.moreButton.click()
|
||||
await this.page.getByTestId('delete-insight-from-insight-view').click()
|
||||
await expect(this.page.locator('.saved-insights')).toBeVisible()
|
||||
}
|
||||
|
||||
async duplicate(): Promise<void> {
|
||||
await this.moreButton.click()
|
||||
await this.page.getByTestId('duplicate-insight-from-insight-view').click()
|
||||
}
|
||||
|
||||
/*
|
||||
* Dashboards
|
||||
*/
|
||||
async addToNewDashboard(dashboardName?: string): Promise<void> {
|
||||
await this.dashboardButton.click()
|
||||
await this.page.locator('.LemonModal').getByText('Add to a new dashboard').click()
|
||||
await this.page.getByTestId('create-dashboard-blank').click()
|
||||
await expect(this.page.locator('.dashboard')).toBeVisible()
|
||||
|
||||
if (dashboardName) {
|
||||
await new DashboardPage(this.page).editName(dashboardName)
|
||||
}
|
||||
}
|
||||
|
||||
async removeDashboard(dashboardName?: string): Promise<void> {
|
||||
await this.dashboardButton.click()
|
||||
if (dashboardName) {
|
||||
await this.page.getByTestId('dashboard-searchfield').fill(dashboardName)
|
||||
}
|
||||
await this.page.getByText('Remove from dashboard').first().click()
|
||||
}
|
||||
|
||||
async openDashboard(dashboardName: string): Promise<void> {
|
||||
await this.dashboardButton.click()
|
||||
await this.page.getByTestId('dashboard-searchfield').fill(dashboardName)
|
||||
await this.page.getByTestId('dashboard-list-item').getByRole('link').first().click()
|
||||
}
|
||||
}
|
||||
22
playwright/utils/index.ts
Normal file
22
playwright/utils/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export function randomString(prefix = ''): string {
|
||||
const id = Math.floor(Math.random() * 1e6)
|
||||
return `${prefix}-${id}`
|
||||
}
|
||||
|
||||
export async function getLemonSwitchValue(page: Page, label: string): Promise<boolean | null> {
|
||||
const button = page.getByLabel(label)
|
||||
const parent = button.locator('..')
|
||||
const classNames = await parent.getAttribute('class')
|
||||
return classNames?.includes('LemonSwitch--checked') || null
|
||||
}
|
||||
|
||||
export async function setLemonSwitchValue(page: Page, label: string, value: boolean): Promise<void> {
|
||||
const button = page.getByLabel(label)
|
||||
const currentValue = await getLemonSwitchValue(page, label)
|
||||
|
||||
if (value !== currentValue) {
|
||||
await button.click()
|
||||
}
|
||||
}
|
||||
28
playwright/utils/navigation.ts
Normal file
28
playwright/utils/navigation.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Page } from '@playwright/test'
|
||||
import { Scene } from 'scenes/sceneTypes'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
type LowercaseEnum<T> = {
|
||||
[K in keyof T]: T[K] extends string ? Lowercase<T[K]> : never
|
||||
}[keyof T]
|
||||
|
||||
/** Derive possible identifiers from the Scene enum. Not all of them are
|
||||
* actually nav items. This will help keep e2e tests up-to-date when
|
||||
* refactoring scenes though. */
|
||||
type Identifier = LowercaseEnum<typeof Scene>
|
||||
|
||||
export class Navigation {
|
||||
readonly page: Page
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
}
|
||||
|
||||
async openHome(): Promise<void> {
|
||||
await this.page.goto(urls.projectHomepage())
|
||||
}
|
||||
|
||||
async openMenuItem(name: Identifier): Promise<void> {
|
||||
await this.page.getByTestId(`menu-item-${name}`).click()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user