Skip to content

Commit 9ac549e

Browse files
authored
feat: add GeolocationClient port for external test drivers (#24211)
## Summary - Extract a `GeolocationClient` port behind the `Geolocation` facade and `GeolocationTracker`. Production wire behavior moves to a new `BrowserGeolocationClient`; `executeJs` expressions, DOM event names and target elements are unchanged. - Add `Geolocation.setClient(GeolocationClient)` and `GeolocationTracker.handle()` as public framework-internal entry points so external browserless test drivers (e.g. [`vaadin/browserless-test`](https://github.com/vaadin/browserless-test)) can swap the production client and reach the active `WatchHandle` without reflection. - Use `SerializableConsumer<T>` for the port's listener types and mark `Geolocation.availabilitySubscription` `transient` so dev-mode UI/session serialization keeps working. Adds the API surface needed by the [Geolocation browserless testing](vaadin/platform#8758) PRD requirement. The actual test driver lives in `vaadin/browserless-test` (separate PR) — keeping it out of `flow` avoids dragging Selenium / TestBench into the dependency tree of consumers that only want browserless unit tests. ## Test plan - [ ] `mvn test -pl :flow-server -Dtest=GeolocationTest` — 33 wire-protocol assertions still pass (production behavior preserved byte-for-byte) - [ ] `mvn test -pl :flow-server -Dtest=GeolocationClientSeamTest` — 4 new tests pin the `setClient` + `handle()` contract - [ ] `mvn test -pl :flow-server -Dtest=SerializationTest,FlowClassesSerializableTest` — dev-mode UI/session serialization still passes - [ ] End-to-end smoke: companion test driver branch in `vaadin/browserless-test` (`feat/geolocation-test-support`) consumes this branch; ran against use-cases/geolocation, all 7 PRD use cases testable browserlessly (12 tests, 0 failures)
1 parent b9356e4 commit 9ac549e

5 files changed

Lines changed: 523 additions & 95 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component.geolocation;
17+
18+
import java.io.Serializable;
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
import java.util.UUID;
22+
import java.util.concurrent.CompletableFuture;
23+
24+
import org.jspecify.annotations.NullMarked;
25+
import org.jspecify.annotations.Nullable;
26+
import org.slf4j.Logger;
27+
import org.slf4j.LoggerFactory;
28+
29+
import com.vaadin.flow.component.Component;
30+
import com.vaadin.flow.component.UI;
31+
import com.vaadin.flow.dom.DomListenerRegistration;
32+
import com.vaadin.flow.dom.Element;
33+
import com.vaadin.flow.function.SerializableConsumer;
34+
import com.vaadin.flow.shared.Registration;
35+
36+
/**
37+
* {@link GeolocationClient} implementation backed by the real browser
38+
* Geolocation API via {@code window.Vaadin.Flow.geolocation} and DOM events.
39+
* This is the default implementation injected at facade construction time;
40+
* external browserless test drivers replace it via
41+
* {@link Geolocation#setClient(GeolocationClient)}.
42+
* <p>
43+
* <b>Framework internal.</b> Application code does not reference this class
44+
* directly.
45+
*/
46+
@NullMarked
47+
final class BrowserGeolocationClient implements GeolocationClient {
48+
49+
private static final Logger LOGGER = LoggerFactory
50+
.getLogger(BrowserGeolocationClient.class);
51+
52+
private record GetResult(@Nullable GeolocationPosition position,
53+
@Nullable GeolocationError error,
54+
@Nullable String availability) implements Serializable {
55+
}
56+
57+
private record AvailabilityDetail(
58+
@Nullable String availability) implements Serializable {
59+
}
60+
61+
private final UI ui;
62+
private final List<SerializableConsumer<GeolocationAvailability>> availabilityListeners = new ArrayList<>();
63+
private final DomListenerRegistration availabilityChangeRegistration;
64+
private GeolocationAvailability currentAvailability;
65+
private boolean closed;
66+
67+
BrowserGeolocationClient(UI ui, GeolocationAvailability seed) {
68+
this.ui = ui;
69+
this.currentAvailability = seed;
70+
availabilityChangeRegistration = ui.getElement()
71+
.addEventListener("vaadin-geolocation-availability-change",
72+
e -> updateAvailability(
73+
e.getEventDetail(AvailabilityDetail.class)
74+
.availability()))
75+
.addEventDetail().allowInert();
76+
}
77+
78+
@Override
79+
public CompletableFuture<GeolocationOutcome> get(
80+
@Nullable GeolocationOptions options) {
81+
CompletableFuture<GeolocationOutcome> future = new CompletableFuture<>();
82+
ui.getElement()
83+
.executeJs("return window.Vaadin.Flow.geolocation.get($0)",
84+
options)
85+
.then(GetResult.class, result -> {
86+
updateAvailability(result.availability());
87+
if (result.position() != null) {
88+
future.complete(result.position());
89+
} else if (result.error() != null) {
90+
future.complete(result.error());
91+
} else {
92+
future.completeExceptionally(new IllegalStateException(
93+
"Geolocation get() returned neither position nor error"));
94+
}
95+
}, err -> future.completeExceptionally(new RuntimeException(
96+
"Client-side geolocation.get failed: " + err)));
97+
return future;
98+
}
99+
100+
@Override
101+
public WatchHandle startWatch(Component owner,
102+
@Nullable GeolocationOptions options,
103+
SerializableConsumer<GeolocationResult> onUpdate) {
104+
return new BrowserWatchHandle(owner, options, onUpdate);
105+
}
106+
107+
@Override
108+
public Registration subscribeAvailability(
109+
SerializableConsumer<GeolocationAvailability> onChange) {
110+
availabilityListeners.add(onChange);
111+
return () -> availabilityListeners.remove(onChange);
112+
}
113+
114+
@Override
115+
public GeolocationAvailability currentAvailability() {
116+
return currentAvailability;
117+
}
118+
119+
@Override
120+
public void close() {
121+
if (closed) {
122+
return;
123+
}
124+
closed = true;
125+
availabilityChangeRegistration.remove();
126+
availabilityListeners.clear();
127+
}
128+
129+
private void updateAvailability(@Nullable String value) {
130+
if (value == null) {
131+
return;
132+
}
133+
GeolocationAvailability next;
134+
try {
135+
next = GeolocationAvailability.valueOf(value);
136+
} catch (IllegalArgumentException ignored) {
137+
return;
138+
}
139+
if (next == currentAvailability) {
140+
return;
141+
}
142+
currentAvailability = next;
143+
for (SerializableConsumer<GeolocationAvailability> listener : new ArrayList<>(
144+
availabilityListeners)) {
145+
listener.accept(next);
146+
}
147+
}
148+
149+
private final class BrowserWatchHandle implements WatchHandle {
150+
151+
private final String watchKey = UUID.randomUUID().toString();
152+
private final Component owner;
153+
private @Nullable DomListenerRegistration positionListener;
154+
private @Nullable DomListenerRegistration errorListener;
155+
private boolean active = true;
156+
157+
BrowserWatchHandle(Component owner,
158+
@Nullable GeolocationOptions options,
159+
SerializableConsumer<GeolocationResult> onUpdate) {
160+
this.owner = owner;
161+
Element el = owner.getElement();
162+
positionListener = el
163+
.addEventListener("vaadin-geolocation-position",
164+
e -> onUpdate.accept(e
165+
.getEventDetail(GeolocationPosition.class)))
166+
.addEventDetail().allowInert();
167+
errorListener = el
168+
.addEventListener("vaadin-geolocation-error",
169+
e -> onUpdate.accept(
170+
e.getEventDetail(GeolocationError.class)))
171+
.addEventDetail().allowInert();
172+
el.executeJs("window.Vaadin.Flow.geolocation.watch(this, $0, $1)",
173+
options, watchKey).then(ignored -> {
174+
}, err -> LOGGER.debug(
175+
"Client-side geolocation.watch failed: {}", err));
176+
}
177+
178+
@Override
179+
public void stop() {
180+
if (!active) {
181+
return;
182+
}
183+
active = false;
184+
if (positionListener != null) {
185+
positionListener.remove();
186+
positionListener = null;
187+
}
188+
if (errorListener != null) {
189+
errorListener.remove();
190+
errorListener = null;
191+
}
192+
ui.getPage()
193+
.executeJs("window.Vaadin.Flow.geolocation.clearWatch($0)",
194+
watchKey)
195+
.then(ignored -> {
196+
}, err -> LOGGER.debug(
197+
"Client-side geolocation.clearWatch failed: {}",
198+
err));
199+
}
200+
201+
@Override
202+
public boolean isActive() {
203+
return active;
204+
}
205+
}
206+
}

‎flow-server/src/main/java/com/vaadin/flow/component/geolocation/Geolocation.java‎

Lines changed: 36 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -104,26 +104,11 @@ public class Geolocation implements Serializable {
104104
private static final Logger LOGGER = LoggerFactory
105105
.getLogger(Geolocation.class);
106106

107-
/**
108-
* Wire shape of a one-shot get() answer: always exactly one of the two
109-
* result fields is populated, plus the availability reported alongside so
110-
* the server can refresh the cache inline.
111-
*/
112-
private record GetResult(@Nullable GeolocationPosition position,
113-
@Nullable GeolocationError error,
114-
@Nullable String availability) implements Serializable {
115-
}
116-
117-
/**
118-
* Wire shape of a permission-change push from the client.
119-
*/
120-
private record AvailabilityDetail(
121-
@Nullable String availability) implements Serializable {
122-
}
123-
124107
private final UI ui;
125108
private final Signal<GeolocationAvailability> availabilityReadOnly;
126109

110+
private GeolocationClient client;
111+
127112
/**
128113
* Creates a new Geolocation facade bound to the given UI.
129114
* <p>
@@ -137,6 +122,8 @@ private record AvailabilityDetail(
137122
* @throws IllegalStateException
138123
* if the UI already has a Geolocation facade
139124
*/
125+
@SuppressWarnings("NullAway") // setClient always assigns client; the guard
126+
// throw above it exits before client is used
140127
public Geolocation(UI ui) {
141128
if (ui.getGeolocation() != null) {
142129
throw new IllegalStateException(
@@ -146,15 +133,12 @@ public Geolocation(UI ui) {
146133
this.ui = ui;
147134
this.availabilityReadOnly = ui.getInternals()
148135
.getGeolocationAvailabilitySignal().asReadonly();
149-
// Listen for client-side permissionchange events so the cached
150-
// availability stays current without requiring a get()/track()
151-
// call to refresh it.
152-
ui.getElement()
153-
.addEventListener("vaadin-geolocation-availability-change",
154-
e -> setAvailability(
155-
e.getEventDetail(AvailabilityDetail.class)
156-
.availability()))
157-
.addEventDetail().allowInert();
136+
GeolocationAvailability seed = ui.getInternals()
137+
.getGeolocationAvailabilitySignal().peek();
138+
if (seed == null) {
139+
seed = GeolocationAvailability.UNKNOWN;
140+
}
141+
setClient(new BrowserGeolocationClient(ui, seed));
158142
}
159143

160144
/**
@@ -192,18 +176,13 @@ public void get(SerializableConsumer<GeolocationOutcome> callback) {
192176
*/
193177
public void get(@Nullable GeolocationOptions options,
194178
SerializableConsumer<GeolocationOutcome> callback) {
195-
ui.getElement()
196-
.executeJs("return window.Vaadin.Flow.geolocation.get($0)",
197-
options)
198-
.then(GetResult.class, result -> {
199-
setAvailability(result.availability());
200-
if (result.position() != null) {
201-
callback.accept(result.position());
202-
} else if (result.error() != null) {
203-
callback.accept(result.error());
204-
}
205-
}, err -> LOGGER.debug("Client-side geolocation.get failed: {}",
206-
err));
179+
client.get(options).whenComplete((outcome, error) -> {
180+
if (error != null) {
181+
LOGGER.debug("Geolocation get() failed", error);
182+
} else {
183+
callback.accept(outcome);
184+
}
185+
});
207186
}
208187

209188
/**
@@ -265,7 +244,7 @@ public GeolocationTracker track(Component owner) {
265244
*/
266245
public GeolocationTracker track(Component owner,
267246
@Nullable GeolocationOptions options) {
268-
return new GeolocationTracker(ui, owner, options);
247+
return new GeolocationTracker(owner, options, client);
269248
}
270249

271250
/**
@@ -317,15 +296,24 @@ public Signal<GeolocationAvailability> availabilitySignal() {
317296
return availabilityReadOnly;
318297
}
319298

320-
private void setAvailability(@Nullable String value) {
321-
if (value == null) {
322-
return;
323-
}
324-
try {
325-
ui.getInternals().setGeolocationAvailability(
326-
GeolocationAvailability.valueOf(value));
327-
} catch (IllegalArgumentException ignored) {
328-
// Unknown value — leave the previous cached value untouched.
299+
/**
300+
* Replaces this facade's geolocation client.
301+
*
302+
* @param client
303+
* the replacement client, never null
304+
*/
305+
void setClient(GeolocationClient client) {
306+
if (this.client != null) {
307+
this.client.close();
329308
}
309+
this.client = client;
310+
wireAvailability(client);
311+
}
312+
313+
private void wireAvailability(GeolocationClient activeClient) {
314+
ui.getInternals()
315+
.setGeolocationAvailability(activeClient.currentAvailability());
316+
activeClient.subscribeAvailability(
317+
next -> ui.getInternals().setGeolocationAvailability(next));
330318
}
331319
}

0 commit comments

Comments
 (0)