diff --git a/ApplicationLibCode/Application/RiaGuiApplication.cpp b/ApplicationLibCode/Application/RiaGuiApplication.cpp index 89b42825b99..7dbab7e24c6 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.cpp +++ b/ApplicationLibCode/Application/RiaGuiApplication.cpp @@ -144,6 +144,11 @@ #include // for usleep #endif // WIN32 +#ifdef ENABLE_SPIX +#include +#include +#endif + //================================================================================================== /// /// \class RiaGuiApplication @@ -213,6 +218,13 @@ RiaGuiApplication::~RiaGuiApplication() // This must be done before any window deletion that might trigger events setNotifyInDestructorFlag( true ); +#ifdef ENABLE_SPIX + // Tear down the Spix server before the windows it watches. The server destructor + // joins its worker thread, after which the bot can be released safely. + m_spixServer.reset(); + m_spixBot.reset(); +#endif + processEvents(); if ( m_mainWindow ) @@ -570,6 +582,17 @@ RiaApplication::ApplicationStatus RiaGuiApplication::handleArguments( gsl::not_n } } +#ifdef ENABLE_SPIX + if ( cvf::Option o = progOpt->option( "spix-port" ) ) + { + if ( o.valueCount() == 1 ) + { + int port = o.value( 0 ).toInt(); + if ( port > 0 ) startSpixServer( port ); + } + } +#endif + // Code generation // ----------------- if ( cvf::Option o = progOpt->option( "generate" ) ) @@ -1840,3 +1863,25 @@ bool RiaGuiApplication::notify( QObject* receiver, QEvent* event ) return done; } + +#ifdef ENABLE_SPIX +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaGuiApplication::startSpixServer( int port ) +{ + try + { + m_spixBot = std::make_unique(); + m_spixServer = std::make_unique( port ); + m_spixBot->runTestServer( *m_spixServer ); + RiaLogging::info( QString( "Spix UI test automation server listening on port %1" ).arg( port ) ); + } + catch ( const std::exception& e ) + { + RiaLogging::error( QString( "Failed to start Spix server on port %1: %2" ).arg( port ).arg( e.what() ) ); + m_spixServer.reset(); + m_spixBot.reset(); + } +} +#endif diff --git a/ApplicationLibCode/Application/RiaGuiApplication.h b/ApplicationLibCode/Application/RiaGuiApplication.h index 67dfcbfd5e9..bf15500a861 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.h +++ b/ApplicationLibCode/Application/RiaGuiApplication.h @@ -70,6 +70,14 @@ namespace caf class FontHolderInterface; } +#ifdef ENABLE_SPIX +namespace spix +{ +class AnyRpcServer; +class QtWidgetsBot; +} // namespace spix +#endif + //================================================================================================== // // @@ -158,6 +166,10 @@ class RiaGuiApplication : public QApplication, public RiaApplication void storeTreeViewState(); +#ifdef ENABLE_SPIX + void startSpixServer( int port ); +#endif + private slots: void slotWorkerProcessFinished( int exitCode, QProcess::ExitStatus exitStatus ); void onLastWindowClosed(); @@ -169,4 +181,9 @@ private slots: std::unique_ptr m_recentFileActionProvider; std::unique_ptr m_maximizeWindowGuard; + +#ifdef ENABLE_SPIX + std::unique_ptr m_spixBot; + std::unique_ptr m_spixServer; +#endif }; diff --git a/ApplicationLibCode/Application/Tools/RiaArgumentParser.cpp b/ApplicationLibCode/Application/Tools/RiaArgumentParser.cpp index bc10b335cd8..eaa61884022 100644 --- a/ApplicationLibCode/Application/Tools/RiaArgumentParser.cpp +++ b/ApplicationLibCode/Application/Tools/RiaArgumentParser.cpp @@ -73,6 +73,10 @@ bool RiaArgumentParser::parseArguments( cvf::ProgramOptions* progOpt ) progOpt->registerOption( "console", "", "Launch as a console application without graphics" ); progOpt->registerOption( "server", "[]", "Launch as a GRPC server. Default port is 50051", cvf::ProgramOptions::SINGLE_VALUE ); progOpt->registerOption( "portnumberfile", "[]", "Write the port number to this file.", cvf::ProgramOptions::SINGLE_VALUE ); + progOpt->registerOption( "spix-port", + "", + "Start Spix UI test automation server on the given HTTP port.", + cvf::ProgramOptions::SINGLE_VALUE ); progOpt->registerOption( "threadcount", "", "Set number of threads for parallel processing.\n", cvf::ProgramOptions::SINGLE_VALUE ); diff --git a/ApplicationLibCode/CMakeLists.txt b/ApplicationLibCode/CMakeLists.txt index 1b11e524e75..de88f1c29c9 100644 --- a/ApplicationLibCode/CMakeLists.txt +++ b/ApplicationLibCode/CMakeLists.txt @@ -355,6 +355,10 @@ target_link_libraries( ) target_link_libraries(ApplicationLibCode PRIVATE ResInsightCommonSettings) +if(RESINSIGHT_ENABLE_SPIX) + target_link_libraries(ApplicationLibCode PUBLIC Spix::QtWidgets) +endif() + target_include_directories( ${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/Commands diff --git a/ApplicationLibCode/UserInterface/RiuMainWindow.cpp b/ApplicationLibCode/UserInterface/RiuMainWindow.cpp index 5915dd28aeb..a2149266e9d 100644 --- a/ApplicationLibCode/UserInterface/RiuMainWindow.cpp +++ b/ApplicationLibCode/UserInterface/RiuMainWindow.cpp @@ -141,6 +141,7 @@ RiuMainWindow::RiuMainWindow() , m_seismicHistogramPanel( nullptr ) { setAttribute( Qt::WA_DeleteOnClose ); + setObjectName( "RiuMainWindow" ); m_mdiArea = new RiuMdiArea( this ); connect( m_mdiArea, SIGNAL( subWindowActivated( QMdiSubWindow* ) ), SLOT( slotSubWindowActivated( QMdiSubWindow* ) ) ); @@ -503,6 +504,7 @@ void RiuMainWindow::createMenus() // Export menu actions QMenu* exportMenu = fileMenu->addMenu( "&Export" ); + exportMenu->setObjectName( "ExportMenu" ); exportMenu->addAction( cmdFeatureMgr->action( "RicSnapshotViewToFileFeature" ) ); exportMenu->addAction( m_snapshotAllViewsToFile ); exportMenu->addAction( cmdFeatureMgr->action( "RicAdvancedSnapshotExportFeature" ) ); @@ -525,6 +527,7 @@ void RiuMainWindow::createMenus() fileMenu->addSeparator(); QMenu* testMenu = fileMenu->addMenu( "&Testing" ); + testMenu->setObjectName( "TestingMenu" ); // Close and Exit actions fileMenu->addSeparator(); @@ -584,6 +587,7 @@ void RiuMainWindow::createMenus() // Windows menu m_windowMenu = menuBar()->addMenu( "&Windows" ); + m_windowMenu->setObjectName( "WindowsMenu" ); connect( m_windowMenu, SIGNAL( aboutToShow() ), SLOT( slotBuildWindowActions() ) ); // Help menu diff --git a/ApplicationLibCode/UserInterface/RiuMenuBarBuildTools.cpp b/ApplicationLibCode/UserInterface/RiuMenuBarBuildTools.cpp index c43784150c7..0714433c714 100644 --- a/ApplicationLibCode/UserInterface/RiuMenuBarBuildTools.cpp +++ b/ApplicationLibCode/UserInterface/RiuMenuBarBuildTools.cpp @@ -42,6 +42,7 @@ QMenu* RiuMenuBarBuildTools::createDefaultFileMenu( QMenuBar* menuBar ) QMenu* fileMenu = new RiuToolTipMenu( menuBar ); fileMenu->setTitle( "&File" ); + fileMenu->setObjectName( "FileMenu" ); menuBar->addMenu( fileMenu ); @@ -60,6 +61,7 @@ QMenu* RiuMenuBarBuildTools::createDefaultEditMenu( QMenuBar* menuBar ) CVF_ASSERT( menuBar && cmdFeatureMgr ); QMenu* editMenu = menuBar->addMenu( "&Edit" ); + editMenu->setObjectName( "EditMenu" ); editMenu->addAction( cmdFeatureMgr->action( "RicSnapshotViewToClipboardFeature" ) ); editMenu->addSeparator(); editMenu->addAction( cmdFeatureMgr->action( "RicShowMemoryCleanupDialogFeature" ) ); @@ -78,6 +80,7 @@ QMenu* RiuMenuBarBuildTools::createDefaultViewMenu( QMenuBar* menuBar ) CVF_ASSERT( menuBar && cmdFeatureMgr ); QMenu* viewMenu = menuBar->addMenu( "&View" ); + viewMenu->setObjectName( "ViewMenu" ); viewMenu->addAction( cmdFeatureMgr->action( "RicViewZoomAllFeature" ) ); return viewMenu; @@ -92,6 +95,7 @@ QMenu* RiuMenuBarBuildTools::createDefaultHelpMenu( QMenuBar* menuBar ) CVF_ASSERT( menuBar && cmdFeatureMgr ); QMenu* helpMenu = menuBar->addMenu( "&Help" ); + helpMenu->setObjectName( "HelpMenu" ); helpMenu->addAction( cmdFeatureMgr->action( "RicHelpAboutFeature" ) ); helpMenu->addAction( cmdFeatureMgr->action( "RicHelpCommandLineFeature" ) ); helpMenu->addAction( cmdFeatureMgr->action( "RicHelpSummaryCommandLineFeature" ) ); diff --git a/ApplicationLibCode/UserInterface/RiuPlotMainWindow.cpp b/ApplicationLibCode/UserInterface/RiuPlotMainWindow.cpp index 3d0ca438090..96f8da259c4 100644 --- a/ApplicationLibCode/UserInterface/RiuPlotMainWindow.cpp +++ b/ApplicationLibCode/UserInterface/RiuPlotMainWindow.cpp @@ -98,6 +98,8 @@ RiuPlotMainWindow::RiuPlotMainWindow() , m_autoUpdateEnabled( false ) , m_autoUpdateTimerId( -1 ) { + setObjectName( "RiuPlotMainWindow" ); + m_mdiArea = new RiuMdiArea( this ); connect( m_mdiArea, SIGNAL( subWindowActivated( QMdiSubWindow* ) ), SLOT( slotSubWindowActivated( QMdiSubWindow* ) ) ); diff --git a/CMakeLists.txt b/CMakeLists.txt index f6967efa491..17bf7e46909 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -255,6 +255,50 @@ if(RESINSIGHT_ENABLE_GRPC) add_definitions(-DENABLE_GRPC) endif() +# ############################################################################## +# Spix UI test automation +# ############################################################################## +option(RESINSIGHT_ENABLE_SPIX + "Enable Spix UI test automation (HTTP/RPC server)" OFF +) +if(RESINSIGHT_ENABLE_SPIX) + # Spix is fetched as source and built against the project's existing Qt6 + # rather than installed via vcpkg, so that vcpkg does not pull in qtbase / + # qtdeclarative. anyrpc is consumed via vcpkg; Spix ships its own + # FindAnyRPC.cmake (module mode) and locates the vcpkg-installed library via + # CMAKE_PREFIX_PATH once add_subdirectory(spix) runs. + + set(SPIX_BUILD_EXAMPLES + OFF + CACHE BOOL "" FORCE + ) + set(SPIX_BUILD_TESTS + OFF + CACHE BOOL "" FORCE + ) + set(SPIX_BUILD_QTQUICK + OFF + CACHE BOOL "" FORCE + ) + set(SPIX_BUILD_QTWIDGETS + ON + CACHE BOOL "" FORCE + ) + + include(FetchContent) + FetchContent_Declare( + spix + GIT_REPOSITORY https://github.com/faaxm/spix.git + GIT_TAG a9c9bb178f6ac00068d4d8dfc3e394337e596cf8 # v0.14 + ) + FetchContent_MakeAvailable(spix) + + add_definitions(-DENABLE_SPIX) + # Spix::QtWidgets is an ALIAS target, so it cannot be added to + # THIRD_PARTY_LIBRARIES (see the set_property(... FOLDER ...) walk later in + # this file). Linkage is wired in ApplicationLibCode/CMakeLists.txt. +endif() + # ############################################################################## # Unity Build # ############################################################################## diff --git a/GrpcInterface/Python/rips/tests_spix/test_spix_case_load.py b/GrpcInterface/Python/rips/tests_spix/test_spix_case_load.py new file mode 100644 index 00000000000..a75334bd66f --- /dev/null +++ b/GrpcInterface/Python/rips/tests_spix/test_spix_case_load.py @@ -0,0 +1,33 @@ +import os +import time +import xmlrpc.client + +import pytest + + +def test_case_load_produces_no_errors(): + """First Spix test beyond the smoke check. + + Assumes ResInsight was launched with: + + --spix-port --case /TEST10K_FLT_LGR_NNC/TEST10K_FLT_LGR_NNC.EGRID + + Verifies the full GUI case-load path completes without errors. + The gRPC rips tests do not exercise this path — they use the + command server rather than the GUI's load codepath. + """ + port = os.environ.get("RESINSIGHT_SPIX_PORT") + if not port: + pytest.skip( + "RESINSIGHT_SPIX_PORT not set; ResInsight must be launched " + "with --spix-port --case /TEST10K_FLT_LGR_NNC.EGRID" + ) + + proxy = xmlrpc.client.ServerProxy(f"http://localhost:{port}/") + + # No widget signal to wait on yet (Qt objectNames not seeded); + # let the async case-load settle before reading errors. + time.sleep(2.0) + + errors = proxy.getErrors() + assert errors == [], f"Spix reported errors after case load: {errors}" diff --git a/GrpcInterface/Python/rips/tests_spix/test_spix_main_window.py b/GrpcInterface/Python/rips/tests_spix/test_spix_main_window.py new file mode 100644 index 00000000000..c6644ff7426 --- /dev/null +++ b/GrpcInterface/Python/rips/tests_spix/test_spix_main_window.py @@ -0,0 +1,29 @@ +import os +import time +import xmlrpc.client + +import pytest + + +def test_main_window_addressable(): + """Verifies Spix can locate RiuMainWindow by objectName. + + Assumes ResInsight was launched with --spix-port . + + This is the first test that depends on widget naming. If it fails, + the regression is most likely in the objectName seeding rather + than in Spix itself. + """ + port = os.environ.get("RESINSIGHT_SPIX_PORT") + if not port: + pytest.skip("RESINSIGHT_SPIX_PORT not set") + + proxy = xmlrpc.client.ServerProxy(f"http://localhost:{port}/") + + # Let the main window finish showing before probing it. + time.sleep(1.0) + + assert proxy.existsAndVisible("RiuMainWindow"), ( + "RiuMainWindow not found by Spix — check that " + 'RiuMainWindow::RiuMainWindow() calls setObjectName("RiuMainWindow")' + ) diff --git a/GrpcInterface/Python/rips/tests_spix/test_spix_menus.py b/GrpcInterface/Python/rips/tests_spix/test_spix_menus.py new file mode 100644 index 00000000000..18a314f4ee7 --- /dev/null +++ b/GrpcInterface/Python/rips/tests_spix/test_spix_menus.py @@ -0,0 +1,47 @@ +import os +import time +import xmlrpc.client + +import pytest + + +def test_main_window_menus_reachable(): + """Verifies window branding and that every seeded top-level menu is + reachable from RiuMainWindow via its objectName. + + Exercises Spix's getStringProperty RPC against seven QMenu widgets + plus the QMainWindow itself. QMenus parented to a menubar report + isVisible()==False until popped up, so existsAndVisible would + return False; getStringProperty still works because the QObject + tree lookup ignores visibility. + + Assumes ResInsight was launched with --spix-port . + """ + port = os.environ.get("RESINSIGHT_SPIX_PORT") + if not port: + pytest.skip("RESINSIGHT_SPIX_PORT not set") + + proxy = xmlrpc.client.ServerProxy(f"http://localhost:{port}/") + + time.sleep(1.0) + + title = proxy.getStringProperty("RiuMainWindow", "windowTitle") + assert "ResInsight" in title, f"Unexpected window title: {title!r}" + + for menu in ( + "FileMenu", + "EditMenu", + "ViewMenu", + "WindowsMenu", + "HelpMenu", + "ExportMenu", + "TestingMenu", + ): + name = proxy.getStringProperty(f"RiuMainWindow//{menu}", "objectName") + assert name == menu, ( + f"Menu '{menu}' not reachable from RiuMainWindow " + f"(getString returned {name!r}). Check setObjectName in " + f"RiuMenuBarBuildTools or RiuMainWindow::createMenus()." + ) + + assert proxy.getErrors() == [] diff --git a/GrpcInterface/Python/rips/tests_spix/test_spix_smoke.py b/GrpcInterface/Python/rips/tests_spix/test_spix_smoke.py new file mode 100644 index 00000000000..2da80fb135c --- /dev/null +++ b/GrpcInterface/Python/rips/tests_spix/test_spix_smoke.py @@ -0,0 +1,18 @@ +import os +import xmlrpc.client + +import pytest + + +def test_spix_server_responds(): + port = os.environ.get("RESINSIGHT_SPIX_PORT") + if not port: + pytest.skip( + "RESINSIGHT_SPIX_PORT not set; ResInsight must be launched with --spix-port " + ) + + proxy = xmlrpc.client.ServerProxy(f"http://localhost:{port}/") + # getErrors() takes no widget path, so this verifies the RPC layer + # is alive without depending on any specific widget's objectName. + errors = proxy.getErrors() + assert isinstance(errors, list) diff --git a/vcpkg.json b/vcpkg.json index 5d20c255691..82525cc0da6 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,5 +1,6 @@ { "dependencies": [ + "anyrpc", "arrow", "boost-filesystem", "boost-spirit",