diff --git a/assets/js/components/Notice/index.js b/assets/js/components/Notice/index.js index c8e0188ebdc..48ab20b5e85 100644 --- a/assets/js/components/Notice/index.js +++ b/assets/js/components/Notice/index.js @@ -57,6 +57,7 @@ const Notice = forwardRef( `googlesitekit-notice--${ type }`, className ) } + role="status" > { ! hideIcon && (
diff --git a/assets/js/components/email-reporting/UserSettingsSelectionPanel/index.js b/assets/js/components/email-reporting/UserSettingsSelectionPanel/index.js index 13fdcdac7ca..3de2cd4344e 100644 --- a/assets/js/components/email-reporting/UserSettingsSelectionPanel/index.js +++ b/assets/js/components/email-reporting/UserSettingsSelectionPanel/index.js @@ -188,7 +188,7 @@ export default function UserSettingsSelectionPanel() { return (
defined( 'WEBSTORIES_VERSION' ), 'postTypes' => $this->get_post_types(), 'storagePrefix' => $this->get_storage_prefix(), - 'referenceDate' => apply_filters( 'googlesitekit_reference_date', null ), + 'referenceDate' => Date::reference_date(), 'productPostType' => $this->get_product_post_type(), 'anyoneCanRegister' => (bool) get_option( 'users_can_register' ), 'isMultisite' => is_multisite(), diff --git a/includes/Core/Authentication/Clients/Client_Factory.php b/includes/Core/Authentication/Clients/Client_Factory.php index c804bc3e88b..d8c872520c6 100644 --- a/includes/Core/Authentication/Clients/Client_Factory.php +++ b/includes/Core/Authentication/Clients/Client_Factory.php @@ -63,6 +63,16 @@ public static function create_client( array $args ) { $http_client = $client->getHttpClient(); $http_client_config = self::get_http_client_config( $http_client->getConfig() ); + + /** + * Filters the Guzzle HTTP client configuration used for Google API requests. + * + * @since n.e.x.t + * + * @param array $http_client_config Guzzle HTTP client configuration array. + */ + $http_client_config = apply_filters( 'googlesitekit_http_client_config', $http_client_config ); + // In Guzzle 6+, the HTTP client is immutable, so only a new instance can be set. $client->setHttpClient( new Client( $http_client_config ) ); diff --git a/includes/Core/Email_Reporting/Initiator_Task.php b/includes/Core/Email_Reporting/Initiator_Task.php index 3d4a68c2b27..3a8c397a71d 100644 --- a/includes/Core/Email_Reporting/Initiator_Task.php +++ b/includes/Core/Email_Reporting/Initiator_Task.php @@ -14,6 +14,7 @@ use DateTimeImmutable; use Google\Site_Kit\Core\Util\BC_Functions; use Google\Site_Kit\Core\User\Email_Reporting_Settings; +use Google\Site_Kit\Core\Util\Date; /** * Handles initiator cron callbacks for email reporting. @@ -61,9 +62,8 @@ public function __construct( Email_Reporting_Scheduler $scheduler, Subscribed_Us */ public function handle_callback_action( $frequency, $scheduled_timestamp = null ) { $timestamp = (int) $scheduled_timestamp; - if ( $timestamp <= 0 ) { - $timestamp = time(); + $timestamp = Date::now(); } $this->scheduler->schedule_next_initiator( $frequency, $timestamp ); diff --git a/includes/Core/Util/Date.php b/includes/Core/Util/Date.php index 106ac9174c7..d2e87e0ac75 100644 --- a/includes/Core/Util/Date.php +++ b/includes/Core/Util/Date.php @@ -1,6 +1,6 @@ gmdate( 'Y-m-d' ), - 'end_date' => gmdate( 'Y-m-d' ), + 'start_date' => gmdate( 'Y-m-d', Date::now() ), + 'end_date' => gmdate( 'Y-m-d', Date::now() ), 'row_limit' => 1, ); diff --git a/tests/playwright/README.md b/tests/playwright/README.md index 2ca0c916c12..f46aaf0a0c2 100644 --- a/tests/playwright/README.md +++ b/tests/playwright/README.md @@ -11,6 +11,10 @@ End-to-end tests for the Site Kit WordPress plugin, built on [Playwright](https: - [Cookie-Based Routing](#cookie-based-routing) - [Authentication Without Login](#authentication-without-login) - [Plugin Activation via Database](#plugin-activation-via-database) + - [Feature Flags](#feature-flags) + - [Google API Fixtures](#google-api-fixtures) + - [Email Testing](#email-testing) + - [Reference Date](#reference-date) - [PHP Error Logging](#php-error-logging) - [Running Tests](#running-tests) - [Writing Tests](#writing-tests) @@ -19,6 +23,8 @@ End-to-end tests for the Site Kit WordPress plugin, built on [Playwright](https: - [Test Annotations](#test-annotations) - [Navigation Helpers](#navigation-helpers) - [Plugin Management](#plugin-management) + - [REST API Helpers](#rest-api-helpers) + - [Email Assertions](#email-assertions) - [Test Infrastructure](#test-infrastructure) - [Docker Services](#docker-services) - [Docker Compose Profiles](#docker-compose-profiles) @@ -38,7 +44,10 @@ This infrastructure enables fast, fully isolated, repeatable E2E tests without r - **Per-test database isolation** — each test gets its own fresh database cloned from a snapshot. - **No real login** — authentication is handled via cookies, bypassing the login flow entirely. - **Plugin state via database** — plugins are activated/deactivated by writing directly to `wp_options`, not through the admin UI. -- **Annotation-driven configuration** — tests declare their requirements (plugins, user) as Playwright annotations, keeping setup out of test bodies. +- **Annotation-driven configuration** — tests declare their requirements (plugins, user, feature flags, fixtures) as Playwright annotations, keeping setup out of test bodies. +- **Google API mocking** — a local fixtures service intercepts all Google API calls and returns pre-recorded responses. +- **Email capture** — a Mailpit SMTP server captures all outgoing emails for assertion in tests. +- **Fixed reference date** — a must-use plugin fixes the WordPress reference date to `2026-01-01` for deterministic time-based tests. --- @@ -54,17 +63,34 @@ tests/playwright/ ├── config/ │ └── viewports.ts # Viewport helpers (mobile, tablet, desktop, large) ├── docker/ # Docker runtime assets +│ ├── fixtures/ # Google API mock service +│ │ ├── data/ # Per-fixture JSON response files keyed by API host +│ │ │ └── email-reporting/ +│ │ │ └── weekly-report-data/ +│ │ │ └── searchconsole.googleapis.com.json +│ │ ├── Dockerfile +│ │ ├── index.ts # Express server: routes requests to fixture JSON +│ │ ├── package.json +│ │ └── tsconfig.json │ ├── mariadb/ │ │ └── backup.sql # WordPress DB snapshot (loaded on container init) │ └── wordpress/ │ ├── Dockerfile # Custom WordPress image (configurable WP version) │ ├── db.php # DB drop-in: routes connections and logs PHP errors │ ├── mu-plugins/ -│ │ └── e2e-authenticate-admin.php # Authenticates user via cookie +│ │ ├── e2e-authenticate-admin.php # Authenticates user via cookie +│ │ ├── e2e-feature-flags.php # Enables feature flags via cookie +│ │ ├── e2e-fixtures.php # Disables SSL verification, forwards fixture header +│ │ └── e2e-reference-date.php # Fixes reference date to 2026-01-01 │ └── plugins/ # Test helper plugins (auto-mounted) +│ ├── email-reporting.php # REST endpoint to trigger email reporting cron │ ├── gcp-credentials.php # Mock GCP OAuth credentials -│ └── proxy-credentials.php # Mock proxy OAuth credentials +│ ├── mailpit.php # Routes wp_mail() through Mailpit SMTP +│ ├── proxy-auth.php # Fakes proxy authentication state +│ └── proxy-credentials.php # Mock proxy OAuth credentials ├── specs/ # Test files +│ ├── email-reporting/ +│ │ └── email-reporting.spec.ts │ └── plugin-activation.spec.ts ├── wordpress/ # TypeScript test utilities │ ├── index.ts # Re-exports @@ -73,7 +99,8 @@ tests/playwright/ │ ├── database.ts # Per-test DB create/drop and error log retrieval │ ├── cookies.ts # Test routing cookies │ ├── plugins.ts # Plugin activation via DB -│ ├── options.ts # Annotation helpers (withPlugins, asUser) +│ ├── options.ts # Annotation helpers (withPlugins, asUser, withFeatureFlags, withFixtures) +│ ├── mailpit.ts # Mailpit email client │ └── error-log-ignore-list.ts # Known PHP errors to ignore per WP version ├── docker-compose.yml ├── package.json @@ -103,10 +130,14 @@ The `WordPressCookies` class injects these cookies into the Playwright browser c - `_wp_test_db` — database name for this test (always set) - `_wp_test_user` — username to authenticate as (set when `_wp:as-user` annotation is present) +- `_wp_test_feature_flags` — comma-separated list of enabled feature flags (set when `_wp:feature-flags` annotation is present) +- `_wp_test_fixtures` — fixture set name for Google API mocking (set when `_wp:fixtures` annotation is present) ### Authentication Without Login -`docker/wordpress/mu-plugins/e2e-authenticate-admin.php` hooks into WordPress's `determine_current_user` filter. It reads the `_wp_test_user` cookie and sets the corresponding WordPress user as the currently authenticated user — no actual login flow required. +`docker/wordpress/mu-plugins/e2e-authenticate-admin.php` hooks into WordPress's `determine_current_user` filter. It reads the `_wp_test_user` cookie (defaulting to `admin` if absent) and sets the corresponding WordPress user as the currently authenticated user — no actual login flow required. + +On `wp-admin` pages, `wp_set_auth_cookie()` is called to persist the session, ensuring redirect flows work correctly. This means tests never need to fill in a username/password form, making them faster and more reliable. @@ -116,6 +147,77 @@ This means tests never need to fill in a username/password form, making them fas Plugins specified via the `withPlugins()` annotation are activated automatically during test setup, before the test body runs. +### Feature Flags + +`docker/wordpress/mu-plugins/e2e-feature-flags.php` hooks into the `googlesitekit_is_feature_enabled` filter at priority 999. It reads the `_wp_test_feature_flags` cookie (a comma-separated list of flag names) and forces those flags to return `true`. + +Use the `withFeatureFlags()` annotation to enable flags for a test: + +```typescript +import { withFeatureFlags } from '../wordpress'; + +test( + 'feature behind a flag', + { annotation: withFeatureFlags( 'myFeatureFlag' ) }, + async ( { wp } ) => { ... } +); +``` + +### Google API Fixtures + +A local Node.js service (`docker/fixtures/`) intercepts all Google API calls made by WordPress and returns pre-recorded JSON responses. The service is configured as a DNS alias for the following hosts on the internal Docker network: + +- `analyticsadmin.googleapis.com` +- `analyticsdata.googleapis.com` +- `searchconsole.googleapis.com` +- `oauth2.googleapis.com` +- `tagmanager.googleapis.com` +- `adsense.googleapis.com` +- `pagespeedonline.googleapis.com` +- `subscribewithgoogle.googleapis.com` +- `www.googleapis.com` +- `storage.googleapis.com` + +**How fixture data works:** + +1. Create a directory under `docker/fixtures/data//` with one JSON file per API host (e.g., `searchconsole.googleapis.com.json`). +2. Each file contains an array of `{ request, response }` pairs matched by URL path. +3. Use the `withFixtures()` annotation to activate a fixture set for a test. +4. The `e2e-fixtures.php` mu-plugin disables SSL certificate verification and forwards the `_wp_test_fixtures` cookie value as a `X-WP-Test-Fixtures` request header to the fixtures service. +5. The fixtures service routes responses based on that header. + +The service also handles Google's multipart batch request format, dispatching each sub-request to its corresponding fixture entry. + +Use the `withFixtures()` annotation: + +```typescript +import { withFixtures } from '../wordpress'; + +test( + 'test with mocked API data', + { annotation: withFixtures( 'email-reporting/weekly-report-data' ) }, + async ( { wp } ) => { ... } +); +``` + +### Email Testing + +A [Mailpit](https://github.com/axllent/mailpit) service (v1.29.2) captures all outgoing emails sent by WordPress via SMTP. + +**How it works:** + +1. Activate the `mailpit.php` test helper plugin via `withPlugins('mailpit.php')` — this redirects `wp_mail()` to Mailpit's SMTP server on port 1025 and sets the sender address to `@example.com`. +2. Use `wp.mailpit` (a `Mailpit` instance) to assert on received emails in your test. +3. After the test, any emails sent by this test are automatically deleted from Mailpit. + +The `Mailpit` class uses Mailpit's [search API](https://mailpit.axllent.org/docs/api-v1/) to scope queries to the current test's sender address, so parallel tests never see each other's emails. + +See [Email Assertions](#email-assertions) for usage examples. + +### Reference Date + +`docker/wordpress/mu-plugins/e2e-reference-date.php` fixes the WordPress reference date to `2026-01-01 00:00:00`. This ensures that any date-dependent logic in Site Kit (e.g., date range calculations, report periods) behaves deterministically regardless of when the tests are run. + ### PHP Error Logging The `db.php` drop-in registers PHP error handlers that capture all errors, warnings, notices, deprecations, uncaught exceptions, and fatal errors into a per-test `wp_e2e_error_log` database table. This ensures that PHP-level problems are surfaced in test results rather than silently ignored. @@ -167,18 +269,19 @@ npm run -w tests/playwright setup The following environment variables configure how tests connect to the running environment: -| Variable | Default | Description | -| ------------------------ | ----------------------- | --------------------------------------- | -| `PLAYWRIGHT_WP_URL` | `http://localhost:9002` | WordPress base URL | -| `PLAYWRIGHT_DB_HOST` | `localhost` | MariaDB host | -| `PLAYWRIGHT_DB_PORT` | `9306` | MariaDB port | -| `PLAYWRIGHT_DB_USER` | `root` | MariaDB user | -| `PLAYWRIGHT_DB_PASSWORD` | `example` | MariaDB password | -| `PLUGIN_PATH` | `../../` | Path to the plugin directory to mount | -| `WP_VERSION` | `5.2.21` | WordPress version to use in Docker | -| `FORBID_ONLY` | _(unset)_ | Fail if `test.only` is present (CI use) | -| `RETRIES` | `0` | Number of retries per failing test | -| `WORKERS` | _(Playwright default)_ | Number of parallel workers | +| Variable | Default | Description | +| -------------------------- | ----------------------- | --------------------------------------- | +| `PLAYWRIGHT_WP_URL` | `http://localhost:9002` | WordPress base URL | +| `PLAYWRIGHT_DB_HOST` | `localhost` | MariaDB host | +| `PLAYWRIGHT_DB_PORT` | `9306` | MariaDB port | +| `PLAYWRIGHT_DB_USER` | `root` | MariaDB user | +| `PLAYWRIGHT_DB_PASSWORD` | `example` | MariaDB password | +| `PLAYWRIGHT_MAILPIT_URL` | `http://localhost:8025` | Mailpit API base URL | +| `PLUGIN_PATH` | `../../` | Path to the plugin directory to mount | +| `WP_VERSION` | `5.2.21` | WordPress version to use in Docker | +| `FORBID_ONLY` | _(unset)_ | Fail if `test.only` is present (CI use) | +| `RETRIES` | `0` | Number of retries per failing test | +| `WORKERS` | _(Playwright default)_ | Number of parallel workers | --- @@ -202,15 +305,18 @@ test( 'my test', async ( { wp } ) => { The `wp` fixture is a `WordPress` instance automatically set up and torn down for each test. It provides: -| Member | Type | Description | -| --------------------------- | -------- | -------------------------------------------------- | -| `wp.page` | `Page` | The Playwright page | -| `wp.baseURL` | `string` | The WordPress base URL | -| `wp.goto(path)` | method | Navigate to an absolute path on the WordPress host | -| `wp.visitAdmin(path?)` | method | Navigate to `/wp-admin/{path}` | -| `wp.visitFrontend(path?)` | method | Navigate to `/{path}` (default: `/`) | -| `wp.activatePlugin(file)` | method | Activate a plugin by its file path | -| `wp.deactivatePlugin(file)` | method | Deactivate a plugin by its file path | +| Member | Type | Description | +| --------------------------- | ---------- | -------------------------------------------------- | +| `wp.page` | `Page` | The Playwright page | +| `wp.baseURL` | `string` | The WordPress base URL | +| `wp.mailpit` | `Mailpit` | Email client scoped to the current test | +| `wp.goto(path)` | method | Navigate to an absolute path on the WordPress host | +| `wp.visitDashboard(hash?)` | method | Navigate to the Site Kit dashboard (`/wp-admin/admin.php?page=googlesitekit-dashboard`) | +| `wp.visitAdmin(path?)` | method | Navigate to `/wp-admin/{path}` | +| `wp.visitFrontend(path?)` | method | Navigate to `/{path}` (default: `/`) | +| `wp.activatePlugin(file)` | method | Activate a plugin by its file path | +| `wp.deactivatePlugin(file)` | method | Deactivate a plugin by its file path | +| `wp.restRequest(...)` | method | Issue a WordPress REST API request via the browser | ### Test Annotations @@ -228,6 +334,8 @@ const details: TestDetails = { test.describe( 'my suite', details, () => { ... } ); ``` +Available users in the database snapshot: `admin`, `admin-2`, `editor`, `author`, `contributor`. + **`withPlugins(...plugins)`** — Activate one or more test helper plugins before the test. Plugin paths are relative to `google-site-kit-test-plugins/`: ```typescript @@ -248,6 +356,30 @@ Multiple plugins can be activated at once: } ``` +**`withFeatureFlags(...flags)`** — Enable one or more Site Kit feature flags for the duration of the test: + +```typescript +import { withFeatureFlags } from '../wordpress'; + +test( + 'test behind a feature flag', + { annotation: withFeatureFlags( 'proactiveUserEngagement' ) }, + async ( { wp } ) => { ... } +); +``` + +**`withFixtures(fixtureSet)`** — Use a named set of pre-recorded Google API responses. The fixture set name maps to a directory under `docker/fixtures/data/`: + +```typescript +import { withFixtures } from '../wordpress'; + +test( + 'test with mocked API data', + { annotation: withFixtures( 'email-reporting/weekly-report-data' ) }, + async ( { wp } ) => { ... } +); +``` + Annotations can be applied at both the `test.describe` (suite) level and the individual `test` level. Test-level annotations are merged with suite-level annotations. ### Navigation Helpers @@ -256,6 +388,10 @@ Annotations can be applied at both the `test.describe` (suite) level and the ind // Navigate to any path on the WordPress host await wp.goto( '/wp-json/wp/v2/posts' ); +// Navigate to the Site Kit dashboard +await wp.visitDashboard(); +await wp.visitDashboard( '#/settings' ); // with hash + // Navigate to a wp-admin page await wp.visitAdmin( 'options-general.php' ); await wp.visitAdmin( 'plugins.php' ); @@ -282,6 +418,56 @@ test( 'my test', async ( { wp } ) => { } ); ``` +### REST API Helpers + +`wp.restRequest()` issues a WordPress REST API request using the browser's `fetch`, inheriting the test's authenticated session: + +```typescript +test( 'my test', async ( { wp } ) => { + const response = await wp.restRequest( 'POST', '/google-site-kit/v1/e2e/email-reporting/trigger-cron', { + body: JSON.stringify( { frequency: 'weekly' } ), + } ); +} ); +``` + +### Email Assertions + +Use `wp.mailpit` to wait for and inspect emails sent during a test. Email capture requires the `mailpit.php` plugin to be activated: + +```typescript +test( + 'sends a welcome email', + { annotation: withPlugins( 'mailpit.php' ) }, + async ( { wp } ) => { + // ... trigger an action that sends email ... + + // Wait for an email to arrive (polls until timeout) + const message = await wp.mailpit.waitForMessage(); + + // Fetch full message details (including body) + const detail = await wp.mailpit.getMessage( message.ID ); + + expect( detail.Subject ).toBe( 'Welcome!' ); + expect( detail.HTML ).toContain( 'Hello' ); + + // Check if any emails arrived matching an optional search query + const found = await wp.mailpit.hasMessage( 'subject:Weekly' ); + } +); +``` + +**`Mailpit` API:** + +| Method | Description | +| ------------------------------- | ------------------------------------------------------------------- | +| `getMessages(query?)` | Fetch all messages, optionally filtered by a search query | +| `getMessage(id)` | Fetch full message detail (body, attachments) for a given ID | +| `waitForMessage(options?)` | Poll until at least one message arrives (default timeout: 2500ms) | +| `hasMessage(query?)` | Return `true` if any messages match the optional query | +| `deleteMessages()` | Delete all messages sent by this test | + +Mailpit automatically scopes queries to the current test's sender address (`@example.com`), so concurrent tests never see each other's emails. + --- ## Test Infrastructure @@ -297,30 +483,51 @@ test( 'my test', async ( { wp } ) => { **`wp` (custom WordPress image — `docker/wordpress/Dockerfile`)** - Port: `9002` → `80` -- Based on `wordpress:php7.4-apache`; the WordPress version is controlled by the `WP_VERSION` build arg (defaults to `5.2.21`) +- Based on `ghcr.io/google/site-kit-wp/playwright-wp`; the WordPress version is controlled by the `WP_VERSION` build arg (defaults to `5.2.21`) - The plugin is mounted at `wp-content/plugins/google-site-kit` from `PLUGIN_PATH` (defaults to `../../` for local dev; CI uses a built artifact) - Test helper plugins are mounted at `wp-content/plugins/google-site-kit-test-plugins` -- `WP_HTTP_BLOCK_EXTERNAL` is enabled (only `*.wordpress.org` is reachable) +- `WP_HTTP_BLOCK_EXTERNAL` is enabled (only `*.wordpress.org` is reachable from the browser; the fixtures service intercepts Google API calls at the network level) - `db.php` drop-in is mounted at `wp-content/db.php` — handles per-test database routing and PHP error logging - `SCRIPT_DEBUG` and `WP_DEBUG_LOG` are enabled; `WP_DEBUG_DISPLAY` is **disabled** (errors go to the log file and the per-test error log table, not the page) - `WP_AUTO_UPDATE_CORE` is disabled - Depends on `mysql` being healthy before starting +**`mailpit` (axllent/mailpit:v1.29.2)** + +- SMTP port: `1025` +- Web UI / REST API port: `8025` +- Captures all outgoing WordPress emails sent via the `mailpit.php` test helper plugin +- Profile: `test` + +**`fixtures` (custom Node.js image — `docker/fixtures/Dockerfile`)** + +- Serves pre-recorded Google API responses for tests +- Configured as a DNS alias for all major Google API hosts on the internal Docker network +- Routes requests based on the `X-WP-Test-Fixtures` header forwarded by `e2e-fixtures.php` +- Profile: `test` + ### Docker Compose Profiles Docker Compose uses profiles to separate test-running services from backup-generation services: -- **`test`** — used when running tests (`npm run start` / `npm run test`). Both `mysql` and `wp` services are started. -- **`generate`** — used by `bin/generate-backup-sql.sh` to generate the database snapshot. Starts the same services but with `WP_DEBUG=0` to suppress debug output during setup. - -Both services belong to both profiles, so the same containers serve both purposes. The profile controls which `docker compose` commands activate the services. +- **`test`** — used when running tests (`npm run start` / `npm run test`). All services (`mysql`, `wp`, `mailpit`, `fixtures`) are started. +- **`generate`** — used by `bin/generate-backup-sql.sh` to generate the database snapshot. Starts `mysql` and `wp` only, with `WP_DEBUG=0` to suppress debug output during setup. ### Must-Use Plugins Must-use plugins in `docker/wordpress/mu-plugins/` are always active and cannot be deactivated through the UI. **`e2e-authenticate-admin.php`** -Hooks into `determine_current_user` to authenticate the user specified in the `_wp_test_user` cookie. This enables tests to act as any WordPress user without performing a real login. +Hooks into `determine_current_user` to authenticate the user specified in the `_wp_test_user` cookie (defaults to `admin`). Also calls `wp_set_auth_cookie()` on admin pages so redirect flows work correctly. This enables tests to act as any WordPress user without performing a real login. + +**`e2e-feature-flags.php`** +Hooks into `googlesitekit_is_feature_enabled` at priority 999 to force the feature flags listed in the `_wp_test_feature_flags` cookie to return `true`. + +**`e2e-fixtures.php`** +Disables SSL certificate verification so WordPress can reach the local fixtures service (which uses a self-signed certificate). Forwards the `_wp_test_fixtures` cookie value as an `X-WP-Test-Fixtures` HTTP header on all outbound requests. + +**`e2e-reference-date.php`** +Fixes the Site Kit reference date to `2026-01-01 00:00:00` so that date-dependent calculations in reports are deterministic across test runs. ### Test Helper Plugins @@ -332,6 +539,15 @@ Filters `googlesitekit_oauth_secret` with placeholder GCP OAuth credentials (cli **`proxy-credentials.php`** Similar to `gcp-credentials.php` but uses the `sitekit.withgoogle.com` proxy domain. Use this to test proxy-based OAuth flows. +**`proxy-auth.php`** +Fakes a completed proxy authentication state so tests can start from an already-authenticated context without going through the OAuth flow. + +**`mailpit.php`** +Configures PHPMailer to route all `wp_mail()` calls through Mailpit's SMTP server on port 1025. Sets the sender address to `@example.com` so the `Mailpit` client can scope email queries to the current test. + +**`email-reporting.php`** +Exposes a REST endpoint (`POST /wp-json/google-site-kit/v1/e2e/email-reporting/trigger-cron`) that synchronously runs the email reporting cron job for a given `frequency`. This lets tests trigger and verify email sends without waiting for WP-Cron to fire naturally. + ### Database Snapshot `docker/mariadb/backup.sql` is the canonical WordPress database snapshot loaded when the MariaDB container starts. It defines the baseline state for all tests. diff --git a/tests/playwright/docker-compose.yml b/tests/playwright/docker-compose.yml index 7b5f9057d01..97d551e419a 100644 --- a/tests/playwright/docker-compose.yml +++ b/tests/playwright/docker-compose.yml @@ -26,6 +26,8 @@ services: interval: 5s timeout: 5s retries: 10 + networks: + - sitekit wp: image: ghcr.io/google/site-kit-wp/playwright-wp:${WP_VERSION:-5.2.21} @@ -50,7 +52,7 @@ services: define( 'WP_DEBUG_LOG', true ); define( 'WP_DEBUG_DISPLAY', false ); define( 'WP_HTTP_BLOCK_EXTERNAL', ! defined( 'WP_CLI' ) ); - define( 'WP_ACCESSIBLE_HOSTS', '*.wordpress.org' ); + define( 'WP_ACCESSIBLE_HOSTS', '*.wordpress.org,*.googleapis.com' ); define( 'WP_AUTO_UPDATE_CORE', false ); PLAYWRIGHT_MAILPIT_URL: http://mailpit:8025 profiles: @@ -64,6 +66,8 @@ services: interval: 5s timeout: 5s retries: 10 + networks: + - sitekit mailpit: image: axllent/mailpit:v1.29.2 @@ -72,3 +76,36 @@ services: - 8025:8025 profiles: - test + networks: + - sitekit + + fixtures: + build: + context: ./docker/fixtures + image: site-kit-wp/playwright-node:22-alpine + command: node --watch index.ts + volumes: + - ./docker/fixtures:/fixtures + expose: + - "80" + - "443" + profiles: + - test + networks: + sitekit: + aliases: + - analyticsadmin.googleapis.com + - analyticsdata.googleapis.com + - searchconsole.googleapis.com + - oauth2.googleapis.com + - tagmanager.googleapis.com + - adsense.googleapis.com + - pagespeedonline.googleapis.com + - subscribewithgoogle.googleapis.com + - www.googleapis.com + - storage.googleapis.com + - people.googleapis.com + - googleads.googleapis.com + +networks: + sitekit: diff --git a/tests/playwright/docker/fixtures/.dockerignore b/tests/playwright/docker/fixtures/.dockerignore new file mode 100644 index 00000000000..0d906d18da5 --- /dev/null +++ b/tests/playwright/docker/fixtures/.dockerignore @@ -0,0 +1,5 @@ +# Ignore everything by default +* + +# Allow the Dockerfile +!Dockerfile diff --git a/tests/playwright/docker/fixtures/Dockerfile b/tests/playwright/docker/fixtures/Dockerfile new file mode 100644 index 00000000000..49a73a41767 --- /dev/null +++ b/tests/playwright/docker/fixtures/Dockerfile @@ -0,0 +1,5 @@ +FROM node:22-alpine + +RUN apk add --no-cache openssl + +WORKDIR /fixtures diff --git a/tests/playwright/docker/fixtures/data/email-reporting/weekly-report-data/searchconsole.googleapis.com.json b/tests/playwright/docker/fixtures/data/email-reporting/weekly-report-data/searchconsole.googleapis.com.json new file mode 100644 index 00000000000..ec6ffb8ac54 --- /dev/null +++ b/tests/playwright/docker/fixtures/data/email-reporting/weekly-report-data/searchconsole.googleapis.com.json @@ -0,0 +1,2995 @@ +{ + "/webmasters/v3/sites/http%3A%2F%2Flocalhost%3A9002/searchAnalytics/query": { + "POST::{\"datastate\":\"all\",\"dimensions\":[\"date\"],\"enddate\":\"2026-01-01\",\"rowlimit\":1000,\"startdate\":\"2025-11-07\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "2025-11-07" + ], + "clicks": 21, + "impressions": 1035, + "ctr": 0.020289855072463767, + "position": 16.8063625106 + }, + { + "keys": [ + "2025-11-08" + ], + "clicks": 18, + "impressions": 1471, + "ctr": 0.012236573759347382, + "position": 17.9338351966 + }, + { + "keys": [ + "2025-11-09" + ], + "clicks": 15, + "impressions": 1901, + "ctr": 0.007890583903208837, + "position": 33.9422079552 + }, + { + "keys": [ + "2025-11-10" + ], + "clicks": 16, + "impressions": 1206, + "ctr": 0.013266998341625208, + "position": 20.1932070742 + }, + { + "keys": [ + "2025-11-11" + ], + "clicks": 21, + "impressions": 1398, + "ctr": 0.015021459227467811, + "position": 19.7605455174 + }, + { + "keys": [ + "2025-11-12" + ], + "clicks": 21, + "impressions": 1403, + "ctr": 0.01496792587312901, + "position": 19.5845314847 + }, + { + "keys": [ + "2025-11-13" + ], + "clicks": 19, + "impressions": 1446, + "ctr": 0.01313969571230982, + "position": 19.6674869898 + }, + { + "keys": [ + "2025-11-14" + ], + "clicks": 20, + "impressions": 1133, + "ctr": 0.0176522506619594, + "position": 17.6876923652 + }, + { + "keys": [ + "2025-11-15" + ], + "clicks": 20, + "impressions": 1203, + "ctr": 0.01662510390689942, + "position": 14.8768659034 + }, + { + "keys": [ + "2025-11-16" + ], + "clicks": 17, + "impressions": 1824, + "ctr": 0.00932017543859649, + "position": 22.7956131326 + }, + { + "keys": [ + "2025-11-17" + ], + "clicks": 15, + "impressions": 1436, + "ctr": 0.010445682451253482, + "position": 21.1214081179 + }, + { + "keys": [ + "2025-11-18" + ], + "clicks": 22, + "impressions": 1335, + "ctr": 0.01647940074906367, + "position": 20.7523967938 + }, + { + "keys": [ + "2025-11-19" + ], + "clicks": 16, + "impressions": 1295, + "ctr": 0.012355212355212355, + "position": 22.2911571451 + }, + { + "keys": [ + "2025-11-20" + ], + "clicks": 16, + "impressions": 1362, + "ctr": 0.011747430249632892, + "position": 18.1258989726 + }, + { + "keys": [ + "2025-11-21" + ], + "clicks": 24, + "impressions": 1156, + "ctr": 0.020761245674740483, + "position": 15.5408597441 + }, + { + "keys": [ + "2025-11-22" + ], + "clicks": 29, + "impressions": 1327, + "ctr": 0.02185380557648832, + "position": 17.0282286845 + }, + { + "keys": [ + "2025-11-23" + ], + "clicks": 25, + "impressions": 1394, + "ctr": 0.017934002869440458, + "position": 18.5241170908 + }, + { + "keys": [ + "2025-11-24" + ], + "clicks": 23, + "impressions": 1219, + "ctr": 0.018867924528301886, + "position": 20.6908042715 + }, + { + "keys": [ + "2025-11-25" + ], + "clicks": 10, + "impressions": 1409, + "ctr": 0.007097232079488999, + "position": 17.3496500143 + }, + { + "keys": [ + "2025-11-26" + ], + "clicks": 13, + "impressions": 986, + "ctr": 0.013184584178498986, + "position": 21.3930983525 + }, + { + "keys": [ + "2025-11-27" + ], + "clicks": 17, + "impressions": 1366, + "ctr": 0.012445095168374817, + "position": 23.1408488962 + }, + { + "keys": [ + "2025-11-28" + ], + "clicks": 8, + "impressions": 1352, + "ctr": 0.005917159763313609, + "position": 24.4623930646 + }, + { + "keys": [ + "2025-11-29" + ], + "clicks": 11, + "impressions": 989, + "ctr": 0.011122345803842264, + "position": 16.1482933752 + }, + { + "keys": [ + "2025-11-30" + ], + "clicks": 18, + "impressions": 1142, + "ctr": 0.01576182136602452, + "position": 19.6023127616 + }, + { + "keys": [ + "2025-12-01" + ], + "clicks": 18, + "impressions": 1037, + "ctr": 0.017357762777242044, + "position": 21.380515033 + }, + { + "keys": [ + "2025-12-02" + ], + "clicks": 14, + "impressions": 1136, + "ctr": 0.01232394366197183, + "position": 19.0105686864 + }, + { + "keys": [ + "2025-12-03" + ], + "clicks": 6, + "impressions": 900, + "ctr": 0.006666666666666667, + "position": 19.1458981042 + }, + { + "keys": [ + "2025-12-04" + ], + "clicks": 7, + "impressions": 1117, + "ctr": 0.006266786034019696, + "position": 22.8215391374 + }, + { + "keys": [ + "2025-12-05" + ], + "clicks": 19, + "impressions": 999, + "ctr": 0.01901901901901902, + "position": 21.114374041 + }, + { + "keys": [ + "2025-12-06" + ], + "clicks": 27, + "impressions": 1104, + "ctr": 0.024456521739130436, + "position": 15.8726505534 + }, + { + "keys": [ + "2025-12-07" + ], + "clicks": 16, + "impressions": 1318, + "ctr": 0.012139605462822459, + "position": 21.2508889085 + }, + { + "keys": [ + "2025-12-08" + ], + "clicks": 11, + "impressions": 1015, + "ctr": 0.01083743842364532, + "position": 20.8154252522 + }, + { + "keys": [ + "2025-12-09" + ], + "clicks": 9, + "impressions": 1005, + "ctr": 0.008955223880597015, + "position": 25.1735521977 + }, + { + "keys": [ + "2025-12-10" + ], + "clicks": 9, + "impressions": 855, + "ctr": 0.010526315789473684, + "position": 25.8868605662 + }, + { + "keys": [ + "2025-12-11" + ], + "clicks": 13, + "impressions": 1009, + "ctr": 0.01288404360753221, + "position": 18.7415517839 + }, + { + "keys": [ + "2025-12-12" + ], + "clicks": 14, + "impressions": 1451, + "ctr": 0.009648518263266712, + "position": 37.4032365431 + }, + { + "keys": [ + "2025-12-13" + ], + "clicks": 8, + "impressions": 3427, + "ctr": 0.0023344032681645753, + "position": 46.0990084305 + }, + { + "keys": [ + "2025-12-14" + ], + "clicks": 9, + "impressions": 1236, + "ctr": 0.007281553398058253, + "position": 17.613080259 + }, + { + "keys": [ + "2025-12-15" + ], + "clicks": 17, + "impressions": 1324, + "ctr": 0.01283987915407855, + "position": 21.0970549186 + }, + { + "keys": [ + "2025-12-16" + ], + "clicks": 6, + "impressions": 1278, + "ctr": 0.004694835680751174, + "position": 21.8021975742 + }, + { + "keys": [ + "2025-12-17" + ], + "clicks": 15, + "impressions": 1030, + "ctr": 0.014563106796116505, + "position": 17.2825756908 + }, + { + "keys": [ + "2025-12-18" + ], + "clicks": 11, + "impressions": 962, + "ctr": 0.011434511434511435, + "position": 17.9789307185 + }, + { + "keys": [ + "2025-12-19" + ], + "clicks": 9, + "impressions": 1408, + "ctr": 0.006392045454545455, + "position": 25.7402250827 + }, + { + "keys": [ + "2025-12-20" + ], + "clicks": 8, + "impressions": 1214, + "ctr": 0.006589785831960461, + "position": 18.5077240844 + }, + { + "keys": [ + "2025-12-21" + ], + "clicks": 12, + "impressions": 1428, + "ctr": 0.008403361344537815, + "position": 20.2919718127 + }, + { + "keys": [ + "2025-12-22" + ], + "clicks": 12, + "impressions": 1223, + "ctr": 0.009811937857726901, + "position": 28.7472251277 + }, + { + "keys": [ + "2025-12-23" + ], + "clicks": 11, + "impressions": 1055, + "ctr": 0.01042654028436019, + "position": 25.9117065387 + }, + { + "keys": [ + "2025-12-24" + ], + "clicks": 7, + "impressions": 1072, + "ctr": 0.0065298507462686565, + "position": 27.1044605377 + }, + { + "keys": [ + "2025-12-25" + ], + "clicks": 7, + "impressions": 1028, + "ctr": 0.006809338521400778, + "position": 24.1312852351 + }, + { + "keys": [ + "2025-12-26" + ], + "clicks": 11, + "impressions": 1105, + "ctr": 0.009954751131221719, + "position": 18.7213020843 + }, + { + "keys": [ + "2025-12-27" + ], + "clicks": 20, + "impressions": 1367, + "ctr": 0.014630577907827359, + "position": 18.9678917749 + }, + { + "keys": [ + "2025-12-28" + ], + "clicks": 19, + "impressions": 1293, + "ctr": 0.014694508894044857, + "position": 18.4402443564 + }, + { + "keys": [ + "2025-12-29" + ], + "clicks": 15, + "impressions": 1589, + "ctr": 0.009439899307740718, + "position": 18.3478339298 + }, + { + "keys": [ + "2025-12-30" + ], + "clicks": 19, + "impressions": 1343, + "ctr": 0.014147431124348473, + "position": 19.5527986928 + }, + { + "keys": [ + "2025-12-31" + ], + "clicks": 24, + "impressions": 1725, + "ctr": 0.01391304347826087, + "position": 17.1229548132 + }, + { + "keys": [ + "2026-01-01" + ], + "clicks": 14, + "impressions": 1247, + "ctr": 0.011226944667201283, + "position": 21.9664363855 + } + ], + "responseAggregationType": "byProperty" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"date\"],\"enddate\":\"2025-12-31\",\"rowlimit\":1000,\"startdate\":\"2025-12-18\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "2025-12-18" + ], + "clicks": 10, + "impressions": 1007, + "ctr": 0.009930486593843098, + "position": 20.2573740973 + }, + { + "keys": [ + "2025-12-19" + ], + "clicks": 11, + "impressions": 1237, + "ctr": 0.00889248181083266, + "position": 25.2898916411 + }, + { + "keys": [ + "2025-12-20" + ], + "clicks": 8, + "impressions": 1168, + "ctr": 0.00684931506849315, + "position": 18.6661088186 + }, + { + "keys": [ + "2025-12-21" + ], + "clicks": 11, + "impressions": 1230, + "ctr": 0.00894308943089431, + "position": 21.9795773078 + }, + { + "keys": [ + "2025-12-22" + ], + "clicks": 13, + "impressions": 1478, + "ctr": 0.008795669824086604, + "position": 29.9689136369 + }, + { + "keys": [ + "2025-12-23" + ], + "clicks": 9, + "impressions": 1152, + "ctr": 0.0078125, + "position": 25.2035119685 + }, + { + "keys": [ + "2025-12-24" + ], + "clicks": 8, + "impressions": 1066, + "ctr": 0.0075046904315197, + "position": 23.6625717436 + }, + { + "keys": [ + "2025-12-25" + ], + "clicks": 7, + "impressions": 1099, + "ctr": 0.006369426751592357, + "position": 22.8826474696 + }, + { + "keys": [ + "2025-12-26" + ], + "clicks": 9, + "impressions": 1368, + "ctr": 0.006578947368421052, + "position": 19.2326060636 + }, + { + "keys": [ + "2025-12-27" + ], + "clicks": 19, + "impressions": 1328, + "ctr": 0.01430722891566265, + "position": 17.4794081691 + }, + { + "keys": [ + "2025-12-28" + ], + "clicks": 22, + "impressions": 1606, + "ctr": 0.0136986301369863, + "position": 21.3452037611 + }, + { + "keys": [ + "2025-12-29" + ], + "clicks": 18, + "impressions": 1710, + "ctr": 0.010526315789473684, + "position": 18.100343472 + }, + { + "keys": [ + "2025-12-30" + ], + "clicks": 21, + "impressions": 1409, + "ctr": 0.014904187366926898, + "position": 18.6761121388 + }, + { + "keys": [ + "2025-12-31" + ], + "clicks": 23, + "impressions": 1482, + "ctr": 0.0155195681511471, + "position": 17.5244561345 + }, + { + "keys": [ + "2026-01-01" + ], + "clicks": 15, + "impressions": 1468, + "ctr": 0.010217983651226158, + "position": 21.057137554 + } + ], + "responseAggregationType": "byProperty" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"query\"],\"enddate\":\"2025-12-31\",\"rowlimit\":10,\"startdate\":\"2025-12-25\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "travel companion in scotland" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 7.6935917653 + }, + { + "keys": [ + "riverside grill market square" + ], + "clicks": 1, + "impressions": 16, + "ctr": 0.0625, + "position": 10.7991286346 + }, + { + "keys": [ + "free midsummer outdoor events" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 6.095016349 + }, + { + "keys": [ + "northern craft brands" + ], + "clicks": 1, + "impressions": 3, + "ctr": 0.3333333333333333, + "position": 0.9114330546 + }, + { + "keys": [ + "highland pub ocean city" + ], + "clicks": 1, + "impressions": 16, + "ctr": 0.0625, + "position": 4.7505389042 + }, + { + "keys": [ + "what does the p sticker mean on cars in scotland" + ], + "clicks": 1, + "impressions": 19, + "ctr": 0.05263157894736842, + "position": 5.7116684155 + }, + { + "keys": [ + "\"riverside bakery\" \"n17 qp33\"" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 51.752125536 + }, + { + "keys": [ + "#mountaincafe" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 17.7879481366 + }, + { + "keys": [ + "22 harbor lane west" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 71.6713411446 + }, + { + "keys": [ + "27.80" + ], + "clicks": 0, + "impressions": 5, + "ctr": 0.0, + "position": 10.6482993569 + } + ], + "responseAggregationType": "byProperty" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"page\"],\"enddate\":\"2025-12-31\",\"rowlimit\":10,\"startdate\":\"2025-12-25\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "http://localhost:9002/tips/mountain-river-golden-bridge/" + ], + "clicks": 28, + "impressions": 1366, + "ctr": 0.020497803806734993, + "position": 5.8481200403 + }, + { + "keys": [ + "http://localhost:9002/guide/silver-forest-morning-light/" + ], + "clicks": 8, + "impressions": 53, + "ctr": 0.1509433962264151, + "position": 6.6761819242 + }, + { + "keys": [ + "http://localhost:9002/events/cobalt-garden-winter-sunrise/" + ], + "clicks": 8, + "impressions": 215, + "ctr": 0.037209302325581395, + "position": 6.8734928269 + }, + { + "keys": [ + "http://localhost:9002/noticias/estrela-pedra-azul-horizonte/" + ], + "clicks": 8, + "impressions": 278, + "ctr": 0.02877697841726619, + "position": 7.4989559502 + }, + { + "keys": [ + "http://localhost:9002/viagem/campo-verde-neblina-alta/" + ], + "clicks": 9, + "impressions": 195, + "ctr": 0.046153846153846156, + "position": 7.0024006925 + }, + { + "keys": [ + "http://localhost:9002/cultura/bronze-velvet-cedar-peak/" + ], + "clicks": 7, + "impressions": 151, + "ctr": 0.046357615894039736, + "position": 6.264289635 + }, + { + "keys": [ + "http://localhost:9002/negocios/amber-cliff-coastal-range/" + ], + "clicks": 3, + "impressions": 38, + "ctr": 0.07894736842105263, + "position": 1.0247043031 + }, + { + "keys": [ + "http://localhost:9002/saude/iron-maple-harvest-valley/" + ], + "clicks": 3, + "impressions": 48, + "ctr": 0.0625, + "position": 10.2687926671 + }, + { + "keys": [ + "http://localhost:9002/carreira/copper-basin-thunder-ridge/" + ], + "clicks": 3, + "impressions": 114, + "ctr": 0.02631578947368421, + "position": 2.7212363344 + }, + { + "keys": [ + "http://localhost:9002/musica/jade-meadow-crystal-falls/" + ], + "clicks": 2, + "impressions": 5, + "ctr": 0.4, + "position": 7.228744201 + } + ], + "responseAggregationType": "byPage" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"query\"],\"enddate\":\"2025-12-31\",\"rowlimit\":50,\"startdate\":\"2025-12-25\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "travel companion in scotland" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 6.4616001386 + }, + { + "keys": [ + "riverside grill market square" + ], + "clicks": 1, + "impressions": 15, + "ctr": 0.06666666666666667, + "position": 11.0718478526 + }, + { + "keys": [ + "free midsummer outdoor events" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 5.6443167748 + }, + { + "keys": [ + "northern craft brands" + ], + "clicks": 1, + "impressions": 3, + "ctr": 0.3333333333333333, + "position": 0.9977063704 + }, + { + "keys": [ + "highland pub ocean city" + ], + "clicks": 1, + "impressions": 18, + "ctr": 0.05555555555555555, + "position": 4.0756648901 + }, + { + "keys": [ + "what does the p sticker mean on cars in scotland" + ], + "clicks": 1, + "impressions": 16, + "ctr": 0.0625, + "position": 5.6161863887 + }, + { + "keys": [ + "\"riverside bakery\" \"n17 qp33\"" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 47.6195526243 + }, + { + "keys": [ + "#mountaincafe" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 16.2613668326 + }, + { + "keys": [ + "22 harbor lane west" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 75.3634895062 + }, + { + "keys": [ + "27.80" + ], + "clicks": 0, + "impressions": 5, + "ctr": 0.0, + "position": 11.3093414422 + }, + { + "keys": [ + "2024 timber wolves" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 17.5842929733 + }, + { + "keys": [ + "5 foot narrow alley" + ], + "clicks": 0, + "impressions": 3, + "ctr": 0.0, + "position": 85.0910316985 + }, + { + "keys": [ + "the ancient harvest festival" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 0.9420153077 + }, + { + "keys": [ + "the coastal wanderers luck" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 28.5126277061 + }, + { + "keys": [ + "enable tap card reader" + ], + "clicks": 0, + "impressions": 5, + "ctr": 0.0, + "position": 47.5197388142 + }, + { + "keys": [ + "adventure trails scotland" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 79.3961643041 + }, + { + "keys": [ + "airport of glasgow" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 56.504870149 + }, + { + "keys": [ + "plan your trip to arrive at the central station" + ], + "clicks": 0, + "impressions": 16, + "ctr": 0.0, + "position": 23.7099233627 + }, + { + "keys": [ + "cabin rental scotland" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 8.5825024454 + }, + { + "keys": [ + "top rated host mountain lodge" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 50.7587952877 + }, + { + "keys": [ + "rent a car highlands" + ], + "clicks": 0, + "impressions": 4, + "ctr": 0.0, + "position": 88.9526479722 + }, + { + "keys": [ + "car rental in the valley" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 11.9047277632 + }, + { + "keys": [ + "vehicle hire coastal route" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 12.2917482673 + }, + { + "keys": [ + "car hire mountain pass" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 61.8017529753 + }, + { + "keys": [ + "marketplace platform opens" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 65.9519248996 + }, + { + "keys": [ + "online shop opening schedule" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 41.6567376227 + }, + { + "keys": [ + "webstore currently active" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 72.9223996793 + }, + { + "keys": [ + "online store northern region" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 82.484268168 + }, + { + "keys": [ + "digital market western coast" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 37.5026845651 + }, + { + "keys": [ + "shop platform debut" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 45.9558796798 + }, + { + "keys": [ + "woodland adventure center" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 58.8884213046 + }, + { + "keys": [ + "outdoor fun parks valley" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 27.910485925 + }, + { + "keys": [ + "marco santos harbor town" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 9.3949739985 + }, + { + "keys": [ + "handcrafted silver band" + ], + "clicks": 0, + "impressions": 3, + "ctr": 0.0, + "position": 31.7795802823 + }, + { + "keys": [ + "braided golden ring" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 11.5924800865 + }, + { + "keys": [ + "the twisted bronze ring" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 7.3076559682 + }, + { + "keys": [ + "new year in the harbor city" + ], + "clicks": 0, + "impressions": 6, + "ctr": 0.0, + "position": 4.2539405208 + }, + { + "keys": [ + "car booking mobile app" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 8.3291180383 + }, + { + "keys": [ + "ancient ruins highland tour" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 100.4399681383 + }, + { + "keys": [ + "are portland trail blazers canadian" + ], + "clicks": 0, + "impressions": 3, + "ctr": 0.0, + "position": 6.7213298244 + }, + { + "keys": [ + "are highland people tall" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 82.8136073018 + }, + { + "keys": [ + "are blondes scandinavian" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 58.7636441102 + }, + { + "keys": [ + "are the denver nuggets colorado" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 5.6564416812 + }, + { + "keys": [ + "are the rangers scottish" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 60.3746822654 + }, + { + "keys": [ + "harbor market weekend hours" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 3.0092712059 + }, + { + "keys": [ + "indoor snow slopes" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 102.1137305191 + }, + { + "keys": [ + "morning bread baker village" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 43.4666941272 + }, + { + "keys": [ + "coastal bread shop opening times" + ], + "clicks": 0, + "impressions": 21, + "ctr": 0.0, + "position": 10.2488598952 + }, + { + "keys": [ + "thornwood manor" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 73.165251667 + }, + { + "keys": [ + "stage actor coastal theater" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 72.2943026458 + } + ], + "responseAggregationType": "byProperty" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"page\"],\"enddate\":\"2025-12-31\",\"rowlimit\":50,\"startdate\":\"2025-12-25\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "http://localhost:9002/tips/mountain-river-golden-bridge/" + ], + "clicks": 26, + "impressions": 1496, + "ctr": 0.017379679144385027, + "position": 5.3187059701 + }, + { + "keys": [ + "http://localhost:9002/guide/silver-forest-morning-light/" + ], + "clicks": 9, + "impressions": 47, + "ctr": 0.19148936170212766, + "position": 6.7785177789 + }, + { + "keys": [ + "http://localhost:9002/events/cobalt-garden-winter-sunrise/" + ], + "clicks": 10, + "impressions": 201, + "ctr": 0.04975124378109453, + "position": 6.4458930889 + }, + { + "keys": [ + "http://localhost:9002/noticias/estrela-pedra-azul-horizonte/" + ], + "clicks": 8, + "impressions": 321, + "ctr": 0.024922118380062305, + "position": 6.8436402511 + }, + { + "keys": [ + "http://localhost:9002/viagem/campo-verde-neblina-alta/" + ], + "clicks": 7, + "impressions": 207, + "ctr": 0.033816425120772944, + "position": 7.9460997161 + }, + { + "keys": [ + "http://localhost:9002/cultura/bronze-velvet-cedar-peak/" + ], + "clicks": 7, + "impressions": 146, + "ctr": 0.04794520547945205, + "position": 6.015986017 + }, + { + "keys": [ + "http://localhost:9002/negocios/amber-cliff-coastal-range/" + ], + "clicks": 3, + "impressions": 41, + "ctr": 0.07317073170731707, + "position": 1.1990087139 + }, + { + "keys": [ + "http://localhost:9002/saude/iron-maple-harvest-valley/" + ], + "clicks": 3, + "impressions": 47, + "ctr": 0.06382978723404255, + "position": 10.4849414689 + }, + { + "keys": [ + "http://localhost:9002/carreira/copper-basin-thunder-ridge/" + ], + "clicks": 3, + "impressions": 120, + "ctr": 0.025, + "position": 3.0472474711 + }, + { + "keys": [ + "http://localhost:9002/musica/jade-meadow-crystal-falls/" + ], + "clicks": 2, + "impressions": 5, + "ctr": 0.4, + "position": 6.9645442116 + }, + { + "keys": [ + "http://localhost:9002/bares/ruby-harbor-evening-tide/" + ], + "clicks": 2, + "impressions": 264, + "ctr": 0.007575757575757576, + "position": 10.160354226 + }, + { + "keys": [ + "http://localhost:9002/historia/golden-harp-sunset-mist/" + ], + "clicks": 2, + "impressions": 75, + "ctr": 0.02666666666666667, + "position": 5.5642309703 + }, + { + "keys": [ + "http://localhost:9002/curiosidades/silver-moon-granite-peak/" + ], + "clicks": 1, + "impressions": 12, + "ctr": 0.08333333333333333, + "position": 5.606138691 + }, + { + "keys": [ + "http://localhost:9002/cultura/emerald-plain-ocean-breeze/" + ], + "clicks": 1, + "impressions": 9, + "ctr": 0.1111111111111111, + "position": 5.4433644003 + }, + { + "keys": [ + "http://localhost:9002/literatura/ivory-tower-autumn-wind/" + ], + "clicks": 1, + "impressions": 16, + "ctr": 0.0625, + "position": 8.6158461592 + }, + { + "keys": [ + "http://localhost:9002/hospedagem/cobalt-lake-morning-dew/" + ], + "clicks": 1, + "impressions": 14, + "ctr": 0.07142857142857142, + "position": 6.2571891963 + }, + { + "keys": [ + "http://localhost:9002/inverno/frost-crystal-winter-plain/" + ], + "clicks": 1, + "impressions": 50, + "ctr": 0.02, + "position": 8.6199672937 + }, + { + "keys": [ + "http://localhost:9002/lazer/midnight-wolf-forest-trail/" + ], + "clicks": 1, + "impressions": 3, + "ctr": 0.3333333333333333, + "position": 4.406202159 + }, + { + "keys": [ + "http://localhost:9002/natal/cedar-ember-snowfall-valley/" + ], + "clicks": 1, + "impressions": 53, + "ctr": 0.018867924528301886, + "position": 6.2309535817 + }, + { + "keys": [ + "http://localhost:9002/noticias/charcoal-ridge-shadow-hollow/" + ], + "clicks": 1, + "impressions": 3, + "ctr": 0.3333333333333333, + "position": 12.6145728869 + }, + { + "keys": [ + "http://localhost:9002/idiomas/stone-bridge-river-current/" + ], + "clicks": 1, + "impressions": 39, + "ctr": 0.02564102564102564, + "position": 68.6820598056 + }, + { + "keys": [ + "http://localhost:9002/esporte/maple-court-distant-peak/" + ], + "clicks": 1, + "impressions": 24, + "ctr": 0.041666666666666664, + "position": 6.1929805619 + }, + { + "keys": [ + "http://localhost:9002/trabalho/garden-petal-morning-song/" + ], + "clicks": 1, + "impressions": 12, + "ctr": 0.08333333333333333, + "position": 9.6566627293 + }, + { + "keys": [ + "http://localhost:9002/cultura/amber-crest-highland-grove/" + ], + "clicks": 1, + "impressions": 21, + "ctr": 0.047619047619047616, + "position": 18.5384965842 + }, + { + "keys": [ + "http://localhost:9002/cinema/velvet-screen-coastal-view/" + ], + "clicks": 1, + "impressions": 56, + "ctr": 0.017857142857142856, + "position": 8.3005623493 + }, + { + "keys": [ + "http://localhost:9002/gastronomia/copper-kettle-orchard-lane/" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 7.6833854415 + }, + { + "keys": [ + "http://localhost:9002/food/willow-market-harbor-walk/" + ], + "clicks": 1, + "impressions": 4, + "ctr": 0.25, + "position": 48.618163037 + }, + { + "keys": [ + "http://localhost:9002/language/pebble-shore-evening-glow/" + ], + "clicks": 1, + "impressions": 78, + "ctr": 0.01282051282051282, + "position": 2.9174002106 + }, + { + "keys": [ + "http://localhost:9002/travel/opal-nest-mountain-hollow/" + ], + "clicks": 1, + "impressions": 3, + "ctr": 0.3333333333333333, + "position": 27.6482552304 + }, + { + "keys": [ + "http://localhost:9002/skills/thunder-voice-open-field/" + ], + "clicks": 1, + "impressions": 27, + "ctr": 0.037037037037037035, + "position": 41.676455329 + }, + { + "keys": [ + "http://localhost:9002/entertainment/pine-ember-starlight-film/" + ], + "clicks": 1, + "impressions": 12, + "ctr": 0.08333333333333333, + "position": 26.8535095816 + }, + { + "keys": [ + "http://localhost:9002/finance/silver-vault-morning-tide/" + ], + "clicks": 1, + "impressions": 6, + "ctr": 0.16666666666666666, + "position": 86.384783401 + }, + { + "keys": [ + "http://localhost:9002/careers/white-coat-summit-trail/" + ], + "clicks": 1, + "impressions": 6, + "ctr": 0.16666666666666666, + "position": 46.7536177135 + }, + { + "keys": [ + "http://localhost:9002/news/iron-fist-shadow-court/" + ], + "clicks": 1, + "impressions": 17, + "ctr": 0.058823529411764705, + "position": 6.9548687846 + }, + { + "keys": [ + "http://localhost:9002/dining/tropical-leaf-harbor-square/" + ], + "clicks": 1, + "impressions": 143, + "ctr": 0.006993006993006993, + "position": 23.3998324771 + }, + { + "keys": [ + "http://localhost:9002/sports/frost-slope-granite-summit/" + ], + "clicks": 1, + "impressions": 55, + "ctr": 0.01818181818181818, + "position": 14.8949147453 + }, + { + "keys": [ + "http://localhost:9002/travel/blue-sky-ocean-crossing/" + ], + "clicks": 1, + "impressions": 9, + "ctr": 0.1111111111111111, + "position": 12.4763951193 + }, + { + "keys": [ + "http://localhost:9002/history/green-island-distant-shore/" + ], + "clicks": 1, + "impressions": 15, + "ctr": 0.06666666666666667, + "position": 8.5353858622 + }, + { + "keys": [ + "http://localhost:9002/news/badge-patrol-river-crossing/" + ], + "clicks": 1, + "impressions": 11, + "ctr": 0.09090909090909091, + "position": 2.1226851208 + }, + { + "keys": [ + "http://localhost:9002/geography/patchwork-map-rolling-hills/" + ], + "clicks": 1, + "impressions": 33, + "ctr": 0.030303030303030304, + "position": 16.8751754212 + }, + { + "keys": [ + "http://localhost:9002/lifestyle/green-plate-meadow-harvest/" + ], + "clicks": 1, + "impressions": 19, + "ctr": 0.05263157894736842, + "position": 8.1284352404 + }, + { + "keys": [ + "http://localhost:9002/travel/shadow-manor-hollow-night/" + ], + "clicks": 1, + "impressions": 4, + "ctr": 0.25, + "position": 9.3870264691 + }, + { + "keys": [ + "http://localhost:9002/nature/deep-water-canyon-mist/" + ], + "clicks": 1, + "impressions": 21, + "ctr": 0.047619047619047616, + "position": 67.1156029584 + }, + { + "keys": [ + "http://localhost:9002/remote-work/sun-terrace-coastal-office/" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 1.0700586478 + }, + { + "keys": [ + "http://localhost:9002/food/samba-box-urban-delivery/" + ], + "clicks": 1, + "impressions": 4, + "ctr": 0.25, + "position": 9.8709198327 + }, + { + "keys": [ + "http://localhost:9002/cinema/reel-emerald-silver-screen/" + ], + "clicks": 1, + "impressions": 32, + "ctr": 0.03125, + "position": 21.5978918815 + }, + { + "keys": [ + "http://localhost:9002/events/clover-parade-golden-hour/" + ], + "clicks": 1, + "impressions": 8, + "ctr": 0.125, + "position": 8.2300929973 + }, + { + "keys": [ + "http://localhost:9002/nightlife/carnival-beat-harbor-lights/" + ], + "clicks": 1, + "impressions": 21, + "ctr": 0.047619047619047616, + "position": 9.5989125936 + }, + { + "keys": [ + "http://localhost:9002/news/midnight-case-rural-shadow/" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 44.7611414124 + }, + { + "keys": [ + "http://localhost:9002/entretenimento/candle-film-winter-night/" + ], + "clicks": 1, + "impressions": 8, + "ctr": 0.125, + "position": 9.4208315472 + } + ], + "responseAggregationType": "byPage" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"query\"],\"enddate\":\"2025-12-24\",\"rowlimit\":10,\"startdate\":\"2025-12-18\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "tour guides in edinburgh" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 11.6780382814 + }, + { + "keys": [ + "top films of the valley 2025" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 18.2616256241 + }, + { + "keys": [ + "palm leaf congregation harbor" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 9.633083688 + }, + { + "keys": [ + "winter films set in the highlands" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 16.144893552 + }, + { + "keys": [ + "mountain tavern old quarter" + ], + "clicks": 1, + "impressions": 10, + "ctr": 0.1, + "position": 3.9860587293 + }, + { + "keys": [ + "coastal pub stone district" + ], + "clicks": 1, + "impressions": 32, + "ctr": 0.03125, + "position": 5.17740842 + }, + { + "keys": [ + "meaning of the green plate on learner cars" + ], + "clicks": 1, + "impressions": 5, + "ctr": 0.2, + "position": 10.1718381251 + }, + { + "keys": [ + "snowboard run highland resort" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 37.9686413774 + }, + { + "keys": [ + "craft gifts mountain village" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 41.4052755911 + }, + { + "keys": [ + "\"riverside bakery\" \"n17 qp33\"" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 48.8880628991 + } + ], + "responseAggregationType": "byProperty" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"page\"],\"enddate\":\"2025-12-24\",\"rowlimit\":10,\"startdate\":\"2025-12-18\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "http://localhost:9002/tips/mountain-river-golden-bridge/" + ], + "clicks": 10, + "impressions": 1033, + "ctr": 0.00968054211035818, + "position": 7.3580617717 + }, + { + "keys": [ + "http://localhost:9002/compras/crystal-shop-window-light/" + ], + "clicks": 5, + "impressions": 47, + "ctr": 0.10638297872340426, + "position": 5.4048488265 + }, + { + "keys": [ + "http://localhost:9002/guide/silver-forest-morning-light/" + ], + "clicks": 5, + "impressions": 51, + "ctr": 0.09803921568627451, + "position": 5.6072403685 + }, + { + "keys": [ + "http://localhost:9002/bares/ruby-harbor-evening-tide/" + ], + "clicks": 3, + "impressions": 265, + "ctr": 0.011320754716981131, + "position": 9.6058576798 + }, + { + "keys": [ + "http://localhost:9002/cultura/bronze-velvet-cedar-peak/" + ], + "clicks": 3, + "impressions": 146, + "ctr": 0.02054794520547945, + "position": 5.0794349669 + }, + { + "keys": [ + "http://localhost:9002/transporte/road-sign-silver-badge/" + ], + "clicks": 3, + "impressions": 108, + "ctr": 0.027777777777777776, + "position": 4.1369980284 + }, + { + "keys": [ + "http://localhost:9002/gastronomia/palm-feast-winter-table/" + ], + "clicks": 3, + "impressions": 49, + "ctr": 0.061224489795918366, + "position": 6.2449536317 + }, + { + "keys": [ + "http://localhost:9002/carreira/badge-patrol-training-field/" + ], + "clicks": 2, + "impressions": 77, + "ctr": 0.025974025974025976, + "position": 3.8009102947 + }, + { + "keys": [ + "http://localhost:9002/viagem/campo-verde-neblina-alta/" + ], + "clicks": 2, + "impressions": 29, + "ctr": 0.06896551724137931, + "position": 5.8010909322 + }, + { + "keys": [ + "http://localhost:9002/entertainment/pine-ember-starlight-film/" + ], + "clicks": 2, + "impressions": 28, + "ctr": 0.07142857142857142, + "position": 44.6527093227 + } + ], + "responseAggregationType": "byPage" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"query\"],\"enddate\":\"2025-12-24\",\"rowlimit\":50,\"startdate\":\"2025-12-18\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "tour guides in edinburgh" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 10.4004629597 + }, + { + "keys": [ + "top films of the valley 2025" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 18.6262998303 + }, + { + "keys": [ + "palm leaf congregation harbor" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 9.0960461402 + }, + { + "keys": [ + "winter films set in the highlands" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 16.1053835918 + }, + { + "keys": [ + "mountain tavern old quarter" + ], + "clicks": 1, + "impressions": 11, + "ctr": 0.09090909090909091, + "position": 3.9577700913 + }, + { + "keys": [ + "coastal pub stone district" + ], + "clicks": 1, + "impressions": 36, + "ctr": 0.027777777777777776, + "position": 4.5324383622 + }, + { + "keys": [ + "meaning of the green plate on learner cars" + ], + "clicks": 1, + "impressions": 6, + "ctr": 0.16666666666666666, + "position": 9.5572400396 + }, + { + "keys": [ + "snowboard run highland resort" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 33.3907998429 + }, + { + "keys": [ + "craft gifts mountain village" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 35.333901642 + }, + { + "keys": [ + "\"riverside bakery\" \"n17 qp33\"" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 53.8403976026 + }, + { + "keys": [ + "+44 (00) 000 0000" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 7.1488447117 + }, + { + "keys": [ + "27.80" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 11.5519168308 + }, + { + "keys": [ + "a great harvest season" + ], + "clicks": 0, + "impressions": 4, + "ctr": 0.0, + "position": 1.0720333332 + }, + { + "keys": [ + "plantain fritters london" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 20.4841092843 + }, + { + "keys": [ + "travel companion in scotland" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 25.0589149215 + }, + { + "keys": [ + "minimum age forest ranger" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 7.2245993921 + }, + { + "keys": [ + "plan your trip to arrive at the central station" + ], + "clicks": 0, + "impressions": 6, + "ctr": 0.0, + "position": 21.6988790925 + }, + { + "keys": [ + "bank home loan for overseas buyers" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 50.9381119512 + }, + { + "keys": [ + "open savings account as foreigner" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 69.7732081204 + }, + { + "keys": [ + "student card tap payment feature" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 87.6668051492 + }, + { + "keys": [ + "cabin rental scotland" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 8.2000928115 + }, + { + "keys": [ + "market store jobs coastal town" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 6.8951945525 + }, + { + "keys": [ + "grocery store positions northern region" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 10.8168228904 + }, + { + "keys": [ + "rent a car highlands" + ], + "clicks": 0, + "impressions": 3, + "ctr": 0.0, + "position": 73.7215800697 + }, + { + "keys": [ + "hire car in the lake district" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 101.3841409864 + }, + { + "keys": [ + "car rental in the valley" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 98.584881833 + }, + { + "keys": [ + "car hire mountain pass" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 65.0061383025 + }, + { + "keys": [ + "stunning cottage stay forest" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 17.2514709799 + }, + { + "keys": [ + "web retail coastal district" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 67.4552242143 + }, + { + "keys": [ + "woodland adventure center" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 75.7706123175 + }, + { + "keys": [ + "a winter holiday film" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 93.4927310869 + }, + { + "keys": [ + "old stone carved emblems" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 78.0919847781 + }, + { + "keys": [ + "marco santos harbor town" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 7.2121132852 + }, + { + "keys": [ + "woven copper ring" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 50.3447632903 + }, + { + "keys": [ + "handcrafted silver band" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 28.465702919 + }, + { + "keys": [ + "braided golden ring" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 52.7353617971 + }, + { + "keys": [ + "stone hearth dining coastal city" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 91.4086566621 + }, + { + "keys": [ + "apps for hiking trails" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 99.8950414531 + }, + { + "keys": [ + "are there really no bears in city parks" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 94.3645362805 + }, + { + "keys": [ + "are there wolves in the forest" + ], + "clicks": 0, + "impressions": 3, + "ctr": 0.0, + "position": 67.023725779 + }, + { + "keys": [ + "regions of the highlands" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 92.8378869567 + }, + { + "keys": [ + "wooden harp emblem history" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 0.9375165083 + }, + { + "keys": [ + "morning bread baker village" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 49.2039239246 + }, + { + "keys": [ + "coastal bread shop opening times" + ], + "clicks": 0, + "impressions": 5, + "ctr": 0.0, + "position": 9.694501707 + }, + { + "keys": [ + "thornwood manor" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 83.6918254689 + }, + { + "keys": [ + "granite ridge estate hotel" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 67.4371060055 + }, + { + "keys": [ + "riverdale pine tree farm" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 10.101678014 + }, + { + "keys": [ + "film actors northern cinema" + ], + "clicks": 0, + "impressions": 4, + "ctr": 0.0, + "position": 59.5909899624 + }, + { + "keys": [ + "au pair highland family" + ], + "clicks": 0, + "impressions": 5, + "ctr": 0.0, + "position": 40.818400758 + }, + { + "keys": [ + "nanny pay rates mountain region" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 91.8122185375 + } + ], + "responseAggregationType": "byProperty" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"page\"],\"enddate\":\"2025-12-24\",\"rowlimit\":50,\"startdate\":\"2025-12-18\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "http://localhost:9002/tips/mountain-river-golden-bridge/" + ], + "clicks": 9, + "impressions": 911, + "ctr": 0.009879253567508232, + "position": 7.628054608 + }, + { + "keys": [ + "http://localhost:9002/compras/crystal-shop-window-light/" + ], + "clicks": 6, + "impressions": 40, + "ctr": 0.15, + "position": 5.9440468114 + }, + { + "keys": [ + "http://localhost:9002/guide/silver-forest-morning-light/" + ], + "clicks": 5, + "impressions": 54, + "ctr": 0.09259259259259259, + "position": 5.8429477069 + }, + { + "keys": [ + "http://localhost:9002/bares/ruby-harbor-evening-tide/" + ], + "clicks": 3, + "impressions": 296, + "ctr": 0.010135135135135136, + "position": 9.119591483 + }, + { + "keys": [ + "http://localhost:9002/cultura/bronze-velvet-cedar-peak/" + ], + "clicks": 3, + "impressions": 133, + "ctr": 0.022556390977443608, + "position": 5.7139523551 + }, + { + "keys": [ + "http://localhost:9002/transporte/road-sign-silver-badge/" + ], + "clicks": 3, + "impressions": 127, + "ctr": 0.023622047244094488, + "position": 4.1643684251 + }, + { + "keys": [ + "http://localhost:9002/gastronomia/palm-feast-winter-table/" + ], + "clicks": 3, + "impressions": 55, + "ctr": 0.05454545454545454, + "position": 7.4337013023 + }, + { + "keys": [ + "http://localhost:9002/carreira/badge-patrol-training-field/" + ], + "clicks": 2, + "impressions": 64, + "ctr": 0.03125, + "position": 4.0876372011 + }, + { + "keys": [ + "http://localhost:9002/viagem/campo-verde-neblina-alta/" + ], + "clicks": 2, + "impressions": 29, + "ctr": 0.06896551724137931, + "position": 5.5999464837 + }, + { + "keys": [ + "http://localhost:9002/entertainment/pine-ember-starlight-film/" + ], + "clicks": 2, + "impressions": 27, + "ctr": 0.07407407407407407, + "position": 43.771918946 + }, + { + "keys": [ + "http://localhost:9002/community/sun-village-western-plain/" + ], + "clicks": 2, + "impressions": 74, + "ctr": 0.02702702702702703, + "position": 10.5584740116 + }, + { + "keys": [ + "http://localhost:9002/social/golden-circle-harbor-meet/" + ], + "clicks": 2, + "impressions": 56, + "ctr": 0.03571428571428571, + "position": 10.4571351945 + }, + { + "keys": [ + "http://localhost:9002/dining/carnival-table-winter-feast/" + ], + "clicks": 2, + "impressions": 65, + "ctr": 0.03076923076923077, + "position": 6.1283021641 + }, + { + "keys": [ + "http://localhost:9002/carreira/copper-basin-thunder-ridge/" + ], + "clicks": 2, + "impressions": 87, + "ctr": 0.022988505747126436, + "position": 4.8286701576 + }, + { + "keys": [ + "http://localhost:9002/idiomas/river-words-morning-phrase/" + ], + "clicks": 1, + "impressions": 34, + "ctr": 0.029411764705882353, + "position": 9.0810823956 + }, + { + "keys": [ + "http://localhost:9002/hospedagem/stone-cottage-hidden-valley/" + ], + "clicks": 1, + "impressions": 7, + "ctr": 0.14285714285714285, + "position": 7.8821458819 + }, + { + "keys": [ + "http://localhost:9002/negocios/amber-cliff-coastal-range/" + ], + "clicks": 1, + "impressions": 27, + "ctr": 0.037037037037037035, + "position": 3.488208645 + }, + { + "keys": [ + "http://localhost:9002/educacao/parchment-seal-emerald-gate/" + ], + "clicks": 1, + "impressions": 7, + "ctr": 0.14285714285714285, + "position": 11.4877983846 + }, + { + "keys": [ + "http://localhost:9002/politica/ballot-stone-distant-home/" + ], + "clicks": 1, + "impressions": 6, + "ctr": 0.16666666666666666, + "position": 5.1045900552 + }, + { + "keys": [ + "http://localhost:9002/language/pebble-shore-evening-glow/" + ], + "clicks": 1, + "impressions": 67, + "ctr": 0.014925373134328358, + "position": 3.1879941183 + }, + { + "keys": [ + "http://localhost:9002/travel/runway-cloud-terminal-gate/" + ], + "clicks": 1, + "impressions": 9, + "ctr": 0.1111111111111111, + "position": 17.8198127624 + }, + { + "keys": [ + "http://localhost:9002/finance/copper-key-vault-bridge/" + ], + "clicks": 1, + "impressions": 5, + "ctr": 0.2, + "position": 14.4036600936 + }, + { + "keys": [ + "http://localhost:9002/sports/frost-slope-granite-summit/" + ], + "clicks": 1, + "impressions": 40, + "ctr": 0.025, + "position": 16.4300345172 + }, + { + "keys": [ + "http://localhost:9002/music/velvet-crown-shamrock-stage/" + ], + "clicks": 1, + "impressions": 12, + "ctr": 0.08333333333333333, + "position": 25.388017946 + }, + { + "keys": [ + "http://localhost:9002/news/cobalt-street-shadow-report/" + ], + "clicks": 1, + "impressions": 19, + "ctr": 0.05263157894736842, + "position": 7.3461353026 + }, + { + "keys": [ + "http://localhost:9002/news/iron-gate-morning-release/" + ], + "clicks": 1, + "impressions": 4, + "ctr": 0.25, + "position": 6.9843530024 + }, + { + "keys": [ + "http://localhost:9002/dining/amber-plate-budget-square/" + ], + "clicks": 1, + "impressions": 15, + "ctr": 0.06666666666666667, + "position": 8.0279443208 + }, + { + "keys": [ + "http://localhost:9002/tag/harbor-stone-evening-patrol/" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 32.5949008766 + }, + { + "keys": [ + "http://localhost:9002/cinema/reel-emerald-silver-screen/" + ], + "clicks": 1, + "impressions": 37, + "ctr": 0.02702702702702703, + "position": 15.8543094708 + }, + { + "keys": [ + "http://localhost:9002/news/velvet-cage-marble-hall/" + ], + "clicks": 1, + "impressions": 10, + "ctr": 0.1, + "position": 19.4063764972 + }, + { + "keys": [ + "http://localhost:9002/culture/paper-trail-cozy-nook/" + ], + "clicks": 1, + "impressions": 177, + "ctr": 0.005649717514124294, + "position": 9.7499893056 + }, + { + "keys": [ + "http://localhost:9002/natureza/deep-fjord-misty-shore/" + ], + "clicks": 1, + "impressions": 4, + "ctr": 0.25, + "position": 5.5717412642 + }, + { + "keys": [ + "http://localhost:9002/gastronomia/morning-table-golden-fork/" + ], + "clicks": 1, + "impressions": 6, + "ctr": 0.16666666666666666, + "position": 60.3926491936 + }, + { + "keys": [ + "http://localhost:9002/saude/iron-maple-harvest-valley/" + ], + "clicks": 1, + "impressions": 23, + "ctr": 0.043478260869565216, + "position": 11.1624994754 + }, + { + "keys": [ + "http://localhost:9002/transporte/silver-wheel-coastal-drive/" + ], + "clicks": 1, + "impressions": 16, + "ctr": 0.0625, + "position": 40.1927355733 + }, + { + "keys": [ + "http://localhost:9002/financas/copper-bridge-key-vault/" + ], + "clicks": 1, + "impressions": 19, + "ctr": 0.05263157894736842, + "position": 8.060019649 + }, + { + "keys": [ + "http://localhost:9002/tag/maple-shadow-trail/" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 18.1740333455 + }, + { + "keys": [ + "http://localhost:9002/negocios/diamond-summit-tower-peak/" + ], + "clicks": 1, + "impressions": 20, + "ctr": 0.05, + "position": 5.457799305 + }, + { + "keys": [ + "http://localhost:9002/" + ], + "clicks": 0, + "impressions": 15, + "ctr": 0.0, + "position": 17.2543605626 + }, + { + "keys": [ + "http://localhost:9002/gastronomia/salt-herb-harvest-plate/" + ], + "clicks": 0, + "impressions": 33, + "ctr": 0.0, + "position": 69.4916386043 + }, + { + "keys": [ + "http://localhost:9002/transporte/cloud-gate-tower-runway/" + ], + "clicks": 0, + "impressions": 2, + "ctr": 0.0, + "position": 8.1054864271 + }, + { + "keys": [ + "http://localhost:9002/idiomas/ancient-tongue-stone-circle/" + ], + "clicks": 0, + "impressions": 52, + "ctr": 0.0, + "position": 39.4819132265 + }, + { + "keys": [ + "http://localhost:9002/viagem/emerald-path-cliff-walk/" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 1.0808310582 + }, + { + "keys": [ + "http://localhost:9002/natal/pine-farm-winter-grove/" + ], + "clicks": 0, + "impressions": 10, + "ctr": 0.0, + "position": 6.3554586644 + }, + { + "keys": [ + "http://localhost:9002/natureza/crystal-cove-ocean-swim/" + ], + "clicks": 0, + "impressions": 5, + "ctr": 0.0, + "position": 23.1464280573 + }, + { + "keys": [ + "http://localhost:9002/viagem/hidden-road-highland-path/" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 10.3183630806 + }, + { + "keys": [ + "http://localhost:9002/features/amber-dawn-harbor-light/" + ], + "clicks": 0, + "impressions": 3, + "ctr": 0.0, + "position": 39.2647335829 + }, + { + "keys": [ + "http://localhost:9002/archive/spring-meadow-golden-oak/" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 5.9289878282 + }, + { + "keys": [ + "http://localhost:9002/archive/autumn-copper-fallen-leaf/" + ], + "clicks": 0, + "impressions": 1, + "ctr": 0.0, + "position": 9.1802667515 + }, + { + "keys": [ + "http://localhost:9002/cinema/cliff-set-ocean-backdrop/" + ], + "clicks": 0, + "impressions": 5, + "ctr": 0.0, + "position": 39.4624021945 + } + ], + "responseAggregationType": "byPage" + } + }, + "POST::{\"datastate\":\"all\",\"dimensions\":[\"query\"],\"enddate\":\"2026-01-01\",\"rowlimit\":\"10\",\"startdate\":\"2025-12-05\"}": { + "status": 200, + "body": { + "rows": [ + { + "keys": [ + "coastal pub stone district" + ], + "clicks": 4, + "impressions": 125, + "ctr": 0.032, + "position": 5.144965203 + }, + { + "keys": [ + "crowdfund relief campaign" + ], + "clicks": 3, + "impressions": 15, + "ctr": 0.2, + "position": 7.3806543963 + }, + { + "keys": [ + "mountain tavern old quarter" + ], + "clicks": 2, + "impressions": 26, + "ctr": 0.07692307692307693, + "position": 3.7363858724 + }, + { + "keys": [ + "northern craft brands" + ], + "clicks": 2, + "impressions": 16, + "ctr": 0.125, + "position": 1.6129050846 + }, + { + "keys": [ + "travel companion in scotland" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 13.922271045 + }, + { + "keys": [ + "tour guides in edinburgh" + ], + "clicks": 1, + "impressions": 1, + "ctr": 1.0, + "position": 11.4932767666 + }, + { + "keys": [ + "top films of the valley 2025" + ], + "clicks": 1, + "impressions": 10, + "ctr": 0.1, + "position": 15.4422440304 + }, + { + "keys": [ + "tropical cafe harbor district" + ], + "clicks": 1, + "impressions": 13, + "ctr": 0.07692307692307693, + "position": 10.6077088395 + }, + { + "keys": [ + "sunflower community chapel" + ], + "clicks": 1, + "impressions": 2, + "ctr": 0.5, + "position": 7.2419597346 + }, + { + "keys": [ + "palm leaf congregation harbor" + ], + "clicks": 1, + "impressions": 4, + "ctr": 0.25, + "position": 10.1125928723 + } + ], + "responseAggregationType": "byProperty" + } + }, + "POST::{\"datastate\":\"all\",\"enddate\":\"2026-01-01\",\"rowlimit\":1,\"startdate\":\"2026-01-01\"}": { + "status": 200, + "body": { + "rows": [ + { + "clicks": 17, + "impressions": 1253, + "ctr": 0.0144325572801182557, + "position": 15.248337028824835 + } + ], + "responseAggregationType": "byProperty" + } + } + } +} \ No newline at end of file diff --git a/tests/playwright/docker/fixtures/index.ts b/tests/playwright/docker/fixtures/index.ts new file mode 100644 index 00000000000..f9c65caef11 --- /dev/null +++ b/tests/playwright/docker/fixtures/index.ts @@ -0,0 +1,263 @@ +/** + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { createServer, IncomingMessage, ServerResponse } from 'node:http'; +import { createServer as createTLSServer } from 'node:https'; +import { execSync } from 'node:child_process'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +type FixtureData = Record< + string, + Record< string, { status: number; body: unknown } > +>; + +interface BatchPart { + contentID: string; + method: string; + url: string; + body: string; +} + +function parseBatchParts( rawBody: string, boundary: string ): BatchPart[] { + const parts: BatchPart[] = []; + const delimiter = '--' + boundary; + + for ( const section of rawBody.split( delimiter ) ) { + const trimmed = section.replace( /^[\r\n]+|[\r\n-]+$/g, '' ); + if ( ! trimmed ) { + continue; + } + + // Split outer MIME headers from the embedded HTTP request. + const blankLine = trimmed.match( /\r?\n\r?\n/ ); + if ( ! blankLine || blankLine.index === undefined ) { + continue; + } + + const outerHeaders = trimmed.slice( 0, blankLine.index ); + const innerRequest = trimmed + .slice( blankLine.index + blankLine[ 0 ].length ) + .trim(); + + const contentIDMatch = outerHeaders.match( /^Content-ID:\s*(.+)$/im ); + if ( ! contentIDMatch ) { + continue; + } + const contentID = contentIDMatch[ 1 ].trim(); + + // Parse the embedded HTTP request line. + const innerLines = innerRequest.split( /\r?\n/ ); + const requestMatch = innerLines[ 0 ].match( /^(\w+)\s+(\S+)\s+HTTP/i ); + if ( ! requestMatch ) { + continue; + } + + const method = requestMatch[ 1 ]; + const url = requestMatch[ 2 ]; + + // The body follows the blank line inside the inner request. + let innerBody = ''; + let blankFound = false; + for ( let index = 1; index < innerLines.length; index++ ) { + if ( ! blankFound ) { + if ( innerLines[ index ].trim() === '' ) { + blankFound = true; + } + continue; + } + innerBody += ( innerBody ? '\n' : '' ) + innerLines[ index ]; + } + + parts.push( { contentID, method, url, body: innerBody.trim() } ); + } + + return parts; +} + +function handleBatchRequest( + body: string, + host: string, + reqContentType: string, + data: FixtureData, + res: ServerResponse +): void { + const boundaryMatch = reqContentType.match( /boundary=(\S+)/i ); + if ( ! boundaryMatch ) { + res.writeHead( 400, { 'Content-Type': 'application/json' } ); + res.end( + JSON.stringify( { error: 'Missing boundary in Content-Type' } ) + ); + return; + } + + const boundary = boundaryMatch[ 1 ]; + const parts = parseBatchParts( body, boundary ); + const responseBoundary = 'batch_' + Date.now(); // eslint-disable-line sitekit/no-direct-date + const responseParts: string[] = []; + + for ( const part of parts ) { + const fixture = lookupFixture( + data, + host, + part.method, + part.url, + part.body + ); + const status = fixture ? fixture.status : 404; + const statusText = status === 200 ? 'OK' : 'Not Found'; + const responseBody = fixture + ? JSON.stringify( fixture.body ) + : JSON.stringify( { error: 'Fixture not found' } ); + + responseParts.push( + `--${ responseBoundary }\r\n` + + 'Content-Type: application/http\r\n' + + `Content-ID: response-${ part.contentID }\r\n` + + '\r\n' + + `HTTP/1.1 ${ status } ${ statusText }\r\n` + + 'Content-Type: application/json\r\n' + + '\r\n' + + responseBody + ); + } + + const responseBody = + responseParts.join( '\r\n' ) + `\r\n--${ responseBoundary }--`; + + res.writeHead( 200, { + 'Content-Type': `multipart/mixed; boundary=${ responseBoundary }`, + } ); + res.end( responseBody ); +} + +function lookupFixture( + data: FixtureData, + host: string, + method: string, + url: string, + body: string +): { status: number; body: unknown } | undefined { + global.console.log( '[%s] %s %s', host, method, url ); + + let key = method; + if ( body ) { + key += '::' + body.toLowerCase(); + } + + const response = data[ url ]?.[ key ]; + if ( ! response ) { + global.console.log( 'Missing fixture for:\n %s', body ); + } + + return response; +} + +function handler( req: IncomingMessage, res: ServerResponse ) { + const host = req.headers.host || 'unknown'; + const method = req.method || 'GET'; + const url = req.url || '/'; + + const jsonContentType = { 'Content-Type': 'application/json' }; + + const fixturesHeader = req.headers[ 'x-wp-test-fixtures' ]; + const fixtures = Array.isArray( fixturesHeader ) + ? fixturesHeader[ 0 ] + : fixturesHeader; + + // If no fixtures are specified, return an empty response. + if ( ! fixtures ) { + res.writeHead( 200, jsonContentType ); + res.end( '{}' ); + return; + } + + let body = ''; + req.on( 'data', ( chunk: Buffer | string ) => ( body += chunk ) ); + req.on( 'end', () => { + try { + const dataPath = join( '/fixtures/data', fixtures, host + '.json' ); + const data = JSON.parse( + readFileSync( dataPath, 'utf8' ) + ) as FixtureData; + + if ( url === '/batch' ) { + const reqContentType = req.headers[ 'content-type' ] || ''; + handleBatchRequest( body, host, reqContentType, data, res ); + return; + } + + const response = lookupFixture( data, host, method, url, body ); + if ( ! response ) { + res.writeHead( 404, jsonContentType ); + res.end( JSON.stringify( { error: 'Fixture not found' } ) ); + return; + } + + res.writeHead( response.status, jsonContentType ); + res.end( JSON.stringify( response.body ) ); + } catch ( err ) { + global.console.error( 'Failed to process request:', err ); + + res.writeHead( 500, jsonContentType ); + res.end( + JSON.stringify( { + error: err instanceof Error ? err.message : String( err ), + } ) + ); + } + } ); +} + +function generateSelfSignedCert(): { key: string; cert: string } { + const dir = mkdtempSync( join( tmpdir(), 'cert-' ) ); + const keyPath = join( dir, 'key.pem' ); + const certPath = join( dir, 'cert.pem' ); + + execSync( + `openssl req -x509 -newkey rsa:2048 -keyout ${ keyPath } -out ${ certPath } -days 365 -nodes -subj "/CN=googleapis.com" -addext "subjectAltName=DNS:*.googleapis.com,DNS:googleapis.com"`, + { stdio: 'pipe' } + ); + + return { + key: readFileSync( keyPath, 'utf8' ), + cert: readFileSync( certPath, 'utf8' ), + }; +} + +function startServers() { + const httpServer = createServer( handler ); + httpServer.listen( 80, () => { + global.console.log( 'HTTP server running on port 80' ); + } ); + + const { key, cert } = generateSelfSignedCert(); + const httpsServer = createTLSServer( { key, cert }, handler ); + httpsServer.listen( 443, () => { + global.console.log( 'HTTPS server running on port 443' ); + } ); +} + +try { + startServers(); +} catch ( err ) { + const msg = err instanceof Error ? err.message : String( err ); + global.console.error( 'Failed to start HTTPS server:', msg ); +} diff --git a/tests/playwright/docker/wordpress/db.php b/tests/playwright/docker/wordpress/db.php index 8aff4f87854..8c4261eda8b 100644 --- a/tests/playwright/docker/wordpress/db.php +++ b/tests/playwright/docker/wordpress/db.php @@ -14,9 +14,7 @@ * @link https://sitekit.withgoogle.com */ -$_e2e_db_name = isset( $_COOKIE['_wp_test_db'] ) - ? preg_replace( '/[^a-zA-Z0-9_]/', '_', $_COOKIE['_wp_test_db'] ) - : DB_NAME; +$_e2e_db_name = isset( $_COOKIE['_wp_test_db'] ) ? $_COOKIE['_wp_test_db'] : DB_NAME; $_e2e_error_level_map = array( E_WARNING => 'E_WARNING', diff --git a/tests/playwright/docker/wordpress/mu-plugins/e2e-feature-flags.php b/tests/playwright/docker/wordpress/mu-plugins/e2e-feature-flags.php new file mode 100644 index 00000000000..dc40a9d6a5a --- /dev/null +++ b/tests/playwright/docker/wordpress/mu-plugins/e2e-feature-flags.php @@ -0,0 +1,30 @@ +isSMTP(); $phpmailer->Host = 'mailpit'; $phpmailer->Port = 1025; $phpmailer->Username = 'admin@example.com'; $phpmailer->Password = ''; - $phpmailer->From = $from_name; - $phpmailer->FromName = 'Site Kit E2E Tests'; $phpmailer->SMTPSecure = ''; $phpmailer->SMTPAuth = false; } diff --git a/tests/playwright/docker/wordpress/mu-plugins/e2e-reference-date.php b/tests/playwright/docker/wordpress/mu-plugins/e2e-reference-date.php new file mode 100644 index 00000000000..10421a0b7bf --- /dev/null +++ b/tests/playwright/docker/wordpress/mu-plugins/e2e-reference-date.php @@ -0,0 +1,17 @@ + WP_REST_Server::EDITABLE, + 'permission_callback' => '__return_true', + 'callback' => function ( WP_REST_Request $request ) { + $frequency = $request->get_param( 'frequency' ); + if ( empty( $frequency ) ) { + return new WP_Error( + 'missing_frequency', + 'Frequency is required.', + array( 'status' => 400 ) + ); + } + + // Trigger the initiator action which creates the batch. + do_action_ref_array( Email_Reporting_Scheduler::ACTION_INITIATOR, array( $frequency ) ); + + // Get the batch ID from the database. + $batch_id = get_batch_id_from_database( $frequency ); + if ( ! $batch_id ) { + return new WP_Error( + 'batch_id_not_found', + 'Batch ID not found in database.', + array( 'status' => 500 ) + ); + } + + // Trigger the worker action with the retrieved batch ID. + do_action_ref_array( Email_Reporting_Scheduler::ACTION_WORKER, array( $batch_id, $frequency, time() ) ); + + return array( 'success' => true ); + }, + ) + ); + } +); + +function get_batch_id_from_database( $frequency ) { + $posts = get_posts( + array( + 'post_type' => Email_Log::POST_TYPE, + 'post_status' => Email_Log::STATUS_SCHEDULED, + 'meta_key' => Email_Log::META_REPORT_FREQUENCY, + 'meta_value' => $frequency, + 'posts_per_page' => 1, + 'orderby' => 'ID', + 'order' => 'DESC', + 'fields' => 'ids', + ) + ); + + if ( empty( $posts ) ) { + return false; + } + + $batch_id = get_post_meta( $posts[0], Email_Log::META_BATCH_ID, true ); + return $batch_id ?: false; +} diff --git a/tests/playwright/docker/wordpress/plugins/proxy-auth.php b/tests/playwright/docker/wordpress/plugins/proxy-auth.php new file mode 100644 index 00000000000..5c78da5f1dc --- /dev/null +++ b/tests/playwright/docker/wordpress/plugins/proxy-auth.php @@ -0,0 +1,71 @@ +encrypt( 'test-access-token' ); + } +); + +/** + * Set Search Console property ID to localhost:9002 and fake site verification. + */ +add_action( + 'init', + function () { + $settings = get_option( Search_Console_Settings::OPTION ); + if ( empty( $settings['propertyID'] ) || 'http://localhost:9002' !== $settings['propertyID'] ) { + $settings['propertyID'] = 'http://localhost:9002'; + update_option( Search_Console_Settings::OPTION, $settings ); + } + + update_user_option( + get_current_user_id(), + 'googlesitekit_site_verified_meta', + 'verified' + ); + } +); + +/** + * Fake all required scopes have been granted. + */ +$_force_all_scopes = function () { + global $_force_all_scopes; + + // Remove the filter hook to prevent an infinite loop in the case where the `googlesitekit_auth_scopes` + // option is retrieved again during the call to `get_required_scopes()`. + remove_filter( 'get_user_option_googlesitekit_auth_scopes', $_force_all_scopes ); + + $required_scopes = ( new OAuth_Client( Plugin::instance()->context() ) )->get_required_scopes(); + + // Restore the filter hook for future calls to retrieve the option. + add_filter( 'get_user_option_googlesitekit_auth_scopes', $_force_all_scopes ); + + return $required_scopes; +}; + +// Ensure the filter hook is initially applied. +add_filter( 'get_user_option_googlesitekit_auth_scopes', $_force_all_scopes ); diff --git a/tests/playwright/specs/email-reporting/email-reporting-page.ts b/tests/playwright/specs/email-reporting/email-reporting-page.ts new file mode 100644 index 00000000000..1f8f9681f74 --- /dev/null +++ b/tests/playwright/specs/email-reporting/email-reporting-page.ts @@ -0,0 +1,250 @@ +/** + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { Locator, Page, expect } from '@playwright/test'; + +export interface VerifyPanelStateOptions { + shouldShowCurrentSubscription?: boolean; + expectedCheckedFrequency?: 'Weekly' | 'Monthly' | 'Quarterly'; + shouldShowSubscribeButton?: boolean; + shouldShowUnsubscribeButton?: boolean; + shouldShowUpdateButton?: boolean; +} + +export class EmailReportingPage { + private readonly page: Page; + private readonly root: Locator; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param page The page. + */ + constructor( page: Page ) { + this.page = page; + this.root = page.locator( '.googlesitekit-email-reporting-settings' ); + } + + /** + * Open the email reporting settings page. + * + * @since n.e.x.t + * + * @return {Promise} The promise that resolves when the email reporting settings page is opened. + */ + async openSettings() { + await this.page.getByRole( 'button', { name: 'Account' } ).click(); + await this.page + .getByRole( 'menuitem', { name: 'Manage email reports' } ) + .click(); + } + + /** + * Get the panel title. + * + * @since n.e.x.t + * + * @return {Locator} The panel title. + */ + get panelTitle() { + return this.root.getByRole( 'heading', { + name: 'Email reports subscription', + } ); + } + + /** + * Get the current subscription badge. + * + * @since n.e.x.t + * + * @return {Locator} The current subscription badge. + */ + get currentSubscriptionBadge() { + return this.root.locator( + '.googlesitekit-frequency-selector__current-subscription' + ); + } + + /** + * Get the frequency radio button. + * + * @since n.e.x.t + * + * @param frequency The frequency to get the radio button for. + * @return The frequency radio button. + */ + getFrequencyRadio( frequency: 'Weekly' | 'Monthly' | 'Quarterly' ) { + return this.root.getByRole( 'radio', { name: frequency } ); + } + + /** + * Get the subscribe button. + * + * @since n.e.x.t + * + * @return {Locator} The subscribe button. + */ + get subscribeButton() { + return this.root.getByRole( 'button', { + name: 'Subscribe', + exact: true, + } ); + } + + /** + * Get the unsubscribe button. + * + * @since n.e.x.t + * + * @return {Locator} The unsubscribe button. + */ + get unsubscribeButton() { + return this.root.getByRole( 'button', { name: 'Unsubscribe' } ); + } + + /** + * Get the update settings button. + * + * @since n.e.x.t + * + * @return {Locator} The update settings button. + */ + get updateSettingsButton() { + return this.root.getByRole( 'button', { name: 'Update settings' } ); + } + + /** + * Get the success notice. + * + * @since n.e.x.t + * + * @return {Locator} The success notice. + */ + get successNotice() { + return this.root + .getByRole( 'status' ) + .filter( { hasText: 'successfully subscribed' } ); + } + + /** + * Subscribe to email reports. + * + * @since n.e.x.t + * + * @return {Promise} The promise that resolves when the email reports are subscribed to. + */ + async subscribe() { + await this.subscribeButton.click(); + } + + /** + * Select the frequency for email reports. + * + * @since n.e.x.t + * + * @param frequency The frequency to select. + * @return {Promise} The promise that resolves when the frequency is selected. + */ + async selectFrequency( frequency: 'Weekly' | 'Monthly' | 'Quarterly' ) { + const radio = this.getFrequencyRadio( frequency ); + await radio.click(); + } + + /** + * Verify the state of the email reporting settings panel. + * + * @since n.e.x.t + * + * @param options The options to verify. + * @return {Promise} The promise that resolves when the email reporting settings panel is verified. + */ + async verifyPanelState( options: VerifyPanelStateOptions = {} ) { + const { + shouldShowCurrentSubscription = false, + expectedCheckedFrequency, + shouldShowSubscribeButton = false, + shouldShowUnsubscribeButton = false, + shouldShowUpdateButton = false, + } = options; + + // Panel title is visible + await expect( this.panelTitle ).toBeVisible(); + + // Current subscription badge visibility + if ( shouldShowCurrentSubscription ) { + await expect( this.currentSubscriptionBadge ).toBeVisible(); + } else { + await expect( this.currentSubscriptionBadge ).not.toBeVisible(); + } + + // Frequency options are visible + await expect( this.getFrequencyRadio( 'Weekly' ) ).toBeVisible(); + await expect( this.getFrequencyRadio( 'Monthly' ) ).toBeVisible(); + await expect( this.getFrequencyRadio( 'Quarterly' ) ).toBeVisible(); + + // Check expected frequency is checked + if ( expectedCheckedFrequency ) { + const frequencies: ( 'Weekly' | 'Monthly' | 'Quarterly' )[] = [ + 'Weekly', + 'Monthly', + 'Quarterly', + ]; + for ( const freq of frequencies ) { + const radio = this.getFrequencyRadio( freq ); + if ( freq === expectedCheckedFrequency ) { + await expect( radio ).toBeChecked(); + } else { + await expect( radio ).not.toBeChecked(); + } + } + } + + // Button visibility + if ( shouldShowSubscribeButton ) { + await expect( this.subscribeButton ).toBeVisible(); + } else { + await expect( this.subscribeButton ).not.toBeVisible(); + } + + if ( shouldShowUnsubscribeButton ) { + await expect( this.unsubscribeButton ).toBeVisible(); + } else { + await expect( this.unsubscribeButton ).not.toBeVisible(); + } + + if ( shouldShowUpdateButton ) { + await expect( this.updateSettingsButton ).toBeVisible(); + } else { + await expect( this.updateSettingsButton ).not.toBeVisible(); + } + } + + /** + * Verify the subscription success notice. + * + * @since n.e.x.t + * + * @return {Promise} The promise that resolves when the subscription success notice is verified. + */ + async verifySubscriptionSuccess() { + await expect( this.successNotice ).toBeVisible( { timeout: 10_000 } ); + } +} diff --git a/tests/playwright/specs/email-reporting/email-reporting.spec.ts b/tests/playwright/specs/email-reporting/email-reporting.spec.ts new file mode 100644 index 00000000000..68ac0a954be --- /dev/null +++ b/tests/playwright/specs/email-reporting/email-reporting.spec.ts @@ -0,0 +1,122 @@ +/** + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import { test, expect } from '../../playwright'; +import { asUser, withPlugins, withFixtures } from '../../wordpress'; +import { + EmailReportingPage, + VerifyPanelStateOptions, +} from './email-reporting-page'; + +const user = asUser( 'admin' ); +const plugins = withPlugins( 'proxy-auth.php', 'email-reporting.php' ); + +test.describe( 'Email Reporting', { annotation: [ user, plugins ] }, () => { + test( + 'should deliver a weekly email report', + { + annotation: [ + withFixtures( 'email-reporting/weekly-report-data' ), + ], + }, + async ( { wp } ) => { + // Go to the Site Kit dashboard page. + await wp.visitDashboard(); + + // Quickly open the email reporting settings panel and select the weekly subscription. + await test.step( 'Subscribe to weekly reports', async () => { + const pageObject = new EmailReportingPage( wp.page ); + await pageObject.openSettings(); + await pageObject.subscribe(); + await pageObject.verifySubscriptionSuccess(); + } ); + + // Trigger the email pipeline to send the weekly report. + await test.step( 'Trigger email pipeline', async () => { + const response = await wp.restRequest( + 'POST', + 'google-site-kit/v1/e2e/email-reporting/trigger-cron', + { + body: JSON.stringify( { frequency: 'weekly' } ), + headers: { 'Content-Type': 'application/json' }, + } + ); + + await expect( response ).toEqual( { success: true } ); + } ); + + // Verify the email was sent and has the correct content. + await test.step( 'Verify email', async () => { + const message = await wp.mailpit.waitForMessage(); + expect( message.Subject ).toContain( + 'Your weekly Site Kit report' + ); + + const detail = await wp.mailpit.getMessage( message.ID ); + await wp.page.setContent( detail.HTML ); + await expect( wp.page ).toHaveScreenshot( { + fullPage: true, + } ); + } ); + } + ); + + test( 'should let user select a subscription', async ( { wp } ) => { + // Go to the Site Kit dashboard page. + await wp.visitDashboard(); + + const pageObject = new EmailReportingPage( wp.page ); + + // Define panel state options for initial state. + const initialPanelState: VerifyPanelStateOptions = { + expectedCheckedFrequency: 'Weekly', + shouldShowSubscribeButton: true, + }; + + // Define panel state options for subscribed state. + const subscribedPanelState: VerifyPanelStateOptions = { + shouldShowCurrentSubscription: true, + expectedCheckedFrequency: 'Monthly', + shouldShowUnsubscribeButton: true, + shouldShowUpdateButton: true, + }; + + // Open the email reporting settings panel. + await test.step( 'Open settings page', async () => { + await pageObject.openSettings(); + } ); + + // Verify the settings panel state. + await test.step( 'Verify settings panel state', async () => { + await pageObject.verifyPanelState( initialPanelState ); + } ); + + // Verify the monthly option can be selected. + await test.step( 'Set monthly option', async () => { + await pageObject.selectFrequency( 'Monthly' ); + await pageObject.subscribe(); + await pageObject.verifySubscriptionSuccess(); + } ); + + // Verify the settings panel state changed. + await test.step( 'Verify settings state changed', async () => { + await pageObject.verifyPanelState( subscribedPanelState ); + } ); + } ); +} ); diff --git a/tests/playwright/specs/email-reporting/email-reporting.spec.ts-snapshots/Email-Reporting-should-deliver-a-weekly-email-report-1-chrome-desktop-linux.png b/tests/playwright/specs/email-reporting/email-reporting.spec.ts-snapshots/Email-Reporting-should-deliver-a-weekly-email-report-1-chrome-desktop-linux.png new file mode 100644 index 00000000000..c5cff266f2a Binary files /dev/null and b/tests/playwright/specs/email-reporting/email-reporting.spec.ts-snapshots/Email-Reporting-should-deliver-a-weekly-email-report-1-chrome-desktop-linux.png differ diff --git a/tests/playwright/specs/email-reporting/email-reporting.spec.ts-snapshots/Email-Reporting-should-deliver-a-weekly-email-report-1-chrome-mobile-linux.png b/tests/playwright/specs/email-reporting/email-reporting.spec.ts-snapshots/Email-Reporting-should-deliver-a-weekly-email-report-1-chrome-mobile-linux.png new file mode 100644 index 00000000000..7bcd3cd10f7 Binary files /dev/null and b/tests/playwright/specs/email-reporting/email-reporting.spec.ts-snapshots/Email-Reporting-should-deliver-a-weekly-email-report-1-chrome-mobile-linux.png differ diff --git a/tests/playwright/wordpress/cookies.ts b/tests/playwright/wordpress/cookies.ts index 7f705a7e7a2..7e43a9d1604 100644 --- a/tests/playwright/wordpress/cookies.ts +++ b/tests/playwright/wordpress/cookies.ts @@ -96,18 +96,42 @@ export class WordPressCookies { }, ]; - const userAnnotation = this.testInfo.annotations.find( - ( { type } ) => type === '_wp:as-user' - ); + const user = this.getAnnotation( '_wp:as-user' ); + if ( user ) { + cookies.push( { ...defaults, name: '_wp_test_user', value: user } ); + } + + const featureFlags = this.getAnnotation( '_wp:feature-flags' ); + if ( featureFlags ) { + cookies.push( { + ...defaults, + name: '_wp_test_feature_flags', + value: featureFlags, + } ); + } - if ( userAnnotation?.description ) { + const fixtures = this.getAnnotation( '_wp:fixtures' ); + if ( fixtures ) { cookies.push( { ...defaults, - name: '_wp_test_user', - value: userAnnotation.description, + name: '_wp_test_fixtures', + value: fixtures, } ); } return this.context.addCookies( cookies ); } + + /** + * Gets an annotation from the test info. + * + * @since n.e.x.t + * + * @param {string} name The name of the annotation to get. + * @return {string | undefined} The annotation value, or undefined if not found. + */ + private getAnnotation( name: string ): string | undefined { + return this.testInfo.annotations.find( ( { type } ) => type === name ) + ?.description; + } } diff --git a/tests/playwright/wordpress/mailpit.ts b/tests/playwright/wordpress/mailpit.ts index 5ffeab244e7..6a760d3f5d3 100644 --- a/tests/playwright/wordpress/mailpit.ts +++ b/tests/playwright/wordpress/mailpit.ts @@ -106,7 +106,7 @@ export class Mailpit { const url = `${ this.baseURL - }/api/v1/messages?query=${ encodeURIComponent( searchQuery ) }`; + }/api/v1/search?query=${ encodeURIComponent( searchQuery ) }`; const response = await fetch( url ); const data = await response.json(); @@ -167,11 +167,11 @@ export class Mailpit { ): Promise< MailpitMessage > { this.interacted = true; - const now = Date.now(); // eslint-disable-line sitekit/no-direct-date - const { query, timeout = 10000, interval = 500 } = options ?? {}; - const deadline = now + timeout; + const { query, timeout = 2_500, interval = 250 } = options ?? {}; + const deadline = Date.now() + timeout; // eslint-disable-line sitekit/no-direct-date - while ( now < deadline ) { + // eslint-disable-next-line sitekit/no-direct-date + while ( Date.now() < deadline ) { const messages = await this.getMessages( query ); if ( messages.length > 0 ) { return messages[ 0 ]; @@ -180,11 +180,13 @@ export class Mailpit { await new Promise( ( resolve ) => setTimeout( resolve, interval ) ); } - throw new Error( - `Timed out waiting for message${ - query ? ` matching "${ query }"` : '' - } after ${ timeout }ms` - ); + let err = 'Timed out waiting for message'; + if ( query ) { + err += ` matching "${ query }"`; + } + err += ` after ${ timeout }ms`; + + throw new Error( err ); } /** diff --git a/tests/playwright/wordpress/options.ts b/tests/playwright/wordpress/options.ts index 765b09c67de..37f332b49e5 100644 --- a/tests/playwright/wordpress/options.ts +++ b/tests/playwright/wordpress/options.ts @@ -20,11 +20,11 @@ import { TestDetailsAnnotation } from '@playwright/test'; /** - * Separator for plugin file paths. + * Separator for annotation values. * * @since 1.175.0 */ -export const PLUGINS_SEPARATOR = ','; +export const ANNOTATION_SEPARATOR = ','; /** * Sets the plugins to activate for the test. @@ -39,7 +39,37 @@ export function withPlugins( ...plugins: string[] ): TestDetailsAnnotation { type: '_wp:plugin', description: plugins .map( ( plugin ) => `google-site-kit-test-plugins/${ plugin }` ) - .join( PLUGINS_SEPARATOR ), + .join( ANNOTATION_SEPARATOR ), + }; +} + +/** + * Sets the feature flags to enable for the test. + * + * @since n.e.x.t + * + * @param {string[]} flags Feature flag names to enable. + * @return {TestDetailsAnnotation} The annotation to use for the test. + */ +export function withFeatureFlags( ...flags: string[] ): TestDetailsAnnotation { + return { + type: '_wp:feature-flags', + description: flags.join( ANNOTATION_SEPARATOR ), + }; +} + +/** + * Sets the fixtures to use for the test. + * + * @since n.e.x.t + * + * @param {string} fixtures The fixtures to use for the test. + * @return {TestDetailsAnnotation} The annotation to use for the test. + */ +export function withFixtures( fixtures: string ): TestDetailsAnnotation { + return { + type: '_wp:fixtures', + description: fixtures, }; } diff --git a/tests/playwright/wordpress/plugins.ts b/tests/playwright/wordpress/plugins.ts index dbcdfa7972e..ef51f9144d8 100644 --- a/tests/playwright/wordpress/plugins.ts +++ b/tests/playwright/wordpress/plugins.ts @@ -23,7 +23,7 @@ import { type Connection, type RowDataPacket } from 'mysql2/promise'; /** * Internal dependencies */ -import { PLUGINS_SEPARATOR } from './options'; +import { ANNOTATION_SEPARATOR } from './options'; /** * Serializes a string array to a PHP serialized format. @@ -36,8 +36,8 @@ import { PLUGINS_SEPARATOR } from './options'; function phpSerializeStringArray( arr: string[] ): string { const items = arr .map( - ( v, i ) => - `i:${ i };s:${ Buffer.byteLength( v, 'utf8' ) }:"${ v }";` + ( v, index ) => + `i:${ index };s:${ Buffer.byteLength( v, 'utf8' ) }:"${ v }";` ) .join( '' ); return `a:${ arr.length }:{${ items }}`; @@ -188,7 +188,7 @@ export class WordPressPlugins { } description - .split( PLUGINS_SEPARATOR ) + .split( ANNOTATION_SEPARATOR ) .forEach( ( pluginFile: string ) => { pluginFiles.push( pluginFile ); } ); diff --git a/tests/playwright/wordpress/wordpress.ts b/tests/playwright/wordpress/wordpress.ts index 4b1344f2ef6..a548aff5a84 100644 --- a/tests/playwright/wordpress/wordpress.ts +++ b/tests/playwright/wordpress/wordpress.ts @@ -159,15 +159,23 @@ export class WordPress { } if ( errors.length > 0 ) { - const summary = errors - .map( - ( e ) => - `[${ e.level }] ${ e.message } (${ e.file }:${ e.line })` - ) - .join( '\n' ); + const uniqueErrors: string[] = []; + errors.forEach( ( err ) => { + let msg = `[${ err.level }] ${ err.message } (${ err.file }:${ err.line })`; + if ( err.backtrace ) { + msg += `\n\t${ err.backtrace + .split( '\n' ) + .join( '\n\t' ) }`; + } + if ( ! uniqueErrors.includes( msg ) ) { + uniqueErrors.push( msg ); + } + } ); + + const summary = uniqueErrors.join( '\n' ); throw new Error( - `${ errors.length } PHP error(s) during test:\n${ summary }` + `${ uniqueErrors.length } PHP error(s) during test:\n${ summary }` ); } } @@ -241,6 +249,27 @@ export class WordPress { return this.page.goto( `${ this.baseURL }${ path }` ); } + /** + * Navigates to the Site Kit dashboard. + * + * @since n.e.x.t + * + * @param hash The hash to navigate to. + * @return {Promise} A promise that resolves when the page is navigated to. + */ + visitDashboard( hash = '' ): Promise< Response | null > { + let stepName = 'Visit Dashboard'; + if ( hash ) { + stepName += ` (#${ hash })`; + } + + return test.step( stepName, () => + this.visitAdmin( + `admin.php?page=googlesitekit-dashboard#${ hash }` + ) + ); + } + /** * Navigates to the given path in the admin area. * @@ -264,4 +293,32 @@ export class WordPress { visitFrontend( path = '/' ): Promise< Response | null > { return this.page.goto( `${ this.baseURL }${ path }` ); } + + /** + * Makes a request to the WordPress REST API using the browser's fetch. + * + * @since n.e.x.t + * + * @param method HTTP method (e.g. 'GET', 'POST'). + * @param route REST route without leading slash (e.g. 'sitekit-e2e/v1/my-endpoint'). + * @param init Optional additional fetch init options (headers, body, etc.). + * @return {Promise} Parsed JSON response body. + */ + restRequest( + method: string, + route: string, + init: Omit< RequestInit, 'method' > = {} + ): Promise< unknown > { + return this.page.evaluate( + async ( { url, method: m, init: index } ) => { + const response = await fetch( url, { method: m, ...index } ); + return response.json(); + }, + { + url: `${ this.baseURL }/wp-json/${ route }`, + method, + init, + } + ); + } }