chore: introduce playwright e2e tests (#28178)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Paul D'Ambra
2025-02-04 18:36:18 +00:00
committed by GitHub
parent d2264966b2
commit a0024d2436
11 changed files with 773 additions and 12 deletions

View File

@@ -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
View 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 }}"}'

View File

@@ -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

View File

@@ -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',
],

View 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
View 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 })
})

View 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)
})

View 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()
}
}

View 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
View 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()
}
}

View 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()
}
}