This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new eab6742628 hardening on widget disable, fixes #7512 (#7515)
eab6742628 is described below

commit eab67426289af07e40066a1f41b1d9eb6d6af68b
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Tue Jul 14 17:18:37 2026 +0200

    hardening on widget disable, fixes #7512 (#7515)
    
    * hardening on widget disable, fixes #7512
    
    * fix test from spamming error stacks
---
 .../generic/GenericDisabledWidgetsTest.java        |  39 ++
 .../databases/hive/HiveDisabledWidgetsTest.java    |  39 ++
 .../impala/ImpalaDisabledWidgetsTest.java          |  39 ++
 .../informix/InformixDisabledWidgetsTest.java      |  39 ++
 .../mssql/MsSqlServerDisabledWidgetsTest.java      |  39 ++
 .../mssqlnative/MsSqlServerNativeDatabaseMeta.java |   8 +-
 .../MsSqlServerDisabledWidgetNpeTest.java          |  43 ++
 .../databases/mysql/MySqlDisabledWidgetsTest.java  |  39 ++
 .../SingleStoreDisabledWidgetsTest.java            |  39 ++
 .../snowflake/SnowflakeDisabledWidgetsTest.java    |  39 ++
 .../hop/beam/engines/BeamDisabledWidgetsTest.java  |  39 ++
 .../SingleThreadedDisabledWidgetsTest.java         |  39 ++
 .../DocumentationDisabledWidgetsTest.java          |  39 ++
 .../org/apache/hop/git/GitCommitPerspective.java   |  54 ++-
 .../java/org/apache/hop/git/GitPerspective.java    |  76 ++--
 .../hop/git/config/GitDisabledWidgetsTest.java     |  39 ++
 .../config/ProjectsDisabledWidgetsTest.java        |  39 ++
 .../reflection/probe/gui/DataProbeGuiPlugin.java   |   9 +-
 .../PipelineResolverDisabledWidgetsTest.java       |  39 ++
 .../arrow/datastream/ArrowDisabledWidgetsTest.java |  39 ++
 .../vfs/azure/config/AzureDisabledWidgetsTest.java |  39 ++
 .../metadata/CassandraDisabledWidgetsTest.java     |  39 ++
 .../databricks/DatabricksDisabledWidgetsTest.java  |  39 ++
 .../dropbox/config/DropboxDisabledWidgetsTest.java |  39 ++
 .../elastic/ElasticDisabledWidgetsTest.java        |  39 ++
 .../vfs/gs/config/GoogleDisabledWidgetsTest.java   |  42 ++
 .../minio/metadata/MinioDisabledWidgetsTest.java   |  39 ++
 .../mongo/metadata/MongoDbConnectionEditor.java    |   6 +
 .../mongo/metadata/MongoDbDisabledWidgetsTest.java |  39 ++
 .../neo4j/execution/Neo4jDisabledWidgetsTest.java  |  39 ++
 .../opensearch/OpenSearchDisabledWidgetsTest.java  |  39 ++
 .../resolver/vault/VaultDisabledWidgetsTest.java   |  39 ++
 .../streaminput/DatastreamDisabledWidgetsTest.java |  41 ++
 .../transforms/ddl/DdlDisabledWidgetsTest.java     |  39 ++
 .../hop/ui/hopgui/DisabledGuiWidgetsNpeTest.java   |  38 ++
 .../hop/ui/core/gui/GuiCompositeWidgets.java       |  20 +-
 .../hop/ui/core/metadata/MetadataManager.java      |  20 +-
 .../hop/ui/core/widget/MetaSelectionLine.java      |   6 +
 .../org/apache/hop/ui/core/widget/OsHelper.java    |  11 +-
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |  27 +-
 .../hopgui/perspective/HopPerspectiveManager.java  |   8 +-
 .../configuration/ConfigurationPerspective.java    |  20 +-
 .../execution/ExecutionPerspective.java            |  47 +-
 .../perspective/explorer/ExplorerPerspective.java  |  15 +-
 .../perspective/metadata/MetadataPerspective.java  |  44 +-
 .../search/HopGuiDescribedVariableSearchable.java  |   8 +-
 .../hop/ui/www/service/WebServiceGuiPlugin.java    |   9 +-
 .../perspective/DisabledPerspectivesTest.java      |  79 ++++
 .../ExecutionPerspectiveDisabledTest.java          |  86 ++++
 .../hop/ui/testing/DisabledGuiWidgetsTestBase.java | 481 +++++++++++++++++++++
 50 files changed, 2129 insertions(+), 83 deletions(-)

diff --git 
a/plugins/databases/generic/src/test/java/org/apache/hop/databases/generic/GenericDisabledWidgetsTest.java
 
b/plugins/databases/generic/src/test/java/org/apache/hop/databases/generic/GenericDisabledWidgetsTest.java
new file mode 100644
index 0000000000..1ec1dd79ca
--- /dev/null
+++ 
b/plugins/databases/generic/src/test/java/org/apache/hop/databases/generic/GenericDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.generic;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class GenericDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases");
+  }
+}
diff --git 
a/plugins/databases/hive/src/test/java/org/apache/hop/databases/hive/HiveDisabledWidgetsTest.java
 
b/plugins/databases/hive/src/test/java/org/apache/hop/databases/hive/HiveDisabledWidgetsTest.java
new file mode 100644
index 0000000000..2963c636de
--- /dev/null
+++ 
b/plugins/databases/hive/src/test/java/org/apache/hop/databases/hive/HiveDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.hive;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class HiveDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases");
+  }
+}
diff --git 
a/plugins/databases/impala/src/test/java/org/apache/hop/databases/impala/ImpalaDisabledWidgetsTest.java
 
b/plugins/databases/impala/src/test/java/org/apache/hop/databases/impala/ImpalaDisabledWidgetsTest.java
new file mode 100644
index 0000000000..b3517a19b5
--- /dev/null
+++ 
b/plugins/databases/impala/src/test/java/org/apache/hop/databases/impala/ImpalaDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.impala;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class ImpalaDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases");
+  }
+}
diff --git 
a/plugins/databases/informix/src/test/java/org/apache/hop/databases/informix/InformixDisabledWidgetsTest.java
 
b/plugins/databases/informix/src/test/java/org/apache/hop/databases/informix/InformixDisabledWidgetsTest.java
new file mode 100644
index 0000000000..403b45bbaf
--- /dev/null
+++ 
b/plugins/databases/informix/src/test/java/org/apache/hop/databases/informix/InformixDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.informix;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class InformixDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases");
+  }
+}
diff --git 
a/plugins/databases/mssql/src/test/java/org/apache/hop/databases/mssql/MsSqlServerDisabledWidgetsTest.java
 
b/plugins/databases/mssql/src/test/java/org/apache/hop/databases/mssql/MsSqlServerDisabledWidgetsTest.java
new file mode 100644
index 0000000000..bc84335c4d
--- /dev/null
+++ 
b/plugins/databases/mssql/src/test/java/org/apache/hop/databases/mssql/MsSqlServerDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.mssql;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class MsSqlServerDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases");
+  }
+}
diff --git 
a/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMeta.java
 
b/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMeta.java
index 68efe29d62..d4b84ce1ec 100644
--- 
a/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMeta.java
+++ 
b/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMeta.java
@@ -94,7 +94,13 @@ public class MsSqlServerNativeDatabaseMeta extends 
MsSqlServerDatabaseMeta
     Button wIntegratedSecurity =
         (Button) 
compositeWidgets.getWidgetsMap().get(ID_INTEGRATED_SECURITY_WIDGET);
 
-    boolean enable = !wIntegratedSecurity.getSelection();
+    // The checkbox is absent when it is switched off in 
disabledGuiElements.xml. There is nothing
+    // to read the setting from then, so we go by what is on the metadata 
itself.
+    //
+    boolean enable =
+        wIntegratedSecurity == null
+            ? !isUsingIntegratedSecurity()
+            : !wIntegratedSecurity.getSelection();
     for (Control control : controls) {
       control.setEnabled(enable);
     }
diff --git 
a/plugins/databases/mssqlnative/src/test/java/org/apache/hop/databases/mssqlnative/MsSqlServerDisabledWidgetNpeTest.java
 
b/plugins/databases/mssqlnative/src/test/java/org/apache/hop/databases/mssqlnative/MsSqlServerDisabledWidgetNpeTest.java
new file mode 100644
index 0000000000..8564f54151
--- /dev/null
+++ 
b/plugins/databases/mssqlnative/src/test/java/org/apache/hop/databases/mssqlnative/MsSqlServerDisabledWidgetNpeTest.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.mssqlnative;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables the widgets of the MS SQL Server database metas, one at a time.
+ *
+ * <p>This is the case that put the harness here: {@code
+ * MsSqlServerNativeDatabaseMeta.enableField()} used to look the {@code 
usingIntegratedSecurity}
+ * checkbox up by id and call {@code getSelection()} on it without a null 
check - the username and
+ * password lookups right above it <em>were</em> guarded - so excluding that 
one checkbox in
+ * disabledGuiElements.xml turned the database dialog into a 
NullPointerException.
+ */
+@Tag("uitest")
+class MsSqlServerDisabledWidgetNpeTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // The hop-core/hop-engine/hop-ui widgets that also sit on this module's 
classpath are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases");
+  }
+}
diff --git 
a/plugins/databases/mysql/src/test/java/org/apache/hop/databases/mysql/MySqlDisabledWidgetsTest.java
 
b/plugins/databases/mysql/src/test/java/org/apache/hop/databases/mysql/MySqlDisabledWidgetsTest.java
new file mode 100644
index 0000000000..99484dfa10
--- /dev/null
+++ 
b/plugins/databases/mysql/src/test/java/org/apache/hop/databases/mysql/MySqlDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.mysql;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class MySqlDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases");
+  }
+}
diff --git 
a/plugins/databases/singlestore/src/test/java/org/apache/hop/databases/singlestore/SingleStoreDisabledWidgetsTest.java
 
