Compare commits

...

15 commits

Author SHA1 Message Date
Yohan Boniface
d4fb92ec56
Fix duplicated content during sync (#2388)
Some checks are pending
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Here are the main fixes:

- mark a synched datalayer as loaded (so the peer does not try to get
data from the server)
- do not mark synched datalayers as dirty
- properly consume the lastKnownHLC, so to get an accurate list of
operations

fix #2219
2024-12-19 17:38:39 +01:00
Yohan Boniface
650110fe8a
feat: reconnect websocket on disconnection (#2389)
This is a port of this PR: #2235

(But it was easier to copy-paste than rebase, given the split of umap.js
and co.)
2024-12-19 17:38:02 +01:00
Yohan Boniface
36fdb8190c chore: stringify sync payload before putting it in the console
This allow to have them displayed by playwright in the python
console.

Co-authored-by: David Larlet <david@larlet.fr>
2024-12-19 17:00:30 +01:00
Yohan Boniface
56f2d3d2f9 feat: reconnect websocket on disconnection
This is a port of this PR: #2235

(But it was easier to copy-paste than rebase, given the split of umap.js
and co.)

Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
Co-authored-by: David Larlet <david@larlet.fr>
2024-12-19 16:51:10 +01:00
Yohan Boniface
80152bf4fb
fix: update star icon on star/unstar (#2387) 2024-12-19 15:23:36 +01:00
Yohan Boniface
9ddca50d21
fix: honour carriage returns in layer description (in caption panel) (#2386)
This could have been a four letters fix (adding class "text"), but I've
seen that `Caption` was still using `DomUtil` instead of `WithTemplate`,
so I made this little effort more…
2024-12-19 15:23:16 +01:00
Yohan Boniface
1996e315e4 chore: allow to create non dirty DataLayer
There are two situations where we want to create "non dirty" datalayers:

- at normal load, we create datalayers that already exist in DB
- at sync, we create datalayers that will be saved by other peer
2024-12-19 13:38:09 +01:00
Yohan Boniface
dd7641c92e chore: mark synched datalayers as "loaded"
Otherwise, when they will get the "_referenceVersion" later, they
will call the server to fetch the data (while they already have
the data, from the sync itself)
2024-12-19 13:38:09 +01:00
Yohan Boniface
6caf4c3ed1 chore: properly consumme lastKnownHLC on list operations call 2024-12-19 13:38:09 +01:00
Yohan Boniface
471abe1f1b chore: honour "sync" parameter in layer.setReferenceVersion 2024-12-19 13:38:09 +01:00
Yohan Boniface
4ea5a28f04 chore: no need to call layer.redraw in layer.render()
We already call hide/show, which is what redraw does
2024-12-19 13:38:09 +01:00
Yohan Boniface
d24f05907c chore: add failing test for #2219 2024-12-19 13:38:09 +01:00
Yohan Boniface
0bc4900b16 fix(caption): honour carriage returns in datalayer description
fix #2385
2024-12-19 11:10:57 +01:00
Yohan Boniface
0e78d58c03 chore: use WithTemplate for Caption 2024-12-19 11:10:57 +01:00
Yohan Boniface
f93d0b5ca7 fix: update star icon on star/unstar 2024-12-19 10:44:32 +01:00
13 changed files with 302 additions and 122 deletions

View file

@ -32,6 +32,10 @@
background-color: var(--color-lightCyan);
color: var(--color-dark);
}
.dark .off.connected-peers {
background-color: var(--color-lightGray);
color: var(--color-darkGray);
}
.leaflet-container .edit-cancel,
.leaflet-container .edit-disable,

View file

@ -1,11 +1,39 @@
import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from './i18n.js'
import * as Utils from './utils.js'
export default class Caption {
const TEMPLATE = `
<div class="umap-caption">
<hgroup>
<h3>
<i class="icon icon-16 icon-caption icon-block"></i>
<span class="map-name" data-ref="name"></span>
</h3>
<h4 data-ref="author"></h4>
</hgroup>
<div class="umap-map-description text" data-ref="description"></div>
<div class="datalayer-container" data-ref="datalayersContainer"></div>
<div class="credits-container">
<details>
<summary>${translate('Credits')}</summary>
<fieldset>
<h5>${translate('User content credits')}</h5>
<p data-ref="userCredits"></p>
<p data-ref="licence">${translate('Map user content has been published under licence')} <a href="#" data-ref="licenceLink"></a></p>
<p data-ref="noLicence">${translate('No licence has been set')}</p>
<h5>${translate('Map background credits')}</h5>
<p><strong data-ref="bgName"></strong> <span data-ref="bgAttribution"></span></p>
<p data-ref="poweredBy"></p>
</fieldset>
</details>
</div>
</div>`
export default class Caption extends Utils.WithTemplate {
constructor(umap, leafletMap) {
super()
this._umap = umap
this._leafletMap = leafletMap
this.loadTemplate(TEMPLATE)
}
isOpen() {
@ -18,94 +46,67 @@ export default class Caption {
}
open() {
const container = DomUtil.create('div', 'umap-caption')
const hgroup = DomUtil.element({ tagName: 'hgroup', parent: container })
DomUtil.createTitle(
hgroup,
this._umap.getDisplayName(),
'icon-caption icon-block',
'map-name'
)
const title = Utils.loadTemplate('<h4></h4>')
hgroup.appendChild(title)
this._umap.addAuthorLink(title)
this.elements.name.textContent = this._umap.getDisplayName()
this.elements.author.innerHTML = ''
this._umap.addAuthorLink(this.elements.author)
if (this._umap.properties.description) {
const description = DomUtil.element({
tagName: 'div',
className: 'umap-map-description text',
safeHTML: Utils.toHTML(this._umap.properties.description),
parent: container,
})
this.elements.description.innerHTML = Utils.toHTML(
this._umap.properties.description
)
} else {
this.elements.description.hidden = true
}
const datalayerContainer = DomUtil.create('div', 'datalayer-container', container)
this.elements.datalayersContainer.innerHTML = ''
this._umap.eachDataLayerReverse((datalayer) =>
this.addDataLayer(datalayer, datalayerContainer)
this.addDataLayer(datalayer, this.elements.datalayersContainer)
)
const creditsContainer = DomUtil.create('div', 'credits-container', container)
this.addCredits(creditsContainer)
this._umap.panel.open({ content: container }).then(() => {
this.addCredits()
this._umap.panel.open({ content: this.element }).then(() => {
// Create the legend when the panel is actually on the DOM
this._umap.eachDataLayerReverse((datalayer) => datalayer.renderLegend())
})
}
addDataLayer(datalayer, container) {
addDataLayer(datalayer, parent) {
if (!datalayer.options.inCaption) return
const p = DomUtil.create('p', `caption-item ${datalayer.cssId}`, container)
const legend = DomUtil.create('span', 'datalayer-legend', p)
const headline = DomUtil.create('strong', '', p)
const template = `
<p class="caption-item ${datalayer.cssId}">
<span class="datalayer-legend"></span>
<strong data-ref="toolbox"></strong>
<span class="text" data-ref="description"></span>
</p>`
const [element, { toolbox, description }] = Utils.loadTemplateWithRefs(template)
if (datalayer.options.description) {
DomUtil.element({
tagName: 'span',
parent: p,
safeHTML: Utils.toHTML(datalayer.options.description),
})
description.innerHTML = Utils.toHTML(datalayer.options.description)
} else {
description.hidden = true
}
datalayer.renderToolbox(headline)
DomUtil.add('span', '', headline, `${datalayer.options.name} `)
datalayer.renderToolbox(toolbox)
parent.appendChild(element)
// Use textContent for security
const name = Utils.loadTemplate('<span></span>')
name.textContent = datalayer.options.name
toolbox.appendChild(name)
}
addCredits(container) {
const credits = DomUtil.createFieldset(container, translate('Credits'))
let title = DomUtil.add('h5', '', credits, translate('User content credits'))
addCredits() {
if (this._umap.properties.shortCredit || this._umap.properties.longCredit) {
DomUtil.element({
tagName: 'p',
parent: credits,
safeHTML: Utils.toHTML(
this._umap.properties.longCredit || this._umap.properties.shortCredit
),
})
}
if (this._umap.properties.licence) {
const licence = DomUtil.add(
'p',
'',
credits,
`${translate('Map user content has been published under licence')} `
)
DomUtil.createLink(
'',
licence,
this._umap.properties.licence.name,
this._umap.properties.licence.url
this.elements.userCredits.innerHTML = Utils.toHTML(
this._umap.properties.longCredit || this._umap.properties.shortCredit
)
} else {
DomUtil.add('p', '', credits, translate('No licence has been set'))
this.elements.userCredits.hidden = true
}
title = DomUtil.create('h5', '', credits)
title.textContent = translate('Map background credits')
const tilelayerCredit = DomUtil.create('p', '', credits)
DomUtil.element({
tagName: 'strong',
parent: tilelayerCredit,
textContent: `${this._leafletMap.selectedTilelayer.options.name} `,
})
DomUtil.element({
tagName: 'span',
parent: tilelayerCredit,
safeHTML: this._leafletMap.selectedTilelayer.getAttribution(),
})
if (this._umap.properties.licence) {
this.elements.licenceLink.href = this._umap.properties.licence.url
this.elements.licenceLink.textContent = this._umap.properties.licence.name
this.elements.noLicence.hidden = true
} else {
this.elements.licence.hidden = true
}
this.elements.bgName.textContent = this._leafletMap.selectedTilelayer.options.name
this.elements.bgAttribution.innerHTML =
this._leafletMap.selectedTilelayer.getAttribution()
const urls = {
leaflet: 'http://leafletjs.com',
django: 'https://www.djangoproject.com',
@ -113,7 +114,7 @@ export default class Caption {
changelog: 'https://docs.umap-project.org/en/master/changelog/',
version: this._umap.properties.umap_version,
}
const creditHTML = translate(
this.elements.poweredBy.innerHTML = translate(
`
Powered by <a href="{leaflet}">Leaflet</a> and
<a href="{django}">Django</a>,
@ -122,6 +123,5 @@ export default class Caption {
`,
urls
)
DomUtil.element({ tagName: 'p', innerHTML: creditHTML, parent: credits })
}
}

View file

@ -20,7 +20,7 @@ import loadPopup from '../rendering/popup.js'
class Feature {
constructor(umap, datalayer, geojson = {}, id = null) {
this._umap = umap
this.sync = umap.sync_engine.proxy(this)
this.sync = umap.syncEngine.proxy(this)
this._marked_for_deletion = false
this._isDirty = false
this._ui = null

View file

@ -41,7 +41,7 @@ export class DataLayer extends ServerStored {
constructor(umap, leafletMap, data = {}) {
super()
this._umap = umap
this.sync = umap.sync_engine.proxy(this)
this.sync = umap.syncEngine.proxy(this)
this._index = Array()
this._features = {}
this._geojson = null
@ -88,7 +88,6 @@ export class DataLayer extends ServerStored {
if (!this.createdOnServer) {
if (this.showAtLoad()) this.show()
this.isDirty = true
}
// Only layers that are displayed on load must be hidden/shown
@ -151,7 +150,6 @@ export class DataLayer extends ServerStored {
for (const field of fields) {
this.layer.onEdit(field, builder)
}
this.redraw()
this.show()
break
case 'remote-data':
@ -592,7 +590,7 @@ export class DataLayer extends ServerStored {
options.name = translate('Clone of {name}', { name: this.options.name })
delete options.id
const geojson = Utils.CopyJSON(this._geojson)
const datalayer = this._umap.createDataLayer(options)
const datalayer = this._umap.createDirtyDataLayer(options)
datalayer.fromGeoJSON(geojson)
return datalayer
}
@ -1066,7 +1064,7 @@ export class DataLayer extends ServerStored {
setReferenceVersion({ response, sync }) {
this._referenceVersion = response.headers.get('X-Datalayer-Version')
this.sync.update('_referenceVersion', this._referenceVersion)
if (sync) this.sync.update('_referenceVersion', this._referenceVersion)
}
async save() {

View file

@ -161,7 +161,7 @@ export default class Importer extends Utils.WithTemplate {
get layer() {
return (
this._umap.datalayers[this.layerId] ||
this._umap.createDataLayer({ name: this.layerName })
this._umap.createDirtyDataLayer({ name: this.layerName })
)
}

View file

@ -3,6 +3,12 @@ import { HybridLogicalClock } from './hlc.js'
import { DataLayerUpdater, FeatureUpdater, MapUpdater } from './updaters.js'
import { WebSocketTransport } from './websocket.js'
// Start reconnecting after 2 seconds, then double the delay each time
// maxing out at 32 seconds.
const RECONNECT_DELAY = 2000
const RECONNECT_DELAY_FACTOR = 2
const MAX_RECONNECT_DELAY = 32000
/**
* The syncEngine exposes an API to sync messages between peers over the network.
*
@ -42,32 +48,65 @@ import { WebSocketTransport } from './websocket.js'
* ```
*/
export class SyncEngine {
constructor(map) {
constructor(umap) {
this._umap = umap
this.updaters = {
map: new MapUpdater(map),
feature: new FeatureUpdater(map),
datalayer: new DataLayerUpdater(map),
map: new MapUpdater(umap),
feature: new FeatureUpdater(umap),
datalayer: new DataLayerUpdater(umap),
}
this.transport = undefined
this._operations = new Operations()
this._reconnectTimeout = null
this._reconnectDelay = RECONNECT_DELAY
this.websocketConnected = false
}
async authenticate(tokenURI, webSocketURI, server) {
const [response, _, error] = await server.get(tokenURI)
async authenticate() {
const websocketTokenURI = this._umap.urls.get('map_websocket_auth_token', {
map_id: this._umap.id,
})
const [response, _, error] = await this._umap.server.get(websocketTokenURI)
if (!error) {
this.start(webSocketURI, response.token)
this.start(response.token)
}
}
start(webSocketURI, authToken) {
this.transport = new WebSocketTransport(webSocketURI, authToken, this)
start(authToken) {
this.transport = new WebSocketTransport(
this._umap.properties.websocketURI,
authToken,
this
)
}
stop() {
if (this.transport) this.transport.close()
if (this.transport) {
this.transport.close()
}
this.transport = undefined
}
onConnection() {
this._reconnectTimeout = null
this._reconnectDelay = RECONNECT_DELAY
this.websocketConnected = true
this.updaters.map.update({ key: 'numberOfConnectedPeers' })
}
reconnect() {
this.websocketConnected = false
this.updaters.map.update({ key: 'numberOfConnectedPeers' })
this._reconnectTimeout = setTimeout(() => {
if (this._reconnectDelay < MAX_RECONNECT_DELAY) {
this._reconnectDelay = this._reconnectDelay * RECONNECT_DELAY_FACTOR
}
this.authenticate()
}, this._reconnectDelay)
}
upsert(subject, metadata, value) {
this._send({ verb: 'upsert', subject, metadata, value })
}
@ -183,9 +222,13 @@ export class SyncEngine {
* @param {string} payload.sender the uuid of the requesting peer
* @param {string} payload.latestKnownHLC the latest known HLC of the requesting peer
*/
onListOperationsRequest({ sender, lastKnownHLC }) {
onListOperationsRequest({ sender, message }) {
debug(
`received operations request from peer ${sender} (since ${message.lastKnownHLC})`
)
this.sendToPeer(sender, 'ListOperationsResponse', {
operations: this._operations.getOperationsSince(lastKnownHLC),
operations: this._operations.getOperationsSince(message.lastKnownHLC),
})
}
@ -446,5 +489,5 @@ export class Operations {
}
function debug(...args) {
console.debug('SYNC ⇆', ...args)
console.debug('SYNC ⇆', ...args.map((x) => JSON.stringify(x)))
}

View file

@ -54,7 +54,10 @@ export class MapUpdater extends BaseUpdater {
export class DataLayerUpdater extends BaseUpdater {
upsert({ value }) {
// Upsert only happens when a new datalayer is created.
this._umap.createDataLayer(value, false)
const datalayer = this._umap.createDataLayer(value, false)
// Prevent the layer to get data from the server, as it will get it
// from the sync.
datalayer._loaded = true
}
update({ key, metadata, value }) {

View file

@ -1,15 +1,59 @@
const PONG_TIMEOUT = 5000
const PING_INTERVAL = 30000
const FIRST_CONNECTION_TIMEOUT = 2000
export class WebSocketTransport {
constructor(webSocketURI, authToken, messagesReceiver) {
this.receiver = messagesReceiver
this.closeRequested = false
this.websocket = new WebSocket(webSocketURI)
this.websocket.onopen = () => {
this.send('JoinRequest', { token: authToken })
this.receiver.onConnection()
}
this.websocket.addEventListener('message', this.onMessage.bind(this))
this.receiver = messagesReceiver
this.websocket.onclose = () => {
console.log('websocket closed')
if (!this.closeRequested) {
console.log('Not requested, reconnecting...')
this.receiver.reconnect()
}
}
this.ensureOpen = setInterval(() => {
if (this.websocket.readyState !== WebSocket.OPEN) {
this.websocket.close()
clearInterval(this.ensureOpen)
}
}, FIRST_CONNECTION_TIMEOUT)
// To ensure the connection is still alive, we send ping and expect pong back.
// Websocket provides a `ping` method to keep the connection alive, but it's
// unfortunately not possible to access it from the WebSocket object.
// See https://making.close.com/posts/reliable-websockets/ for more details.
this.pingInterval = setInterval(() => {
if (this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send('ping')
this.pongReceived = false
setTimeout(() => {
if (!this.pongReceived) {
console.warn('No pong received, reconnecting...')
this.websocket.close()
clearInterval(this.pingInterval)
}
}, PONG_TIMEOUT)
}
}, PING_INTERVAL)
}
onMessage(wsMessage) {
this.receiver.receive(JSON.parse(wsMessage.data))
if (wsMessage.data === 'pong') {
this.pongReceived = true
} else {
this.receiver.receive(JSON.parse(wsMessage.data))
}
}
send(kind, payload) {
@ -20,6 +64,7 @@ export class WebSocketTransport {
}
close() {
this.closeRequested = true
this.websocket.close()
}
}

View file

@ -61,8 +61,6 @@ export default class Umap extends ServerStored {
)
this.searchParams = new URLSearchParams(window.location.search)
this.sync_engine = new SyncEngine(this)
this.sync = this.sync_engine.proxy(this)
// Locale name (pt_PT, en_US…)
// To be used for Django localization
if (geojson.properties.locale) setLocale(geojson.properties.locale)
@ -124,6 +122,9 @@ export default class Umap extends ServerStored {
this.share = new Share(this)
this.rules = new Rules(this)
this.syncEngine = new SyncEngine(this)
this.sync = this.syncEngine.proxy(this)
if (this.hasEditMode()) {
this.editPanel = new EditPanel(this, this._leafletMap)
this.fullPanel = new FullPanel(this, this._leafletMap)
@ -323,14 +324,14 @@ export default class Umap extends ServerStored {
dataUrl = decodeURIComponent(dataUrl)
dataUrl = this.renderUrl(dataUrl)
dataUrl = this.proxyUrl(dataUrl)
const datalayer = this.createDataLayer()
const datalayer = this.createDirtyDataLayer()
await datalayer
.importFromUrl(dataUrl, dataFormat)
.then(() => datalayer.zoomTo())
}
} else if (data) {
data = decodeURIComponent(data)
const datalayer = this.createDataLayer()
const datalayer = this.createDirtyDataLayer()
await datalayer.importRaw(data, dataFormat).then(() => datalayer.zoomTo())
}
}
@ -598,8 +599,14 @@ export default class Umap extends ServerStored {
return datalayer
}
createDirtyDataLayer(options) {
const datalayer = this.createDataLayer(options, true)
datalayer.isDirty = true
return datalayer
}
newDataLayer() {
const datalayer = this.createDataLayer({})
const datalayer = this.createDirtyDataLayer({})
datalayer.edit()
}
@ -1257,18 +1264,13 @@ export default class Umap extends ServerStored {
}
async initSyncEngine() {
// this.properties.websocketEnabled is set by the server admin
if (this.properties.websocketEnabled === false) return
// this.properties.syncEnabled is set by the user in the map settings
if (this.properties.syncEnabled !== true) {
this.sync.stop()
} else {
const ws_token_uri = this.urls.get('map_websocket_auth_token', {
map_id: this.id,
})
await this.sync.authenticate(
ws_token_uri,
this.properties.websocketURI,
this.server
)
await this.sync.authenticate()
}
}
@ -1343,7 +1345,17 @@ export default class Umap extends ServerStored {
},
numberOfConnectedPeers: () => {
Utils.eachElement('.connected-peers span', (el) => {
el.textContent = this.sync.getNumberOfConnectedPeers()
if (this.sync.websocketConnected) {
el.textContent = this.sync.getNumberOfConnectedPeers()
} else {
el.textContent = translate('Disconnected')
}
el.parentElement.classList.toggle('off', !this.sync.websocketConnected)
})
},
'properties.starred': () => {
Utils.eachElement('.map-star', (el) => {
el.classList.toggle('starred', this.properties.starred)
})
},
}
@ -1383,7 +1395,7 @@ export default class Umap extends ServerStored {
fallback.show()
return fallback
}
return this.createDataLayer()
return this.createDirtyDataLayer()
}
findDataLayer(method, context) {
@ -1531,7 +1543,7 @@ export default class Umap extends ServerStored {
? translate('Map has been starred')
: translate('Map has been unstarred')
)
this.render(['starred'])
this.render(['properties.starred'])
}
processFileToImport(file, layer, type) {
@ -1547,7 +1559,7 @@ export default class Umap extends ServerStored {
if (type === 'umap') {
this.importUmapFile(file, 'umap')
} else {
if (!layer) layer = this.createDataLayer({ name: file.name })
if (!layer) layer = this.createDirtyDataLayer({ name: file.name })
layer.importFromFile(file, type)
}
}
@ -1573,7 +1585,7 @@ export default class Umap extends ServerStored {
delete geojson._storage
}
delete geojson._umap_options?.id // Never trust an id at this stage
const dataLayer = this.createDataLayer(geojson._umap_options)
const dataLayer = this.createDirtyDataLayer(geojson._umap_options)
dataLayer.fromUmapGeoJSON(geojson)
}

View file

@ -541,11 +541,7 @@ U.StarControl = L.Control.Button.extend({
options: {
position: 'topleft',
title: L._('Star this map'),
},
getClassName: function () {
const status = this._umap.properties.starred ? ' starred' : ''
return `leaflet-control-star umap-control${status}`
className: 'leaflet-control-star map-star umap-control',
},
onClick: function () {

View file

@ -8,8 +8,11 @@ import { MapUpdater } from '../js/modules/sync/updaters.js'
import { SyncEngine, Operations } from '../js/modules/sync/engine.js'
describe('SyncEngine', () => {
const websocketTokenURI = 'http://localhost:8000/api/v1/maps/1/websocket_auth_token/'
const websocketURI = 'ws://localhost:8000/ws/maps/1/'
it('should initialize methods even before start', () => {
const engine = new SyncEngine({})
const engine = new SyncEngine({}, websocketTokenURI, websocketURI)
engine.upsert()
engine.update()
engine.delete()

View file

@ -39,6 +39,9 @@ def test_websocket_connection_can_sync_markers(
a_map_el.click(position={"x": 220, "y": 220})
expect(a_marker_pane).to_have_count(1)
expect(b_marker_pane).to_have_count(1)
# Peer B should not be in state dirty
expect(peerB.get_by_role("button", name="View")).to_be_visible()
expect(peerB.get_by_role("button", name="Cancel edits")).to_be_hidden()
peerA.locator("body").type("Synced name")
peerA.locator("body").press("Escape")
@ -415,3 +418,69 @@ def test_should_sync_datalayers(new_page, live_server, websocket_server, tilelay
peerA.get_by_role("button", name="Save").click()
assert DataLayer.objects.count() == 2
@pytest.mark.xdist_group(name="websockets")
def test_create_and_sync_map(
new_page, live_server, websocket_server, tilelayer, login, user
):
# Create a syncable map with peerA
peerA = login(user, prefix="Page A")
peerA.goto(f"{live_server.url}/en/map/new/")
with peerA.expect_response(re.compile("./map/create/.*")):
peerA.get_by_role("button", name="Save Draft").click()
peerA.get_by_role("link", name="Map advanced properties").click()
peerA.get_by_text("Real-time collaboration", exact=True).click()
peerA.get_by_text("Enable real-time").click()
peerA.get_by_role("link", name="Update permissions and editors").click()
peerA.locator('select[name="share_status"]').select_option(str(Map.PUBLIC))
with peerA.expect_response(re.compile("./update/settings/.*")):
peerA.get_by_role("button", name="Save").click()
expect(peerA.get_by_role("button", name="Cancel edits")).to_be_hidden()
# Quit edit mode
peerA.get_by_role("button", name="View").click()
# Open map and go to edit mode with peer B
peerB = new_page("Page B")
peerB.goto(peerA.url)
peerB.get_by_role("button", name="Edit").click()
# Create a marker from peerA
markersA = peerA.locator(".leaflet-marker-pane > div")
markersB = peerB.locator(".leaflet-marker-pane > div")
expect(markersA).to_have_count(0)
expect(markersB).to_have_count(0)
# Add a marker from peer A
peerA.get_by_role("button", name="Edit").click()
peerA.get_by_title("Draw a marker").click()
peerA.locator("#map").click(position={"x": 220, "y": 220})
expect(markersA).to_have_count(1)
expect(markersB).to_have_count(1)
# Save and quit edit mode again
with peerA.expect_response(re.compile("./datalayer/create/.*")):
peerA.get_by_role("button", name="Save").click()
peerA.get_by_role("button", name="View").click()
expect(markersA).to_have_count(1)
expect(markersB).to_have_count(1)
peerA.wait_for_timeout(500)
expect(markersA).to_have_count(1)
expect(markersB).to_have_count(1)
# Peer B should not be in state dirty
expect(peerB.get_by_role("button", name="View")).to_be_visible()
expect(peerB.get_by_role("button", name="Cancel edits")).to_be_hidden()
# Add a marker from peer B
peerB.get_by_title("Draw a marker").click()
peerB.locator("#map").click(position={"x": 200, "y": 200})
expect(markersB).to_have_count(2)
expect(markersA).to_have_count(1)
with peerB.expect_response(re.compile("./datalayer/update/.*")):
peerB.get_by_role("button", name="Save").click()
expect(markersB).to_have_count(2)
expect(markersA).to_have_count(1)
peerA.get_by_role("button", name="Edit").click()
expect(markersA).to_have_count(2)
expect(markersB).to_have_count(2)

View file

@ -126,6 +126,10 @@ async def join_and_listen(
try:
async for raw_message in websocket:
if raw_message == "ping":
await websocket.send("pong")
continue
# recompute the peers list at the time of message-sending.
# as doing so beforehand would miss new connections
other_peers = connections.get_other_peers(websocket)
@ -192,4 +196,7 @@ def run(host: str, port: int):
logging.debug(f"Waiting for connections on {host}:{port}")
await asyncio.Future() # run forever
asyncio.run(_serve())
try:
asyncio.run(_serve())
except KeyboardInterrupt:
print("Closing WebSocket server")