| /******************************************************************************* |
| * Copyright (c) 2010-2018 BSI Business Systems Integration AG. |
| * All rights reserved. This program and the accompanying materials |
| * are made available under the terms of the Eclipse Public License v1.0 |
| * which accompanies this distribution, and is available at |
| * http://www.eclipse.org/legal/epl-v10.html |
| * |
| * Contributors: |
| * BSI Business Systems Integration AG - initial API and implementation |
| ******************************************************************************/ |
| package org.eclipse.scout.rt.ui.html.selenium.junit; |
| |
| import static org.eclipse.scout.rt.ui.html.selenium.util.SeleniumUtil.shortPause; |
| import static org.eclipse.scout.rt.ui.html.selenium.util.SeleniumUtil.variablePause; |
| |
| import java.util.List; |
| import java.util.Set; |
| import java.util.concurrent.TimeUnit; |
| import java.util.function.Function; |
| |
| import org.eclipse.scout.rt.client.ui.form.fields.IValueField; |
| import org.eclipse.scout.rt.client.ui.messagebox.MessageBox; |
| import org.eclipse.scout.rt.ui.html.selenium.SeleniumSuiteState; |
| import org.eclipse.scout.rt.ui.html.selenium.util.SeleniumDriver; |
| import org.eclipse.scout.rt.ui.html.selenium.util.SeleniumExpectedConditions; |
| import org.eclipse.scout.rt.ui.html.selenium.util.SeleniumUtil; |
| import org.junit.AfterClass; |
| import org.junit.BeforeClass; |
| import org.junit.Rule; |
| import org.junit.rules.RuleChain; |
| import org.junit.rules.TestRule; |
| import org.openqa.selenium.By; |
| import org.openqa.selenium.JavascriptExecutor; |
| import org.openqa.selenium.Keys; |
| import org.openqa.selenium.WebDriver; |
| import org.openqa.selenium.WebDriver.TargetLocator; |
| import org.openqa.selenium.WebElement; |
| import org.openqa.selenium.interactions.Actions; |
| import org.openqa.selenium.support.ui.ExpectedConditions; |
| import org.openqa.selenium.support.ui.WebDriverWait; |
| |
| public abstract class AbstractSeleniumTest { |
| |
| public static final int DEFAULT_WAIT_UNTIL_TIMEOUT = 10; // seconds |
| |
| private static WebDriver s_driver; |
| |
| private final IgnoreTestOnMacOSRule m_ignoreTestOnMacOSRule = new IgnoreTestOnMacOSRule(this); |
| private final BrowserLogRule m_browserLogRule = new BrowserLogRule(this); |
| private final SessionRule m_sessionRule = new SessionRule(this); |
| private final ScreenshotRule m_screenshotRule = new ScreenshotRule(this); |
| |
| /** |
| * Wrap screenshot rule by session rule to make sure, screenshot is taken BEFORE the session is logged out. |
| * |
| * @see https://github.com/junit-team/junit/issues/906 |
| */ |
| @Rule |
| public final TestRule m_mainRule = RuleChain |
| .outerRule(m_ignoreTestOnMacOSRule) |
| .around(m_browserLogRule) |
| .around(m_sessionRule) |
| .around(m_screenshotRule); |
| |
| private WebElement m_previousClickTarget; |
| private int m_waitUntilTimeout = DEFAULT_WAIT_UNTIL_TIMEOUT; // seconds |
| |
| /** |
| * When we're running in a suite, the suite deals with starting the Driver, When the test runs alone, it must start |
| * the Driver itself. |
| */ |
| @BeforeClass |
| public static void setUpBeforeClass() { |
| if (SeleniumSuiteState.isSuiteStarted()) { |
| s_driver = SeleniumSuiteState.getDriver(); |
| } |
| else { |
| s_driver = SeleniumDriver.setUpDriver(); |
| System.out.println("Selenium driver started by AbstractSeleniumTest"); |
| } |
| } |
| |
| /** |
| * When we're running in a suite, the suite deals with stopping the Driver, When the test runs alone, it must stop the |
| * Driver itself. |
| */ |
| @AfterClass |
| public static void tearDownAfterClass() { |
| if (!SeleniumSuiteState.isSuiteStarted()) { |
| if (s_driver != null) { |
| s_driver.quit(); |
| } |
| System.out.println("Selenium driver terminated by AbstractSeleniumTest"); |
| } |
| s_driver = null; |
| } |
| |
| public WebDriver getDriver() { |
| return s_driver; |
| } |
| |
| /** |
| * Reloads the page (Ctrl + R). |
| */ |
| public void refresh() { |
| s_driver.navigate().refresh(); |
| } |
| |
| public TargetLocator switchTo() { |
| return s_driver.switchTo(); |
| } |
| |
| public WebElement findElement(By by) { |
| return findElement(null, by); |
| } |
| |
| public List<WebElement> findElements(By by) { |
| return findElements(null, by); |
| } |
| |
| public List<WebElement> findElements(WebElement parent, By by) { |
| if (parent == null) { |
| return s_driver.findElements(by); |
| } |
| else { |
| return parent.findElements(by); |
| } |
| } |
| |
| public WebElement findElement(WebElement parent, By by) { |
| if (parent == null) { |
| return s_driver.findElement(by); |
| } |
| else { |
| return parent.findElement(by); |
| } |
| } |
| |
| /** |
| * Finds the first {@link WebElement} that represents the given model class. |
| */ |
| public WebElement findElement(Class<?> modelClass) { |
| return findElement(null, modelClass); |
| } |
| |
| /** |
| * Finds the first {@link WebElement} that represents the given model class, and which is located somewhere beneath |
| * the given parent. |
| */ |
| public WebElement findElement(WebElement parent, Class<?> modelClass) { |
| return findElement(parent, SeleniumUtil.byModelClass(modelClass)); |
| } |
| |
| /** |
| * Finds the first <code>input</code> {@link WebElement} that represents the given model class (e.g. text- or smart |
| * field). |
| */ |
| public WebElement findInputField(Class<?> modelClass) { |
| return findInputField(SeleniumUtil.byModelClass(modelClass)); |
| } |
| |
| /** |
| * Finds the first <code>input</code> {@link WebElement} that is found in the hierarchy referenced by the given |
| * locator (by). |
| * |
| * @param by |
| * @return |
| */ |
| public WebElement findInputField(By by) { |
| return findElement(by).findElement(By.tagName("input")); |
| } |
| |
| /** |
| * Finds the first <code>input</code> {@link WebElement} that represents the given model class, and which is located |
| * somewhere beneath the given parent (e.g. text- or smart field). |
| */ |
| public WebElement findInputField(WebElement parent, Class<?> modelClass) { |
| return findElement(parent, modelClass).findElement(By.tagName("input")); |
| } |
| |
| /** |
| * Finds the first <code>text area</code> {@link WebElement} that represents the given model class. |
| */ |
| public WebElement findTextArea(Class<?> modelClass) { |
| return findTextArea(null, modelClass); |
| } |
| |
| /** |
| * Finds the first <code>text area</code> {@link WebElement} that represents the given model class, and which is |
| * located somewhere beneath the given parent. |
| */ |
| public WebElement findTextArea(WebElement parent, Class<?> modelClass) { |
| return findElement(parent, modelClass).findElement(By.tagName("textarea")); |
| } |
| |
| /** |
| * Fills the given value into the first <code>input</code> {@link WebElement} that represents the given model class. |
| * In turn, that field is the focus owner. |
| */ |
| public WebElement fillInputField(Class<? extends IValueField<?>> modelClass, String value) { |
| return fillInputField(null, modelClass, value); |
| } |
| |
| /** |
| * Fills the given value into the first <code>input</code> {@link WebElement} that represents the given model class, |
| * and which is located somewhere beneath the given parent. In turn, that field is the focus owner. |
| */ |
| public WebElement fillInputField(WebElement parent, Class<? extends IValueField<?>> modelClass, String value) { |
| waitUntilDataRequestPendingDone(); |
| WebElement inputField = waitUntilInputFieldClickable(parent, modelClass); |
| return fillInputField(inputField, value); |
| } |
| |
| /** |
| * Fills in the given value into the given {@link WebElement}. |
| */ |
| public WebElement fillInputField(WebElement inputField, String value) { |
| inputField.click(); |
| variablePause(1); |
| waitUntilDataRequestPendingDone(); |
| |
| clearInput(inputField); |
| variablePause(2); |
| waitUntilDataRequestPendingDone(); |
| |
| inputField.sendKeys(value); |
| variablePause(2); |
| waitUntilDataRequestPendingDone(); |
| |
| switchTo().activeElement().sendKeys(Keys.TAB); |
| variablePause(2); |
| waitUntilDataRequestPendingDone(); |
| |
| switchTo().activeElement().sendKeys(Keys.chord(Keys.SHIFT, Keys.TAB)); |
| shortPause(); |
| waitUntilDataRequestPendingDone(); |
| |
| return inputField; |
| } |
| |
| public void waitUntilScoutSession() { |
| waitUntil(SeleniumExpectedConditions.scriptToReturnTrue("return !!(scout && scout.sessions && scout.sessions.length && scout.sessions[0])")); |
| } |
| |
| /** |
| * Finds the message box top on stack. |
| */ |
| public WebElement waitUntilMessageBox() { |
| return waitUntilMessageBox("last()"); |
| } |
| |
| /** |
| * Finds the message box at the given DOM position (1-based). |
| */ |
| public WebElement waitUntilMessageBox(String xPathIndex) { |
| return waitUntilElementClickable(SeleniumUtil.byModelClass(MessageBox.class, xPathIndex)); |
| } |
| |
| /** |
| * Finds the dialog top on stack. |
| */ |
| public WebElement waitUntilDialog() { |
| return waitUntilDialog("last()"); |
| } |
| |
| /** |
| * Finds the dialog at the given DOM position (1-based). |
| */ |
| public WebElement waitUntilDialog(String xPathIndex) { |
| return waitUntilElementClickable(By.xpath(String.format("//div[contains(@class, 'dialog')][%s]", xPathIndex))); |
| } |
| |
| /** |
| * Finds the current form-view (does not match detail-forms!). |
| */ |
| public WebElement waitUntilView() { |
| return waitUntilElementClickable(By.cssSelector(".form.view:not(.detail-form)")); |
| } |
| |
| public WebElement waitUntilDetailForm() { |
| return waitUntilElementClickable(By.cssSelector(".detail-form")); |
| } |
| |
| public WebElement waitUntilDetailTable() { |
| return waitUntilElementClickable(By.cssSelector(".detail-table")); |
| } |
| |
| /** |
| * Finds the message box button at the given index position (1-based, or last() to get the last button). |
| */ |
| public WebElement findFirstMessageBoxButton(WebElement messageBox) { |
| return findMessageBoxButton(messageBox, "1"); |
| } |
| |
| /** |
| * Finds the message box button at the given index position (1-based, or last() to get the last opened). |
| */ |
| public WebElement findMessageBoxButton(WebElement messageBox, String xPathIndex) { |
| return messageBox.findElement(By.xpath(String.format("//*[@class='box-button unfocusable'][%s]", xPathIndex))); |
| } |
| |
| public boolean elementNotExists(String modelClass) { |
| return s_driver.findElements(SeleniumUtil.byModelClass(modelClass)).isEmpty(); |
| } |
| |
| public boolean elementNotExists(Class<?> modelClass) { |
| return s_driver.findElements(SeleniumUtil.byModelClass(modelClass)).isEmpty(); |
| } |
| |
| public boolean elementExists(Class<?> modelClass) { |
| return !this.elementNotExists(modelClass); |
| } |
| |
| public boolean waitUntilElementStaleness(WebElement element) { |
| return waitUntil(ExpectedConditions.stalenessOf(element)); |
| } |
| |
| public WebElement waitUntilElementClickable(Class<?> modelClass) { |
| return waitUntilElementClickable(null, modelClass); |
| } |
| |
| public WebElement waitUntilElementClickable(WebElement parent, Class<?> modelClass) { |
| return waitUntilElementClickable(parent, SeleniumUtil.byModelClass(modelClass)); |
| } |
| |
| public WebElement waitUntilElementClickable(By by) { |
| return waitUntilElementClickable(null, by); |
| } |
| |
| public WebElement waitUntilElementClickable(WebElement element) { |
| return waitUntil(ExpectedConditions.elementToBeClickable(element)); |
| } |
| |
| public WebElement waitUntilElementClickable(WebElement parent, By locator) { |
| if (parent == null) { |
| return waitUntil(ExpectedConditions.elementToBeClickable(locator)); |
| } |
| else { |
| return waitUntil(SeleniumExpectedConditions.childElementToBeClickable(parent, locator)); |
| } |
| } |
| |
| public WebElement waitUntilInputFieldClickable(Class<?> modelClass) { |
| return waitUntilInputFieldClickable(null, modelClass); |
| } |
| |
| public WebElement waitUntilInputFieldClickable(WebElement parent, Class<?> modelClass) { |
| return waitUntilElementClickable(parent, modelClass).findElement(By.tagName("input")); |
| } |
| |
| public WebElement waitUntilTextAreaClickable(Class<?> modelClass) { |
| return waitUntilTextAreaClickable(null, modelClass); |
| } |
| |
| public WebElement waitUntilTextAreaClickable(WebElement parent, Class<?> modelClass) { |
| return waitUntilElementClickable(parent, modelClass).findElement(By.tagName("textarea")); |
| } |
| |
| public WebElement waitUntilCheckBoxClickable(WebElement parent, Class<?> modelClass) { |
| return waitUntilElementClickable(parent, modelClass).findElement(By.className("check-box")); |
| } |
| |
| public WebElement waitUntilButtonClickable(WebElement parent, Class<?> modelClass) { |
| return waitUntilElementClickable(parent, modelClass).findElement(By.className("button")); |
| } |
| |
| public WebElement waitUntilLinkButtonClickable(WebElement parent, Class<?> modelClass) { |
| return waitUntilElementClickable(parent, modelClass).findElement(By.className("link-button")); |
| } |
| |
| public WebElement waitUntilMenuItemClickable(WebElement parent, String menuText) { |
| return waitUntilElementClickable(parent, By.xpath("//div[contains(@class, 'menu-item')]/span[contains(@class, 'text') and contains(text(), '" + menuText + "')]/..")); |
| } |
| |
| public WebElement waitUntilMenuItemClickable(String menuText) { |
| return waitUntilMenuItemClickable(null, menuText); |
| } |
| |
| public WebElement waitUntilTreeNodeClickable(WebElement parent, String nodeText) { |
| return waitUntilElementClickable(parent, By.xpath("//div[contains(@class, 'tree-node')]/span[contains(@class, 'text') and contains(text(), '" + nodeText + "')]")); |
| } |
| |
| public WebElement waitUntilTreeNodeClickable(String nodeText) { |
| return waitUntilTreeNodeClickable(null, nodeText); |
| } |
| |
| public WebElement waitUntilTableCellClickable(WebElement parent, String cellText) { |
| return waitUntilElementClickable(parent, By.xpath("//div[contains(@class, 'table-row')]/div[contains(@class, 'table-cell') and contains(text(), '" + cellText + "')]")); |
| } |
| |
| public WebElement waitUntilTableCellClickable(String cellText) { |
| return waitUntilTableCellClickable(null, cellText); |
| } |
| |
| /** |
| * Waits for all pending server calls to finish. |
| */ |
| public void waitUntilDataRequestPendingDone() { |
| // Always wait a short time, because session.sendEvent() schedules the sending via setTimeout. When an |
| // action is performed that causes a server request, this method might therefore not "see" the scheduled |
| // server call yet. |
| SeleniumUtil.pause(50, TimeUnit.MILLISECONDS); |
| |
| WebElement entryPoint = findElement(By.className("scout")); |
| waitUntil(ExpectedConditions.not(SeleniumExpectedConditions.attributeToEqualsValue(entryPoint, "data-request-pending", "true"))); |
| } |
| |
| public void clickCheckBox(WebElement parent, Class<?> modelClass) { |
| clickCheckBox(parent.findElement(SeleniumUtil.byModelClass(modelClass))); |
| } |
| |
| public void clickCheckBox(Class<?> modelClass) { |
| clickCheckBox(findElement(SeleniumUtil.byModelClass(modelClass))); |
| } |
| |
| public void clickCheckBox(WebElement checkBoxField) { |
| findElement(checkBoxField, By.className("check-box")).click(); |
| } |
| |
| /** |
| * Waits until the given check-box has the requested checked state. |
| */ |
| public WebElement waitUntilCheckBoxChecked(WebElement checkBoxField, boolean checked) { |
| return waitUntil(SeleniumExpectedConditions.checkBoxToBeChecked(checkBoxField, checked)); |
| } |
| |
| /** |
| * Waits until the given check-box is checked. |
| */ |
| public WebElement waitUntilCheckBoxChecked(WebElement checkBoxField) { |
| return waitUntilCheckBoxChecked(checkBoxField, true); |
| } |
| |
| protected <V> V waitUntil(Function<WebDriver, V> condition) { |
| return waitUntil(condition, m_waitUntilTimeout); |
| } |
| |
| protected <V> V waitUntil(Function<WebDriver, V> condition, int timeoutInSeconds) { |
| return new WebDriverWait(s_driver, timeoutInSeconds).until(condition); |
| } |
| |
| public void resetWaitUntilTimeout() { |
| setWaitUntilTimeout(DEFAULT_WAIT_UNTIL_TIMEOUT); |
| } |
| |
| /** |
| * Sets the timeout in seconds which is used internally for every <i>waitUntil*()</i> call without explicit timeout. |
| * Use this method in your test, when you must wait longer than the default timeout which is |
| * {@value #DEFAULT_WAIT_UNTIL_TIMEOUT} seconds. At the end of your test you should reset the timeout to the default |
| * value. |
| */ |
| public void setWaitUntilTimeout(int timeoutInSeconds) { |
| m_waitUntilTimeout = timeoutInSeconds; |
| } |
| |
| /** |
| * @return the timeout in seconds to be used by {@link #waitUntil(Function)} |
| */ |
| public int getWaitUntilTimeout() { |
| return m_waitUntilTimeout; |
| } |
| |
| public WebElement waitForFirstTableRow() { |
| String xpath = "//div[contains(@class, 'table-row')][1]"; |
| return waitUntilElementClickable(By.xpath(xpath)); |
| } |
| |
| /** |
| * Waits until a radio-button within the given radio button group is checked. The button is identified by its text. |
| */ |
| public WebElement waitUntilRadioButtonChecked(WebElement radioButtonGroup, String radioButtonText) { |
| return waitUntil(SeleniumExpectedConditions.radioButtonToBeChecked(radioButtonGroup, radioButtonText)); |
| } |
| |
| /** |
| * Clicks the element at the given offset position (from top-left corner). |
| */ |
| public void clickAtOffset(WebElement element, int xOffset, int yOffset) { |
| Actions builder = new Actions(getDriver()); |
| builder.moveToElement(element, xOffset, yOffset).click().build().perform(); |
| } |
| |
| public Set<String> getWindowHandles() { |
| return s_driver.getWindowHandles(); |
| } |
| |
| public String getWindowHandle() { |
| return s_driver.getWindowHandle(); |
| } |
| |
| /** |
| * @return the OS dependent "control key" for use in key combinations like <code>Ctrl-C</code> ({@link Keys#COMMAND} |
| * on Mac OS X, {@link Keys#CONTROL} on all other systems). |
| */ |
| public Keys getOsDependentCtrlKey() { |
| // MacOS uses command key instead of ctrl |
| Keys ctrlKey = Keys.CONTROL; |
| if (SeleniumUtil.isMacOS()) { |
| ctrlKey = Keys.COMMAND; |
| } |
| return ctrlKey; |
| } |
| |
| /** |
| * Performs select all on the given element. Since command-key + A does not work with ChromeDriver on OSX we execute |
| * JavaScript on that platform. ChromeDriver developers don't seem to be motivated to fix that bug. |
| * |
| * @see https://bugs.chromium.org/p/chromedriver/issues/detail?id=30 |
| */ |
| public void selectAll(WebElement element) { |
| if (SeleniumUtil.isMacOS()) { |
| ((JavascriptExecutor) getDriver()).executeScript("arguments[0].select();", element); |
| } |
| else { |
| element.sendKeys(getSelectAllKeys()); |
| } |
| } |
| |
| public CharSequence getSelectAllKeys() { |
| return Keys.chord(getOsDependentCtrlKey(), "a"); |
| } |
| |
| public CharSequence getCopyKeys() { |
| return Keys.chord(getOsDependentCtrlKey(), "c"); |
| } |
| |
| public CharSequence getPasteKeys() { |
| return Keys.chord(getOsDependentCtrlKey(), "v"); |
| } |
| |
| public void doubleClickOnElement(WebElement element) { |
| Actions actions = new Actions(getDriver()); |
| actions.moveToElement(element).doubleClick().perform(); |
| } |
| |
| /** |
| * @param element |
| * @return the parent element of the given element (by using findElement and xpath '..') |
| */ |
| public WebElement findParentElement(WebElement element) { |
| return element.findElement(By.xpath("..")); |
| } |
| |
| /** |
| * Sends a "click" event to the given element. If the same element is clicked twice in a row, a short delay is applied |
| * to prevent generation of a "double click" event. |
| * <p> |
| * If the click caused a server call, the methods waits until the request has finished. It also waits for UI |
| * animations using a .animation-wrapper to complete. |
| */ |
| public void clickAndWait(WebElement element) { |
| // If the same element is clicked twice in a row, add a small pause to prevent a double click |
| if (m_previousClickTarget == element) { |
| SeleniumUtil.pause(500, TimeUnit.MILLISECONDS); |
| } |
| m_previousClickTarget = element; |
| |
| element.click(); |
| |
| // Wait for pending server calls to finish |
| waitUntilDataRequestPendingDone(); |
| |
| // Wait for animations to finish |
| waitUntilAnimationWrapperDone(); |
| } |
| |
| /** |
| * Waits until no elements with the CSS class <code>animation-wrapper</code> exist. Useful while testing tree node |
| * expansion. |
| */ |
| public void waitUntilAnimationWrapperDone() { |
| for (WebElement animation : findElements(By.className("animation-wrapper"))) { |
| waitUntilElementStaleness(animation); |
| } |
| } |
| |
| public void waitUntilWindowsCount(final int expectedWindowsCount) { |
| waitUntil(webDriver -> { |
| int numWindows = webDriver.getWindowHandles().size(); |
| if (numWindows == expectedWindowsCount) { |
| return numWindows; |
| } |
| else { |
| return null; |
| } |
| }); |
| } |
| |
| public void clearInput(WebElement input) { |
| this.clearInput(input, false); |
| } |
| |
| /** |
| * Used as a replacement for WebElement#clear. Google changed the way clear() works in ChromeDriver > 2.43 and the |
| * method now causes the field to loose focus, which triggers a lot of focus-out handlers that haven't called before |
| * version 2.43. Thus this method works with 'select all' and backspace. Additionally it also triggers key/up down |
| * events which is also a desired side-effect of this method (clear() does not trigger key events). |
| * <p> |
| * Note: you can still use WebElement#clear if focus loss doesn't matter. But for a lot of widgets, especially |
| * SmartFields it makes a difference. |
| * |
| * @param input |
| * @param withPause |
| * whether or not a short pause should be made after the field has been cleared, this may be necessary for |
| * some SmartFields |
| */ |
| public void clearInput(WebElement input, boolean withPause) { |
| selectAll(input); |
| input.sendKeys(Keys.BACK_SPACE); |
| if (withPause) { |
| SeleniumUtil.shortPause(); |
| } |
| } |
| |
| /** |
| * Fails when the given element does not contain all of the given CSS classes. |
| * |
| * @param element |
| * @param expectedCssClass |
| * A single CSS class-name or multiple CSS class-names separated by space. Example: <code>'menu-item'</code> |
| * or <code>'menu-item selected'</code>. If multiple CSS class-names are given, the given element must have |
| * all of these classes, otherwise the assert will fail. |
| */ |
| public void assertCssClass(WebElement element, String expectedCssClass) { |
| waitUntil(SeleniumExpectedConditions.elementToHaveCssClass(element, expectedCssClass)); |
| } |
| |
| /** |
| * Fails when the given element contains at least one of the given CSS classes. |
| * |
| * @param element |
| * @param expectedCssClass |
| */ |
| public void assertCssClassNotExists(WebElement element, String expectedCssClass) { |
| waitUntil(SeleniumExpectedConditions.elementNotToHaveCssClass(element, expectedCssClass)); |
| } |
| |
| /** |
| * Hides the navigation and waits until it is gone. |
| */ |
| public void hideDesktopNavigation() { |
| findElement(By.cssSelector(".collapse-handle-body.left")).click(); |
| waitUntilElementClickable(By.cssSelector(".collapse-handle.both-visible")); |
| findElement(By.cssSelector(".collapse-handle-body.left")).click(); |
| waitUntilElementStaleness(findElement(By.className("desktop-navigation"))); |
| } |
| } |