b/plugins/databases/singlestore/src/test/java/org/apache/hop/databases/singlestore/SingleStoreDisabledWidgetsTest.java
new file mode 100644
index 0000000000..11eaa850ee
--- /dev/null
+++ 
b/plugins/databases/singlestore/src/test/java/org/apache/hop/databases/singlestore/SingleStoreDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.singlestore;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class SingleStoreDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases");
+  }
+}
diff --git 
a/plugins/databases/snowflake/src/test/java/org/apache/hop/databases/snowflake/SnowflakeDisabledWidgetsTest.java
 
b/plugins/databases/snowflake/src/test/java/org/apache/hop/databases/snowflake/SnowflakeDisabledWidgetsTest.java
new file mode 100644
index 0000000000..10ea65a6e1
--- /dev/null
+++ 
b/plugins/databases/snowflake/src/test/java/org/apache/hop/databases/snowflake/SnowflakeDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.snowflake;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class SnowflakeDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases");
+  }
+}
diff --git 
a/plugins/engines/beam/src/test/java/org/apache/hop/beam/engines/BeamDisabledWidgetsTest.java
 
b/plugins/engines/beam/src/test/java/org/apache/hop/beam/engines/BeamDisabledWidgetsTest.java
new file mode 100644
index 0000000000..477e8a2def
--- /dev/null
+++ 
b/plugins/engines/beam/src/test/java/org/apache/hop/beam/engines/BeamDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.beam.engines;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class BeamDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.beam");
+  }
+}
diff --git 
a/plugins/engines/single-threaded/src/test/java/org/apache/hop/pipeline/engines/singlethreaded/SingleThreadedDisabledWidgetsTest.java
 
b/plugins/engines/single-threaded/src/test/java/org/apache/hop/pipeline/engines/singlethreaded/SingleThreadedDisabledWidgetsTest.java
new file mode 100644
index 0000000000..c1991436df
--- /dev/null
+++ 
b/plugins/engines/single-threaded/src/test/java/org/apache/hop/pipeline/engines/singlethreaded/SingleThreadedDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline.engines.singlethreaded;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class SingleThreadedDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.pipeline.engines.singlethreaded");
+  }
+}
diff --git 
a/plugins/misc/documentation/src/test/java/org/apache/hop/documentation/DocumentationDisabledWidgetsTest.java
 
b/plugins/misc/documentation/src/test/java/org/apache/hop/documentation/DocumentationDisabledWidgetsTest.java
new file mode 100644
index 0000000000..6477e3b793
--- /dev/null
+++ 
b/plugins/misc/documentation/src/test/java/org/apache/hop/documentation/DocumentationDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.documentation;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class DocumentationDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.documentation");
+  }
+}
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
index d5216ff009..b919e537cf 100644
--- 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
+++ 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitCommitPerspective.java
@@ -73,6 +73,7 @@ import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Event;
 import org.eclipse.swt.widgets.Menu;
+import org.eclipse.swt.widgets.MenuItem;
 import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.Text;
 import org.eclipse.swt.widgets.Tree;
@@ -136,9 +137,28 @@ public class GitCommitPerspective implements 
IHopPerspective {
   @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'o', global = 
true)
   @Override
   public void activate() {
+    if (hopGui == null) {
+      // The perspective is switched off in disabledGuiElements.xml: it was 
never initialized, so
+      // there is nothing to switch to. The git toolbar and menu items that 
lead here live on the
+      // explorer perspective and are still there.
+      //
+      return;
+    }
     hopGui.setActivePerspective(this);
   }
 
