Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"config": {
"WP_DEBUG": true,
"WP_DEBUG_LOG": true,
"WP_DEBUG_DISPLAY": false,
"WP_ENVIRONMENT_TYPE": "local"
}
}
37 changes: 37 additions & 0 deletions bin/wp-env-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,40 @@ cat > "$CREDENTIALS_FILE" <<EOF
EOF

echo "Credentials written to $CREDENTIALS_FILE"

# ---------------------------------------------------------------------------
# Wait until WordPress is fully provisioned
#
# The Playground runtime keeps installing plugins (Blueprint steps) after it
# starts accepting requests. During those installs WordPress drops a
# `.maintenance` file and serves a 503, and there is a brief race where the
# file is removed mid-request and PHP fatals. Both corrupt the first editor
# REST call, so we gate here until the settings endpoint returns valid JSON.
# ---------------------------------------------------------------------------

echo "Waiting for the editor settings endpoint to be ready..."

SETTINGS_URL="$SITE_URL/?rest_route=/wp-block-editor/v1/settings"
READY_MAX_RETRIES=45
READY_RETRY_INTERVAL=2

for attempt in $(seq 1 $READY_MAX_RETRIES); do
if curl -s -H "Authorization: $AUTH_HEADER" "$SETTINGS_URL" | node -e "
let data = '';
process.stdin.on('data', chunk => 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
77 changes: 77 additions & 0 deletions e2e/fetch-json.js
Original file line number Diff line number Diff line change
@@ -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<Object>} 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 }`
);
}
150 changes: 150 additions & 0 deletions e2e/fetch-json.test.js
Original file line number Diff line number Diff line change
@@ -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: '<br /><b>Warning</b>: 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 );
} );
} );
36 changes: 13 additions & 23 deletions e2e/wp-env-fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading