Bug 578005 - Extend tests to cover all feature-based launch attributes

I.e. all attributes supported at the moment.

Change-Id: I3b702315afa9c319bff77f075732ac993c342589
Signed-off-by: Hannes Wellmann <wellmann.hannes1@gmx.net>
Reviewed-on: https://git.eclipse.org/r/c/pde/eclipse.pde.ui/+/189226
Tested-by: PDE Bot <pde-bot@eclipse.org>
diff --git a/ui/org.eclipse.pde.ui.tests/META-INF/MANIFEST.MF b/ui/org.eclipse.pde.ui.tests/META-INF/MANIFEST.MF
index 1d451b3..a804e85 100644
--- a/ui/org.eclipse.pde.ui.tests/META-INF/MANIFEST.MF
+++ b/ui/org.eclipse.pde.ui.tests/META-INF/MANIFEST.MF
@@ -42,6 +42,7 @@
  org.eclipse.pde.genericeditor.extension,
  org.eclipse.equinox.simpleconfigurator.manipulator;bundle-version="2.1.300"
 Import-Package: org.assertj.core.api;version="3.14.0",
+ org.assertj.core.presentation;version="3.21.0",
  org.junit.jupiter.api.function;version="5.8.1"
 Eclipse-LazyStart: true
 Bundle-RequiredExecutionEnvironment: JavaSE-11
diff --git a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/ee/ExecutionEnvironmentTests.java b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/ee/ExecutionEnvironmentTests.java
index 96d4aca..444eea9 100644
--- a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/ee/ExecutionEnvironmentTests.java
+++ b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/ee/ExecutionEnvironmentTests.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- * Copyright (c) 2008, 2018 IBM Corporation and others.
+ * Copyright (c) 2008, 2022 IBM Corporation and others.
  *
  * This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
