This commit is contained in:
nnduc
2026-10-24 14:03:52 +07:00
commit 4fe046de58
4790 changed files with 311146 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
xmpp/
xmpp-src/
shots/
data/
server.log
server.pid
*.out
+20
View File
@@ -0,0 +1,20 @@
# End-to-end tests
They drive a running movim-rs (and, for the `browser_*` ones, the real Movim
frontend in a headless Chromium) against a local Prosody.
./setup.sh # once: builds Prosody locally, creates alice, bob and carol
cargo build && ./run.sh # starts movim-rs on 127.0.0.1:8099
uv run --with websockets --with requests python e2e.py
uv run --with websockets --with requests python e2e_muc.py
uv run --with websockets --with requests python setup_roster.py # alice and bob become contacts
uv run --with playwright python -m playwright install chromium
uv run --with playwright python browser_smoke.py # also browser_call, browser_omemo, ...
uv run --with playwright python browser_muji.py # 3-way group call (--video for video)
uv run --with playwright python browser_preview.py # link previews (serves pages on :8765)
Screenshots are written to `shots/`.
On a fresh database the first login replays the whole archive (MAM), so the
scripts match on the ids of the messages they send rather than on the first
message that shows up. The Python clients send no timezone, so times are UTC.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+66
View File
@@ -0,0 +1,66 @@
"""Drive the real Movim frontend served by movim-rs with a headless Chromium."""
import asyncio, sys, os
from playwright.async_api import async_playwright
BASE = "http://127.0.0.1:8099"
OUT = os.path.dirname(os.path.abspath(__file__)) + "/shots"
os.makedirs(OUT, exist_ok=True)
async def login(browser, jid, password, name):
ctx = await browser.new_context(viewport={"width": 1400, "height": 900})
page = await ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(f"[{name}] pageerror: {e} {e.stack[:600] if e.stack else ''}"))
page.on("console", lambda m: m.type == "error" and errors.append(f"[{name}] console: {m.text}"))
await page.goto(BASE + "/login")
await page.wait_for_selector("#login_widget input[type=submit]:not([disabled])", timeout=15000)
await page.fill("input#username", jid)
await page.fill("input#password", password)
await page.click("#login_widget input[type=submit]")
await page.wait_for_url("**/chat**", timeout=30000)
await page.wait_for_timeout(3000)
await page.screenshot(path=f"{OUT}/{name}_1_chat.png")
return ctx, page, errors
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
actx, alice, aerr = await login(browser, "alice@localhost", "alicepass", "alice")
bctx, bob, berr = await login(browser, "bob@localhost", "bobpass", "bob")
# Alice opens the discussion with Bob
await alice.goto(BASE + "/chat/bob@localhost")
await alice.wait_for_selector("#chat_textarea", timeout=15000)
await alice.wait_for_timeout(2000)
await alice.fill("#chat_textarea", "Hello from the browser 🙂")
await alice.press("#chat_textarea", "Enter")
await alice.wait_for_timeout(2000)
await alice.screenshot(path=f"{OUT}/alice_2_sent.png")
# Bob sees the chat in the list and opens it
await bob.wait_for_timeout(1000)
await bob.screenshot(path=f"{OUT}/bob_2_list.png")
await bob.goto(BASE + "/chat/alice@localhost")
await bob.wait_for_selector("#chat_textarea", timeout=15000)
await bob.wait_for_timeout(2500)
await bob.screenshot(path=f"{OUT}/bob_3_chat.png")
bubbles = await bob.locator("#chat_widget .bubble p").all_inner_texts()
print("bob bubbles:", bubbles[-5:])
# Mobile layout
mctx = await browser.new_context(viewport={"width": 400, "height": 800}, storage_state=await bctx.storage_state())
mobile = await mctx.new_page()
await mobile.goto(BASE + "/chat")
await mobile.wait_for_timeout(3000)
await mobile.screenshot(path=f"{OUT}/bob_4_mobile.png")
for e in aerr + berr:
print(e)
await browser.close()
if __name__ == "__main__":
asyncio.run(main())
+40
View File
@@ -0,0 +1,40 @@
import asyncio
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(args=["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"])
actx, alice, aerr = await login(browser, "alice@localhost", "alicepass", "callalice")
bctx, bob, berr = await login(browser, "bob@localhost", "bobpass", "callbob")
for c in (actx, bctx):
await c.grant_permissions(["microphone", "camera"], origin=BASE)
await alice.goto(BASE + "/chat/bob@localhost")
await alice.wait_for_selector("#chat_textarea", timeout=15000)
await alice.wait_for_timeout(3000)
await alice.screenshot(path=f"{OUT}/call_0_header.png")
btn = alice.locator("#chat_header span.control.icon[title]").first
print("call buttons:", await alice.locator("#chat_header span.control.icon[title]").count())
await btn.click()
await alice.wait_for_selector("#lobby_start:not(.disabled)", timeout=15000)
await alice.screenshot(path=f"{OUT}/call_1_lobby.png")
await alice.click("#lobby_start")
await bob.wait_for_selector("#lobby_start", timeout=15000)
await bob.wait_for_timeout(1500)
await bob.screenshot(path=f"{OUT}/call_2_incoming.png")
await bob.wait_for_selector("#lobby_start:not(.disabled)", timeout=15000)
await bob.click("#lobby_start")
await alice.wait_for_timeout(6000)
await alice.screenshot(path=f"{OUT}/call_3_alice.png")
await bob.screenshot(path=f"{OUT}/call_3_bob.png")
states = await alice.evaluate("Object.values(MovimJingles.sessions || {}).map(s => s.pc && s.pc.connectionState)")
print("alice peer connection states:", states)
# Hang up
await alice.evaluate("Visio_ajaxGoodbye(document.querySelector('#visio').dataset.jid || 'bob@localhost', Object.keys(MovimJingles.sessions)[0] ? MovimJingles.sessions[Object.keys(MovimJingles.sessions)[0]].id : '')")
await alice.wait_for_timeout(3000)
await bob.screenshot(path=f"{OUT}/call_4_bob_end.png")
for e in aerr + berr:
print(e)
await browser.close()
asyncio.run(main())
+36
View File
@@ -0,0 +1,36 @@
import asyncio, time
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
# Account creation
ctx = await browser.new_context(viewport={"width": 1200, "height": 900})
page = await ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(f"pageerror: {e}"))
await page.goto(BASE + "/account")
await page.wait_for_timeout(1000)
await page.screenshot(path=f"{OUT}/reg_1_account.png")
await page.click("#subscribe li.block")
await page.wait_for_selector("form[name=data] input[name=username]", timeout=15000)
await page.screenshot(path=f"{OUT}/reg_2_form.png")
user = f"carol{int(time.time())}"
await page.fill("form[name=data] input[name=username]", user)
await page.fill("form[name=data] input[name=password]", "carolpass")
await page.click("form[name=data] button")
await page.wait_for_timeout(2500)
await page.screenshot(path=f"{OUT}/reg_3_done.png")
print("registered", user, "->", await page.locator("#subscribe .placeholder h4").all_inner_texts())
# Configuration page
ctx2, page2, errors2 = await login(browser, f"{user}@localhost", "carolpass", "carol")
await page2.goto(BASE + "/configuration")
await page2.wait_for_timeout(3000)
await page2.screenshot(path=f"{OUT}/config_1.png")
for e in errors + errors2:
print(e)
await browser.close()
asyncio.run(main())
+35
View File
@@ -0,0 +1,35 @@
import os
import asyncio
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
ctx, page, errors = await login(browser, "alice@localhost", "alicepass", "logout")
await page.evaluate("Presence_ajaxAskLogout()")
await page.wait_for_timeout(800)
await page.click("#dialog footer button[name=submit]")
await page.wait_for_url("**/login**", timeout=15000)
await page.wait_for_timeout(2000)
print("after logout:", page.url, "quick login keys:", await page.evaluate("localStorage.getItem('quickDeviceId')"))
await page.screenshot(path=f"{OUT}/logout.png")
# Login again, then reload: quick login must work after a server restart
await page.fill("input#username", "alice@localhost")
await page.fill("input#password", "alicepass")
await page.click("#login_widget input[type=submit]")
await page.wait_for_url("**/chat**", timeout=30000)
print("logged again:", page.url)
import subprocess
subprocess.run([os.path.join(os.path.dirname(os.path.abspath(__file__)), "run.sh")])
await page.wait_for_timeout(1000)
await page.reload()
await page.wait_for_url("**/chat**", timeout=40000)
await page.wait_for_timeout(8000)
print("after server restart:", page.url)
await page.screenshot(path=f"{OUT}/restart.png")
for e in errors:
print(e[:200])
await browser.close()
asyncio.run(main())
+34
View File
@@ -0,0 +1,34 @@
import asyncio, os
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
ctx, page, errors = await login(browser, "bob@localhost", "bobpass", "bobmuc")
await page.wait_for_timeout(3000)
await page.screenshot(path=f"{OUT}/muc_1_list.png")
room = page.locator("#rooms ul.list.rooms li.room").first
await room.click()
await page.wait_for_selector("#chat_textarea", timeout=15000)
await page.wait_for_timeout(3000)
await page.fill("#chat_textarea", "Hi everyone @alice")
await page.press("#chat_textarea", "Enter")
await page.wait_for_timeout(2500)
await page.screenshot(path=f"{OUT}/muc_2_room.png")
# Room drawer
await page.click("#chat_widget header span.primary.icon.bubble")
await page.wait_for_timeout(1500)
await page.screenshot(path=f"{OUT}/muc_3_drawer.png")
# Search drawer (new chat)
await page.keyboard.press("Escape")
await page.goto(BASE + "/chat")
await page.wait_for_timeout(2000)
await page.click("main div[role=menu] > button.action")
await page.wait_for_timeout(2000)
await page.screenshot(path=f"{OUT}/muc_4_search.png")
for e in errors:
print(e)
await browser.close()
asyncio.run(main())
+103
View File
@@ -0,0 +1,103 @@
"""Muji (XEP-0272) group call between three browsers in a group chat."""
import asyncio, time
from playwright.async_api import async_playwright
import sys
from browser import login, BASE, OUT
VIDEO = 'true' if '--video' in sys.argv else 'false'
async def open_room(page, room):
await page.evaluate(f"Chat_ajaxGetRoom('{room}')")
await page.wait_for_selector("#chat_textarea", timeout=15000)
await page.wait_for_timeout(1500)
async def join_from_lobby(page):
await page.wait_for_selector("#lobby_start:not(.disabled)", timeout=15000)
await page.click("#lobby_start")
async def sessions(page):
return await page.evaluate(
"Object.fromEntries(Object.entries(MovimJingles.sessions || {}).map(([j, s]) => [j, s.pc && s.pc.connectionState]))"
)
async def main():
room = f"call{int(time.time())}@conference.localhost"
async with async_playwright() as p:
browser = await p.chromium.launch(args=["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"])
users = [("alice", "alicepass"), ("bob", "bobpass"), ("carol", "carolpass")]
pages, errors = {}, []
for name, password in users:
ctx, page, err = await login(browser, f"{name}@localhost", password, f"muji{name}")
await ctx.grant_permissions(["microphone", "camera"], origin=BASE)
pages[name] = page
await page.evaluate("""
window.addEventListener('unhandledrejection', e => console.error('REJECTION', e.reason && e.reason.stack));
window.addEventListener('error', e => console.error('ERROR', e.error && e.error.stack));
""")
errors.append(err)
alice, bob, carol = pages["alice"], pages["bob"], pages["carol"]
# Alice creates a group chat and invites the others, who join it
await alice.evaluate(f"""RoomsUtils_ajaxAddCreate({{
jid: {{value: '{room}'}}, name: {{value: 'Call room'}}, nick: {{value: 'alice'}},
type: {{value: 'groupchat'}}, autojoin: {{value: true}}, pinned: {{value: false}},
notify: {{value: 'always'}}
}})""")
await alice.wait_for_timeout(3000)
for name in ("bob", "carol"):
await alice.evaluate(f"RoomsUtils_ajaxInvite({{to: {{value: '{room}'}}, invite: {{value: '{name}@localhost'}}}})")
await alice.wait_for_timeout(1500)
for name in ("bob", "carol"):
await pages[name].evaluate(f"""RoomsUtils_ajaxAddConfirm({{
jid: {{value: '{room}'}}, name: {{value: 'Call room'}}, nick: {{value: '{name}'}},
autojoin: {{value: true}}, pinned: {{value: false}}, notify: {{value: 'on-mention'}}
}})""")
await alice.wait_for_timeout(3000)
for page in pages.values():
await open_room(page, room)
await alice.screenshot(path=f"{OUT}/muji_0_header.png")
# Alice starts the call
await alice.evaluate(f"Visio_ajaxGetMujiLobby('{room}', true, {VIDEO})")
await join_from_lobby(alice)
await alice.wait_for_timeout(3000)
await alice.screenshot(path=f"{OUT}/muji_1_alice_waiting.png")
await bob.screenshot(path=f"{OUT}/muji_1_bob_header.png")
print("bob sees a call to join:", await bob.locator("button.oppose.color.blue").count() > 0)
# Bob then Carol join
for name, page in (("bob", bob), ("carol", carol)):
button = page.locator("button.oppose.color.blue").first
try:
await button.click(timeout=10000)
except Exception:
await page.screenshot(path=f"{OUT}/muji_fail_{name}.png")
print(name, "header:", await page.content())
raise
await join_from_lobby(page)
await page.wait_for_timeout(6000)
print(f"after {name} joined:", {n: await sessions(pg) for n, pg in pages.items()})
for name, page in pages.items():
await page.screenshot(path=f"{OUT}/muji_2_{name}.png")
print(name, "sessions:", await sessions(page))
print("JS errors before leaving:", sum(len(e) for e in errors))
# Carol leaves, the others keep talking
await carol.evaluate(f"Visio_ajaxLeaveMuji('{room}')")
await alice.wait_for_timeout(3000)
print("after carol left, alice sessions:", await sessions(alice))
print("carol sessions:", await sessions(carol))
await alice.screenshot(path=f"{OUT}/muji_3_alice.png")
for err in errors:
for e in err:
print(e)
await browser.close()
asyncio.run(main())
+38
View File
@@ -0,0 +1,38 @@
import asyncio
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
actx, alice, aerr = await login(browser, "alice@localhost", "alicepass", "omemoalice")
bctx, bob, berr = await login(browser, "bob@localhost", "bobpass", "omemobob")
# Let both browsers generate and publish their bundles
await alice.wait_for_timeout(5000)
await bob.wait_for_timeout(1000)
print("alice device:", await alice.evaluate("new ChatOmemoStorage().getLocalRegistrationId()"))
print("bob device:", await bob.evaluate("new ChatOmemoStorage().getLocalRegistrationId()"))
await alice.goto(BASE + "/chat/bob@localhost")
await alice.wait_for_selector("#chat_textarea", timeout=15000)
await alice.wait_for_timeout(2000)
print("alice state before:", await alice.evaluate("Chat.getOmemoState()"))
await alice.evaluate("ChatOmemo.enableContactState('bob@localhost', false)")
await alice.wait_for_timeout(5000)
print("alice state after:", await alice.evaluate("Chat.getOmemoState()"))
await alice.fill("#chat_textarea", "This is a secret 🔐")
await alice.press("#chat_textarea", "Enter")
await alice.wait_for_timeout(3000)
await alice.screenshot(path=f"{OUT}/omemo_1_alice.png")
await bob.goto(BASE + "/chat/alice@localhost")
await bob.wait_for_selector("#chat_textarea", timeout=15000)
await bob.wait_for_timeout(5000)
await bob.screenshot(path=f"{OUT}/omemo_2_bob.png")
texts = await bob.locator("#chat_widget .bubble p").all_inner_texts()
print("bob last bubbles:", texts[-3:])
for e in aerr + berr:
print(e)
await browser.close()
asyncio.run(main())
+60
View File
@@ -0,0 +1,60 @@
"""Link previews: a page with OpenGraph tags and a direct picture link."""
import asyncio, functools, http.server, os, shutil, tempfile, threading
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
HERE = os.path.dirname(os.path.abspath(__file__))
PORT = 8765
PAGE = """<!doctype html><html><head>
<title>Fallback title</title>
<meta property="og:title" content="A very interesting article">
<meta property="og:description" content="Everything you always wanted to know about link previews.">
<meta property="og:image" content="/test.png">
<meta property="og:site_name" content="Example News">
<link rel="icon" href="/test.png">
</head><body>Hello</body></html>"""
def serve():
root = tempfile.mkdtemp()
with open(os.path.join(root, "article.html"), "w") as f:
f.write(PAGE)
shutil.copy(os.path.join(HERE, "test.png"), os.path.join(root, "test.png"))
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=root)
server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
async def main():
server = serve()
async with async_playwright() as p:
browser = await p.chromium.launch()
_, alice, aerr = await login(browser, "alice@localhost", "alicepass", "previewalice")
_, bob, berr = await login(browser, "bob@localhost", "bobpass", "previewbob")
await alice.goto(BASE + "/chat/bob@localhost")
await alice.wait_for_selector("#chat_textarea", timeout=15000)
await bob.goto(BASE + "/chat/alice@localhost")
await bob.wait_for_selector("#chat_textarea", timeout=15000)
await bob.wait_for_timeout(1500)
for text in (f"Have a look http://127.0.0.1:{PORT}/article.html", f"http://127.0.0.1:{PORT}/test.png"):
await bob.fill("#chat_textarea", text)
await bob.press("#chat_textarea", "Enter")
await bob.wait_for_timeout(1500)
await alice.wait_for_timeout(4000)
for name, page in (("alice", alice), ("bob", bob)):
await page.screenshot(path=f"{OUT}/preview_{name}.png")
cards = await page.locator("#chat_widget ul.list.card").all_inner_texts()
pictures = await page.locator("#chat_widget div.bubble img").count()
print(name, "cards:", [c.replace("\n", " | ")[:120] for c in cards[-2:]], "pictures:", pictures)
for e in aerr + berr:
print(e)
await browser.close()
server.shutdown()
asyncio.run(main())
+50
View File
@@ -0,0 +1,50 @@
import asyncio
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
STEPS = [
("contact drawer", "ChatActions_ajaxGetContact('bob@localhost')"),
("search drawer", "Search_ajaxRequest()"),
("notifications drawer", "Notifications_ajaxRequest()"),
("presence menu", "Presence_ajaxHttpMenu()"),
("emoji picker", "Stickers_ajaxReaction(null)"),
("chat search", "ChatActions_ajaxShowSearchDialog('bob@localhost', false)"),
("add contact dialog", "Notifications_ajaxAddAsk('carol@localhost')"),
("room discover", "RoomsUtils_ajaxHttpRoomDiscover()"),
("create room", "RoomsUtils_ajaxAdd(null, null, true)"),
("clear history dialog", "Chat_ajaxClearHistory('bob@localhost')"),
("logout dialog", "Presence_ajaxAskLogout()"),
("upload dialog", "Upload_ajaxGetPanel()"),
]
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
ctx, page, errors = await login(browser, "alice@localhost", "alicepass", "smoke")
await page.goto(BASE + "/chat/bob@localhost")
await page.wait_for_selector("#chat_textarea", timeout=15000)
await page.wait_for_timeout(2000)
# message dialog on the last message
mid = await page.evaluate("document.querySelector('#chat_widget .message[data-mid]:last-of-type')?.dataset.mid")
steps = STEPS + ([("message dialog", f"ChatActions_ajaxShowMessageDialog({mid})")] if mid else [])
for i, (name, js) in enumerate(steps):
before = len(errors)
await page.evaluate(js)
await page.wait_for_timeout(1200)
await page.screenshot(path=f"{OUT}/smoke_{i:02d}.png")
print(f"{name}: {'ERR ' + errors[-1][:150] if len(errors) > before else 'ok'}")
await page.evaluate("Dialog_ajaxClear(); Drawer_ajaxClear(); document.querySelector('#drawer')?.classList.add('empty')")
await page.wait_for_timeout(300)
# configuration tabs
await page.goto(BASE + "/configuration")
await page.wait_for_timeout(2500)
for tab in await page.locator("#navtabs li").all():
await tab.click()
await page.wait_for_timeout(700)
await page.screenshot(path=f"{OUT}/smoke_config_last.png")
print("errors total:", len(errors))
for e in errors[:10]:
print(e[:300])
await browser.close()
asyncio.run(main())
+26
View File
@@ -0,0 +1,26 @@
import asyncio
from playwright.async_api import async_playwright
from browser import login, BASE, OUT
import os
S = os.path.dirname(os.path.abspath(__file__))
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
ctx, page, errors = await login(browser, "alice@localhost", "alicepass", "aliceup")
await page.goto(BASE + "/chat/bob@localhost")
await page.wait_for_selector("#chat_textarea", timeout=15000)
await page.wait_for_timeout(2000)
for f in ("test.png", "notes.txt"):
await page.evaluate("Upload_ajaxGetPanel()")
await page.wait_for_selector("#upload", timeout=10000)
await page.set_input_files("input#file", f"{S}/{f}")
await page.wait_for_timeout(1500)
await page.click("#upload_button")
await page.wait_for_timeout(4000)
await page.screenshot(path=f"{OUT}/upload_1.png")
for e in errors:
print(e)
await browser.close()
asyncio.run(main())
+138
View File
@@ -0,0 +1,138 @@
import uuid
"""End-to-end test of movim-rs, simulating what the browser does."""
import asyncio, json, sys, re
import requests, websockets
BASE = "http://127.0.0.1:8099"
class Client:
def __init__(self, jid, password):
self.jid, self.password = jid, password
self.http = requests.Session()
self.ws = None
self.received = []
def cookie(self):
return self.http.cookies.get("MOVIM_SESSION_ID")
async def connect_ws(self):
self.ws = await websockets.connect(
BASE.replace("http", "ws") + "/ws/",
additional_headers={"Cookie": f"MOVIM_SESSION_ID={self.cookie()}", "Origin": BASE},
)
async def recv_until(self, pred, timeout=15, seen=False):
if seen:
for m in self.received:
if pred(m):
return m
end = asyncio.get_event_loop().time() + timeout
while True:
left = end - asyncio.get_event_loop().time()
if left <= 0:
raise TimeoutError("timeout waiting")
msg = json.loads(await asyncio.wait_for(self.ws.recv(), left))
self.received.append(msg)
if pred(msg):
return msg
async def call(self, widget, func, params=None):
body = {"w": widget, "f": func}
if params is not None:
body["p"] = params
await self.ws.send(json.dumps({"func": "message", "b": body}))
def ajax(self, widget, func, params=None, page="chat", daemon=False):
body = {"c": page, "w": widget, "f": func}
if params is not None:
body["p"] = params
r = self.http.post(BASE + ("/ajaxd" if daemon else "/ajax"), json={"func": "message", "b": body})
return r
async def login(self):
r = self.http.get(BASE + "/login")
assert r.status_code == 200, r.status_code
await self.connect_ws()
await self.recv_until(lambda m: m.get("func") == "started")
await self.ws.send(json.dumps({"func": "register", "host": self.jid.split("@")[1]}))
await self.recv_until(lambda m: m.get("func") == "registered")
form = {"username": {"value": self.jid}, "password": {"value": self.password}}
await self.call("Login", "ajaxLogin", [form, self.cookie(), "Europe/Paris"])
msg = await self.recv_until(lambda m: m.get("func") in ("MovimUtils.reloadThis", "MovimTpl.fill", "Login.setCookie") and (m.get("func") != "MovimTpl.fill" or m["p"][0] == "#error"), timeout=30)
if msg["func"] == "Login.setCookie":
# An existing session is shared
self.http.cookies.set("MOVIM_SESSION_ID", msg["p"][0], domain="127.0.0.1")
elif msg["func"] != "MovimUtils.reloadThis":
raise RuntimeError("login failed: " + re.sub(r"\s+", " ", msg["p"][1])[:300])
await self.ws.close()
# Reload the page like the browser would
r = self.http.get(BASE + "/chat")
assert r.status_code == 200 and "chat_widget" in r.text, (r.status_code, r.url)
await self.connect_ws()
async def main():
alice = Client("alice@localhost", "alicepass")
bob = Client("bob@localhost", "bobpass")
await alice.login()
print("alice logged in")
await bob.login()
print("bob logged in")
# On a fresh database the first login replays the archive (MAM): let it settle
await asyncio.sleep(3)
# Same calls as the chat page on load
for c in (alice, bob):
await c.call("Chat", "ajaxInit")
await c.call("Chats", "ajaxHttpGet")
await c.call("Rooms", "ajaxHttpGet")
await c.call("Presence", "ajaxHttpGetPresence")
await alice.recv_until(lambda m: m.get("func") == "Chat.init")
print("init ok")
# Alice opens a chat with Bob and sends a message
await alice.call("Chat", "ajaxGet", ["bob@localhost"])
await alice.recv_until(lambda m: m.get("func") == "Chat.setSpecificElements")
msgid = str(uuid.uuid4())
r = alice.ajax("Chat", "ajaxHttpDaemonSendMessage", [msgid, "bob@localhost", "Hello *Bob* 👋 https://movim.eu", False], daemon=True)
assert r.status_code == 200, r.status_code
# Match on our message id: older messages may be replayed from the archive
m = await alice.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and msgid in json.dumps(m))
print("alice sees her message:", json.dumps(m["p"][0], ensure_ascii=False)[:400])
# Bob receives it
m = await bob.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and msgid in json.dumps(m))
msg = next(v for p in m["p"][0].values() for v in p.values() if msgid in json.dumps(v))
print("bob receives:", msg["body"][:200], "| from", msg["jidfrom"])
await bob.recv_until(lambda m: m.get("func") == "Notif.desktop", seen=True)
print("bob notified")
# Bob opens the discussion and the receipt/displayed flow
await bob.call("Notif", "ajaxCurrent", ["chat|alice@localhost"])
await bob.call("Chat", "ajaxGet", ["alice@localhost"])
await bob.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper")
await bob.call("Chat", "ajaxDisplayed", ["alice@localhost", msg["id"]])
m = await alice.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and any(v.get("displayed") for p in m["p"][0].values() for v in p.values()))
print("alice sees displayed marker")
# Bob reacts
r = bob.ajax("Chat", "ajaxHttpDaemonSendReaction", [msg["mid"], "👍"], daemon=True)
m = await alice.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and any(v.get("reactionsHtml") for p in m["p"][0].values() for v in p.values()))
print("alice sees the reaction")
# Chat states
await bob.call("Chat", "ajaxSendComposing", ["alice@localhost", False])
m = await alice.recv_until(lambda m: m.get("func") == "MovimTpl.fill" and m["p"][0].endswith("-state") and m["p"][1] != "")
print("alice sees bob composing")
# Bob replies, Alice corrects her message
r = bob.ajax("Chat", "ajaxHttpDaemonSendMessage", [str(uuid.uuid4()), "alice@localhost", "Hi Alice", False, None, msg["mid"]], daemon=True)
m = await alice.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and any(v.get("parent") for p in m["p"][0].values() for v in p.values()))
print("alice receives the reply with its parent")
print("ALL OK")
if __name__ == "__main__":
asyncio.run(main())
+70
View File
@@ -0,0 +1,70 @@
import uuid
"""MUC end-to-end test."""
import asyncio, json, time
from e2e import Client
async def main():
alice = Client("alice@localhost", "alicepass")
bob = Client("bob@localhost", "bobpass")
await alice.login()
await bob.login()
for c in (alice, bob):
await c.call("Chat", "ajaxInit")
await c.call("Rooms", "ajaxHttpGet")
room = f"test{int(time.time())}@conference.localhost"
form = {
"jid": {"value": room},
"name": {"value": "Test room"},
"nick": {"value": "alice"},
"type": {"value": "groupchat"},
"autojoin": {"value": True},
"pinned": {"value": False},
"notify": {"value": "always"},
}
await alice.call("RoomsUtils", "ajaxAddCreate", [form])
await alice.recv_until(lambda m: m.get("func") == "Rooms.setRoom" and "connected" in m["p"][1] and room.split("@")[0] in m["p"][1], timeout=20)
print("room created and joined:", room)
# Invite Bob
await alice.call("RoomsUtils", "ajaxInvite", [{"to": {"value": room}, "invite": {"value": "bob@localhost"}}])
m = await bob.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and "invitation" in json.dumps(m), timeout=10)
print("bob got the invitation")
# Bob joins and adds the bookmark
await bob.call("RoomsUtils", "ajaxAddConfirm", [{
"jid": {"value": room}, "name": {"value": "Test room"}, "nick": {"value": "bob"},
"autojoin": {"value": True}, "pinned": {"value": True}, "notify": {"value": "on-mention"},
}])
await bob.recv_until(lambda m: m.get("func") == "Rooms.setRoom" and "connected" in m["p"][1], timeout=20)
print("bob joined")
# Alice sends a message, Bob opens the room
await alice.call("Chat", "ajaxGetRoom", [room])
await alice.recv_until(lambda m: m.get("func") == "Chat.setSpecificElements")
r = alice.ajax("Chat", "ajaxHttpDaemonSendMessage", [str(uuid.uuid4()), room, "Hello bob in the room", True], daemon=True)
m = await alice.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and "Hello bob in the room" in json.dumps(m))
msg = list(list(m["p"][0].values())[0].values())[0]
print("alice sees her reflected MUC message, mine =", msg.get("mine"), "stanzaid =", msg.get("stanzaid"))
m = await bob.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and "Hello bob in the room" in json.dumps(m))
print("bob receives the MUC message")
m = await bob.recv_until(lambda m: m.get("func") == "Notif.desktop", seen=True, timeout=5)
print("bob notified (mention)")
# Bob opens the room: history from the DB
await bob.call("Chat", "ajaxGetRoom", [room])
m = await bob.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and "Hello bob in the room" in json.dumps(m))
await bob.call("Chat", "ajaxHttpGetPresences", [room])
m = await bob.recv_until(lambda m: m.get("func") == "MovimTpl.fill" and m["p"][0].endswith("-nav"))
print("bob sees the members:", "alice" in m["p"][1] and "bob" in m["p"][1])
# Reaction in the MUC
r = bob.ajax("Chat", "ajaxHttpDaemonSendReaction", [msg["mid"] if msg.get("mid") else 0, "❤️"], daemon=True)
# Subject
await alice.call("RoomsUtils", "ajaxSetSubject", [room, {"subject": {"value": "Our subject"}}])
print("ALL OK")
asyncio.run(main())
+20
View File
@@ -0,0 +1,20 @@
import os
import uuid
import asyncio, json, subprocess, time
from e2e import Client
S = os.path.dirname(os.path.abspath(__file__))
async def main():
alice = Client("alice@localhost", "alicepass"); bob = Client("bob@localhost", "bobpass")
await alice.login(); await bob.login()
print("logged in, restarting prosody")
subprocess.run([f"{S}/prosody.sh", "restart"], capture_output=True)
t = time.time()
await alice.recv_until(lambda m: m.get("func") == "MovimUtils.addClass" and m["p"][0] == "#status_websocket" and m["p"][1] == "hide", timeout=90)
await bob.recv_until(lambda m: m.get("func") == "MovimUtils.addClass" and m["p"][0] == "#status_websocket" and m["p"][1] == "hide", timeout=90)
print(f"both reconnected after {time.time()-t:.1f}s")
bob.ajax("Chat", "ajaxHttpDaemonSendMessage", [str(uuid.uuid4()), "alice@localhost", "after the restart", False], daemon=True)
await alice.recv_until(lambda m: m.get("func") == "Chat.appendMessagesWrapper" and "after the restart" in json.dumps(m), timeout=15)
print("alice receives messages after reconnection: OK")
asyncio.run(main())
+1
View File
@@ -0,0 +1 @@
hello file
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
D=$(cd "$(dirname "$0")" && pwd)
P=$D/xmpp
export LUA_PATH="$P/share/lua/5.4/?.lua;$P/share/lua/5.4/?/init.lua;;"
export LUA_CPATH="$P/lib/lua/5.4/?.so;;"
exec "$P/prosody/bin/prosodyctl" --config "$P/prosody.cfg.lua" "$@"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# (Re)start movim-rs on 127.0.0.1:8099 against the local Prosody
D=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$D/../.." && pwd)
[ -f "$D/server.pid" ] && kill "$(cat "$D/server.pid")" 2>/dev/null
sleep 0.3
cd "$ROOT"
MOVIM_BIND=127.0.0.1:8099 MOVIM_DOMAIN=localhost MOVIM_ALLOW_PRIVATE_URLS=1 \
MOVIM_XMPP_PLAINTEXT=127.0.0.1:5222 MOVIM_DATA_DIR="$D/data" RUST_LOG=${RUST_LOG:-movim=info} \
./target/debug/movim > "$D/server.log" 2>&1 &
echo $! > "$D/server.pid"
sleep 1
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
# Build a local Prosody test server (no root needed) in tests/e2e/xmpp and
# create the test accounts alice/alicepass and bob/bobpass.
# Requires: lua 5.4 headers, expat, openssl, curl, gcc, make.
set -e
D=$(cd "$(dirname "$0")" && pwd)
P=$D/xmpp
SRC=$D/xmpp-src
mkdir -p "$SRC" "$P/lib/lua/5.4" "$P/share/lua/5.4/lxp" "$P/data" "$P/certs"
cd "$SRC"
[ -d prosody-13.0.7 ] || curl -sL https://prosody.im/downloads/source/prosody-13.0.7.tar.gz | tar xz
[ -d luasocket-3.1.0 ] || curl -sL https://github.com/lunarmodules/luasocket/archive/refs/tags/v3.1.0.tar.gz | tar xz
[ -d luafilesystem-1_8_0 ] || curl -sL https://github.com/lunarmodules/luafilesystem/archive/refs/tags/v1_8_0.tar.gz | tar xz
[ -d luaexpat-1.5.2 ] || curl -sL https://github.com/lunarmodules/luaexpat/archive/refs/tags/1.5.2.tar.gz | tar xz
[ -d luasec-1.3.2 ] || curl -sL https://github.com/lunarmodules/luasec/archive/refs/tags/v1.3.2.tar.gz | tar xz
(cd luasocket-3.1.0 && make LUAV=5.4 LUAINC_linux=/usr/include/lua5.4 linux >/dev/null && make LUAV=5.4 prefix="$P" install >/dev/null)
gcc -O2 -fPIC -shared -I/usr/include/lua5.4 luafilesystem-1_8_0/src/lfs.c -o "$P/lib/lua/5.4/lfs.so"
gcc -O2 -fPIC -shared -I/usr/include/lua5.4 -Iluaexpat-1.5.2/src luaexpat-1.5.2/src/lxplib.c -lexpat -o "$P/lib/lua/5.4/lxp.so" 2>/dev/null
cp luaexpat-1.5.2/src/lxp/*.lua "$P/share/lua/5.4/lxp/"
(cd luasec-1.3.2 && make linux INC_PATH="-I/usr/include/lua5.4" LIB_PATH="" >/dev/null && make install LUAPATH="$P/share/lua/5.4" LUACPATH="$P/lib/lua/5.4" >/dev/null)
(cd prosody-13.0.7 && ./configure --lua-version=5.4 --with-lua-include=/usr/include/lua5.4 --prefix="$P/prosody" --no-example-certs >/dev/null && make >/dev/null && make install >/dev/null)
cat > "$P/prosody.cfg.lua" <<CFG
prosodyctl_service_warnings = false
pidfile = "$P/prosody.pid"
data_path = "$P/data"
certificates = "$P/certs"
log = { info = "$P/prosody.log"; error = "$P/prosody.err" }
c2s_ports = { 5222 }
http_ports = { 5280 }
http_interfaces = { "127.0.0.1" }
https_ports = {}
interfaces = { "127.0.0.1" }
c2s_require_encryption = false
allow_unencrypted_plain_auth = true
authentication = "internal_hashed"
allow_registration = true
modules_enabled = {
"roster"; "saslauth"; "disco"; "carbons"; "pep"; "private"; "blocklist";
"vcard4"; "vcard_legacy"; "version"; "uptime"; "time"; "ping"; "register";
"mam"; "csi_simple"; "smacks"; "bookmarks"; "server_contact_info";
}
modules_disabled = { "s2s"; "tls" }
archive_expires_after = "never"
default_archive_policy = true
http_host = "localhost"
http_external_url = "http://localhost:5280/"
VirtualHost "localhost"
Component "conference.localhost" "muc"
modules_enabled = { "muc_mam" }
Component "upload.localhost" "http_file_share"
http_file_share_size_limit = 20*1024*1024
CFG
"$D/prosody.sh" start
sleep 2
"$D/prosody.sh" register alice localhost alicepass || true
"$D/prosody.sh" register bob localhost bobpass || true
"$D/prosody.sh" register carol localhost carolpass || true
echo "Prosody is running on 127.0.0.1:5222"
+17
View File
@@ -0,0 +1,17 @@
import asyncio, json
from e2e import Client
async def main():
alice = Client("alice@localhost", "alicepass")
bob = Client("bob@localhost", "bobpass")
await alice.login(); await bob.login()
await alice.call("Notifications", "ajaxAdd", [{"searchjid": {"value": "bob@localhost"}, "alias": {"value": "Bob"}, "group": {"value": "Friends"}}])
await bob.recv_until(lambda m: m.get("func") == "Notifications.setCounters" and m["p"][0] not in ("", 0), timeout=10)
print("bob got the request")
await bob.call("Notifications", "ajaxAccept", ["alice@localhost"])
await asyncio.sleep(3)
await alice.call("Search", "ajaxHttpInitRoster")
m = await alice.recv_until(lambda m: m.get("func") == "MovimTpl.fill" and m["p"][0] == "#roster")
print("alice roster has bob:", "bob@localhost" in m["p"][1], "online:", "status online" in m["p"][1])
asyncio.run(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB