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 1c3f156e7..19dcf5a22 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..388ca8ffd --- /dev/null +++ b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/executable/cases/project/IDFProjectDebugProcessTest.java @@ -0,0 +1,523 @@ +/******************************************************************************* + * 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.swtbot.eclipse.finder.SWTWorkbenchBot; +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.BeforeClass; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; + +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) + }; + + @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); + + Fixture.givenNewEspressifIDFProjectIsSelected("EspressIf", "Espressif IDF Project"); + Fixture.givenProjectNameIs(PROJECT_NAME); + Fixture.whenNewProjectIsSelected(); + Fixture.whenTurnOffOpenSerialMonitorAfterFlashingInLaunchConfig(); + + String esp32SerialPort = Fixture.whenDetectAndSelectEsp32UartSerialPort(); + assertTrue("No ESP32 UART target detected from Serial Port auto-detection", + esp32SerialPort != null); + + Fixture.whenProjectIsBuiltUsingContextMenu(); + Fixture.whenFlashProject(); + Fixture.thenVerifyFlashDoneSuccessfully(); + + assertTrue("ESP32-ETHERNET-KIT board not detected in New ESP Target Board combo", + Fixture.whenSelectEsp32EthernetKitBoard()); + + // Start debug only via Debug As — do not flip Launch Bar mode/config first. + // LaunchBarListener toggles RUN↔DEBUG on descriptor changes and can terminate + // an active OpenOCD session when the Debug perspective opens. + Fixture.whenStartDebuggingUsingContextMenu(); + Fixture.thenVerifyDebugSessionStarted(); + Fixture.thenVerifyNoFatalOpenOcdErrors(); + Fixture.whenStepOver(); + Fixture.thenVerifyDebugSessionStillActive(); + Fixture.whenStopDebugging(); + } + + 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.launchCommandUsingContextMenu(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 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); + } + + private static void cleanupEnvironment() + { + try + { + ProjectTestOperations.leaveDebugUi(bot); + } + catch (Exception e) + { + System.err.println("leaveDebugUi 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(); + } + + 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/operations/ProjectTestOperations.java b/tests/com.espressif.idf.ui.test/src/com/espressif/idf/ui/test/operations/ProjectTestOperations.java index e09df6a66..8158ca43c 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 @@ -8,21 +8,35 @@ import java.io.IOException; import java.text.MessageFormat; import java.util.Arrays; +import java.util.Locale; import java.util.Optional; +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.DebugPlugin; +import org.eclipse.debug.core.ILaunch; +import org.eclipse.debug.core.ILaunchManager; +import org.eclipse.debug.core.model.IDebugTarget; +import org.eclipse.debug.core.model.IStackFrame; +import org.eclipse.debug.core.model.IThread; import org.eclipse.swt.widgets.MenuItem; import org.eclipse.swtbot.eclipse.finder.SWTWorkbenchBot; 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.Result; +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; @@ -30,10 +44,17 @@ import org.eclipse.swtbot.swt.finder.widgets.SWTBotMenu; import org.eclipse.swtbot.swt.finder.widgets.SWTBotShell; import org.eclipse.swtbot.swt.finder.widgets.SWTBotTable; +import org.eclipse.swtbot.swt.finder.widgets.SWTBotToolbarButton; import org.eclipse.swtbot.swt.finder.widgets.SWTBotToolbarDropDownButton; 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.eclipse.ui.handlers.IHandlerService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,90 +76,1315 @@ 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 Logger logger = LoggerFactory.getLogger(ProjectTestOperations.class); private static final int DELETE_PROJECT_TIMEOUT = 240000; /** - * Build a project using the context menu by right clicking on the project - * - * @param projectName project name to build - * @param bot current SWT bot reference + * Build a project using the context menu by right clicking on the project + * + * @param projectName project name to build + * @param bot current SWT bot reference + */ + public static void buildProjectUsingContextMenu(String projectName, SWTWorkbenchBot bot) + { + SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot); + if (projectItem != null) + { + projectItem.select(); + projectItem.contextMenu("Build Project").click(); + projectItem.expand(); + projectItem.select("build"); + } + } + + /** + * Waits for the current build operation to be completed + * + * @param bot current SWT bot reference + * @throws IOException + */ + public static void waitForProjectBuild(SWTWorkbenchBot bot) throws IOException + { + SWTBotView consoleView = viewConsole("CDT Build Console", bot); + consoleView.show(); + consoleView.setFocus(); + try { + TestWidgetWaitUtility.waitUntilViewContains(bot, "Build complete", consoleView, + DefaultPropertyFetcher.getLongPropertyValue(DEFAULT_PROJECT_BUILD_WAIT_PROPERTY, 300000)); + } catch (Exception e) { + throw new AssertionError("Project Build failed", e); + } + } + + public static void waitForProjectFlash(SWTWorkbenchBot bot) throws IOException + { + SWTBotView view = bot.viewByPartName("Console"); + view.setFocus(); + TestWidgetWaitUtility.waitUntilViewContains(bot, "Hard resetting via RTS pin...", view, + DefaultPropertyFetcher.getLongPropertyValue(DEFAULT_FLASH_WAIT_PROPERTY, 120000)); + } + + public static void waitForProjectNewComponentInstalled(SWTWorkbenchBot bot) throws IOException + { + SWTBotView consoleView = viewConsole("ESP-IDF Console", bot); + consoleView.show(); + consoleView.setFocus(); + TestWidgetWaitUtility.waitUntilViewContains(bot, "Successfully added dependency", consoleView, + DefaultPropertyFetcher.getLongPropertyValue("Install New Component", 10000)); + } + + public static SWTBotView viewConsole(String consoleType, SWTWorkbenchBot bot) + { + SWTBotView view = bot.viewByPartName("Console"); + view.setFocus(); + SWTBotToolbarDropDownButton b = view.toolbarDropDownButton("Display Selected Console"); + String regex = ".*" + Pattern.quote(consoleType) + "( \\[.*\\])?.*"; + org.hamcrest.Matcher withRegex = WidgetMatcherFactory.withRegex(regex); + b.menuItem(withRegex).click(); + view.setFocus(); + return view; + } + + public static void createDebugConfiguration(String projectName, SWTWorkbenchBot bot) + { + SWTBotView projectExplorerBotView = bot.viewByTitle("Project Explorer"); + projectExplorerBotView.show(); + projectExplorerBotView.setFocus(); + SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot); + if (projectItem != null) + { + projectItem.select().contextMenu("Debug As").menu("Debug Configurations...").click(); + bot.tree().getTreeItem("ESP-IDF GDB OpenOCD Debugging").select(); + bot.tree().getTreeItem("ESP-IDF GDB OpenOCD Debugging").doubleClick(); + bot.button("Close").click(); + } + + } + + /** + * Starts debugging via Project Explorer context menu: Debug As → Debug Configurations..., + * selects the ESP-IDF OpenOCD debug config, clicks Debug, and accepts the perspective switch if prompted. + * + * @param projectName project whose debug configuration should be launched + * @param bot current SWT bot reference + */ + 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); + } + + projectItem.select(); + projectItem.contextMenu("Debug As").menu("Debug Configurations...").click(); + + TestWidgetWaitUtility.waitForDialogToAppear(bot, "Debug Configurations", 10000); + + bot.tree().getTreeItem("ESP-IDF GDB OpenOCD Debugging").select(); + bot.tree().getTreeItem("ESP-IDF GDB OpenOCD Debugging").expand(); + + String primaryDebugConfig = projectName + " Debug"; + String fallbackDebugConfig = projectName + " Configuration"; + try + { + bot.tree().getTreeItem("ESP-IDF GDB OpenOCD Debugging").getNode(primaryDebugConfig).select(); + } + catch (WidgetNotFoundException e) + { + try + { + bot.tree().getTreeItem("ESP-IDF GDB OpenOCD Debugging").getNode(fallbackDebugConfig).select(); + } + catch (WidgetNotFoundException e2) + { + // Last resort: use the first child config under the OpenOCD type. + bot.tree().getTreeItem("ESP-IDF GDB OpenOCD Debugging").getNode(0).select(); + } + } + + bot.waitUntil(Conditions.widgetIsEnabled(bot.button("Debug")), 5000); + bot.button("Debug").click(); + // Dialog usually appears later, when GDB suspends — also handled in waitForDebugSessionStarted. + acceptDebugPerspectiveSwitchIfPresent(bot, 5000); + } + + /** + * 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; + boolean perspectiveHandled = false; + + while (System.currentTimeMillis() < deadline) + { + if (!perspectiveHandled) + { + perspectiveHandled = acceptDebugPerspectiveSwitchIfPresent(bot, 1000); + } + + // Do not flip Console pages while polling — that opens a sticky dropdown menu. + String consoleText = readVisibleConsoleText(bot); + + 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(bot, 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(); + String text = view.bot().styledText().getText(); + return text != null ? text : ""; + } + catch (Exception e) + { + logger.debug("Could not read visible Console view: {}", e.getMessage()); + return ""; + } + } + + /** + * 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(SWTWorkbenchBot bot, String consoleText) + { + // Prefer the debug model only while polling. Expanding the Debug view tree every + // second can leave SWTBot stuck if a Surefire timeout interrupts mid-expand. + return isSuspendedAtAppMainInDebugModel() || 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")); + } + + /** + * 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 via debug model API, Debug toolbar / Run menu, Debug view context menu, + * or Project Explorer fallback. Does not use keyboard shortcuts. + *

+ * FreeRTOS threads often show {@code Running} while halted, so readiness is based on an + * {@code app_main} stack frame (or a visible Step Over control), not {@code isSuspended()} alone. + * + * @param projectName project to use for the context-menu fallback + * @param bot current SWT bot reference + */ + public static void performDebugStepOver(String projectName, SWTWorkbenchBot bot) + { + 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; + 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); + // FreeRTOS: app_main frame is enough; isSuspended()/canStepOver() are often false. + return findThreadWithAppMainFrame() != null + || canStepOverInDebugModel() + || isStepOverToolbarPresent(workbenchBot); + } + + @Override + public String getFailureMessage() + { + return "Debug Step Over not ready within timeout — no app_main stack frame, " + + "canStepOver=false, and Step Over toolbar not found. " + + "Launch active=" + hasActiveLaunch() + + ", app_main frame=" + (findThreadWithAppMainFrame() != null) + + ", canStepOver=" + canStepOverInDebugModel() + + ", stepOverToolbar=" + isStepOverToolbarPresent(workbenchBot); + } + }, 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() + ", app_main frame=" + + (findThreadWithAppMainFrame() != null), + e); + } + throw e; + } + + bot.sleep(1500); + + if (stepOverViaDebugModel()) + { + bot.sleep(3000); + return; + } + if (stepOverViaDebugCommand()) + { + bot.sleep(3000); + return; + } + if (clickToolbarStepOver(bot)) + { + bot.sleep(3000); + return; + } + if (clickRunMenuStepOver(bot)) + { + bot.sleep(3000); + return; + } + if (clickDebugViewStepOver(bot)) + { + bot.sleep(3000); + return; + } + if (clickProjectContextMenuStepOver(projectName, bot)) + { + bot.sleep(3000); + return; + } + + throw new AssertionError( + "Failed to perform Step Over (debug API, command, toolbar, Run menu, Debug view, " + + "and Project Explorer all failed). Launch active=" + hasActiveLaunch() + + ", app_main frame=" + (findThreadWithAppMainFrame() != null) + + ", canStepOver=" + canStepOverInDebugModel()); + } + + 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() || !thread.hasStackFrames()) + { + continue; + } + for (IStackFrame frame : thread.getStackFrames()) + { + 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 stepOverViaDebugModel() + { + IThread appMainThread = findThreadWithAppMainFrame(); + if (appMainThread != null) + { + try + { + if (appMainThread.canStepOver()) + { + appMainThread.stepOver(); + return true; + } + } + catch (DebugException e) + { + logger.debug("stepOverViaDebugModel(app_main): {}", e.getMessage()); + } + } + + ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager(); + ILaunch[] launches = launchManager.getLaunches(); + if (launches == null) + { + return false; + } + 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.canStepOver()) + { + thread.stepOver(); + return true; + } + } + } + catch (DebugException e) + { + logger.debug("stepOverViaDebugModel: {}", e.getMessage()); + } + } + } + return false; + } + + private static boolean stepOverViaDebugCommand() + { + try + { + Boolean ok = UIThreadRunnable.syncExec(new Result() + { + @Override + public Boolean run() + { + try + { + IHandlerService handlers = PlatformUI.getWorkbench().getService(IHandlerService.class); + if (handlers == null) + { + return Boolean.FALSE; + } + handlers.executeCommand("org.eclipse.debug.ui.commands.StepOver", null); + return Boolean.TRUE; + } + catch (Exception e) + { + logger.debug("stepOverViaDebugCommand: {}", e.getMessage()); + return Boolean.FALSE; + } + } + }); + return Boolean.TRUE.equals(ok); + } + catch (Exception e) + { + logger.debug("stepOverViaDebugCommand failed: {}", e.getMessage()); + return false; + } + } + + private static boolean isStepOverToolbarPresent(SWTWorkbenchBot bot) + { + return findStepOverToolbarButton(bot) != null + || isToolbarButtonPresent(bot, "Step &Over (F6)") + || isToolbarButtonPresent(bot, "Step Over (F6)") + || isToolbarButtonPresent(bot, "Step Over"); + } + + private static SWTBotToolbarButton findStepOverToolbarButton(SWTWorkbenchBot bot) + { + try + { + for (SWTBotToolbarButton button : bot.toolbarButtons()) + { + String tip = button.getToolTipText(); + if (tip != null && tip.toLowerCase(Locale.ENGLISH).contains("step over")) + { + return button; + } + } + } + catch (Exception e) + { + logger.debug("findStepOverToolbarButton: {}", e.getMessage()); + } + return null; + } + + private static boolean clickToolbarStepOver(SWTWorkbenchBot bot) + { + SWTBotToolbarButton matched = findStepOverToolbarButton(bot); + if (matched != null) + { + try + { + matched.click(); + return true; + } + catch (Exception e) + { + logger.debug("Matched Step Over toolbar click failed: {}", e.getMessage()); + } + } + + String[] tooltips = { "Step &Over (F6)", "Step Over (F6)", "Step Over", "Step Over (F6) (Alt+Shift+O)" }; + for (String tooltip : tooltips) + { + try + { + bot.toolbarButtonWithTooltip(tooltip).click(); + return true; + } + catch (WidgetNotFoundException ignored) + { + } + } + return false; + } + + private static boolean clickRunMenuStepOver(SWTWorkbenchBot bot) + { + String[] labels = { "Step Over (F6)", "Step &Over (F6)", "Step Over", "Step &Over" }; + for (String label : labels) + { + try + { + bot.menu("Run").menu(label).click(); + return true; + } + catch (WidgetNotFoundException ignored) + { + } + catch (Exception e) + { + logger.debug("Run menu Step Over ({}) failed: {}", label, e.getMessage()); + } + } + return false; + } + + private static boolean clickDebugViewStepOver(SWTWorkbenchBot bot) + { + try + { + SWTBotView debugView = bot.viewByPartName("Debug"); + debugView.show(); + debugView.setFocus(); + bot.sleep(1000); + SWTBotTree tree = debugView.bot().tree(); + SWTBotTreeItem appMain = findTreeItemContaining(tree.getAllItems(), "app_main"); + if (appMain == null) + { + return false; + } + appMain.select(); + bot.sleep(500); + try + { + appMain.contextMenu("Step Over").click(); + return true; + } + catch (WidgetNotFoundException e) + { + appMain.contextMenu("Step Over (F6)").click(); + return true; + } + } + catch (Exception e) + { + logger.debug("Debug view Step Over failed: {}", e.getMessage()); + return false; + } + } + + private static SWTBotTreeItem findTreeItemContaining(SWTBotTreeItem[] items, String text) + { + if (items == null) + { + return null; + } + String needle = text.toLowerCase(Locale.ENGLISH); + for (SWTBotTreeItem item : items) + { + if (item == null) + { + continue; + } + String label = item.getText(); + if (label != null && label.toLowerCase(Locale.ENGLISH).contains(needle)) + { + return item; + } + try + { + item.expand(); + } + catch (Exception ignored) + { + } + SWTBotTreeItem nested = findTreeItemContaining(item.getItems(), text); + if (nested != null) + { + return nested; + } + } + return null; + } + + private static boolean clickProjectContextMenuStepOver(String projectName, SWTWorkbenchBot bot) + { + try + { + SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot); + if (projectItem == null) + { + return false; + } + projectItem.select(); + try + { + projectItem.contextMenu("Step Over").click(); + return true; + } + catch (WidgetNotFoundException e) + { + projectItem.contextMenu("Step Over (F6)").click(); + return true; + } + } + catch (Exception e) + { + logger.debug("Project context menu Step Over failed: {}", e.getMessage()); + return false; + } + } + + private static boolean canStepOverInDebugModel() + { + ILaunchManager launchManager = DebugPlugin.getDefault().getLaunchManager(); + ILaunch[] launches = launchManager.getLaunches(); + if (launches == null) + { + return false; + } + + 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()) + { + // Do not require isSuspended() — FreeRTOS often reports Running while halted. + if (thread != null && thread.canStepOver()) + { + return true; + } + } + } + catch (DebugException e) + { + logger.debug("canStepOverInDebugModel: {}", e.getMessage()); + } + } + } + return false; + } + + private static boolean isToolbarButtonPresent(SWTWorkbenchBot bot, String tooltip) + { + try + { + bot.toolbarButtonWithTooltip(tooltip); + return true; + } + catch (WidgetNotFoundException e) + { + return false; + } + } + + /** + * 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) + { + stopDebugSessionAndKillProcesses(bot); + 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 buildProjectUsingContextMenu(String projectName, SWTWorkbenchBot bot) + public static void cancelJobsThatBlockWorkbenchIdle() { - SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot); - if (projectItem != null) + Job[] jobs = Job.getJobManager().find(null); + if (jobs == null) { - projectItem.select(); - projectItem.contextMenu("Build Project").click(); - projectItem.expand(); - projectItem.select("build"); + 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(); } } /** - * Waits for the current build operation to be completed - * + * Best-effort close of views typically opened by the Debug perspective. + * * @param bot current SWT bot reference - * @throws IOException */ - public static void waitForProjectBuild(SWTWorkbenchBot bot) throws IOException + public static void closeDebugRelatedViews(SWTWorkbenchBot bot) { - SWTBotView consoleView = viewConsole("CDT Build Console", bot); - consoleView.show(); - consoleView.setFocus(); - try { - TestWidgetWaitUtility.waitUntilViewContains(bot, "Build complete", consoleView, - DefaultPropertyFetcher.getLongPropertyValue(DEFAULT_PROJECT_BUILD_WAIT_PROPERTY, 300000)); - } catch (Exception e) { - throw new AssertionError("Project Build failed", e); + 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) + { + } } } - public static void waitForProjectFlash(SWTWorkbenchBot bot) throws IOException + /** + * 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) { - SWTBotView view = bot.viewByPartName("Console"); - view.setFocus(); - TestWidgetWaitUtility.waitUntilViewContains(bot, "Hard resetting via RTS pin...", view, - DefaultPropertyFetcher.getLongPropertyValue(DEFAULT_FLASH_WAIT_PROPERTY, 120000)); + try + { + terminateAllLaunches(); + } + catch (Exception e) + { + logger.warn("Failed to terminate Eclipse launches during debug cleanup", e); + } + + killDebugProcesses(); } - public static void waitForProjectNewComponentInstalled(SWTWorkbenchBot bot) throws IOException + /** + * 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) { - SWTBotView consoleView = viewConsole("ESP-IDF Console", bot); - consoleView.show(); - consoleView.setFocus(); - TestWidgetWaitUtility.waitUntilViewContains(bot, "Successfully added dependency", consoleView, - DefaultPropertyFetcher.getLongPropertyValue("Install New Component", 10000)); + 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); + } } - public static SWTBotView viewConsole(String consoleType, SWTWorkbenchBot bot) + /** + * 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) { - SWTBotView view = bot.viewByPartName("Console"); - view.setFocus(); - SWTBotToolbarDropDownButton b = view.toolbarDropDownButton("Display Selected Console"); - String regex = ".*" + Pattern.quote(consoleType) + "( \\[.*\\])?.*"; - org.hamcrest.Matcher withRegex = WidgetMatcherFactory.withRegex(regex); - b.menuItem(withRegex).click(); - view.setFocus(); - return view; + try + { + terminateAllLaunches(); + } + catch (Exception e) + { + logger.warn("forceClean: terminateAllLaunches failed", e); + } + + killDebugProcesses(); + + 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(); } - public static void createDebugConfiguration(String projectName, SWTWorkbenchBot bot) + /** + * Closes all open editors without prompting (UI-thread API). + */ + public static void closeAllEditorsViaApi() { - SWTBotView projectExplorerBotView = bot.viewByTitle("Project Explorer"); - projectExplorerBotView.show(); - projectExplorerBotView.setFocus(); - SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot); - if (projectItem != null) + UIThreadRunnable.syncExec(new VoidResult() { - projectItem.select().contextMenu("Debug As").menu("Debug Configurations...").click(); - bot.tree().getTreeItem("ESP-IDF GDB OpenOCD Debugging").select(); - bot.tree().getTreeItem("ESP-IDF GDB OpenOCD Debugging").doubleClick(); - bot.button("Close").click(); + @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 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); + } + } + } + + /** + * Force-terminates OpenOCD and ESP GDB processes left behind by a debug session. + * Mirrors the VS Code UI-test {@code killDebugProcesses} helper. Exit status from + * {@code pkill} when no process matches is ignored. + */ + public static void killDebugProcesses() + { + String[] patterns = new String[] { "openocd", "xtensa-esp.*-gdb", "riscv32-esp.*-gdb" }; + for (String pattern : patterns) + { + try + { + Process process = new ProcessBuilder("pkill", "-f", pattern).redirectErrorStream(true).start(); + process.waitFor(5, TimeUnit.SECONDS); + } + catch (Exception e) + { + logger.debug("pkill for pattern '{}' skipped or failed: {}", pattern, e.getMessage()); + } + } + + try + { + Thread.sleep(1500); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } } public static void openProjectComponentYMLFileInTextEditorUsingContextMenu(String projectName, SWTWorkbenchBot bot) @@ -690,13 +1936,22 @@ public static void deleteAllProjects(SWTWorkbenchBot bot) public static void launchCommandUsingContextMenu(String projectName, SWTWorkbenchBot bot, String contextMenuLabel) { + // After a Debug-perspective test, Project Explorer / focus may still be on Debug UI — + // restore C/C++ and focus the main window so the context menu actually opens the dialog. + openCCppPerspective(bot); + focusMainWindow(bot.shells()); + SWTBotTreeItem projectItem = fetchProjectFromProjectExplorer(projectName, bot); - if (projectItem != null) + if (projectItem == null) { - projectItem.select(); - projectItem.contextMenu(contextMenuLabel).click(); + throw new WidgetNotFoundException("Project not found in Project Explorer: " + projectName); } - WaitUtils.waitForJobs(); + projectItem.select(); + projectItem.contextMenu(contextMenuLabel).click(); + // Do not WaitUtils.waitForJobs() here. For dialogs like "Run Configurations" the shell + // appears immediately while background jobs (e.g. Language Server) may keep running; + // waiting for idle first makes the caller's waitForDialogToAppear miss a visible dialog + // or time out for the wrong reason. Callers that need jobs to finish should wait themselves. } public static void findInConsole(SWTWorkbenchBot bot, String consoleName, String findText) throws IOException