From cb9bb08995ebb299d26294c7250df090ba453ab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:32:34 +0000 Subject: [PATCH 1/2] Add settings page regression tests for the Logging and Debug tabs Logging: Log Retention's default and its effect on how many days of log files are kept, its validation (in the browser and in the plugin), the tab's note on the retention in force, and the log viewer. Debug: Disable Test Data. A legacy fixture checks settings stored by Carbon Fields. With this, every setting on the settings page has regression tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL2bFrTk84JMy3CPAsCTV9 --- docs/e2e-testing.md | 4 +- .../tests/settings/legacy/logging-debug.json | 4 + .../tests/settings/logging-debug.spec.ts | 152 ++++++++++++++++++ packages/join-e2e/tests/settings/support.ts | 23 ++- 4 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 packages/join-e2e/tests/settings/legacy/logging-debug.json create mode 100644 packages/join-e2e/tests/settings/logging-debug.spec.ts diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 6596c81..ac3018c 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -81,8 +81,8 @@ The suite currently contains 78 tests across 11 spec files (one skipped pending | 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 | `integrations-crm.spec.ts`, `integrations-payments.spec.ts`, `integrations-logging.spec.ts` | Every setting. Joins through the real form reach Zetkin (join form, or the API with OAuth; sandbox/live), Mailchimp, Action Network, Auth0 and the join-complete webhook with the credentials and IDs set here; the step webhook; webhook URLs never reach the form. Stripe direct debit and Direct Debit only (the payment methods Stripe's fields are set up with), the subscriptions download; GoCardless sandbox/live; Chargebee hosted pages; getAddress.io and Ideal Postcodes lookups and their key checks; Sentry, Google Cloud Logging and Loggly receiving the plugin's logs (all fake API). Known problem: JOIN-167 (subscriptions download breaks with no Stripe invoices). | -| Logging | | Not yet | -| Debug | | Not yet | +| Logging | `logging-debug.spec.ts` | Every setting. Log Retention: default of 10 days, the set number of days of log files kept, refused unless a whole number of at least 1 (by the browser and by the plugin), and the tab's note on where the number came from. The latest log is shown on the tab. Where else logs go is on the Integrations tab. | +| Debug | `logging-debug.spec.ts` | Every setting: Disable Test Data leaves the join form empty in a test-data build. | ## What is not covered diff --git a/packages/join-e2e/tests/settings/legacy/logging-debug.json b/packages/join-e2e/tests/settings/legacy/logging-debug.json new file mode 100644 index 0000000..2642f88 --- /dev/null +++ b/packages/join-e2e/tests/settings/legacy/logging-debug.json @@ -0,0 +1,4 @@ +{ + "_join_flow_log_retention_days": "4", + "_disable_test_data": "yes" +} diff --git a/packages/join-e2e/tests/settings/logging-debug.spec.ts b/packages/join-e2e/tests/settings/logging-debug.spec.ts new file mode 100644 index 0000000..f7c09e1 --- /dev/null +++ b/packages/join-e2e/tests/settings/logging-debug.spec.ts @@ -0,0 +1,152 @@ +import { APIRequestContext } from '@playwright/test'; +import { STANDARD_JOIN_PAGE, expect, getSettings, joinFormEnv, loadLegacySettings, openJoinForm, test, wpEvalJson } from './fixtures'; + +/** + * Settings page, Logging and Debug tabs. + * + * Logging: how many days of log files are kept, and the log shown on the tab. + * Where else logs go (Sentry, Google Cloud, Loggly) is on the Integrations tab + * (integrations-logging.spec.ts). + * + * Debug: turning off the join form's test data. + */ + +/** Makes the plugin log a line naming `email` ("Recording CK Join form step"). */ +async function logStep(request: APIRequestContext, email: string): Promise { + const response = await request.post('/wp-json/join/v1/step', { data: { email, stage: 'details' } }); + expect(response.ok()).toBe(true); +} + +/** + * Lays down a log file for each of the last `days` days (as if the site had + * been logging that long), removes today's so the next log line starts a new + * day's file, and returns the dates of the files left once that has happened. + */ +async function logFilesKeptAfter(request: APIRequestContext, days: number): Promise { + wpEvalJson(` + $dir = \\CommonKnowledge\\JoinBlock\\Logging::getLogDirectory(); + $existing = glob("$dir/debug-*.log") ?: []; + preg_match('/debug-([0-9a-f]+)-/', basename($existing[0] ?? 'debug-e2e-'), $match); + $hash = $match[1] ?? bin2hex(random_bytes(18)); + array_map('unlink', $existing); + for ($i = 1; $i <= ${days}; $i++) { + file_put_contents("$dir/debug-$hash-" . date('Y-m-d', strtotime("-$i days")) . '.log', "old\\n"); + } + return true; + `); + await logStep(request, 'rotation@example.org'); + return wpEvalJson(` + $dir = \\CommonKnowledge\\JoinBlock\\Logging::getLogDirectory(); + return array_map(fn ($file) => substr(basename($file, '.log'), -10), glob("$dir/debug-*.log") ?: []); + `).sort(); +} + +/** The date `daysAgo` days ago in the WordPress container, e.g. 2026-10-20. */ +const daysAgo = (days: number) => wpEvalJson(`return date('Y-m-d', strtotime('-${days} days'));`); + +test.describe('Logging', () => { + test('logs are kept for 10 days unless Log Retention says otherwise', async ({ settings, request }) => { + expect(getSettings(['join_flow_log_retention_days']).join_flow_log_retention_days).toBe(''); + + await settings.open('Logging'); + await expect( + settings.page.getByText( + 'Logs are retained for 10 days (default, as neither the Log Retention setting nor JOIN_FLOW_LOG_RETENTION_DAYS is set).', + ), + ).toBeVisible(); + + const kept = await logFilesKeptAfter(request, 14); + expect(kept).toHaveLength(10); + expect(kept[0]).toBe(daysAgo(9)); + }); + + test('Log Retention (days) sets how many days of log files are kept', async ({ settings, request }) => { + await settings.open('Logging'); + await settings.setText('Log Retention (days)', '3'); + await settings.save(); + + expect(getSettings(['join_flow_log_retention_days']).join_flow_log_retention_days).toBe('3'); + + await settings.tab('Logging'); + await expect( + settings.page.getByText('Logs are retained for 3 days (from the Log Retention setting above).'), + ).toBeVisible(); + + const kept = await logFilesKeptAfter(request, 6); + expect(kept).toEqual([daysAgo(2), daysAgo(1), daysAgo(0)]); + }); + + for (const value of ['0', '2.5', '-1']) { + test(`Log Retention of ${value} is refused, and nothing is saved`, async ({ settings }) => { + await settings.open('Logging'); + await settings.setText('Log Retention (days)', value); + await settings.tab('Debug'); + await settings.setCheckbox('Disable Test Data', true); + + await settings.expectSaveRefused(); + // The plugin checks it too, for saves that skip the browser's checks. + expect(await settings.saveSkippingBrowserChecksExpectingErrors()).toEqual([ + 'Log Retention must be a whole number of days, at least 1 (see Logging tab).', + ]); + expect(getSettings(['join_flow_log_retention_days', 'disable_test_data'])).toEqual({ + join_flow_log_retention_days: '', + disable_test_data: false, + }); + }); + } + + test('the tab shows the latest log', async ({ settings, request }) => { + // A join on a plan that doesn't exist logs the plan's ID. + const plan = `log-viewer-${Date.now()}`; + const response = await request.post('/wp-json/join/v1/join', { data: { email: 'someone@example.com', membership: plan } }); + expect(response.status()).toBe(500); + + await settings.open('Logging'); + await expect(settings.page.locator('.cf-container__fields pre')).toContainText(`Invalid membership plan: ${plan}`); + }); +}); + +test.describe('Debug', () => { + test('the join form is filled with test data when it was built with it', async ({ page }) => { + expect(getSettings(['disable_test_data']).disable_test_data).toBe(false); + expect((await joinFormEnv(page, STANDARD_JOIN_PAGE)).DISABLE_TEST_DATA).toBe(false); + + // The E2E suite builds the form with REACT_APP_USE_TEST_DATA (npm test does). + await openJoinForm(page); + await expect(page.locator('input#firstName')).toHaveValue('Test'); + }); + + test('Disable Test Data leaves the join form empty', async ({ settings, page }) => { + await settings.open('Debug'); + await settings.setCheckbox('Disable Test Data', true); + await settings.save(); + + expect(getSettings(['disable_test_data']).disable_test_data).toBe(true); + expect((await joinFormEnv(page, STANDARD_JOIN_PAGE)).DISABLE_TEST_DATA).toBe(true); + + await openJoinForm(page); + await expect(page.locator('input#firstName')).toHaveValue(''); + await expect(page.locator('input#email')).toHaveValue(''); + }); +}); + +test.describe('Logging and Debug settings saved before the settings page was rebuilt', () => { + // See tests/settings/legacy/README.md. + test('are still read and still apply', async ({ settings, page, request }) => { + loadLegacySettings('logging-debug'); + + expect(getSettings(['join_flow_log_retention_days', 'disable_test_data'])).toEqual({ + join_flow_log_retention_days: '4', + disable_test_data: true, + }); + + await openJoinForm(page); + await expect(page.locator('input#firstName')).toHaveValue(''); + + await settings.open('Logging'); + await expect( + settings.page.getByText('Logs are retained for 4 days (from the Log Retention setting above).'), + ).toBeVisible(); + expect(await logFilesKeptAfter(request, 8)).toHaveLength(4); + }); +}); diff --git a/packages/join-e2e/tests/settings/support.ts b/packages/join-e2e/tests/settings/support.ts index 3b8c7a0..95140a8 100644 --- a/packages/join-e2e/tests/settings/support.ts +++ b/packages/join-e2e/tests/settings/support.ts @@ -330,16 +330,33 @@ export class JoinSettingsPage { /** * Checks the page will not save as it stands: either it won't let the save - * be made (today it disables Save while a required field is empty), or it - * rejects it with an error. + * be made (today it disables Save while a required field is empty, and the + * browser won't submit a field that breaks its own rules, such as a number + * below its minimum), or it rejects it with an error. */ async expectSaveRefused(): Promise { - if (await this.page.locator('#publish').isDisabled()) { + const publish = this.page.locator('#publish'); + if (await publish.isDisabled()) { + return; + } + if (!(await publish.evaluate((button: HTMLInputElement) => button.form?.checkValidity() ?? true))) { return; } await this.saveExpectingErrors(); } + /** + * Saves with the browser's own checks on fields turned off, as a request + * made without the browser would be, expecting the plugin to reject it. + * Returns its error messages. + */ + async saveSkippingBrowserChecksExpectingErrors(): Promise { + await this.page.locator('#publish').evaluate((button: HTMLInputElement) => { + if (button.form) button.form.noValidate = true; + }); + return this.saveExpectingErrors(); + } + private async submit(): Promise { await Promise.all([this.page.waitForNavigation(), this.page.locator('#publish').click()]); } From 97655a42eaa81d6d80fcf35514594640149f7964 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 19:27:43 +0000 Subject: [PATCH 2/2] Address review on the Logging and Debug tests - One expectSaveRefused (from the Membership Plans branch), which tries the save rather than asking the form whether it's valid, so a form with `novalidate` can't slip through; saveSkippingBrowserChecksExpectingErrors is replaced by saveSkippingPageChecksExpectingErrors. The retention test re-enters its invalid values before the second save, whichever way the first was refused. - Log rotation is checked against the dates the test laid down, so a run across midnight passes, with fewer WP-CLI calls. - Each test's log files are backed up and put back afterwards. - The default retention test is skipped, saying why, when JOIN_FLOW_LOG_RETENTION_DAYS is set in packages/join-block/.env. - Skipped test for the Logging tab showing a stale log file (JOIN-172). - logInfo, logInvalidPlan and PHP_LOG_FILES are shared from support.ts with the Integrations logging spec. - No Carbon Fields class in the spec. - Docs: GoCardless, Chargebee and address lookup are no longer listed as untested. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL2bFrTk84JMy3CPAsCTV9 --- docs/e2e-testing.md | 8 +- .../settings/integrations-logging.spec.ts | 50 ++++---- .../tests/settings/logging-debug.spec.ts | 115 +++++++++++++----- packages/join-e2e/tests/settings/support.ts | 39 ++++++ 4 files changed, 148 insertions(+), 64 deletions(-) diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 738511c..3e35e44 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -81,7 +81,7 @@ The join form specs (the table below) are listed with their test counts. The set | 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: 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 | `integrations-crm.spec.ts`, `integrations-payments.spec.ts`, `integrations-logging.spec.ts` | Every setting. Joins through the real form reach Zetkin (join form, or the API with OAuth; sandbox/live), Mailchimp, Action Network, Auth0 and the join-complete webhook with the credentials and IDs set here; the step webhook; webhook URLs never reach the form. Stripe direct debit and Direct Debit only (the payment methods Stripe's fields are set up with), the subscriptions download; GoCardless sandbox/live; Chargebee hosted pages; getAddress.io and Ideal Postcodes lookups and their key checks; Sentry, Google Cloud Logging and Loggly receiving the plugin's logs (all fake API). Known problem: JOIN-167 (subscriptions download breaks with no Stripe invoices). | -| Logging | `logging-debug.spec.ts` | Every setting. Log Retention: default of 10 days, the set number of days of log files kept, refused unless a whole number of at least 1 (by the browser and by the plugin), and the tab's note on where the number came from. The latest log is shown on the tab. Where else logs go is on the Integrations tab. | +| Logging | `logging-debug.spec.ts` | Every setting. Log Retention: default of 10 days, the set number of days of log files kept, refused unless a whole number of at least 1 (by the browser and by the plugin), and the tab's note on where the number came from. The latest log is shown on the tab. Where else logs go is on the Integrations tab. Known problem: JOIN-172 (the tab can show a stale log file). | | Debug | `logging-debug.spec.ts` | Every setting: Disable Test Data leaves the join form empty in a test-data build. | ## What is not covered @@ -92,11 +92,11 @@ The `captureJoinBodyViaStripeRedirect` helper bypasses the payment stage entirel ### GoCardless flow -Zero coverage. GoCardless is the primary payment method for Direct Debit members. The hosted redirect flow, API flow, and mandate creation are all untested. There is no equivalent of `captureJoinBodyViaStripeRedirect` for GoCardless. +Partly covered by the settings tests (`tests/settings/integrations-payments.spec.ts`, `features-integrations.spec.ts`): members are sent to GoCardless's hosted billing flow, set up with the access token and environment, and Custom Pages take bank details in the form. Coming back from GoCardless, mandate creation and the join that follows are untested; there is no equivalent of `captureJoinBodyViaStripeRedirect` for GoCardless. ### Chargebee flow -Zero coverage. Neither the hosted pages flow nor the API flow is tested. +Partly covered by the settings tests (`tests/settings/integrations-payments.spec.ts`): with hosted pages on, card payments go to a Chargebee page created with the site and API key; without, the form shows its card fields. Coming back from Chargebee and the join that follows are untested. ### Update flow (`IS_UPDATE_FLOW=true`) @@ -116,7 +116,7 @@ Now covered by the settings tests (`tests/settings/features.spec.ts`). ### Address lookup -`USE_POSTCODE_LOOKUP` enables a postcode-to-address autocomplete. Not tested. +`USE_POSTCODE_LOOKUP` enables a postcode-to-address autocomplete. Now covered by the settings tests (`tests/settings/integrations-payments.spec.ts`), for both getAddress.io and Ideal Postcodes. ### `/join` response handling diff --git a/packages/join-e2e/tests/settings/integrations-logging.spec.ts b/packages/join-e2e/tests/settings/integrations-logging.spec.ts index 8c61761..f71d685 100644 --- a/packages/join-e2e/tests/settings/integrations-logging.spec.ts +++ b/packages/join-e2e/tests/settings/integrations-logging.spec.ts @@ -1,6 +1,16 @@ -import { APIRequestContext } from '@playwright/test'; import { generateKeyPairSync } from 'crypto'; -import { expect, fakeApiRequests, getSettings, joinFormEnv, openJoinForm, test, wpEvalJson } from './fixtures'; +import { + PHP_LOG_FILES, + expect, + fakeApiRequests, + getSettings, + joinFormEnv, + logInfo, + logInvalidPlan, + openJoinForm, + test, + wpEvalJson, +} from './fixtures'; /** * Settings page, Integrations tab: where the plugin's logs go (Sentry, Google @@ -12,34 +22,16 @@ import { expect, fakeApiRequests, getSettings, joinFormEnv, openJoinForm, test, * the service's fake. */ -/** Makes the plugin log an info line ("Recording CK Join form step"). */ -async function logSomething(request: APIRequestContext): Promise { - const response = await request.post('/wp-json/join/v1/step', { data: { email: 'someone@example.com', stage: 'details' } }); - expect(response.ok()).toBe(true); -} - -/** Makes the plugin log an error ("Invalid membership plan: ..."). */ -async function logAnError(request: APIRequestContext): Promise { - const response = await request.post('/wp-json/join/v1/join', { - data: { email: 'someone@example.com', membership: 'no-such-plan' }, - }); - expect(response.status()).toBe(500); -} - /** The size of the plugin's log files on disk, in bytes. */ const logFilesSize = () => - wpEvalJson(` - $dir = \\CommonKnowledge\\JoinBlock\\Logging::getLogDirectory(); - clearstatcache(); - return array_sum(array_map('filesize', glob("$dir/debug-*.log") ?: [])); - `); + wpEvalJson(`clearstatcache(); return array_sum(array_map('filesize', ${PHP_LOG_FILES}));`); -const LOGGING_HOSTS = ['logs-01.loggly.com', 'sentry.e2e.example', 'oauth2.googleapis.com', 'logging.googleapis.com']; +const LOGGING_HOSTS = ['logs-01.loggly.com', 'sentry.e2e.example', 'logging.googleapis.com']; test('by default, logs are written to disk and sent nowhere else', async ({ request }) => { const before = logFilesSize(); - await logSomething(request); - await logAnError(request); + await logInfo(request); + await logInvalidPlan(request); expect(logFilesSize()).toBeGreaterThan(before); const sent = await fakeApiRequests(); @@ -62,7 +54,7 @@ test.describe('Loggly', () => { }); const before = logFilesSize(); - await logSomething(request); + await logInfo(request); const sent = await fakeApiRequests('logs-01.loggly.com'); expect(sent.length).toBeGreaterThan(0); @@ -77,7 +69,7 @@ test.describe('Loggly', () => { await settings.setText('Loggly Customer Token', 'e2e-loggly-token'); await settings.save(); - await logSomething(request); + await logInfo(request); const [first] = await fakeApiRequests('logs-01.loggly.com'); expect(first.headers['x-loggly-tag']).toBe('join-block'); @@ -109,10 +101,10 @@ test.describe('Sentry', () => { expect(getSettings(['sentry_dsn']).sentry_dsn).toBe(DSN); - await logSomething(request); + await logInfo(request); expect(await fakeApiRequests('sentry.e2e.example')).toEqual([]); - await logAnError(request); + await logInvalidPlan(request); const [event] = await fakeApiRequests('sentry.e2e.example'); expect(event).toMatchObject({ method: 'POST', path: '/api/42/envelope/' }); expect(event.headers['x-sentry-auth']).toContain('sentry_key=e2epublickey'); @@ -160,7 +152,7 @@ test.describe('Google Cloud Logging', () => { google_cloud_key_file_contents: keyFile, }); - await logSomething(request); + await logInfo(request); const writes = (await fakeApiRequests('logging.googleapis.com')).filter( (request) => request.method === 'POST' && request.path === '/v2/entries:write', diff --git a/packages/join-e2e/tests/settings/logging-debug.spec.ts b/packages/join-e2e/tests/settings/logging-debug.spec.ts index 8b9416c..fa15fef 100644 --- a/packages/join-e2e/tests/settings/logging-debug.spec.ts +++ b/packages/join-e2e/tests/settings/logging-debug.spec.ts @@ -1,5 +1,19 @@ import { APIRequestContext } from '@playwright/test'; -import { STANDARD_JOIN_PAGE, expect, getSettings, joinFormEnv, loadLegacySettings, openJoinForm, test, wpEvalJson } from './fixtures'; +import { + PHP_LOG_FILES, + STANDARD_JOIN_PAGE, + backUpLogFiles, + expect, + getSettings, + joinFormEnv, + loadLegacySettings, + logInfo, + logInvalidPlan, + openJoinForm, + restoreLogFiles, + test, + wpEvalJson, +} from './fixtures'; /** * Settings page, Logging and Debug tabs. @@ -11,41 +25,58 @@ import { STANDARD_JOIN_PAGE, expect, getSettings, joinFormEnv, loadLegacySetting * Debug: turning off the join form's test data. */ -/** Makes the plugin log a line naming `email` ("Recording CK Join form step"). */ -async function logStep(request: APIRequestContext, email: string): Promise { - const response = await request.post('/wp-json/join/v1/step', { data: { email, stage: 'details' } }); - expect(response.ok()).toBe(true); -} +// These tests replace the log files; each test's are put back afterwards. +test.beforeEach(() => backUpLogFiles()); +test.afterEach(() => restoreLogFiles()); /** - * Lays down a log file for each of the last `days` days (as if the site had - * been logging that long), removes today's so the next log line starts a new - * day's file, and returns the dates of the files left once that has happened. + * Replaces the log files with one for each of the `days` days before today + * (as if the site had been logging that long), so the next log line starts a + * new day's file and the plugin rotates the old ones. Returns the dates laid + * down and the dates kept after rotation, both newest first. */ -async function logFilesKeptAfter(request: APIRequestContext, days: number): Promise { - wpEvalJson(` +async function rotateLogFiles(request: APIRequestContext, days: number) { + const laidDown = wpEvalJson(` $dir = \\CommonKnowledge\\JoinBlock\\Logging::getLogDirectory(); - $existing = glob("$dir/debug-*.log") ?: []; + $existing = ${PHP_LOG_FILES}; preg_match('/debug-([0-9a-f]+)-/', basename($existing[0] ?? 'debug-e2e-'), $match); $hash = $match[1] ?? bin2hex(random_bytes(18)); array_map('unlink', $existing); + $dates = []; for ($i = 1; $i <= ${days}; $i++) { - file_put_contents("$dir/debug-$hash-" . date('Y-m-d', strtotime("-$i days")) . '.log', "old\\n"); + $dates[] = date('Y-m-d', strtotime("-$i days")); + file_put_contents("$dir/debug-$hash-" . end($dates) . '.log', "old\\n"); } - return true; + return $dates; `); - await logStep(request, 'rotation@example.org'); - return wpEvalJson(` - $dir = \\CommonKnowledge\\JoinBlock\\Logging::getLogDirectory(); - return array_map(fn ($file) => substr(basename($file, '.log'), -10), glob("$dir/debug-*.log") ?: []); - `).sort(); + await logInfo(request); + const kept = wpEvalJson(`return array_map(fn ($file) => substr(basename($file, '.log'), -10), ${PHP_LOG_FILES});`) + .sort() + .reverse(); + return { laidDown, kept }; } -/** The date `daysAgo` days ago in the WordPress container, e.g. 2026-10-20. */ -const daysAgo = (days: number) => wpEvalJson(`return date('Y-m-d', strtotime('-${days} days'));`); +/** + * Checks rotation kept `days` days of files: the new day's, and the newest of + * those laid down. (Worked out from the dates rotateLogFiles saw, so a run + * across midnight still passes.) + */ +function expectKept({ laidDown, kept }: { laidDown: string[]; kept: string[] }, days: number): void { + expect(kept).toHaveLength(days); + expect(kept[0] > laidDown[0]).toBe(true); + expect(kept.slice(1)).toEqual(laidDown.slice(0, days - 1)); +} + +/** JOIN_FLOW_LOG_RETENTION_DAYS from the plugin's environment (packages/join-block/.env), if set. */ +const retentionFromEnvironment = () => + wpEvalJson(`return $_ENV['JOIN_FLOW_LOG_RETENTION_DAYS'] ?? null;`); test.describe('Logging', () => { test('logs are kept for 10 days unless Log Retention says otherwise', async ({ settings, request }) => { + test.skip( + retentionFromEnvironment() !== null, + 'JOIN_FLOW_LOG_RETENTION_DAYS is set in packages/join-block/.env, which replaces the default', + ); expect(getSettings(['join_flow_log_retention_days']).join_flow_log_retention_days).toBe(''); await settings.open('Logging'); @@ -55,9 +86,7 @@ test.describe('Logging', () => { ), ).toBeVisible(); - const kept = await logFilesKeptAfter(request, 14); - expect(kept).toHaveLength(10); - expect(kept[0]).toBe(daysAgo(9)); + expectKept(await rotateLogFiles(request, 14), 10); }); test('Log Retention (days) sets how many days of log files are kept', async ({ settings, request }) => { @@ -72,8 +101,7 @@ test.describe('Logging', () => { settings.page.getByText('Logs are retained for 3 days (from the Log Retention setting above).'), ).toBeVisible(); - const kept = await logFilesKeptAfter(request, 6); - expect(kept).toEqual([daysAgo(2), daysAgo(1), daysAgo(0)]); + expectKept(await rotateLogFiles(request, 6), 3); }); for (const value of ['0', '2.5', '-1']) { @@ -85,6 +113,12 @@ test.describe('Logging', () => { await settings.expectSaveRefused(); // The plugin checks it too, for saves that skip the browser's checks. + // (If the refusal above was a reload, the page shows the saved values + // again, so enter the invalid ones again.) + await settings.tab('Logging'); + await settings.setText('Log Retention (days)', value); + await settings.tab('Debug'); + await settings.setCheckbox('Disable Test Data', true); expect(await settings.saveSkippingPageChecksExpectingErrors()).toEqual([ 'Log Retention must be a whole number of days, at least 1 (see Logging tab).', ]); @@ -96,13 +130,32 @@ test.describe('Logging', () => { } test('the tab shows the latest log', async ({ settings, request }) => { - // A join on a plan that doesn't exist logs the plan's ID. const plan = `log-viewer-${Date.now()}`; - const response = await request.post('/wp-json/join/v1/join', { data: { email: 'someone@example.com', membership: plan } }); - expect(response.status()).toBe(500); + await logInvalidPlan(request, plan); + + await settings.open('Logging'); + await expect(settings.page.locator('pre', { hasText: `Invalid membership plan: ${plan}` })).toBeVisible(); + }); + + // The tab picks its file by a different rule from the plugin's, so with + // another log file in the folder it can show a stale one (Linear JOIN-172). + // Re-enable once it's fixed. + test.fixme('the tab shows the log being written, even with another log file in the folder', async ({ + settings, + request, + }) => { + await logInfo(request); + // A stale file under another name, as an earlier install might leave. + wpEvalJson(` + $dir = \\CommonKnowledge\\JoinBlock\\Logging::getLogDirectory(); + file_put_contents("$dir/debug-zzzz-" . date('Y-m-d', strtotime('-1 day')) . '.log', "stale\\n"); + return true; + `); + const plan = `log-viewer-${Date.now()}`; + await logInvalidPlan(request, plan); await settings.open('Logging'); - await expect(settings.page.locator('.cf-container__fields pre')).toContainText(`Invalid membership plan: ${plan}`); + await expect(settings.page.locator('pre', { hasText: `Invalid membership plan: ${plan}` })).toBeVisible(); }); }); @@ -147,6 +200,6 @@ test.describe('Logging and Debug settings saved before the settings page was reb await expect( settings.page.getByText('Logs are retained for 4 days (from the Log Retention setting above).'), ).toBeVisible(); - expect(await logFilesKeptAfter(request, 8)).toHaveLength(4); + expectKept(await rotateLogFiles(request, 8), 4); }); }); diff --git a/packages/join-e2e/tests/settings/support.ts b/packages/join-e2e/tests/settings/support.ts index 5401092..b50966f 100644 --- a/packages/join-e2e/tests/settings/support.ts +++ b/packages/join-e2e/tests/settings/support.ts @@ -151,6 +151,45 @@ export async function postJoin(request: APIRequestContext, data: Record { + const response = await request.post('/wp-json/join/v1/step', { data: { email: 'someone@example.com', stage: 'details' } }); + expect(response.ok()).toBe(true); +} + +/** Makes the plugin log an error naming `plan` ("Invalid membership plan: "). */ +export async function logInvalidPlan(request: APIRequestContext, plan = 'no-such-plan'): Promise { + const response = await request.post('/wp-json/join/v1/join', { data: { email: 'someone@example.com', membership: plan } }); + expect(response.status()).toBe(500); +} + +/** PHP for the paths of the plugin's log files, for use in wpEvalJson(). */ +export const PHP_LOG_FILES = `(glob(\\CommonKnowledge\\JoinBlock\\Logging::getLogDirectory() . '/debug-*.log') ?: [])`; + +const PHP_LOG_BACKUP = `WP_CONTENT_DIR . '/e2e-log-backup'`; + +/** Copies the plugin's log files aside, for tests that change them (see restoreLogFiles). */ +export function backUpLogFiles(): void { + wpEvalJson(` + $backup = ${PHP_LOG_BACKUP}; + wp_mkdir_p($backup); + array_map('unlink', glob("$backup/*") ?: []); + foreach (${PHP_LOG_FILES} as $file) copy($file, "$backup/" . basename($file)); + return true; + `); +} + +/** Puts the plugin's log files back as backUpLogFiles() found them. */ +export function restoreLogFiles(): void { + wpEvalJson(` + $backup = ${PHP_LOG_BACKUP}; + $dir = \\CommonKnowledge\\JoinBlock\\Logging::getLogDirectory(); + array_map('unlink', ${PHP_LOG_FILES}); + foreach (glob("$backup/*") ?: [] as $file) rename($file, "$dir/" . basename($file)); + return true; + `); +} + /** Delivers an event to the plugin's Stripe webhook endpoint, as Stripe would. */ export async function postStripeWebhook(request: APIRequestContext, type: string, data: Record) { const response = await request.post('/wp-json/join/v1/stripe/webhook', { data: { type, data } });