@@ -195,7 +195,7 @@
 	@Test
 	public void testNoEnvironment() throws Exception {
 		try {
-			IJavaProject project = ProjectUtils.createPluginProject("no.env", null);
+			IJavaProject project = ProjectUtils.createPluginProject("no.env", (IExecutionEnvironment) null);
 			assertTrue("Project was not created", project.exists());
 
 			Hashtable<String, String> options = JavaCore.getOptions();
diff --git a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/AbstractLaunchTest.java b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/AbstractLaunchTest.java
index 9203ea4..3599d18 100644
--- a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/AbstractLaunchTest.java
+++ b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/AbstractLaunchTest.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- *  Copyright (c) 2019, 2021 Julian Honnen and others.
+ *  Copyright (c) 2019, 2022 Julian Honnen and others.
  *
  *  This program and the accompanying materials
  *  are made available under the terms of the Eclipse Public License 2.0
@@ -16,16 +16,21 @@
  *******************************************************************************/
 package org.eclipse.pde.ui.tests.launcher;
 
+import static java.util.Comparator.comparing;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
-import java.util.Arrays;
-import java.util.NoSuchElementException;
+import java.util.*;
+import java.util.Map.Entry;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import org.assertj.core.api.Assertions;
+import org.assertj.core.presentation.StandardRepresentation;
 import org.eclipse.core.resources.IProject;
 import org.eclipse.debug.core.*;
 import org.eclipse.pde.core.plugin.*;
+import org.eclipse.pde.core.target.NameVersionDescriptor;
 import org.eclipse.pde.ui.tests.util.ProjectUtils;
 import org.eclipse.pde.ui.tests.util.TargetPlatformUtil;
 import org.junit.*;
@@ -50,7 +55,7 @@
 	@Rule
 	public final TestRule deleteCreatedTestProjectsAfter = ProjectUtils.DELETE_CREATED_WORKSPACE_PROJECTS_AFTER;
 
-	protected ILaunchConfiguration getLaunchConfiguration(String name) {
+	protected static ILaunchConfiguration getLaunchConfiguration(String name) {
 		ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager();
 		return launchManager.getLaunchConfiguration(launchConfigsProject.getFile(name));
 	}
@@ -78,6 +83,57 @@
 		Stream<IPluginModelBase> candiates = Arrays.stream(models);
 		return candiates.filter(model -> version.equals(Version.parseVersion(model.getPluginBase().getVersion())))
 				.findFirst() // always take first like BundleLaunchHelper
-				.orElseThrow(() -> new NoSuchElementException("No " + type + " model " + id + "-" + version + "found"));
+				.orElseThrow(
+						() -> new NoSuchElementException("No " + type + " model " + id + "-" + version + " found"));
 	}
+
+	static NameVersionDescriptor bundle(String id, String version) {
+		return new NameVersionDescriptor(id, version);
+	}
+
+	static BundleLocationDescriptor workspaceBundle(String id, String version) {
+		Objects.requireNonNull(version);
+		return () -> findWorkspaceModel(id, version);
+	}
+
+	static BundleLocationDescriptor targetBundle(String id, String version) {
+		Objects.requireNonNull(version);
+		// PluginRegistry.findModel does not consider external models when
+		// workspace models are present and returns the 'last' plug-in if
+		// multiple with the same version exist
+		return () -> findTargetModel(id, version);
+	}
+
+	static interface BundleLocationDescriptor {
+		IPluginModelBase findModel();
+	}
+
+	static void assertPluginMapsEquals(String message, Map<IPluginModelBase, String> expected,
+			Map<IPluginModelBase, String> actual) {
+		// Like Assert.assertEquals() but with more expressive and easier to
+		// compare failure message
+		Assertions.assertThat(actual).withRepresentation(new StandardRepresentation() {
+			@Override
+			public String toStringOf(Object object) {
+				if (object instanceof IPluginModelBase) {
+					IPluginModelBase plugin = (IPluginModelBase) object;
+					String location = plugin.getUnderlyingResource() != null ? "w" : "e";
+					IPluginBase p = plugin.getPluginBase();
+					return p.getId() + "-" + p.getVersion() + "(" + location + ")";
+				}
+				if (object instanceof Map) {
+					@SuppressWarnings("unchecked")
+					var entries = ((Map<IPluginModelBase, String>) object).entrySet().stream();
+					return entries.sorted(PLUGIN_COMPARATOR).map(super::toStringOf)
+							.collect(Collectors.joining(",\n", "{\n", "\n}"));
+				}
+				return super.toStringOf(object);
+			}
+		}).as(message).isEqualTo(expected);
+	}
+
+	private static final Comparator<Entry<IPluginModelBase, String>> PLUGIN_COMPARATOR = comparing(Entry::getKey,
+			comparing((IPluginModelBase p) -> p.getPluginBase(),
+					comparing(IPluginBase::getId).thenComparing(IPluginBase::getVersion))
+			.thenComparing((IPluginModelBase p) -> p.getUnderlyingResource() == null));
 }
diff --git a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/FeatureBasedLaunchTest.java b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/FeatureBasedLaunchTest.java
index f3df502..0cc80ba 100644
--- a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/FeatureBasedLaunchTest.java
+++ b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/FeatureBasedLaunchTest.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- *  Copyright (c) 2019, 2021 Julian Honnen and others.
+ *  Copyright (c) 2019, 2022 Julian Honnen and others.
  *
  *  This program and the accompanying materials
  *  are made available under the terms of the Eclipse Public License 2.0
@@ -11,85 +11,878 @@
  *  Contributors:
  *     Julian Honnen <julian.honnen@vector.com> - initial API and implementation
  *     Andras Peteri <apeteri@b2international.com> - extracted common superclass
- *     Hannes Wellmann - Bug 577116: Improve test utility method reusability
+ *     Hannes Wellmann - Bug 577116 - Improve test utility method reusability
+ *     Hannes Wellmann - Bug 578005 - Extend tests to fully cover feature-based launches
  *******************************************************************************/
 package org.eclipse.pde.ui.tests.launcher;
 
+import static java.util.Collections.emptySet;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.eclipse.pde.internal.core.ICoreConstants.DEFAULT_VERSION;
+import static org.eclipse.pde.ui.tests.util.ProjectUtils.createPluginProject;
 
-import java.util.LinkedHashMap;
-import java.util.Map;
+import java.io.PrintWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
 import java.util.Map.Entry;
-import java.util.stream.Stream;
+import java.util.stream.Collectors;
 import org.eclipse.core.resources.*;
-import org.eclipse.core.runtime.CoreException;
-import org.eclipse.core.runtime.IPath;
-import org.eclipse.debug.core.ILaunchConfiguration;
-import org.eclipse.pde.core.plugin.*;
+import org.eclipse.core.runtime.*;
+import org.eclipse.core.runtime.jobs.Job;
+import org.eclipse.debug.core.*;
+import org.eclipse.jface.operation.IRunnableWithProgress;
+import org.eclipse.pde.core.plugin.IMatchRules;
+import org.eclipse.pde.core.plugin.IPluginModelBase;
+import org.eclipse.pde.core.target.NameVersionDescriptor;
+import org.eclipse.pde.internal.core.FeatureModelManager;
+import org.eclipse.pde.internal.core.PDECore;
+import org.eclipse.pde.internal.core.feature.FeatureChild;
+import org.eclipse.pde.internal.core.feature.WorkspaceFeatureModel;
+import org.eclipse.pde.internal.core.ifeature.*;
 import org.eclipse.pde.internal.launching.launcher.BundleLauncherHelper;
-import org.eclipse.pde.internal.ui.wizards.feature.CreateFeatureProjectOperation;
+import org.eclipse.pde.internal.ui.wizards.feature.AbstractCreateFeatureOperation;
 import org.eclipse.pde.internal.ui.wizards.feature.FeatureData;
+import org.eclipse.pde.launching.IPDELauncherConstants;
+import org.eclipse.pde.ui.tests.util.TargetPlatformUtil;
 import org.junit.*;
+import org.junit.rules.TemporaryFolder;
 
 public class FeatureBasedLaunchTest extends AbstractLaunchTest {
 
-	private static IProject featureProject;
-
-	private ILaunchConfiguration fFeatureBasedWithStartLevels;
-
-	@BeforeClass
-	public static void createTestFeature() throws Exception {
-
-		FeatureData featureData = new FeatureData();
-		featureData.id = FeatureBasedLaunchTest.class.getName() + "-feature";
-		featureData.version = "1.0.0";
-
-		IPluginBase[] contents = Stream.of("javax.inject", "org.eclipse.core.runtime", "org.eclipse.ui") //
-				.map(id -> PluginRegistry.findModel(id).getPluginBase()) //
-				.toArray(IPluginBase[]::new);
-
-		IWorkspaceRoot workspaceRoot = ResourcesPlugin.getWorkspace().getRoot();
-		featureProject = workspaceRoot.getProject(featureData.id);
-		IPath location = workspaceRoot.getLocation().append(featureProject.getName());
-		CreateFeatureProjectOperation operation = new CreateFeatureProjectOperation(featureProject, location,
-				featureData, contents, null) {
-			@Override
-			protected void openFeatureEditor(IFile manifestFile) {
-				// don't open in headless tests
-			}
-		};
-		operation.run(null);
-	}
+	@Rule
+	public TemporaryFolder folder = new TemporaryFolder();
+	private Path tpJarDirectory;
 
 	@Before
-	public void setupLaunchConfig() throws Exception {
-		fFeatureBasedWithStartLevels = getLaunchConfiguration("feature-based-with-startlevels.launch");
+	public void setup() throws Exception {
+		tpJarDirectory = folder.newFolder("TPJarDirectory").toPath();
 	}
 
+	// --- tests ---
+
 	@Test
-	public void testOldEntryWithoutConfigurationHasDefaults() throws Exception {
-		checkStartLevels("javax.inject", "default:default");
-	}
+	public void testGetMergedBundleMap_autostartLevels() throws Throwable {
+		TargetPlatformUtil.setRunningPlatformAsTarget();
 
-	@Test
-	public void testUseConfiguredStartLevels() throws Exception {
-		checkStartLevels("org.eclipse.core.runtime", "1:true");
-	}
+		createFeatureProject(FeatureBasedLaunchTest.class.getName() + "-feature", "1.0.0", f -> {
+			addIncludedPlugin(f, "javax.inject", DEFAULT_VERSION);
+			addIncludedPlugin(f, "org.eclipse.core.runtime", DEFAULT_VERSION);
+			addIncludedPlugin(f, "org.eclipse.ui", DEFAULT_VERSION);
+		});
+		ILaunchConfiguration lc = getLaunchConfiguration("feature-based-with-startlevels.launch");
 
-	@Test
-	public void testIgnoreConfiguredStartLevelsOfUncheckedPlugin() throws Exception {
-		checkStartLevels("org.eclipse.ui", "default:default");
-	}
-
-	private void checkStartLevels(String pluginId, String expectedStartLevels) throws CoreException {
-		Map<IPluginModelBase, String> bundleMap = BundleLauncherHelper.getMergedBundleMap(fFeatureBasedWithStartLevels,
-				false);
+		Map<IPluginModelBase, String> bundleMap = BundleLauncherHelper.getMergedBundleMap(lc, false);
 
 		Map<String, String> byId = new LinkedHashMap<>();
 		for (Entry<IPluginModelBase, String> entry : bundleMap.entrySet()) {
 			byId.put(entry.getKey().getPluginBase().getId(), entry.getValue());
 		}
 
-		assertThat(byId).containsEntry(pluginId, expectedStartLevels);
+		assertThat(byId)//
+		.as("old entry without configuration has defaults").containsEntry("javax.inject", "default:default")
+		.as("use configured start-levels").containsEntry("org.eclipse.core.runtime", "1:true")
+		.as("ignore configured start-levels of uncheckedplugin")
+		.containsEntry("org.eclipse.ui", "default:default");
+	}
+
+	// --- defined feature selection ---
+
+	@Test
+	public void testGetMergedBundleMap_featureSelectionForLocationWorkspace_latestWorkspaceFeature() throws Throwable {
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"), //
+				bundle("plugin.b", "1.0.0"), //
+				bundle("plugin.c", "1.0.0"), //
+				bundle("plugin.d", "1.0.0"));
+
+		createFeatureProject("feature.a", "2.0.0", f -> {
+			addIncludedPlugin(f, "plugin.a", "1.0.0");
+		});
+		createFeatureProject("feature.a", "1.0.0", f -> {
+			addIncludedPlugin(f, "plugin.b", "1.0.0");
+		});
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "2.0.0", f -> {
+					addIncludedPlugin(f, "plugin.c", "1.0.0");
+				}), //
+				targetFeature("feature.z", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.d", "1.0.0");
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		ILaunchConfigurationWorkingCopy wc = createFeatureLaunchConfig();
+		wc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+		wc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_WORKSPACE);
+
+		assertGetMergedBundleMap(wc, Set.of( //
+				targetBundle("plugin.b", "1.0.0")));
+	}
+
+	@Test
+	public void testGetMergedBundleMap_featureSelectionForLocationWorkspaceButNoWorkspaceFeaturePresent_latestExternalFeature()
+			throws Throwable {
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"), //
+				bundle("plugin.b", "1.0.0"), //
+				bundle("plugin.c", "1.0.0"));
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "2.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a", "1.0.0");
+				}), //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.b", "1.0.0");
+				}), //
+				targetFeature("feature.z", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.c", "1.0.0");
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		ILaunchConfigurationWorkingCopy wc = createFeatureLaunchConfig();
+		wc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+		wc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_WORKSPACE);
+
+		assertGetMergedBundleMap(wc, Set.of( //
+				targetBundle("plugin.a", "1.0.0")));
+	}
+
+	@Test
+	public void testGetMergedBundleMap_featureSelectionForLocationExternal_latestExternalFeature() throws Throwable {
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"), //
+				bundle("plugin.b", "1.0.0"), //
+				bundle("plugin.c", "1.0.0"), //
+				bundle("plugin.d", "1.0.0"));
+
+		createFeatureProject("feature.a", "1.0.0", f -> {
+			addIncludedPlugin(f, "plugin.a", "1.0.0");
+		});
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "2.0.0", f -> {
+					addIncludedPlugin(f, "plugin.b", "1.0.0");
+				}), //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.c", "1.0.0");
+				}), //
+				targetFeature("feature.z", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.d", "1.0.0");
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		ILaunchConfigurationWorkingCopy wc = createFeatureLaunchConfig();
+		wc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+		wc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_EXTERNAL);
+
+		assertGetMergedBundleMap(wc, Set.of( //
+				targetBundle("plugin.b", "1.0.0")));
+	}
+
+	@Test
+	public void testGetMergedBundleMap_featureSelectionForLocationExternalButNoExternalFeaturePresent_noFeature()
+			throws Throwable {
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"));
+
+		createFeatureProject("feature.a", "2.0.0", f -> {
+			addIncludedPlugin(f, "plugin.a", "1.0.0");
+		});
+
+		List<NameVersionDescriptor> targetFeatures = List.of();
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		ILaunchConfigurationWorkingCopy wc = createFeatureLaunchConfig();
+		wc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+		wc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_EXTERNAL);
+
+		assertGetMergedBundleMap(wc, emptySet());
+	}
+
+	// --- included plug-ins ---
+
+	@Test
+	public void testGetMergedBundleMap_includedPluginWithDefaultVersion() throws Throwable {
+		createPluginProject("plugin.a", "1.0.0");
+		createPluginProject("plugin.a", "1.0.1");
+		createPluginProject("plugin.b", "1.0.0");
+
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "2.0.0"), //
+				bundle("plugin.a", "2.0.1"), //
+				bundle("plugin.c", "1.0.0"));
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a", DEFAULT_VERSION);
+				}), //
+				targetFeature("feature.b", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.b", DEFAULT_VERSION);
+				}), //
+				targetFeature("feature.c", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.c", DEFAULT_VERSION);
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution explicit workspace", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.0.1")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:external"));
+
+			assertGetMergedBundleMap("pluginResolution explicit external", lc, Set.of( //
+					targetBundle("plugin.a", "2.0.1")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION, IPDELauncherConstants.LOCATION_WORKSPACE);
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+
+			assertGetMergedBundleMap("pluginResolution default workspace", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.0.1")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION, IPDELauncherConstants.LOCATION_EXTERNAL);
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+
+			assertGetMergedBundleMap("pluginResolution default external", lc, Set.of( //
+					targetBundle("plugin.a", "2.0.1")));
+		}
+		// Plug-ins only in secondary location
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.b:external"));
+
+			assertGetMergedBundleMap("pluginResolution explicit workspace", lc, Set.of( //
+					workspaceBundle("plugin.b", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.c:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution explicit external", lc, Set.of( //
+					targetBundle("plugin.c", "1.0.0")));
+		}
+	}
+
+	@Test
+	public void testGetMergedBundleMap_includedPluginWithSpecificVersion() throws Throwable {
+		createPluginProject("plugin.a", "1.0.0");
+		createPluginProject("plugin.a", "1.2.0");
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"), //
+				bundle("plugin.a", "1.1.0"), //
+				bundle("plugin.z", "1.0.0"));
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a", "1.0.0");
+				}), //
+				targetFeature("feature.b", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a", "2.0.0");
+				}), //
+				targetFeature("feature.c", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a", "1.1.0");
+				}), //
+				targetFeature("feature.d", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a", "1.2.0");
+				}), //
+				targetFeature("feature.e", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a", "1.0.0.someQualifier");
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		// Perfect version-match in primary location (while secondary location
+		// has matches too)
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution explicit workspace", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:external"));
+
+			assertGetMergedBundleMap("pluginResolution explicit external", lc, Set.of( //
+					targetBundle("plugin.a", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION, IPDELauncherConstants.LOCATION_WORKSPACE);
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+
+			assertGetMergedBundleMap("pluginResolution default workspace", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION, IPDELauncherConstants.LOCATION_EXTERNAL);
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+
+			assertGetMergedBundleMap("pluginResolution default external", lc, Set.of( //
+					targetBundle("plugin.a", "1.0.0")));
+		}
+		// Unqualified version-match in primary location
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.e:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution explicit workspace", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.2.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.e:external"));
+
+			assertGetMergedBundleMap("pluginResolution explicit external", lc, Set.of( //
+					targetBundle("plugin.a", "1.1.0")));
+		}
+		// no version-match at all (for included plug-ins the latest plug-in of
+		// a location is added if there is no match in the primary location)
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.b:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution workspace no match", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.2.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.b:external"));
+
+			assertGetMergedBundleMap("pluginResolution external no match", lc, Set.of( //
+					targetBundle("plugin.a", "1.1.0")));
+		}
+		// Perfect version match only in secondary location (but another version
+		// is present in the primary too).
+		// To be able to conveniently override a specific version of a plug-in,
+		// which is actually included by a (transitive) feature from the TP, by
+		// a plug-in from the workspace (which happens frequently when
+		// one has just updated the plug-in version) the latest plug-in from the
+		// primary location is taken if one is present and none matches the
+		// specified version exactly (with or without considering qualifiers).
+		// See also https://bugs.eclipse.org/bugs/show_bug.cgi?id=576887
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.c:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution workspace but match is external", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.2.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.d:external"));
+
+			assertGetMergedBundleMap("pluginResolution external but match is in workspace", lc, Set.of( //
+					targetBundle("plugin.a", "1.1.0")));
+		}
+	}
+
+	// --- required/imported plug-in dependencies ---
+
+	@Test
+	public void testGetMergedBundleMap_requiredPluginWithNoVersion() throws Throwable {
+		createPluginProject("plugin.a", "1.0.0");
+		createPluginProject("plugin.a", "1.1.0");
+		createPluginProject("plugin.b", "1.0.0");
+
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "2.0.0"), //
+				bundle("plugin.a", "2.1.0"), //
+				bundle("plugin.c", "1.0.0"));
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addRequiredPlugin(f, "plugin.a", null, IMatchRules.NONE);
+				}), //
+				targetFeature("feature.b", "1.0.0", f -> {
+					addRequiredPlugin(f, "plugin.b", null, IMatchRules.NONE);
+				}), //
+				targetFeature("feature.c", "1.0.0", f -> {
+					addRequiredPlugin(f, "plugin.c", null, IMatchRules.NONE);
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		// explicit pluginResolution location
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution explicit workspace", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.1.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:external"));
+
+			assertGetMergedBundleMap("pluginResolution explicit external", lc, Set.of( //
+					targetBundle("plugin.a", "2.1.0")));
+		}
+		// default pluginResolution location
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION, IPDELauncherConstants.LOCATION_WORKSPACE);
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+
+			assertGetMergedBundleMap("pluginResolution default workspace", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.1.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION, IPDELauncherConstants.LOCATION_EXTERNAL);
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.a:default"));
+
+			assertGetMergedBundleMap("pluginResolution default external", lc, Set.of( //
+					targetBundle("plugin.a", "2.1.0")));
+		}
+		// Plug-ins only in secondary location
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.b:external"));
+
+			assertGetMergedBundleMap("pluginResolution external but match in workspace", lc, Set.of( //
+					workspaceBundle("plugin.b", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.c:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution workspace but match is external", lc, Set.of( //
+					targetBundle("plugin.c", "1.0.0")));
+		}
+	}
+
+	@Test
+	public void testGetMergedBundleMap_requiredPluginWithSpecificVersion1() throws Throwable {
+		createPluginProject("plugin.a", "1.0.0");
+		createPluginProject("plugin.a", "1.1.2");
+		createPluginProject("plugin.a", "2.0.0");
+
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"), //
+				bundle("plugin.a", "1.2.3"), //
+				bundle("plugin.a", "3.0.0"), //
+				bundle("plugin.z", "1.0.0"));
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addRequiredPlugin(f, "plugin.a", "1.0.0", IMatchRules.NONE);
+				}), //
+				targetFeature("feature.b", "1.0.0", f -> {
+					addRequiredPlugin(f, "plugin.a", "1.0.0", IMatchRules.COMPATIBLE);
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		// MatchRule NONE/COMPATIBLE (behave the same according to VersionUtil)
+		// and location resolution tests.
+		// explicit pluginResolution location
+		{
+			for (String feature : List.of("feature.a", "feature.b")) {
+				ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+				lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of(feature + ":workspace"));
+
+				assertGetMergedBundleMap("pluginResolution explicit workspace", lc, Set.of( //
+						workspaceBundle("plugin.a", "1.0.0")));
+			}
+		}
+		{
+			for (String feature : List.of("feature.a", "feature.b")) {
+				ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+				lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of(feature + ":external"));
+
+				assertGetMergedBundleMap("pluginResolution explicit external", lc, Set.of( //
+						targetBundle("plugin.a", "1.0.0")));
+			}
+		}
+		// default pluginResolution location
+		{
+			for (String feature : List.of("feature.a", "feature.b")) {
+				ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+				lc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION,
+						IPDELauncherConstants.LOCATION_WORKSPACE);
+				lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of(feature + ":default"));
+
+				assertGetMergedBundleMap("pluginResolution default workspace", lc, Set.of( //
+						workspaceBundle("plugin.a", "1.0.0")));
+			}
+		}
+		{
+			for (String feature : List.of("feature.a", "feature.b")) {
+				ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+				lc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION,
+						IPDELauncherConstants.LOCATION_EXTERNAL);
+				lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of(feature + ":default"));
+
+				assertGetMergedBundleMap("pluginResolution default external", lc, Set.of( //
+						targetBundle("plugin.a", "1.0.0")));
+			}
+		}
+	}
+
+	@Test
+	public void testGetMergedBundleMap_requiredPluginWithSpecificVersion2() throws Throwable {
+		createPluginProject("plugin.a", "1.0.0");
+		createPluginProject("plugin.a", "1.0.1");
+		createPluginProject("plugin.a", "1.1.0");
+		createPluginProject("plugin.a", "1.1.2");
+		createPluginProject("plugin.a", "2.0.0");
+
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"), //
+				bundle("plugin.a", "1.0.2"), //
+				bundle("plugin.a", "1.2.0"), //
+				bundle("plugin.a", "1.2.3"), //
+				bundle("plugin.a", "3.0.0"), //
+				bundle("plugin.z", "1.0.0"));
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.c", "1.0.0", f -> {
+					addRequiredPlugin(f, "plugin.a", "1.0.0", IMatchRules.EQUIVALENT);
+				}), //
+				targetFeature("feature.d", "1.0.0", f -> {
+					addRequiredPlugin(f, "plugin.a", "1.1.0", IMatchRules.EQUIVALENT);
+				}), //
+				targetFeature("feature.e", "1.0.0", f -> {
+					addRequiredPlugin(f, "plugin.a", "1.2.0", IMatchRules.EQUIVALENT);
+				}), //
+				targetFeature("feature.f", "1.0.0", f -> {
+					addRequiredPlugin(f, "plugin.a", "8.0.0", IMatchRules.EQUIVALENT);
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		// No need to test all match-rules. Just have to check that version
+		// match rules are obeyed. For the following cases match-rule EQUIVALENT
+		// is used to check different per-location-availability scenarios.
+
+		// match in primary location (while secondary location has matches too)
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.c:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution workspace", lc, Set.of( //
+					workspaceBundle("plugin.a", "1.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.c:external"));
+
+			assertGetMergedBundleMap("pluginResolution external", lc, Set.of( //
+					targetBundle("plugin.a", "1.0.0")));
+		}
+		// match only in secondary location
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.d:external"));
+
+			assertGetMergedBundleMap("pluginResolution external but match in workspace", lc, Set.of( //
+					targetBundle("plugin.a", "3.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.e:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution workspace but match is external", lc, Set.of( //
+					workspaceBundle("plugin.a", "2.0.0")));
+		}
+		// no match at all
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.f:external"));
+
+			assertGetMergedBundleMap("pluginResolution external no match", lc, Set.of( //
+					targetBundle("plugin.a", "3.0.0")));
+		}
+		{
+			ILaunchConfigurationWorkingCopy lc = createFeatureLaunchConfig();
+			lc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of("feature.f:workspace"));
+
+			assertGetMergedBundleMap("pluginResolution workspace no match", lc, Set.of( //
+					workspaceBundle("plugin.a", "2.0.0")));
+		}
+	}
+
+	// --- miscellaneous cases ---
+
+	@Test
+	public void testGetMergedBundleMap_multipleInclusionOfPluginAndFeature() throws Throwable {
+
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"), //
+				bundle("plugin.b", "1.0.0"), //
+				bundle("plugin.z", "1.0.0"));
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a", "1.0.0");
+					addRequiredPlugin(f, "plugin.a", "1.0.0", IMatchRules.PERFECT);
+				}), //
+				targetFeature("feature.b", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.a", "1.0.0");
+					addRequiredPlugin(f, "plugin.a", "1.0.0", IMatchRules.PERFECT);
+				}), //
+				targetFeature("feature.c", "1.0.0", f -> {
+					addIncludedFeature(f, "feature.z", "1.0.0");
+					addRequiredFeature(f, "feature.z", "1.0.0", IMatchRules.PERFECT);
+				}), //
+				targetFeature("feature.d", "1.0.0", f -> {
+					addIncludedFeature(f, "feature.z", "1.0.0");
+					addRequiredFeature(f, "feature.z", "1.0.0", IMatchRules.PERFECT);
+				}), //
+
+				targetFeature("feature.z", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.z", "1.0.0");
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		ILaunchConfigurationWorkingCopy wc = createFeatureLaunchConfig();
+		wc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of( //
+				"feature.a:default", //
+				"feature.b:default", //
+				"feature.c:default", //
+				"feature.d:default"));
+
+		assertGetMergedBundleMap(wc, Set.of( //
+				targetBundle("plugin.a", "1.0.0")));
+	}
+
+	@Test
+	public void testGetMergedBundleMap_additionalPlugins() throws Throwable {
+		createPluginProject("plugin.a", "1.0.0");
+		createPluginProject("plugin.b", "1.0.0");
+		createPluginProject("plugin.d", "1.0.0");
+		createPluginProject("plugin.e", "1.0.0");
+
+		List<NameVersionDescriptor> targetBundles = List.of( //
+				bundle("plugin.a", "1.0.0"), //
+				bundle("plugin.b", "1.0.0"), //
+				bundle("plugin.c", "1.0.0"), //
+				bundle("plugin.d", "1.0.0"), //
+				bundle("plugin.e", "1.0.0"), //
+				bundle("plugin.f", "2.0.0"), //
+				bundle("plugin.z", "1.0.0"));
+
+		List<NameVersionDescriptor> targetFeatures = List.of( //
+				targetFeature("feature.a", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.d", "1.0.0");
+				}), //
+				targetFeature("feature.b", "1.0.0", f -> {
+					addIncludedPlugin(f, "plugin.e", "1.0.0");
+				}));
+
+		setTargetPlatform(targetBundles, targetFeatures);
+
+		ILaunchConfigurationWorkingCopy wc = createFeatureLaunchConfig();
+		wc.setAttribute(IPDELauncherConstants.SELECTED_FEATURES, Set.of( //
+				"feature.a:external", //
+				"feature.b:workspace"));
+		wc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION, IPDELauncherConstants.LOCATION_EXTERNAL);
+		wc.setAttribute(IPDELauncherConstants.ADDITIONAL_PLUGINS, Set.of( //
+				// id:version:location:enabled:startLeval:autoStart
+				"plugin.a:1.0.0:default:true:1:true", //
+				"plugin.b:1.0.0:workspace:true:2:true", //
+				"plugin.c:1.0.0:workspace:true:3:true", //
+				"plugin.d:1.0.0:external:true:4:true", // overwrite from feature
+				"plugin.e:1.0.0:external:true:5:true", // attempted overwrite
+				"plugin.f:1.0.0:default:true:6:true", // not matching version
+				"plugin.z:1.0.0:external:false:7:true") // disabled
+				);
+		// overwriting the plug-in also included by a feature only works
+		// if the same primary location is used and both pull in the
+		// same version. Otherwise two different bundles are added
+
+		assertGetMergedBundleMap(wc, Map.of( //
+				targetBundle("plugin.a", "1.0.0"), "1:true", //
+				workspaceBundle("plugin.b", "1.0.0"), "2:true", //
+				targetBundle("plugin.c", "1.0.0"), "3:true", //
+				targetBundle("plugin.d", "1.0.0"), "4:true", //
+				targetBundle("plugin.e", "1.0.0"), "5:true", //
+				workspaceBundle("plugin.e", "1.0.0"), "default:default", //
+				targetBundle("plugin.f", "2.0.0"), "6:true"));
+	}
+
+	// --- utility methods ---
+
+	private static interface CoreConsumer<E> {
+		void accept(E e) throws CoreException;
+	}
+
+	private static void createFeatureProject(String id, String version, CoreConsumer<IFeature> featureSetup)
+			throws Throwable {
+		createFeature(id, version, id + "_" + version.replace('.', '_'), featureSetup);
+	}
+
+	private static IFeature createFeature(String id, String version, String projectName,
+			CoreConsumer<IFeature> featureSetup) throws Throwable {
+		FeatureData featureData = new FeatureData();
+		featureData.id = id;
+		featureData.version = version;
+
+		IWorkspaceRoot workspaceRoot = ResourcesPlugin.getWorkspace().getRoot();
+		IProject project = workspaceRoot.getProject(projectName);
+		IPath location = workspaceRoot.getLocation().append(project.getName());
+
+		IRunnableWithProgress operation = new AbstractCreateFeatureOperation(project, location, featureData, null) {
+			@Override
+			protected void configureFeature(IFeature feature, WorkspaceFeatureModel model) throws CoreException {
+				featureSetup.accept(feature);
+			}
+
+			@Override
+			protected void openFeatureEditor(IFile manifestFile) {
+				// don't open in headless tests
+			}
+		};
+		operation.run(new NullProgressMonitor());
+		FeatureModelManager featureModelManager = PDECore.getDefault().getFeatureModelManager();
+		return featureModelManager.getFeatureModel(project).getFeature();
+	}
+
+	// Created Feature-projects get reloaded shortly after their creation, in
+	// the end of the auto-build job (due to some pending resource-changed
+	// events). In the beginning of the reload the feature model is reset and
+	// all fields become null/0. So if the model is read inbetween the model
+	// state could be inconsistent. This creates a race condition, which
+	// occasionally leads to test-failure. All my attempts to consume all
+	// resource-change events immediately to resolve the race-condition failed.
+	// I also tried to await the World-Changed event fired on a FeatureModel
+	// once it was reloaded but then the test spent most of its runtime waiting.
+	// Blocking all other operations was the simplest and fastest solution.
+	@BeforeClass
+	public static void acquireWorkspaceRuleToAvoidFeatureReloadByAutobuild() {
+		IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot();
+		Job.getJobManager().beginRule(root, null);
+	}
+
+	@AfterClass
+	public static void releaseWorkspaceRule() {
+		IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot();
+		Job.getJobManager().endRule(root);
+	}
+
+	private static void addRequiredPlugin(IFeature feature, String id, String version, int matchRule)
+			throws CoreException {
+		addImport(feature, id, version, matchRule, IFeatureImport.PLUGIN);
+	}
+
+	private static void addRequiredFeature(IFeature feature, String id, String version, int matchRule)
+			throws CoreException {
+		addImport(feature, id, version, matchRule, IFeatureImport.FEATURE);
+	}
+
+	private static void addImport(IFeature feature, String id, String version, int matchRule, int type)
+			throws CoreException {
+		IFeatureModelFactory factory = feature.getModel().getFactory();
+		IFeatureImport featureImport = factory.createImport();
+		featureImport.setId(id);
+		featureImport.setVersion(version);
+		featureImport.setMatch(matchRule);
+		featureImport.setType(type);
+
+		feature.addImports(new IFeatureImport[] { featureImport });
+	}
+
+	private static FeatureChild addIncludedFeature(IFeature feature, String id, String version) throws CoreException {
+		FeatureChild featureChild = (FeatureChild) feature.getModel().getFactory().createChild();
+		featureChild.setId(id);
+		featureChild.setVersion(version);
+		featureChild.setOptional(false);
+		feature.addIncludedFeatures(new IFeatureChild[] { featureChild });
+		return featureChild;
+	}
+
+	private static IFeaturePlugin addIncludedPlugin(IFeature feature, String id, String version) throws CoreException {
+		IFeaturePlugin featurePlugin = feature.getModel().getFactory().createPlugin();
+		featurePlugin.setId(id);
+		featurePlugin.setVersion(version);
+		featurePlugin.setUnpack(false);
+		feature.addPlugins(new IFeaturePlugin[] { featurePlugin });
+		return featurePlugin;
+	}
+
+	private NameVersionDescriptor targetFeature(String featureId, String featureVersion,
+			CoreConsumer<IFeature> featureSetup) throws Throwable {
+
+		IFeature feature = createFeature(featureId, featureVersion, "tp-feature-temp-project", featureSetup);
+
+		WorkspaceFeatureModel model = (WorkspaceFeatureModel) feature.getModel();
+		IResource resource = model.getUnderlyingResource();
+
+		Path featureDirectory = tpJarDirectory.resolve(Path.of("features", featureId + "_" + featureVersion));
+		Files.createDirectories(featureDirectory);
+		Path featureFile = featureDirectory.resolve(resource.getProjectRelativePath().toString());
+		try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(featureFile));) {
+			model.save(writer);
+		}
+		IProject project = resource.getProject();
+		project.delete(IResource.FORCE | IResource.ALWAYS_DELETE_PROJECT_CONTENT, null);
+
+		return new NameVersionDescriptor(featureId, featureVersion, NameVersionDescriptor.TYPE_FEATURE);
+	}
+
+	private void setTargetPlatform(List<NameVersionDescriptor> targetPlugins,
+			List<NameVersionDescriptor> targetFeatures) throws Exception {
+		TargetPlatformUtil.setDummyBundlesAsTarget(targetPlugins, tpJarDirectory);
+	}
+
+	private static ILaunchConfigurationWorkingCopy createFeatureLaunchConfig() throws CoreException {
+		String name = "feature-based-Eclipse-app";
+		ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager();
+		ILaunchConfigurationType type = launchManager.getLaunchConfigurationType("org.eclipse.pde.ui.RuntimeWorkbench");
+		ILaunchConfigurationWorkingCopy wc = type.newInstance(null, name);
+		wc.setAttribute(IPDELauncherConstants.USE_CUSTOM_FEATURES, true);
+		wc.setAttribute(IPDELauncherConstants.USE_DEFAULT, false);
+		wc.setAttribute(IPDELauncherConstants.FEATURE_DEFAULT_LOCATION, IPDELauncherConstants.LOCATION_WORKSPACE);
+		wc.setAttribute(IPDELauncherConstants.FEATURE_PLUGIN_RESOLUTION, IPDELauncherConstants.LOCATION_WORKSPACE);
+		return wc;
+	}
+
+	private static void assertGetMergedBundleMap(ILaunchConfiguration launchConfig,
+			Set<BundleLocationDescriptor> expectedBundles) throws Exception {
+		assertGetMergedBundleMap(null, launchConfig, expectedBundles);
+	}
+
+	private static void assertGetMergedBundleMap(String message, ILaunchConfiguration launchConfig,
+			Set<BundleLocationDescriptor> expectedBundles) throws Exception {
+
+		Map<BundleLocationDescriptor, String> expectedBundleMap = expectedBundles.stream()
+				.collect(Collectors.toMap(b -> b, b -> "default:default"));
+		assertGetMergedBundleMap(message, launchConfig, expectedBundleMap);
+	}
+
+	private static void assertGetMergedBundleMap(ILaunchConfiguration launchConfig,
+			Map<BundleLocationDescriptor, String> expectedBundleMap) throws Exception {
+		assertGetMergedBundleMap(null, launchConfig, expectedBundleMap);
+	}
+
+	private static void assertGetMergedBundleMap(String message, ILaunchConfiguration launchConfig,
+			Map<BundleLocationDescriptor, String> expectedBundleMap) throws Exception {
+
+		Map<IPluginModelBase, String> bundleMap = BundleLauncherHelper.getMergedBundleMap(launchConfig, false);
+
+		Map<IPluginModelBase, String> expectedPluginMap = new HashMap<>();
+		expectedBundleMap.forEach((pd, start) -> {
+			expectedPluginMap.put(pd.findModel(), start);
+		});
+
+		assertPluginMapsEquals(message, expectedPluginMap, bundleMap);
 	}
 }
