Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/e2e-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The setup script (`scripts/setup.php`) creates these WordPress pages:

## What is covered

The suite currently contains 52 passing tests across 7 spec files.
The suite currently contains 59 passing tests across 9 spec files.

| Spec file | Area | Tests | What it verifies |
|-----------|------|------:|------------------|
Expand All @@ -60,6 +60,8 @@ The suite currently contains 52 passing tests across 7 spec files.
| `supporter-mode-monthly.spec.ts` | Supporter monthly | 9 | Donation page is first step, breadcrumb text, monthly toggle default, tier selection updates CTA text, donation-to-details progression, `/join` body fields, custom amount input |
| `supporter-mode-oneoff.spec.ts` | Supporter one-off | 6 | One-off tab enabled under correct env flags, CTA text changes, `/join` body contains `recurDonation=false`, `paymentMethod=creditCard` forced, custom one-off amounts |
| `supporter-mode-edge-cases.spec.ts` | Edge cases | 7 | One-off disabled under `STRIPE_DIRECT_DEBIT_ONLY`, explanatory note shown, monthly still works with DD-only, "no amounts configured" warning, standard vs supporter `/join` body differences |
| `allow-cards-override.spec.ts` | Per-block card override | 5 | `allow_cards_override` causes the PHP env JSON to emit `STRIPE_DIRECT_DEBIT_ONLY=false`, one-off tab enabled on override page, `/join` body for one-off donation on override page |
| `wp-admin-smoke.spec.ts` | wp-admin backend | 2 | All 6 CK Join settings-page tabs load without a PHP error or 5xx; updating a Carbon Fields text field and clicking Save persists the value across a page reload |

### Coverage strengths

Expand Down Expand Up @@ -128,6 +130,10 @@ Network errors, server validation errors, Stripe card declines, and GoCardless m

Mailchimp, Action Network, Zetkin, Auth0, and lapsing/unlapsing webhooks are backend concerns. Mailchimp is covered by PHPUnit (`JoinServiceMailchimpTest.php`). These are not appropriate for frontend e2e tests.

### Gutenberg block editor

`wp-admin-smoke.spec.ts` covers the plugin's settings-page surface only. The block editor experience (inserting the CK Join block into a page, configuring per-block fields, switching between variations, saving the post) is not exercised. This is a known future-tier item if backend regressions land inside the block editor rather than the settings page.


## Future coverage workstreams

Expand Down
22 changes: 22 additions & 0 deletions packages/join-e2e/scripts/setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,28 @@ function ck_e2e_upsert_page(string $slug, string $title, string $content): int
// that care about it.
carbon_set_theme_option('stripe_direct_debit_only', true);

// Pre-populate the required Copy-tab fields and a single membership plan so
// the CK Join settings page can reach a saveable state in admin-surface e2e
// tests. Without these, the Carbon Fields save button stays disabled (empty
// required fields) and the cross-validator rejects the save (no plans).
carbon_set_theme_option('organisation_name', 'CK E2E Test Org');
carbon_set_theme_option('organisation_bank_name', 'CK E2E Test Bank');
carbon_set_theme_option('organisation_email_address', 'e2e-test@example.com');
carbon_set_theme_option('membership_plans', [
[
'_type' => '_',
'label' => 'Admin E2E Plan',
'id' => 'admin-e2e-plan',
'amount' => '5',
'allow_custom_amount' => '',
'frequency' => 'monthly',
'currency' => 'GBP',
'description' => '',
'add_tags' => '',
'remove_tags' => '',
],
]);

// Persist URLs as options so get-page-url.sh can retrieve them.
update_option('ck_e2e_standard_page_url', get_permalink($standard_page_id));
update_option('ck_e2e_free_page_url', get_permalink($free_page_id));
Expand Down
207 changes: 207 additions & 0 deletions packages/join-e2e/tests/wp-admin-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { execSync } from 'child_process';
import { test, expect, Page } from '@playwright/test';

/**
* wp-admin smoke test
*
* The plugin registers exactly one admin surface via Carbon Fields:
*
* /wp-admin/admin.php?page=crb_carbon_fields_container_ck_join_flow
*
* with six tabs: Features, Membership Plans, Theme, Copy, Integrations,
* Logging (defined in packages/join-block/src/Settings.php:236-242).
*
* The most recent tag was cut after fixing a regression that made this admin
* page unusable — clicking around the backend with the plugin activated
* produced errors. This spec is the minimum coverage that would have caught
* that: prove every tab loads and that a Carbon Fields field round-trips
* through the Save button.
*/

const SETTINGS_URL =
'/wp-admin/admin.php?page=crb_carbon_fields_container_ck_join_flow.php';

const TABS = [
'Features',
'Membership Plans',
'Theme',
'Copy',
'Integrations',
'Logging',
] as const;

/**
* Log in via the standard wp-login form using wp-env defaults.
*
* Inlined rather than factored into a shared helper because there is only
* one admin spec so far; once there are two, extracting this to a helper is
* worthwhile.
*/
async function loginAsAdmin(page: Page): Promise<void> {
await page.goto('/wp-login.php');
await page.waitForSelector('input[name="log"]');
// WordPress' login form autofocuses #user_login and some Chromium builds
// race with autocomplete. Use the form-field names directly and clear
// explicitly before typing, so the fill survives any late-arriving JS.
const userInput = page.locator('input[name="log"]');
const passInput = page.locator('input[name="pwd"]');
await userInput.click();
await userInput.fill('');
await userInput.fill('admin');
await passInput.click();
await passInput.fill('');
await passInput.fill('password');
await page.click('input[name="wp-submit"]');
// The admin bar appears once the authenticated dashboard has rendered.
await page.waitForSelector('#wpadminbar', { timeout: 15000 });
}

