Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b8dbc03
Improve Blueprint-related error reporting
adamziel Nov 6, 2025
b147b57
Lint
adamziel Nov 18, 2025
6d445a3
Display clear Blueprint syntax errors
adamziel Nov 18, 2025
2057957
Exclude error object from redux store immutability
adamziel Nov 18, 2025
aa42612
Adjust CSS of the error modal
adamziel Nov 18, 2025
e3e3673
Nice, large error modal
adamziel Nov 18, 2025
21eacf3
CSS tweaks
adamziel Nov 18, 2025
7f68667
CSS tweaks
adamziel Nov 18, 2025
7620aa3
CSS tweaks
adamziel Nov 18, 2025
5989651
CSS tweaks
adamziel Nov 18, 2025
9cd720e
Merge error reporting into the new modal
adamziel Nov 18, 2025
cac7e0e
Remove the old "report error" modal
adamziel Nov 18, 2025
e68bee5
Remove the old "report error" modal
adamziel Nov 18, 2025
d56bec1
Tweak modal header
adamziel Nov 18, 2025
88c88fa
Tweak error messages
adamziel Nov 18, 2025
97f1bfa
Tweak blueprint-url error presentation
adamziel Nov 18, 2025
8d20f08
Improve reported errors
adamziel Nov 18, 2025
60ac845
Document mock temporary site creation on error during boot
adamziel Nov 18, 2025
23fc7f6
Finalize Playground boot if the client was created
adamziel Nov 18, 2025
5a669c1
Merge branch 'trunk' into improve-error-reporting
adamziel Nov 18, 2025
a67b8cc
Lint
adamziel Nov 18, 2025
68649da
Explain bundled resources
adamziel Nov 18, 2025
62b7c3d
Move error-related css to separate stylesheet
adamziel Nov 18, 2025
ed36c36
Restore .full-size to playground-viewport styles
adamziel Nov 18, 2025
915e509
Do not offer "try again" option when bundle fs fails
adamziel Nov 18, 2025
04bb965
css tweaks
adamziel Nov 18, 2025
7381fc7
factor error information to co-locate it
adamziel Nov 19, 2025
65390fb
remove constants.ts
adamziel Nov 19, 2025
1eab360
document helpers
adamziel Nov 19, 2025
b7bbb11
Lint
adamziel Nov 19, 2025
51b03f8
Remove the concept of messageToOmit
adamziel Nov 19, 2025
d0317af
Simplify the error handling logic
adamziel Nov 19, 2025
084ecd5
Remove unused React import
adamziel Nov 19, 2025
a51a4da
Merge branch 'trunk' into improve-error-reporting
adamziel Nov 19, 2025
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
8 changes: 7 additions & 1 deletion packages/playground/blueprints/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export {
isBlueprintBundle,
compileBlueprintV1,
runBlueprintV1Steps,
InvalidBlueprintError,
BlueprintStepExecutionError,

// BC:
compileBlueprintV1 as compileBlueprint,
Expand Down Expand Up @@ -49,6 +51,7 @@ export type {
VFSReference,
VFSResource,
} from './lib/v1/resources';
export { BlueprintFilesystemRequiredError } from './lib/v1/resources';
export * from './lib/steps';
export * from './lib/steps/handlers';
export type {
Expand All @@ -61,7 +64,10 @@ export { getV2Runner } from './lib/v2/get-v2-runner';
export { runBlueprintV2 } from './lib/v2/run-blueprint-v2';
export type { BlueprintMessage } from './lib/v2/run-blueprint-v2';

export { resolveRemoteBlueprint } from './lib/resolve-remote-blueprint';
export {
resolveRemoteBlueprint,
BlueprintFetchError,
} from './lib/resolve-remote-blueprint';
export { wpContentFilesExcludedFromExport } from './lib/utils/wp-content-files-excluded-from-exports';
export { resolveRuntimeConfiguration } from './lib/resolve-runtime-configuration';

Expand Down
69 changes: 44 additions & 25 deletions packages/playground/blueprints/src/lib/resolve-remote-blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import {
} from '@wp-playground/storage';
import type { BlueprintBundle } from './types';

export class BlueprintFetchError extends Error {
constructor(
message: string,
public readonly url: string,
options?: ErrorOptions
) {
super(message, options);
this.name = 'BlueprintFetchError';
}
}

/**
* Resolves a remote blueprint from a URL.
*
Expand All @@ -15,34 +26,42 @@ import type { BlueprintBundle } from './types';
export async function resolveRemoteBlueprint(
url: string
): Promise<BlueprintBundle> {
const response = await fetch(url, {
credentials: 'omit',
});
if (!response.ok) {
throw new Error(`Failed to fetch blueprint from ${url}`);
}
const blueprintBytes = await response.arrayBuffer();
try {
const blueprintText = new TextDecoder().decode(blueprintBytes);
JSON.parse(blueprintText);
const response = await fetch(url, {
credentials: 'omit',
});
if (!response.ok) {
throw new Error(`Failed to fetch blueprint from ${url}`);
}
const blueprintBytes = await response.arrayBuffer();
try {
const blueprintText = new TextDecoder().decode(blueprintBytes);
JSON.parse(blueprintText);

// No exceptions, good! We're dealing with a JSON file. Let's
// resolve the "bundled" resources from the same remote URL.
return new OverlayFilesystem([
new InMemoryFilesystem({
'blueprint.json': blueprintText,
}),
new FetchFilesystem({
baseUrl: url,
}),
]);
} catch {
// If the blueprint is not a JSON file, check if it's a ZIP file.
if (await looksLikeZipFile(blueprintBytes)) {
return ZipFilesystem.fromArrayBuffer(blueprintBytes);
// No exceptions, good! We're dealing with a JSON file. Let's
// resolve the "bundled" resources from the same remote URL.
return new OverlayFilesystem([
new InMemoryFilesystem({
'blueprint.json': blueprintText,
}),
new FetchFilesystem({
baseUrl: url,
}),
]);
} catch {
// If the blueprint is not a JSON file, check if it's a ZIP file.
if (await looksLikeZipFile(blueprintBytes)) {
return ZipFilesystem.fromArrayBuffer(blueprintBytes);
}
throw new Error(
`Blueprint file at ${url} is neither a valid JSON nor a ZIP file.`
);
}
throw new Error(
`Blueprint file at ${url} is neither a valid JSON nor a ZIP file.`
} catch (error) {
throw new BlueprintFetchError(
`Blueprint file at ${url} is neither a valid JSON nor a ZIP file.`,
url,
{ cause: error }
);
}
}
Expand Down
12 changes: 7 additions & 5 deletions packages/playground/blueprints/src/lib/steps/activate-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const activatePlugin: StepHandler<ActivatePluginStep> = async (
/**
* Instead of trusting the activation response, check the active plugins list.
*
* We try to discard any extra output via output buffering. The output of the script below
* We try to discard any extra output via output buffering. The output of the script below
* end with `{"success": true}` or `{"success": false}`. Only `{"success": true}` is
* treated as a successful plugin activation.
*/
Expand Down Expand Up @@ -152,9 +152,11 @@ export const activatePlugin: StepHandler<ActivatePluginStep> = async (
}

throw new Error(
`Plugin ${pluginPath} could not be activated – WordPress exited with no error. ` +
`Sometimes, when $_SERVER or site options are not configured correctly, ` +
`WordPress exits early with a 301 redirect. ` +
`Inspect the "debug" logs in the console for more details.`
`Plugin ${pluginPath} could not be activated - WordPress exited with exit code ${activatePluginResult.exitCode}. ` +
`Inspect the "debug" logs in the console for more details. Output headers: ${JSON.stringify(
activatePluginResult.headers,
null,
2
)}`
);
};
10 changes: 6 additions & 4 deletions packages/playground/blueprints/src/lib/steps/activate-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ export const activateTheme: StepHandler<ActivateThemeStep> = async (
if (result.text !== 'Theme activated successfully') {
logger.debug(result);
throw new Error(
`Theme ${themeFolderName} could not be activated – WordPress exited with no error. ` +
`Sometimes, when $_SERVER or site options are not configured correctly, ` +
`WordPress exits early with a 301 redirect. ` +
`Inspect the "debug" logs in the console for more details`
`Theme ${themeFolderName} could not be activated - WordPress exited with exit code ${result.exitCode}. ` +
`Inspect the "debug" logs in the console for more details. Output headers: ${JSON.stringify(
result.headers,
null,
2
)}`
);
}
};
147 changes: 134 additions & 13 deletions packages/playground/blueprints/src/lib/v1/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,50 @@ const keyedStepHandlers = {
*/
import blueprintValidator from '../../../public/blueprint-schema-validator';
import { defaultWpCliPath, defaultWpCliResource } from '../steps/wp-cli';
import type { ErrorObject } from 'ajv';

export class InvalidBlueprintError extends Error {
constructor(message: string, public readonly validationErrors?: unknown) {
super(message);
this.name = 'InvalidBlueprintError';
}
}

/**
* Error thrown when a single Blueprint step fails during execution.
*
* This error carries structured information about the failing step so that
* consumers (e.g. the Playground UI) do not have to parse human‑readable
* error messages to understand what went wrong.
*/
export class BlueprintStepExecutionError extends Error {
public readonly stepNumber: number;
public readonly step: StepDefinition;
public readonly messages: string[];

constructor(options: {
stepNumber: number;
step: StepDefinition;
cause: unknown;
}) {
const { stepNumber, step, cause } = options;
const causeError =
cause instanceof Error ? cause : new Error(String(cause));
const baseMessage = `Error when executing the blueprint step #${stepNumber}`;
const fullMessage = causeError.message
? `${baseMessage}: ${causeError.message}`
: baseMessage;

super(fullMessage, { cause: causeError });
this.name = 'BlueprintStepExecutionError';
this.stepNumber = stepNumber;
this.step = step;
this.messages = (causeError.message || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
}

export type CompiledV1Step = (php: UniversalPHP) => Promise<void> | void;

Expand Down Expand Up @@ -307,14 +351,17 @@ function compileBlueprintJson(

const { valid, errors } = validateBlueprint(blueprint);
if (!valid) {
const e = new Error(
`Invalid blueprint: ${errors![0].message} at ${
errors![0].instancePath
}`
const formattedErrors = formatValidationErrors(blueprint, errors ?? []);

throw new InvalidBlueprintError(
`Invalid Blueprint: The Blueprint does not conform to the schema.\n\n` +
`Found ${
errors!.length
} validation error(s):\n\n${formattedErrors}\n\n` +
`Please review your Blueprint and fix these issues. ` +
`Learn more about the Blueprint format: https://wordpress.github.io/wordpress-playground/blueprints/data-format`,
errors
);
// Attach Ajv output to the thrown object for easier debugging
(e as any).errors = errors;
throw e;
}

onBlueprintValidated(blueprint);
Expand Down Expand Up @@ -378,12 +425,12 @@ function compileBlueprintJson(
const result = await run(playground);
onStepCompleted(result, step);
} catch (e) {
throw new Error(
`Error when executing the blueprint step #${i} (${JSON.stringify(
step
)}) ${e instanceof Error ? `: ${e.message}` : e}`,
{ cause: e }
);
const stepNumber = Number(i) + 1;
throw new BlueprintStepExecutionError({
stepNumber,
step,
cause: e,
});
}
}
} finally {
Expand Down Expand Up @@ -419,6 +466,80 @@ function compileBlueprintJson(
};
}

function formatValidationErrors(
blueprint: BlueprintV1Declaration,
errors: ErrorObject<string, unknown>[]
) {
return errors
.map((err, index) => {
const path = err.instancePath || '/';
let message = err.message || 'validation failed';

// For "additional properties" errors, highlight the actual problematic key
let highlightedSnippet = '';
if (message.includes('must NOT have additional properties')) {
// Extract the property name from the error params
const additionalProperty = (err.params as any)
?.additionalProperty;
if (additionalProperty) {
message = `has unexpected property "${additionalProperty}"`;

// Try to show the offending key highlighted
try {
const pathParts = path.split('/').filter(Boolean);
let currentValue: any = blueprint;
for (const part of pathParts) {
if (
currentValue &&
typeof currentValue === 'object'
) {
currentValue = currentValue[part];
}
}

if (currentValue && typeof currentValue === 'object') {
const offendingValue =
currentValue[additionalProperty];
const valueStr = JSON.stringify(offendingValue);
highlightedSnippet = `\n "${additionalProperty}": ${valueStr}\n ${'^'.repeat(
additionalProperty.length + 2
)} This property is not recognized`;
}
} catch {
// If we can't extract context, that's okay
}
}
} else {
// For other errors, try to extract the offending value
try {
const pathParts = path.split('/').filter(Boolean);
let currentValue: any = blueprint;
for (const part of pathParts) {
if (currentValue && typeof currentValue === 'object') {
currentValue = currentValue[part];
}
}
if (currentValue !== undefined) {
const valueStr = JSON.stringify(currentValue, null, 2);
// Limit snippet length
const snippet =
valueStr.length > 200
? valueStr.substring(0, 200) + '...'
: valueStr;
highlightedSnippet = `\n Value: ${snippet}`;
}
} catch {
// If we can't extract context, that's okay
}
}

return `${
index + 1
}. At path "${path}": ${message}${highlightedSnippet}`;
})
.join('\n\n');
}

export function validateBlueprint(blueprintMaybe: object) {
const valid = blueprintValidator(blueprintMaybe);
if (valid) {
Expand Down
Loading