From 97e5ddee990a6333284f9dacf6fd4ed2c3daa69c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:11:56 +0000 Subject: [PATCH 1/2] Add settings page regression tests for the Theme and Copy tabs Theme: colour defaults, each colour reaching the form's styles (primary on the buttons), custom CSS applied, and Theme settings in the legacy Carbon Fields format. Copy: every setting's default or fallback and where it shows: details, plan and bank-details steps, the confirmation step (organisation details and joining verb), sidebar heading and stage label; plus legacy data. Known problems, as test.fixme: Home Address Copy never shows (JOIN-162), the day-of-month copy is unused (JOIN-163), the organisation details are marked required but not enforced (JOIN-164). Also: colour and rich text helpers on JoinSettingsPage, getSettings to read many settings in one WP-CLI call, an e2e-custom-fields seed page, and openJoinForm now starts the form afresh (it remembers its step in session storage and the history entry). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL2bFrTk84JMy3CPAsCTV9 --- docs/e2e-testing.md | 5 +- packages/join-e2e/scripts/setup.php | 17 + packages/join-e2e/tests/settings/copy.spec.ts | 364 ++++++++++++++++++ packages/join-e2e/tests/settings/fixtures.ts | 13 +- .../join-e2e/tests/settings/legacy/copy.json | 26 ++ .../join-e2e/tests/settings/legacy/theme.json | 6 + packages/join-e2e/tests/settings/support.ts | 31 ++ .../join-e2e/tests/settings/theme.spec.ts | 88 +++++ 8 files changed, 547 insertions(+), 3 deletions(-) create mode 100644 packages/join-e2e/tests/settings/copy.spec.ts create mode 100644 packages/join-e2e/tests/settings/legacy/copy.json create mode 100644 packages/join-e2e/tests/settings/legacy/theme.json create mode 100644 packages/join-e2e/tests/settings/theme.spec.ts diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 698b81e..a061a8c 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -45,6 +45,7 @@ The setup script (`scripts/setup.php`) creates these WordPress pages: | `e2e-supporter` | Supporter mode with 3 tiers (GBP 5, 10, 20/month) | | `e2e-supporter-custom` | Supporter mode with custom amount allowed | | `e2e-supporter-no-plans` | Supporter mode with no plans (triggers warning UI) | +| `e2e-custom-fields` | Standard membership with one custom field ("Branch") | ## What is covered @@ -77,8 +78,8 @@ The suite currently contains 78 tests across 11 spec files (one skipped pending |---|---|---| | Features | `features.spec.ts`, `features-integrations.spec.ts` | Every setting. Integration switches: refused without credentials, saved with them, payment-step and form effects. Use Stripe sets global plans up in Stripe on save (fake API). Known problem: JOIN-161 (a rejected Stripe key crashes the save). | | Membership Plans | | Not yet | -| Theme | | Not yet | -| Copy | | Not yet | +| Theme | `theme.spec.ts` | Every setting: colour defaults, each colour reaching the form's styles (the primary colour on its buttons), custom CSS applied. | +| Copy | `copy.spec.ts` | Every setting: defaults and fallbacks, and where each shows (details, plan and bank-details steps, confirmation step, sidebar). Known problems: JOIN-162 (Home Address Copy never shows), JOIN-163 (day-of-month copy unused), JOIN-164 (organisation details marked required but not enforced). | | Integrations | | Not yet. What Mailchimp, Action Network and Zetkin receive on a join will be covered here. | | Logging | | Not yet | | Debug | | Not yet | diff --git a/packages/join-e2e/scripts/setup.php b/packages/join-e2e/scripts/setup.php index 12bc737..1def705 100644 --- a/packages/join-e2e/scripts/setup.php +++ b/packages/join-e2e/scripts/setup.php @@ -344,6 +344,22 @@ 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); +// Custom fields page: standard join with one custom field, so the form shows +// its "more about you" section (and the settings page's heading for it). +$custom_fields_page_id = ck_e2e_upsert_page( + 'e2e-custom-fields', + 'E2E Custom Fields Test', + ck_e2e_make_block_content($standard_plans, [ + 'custom_fields' => [[ + '_type' => '_', + 'label' => 'Branch', + 'id' => 'branch', + 'required' => false, + 'field_type' => 'text', + ]], + ]) +); + // 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)); @@ -353,6 +369,7 @@ function ck_e2e_upsert_page(string $slug, string $title, string $content): int update_option('ck_e2e_supporter_no_plans_page_url', get_permalink($supporter_no_plans_page_id)); update_option('ck_e2e_allow_cards_override_page_url', get_permalink($allow_cards_override_page_id)); update_option('ck_e2e_multi_currency_page_url', get_permalink($multi_currency_page_id)); +update_option('ck_e2e_custom_fields_page_url', get_permalink($custom_fields_page_id)); echo 'Standard page URL: ' . get_permalink($standard_page_id) . "\n"; echo 'Free page URL: ' . get_permalink($free_page_id) . "\n"; diff --git a/packages/join-e2e/tests/settings/copy.spec.ts b/packages/join-e2e/tests/settings/copy.spec.ts new file mode 100644 index 0000000..926ef46 --- /dev/null +++ b/packages/join-e2e/tests/settings/copy.spec.ts @@ -0,0 +1,364 @@ +import { Page } from '@playwright/test'; +import { CONTINUE } from '../helpers'; +import { + FREE_JOIN_PAGE, + STANDARD_JOIN_PAGE, + expect, + getSetting, + getSettings, + joinFormEnv, + loadLegacySettings, + openJoinForm, + test, +} from './fixtures'; + +/** + * Settings page, Copy tab: the join form's wording. + * + * For each setting: the default before anything is saved, a new value saved + * through the settings page, and where it shows on the join form. + */ + +const CUSTOM_FIELDS_PAGE = '/e2e-custom-fields/'; +const MULTI_CURRENCY_PAGE = '/e2e-multi-currency/'; + +const details = (page: Page) => page.locator('form.form-content').first(); + +/** From the details step to the plan step. */ +async function goToPlans(page: Page, url = STANDARD_JOIN_PAGE) { + await openJoinForm(page, url); + await page.locator(CONTINUE).click(); + await page.waitForSelector('[role="radiogroup"]'); +} + +/** + * Switches on GoCardless with its Custom Pages (bank details taken in the + * form), the route by which a Direct Debit member reaches the confirmation + * step. Leaves the settings page unsaved. + */ +async function useGoCardlessCustomPages(settings: import('./support').JoinSettingsPage) { + await settings.open('Features'); + await settings.enableIntegration('Use GoCardless'); + await settings.setCheckbox('Use GoCardless Custom Pages', true); +} + +/** From the details step to the bank details step (GoCardless Custom Pages). */ +async function goToBankDetails(page: Page) { + await goToPlans(page); + await page.locator(CONTINUE).click(); + await page.waitForSelector('input#ddAccountNumber'); +} + +/** On to the confirmation step for a £5 a month Direct Debit membership. */ +async function goToConfirmStep(page: Page) { + await goToBankDetails(page); + await page.locator('input#ddConfirmAccountHolder').check(); + await page.locator('button[type="submit"]').last().click(); + await expect(page.getByText('Confirm your details')).toBeVisible(); +} + +/** Clicks the confirmation step's join button, holding the join request open. */ +async function clickJoin(page: Page, buttonText: string) { + await page.route('**/wp-json/join/v1/join', () => { + // Never answered, so the "joining" message stays on screen. + }); + await page.getByRole('button', { name: buttonText }).click(); +} + +test.describe('Organisation details', () => { + // Linear JOIN-164: marked required (*), but a blank value saves. + test.fixme('are required', async ({ settings }) => { + await settings.open('Copy'); + await settings.setText('Organisation Name*', ''); + + expect((await settings.saveExpectingErrors()).join(' ')).toContain('Organisation Name'); + expect(getSetting('organisation_name')).toBe('E2E Organisation'); + }); + + test('show in the Direct Debit wording and on the confirmation step, with the Joining Verb', async ({ + settings, + page, + }) => { + await useGoCardlessCustomPages(settings); + await settings.tab('Copy'); + await settings.setText('Organisation Name*', 'Test Union'); + await settings.setText('Organisation Bank Name*', 'TESTUNION'); + await settings.setText('Organisation Email Address*', 'members@test-union.example'); + await settings.setText('Joining Verb', 'Signing up to'); + await settings.save(); + + expect( + getSettings(['organisation_name', 'organisation_bank_name', 'organisation_email_address', 'joining_verb']), + ).toEqual({ + organisation_name: 'Test Union', + organisation_bank_name: 'TESTUNION', + organisation_email_address: 'members@test-union.example', + joining_verb: 'Signing up to', + }); + + await goToBankDetails(page); + await expect(page.getByText(/your Direct Debit Test Union will notify you/)).toBeVisible(); + + await goToConfirmStep(page); + await expect(page.getByText('You are paying for your membership of Test Union by Direct Debit.')).toBeVisible(); + await expect(page.getByText('On your bank statement the charge will appear as "TESTUNION".')).toBeVisible(); + await expect(page.getByRole('link', { name: 'members@test-union.example' })).toHaveAttribute( + 'href', + 'mailto:members@test-union.example', + ); + + await clickJoin(page, 'Join Test Union'); + await expect(page.getByText('Signing up to Test Union')).toBeVisible(); + }); + + test('Joining Verb falls back to "Joining" when blank', async ({ settings, page }) => { + await useGoCardlessCustomPages(settings); + await settings.tab('Copy'); + await settings.setText('Joining Verb', ''); + await settings.save(); + + expect((await joinFormEnv(page)).JOINING_VERB).toBe('Joining'); + await goToConfirmStep(page); + await clickJoin(page, 'Join E2E Organisation'); + await expect(page.getByText('Joining E2E Organisation')).toBeVisible(); + }); +}); + +test.describe('Details step', () => { + test('About You Heading and Copy', async ({ settings, page }) => { + const defaults = getSettings(['about_you_heading', 'about_you_copy']); + await openJoinForm(page); + await expect(page.getByRole('heading', { name: defaults.about_you_heading as string })).toBeVisible(); + + await settings.open('Copy'); + await settings.setText('About You Heading', 'Who are you?'); + await settings.setRichText('About You Copy', '