/**
* Errors we expect on a freshly-installed test WordPress with no credentials
* filled in. These come from third-party SDKs that the plugin initialises
* with empty keys; they are pre-existing, documented, and not the kind of
* regression this smoke test is chartered to catch.
*/
const IGNORED_ERROR_PATTERNS: RegExp[] = [
/Please call Stripe\(\) with your publishable key/i,
];

/**
* Attach listeners that fail the test if WordPress returns a 5xx or if the
* page fires an uncaught JS error originating from the plugin or WordPress
* itself (as opposed to third-party SDKs complaining about empty test creds).
*/
function guardAgainstAdminBreakage(page: Page): { errors: Error[] } {
const errors: Error[] = [];
page.on('pageerror', (err) => {
if (IGNORED_ERROR_PATTERNS.some((re) => re.test(err.message))) {
return;
}
errors.push(err);
});
page.on('response', (response) => {
const status = response.status();
if (status >= 500 && response.url().includes('/wp-admin/')) {
errors.push(
new Error(`5xx on ${response.request().method()} ${response.url()}: ${status}`),
);
}
});
return { errors };
}

test.describe('Settings page loads', () => {
test('every tab renders without a PHP error or 5xx', async ({ page }) => {
const { errors } = guardAgainstAdminBreakage(page);
await loginAsAdmin(page);
await page.goto(SETTINGS_URL);

// The Carbon Fields React app hydrates the settings container on load;
// the tab list is rendered inside it.
await page.waitForSelector('li[role="tab"]');

for (const label of TABS) {
const tab = page.locator(`li[role="tab"]:has(button:text-is("${label}"))`);
await expect(tab).toBeVisible();
await tab.locator('button').click();
await expect(tab).toHaveAttribute('aria-selected', 'true');

// Exactly one field panel must be visible after the click. Carbon Fields
// toggles panels with the `hidden` attribute, so ":not([hidden])" is the
// truth source here.
const visiblePanels = page.locator('.cf-container__fields:not([hidden])');
await expect(visiblePanels).toHaveCount(1);
}

expect(errors, `page errors: ${errors.map((e) => e.message).join(' | ')}`).toHaveLength(0);
});
});

/**
* The Carbon Fields save button submits the full container (every tab), so
* any option that isn't mirrored as a visible form field gets cleared on
* save. In particular, stripe_direct_debit_only — seeded as true by
* setup.php for the allow-cards-override spec — is inside a conditional tab
* and gets wiped once the round-trip test saves the form.
*
* Re-seed by re-running the admin-fixture portion of setup.php after every
* round-trip save so later specs see the state they expect. This is a
* cross-spec compatibility concern rather than a smoke-test concern.
*/
function restoreAdminFixtures(): void {
execSync(
[
'npx wp-env run tests-cli wp eval',
`"carbon_set_theme_option('stripe_direct_debit_only', true);"`,
].join(' '),
{ stdio: 'ignore' },
);
}

test.describe('Carbon Fields round-trip', () => {
test.afterAll(() => {
restoreAdminFixtures();
});

test('updating organisation_name on the Copy tab persists after reload', async ({
page,
}) => {
const { errors } = guardAgainstAdminBreakage(page);
await loginAsAdmin(page);
await page.goto(SETTINGS_URL);
await page.waitForSelector('li[role="tab"]');

// Switch to the Copy tab.
await page
.locator('li[role="tab"]:has(button:text-is("Copy"))')
.locator('button')
.click();

// Find the organisation_name field via its label (Carbon Fields titleizes
// the field name into "Organisation Name").
const input = page.getByRole('textbox', { name: /^Organisation Name\*?$/ }).first();
await expect(input).toBeVisible();

const original = (await input.inputValue()) ?? '';
const testValue = `CK Smoke Test ${Date.now()}`;

await input.click();
await input.fill(testValue);
// Carbon Fields' Redux store picks up dirty state from the input's native
// change event, which React's controlled-input plumbing fires on blur.
// Press Tab explicitly so the Save button transitions to enabled before
// we try to click it.
await input.press('Tab');
await expect(page.locator('input[type="submit"]#publish')).toBeEnabled();
await page.click('input[type="submit"]#publish');

// After save, Carbon Fields reloads the page with settings-updated=true.
await page.waitForURL(/settings-updated=true/);
await expect(page.locator('.settings-error.updated')).toBeVisible();

// Reload to prove the new value is what came back from the database,
// not just what is still in the browser's form state.
await page.reload();
await page.waitForSelector('li[role="tab"]');
await page
.locator('li[role="tab"]:has(button:text-is("Copy"))')
.locator('button')
.click();
await expect(
page.getByRole('textbox', { name: /^Organisation Name\*?$/ }).first(),
).toHaveValue(testValue);

// Restore the original value so repeated local runs stay idempotent.
const restoreInput = page
.getByRole('textbox', { name: /^Organisation Name\*?$/ })
.first();
await restoreInput.click();
await restoreInput.fill(original);
await restoreInput.press('Tab');
await expect(page.locator('input[type="submit"]#publish')).toBeEnabled();
await page.click('input[type="submit"]#publish');
await page.waitForURL(/settings-updated=true/);

expect(errors, `page errors: ${errors.map((e) => e.message).join(' | ')}`).toHaveLength(0);
});
});
Loading