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,
+ }
+ );
+ }
}