diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index b4e35f7..0380db4 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -49,7 +49,7 @@ The setup script (`scripts/setup.php`) creates these WordPress pages: ## What is covered -The suite currently contains 78 tests across 11 spec files (one skipped pending JOIN-158). +The join form specs (the table below) are listed with their test counts. The settings page specs in `tests/settings/` are covered in "Settings page coverage" below. Run `npx playwright test --list` in `packages/join-e2e` for the current total. | Spec file | Area | Tests | What it verifies | |-----------|------|------:|------------------| @@ -69,6 +69,20 @@ The suite currently contains 78 tests across 11 spec files (one skipped pending - **Validation** for required fields and email format is exercised. +## Settings page coverage + +`tests/settings/` tests the settings on WP Admin > Join: each is saved (read back through `Settings::get`), has its effect, and still works when loaded as Carbon Fields stored it. The readme ("Settings page regression tests") explains how these tests are built. Known problems are `test.fixme` tests linked to Linear issues. + +| Tab | Spec | Covered | +|---|---|---| +| 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 | +| Integrations | | Not yet. What Mailchimp, Action Network and Zetkin receive on a join will be covered here. | +| Logging | | Not yet | +| Debug | | Not yet | + ## What is not covered ### Payment UI rendering @@ -89,16 +103,11 @@ No tests. When `IS_UPDATE_FLOW=true`, personal details are skipped and the form ### Payment method selection page -The `payment-method` step (choosing between Direct Debit and Card when multiple providers are enabled) is never exercised. Tests either mock everything or bypass payment. +Now partly covered by the settings tests: `tests/settings/features-integrations.spec.ts` checks the step appears, with both methods, when GoCardless and Chargebee are both on. Choosing a method and going on from there is still untested. ### Optional data collection flags -The following flags control whether additional fields appear on the details page, but none are tested: - -- `COLLECT_DATE_OF_BIRTH` -- `COLLECT_COUNTY` -- `COLLECT_HEAR_ABOUT_US` -- `COLLECT_PHONE_AND_EMAIL_CONTACT_CONSENT` +Now covered by the settings tests (`tests/settings/features.spec.ts`). ### Custom fields diff --git a/packages/join-e2e/docker-compose.yml b/packages/join-e2e/docker-compose.yml index cd7547b..a8c6596 100644 --- a/packages/join-e2e/docker-compose.yml +++ b/packages/join-e2e/docker-compose.yml @@ -9,6 +9,15 @@ x-wordpress-volumes: &wordpress-volumes - wordpress:/var/www/html - ../join-block:/var/www/html/wp-content/plugins/join-block - ./scripts:/var/www/html/wp-content/e2e-scripts + # Trust the fake API's test CA: PHP's curl and streams, WordPress's own + # HTTP API (via a must-use plugin) and the Stripe and Chargebee SDKs each have their own CA + # setting. Nothing may be mounted over WordPress's core files: the image + # copies them into the volume on first start and fails if one is in the way. + - ./fake-api/certs/ca.crt:/usr/local/share/e2e-fake-api-ca.crt:ro + - ./fake-api/php-ca.ini:/usr/local/etc/php/conf.d/zz-e2e-fake-api-ca.ini:ro + - ./fake-api/e2e-fake-api-ca.php:/var/www/html/wp-content/mu-plugins/e2e-fake-api-ca.php:ro + - ./fake-api/certs/ca.crt:/var/www/html/wp-content/plugins/join-block/vendor/stripe/stripe-php/data/ca-certificates.crt:ro + - ./fake-api/certs/ca.crt:/var/www/html/wp-content/plugins/join-block/vendor/chargebee/chargebee-php/lib/ssl/ca-certs.crt:ro x-wordpress-environment: &wordpress-environment WORDPRESS_DB_HOST: db @@ -17,6 +26,38 @@ x-wordpress-environment: &wordpress-environment WORDPRESS_DB_NAME: wordpress services: + # Stands in for every third-party API the plugin calls. Inside this network + # the aliases below resolve here instead of to the real services. Tests talk + # to its control API on localhost:8890 (tests/settings/fake-api.ts). + fake-api: + image: node:22.23.4-alpine + command: ['node', '/app/server.js'] + volumes: + - ./fake-api:/app:ro + ports: + - '8890:8080' + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:8080/health'] + interval: 2s + timeout: 5s + retries: 30 + networks: + default: + # Keep in step with fake-api/certs/san.cnf. + aliases: + - api.stripe.com + - actionnetwork.org + - api.zetk.in + - api.dev.zetkin.org + - us1.api.mailchimp.com + - e2e.eu.auth0.com + - api.gocardless.com + - api-sandbox.gocardless.com + - e2e.chargebee.com + - api.postcodes.io + - api.getaddress.io + - api.ideal-postcodes.co.uk + db: image: mariadb:11.4 environment: @@ -35,6 +76,8 @@ services: depends_on: db: condition: service_healthy + fake-api: + condition: service_healthy ports: - '8889:80' volumes: *wordpress-volumes @@ -45,10 +88,12 @@ services: define('WP_DEBUG_LOG', true); define('SCRIPT_DEBUG', true); - # One-off WP-CLI runs: docker compose run --rm cli wp + # WP-CLI, kept running so commands start quickly: + # docker compose exec cli wp (or scripts/env.sh wp ) cli: image: wordpress:cli-2.12.0-php8.2 - profiles: [cli] + command: ['sleep', 'infinity'] + init: true user: '33:33' depends_on: - wordpress diff --git a/packages/join-e2e/fake-api/certs/ca.crt b/packages/join-e2e/fake-api/certs/ca.crt new file mode 100644 index 0000000..e19edcf --- /dev/null +++ b/packages/join-e2e/fake-api/certs/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDPzCCAiegAwIBAgIUL1NIuEmpA0RWqABA149MgtVN6S8wDQYJKoZIhvcNAQEL +BQAwLjEsMCoGA1UEAwwjSm9pbiBFMkUgdGVzdCBDQSAobm90IGZvciByZWFsIHVz +ZSkwIBcNMjYxMDIzMTc1NDA2WhgPMjEyNjA5MjkxNzU0MDZaMC4xLDAqBgNVBAMM +I0pvaW4gRTJFIHRlc3QgQ0EgKG5vdCBmb3IgcmVhbCB1c2UpMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5MZLGxuo1wv3q/KZlHPYs1msn5HP9/MS93px +sfubWlrPxW0CWIxTMtv98kyeNggsQ6pqbFdpLSpDrMI2L4+OTYwTwV+LrzV4neSm +GXHVAqB93QLaHLQwdWdjiGJ8vaX069c8LzNvnZb8KVf7MtLWX2MsRNYJtXCCtxuT +wV3RZDQs4TNOqGikoyRg/JNCxkgBPHNSm7ptCj3rj2rbQGDM7nu1skBzH3L6xKiA +QGiYb+vNDWcdK4I38cA8BbQdS+DXvHIf+Y6Qlq8h6esI7a8pnbhm60RIHNvsEdFq +qPFPowydKT8RyjABZHnajyYXyv3PGeGjXC/hRRTsaJg54GUxiwIDAQABo1MwUTAd +BgNVHQ4EFgQU6xlodRtSMB1hTbE+cfkyMBWT2QUwHwYDVR0jBBgwFoAU6xlodRtS +MB1hTbE+cfkyMBWT2QUwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEAGjcNh/YqYwRw5w+ilPybR9DHl0OG7/k7VXrGRqml0QvbDD1h4jR4Hqz2AwXr +HFt6LdnXOvlVk+u6t6FvNflFu059sNeJx7b5FRBoTvrjNZfFehoKD5NkuamGGWdk +XIEeG0NIBaYF4DYu9qK1jKL1DcpM4lFOfy55pcNEqyBEyULmpJVYhBAsddb4gtuU +oZHM/rZdp/k463E53SHUPMD3arh3CDcq+JLSr3uDs7nRex7HfY0U7DFd4DlcuuH/ +oWnAGLOcb17zzahyAdsmCetiGuzAp7SU7p6DBLnT3Yr5i/CjhWKoKCDqAIaybIgw +dIdD1NBq1DZNpTYgPl/dvaa5fg== +-----END CERTIFICATE----- diff --git a/packages/join-e2e/fake-api/certs/generate.sh b/packages/join-e2e/fake-api/certs/generate.sh new file mode 100644 index 0000000..013f30e --- /dev/null +++ b/packages/join-e2e/fake-api/certs/generate.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Regenerates the fake API's test CA and server certificate. +# +# Only needed when adding a hostname: add it to san.cnf, run this, and add the +# same hostname to the fake-api network aliases in ../../docker-compose.yml. +# The CA key is deleted afterwards, so these files can't sign anything else. +# Test-only: trusted only inside the E2E Docker network. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 36500 \ + -subj "/CN=Join E2E test CA (not for real use)" +openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr \ + -subj "/CN=fake-api" -config san.cnf +openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out server.crt -days 36500 -extfile san.cnf -extensions ext +rm -f server.csr ca.srl ca.key diff --git a/packages/join-e2e/fake-api/certs/san.cnf b/packages/join-e2e/fake-api/certs/san.cnf new file mode 100644 index 0000000..f410223 --- /dev/null +++ b/packages/join-e2e/fake-api/certs/san.cnf @@ -0,0 +1,22 @@ +[req] +distinguished_name = dn +[dn] +[ext] +subjectAltName = @alt +basicConstraints = CA:FALSE +keyUsage = digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +[alt] +DNS.1 = api.stripe.com +DNS.2 = actionnetwork.org +DNS.3 = api.zetk.in +DNS.4 = api.dev.zetkin.org +DNS.5 = us1.api.mailchimp.com +DNS.6 = e2e.eu.auth0.com +DNS.7 = api.gocardless.com +DNS.8 = api-sandbox.gocardless.com +DNS.9 = e2e.chargebee.com +DNS.10 = api.postcodes.io +DNS.11 = api.getaddress.io +DNS.12 = api.ideal-postcodes.co.uk +DNS.13 = fake-api diff --git a/packages/join-e2e/fake-api/certs/server.crt b/packages/join-e2e/fake-api/certs/server.crt new file mode 100644 index 0000000..667cb04 --- /dev/null +++ b/packages/join-e2e/fake-api/certs/server.crt @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIETTCCAzWgAwIBAgIUcBvmd6AYMCx4hAsRuJYvExc18BUwDQYJKoZIhvcNAQEL +BQAwLjEsMCoGA1UEAwwjSm9pbiBFMkUgdGVzdCBDQSAobm90IGZvciByZWFsIHVz +ZSkwIBcNMjYxMDIzMTc1NDA2WhgPMjEyNjA5MjkxNzU0MDZaMBMxETAPBgNVBAMM +CGZha2UtYXBpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAreVk2RGX +irbBcE/J6YDanGGDwf404Lh9ni6Akw2zEANeVLxqvKiyi5l8wK1q+ah3J2jqo/LU +KrD65ZuhscZW5V+t9w/KqTkOE/4O76s3wJB8Y4sQcUuW4XtV/Wd028gZORu/DMp9 +A1lK7ysqtuu/6K1D1/8evI6LsMNjrQshh3FXcJiUhcrfgrmM8lWVQE652KJIeRTi +iKQm9Xiwo8+Pj632bbmTfvCA6yLHrhOaLS9OB14XSjzzXSoxqLTLP9UTA6GvMLGi +FIlzl/ch8ZlnI9t/37JiEhBY2QzaEymKl2yLNcgG2YCQx71T8xAuuwu7Yy2E/A4k +C2o/LQcp2iJPowIDAQABo4IBejCCAXYwggEFBgNVHREEgf0wgfqCDmFwaS5zdHJp +cGUuY29tghFhY3Rpb25uZXR3b3JrLm9yZ4ILYXBpLnpldGsuaW6CEmFwaS5kZXYu +emV0a2luLm9yZ4IVdXMxLmFwaS5tYWlsY2hpbXAuY29tghBlMmUuZXUuYXV0aDAu +Y29tghJhcGkuZ29jYXJkbGVzcy5jb22CGmFwaS1zYW5kYm94LmdvY2FyZGxlc3Mu +Y29tghFlMmUuY2hhcmdlYmVlLmNvbYIQYXBpLnBvc3Rjb2Rlcy5pb4IRYXBpLmdl +dGFkZHJlc3MuaW+CGWFwaS5pZGVhbC1wb3N0Y29kZXMuY28udWuCCGZha2UtYXBp +MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB0G +A1UdDgQWBBRmOT+xWfG1qKOcrTEPx4PG5jjkwzAfBgNVHSMEGDAWgBTrGWh1G1Iw +HWFNsT5x+TIwFZPZBTANBgkqhkiG9w0BAQsFAAOCAQEAM0haZ0adhRNvYMHkNm3B +IH614SRIsOlFt2SUYi33DYIy0Im0Mk3BGozgx/zY2/8LUu6mon47aKjJJpP922ew +MzzswMoAM7FnKiN0DV9asfsmCH8OeePjFHde4VWU6elTQ2Tb5Lmg0Tqafaew4sB6 +xFe4usEl3AIaYtoZ6MULdsdNOXF42M4xlNp2l7uMcJT1IYQODxByJ1vd40k0q1Uz +uZx4qiRsF7HqV4q2/emjUu/67R871J5uM8aKxvRS2YLjuP2JruTOnjSQbJf1js60 +PcGGWPREVvveXSLJiwD9DpXHjApKTZjMj7lk5fcGYe+Ka0CDxmhHhewiGiblRjcJ +YA== +-----END CERTIFICATE----- diff --git a/packages/join-e2e/fake-api/certs/server.key b/packages/join-e2e/fake-api/certs/server.key new file mode 100644 index 0000000..2dffa2c --- /dev/null +++ b/packages/join-e2e/fake-api/certs/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCt5WTZEZeKtsFw +T8npgNqcYYPB/jTguH2eLoCTDbMQA15UvGq8qLKLmXzArWr5qHcnaOqj8tQqsPrl +m6GxxlblX633D8qpOQ4T/g7vqzfAkHxjixBxS5bhe1X9Z3TbyBk5G78Myn0DWUrv +Kyq267/orUPX/x68jouww2OtCyGHcVdwmJSFyt+CuYzyVZVATrnYokh5FOKIpCb1 +eLCjz4+PrfZtuZN+8IDrIseuE5otL04HXhdKPPNdKjGotMs/1RMDoa8wsaIUiXOX +9yHxmWcj23/fsmISEFjZDNoTKYqXbIs1yAbZgJDHvVPzEC67C7tjLYT8DiQLaj8t +BynaIk+jAgMBAAECggEASIz4vsGiroph+SgozuVmD7F1haW3zPvb2PeAyBmCmA83 +t3WmuJBPcYO3czHG/GMnV0dkAcPJdghBnTMJyoA799Wr4HRzspdtwrSP6blO+OWv +J964nPQS/n71GNSlgh6hO1zSoBUJBDEoXNFvMEL6XaqQMBkt6zAzoqtaNjN7KSTV +qfPyySMcnRJNHZGsSz8i4dekGZDUSwUV7u7kjmp2vlL5vo6T6tlY2JqHYRcm/qdl +GqI6JuH2fFmaLDlP2eyUu3Judqoq0PrwMgcEVz0Rtg7szEUcHrZg6O7YVVWVCyuS ++nC9yPtmbs1oyiXXQsKdItZiy5LwdzuGKXXrXw7WHQKBgQDe7ioZzHhQS39hg87o +YJQJdtlyTemgFywjtItWeX2Arq2n1nj4BioxJlvtEMXuFntYqUgJSOOB2m0n2k2v +duT7EUrON6+xcoHaGDaoT9qZHAzEr5OQb+dJmTUlWWDuU5xGGeBwFmIboO/2vNeS +E9/tqo9L2VU5P9WV2YPHH+pxxQKBgQDHsSQeVPSFzfLw6UvwiMrco8SdU15TOjFF +7hUzNESq/qo3WPuNiAiNDE8frWqVXzMRpbeYpujAFZd+XUONjI29z8CBTbcrZYjp +PKW1VxLn5gL1MCsGModdRnupzp8DVPvRuQhG1wgfqKLtP0wMvhLPkOpO24V5LokL +FRwnaP/aRwKBgQCIdHm6ruiaJYxD8T1OCgezP1dcWwp83pI7DMEZXmYOxV0wVdxX +3NYonZU5J8IvuP0pSPz6Ce6QRlX4Rey1UW5Zq70BOx7E3BVVnOqf3bktbPj3z4zg +pU6KQ0g/o1TsrKsEUcSKd3j39mvLuM2u8ZFo1bS+tbovv74J6kl6MjP8DQKBgH1B +q7oq58YWTNC4RZ327+g+AVH9vSjPU6DdmgaoPnpTi0l0UzWPom12zxq7mCNht5LW +M7dY2p3B7Wih74GWLEJnUgTJTp+17I8AL6DzoTQ8npZVRGd6wlXLpD37ENryL5J5 +Uau9rabODIRsM7O6deokqBQWtj4w5l1BPIur3PB5AoGAYSXUMSGROOwFsJBWMyV0 +Zkki7WTEqtkWD2WA4sggmxQw9jtbuiud558v6BT8RwbolUWICeNlfae0sxNNhMOV +9vQTeemYjZIDTaK8iJ7RdEqoLl27SjhmPsnXdn4+eunBEBUahs2U3QtAJ0SxLSlU +W8tmx1Coy7OXOWnP/jc1PoA= +-----END PRIVATE KEY----- diff --git a/packages/join-e2e/fake-api/e2e-fake-api-ca.php b/packages/join-e2e/fake-api/e2e-fake-api-ca.php new file mode 100644 index 0000000..192369b --- /dev/null +++ b/packages/join-e2e/fake-api/e2e-fake-api-ca.php @@ -0,0 +1,14 @@ + { + if (index === parts.length - 1) { + target[part] = value; + } else { + target[part] = target[part] || {}; + target = target[part]; + } + }); + } + return result; +} + +/** A tiny in-memory Stripe: enough for the plugin's product and price set-up. */ +function fakeStripe(method, url, body) { + const match = url.pathname.match(/^\/v1\/([a-z_]+)(?:\/([^/]+))?$/); + if (!match) return null; + const [, type, idOrAction] = match; + const store = stripe.objects[type]; + if (!store) return null; + const prefix = { products: 'prod', prices: 'price', customers: 'cus', subscriptions: 'sub' }[type]; + + if (method === 'GET' && idOrAction === 'search') { + const query = url.searchParams.get('query') || ''; + // e.g. active:'true' AND metadata['membership_plan']:'standard_monthly_gbp' + const conditions = [...query.matchAll(/(metadata\['([^']+)'\]|[a-z_]+):'([^']*)'/g)].map( + ([, field, metadataKey, value]) => ({ field, metadataKey, value }), + ); + const data = store.filter((object) => + conditions.every(({ field, metadataKey, value }) => { + if (metadataKey) return (object.metadata || {})[metadataKey] === value; + if (field === 'active') return String(object.active) === value; + if (field === 'type') return (object.recurring ? 'recurring' : 'one_time') === value; + return String(object[field]) === value; + }), + ); + return { status: 200, body: { object: 'search_result', url: url.pathname, has_more: false, data } }; + } + if (method === 'POST' && !idOrAction) { + stripe.counter += 1; + const object = { id: `${prefix}_e2e_${stripe.counter}`, object: type.replace(/s$/, ''), active: true, livemode: false, ...body }; + if (body && body.unit_amount) object.unit_amount = Number(body.unit_amount); + store.push(object); + return { status: 200, body: object }; + } + if (method === 'GET' && idOrAction) { + const object = store.find((candidate) => candidate.id === idOrAction); + return object ? { status: 200, body: object } : { status: 404, body: { error: { type: 'invalid_request_error', message: 'No such object' } } }; + } + return null; +} + +function respond(host, method, url, body) { + const override = overrides.find( + (rule) => + (!rule.host || rule.host === host) && + (!rule.method || rule.method === method) && + (!rule.pathPattern || rule.pathPattern.test(url.pathname)), + ); + if (override) return { status: override.status || 200, body: override.body ?? {} }; + if (host === 'api.stripe.com') { + const result = fakeStripe(method, url, body); + if (result) return result; + } + return { status: 200, body: {} }; +} + +function handleService(request, response) { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + const host = (request.headers.host || '').split(':')[0]; + const url = new URL(request.url, `https://${host}`); + const raw = Buffer.concat(chunks).toString('utf8'); + const body = parseBody(raw, request.headers['content-type']); + requests.push({ + time: new Date().toISOString(), + host, + method: request.method, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: request.headers, + body, + }); + const result = respond(host, request.method, url, body); + response.writeHead(result.status, { 'Content-Type': 'application/json' }); + response.end(typeof result.body === 'string' ? result.body : JSON.stringify(result.body)); + }); +} + +function handleControl(request, response) { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + const send = (status, body) => { + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(body)); + }; + // A bad request from a test gets a 400, rather than crashing the fake for + // every test after it. + const readJson = () => { + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch (error) { + send(400, { error: `invalid JSON: ${error.message}` }); + return undefined; + } + }; + if (request.method === 'GET' && request.url === '/requests') return send(200, requests); + if (request.method === 'POST' && request.url === '/reset') { + requests = []; + overrides = []; + stripe = freshStripe(); + return send(200, { ok: true }); + } + if (request.method === 'POST' && request.url === '/overrides') { + const rule = readJson(); + if (rule === undefined) return; + try { + rule.pathPattern = rule.path ? new RegExp(rule.path) : null; + } catch (error) { + return send(400, { error: `invalid path pattern: ${error.message}` }); + } + overrides.unshift(rule); + return send(200, { ok: true }); + } + if (request.method === 'GET' && request.url === '/health') return send(200, { ok: true }); + send(404, { error: 'unknown control endpoint' }); + }); +} + +const certs = path.join(__dirname, 'certs'); +https + .createServer({ key: fs.readFileSync(path.join(certs, 'server.key')), cert: fs.readFileSync(path.join(certs, 'server.crt')) }, handleService) + .listen(443); +http.createServer(handleService).listen(80); +http.createServer(handleControl).listen(8080); +console.log('Fake APIs listening on 443 and 80; control API on 8080'); diff --git a/packages/join-e2e/scripts/env.sh b/packages/join-e2e/scripts/env.sh index 2c8745d..0f88ce3 100755 --- a/packages/join-e2e/scripts/env.sh +++ b/packages/join-e2e/scripts/env.sh @@ -13,10 +13,12 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.." URL="http://localhost:8889" READY_TIMEOUT="${WP_READY_TIMEOUT:-120}" -# COMPOSE_PROGRESS rather than --progress: older Compose versions reject the -# flag but ignore the variable. wp() { - COMPOSE_PROGRESS=quiet docker compose run --rm --no-deps -T cli wp "$@" + if [ -z "$(docker compose ps --status running --quiet cli 2>/dev/null)" ]; then + echo "The E2E containers aren't running. Start them with: scripts/env.sh start" >&2 + exit 1 + fi + docker compose exec -T cli wp "$@" } # On first start the wordpress container copies WordPress into the shared diff --git a/packages/join-e2e/scripts/load-raw-options.php b/packages/join-e2e/scripts/load-raw-options.php new file mode 100644 index 0000000..ed0821d --- /dev/null +++ b/packages/join-e2e/scripts/load-raw-options.php @@ -0,0 +1,25 @@ + $value) { + update_option($name, $value); +} + +WP_CLI::success(count($options) . ' options written.'); diff --git a/packages/join-e2e/scripts/seed.sh b/packages/join-e2e/scripts/seed.sh index 48afd63..c49aa82 100755 --- a/packages/join-e2e/scripts/seed.sh +++ b/packages/join-e2e/scripts/seed.sh @@ -16,4 +16,8 @@ cd "${PACKAGE_DIR}" scripts/env.sh wp eval-file /var/www/html/wp-content/e2e-scripts/setup.php scripts/env.sh wp rewrite flush --hard +# The settings tests restore this after changing settings (tests/settings/support.ts). +echo "--- Snapshotting options ---" +scripts/env.sh wp db export /var/www/html/wp-content/e2e-options-snapshot.sql --tables="$(scripts/env.sh wp db prefix)options" + echo "--- Seed complete ---" diff --git a/packages/join-e2e/tests/admin-plan-redirect.spec.ts b/packages/join-e2e/tests/admin-plan-redirect.spec.ts index e592351..a00e46d 100644 --- a/packages/join-e2e/tests/admin-plan-redirect.spec.ts +++ b/packages/join-e2e/tests/admin-plan-redirect.spec.ts @@ -1,7 +1,6 @@ import { test, expect, Locator, Page } from '@playwright/test'; -import { execSync } from 'child_process'; -import path from 'path'; import { loginAsAdmin, mockRestEndpoints, CONTINUE } from './helpers'; +import { JoinSettingsPage, resetSettings, setBaselinePlan, wp } from './settings/support'; /** * Configuring a redirecting plan through wp-admin, end to end @@ -13,18 +12,9 @@ import { loginAsAdmin, mockRestEndpoints, CONTINUE } from './helpers'; * The settings test replaces the global plans, which no other spec relies on. * Saving the settings page also rewrites every other global setting from the * form, including ones the seed sets directly (STRIPE_DIRECT_DEBIT_ONLY), so - * the seed is re-run afterwards to put them back for the specs that follow. + * all settings are put back as the seed left them afterwards, for the specs + * that follow. */ - -function wpCli(command: string): void { - execSync(`scripts/env.sh ${command}`, { cwd: path.resolve(__dirname, '..'), stdio: 'ignore' }); -} - -function reseed(): void { - wpCli('wp eval-file /var/www/html/wp-content/e2e-scripts/setup.php'); -} - -const SETTINGS_PAGE = '/wp-admin/admin.php?page=crb_carbon_fields_container_ck_join_flow.php'; const GLOBAL_PLANS_PAGE = '/e2e-global-plans-join/'; const REDIRECT_TARGET = '/e2e-redirect-target/'; const REDIRECT_TARGET_TITLE = 'E2E Redirect Target'; @@ -113,12 +103,12 @@ async function choosePlan(page: Page, label: string): Promise { test.describe('Global plans on the Join settings page', () => { test.describe.configure({ mode: 'serial' }); - test.afterAll(reseed); + test.afterAll(resetSettings); test('the page picker only shows once the box is ticked, and the choice is saved', async ({ page }) => { page.on('dialog', (dialog) => dialog.accept()); await loginAsAdmin(page); - await page.goto(SETTINGS_PAGE); + await page.goto(JoinSettingsPage.URL); await page.getByRole('tab', { name: 'Membership Plans' }).click(); const plansField = field(page.locator('.cf-container'), 'Membership Plans'); @@ -161,7 +151,7 @@ test.describe('Global plans on the Join settings page', () => { await redirecting.getByLabel(REDIRECT_CHECKBOX).check(); await expect(selectedPages(redirectPicker(redirecting))).toContainText(REDIRECT_TARGET_TITLE); - await Promise.all([page.waitForNavigation(), page.locator('#publish').click()]); + await new JoinSettingsPage(page).save(); await page.getByRole('tab', { name: 'Membership Plans' }).click(); const saved = field(page.locator('.cf-container'), 'Membership Plans').locator('.cf-complex__group'); @@ -199,13 +189,13 @@ test.describe('Global plans on the Join settings page', () => { test.describe('Redirect help text', () => { test('names the CRMs the site uses, since redirected people are not recorded in them', async ({ page }) => { const NOT_RECORDED = 'They are not signed up as members and not recorded in Mailchimp and Zetkin.'; - const setCrms = (on: boolean) => - wpCli(`wp eval 'carbon_set_theme_option("use_mailchimp", ${on}); carbon_set_theme_option("use_zetkin", ${on});'`); - setCrms(true); + // The checkbox is part of each plan, so there must be one to see. + setBaselinePlan(); + wp('eval', 'carbon_set_theme_option("use_mailchimp", true); carbon_set_theme_option("use_zetkin", true);'); try { await loginAsAdmin(page); - await page.goto(SETTINGS_PAGE); + await page.goto(JoinSettingsPage.URL); await page.getByRole('tab', { name: 'Membership Plans' }).click(); const checkbox = field(page.locator('.cf-container'), 'Membership Plans') @@ -221,7 +211,7 @@ test.describe('Redirect help text', () => { plansField.locator('.cf-field.cf-checkbox', { hasText: REDIRECT_CHECKBOX }).first(), ).toContainText(NOT_RECORDED); } finally { - setCrms(false); + resetSettings(); } }); }); @@ -286,7 +276,7 @@ test.describe('Custom plans in the CK Join Form block', () => { await choosePlan(page, 'Block Standard'); await expect(page.locator('.progress-step--current')).toContainText('Payment'); } finally { - wpCli(`wp post delete ${pageId} --force`); + wp('post', 'delete', String(pageId), '--force'); } } }); diff --git a/packages/join-e2e/tests/helpers.ts b/packages/join-e2e/tests/helpers.ts index b09c10b..e0eb602 100644 --- a/packages/join-e2e/tests/helpers.ts +++ b/packages/join-e2e/tests/helpers.ts @@ -31,6 +31,9 @@ export async function mockRestEndpoints(page: Page): Promise { * Must be called BEFORE page.goto() so the route is in place when the page * loads. The overrides are merged on top of whatever the server returns. */ +/** The join form's settings: the page's