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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,50 @@ describe('PlaygroundWorkerEndpointBlueprints', () => {
}, 10000);

it.each([
['6.8.0', 'https://wordpress.org/wordpress-6.8.zip'],
['7.0-rc1', 'https://wordpress.org/wordpress-7.0-RC1.zip'],
{
wpVersion: '6.8.0',
releaseUrl: 'https://wordpress.org/wordpress-6.8.zip',
status: 200,
},
{
wpVersion: '7.0-rc1',
releaseUrl: 'https://wordpress.org/wordpress-7.0-RC1.zip',
status: 200,
},
{
wpVersion: '6.8.0',
releaseUrl: 'https://wordpress.org/wordpress-6.8.zip',
status: 404,
expectedName: 'ResourceUnavailableError',
expectedMessage: 'WordPress 6.8 is not available for download.',
},
{
wpVersion: '6.8.0',
releaseUrl: 'https://wordpress.org/wordpress-6.8.zip',
status: 500,
expectedName: 'Error',
expectedMessage: 'Failed to download WordPress 6.8 (HTTP 500)',
},
])(
'downloads concrete WordPress release %s without changing its identity',
async (wpVersion, releaseUrl) => {
const bootWordPress = vi.fn();
'handles a concrete WordPress release $wpVersion with HTTP $status',
async ({
wpVersion,
releaseUrl,
status,
expectedName,
expectedMessage,
}) => {
vi.stubGlobal(
'fetch',
vi.fn(async (url) =>
String(url).includes('wordpress-')
? new Response(null, { status })
: new Response(new ArrayBuffer(0))
)
);
const bootWordPress = vi.fn(async (_requestHandler, options) => {
await options.wordPressZip;
});
let endpoint:
| {
boot(options: Record<string, unknown>): Promise<void>;
Expand Down Expand Up @@ -241,14 +279,22 @@ describe('PlaygroundWorkerEndpointBlueprints', () => {
undefined
);

await endpoint.boot({
const boot = endpoint.boot({
scope: 'test',
phpVersion: '8.3',
wpVersion,
wordpressInstallMode: 'download-and-install',
corsProxyUrl: 'https://proxy.test/?url=',
withNetworking: false,
});
if (expectedMessage) {
await expect(boot).rejects.toMatchObject({
name: expectedName,
message: expectedMessage,
});
} else {
await boot;
}

expect(fetch).toHaveBeenCalledWith(
`https://proxy.test/?url=${releaseUrl}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,31 @@ class ArtifactExpiredError extends Error {
}
}

/**
* Identifies a concrete WordPress release that wordpress.org cannot provide.
*
* This error is reserved for HTTP 404 responses from the versioned WordPress
* download endpoint. Other HTTP responses and transport failures remain normal
* download errors because retrying them or changing the network may succeed.
*
* Errors crossing the worker boundary may lose their prototype, but retain the
* class name as `originalErrorClassName`. Giving this condition a stable name
* lets the website show unavailable-resource guidance instead of treating it as
* a transient download failure.
*/
class ResourceUnavailableError extends Error {
/**
* Creates an unavailable-resource error with a name that survives worker
* error serialization.
*
* @param message The exact resource and version that could not be downloaded.
*/
constructor(message: string) {
super(message);
this.name = 'ResourceUnavailableError';
}
}

class PlaygroundWorkerEndpointBlueprints extends PlaygroundWorkerEndpoint {
override async boot({
scope,
Expand Down Expand Up @@ -152,6 +177,11 @@ class PlaygroundWorkerEndpointBlueprints extends PlaygroundWorkerEndpoint {
.monitorFetch(fetch(downloadUrl))
.then((response) => {
if (!response.ok) {
if (response.status === 404) {
throw new ResourceUnavailableError(
`WordPress ${normalizedVersion} is not available for download.`
);
}
throw new Error(
`Failed to download WordPress ${normalizedVersion} (HTTP ${response.status})`
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function getSiteErrorView(
if (
blueprintStepError &&
error !== 'network-firewall-interference' &&
error !== 'resource-unavailable' &&
error !== 'resource-download-failed'
) {
return blueprintStepExecutionView(context);
Expand Down Expand Up @@ -64,6 +65,8 @@ export function getSiteErrorView(
return initialOpfsSyncInterruptedView(context);
case 'network-firewall-interference':
return networkFirewallInterferenceView(context);
case 'resource-unavailable':
return resourceUnavailableView(context);
case 'resource-download-failed':
return resourceDownloadFailedView(context);
case 'site-boot-failed':
Expand Down Expand Up @@ -562,6 +565,46 @@ function networkFirewallInterferenceView({
};
}

/**
* Builds the error view for a resource that is unavailable for download.
*
* Displays the error message when available and falls back to a generic message.
*
* @returns The unavailable-resource view configuration.
*/
function resourceUnavailableView({
errorDetails,
helpers,
}: SiteErrorViewContext): SiteErrorViewConfig {
let message = 'A required resource is not available for download.';
if (typeof errorDetails === 'string') {
message = errorDetails;
} else if (
errorDetails &&
typeof errorDetails === 'object' &&
'message' in errorDetails &&
typeof errorDetails.message === 'string'
) {
message = errorDetails.message;
}
return {
title: 'Required resource is unavailable',
isDeveloperError: true,
hideReportButton: true,
detailSummaryOverride: 'Download details',
body: <p className={css.errorLead}>{message}</p>,
actions: [
<Button
variant="primary"
key="start-without-blueprint"
onClick={helpers.reloadWithoutBlueprint}
>
Start without a Blueprint
</Button>,
],
};
}

function resourceDownloadFailedView({
errorDetails,
}: SiteErrorViewContext): SiteErrorViewConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,32 @@ describe('bootSiteClient', () => {
);
});

it('classifies a worker resource-unavailable error', async () => {
const unavailableError = Object.assign(
new Error('WordPress 6.8 is not available for download.'),
{ originalErrorClassName: 'ResourceUnavailableError' }
);
vi.mocked(startPlaygroundWeb).mockRejectedValueOnce(unavailableError);
const site = createSite('unavailable-version');
const state = createState(site);
const dispatch = createDispatch(state);

await bootSiteClient(
'unavailable-version',
document.createElement('iframe'),
{ signal: new AbortController().signal }
)(dispatch, () => state);

expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'ui/setActiveSiteError',
payload: expect.objectContaining({
error: 'resource-unavailable',
}),
})
);
});

it('does not add client info when iframe boot finishes after abort', async () => {
let resolveStart = () => {};
const startFinished = new Promise<void>((resolve) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,16 @@ export function bootSiteClient(
details: e,
})
);
} else if (
(e as any).name === 'ResourceUnavailableError' ||
(e as any).originalErrorClassName === 'ResourceUnavailableError'
) {
dispatch(
setActiveSiteError({
error: 'resource-unavailable',
details: e,
})
);
} else if (firewallError) {
dispatch(
setActiveSiteError({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type SiteError =
| 'blueprint-filesystem-required'
| 'blueprint-validation-failed'
| 'network-firewall-interference'
| 'resource-unavailable'
| 'resource-download-failed';

export type DockPaneSection =
Expand Down
Loading