Tell us who you are.

'); + await settings.save(); + + expect(getSetting('about_you_heading')).toBe('Who are you?'); + expect(getSetting('about_you_copy')).toContain('Tell us who you are.'); + expect((await joinFormEnv(page)).ABOUT_YOU_COPY).toContain('

Tell us who you are.

'); + + await openJoinForm(page); + await expect(page.getByRole('heading', { name: 'Who are you?' })).toBeVisible(); + await expect(details(page).locator('strong', { hasText: 'who you are' })).toBeVisible(); + }); + + test('Date Of Birth Heading and Copy (with date of birth collected)', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Date Of Birth', true); + await settings.tab('Copy'); + await settings.setText('Date Of Birth Heading', 'Your birthday'); + await settings.setRichText('Date Of Birth Copy', '

We ask so we can check your age.

'); + await settings.save(); + + expect(getSettings(['date_of_birth_heading'])).toEqual({ date_of_birth_heading: 'Your birthday' }); + + await openJoinForm(page); + await expect(page.getByRole('heading', { name: 'Your birthday' })).toBeVisible(); + await expect(page.getByText('We ask so we can check your age.')).toBeVisible(); + }); + + test('Password Purpose (with Auth0 accounts on)', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Create Auth0 Account'); + await settings.tab('Copy'); + await settings.setRichText('Password Purpose', '

Use this password to log in to the members area.

'); + await settings.save(); + + expect(getSetting('password_purpose')).toContain('Use this password to log in to the members area.'); + + await openJoinForm(page); + await expect(page.getByText('Use this password to log in to the members area.')).toBeVisible(); + }); + + // Linear JOIN-162: the form only shows this copy when a build-time + // HIDE_HOME_ADDRESS_COPY flag is set (the condition is inverted), and the + // plugin never sets it, so the copy never shows. + test.fixme('Home Address Copy shows above the address fields', async ({ settings, page }) => { + await settings.open('Copy'); + await settings.setRichText('Home Address Copy', '

We post your membership card here.

'); + await settings.save(); + + expect(getSetting('home_address_copy')).toContain('We post your membership card here.'); + await openJoinForm(page); + await expect(page.getByText('We post your membership card here.')).toBeVisible(); + }); + + test('Form section heading names the custom fields section, and can be removed', async ({ settings, page }) => { + expect(getSetting('custom_fields_heading')).toBe('More about you'); + await openJoinForm(page, CUSTOM_FIELDS_PAGE); + await expect(page.getByRole('heading', { name: 'More about you' })).toBeVisible(); + + await settings.open('Copy'); + await settings.setText('Form section heading (leave blank for no heading)', 'About your work'); + await settings.save(); + await openJoinForm(page, CUSTOM_FIELDS_PAGE); + await expect(page.getByRole('heading', { name: 'About your work' })).toBeVisible(); + + await settings.open('Copy'); + await settings.setText('Form section heading (leave blank for no heading)', ''); + await settings.save(); + await openJoinForm(page, CUSTOM_FIELDS_PAGE); + await expect(page.getByLabel('Branch')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'About your work' })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'More about you' })).toHaveCount(0); + }); + + test('Contact Details Heading and Copy', async ({ settings, page }) => { + await settings.open('Copy'); + await settings.setText('Contact Details Heading', 'How to reach you'); + await settings.setRichText('Contact Details Copy', '

We only use these for membership.

'); + await settings.save(); + + expect(getSetting('contact_details_heading')).toBe('How to reach you'); + + await openJoinForm(page); + await expect(page.getByRole('heading', { name: 'How to reach you' })).toBeVisible(); + await expect(page.getByText('We only use these for membership.')).toBeVisible(); + }); + + test('Contact Consent Copy (with contact consent collected)', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox('Collect Phone And Email Contact Consent', true); + await settings.tab('Copy'); + await settings.setRichText('Contact Consent Copy', '

Tick how we may contact you.

'); + await settings.save(); + + await openJoinForm(page); + await expect(page.getByText('Tick how we may contact you.')).toBeVisible(); + }); + + test('"How did you hear about us?" heading, options and details label', async ({ settings, page }) => { + await settings.open('Features'); + await settings.setCheckbox("Ask 'How did you hear about us?'", true); + await settings.tab('Copy'); + await settings.setText('"How did you hear about us?" heading', 'Where did you find us?'); + await settings.setText('"How did you hear about us?" options', ' A friend ,Social media,, A poster '); + await settings.setText('"How did you hear about us?" additional details textbox label', 'Tell us more'); + await settings.save(); + + expect(getSetting('hear_about_us_options')).toBe(' A friend ,Social media,, A poster '); + // Trimmed, and empty entries dropped. + expect((await joinFormEnv(page)).HEAR_ABOUT_US_OPTIONS).toEqual(['A friend', 'Social media', 'A poster']); + + await openJoinForm(page); + await expect(page.getByRole('heading', { name: 'Where did you find us?' })).toBeVisible(); + const question = page.locator('select#howDidYouHearAboutUs'); + await expect(question.locator('option')).toHaveText([ + 'Choose an option', + 'A friend', + 'Social media', + 'A poster', + 'Other', + ]); + await question.selectOption('other'); + await expect(page.getByLabel('Tell us more')).toBeVisible(); + }); + + test('Privacy Copy', async ({ settings, page }) => { + await settings.open('Copy'); + await settings.setRichText('Privacy Copy', '

Read our privacy policy.

'); + await settings.save(); + + await openJoinForm(page); + await expect(page.getByRole('link', { name: 'privacy policy' })).toHaveAttribute('href', 'https://example.org/privacy'); + }); +}); + +test.describe('Plan step', () => { + test('Membership Tiers Heading and Copy, with the copy\'s built-in default', async ({ settings, page }) => { + expect(getSetting('membership_tiers_copy')).toBe(''); + await goToPlans(page); + await expect(page.getByText('You can change or cancel whenever you want.')).toBeVisible(); + + await settings.open('Copy'); + await settings.setText('Membership Tiers Heading', 'Pick a plan'); + await settings.setRichText('Membership Tiers Copy', '

All plans include voting rights.

'); + await settings.save(); + + await goToPlans(page); + await expect(page.getByRole('heading', { name: 'Pick a plan' })).toBeVisible(); + await expect(page.getByText('All plans include voting rights.')).toBeVisible(); + await expect(page.getByText('You can change or cancel whenever you want.')).toHaveCount(0); + }); + + test('Currency Selection Copy shows when there is a choice of currency', async ({ settings, page }) => { + await settings.open('Copy'); + await settings.setRichText('Currency Selection Copy', '

Pay in the currency of your bank account.

'); + await settings.save(); + + await goToPlans(page, MULTI_CURRENCY_PAGE); + await expect(page.getByText('Pay in the currency of your bank account.')).toBeVisible(); + + await goToPlans(page); + await expect(page.getByText('Pay in the currency of your bank account.')).toHaveCount(0); + }); + + // Linear JOIN-163: passed to the form, but nothing in the form shows it. + test.fixme('"Only valid for monthly subscriptions" (day of month copy) labels the payment day', async ({ settings, page }) => { + await settings.open('Copy'); + await settings.setText('Only valid for monthly subscriptions', 'Which day should we take payment?'); + await settings.save(); + + expect((await joinFormEnv(page)).SUBSCRIPTION_DAY_OF_MONTH_COPY).toBe('Which day should we take payment?'); + await goToPlans(page); + await page.locator(CONTINUE).click(); + await expect(page.getByText('Which day should we take payment?')).toBeVisible(); + }); +}); + +test.describe('Sidebar and steps', () => { + test('Join Form Sidebar Heading and Membership Stage Label', async ({ settings, page }) => { + await openJoinForm(page); + await expect(page.locator('.progress-steps h6')).toHaveText('Join Us'); + await expect(page.locator('.progress-steps')).toContainText('Your Membership'); + + await settings.open('Copy'); + await settings.setText('Join Form Sidebar Heading', 'Become a member'); + await settings.setText('Membership Stage Label', 'Your Plan'); + await settings.save(); + + expect(getSettings(['join_form_sidebar_heading', 'membership_stage_label'])).toEqual({ + join_form_sidebar_heading: 'Become a member', + membership_stage_label: 'Your Plan', + }); + + await goToPlans(page, FREE_JOIN_PAGE); + await expect(page.locator('.progress-steps h6')).toHaveText('Become a member'); + await expect(page.locator('.progress-step--current')).toContainText('Your Plan'); + }); + + test('both fall back to their defaults when blank', async ({ settings, page }) => { + await settings.open('Copy'); + await settings.setText('Join Form Sidebar Heading', ''); + await settings.setText('Membership Stage Label', ''); + await settings.save(); + + const env = await joinFormEnv(page); + expect([env.JOIN_FORM_SIDEBAR_HEADING, env.MEMBERSHIP_STAGE_LABEL]).toEqual(['Join Us', 'Your Membership']); + }); +}); + +test.describe('Copy saved before the settings page was rebuilt', () => { + // See tests/settings/legacy/README.md. + test('is still read and still shows on the form', async ({ page }) => { + loadLegacySettings('copy'); + + expect(getSettings(['organisation_name', 'about_you_heading', 'hear_about_us_options'])).toEqual({ + organisation_name: 'Legacy Union', + about_you_heading: 'Legacy about you', + hear_about_us_options: 'Legacy friend,Legacy poster', + }); + + const env = await joinFormEnv(page); + expect(env.ABOUT_YOU_COPY).toContain('Legacy about copy'); + expect(env.HEAR_ABOUT_US_OPTIONS).toEqual(['Legacy friend', 'Legacy poster']); + + await openJoinForm(page); + await expect(page.getByRole('heading', { name: 'Legacy about you' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Legacy contact details' })).toBeVisible(); + await expect(page.locator('.progress-steps h6')).toHaveText('Legacy sidebar'); + await expect(page.locator('.progress-steps')).toContainText('Legacy stage'); + }); +}); diff --git a/packages/join-e2e/tests/settings/fixtures.ts b/packages/join-e2e/tests/settings/fixtures.ts index ebed372..0124bb8 100644 --- a/packages/join-e2e/tests/settings/fixtures.ts +++ b/packages/join-e2e/tests/settings/fixtures.ts @@ -53,9 +53,20 @@ export const test = base.extend<{ settings: JoinSettingsPage; baseline: void }, export { expect }; -/** Opens a join form as a visitor, with the form's REST calls stubbed. */ +/** + * Opens a join form at its first step as a visitor, with the form's REST + * calls stubbed. The form remembers progress in the browser tab (session + * storage, and the history entry, which reopening the same URL keeps), so + * both are cleared first. + */ export async function openJoinForm(page: Page, url = STANDARD_JOIN_PAGE): Promise { await mockRestEndpoints(page); + if (page.url().startsWith('http')) { + await page.evaluate(() => { + sessionStorage.clear(); + history.replaceState(null, ''); + }); + } await page.goto(url); await page.waitForSelector('input#firstName'); } diff --git a/packages/join-e2e/tests/settings/legacy/copy.json b/packages/join-e2e/tests/settings/legacy/copy.json new file mode 100644 index 0000000..2cf10ca --- /dev/null +++ b/packages/join-e2e/tests/settings/legacy/copy.json @@ -0,0 +1,26 @@ +{ + "_organisation_name": "Legacy Union", + "_organisation_bank_name": "LEGACYUNION", + "_organisation_email_address": "members@legacy-union.example", + "_about_you_heading": "Legacy about you", + "_about_you_copy": "Legacy about copy", + "_date_of_birth_heading": "Legacy birthday", + "_date_of_birth_copy": "Legacy date of birth copy", + "_password_purpose": "Legacy password purpose", + "_home_address_copy": "Legacy home address copy", + "_custom_fields_heading": "Legacy more about you", + "_contact_details_heading": "Legacy contact details", + "_contact_details_copy": "Legacy contact details copy", + "_contact_consent_copy": "Legacy contact consent copy", + "_hear_about_us_heading": "Legacy heard about us", + "_hear_about_us_options": "Legacy friend,Legacy poster", + "_hear_about_us_details": "Legacy details", + "_privacy_copy": "Legacy privacy copy", + "_membership_tiers_heading": "Legacy tiers heading", + "_membership_tiers_copy": "Legacy tiers copy", + "_currency_selection_copy": "Legacy currency copy", + "_subscription_day_of_month_copy": "Legacy day of month", + "_join_form_sidebar_heading": "Legacy sidebar", + "_membership_stage_label": "Legacy stage", + "_joining_verb": "Legacy joining" +} diff --git a/packages/join-e2e/tests/settings/legacy/theme.json b/packages/join-e2e/tests/settings/legacy/theme.json new file mode 100644 index 0000000..6be240f --- /dev/null +++ b/packages/join-e2e/tests/settings/legacy/theme.json @@ -0,0 +1,6 @@ +{ + "_theme_primary_color": "#336699", + "_theme_gray_color": "#AABBCC", + "_theme_background_color": "#FAFAFA", + "_custom_css": ".ck-join-flow { outline: 3px solid rgb(255, 0, 0); }" +} diff --git a/packages/join-e2e/tests/settings/support.ts b/packages/join-e2e/tests/settings/support.ts index caca10b..0baeae7 100644 --- a/packages/join-e2e/tests/settings/support.ts +++ b/packages/join-e2e/tests/settings/support.ts @@ -181,6 +181,37 @@ export class JoinSettingsPage { await this.field(label).fill(value); } + /** Sets a colour field to a hex colour, e.g. '#336699', through its colour picker. */ + async setColor(label: string, hex: string): Promise { + const field = this.page.locator('.cf-field.cf-color').filter({ + has: this.page.locator('.cf-field__label', { hasText: new RegExp(`^${label}$`) }), + }); + await field.locator('.cf-color__toggle').click(); + const hexInput = field.locator('.sketch-picker input').first(); + // Don't press Enter here: it submits the whole settings form. + await hexInput.fill(hex.replace('#', '')); + // Clicking outside closes the picker. + await field.locator('.cf-field__label').click(); + await expect(this.page.locator('.cf-color__picker')).toHaveCount(0); + await expect(field.locator('input[type="hidden"]')).toHaveValue(new RegExp(`^${hex}$`, 'i')); + } + + /** Sets a rich text (editor) field's content to the given HTML. */ + async setRichText(label: string, html: string): Promise { + const editorId = await this.page + .locator('.cf-container__fields .cf-field__label', { hasText: new RegExp(`^${label}$`) }) + .getAttribute('for'); + await this.page.waitForFunction((id) => (window as any).tinymce?.get(id), editorId); + await this.page.evaluate( + ({ id, content }) => { + const editor = (window as any).tinymce.get(id); + editor.setContent(content); + editor.fire('change'); + }, + { id: editorId, content: html }, + ); + } + /** * Ticks an integration switch on the Features tab and fills in the * credentials it needs on the Integrations tab (INTEGRATION_CREDENTIALS). diff --git a/packages/join-e2e/tests/settings/theme.spec.ts b/packages/join-e2e/tests/settings/theme.spec.ts new file mode 100644 index 0000000..1b34b07 --- /dev/null +++ b/packages/join-e2e/tests/settings/theme.spec.ts @@ -0,0 +1,88 @@ +import { Page } from '@playwright/test'; +import { CONTINUE } from '../helpers'; +import { expect, getSettings, loadLegacySettings, openJoinForm, test } from './fixtures'; + +/** + * Settings page, Theme tab: the join form's colours and custom CSS. + * + * The plugin writes the colours as CSS variables on the join form's page, and + * the form's stylesheet uses them. + */ + +/** A CSS custom property's value on the page's root element. */ +const cssVariable = (page: Page, name: string) => + page.evaluate((property) => getComputedStyle(document.documentElement).getPropertyValue(property).trim(), name); + +/** A computed style property of the first element matching `selector`. */ +const computed = (page: Page, selector: string, property: string) => + page.locator(selector).first().evaluate((element, name) => getComputedStyle(element).getPropertyValue(name), property); + +test('the colours have defaults before anything is saved', async ({ page }) => { + expect(getSettings(['theme_primary_color', 'theme_gray_color', 'theme_background_color'])).toEqual({ + theme_primary_color: '#007bff', + theme_gray_color: '#dfdcda', + theme_background_color: '#f4f1ee', + }); + + await openJoinForm(page); + expect(await cssVariable(page, '--ck-join-form-primary-color')).toBe('#007bff'); + expect(await cssVariable(page, '--ck-join-form-gray-color')).toBe('#dfdcda'); + expect(await cssVariable(page, '--ck-join-form-background-color')).toBe('#f4f1ee'); +}); + +test('Primary Color colours the form\'s buttons', async ({ settings, page }) => { + await settings.open('Theme'); + await settings.setColor('Primary Color', '#336699'); + await settings.save(); + + expect(getSettings(['theme_primary_color']).theme_primary_color).toMatch(/^#336699$/i); + + await openJoinForm(page); + expect((await cssVariable(page, '--ck-join-form-primary-color')).toLowerCase()).toBe('#336699'); + expect(await computed(page, CONTINUE, 'background-color')).toBe('rgb(51, 102, 153)'); +}); + +test('Gray Color and Background Color reach the form\'s styles', async ({ settings, page }) => { + await settings.open('Theme'); + await settings.setColor('Gray Color', '#aabbcc'); + await settings.setColor('Background Color', '#fafafa'); + await settings.save(); + + const saved = getSettings(['theme_gray_color', 'theme_background_color']); + expect(saved.theme_gray_color).toMatch(/^#aabbcc$/i); + expect(saved.theme_background_color).toMatch(/^#fafafa$/i); + + await openJoinForm(page); + expect((await cssVariable(page, '--ck-join-form-gray-color')).toLowerCase()).toBe('#aabbcc'); + expect((await cssVariable(page, '--ck-join-form-background-color')).toLowerCase()).toBe('#fafafa'); +}); + +test('Custom Css is added to the join form\'s page', async ({ settings, page }) => { + const css = '.ck-join-flow { outline: 3px solid rgb(255, 0, 0); }'; + await settings.open('Theme'); + await settings.setText('Custom Css', css); + await settings.save(); + + expect(getSettings(['custom_css']).custom_css).toBe(css); + + await openJoinForm(page); + expect(await computed(page, '.ck-join-flow', 'outline')).toBe('rgb(255, 0, 0) solid 3px'); +}); + +test.describe('Theme saved before the settings page was rebuilt', () => { + // See tests/settings/legacy/README.md. + test('is still read and still styles the form', async ({ page }) => { + loadLegacySettings('theme'); + + expect(getSettings(['theme_primary_color', 'theme_gray_color', 'theme_background_color', 'custom_css'])).toEqual({ + theme_primary_color: '#336699', + theme_gray_color: '#AABBCC', + theme_background_color: '#FAFAFA', + custom_css: '.ck-join-flow { outline: 3px solid rgb(255, 0, 0); }', + }); + + await openJoinForm(page); + expect(await computed(page, CONTINUE, 'background-color')).toBe('rgb(51, 102, 153)'); + expect(await computed(page, '.ck-join-flow', 'outline')).toBe('rgb(255, 0, 0) solid 3px'); + }); +}); From 4bd4c72ec41475c73d973eda4a06f6e931336bbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 15:48:07 +0000 Subject: [PATCH 2/2] Address review on the Theme and Copy tests - The Currency Selection Copy test used a page slug that only worked via WordPress's 404 guessing; the join form page constants now live in support.ts and multi-currency-plans.spec.ts shares them. - JoinSettingsPage matches labels exactly, escaping pattern characters, so labels like '"How did you hear about us?" heading' work. - Every Copy setting's default is checked, and every rich text setting is read back through Settings::get, not only checked on the form. - The legacy Copy test checks every row it loads; loadLegacySettings returns them. - Gray Color and Background Color are checked on the elements that use them, in the legacy test too. - The join form's REST stubs are registered once per page, and the join request clickJoin holds open is let go after each test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL2bFrTk84JMy3CPAsCTV9 --- docs/e2e-testing.md | 2 +- .../tests/multi-currency-plans.spec.ts | 2 +- packages/join-e2e/tests/settings/copy.spec.ts | 98 ++++++++++++++++--- packages/join-e2e/tests/settings/fixtures.ts | 8 +- packages/join-e2e/tests/settings/support.ts | 26 +++-- .../join-e2e/tests/settings/theme.spec.ts | 8 ++ 6 files changed, 121 insertions(+), 23 deletions(-) diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 913f8f4..ed5ff6f 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -79,7 +79,7 @@ The join form specs (the table below) are listed with their test counts. The set | Features | `features.spec.ts`, `features-integrations.spec.ts` | Every setting. Integration switches: refused without credentials, saved with them, payment-step and form effects. Use Stripe sets global plans up in Stripe on save (fake API). Known problem: JOIN-161 (a rejected Stripe key crashes the save). | | Membership Plans | | Not yet | | Theme | `theme.spec.ts` | Every setting: colour defaults, each colour reaching the form's styles (the primary colour on its buttons), custom CSS applied. | -| Copy | `copy.spec.ts` | Every setting: defaults and fallbacks, and where each shows (details, plan and bank-details steps, confirmation step, sidebar). Known problems: JOIN-162 (Home Address Copy never shows), JOIN-163 (day-of-month copy unused), JOIN-164 (organisation details marked required but not enforced). | +| Copy | `copy.spec.ts` | Every setting: each one's default (or none), the fallbacks for blank sidebar, stage label and joining verb, and where each shows (details, plan and bank-details steps, confirmation step, sidebar). Known problems: JOIN-162 (Home Address Copy never shows), JOIN-163 (day-of-month copy unused), JOIN-164 (organisation details marked required but not enforced). | | Integrations | | Not yet. What Mailchimp, Action Network and Zetkin receive on a join will be covered here. | | Logging | | Not yet | | Debug | | Not yet | diff --git a/packages/join-e2e/tests/multi-currency-plans.spec.ts b/packages/join-e2e/tests/multi-currency-plans.spec.ts index 6db2b05..690c4d0 100644 --- a/packages/join-e2e/tests/multi-currency-plans.spec.ts +++ b/packages/join-e2e/tests/multi-currency-plans.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { CONTINUE, mockRestEndpoints } from './helpers'; +import { MULTI_CURRENCY_PAGE } from './settings/support'; /** * Multi-currency plan selection @@ -17,7 +18,6 @@ import { CONTINUE, mockRestEndpoints } from './helpers'; * - "Standard" EUR €7/month (id: standard-eur) */ -const MULTI_CURRENCY_PAGE = '/e2e-multi-currency-join/'; async function advanceToPlanStep(page: import('@playwright/test').Page) { await mockRestEndpoints(page); diff --git a/packages/join-e2e/tests/settings/copy.spec.ts b/packages/join-e2e/tests/settings/copy.spec.ts index 926ef46..6da99a7 100644 --- a/packages/join-e2e/tests/settings/copy.spec.ts +++ b/packages/join-e2e/tests/settings/copy.spec.ts @@ -1,7 +1,10 @@ import { Page } from '@playwright/test'; import { CONTINUE } from '../helpers'; import { + CUSTOM_FIELDS_PAGE, FREE_JOIN_PAGE, + JoinSettingsPage, + MULTI_CURRENCY_PAGE, STANDARD_JOIN_PAGE, expect, getSetting, @@ -19,9 +22,6 @@ import { * through the settings page, and where it shows on the join form. */ -const CUSTOM_FIELDS_PAGE = '/e2e-custom-fields/'; -const MULTI_CURRENCY_PAGE = '/e2e-multi-currency/'; - const details = (page: Page) => page.locator('form.form-content').first(); /** From the details step to the plan step. */ @@ -36,7 +36,7 @@ async function goToPlans(page: Page, url = STANDARD_JOIN_PAGE) { * form), the route by which a Direct Debit member reaches the confirmation * step. Leaves the settings page unsaved. */ -async function useGoCardlessCustomPages(settings: import('./support').JoinSettingsPage) { +async function useGoCardlessCustomPages(settings: JoinSettingsPage) { await settings.open('Features'); await settings.enableIntegration('Use GoCardless'); await settings.setCheckbox('Use GoCardless Custom Pages', true); @@ -57,6 +57,11 @@ async function goToConfirmStep(page: Page) { await expect(page.getByText('Confirm your details')).toBeVisible(); } +// clickJoin holds the join request open; let it go when the test is done. +test.afterEach(async ({ page }) => { + await page.unrouteAll({ behavior: 'ignoreErrors' }); +}); + /** Clicks the confirmation step's join button, holding the join request open. */ async function clickJoin(page: Page, buttonText: string) { await page.route('**/wp-json/join/v1/join', () => { @@ -65,6 +70,57 @@ async function clickJoin(page: Page, buttonText: string) { await page.getByRole('button', { name: buttonText }).click(); } +test('every Copy setting has its default before anything is saved', () => { + expect( + getSettings([ + 'about_you_heading', + 'about_you_copy', + 'date_of_birth_heading', + 'date_of_birth_copy', + 'password_purpose', + 'home_address_copy', + 'custom_fields_heading', + 'contact_details_heading', + 'contact_details_copy', + 'contact_consent_copy', + 'hear_about_us_heading', + 'hear_about_us_options', + 'hear_about_us_details', + 'privacy_copy', + 'membership_tiers_heading', + 'membership_tiers_copy', + 'currency_selection_copy', + 'subscription_day_of_month_copy', + 'join_form_sidebar_heading', + 'membership_stage_label', + 'joining_verb', + ]), + ).toEqual({ + about_you_heading: 'Tell us about you', + about_you_copy: 'All fields marked with an asterisk (*) are required.', + date_of_birth_heading: 'Date of birth', + date_of_birth_copy: "We collect every member's date of birth because our membership types are based on age.", + password_purpose: '', + home_address_copy: '', + custom_fields_heading: 'More about you', + contact_details_heading: 'Contact details', + contact_details_copy: 'We’ll use this to keep in touch about things that matter to you.', + contact_consent_copy: 'How would you like us to contact you?', + hear_about_us_heading: 'How did you hear about us?', + hear_about_us_options: 'From another member, An email from us, Social media, Press/radio, TV, Other', + hear_about_us_details: 'Further information', + privacy_copy: '', + membership_tiers_heading: "Choose the plan that's right for you", + membership_tiers_copy: '', + currency_selection_copy: '', + subscription_day_of_month_copy: 'Day of month to take payment', + join_form_sidebar_heading: 'Join Us', + membership_stage_label: 'Your Membership', + joining_verb: 'Joining', + }); + // The organisation details have no defaults; the seed sets them (scripts/setup.php). +}); + test.describe('Organisation details', () => { // Linear JOIN-164: marked required (*), but a blank value saves. test.fixme('are required', async ({ settings }) => { @@ -152,7 +208,9 @@ test.describe('Details step', () => { await settings.setRichText('Date Of Birth Copy', '

We ask so we can check your age.

'); await settings.save(); - expect(getSettings(['date_of_birth_heading'])).toEqual({ date_of_birth_heading: 'Your birthday' }); + const saved = getSettings(['date_of_birth_heading', 'date_of_birth_copy']); + expect(saved.date_of_birth_heading).toBe('Your birthday'); + expect(saved.date_of_birth_copy).toContain('We ask so we can check your age.'); await openJoinForm(page); await expect(page.getByRole('heading', { name: 'Your birthday' })).toBeVisible(); @@ -211,7 +269,9 @@ test.describe('Details step', () => { await settings.setRichText('Contact Details Copy', '

We only use these for membership.

'); await settings.save(); - expect(getSetting('contact_details_heading')).toBe('How to reach you'); + const saved = getSettings(['contact_details_heading', 'contact_details_copy']); + expect(saved.contact_details_heading).toBe('How to reach you'); + expect(saved.contact_details_copy).toContain('We only use these for membership.'); await openJoinForm(page); await expect(page.getByRole('heading', { name: 'How to reach you' })).toBeVisible(); @@ -225,6 +285,8 @@ test.describe('Details step', () => { await settings.setRichText('Contact Consent Copy', '

Tick how we may contact you.

'); await settings.save(); + expect(getSetting('contact_consent_copy')).toContain('Tick how we may contact you.'); + await openJoinForm(page); await expect(page.getByText('Tick how we may contact you.')).toBeVisible(); }); @@ -238,7 +300,11 @@ test.describe('Details step', () => { await settings.setText('"How did you hear about us?" additional details textbox label', 'Tell us more'); await settings.save(); - expect(getSetting('hear_about_us_options')).toBe(' A friend ,Social media,, A poster '); + expect(getSettings(['hear_about_us_heading', 'hear_about_us_options', 'hear_about_us_details'])).toEqual({ + hear_about_us_heading: 'Where did you find us?', + hear_about_us_options: ' A friend ,Social media,, A poster ', + hear_about_us_details: 'Tell us more', + }); // Trimmed, and empty entries dropped. expect((await joinFormEnv(page)).HEAR_ABOUT_US_OPTIONS).toEqual(['A friend', 'Social media', 'A poster']); @@ -261,6 +327,8 @@ test.describe('Details step', () => { await settings.setRichText('Privacy Copy', '

Read our privacy policy.

'); await settings.save(); + expect(getSetting('privacy_copy')).toContain('privacy policy'); + await openJoinForm(page); await expect(page.getByRole('link', { name: 'privacy policy' })).toHaveAttribute('href', 'https://example.org/privacy'); }); @@ -277,6 +345,10 @@ test.describe('Plan step', () => { await settings.setRichText('Membership Tiers Copy', '

All plans include voting rights.

'); await settings.save(); + const saved = getSettings(['membership_tiers_heading', 'membership_tiers_copy']); + expect(saved.membership_tiers_heading).toBe('Pick a plan'); + expect(saved.membership_tiers_copy).toContain('All plans include voting rights.'); + await goToPlans(page); await expect(page.getByRole('heading', { name: 'Pick a plan' })).toBeVisible(); await expect(page.getByText('All plans include voting rights.')).toBeVisible(); @@ -288,6 +360,8 @@ test.describe('Plan step', () => { await settings.setRichText('Currency Selection Copy', '

Pay in the currency of your bank account.

'); await settings.save(); + expect(getSetting('currency_selection_copy')).toContain('Pay in the currency of your bank account.'); + await goToPlans(page, MULTI_CURRENCY_PAGE); await expect(page.getByText('Pay in the currency of your bank account.')).toBeVisible(); @@ -343,13 +417,11 @@ test.describe('Sidebar and steps', () => { test.describe('Copy saved before the settings page was rebuilt', () => { // See tests/settings/legacy/README.md. test('is still read and still shows on the form', async ({ page }) => { - loadLegacySettings('copy'); + const legacy = loadLegacySettings('copy'); - expect(getSettings(['organisation_name', 'about_you_heading', 'hear_about_us_options'])).toEqual({ - organisation_name: 'Legacy Union', - about_you_heading: 'Legacy about you', - hear_about_us_options: 'Legacy friend,Legacy poster', - }); + // Every row, read back by its setting's key (the row name without the "_"). + const expected = Object.fromEntries(Object.entries(legacy).map(([option, value]) => [option.slice(1), value])); + expect(getSettings(Object.keys(expected))).toEqual(expected); const env = await joinFormEnv(page); expect(env.ABOUT_YOU_COPY).toContain('Legacy about copy'); diff --git a/packages/join-e2e/tests/settings/fixtures.ts b/packages/join-e2e/tests/settings/fixtures.ts index e85af4c..b183643 100644 --- a/packages/join-e2e/tests/settings/fixtures.ts +++ b/packages/join-e2e/tests/settings/fixtures.ts @@ -57,6 +57,9 @@ export const test = base.extend<{ settings: JoinSettingsPage; baseline: void }, export { expect }; +/** Pages whose join form REST calls are already stubbed (stub them once each). */ +const pagesWithStubs = new WeakSet(); + /** * Opens a join form at its first step as a visitor, with the form's REST * calls stubbed. The form remembers progress in the browser tab (session @@ -64,7 +67,10 @@ export { expect }; * both are cleared first. */ export async function openJoinForm(page: Page, url = STANDARD_JOIN_PAGE): Promise { - await mockRestEndpoints(page); + if (!pagesWithStubs.has(page)) { + await mockRestEndpoints(page); + pagesWithStubs.add(page); + } if (page.url().startsWith('http')) { await page.evaluate(() => { sessionStorage.clear(); diff --git a/packages/join-e2e/tests/settings/support.ts b/packages/join-e2e/tests/settings/support.ts index 527d535..16e7799 100644 --- a/packages/join-e2e/tests/settings/support.ts +++ b/packages/join-e2e/tests/settings/support.ts @@ -27,6 +27,13 @@ const OPTIONS_SNAPSHOT = '/var/www/html/wp-content/e2e-options-snapshot.sql'; export const STANDARD_JOIN_PAGE = '/e2e-standard-join/'; export const FREE_JOIN_PAGE = '/e2e-free-join/'; +/** A join form with one custom field, so it shows the "more about you" section. */ +export const CUSTOM_FIELDS_PAGE = '/e2e-custom-fields/'; +/** A join form offering the same plan in GBP and EUR. */ +export const MULTI_CURRENCY_PAGE = '/e2e-multi-currency-join/'; + +/** A pattern matching exactly `text`, with any pattern characters in it escaped. */ +const exactly = (text: string) => new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); /** Runs WP-CLI against the E2E site and returns its standard output. */ export function wp(...args: string[]): string { @@ -104,13 +111,18 @@ export function setBaselinePlan(): void { * page was rebuilt (see tests/settings/legacy), to check that existing sites * keep working. */ -export function loadRawOptions(options: Record): void { +export function loadRawOptions(options: Record): void { wp('eval-file', '/var/www/html/wp-content/e2e-scripts/load-raw-options.php', JSON.stringify(options)); } -/** Loads tests/settings/legacy/.json: settings as Carbon Fields stored them. */ -export function loadLegacySettings(name: string): void { - loadRawOptions(JSON.parse(readFileSync(path.join(__dirname, 'legacy', `${name}.json`), 'utf8'))); +/** + * Loads tests/settings/legacy/.json, settings as Carbon Fields stored + * them, and returns the rows it loaded. + */ +export function loadLegacySettings(name: string): Record { + const options = JSON.parse(readFileSync(path.join(__dirname, 'legacy', `${name}.json`), 'utf8')); + loadRawOptions(options); + return options; } /** The settings the plugin hands the join form: the page's