Skip to content

Browserless Testing for the Clipboard API #64

@heruan

Description

@heruan

Implements the same two-PR pattern used for Geolocation (#24259): port extraction in flow, plus an in-memory client and simulator in vaadin/browserless-test. Addresses the three explicit affordances from PRD vaadin/platform#8759 §Browserless testing.


Goals

From PRD 8759 §Browserless testing:

  1. Simulate the result of a clipboard write (success / failure) and verify onSuccess / onError callbacks.
  2. Simulate an incoming paste event with configurable text, HTML, and file bytes; verify paste / paste-start / paste-failed listeners and the upload-handler bytes path.
  3. Query and override availability in tests.

Non-goals: changing the public Clipboard static facade, replicating browser-side click-trusted-event guarantees in tests, or shipping a META-INF/services factory from flow itself (it lives in browserless-test, per the Geolocation precedent in #24259).


Why the Geolocation Pattern Doesn't Apply 1:1

Concern Geolocation Clipboard Decision
Facade shape Geolocation instance per UI Clipboard static final utility Keep static facade; static methods resolve a per-UI ClipboardClient via UIInternals
Lifetime One tracker / one position request at a time N copy registrations, N paste/copy/cut listeners, possibly stacked on the same element Client owns a registry keyed by element + registration ID
Bytes path None Pasted files run through UploadHandler over a real HTTP fetch Browserless client must invoke UploadHandler synthetically without HTTP
Output channel executeJs + DOM events Same + DOM property (__clipboardText) + ReturnChannelRegistration (success/error) Port must expose the return channels so tests can drive them
UNSUPPORTED source Same browser-details param v-ca browser-details param already wired (ExtendedClientDetails:512-520) Reuse UIInternals.setClipboardAvailability(...); drop "framework internal" from its Javadoc or wrap in a public seam

Changes in vaadin/flow

Two PRs, mirroring the Geolocation split. PR-A is the port extraction; PR-B is the Lookup-based factory SPI. Skipping PR-B and going straight to Lookup also works — the only reason Geolocation needed two PRs was classloader fallout from the package-private seam, which we can avoid up front.

3.1 New SPI Types (com.vaadin.flow.component.clipboard)

ClipboardClient — public interface, port for all wire operations.

public interface ClipboardClient extends Serializable {

    // --- copy-on-click ---
    CopyRegistration registerCopyText(Element trigger, String text,
            @Nullable Command onSuccess, @Nullable Command onError);
    CopyRegistration registerCopyFromSource(Element trigger, Element source,
            @Nullable Command onSuccess, @Nullable Command onError);
    CopyRegistration registerCopyImage(Element trigger, Element imageSource,
            @Nullable Command onSuccess, @Nullable Command onError);

    // --- paste ---
    PasteRegistration registerPasteListener(Element target,
            UploadHandler uploadHandler,
            @Nullable SerializableConsumer<ClipboardEvent> onComplete,
            @Nullable SerializableConsumer<ClipboardPasteStartEvent> onStart,
            @Nullable SerializableConsumer<ClipboardPasteFailedEvent> onFailed);

    // --- copy/cut DOM listener forwarding ---
    Registration registerClipboardEventListener(Element target, String eventType,
            SerializableConsumer<ClipboardEvent> listener);

    // --- availability ---
    void setAvailability(ClipboardAvailability availability);

    interface CopyRegistration extends Registration {
        void updateText(String text);   // backs ClipboardCopy.setValue
    }

    interface PasteRegistration extends Registration {
        Element target();
        UploadHandler uploadHandler();
    }
}

CopyRegistration / PasteRegistration are the public inspection types — equivalent to Geolocation's GeolocationTrackerSession / GeolocationRequest.

BrowserClipboardClient — package-private default implementation. Lifts the current executeJs / DOM-event / ReturnChannelMap code out of Clipboard.java and moves it behind the interface. Production wire behavior unchanged; Clipboard ends up as a thin facade.

ClipboardClientFactory — public SPI, resolved via com.vaadin.flow.di.Lookup.

public interface ClipboardClientFactory extends Serializable {
    ClipboardClient create(UI ui);
}

Resolution mirrors GeolocationClientFactory (#24259): VaadinService.getCurrent().getContext().getAttribute(Lookup.class).lookup(ClipboardClientFactory.class), falling back to BrowserClipboardClient when none is registered. Flow ships no META-INF/services file — production uses the fallback.

3.2 Clipboard Facade Refactor

Rewrite the static methods to delegate, keeping the public signatures byte-for-byte stable. Each static method becomes:

public static ClipboardCopy copyOnClick(Component trigger, String text) {
    Objects.requireNonNull(trigger, ...);
    ClipboardClient client = client(trigger);     // resolve via UI internals
    CopyRegistration reg = client.registerCopyText(
            trigger.getElement(), text != null ? text : "", null, null);
    return new ClipboardCopy(reg);
}

ClipboardCopy.setValue(...) and remove() delegate to the CopyRegistration. The current __clipboardText property and Element.setAttribute("__clipboard-paste-upload", uploadHandler) plumbing becomes a BrowserClipboardClient implementation detail and disappears from the public surface.

Side effect: fixes the two registration-collision bugs from the earlier review — per-registration state now lives in the client's registry, not on the element.

3.3 UIInternals Changes

The clipboardAvailabilitySignal stays in place. Add:

public ClipboardClient getClipboardClient();  // lazy-resolves via Lookup, caches per UI

Drop "For framework use only" from setClipboardAvailability(ClipboardAvailability)'s Javadoc — it's the public override path for tests, matching PRD affordance #3.

3.4 In-JAR Tests

New ClipboardClientSeamTest (parallel to GeolocationClientSeamTest) covering:

  • The Clipboard facade resolves the client via Lookup.
  • A registered ClipboardClientFactory overrides the default.
  • Without a factory the fallback is BrowserClipboardClient.
  • CopyRegistration.updateText(...) propagates to ClipboardCopy.setValue(...).

Existing ClipboardTest keeps its wire-protocol assertions to pin production behavior. The Clipboard.PasteState inner class becomes a BrowserClipboardClient implementation detail (still package-private, still unit-tested for its dispatch order).


Changes in vaadin/browserless-test

New package com.vaadin.flow.component.clipboard under shared/.

4.1 BrowserlessClipboardClient (package-private)

Implements ClipboardClient with no DOM at all. Maintains:

  • Map<RegistrationId, CopyHandle> for active copy registrations, each carrying: trigger element ref, current text or live source ref, success/error commands.
  • Map<Element, PasteHandle> for paste registrations, each carrying: upload handler, the three listener callbacks.
  • Map<Element, List<SerializableConsumer<ClipboardEvent>>> for copy/cut listeners.
  • Latest ClipboardAvailability (mirror of UIInternals signal).

Files arrive as in-memory byte[]s. The client invokes UploadHandler.handleUploadRequest with a synthetic UploadEvent backed by a ByteArrayInputStream and a stub VaadinRequest/VaadinResponse. If wiring a full UploadEvent is too brittle, fall back to special-casing InMemoryUploadHandler and TemporaryFileUploadHandler — both are public and easily fed via reflection-free pathways. See open question §6.

4.2 BrowserlessClipboardClientFactory

public final class BrowserlessClipboardClientFactory
        implements ClipboardClientFactory {
    @Override
    public ClipboardClient create(UI ui) {
        return ClipboardSimulator.forUi(ui).client();
    }
}

Registered via META-INF/services/com.vaadin.flow.component.clipboard.ClipboardClientFactory.

4.3 ClipboardSimulator (public test API)

Idempotent forUi(UI); one instance per UI, stored via ComponentUtil.setData(...), same pattern as GeolocationTestController.

public final class ClipboardSimulator {

    public static ClipboardSimulator forUi(UI ui);

    // --- availability (PRD affordance #3) ---
    public ClipboardAvailability currentAvailability();
    public void setAvailability(ClipboardAvailability availability);

    // --- copy (PRD affordance #1) ---
    public List<CopyHandleInspection> activeCopyHandles();
    public CopyHandleInspection copyHandle(Component trigger);
    public void simulateCopyClick(Component trigger);
    public void simulateCopySuccess(Component trigger);
    public void simulateCopyError(Component trigger, String reason);

    // --- paste (PRD affordance #2) ---
    public List<PasteSessionInspection> activePasteSessions();
    public PasteBuilder pasteInto(Component target);

    public interface PasteBuilder {
        PasteBuilder text(String text);
        PasteBuilder html(String html);
        PasteBuilder file(String name, String mimeType, byte[] data);
        PasteBuilder failFile(String name, String mimeType, long size, String reason);
        void simulate();   // fires start → uploads → complete
    }

    // --- copy/cut DOM events ---
    public void simulateCopy(Component target, String text, @Nullable String html);
    public void simulateCut(Component target, String text, @Nullable String html);
}

Inspection types:

public record CopyHandleInspection(
        Component trigger, CopyKind kind,
        @Nullable String currentText, @Nullable Component sourceComponent,
        @Nullable Component imageSource,
        boolean hasSuccessCallback, boolean hasErrorCallback) {}

public enum CopyKind { TEXT, FROM_SOURCE, IMAGE }

public record PasteSessionInspection(
        Component target,
        Class<? extends UploadHandler> uploadHandlerType,
        boolean hasStartListener, boolean hasFailedListener,
        boolean hasCompleteListener) {}

PasteBuilder.simulate() runs the same dispatch order the real client guarantees: fire paste-start synchronously → run each file through the upload handler (or emit paste-failed for failFile entries) → fire paste-complete. This exercises both the listener wiring and the PasteState succeeded-count contract.

4.4 Tests

  • ClipboardSimulatorTest — direct controller behavior: handle inspection, availability override, paste with text/HTML/files, failed-file routing, simulate-on-empty (no registrations).
  • ClipboardFacadeIntegrationTest — exercises through the real Clipboard static facade:
    • Clipboard.copyOnClick(btn, "x")simulator.simulateCopySuccess(btn) triggers nothing (no callback); simulator.copyHandle(btn).currentText() equals "x"; ClipboardCopy.setValue("y") updates the inspection.
    • With success callback: simulateCopySuccess invokes the Command.
    • copyOnClick(btn, source): simulateCopyClick(btn) reads source value live (mirrors readSourceText semantics) and fires success.
    • addPasteListener(target, listener) + simulator.pasteInto(target).text("hi").file("a.png", "image/png", bytes).simulate() → listener receives ClipboardEvent with one ClipboardFile.
    • Pluggable upload handler variant: handler receives bytes; ClipboardEvent.getFiles() is empty per current contract.
    • addPasteStartListener + addPasteFailedListener fire in the correct order.
    • addCopyListener / addCutListener driven via simulator.simulateCopy(...) / simulator.simulateCut(...).
    • Clipboard.availabilityHintSignal().peek() reflects simulator.setAvailability(UNSUPPORTED).

Target: every PRD requirement checkbox testable purely from JUnit.


Recommended PR Sequence

  1. flow PR-A — extract ClipboardClient port, ship BrowserClipboardClient as default, refactor Clipboard facade to delegate. No behavior change. Fixes the two registration-collision bugs as a side effect.
  2. flow PR-B — add ClipboardClientFactory + Lookup resolution. Can be merged with PR-A if reviewers prefer one PR; Geolocation only split because of classloader fallout discovered late.
  3. browserless-test PRBrowserlessClipboardClient, BrowserlessClipboardClientFactory, ClipboardSimulator, tests. Depends on a 25.x-SNAPSHOT carrying PR-A+B.

Open Questions

  • UploadEvent synthesis. Constructing a full UploadEvent outside an HTTP request needs prototyping. The cleanest fallback is a package-private hook on InMemoryUploadHandler / TemporaryFileUploadHandler that bypasses the request plumbing (mirrors how Geolocation.setClient stays package-private). Worth a spike before committing to the simulator's file(...) shape.

  • Per-element registration stacking. Today, two copyOnClicks on the same trigger silently overwrite each other (review item fix: Make ClassGraph discover package-private @Route views #1). The port refactor lets us either reject the second call with IllegalStateException or maintain a stack. The simulator API assumes one handle per trigger; if we keep stack semantics, copyHandle(Component) returns the active head and activeCopyHandles() enumerates the stack. Decision belongs to PR-A.

  • Clipboard.read* server-initiated methods. The current PR description advertises writeText / readText / writeImage but they don't exist in the code. If added, the port grows by three methods and the simulator gains corresponding simulateReadText(...) / etc. drivers. Out of scope for this spec; flagging for the API author.

  • Lookup vs. UIInternals storage for the client. Geolocation lazy-creates the client in the Geolocation constructor and caches on the instance. The static Clipboard facade has no instance, so the cache lives on UIInternals. Resolution must happen at first use, not at UI construction, so factories registered after UI init still take effect (matches Geolocation's behavior).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels
    No fields configured for Feature.

    Projects

    Status

    🟢Ready to Go

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions