package guitests; import static com.google.common.io.Files.getFileExtension; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.TimeoutException; import java.util.function.Predicate; import java.util.function.Supplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.hamcrest.Matcher; import org.junit.Before; import org.junit.BeforeClass; import org.loadui.testfx.GuiTest; import org.loadui.testfx.exceptions.NoNodesFoundException; import org.loadui.testfx.exceptions.NoNodesVisibleException; import org.loadui.testfx.utils.KeyCodeUtils; import org.testfx.api.FxRobot; import org.testfx.api.FxToolkit; import com.google.common.util.concurrent.SettableFuture; import backend.interfaces.RepoStore; import javafx.application.Platform; import javafx.event.ActionEvent; import javafx.geometry.Bounds; import javafx.scene.Node; import javafx.scene.control.ComboBoxBase; import javafx.scene.control.ContextMenu; import javafx.scene.control.Menu; import javafx.scene.control.MenuItem; import javafx.scene.control.TextField; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.input.MouseButton; import javafx.scene.layout.FlowPane; import javafx.stage.Stage; import ui.IdGenerator; import ui.MenuControl; import ui.TestController; import ui.UI; import ui.components.FilterTextField; import ui.issuepanel.FilterPanel; import ui.listpanel.ListPanel; import ui.listpanel.ListPanelCell; import util.PlatformEx; import util.PlatformSpecific; public class UITest extends FxRobot { protected static final SettableFuture<Stage> STAGE_FUTURE = SettableFuture.create(); private static final Logger logger = LogManager.getLogger(UITest.class.getName()); private static final Map<Character, KeyCode> specialCharsMap = getSpecialCharsMap(); private static final int EVENT_DELAY = 2000; /** * Sets TestFX properties to run in headless mode with * system properties that ensures tests run without any issues * as suggested by TestFX documentation */ static { if (Boolean.getBoolean("headless")) { System.setProperty("java.awt.robot", "true"); System.setProperty("testfx.robot", "glass"); System.setProperty("testfx.headless", "true"); System.setProperty("prism.order", "sw"); System.setProperty("prism.text", "t2k"); } } protected static class TestUI extends UI { public TestUI() { super(); } @Override public void start(Stage primaryStage) { super.start(primaryStage); STAGE_FUTURE.set(primaryStage); } } @BeforeClass public static void setupSpec() { try { FxToolkit.registerPrimaryStage(); FxToolkit.hideStage(); } catch (TimeoutException e) { logger.error(e.getMessage()); e.printStackTrace(); } } @Before public void setStage() throws TimeoutException { FxToolkit.setupStage(this::handleSetupStage); } @Before public void setup() throws Exception { FxToolkit.setupApplication(TestUI.class, "--test=true", "--bypasslogin=true"); } public Stage getStage() { return FxToolkit.toolkitContext().getRegisteredStage(); } protected void handleSetupStage(Stage stage) { // delete test configs if they exist clearAllTestConfigs(); clearTestFolder(); beforeStageStarts(); PlatformEx.runAndWait(stage::show); } public static void clearTestFolder() { Path testDirectory = Paths.get(RepoStore.TEST_DIRECTORY); try { if (!Files.exists(testDirectory)) { throw new FileNotFoundException(testDirectory.toString()); } Files.walk(Paths.get(RepoStore.TEST_DIRECTORY), 1) .filter(Files::isRegularFile) .filter(p -> getFileExtension(String.valueOf(p.getFileName())).equalsIgnoreCase("json") || getFileExtension(String.valueOf(p.getFileName())).equalsIgnoreCase("json-err") ) .forEach(p -> new File(p.toAbsolutePath().toString()).delete()); } catch (IOException e) { e.printStackTrace(); } } public static void clearAllTestConfigs() { clearTestConfig(TestController.TEST_DIRECTORY, TestController.TEST_SESSION_CONFIG_FILENAME); clearTestConfig(TestController.TEST_DIRECTORY, TestController.TEST_USER_CONFIG_FILENAME); } private static void clearTestConfig(String directory, String filename) { // delete test.json if it exists File testConfig = new File(directory, filename); if (testConfig.exists() && testConfig.isFile()) { assert testConfig.delete(); } } private static Map<Character, KeyCode> getSpecialCharsMap() { Map<Character, KeyCode> specialChars = new HashMap<Character, KeyCode>(); specialChars.put('~', KeyCode.BACK_QUOTE); specialChars.put('!', KeyCode.DIGIT1); specialChars.put('@', KeyCode.DIGIT2); specialChars.put('#', KeyCode.DIGIT3); specialChars.put('$', KeyCode.DIGIT4); specialChars.put('%', KeyCode.DIGIT5); specialChars.put('^', KeyCode.DIGIT6); specialChars.put('&', KeyCode.DIGIT7); specialChars.put('*', KeyCode.DIGIT8); specialChars.put('(', KeyCode.DIGIT9); specialChars.put(')', KeyCode.DIGIT0); specialChars.put('_', KeyCode.MINUS); specialChars.put('+', KeyCode.EQUALS); specialChars.put('{', KeyCode.BRACELEFT); specialChars.put('}', KeyCode.BRACERIGHT); specialChars.put(':', KeyCode.SEMICOLON); specialChars.put('"', KeyCode.QUOTE); specialChars.put('<', KeyCode.COMMA); specialChars.put('>', KeyCode.PERIOD); specialChars.put('?', KeyCode.SLASH); return Collections.unmodifiableMap(specialChars); } protected void beforeStageStarts() { // method to be overridden if anything needs to be done (e.g. to the json) before the stage starts } public void waitUntilNodeAppears(Node node) { GuiTest.waitUntil(node, n -> n.isVisible() && n.getParent() != null); } public void waitUntilNodeDisappears(Node node) { GuiTest.waitUntil(node, n -> !n.isVisible() || n.getParent() == null); } public void waitUntilNodeAppears(String selector) { awaitCondition(() -> existsQuiet(selector)); } public void waitUntilNodeDisappears(String selector) { awaitCondition(() -> !existsQuiet(selector)); } public void waitUntilNodeAppears(Matcher<Object> matcher) { // We use find because there's no `exists` for matchers awaitCondition(() -> findQuiet(matcher).isPresent()); } public void waitUntilNodeDisappears(Matcher<Object> matcher) { // We use find because there's no `exists` for matchers awaitCondition(() -> !findQuiet(matcher).isPresent()); } public <T extends Node> void waitUntil(String selector, Predicate<T> condition) { awaitCondition(() -> condition.test(GuiTest.find(selector))); } /** * Waits for the result of a function, then asserts that it is equal to some value. */ public <T> void waitAndAssertEquals(T expected, Supplier<T> actual) { awaitCondition(() -> expected.equals(actual.get())); } public <T> void waitForValue(ComboBoxBase<T> comboBoxBase) { GuiTest.waitUntil(comboBoxBase, c -> c.getValue() != null); } public <T extends Node> T findOrWaitFor(String selector) { waitUntilNodeAppears(selector); return GuiTest.find(selector); } public <T extends Node> Optional<T> findQuiet(String selectorOrText) { try { return Optional.of(GuiTest.find(selectorOrText)); } catch (NoNodesFoundException | NoNodesVisibleException e) { return Optional.empty(); } } private <T extends Node> Optional<T> findQuiet(Matcher<Object> matcher) { try { return Optional.ofNullable(GuiTest.find(matcher)); } catch (NoNodesFoundException | NoNodesVisibleException e) { return Optional.empty(); } } public boolean existsQuiet(String selector) { try { return GuiTest.exists(selector); } catch (NoNodesFoundException | NoNodesVisibleException e) { return false; } } /** * Allows test threads to busy-wait on some condition. * <p> * Taken from org.loadui.testfx.utils, but modified to synchronise with * the JavaFX Application Thread, with a lower frequency. * The additional synchronisation prevents bugs where * <p> * awaitCondition(a); * awaitCondition(b); * <p> * sometimes may not be equivalent to * <p> * awaitCondition(a && b); * <p> * The lower frequency is a bit more efficient, since a frequency of 10 ms * just isn't necessary for GUI interactions, and we're bottlenecked by the FX * thread anyway. */ public void awaitCondition(Callable<Boolean> condition) { awaitCondition(condition, 5); } protected void awaitCondition(Callable<Boolean> condition, int timeoutInSeconds) { long timeout = System.currentTimeMillis() + timeoutInSeconds * 1000; try { while (!condition.call()) { Thread.sleep(100); PlatformEx.waitOnFxThread(); if (System.currentTimeMillis() > timeout) { throw new TimeoutException(); } } } catch (Exception e) { throw new RuntimeException(e); } } /** * Like drag(from).to(to), but does not relocate the mouse if the target moves. */ public void dragUnconditionally(FilterPanel panelFrom, FilterPanel panelTo) { Node from = dragSrc(panelFrom); Node to = dragDest(panelTo); Bounds fromBound = from.localToScene(from.getBoundsInLocal()); Bounds toBound = to.localToScene(to.getBoundsInLocal()); drag(fromBound.getMinX(), fromBound.getMaxY(), MouseButton.PRIMARY) .moveTo(toBound.getMaxX(), toBound.getMaxY()) .drop(); } private Node dragSrc(FilterPanel panel) { return panel.getCloseButton(); } private Node dragDest(FilterPanel panel) { return panel.getFilterTextField(); } /** * Automate menu traversal by clicking them in order of input parameter * * @param menuNames array of strings of menu item names in sequence of traversal */ public void clickMenu(String... menuNames) { for (String menuName : menuNames) { clickOn(menuName); } } /** * Clicks the label picker's TextField */ public void clickLabelPickerTextField() { waitUntilNodeAppears(IdGenerator.getLabelPickerTextFieldIdReference()); clickOn(IdGenerator.getLabelPickerTextFieldIdReference()); } /** * Clicks the milestone picker's TextField */ public void clickMilestonePickerTextField() { waitUntilNodeAppears(IdGenerator.getMilestonePickerTextFieldIdReference()); clickOn(IdGenerator.getMilestonePickerTextFieldIdReference()); } /** * Clicks the assignee picker's TextField */ public void clickAssigneePickerTextField() { waitUntilNodeAppears(IdGenerator.getAssigneePickerTextFieldIdReference()); clickOn(IdGenerator.getAssigneePickerTextFieldIdReference()); } /** * Gets the label picker's TextField */ public TextField getLabelPickerTextField() { return GuiTest.find(IdGenerator.getLabelPickerTextFieldIdReference()); } /** * Gets the label picker's TextField */ public TextField getMilestonePickerTextField() { return GuiTest.find(IdGenerator.getMilestonePickerTextFieldIdReference()); } /** * Gets the assignee picker's TextField */ public TextField getAssigneePickerTextField() { return GuiTest.find(IdGenerator.getAssigneePickerTextFieldIdReference()); } /** * Gets the assignee picker's AssignedUserPane */ public FlowPane getAssigneePickerAssignedUserPane() { return GuiTest.find(IdGenerator.getAssigneePickerAssignedUserPaneIdReference()); } /** * Clicks the FilterTextField of the panel at {@code panelIndex} * @param panelIndex */ public void clickFilterTextFieldAtPanel(int panelIndex) { // Wait for a node to be associated with a scene to prevent NullPointerException sleep(EVENT_DELAY); TextField field = getFilterTextFieldAtPanel(panelIndex); clickOn(field); } /** * Gets the FilterTextField of the panel at {@code panelIndex} * @param panelIndex */ public FilterTextField getFilterTextFieldAtPanel(int panelIndex) { waitUntilNodeAppears(IdGenerator.getPanelFilterTextFieldIdReference(panelIndex)); return GuiTest.find(IdGenerator.getPanelFilterTextFieldIdReference(panelIndex)); } /** * Clicks the issue with id {@code issueId} at panel {@code panelIndex} * @param panelIndex * @param issueId */ public void clickIssue(int panelIndex, int issueId) { waitUntilNodeAppears(IdGenerator.getPanelCellIdReference(panelIndex, issueId)); clickOn(IdGenerator.getPanelCellIdReference(panelIndex, issueId)); } /** * Right clicks the issue with id {@code issueId} at panel {@code panelIndex} * @param panelIndex * @param issueId */ public void rightClickIssue(int panelIndex, int issueId) { waitUntilNodeAppears(IdGenerator.getPanelCellIdReference(panelIndex, issueId)); rightClickOn(IdGenerator.getPanelCellIdReference(panelIndex, issueId)); } /** * Clicks the panel {@code panelIndex} * @param panelIndex */ public void clickPanel(int panelIndex) { clickOn(IdGenerator.getPanelIdReference(panelIndex)); } /** * Right clicks the panel {@code panelIndex} * @param panelIndex */ public void rightClickPanel(int panelIndex) { rightClickOn(IdGenerator.getPanelIdReference(panelIndex)); } /** * Gets the panel {@code panelIndex} * @param panelIndex */ public ListPanel getPanel(int panelIndex) { waitUntilNodeAppears(IdGenerator.getPanelIdReference(panelIndex)); return GuiTest.find(IdGenerator.getPanelIdReference(panelIndex)); } /** * Gets the issue cell of issue {@code issueId} at panel {@code panelIndex} * @param panelIndex * @param issueId */ public ListPanelCell getIssueCell(int panelIndex, int issueId) { waitUntilNodeAppears(IdGenerator.getPanelCellIdReference(panelIndex, issueId)); return GuiTest.find(IdGenerator.getPanelCellIdReference(panelIndex, issueId)); } /** * Sets a text field with given text. Does not simulate clicking and typing * in the text field. * * @param fieldId * @param text */ public void setTextField(String fieldId, String text) { waitUntilNodeAppears(fieldId); ((TextField) GuiTest.find(fieldId)).setText(text); } /** * Traverses menu from the given menu list, looking for a chain of nodes with given names and triggering * their associated action. */ private void traverseMenu(List<? extends MenuItem> menus, String... names) { MenuItem current = menus.stream() .filter(m -> m.getText().equals(names[0])) .findFirst() .orElseThrow(() -> new IllegalArgumentException( String.format("%s is not a valid menu item", names[0]))); for (int i = 1; i < names.length; i++) { final int j = i; if (!(current instanceof Menu)) { throw new IllegalArgumentException( String.format("Menu %s is not as nested as arguments require", names[0])); } current = ((Menu) current).getItems().stream() .filter(m -> m.getText().equals(names[j])) .findFirst() .orElseThrow(() -> new IllegalArgumentException( String.format("%s is not a valid menu item", names[j]))); } current.getOnAction().handle(new ActionEvent()); } /** * Traverses HubTurbo's menu, looking for a chain of nodes with the * given names and triggering their associated action. * <p> * This is a more reliable method of triggering menu items than * {@link #clickMenu}, especially when dealing with nested menu items. * It is a drop-in replacement in most cases. * <p> * Caveats: ensure that adequate synchronisation is used after this method * if it is called from a thread other than the UI thread. * * @param names the chain of menu nodes to visit */ public void traverseHubTurboMenu(String... names) { assert names.length > 0 : "traverseHubTurboMenu called with no arguments"; Platform.runLater(() -> { MenuControl root = TestController.getUI().getMenuControl(); traverseMenu(root.getMenus(), names); }); } /** * Similar with traverseHubTurboMenu, including the caveats, but this method traverses a ContextMenu instead. */ public void traverseContextMenu(ContextMenu contextMenu, String... names) { assert names.length > 0 : "traverseContextMenu called with no arguments"; Platform.runLater(() -> traverseMenu(contextMenu.getItems(), names)); } private List<KeyCode> getKeyCodes(KeyCodeCombination combination) { List<KeyCode> keys = new ArrayList<>(); if (combination.getAlt() == KeyCombination.ModifierValue.DOWN) { keys.add(KeyCode.ALT); } if (combination.getShift() == KeyCombination.ModifierValue.DOWN) { keys.add(KeyCode.SHIFT); } if (combination.getMeta() == KeyCombination.ModifierValue.DOWN) { keys.add(KeyCode.META); } if (combination.getControl() == KeyCombination.ModifierValue.DOWN) { keys.add(KeyCode.CONTROL); } if (combination.getShortcut() == KeyCombination.ModifierValue.DOWN) { // Fix bug with internal method not having a proper code for SHORTCUT. // Dispatch manually based on platform. if (PlatformSpecific.isOnMac()) { keys.add(KeyCode.META); } else { keys.add(KeyCode.CONTROL); } } keys.add(combination.getCode()); return keys; } public void push(KeyCode keyCode, int times) { assert times > 0; for (int i = 0; i < times; i++) { push(keyCode); } } public void pushKeys(KeyCodeCombination combination) { pushKeys(getKeyCodes(combination)); } public void pushKeys(KeyCode... keys) { pushKeys(Arrays.asList(keys)); } private void pushKeys(List<KeyCode> keys) { keys.forEach(this::press); for (int i = keys.size() - 1; i >= 0; i--) { release(keys.get(i)); } PlatformEx.waitOnFxThread(); } public void press(KeyCodeCombination combination) { press(getKeyCodes(combination)); } private void press(List<KeyCode> keys) { keys.forEach(this::press); for (int i = keys.size() - 1; i >= 0; i--) { release(keys.get(i)); } PlatformEx.waitOnFxThread(); } /** * Used to select the whole filter text so that it can be replaced */ public void selectAll() { pushKeys(new KeyCodeCombination(KeyCode.A, KeyCombination.SHORTCUT_DOWN)); } public FxRobot type(String text) { for (int i = 0; i < text.length(); i++) { if (specialCharsMap.containsKey(text.charAt(i))) { press(KeyCode.SHIFT).press(specialCharsMap.get(text.charAt(i))) .release(specialCharsMap.get(text.charAt(i))).release(KeyCode.SHIFT); } else { char typed = text.charAt(i); KeyCode identified = KeyCodeUtils.findKeyCode(typed); if (Character.isUpperCase(typed)) { press(KeyCode.SHIFT).type(identified).release(KeyCode.SHIFT); } else { type(identified); } } } return this; } /** * Performs UI login on the login dialog box. * @param owner The owner of the repo. * @param repoName The repository name * @param username The Github username * @param password The Github password */ public void login(String owner, String repoName, String username, String password){ selectAll(); type(owner).push(KeyCode.TAB); type(repoName).push(KeyCode.TAB); type(username).push(KeyCode.TAB); type(password); clickOn("Sign in"); } /** * Performs logout from File -> Logout on HubTurbo's pView. */ public void logout(){ clickMenu("App", "Logout"); } /** * Clicks on menu item with target text * @param menu * @param target */ public void clickMenuItem(ContextMenu menu, String target) { menu.getItems() .stream() .filter(item -> item.getText().equals(target)) .findFirst().ifPresent(item -> { Platform.runLater(item::fire); }); } public void waitBeforeClick(String nodeQuery) { waitUntilNodeAppears(nodeQuery); clickOn(nodeQuery); } }