+  /**
+   * Enable or disable a context menu item. Every menu item can be switched 
off with an exclusion
+   * for its id in disabledGuiElements.xml, in which case it was never created 
and there is nothing
+   * here to enable.
+   */
+  private static void setMenuItemEnabled(GuiMenuWidgets menuWidgets, String 
id, boolean enabled) {
+    MenuItem menuItem = menuWidgets.findMenuItem(id);
+    if (menuItem != null) {
+      menuItem.setEnabled(enabled);
+    }
+  }
+
   @Override
   public void perspectiveActivated() {
     // TODO: avoid refresh when perspective is activated, detect change in the 
config, metadata,
@@ -148,7 +168,7 @@ public class GitCommitPerspective implements 
IHopPerspective {
 
   @Override
   public boolean isActive() {
-    return hopGui.isActivePerspective(this);
+    return hopGui != null && hopGui.isActivePerspective(this);
   }
 
   @Override
@@ -232,28 +252,16 @@ public class GitCommitPerspective implements 
IHopPerspective {
         event -> {
           List<UIFile> selectedFiles = this.getSelectedFiles();
 
-          menuWidgets
-              .findMenuItem(CONTEXT_MENU_ADD)
-              .setEnabled(!getSelectedUntrackedFiles().isEmpty());
-          menuWidgets
-              .findMenuItem(CONTEXT_MENU_ADD_TO_GIT_IGNORE)
-              .setEnabled(!selectedFiles.isEmpty());
-          menuWidgets
-              .findMenuItem(CONTEXT_MENU_SHOW_TEXT_DIFF)
-              // .setEnabled(selectedFiles.stream().anyMatch(file -> 
file.getChangeType() ==
-              // DiffEntry.ChangeType.MODIFY));
-              .setEnabled(!selectedFiles.isEmpty());
-          menuWidgets
-              .findMenuItem(CONTEXT_MENU_SHOW_GRAPH_DIFF)
-              // .setEnabled(selectedFiles.stream().anyMatch(file -> 
file.getChangeType() ==
-              // DiffEntry.ChangeType.MODIFY));
-              .setEnabled(
-                  selectedFiles.stream()
-                      .anyMatch(file -> 
FileTypeUtils.isHopFileType(file.getName())));
-          menuWidgets
-              .findMenuItem(CONTEXT_MENU_RESTORE)
-              .setEnabled(!getSelectedStagedFiles().isEmpty());
-          
menuWidgets.findMenuItem(CONTEXT_MENU_DELETE).setEnabled(!selectedFiles.isEmpty());
+          setMenuItemEnabled(menuWidgets, CONTEXT_MENU_ADD, 
!getSelectedUntrackedFiles().isEmpty());
+          setMenuItemEnabled(menuWidgets, CONTEXT_MENU_ADD_TO_GIT_IGNORE, 
!selectedFiles.isEmpty());
+          setMenuItemEnabled(menuWidgets, CONTEXT_MENU_SHOW_TEXT_DIFF, 
!selectedFiles.isEmpty());
+          setMenuItemEnabled(
+              menuWidgets,
+              CONTEXT_MENU_SHOW_GRAPH_DIFF,
+              selectedFiles.stream().anyMatch(file -> 
FileTypeUtils.isHopFileType(file.getName())));
+          setMenuItemEnabled(
+              menuWidgets, CONTEXT_MENU_RESTORE, 
!getSelectedStagedFiles().isEmpty());
+          setMenuItemEnabled(menuWidgets, CONTEXT_MENU_DELETE, 
!selectedFiles.isEmpty());
 
           // Show the menu
           menu.setVisible(true);
diff --git 
a/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java 
b/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
index 71016752b7..c0ffcb7294 100644
--- a/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
+++ b/plugins/misc/git/src/main/java/org/apache/hop/git/GitPerspective.java
@@ -236,9 +236,28 @@ public class GitPerspective implements IHopPerspective {
   @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'g', global = 
true)
   @Override
   public void activate() {
+    if (hopGui == null) {
+      // The perspective is switched off in disabledGuiElements.xml: it was 
never initialized, so
+      // there is nothing to switch to. The git toolbar and menu items that 
lead here live on the
+      // explorer perspective and are still there.
+      //
+      return;
+    }
     hopGui.setActivePerspective(this);
   }
 
+  /**
+   * Enable or disable a context menu item. Every menu item can be switched 
off with an exclusion
+   * for its id in disabledGuiElements.xml, in which case it was never created 
and there is nothing
+   * here to enable.
+   */
+  private static void setMenuItemEnabled(GuiMenuWidgets menuWidgets, String 
id, boolean enabled) {
+    MenuItem menuItem = menuWidgets.findMenuItem(id);
+    if (menuItem != null) {
+      menuItem.setEnabled(enabled);
+    }
+  }
+
   @Override
   public void perspectiveActivated() {
     // TODO: avoid refresh when perspective is activated, detect change in the 
file explorer model
@@ -249,7 +268,7 @@ public class GitPerspective implements IHopPerspective {
 
   @Override
   public boolean isActive() {
-    return hopGui.isActivePerspective(this);
+    return hopGui != null && hopGui.isActivePerspective(this);
   }
 
   @Override
@@ -371,23 +390,26 @@ public class GitPerspective implements IHopPerspective {
           boolean isRemotes = ref.getName().startsWith(Constants.R_REMOTES);
           boolean isTags = ref.getName().startsWith(Constants.R_TAGS);
 
-          
refMenuWidgets.findMenuItem(REF_CONTEXT_MENU_CHECKOUT).setEnabled(!isCurrentBranch);
-          
refMenuWidgets.findMenuItem(REF_CONTEXT_MENU_MERGE_BRANCH).setEnabled(!isCurrentBranch);
-          
refMenuWidgets.findMenuItem(REF_CONTEXT_MENU_PUSH).setEnabled(isHeads);
-          
refMenuWidgets.findMenuItem(REF_CONTEXT_MENU_PULL).setEnabled(isHeads);
-          
refMenuWidgets.findMenuItem(REF_CONTEXT_MENU_RENAME).setEnabled(isHeads);
-          
refMenuWidgets.findMenuItem(REF_CONTEXT_MENU_DELETE).setEnabled(!isCurrentBranch);
+          setMenuItemEnabled(refMenuWidgets, REF_CONTEXT_MENU_CHECKOUT, 
!isCurrentBranch);
+          setMenuItemEnabled(refMenuWidgets, REF_CONTEXT_MENU_PUSH, isHeads);
+          setMenuItemEnabled(refMenuWidgets, REF_CONTEXT_MENU_PULL, isHeads);
+          setMenuItemEnabled(refMenuWidgets, REF_CONTEXT_MENU_RENAME, isHeads);
+          setMenuItemEnabled(refMenuWidgets, REF_CONTEXT_MENU_DELETE, 
!isCurrentBranch);
 
           MenuItem menuItem = 
refMenuWidgets.findMenuItem(REF_CONTEXT_MENU_CREATE_BRANCH);
-          menuItem.setEnabled(isHeads || isRemotes);
-          menuItem.setText(
-              BaseMessages.getString(PKG, 
"GitPerspective.Menu.CreateBranchFrom.Text", branch));
+          if (menuItem != null) {
+            menuItem.setEnabled(isHeads || isRemotes);
+            menuItem.setText(
+                BaseMessages.getString(PKG, 
"GitPerspective.Menu.CreateBranchFrom.Text", branch));
+          }
 
           menuItem = 
refMenuWidgets.findMenuItem(REF_CONTEXT_MENU_MERGE_BRANCH);
-          menuItem.setEnabled(!isCurrentBranch);
-          menuItem.setText(
-              BaseMessages.getString(
-                  PKG, "GitPerspective.Menu.MergeInto.Text", branch, 
currentBranch));
+          if (menuItem != null) {
+            menuItem.setEnabled(!isCurrentBranch);
+            menuItem.setText(
+                BaseMessages.getString(
+                    PKG, "GitPerspective.Menu.MergeInto.Text", branch, 
currentBranch));
+          }
         });
 
     // Create Tree editor for rename
@@ -437,10 +459,11 @@ public class GitPerspective implements IHopPerspective {
           RevCommit commit = getSelectedCommit();
           String path = getSelectedFile();
           if (commit != null && path != null) {
-            
fileMenuWidgets.findMenuItem(FILE_CONTEXT_MENU_SHOW_TEXT_DIFF).setEnabled(true);
-            fileMenuWidgets
-                .findMenuItem(FILE_CONTEXT_MENU_SHOW_GRAPH_DIFF)
-                .setEnabled(FileTypeUtils.isHopFileType(path));
+            setMenuItemEnabled(fileMenuWidgets, 
FILE_CONTEXT_MENU_SHOW_TEXT_DIFF, true);
+            setMenuItemEnabled(
+                fileMenuWidgets,
+                FILE_CONTEXT_MENU_SHOW_GRAPH_DIFF,
+                FileTypeUtils.isHopFileType(path));
           } else {
             event.doit = false;
           }
@@ -1488,18 +1511,17 @@ public class GitPerspective implements IHopPerspective {
 
             boolean isCommitInCurrentBranch = isCommitInCurrentBranch(commit);
 
-            historyMenuWidgets
-                .findMenuItem(HISTORY_CONTEXT_MENU_RESET)
-                .setEnabled(isCommitInCurrentBranch);
+            setMenuItemEnabled(
+                historyMenuWidgets, HISTORY_CONTEXT_MENU_RESET, 
isCommitInCurrentBranch);
 
             // Revert commit with multiple parents isn't allowed
-            historyMenuWidgets
-                .findMenuItem(HISTORY_CONTEXT_MENU_REVERT_COMMIT)
-                .setEnabled(isCommitInCurrentBranch && commit.getParentCount() 
== 1);
+            setMenuItemEnabled(
+                historyMenuWidgets,
+                HISTORY_CONTEXT_MENU_REVERT_COMMIT,
+                isCommitInCurrentBranch && commit.getParentCount() == 1);
 
-            historyMenuWidgets
-                .findMenuItem(HISTORY_CONTEXT_MENU_CHERRY_PICK)
-                .setEnabled(!isCommitInCurrentBranch);
+            setMenuItemEnabled(
+                historyMenuWidgets, HISTORY_CONTEXT_MENU_CHERRY_PICK, 
!isCommitInCurrentBranch);
 
           } else {
             event.doit = false;
diff --git 
a/plugins/misc/git/src/test/java/org/apache/hop/git/config/GitDisabledWidgetsTest.java
 
b/plugins/misc/git/src/test/java/org/apache/hop/git/config/GitDisabledWidgetsTest.java
new file mode 100644
index 0000000000..03ba09fbfe
--- /dev/null
+++ 
b/plugins/misc/git/src/test/java/org/apache/hop/git/config/GitDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.git.config;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class GitDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.git");
+  }
+}
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/config/ProjectsDisabledWidgetsTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/config/ProjectsDisabledWidgetsTest.java
new file mode 100644
index 0000000000..2e9e3397e7
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/config/ProjectsDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.config;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class ProjectsDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.projects");
+  }
+}
diff --git 
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/gui/DataProbeGuiPlugin.java
 
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/gui/DataProbeGuiPlugin.java
index 61f8946299..4165cfcb81 100644
--- 
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/gui/DataProbeGuiPlugin.java
+++ 
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/gui/DataProbeGuiPlugin.java
@@ -115,13 +115,18 @@ public class DataProbeGuiPlugin {
 
         // See if it's open in the metadata perspective...
         //
+        // The perspective is absent when it is switched off in 
disabledGuiElements.xml, and then
+        // nothing is open in it.
+        //
         MetadataPerspective perspective =
             (MetadataPerspective)
                 
hopGui.getPerspectiveManager().findPerspective(MetadataPerspective.class);
         String key = 
PipelineProbe.class.getAnnotation(HopMetadata.class).key();
         PipelineProbeEditor editor =
-            (PipelineProbeEditor) perspective.findEditor(key, 
pipelineProbe.getName());
-        if (editor != null) {
+            perspective == null
+                ? null
+                : (PipelineProbeEditor) perspective.findEditor(key, 
pipelineProbe.getName());
+        if (perspective != null && editor != null) {
           // We're going to change the current metadata and flag it as 
changed...
           //
           pipelineProbe = new PipelineProbe();
diff --git 
a/plugins/resolvers/pipeline/src/test/java/org/apache/hop/resolvers/pipeline/PipelineResolverDisabledWidgetsTest.java
 
b/plugins/resolvers/pipeline/src/test/java/org/apache/hop/resolvers/pipeline/PipelineResolverDisabledWidgetsTest.java
new file mode 100644
index 0000000000..dede9510a0
--- /dev/null
+++ 
b/plugins/resolvers/pipeline/src/test/java/org/apache/hop/resolvers/pipeline/PipelineResolverDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.resolvers.pipeline;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class PipelineResolverDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.resolvers");
+  }
+}
diff --git 
a/plugins/tech/arrow/src/test/java/org/apache/hop/arrow/datastream/ArrowDisabledWidgetsTest.java
 
b/plugins/tech/arrow/src/test/java/org/apache/hop/arrow/datastream/ArrowDisabledWidgetsTest.java
new file mode 100644
index 0000000000..a14b70495a
--- /dev/null
+++ 
b/plugins/tech/arrow/src/test/java/org/apache/hop/arrow/datastream/ArrowDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.arrow.datastream;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class ArrowDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.arrow");
+  }
+}
diff --git 
a/plugins/tech/azure/src/test/java/org/apache/hop/vfs/azure/config/AzureDisabledWidgetsTest.java
 
b/plugins/tech/azure/src/test/java/org/apache/hop/vfs/azure/config/AzureDisabledWidgetsTest.java
new file mode 100644
index 0000000000..973c00c1d0
--- /dev/null
+++ 
b/plugins/tech/azure/src/test/java/org/apache/hop/vfs/azure/config/AzureDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.vfs.azure.config;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class AzureDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.vfs.azure", 
"org.apache.hop.core.variables.resolver");
+  }
+}
diff --git 
a/plugins/tech/cassandra/src/test/java/org/apache/hop/databases/cassandra/metadata/CassandraDisabledWidgetsTest.java
 
b/plugins/tech/cassandra/src/test/java/org/apache/hop/databases/cassandra/metadata/CassandraDisabledWidgetsTest.java
new file mode 100644
index 0000000000..77eb6bee32
--- /dev/null
+++ 
b/plugins/tech/cassandra/src/test/java/org/apache/hop/databases/cassandra/metadata/CassandraDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.cassandra.metadata;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class CassandraDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.databases.cassandra");
+  }
+}
diff --git 
a/plugins/tech/databricks/src/test/java/org/apache/hop/database/databricks/DatabricksDisabledWidgetsTest.java
 
b/plugins/tech/databricks/src/test/java/org/apache/hop/database/databricks/DatabricksDisabledWidgetsTest.java
new file mode 100644
index 0000000000..9d11e5d143
--- /dev/null
+++ 
b/plugins/tech/databricks/src/test/java/org/apache/hop/database/databricks/DatabricksDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.database.databricks;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class DatabricksDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.database.databricks");
+  }
+}
diff --git 
a/plugins/tech/dropbox/src/test/java/org/apache/hop/vfs/dropbox/config/DropboxDisabledWidgetsTest.java
 
b/plugins/tech/dropbox/src/test/java/org/apache/hop/vfs/dropbox/config/DropboxDisabledWidgetsTest.java
new file mode 100644
index 0000000000..5b00d56817
--- /dev/null
+++ 
b/plugins/tech/dropbox/src/test/java/org/apache/hop/vfs/dropbox/config/DropboxDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.vfs.dropbox.config;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class DropboxDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.vfs.dropbox");
+  }
+}
diff --git 
a/plugins/tech/elastic/src/test/java/org/apache/hop/execution/elastic/ElasticDisabledWidgetsTest.java
 
b/plugins/tech/elastic/src/test/java/org/apache/hop/execution/elastic/ElasticDisabledWidgetsTest.java
new file mode 100644
index 0000000000..ab3880184b
--- /dev/null
+++ 
b/plugins/tech/elastic/src/test/java/org/apache/hop/execution/elastic/ElasticDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.execution.elastic;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class ElasticDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.execution.elastic");
+  }
+}
diff --git 
a/plugins/tech/google/src/test/java/org/apache/hop/vfs/gs/config/GoogleDisabledWidgetsTest.java
 
