From 8cae99169cbca9d01f2de4cd427746dbbf2a3da7 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 6 Jul 2026 16:49:22 +1000 Subject: [PATCH 1/4] fix(e2e): stop wp-env warm-up JSON parse flakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E runs failed intermittently when the wp-env REST endpoints returned a non-JSON body during warm-up — a PHP notice printed into the response (`
Warning...`) or a transient `500` — and `response.json()` threw. Because editor settings are cached in module scope only on success, a single early hiccup poisoned the first fetch and failed every early spec while later ones passed: the flaky signature. Two complementary fixes: - Set `WP_DEBUG_DISPLAY: false` so PHP notices never print into REST bodies. `WP_DEBUG_LOG` stays on, so notices still land in `debug.log`. - Route the settings and assets fetches through a `fetchJson` helper that reads the body as text, retries transient `5xx`/parse failures, and surfaces a body snippet in the final error instead of an opaque `Unexpected token '<'`. --- Generated with the help of Claude Code, https://claude.com/claude-code Co-Authored-By: Claude Opus 4.8 (1M context) --- .wp-env.json | 1 + e2e/wp-env-fixtures.js | 89 +++++++++++++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 23 deletions(-) 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/e2e/wp-env-fixtures.js b/e2e/wp-env-fixtures.js index fd1a5483e..eb2070721 100644 --- a/e2e/wp-env-fixtures.js +++ b/e2e/wp-env-fixtures.js @@ -30,6 +30,64 @@ let cachedEditorSettings = null; /** Cached editor assets — fetched once and reused across all tests. */ let cachedEditorAssets = null; +const MAX_FETCH_ATTEMPTS = 5; +const FETCH_RETRY_DELAY_MS = 1000; + +const delay = ( ms ) => new Promise( ( resolve ) => setTimeout( resolve, ms ) ); + +/** + * 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 '<'". + * + * @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. + */ +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 ) { + throw new Error( + `HTTP ${ response.status } ${ + response.statusText + } — ${ body.slice( 0, 200 ) }` + ); + } + + try { + return JSON.parse( body ); + } catch { + throw new Error( + `response was not valid JSON — ${ body.slice( 0, 200 ) }` + ); + } + } catch ( 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 }` + ); +} + /** * Fetch editor settings from the wp-env WordPress instance. * @@ -44,20 +102,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 +124,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; } From 4a07b75718a831d7f60e9f6108c753975f9f0d31 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 6 Jul 2026 17:02:12 +1000 Subject: [PATCH 2/4] fix(e2e): gate suite on WordPress leaving maintenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI surfaced the real warm-up failure the earlier retry only papered over: the Playground runtime keeps installing plugins (Blueprint steps) after it starts serving requests. During those installs WordPress drops a `.maintenance` file and returns a `503`, and there is a race where the file is removed mid-request and PHP fatals — both corrupt the first editor REST call. Gate provisioning in `wp-env-setup.sh` on the settings endpoint returning valid JSON, so the suite never starts against a half-provisioned instance. Raise the in-test fetch retry budget (5s -> 30s) as a backstop for any mid-run blip. --- Generated with the help of Claude Code, https://claude.com/claude-code Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/wp-env-setup.sh | 37 +++++++++++++++++++++++++++++++++++++ e2e/wp-env-fixtures.js | 4 ++-- 2 files changed, 39 insertions(+), 2 deletions(-) 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/wp-env-fixtures.js b/e2e/wp-env-fixtures.js index eb2070721..b29c21982 100644 --- a/e2e/wp-env-fixtures.js +++ b/e2e/wp-env-fixtures.js @@ -30,8 +30,8 @@ let cachedEditorSettings = null; /** Cached editor assets — fetched once and reused across all tests. */ let cachedEditorAssets = null; -const MAX_FETCH_ATTEMPTS = 5; -const FETCH_RETRY_DELAY_MS = 1000; +const MAX_FETCH_ATTEMPTS = 15; +const FETCH_RETRY_DELAY_MS = 2000; const delay = ( ms ) => new Promise( ( resolve ) => setTimeout( resolve, ms ) ); From eeb6fc8e7a6be9e2747ff02d95edfa90bf863524 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 20 Jul 2026 15:44:56 +1000 Subject: [PATCH 3/4] fix(e2e): fail fast on non-retryable REST 4xx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fetchJson` retried every failure alike, so a misconfiguration that will never heal — a bad `authHeader`, a wrong route — burned the full 15 attempts (30s) before surfacing. Bounding retries to 5xx, 408/429, network rejections and parse failures matches what the helper documents and lets a real breakage surface in seconds. Raised in review on #540. Covering the retry logic needs Vitest, but `e2e/` was excluded from it wholesale and Playwright's default `testMatch` claims `*.test.js` too. Narrowing both patterns to `*.spec.js` lets the two runners share the directory: Playwright still collects the same 49 specs, Vitest picks up the helper tests. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.8 --- e2e/wp-env-fixtures.js | 32 ++++++-- e2e/wp-env-fixtures.test.js | 150 ++++++++++++++++++++++++++++++++++++ playwright.config.js | 3 + vitest.config.js | 2 +- 4 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 e2e/wp-env-fixtures.test.js diff --git a/e2e/wp-env-fixtures.js b/e2e/wp-env-fixtures.js index b29c21982..cf396f1f6 100644 --- a/e2e/wp-env-fixtures.js +++ b/e2e/wp-env-fixtures.js @@ -35,6 +35,9 @@ 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. @@ -44,12 +47,16 @@ const delay = ( ms ) => new Promise( ( resolve ) => setTimeout( resolve, ms ) ); * 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. */ -async function fetchJson( url, creds, label ) { +export async function fetchJson( url, creds, label ) { let lastError; for ( let attempt = 1; attempt <= MAX_FETCH_ATTEMPTS; attempt++ ) { @@ -61,11 +68,21 @@ async function fetchJson( url, creds, label ) { const body = await response.text(); if ( ! response.ok ) { - throw new Error( - `HTTP ${ response.status } ${ - response.statusText - } — ${ body.slice( 0, 200 ) }` - ); + 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 { @@ -76,6 +93,9 @@ async function fetchJson( url, creds, label ) { ); } } catch ( error ) { + if ( error instanceof NonRetryableError ) { + throw error; + } lastError = error; if ( attempt < MAX_FETCH_ATTEMPTS ) { await delay( FETCH_RETRY_DELAY_MS ); diff --git a/e2e/wp-env-fixtures.test.js b/e2e/wp-env-fixtures.test.js new file mode 100644 index 000000000..f94bbdbda --- /dev/null +++ b/e2e/wp-env-fixtures.test.js @@ -0,0 +1,150 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { fetchJson } from './wp-env-fixtures'; + +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/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', From ec128f8b6aadedf0723cad65dafedee1c0c5c255 Mon Sep 17 00:00:00 2001 From: Gio Lodi Date: Mon, 20 Jul 2026 15:50:41 +1000 Subject: [PATCH 4/4] fix(e2e): extract fetchJson to a pure module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wp-env-fixtures.js` calls `readCredentials()` at module scope, so importing it anywhere without a provisioned wp-env throws. That made the unit test for `fetchJson` unrunnable on CI, where `.wp-env.credentials.json` does not exist — it only passed locally because a stale credentials file happened to sit in the worktree. Moving the helper to `e2e/fetch-json.js` keeps the retry logic testable without dragging in the credential-bound fixture setup. --- Generated with the help of Claude Code, https://claude.ai/code Co-Authored-By: Claude Code Opus 4.8 --- e2e/fetch-json.js | 77 +++++++++++++++++ ...nv-fixtures.test.js => fetch-json.test.js} | 2 +- e2e/wp-env-fixtures.js | 83 ++----------------- 3 files changed, 83 insertions(+), 79 deletions(-) create mode 100644 e2e/fetch-json.js rename e2e/{wp-env-fixtures.test.js => fetch-json.test.js} (98%) 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/wp-env-fixtures.test.js b/e2e/fetch-json.test.js similarity index 98% rename from e2e/wp-env-fixtures.test.js rename to e2e/fetch-json.test.js index f94bbdbda..12b683c81 100644 --- a/e2e/wp-env-fixtures.test.js +++ b/e2e/fetch-json.test.js @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; /** * Internal dependencies */ -import { fetchJson } from './wp-env-fixtures'; +import { fetchJson } from './fetch-json'; const CREDS = { authHeader: 'Basic dGVzdDp0ZXN0' }; const URL = 'http://localhost/wp-json/wp-block-editor/v1/settings'; diff --git a/e2e/wp-env-fixtures.js b/e2e/wp-env-fixtures.js index cf396f1f6..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' @@ -30,84 +35,6 @@ let cachedEditorSettings = null; /** Cached editor assets — fetched once and reused across all tests. */ let cachedEditorAssets = null; -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 }` - ); -} - /** * Fetch editor settings from the wp-env WordPress instance. *