diff --git a/Core/GDCore/IDE/Events/ArbitraryEventsWorker.cpp b/Core/GDCore/IDE/Events/ArbitraryEventsWorker.cpp index bbc970919f87..916a548c0eb2 100644 --- a/Core/GDCore/IDE/Events/ArbitraryEventsWorker.cpp +++ b/Core/GDCore/IDE/Events/ArbitraryEventsWorker.cpp @@ -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) { diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 5ddbe2d01686..b628638da425 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -3513,6 +3513,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] diff --git a/GDevelop.js/Bindings/Wrapper.cpp b/GDevelop.js/Bindings/Wrapper.cpp index f5f45d1324d0..4aa4c71750d2 100644 --- a/GDevelop.js/Bindings/Wrapper.cpp +++ b/GDevelop.js/Bindings/Wrapper.cpp @@ -368,6 +368,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($1); + }, + (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($1, $2, $3); + }, + (int)this, + (int)&instruction, + isCondition, + (int)&GetProjectScopedContainers()); + } +}; + class InitialInstanceJSFunctorWrapper : public gd::InitialInstanceFunctor { public: InitialInstanceJSFunctorWrapper(){}; diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index 4f40c764e896..a729f277fdd9 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -2573,6 +2573,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 { diff --git a/GDevelop.js/types/gdreadonlyarbitraryeventsworkerwithcontext.js b/GDevelop.js/types/gdreadonlyarbitraryeventsworkerwithcontext.js new file mode 100644 index 000000000000..4a772a1b863d --- /dev/null +++ b/GDevelop.js/types/gdreadonlyarbitraryeventsworkerwithcontext.js @@ -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; +}; \ No newline at end of file diff --git a/GDevelop.js/types/gdreadonlyarbitraryeventsworkerwithcontextjs.js b/GDevelop.js/types/gdreadonlyarbitraryeventsworkerwithcontextjs.js new file mode 100644 index 000000000000..8cf83f651eaf --- /dev/null +++ b/GDevelop.js/types/gdreadonlyarbitraryeventsworkerwithcontextjs.js @@ -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; +}; \ No newline at end of file diff --git a/GDevelop.js/types/libgdevelop.js b/GDevelop.js/types/libgdevelop.js index ffc1b62b68fe..abcee41f46a4 100644 --- a/GDevelop.js/types/libgdevelop.js +++ b/GDevelop.js/types/libgdevelop.js @@ -248,6 +248,8 @@ declare class libGDevelop { InstructionsTypeRenamer: Class; EventsContext: Class; EventsContextAnalyzer: Class; + ReadOnlyArbitraryEventsWorkerWithContext: Class; + ReadOnlyArbitraryEventsWorkerWithContextJS: Class; ArbitraryResourceWorker: Class; ArbitraryResourceWorkerJS: Class; ResourcesMergingHelper: Class; diff --git a/GDevelop.js/update-bindings.js b/GDevelop.js/update-bindings.js index 858bec67552f..e30db775fc6a 100644 --- a/GDevelop.js/update-bindings.js +++ b/GDevelop.js/update-bindings.js @@ -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); diff --git a/newIDE/app/src/CommandPalette/CommandsList.js b/newIDE/app/src/CommandPalette/CommandsList.js index 93d2448ddb0d..bd2d918c7054 100644 --- a/newIDE/app/src/CommandPalette/CommandsList.js +++ b/newIDE/app/src/CommandPalette/CommandsList.js @@ -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', diff --git a/newIDE/app/src/EventsSheet/index.js b/newIDE/app/src/EventsSheet/index.js index 6fec980f1111..1024a87db0d8 100644 --- a/newIDE/app/src/EventsSheet/index.js +++ b/newIDE/app/src/EventsSheet/index.js @@ -120,6 +120,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; @@ -212,6 +213,7 @@ type State = {| showSearchPanel: boolean, searchResults: ?Array, searchFocusOffset: ?number, + navigationHighlightEvent: ?gdBaseEvent, layoutVariablesDialogOpen: boolean, @@ -307,6 +309,7 @@ export class EventsSheetComponentWithoutHandle extends React.Component< showSearchPanel: false, searchResults: null, searchFocusOffset: null, + navigationHighlightEvent: null, layoutVariablesDialogOpen: false, @@ -381,6 +384,33 @@ export class EventsSheetComponentWithoutHandle extends React.Component< ); }; + scrollToEventPath = (eventPath: Array) => { + 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; @@ -2022,8 +2052,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={ @@ -2243,6 +2279,7 @@ export type EventsSheetInterface = {| updateToolbar: () => void, onResourceExternallyChanged: ({| identifier: string |}) => void, onEventsModifiedOutsideEditor: (changes: OutOfEditorChanges) => void, + scrollToEventPath: (eventPath: Array) => void, |}; // EventsSheet is a wrapper so that the component can use multiple @@ -2252,6 +2289,7 @@ const EventsSheet = (props, ref) => { updateToolbar, onResourceExternallyChanged, onEventsModifiedOutsideEditor, + scrollToEventPath, })); const { @@ -2271,6 +2309,9 @@ const EventsSheet = (props, ref) => { addNewAiGeneratedEventIds(changes.newOrChangedAiGeneratedEventIds); if (component.current) component.current.onEventsModifiedOutsideEditor(); }; + const scrollToEventPath = (eventPath: Array) => { + if (component.current) component.current.scrollToEventPath(eventPath); + }; const authenticatedUser = React.useContext(AuthenticatedUserContext); const preferences = React.useContext(PreferencesContext); diff --git a/newIDE/app/src/ExportAndShare/DiagnosticReportDialog.js b/newIDE/app/src/ExportAndShare/DiagnosticReportDialog.js index fa60c872ec1d..bd5ec5ed4ef8 100644 --- a/newIDE/app/src/ExportAndShare/DiagnosticReportDialog.js +++ b/newIDE/app/src/ExportAndShare/DiagnosticReportDialog.js @@ -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,204 @@ 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, + }, + expandButton: { + padding: 4, + flexShrink: 0, + marginLeft: 'auto', + }, + 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 ( + + +
+ navigateToError(error)}> + {error.locationType === 'scene' ? 'scene' : 'external-events'}:{' '} + {error.locationName} + +
+
+ +
+
setIsExpanded(!isExpanded) : undefined + } + title={ + couldBeTruncated && !isExpanded + ? error.instructionSentence + : undefined + } + > + {typeLabel}{' '} + {error.instructionSentence} +
+ {couldBeTruncated && ( + setIsExpanded(!isExpanded)} + > + {isExpanded ? : } + + )} +
+
+
+ ); +}; + +type InvalidParametersSectionProps = {| + validationErrors: Array, + invalidParametersCount: number, + navigateToError: (error: ValidationError) => void, + gdevelopTheme: any, +|}; + +const InvalidParametersSection = ({ + validationErrors, + invalidParametersCount, + navigateToError, + gdevelopTheme, +}: InvalidParametersSectionProps) => { + return ( + + + Invalid parameters in events ({invalidParametersCount}) + + + + The following events have invalid parameters (shown with red underline + in the events sheet). Click a location to navigate there. + + + + + + + Location + + + Instruction + + + + + {validationErrors + .filter(error => error.type !== 'missing-instruction') + .map((error, index) => ( + + ))} + +
+
+ ); +}; + type Props = {| + project: gdProject, wholeProjectDiagnosticReport: gdWholeProjectDiagnosticReport, onClose: () => void, + onNavigateToLayoutEvent: ( + layoutName: string, + eventPath: Array + ) => void, + onNavigateToExternalEventsEvent: ( + externalEventsName: string, + eventPath: Array + ) => void, |}; const addFor = (map, key, value) => { @@ -34,12 +226,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 +467,9 @@ export default function DiagnosticReportDialog({ [gdevelopTheme.list.itemsBackgroundColor] ); + const hasNativeReport = wholeProjectDiagnosticReport.hasAnyIssue(); + const hasAnyIssue = hasNativeReport || hasValidationErrors; + return ( + {!hasAnyIssue && ( + + No issues found in your project. + + )} + + {/* Missing instructions from extensions */} + {hasMissingInstructions && ( + + + + Missing actions/conditions/expressions ( + {missingInstructionsCount}) + + + + + 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. + + + + + + + Extension + + + Missing instructions + + + Location + + + + + {[...groupedErrors.missingInstructions.entries()].map( + ([extensionName, errors]) => ( + + + + {extensionName} + + + + + {[ + ...new Set( + errors.map(e => + renderMissingInstructionName(e.instructionType) + ) + ), + ].join(', ')} + + + + {[ + ...new Set( + errors.map( + e => `${e.locationType}: ${e.locationName}` + ) + ), + ].map(location => { + const error = errors.find( + e => + `${e.locationType}: ${e.locationName}` === + location + ); + return ( + + error && navigateToError(error)} + > + {location} + + + ); + })} + + + ) + )} + +
+
+ )} + + {/* Invalid parameters */} + {hasInvalidParameters && ( + + )} + + {/* Native diagnostic report (from C++ code) */} {mapFor(0, wholeProjectDiagnosticReport.count(), index => { const diagnosticReport = wholeProjectDiagnosticReport.get(index); return ( diff --git a/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js b/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js index 09b8c14e7cf5..dd2090092d45 100644 --- a/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js +++ b/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js @@ -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', diff --git a/newIDE/app/src/MainFrame/EditorContainers/EventsEditorContainer.js b/newIDE/app/src/MainFrame/EditorContainers/EventsEditorContainer.js index 5a7dcf1c0889..b5c295718d4f 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/EventsEditorContainer.js +++ b/newIDE/app/src/MainFrame/EditorContainers/EventsEditorContainer.js @@ -57,6 +57,10 @@ export class EventsEditorContainer extends React.Component) { + if (this.editor) this.editor.scrollToEventPath(eventPath); + } + forceUpdateEditor() { // No updates to be done. } diff --git a/newIDE/app/src/MainFrame/EditorContainers/ExternalEventsEditorContainer.js b/newIDE/app/src/MainFrame/EditorContainers/ExternalEventsEditorContainer.js index 8fde63332555..e4eaf556df54 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/ExternalEventsEditorContainer.js +++ b/newIDE/app/src/MainFrame/EditorContainers/ExternalEventsEditorContainer.js @@ -87,6 +87,10 @@ export class ExternalEventsEditorContainer extends React.Component< if (this.editor) this.editor.updateToolbar(); } + scrollToEventPath(eventPath: Array) { + if (this.editor) this.editor.scrollToEventPath(eventPath); + } + forceUpdateEditor() { // No updates to be done. } diff --git a/newIDE/app/src/MainFrame/MainFrameCommands.js b/newIDE/app/src/MainFrame/MainFrameCommands.js index 7cd0a0783e8c..e1bff24c901e 100644 --- a/newIDE/app/src/MainFrame/MainFrameCommands.js +++ b/newIDE/app/src/MainFrame/MainFrameCommands.js @@ -47,6 +47,7 @@ type CommandHandlers = {| onLaunchNetworkPreview: () => Promise, onHotReloadPreview: () => Promise, onLaunchPreviewWithDiagnosticReport: () => Promise, + onOpenDiagnosticReport: () => void, allowNetworkPreview: boolean, onOpenHomePage: () => void, onCreateProject: () => void, @@ -111,6 +112,10 @@ const useMainFrameCommands = (handlers: CommandHandlers) => { } ); + useCommand('OPEN_DIAGNOSTIC_REPORT', !!handlers.project, { + handler: handlers.onOpenDiagnosticReport, + }); + useCommand('OPEN_HOME_PAGE', true, { handler: handlers.onOpenHomePage, }); diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js index d4d7d0d85caa..c36122af2f48 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js @@ -211,6 +211,7 @@ export type PreferencesValues = {| showInAppTutorialDeveloperMode: boolean, showDeprecatedInstructionWarning: boolean, openDiagnosticReportAutomatically: boolean, + blockPreviewAndExportOnDiagnosticErrors: boolean, use3DEditor: boolean, showBasicProfilingCounters: boolean, inAppTutorialsProgress: InAppTutorialProgressDatabase, @@ -302,6 +303,8 @@ export type Preferences = {| setShowInAppTutorialDeveloperMode: (enabled: boolean) => void, setOpenDiagnosticReportAutomatically: (enabled: boolean) => void, getOpenDiagnosticReportAutomatically: () => boolean, + setBlockPreviewAndExportOnDiagnosticErrors: (enabled: boolean) => void, + getBlockPreviewAndExportOnDiagnosticErrors: () => boolean, setShowDeprecatedInstructionWarning: (enabled: boolean) => void, getShowDeprecatedInstructionWarning: () => boolean, setUse3DEditor: (enabled: boolean) => void, @@ -386,6 +389,7 @@ export const initialPreferences = { showCreateSectionByDefault: false, showInAppTutorialDeveloperMode: false, openDiagnosticReportAutomatically: true, + blockPreviewAndExportOnDiagnosticErrors: false, showDeprecatedInstructionWarning: false, use3DEditor: isWebGLSupported(), showBasicProfilingCounters: false, @@ -461,6 +465,8 @@ export const initialPreferences = { setShowDeprecatedInstructionWarning: (enabled: boolean) => {}, getOpenDiagnosticReportAutomatically: () => true, setOpenDiagnosticReportAutomatically: (enabled: boolean) => {}, + getBlockPreviewAndExportOnDiagnosticErrors: () => false, + setBlockPreviewAndExportOnDiagnosticErrors: (enabled: boolean) => {}, getShowDeprecatedInstructionWarning: () => false, setUse3DEditor: (enabled: boolean) => {}, getUse3DEditor: () => false, diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index 8e4b454c8734..d8783f12e368 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -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` )} /> + { getOpenDiagnosticReportAutomatically: this._getOpenDiagnosticReportAutomatically.bind( this ), + setBlockPreviewAndExportOnDiagnosticErrors: this._setBlockPreviewAndExportOnDiagnosticErrors.bind( + this + ), + getBlockPreviewAndExportOnDiagnosticErrors: this._getBlockPreviewAndExportOnDiagnosticErrors.bind( + this + ), setShowDeprecatedInstructionWarning: this._setShowDeprecatedInstructionWarning.bind( this ), @@ -492,6 +498,24 @@ export default class PreferencesProvider extends React.Component { 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: boolean ) { diff --git a/newIDE/app/src/MainFrame/UseNavigationToEvent.js b/newIDE/app/src/MainFrame/UseNavigationToEvent.js new file mode 100644 index 000000000000..d3d73466d46d --- /dev/null +++ b/newIDE/app/src/MainFrame/UseNavigationToEvent.js @@ -0,0 +1,67 @@ +// @flow +import * as React from 'react'; +import { type EditorTabsState } from './EditorTabs/EditorTabsHandler'; + +export type EventNavigationTarget = {| + name: string, + locationType: 'layout' | 'external-events', + eventPath: Array, +|}; + +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(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 + ) { + editor.editorRef.scrollToEventPath(eventPath); + setPendingEventNavigation(null); + return; + } + } + } + + setPendingEventNavigation(null); + }, EDITOR_MOUNT_DELAY_MS); + + return () => clearTimeout(timeoutId); + }, + [pendingEventNavigation, editorTabs] + ); + + return { setPendingEventNavigation }; +}; diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index 48ed7957612a..1b06557b02a0 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -122,7 +122,11 @@ import { } from './MainMenu'; import useForceUpdate from '../Utils/UseForceUpdate'; import useStateWithCallback from '../Utils/UseSetStateWithCallback'; -import { useKeyboardShortcuts, useShortcutMap } from '../KeyboardShortcuts'; +import { + getShortcutDisplayName, + useKeyboardShortcuts, + useShortcutMap, +} from '../KeyboardShortcuts'; import useMainFrameCommands from './MainFrameCommands'; import { CommandPaletteWithAlgoliaSearch, @@ -190,6 +194,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'; @@ -204,6 +209,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'; @@ -456,6 +462,51 @@ const MainFrame = (props: Props) => { const preferences = React.useContext(PreferencesContext); const { setHasProjectOpened } = preferences; const { previewLoadingRef, setPreviewLoading } = usePreviewLoadingState(); + const shortcutMap = useShortcutMap(); + + /** + * Checks for diagnostic errors in the project if blocking is enabled. + * Returns true if there are errors and the action should be blocked. + */ + const checkDiagnosticErrorsAndBlock = React.useCallback( + async ( + project: ?gdProject, + actionType: 'preview' | 'export' + ): Promise => { + if ( + !project || + !preferences.getBlockPreviewAndExportOnDiagnosticErrors() + ) { + return false; + } + + try { + const validationErrors = scanProjectForValidationErrors(project); + if (validationErrors.length > 0) { + const shortcut = getShortcutDisplayName( + shortcutMap['OPEN_DIAGNOSTIC_REPORT'] + ); + await showAlert({ + title: t`Diagnostic errors found`, + message: + actionType === 'preview' + ? t`Your project has ${ + validationErrors.length + } diagnostic error(s). Please fix them before launching a preview. Press ${shortcut} to open the diagnostic report.` + : t`Your project has ${ + validationErrors.length + } diagnostic error(s). Please fix them before exporting. Press ${shortcut} to open the diagnostic report.`, + }); + return true; + } + } catch (error) { + console.error('Error scanning project for validation errors:', error); + } + + return false; + }, + [preferences, shortcutMap, showAlert] + ); const [previewState, setPreviewState] = React.useState(initialPreviewState); const commandPaletteRef = React.useRef((null: ?CommandPaletteInterface)); const inAppTutorialOrchestratorRef = React.useRef( @@ -529,6 +580,9 @@ const MainFrame = (props: Props) => { diagnosticReportDialogOpen, setDiagnosticReportDialogOpen, ] = React.useState(false); + const { setPendingEventNavigation } = useNavigationToEvent({ + editorTabs: state.editorTabs, + }); const [ fileMetadataOpeningProgress, setFileMetadataOpeningProgress, @@ -797,13 +851,17 @@ const MainFrame = (props: Props) => { ); const openShareDialog = React.useCallback( - (initialTab?: ShareTab) => { + async (initialTab?: ShareTab) => { + if (await checkDiagnosticErrorsAndBlock(currentProject, 'export')) { + return; + } + notifyPreviewOrExportWillStart(state.editorTabs); setShareDialogInitialTab(initialTab || null); setShareDialogOpen(true); }, - [state.editorTabs] + [state.editorTabs, currentProject, checkDiagnosticErrorsAndBlock] ); const closeShareDialog = React.useCallback( @@ -2142,6 +2200,10 @@ const MainFrame = (props: Props) => { if (!currentProject) return; if (currentProject.getLayoutsCount() === 0) return; + if (await checkDiagnosticErrorsAndBlock(currentProject, 'preview')) { + return; + } + console.info( `Launching a new ${ isForInGameEdition ? 'in-game edition preview' : 'preview' @@ -2359,6 +2421,7 @@ const MainFrame = (props: Props) => { inGameEditorSettings, previewLoadingRef, setPreviewLoading, + checkDiagnosticErrorsAndBlock, ] ); @@ -4577,6 +4640,7 @@ const MainFrame = (props: Props) => { onLaunchDebugPreview: launchDebuggerAndPreview, onLaunchNetworkPreview: launchNetworkPreview, onLaunchPreviewWithDiagnosticReport: launchPreviewWithDiagnosticReport, + onOpenDiagnosticReport: () => setDiagnosticReportDialogOpen(true), onOpenHomePage: openHomePage, onCreateProject: () => setNewProjectSetupDialogOpen(true), onOpenProject: () => openOpenFromStorageProviderDialog(), @@ -4670,7 +4734,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, @@ -5182,8 +5245,29 @@ const MainFrame = (props: Props) => { )} {diagnosticReportDialogOpen && currentProject && ( 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 && ( diff --git a/newIDE/app/src/Utils/EventsValidationScanner.js b/newIDE/app/src/Utils/EventsValidationScanner.js new file mode 100644 index 000000000000..77dcccb91972 --- /dev/null +++ b/newIDE/app/src/Utils/EventsValidationScanner.js @@ -0,0 +1,331 @@ +// @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, +|}; + +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 = [] +): Map> => { + const map = new Map>(); + 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>, + errors: Array +): gdReadOnlyArbitraryEventsWorkerWithContextJS => { + const worker = new gd.ReadOnlyArbitraryEventsWorkerWithContextJS(); + + let currentEventPath: Array = []; + + worker.doVisitEvent = (eventPtr: number) => { + const path = eventPtrToPathMap.get(eventPtr); + if (path) { + currentEventPath = path; + } + }; + + worker.doVisitInstruction = ( + instructionPtr: number, + isConditionInt: number, + projectScopedContainersPtr: number + ) => { + const instruction = gd.wrapPointer(instructionPtr, gd.Instruction); + const projectScopedContainers = gd.wrapPointer( + projectScopedContainersPtr, + gd.ProjectScopedContainers + ); + // C++ passes boolean as 0/1 through EM_ASM + const isCondition: boolean = !!isConditionInt; + + 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 => { + const errors: Array = []; + 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>, + invalidParameters: Map>, +|}; + +/** + * 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 +): ?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 +): GroupedValidationErrors => { + const missingInstructions = new Map>(); + const invalidParameters = new Map>(); + + 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 }; +}; diff --git a/newIDE/app/src/Utils/EventsValidationScanner.spec.js b/newIDE/app/src/Utils/EventsValidationScanner.spec.js new file mode 100644 index 000000000000..b2c64ac9002c --- /dev/null +++ b/newIDE/app/src/Utils/EventsValidationScanner.spec.js @@ -0,0 +1,455 @@ +// @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', + }, + { + type: 'missing-instruction', + isCondition: true, + instructionType: 'ExtA::Condition1', + instructionSentence: 'Condition 1', + locationName: 'Scene1', + locationType: 'scene', + }, + { + type: 'missing-instruction', + isCondition: false, + instructionType: 'ExtB::Action1', + instructionSentence: 'Action B', + locationName: 'Scene2', + locationType: 'scene', + }, + ]; + + const grouped = groupValidationErrors(errors); + + expect(grouped.missingInstructions.size).toBe(2); + expect(grouped.missingInstructions.get('ExtA')?.length).toBe(2); + expect(grouped.missingInstructions.get('ExtB')?.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', + }, + { + type: 'invalid-parameter', + isCondition: false, + instructionType: 'Action2', + instructionSentence: 'Action 2', + parameterIndex: 1, + parameterValue: '', + locationName: 'Scene1', + locationType: 'scene', + }, + { + type: 'invalid-parameter', + isCondition: false, + instructionType: 'Action3', + instructionSentence: 'Action 3', + parameterIndex: 0, + parameterValue: '', + locationName: 'Events1', + locationType: 'external-events', + }, + ]; + + const grouped = groupValidationErrors(errors); + + expect(grouped.invalidParameters.size).toBe(2); + expect(grouped.invalidParameters.get('scene: Scene1')?.length).toBe(2); + expect( + grouped.invalidParameters.get('external-events: Events1')?.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'); + } + }); + }); +}); diff --git a/newIDE/app/src/stories/componentStories/ExportAndShare/DiagnosticReportDialog.stories.js b/newIDE/app/src/stories/componentStories/ExportAndShare/DiagnosticReportDialog.stories.js new file mode 100644 index 000000000000..fdeb398896e8 --- /dev/null +++ b/newIDE/app/src/stories/componentStories/ExportAndShare/DiagnosticReportDialog.stories.js @@ -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 = () => ( + +); +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 = () => ( + { + action('onNavigateToLayoutEvent')({ layoutName, eventPath }); + }} + onNavigateToExternalEventsEvent={(externalEventsName, eventPath) => { + action('onNavigateToExternalEventsEvent')({ + externalEventsName, + eventPath, + }); + }} + /> +); +WithNavigationCallbacks.storyName = 'With navigation callbacks';