diff --git a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/PluginBasedLaunchTest.java b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/PluginBasedLaunchTest.java
index 412d055..d053e17 100644
--- a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/PluginBasedLaunchTest.java
+++ b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/launcher/PluginBasedLaunchTest.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- *  Copyright (c) 2021, 2021 Hannes Wellmann and others.
+ *  Copyright (c) 2021, 2022 Hannes Wellmann and others.
  *
  *  This program and the accompanying materials
  *  are made available under the terms of the Eclipse Public License 2.0
@@ -15,7 +15,6 @@
 
 import static org.junit.Assert.assertEquals;
 
-import java.io.IOException;
 import java.nio.file.Path;
 import java.util.*;
 import java.util.function.Consumer;
@@ -810,10 +809,6 @@
 
 	// --- utilities ---
 
-	private static NameVersionDescriptor bundle(String id, String version) {
-		return new NameVersionDescriptor(id, version);
-	}
-
 	private void assertGetMergedBundleMap(List<NameVersionDescriptor> workspacePlugins,
 			List<NameVersionDescriptor> targetPlugins, Consumer<ILaunchConfigurationWorkingCopy> launchConfigPreparer,
 			Set<BundleLocationDescriptor> expectedBundles) throws Exception {
@@ -839,11 +834,11 @@
 			expectedPluginMap.put(pd.findModel(), start);
 		});
 
