From a74b6bb2fe0d8da4553c2d50794ad18d4dc793ad Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Fri, 24 Jul 2026 13:13:25 +0000 Subject: [PATCH] feat: simulate Flow triggers and actions for clipboard testing Add a browserless simulation of Flow's client-side trigger/action API, which is otherwise unreachable server-side. `TriggerSimulation` installs a `Triggers` arming listener per mocked environment and fires armed triggers, dispatching matched `ActionSimulator`s. First slice covers the clipboard: `ClipboardSimulator` exposes clipboard-write state, and `click()` on `Clickable`/`ContextMenuTester` now fires client-side triggers so context-menu actions run. Installed and torn down in `BaseBrowserlessTest`, `BrowserlessApplicationContext` and `AbstractBrowserlessExtension`. --- .../AbstractBrowserlessExtension.java | 10 + .../com/example/base/ClipboardSmokeView.java | 46 +++ .../quarkus/ClipboardSmokeTest.java | 57 ++++ .../browserless/BaseBrowserlessTest.java | 17 ++ .../BrowserlessApplicationContext.java | 12 + .../com/vaadin/browserless/Clickable.java | 5 + .../browserless/trigger/ActionSimulator.java | 46 +++ .../trigger/ClipboardActionSimulators.java | 106 +++++++ .../trigger/SimulationContext.java | 72 +++++ .../trigger/TriggerSimulation.java | 289 ++++++++++++++++++ .../clipboard/ClipboardSimulator.java | 207 +++++++++++++ .../contextmenu/ContextMenuTester.java | 5 + .../browserless/ClipboardSimulationTest.java | 152 +++++++++ .../TriggerSimulationSerializationTest.java | 68 +++++ .../testapp/clipboard/ClipboardSmokeView.java | 46 +++ .../browserless/ClipboardSmokeTest.java | 61 ++++ 16 files changed, 1199 insertions(+) create mode 100644 quarkus/src/test/java/com/example/base/ClipboardSmokeView.java create mode 100644 quarkus/src/test/java/com/vaadin/browserless/quarkus/ClipboardSmokeTest.java create mode 100644 shared/src/main/java/com/vaadin/browserless/trigger/ActionSimulator.java create mode 100644 shared/src/main/java/com/vaadin/browserless/trigger/ClipboardActionSimulators.java create mode 100644 shared/src/main/java/com/vaadin/browserless/trigger/SimulationContext.java create mode 100644 shared/src/main/java/com/vaadin/browserless/trigger/TriggerSimulation.java create mode 100644 shared/src/main/java/com/vaadin/flow/component/clipboard/ClipboardSimulator.java create mode 100644 shared/src/test/java/com/vaadin/browserless/ClipboardSimulationTest.java create mode 100644 shared/src/test/java/com/vaadin/browserless/trigger/TriggerSimulationSerializationTest.java create mode 100644 spring/src/test/java/com/testapp/clipboard/ClipboardSmokeView.java create mode 100644 spring/src/test/java/com/vaadin/browserless/ClipboardSmokeTest.java diff --git a/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java b/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java index 9f75b9f4..edfa1b5d 100644 --- a/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java +++ b/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java @@ -29,12 +29,15 @@ import com.vaadin.browserless.internal.Routes; import com.vaadin.browserless.locator.Locators; import com.vaadin.browserless.mocks.MockedUI; +import com.vaadin.browserless.trigger.TriggerSimulation; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.HasElement; import com.vaadin.flow.component.Key; import com.vaadin.flow.component.KeyModifier; import com.vaadin.flow.component.UI; import com.vaadin.flow.router.HasUrlParameter; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.shared.Registration; /** * Abstract base for browserless JUnit 5 extensions. Holds all shared state and @@ -50,6 +53,7 @@ abstract class AbstractBrowserlessExtension // Runtime state private TestSignalEnvironment signalsTestEnvironment; + private Registration triggerArmingRegistration; private Runnable cleanupAction; // --- Protected builder helpers --- @@ -100,6 +104,10 @@ private void standaloneCleanup() { signalsTestEnvironment.unregister(); signalsTestEnvironment = null; } + if (triggerArmingRegistration != null) { + triggerArmingRegistration.remove(); + triggerArmingRegistration = null; + } MockVaadin.tearDown(); } @@ -132,6 +140,8 @@ private void standaloneInit(Class testClass) { Routes routes = RouteDiscovery.discover(packages); MockVaadin.setup(routes, MockedUI::new, services); signalsTestEnvironment = TestSignalEnvironment.register(); + triggerArmingRegistration = TriggerSimulation + .install(VaadinService.getCurrent()); } // --- Testing DSL --- diff --git a/quarkus/src/test/java/com/example/base/ClipboardSmokeView.java b/quarkus/src/test/java/com/example/base/ClipboardSmokeView.java new file mode 100644 index 00000000..f632f786 --- /dev/null +++ b/quarkus/src/test/java/com/example/base/ClipboardSmokeView.java @@ -0,0 +1,46 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.example.base; + +import com.vaadin.flow.component.clipboard.Clipboard; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.router.Route; + +/** + * Test fixture wiring both a copy ({@code writeText}) and a read + * ({@code readText}) clipboard binding to buttons, so a browserless smoke test + * can verify trigger/action simulation end to end. + */ +@Route("clipboard-smoke") +public class ClipboardSmokeView extends Div { + + public static final String COPY_TEXT = "smoke-copied-value"; + + public final NativeButton copy = new NativeButton("Copy"); + public final NativeButton paste = new NativeButton("Paste"); + + public String copied; + public String pasted; + + public ClipboardSmokeView() { + Clipboard.onClick(copy).writeText(COPY_TEXT, c -> copied = c, e -> { + }); + Clipboard.onClick(paste).readText(t -> pasted = t, e -> { + }); + add(copy, paste); + } +} diff --git a/quarkus/src/test/java/com/vaadin/browserless/quarkus/ClipboardSmokeTest.java b/quarkus/src/test/java/com/vaadin/browserless/quarkus/ClipboardSmokeTest.java new file mode 100644 index 00000000..2b5ddaf3 --- /dev/null +++ b/quarkus/src/test/java/com/vaadin/browserless/quarkus/ClipboardSmokeTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless.quarkus; + +import com.example.base.ClipboardSmokeView; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.clipboard.ClipboardSimulator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Smoke test that Flow trigger/action simulation works under + * {@link QuarkusBrowserlessTest}: clicking a clipboard-bound button fires the + * client-side trigger, so the {@link ClipboardSimulator} and the application's + * callbacks behave as in a browser. + */ +@QuarkusTest +@ViewPackages(packages = "com.example") +class ClipboardSmokeTest extends QuarkusBrowserlessTest { + + @Test + void clickingCopy_firesWriteTrigger() { + ClipboardSmokeView view = navigate(ClipboardSmokeView.class); + + test(view.copy).click(); + + assertEquals(ClipboardSmokeView.COPY_TEXT, + ClipboardSimulator.current().text()); + assertEquals(ClipboardSmokeView.COPY_TEXT, view.copied); + } + + @Test + void clickingPaste_firesReadTrigger() { + ClipboardSmokeView view = navigate(ClipboardSmokeView.class); + ClipboardSimulator.current().setText("pasted-value"); + + test(view.paste).click(); + + assertEquals("pasted-value", view.pasted); + } +} diff --git a/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java b/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java index 7aee5b29..3daac514 100644 --- a/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java +++ b/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java @@ -27,13 +27,16 @@ import com.vaadin.browserless.internal.MockVaadin; import com.vaadin.browserless.internal.Routes; import com.vaadin.browserless.mocks.MockedUI; +import com.vaadin.browserless.trigger.TriggerSimulation; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.HasElement; import com.vaadin.flow.component.Key; import com.vaadin.flow.component.KeyModifier; import com.vaadin.flow.component.UI; import com.vaadin.flow.router.HasUrlParameter; +import com.vaadin.flow.server.VaadinService; import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.shared.Registration; /** * Base class for browserless tests. @@ -54,6 +57,8 @@ public abstract class BaseBrowserlessTest { private TestSignalEnvironment signalsTestEnvironment; + private Registration triggerArmingRegistration; + protected synchronized Routes discoverRoutes() { return discoverRoutes(scanPackages()); } @@ -81,6 +86,14 @@ protected void initVaadinEnvironment() { protected void initSignalsSupport() { signalsTestEnvironment = TestSignalEnvironment.register(); + // Observe trigger arming for this environment, so client-side triggers + // (e.g. Clipboard bindings) armed during navigation are recorded for + // simulation. Removed in cleanVaadinEnvironment(). Runs here — the + // point + // every init path (plain, Spring, Quarkus) reaches — before the test + // navigates. + triggerArmingRegistration = TriggerSimulation + .install(VaadinService.getCurrent()); } /** @@ -123,6 +136,10 @@ protected void cleanVaadinEnvironment() { signalsTestEnvironment.unregister(); signalsTestEnvironment = null; } + if (triggerArmingRegistration != null) { + triggerArmingRegistration.remove(); + triggerArmingRegistration = null; + } MockVaadin.tearDown(); } diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java index 791409cd..48e30f79 100644 --- a/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java +++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java @@ -33,11 +33,13 @@ import com.vaadin.browserless.internal.UIFactory; import com.vaadin.browserless.mocks.MockVaadinServlet; import com.vaadin.browserless.mocks.MockedUI; +import com.vaadin.browserless.trigger.TriggerSimulation; import com.vaadin.flow.component.Component; import com.vaadin.flow.server.VaadinRequest; import com.vaadin.flow.server.VaadinService; import com.vaadin.flow.server.VaadinServlet; import com.vaadin.flow.server.VaadinServletService; +import com.vaadin.flow.shared.Registration; /** * Application-level context for multi-user browserless testing. @@ -83,6 +85,7 @@ public class BrowserlessApplicationContext implements AutoCloseable { private final List closeHooks; private final List users = new ArrayList<>(); private TestSignalEnvironment signalsTestEnvironment; + private Registration triggerArmingRegistration; private RequestContextHandler requestContextHandler; private boolean closed; @@ -95,6 +98,11 @@ public class BrowserlessApplicationContext implements AutoCloseable { // here so it covers session-init listeners fired by // BrowserlessUserContext. this.signalsTestEnvironment = TestSignalEnvironment.register(); + // Observe trigger arming for this environment before any window/UI is + // created, so client-side triggers armed during navigation are recorded + // for simulation. Bound to this environment's service so it ignores + // armings from other (concurrent) environments. Removed in close(). + this.triggerArmingRegistration = TriggerSimulation.install(service); } /** @@ -268,6 +276,10 @@ public void close() { signalsTestEnvironment.unregister(); signalsTestEnvironment = null; } + if (triggerArmingRegistration != null) { + triggerArmingRegistration.remove(); + triggerArmingRegistration = null; + } MockVaadin.fireServiceDestroy(service); VaadinService.setCurrent(null); List hookFailures = new ArrayList<>(); diff --git a/shared/src/main/java/com/vaadin/browserless/Clickable.java b/shared/src/main/java/com/vaadin/browserless/Clickable.java index 639ffc48..7595b5ab 100644 --- a/shared/src/main/java/com/vaadin/browserless/Clickable.java +++ b/shared/src/main/java/com/vaadin/browserless/Clickable.java @@ -15,6 +15,7 @@ */ package com.vaadin.browserless; +import com.vaadin.browserless.trigger.TriggerSimulation; import com.vaadin.flow.component.ClickEvent; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentUtil; @@ -111,5 +112,9 @@ default void click(int button, MetaKeys metaKeys) { new ClickEvent<>(component, true, 0, 0, 0, 0, 0, button, metaKeys.isCtrl(), metaKeys.isShift(), metaKeys.isAlt(), metaKeys.isMeta())); + // A real browser also runs any client-side click triggers on this + // component (e.g. Clipboard.onClick bindings) during the gesture; + // reproduce their server-observable effect here. + TriggerSimulation.fireClick(component, button, metaKeys); } } diff --git a/shared/src/main/java/com/vaadin/browserless/trigger/ActionSimulator.java b/shared/src/main/java/com/vaadin/browserless/trigger/ActionSimulator.java new file mode 100644 index 00000000..509c6e8d --- /dev/null +++ b/shared/src/main/java/com/vaadin/browserless/trigger/ActionSimulator.java @@ -0,0 +1,46 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless.trigger; + +import com.vaadin.flow.component.trigger.internal.Action; +import com.vaadin.flow.component.trigger.internal.Trigger; + +/** + * Reproduces, on the server, the server-observable effect of an {@link Action} + * when its trigger fires — without a browser. Registered per action type with + * {@link TriggerSimulation} and invoked by + * {@link TriggerSimulation#fire(com.vaadin.flow.component.Component, String, tools.jackson.databind.node.ObjectNode)}. + * + * @param + * the action type this simulator handles + * @since 1.1 + */ +@FunctionalInterface +public interface ActionSimulator { + + /** + * Simulates the given action firing on the given trigger. + * + * @param action + * the action to simulate, not {@code null} + * @param trigger + * the trigger the action was armed on, not {@code null} + * @param context + * the simulation context (event payload, UI, input evaluation), + * not {@code null} + */ + void simulate(A action, Trigger trigger, SimulationContext context); +} diff --git a/shared/src/main/java/com/vaadin/browserless/trigger/ClipboardActionSimulators.java b/shared/src/main/java/com/vaadin/browserless/trigger/ClipboardActionSimulators.java new file mode 100644 index 00000000..85146dcb --- /dev/null +++ b/shared/src/main/java/com/vaadin/browserless/trigger/ClipboardActionSimulators.java @@ -0,0 +1,106 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless.trigger; + +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JsonNode; + +import com.vaadin.flow.component.clipboard.ClipboardPayload; +import com.vaadin.flow.component.clipboard.ClipboardSimulator; +import com.vaadin.flow.component.trigger.internal.Action; +import com.vaadin.flow.component.trigger.internal.PromiseAction.Error; +import com.vaadin.flow.component.trigger.internal.ReadFromClipboardAction; +import com.vaadin.flow.component.trigger.internal.Trigger; +import com.vaadin.flow.component.trigger.internal.WriteToClipboardAction; +import com.vaadin.flow.internal.JacksonUtils; + +/** + * Built-in {@link ActionSimulator}s that back {@code Clipboard} against the + * per-UI {@link ClipboardSimulator}: a write stores into it (and reports the + * copied string), a read serves from it. Both honour the clipboard's denial + * flags by delivering a {@code NotAllowedError} instead. Image writes have no + * server-evaluable input and are rejected outright. + */ +final class ClipboardActionSimulators { + + private ClipboardActionSimulators() { + } + + static void registerInto(Registrar registrar) { + registrar.register(WriteToClipboardAction.class, + ClipboardActionSimulators::simulateWrite); + registrar.register(ReadFromClipboardAction.class, + ClipboardActionSimulators::simulateRead); + } + + /** + * Bridges to {@link TriggerSimulation#register} without a static import. + */ + @FunctionalInterface + interface Registrar { + void register(Class type, + ActionSimulator simulator); + } + + private static void simulateWrite(WriteToClipboardAction action, + Trigger trigger, SimulationContext context) { + if (action.getImageInput() != null && action.getTextInput() == null + && action.getHtmlInput() == null) { + throw new UnsupportedOperationException( + "image clipboard is not supported in browserless tests"); + } + ClipboardSimulator clipboard = ClipboardSimulator + .forUI(context.getUI()); + if (clipboard.isWriteDenied()) { + action.deliverError(trigger, notAllowed("write")); + return; + } + String text = asString(context.evaluate(action.getTextInput())); + String html = asString(context.evaluate(action.getHtmlInput())); + clipboard.setContents(text, html); + // onCopied receives text/plain if present, otherwise text/html. + String copied = text != null ? text : html; + action.deliverSuccess(trigger, copied == null ? JacksonUtils.nullNode() + : JacksonUtils.writeValue(copied)); + } + + private static void simulateRead(ReadFromClipboardAction action, + Trigger trigger, SimulationContext context) { + ClipboardSimulator clipboard = ClipboardSimulator + .forUI(context.getUI()); + if (clipboard.isReadDenied()) { + action.deliverError(trigger, notAllowed("read")); + return; + } + if (clipboard.isEmpty()) { + // Empty clipboard => onPayload(null). + action.deliverSuccess(trigger, JacksonUtils.nullNode()); + return; + } + action.deliverSuccess(trigger, JacksonUtils.writeValue( + new ClipboardPayload(clipboard.text(), clipboard.html()))); + } + + private static Error notAllowed(String operation) { + return new Error("NotAllowedError", + "Clipboard " + operation + " denied in browserless test"); + } + + @Nullable + private static String asString(@Nullable JsonNode node) { + return node == null || node.isNull() ? null : node.asString(); + } +} diff --git a/shared/src/main/java/com/vaadin/browserless/trigger/SimulationContext.java b/shared/src/main/java/com/vaadin/browserless/trigger/SimulationContext.java new file mode 100644 index 00000000..ddf94f1b --- /dev/null +++ b/shared/src/main/java/com/vaadin/browserless/trigger/SimulationContext.java @@ -0,0 +1,72 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless.trigger; + +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.trigger.internal.Action; + +/** + * The context handed to an {@link ActionSimulator} when a trigger is fired: the + * simulated event payload, the owning {@link UI}, and a helper to evaluate an + * action's {@link Action.Input inputs} server-side. + * + * @since 1.1 + */ +public final class SimulationContext { + + private final UI ui; + private final ObjectNode eventData; + + SimulationContext(UI ui, ObjectNode eventData) { + this.ui = ui; + this.eventData = eventData; + } + + /** + * The UI the fired trigger belongs to. + * + * @return the UI, never {@code null} + */ + public UI getUI() { + return ui; + } + + /** + * The simulated event payload supplied to + * {@link TriggerSimulation#fire(com.vaadin.flow.component.Component, String, ObjectNode)}. + * + * @return the event data, never {@code null} + */ + public ObjectNode getEventData() { + return eventData; + } + + /** + * Evaluates the given input server-side, reproducing the value it would + * produce on the client at fire time (see {@link Action.Input#evaluate}). + * + * @param input + * the input to evaluate, not {@code null} + * @return the input's value as a {@link JsonNode} + */ + public JsonNode evaluate(Action.@Nullable Input input) { + return input == null ? null : input.evaluate(eventData); + } +} diff --git a/shared/src/main/java/com/vaadin/browserless/trigger/TriggerSimulation.java b/shared/src/main/java/com/vaadin/browserless/trigger/TriggerSimulation.java new file mode 100644 index 00000000..52bdcd9b --- /dev/null +++ b/shared/src/main/java/com/vaadin/browserless/trigger/TriggerSimulation.java @@ -0,0 +1,289 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless.trigger; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.jackson.databind.node.ObjectNode; + +import com.vaadin.browserless.MetaKeys; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.trigger.internal.Action; +import com.vaadin.flow.component.trigger.internal.DomEventTrigger; +import com.vaadin.flow.component.trigger.internal.Trigger; +import com.vaadin.flow.component.trigger.internal.Triggers; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.internal.JacksonUtils; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.shared.Registration; + +/** + * Browserless simulation of Flow's client-side trigger/action API. + *

