diff --git a/.wp-env.json b/.wp-env.json index 60fac31da..afed5482f 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -9,6 +9,7 @@ "config": { "WP_DEBUG": true, "WP_DEBUG_LOG": true, + "WP_DEBUG_DISPLAY": false, "WP_ENVIRONMENT_TYPE": "local" } } diff --git a/bin/wp-env-setup.sh b/bin/wp-env-setup.sh index 77e431813..e999a4d81 100755 --- a/bin/wp-env-setup.sh +++ b/bin/wp-env-setup.sh @@ -169,3 +169,40 @@ cat > "$CREDENTIALS_FILE" < data += chunk); + process.stdin.on('end', () => { + try { JSON.parse(data); } + catch { process.exit(1); } + }); + "; then + echo "Editor settings endpoint is ready." + break + fi + + if [ "$attempt" -eq "$READY_MAX_RETRIES" ]; then + echo "Error: editor settings endpoint did not return valid JSON after $((READY_MAX_RETRIES * READY_RETRY_INTERVAL)) seconds." + exit 1 + fi + + sleep $READY_RETRY_INTERVAL +done diff --git a/e2e/fetch-json.js b/e2e/fetch-json.js new file mode 100644 index 000000000..a55774a95 --- /dev/null +++ b/e2e/fetch-json.js @@ -0,0 +1,77 @@ +const MAX_FETCH_ATTEMPTS = 15; +const FETCH_RETRY_DELAY_MS = 2000; + +const delay = ( ms ) => new Promise( ( resolve ) => setTimeout( resolve, ms ) ); + +/** Marks a failure that retrying cannot resolve, so the loop gives up at once. */ +class NonRetryableError extends Error {} + +/** + * Fetch and parse JSON from a wp-env REST endpoint, retrying on transient + * failures. + * + * During warm-up the Playground/wp-env instance can briefly return a 5xx or + * leak a PHP notice into the body, which corrupts the JSON. Both surface here + * as errors we retry, and the last failure carries a snippet of the body so a + * genuine leak is diagnosable instead of an opaque "Unexpected token '<'". + * + * A 4xx other than 408/429 means the request itself is wrong — bad credentials + * or a wrong route — so it fails immediately rather than after the full retry + * budget. + * + * @param {string} url Fully-qualified REST URL. + * @param {Object} creds Credentials object from readCredentials(). + * @param {string} label Human-readable resource name for error messages. + * @return {Promise} The parsed JSON response. + */ +export async function fetchJson( url, creds, label ) { + let lastError; + + for ( let attempt = 1; attempt <= MAX_FETCH_ATTEMPTS; attempt++ ) { + try { + const response = await fetch( url, { + headers: { Authorization: creds.authHeader }, + } ); + + const body = await response.text(); + + if ( ! response.ok ) { + const message = `HTTP ${ response.status } ${ + response.statusText + } — ${ body.slice( 0, 200 ) }`; + + if ( + response.status < 500 && + response.status !== 408 && + response.status !== 429 + ) { + throw new NonRetryableError( + `Failed to fetch ${ label }: ${ message }` + ); + } + + throw new Error( message ); + } + + try { + return JSON.parse( body ); + } catch { + throw new Error( + `response was not valid JSON — ${ body.slice( 0, 200 ) }` + ); + } + } catch ( error ) { + if ( error instanceof NonRetryableError ) { + throw error; + } + lastError = error; + if ( attempt < MAX_FETCH_ATTEMPTS ) { + await delay( FETCH_RETRY_DELAY_MS ); + } + } + } + + throw new Error( + `Failed to fetch ${ label } after ${ MAX_FETCH_ATTEMPTS } attempts: ${ lastError.message }` + ); +} diff --git a/e2e/fetch-json.test.js b/e2e/fetch-json.test.js new file mode 100644 index 000000000..12b683c81 --- /dev/null +++ b/e2e/fetch-json.test.js @@ -0,0 +1,150 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { fetchJson } from './fetch-json'; + +const CREDS = { authHeader: 'Basic dGVzdDp0ZXN0' }; +const URL = 'http://localhost/wp-json/wp-block-editor/v1/settings'; + +const respond = ( { status = 200, statusText = 'OK', body = '{}' } = {} ) => ( { + ok: status >= 200 && status < 300, + status, + statusText, + text: async () => body, +} ); + +/** + * Drive a fetchJson call to completion, flushing the retry delays so the test + * does not wait out the real backoff. + * + * @param {Promise} promise The pending fetchJson promise. + * @return {Promise} Settles the same way the input promise does. + */ +async function withTimersFlushed( promise ) { + const settled = promise.then( + ( value ) => ( { value } ), + ( error ) => ( { error } ) + ); + await vi.runAllTimersAsync(); + const result = await settled; + if ( result.error ) { + throw result.error; + } + return result.value; +} + +describe( 'fetchJson', () => { + beforeEach( () => { + vi.useFakeTimers(); + global.fetch = vi.fn(); + } ); + + afterEach( () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + } ); + + it( 'returns the parsed body on a successful response', async () => { + global.fetch.mockResolvedValue( respond( { body: '{"foo":"bar"}' } ) ); + + await expect( + withTimersFlushed( fetchJson( URL, CREDS, 'editor settings' ) ) + ).resolves.toEqual( { foo: 'bar' } ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'retries a transient 5xx and succeeds once the endpoint recovers', async () => { + global.fetch + .mockResolvedValueOnce( + respond( { status: 503, statusText: 'Service Unavailable' } ) + ) + .mockResolvedValue( respond( { body: '{"ready":true}' } ) ); + + await expect( + withTimersFlushed( fetchJson( URL, CREDS, 'editor settings' ) ) + ).resolves.toEqual( { ready: true } ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'retries a PHP notice leaking into the body', async () => { + global.fetch + .mockResolvedValueOnce( + respond( { body: '
Warning: something{}' } ) + ) + .mockResolvedValue( respond( { body: '{"ready":true}' } ) ); + + await expect( + withTimersFlushed( fetchJson( URL, CREDS, 'editor settings' ) ) + ).resolves.toEqual( { ready: true } ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'retries a network-level rejection', async () => { + global.fetch + .mockRejectedValueOnce( new Error( 'ECONNREFUSED' ) ) + .mockResolvedValue( respond( { body: '{"ready":true}' } ) ); + + await expect( + withTimersFlushed( fetchJson( URL, CREDS, 'editor settings' ) ) + ).resolves.toEqual( { ready: true } ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'fails immediately on a 401, without burning the retry budget', async () => { + global.fetch.mockResolvedValue( + respond( { + status: 401, + statusText: 'Unauthorized', + body: '{"code":"incorrect_password"}', + } ) + ); + + await expect( + withTimersFlushed( fetchJson( URL, CREDS, 'editor settings' ) ) + ).rejects.toThrow( /Failed to fetch editor settings: HTTP 401/ ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'fails immediately on a 404, without burning the retry budget', async () => { + global.fetch.mockResolvedValue( + respond( { status: 404, statusText: 'Not Found', body: 'nope' } ) + ); + + await expect( + withTimersFlushed( fetchJson( URL, CREDS, 'editor assets' ) ) + ).rejects.toThrow( /Failed to fetch editor assets: HTTP 404/ ); + expect( global.fetch ).toHaveBeenCalledTimes( 1 ); + } ); + + it.each( [ 408, 429 ] )( + 'retries a %i, which can clear on its own', + async ( status ) => { + global.fetch + .mockResolvedValueOnce( respond( { status } ) ) + .mockResolvedValue( respond( { body: '{"ready":true}' } ) ); + + await expect( + withTimersFlushed( fetchJson( URL, CREDS, 'editor settings' ) ) + ).resolves.toEqual( { ready: true } ); + expect( global.fetch ).toHaveBeenCalledTimes( 2 ); + } + ); + + it( 'gives up after the retry budget and reports the last body snippet', async () => { + global.fetch.mockResolvedValue( + respond( { status: 500, statusText: 'Internal Server Error' } ) + ); + + await expect( + withTimersFlushed( fetchJson( URL, CREDS, 'editor settings' ) ) + ).rejects.toThrow( + /Failed to fetch editor settings after 15 attempts: HTTP 500/ + ); + expect( global.fetch ).toHaveBeenCalledTimes( 15 ); + } ); +} ); diff --git a/e2e/wp-env-fixtures.js b/e2e/wp-env-fixtures.js index fd1a5483e..e696b33e3 100644 --- a/e2e/wp-env-fixtures.js +++ b/e2e/wp-env-fixtures.js @@ -4,6 +4,11 @@ import fs from 'node:fs'; import path from 'node:path'; +/** + * Internal dependencies + */ +import { fetchJson } from './fetch-json'; + const CREDENTIALS_PATH = path.resolve( import.meta.dirname, '../.wp-env.credentials.json' @@ -44,20 +49,11 @@ async function fetchEditorSettings( creds ) { return cachedEditorSettings; } - const response = await fetch( + cachedEditorSettings = await fetchJson( `${ creds.siteApiRoot }wp-block-editor/v1/settings`, - { - headers: { Authorization: creds.authHeader }, - } + creds, + 'editor settings' ); - - if ( ! response.ok ) { - throw new Error( - `Failed to fetch editor settings: ${ response.status } ${ response.statusText }` - ); - } - - cachedEditorSettings = await response.json(); return cachedEditorSettings; } @@ -75,17 +71,11 @@ async function fetchEditorAssets( creds ) { const url = new URL( `${ creds.siteApiRoot }wpcom/v2/editor-assets` ); url.searchParams.set( 'exclude', 'core,gutenberg' ); - const response = await fetch( url.toString(), { - headers: { Authorization: creds.authHeader }, - } ); - - if ( ! response.ok ) { - throw new Error( - `Failed to fetch editor assets: ${ response.status } ${ response.statusText }` - ); - } - - cachedEditorAssets = await response.json(); + cachedEditorAssets = await fetchJson( + url.toString(), + creds, + 'editor assets' + ); return cachedEditorAssets; } diff --git a/playwright.config.js b/playwright.config.js index d2c241bd1..2bbd73886 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -10,6 +10,9 @@ const url = isCI ? 'http://localhost:4173' : 'http://localhost:5173'; export default defineConfig( { testDir: './e2e', + // Narrower than the default, which also claims `*.test.js` — those belong to + // Vitest, which covers the non-spec helpers in this directory. + testMatch: '**/*.spec.js', outputDir: './e2e/test-results', fullyParallel: true, workers: isCI ? 1 : undefined, diff --git a/vitest.config.js b/vitest.config.js index 5680b58cc..1a5ff1041 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -7,7 +7,7 @@ import react from '@vitejs/plugin-react'; export default defineConfig( { plugins: [ react() ], test: { - exclude: [ 'e2e/**', 'node_modules/**' ], + exclude: [ 'e2e/**/*.spec.js', 'node_modules/**' ], setupFiles: [ './vitest.setup.js' ], css: false, environment: 'jsdom',