b/plugins/tech/google/src/test/java/org/apache/hop/vfs/gs/config/GoogleDisabledWidgetsTest.java
new file mode 100644
index 0000000000..d973cfd17f
--- /dev/null
+++ 
b/plugins/tech/google/src/test/java/org/apache/hop/vfs/gs/config/GoogleDisabledWidgetsTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.vfs.gs.config;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class GoogleDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of(
+        "org.apache.hop.vfs.gs",
+        "org.apache.hop.vfs.googledrive",
+        "org.apache.hop.core.variables.resolver");
+  }
+}
diff --git 
a/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/metadata/MinioDisabledWidgetsTest.java
 
b/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/metadata/MinioDisabledWidgetsTest.java
new file mode 100644
index 0000000000..21c66a5ef9
--- /dev/null
+++ 
b/plugins/tech/minio/src/test/java/org/apache/hop/vfs/minio/metadata/MinioDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.vfs.minio.metadata;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class MinioDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.vfs.minio");
+  }
+}
diff --git 
a/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnectionEditor.java
 
b/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnectionEditor.java
index 23da8a6cb3..3f4629c83a 100644
--- 
a/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnectionEditor.java
+++ 
b/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnectionEditor.java
@@ -153,6 +153,12 @@ public class MongoDbConnectionEditor extends 
MetadataEditor<MongoDbConnection>
 
   public void getDbNames() {
     ComboVar wDbName = (ComboVar) 
widgets.getWidgetsMap().get(MongoDbConnection.WIDGET_ID_DB_NAME);
+    if (wDbName == null) {
+      // The database name combo can be switched off in 
disabledGuiElements.xml, in which case there
+      // is nothing to fill in.
+      //
+      return;
+    }
     IVariables variables = manager.getVariables();
     ILogChannel log = LogChannel.UI;
 
diff --git 
a/plugins/tech/mongodb/src/test/java/org/apache/hop/mongo/metadata/MongoDbDisabledWidgetsTest.java
 
b/plugins/tech/mongodb/src/test/java/org/apache/hop/mongo/metadata/MongoDbDisabledWidgetsTest.java
new file mode 100644
index 0000000000..d9c4c761d5
--- /dev/null
+++ 
b/plugins/tech/mongodb/src/test/java/org/apache/hop/mongo/metadata/MongoDbDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.mongo.metadata;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class MongoDbDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.mongo");
+  }
+}
diff --git 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/Neo4jDisabledWidgetsTest.java
 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/Neo4jDisabledWidgetsTest.java
new file mode 100644
index 0000000000..00bf55f234
--- /dev/null
+++ 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/Neo4jDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.neo4j.execution;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class Neo4jDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.neo4j");
+  }
+}
diff --git 
a/plugins/tech/opensearch/src/test/java/org/apache/hop/execution/opensearch/OpenSearchDisabledWidgetsTest.java
 
b/plugins/tech/opensearch/src/test/java/org/apache/hop/execution/opensearch/OpenSearchDisabledWidgetsTest.java
new file mode 100644
index 0000000000..65e01bea9d
--- /dev/null
+++ 
b/plugins/tech/opensearch/src/test/java/org/apache/hop/execution/opensearch/OpenSearchDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.execution.opensearch;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class OpenSearchDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.execution.opensearch");
+  }
+}
diff --git 
a/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultDisabledWidgetsTest.java
 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultDisabledWidgetsTest.java
new file mode 100644
index 0000000000..56c53f14cf
--- /dev/null
+++ 
b/plugins/tech/vault/src/test/java/org/apache/hop/core/variables/resolver/vault/VaultDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.variables.resolver.vault;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class VaultDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.core.variables.resolver.vault");
+  }
+}
diff --git 
a/plugins/transforms/datastream/src/test/java/org/apache/hop/pipeline/transforms/streaminput/DatastreamDisabledWidgetsTest.java
 
b/plugins/transforms/datastream/src/test/java/org/apache/hop/pipeline/transforms/streaminput/DatastreamDisabledWidgetsTest.java
new file mode 100644
index 0000000000..5b92b2ef6a
--- /dev/null
+++ 
b/plugins/transforms/datastream/src/test/java/org/apache/hop/pipeline/transforms/streaminput/DatastreamDisabledWidgetsTest.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline.transforms.streaminput;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class DatastreamDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of(
+        "org.apache.hop.pipeline.transforms.streaminput",
+        "org.apache.hop.pipeline.transforms.streamoutput");
+  }
+}
diff --git 
a/plugins/transforms/ddl/src/test/java/org/apache/hop/pipeline/transforms/ddl/DdlDisabledWidgetsTest.java
 
