diff --git a/tests/com.espressif.idf.ui.test/META-INF/MANIFEST.MF b/tests/com.espressif.idf.ui.test/META-INF/MANIFEST.MF
index 5556ed8d8..ee1dbab37 100644
--- a/tests/com.espressif.idf.ui.test/META-INF/MANIFEST.MF
+++ b/tests/com.espressif.idf.ui.test/META-INF/MANIFEST.MF
@@ -7,6 +7,7 @@ Bundle-RequiredExecutionEnvironment: JavaSE-21
Automatic-Module-Name: com.espressif.idf.tests
Require-Bundle: org.eclipse.swtbot.go;bundle-version="2.7.0",
org.eclipse.launchbar.core,
+ org.eclipse.debug.core,
slf4j.api,
com.espressif.idf.ui;bundle-version="1.0.1"
Bundle-ActivationPolicy: lazy
diff --git a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectDebugProcessTest.java b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectDebugProcessTest.java
new file mode 100644
index 000000000..59f5f8b40
--- /dev/null
+++ b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectDebugProcessTest.java
@@ -0,0 +1,759 @@
+/*******************************************************************************
+ * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved.
+ * Use is subject to license terms.
+ *******************************************************************************/
+package com.espressif.idf.ui.test.executable.cases.project;
+
+import static org.eclipse.swtbot.swt.finder.waits.Conditions.widgetIsEnabled;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeTrue;
+
+import java.io.IOException;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.lang3.SystemUtils;
+import org.eclipse.core.resources.IBuildConfiguration;
+import org.eclipse.core.resources.IProject;
+import org.eclipse.core.resources.ResourcesPlugin;
+import org.eclipse.debug.core.DebugPlugin;
+import org.eclipse.debug.core.ILaunchManager;
+import org.eclipse.debug.core.ILaunchMode;
+import org.eclipse.launchbar.core.ILaunchBarManager;
+import org.eclipse.swtbot.eclipse.finder.SWTWorkbenchBot;
+import org.eclipse.swtbot.swt.finder.waits.DefaultCondition;
+import org.eclipse.swtbot.swt.finder.exceptions.WidgetNotFoundException;
+import org.eclipse.swtbot.swt.finder.junit.SWTBotJunit4ClassRunner;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotCheckBox;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotCombo;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotShell;
+import org.junit.AfterClass;
+import org.junit.AssumptionViolatedException;
+import org.junit.BeforeClass;
+import org.junit.FixMethodOrder;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestRule;
+import org.junit.runner.RunWith;
+import org.junit.runners.MethodSorters;
+import org.junit.runners.model.Statement;
+
+import com.espressif.idf.core.IDFCorePlugin;
+import com.espressif.idf.core.build.IDFBuildConfigurationProvider;
+import com.espressif.idf.ui.test.common.WorkBenchSWTBot;
+import com.espressif.idf.ui.test.common.utility.TestWidgetWaitUtility;
+import com.espressif.idf.ui.test.operations.EnvSetupOperations;
+import com.espressif.idf.ui.test.operations.ProjectTestOperations;
+import com.espressif.idf.ui.test.operations.selectors.LaunchBarConfigSelector;
+import com.espressif.idf.ui.test.operations.selectors.LaunchBarTargetSelector;
+
+/**
+ * Hardware E2E test: create → build → UART flash (ESP32) → select ESP32-ETHERNET-KIT →
+ * start OpenOCD/GDB debugging via Debug As and verify the session (Step Over).
+ *
+ * Mirrors the VS Code hardware debug flow from {@code project-hardware-e2e-test.ts}.
+ *
+ * @author Andrii Filippov
+ *
+ */
+@SuppressWarnings("restriction")
+@RunWith(SWTBotJunit4ClassRunner.class)
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class IDFProjectDebugProcessTest
+{
+ private static final String PROJECT_NAME = "NewProjectDebugProcessTest";
+ private static final String ESP32_TARGET = "esp32";
+ private static final String ETHERNET_KIT_BOARD_PREFIX = "ESP32-ETHERNET-KIT";
+ private static final Pattern DEBUG_FATAL_ERROR_PATTERN = Pattern.compile(
+ "Target failure|Error: .*failed to halt|OpenOCD failed|LIBUSB_ERROR|failed to connect",
+ Pattern.CASE_INSENSITIVE);
+
+ private static final Pattern[] TARGET_DETECTION_PATTERNS = new Pattern[] {
+ Pattern.compile("Connected to\\s+(ESP32[-A-Z0-9]*)\\b", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("Chip type:\\s*(ESP32[-A-Z0-9]*)\\b", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("Detecting chip type\\.\\.\\.\\s*(ESP32[-A-Z0-9]*)\\b", Pattern.CASE_INSENSITIVE)
+ };
+
+ /**
+ * Rewrites a blank SWTBot {@link AssertionError} so the runner summary, which prints only
+ * {@code class: message}, still shows the step and the last checkpoint.
+ */
+ @Rule
+ public final TestRule reportDebugStep = (base, description) -> new Statement()
+ {
+ @Override
+ public void evaluate() throws Throwable
+ {
+ try
+ {
+ base.evaluate();
+ }
+ catch (AssumptionViolatedException skipped)
+ {
+ throw skipped;
+ }
+ catch (Throwable failure)
+ {
+ String message = failure.getMessage();
+ if (message != null && message.startsWith("Debug test failed at step ["))
+ {
+ throw failure;
+ }
+ String cause = failure.getClass().getSimpleName() + ": "
+ + (message == null || message.trim().isEmpty() ? "" : message.trim());
+ cause = cause.replace('\n', ' ').replace('\r', ' ');
+ throw new AssertionError("Debug test failed at step [" + ProjectTestOperations.debugCheckpoint()
+ + "]. Cause: " + cause);
+ }
+ }
+ };
+
+ @BeforeClass
+ public static void beforeTestClass() throws Exception
+ {
+ Fixture.loadEnv();
+ }
+
+ @AfterClass
+ public static void tearDown()
+ {
+ Fixture.cleanupEnvironment();
+ }
+
+ @Test
+ public void givenNewProjectBuiltAndFlashedViaUartWhenDebugWithEthernetKitThenDebugSessionStarts()
+ throws Exception
+ {
+ assumeTrue("Linux only: hardware debug test requires Linux CI/lab boards", SystemUtils.IS_OS_LINUX);
+
+ runDebugStep("create project", () -> {
+ Fixture.givenNewEspressifIDFProjectIsSelected("EspressIf", "Espressif IDF Project");
+ Fixture.givenProjectNameIs(PROJECT_NAME);
+ Fixture.whenNewProjectIsSelected();
+ });
+ runDebugStep("turn off serial monitor after flashing",
+ () -> Fixture.whenTurnOffOpenSerialMonitorAfterFlashingInLaunchConfig());
+ runDebugStep("detect ESP32 UART port", () -> {
+ String esp32SerialPort = Fixture.whenDetectAndSelectEsp32UartSerialPort();
+ assumeTrue("Skipping hardware debug test: no ESP32 UART target detected", esp32SerialPort != null);
+ });
+ runDebugStep("build project", () -> Fixture.whenProjectIsBuiltUsingContextMenu());
+ runDebugStep("flash project via UART", () -> Fixture.whenFlashProject());
+ runDebugStep("verify flash finished", () -> Fixture.thenVerifyFlashDoneSuccessfully());
+ runDebugStep("select ESP32-ETHERNET-KIT", () -> assumeTrue(
+ "Skipping hardware debug test: ESP32-ETHERNET-KIT board was not detected",
+ Fixture.whenSelectEsp32EthernetKitBoard()));
+
+ // Run-mode steps stay above. The ESP-IDF Application configuration becomes the debug
+ // configuration only after Launch Mode changes from Run to Debug.
+ runDebugStep("switch Launch Mode to Debug", () -> Fixture.whenLaunchModeIsSwitchedToDebug());
+ runDebugStep("start debugging", () -> Fixture.whenStartDebuggingUsingContextMenu());
+ runDebugStep("wait until suspended at app_main", () -> Fixture.thenVerifyDebugSessionStarted());
+ runDebugStep("check OpenOCD console after suspend", () -> Fixture.thenVerifyNoFatalOpenOcdErrors());
+ runDebugStep("Step Over", () -> Fixture.whenStepOver());
+ runDebugStep("check session still active after Step Over", () -> Fixture.thenVerifyDebugSessionStillActive());
+ runDebugStep("stop debugging", () -> Fixture.whenStopDebugging());
+ }
+
+ @FunctionalInterface
+ private interface DebugStep
+ {
+ void run() throws Exception;
+ }
+
+ /**
+ * Runs one debug-test step and always reports the step name. The hardware runner summary prints
+ * only {@code exception class: message}, and SWTBot sometimes throws {@link AssertionError} with
+ * an empty message, so the step name and a short cause are put into the message itself.
+ */
+ private static void runDebugStep(String step, DebugStep action) throws Exception
+ {
+ ProjectTestOperations.noteDebugCheckpoint(step);
+ System.out.println("[IDFProjectDebugProcessTest] START " + step);
+ long started = System.currentTimeMillis();
+ try
+ {
+ action.run();
+ System.out.println("[IDFProjectDebugProcessTest] OK " + step + " in "
+ + (System.currentTimeMillis() - started) + " ms");
+ }
+ catch (AssumptionViolatedException skipped)
+ {
+ System.out.println("[IDFProjectDebugProcessTest] SKIP " + step + ": " + skipped.getMessage());
+ throw skipped;
+ }
+ catch (Throwable failure)
+ {
+ long elapsed = System.currentTimeMillis() - started;
+ String checkpoint = ProjectTestOperations.debugCheckpoint();
+ String message = "Debug test failed at step [" + step + "]"
+ + (checkpoint.isEmpty() || checkpoint.equals(step) ? "" : ", checkpoint [" + checkpoint + "]")
+ + " after " + elapsed + " ms. Cause: " + oneLineCause(failure);
+ System.err.println("[IDFProjectDebugProcessTest] FAIL " + message);
+ System.err.println(describeFailure(failure));
+ System.err.println(Fixture.failureContext());
+ // No cause chain: the runner summary prints the root exception, and that used to be a
+ // blank SWTBot AssertionError.
+ throw new AssertionError(message);
+ }
+ }
+
+ private static String oneLineCause(Throwable failure)
+ {
+ String text = failure.getMessage();
+ String summary = failure.getClass().getSimpleName() + ": "
+ + (text == null || text.trim().isEmpty() ? "" : text.trim());
+ summary = summary.replace('\n', ' ').replace('\r', ' ');
+ if (summary.length() > 300)
+ {
+ summary = summary.substring(0, 300);
+ }
+ return summary;
+ }
+
+ private static String describeFailure(Throwable failure)
+ {
+ StringBuilder detail = new StringBuilder();
+ Throwable current = failure;
+ int depth = 0;
+ while (current != null && depth < 4)
+ {
+ if (depth > 0)
+ {
+ detail.append(" Caused by: ");
+ }
+ detail.append(current.getClass().getName());
+ String message = current.getMessage();
+ detail.append(": ").append(message == null || message.trim().isEmpty() ? "" : message.trim());
+ StackTraceElement[] stack = current.getStackTrace();
+ int frames = Math.min(stack.length, 6);
+ for (int i = 0; i < frames; i++)
+ {
+ detail.append("\n at ").append(stack[i]);
+ }
+ current = current.getCause();
+ depth++;
+ }
+ return detail.toString();
+ }
+
+ private static class Fixture
+ {
+ private static SWTWorkbenchBot bot;
+ private static String category;
+ private static String subCategory;
+ private static String projectName;
+
+ private static void loadEnv() throws Exception
+ {
+ bot = WorkBenchSWTBot.getBot();
+ EnvSetupOperations.setupEspressifEnv(bot);
+ bot.sleep(1000);
+ ProjectTestOperations.deleteAllProjects(bot);
+ }
+
+ private static void givenNewEspressifIDFProjectIsSelected(String category, String subCategory)
+ {
+ Fixture.category = category;
+ Fixture.subCategory = subCategory;
+ }
+
+ private static void givenProjectNameIs(String projectName)
+ {
+ Fixture.projectName = projectName;
+ }
+
+ private static void whenNewProjectIsSelected() throws Exception
+ {
+ ProjectTestOperations.setupProject(projectName, category, subCategory, bot);
+ TestWidgetWaitUtility.waitForOperationsInProgressToFinishAsync(bot);
+ }
+
+ private static void whenTurnOffOpenSerialMonitorAfterFlashingInLaunchConfig() throws Exception
+ {
+ LaunchBarConfigSelector configSelector = new LaunchBarConfigSelector(bot);
+ configSelector.clickEdit();
+
+ TestWidgetWaitUtility.waitForDialogToAppear(bot, "Edit Configuration", 20000);
+
+ bot.cTabItem("Main").activate();
+
+ SWTBotCheckBox checkBox = bot.checkBox("Open Serial Monitor After Flashing");
+ if (checkBox.isChecked())
+ {
+ checkBox.click();
+ }
+
+ bot.button("OK").click();
+ }
+
+ /**
+ * Opens New ESP Target, scans serial ports with detailed output, and stops as soon as
+ * an esp32 chip is detected. Finishes the dialog with that port selected.
+ *
+ * @return the selected ESP32 serial port, or {@code null} if none was found
+ */
+ private static String whenDetectAndSelectEsp32UartSerialPort() throws Exception
+ {
+ LaunchBarTargetSelector targetSelector = new LaunchBarTargetSelector(bot);
+ targetSelector.clickEdit();
+
+ TestWidgetWaitUtility.waitForDialogToAppear(bot, "New ESP Target", 20000);
+
+ SWTBotShell shell = bot.shell("New ESP Target");
+ shell.setFocus();
+
+ SWTBotCheckBox detailedOutput = bot.checkBox("Enable detailed output");
+ if (!detailedOutput.isChecked())
+ {
+ detailedOutput.click();
+ }
+
+ SWTBotCombo serialPortCombo = bot.comboBoxWithLabel("Serial Port:");
+ String[] serialPorts = serialPortCombo.items();
+
+ for (String serialPort : serialPorts)
+ {
+ if (serialPort == null || serialPort.trim().isEmpty())
+ {
+ continue;
+ }
+
+ System.out.println("Checking serial port: " + serialPort);
+
+ String outputBeforeSelection = readTargetDetectionOutput();
+ serialPortCombo.setSelection(serialPort);
+
+ // Wait for target auto-detection output to be printed.
+ bot.sleep(3000);
+
+ String outputAfterSelection = readTargetDetectionOutput();
+ String newOutput = getNewOutputPart(outputBeforeSelection, outputAfterSelection);
+ String detectedTarget = extractTargetFromDetectionOutput(newOutput);
+
+ if (detectedTarget == null || detectedTarget.trim().isEmpty())
+ {
+ System.out.println("No ESP target detected for serial port: " + serialPort);
+ continue;
+ }
+
+ System.out.println("Detected ESP target: " + detectedTarget + " on port: " + serialPort);
+
+ if (ESP32_TARGET.equals(detectedTarget))
+ {
+ System.out.println("ESP32 UART port found — stopping discovery and applying: " + serialPort);
+ TestWidgetWaitUtility.waitForOperationsInProgressToFinishSync(bot);
+ shell.setFocus();
+ bot.button("Finish").click();
+ TestWidgetWaitUtility.waitForOperationsInProgressToFinishAsync(bot);
+ return serialPort;
+ }
+ }
+
+ System.out.println("No esp32 target among detected ports");
+ bot.button("Cancel").click();
+ return null;
+ }
+
+ /**
+ * Selects an ESP32-ETHERNET-KIT board entry from the New ESP Target Board combo.
+ *
+ * @return true if a matching board was found and selected
+ */
+ private static boolean whenSelectEsp32EthernetKitBoard() throws Exception
+ {
+ LaunchBarTargetSelector targetSelector = new LaunchBarTargetSelector(bot);
+ targetSelector.clickEdit();
+
+ TestWidgetWaitUtility.waitForDialogToAppear(bot, "New ESP Target", 20000);
+
+ SWTBotShell shell = bot.shell("New ESP Target");
+ shell.setFocus();
+
+ // Ensure IDF target is esp32 so Ethernet Kit boards are listed.
+ try
+ {
+ bot.comboBoxWithLabel("IDF Target").setSelection(ESP32_TARGET);
+ bot.sleep(2000);
+ }
+ catch (WidgetNotFoundException ignored)
+ {
+ // Label text may differ slightly across versions; Board combo is still attempted.
+ }
+
+ SWTBotCombo boardCombo = bot.comboBoxWithLabel("Board:");
+ String[] boards = boardCombo.items();
+ String match = null;
+
+ for (String board : boards)
+ {
+ if (board != null && board.startsWith(ETHERNET_KIT_BOARD_PREFIX))
+ {
+ match = board;
+ break;
+ }
+ }
+
+ if (match == null)
+ {
+ System.out.println("ESP32-ETHERNET-KIT not found in Board combo. Available: "
+ + String.join(", ", boards));
+ bot.button("Cancel").click();
+ return false;
+ }
+
+ System.out.println("Selecting board: " + match);
+ boardCombo.setSelection(match);
+ TestWidgetWaitUtility.waitForOperationsInProgressToFinishSync(bot);
+ shell.setFocus();
+ bot.button("Finish").click();
+ TestWidgetWaitUtility.waitForOperationsInProgressToFinishAsync(bot);
+ return true;
+ }
+
+ private static void whenProjectIsBuiltUsingContextMenu() throws IOException
+ {
+ ProjectTestOperations.buildProjectUsingContextMenu(projectName, bot);
+ ProjectTestOperations.waitForProjectBuild(bot);
+ TestWidgetWaitUtility.waitForOperationsInProgressToFinishAsync(bot);
+ }
+
+ private static void whenFlashProject() throws IOException
+ {
+ ProjectTestOperations.launchCommandUsingContextMenuWithoutWaitingForJobs(projectName, bot,
+ "Run Configurations...");
+
+ TestWidgetWaitUtility.waitForDialogToAppear(bot, "Run Configurations", 10000);
+
+ bot.tree().getTreeItem("ESP-IDF Application").select();
+ bot.tree().getTreeItem("ESP-IDF Application").expand();
+ bot.tree().getTreeItem("ESP-IDF Application").getNode(projectName).select();
+
+ bot.waitUntil(widgetIsEnabled(bot.button("Run")), 5000);
+ bot.button("Run").click();
+ }
+
+ private static void thenVerifyFlashDoneSuccessfully() throws Exception
+ {
+ ProjectTestOperations.waitForProjectFlash(bot);
+ }
+
+ private static void whenLaunchModeIsSwitchedToDebug()
+ {
+ ProjectTestOperations.noteDebugCheckpoint("set Launch Mode to Debug");
+ ILaunchMode mode = DebugPlugin.getDefault().getLaunchManager().getLaunchMode(ILaunchManager.DEBUG_MODE);
+ if (mode == null)
+ {
+ throw new AssertionError("Unknown launch mode: " + ILaunchManager.DEBUG_MODE);
+ }
+ try
+ {
+ IDFCorePlugin.getService(ILaunchBarManager.class).setActiveLaunchMode(mode);
+ }
+ catch (Exception e)
+ {
+ throw new AssertionError("Unable to select the Debug launch mode", e);
+ }
+
+ String debugConfigPrefix = IDFBuildConfigurationProvider.ID + "/idf." + ILaunchManager.DEBUG_MODE + ".";
+ bot.waitUntil(new DefaultCondition()
+ {
+ @Override
+ public boolean test() throws Exception
+ {
+ return activeBuildConfigName().startsWith(debugConfigPrefix);
+ }
+
+ @Override
+ public String getFailureMessage()
+ {
+ return "Launch Mode is Debug, but the active build configuration is still "
+ + activeBuildConfigName();
+ }
+ }, 30000, 500);
+ ProjectTestOperations.noteDebugCheckpoint("active configuration is " + activeBuildConfigName());
+ }
+
+ private static String activeBuildConfigName()
+ {
+ IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(projectName);
+ if (!project.isAccessible())
+ {
+ return IBuildConfiguration.DEFAULT_CONFIG_NAME;
+ }
+ try
+ {
+ return project.getActiveBuildConfig().getName();
+ }
+ catch (Exception e)
+ {
+ return IBuildConfiguration.DEFAULT_CONFIG_NAME;
+ }
+ }
+
+ private static void whenStartDebuggingUsingContextMenu()
+ {
+ ProjectTestOperations.startDebuggingUsingContextMenu(projectName, bot);
+ // Give OpenOCD/GDB and perspective-switch UI time to appear.
+ bot.sleep(3000);
+ }
+
+ private static void thenVerifyDebugSessionStarted() throws Exception
+ {
+ ProjectTestOperations.waitForDebugSessionStarted(bot);
+ // Settle Debug perspective / toolbar after suspend at app_main.
+ bot.sleep(3000);
+ }
+
+ private static void thenVerifyNoFatalOpenOcdErrors()
+ {
+ String consoleText = ProjectTestOperations.readDebugRelatedConsoleText(bot);
+ assertFalse("Fatal OpenOCD error detected during debug session.\nConsole:\n" + consoleText,
+ DEBUG_FATAL_ERROR_PATTERN.matcher(consoleText).find());
+ assertFalse("Debug session already shut down before assertions.\nConsole:\n" + consoleText,
+ consoleText.contains("shutdown command invoked")
+ || consoleText.contains("dropped 'gdb' connection"));
+ assertTrue("Expected an active debug launch after suspend", ProjectTestOperations.hasActiveLaunch());
+ }
+
+ private static void whenStepOver()
+ {
+ bot.sleep(2000);
+ ProjectTestOperations.performDebugStepOver(projectName, bot);
+ bot.sleep(2000);
+ }
+
+ private static void thenVerifyDebugSessionStillActive()
+ {
+ String consoleText = ProjectTestOperations.readDebugRelatedConsoleText(bot);
+ assertFalse("Fatal OpenOCD error after Step Over.\nConsole:\n" + consoleText,
+ DEBUG_FATAL_ERROR_PATTERN.matcher(consoleText).find());
+ assertFalse("Debug session terminated unexpectedly after Step Over.\nConsole:\n" + consoleText,
+ consoleText.contains("shutdown command invoked"));
+ assertTrue("Debug launch terminated unexpectedly after Step Over",
+ ProjectTestOperations.hasActiveLaunch());
+ }
+
+ private static void whenStopDebugging()
+ {
+ stopDebugSessionAndKillProcesses();
+ bot.sleep(2000);
+ }
+
+ private static void stopDebugSessionAndKillProcesses()
+ {
+ ProjectTestOperations.stopDebugSessionAndKillProcesses(bot, projectName);
+ }
+
+ /**
+ * Short snapshot attached to a failed step. Kept bounded so the runner summary stays readable.
+ */
+ private static String failureContext()
+ {
+ StringBuilder context = new StringBuilder();
+ try
+ {
+ context.append("activeLaunch=").append(ProjectTestOperations.hasActiveLaunch());
+ }
+ catch (Throwable failure)
+ {
+ context.append("activeLaunch=");
+ }
+ try
+ {
+ String console = bot == null ? "" : ProjectTestOperations.readDebugRelatedConsoleText(bot);
+ if (console.length() > 1200)
+ {
+ console = console.substring(console.length() - 1200);
+ }
+ context.append("\nconsoleTail:\n").append(console.isEmpty() ? "" : console);
+ }
+ catch (Throwable failure)
+ {
+ context.append("\nconsoleTail=");
+ }
+ return context.toString();
+ }
+
+ private static void cleanupEnvironment()
+ {
+ try
+ {
+ ProjectTestOperations.leaveDebugUi(bot, projectName);
+ }
+ catch (Exception e)
+ {
+ System.err.println("leaveDebugUi failed: " + e.getMessage());
+ }
+
+ // Launch Mode is workbench-wide. Later tests edit the ESP-IDF Application configuration in Run.
+ try
+ {
+ ILaunchMode runMode = DebugPlugin.getDefault().getLaunchManager()
+ .getLaunchMode(ILaunchManager.RUN_MODE);
+ if (runMode != null)
+ {
+ IDFCorePlugin.getService(ILaunchBarManager.class).setActiveLaunchMode(runMode);
+ }
+ }
+ catch (Exception e)
+ {
+ System.err.println("restore Run launch mode failed: " + e.getMessage());
+ }
+
+ // Delete via workspace API first — UI deleteAllProjects calls WaitUtils.waitForJobs()
+ // which times out for ~5 minutes when Language Server jobs never go idle after debug,
+ // leaving NewProjectDebugProcessTest in the workspace for the next test class.
+ try
+ {
+ ProjectTestOperations.cancelJobsThatBlockWorkbenchIdle();
+ ProjectTestOperations.deleteAllProjectsViaWorkspaceApi();
+ }
+ catch (Exception e)
+ {
+ System.err.println("deleteAllProjectsViaWorkspaceApi failed: " + e.getMessage());
+ }
+
+ try
+ {
+ ProjectTestOperations.closeAllProjects(bot);
+ }
+ catch (Exception e)
+ {
+ System.err.println("closeAllProjects failed: " + e.getMessage());
+ }
+
+ try
+ {
+ ProjectTestOperations.cancelJobsThatBlockWorkbenchIdle();
+ ProjectTestOperations.deleteAllProjects(bot);
+ }
+ catch (Exception e)
+ {
+ System.err.println("UI deleteAllProjects failed (expected if jobs never idle): " + e.getMessage());
+ ProjectTestOperations.deleteAllProjectsViaWorkspaceApi();
+ }
+
+ ProjectTestOperations.openCCppPerspective(bot);
+ ProjectTestOperations.killDebugProcesses(projectName);
+ }
+
+ private static String getNewOutputPart(String outputBeforeSelection, String outputAfterSelection)
+ {
+ if (outputAfterSelection == null)
+ {
+ return "";
+ }
+ if (outputBeforeSelection == null || outputBeforeSelection.isEmpty())
+ {
+ return outputAfterSelection;
+ }
+ if (outputAfterSelection.startsWith(outputBeforeSelection))
+ {
+ return outputAfterSelection.substring(outputBeforeSelection.length());
+ }
+ return outputAfterSelection;
+ }
+
+ private static String readTargetDetectionOutput()
+ {
+ try
+ {
+ return bot.styledText().getText();
+ }
+ catch (Exception ignored)
+ {
+ }
+
+ String bestCandidate = "";
+ for (int i = 0; i < 10; i++)
+ {
+ try
+ {
+ String text = bot.text(i).getText();
+ if (text != null && containsChipInfo(text))
+ {
+ return text;
+ }
+ if (text != null && text.length() > bestCandidate.length())
+ {
+ bestCandidate = text;
+ }
+ }
+ catch (Exception ignored)
+ {
+ break;
+ }
+ }
+ return bestCandidate == null ? "" : bestCandidate;
+ }
+
+ private static boolean containsChipInfo(String text)
+ {
+ return text != null && (text.contains("Connected to ESP32") || text.contains("Chip type:")
+ || text.contains("Detecting chip type"));
+ }
+
+ private static String extractTargetFromDetectionOutput(String output)
+ {
+ if (output == null || output.trim().isEmpty())
+ {
+ return null;
+ }
+ for (Pattern pattern : TARGET_DETECTION_PATTERNS)
+ {
+ Matcher matcher = pattern.matcher(output);
+ if (matcher.find())
+ {
+ return normalizeDetectedChipToIdfTarget(matcher.group(1));
+ }
+ }
+ return null;
+ }
+
+ private static String normalizeDetectedChipToIdfTarget(String chipName)
+ {
+ if (chipName == null)
+ {
+ return null;
+ }
+ String chip = chipName.trim().toUpperCase(Locale.ROOT);
+ if (chip.startsWith("ESP32-C61"))
+ {
+ return "esp32c61";
+ }
+ if (chip.startsWith("ESP32-C6"))
+ {
+ return "esp32c6";
+ }
+ if (chip.startsWith("ESP32-C5"))
+ {
+ return "esp32c5";
+ }
+ if (chip.startsWith("ESP32-H2"))
+ {
+ return "esp32h2";
+ }
+ if (chip.startsWith("ESP32-S3"))
+ {
+ return "esp32s3";
+ }
+ if (chip.startsWith("ESP32-S2"))
+ {
+ return "esp32s2";
+ }
+ if (chip.startsWith("ESP32"))
+ {
+ return "esp32";
+ }
+ return null;
+ }
+ }
+}
diff --git a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectJTAGFlashTest.java b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectJTAGFlashTest.java
index d6a98cf22..ef8d7696c 100644
--- a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectJTAGFlashTest.java
+++ b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectJTAGFlashTest.java
@@ -112,7 +112,8 @@ private static void whenProjectIsBuiltUsingContextMenu() throws IOException
private static void whenFlashProject() throws IOException
{
- ProjectTestOperations.launchCommandUsingContextMenu(projectName, bot, "Run Configurations...");
+ ProjectTestOperations.launchCommandUsingContextMenuWithoutWaitingForJobs(projectName, bot,
+ "Run Configurations...");
TestWidgetWaitUtility.waitForDialogToAppear(bot, "Run Configurations", 10000);
bot.tree().getTreeItem("ESP-IDF Application").select();
bot.tree().getTreeItem("ESP-IDF Application").expand();
diff --git a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/operations/ProjectTestOperations.java b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/operations/ProjectTestOperations.java
index e3a7e7e85..a9b8207ed 100644
--- a/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/operations/ProjectTestOperations.java
+++ b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/operations/ProjectTestOperations.java
@@ -6,17 +6,34 @@
import java.io.File;
import java.io.IOException;
+import java.lang.reflect.Method;
import java.text.MessageFormat;
import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
+import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.jobs.Job;
+
+import org.eclipse.debug.core.DebugException;
+import org.eclipse.debug.core.ILaunch;
+import org.eclipse.debug.core.ILaunchConfiguration;
+import org.eclipse.debug.core.ILaunchManager;
+import org.eclipse.debug.core.model.IDebugTarget;
+import org.eclipse.debug.core.model.IProcess;
+import org.eclipse.debug.core.model.IStackFrame;
+import org.eclipse.debug.core.model.IThread;
+import org.eclipse.debug.core.model.RuntimeProcess;
import org.eclipse.debug.core.DebugPlugin;
import org.eclipse.debug.core.ILaunchMode;
import org.eclipse.launchbar.core.ILaunchBarManager;
@@ -25,7 +42,10 @@
import org.eclipse.swtbot.eclipse.finder.widgets.SWTBotEditor;
import org.eclipse.swtbot.eclipse.finder.widgets.SWTBotView;
import org.eclipse.swtbot.swt.finder.exceptions.WidgetNotFoundException;
+import org.eclipse.swtbot.swt.finder.finders.UIThreadRunnable;
import org.eclipse.swtbot.swt.finder.matchers.WidgetMatcherFactory;
+import org.eclipse.swtbot.swt.finder.results.VoidResult;
+import org.eclipse.swtbot.swt.finder.waits.Conditions;
import org.eclipse.swtbot.swt.finder.waits.DefaultCondition;
import org.eclipse.swtbot.swt.finder.widgets.SWTBotButton;
import org.eclipse.swtbot.swt.finder.widgets.SWTBotCheckBox;
@@ -37,6 +57,11 @@
import org.eclipse.swtbot.swt.finder.widgets.SWTBotTree;
import org.eclipse.swtbot.swt.finder.widgets.SWTBotTreeItem;
import org.eclipse.ui.IPageLayout;
+import org.eclipse.ui.IPerspectiveDescriptor;
+import org.eclipse.ui.IWorkbench;
+import org.eclipse.ui.IWorkbenchPage;
+import org.eclipse.ui.IWorkbenchWindow;
+import org.eclipse.ui.PlatformUI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -59,8 +84,23 @@ public class ProjectTestOperations
private static final String DEFAULT_FLASH_WAIT_PROPERTY = "default.project.flash.wait";
+ private static final String CDT_PERSPECTIVE_ID = "org.eclipse.cdt.ui.CPerspective";
+
+ private static final String DEBUG_PERSPECTIVE_ID = "org.eclipse.debug.ui.DebugPerspective";
+
+ private static final String STEP_OVER_TOOLTIP = "Step &Over (F6)";
+
+ private static final String[] STEP_OVER_CONTEXT_MENU_LABELS = { "Step Over (F6)", "Step Over", "Step &Over (F6)",
+ "Step &Over" };
+
private static final Logger logger = LoggerFactory.getLogger(ProjectTestOperations.class);
+ /** OpenOCD and GDB PIDs captured while Eclipse still owns the debug launch. */
+ private static final Set rememberedDebugProcessIds = new LinkedHashSet<>();
+
+ /** Last debug-test action, included in the one-line failure the runner summary prints. */
+ private static volatile String debugCheckpoint = "";
+
private static final int DELETE_PROJECT_TIMEOUT = 240000;
/**
@@ -876,4 +916,1470 @@ public static void joinJobByName(String jobName)
}
}
}
+
+ public static void noteDebugCheckpoint(String checkpoint)
+ {
+ debugCheckpoint = checkpoint == null ? "" : checkpoint;
+ System.out.println("[IDFProjectDebugProcessTest] checkpoint " + debugCheckpoint);
+ }
+
+ public static String debugCheckpoint()
+ {
+ return debugCheckpoint == null ? "" : debugCheckpoint;
+ }
+
+ public static void startDebuggingUsingContextMenu(String projectName, SWTWorkbenchBot bot)
+ {
+ SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot);
+ if (projectItem == null)
+ {
+ throw new WidgetNotFoundException("Project not found in Project Explorer: " + projectName);
+ }
+
+ noteDebugCheckpoint("open Debug Configurations");
+ projectItem.select();
+ projectItem.contextMenu("Debug As").menu("Debug Configurations...").click();
+
+ TestWidgetWaitUtility.waitForDialogToAppear(bot, "Debug Configurations", 10000);
+
+ noteDebugCheckpoint("select ESP-IDF GDB OpenOCD debug configuration");
+ selectDebugLaunchConfiguration(bot, projectName);
+
+ bot.waitUntil(Conditions.widgetIsEnabled(bot.button("Debug")), 5000);
+ noteDebugCheckpoint("click Debug");
+ bot.button("Debug").click();
+ // Do not switch perspective here. The Confirm Perspective Switch dialog appears while GDB
+ // is still printing, and accepting it cuts the console before
+ // "hit Temporary breakpoint ... app_main". waitForDebugSessionStarted accepts it afterwards.
+ }
+
+ /**
+ * Selects the project's debug launch. Older IDF builds keep it under ESP-IDF GDB OpenOCD Debugging.
+ * After launch and debug configurations were unified, the same project config lives under ESP-IDF
+ * Application and is launched in debug mode.
+ */
+ private static void selectDebugLaunchConfiguration(SWTWorkbenchBot bot, String projectName)
+ {
+ String[] configNames = { projectName + " Debug", projectName + " Configuration", projectName };
+ if (selectTreeConfig(bot, "ESP-IDF GDB OpenOCD Debugging", configNames))
+ {
+ noteDebugCheckpoint("selected OpenOCD debug configuration");
+ return;
+ }
+ if (selectTreeConfig(bot, "ESP-IDF Application", new String[] { projectName }))
+ {
+ noteDebugCheckpoint("selected ESP-IDF Application configuration for debug");
+ return;
+ }
+ throw new AssertionError("No debug launch configuration for " + projectName
+ + ". Tree: " + visibleLaunchTreeItems(bot));
+ }
+
+ private static boolean selectTreeConfig(SWTWorkbenchBot bot, String launchType, String[] configNames)
+ {
+ SWTBotTreeItem typeItem;
+ try
+ {
+ typeItem = bot.tree().getTreeItem(launchType);
+ }
+ catch (WidgetNotFoundException e)
+ {
+ return false;
+ }
+ typeItem.select();
+ typeItem.expand();
+ List nodes = typeItem.getNodes();
+ for (String configName : configNames)
+ {
+ for (String node : nodes)
+ {
+ if (node.equals(configName))
+ {
+ typeItem.getNode(configName).select();
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static String visibleLaunchTreeItems(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ StringBuilder items = new StringBuilder();
+ for (SWTBotTreeItem item : bot.tree().getAllItems())
+ {
+ if (items.length() > 0)
+ {
+ items.append(", ");
+ }
+ items.append(item.getText());
+ item.expand();
+ for (String child : item.getNodes())
+ {
+ items.append(" / ").append(child);
+ }
+ }
+ return items.toString();
+ }
+ catch (RuntimeException e)
+ {
+ return "";
+ }
+ }
+
+ /**
+ * Accepts the Eclipse "Confirm Perspective Switch" dialog when it appears after the debug
+ * session suspends. Does not check "Remember my decision" so later UI tests are not affected.
+ *
+ * @param bot current SWT bot reference
+ * @param timeout how long to wait for the dialog in milliseconds
+ * @return {@code true} if the dialog was found and dismissed
+ */
+ public static boolean acceptDebugPerspectiveSwitchIfPresent(SWTWorkbenchBot bot, long timeout)
+ {
+ try
+ {
+ TestWidgetWaitUtility.waitForDialogToAppear(bot, "Confirm Perspective Switch", timeout);
+ SWTBotShell shell = bot.shell("Confirm Perspective Switch");
+ shell.activate();
+ shell.setFocus();
+
+ try
+ {
+ SWTBotCheckBox remember = shell.bot().checkBox("Remember my decision");
+ // Do not persist the decision — it poisons later UI tests in the same workbench.
+ if (remember.isChecked())
+ {
+ remember.click();
+ }
+ }
+ catch (WidgetNotFoundException ignored)
+ {
+ }
+
+ try
+ {
+ shell.bot().button("Switch").click();
+ }
+ catch (WidgetNotFoundException e)
+ {
+ shell.bot().button("Yes").click();
+ }
+
+ // Give the Debug perspective time to finish opening before further toolbar clicks.
+ bot.sleep(2000);
+ return true;
+ }
+ catch (Exception ignored)
+ {
+ // Perspective switch may already be remembered / suppressed.
+ return false;
+ }
+ }
+
+ /**
+ * @see #acceptDebugPerspectiveSwitchIfPresent(SWTWorkbenchBot, long)
+ */
+ public static void acceptDebugPerspectiveSwitchIfPresent(SWTWorkbenchBot bot)
+ {
+ acceptDebugPerspectiveSwitchIfPresent(bot, 30000);
+ }
+
+ /**
+ * Waits until GDB has suspended at {@code app_main} (not merely OpenOCD "Target halted" during
+ * reset) and opens the Debug perspective. Does not require the Step Over toolbar button —
+ * that control is often missing/unreliable under SWTBot even when the session is suspended.
+ *
+ * Prefers the Eclipse debug model over console-page switching. Repeatedly opening
+ * "Display Selected Console" leaves the dropdown open and stalls the UI under SWTBot.
+ *
+ * @param bot current SWT bot reference
+ * @throws IOException if property lookup fails
+ */
+ public static void waitForDebugSessionStarted(SWTWorkbenchBot bot) throws IOException
+ {
+ // Prefer a bounded debug timeout; the shared flash wait property is often hours-long.
+ long timeout = Math.min(
+ DefaultPropertyFetcher.getLongPropertyValue(DEFAULT_FLASH_WAIT_PROPERTY, 120000),
+ 180000);
+ long deadline = System.currentTimeMillis() + timeout;
+ long nextHeartbeat = System.currentTimeMillis();
+ boolean perspectiveHandled = false;
+
+ while (System.currentTimeMillis() < deadline)
+ {
+ // Read without show/setFocus, and do not accept the perspective dialog yet.
+ // Both steal focus while GDB is printing and leave the log at "[Switching to Thread".
+ rememberDebugProcessIds(null);
+ String consoleText = readConsoleTextQuietly(bot);
+ if (System.currentTimeMillis() >= nextHeartbeat)
+ {
+ noteDebugCheckpoint("waiting for app_main breakpoint; " + consoleTail(consoleText));
+ nextHeartbeat = System.currentTimeMillis() + 15000;
+ }
+
+ if (consoleText.contains("shutdown command invoked")
+ || consoleText.contains("dropped 'gdb' connection"))
+ {
+ throw new AssertionError(
+ "Debug session shut down before a breakpoint suspend was observed.\nConsole:\n"
+ + consoleText);
+ }
+
+ if (isSuspendedAtBreakpoint(consoleText))
+ {
+ noteDebugCheckpoint("suspend recognized: " + describeSuspendSignal(consoleText));
+ if (!perspectiveHandled)
+ {
+ perspectiveHandled = acceptDebugPerspectiveSwitchIfPresent(bot, 30000);
+ }
+ openDebugPerspective(bot);
+ if (!hasActiveLaunch())
+ {
+ throw new AssertionError(
+ "Debug launch terminated immediately after suspend at app_main.\nConsole:\n"
+ + consoleText);
+ }
+ return;
+ }
+
+ bot.sleep(1000);
+ }
+
+ String lastConsole = readVisibleConsoleText(bot);
+ throw new AssertionError(
+ "Debug session did not suspend at app_main within timeout (debug model or visible console).\nLast console text:\n"
+ + lastConsole);
+ }
+
+ /**
+ * Reads text from the Console view page that is currently visible (no console-page switching).
+ * Switching via "Display Selected Console" leaves a sticky dropdown open under SWTBot and
+ * stalls the debug wait loop.
+ */
+ public static String readVisibleConsoleText(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ SWTBotView view = bot.viewByPartName("Console");
+ view.show();
+ view.setFocus();
+ return textOf(view);
+ }
+ catch (Exception e)
+ {
+ logger.debug("Could not read visible Console view: {}", e.getMessage());
+ return "";
+ }
+ }
+
+ /**
+ * Reads the Console view without activating it. Used while GDB is still printing the suspend line.
+ */
+ private static String readConsoleTextQuietly(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ return textOf(bot.viewByPartName("Console"));
+ }
+ catch (Exception e)
+ {
+ logger.debug("Could not read Console view quietly: {}", e.getMessage());
+ return "";
+ }
+ }
+
+ private static String textOf(SWTBotView view)
+ {
+ String text = view.bot().styledText().getText();
+ return text != null ? text : "";
+ }
+
+ /**
+ * Reads console text used for debug assertions from the currently visible Console page only.
+ */
+ public static String readDebugRelatedConsoleText(SWTWorkbenchBot bot)
+ {
+ return readVisibleConsoleText(bot);
+ }
+
+ private static boolean isSuspendedAtBreakpoint(String consoleText)
+ {
+ // Do not stop on "[Switching to Thread" or debug_reason=1. Those lines are printed
+ // before "hit Temporary breakpoint ... app_main", and leaving the wait there interrupts startup.
+ return isSuspendedAtBreakpointInConsole(consoleText);
+ }
+
+ private static boolean isSuspendedAtBreakpointInConsole(String consoleText)
+ {
+ if (consoleText == null || consoleText.isEmpty())
+ {
+ return false;
+ }
+ return consoleText.contains("hit Temporary breakpoint") || consoleText.contains("hit Breakpoint")
+ || consoleText.contains("hit breakpoint")
+ || (consoleText.contains("Temporary breakpoint") && consoleText.contains("app_main"));
+ }
+
+ private static String describeSuspendSignal(String consoleText)
+ {
+ if (consoleText != null && consoleText.contains("hit Temporary breakpoint"))
+ {
+ return "console contains 'hit Temporary breakpoint ... app_main'";
+ }
+ return "console contains the app_main breakpoint line";
+ }
+
+ private static String consoleTail(String consoleText)
+ {
+ if (consoleText == null || consoleText.isEmpty())
+ {
+ return "console=";
+ }
+ String[] lines = consoleText.split("\\R");
+ String last = lines[lines.length - 1].trim();
+ if (last.length() > 160)
+ {
+ last = last.substring(last.length() - 160);
+ }
+ return "console=\"" + last.replace('\t', ' ') + "\"";
+ }
+
+ /**
+ * True when an active debug target has a thread whose stack includes {@code app_main}.
+ * Prefer this over OpenOCD console text — GDB often suspends in the UI without printing
+ * {@code hit Temporary breakpoint} on the IDF Process Console.
+ *
+ * FreeRTOS-aware GDB often reports the main thread as {@code Running} even while the CPU is
+ * halted at {@code app_main}; do not require {@link IThread#isSuspended()}.
+ */
+ public static boolean isSuspendedAtAppMainInDebugModel()
+ {
+ return findThreadWithAppMainFrame() != null;
+ }
+
+ /**
+ * Performs Step Over from the Debug UI: the workbench toolbar button
+ * {@code Step &Over (F6)}, or right-click on the suspended thread in the Debug view.
+ *
+ * @param projectName unused; kept for call-site compatibility
+ * @param bot current SWT bot reference
+ */
+ public static void performDebugStepOver(String projectName, SWTWorkbenchBot bot)
+ {
+ noteDebugCheckpoint("Step Over: open Debug perspective");
+ acceptDebugPerspectiveSwitchIfPresent(bot, 2000);
+ openDebugPerspective(bot);
+ // Let Debug perspective / toolbar finish loading before probing Step Over controls.
+ bot.sleep(3000);
+
+ if (!hasActiveLaunch())
+ {
+ throw new AssertionError("Cannot Step Over — no active debug launch");
+ }
+
+ final SWTWorkbenchBot workbenchBot = bot;
+ noteDebugCheckpoint("Step Over: wait until the toolbar or Debug view thread is ready");
+ try
+ {
+ workbenchBot.waitUntil(new DefaultCondition()
+ {
+ @Override
+ public boolean test() throws Exception
+ {
+ if (!hasActiveLaunch())
+ {
+ throw new AssertionError(
+ "Debug launch terminated before Step Over became available (OpenOCD/GDB already stopped)");
+ }
+ acceptDebugPerspectiveSwitchIfPresent(workbenchBot, 200);
+ return isStepOverToolbarPresent(workbenchBot)
+ || findDebugThreadItem(workbenchBot) != null
+ || findThreadWithAppMainFrame() != null;
+ }
+
+ @Override
+ public String getFailureMessage()
+ {
+ return "Debug Step Over not ready within timeout — Step Over toolbar not found "
+ + "and no thread in the Debug view. Launch active=" + hasActiveLaunch()
+ + ", stepOverToolbar=" + isStepOverToolbarPresent(workbenchBot)
+ + ", debugThread=" + (findDebugThreadItem(workbenchBot) != null);
+ }
+ }, 60000, 500);
+ }
+ catch (AssertionError e)
+ {
+ // Ensure CI always shows a non-empty reason (some runners truncate blank AssertionError).
+ String detail = e.getMessage();
+ if (detail == null || detail.trim().isEmpty())
+ {
+ throw new AssertionError(
+ "Debug Step Over not ready within timeout (empty wait failure). Launch active="
+ + hasActiveLaunch(),
+ e);
+ }
+ throw e;
+ }
+
+ bot.sleep(1500);
+
+ noteDebugCheckpoint("Step Over: click toolbar button");
+ if (clickToolbarStepOver(bot))
+ {
+ noteDebugCheckpoint("Step Over: toolbar click done");
+ bot.sleep(3000);
+ return;
+ }
+ noteDebugCheckpoint("Step Over: click Debug view context menu");
+ if (clickDebugViewStepOver(bot))
+ {
+ noteDebugCheckpoint("Step Over: context menu click done");
+ bot.sleep(3000);
+ return;
+ }
+
+ throw new AssertionError(
+ "Failed to perform Step Over (toolbar button 'Step &Over (F6)' and Debug view "
+ + "thread context menu both failed). Launch active=" + hasActiveLaunch()
+ + ", stepOverToolbar=" + isStepOverToolbarPresent(bot)
+ + ", debugThread=" + (findDebugThreadItem(bot) != null));
+ }
+
+ private static IThread findThreadWithAppMainFrame()
+ {
+ ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager();
+ ILaunch[] launches = launchManager.getLaunches();
+ if (launches == null)
+ {
+ return null;
+ }
+
+ for (ILaunch launch : launches)
+ {
+ if (launch == null || launch.isTerminated())
+ {
+ continue;
+ }
+ IDebugTarget[] targets = launch.getDebugTargets();
+ if (targets == null)
+ {
+ continue;
+ }
+ for (IDebugTarget target : targets)
+ {
+ if (target == null || target.isTerminated())
+ {
+ continue;
+ }
+ try
+ {
+ if (!target.hasThreads())
+ {
+ continue;
+ }
+ for (IThread thread : target.getThreads())
+ {
+ if (thread == null || thread.isTerminated())
+ {
+ continue;
+ }
+ // FreeRTOS GDB often labels the halted main thread as Running, and
+ // hasStackFrames() is then false even though the UI shows app_main.
+ IStackFrame[] frames;
+ try
+ {
+ frames = thread.getStackFrames();
+ }
+ catch (DebugException e)
+ {
+ logger.debug("findThreadWithAppMainFrame: {}", e.getMessage());
+ continue;
+ }
+ if (frames == null)
+ {
+ continue;
+ }
+ for (IStackFrame frame : frames)
+ {
+ if (frame == null)
+ {
+ continue;
+ }
+ String name = frame.getName();
+ if (name != null && name.toLowerCase(Locale.ENGLISH).contains("app_main"))
+ {
+ return thread;
+ }
+ }
+ }
+ }
+ catch (DebugException e)
+ {
+ logger.debug("findThreadWithAppMainFrame: {}", e.getMessage());
+ }
+ }
+ }
+ return null;
+ }
+
+ private static boolean isStepOverToolbarPresent(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ bot.toolbarButtonWithTooltip(STEP_OVER_TOOLTIP);
+ return true;
+ }
+ catch (WidgetNotFoundException e)
+ {
+ return false;
+ }
+ }
+
+ private static boolean clickToolbarStepOver(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ bot.toolbarButtonWithTooltip(STEP_OVER_TOOLTIP).click();
+ return true;
+ }
+ catch (WidgetNotFoundException e)
+ {
+ logger.debug("Step Over toolbar click failed: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Right-clicks the suspended thread in the Debug view and selects {@code Step Over (F6)}.
+ */
+ private static boolean clickDebugViewStepOver(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ SWTBotView debugView = findDebugView(bot);
+ if (debugView == null)
+ {
+ return false;
+ }
+ debugView.show();
+ debugView.setFocus();
+ bot.sleep(1000);
+ SWTBotTreeItem thread = findDebugThreadItem(bot);
+ if (thread == null)
+ {
+ return false;
+ }
+ thread.select();
+ bot.sleep(500);
+ for (String label : STEP_OVER_CONTEXT_MENU_LABELS)
+ {
+ try
+ {
+ thread.contextMenu(label).click();
+ return true;
+ }
+ catch (WidgetNotFoundException ignored)
+ {
+ }
+ }
+ return false;
+ }
+ catch (Exception e)
+ {
+ logger.debug("Debug view Step Over failed: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ private static SWTBotView findDebugView(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ for (SWTBotView view : bot.views())
+ {
+ if (view != null && "Debug".equals(view.getTitle()))
+ {
+ return view;
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ logger.debug("findDebugView: {}", e.getMessage());
+ }
+ return null;
+ }
+
+ private static SWTBotTreeItem findDebugThreadItem(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ SWTBotView debugView = findDebugView(bot);
+ if (debugView == null)
+ {
+ return null;
+ }
+ debugView.show();
+ SWTBotTree tree = debugView.bot().tree();
+ SWTBotTreeItem[] roots = tree.getAllItems();
+ SWTBotTreeItem mainThread = findThreadItem(roots, true);
+ if (mainThread != null)
+ {
+ return mainThread;
+ }
+ return findThreadItem(roots, false);
+ }
+ catch (Exception e)
+ {
+ logger.debug("findDebugThreadItem: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private static SWTBotTreeItem findThreadItem(SWTBotTreeItem[] items, boolean mainOnly)
+ {
+ if (items == null)
+ {
+ return null;
+ }
+ for (SWTBotTreeItem item : items)
+ {
+ if (item == null)
+ {
+ continue;
+ }
+ String label = item.getText();
+ if (label != null)
+ {
+ String lower = label.toLowerCase(Locale.ENGLISH);
+ boolean isThread = lower.contains("thread #") || lower.startsWith("thread ");
+ boolean isMain = lower.contains("[main]") || lower.contains("name: main");
+ if (isThread && (!mainOnly || isMain))
+ {
+ return item;
+ }
+ }
+ try
+ {
+ item.expand();
+ }
+ catch (Exception ignored)
+ {
+ }
+ SWTBotTreeItem nested = findThreadItem(item.getItems(), mainOnly);
+ if (nested != null)
+ {
+ return nested;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Opens the Eclipse Debug perspective via Platform UI API (no menus/dialogs).
+ *
+ * @param bot current SWT bot reference (may be {@code null})
+ */
+ public static void openDebugPerspective(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ UIThreadRunnable.syncExec(new VoidResult()
+ {
+ @Override
+ public void run()
+ {
+ IWorkbench workbench = PlatformUI.getWorkbench();
+ IWorkbenchWindow window = workbench.getActiveWorkbenchWindow();
+ if (window == null || window.getActivePage() == null)
+ {
+ return;
+ }
+ IPerspectiveDescriptor descriptor = workbench.getPerspectiveRegistry()
+ .findPerspectiveWithId(DEBUG_PERSPECTIVE_ID);
+ if (descriptor != null)
+ {
+ window.getActivePage().setPerspective(descriptor);
+ }
+ }
+ });
+ if (bot != null)
+ {
+ bot.sleep(1000);
+ }
+ }
+ catch (Exception e)
+ {
+ logger.warn("Failed to open Debug perspective", e);
+ }
+ }
+
+ /**
+ * Returns {@code true} if an active (non-terminated) Eclipse launch still exists.
+ */
+ public static boolean hasActiveLaunch()
+ {
+ ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager();
+ ILaunch[] launches = launchManager.getLaunches();
+ if (launches == null)
+ {
+ return false;
+ }
+ for (ILaunch launch : launches)
+ {
+ if (launch != null && !launch.isTerminated())
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Stops the active launch / debug session using the Launch Bar Stop button.
+ *
+ * @param bot current SWT bot reference
+ */
+ public static void stopLaunchUsingLaunchBar(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ bot.toolbarButtonWithTooltip("Stop").click();
+ bot.sleep(2000);
+ }
+ catch (WidgetNotFoundException e)
+ {
+ logger.warn("Stop button not found while trying to stop launch/debug session");
+ }
+ }
+
+ /**
+ * Leaves the Debug UI before shared project cleanup: terminate OpenOCD/GDB, close editors,
+ * switch to C/C++, close Debug-related views, and cancel background jobs that would otherwise
+ * keep {@link WaitUtils#waitForJobs()} from returning (Language Server / indexer), which
+ * poisons later UI tests' {@code deleteAllProjects}.
+ *
+ * @param bot current SWT bot reference
+ */
+ public static void leaveDebugUi(SWTWorkbenchBot bot)
+ {
+ leaveDebugUi(bot, null);
+ }
+
+ /**
+ * @see #leaveDebugUi(SWTWorkbenchBot)
+ * @param projectName debug project whose OpenOCD/GDB processes may be stopped; {@code null} limits
+ * cleanup to debug launches still registered in this workbench
+ */
+ public static void leaveDebugUi(SWTWorkbenchBot bot, String projectName)
+ {
+ stopDebugSessionAndKillProcesses(bot, projectName);
+ closeAllEditorsViaApi();
+ openCCppPerspective(bot);
+ closeDebugRelatedViews(bot);
+ cancelJobsThatBlockWorkbenchIdle();
+ if (bot != null)
+ {
+ try
+ {
+ closeSecondaryShells(bot);
+ focusMainWindow(bot.shells());
+ }
+ catch (Exception e)
+ {
+ logger.warn("leaveDebugUi: could not focus main window", e);
+ }
+ }
+ }
+
+ /**
+ * Cancels long-running CDT/LSP/refresh jobs that prevent {@code Job.getJobManager().isIdle()}
+ * after a hardware debug session. Safe to call from {@code @AfterClass}.
+ */
+ public static void cancelJobsThatBlockWorkbenchIdle()
+ {
+ Job[] jobs = Job.getJobManager().find(null);
+ if (jobs == null)
+ {
+ return;
+ }
+
+ for (Job job : jobs)
+ {
+ if (job == null || job.getState() == Job.NONE)
+ {
+ continue;
+ }
+
+ String name = job.getName();
+ if (name == null)
+ {
+ continue;
+ }
+
+ String lower = name.toLowerCase(Locale.ENGLISH);
+ if (lower.contains("language server") || lower.contains("clangd") || lower.contains("indexer")
+ || lower.contains("c/c++") || lower.contains("cdt ") || lower.contains("reconcil")
+ || lower.contains("refresh") || lower.contains("building workspace")
+ || lower.contains("updating") || lower.contains("decorate")
+ || lower.contains("openocd") || lower.contains("gdb"))
+ {
+ logger.info("Cancelling job that may block workbench idle: {}", name);
+ job.cancel();
+ }
+ }
+
+ try
+ {
+ Thread.sleep(2000);
+ }
+ catch (InterruptedException e)
+ {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * Best-effort close of views typically opened by the Debug perspective.
+ *
+ * @param bot current SWT bot reference
+ */
+ public static void closeDebugRelatedViews(SWTWorkbenchBot bot)
+ {
+ if (bot == null)
+ {
+ return;
+ }
+
+ String[] viewTitles = new String[] { "Debug", "Breakpoints", "Variables", "Expressions",
+ "Registers", "Memory", "Disassembly", "Modules", "Signals", "Executables" };
+
+ for (String title : viewTitles)
+ {
+ try
+ {
+ SWTBotView view = bot.viewByTitle(title);
+ view.close();
+ }
+ catch (Exception ignored)
+ {
+ }
+ }
+ }
+
+ /**
+ * Best-effort cleanup of an active debug session via the debug API and process kill.
+ * Avoids clicking Launch Bar Stop / Debug Terminate toolbars — those tooltips are ambiguous
+ * under SWTBot and can race with an active session during perspective changes.
+ *
+ * @param bot current SWT bot reference (may be {@code null} if UI is unavailable)
+ */
+ public static void stopDebugSessionAndKillProcesses(SWTWorkbenchBot bot)
+ {
+ stopDebugSessionAndKillProcesses(bot, null);
+ }
+
+ /**
+ * @see #stopDebugSessionAndKillProcesses(SWTWorkbenchBot)
+ * @param projectName project whose debug launch should be stopped
+ */
+ public static void stopDebugSessionAndKillProcesses(SWTWorkbenchBot bot, String projectName)
+ {
+ // Capture PIDs before terminate. Eclipse may drop the Process handle once the launch ends.
+ killDebugProcesses(projectName);
+ }
+
+ /**
+ * Switches the workbench back to the C/C++ perspective via the Platform UI API.
+ * Avoids Window → Perspective menus / "Open Perspective" dialogs, which can leave a
+ * modal shell open under SWTBot and block {@code @AfterClass} cleanup.
+ *
+ * @param bot current SWT bot reference (unused; kept for call-site consistency)
+ */
+ public static void openCCppPerspective(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ UIThreadRunnable.syncExec(new VoidResult()
+ {
+ @Override
+ public void run()
+ {
+ IWorkbench workbench = PlatformUI.getWorkbench();
+ IWorkbenchWindow window = workbench.getActiveWorkbenchWindow();
+ if (window == null)
+ {
+ return;
+ }
+ IWorkbenchPage page = window.getActivePage();
+ if (page == null)
+ {
+ return;
+ }
+ IPerspectiveDescriptor descriptor = workbench.getPerspectiveRegistry()
+ .findPerspectiveWithId(CDT_PERSPECTIVE_ID);
+ if (descriptor == null)
+ {
+ logger.warn("C/C++ perspective id not found: {}", CDT_PERSPECTIVE_ID);
+ return;
+ }
+ IPerspectiveDescriptor current = page.getPerspective();
+ if (current != null && DEBUG_PERSPECTIVE_ID.equals(current.getId()))
+ {
+ page.closePerspective(current, false, false);
+ }
+ page.setPerspective(descriptor);
+ }
+ });
+ if (bot != null)
+ {
+ closeSecondaryShells(bot);
+ focusMainWindow(bot.shells());
+ }
+ }
+ catch (Exception e)
+ {
+ logger.warn("Failed to switch back to C/C++ perspective", e);
+ }
+ }
+
+ /**
+ * Force-cleans workbench state after a debug test (including timeout/failure).
+ * Stops debug processes, returns to C/C++, then deletes projects. Project deletion
+ * runs on the calling thread (not the UI thread) — {@code syncExec} +
+ * {@code IProject.delete} can deadlock / silently no-op under SWTBot.
+ *
+ * @param bot current SWT bot reference (may be {@code null})
+ */
+ public static void forceCleanWorkbenchAfterDebugTest(SWTWorkbenchBot bot)
+ {
+ try
+ {
+ killDebugProcesses(null);
+ }
+ catch (Exception e)
+ {
+ logger.warn("forceClean: killDebugProcesses failed", e);
+ }
+
+ try
+ {
+ openCCppPerspective(bot);
+ }
+ catch (Exception e)
+ {
+ logger.warn("forceClean: openCCppPerspective failed", e);
+ }
+
+ try
+ {
+ if (bot != null)
+ {
+ closeSecondaryShells(bot);
+ focusMainWindow(bot.shells());
+ }
+ }
+ catch (Exception e)
+ {
+ logger.warn("forceClean: closeSecondaryShells failed", e);
+ }
+
+ try
+ {
+ closeAllEditorsViaApi();
+ }
+ catch (Exception e)
+ {
+ logger.warn("forceClean: closeAllEditorsViaApi failed", e);
+ }
+
+ try
+ {
+ deleteAllProjectsViaWorkspaceApi();
+ }
+ catch (Exception e)
+ {
+ logger.warn("forceClean: deleteAllProjectsViaWorkspaceApi failed", e);
+ }
+
+ // Fallback used by every other UI test — UI delete if workspace API left anything.
+ if (bot != null && workspaceHasProjects())
+ {
+ try
+ {
+ logger.warn("forceClean: projects still present after workspace API delete; falling back to UI delete");
+ closeAllProjects(bot);
+ deleteAllProjects(bot);
+ }
+ catch (Exception e)
+ {
+ logger.warn("forceClean: UI project cleanup failed", e);
+ }
+ }
+
+ killDebugProcesses(null);
+ }
+
+ /**
+ * Closes all open editors without prompting (UI-thread API).
+ */
+ public static void closeAllEditorsViaApi()
+ {
+ UIThreadRunnable.syncExec(new VoidResult()
+ {
+ @Override
+ public void run()
+ {
+ IWorkbenchWindow window = PlatformUI.getWorkbench().getActiveWorkbenchWindow();
+ if (window == null || window.getActivePage() == null)
+ {
+ return;
+ }
+ window.getActivePage().closeAllEditors(false);
+ }
+ });
+ }
+
+ /**
+ * Deletes every workspace project via the resources API on the calling thread
+ * (no UI {@code syncExec}). Safe for {@code @AfterClass} cleanup.
+ */
+ public static void deleteAllProjectsViaWorkspaceApi()
+ {
+ IProject[] projects = ResourcesPlugin.getWorkspace().getRoot().getProjects();
+ if (projects == null)
+ {
+ return;
+ }
+
+ for (IProject project : projects)
+ {
+ if (project == null || !project.exists())
+ {
+ continue;
+ }
+
+ String name = project.getName();
+ try
+ {
+ if (project.isOpen())
+ {
+ project.close(null);
+ }
+ }
+ catch (CoreException e)
+ {
+ logger.warn("Could not close project {}: {}", name, e.getMessage());
+ }
+
+ try
+ {
+ if (project.exists())
+ {
+ project.delete(IResource.ALWAYS_DELETE_PROJECT_CONTENT | IResource.FORCE, null);
+ logger.info("Deleted workspace project {}", name);
+ }
+ }
+ catch (CoreException e)
+ {
+ logger.warn("Could not delete project {}: {}", name, e.getMessage());
+ }
+ }
+ }
+
+ private static boolean workspaceHasProjects()
+ {
+ IProject[] projects = ResourcesPlugin.getWorkspace().getRoot().getProjects();
+ if (projects == null)
+ {
+ return false;
+ }
+ for (IProject project : projects)
+ {
+ if (project != null && project.exists())
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Terminates debug launches that belong to {@code projectName}. When {@code projectName} is
+ * {@code null}, only launches in this workbench that still own an OpenOCD or ESP GDB process
+ * are terminated.
+ */
+ public static void terminateDebugLaunches(String projectName)
+ {
+ for (ILaunch launch : debugLaunches(projectName))
+ {
+ if (launch.isTerminated())
+ {
+ continue;
+ }
+ try
+ {
+ launch.terminate();
+ }
+ catch (DebugException e)
+ {
+ logger.warn("Failed to terminate launch: " + launch, e);
+ }
+ }
+ waitForLaunchesToTerminate(debugLaunches(projectName), 5000);
+ }
+
+ /**
+ * Stops OpenOCD and ESP GDB processes that belong to {@code projectName}. PIDs are taken before
+ * {@link ILaunch#terminate()}. A process that stays alive is signaled by that PID. Host-wide
+ * {@code pkill -f} is not used.
+ *
+ * @param projectName project whose debug tools may be stopped; {@code null} only signals
+ * processes still attached to a launch in this workbench
+ */
+ public static void killDebugProcesses(String projectName)
+ {
+ rememberDebugProcessIds(projectName);
+ Set pids = new LinkedHashSet<>(rememberedDebugProcessIds);
+ for (ILaunch launch : debugLaunches(projectName))
+ {
+ pids.addAll(processIds(launch));
+ if (!launch.isTerminated())
+ {
+ try
+ {
+ launch.terminate();
+ }
+ catch (DebugException e)
+ {
+ logger.warn("Failed to terminate launch before PID cleanup: " + launch, e);
+ }
+ }
+ }
+ waitForLaunchesToTerminate(debugLaunches(projectName), 5000);
+ for (Long pid : pids)
+ {
+ signalProcess(pid);
+ }
+ killOrphanGdbProcesses(projectName);
+ rememberedDebugProcessIds.clear();
+ }
+
+ /**
+ * Stops OpenOCD and ESP GDB processes still attached to a launch in this workbench.
+ * Does not signal processes outside this Eclipse session.
+ */
+ public static void killDebugProcesses()
+ {
+ killDebugProcesses(null);
+ }
+
+ private static void rememberDebugProcessIds(String projectName)
+ {
+ for (ILaunch launch : debugLaunches(projectName))
+ {
+ for (Long pid : processIds(launch))
+ {
+ if (rememberedDebugProcessIds.add(pid))
+ {
+ logger.info("Remembered debug process {}", pid);
+ }
+ }
+ }
+ }
+
+ private static ILaunch[] debugLaunches(String projectName)
+ {
+ ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager();
+ ILaunch[] launches = launchManager.getLaunches();
+ if (launches == null)
+ {
+ return new ILaunch[0];
+ }
+ return Stream.of(launches).filter(launch -> isDebugToolLaunch(launch, projectName)).toArray(ILaunch[]::new);
+ }
+
+ private static boolean isDebugToolLaunch(ILaunch launch, String projectName)
+ {
+ if (launch == null)
+ {
+ return false;
+ }
+ if (projectName != null && !projectName.isEmpty() && !launchBelongsToProject(launch, projectName))
+ {
+ return false;
+ }
+ if (projectName == null || projectName.isEmpty())
+ {
+ return launchHasDebugToolProcess(launch);
+ }
+ return ILaunchManager.DEBUG_MODE.equals(launch.getLaunchMode()) || launchHasDebugToolProcess(launch);
+ }
+
+ private static boolean launchBelongsToProject(ILaunch launch, String projectName)
+ {
+ ILaunchConfiguration configuration = launch.getLaunchConfiguration();
+ if (configuration != null && configuration.getName() != null && configuration.getName().contains(projectName))
+ {
+ return true;
+ }
+ for (IProcess process : processesOf(launch))
+ {
+ String command = process.getAttribute(IProcess.ATTR_CMDLINE);
+ if (command != null && command.contains(projectName))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean launchHasDebugToolProcess(ILaunch launch)
+ {
+ for (IProcess process : processesOf(launch))
+ {
+ String command = process.getAttribute(IProcess.ATTR_CMDLINE);
+ if (command != null && isDebugToolCommand(command))
+ {
+ return true;
+ }
+ String label = process.getLabel();
+ if (label != null && isDebugToolCommand(label))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static IProcess[] processesOf(ILaunch launch)
+ {
+ IProcess[] processes = launch.getProcesses();
+ return processes == null ? new IProcess[0] : processes;
+ }
+
+ private static Set processIds(ILaunch launch)
+ {
+ Set pids = new LinkedHashSet<>();
+ for (IProcess process : processesOf(launch))
+ {
+ if (!(process instanceof RuntimeProcess))
+ {
+ continue;
+ }
+ Process systemProcess = systemProcess(process);
+ if (systemProcess == null)
+ {
+ continue;
+ }
+ try
+ {
+ pids.add(Long.valueOf(systemProcess.pid()));
+ }
+ catch (UnsupportedOperationException e)
+ {
+ logger.debug("Debug process has no PID: {}", e.getMessage());
+ }
+ }
+ return pids;
+ }
+
+ /**
+ * {@link RuntimeProcess#getSystemProcess()} is protected. The test reads it to signal that OS
+ * process only.
+ */
+ private static Process systemProcess(IProcess process)
+ {
+ try
+ {
+ Method method = RuntimeProcess.class.getDeclaredMethod("getSystemProcess");
+ method.setAccessible(true);
+ return (Process) method.invoke(process);
+ }
+ catch (ReflectiveOperationException e)
+ {
+ logger.debug("Could not read debug process handle: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private static void waitForLaunchesToTerminate(ILaunch[] launches, long timeoutMs)
+ {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (System.currentTimeMillis() < deadline)
+ {
+ boolean finished = true;
+ for (ILaunch launch : launches)
+ {
+ if (launch != null && !launch.isTerminated())
+ {
+ finished = false;
+ break;
+ }
+ }
+ if (finished)
+ {
+ return;
+ }
+ try
+ {
+ Thread.sleep(200);
+ }
+ catch (InterruptedException e)
+ {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ }
+
+ private static void signalProcess(long pid)
+ {
+ if (pid <= 0 || pid == ProcessHandle.current().pid())
+ {
+ return;
+ }
+ Optional handle = ProcessHandle.of(pid);
+ if (handle.isEmpty() || !handle.get().isAlive())
+ {
+ return;
+ }
+ logger.info("Stopping debug process {}", pid);
+ handle.get().destroy();
+ try
+ {
+ handle.get().onExit().get(2, TimeUnit.SECONDS);
+ }
+ catch (Exception e)
+ {
+ logger.debug("Debug process {} did not exit after SIGTERM: {}", pid, e.getMessage());
+ }
+ if (handle.get().isAlive())
+ {
+ logger.info("Killing debug process {}", pid);
+ handle.get().destroyForcibly();
+ }
+ }
+
+ /**
+ * Stops an ESP GDB process whose command line contains the project name or its workspace path.
+ * OpenOCD is not searched this way: its arguments usually omit the project directory.
+ */
+ private static void killOrphanGdbProcesses(String projectName)
+ {
+ if (projectName == null || projectName.isEmpty() || !System.getProperty("os.name", "")
+ .toLowerCase(Locale.ENGLISH).contains("linux"))
+ {
+ return;
+ }
+ String output;
+ try
+ {
+ Process process = new ProcessBuilder("ps", "-ax", "-o", "pid=,command=").redirectErrorStream(true)
+ .start();
+ output = new String(process.getInputStream().readAllBytes());
+ process.waitFor(5, TimeUnit.SECONDS);
+ }
+ catch (Exception e)
+ {
+ logger.debug("Could not list processes for debug cleanup: {}", e.getMessage());
+ return;
+ }
+ for (String line : output.split("\\R"))
+ {
+ String trimmed = line.trim();
+ int split = trimmed.indexOf(' ');
+ if (split <= 0)
+ {
+ continue;
+ }
+ String command = trimmed.substring(split + 1);
+ if (!commandBelongsToProject(command, projectName) || !isGdbCommand(command))
+ {
+ continue;
+ }
+ try
+ {
+ signalProcess(Long.parseLong(trimmed.substring(0, split).trim()));
+ }
+ catch (NumberFormatException e)
+ {
+ logger.debug("Could not parse process id from: {}", trimmed);
+ }
+ }
+ }
+
+ private static boolean commandBelongsToProject(String command, String projectName)
+ {
+ if (command.contains(projectName))
+ {
+ return true;
+ }
+ IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(projectName);
+ if (project != null && project.exists() && project.getLocation() != null)
+ {
+ return command.contains(project.getLocation().toOSString());
+ }
+ return false;
+ }
+
+ private static boolean isGdbCommand(String command)
+ {
+ String lower = command.toLowerCase(Locale.ENGLISH);
+ return lower.contains("gdb") && (lower.contains("xtensa-esp") || lower.contains("riscv32-esp"));
+ }
+
+ private static boolean isDebugToolCommand(String command)
+ {
+ String lower = command.toLowerCase(Locale.ENGLISH);
+ return lower.contains("openocd") || (lower.contains("gdb")
+ && (lower.contains("xtensa-esp") || lower.contains("riscv32-esp")));
+ }
+
+ /**
+ * Terminates every non-terminated launch registered with the Eclipse debug framework.
+ */
+ public static void terminateAllLaunches()
+ {
+ ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager();
+ ILaunch[] launches = launchManager.getLaunches();
+ if (launches == null)
+ {
+ return;
+ }
+
+ for (ILaunch launch : launches)
+ {
+ if (launch == null || launch.isTerminated())
+ {
+ continue;
+ }
+ try
+ {
+ launch.terminate();
+ }
+ catch (DebugException e)
+ {
+ logger.warn("Failed to terminate launch: " + launch, e);
+ }
+ }
+ }
+
+ public static void launchCommandUsingContextMenuWithoutWaitingForJobs(String projectName, SWTWorkbenchBot bot,
+ String contextMenuLabel)
+ {
+ openCCppPerspective(bot);
+ focusMainWindow(bot.shells());
+
+ SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot);
+ if (projectItem == null)
+ {
+ throw new WidgetNotFoundException("Project not found in Project Explorer: " + projectName);
+ }
+ projectItem.select();
+ projectItem.contextMenu(contextMenuLabel).click();
+ }
}