+ * Triggers install client-side JavaScript, so a server-side click never reaches + * them. This engine bridges the gap without a browser: it observes trigger + * arming through {@link Triggers#addArmingListener} (recording, per {@link UI}, + * which actions are wired to which host and DOM event), and {@link #fire} then + * reproduces the server-observable effect of each matching action through a + * registered {@link ActionSimulator}. + *

+ * The arming observer is installed per mocked environment via {@link #install} + * and removed when that environment is torn down — mirroring how + * {@code TestSignalEnvironment} is registered/unregistered — so no test-only + * listener is left behind on the process-global {@link Triggers} registry + * between tests. Built-in action simulators, being stateless, are registered + * once on class initialisation. + *

+ * Test tooling drives this indirectly — e.g. clicking a button through a + * component tester fires {@code "click"} here in addition to the server-side + * click event. + *

+ * For internal use only. May be renamed or removed in a future release. + * + * @since 1.1 + */ +public final class TriggerSimulation { + + private static final Logger LOGGER = LoggerFactory + .getLogger(TriggerSimulation.class); + + private static final Map, ActionSimulator> SIMULATORS = new ConcurrentHashMap<>(); + + static { + // Stateless universal simulators; register once for the process. + ClipboardActionSimulators.registerInto(TriggerSimulation::register); + } + + private TriggerSimulation() { + } + + /** + * Installs a trigger-arming observer for the given mocked environment's + * {@link VaadinService} and returns a {@link Registration} that removes it. + * Call at environment setup, before the application arms any triggers (i.e. + * before navigation), and remove it at teardown. + *

+ * {@link Triggers} scopes the observer to {@code owner}'s service, so it is + * notified only of armings from this environment — concurrent environments + * (parallel tests, multiple apps) stay isolated without any filtering here. + * + * @param owner + * the service of the environment this observer belongs to, not + * {@code null} + * @return a registration that removes the observer on teardown, never + * {@code null} + */ + public static Registration install(VaadinService owner) { + return Triggers.addArmingListener(owner, new Triggers.ArmingListener() { + @Override + public void onArmed(Trigger trigger, List actions) { + UI ui = uiOf(trigger); + if (ui != null) { + registryFor(ui).armed(trigger, actions); + } + } + + @Override + public void onDisarmed(Trigger trigger) { + UI ui = uiOf(trigger); + if (ui != null) { + registryFor(ui).disarmed(trigger); + } + } + }); + } + + /** + * Registers a simulator for an action type, replacing any previous one. + * + * @param actionType + * the action class to handle, not {@code null} + * @param simulator + * the simulator, not {@code null} + * @param + * the action type + */ + public static void register(Class actionType, + ActionSimulator simulator) { + SIMULATORS.put(actionType, simulator); + } + + /** + * Fires the DOM-event triggers armed on {@code host} that match + * {@code eventType}, running each of their actions through its simulator. + * Unknown action types (e.g. pure client-side ones with no server effect) + * are ignored. + * + * @param host + * the component the gesture targets, not {@code null} + * @param eventType + * the DOM event name (e.g. {@code "click"}), not {@code null} + * @param eventData + * the simulated event payload, not {@code null} + */ + public static void fire(Component host, String eventType, + ObjectNode eventData) { + UI ui = host.getUI().orElse(UI.getCurrent()); + if (ui == null) { + return; + } + Registry registry = ComponentUtil.getData(ui, Registry.class); + if (registry == null) { + return; + } + SimulationContext context = new SimulationContext(ui, eventData); + for (Registry.Armed armed : registry.armedOn(host.getElement())) { + if (armed.trigger() instanceof DomEventTrigger dom + && eventType.equals(dom.getEventName())) { + armed.actions().forEach( + action -> dispatch(action, armed.trigger(), context)); + } + } + } + + /** + * Fires the {@code "click"} triggers armed on {@code host} as a left-button + * click with no modifier keys. Shorthand for + * {@link #fireClick(Component, int, MetaKeys)}. + * + * @param host + * the component the click targets, not {@code null} + */ + public static void fireClick(Component host) { + fireClick(host, 0, new MetaKeys()); + } + + /** + * Fires the {@code "click"} triggers armed on {@code host} with the given + * mouse button and modifier-key state, building the event payload the + * {@code MouseEvent}-based inputs expect. This is the single place callers + * (component click, context-menu item click, …) go through to reproduce a + * client-side click gesture. + * + * @param host + * the component the click targets, not {@code null} + * @param button + * the mouse button ({@code 0} left, {@code 1} middle, {@code 2} + * right) + * @param metaKeys + * the modifier keys held during the click, not {@code null} + */ + public static void fireClick(Component host, int button, + MetaKeys metaKeys) { + ObjectNode eventData = JacksonUtils.createObjectNode(); + eventData.put("button", button); + eventData.put("shiftKey", metaKeys.isShift()); + eventData.put("ctrlKey", metaKeys.isCtrl()); + eventData.put("altKey", metaKeys.isAlt()); + eventData.put("metaKey", metaKeys.isMeta()); + fire(host, "click", eventData); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static void dispatch(Action action, Trigger trigger, + SimulationContext context) { + ActionSimulator simulator = SIMULATORS.get(action.getClass()); + if (simulator == null) { + LOGGER.debug("No ActionSimulator registered for {}; ignoring", + action.getClass().getName()); + return; + } + simulator.simulate(action, trigger, context); + } + + private static UI uiOf(Trigger trigger) { + UI current = UI.getCurrent(); + if (current != null) { + return current; + } + return trigger.getHost().getComponent().flatMap(Component::getUI) + .orElse(null); + } + + private static Registry registryFor(UI ui) { + Registry registry = ComponentUtil.getData(ui, Registry.class); + if (registry == null) { + registry = new Registry(); + ComponentUtil.setData(ui, Registry.class, registry); + } + return registry; + } + + /** + * Per-UI record of armed triggers, keyed by host element. Stored as UI data + * so it is discarded with the UI. + *

+ * Implements {@link Serializable} because it lives in the UI's attribute + * map and must not break a session-serialization test. The armed-trigger + * graph (triggers, actions, elements) is held {@code transient}: it is + * test-only state that should not ride the serialized session, so a + * deserialized registry simply starts empty. + */ + static final class Registry implements Serializable { + + record Armed(Trigger trigger, List actions) { + } + + // host element -> (trigger -> accumulated actions, insertion-ordered) + private transient Map>> byHost = new LinkedHashMap<>(); + + private void readObject(ObjectInputStream in) + throws IOException, ClassNotFoundException { + in.defaultReadObject(); + byHost = new LinkedHashMap<>(); + } + + void armed(Trigger trigger, List actions) { + byHost.computeIfAbsent(trigger.getHost(), + h -> new LinkedHashMap<>()) + .computeIfAbsent(trigger, t -> new ArrayList<>()) + .addAll(actions); + } + + void disarmed(Trigger trigger) { + LinkedHashMap> triggers = byHost + .get(trigger.getHost()); + if (triggers != null) { + triggers.remove(trigger); + if (triggers.isEmpty()) { + byHost.remove(trigger.getHost()); + } + } + } + + List armedOn(Element host) { + LinkedHashMap> triggers = byHost.get(host); + if (triggers == null) { + return List.of(); + } + List result = new ArrayList<>(); + triggers.forEach((trigger, actions) -> result + .add(new Armed(trigger, List.copyOf(actions)))); + return result; + } + } +} diff --git a/shared/src/main/java/com/vaadin/flow/component/clipboard/ClipboardSimulator.java b/shared/src/main/java/com/vaadin/flow/component/clipboard/ClipboardSimulator.java new file mode 100644 index 00000000..14caa472 --- /dev/null +++ b/shared/src/main/java/com/vaadin/flow/component/clipboard/ClipboardSimulator.java @@ -0,0 +1,207 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.component.clipboard; + +import java.io.Serializable; + +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.component.UI; + +/** + * Browserless test driver for the {@link Clipboard} API: a per-window stand-in + * for the browser's system clipboard. Tests describe the clipboard's contents + * and access state, then exercise the application, which reads and writes it + * through {@code Clipboard} bindings. + *

+ * A copy binding ({@code Clipboard.onClick(button).writeText(field)}) is + * exercised by clicking the button through its component tester: the click + * fires the underlying trigger, so the value lands here and can be asserted + * with {@link #text()} / {@link #html()}. A read binding + * ({@code Clipboard.onClick(button).readText(...)}) is exercised the same way + * after seeding the clipboard with {@link #setText}, {@link #setHtml}, or + * {@link #setContents}; the application's callback then receives the contents. + *

+ * {@link #denyRead()}, {@link #denyWrite()}, and {@link #denyAccess()} simulate + * the browser refusing clipboard access, so the application's {@code onError} + * callback receives a {@code NotAllowedError}. Image clipboard payloads are not + * supported. + *

+ * Obtain via {@link #current()} or {@link #forUI(UI)}: idempotent, both create + * the simulator on the first call and return the same instance afterward. The + * clipboard is scoped to a single window (UI); windows do not share it. + * + * @since 1.1 + */ +public final class ClipboardSimulator implements Serializable { + + private @Nullable String text; + private @Nullable String html; + private boolean readDenied; + private boolean writeDenied; + + private ClipboardSimulator() { + } + + /** + * Returns the simulator bound to {@link UI#getCurrent()}. + * + * @return the simulator for the current UI, never {@code null} + */ + public static ClipboardSimulator current() { + return forUI(UI.getCurrent()); + } + + /** + * Returns the simulator bound to the given UI, creating and storing one on + * first access. + * + * @param ui + * the UI to query, not {@code null} + * @return the UI's clipboard simulator, never {@code null} + */ + public static ClipboardSimulator forUI(UI ui) { + ClipboardSimulator simulator = ComponentUtil.getData(ui, + ClipboardSimulator.class); + if (simulator == null) { + simulator = new ClipboardSimulator(); + ComponentUtil.setData(ui, ClipboardSimulator.class, simulator); + } + return simulator; + } + + /** + * The current {@code text/plain} contents, or {@code null} if none. + * + * @return the plain-text contents, or {@code null} + */ + @Nullable + public String text() { + return text; + } + + /** + * The current {@code text/html} contents, or {@code null} if none. + * + * @return the HTML contents, or {@code null} + */ + @Nullable + public String html() { + return html; + } + + /** + * Whether the clipboard holds no contents. + * + * @return {@code true} if empty + */ + public boolean isEmpty() { + return text == null && html == null; + } + + /** + * Seeds the {@code text/plain} contents, leaving the HTML slot unchanged. + * + * @param text + * the plain text, or {@code null} to clear the slot + */ + public void setText(@Nullable String text) { + this.text = text; + } + + /** + * Seeds the {@code text/html} contents, leaving the plain-text slot + * unchanged. + * + * @param html + * the HTML, or {@code null} to clear the slot + */ + public void setHtml(@Nullable String html) { + this.html = html; + } + + /** + * Seeds both {@code text/plain} and {@code text/html} contents at once. + * + * @param text + * the plain text, or {@code null} + * @param html + * the HTML, or {@code null} + */ + public void setContents(@Nullable String text, @Nullable String html) { + this.text = text; + this.html = html; + } + + /** + * Clears all contents. + */ + public void clear() { + text = null; + html = null; + } + + /** + * Makes subsequent clipboard reads fail with a {@code NotAllowedError}, + * simulating a denied {@code clipboard-read} permission. + */ + public void denyRead() { + readDenied = true; + } + + /** + * Makes subsequent clipboard writes fail with a {@code NotAllowedError}. + */ + public void denyWrite() { + writeDenied = true; + } + + /** + * Makes subsequent clipboard reads and writes fail with a + * {@code NotAllowedError}. + */ + public void denyAccess() { + denyRead(); + denyWrite(); + } + + /** + * Restores clipboard access after a {@code deny*} call. + */ + public void grantAccess() { + readDenied = false; + writeDenied = false; + } + + /** + * Whether reads are currently denied. + * + * @return {@code true} if reads are denied + */ + public boolean isReadDenied() { + return readDenied; + } + + /** + * Whether writes are currently denied. + * + * @return {@code true} if writes are denied + */ + public boolean isWriteDenied() { + return writeDenied; + } +} diff --git a/shared/src/main/java/com/vaadin/flow/component/contextmenu/ContextMenuTester.java b/shared/src/main/java/com/vaadin/flow/component/contextmenu/ContextMenuTester.java index 16bc05d9..2f5de430 100644 --- a/shared/src/main/java/com/vaadin/flow/component/contextmenu/ContextMenuTester.java +++ b/shared/src/main/java/com/vaadin/flow/component/contextmenu/ContextMenuTester.java @@ -23,6 +23,7 @@ import com.vaadin.browserless.ComponentTester; import com.vaadin.browserless.Tests; import com.vaadin.browserless.internal.PrettyPrintTreeKt; +import com.vaadin.browserless.trigger.TriggerSimulation; import com.vaadin.flow.component.ClickEvent; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentUtil; @@ -517,6 +518,10 @@ private void clickMenuItem(MenuItem menuItem) { } ComponentUtil.fireEvent(menuItem, new ClickEvent<>(menuItem, true, 0, 0, 0, 0, 1, 0, false, false, false, false)); + // A real browser also runs any client-side click triggers on the item + // (e.g. Clipboard.onClick bindings) during the gesture; reproduce their + // server-observable effect here, as Clickable#click does for buttons. + TriggerSimulation.fireClick(menuItem); } private void attachMenuToUI() { diff --git a/shared/src/test/java/com/vaadin/browserless/ClipboardSimulationTest.java b/shared/src/test/java/com/vaadin/browserless/ClipboardSimulationTest.java new file mode 100644 index 00000000..a4046373 --- /dev/null +++ b/shared/src/test/java/com/vaadin/browserless/ClipboardSimulationTest.java @@ -0,0 +1,152 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless; + +import org.junit.jupiter.api.Test; + +import com.vaadin.flow.component.clipboard.Clipboard; +import com.vaadin.flow.component.clipboard.ClipboardSimulator; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.trigger.internal.PromiseAction.Error; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end browserless simulation of Flow's {@code Clipboard} write/read + * bindings: clicking a bound button fires the client-side trigger and the + * virtual clipboard + application callbacks behave as in a browser. + */ +class ClipboardSimulationTest { + + static class ClipboardView extends Div { + final NativeButton copy = new NativeButton("copy"); + final NativeButton paste = new NativeButton("paste"); + String copied; + int copyCount; + Error copyError; + String pasted; + Error pasteError; + + ClipboardView() { + Clipboard.onClick(copy).writeText("hello", c -> { + copied = c; + copyCount++; + }, e -> copyError = e); + Clipboard.onClick(paste).readText(t -> pasted = t, + e -> pasteError = e); + add(copy, paste); + } + } + + private static BrowserlessUIContext open() { + return BrowserlessUIContext.forComponent(ClipboardView::new); + } + + private static ClipboardSimulator clipboard(BrowserlessUIContext window) { + return ClipboardSimulator.forUI(window.getUI()); + } + + @Test + void click_copyButton_writesToClipboardAndRunsOnCopied() { + try (BrowserlessUIContext window = open()) { + ClipboardView view = window.find(ClipboardView.class).single(); + + window.test(view.copy).click(); + + assertEquals("hello", clipboard(window).text()); + assertEquals("hello", view.copied); + assertNull(view.copyError); + } + } + + @Test + void click_pasteButton_readsSeededClipboardAndRunsOnPayload() { + try (BrowserlessUIContext window = open()) { + ClipboardView view = window.find(ClipboardView.class).single(); + clipboard(window).setText("world"); + + window.test(view.paste).click(); + + assertEquals("world", view.pasted); + assertNull(view.pasteError); + } + } + + @Test + void deniedRead_runsOnError() { + try (BrowserlessUIContext window = open()) { + ClipboardView view = window.find(ClipboardView.class).single(); + clipboard(window).setText("world"); + clipboard(window).denyRead(); + + window.test(view.paste).click(); + + assertNull(view.pasted); + assertEquals("NotAllowedError", view.pasteError.name()); + } + } + + @Test + void deniedWrite_runsOnError() { + try (BrowserlessUIContext window = open()) { + ClipboardView view = window.find(ClipboardView.class).single(); + clipboard(window).denyWrite(); + + window.test(view.copy).click(); + + assertNull(view.copied); + assertEquals("NotAllowedError", view.copyError.name()); + assertNull(clipboard(window).text()); + } + } + + @Test + void concurrentEnvironments_areScopedToTheirOwnService() { + ClipboardView[] a = new ClipboardView[1]; + ClipboardView[] b = new ClipboardView[1]; + try (BrowserlessUIContext w1 = BrowserlessUIContext + .forComponent(() -> a[0] = new ClipboardView()); + BrowserlessUIContext w2 = BrowserlessUIContext + .forComponent(() -> b[0] = new ClipboardView())) { + // w2's view was armed while BOTH environments' arming observers + // were + // installed. If observers weren't scoped to their own service, both + // would record into w2 and the action would fire twice. + w2.test(b[0].copy).click(); + assertEquals(1, b[0].copyCount, "action must fire exactly once"); + assertEquals("hello", ClipboardSimulator.forUI(w2.getUI()).text()); + + // The other environment is untouched. + assertEquals(0, a[0].copyCount); + assertTrue(ClipboardSimulator.forUI(w1.getUI()).isEmpty()); + } + } + + @Test + void clipboardSimulator_isSerializable() { + try (BrowserlessUIContext window = open()) { + ClipboardSimulator clipboard = clipboard(window); + clipboard.setContents("plain", "rich"); + clipboard.denyWrite(); + // Stored in the UI attribute map, so it must not break session + // serialization. + SerializationDebugUtil.assertSerializable(clipboard); + } + } +} diff --git a/shared/src/test/java/com/vaadin/browserless/trigger/TriggerSimulationSerializationTest.java b/shared/src/test/java/com/vaadin/browserless/trigger/TriggerSimulationSerializationTest.java new file mode 100644 index 00000000..92c3047a --- /dev/null +++ b/shared/src/test/java/com/vaadin/browserless/trigger/TriggerSimulationSerializationTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless.trigger; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.trigger.internal.Action; +import com.vaadin.flow.component.trigger.internal.ClickTrigger; +import com.vaadin.flow.component.trigger.internal.SetPropertyAction; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The registry and virtual clipboard are stored in the UI attribute map, so + * they must not break a session-serialization test. + */ +class TriggerSimulationSerializationTest { + + @Test + void registry_serializes_droppingArmedGraph() throws Exception { + Div host = new Div(); + ClickTrigger trigger = new ClickTrigger(host); + Action action = new SetPropertyAction<>(host, "value", "x"); + + TriggerSimulation.Registry registry = new TriggerSimulation.Registry(); + registry.armed(trigger, List.of(action)); + assertFalse(registry.armedOn(host.getElement()).isEmpty()); + + TriggerSimulation.Registry restored = roundTrip(registry); + + // The transient armed-trigger graph is not serialized; a deserialized + // registry starts empty rather than failing on the graph. + assertTrue(restored.armedOn(host.getElement()).isEmpty()); + } + + @SuppressWarnings("unchecked") + private static T roundTrip(T object) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(object); + } + try (ObjectInputStream in = new ObjectInputStream( + new ByteArrayInputStream(bytes.toByteArray()))) { + return (T) in.readObject(); + } + } +} diff --git a/spring/src/test/java/com/testapp/clipboard/ClipboardSmokeView.java b/spring/src/test/java/com/testapp/clipboard/ClipboardSmokeView.java new file mode 100644 index 00000000..332199a4 --- /dev/null +++ b/spring/src/test/java/com/testapp/clipboard/ClipboardSmokeView.java @@ -0,0 +1,46 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.testapp.clipboard; + +import com.vaadin.flow.component.clipboard.Clipboard; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.router.Route; + +/** + * Test fixture wiring both a copy ({@code writeText}) and a read + * ({@code readText}) clipboard binding to buttons, so a browserless smoke test + * can verify trigger/action simulation end to end. + */ +@Route("clipboard-smoke") +public class ClipboardSmokeView extends Div { + + public static final String COPY_TEXT = "smoke-copied-value"; + + public final NativeButton copy = new NativeButton("Copy"); + public final NativeButton paste = new NativeButton("Paste"); + + public String copied; + public String pasted; + + public ClipboardSmokeView() { + Clipboard.onClick(copy).writeText(COPY_TEXT, c -> copied = c, e -> { + }); + Clipboard.onClick(paste).readText(t -> pasted = t, e -> { + }); + add(copy, paste); + } +} diff --git a/spring/src/test/java/com/vaadin/browserless/ClipboardSmokeTest.java b/spring/src/test/java/com/vaadin/browserless/ClipboardSmokeTest.java new file mode 100644 index 00000000..7558d764 --- /dev/null +++ b/spring/src/test/java/com/vaadin/browserless/ClipboardSmokeTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless; + +import com.testapp.clipboard.ClipboardSmokeView; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ContextConfiguration; + +import com.vaadin.flow.component.clipboard.ClipboardSimulator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Smoke test that Flow trigger/action simulation works under + * {@link SpringBrowserlessTest}: clicking a clipboard-bound button fires the + * client-side trigger, so the {@link ClipboardSimulator} and the application's + * callbacks behave as in a browser. + */ +@ContextConfiguration(classes = ClipboardSmokeTest.TestConfig.class) +@ViewPackages(classes = ClipboardSmokeView.class) +class ClipboardSmokeTest extends SpringBrowserlessTest { + + @Test + void clickingCopy_firesWriteTrigger() { + ClipboardSmokeView view = navigate(ClipboardSmokeView.class); + + test(view.copy).click(); + + assertEquals(ClipboardSmokeView.COPY_TEXT, + ClipboardSimulator.current().text()); + assertEquals(ClipboardSmokeView.COPY_TEXT, view.copied); + } + + @Test + void clickingPaste_firesReadTrigger() { + ClipboardSmokeView view = navigate(ClipboardSmokeView.class); + ClipboardSimulator.current().setText("pasted-value"); + + test(view.paste).click(); + + assertEquals("pasted-value", view.pasted); + } + + @Configuration + static class TestConfig { + } +}