Improve the Diagnostic Report (#8090)

- Add a shortcut (**F7** by default) and command in the command palette to open diagnostic report at any time
- The report now scans project for missing actions/conditions/expressions from extensions and reports invalid parameters (shown with red underline in Events Sheet)
- Click on error location to navigate directly to the event
This commit is contained in:
Gleb Volkov
2026-02-16 14:33:46 +01:00
committed by GitHub
parent d56ce32ce8
commit a0d4bfbe72
25 changed files with 1577 additions and 22 deletions
@@ -99,10 +99,6 @@ void AbstractReadOnlyArbitraryEventsWorker::VisitEventList(const gd::EventsList&
break;
}
events[i].AcceptVisitor(*this);
if (events[i].CanHaveSubEvents()) {
VisitEventList(events[i].GetSubEvents());
}
}
}
@@ -125,6 +121,12 @@ void AbstractReadOnlyArbitraryEventsWorker::VisitEvent(const gd::BaseEvent& even
}
VisitInstructionList(*actionsVectors[j], false);
}
// Visit sub-events inside VisitEvent to ensure local variables remain in scope
// when using ReadOnlyArbitraryEventsWorkerWithContext
if (!shouldStopIteration && event.CanHaveSubEvents()) {
VisitEventList(event.GetSubEvents());
}
}
void AbstractReadOnlyArbitraryEventsWorker::VisitLinkEvent(const gd::LinkEvent& linkEvent) {
+15
View File
@@ -3642,6 +3642,21 @@ interface EventsContextAnalyzer {
void Launch([Ref] EventsList events, [Const, Ref] ProjectScopedContainers projectScopedContainers);
};
interface ReadOnlyArbitraryEventsWorkerWithContext {
void Launch([Const, Ref] EventsList events, [Const, Ref] ProjectScopedContainers projectScopedContainers);
};
[JSImplementation=ReadOnlyArbitraryEventsWorkerWithContext]
interface ReadOnlyArbitraryEventsWorkerWithContextJS {
void ReadOnlyArbitraryEventsWorkerWithContextJS();
// Called for each event visited
void DoVisitEvent([Const, Ref] BaseEvent event);
// Called for each instruction visited, with the current scoped containers (includes local variables)
void DoVisitInstruction([Const, Ref] Instruction instruction, boolean isCondition, [Const, Ref] ProjectScopedContainers projectScopedContainers);
};
ReadOnlyArbitraryEventsWorkerWithContextJS implements ReadOnlyArbitraryEventsWorkerWithContext;
interface ArbitraryResourceWorker {
};
[JSImplementation=ArbitraryResourceWorker]
+36
View File
@@ -371,6 +371,42 @@ class AbstractFileSystemJS : public AbstractFileSystem {
virtual ~AbstractFileSystemJS(){};
};
/**
* \brief Manual binding of gd::ReadOnlyArbitraryEventsWorkerWithContext to allow
* overriding DoVisitEvent and DoVisitInstruction from JavaScript.
*/
class ReadOnlyArbitraryEventsWorkerWithContextJS : public ReadOnlyArbitraryEventsWorkerWithContext {
public:
ReadOnlyArbitraryEventsWorkerWithContextJS(){};
virtual ~ReadOnlyArbitraryEventsWorkerWithContextJS(){};
virtual void DoVisitEvent(const gd::BaseEvent &event) {
EM_ASM(
{
var self = Module['getCache'](Module['ReadOnlyArbitraryEventsWorkerWithContextJS'])[$0];
if (!self.hasOwnProperty('doVisitEvent'))
throw 'a JSImplementation must implement all functions, you forgot ReadOnlyArbitraryEventsWorkerWithContextJS::doVisitEvent.';
self.doVisitEvent(wrapPointer($1, Module['BaseEvent']));
},
(int)this,
(int)&event);
}
virtual void DoVisitInstruction(const gd::Instruction &instruction, bool isCondition) {
EM_ASM(
{
var self = Module['getCache'](Module['ReadOnlyArbitraryEventsWorkerWithContextJS'])[$0];
if (!self.hasOwnProperty('doVisitInstruction'))
throw 'a JSImplementation must implement all functions, you forgot ReadOnlyArbitraryEventsWorkerWithContextJS::doVisitInstruction.';
self.doVisitInstruction(wrapPointer($1, Module['Instruction']), !!$2, wrapPointer($3, Module['ProjectScopedContainers']));
},
(int)this,
(int)&instruction,
isCondition,
(int)&GetProjectScopedContainers());
}
};
class InitialInstanceJSFunctorWrapper : public gd::InitialInstanceFunctor {
public:
InitialInstanceJSFunctorWrapper(){};
+10
View File
@@ -2673,6 +2673,16 @@ export class EventsContextAnalyzer extends EmscriptenObject {
launch(events: EventsList, projectScopedContainers: ProjectScopedContainers): void;
}
export class ReadOnlyArbitraryEventsWorkerWithContext extends EmscriptenObject {
launch(events: EventsList, projectScopedContainers: ProjectScopedContainers): void;
}
export class ReadOnlyArbitraryEventsWorkerWithContextJS extends ReadOnlyArbitraryEventsWorkerWithContext {
constructor();
doVisitEvent(event: BaseEvent): void;
doVisitInstruction(instruction: Instruction, isCondition: boolean, projectScopedContainers: ProjectScopedContainers): void;
}
export class ArbitraryResourceWorker extends EmscriptenObject {}
export class ArbitraryResourceWorkerJS extends ArbitraryResourceWorker {
@@ -0,0 +1,6 @@
// Automatically generated by GDevelop.js/scripts/generate-types.js
declare class gdReadOnlyArbitraryEventsWorkerWithContext {
launch(events: gdEventsList, projectScopedContainers: gdProjectScopedContainers): void;
delete(): void;
ptr: number;
};
@@ -0,0 +1,8 @@
// Automatically generated by GDevelop.js/scripts/generate-types.js
declare class gdReadOnlyArbitraryEventsWorkerWithContextJS extends gdReadOnlyArbitraryEventsWorkerWithContext {
constructor(): void;
doVisitEvent(event: gdBaseEvent): void;
doVisitInstruction(instruction: gdInstruction, isCondition: boolean, projectScopedContainers: gdProjectScopedContainers): void;
delete(): void;
ptr: number;
};
+2
View File
@@ -260,6 +260,8 @@ declare class libGDevelop {
InstructionsTypeRenamer: Class<gdInstructionsTypeRenamer>;
EventsContext: Class<gdEventsContext>;
EventsContextAnalyzer: Class<gdEventsContextAnalyzer>;
ReadOnlyArbitraryEventsWorkerWithContext: Class<gdReadOnlyArbitraryEventsWorkerWithContext>;
ReadOnlyArbitraryEventsWorkerWithContextJS: Class<gdReadOnlyArbitraryEventsWorkerWithContextJS>;
ArbitraryResourceWorker: Class<gdArbitraryResourceWorker>;
ArbitraryResourceWorkerJS: Class<gdArbitraryResourceWorkerJS>;
ResourcesMergingHelper: Class<gdResourcesMergingHelper>;
+3
View File
@@ -64,11 +64,14 @@ function patchGlueCppFile(cb) {
'BehaviorJsImplementation',
'ObjectJsImplementation',
'BehaviorSharedDataJsImplementation',
'ReadOnlyArbitraryEventsWorkerWithContextJS',
];
var functionsToErase = [
'emscripten_bind_ArbitraryResourceWorkerJS_ExposeImage_1',
'emscripten_bind_ArbitraryResourceWorkerJS_ExposeShader_1',
'emscripten_bind_ArbitraryResourceWorkerJS_ExposeFile_1',
'emscripten_bind_ReadOnlyArbitraryEventsWorkerWithContextJS_DoVisitEvent_1',
'emscripten_bind_ReadOnlyArbitraryEventsWorkerWithContextJS_DoVisitInstruction_3',
];
fs.readFile(file, function(err, data) {
if (err) cb(err);
@@ -10,6 +10,7 @@ export type CommandName =
| 'LAUNCH_NETWORK_PREVIEW'
| 'HOT_RELOAD_PREVIEW'
| 'LAUNCH_PREVIEW_WITH_DIAGNOSTIC_REPORT'
| 'OPEN_DIAGNOSTIC_REPORT'
| 'OPEN_HOME_PAGE'
| 'CREATE_NEW_PROJECT'
| 'OPEN_PROJECT'
@@ -118,6 +119,10 @@ const commandsList: { [CommandName]: CommandMetadata } = {
area: 'PROJECT',
displayText: t`Launch preview with diagnostic report`,
},
OPEN_DIAGNOSTIC_REPORT: {
area: 'PROJECT',
displayText: t`Show diagnostic report`,
},
OPEN_HOME_PAGE: { area: 'IDE', displayText: t`Show Home` },
CREATE_NEW_PROJECT: {
area: 'GENERAL',
+43 -2
View File
@@ -124,6 +124,7 @@ import LocalVariablesDialog from '../VariablesList/LocalVariablesDialog';
import GlobalAndSceneVariablesDialog from '../VariablesList/GlobalAndSceneVariablesDialog';
import { type HotReloadPreviewButtonProps } from '../HotReload/HotReloadPreviewButton';
import { useHighlightedAiGeneratedEvent } from './UseHighlightedAiGeneratedEvent';
import { findEventByPath } from '../Utils/EventsValidationScanner';
const gd: libGDevelop = global.gd;
@@ -222,6 +223,7 @@ type State = {|
showSearchPanel: boolean,
searchResults: ?Array<gdBaseEvent>,
searchFocusOffset: ?number,
navigationHighlightEvent: ?gdBaseEvent,
layoutVariablesDialogOpen: boolean,
@@ -317,6 +319,7 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
showSearchPanel: false,
searchResults: null,
searchFocusOffset: null,
navigationHighlightEvent: null,
layoutVariablesDialogOpen: false,
@@ -391,6 +394,33 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
);
};
scrollToEventPath = (eventPath: Array<number>) => {
const eventsTree = this._eventsTree;
if (!eventsTree || eventPath.length === 0) return;
// Find the event at the path
const event = findEventByPath(this.props.events, eventPath);
if (!event) return;
// Unfold and scroll to the event
eventsTree.unfoldForEvent(event);
// Highlight the event like search results
this.setState({ navigationHighlightEvent: event });
setTimeout(() => {
const row = eventsTree.getEventRow(event);
if (row !== -1) {
eventsTree.scrollToRow(row);
}
}, 100 /* Give some time for the events sheet to render before scrolling */);
// Clear the highlight after a few seconds
setTimeout(() => {
this.setState({ navigationHighlightEvent: null });
}, 3000);
};
updateToolbar() {
if (!this.props.setToolbar) return;
@@ -2228,8 +2258,14 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
}}
onOpenExternalEvents={onOpenExternalEvents}
onOpenLayout={onOpenLayout}
searchResults={eventsSearchResultEvents}
searchFocusOffset={searchFocusOffset}
searchResults={
this.state.navigationHighlightEvent
? [this.state.navigationHighlightEvent]
: eventsSearchResultEvents
}
searchFocusOffset={
this.state.navigationHighlightEvent ? 0 : searchFocusOffset
}
onEventMoved={this._onEventMoved}
onEndEditingEvent={this._onEndEditingStringEvent}
showObjectThumbnails={
@@ -2468,6 +2504,7 @@ export type EventsSheetInterface = {|
updateToolbar: () => void,
onResourceExternallyChanged: ({| identifier: string |}) => void,
onEventsModifiedOutsideEditor: (changes: OutOfEditorChanges) => void,
scrollToEventPath: (eventPath: Array<number>) => void,
|};
// EventsSheet is a wrapper so that the component can use multiple
@@ -2477,6 +2514,7 @@ const EventsSheet = (props, ref) => {
updateToolbar,
onResourceExternallyChanged,
onEventsModifiedOutsideEditor,
scrollToEventPath,
}));
const {
@@ -2496,6 +2534,9 @@ const EventsSheet = (props, ref) => {
addNewAiGeneratedEventIds(changes.newOrChangedAiGeneratedEventIds);
if (component.current) component.current.onEventsModifiedOutsideEditor();
};
const scrollToEventPath = (eventPath: Array<number>) => {
if (component.current) component.current.scrollToEventPath(eventPath);
};
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const preferences = React.useContext(PreferencesContext);
@@ -3,7 +3,7 @@ import { Trans } from '@lingui/macro';
import * as React from 'react';
import Text from '../UI/Text';
import Toggle from '../UI/Toggle';
import { ColumnStackLayout } from '../UI/Layout';
import { ColumnStackLayout, LineStackLayout } from '../UI/Layout';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import { mapFor } from '../Utils/MapFor';
import {
@@ -16,12 +16,208 @@ import {
} from '../UI/Table';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import PreferencesContext from '../MainFrame/Preferences/PreferencesContext';
import AlertMessage from '../UI/AlertMessage';
import {
scanProjectForValidationErrors,
groupValidationErrors,
type ValidationError,
} from '../Utils/EventsValidationScanner';
import { getFunctionNameFromType } from '../EventsFunctionsExtensionsLoader';
import Link from '../UI/Link';
import IconButton from '../UI/IconButton';
import ChevronArrowRight from '../UI/CustomSvgIcons/ChevronArrowRight';
import ChevronArrowBottom from '../UI/CustomSvgIcons/ChevronArrowBottom';
const gd: libGDevelop = global.gd;
const styles = {
table: {
tableLayout: 'fixed',
width: '100%',
},
locationCell: {
width: '33%',
verticalAlign: 'top',
},
locationText: {
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
},
instructionCell: {
width: '67%',
overflow: 'hidden',
},
instructionContent: {
display: 'flex',
alignItems: 'flex-start',
gap: 8,
width: '100%',
},
instructionTextCollapsed: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
},
instructionTextExpanded: {
flex: 1,
wordBreak: 'break-word',
whiteSpace: 'normal',
minWidth: 0,
},
expandButtonContainer: {
flexShrink: 0,
marginLeft: 'auto',
},
expandButton: {
padding: 4,
},
typeLabel: {
fontWeight: 'bold',
},
};
type InvalidParameterRowProps = {|
error: ValidationError,
navigateToError: (error: ValidationError) => void,
backgroundColor: string,
|};
// Threshold for assuming text might be truncated in the table cell
const TRUNCATION_THRESHOLD_CHARS = 60;
const InvalidParameterRow = ({
error,
navigateToError,
backgroundColor,
}: InvalidParameterRowProps) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const typeLabel = error.isCondition ? 'Condition' : 'Action';
const couldBeTruncated =
error.instructionSentence.length > TRUNCATION_THRESHOLD_CHARS;
return (
<TableRow
style={{
backgroundColor,
}}
>
<TableRowColumn style={styles.locationCell}>
<div style={styles.locationText}>
<Link href="#" onClick={() => navigateToError(error)}>
{error.locationType === 'scene' ? 'scene' : 'external-events'}:{' '}
{error.locationName}
</Link>
</div>
</TableRowColumn>
<TableRowColumn style={styles.instructionCell}>
<div style={styles.instructionContent}>
<div
style={{
...(isExpanded
? styles.instructionTextExpanded
: styles.instructionTextCollapsed),
cursor: couldBeTruncated ? 'pointer' : 'default',
}}
onClick={
couldBeTruncated ? () => setIsExpanded(!isExpanded) : undefined
}
title={
couldBeTruncated && !isExpanded
? error.instructionSentence
: undefined
}
>
<span style={styles.typeLabel}>{typeLabel}</span>{' '}
{error.instructionSentence}
</div>
{couldBeTruncated && (
<div style={styles.expandButtonContainer}>
<IconButton
size="small"
style={styles.expandButton}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? <ChevronArrowBottom /> : <ChevronArrowRight />}
</IconButton>
</div>
)}
</div>
</TableRowColumn>
</TableRow>
);
};
type InvalidParametersSectionProps = {|
validationErrors: Array<ValidationError>,
invalidParametersCount: number,
navigateToError: (error: ValidationError) => void,
gdevelopTheme: any,
|};
const InvalidParametersSection = ({
validationErrors,
invalidParametersCount,
navigateToError,
gdevelopTheme,
}: InvalidParametersSectionProps) => {
return (
<ColumnStackLayout noMargin>
<Text size="block-title">
<Trans>Invalid parameters in events ({invalidParametersCount})</Trans>
</Text>
<AlertMessage kind="error">
<Trans>
The following events have invalid parameters (shown with red underline
in the events sheet). Click a location to navigate there.
</Trans>
</AlertMessage>
<Table style={styles.table}>
<TableHeader>
<TableRow>
<TableHeaderColumn style={styles.locationCell}>
<Trans>Location</Trans>
</TableHeaderColumn>
<TableHeaderColumn style={styles.instructionCell}>
<Trans>Instruction</Trans>
</TableHeaderColumn>
</TableRow>
</TableHeader>
<TableBody>
{validationErrors
.filter(error => error.type !== 'missing-instruction')
.map((error, index) => (
<InvalidParameterRow
key={`${error.locationName}-${error.eventPath.join('-')}-${
error.instructionType
}-${error.parameterIndex ?? ''}-${index}`}
error={error}
navigateToError={navigateToError}
backgroundColor={gdevelopTheme.list.itemsBackgroundColor}
/>
))}
</TableBody>
</Table>
</ColumnStackLayout>
);
};
type Props = {|
project: gdProject,
wholeProjectDiagnosticReport: gdWholeProjectDiagnosticReport,
onClose: () => void,
onNavigateToLayoutEvent: (
layoutName: string,
eventPath: Array<number>
) => void,
onNavigateToExternalEventsEvent: (
externalEventsName: string,
eventPath: Array<number>
) => void,
|};
const addFor = (map, key, value) => {
@@ -34,12 +230,63 @@ const addFor = (map, key, value) => {
};
export default function DiagnosticReportDialog({
project,
wholeProjectDiagnosticReport,
onClose,
onNavigateToLayoutEvent,
onNavigateToExternalEventsEvent,
}: Props) {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const preferences = React.useContext(PreferencesContext);
// Scan project for validation errors (missing instructions, invalid parameters)
const validationErrors = React.useMemo(
() => {
try {
return scanProjectForValidationErrors(project);
} catch (error) {
console.error('Error scanning project for validation errors:', error);
return [];
}
},
[project]
);
const groupedErrors = React.useMemo(
() => groupValidationErrors(validationErrors),
[validationErrors]
);
const missingInstructionsCount = validationErrors.filter(
e => e.type === 'missing-instruction'
).length;
const invalidParametersCount = validationErrors.filter(
e => e.type !== 'missing-instruction'
).length;
const hasMissingInstructions = missingInstructionsCount > 0;
const hasInvalidParameters = invalidParametersCount > 0;
const hasValidationErrors = hasMissingInstructions || hasInvalidParameters;
const navigateToError = React.useCallback(
(error: ValidationError) => {
onClose();
if (error.locationType === 'scene') {
onNavigateToLayoutEvent(error.locationName, error.eventPath);
} else if (error.locationType === 'external-events') {
onNavigateToExternalEventsEvent(error.locationName, error.eventPath);
}
},
[onClose, onNavigateToLayoutEvent, onNavigateToExternalEventsEvent]
);
const renderMissingInstructionName = (type: string) => {
const { name, behaviorName } = getFunctionNameFromType(type);
if (behaviorName) {
return `${name} (${behaviorName})`;
}
return name;
};
const renderDiagnosticReport = React.useCallback(
(diagnosticReport: gdDiagnosticReport) => {
// TODO Generalize error aggregation when enough errors are handled to have a clearer view.
@@ -224,6 +471,9 @@ export default function DiagnosticReportDialog({
[gdevelopTheme.list.itemsBackgroundColor]
);
const hasNativeReport = wholeProjectDiagnosticReport.hasAnyIssue();
const hasAnyIssue = hasNativeReport || hasValidationErrors;
return (
<Dialog
id="diagnostic-report-dialog"
@@ -251,9 +501,116 @@ export default function DiagnosticReportDialog({
onRequestClose={onClose}
onApply={onClose}
open
maxWidth="sm"
maxWidth="md"
>
<ColumnStackLayout noMargin useLargeSpacer>
{!hasAnyIssue && (
<AlertMessage kind="info">
<Trans>No issues found in your project.</Trans>
</AlertMessage>
)}
{/* Missing instructions from extensions */}
{hasMissingInstructions && (
<ColumnStackLayout noMargin>
<Text size="block-title">
<Trans>
Missing actions/conditions/expressions (
{missingInstructionsCount})
</Trans>
</Text>
<AlertMessage kind="warning">
<Trans>
The following actions, conditions, or expressions no longer
exist in their extensions. This can happen when an extension's
API has changed or when functionality has been removed. Update
or remove these instructions.
</Trans>
</AlertMessage>
<Table>
<TableHeader>
<TableRow>
<TableHeaderColumn>
<Trans>Extension</Trans>
</TableHeaderColumn>
<TableHeaderColumn>
<Trans>Missing instructions</Trans>
</TableHeaderColumn>
<TableHeaderColumn>
<Trans>Location</Trans>
</TableHeaderColumn>
</TableRow>
</TableHeader>
<TableBody>
{[...groupedErrors.missingInstructions.entries()].map(
([extensionName, errors]) => (
<TableRow
key={`missing-ext-${extensionName}`}
style={{
backgroundColor:
gdevelopTheme.list.itemsBackgroundColor,
}}
>
<TableRowColumn>
<Text size="body" color="error">
{extensionName}
</Text>
</TableRowColumn>
<TableRowColumn>
<Text size="body" allowSelection>
{[
...new Set(
errors.map(e =>
renderMissingInstructionName(e.instructionType)
)
),
].join(', ')}
</Text>
</TableRowColumn>
<TableRowColumn>
{[
...new Set(
errors.map(
e => `${e.locationType}: ${e.locationName}`
)
),
].map(location => {
const error = errors.find(
e =>
`${e.locationType}: ${e.locationName}` ===
location
);
return (
<LineStackLayout key={location} noMargin>
<Link
href="#"
onClick={() => error && navigateToError(error)}
>
{location}
</Link>
</LineStackLayout>
);
})}
</TableRowColumn>
</TableRow>
)
)}
</TableBody>
</Table>
</ColumnStackLayout>
)}
{/* Invalid parameters */}
{hasInvalidParameters && (
<InvalidParametersSection
validationErrors={validationErrors}
invalidParametersCount={invalidParametersCount}
navigateToError={navigateToError}
gdevelopTheme={gdevelopTheme}
/>
)}
{/* Native diagnostic report (from C++ code) */}
{mapFor(0, wholeProjectDiagnosticReport.count(), index => {
const diagnosticReport = wholeProjectDiagnosticReport.get(index);
return (
@@ -10,6 +10,7 @@ const defaultShortcuts: ShortcutMap = {
LAUNCH_DEBUG_PREVIEW: 'F6',
HOT_RELOAD_PREVIEW: 'F5',
LAUNCH_NETWORK_PREVIEW: 'F8',
OPEN_DIAGNOSTIC_REPORT: 'F7',
OPEN_HOME_PAGE: '',
CREATE_NEW_PROJECT: 'CmdOrCtrl+Alt+KeyN',
OPEN_PROJECT: 'CmdOrCtrl+KeyO',
@@ -63,6 +63,10 @@ export class EventsEditorContainer extends React.Component<RenderEditorContainer
}
}
scrollToEventPath(eventPath: Array<number>) {
if (this.editor) this.editor.scrollToEventPath(eventPath);
}
forceUpdateEditor() {
// No updates to be done.
}
@@ -93,6 +93,10 @@ export class ExternalEventsEditorContainer extends React.Component<
}
}
scrollToEventPath(eventPath: Array<number>) {
if (this.editor) this.editor.scrollToEventPath(eventPath);
}
forceUpdateEditor() {
// No updates to be done.
}
@@ -48,6 +48,7 @@ type CommandHandlers = {|
onLaunchNetworkPreview: () => Promise<void>,
onHotReloadPreview: () => Promise<void>,
onLaunchPreviewWithDiagnosticReport: () => Promise<void>,
onOpenDiagnosticReport: () => void,
allowNetworkPreview: boolean,
onOpenHomePage: () => void,
onCreateProject: () => void,
@@ -112,6 +113,10 @@ const useMainFrameCommands = (handlers: CommandHandlers) => {
}
);
useCommand('OPEN_DIAGNOSTIC_REPORT', !!handlers.project, {
handler: handlers.onOpenDiagnosticReport,
});
useCommand('OPEN_HOME_PAGE', true, {
handler: handlers.onOpenHomePage,
});
@@ -214,6 +214,7 @@ export type PreferencesValues = {|
| 'icon'
| 'icon-and-deprecated-warning-text',
openDiagnosticReportAutomatically: boolean,
blockPreviewAndExportOnDiagnosticErrors: boolean,
use3DEditor: boolean,
showBasicProfilingCounters: boolean,
inAppTutorialsProgress: InAppTutorialProgressDatabase,
@@ -312,6 +313,8 @@ export type Preferences = {|
setShowInAppTutorialDeveloperMode: (enabled: boolean) => void,
setOpenDiagnosticReportAutomatically: (enabled: boolean) => void,
getOpenDiagnosticReportAutomatically: () => boolean,
setBlockPreviewAndExportOnDiagnosticErrors: (enabled: boolean) => void,
getBlockPreviewAndExportOnDiagnosticErrors: () => boolean,
setShowDeprecatedInstructionWarning: (
value: 'no' | 'icon' | 'icon-and-deprecated-warning-text'
) => void,
@@ -402,6 +405,7 @@ export const initialPreferences = {
showCreateSectionByDefault: false,
showInAppTutorialDeveloperMode: false,
openDiagnosticReportAutomatically: true,
blockPreviewAndExportOnDiagnosticErrors: false,
showDeprecatedInstructionWarning: 'no',
use3DEditor: isWebGLSupported(),
showBasicProfilingCounters: false,
@@ -481,6 +485,8 @@ export const initialPreferences = {
) => {},
getOpenDiagnosticReportAutomatically: () => true,
setOpenDiagnosticReportAutomatically: (enabled: boolean) => {},
getBlockPreviewAndExportOnDiagnosticErrors: () => false,
setBlockPreviewAndExportOnDiagnosticErrors: (enabled: boolean) => {},
getShowDeprecatedInstructionWarning: () => 'no',
setUse3DEditor: (enabled: boolean) => {},
getUse3DEditor: () => false,
@@ -73,6 +73,7 @@ const PreferencesDialog = ({
setShowExperimentalExtensions,
setShowInAppTutorialDeveloperMode,
setOpenDiagnosticReportAutomatically,
setBlockPreviewAndExportOnDiagnosticErrors,
setShowDeprecatedInstructionWarning,
setUse3DEditor,
setShowBasicProfilingCounters,
@@ -444,6 +445,15 @@ const PreferencesDialog = ({
t`Automatically open the diagnostic report at preview`
)}
/>
<CompactToggleField
labelColor="primary"
hideTooltip
onCheck={setBlockPreviewAndExportOnDiagnosticErrors}
checked={values.blockPreviewAndExportOnDiagnosticErrors}
label={i18n._(
t`Block preview and export when diagnostic errors are found`
)}
/>
<CompactToggleField
labelColor="primary"
hideTooltip
@@ -171,6 +171,12 @@ export default class PreferencesProvider extends React.Component<Props, State> {
getOpenDiagnosticReportAutomatically: this._getOpenDiagnosticReportAutomatically.bind(
this
),
setBlockPreviewAndExportOnDiagnosticErrors: this._setBlockPreviewAndExportOnDiagnosticErrors.bind(
this
),
getBlockPreviewAndExportOnDiagnosticErrors: this._getBlockPreviewAndExportOnDiagnosticErrors.bind(
this
),
setShowDeprecatedInstructionWarning: this._setShowDeprecatedInstructionWarning.bind(
this
),
@@ -515,6 +521,24 @@ export default class PreferencesProvider extends React.Component<Props, State> {
return this.state.values.openDiagnosticReportAutomatically;
}
_setBlockPreviewAndExportOnDiagnosticErrors(
blockPreviewAndExportOnDiagnosticErrors: boolean
) {
this.setState(
state => ({
values: {
...state.values,
blockPreviewAndExportOnDiagnosticErrors,
},
}),
() => this._persistValuesToLocalStorage(this.state)
);
}
_getBlockPreviewAndExportOnDiagnosticErrors() {
return this.state.values.blockPreviewAndExportOnDiagnosticErrors;
}
_setShowDeprecatedInstructionWarning(
showDeprecatedInstructionWarning:
| 'no'
@@ -0,0 +1,68 @@
// @flow
import * as React from 'react';
import { type EditorTabsState } from './EditorTabs/EditorTabsHandler';
export type EventNavigationTarget = {|
name: string,
locationType: 'layout' | 'external-events',
eventPath: Array<number>,
|};
type UseNavigationToEventProps = {|
editorTabs: EditorTabsState,
|};
type UseNavigationToEventResult = {|
setPendingEventNavigation: (target: EventNavigationTarget | null) => void,
|};
const EDITOR_MOUNT_DELAY_MS = 300;
/**
* Hook to handle navigation to a specific event in an events editor.
* Sets up a pending navigation that will scroll to the event once the editor is mounted.
*/
export const useNavigationToEvent = ({
editorTabs,
}: UseNavigationToEventProps): UseNavigationToEventResult => {
const [
pendingEventNavigation,
setPendingEventNavigation,
] = React.useState<?EventNavigationTarget>(null);
React.useEffect(
() => {
if (!pendingEventNavigation) return;
const timeoutId = setTimeout(() => {
const { name, locationType, eventPath } = pendingEventNavigation;
const editorKind =
locationType === 'layout' ? 'layout events' : 'external events';
for (const paneIdentifier in editorTabs.panes) {
const pane = editorTabs.panes[paneIdentifier];
for (const editor of pane.editors) {
if (
editor.kind === editorKind &&
editor.projectItemName === name &&
editor.editorRef &&
editor.editorRef.scrollToEventPath
) {
// $FlowFixMe - scrollToEventPath is checked above
editor.editorRef.scrollToEventPath(eventPath);
setPendingEventNavigation(null);
return;
}
}
}
setPendingEventNavigation(null);
}, EDITOR_MOUNT_DELAY_MS);
return () => clearTimeout(timeoutId);
},
[pendingEventNavigation, editorTabs]
);
return { setPendingEventNavigation };
};
+114 -14
View File
@@ -192,6 +192,7 @@ import { type CourseChapter } from '../Utils/GDevelopServices/Asset';
import useVersionHistory from '../VersionHistory/UseVersionHistory';
import { ProjectManagerDrawer } from '../ProjectManager/ProjectManagerDrawer';
import DiagnosticReportDialog from '../ExportAndShare/DiagnosticReportDialog';
import { scanProjectForValidationErrors } from '../Utils/EventsValidationScanner';
import useSaveReminder from './UseSaveReminder';
import { useMultiplayerLobbyConfigurator } from './UseMultiplayerLobbyConfigurator';
import { useAuthenticatedPlayer } from './UseAuthenticatedPlayer';
@@ -212,6 +213,7 @@ import {
isEditorHotReloadNeeded,
} from '../EmbeddedGame/EmbeddedGameFrame';
import useHomePageSwitch from './useHomePageSwitch';
import { useNavigationToEvent } from './UseNavigationToEvent';
import RobotIcon from '../ProjectCreation/RobotIcon';
import PublicProfileContext from '../Profile/PublicProfileContext';
import { useGamesPlatformFrame } from './EditorContainers/HomePage/PlaySection/UseGamesPlatformFrame';
@@ -466,6 +468,57 @@ const MainFrame = (props: Props) => {
const preferences = React.useContext(PreferencesContext);
const { setHasProjectOpened } = preferences;
const { previewLoadingRef, setPreviewLoading } = usePreviewLoadingState();
const shortcutMap = useShortcutMap();
const [
diagnosticReportDialogOpen,
setDiagnosticReportDialogOpen,
] = React.useState<boolean>(false);
/**
* Checks for diagnostic errors in the project if blocking is enabled.
* Returns true if there are errors and the action should be blocked.
*/
const checkDiagnosticErrorsAndIfShouldBlock = React.useCallback(
async (
project: ?gdProject,
actionType: 'preview' | 'export'
): Promise<boolean> => {
if (
!project ||
!preferences.getBlockPreviewAndExportOnDiagnosticErrors()
) {
return false;
}
try {
const validationErrors = scanProjectForValidationErrors(project);
if (validationErrors.length > 0) {
const openReport = await showConfirmation({
title: t`Diagnostic errors found`,
message:
actionType === 'preview'
? t`Your project has ${
validationErrors.length
} diagnostic error(s). Please fix them before launching a preview.`
: t`Your project has ${
validationErrors.length
} diagnostic error(s). Please fix them before exporting.`,
dismissButtonLabel: t`Close`,
confirmButtonLabel: t`Open report`,
});
if (openReport) {
setDiagnosticReportDialogOpen(true);
}
return true;
}
} catch (error) {
console.error('Error scanning project for validation errors:', error);
}
return false;
},
[preferences, showConfirmation, setDiagnosticReportDialogOpen]
);
const [previewState, setPreviewState] = React.useState(initialPreviewState);
const commandPaletteRef = React.useRef((null: ?CommandPaletteInterface));
const inAppTutorialOrchestratorRef = React.useRef<?InAppTutorialOrchestratorInterface>(
@@ -535,10 +588,9 @@ const MainFrame = (props: Props) => {
quitInAppTutorialDialogOpen,
setQuitInAppTutorialDialogOpen,
] = React.useState<boolean>(false);
const [
diagnosticReportDialogOpen,
setDiagnosticReportDialogOpen,
] = React.useState<boolean>(false);
const { setPendingEventNavigation } = useNavigationToEvent({
editorTabs: state.editorTabs,
});
const [
fileMetadataOpeningProgress,
setFileMetadataOpeningProgress,
@@ -807,13 +859,19 @@ const MainFrame = (props: Props) => {
);
const openShareDialog = React.useCallback(
(initialTab?: ShareTab) => {
async (initialTab?: ShareTab) => {
if (
await checkDiagnosticErrorsAndIfShouldBlock(currentProject, 'export')
) {
return;
}
notifyPreviewOrExportWillStart(state.editorTabs);
setShareDialogInitialTab(initialTab || null);
setShareDialogOpen(true);
},
[state.editorTabs]
[state.editorTabs, currentProject, checkDiagnosticErrorsAndIfShouldBlock]
);
const closeShareDialog = React.useCallback(
@@ -2183,6 +2241,12 @@ const MainFrame = (props: Props) => {
if (!currentProject) return;
if (currentProject.getLayoutsCount() === 0) return;
if (
await checkDiagnosticErrorsAndIfShouldBlock(currentProject, 'preview')
) {
return;
}
console.info(
`Launching a new ${
isForInGameEdition ? 'in-game edition preview' : 'preview'
@@ -2400,6 +2464,7 @@ const MainFrame = (props: Props) => {
inGameEditorSettings,
previewLoadingRef,
setPreviewLoading,
checkDiagnosticErrorsAndIfShouldBlock,
]
);
@@ -4644,6 +4709,7 @@ const MainFrame = (props: Props) => {
onLaunchDebugPreview: launchDebuggerAndPreview,
onLaunchNetworkPreview: launchNetworkPreview,
onLaunchPreviewWithDiagnosticReport: launchPreviewWithDiagnosticReport,
onOpenDiagnosticReport: () => setDiagnosticReportDialogOpen(true),
onOpenHomePage: openHomePage,
onCreateProject: () => setNewProjectSetupDialogOpen(true),
onOpenProject: () => openOpenFromStorageProviderDialog(),
@@ -4653,8 +4719,12 @@ const MainFrame = (props: Props) => {
onCloseProject: async () => {
askToCloseProject();
},
onExportGame: () => openShareDialog('publish'),
onInviteCollaborators: () => openShareDialog('invite'),
onExportGame: () => {
openShareDialog('publish');
},
onInviteCollaborators: () => {
openShareDialog('invite');
},
onOpenLayout: name => {
openLayout(name);
},
@@ -4737,7 +4807,6 @@ const MainFrame = (props: Props) => {
previewLoading === 'hot-reload-for-in-game-edition';
const showLoaderImmediately =
isProjectOpening || isLoadingProject || previewLoading === 'preview';
const shortcutMap = useShortcutMap();
const buildMainMenuProps = {
i18n: i18n,
@@ -4756,8 +4825,12 @@ const MainFrame = (props: Props) => {
onShowVersionHistory: openVersionHistoryPanel,
onCloseProject: askToCloseProject,
onCloseApp: closeApp,
onExportProject: () => openShareDialog('publish'),
onInviteCollaborators: () => openShareDialog('invite'),
onExportProject: () => {
openShareDialog('publish');
},
onInviteCollaborators: () => {
openShareDialog('invite');
},
onCreateProject: () => setNewProjectSetupDialogOpen(true),
onOpenProjectManager: () => openProjectManager(true),
onOpenHomePage: openHomePage,
@@ -4800,7 +4873,9 @@ const MainFrame = (props: Props) => {
saveProjectAsWithStorageProvider: saveProjectAsWithStorageProvider,
onCheckoutVersion: onCheckoutVersion,
getOrLoadProjectVersion: getOrLoadProjectVersion,
openShareDialog: openShareDialog,
openShareDialog: tab => {
openShareDialog(tab);
},
launchDebuggerAndPreview: launchDebuggerAndPreview,
launchNewPreview: launchNewPreview,
launchNetworkPreview: launchNetworkPreview,
@@ -4898,7 +4973,9 @@ const MainFrame = (props: Props) => {
sourceGameId: quickCustomizationDialogOpenedFromGameId || '',
getIncludeFileHashs:
eventsFunctionsExtensionsContext.getIncludeFileHashs,
onExport: () => openShareDialog('publish'),
onExport: () => {
openShareDialog('publish');
},
onCaptureFinished,
},
(previewLauncher: ?PreviewLauncherInterface) => {
@@ -4959,7 +5036,9 @@ const MainFrame = (props: Props) => {
onExtensionInstalled={onExtensionInstalled}
onSceneAdded={onSceneAdded}
onExternalLayoutAdded={onExternalLayoutAdded}
onShareProject={() => openShareDialog()}
onShareProject={() => {
openShareDialog();
}}
isOpen={projectManagerOpen}
hotReloadPreviewButtonProps={hotReloadPreviewButtonProps}
resourceManagementProps={resourceManagementProps}
@@ -5265,8 +5344,29 @@ const MainFrame = (props: Props) => {
)}
{diagnosticReportDialogOpen && currentProject && (
<DiagnosticReportDialog
project={currentProject}
wholeProjectDiagnosticReport={currentProject.getWholeProjectDiagnosticReport()}
onClose={() => setDiagnosticReportDialogOpen(false)}
onNavigateToLayoutEvent={(layoutName, eventPath) => {
setPendingEventNavigation({
name: layoutName,
locationType: 'layout',
eventPath,
});
openLayout(layoutName, {
openEventsEditor: true,
openSceneEditor: false,
focusWhenOpened: 'events',
});
}}
onNavigateToExternalEventsEvent={(externalEventsName, eventPath) => {
setPendingEventNavigation({
name: externalEventsName,
locationType: 'external-events',
eventPath,
});
openExternalEvents(externalEventsName);
}}
/>
)}
{standaloneDialogOpen && (
+6
View File
@@ -15,11 +15,17 @@ type TableCellCommonProps = {|
paddingRight?: number,
textAlign?: string,
wordBreak?: 'break-word',
verticalAlign?: string,
overflow?: string,
|},
|};
type TableProps = {|
children: React.Node, // Should be TableHeader, TableBody or TableFooter
style?: {|
tableLayout?: string,
width?: string | number,
|},
|};
/**
@@ -34,6 +34,7 @@ const allowedPreferenceKeys: $ReadOnlyArray<
'showInAppTutorialDeveloperMode',
'showDeprecatedInstructionWarning',
'openDiagnosticReportAutomatically',
'blockPreviewAndExportOnDiagnosticErrors',
'use3DEditor',
'showBasicProfilingCounters',
'newProjectsDefaultFolder',
@@ -0,0 +1,325 @@
// @flow
// Scanner for validation errors in events (missing instructions, invalid parameters)
import { mapFor } from './MapFor';
import { getFunctionNameFromType } from '../EventsFunctionsExtensionsLoader';
const gd: libGDevelop = global.gd;
export type ValidationErrorType =
| 'missing-instruction'
| 'invalid-parameter'
| 'missing-parameter';
export type ValidationError = {|
type: ValidationErrorType,
isCondition: boolean,
instructionType: string,
instructionSentence: string,
parameterIndex?: number,
parameterValue?: string,
locationName: string,
locationType: 'scene' | 'external-events' | 'extension',
eventPath: Array<number>,
|};
const getInstructionSentence = (
instruction: gdInstruction,
metadata: gdInstructionMetadata
): string => {
const formatter = gd.InstructionSentenceFormatter.get();
const formattedTexts = formatter.getAsFormattedText(instruction, metadata);
let sentence = '';
mapFor(0, formattedTexts.size(), i => {
sentence += formattedTexts.getString(i);
});
return sentence.trim() || instruction.getType();
};
/**
* Build a map from event pointer to its path in the events list.
* This allows us to track event paths when using the C++ worker.
*/
const buildEventPtrToPathMap = (
eventsList: gdEventsList,
parentPath: Array<number> = []
): Map<number, Array<number>> => {
const map = new Map<number, Array<number>>();
mapFor(0, eventsList.getEventsCount(), index => {
const event = eventsList.getEventAt(index);
const currentPath = [...parentPath, index];
// $FlowFixMe - ptr is a number identifying the C++ object
map.set(event.ptr, currentPath);
if (event.canHaveSubEvents()) {
const subEventsMap = buildEventPtrToPathMap(
event.getSubEvents(),
currentPath
);
subEventsMap.forEach((path, ptr) => map.set(ptr, path));
}
});
return map;
};
/**
* Create a validation worker that uses C++ event traversal.
* This leverages ReadOnlyArbitraryEventsWorkerWithContext which properly
* handles local variable scoping as it traverses the event tree.
*/
const createValidationWorker = (
platform: gdPlatform,
locationName: string,
locationType: 'scene' | 'external-events' | 'extension',
eventPtrToPathMap: Map<number, Array<number>>,
errors: Array<ValidationError>
): gdReadOnlyArbitraryEventsWorkerWithContextJS => {
const worker = new gd.ReadOnlyArbitraryEventsWorkerWithContextJS();
let currentEventPath: Array<number> = [];
// $FlowFixMe - overriding C++ method:
worker.doVisitEvent = (event: gdBaseEvent) => {
const path = eventPtrToPathMap.get(event.ptr);
if (path) {
currentEventPath = path;
}
};
// $FlowFixMe - overriding C++ method:
worker.doVisitInstruction = (
instruction: gdInstruction,
isCondition: boolean,
projectScopedContainers: gdProjectScopedContainers
) => {
const type = instruction.getType();
// Skip empty instruction types
if (!type || type.trim() === '') {
return;
}
// Get metadata
const metadata = isCondition
? gd.MetadataProvider.getConditionMetadata(gd.JsPlatform.get(), type)
: gd.MetadataProvider.getActionMetadata(gd.JsPlatform.get(), type);
const isBad = gd.MetadataProvider.isBadInstructionMetadata(metadata);
// Check if instruction is missing (from uninstalled extension)
if (isBad) {
errors.push({
type: 'missing-instruction',
isCondition,
instructionType: type,
instructionSentence: type,
locationName,
locationType,
eventPath: [...currentEventPath],
});
return;
}
const instructionSentence = getInstructionSentence(instruction, metadata);
// Validate parameters
const parametersCount = metadata.getParametersCount();
mapFor(0, parametersCount, parameterIndex => {
const parameterMetadata = metadata.getParameter(parameterIndex);
const parameterType = parameterMetadata.getType();
const value = instruction.getParameter(parameterIndex).getPlainString();
// Skip validation for layer parameter with empty value (default layer)
if (parameterType === 'layer' && value === '') {
return;
}
// Skip codeOnly parameters
if (parameterMetadata.isCodeOnly()) {
return;
}
// Skip optional parameters with empty values (they will use defaults)
if (value === '' && parameterMetadata.isOptional()) {
return;
}
// Skip parameters with empty values that have default values
if (value === '' && parameterMetadata.getDefaultValue() !== '') {
return;
}
// Skip yesorno parameters with empty values (they default to "no")
if (parameterType === 'yesorno' && value === '') {
return;
}
// Check if parameter is valid using the projectScopedContainers
// passed from C++, which includes local variables in scope
const isValid = gd.InstructionValidator.isParameterValid(
platform,
projectScopedContainers,
instruction,
metadata,
parameterIndex,
value
);
if (!isValid) {
errors.push({
type: value === '' ? 'missing-parameter' : 'invalid-parameter',
isCondition,
instructionType: type,
instructionSentence,
parameterIndex,
parameterValue: value,
locationName,
locationType,
eventPath: [...currentEventPath],
});
}
});
};
return worker;
};
/**
* Scans the entire project for validation errors in events.
* This includes missing instructions (from uninstalled extensions)
* and invalid parameters.
*/
export const scanProjectForValidationErrors = (
project: gdProject
): Array<ValidationError> => {
const errors: Array<ValidationError> = [];
const platform = gd.JsPlatform.get();
// Scan all layouts (scenes)
mapFor(0, project.getLayoutsCount(), index => {
const layout = project.getLayoutAt(index);
const locationName = layout.getName();
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
project,
layout
);
const eventPtrToPathMap = buildEventPtrToPathMap(layout.getEvents());
const worker = createValidationWorker(
platform,
locationName,
'scene',
eventPtrToPathMap,
errors
);
worker.launch(layout.getEvents(), projectScopedContainers);
worker.delete();
});
// Scan all external events
mapFor(0, project.getExternalEventsCount(), index => {
const externalEvents = project.getExternalEventsAt(index);
const locationName = externalEvents.getName();
// External events are associated with a layout
const associatedLayoutName = externalEvents.getAssociatedLayout();
let projectScopedContainers;
if (associatedLayoutName && project.hasLayoutNamed(associatedLayoutName)) {
const layout = project.getLayout(associatedLayoutName);
projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
project,
layout
);
} else {
projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProject(
project
);
}
const eventPtrToPathMap = buildEventPtrToPathMap(
externalEvents.getEvents()
);
const worker = createValidationWorker(
platform,
locationName,
'external-events',
eventPtrToPathMap,
errors
);
worker.launch(externalEvents.getEvents(), projectScopedContainers);
worker.delete();
});
return errors;
};
export type GroupedValidationErrors = {|
missingInstructions: Map<string, Array<ValidationError>>,
invalidParameters: Map<string, Array<ValidationError>>,
|};
/**
* Finds an event by its path in the events list.
* Returns null if the event cannot be found.
*/
export const findEventByPath = (
eventsList: gdEventsList,
path: Array<number>
): ?gdBaseEvent => {
if (path.length === 0) return null;
let currentEventsList = eventsList;
let event: ?gdBaseEvent = null;
for (let i = 0; i < path.length; i++) {
const index = path[i];
if (index < 0 || index >= currentEventsList.getEventsCount()) {
return null;
}
event = currentEventsList.getEventAt(index);
// If not at the last index, go to sub-events
if (i < path.length - 1) {
if (!event.canHaveSubEvents()) {
return null;
}
currentEventsList = event.getSubEvents();
}
}
return event;
};
/**
* Groups validation errors by type for display in the UI.
*/
export const groupValidationErrors = (
errors: Array<ValidationError>
): GroupedValidationErrors => {
const missingInstructions = new Map<string, Array<ValidationError>>();
const invalidParameters = new Map<string, Array<ValidationError>>();
for (const error of errors) {
if (error.type === 'missing-instruction') {
// Group by extension name
const { extensionName } = getFunctionNameFromType(error.instructionType);
const key = extensionName || 'Unknown';
if (!missingInstructions.has(key)) {
missingInstructions.set(key, []);
}
const missingList = missingInstructions.get(key);
if (missingList) missingList.push(error);
} else {
// Group by location
const key = `${error.locationType}: ${error.locationName}`;
if (!invalidParameters.has(key)) {
invalidParameters.set(key, []);
}
const invalidList = invalidParameters.get(key);
if (invalidList) invalidList.push(error);
}
}
return { missingInstructions, invalidParameters };
};
@@ -0,0 +1,465 @@
// @flow
import {
scanProjectForValidationErrors,
groupValidationErrors,
findEventByPath,
} from './EventsValidationScanner';
import { makeTestProject } from '../fixtures/TestProject';
const gd: libGDevelop = global.gd;
describe('EventsValidationScanner', () => {
describe('scanProjectForValidationErrors', () => {
it('returns empty array for a project without events with errors', () => {
const { project } = makeTestProject(gd);
const errors = scanProjectForValidationErrors(project);
// Test project has valid events, should have no or very few errors
expect(Array.isArray(errors)).toBe(true);
});
it('detects missing instructions for invalid action types', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
// Add an event with an invalid action type
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::Standard',
0
);
const standardEvent = gd.asStandardEvent(event);
const actions = standardEvent.getActions();
const invalidAction = new gd.Instruction();
invalidAction.setType('NonExistentExtension::NonExistentAction');
actions.insert(invalidAction, 0);
invalidAction.delete();
const errors = scanProjectForValidationErrors(project);
const missingInstructionErrors = errors.filter(
e => e.type === 'missing-instruction'
);
expect(missingInstructionErrors.length).toBeGreaterThan(0);
const targetError = missingInstructionErrors.find(
e => e.instructionType === 'NonExistentExtension::NonExistentAction'
);
expect(targetError).toBeDefined();
if (targetError) {
expect(targetError.isCondition).toBe(false);
expect(targetError.locationType).toBe('scene');
expect(targetError.locationName).toBe(testLayout.getName());
}
});
it('detects missing conditions for invalid condition types', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
// Add an event with an invalid condition type
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::Standard',
0
);
const standardEvent = gd.asStandardEvent(event);
const conditions = standardEvent.getConditions();
const invalidCondition = new gd.Instruction();
invalidCondition.setType('NonExistentExtension::NonExistentCondition');
conditions.insert(invalidCondition, 0);
invalidCondition.delete();
const errors = scanProjectForValidationErrors(project);
const missingInstructionErrors = errors.filter(
e =>
e.type === 'missing-instruction' &&
e.instructionType === 'NonExistentExtension::NonExistentCondition'
);
expect(missingInstructionErrors.length).toBeGreaterThan(0);
const targetError = missingInstructionErrors[0];
expect(targetError.isCondition).toBe(true);
});
it('includes eventPath for each error', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
// Add an event with an invalid action
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::Standard',
0
);
const standardEvent = gd.asStandardEvent(event);
const actions = standardEvent.getActions();
const invalidAction = new gd.Instruction();
invalidAction.setType('Test::InvalidAction');
actions.insert(invalidAction, 0);
invalidAction.delete();
const errors = scanProjectForValidationErrors(project);
const targetError = errors.find(
e => e.instructionType === 'Test::InvalidAction'
);
expect(targetError).toBeDefined();
if (targetError) {
expect(Array.isArray(targetError.eventPath)).toBe(true);
expect(targetError.eventPath.length).toBeGreaterThan(0);
expect(targetError.eventPath[0]).toBe(0); // First event
}
});
describe('external events scanning', () => {
it('detects errors in external events', () => {
const { project, testExternalEvents1 } = makeTestProject(gd);
const events = testExternalEvents1.getEvents();
// Add an event with an invalid action
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::Standard',
0
);
const standardEvent = gd.asStandardEvent(event);
const actions = standardEvent.getActions();
const invalidAction = new gd.Instruction();
invalidAction.setType('External::InvalidAction');
actions.insert(invalidAction, 0);
invalidAction.delete();
const errors = scanProjectForValidationErrors(project);
const targetError = errors.find(
e => e.instructionType === 'External::InvalidAction'
);
expect(targetError).toBeDefined();
if (targetError) {
expect(targetError.locationType).toBe('external-events');
expect(targetError.locationName).toBe(testExternalEvents1.getName());
expect(targetError.type).toBe('missing-instruction');
}
});
it('scans external events with associated layout context', () => {
const { project, testExternalEvents1, testLayout } = makeTestProject(
gd
);
// Associate external events with a layout
testExternalEvents1.setAssociatedLayout(testLayout.getName());
const events = testExternalEvents1.getEvents();
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::Standard',
0
);
const standardEvent = gd.asStandardEvent(event);
const actions = standardEvent.getActions();
const invalidAction = new gd.Instruction();
invalidAction.setType('AssociatedLayout::InvalidAction');
actions.insert(invalidAction, 0);
invalidAction.delete();
const errors = scanProjectForValidationErrors(project);
const targetError = errors.find(
e => e.instructionType === 'AssociatedLayout::InvalidAction'
);
expect(targetError).toBeDefined();
if (targetError) {
expect(targetError.locationType).toBe('external-events');
}
});
});
describe('WhileEvent scanning', () => {
it('detects errors in WhileEvent conditions', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::While',
0
);
const whileEvent = gd.asWhileEvent(event);
const conditions = whileEvent.getConditions();
const invalidCondition = new gd.Instruction();
invalidCondition.setType('While::InvalidCondition');
conditions.insert(invalidCondition, 0);
invalidCondition.delete();
const errors = scanProjectForValidationErrors(project);
const targetError = errors.find(
e => e.instructionType === 'While::InvalidCondition'
);
expect(targetError).toBeDefined();
if (targetError) {
expect(targetError.isCondition).toBe(true);
expect(targetError.type).toBe('missing-instruction');
}
});
it('detects errors in WhileEvent while-conditions', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::While',
0
);
const whileEvent = gd.asWhileEvent(event);
const whileConditions = whileEvent.getWhileConditions();
const invalidCondition = new gd.Instruction();
invalidCondition.setType('While::InvalidWhileCondition');
whileConditions.insert(invalidCondition, 0);
invalidCondition.delete();
const errors = scanProjectForValidationErrors(project);
const targetError = errors.find(
e => e.instructionType === 'While::InvalidWhileCondition'
);
expect(targetError).toBeDefined();
if (targetError) {
expect(targetError.isCondition).toBe(true);
}
});
it('detects errors in WhileEvent actions', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::While',
0
);
const whileEvent = gd.asWhileEvent(event);
const actions = whileEvent.getActions();
const invalidAction = new gd.Instruction();
invalidAction.setType('While::InvalidAction');
actions.insert(invalidAction, 0);
invalidAction.delete();
const errors = scanProjectForValidationErrors(project);
const targetError = errors.find(
e => e.instructionType === 'While::InvalidAction'
);
expect(targetError).toBeDefined();
if (targetError) {
expect(targetError.isCondition).toBe(false);
}
});
});
describe('ForEachEvent scanning', () => {
it('detects errors in ForEachEvent conditions', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::ForEach',
0
);
const forEachEvent = gd.asForEachEvent(event);
const conditions = forEachEvent.getConditions();
const invalidCondition = new gd.Instruction();
invalidCondition.setType('ForEach::InvalidCondition');
conditions.insert(invalidCondition, 0);
invalidCondition.delete();
const errors = scanProjectForValidationErrors(project);
const targetError = errors.find(
e => e.instructionType === 'ForEach::InvalidCondition'
);
expect(targetError).toBeDefined();
if (targetError) {
expect(targetError.isCondition).toBe(true);
expect(targetError.type).toBe('missing-instruction');
}
});
it('detects errors in ForEachEvent actions', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
const event = events.insertNewEvent(
project,
'BuiltinCommonInstructions::ForEach',
0
);
const forEachEvent = gd.asForEachEvent(event);
const actions = forEachEvent.getActions();
const invalidAction = new gd.Instruction();
invalidAction.setType('ForEach::InvalidAction');
actions.insert(invalidAction, 0);
invalidAction.delete();
const errors = scanProjectForValidationErrors(project);
const targetError = errors.find(
e => e.instructionType === 'ForEach::InvalidAction'
);
expect(targetError).toBeDefined();
if (targetError) {
expect(targetError.isCondition).toBe(false);
}
});
});
});
describe('groupValidationErrors', () => {
it('groups missing instructions by extension name', () => {
const errors = [
{
type: 'missing-instruction',
isCondition: false,
instructionType: 'ExtA::Action1',
instructionSentence: 'Action 1',
locationName: 'Scene1',
locationType: 'scene',
eventPath: [0],
},
{
type: 'missing-instruction',
isCondition: true,
instructionType: 'ExtA::Condition1',
instructionSentence: 'Condition 1',
locationName: 'Scene1',
locationType: 'scene',
eventPath: [1],
},
{
type: 'missing-instruction',
isCondition: false,
instructionType: 'ExtB::Action1',
instructionSentence: 'Action B',
locationName: 'Scene2',
locationType: 'scene',
eventPath: [0],
},
];
const grouped = groupValidationErrors(errors);
expect(grouped.missingInstructions.size).toBe(2);
const extAErrors = grouped.missingInstructions.get('ExtA');
const extBErrors = grouped.missingInstructions.get('ExtB');
expect(extAErrors && extAErrors.length).toBe(2);
expect(extBErrors && extBErrors.length).toBe(1);
});
it('groups invalid parameters by location', () => {
const errors = [
{
type: 'invalid-parameter',
isCondition: false,
instructionType: 'Action1',
instructionSentence: 'Action 1',
parameterIndex: 0,
parameterValue: '',
locationName: 'Scene1',
locationType: 'scene',
eventPath: [0],
},
{
type: 'invalid-parameter',
isCondition: false,
instructionType: 'Action2',
instructionSentence: 'Action 2',
parameterIndex: 1,
parameterValue: '',
locationName: 'Scene1',
locationType: 'scene',
eventPath: [1],
},
{
type: 'invalid-parameter',
isCondition: false,
instructionType: 'Action3',
instructionSentence: 'Action 3',
parameterIndex: 0,
parameterValue: '',
locationName: 'Events1',
locationType: 'external-events',
eventPath: [0],
},
];
const grouped = groupValidationErrors(errors);
expect(grouped.invalidParameters.size).toBe(2);
const sceneErrors = grouped.invalidParameters.get('scene: Scene1');
const extEventsErrors = grouped.invalidParameters.get(
'external-events: Events1'
);
expect(sceneErrors && sceneErrors.length).toBe(2);
expect(extEventsErrors && extEventsErrors.length).toBe(1);
});
});
describe('findEventByPath', () => {
it('returns null for empty path', () => {
const { testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
const result = findEventByPath(events, []);
expect(result).toBeNull();
});
it('returns null for invalid index', () => {
const { testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
const result = findEventByPath(events, [999]);
expect(result).toBeNull();
});
it('finds event at root level', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
// Add an event
events.insertNewEvent(project, 'BuiltinCommonInstructions::Standard', 0);
const result = findEventByPath(events, [0]);
expect(result).not.toBeNull();
if (result) {
expect(result.getType()).toBe('BuiltinCommonInstructions::Standard');
}
});
it('finds nested event', () => {
const { project, testLayout } = makeTestProject(gd);
const events = testLayout.getEvents();
// Add a parent event
const parentEvent = events.insertNewEvent(
project,
'BuiltinCommonInstructions::Standard',
0
);
// Add a child event
const subEvents = parentEvent.getSubEvents();
subEvents.insertNewEvent(
project,
'BuiltinCommonInstructions::Comment',
0
);
const result = findEventByPath(events, [0, 0]);
expect(result).not.toBeNull();
if (result) {
expect(result.getType()).toBe('BuiltinCommonInstructions::Comment');
}
});
});
});
@@ -0,0 +1,51 @@
// @flow
import * as React from 'react';
import { action } from '@storybook/addon-actions';
// Keep first as it creates the `global.gd` object:
import { testProject } from '../../GDevelopJsInitializerDecorator';
import DiagnosticReportDialog from '../../../ExportAndShare/DiagnosticReportDialog';
export default {
title: 'ExportAndShare/DiagnosticReportDialog',
component: DiagnosticReportDialog,
};
/**
* Shows the diagnostic report dialog with no errors.
* The dialog displays a message indicating no issues were found.
*/
export const Empty = () => (
<DiagnosticReportDialog
project={testProject.project}
wholeProjectDiagnosticReport={testProject.project.getWholeProjectDiagnosticReport()}
onClose={action('onClose')}
onNavigateToLayoutEvent={action('onNavigateToLayoutEvent')}
onNavigateToExternalEventsEvent={action('onNavigateToExternalEventsEvent')}
/>
);
Empty.storyName = 'Empty (No errors)';
/**
* Shows the diagnostic report dialog with navigation callbacks.
* When errors are found, clicking on them will trigger navigation to the
* corresponding event in the layout or external events sheet.
*/
export const WithNavigationCallbacks = () => (
<DiagnosticReportDialog
project={testProject.project}
wholeProjectDiagnosticReport={testProject.project.getWholeProjectDiagnosticReport()}
onClose={action('onClose')}
onNavigateToLayoutEvent={(layoutName, eventPath) => {
action('onNavigateToLayoutEvent')({ layoutName, eventPath });
}}
onNavigateToExternalEventsEvent={(externalEventsName, eventPath) => {
action('onNavigateToExternalEventsEvent')({
externalEventsName,
eventPath,
});
}}
/>
);
WithNavigationCallbacks.storyName = 'With navigation callbacks';