-		assertEquals(expectedPluginMap, bundleMap);
+		assertPluginMapsEquals(null, expectedPluginMap, bundleMap);
 	}
 
 	private void setUpWorkspace(List<NameVersionDescriptor> workspacePlugins, List<NameVersionDescriptor> targetPlugins)
-			throws CoreException, IOException, InterruptedException {
+			throws Exception {
 		ProjectUtils.createWorkspacePluginProjects(workspacePlugins);
 		TargetPlatformUtil.setDummyBundlesAsTarget(targetPlugins, tpJarDirectory);
 	}
@@ -857,21 +852,4 @@
 		wc.setAttribute(IPDELauncherConstants.USE_DEFAULT, false);
 		return wc;
 	}
-
-	private static BundleLocationDescriptor workspaceBundle(String id, String version) {
-		Objects.requireNonNull(version);
-		return () -> findWorkspaceModel(id, version);
-	}
-
-	private static BundleLocationDescriptor targetBundle(String id, String version) {
-		Objects.requireNonNull(version);
-		// PluginRegistry.findModel does not consider external models when
-		// workspace models are present and returns the 'last' plug-in if
-		// multiple with the same version exist
-		return () -> findTargetModel(id, version);
-	}
-
-	private static interface BundleLocationDescriptor {
-		IPluginModelBase findModel();
-	}
 }