b/plugins/transforms/ddl/src/test/java/org/apache/hop/pipeline/transforms/ddl/DdlDisabledWidgetsTest.java
new file mode 100644
index 0000000000..d3c3f94605
--- /dev/null
+++ 
b/plugins/transforms/ddl/src/test/java/org/apache/hop/pipeline/transforms/ddl/DdlDisabledWidgetsTest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline.transforms.ddl;
+
+import java.util.List;
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} this plugin registers, one at a 
time, and checks that
+ * its composite still builds and still survives the listener callbacks. See 
{@link
+ * DisabledGuiWidgetsTestBase}.
+ */
+@Tag("uitest")
+class DdlDisabledWidgetsTest extends DisabledGuiWidgetsTestBase {
+
+  @Override
+  protected List<String> packagesUnderTest() {
+    // hop-core, hop-engine and hop-ui are on this module's classpath too; 
their widgets are covered
+    // by the same harness in hop-ui-rcp.
+    //
+    return List.of("org.apache.hop.pipeline.transforms.ddl");
+  }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/DisabledGuiWidgetsNpeTest.java 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/DisabledGuiWidgetsNpeTest.java
new file mode 100644
index 0000000000..b6e8ad98d7
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/hopgui/DisabledGuiWidgetsNpeTest.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import org.apache.hop.ui.testing.DisabledGuiWidgetsTestBase;
+import org.junit.jupiter.api.Tag;
+
+/**
+ * Disables every {@code @GuiWidgetElement} that hop-core, hop-engine and 
hop-ui register - one at a
+ * time - and checks that its composite still builds and still survives the 
listener callbacks.
+ *
+ * <p>This lives in hop-ui-rcp rather than in hop-ui because the harness needs 
the standalone
+ * look-and-feel implementations (TextSizeUtilFacadeImpl and friends) that 
this module supplies, and
+ * hop-ui cannot depend on them. The widgets that plugins add are covered by 
the same harness in
+ * each plugin's own module: a module only ever sees the widgets on its own 
classpath.
+ *
+ * <p>Run: {@code mvn -pl rcp test} (skipped without a display; {@code 
-XstartOnFirstThread} is
+ * added automatically on macOS).
+ */
+@Tag("uitest")
+class DisabledGuiWidgetsNpeTest extends DisabledGuiWidgetsTestBase {
+  // No package filter: everything on this module's classpath is hop-core, 
hop-engine or hop-ui.
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java
index d400416e1c..ce7b08e218 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java
@@ -856,6 +856,13 @@ public class GuiCompositeWidgets {
       Control control = widgetsMap.get(guiElements.getId());
       if (control != null) {
 
+        // A widget element declared on a method - a composite, a link, a 
button - has no field
+        // behind it, so there is no value to put into it.
+        //
+        if (guiElements.getFieldName() == null) {
+          return;
+        }
+
         // What's the value?
         //
         Object value = null;
@@ -866,11 +873,7 @@ public class GuiCompositeWidgets {
                   .invoke(sourceData);
         } catch (Exception e) {
           LogChannel.UI.logError(
-              "Unable to get value for field: '"
-                  + guiElements.getFieldName()
-                  + "' : "
-                  + e.getMessage());
-          e.printStackTrace();
+              "Unable to get value for field: '" + guiElements.getFieldName() 
+ "'", e);
         }
         String stringValue = value == null ? "" : Const.NVL(value.toString(), 
"");
 
@@ -976,6 +979,13 @@ public class GuiCompositeWidgets {
       Control control = widgetsMap.get(guiElements.getId());
       if (control != null) {
 
+        // A widget element declared on a method - a composite, a link, a 
button - has no field
+        // behind it, so there is no value to retrieve from it.
+        //
+        if (guiElements.getFieldName() == null) {
+          return;
+        }
+
         // What's the value?
         //
         Object value = null;
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataManager.java 
b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataManager.java
index 39d2cef91b..76a7e7889b 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataManager.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataManager.java
@@ -187,10 +187,11 @@ public class MetadataManager<T extends IHopMetadata> {
       MetadataEditor<T> editor = this.createEditor(element);
       editor.setTitle(element.getName());
 
-      // Open this element in the metadata perspective if that one is active.
+      // Open this element in the metadata perspective if that one is active. 
The perspective is
+      // absent when it is switched off in disabledGuiElements.xml, and we 
fall back to the dialog.
       //
       MetadataPerspective perspective = HopGui.getMetadataPerspective();
-      if (perspective.isActive()) {
+      if (perspective != null && perspective.isActive()) {
         perspective.addEditor(editor);
         return false;
       } else {
@@ -498,7 +499,20 @@ public class MetadataManager<T extends IHopMetadata> {
               "MetadataManager.New.Label",
               TranslateUtil.translate(this.getManagedName(), managedClass)));
 
-      MetadataPerspective perspective = MetadataPerspective.getInstance();
+      // The metadata perspective is absent when it is switched off in 
disabledGuiElements.xml.
+      // There is no editor tab to open then, so we edit the new element in a 
dialog instead.
+      //
+      MetadataPerspective perspective = HopGui.getMetadataPerspective();
+      if (perspective == null) {
+        MetadataEditorDialog dialog = new 
MetadataEditorDialog(hopGui.getActiveShell(), editor);
+        if (dialog.open() == null) {
+          return null;
+        }
+        ExtensionPointHandler.callExtensionPoint(
+            hopGui.getLog(), variables, 
HopExtensionPoint.HopGuiMetadataObjectCreated.id, element);
+        return element;
+      }
+
       perspective.activate();
       perspective.addEditor(editor);
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/MetaSelectionLine.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/MetaSelectionLine.java
index c635aa1bfa..5010739331 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/MetaSelectionLine.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/MetaSelectionLine.java
@@ -286,6 +286,12 @@ public class MetaSelectionLine<T extends IHopMetadata> 
extends Composite {
       image = "ui/images/metadata.svg")
   public void viewInPerspective() {
     MetadataPerspective perspective = HopGui.getMetadataPerspective();
+    if (perspective == null) {
+      // The metadata perspective is switched off in disabledGuiElements.xml: 
there is nowhere to
+      // view the element.
+      //
+      return;
+    }
     perspective.activate();
     String elementName = variables.resolve(wCombo.getText());
     if (StringUtils.isEmpty(elementName)) {
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/OsHelper.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/OsHelper.java
index 5c6ed0339b..c11a40da8c 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/OsHelper.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/OsHelper.java
@@ -22,6 +22,7 @@ import java.util.Arrays;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.ui.hopgui.HopGui;
+import 
org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Menu;
@@ -127,8 +128,16 @@ public class OsHelper {
 
             break;
           case SWT.ID_PREFERENCES:
+            // The perspective is absent when it is switched off in 
disabledGuiElements.xml.
+            //
             item.addListener(
-                SWT.Selection, event -> 
HopGui.getConfigurationPerspective().activate());
+                SWT.Selection,
+                event -> {
+                  ConfigurationPerspective perspective = 
HopGui.getConfigurationPerspective();
+                  if (perspective != null) {
+                    perspective.activate();
+                  }
+                });
 
             break;
           default:
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index 920bbd46a8..c0163521e7 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -571,7 +571,10 @@ public class HopGui
 
     // Activate the default perspective
     //
-    getExplorerPerspective().activate();
+    IHopPerspective defaultPerspective = getDefaultPerspective();
+    if (defaultPerspective != null) {
+      defaultPerspective.activate();
+    }
 
     // See if we need to show the Welcome dialog
     //
@@ -2026,7 +2029,12 @@ public class HopGui
   public void setActivePerspective(IHopPerspective perspective) {
 
     if (perspective == null) {
-      perspective = getExplorerPerspective();
+      perspective = getDefaultPerspective();
+    }
+    if (perspective == null) {
+      // Every single perspective is disabled: there is nothing to put on top.
+      //
+      return;
     }
 
     activePerspective = perspective;
@@ -2406,6 +2414,21 @@ public class HopGui
     return 
HopGui.getInstance().getPerspectiveManager().findPerspective(ExplorerPerspective.class);
   }
 
+  /**
+   * The perspective to show when no other one is selected. That is normally 
the explorer
+   * perspective, but it can be switched off with an exclusion in 
disabledGuiElements.xml, in which
+   * case we settle for the first perspective that did get loaded.
+   *
+   * @return The default perspective, or null if every perspective is disabled.
+   */
+  public IHopPerspective getDefaultPerspective() {
+    ExplorerPerspective explorerPerspective = getExplorerPerspective();
+    if (explorerPerspective != null) {
+      return explorerPerspective;
+    }
+    return 
perspectiveManager.getPerspectives().stream().findFirst().orElse(null);
+  }
+
   public static ConfigurationPerspective getConfigurationPerspective() {
     return HopGui.getInstance()
         .getPerspectiveManager()
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/HopPerspectiveManager.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/HopPerspectiveManager.java
index c722073b5c..e64366ae3b 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/HopPerspectiveManager.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/HopPerspectiveManager.java
@@ -19,7 +19,7 @@ package org.apache.hop.ui.hopgui.perspective;
 
 import java.util.ArrayList;
 import java.util.Comparator;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentLinkedQueue;
@@ -43,7 +43,11 @@ public class HopPerspectiveManager {
 
   public HopPerspectiveManager(HopGui hopGui) {
     this.hopGui = hopGui;
-    this.perspectivesMap = new HashMap<>();
+    // Insertion ordered: HopGui adds the perspectives sorted by plugin id, 
which is the order they
+    // appear in the sidebar. getPerspectives() then hands them out in that 
same order, so falling
+    // back to "the first perspective" is predictable.
+    //
+    this.perspectivesMap = new LinkedHashMap<>();
     this.listeners = new ConcurrentLinkedQueue<>();
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
index d37028ea62..4f19a70ac5 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/ConfigurationPerspective.java
@@ -131,9 +131,22 @@ public class ConfigurationPerspective implements 
IHopPerspective {
   @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'c', global = 
true)
   @Override
   public void activate() {
+    if (!isInitialized()) {
+      return;
+    }
     hopGui.setActivePerspective(this);
   }
 
+  /**
+   * When this perspective is disabled (an exclusion in 
disabledGuiElements.xml) HopGui skips it
+   * while loading the perspectives, so initialize() never runs. The singleton 
still exists because
+   * the class is instantiated to register the GUI elements it declares, so 
callers reaching it
+   * through getInstance() get an instance without a HopGui or any widgets to 
work with.
+   */
+  private boolean isInitialized() {
+    return hopGui != null;
+  }
+
   @Override
   public void perspectiveActivated() {
     // Reload values in all tabs when perspective is activated
@@ -164,7 +177,7 @@ public class ConfigurationPerspective implements 
IHopPerspective {
 
   @Override
   public boolean isActive() {
-    return hopGui.isActivePerspective(this);
+    return isInitialized() && hopGui.isActivePerspective(this);
   }
 
   @Override
@@ -848,6 +861,11 @@ public class ConfigurationPerspective implements 
IHopPerspective {
   }
 
   public void showSystemVariablesTab() {
+    if (!isInitialized()) {
+      // There is no tree to navigate.
+      //
+      return;
+    }
     // Navigate to system variables in the tree
     for (TreeItem item : categoryTree.getItems()) {
       if (item.getText().toLowerCase().contains("variable")) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java
index 28a30dd754..6f2293cde6 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java
@@ -197,6 +197,17 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
     instance = this;
   }
 
+  /**
+   * When this perspective is disabled (an exclusion in 
disabledGuiElements.xml) HopGui skips it
+   * while loading the perspectives, so {@link #initialize(HopGui, Composite)} 
never runs. The
+   * singleton still exists because the @GuiPlugin class is instantiated to 
register the GUI
+   * elements it declares, so callers reaching us through {@link 
#getInstance()} get an
+   * uninitialized instance which has no widgets and no state to work with.
+   */
+  private boolean isInitialized() {
+    return hopGui != null;
+  }
+
   @Override
   public String getId() {
     return "execution-perspective";
@@ -206,6 +217,9 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
   @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'i', global = 
true)
   @Override
   public void activate() {
+    if (!isInitialized()) {
+      return;
+    }
     // Prevents refreshes when not needed.
     if (!hopGui.isActivePerspective(this)) {
       hopGui.setActivePerspective(this);
@@ -221,7 +235,7 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
 
   @Override
   public boolean isActive() {
-    return hopGui.isActivePerspective(this);
+    return isInitialized() && hopGui.isActivePerspective(this);
   }
 
   @Override
@@ -690,7 +704,7 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
     // Only refresh if we're actually displaying anything.
     // Let's be conservative with responsiveness.
     //
-    if (!hopGui.isActivePerspective(this)) {
+    if (!isInitialized() || !hopGui.isActivePerspective(this)) {
       return;
     }
 
@@ -936,8 +950,10 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
       toolTip = "i18n::ExecutionPerspective.ToolbarElement.TimeFilter.Tooltip")
   public void selectTimeFilter() {
     ToolItem item = toolBarWidgets.findToolItem(TOOLBAR_ITEM_TIME_FILTER);
-    String lastPeriodDescription = ((Combo) item.getControl()).getText();
-    this.timeFilter = LastPeriod.lookupDescription(lastPeriodDescription);
+    if (item == null || !(item.getControl() instanceof Combo combo)) {
+      return;
+    }
+    this.timeFilter = LastPeriod.lookupDescription(combo.getText());
 
     // Update the icon && apply the filter
     updateGui();
@@ -958,8 +974,13 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
     this.onlyShowingFailed = false;
     this.filterText = "";
 
+    // The filter box itself can be switched off in disabledGuiElements.xml 
while this button is
+    // not, in which case there is no box to clear.
+    //
     ToolItem item = toolBarWidgets.findToolItem(TOOLBAR_ITEM_FILTER_TEXT);
-    ((Text) item.getControl()).setText(FILTER_NAME_DATE_ID);
+    if (item != null && item.getControl() instanceof Text text) {
+      text.setText(FILTER_NAME_DATE_ID);
+    }
 
     // Update the icon && apply the filter
     updateGui();
@@ -974,7 +995,10 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
       defaultText = FILTER_NAME_DATE_ID)
   public void selectTextFilter() {
     ToolItem item = toolBarWidgets.findToolItem(TOOLBAR_ITEM_FILTER_TEXT);
-    this.filterText = ((Text) item.getControl()).getText();
+    if (item == null || !(item.getControl() instanceof Text text)) {
+      return;
+    }
+    this.filterText = text.getText();
 
     // Update the icon && apply the filter
     updateGui();
@@ -1175,6 +1199,12 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
   }
 
   public void saveState() {
+    // Nothing was ever restored or shown, so saving would only overwrite the 
state on disk with
+    // the field defaults.
+    //
+    if (!isInitialized()) {
+      return;
+    }
     try {
       AuditStateMap stateMap = new AuditStateMap();
       stateMap.add(
@@ -1207,6 +1237,11 @@ public class ExecutionPerspective implements 
IHopPerspective, TabClosable {
 
   /** Restore the state of this perspective. */
   public void restoreState() {
+    // There is no toolbar or tree to restore the state into.
+    //
+    if (!isInitialized()) {
+      return;
+    }
     try {
       AuditStateMap stateMap =
           AuditManager.getActive()
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index a6adbaed89..4c943ddeac 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -316,7 +316,7 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
   @Override
   public boolean isActive() {
-    return hopGui.isActivePerspective(this);
+    return hopGui != null && hopGui.isActivePerspective(this);
   }
 
   @Override
@@ -634,10 +634,13 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
           }
           MenuItem openInExplorerItem = 
menuWidgets.findMenuItem(CONTEXT_MENU_OPEN_IN_EXPLORER);
           if (openInExplorerItem != null) {
-            openInExplorerItem.setEnabled(HopVfs.isLocalFileSystem(tif.path));
+            openInExplorerItem.setEnabled(tif != null && 
HopVfs.isLocalFileSystem(tif.path));
           }
 
-          
menuWidgets.findMenuItem(CONTEXT_MENU_RENAME).setEnabled(selection.length == 1);
+          MenuItem renameItem = menuWidgets.findMenuItem(CONTEXT_MENU_RENAME);
+          if (renameItem != null) {
+            renameItem.setEnabled(selection.length == 1);
+          }
 
           // Finding references is only meaningful for a single pipeline or 
workflow file.
           //
@@ -4117,6 +4120,12 @@ public class ExplorerPerspective implements 
IHopPerspective, TabClosable, IFileD
 
   /** Save the file explorer panel visibility state so it persists across 
restarts. */
   public void saveExplorerStateOnShutdown() {
+    // When the perspective is disabled it was never initialized, so saving 
would only overwrite
+    // the state on disk with the field defaults.
+    //
+    if (hopGui == null) {
+      return;
+    }
     try {
       AuditStateMap stateMap = new AuditStateMap();
       stateMap.add(
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
index f9e2fc874d..56a4eb1d9b 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
@@ -241,6 +241,17 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
     this.metadataFileType = new MetadataFileType();
   }
 
+  /**
+   * When this perspective is disabled (an exclusion in 
disabledGuiElements.xml) HopGui skips it
+   * while loading the perspectives, so initialize() never runs. The singleton 
still exists because
+   * the class is instantiated to register the GUI elements it declares, so 
callers reaching it
+   * through {@link #getInstance()} get an instance without a HopGui, tree or 
tab folder to work
+   * with.
+   */
+  private boolean isInitialized() {
+    return hopGui != null;
+  }
+
   @Override
   public String getId() {
     return "metadata-perspective";
@@ -250,6 +261,9 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'm', global = 
true)
   @Override
   public void activate() {
+    if (!isInitialized()) {
+      return;
+    }
     hopGui.setActivePerspective(this);
   }
 
@@ -261,7 +275,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
 
   @Override
   public boolean isActive() {
-    return hopGui.isActivePerspective(this);
+    return isInitialized() && hopGui.isActivePerspective(this);
   }
 
   @Override
@@ -736,6 +750,11 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
    * at that position; otherwise it is appended at the end.
    */
   public void addEditor(MetadataEditor<?> editor, int tabIndex) {
+    if (!isInitialized()) {
+      // There is no tab folder to add the editor to.
+      //
+      return;
+    }
 
     // Create tab item
     //
@@ -890,6 +909,9 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   }
 
   public void setActiveEditor(MetadataEditor<?> editor) {
+    if (!isInitialized()) {
+      return;
+    }
     for (CTabItem item : tabFolder.getItems()) {
       if (item.getData().equals(editor)) {
         tabFolder.setSelection(item);
@@ -904,7 +926,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   }
 
   public MetadataEditor<?> getActiveEditor() {
-    if (tabFolder.getSelectionIndex() < 0) {
+    if (!isInitialized() || tabFolder.getSelectionIndex() < 0) {
       return null;
     }
 
@@ -1997,7 +2019,7 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
 
   public void updateEditor(MetadataEditor<?> editor) {
 
-    if (editor == null) return;
+    if (editor == null || !isInitialized()) return;
 
     // Update TabItem
     //
@@ -2075,6 +2097,11 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   @GuiKeyboardShortcut(key = SWT.F5)
   @GuiOsxKeyboardShortcut(key = SWT.F5)
   public void refresh() {
+    if (!isInitialized()) {
+      // There is no tree to render the metadata into.
+      //
+      return;
+    }
     reloadModel();
     renderTree();
     // Keep the overview catalog in sync when it is the visible panel (no 
editor open).
@@ -2720,6 +2747,9 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
 
   @Override
   public boolean remove(IHopFileTypeHandler typeHandler) {
+    if (!isInitialized()) {
+      return false;
+    }
     if (typeHandler instanceof MetadataEditor<?> editor && 
editor.isCloseable()) {
 
       editors.remove(editor);
@@ -2754,6 +2784,9 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
   @Override
   public List<TabItemHandler> getItems() {
     List<TabItemHandler> items = new ArrayList<>();
+    if (!isInitialized()) {
+      return items;
+    }
     for (CTabItem tabItem : tabFolder.getItems()) {
       for (MetadataEditor<?> editor : editors) {
         if (tabItem.getData().equals(editor)) {
@@ -2847,6 +2880,11 @@ public class MetadataPerspective implements 
IHopPerspective, TabClosable, IMetad
 
   /** Finds the tree node for a metadata type (by key), searching inside the 
category headers. */
   private TreeItem findTypeItem(String key) {
+    if (!isInitialized()) {
+      // There is no tree to navigate to.
+      //
+      return null;
+    }
     for (TreeItem categoryItem : tree.getItems()) {
       for (TreeItem typeItem : categoryItem.getItems()) {
         if (TYPE.equals(typeItem.getData(KEY_TYPE)) && 
key.equals(typeItem.getData())) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiDescribedVariableSearchable.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiDescribedVariableSearchable.java
index 1c7e8181af..ee0ef8eabc 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiDescribedVariableSearchable.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/search/HopGuiDescribedVariableSearchable.java
@@ -68,9 +68,13 @@ public class HopGuiDescribedVariableSearchable implements 
ISearchable<DescribedV
       String realConfigFilename = 
HopGui.getInstance().getVariables().resolve(configFilename);
 
       if (realConfigFilename == null) {
+        // The perspective is absent when it is switched off in 
disabledGuiElements.xml.
+        //
         ConfigurationPerspective perspective = 
HopGui.getConfigurationPerspective();
-        perspective.activate();
-        perspective.showSystemVariablesTab();
+        if (perspective != null) {
+          perspective.activate();
+          perspective.showSystemVariablesTab();
+        }
       } else {
         if (new File(realConfigFilename).exists()) {
           DescribedVariablesConfigFile configFile =
diff --git 
a/ui/src/main/java/org/apache/hop/ui/www/service/WebServiceGuiPlugin.java 
b/ui/src/main/java/org/apache/hop/ui/www/service/WebServiceGuiPlugin.java
index 001bc90709..6b2bc89532 100644
--- a/ui/src/main/java/org/apache/hop/ui/www/service/WebServiceGuiPlugin.java
+++ b/ui/src/main/java/org/apache/hop/ui/www/service/WebServiceGuiPlugin.java
@@ -132,13 +132,18 @@ public class WebServiceGuiPlugin {
 
         // See if it's open in the metadata perspective...
         //
+        // The perspective is absent when it is switched off in 
disabledGuiElements.xml, and then
+        // nothing is open in it.
+        //
         MetadataPerspective perspective =
             (MetadataPerspective)
                 
hopGui.getPerspectiveManager().findPerspective(MetadataPerspective.class);
         String key = WebService.class.getAnnotation(HopMetadata.class).key();
         WebServiceEditor editor =
-            (WebServiceEditor) perspective.findEditor(key, 
webService.getName());
-        if (editor != null) {
+            perspective == null
+                ? null
+                : (WebServiceEditor) perspective.findEditor(key, 
webService.getName());
+        if (perspective != null && editor != null) {
           // We're going to change the current metadata and flag it as 
changed...
           //
           webService = new WebService();
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/DisabledPerspectivesTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/DisabledPerspectivesTest.java
new file mode 100644
index 0000000000..a426959e42
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/DisabledPerspectivesTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import 
org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective;
+import org.apache.hop.ui.hopgui.perspective.metadata.MetadataPerspective;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Any perspective can be switched off with an exclusion for its plugin id in
+ * disabledGuiElements.xml. HopGui then skips it while loading the 
perspectives, so initialize() is
+ * never called - but the singleton still exists, because the class is 
instantiated to register the
+ * GUI elements it declares. Everything that reaches such a perspective 
through getInstance() gets
+ * an instance with no HopGui, no tree and no tab folder.
+ *
+ * <p>These tests pin down that those instances stay inert instead of throwing 
a
+ * NullPointerException. No display is needed: not calling initialize() is 
exactly what being
+ * disabled amounts to.
+ *
+ * <p>The Execution perspective has its own test next to it; the git 
perspectives are covered in the
+ * git plugin.
+ */
+class DisabledPerspectivesTest {
+
+  /**
+   * The metadata perspective is the one that hurts most: every metadata 
editor, every "edit" button
+   * next to a metadata combo, and the audit log that restores tabs on startup 
all reach for it.
+   */
+  @Test
+  void aDisabledMetadataPerspectiveStaysInert() {
+    MetadataPerspective perspective = new MetadataPerspective();
+
+    assertFalse(perspective.isActive());
+    assertDoesNotThrow(
+        () -> {
+          perspective.activate();
+          // HopGui#open restores the metadata tabs of the last session 
through the audit log.
+          perspective.refresh();
+          perspective.updateEditor(null);
+          perspective.setActiveEditor(null);
+        });
+    assertNull(perspective.getActiveEditor());
+    assertTrue(perspective.getItems().isEmpty());
+    assertFalse(perspective.remove(null));
+  }
+
+  @Test
+  void aDisabledConfigurationPerspectiveStaysInert() {
+    ConfigurationPerspective perspective = new ConfigurationPerspective();
+
+    assertFalse(perspective.isActive());
+    assertDoesNotThrow(
+        () -> {
+          // The macOS Preferences menu item and the variable search results 
both land here.
+          perspective.activate();
+          perspective.showSystemVariablesTab();
+        });
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspectiveDisabledTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspectiveDisabledTest.java
new file mode 100644
index 0000000000..acda548165
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspectiveDisabledTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective.execution;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The Execution perspective can be switched off with an exclusion for its 
plugin id
+ * ("150-HopExecutionPerspective") in disabledGuiElements.xml. 
HopGui#loadPerspectives() then skips
+ * the perspective, so initialize() is never called on it. The singleton still 
exists, because the
+ * class is instantiated to register the GUI elements it declares, which 
leaves callers that reach
+ * it through getInstance() holding an instance without a HopGui or any 
widgets.
+ *
+ * <p>These tests pin down that such an instance stays inert instead of 
throwing a
+ * NullPointerException. No display is needed: not calling initialize() is 
exactly what being
+ * disabled amounts to.
+ */
+class ExecutionPerspectiveDisabledTest {
+
+  /** A perspective as HopGui leaves it behind when it is disabled: 
constructed, not initialized. */
+  private ExecutionPerspective disabledPerspective;
+
+  @BeforeEach
+  void createDisabledPerspective() {
+    disabledPerspective = new ExecutionPerspective();
+
+    // The constructor is what publishes the singleton, so every getInstance() 
caller in the code
+    // base gets this uninitialized instance.
+    assertSame(disabledPerspective, ExecutionPerspective.getInstance());
+  }
+
+  @Test
+  void startingHopGuiDoesNotFail() {
+    // HopGui#open restores the state of the perspective in its async open 
runnable.
+    assertDoesNotThrow(() -> disabledPerspective.restoreState());
+  }
+
+  @Test
+  void openingAProjectDoesNotFail() {
+    // ProjectsGuiPlugin#enableHopGuiProject restores the state and refreshes 
when active.
+    assertDoesNotThrow(
+        () -> {
+          disabledPerspective.restoreState();
+          if (disabledPerspective.isActive()) {
+            disabledPerspective.refresh();
+          }
+        });
+  }
+
+  @Test
+  void closingAProjectDoesNotFail() {
+    // ProjectsGuiPlugin#enableHopGuiProject and HopGuiFileDelegate#fileExit 
save the state.
+    assertDoesNotThrow(() -> disabledPerspective.saveState());
+  }
+
+  @Test
+  void theKeyboardShortcutDoesNotFail() {
+    // The Ctrl-Shift-I shortcut on activate() is registered even though the 
perspective is not.
+    assertDoesNotThrow(() -> disabledPerspective.activate());
+  }
+
+  @Test
+  void aDisabledPerspectiveIsNeverActive() {
+    assertFalse(disabledPerspective.isActive());
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/testing/DisabledGuiWidgetsTestBase.java 
b/ui/src/test/java/org/apache/hop/ui/testing/DisabledGuiWidgetsTestBase.java
new file mode 100644
index 0000000000..f304b1935e
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/testing/DisabledGuiWidgetsTestBase.java
@@ -0,0 +1,481 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.testing;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.DynamicTest.dynamicTest;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.apache.hop.core.gui.plugin.GuiElements;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuItem;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarItem;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.GuiMenuWidgets;
+import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.hopgui.HopGuiEnvironment;
+import org.apache.hop.ui.hopgui.ToolbarFacade;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.SWTError;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Menu;
+import org.eclipse.swt.widgets.Shell;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestFactory;
+
+/**
+ * Anything in Hop that carries an id can be switched off with an {@code 
<exclusion>} for that id in
+ * {@code disabledGuiElements.xml}: a {@code @GuiWidgetElement}, a {@code 
@GuiToolbarElement}, a
+ * {@code @GuiMenuElement}, and more. The filtering happens at registration 
time, so the net effect
+ * is always the same - the element is simply absent from the registry and 
never gets built. Code
+ * that later looks it up by id gets a {@code null} back, and any unguarded 
dereference turns a
+ * supported configuration into a crash.
+ *
+ * <p>This harness makes that class of bugs testable. It discovers every 
element registered on the
+ * classpath of the module under test and, for each one, rebuilds the thing 
that holds it -
+ * composite, toolbar or menu - with exactly that element disabled, driving 
the callbacks the
+ * framework invokes. One dynamic test per element, so a failure names the 
element that cannot be
+ * disabled.
+ *
+ * <p>To cover a module, subclass this and narrow {@link #packagesUnderTest()} 
to the module's own
+ * packages - hop-core, hop-engine and hop-ui ride along on every plugin's 
classpath and are already
+ * covered by the hop-ui-rcp test.
+ *
+ * <p>What is <em>not</em> covered: an element that is only ever dereferenced 
from an event handler
+ * (a tree selection listener, say) is not reached by building the widget, so 
those call sites have
+ * to use the null-safe accessors ({@code GuiMenuWidgets.enableMenuItem(id, 
enabled)} rather than
+ * {@code findMenuItem(id).setEnabled(enabled)}). Keyboard shortcuts, context 
actions and config
+ * plugins are disabled by simply not being registered and are never looked up 
by id, so there is
+ * nothing to break.
+ *
+ * <p>A container that cannot be built at all in a test JVM (no public no-arg 
constructor, class not
+ * on the classpath, a widget that needs a live back end, ...) is <em>skipped, 
with the reason</em>
+ * rather than failed: each is first built with nothing disabled, and only if 
that baseline works do
+ * the disable cases have to hold. That keeps a failure here meaning one thing 
- disabling this
+ * element broke something.
+ */
+public abstract class DisabledGuiWidgetsTestBase extends SwtBotTestBase {
+
+  /**
+   * Registers the {@code @GuiPlugin} classes so the elements we enumerate 
below are in the
+   * registry. Runs after {@link SwtBotTestBase}'s own setup, which skips the 
whole class when there
+   * is no display.
+   */
+  @BeforeAll
+  static void registerGuiPluginElements() throws Exception {
+    HopGuiEnvironment.init();
+  }
+
+  /** Per container: why it cannot be built at all here, or "" when it builds 
fine. */
+  private final Map<String, String> baselineFailures = new HashMap<>();
+
+  /**
+   * The packages whose elements are tested, subpackages included. Empty - the 
default - tests every
+   * element registered on the classpath. A plugin narrows this to its own 
packages, because
+   * hop-core, hop-engine and hop-ui are on its classpath too and their 
elements are already covered
+   * in hop-ui-rcp.
+   */
+  protected List<String> packagesUnderTest() {
+    return List.of();
+  }
+
+  /** One disable-able element: the class that owns it, the container it sits 
in, and its id. */
+  private record Element(String owner, String container, String id) {}
+
+  @Test
+  void theModuleRegistersSomethingThatCanBeDisabled() {
+    assertFalse(
+        compositeElements().isEmpty() && toolbarElements().isEmpty() && 
menuElements().isEmpty(),
+        "Nothing that disabledGuiElements.xml can switch off is registered for 
"
+            + (packagesUnderTest().isEmpty() ? "any package" : 
packagesUnderTest())
+            + ", so the tests below would pass without testing anything");
+  }
+
+  @TestFactory
+  Stream<DynamicTest> disablingAWidgetMustNotBreakItsComposite() {
+    return casesFor(compositeElements(), "composite", this::buildComposite);
+  }
+
+  @TestFactory
+  Stream<DynamicTest> disablingAToolbarItemMustNotBreakItsToolbar() {
+    return casesFor(toolbarElements(), "toolbar", this::buildToolbar);
+  }
+
+  @TestFactory
+  Stream<DynamicTest> disablingAMenuItemMustNotBreakItsMenu() {
+    return casesFor(menuElements(), "menu", this::buildMenu);
+  }
+
+  /** Builds one dynamic test per element: disable it, rebuild what holds it, 
nothing may break. */
+  private Stream<DynamicTest> casesFor(List<Element> elements, String kind, 
Builder builder) {
+    return elements.stream()
+        .map(
+            element ->
+                dynamicTest(
+                    simpleName(element.owner()) + " " + kind + " without " + 
element.id(),
+                    () -> disablingIsHarmless(element, builder)));
+  }
+
+  private void disablingIsHarmless(Element element, Builder builder) {
+    String baselineFailure =
+        baselineFailures.computeIfAbsent(
+            element.owner() + "#" + element.container(), key -> 
baselineFailure(element, builder));
+
+    Assumptions.assumeTrue(
+        baselineFailure.isEmpty(),
+        () ->
+            element.container()
+                + " of "
+                + element.owner()
+                + " cannot be built in a test JVM even with all its elements 
present, so disabling"
+                + " one of them proves nothing: "
+                + baselineFailure);
+
+    try {
+      builder.build(element.owner(), element.container(), element.id());
+    } catch (Exception | SWTError e) {
+      // Surefire reports a dynamic test as "[n]", so the failure itself has 
to say which element it
+      // was: that name is the whole point of the test.
+      //
+      throw new AssertionError(
+          "Disabling '"
+              + element.id()
+              + "' of "
+              + element.owner()
+              + " breaks it. Add an exclusion for that id to 
disabledGuiElements.xml and the GUI"
+              + " stops working.",
+          e);
+    }
+  }
+
+  /**
+   * Why this container cannot be built with all its elements present, or "" 
when it builds fine.
+   */
+  private String baselineFailure(Element element, Builder builder) {
+    try {
+      builder.build(element.owner(), element.container(), null);
+      return "";
+    } catch (Exception | SWTError e) {
+      return e.toString();
+    }
+  }
+
+  // 
----------------------------------------------------------------------------------------------
+  // Discovery: what an exclusion in disabledGuiElements.xml can remove
+  // 
----------------------------------------------------------------------------------------------
+
+  /** {@code @GuiWidgetElement}: the class declaring them -> the parent id -> 
the widget tree. */
+  private List<Element> compositeElements() {
+    List<Element> elements = new ArrayList<>();
+    GuiRegistry.getInstance()
+        .getDataElementsMap()
+        .forEach(
+            (className, perParent) -> {
+              if (isUnderTest(className)) {
+                perParent.forEach(
+                    (parentId, tree) ->
+                        collectWidgetIds(tree)
+                            .forEach(id -> elements.add(new Element(className, 
parentId, id))));
+              }
+            });
+    return elements;
+  }
+
+  /** {@code @GuiToolbarElement}: the toolbar root -> the items on it. */
+  private List<Element> toolbarElements() {
+    List<Element> elements = new ArrayList<>();
+    GuiRegistry.getInstance()
+        .getGuiToolbarMap()
+        .forEach(
+            (root, items) ->
+                items
+                    .values()
+                    .forEach(
+                        item -> {
+                          if (!item.isIgnored() && 
isUnderTest(item.getListenerClass())) {
+                            elements.add(new Element(item.getListenerClass(), 
root, item.getId()));
+                          }
+                        }));
+    return elements;
+  }
+
+  /** {@code @GuiMenuElement}: the menu root -> the items in it. */
+  private List<Element> menuElements() {
+    List<Element> elements = new ArrayList<>();
+    GuiRegistry.getInstance()
+        .getGuiMenuMap()
+        .forEach(
+            (root, items) ->
+                items
+                    .values()
+                    .forEach(
+                        item -> {
+                          if (isUnderTest(item.getListenerClassName())) {
+                            elements.add(
+                                new Element(item.getListenerClassName(), root, 
item.getId()));
+                          }
+                        }));
+    return elements;
+  }
+
+  // 
----------------------------------------------------------------------------------------------
+  // Builders: recreate what the application builds, with one element taken 
out of the registry
+  // 
----------------------------------------------------------------------------------------------
+
+  /** Builds a container with one element disabled, or with everything present 
when id is null. */
+  @FunctionalInterface
+  private interface Builder {
+    void build(String owner, String container, String disabledId);
+  }
+
+  /**
+   * Recreates what the application does with a composite: build the widgets, 
push the values of the
+   * source object into them, and let the source object - which is its own 
listener, the way {@code
+   * ConfigPluginOptionsTab} and the database dialogs wire it up - react to 
them.
+   */
+  private void buildComposite(String className, String parentId, String 
disabledWidgetId) {
+    ensureDisplay();
+
+    GuiElements tree = GuiRegistry.getInstance().findGuiElements(className, 
parentId);
+
+    // The element that holds the widget, and where in it the widget sits, so 
it can go back exactly
+    // where it was. Null when we build the composite as-is.
+    //
+    GuiElements owner = disabledWidgetId == null ? null : findOwner(tree, 
disabledWidgetId);
+    GuiElements disabled = owner == null ? null : 
owner.findChild(disabledWidgetId);
+    int position = owner == null ? -1 : owner.getChildren().indexOf(disabled);
+
+    Shell shell = new Shell(display);
+    shell.setLayout(new FormLayout());
+    try {
+      if (owner != null) {
+        owner.getChildren().remove(position);
+      }
+
+      Object source = newSourceObject(className);
+      GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
+      widgets.createCompositeWidgets(source, null, shell, parentId, null);
+      widgets.setWidgetsContents(source, shell, parentId);
+
+      if (source instanceof IGuiPluginCompositeWidgetsListener listener) {
+        widgets.setWidgetsListener(listener);
+        listener.widgetsCreated(widgets);
+        listener.widgetsPopulated(widgets);
+        // Every widget that is still there can be modified by the user, and 
each of those fires the
+        // callback that tends to reach for the widget that is no longer there.
+        //
+        for (Map.Entry<String, Control> widget :
+            new ArrayList<>(widgets.getWidgetsMap().entrySet())) {
+          listener.widgetModified(widgets, widget.getValue(), widget.getKey());
+        }
+        listener.persistContents(widgets);
+      }
+    } finally {
+      // Put the widget back: the registry is a singleton shared with every 
other case.
+      //
+      if (owner != null) {
+        owner.getChildren().add(position, disabled);
+      }
+      disposeQuietly(shell);
+    }
+  }
+
+  /**
+   * Builds the toolbar the way a perspective or dialog does, minus the 
disabled item.
+   *
+   * <p>The owning object is registered first, exactly as the application does 
it. That object is
+   * not decoration: Hop looks the gui plugin class up for every item, and a 
combo item asks it for
+   * its values ({@code getZoomLevels()} and friends). So a toolbar whose 
owner is a live UI object
+   * that cannot be constructed in a test JVM - HopGuiPipelineGraph, 
TableView, the execution
+   * viewers - cannot be built the way the application builds it, and is 
skipped with that as the
+   * reason rather than built half-way while Hop logs an error per item.
+   */
+  private void buildToolbar(String owner, String root, String disabledItemId) {
+    ensureDisplay();
+
+    Map<String, GuiToolbarItem> items = 
GuiRegistry.getInstance().getGuiToolbarMap().get(root);
+    GuiToolbarItem disabled = disabledItemId == null ? null : 
items.get(disabledItemId);
+
+    Shell shell = new Shell(display);
+    shell.setLayout(new FormLayout());
+    try {
+      if (disabled != null) {
+        items.remove(disabledItemId);
+      }
+
+      // Hop resolves the gui plugin class of every item that is left on the 
toolbar - to ask it for
+      // an image, for its combo values - so all of them have to be 
constructible here, not only the
+      // owner. Items can be contributed to a root by more than one class.
+      //
+      for (GuiToolbarItem item : items.values()) {
+        newSourceObject(item.getListenerClass());
+      }
+
+      IToolbarContainer container =
+          ToolbarFacade.createToolbarContainer(shell, SWT.WRAP | SWT.LEFT | 
SWT.HORIZONTAL);
+      GuiToolbarWidgets widgets = new GuiToolbarWidgets();
+      widgets.registerGuiPluginObject(newSourceObject(owner));
+      widgets.createToolbarWidgets(container, root);
+    } finally {
+      if (disabled != null) {
+        items.put(disabledItemId, disabled);
+      }
+      disposeQuietly(shell);
+    }
+  }
+
+  /** Builds the menu the way HopGui and the perspectives do, minus the 
disabled item. */
+  private void buildMenu(String owner, String root, String disabledItemId) {
+    ensureDisplay();
+
+    Map<String, GuiMenuItem> items = 
GuiRegistry.getInstance().getGuiMenuMap().get(root);
+    GuiMenuItem disabled = disabledItemId == null ? null : 
items.get(disabledItemId);
+
+    Shell shell = new Shell(display);
+    try {
+      if (disabled != null) {
+        items.remove(disabledItemId);
+      }
+
+      // Unlike the toolbar, building a menu never resolves the owning gui 
plugin object - that only
+      // happens when an item is clicked - so there is nothing to register 
here.
+      //
+      new GuiMenuWidgets().createMenuWidgets(root, shell, new Menu(shell, 
SWT.POP_UP));
+    } finally {
+      if (disabled != null) {
+        items.put(disabledItemId, disabled);
+      }
+      disposeQuietly(shell);
+    }
+  }
+
+  private void disposeQuietly(Shell shell) {
+    shell.dispose();
+    while (display.readAndDispatch()) {
+      // flush the disposal before the next case builds its own shell
+    }
+  }
+
+  private Object newSourceObject(String className) {
+    try {
+      Class<?> sourceClass = Class.forName(className, true, 
getClass().getClassLoader());
+
+      // The config plugins hand out an instance filled in from the 
configuration, and that is the
+      // one the config dialog builds its widgets around. A bare constructor 
would leave their
+      // Boolean fields null, and the composite blows up on unboxing before we 
get to test anything.
+      //
+      Method getInstance = singletonAccessor(sourceClass);
+      if (getInstance != null) {
+        Object singleton = getInstance.invoke(null);
+        if (singleton != null) {
+          return singleton;
+        }
+        // The singleton only exists inside a running HopGui. Fall back to a 
fresh instance, which
+        // is exactly what BaseGuiWidgets.findGuiPluginInstance does when it 
finds nothing
+        // registered.
+        //
+      }
+      return sourceClass.getConstructor().newInstance();
+    } catch (ReflectiveOperationException e) {
+      // No usable constructor, or the class is not on this module's 
classpath. The baseline build
+      // hits this first, so the composite ends up skipped rather than failed.
+      //
+      throw new CompositeBuildException(e);
+    }
+  }
+
+  /**
+   * The class's own {@code static getInstance()}, when it has one that hands 
out an instance of
+   * itself - which is how the config dialog gets the object it builds the 
widgets around.
+   */
+  private Method singletonAccessor(Class<?> sourceClass) {
+    try {
+      Method getInstance = sourceClass.getMethod("getInstance");
+      boolean handsOutItself =
+          Modifier.isStatic(getInstance.getModifiers())
+              && sourceClass.isAssignableFrom(getInstance.getReturnType());
+      return handsOutItself ? getInstance : null;
+    } catch (NoSuchMethodException e) {
+      return null;
+    }
+  }
+
+  /** The element that holds the widget, which is where it has to be removed 
from (and restored). */
+  private GuiElements findOwner(GuiElements element, String widgetId) {
+    for (GuiElements child : element.getChildren()) {
+      if (widgetId.equals(child.getId())) {
+        return element;
+      }
+      GuiElements owner = findOwner(child, widgetId);
+      if (owner != null) {
+        return owner;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Every widget in the tree that an exclusion could remove, groups and 
nested widgets included.
+   */
+  private List<String> collectWidgetIds(GuiElements element) {
+    List<String> ids = new ArrayList<>();
+    for (GuiElements child : element.getChildren()) {
+      if (child.getId() != null && !child.isIgnored()) {
+        ids.add(child.getId());
+      }
+      ids.addAll(collectWidgetIds(child));
+    }
+    return ids;
+  }
+
+  private String simpleName(String className) {
+    return className == null ? "?" : 
className.substring(className.lastIndexOf('.') + 1);
+  }
+
+  private boolean isUnderTest(String className) {
+    if (className == null) {
+      return false;
+    }
+    List<String> packages = packagesUnderTest();
+    return packages.isEmpty()
+        || packages.stream().anyMatch(name -> className.startsWith(name + 
"."));
+  }
+
+  /**
+   * A container is built through an unchecked call, so the reflective failure 
of a source object
+   * that has no usable constructor has to travel as one.
+   */
+  private static class CompositeBuildException extends RuntimeException {
+    CompositeBuildException(Throwable cause) {
+      super(cause);
+    }
+  }
+}

Reply via email to