diff --git a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/util/ProjectUtils.java b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/util/ProjectUtils.java
index 7ededbe..05b2b26 100644
--- a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/util/ProjectUtils.java
+++ b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/util/ProjectUtils.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- * Copyright (c) 2008, 2021 IBM Corporation and others.
+ * Copyright (c) 2008, 2022 IBM Corporation and others.
  *
  * This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License 2.0
@@ -158,15 +158,16 @@
 	public static List<IProject> createWorkspacePluginProjects(List<NameVersionDescriptor> workspacePlugins)
 			throws CoreException {
 		List<IProject> projects = new ArrayList<>();
-		for (NameVersionDescriptor pluginDescription : workspacePlugins) {
-			String bundleSymbolicName = pluginDescription.getId();
-			String bundleVersion = pluginDescription.getVersion();
-			String projectName = bundleSymbolicName + bundleVersion.replace('.', '_');
-			projects.add(createPluginProject(projectName, bundleSymbolicName, bundleVersion));
+		for (NameVersionDescriptor plugin : workspacePlugins) {
+			projects.add(createPluginProject(plugin.getId(), plugin.getVersion()));
 		}
 		return projects;
 	}
 
+	public static IProject createPluginProject(String bundleSymbolicName, String bundleVersion) throws CoreException {
+		return createPluginProject(bundleSymbolicName + bundleVersion.replace('.', '_'), bundleSymbolicName, bundleVersion);
+	}
+
 	public static IProject createPluginProject(String projectName, String bundleSymbolicName, String version)
 			throws CoreException {
 		IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(projectName);
diff --git a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/util/TargetPlatformUtil.java b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/util/TargetPlatformUtil.java
index 759d37e..8f8fdd9 100644
--- a/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/util/TargetPlatformUtil.java
+++ b/ui/org.eclipse.pde.ui.tests/src/org/eclipse/pde/ui/tests/util/TargetPlatformUtil.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- *  Copyright (c) 2019, 2021 Julian Honnen and others.
+ *  Copyright (c) 2019, 2022 Julian Honnen and others.
  *
  *  This program and the accompanying materials
  *  are made available under the terms of the Eclipse Public License 2.0
@@ -135,6 +135,8 @@
 
 	private static ITargetLocation createDummyBundlesLocation(List<NameVersionDescriptor> targetPlugins,
 			Path jarDirectory) throws IOException {
+		Path pluginsDirectory = jarDirectory.resolve("plugins");
+		Files.createDirectories(pluginsDirectory);
 		for (NameVersionDescriptor bundleNameVersion : targetPlugins) {
 
 			Manifest manifest = createDummyBundleManifest(bundleNameVersion.getId(), bundleNameVersion.getVersion());
@@ -143,7 +145,7 @@
 			String bundleSymbolicName = Objects.requireNonNull(mainAttributes.getValue(Constants.BUNDLE_SYMBOLICNAME));
 			String bundleVersion = Objects.requireNonNull(mainAttributes.getValue(Constants.BUNDLE_VERSION));
 
-			Path jarPath = jarDirectory.resolve(bundleSymbolicName + "_" + bundleVersion + ".jar");
+			Path jarPath = pluginsDirectory.resolve(bundleSymbolicName + "_" + bundleVersion + ".jar");
 			try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(jarPath));) {
 				out.putNextEntry(new ZipEntry(JarFile.MANIFEST_NAME));
 				manifest.write(out);