aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/harness/photo_sync_probe.py34
-rwxr-xr-xpackages/meshbay-hub/tests/harness/photo_video_probe.py170
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py13
-rw-r--r--packages/meshbay-hub/tests/test_drive_sync.py180
-rw-r--r--packages/meshbay-hub/tests/test_first_load_is_lean.py1
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync.py68
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py23
-rw-r--r--packages/meshbay-hub/tests/test_photo_videos.py51
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py4
10 files changed, 536 insertions, 10 deletions
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
index 3ae73c1..3fcce8a 100644
--- a/packages/meshbay-hub/tests/harness/photo_sync_probe.py
+++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
@@ -97,6 +97,38 @@ window.meshbay = {
permit: async () => ({}), configure: async () => ({}), plan: async () => ({ item: null }),
sent: async () => true, completed: async () => ({}), failed: async () => true,
},
+ calendarSync: {
+ status: async () => ({
+ permission: 'granted', on: MODE === 'configured', destination: dest,
+ dir: dest && '__FOLDER__/bob-calendar', sent: 5, lastCompleted: Date.now(),
+ lastSent: dest && '__FOLDER__/bob-calendar/calendar-2026-10-10-0900.ics', failure: null,
+ }),
+ permit: async () => ({}), configure: async () => ({}), plan: async () => ({ item: null }),
+ sent: async () => true, completed: async () => ({}), failed: async () => true,
+ },
+ driveSync: {
+ status: async () => ({
+ on: MODE === 'configured', unmetered: true, destination: dest,
+ dir: dest && '__FOLDER__/bob-drive', sent: 31, lastCompleted: Date.now(), failure: null,
+ folders: [{ id: 't1', name: 'Documents' },
+ { id: 't2', name: 'Scans_from_the_office_printer_with_a_very_long_name_2026' }],
+ }),
+ addFolder: async () => { throw new Error('Refused: media_folder'); },
+ removeFolder: async () => ({}), configure: async () => ({}), plan: async () => ({ items: [] }),
+ sent: async () => true, completed: async () => ({}), failed: async () => true,
+ keepAlive: async () => true,
+ },
+ whatsappSync: {
+ status: async () => ({
+ on: MODE === 'configured', unmetered: true, destination: dest, options: { media: false },
+ dir: dest && '__FOLDER__/bob-whatsapp', sent: 4, lastCompleted: Date.now(), failure: null,
+ folders: [{ id: 'wa', name: 'WhatsApp' }],
+ }),
+ chooseFolder: async () => ({}), forgetFolder: async () => ({}), configure: async () => ({}),
+ plan: async () => ({ items: [] }), sent: async () => true, manifest: async () => ({ item: null }),
+ manifestSent: async () => true, completed: async () => ({}), failed: async () => true,
+ keepAlive: async () => true,
+ },
messageSync: {
status: async () => ({
permission: 'granted', on: MODE === 'configured', destination: dest,
@@ -156,7 +188,7 @@ setTimeout(() => {
.map((e) => `${e.tagName}.${e.className} ${Math.round(e.getBoundingClientRect().right)}`);
out[f.id] = {
vw, scrollW: d.documentElement.scrollWidth, wide: wide.slice(0, 6),
- text: d.querySelector('#root').innerText.slice(0, 3000),
+ text: d.querySelector('#root').innerText.slice(0, 8000),
err: d.documentElement.dataset.err || '',
checked: [...d.querySelectorAll('#root input:checked')]
.map((i) => i.closest('label').innerText.split('\\n')[0]),
diff --git a/packages/meshbay-hub/tests/harness/photo_video_probe.py b/packages/meshbay-hub/tests/harness/photo_video_probe.py
new file mode 100755
index 0000000..02ebb90
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/photo_video_probe.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+"""
+Videos among the photos of an album, in a real browser.
+
+Mounts the shipped `PhotosApp` on a made-up index of two photos and a phone's
+clip in one folder, with an `onPreview` that records what it was asked to
+play. Reads the album card, the clip's tile, the lightbox on the clip, and a
+slideshow started on it.
+
+ photo_video_probe.py
+
+Prints JSON: one entry per case.
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8777
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<div id="photos"></div>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale, setLocale } from '/i18n.js';
+import { PhotosApp } from '/photos-app.js';
+
+const LOGS = [];
+addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e)));
+addEventListener('unhandledrejection',
+ (e) => LOGS.push('rejection: ' + (e.reason && e.reason.message || e.reason)));
+const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const ENTRIES = [
+ { id: 'p1', name: 'a.jpg', path: 'Pics/2026-10', size: 4, type: 'image', added_at: 1 },
+ { id: 'v1', name: 'b.mp4', path: 'Pics/2026-10', size: 9e8, type: 'video', added_at: 2,
+ duration: 42 },
+ { id: 'p2', name: 'c.jpg', path: 'Pics/2026-10', size: 4, type: 'image', added_at: 3 },
+];
+const PLAYED = [];
+const ref = { current: null };
+
+(async () => {
+ const cases = [];
+ try {
+ setLocale('en');
+ await initLocale();
+ render(html`<${PhotosApp} groupId="g" transportRef=${ref} gekRef=${ref}
+ status="connected" entries=${ENTRIES} photoDirectories=${['Pics']}
+ setError=${() => {}} linkFor=${() => null}
+ onPreview=${(e) => PLAYED.push(e.id)} />`, document.getElementById('photos'));
+ await frame();
+ const card = document.querySelector('.photo-album-card');
+ cases.push({ case: 'card', text: card ? card.textContent : null });
+ card.click(); await frame();
+
+ const tiles = [...document.querySelectorAll('.photo-tile')];
+ cases.push({ case: 'tiles', count: tiles.length,
+ badges: tiles.map((t) => (t.querySelector('.photo-tile-video') || {}).textContent || null) });
+
+ tiles[1].click(); await frame();
+ const title = () => (document.querySelector('.photo-lightbox .video-title') || {}).textContent;
+ const play = document.querySelector('.photo-lightbox-play');
+ const onClip = title();
+ if (play) { play.click(); await frame(); }
+ cases.push({ case: 'lightbox', title: onClip, play: Boolean(play), played: PLAYED.slice(),
+ info: (document.querySelector('.photo-lightbox-info') || {}).textContent });
+
+ const show = [...document.querySelectorAll('.photo-lightbox .video-close')]
+ .find((b) => (b.title || '').toLowerCase().includes('slideshow'));
+ if (show) show.click();
+ await wait(300); await frame();
+ cases.push({ case: 'slideshow', button: Boolean(show), now: title() });
+
+ parent.postMessage({ cases, logs: LOGS, origin: location.origin }, '*');
+ } catch (err) {
+ parent.postMessage({ error: String(err && (err.stack || err)), logs: LOGS }, '*');
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+addEventListener('message', (e) => {
+ fetch('/log', { method: 'POST', body: JSON.stringify(e.data) });
+});
+const f = document.createElement('iframe');
+f.src = '/case';
+f.style.cssText = 'width:1100px;height:800px;border:0;display:block';
+document.getElementById('frames').appendChild(f);
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ elif path == "/case":
+ self._send(FRAME.encode(), "text/html; charset=utf-8")
+ else:
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,900",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index 2990e9b..cd5a36e 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -139,16 +139,21 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
SRC / "photos" / "PhotoChannels.kt", SRC / "phonesync" / "Destination.kt"):
native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M))
# Contacts and messages share one class, its channels named by a prefix.
- docs = set(re.findall(r'^\s*"(\w+)" ->', _read(SRC / "phonesync" / "DocChannels.kt"), flags=re.M))
+ docs = set(re.findall(r'^\s*"([\w-]+)" ->', _read(SRC / "phonesync" / "DocChannels.kt"), flags=re.M))
for prefix in re.findall(r'DocChannels\("(\w+)"', _read(SRC / "MainActivity.kt")):
native |= {f"{prefix}:{name}" for name in docs}
+ # The chosen-folder backups too, their prefixes in DriveKind.kt.
+ drive = set(re.findall(r'^\s*"([\w-]+)" ->', _read(SRC / "drive" / "DriveChannels.kt"), flags=re.M))
+ for prefix in re.findall(r'prefix = "(\w+)"', _read(SRC / "drive" / "DriveKind.kt")):
+ native |= {f"{prefix}:{name}" for name in drive}
shim = _shim_channels()
assert shim, "no channel found in the shim"
# A phone has a push distributor to talk to and a camera roll and an
# address book to back up, and a desktop has none of them; those families are the ones the desktop lacks
# rather than the ones it shares.
phone_only = {c for c in shim if c.startswith(("push:", "photosync:", "phonesync:", "contactsync:",
- "messagesync:"))}
+ "calendarsync:", "messagesync:", "drivesync:",
+ "wasync:"))}
assert any(c.startswith("push:") for c in phone_only), "the push channels are gone"
assert any(c.startswith("photosync:") for c in phone_only), "the backup channels are gone"
assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}"
@@ -267,9 +272,11 @@ def test_only_the_personal_profile_is_backed_up():
assert "Profiles.isPersonal(this)" in activity and "const BACKUP = $backups;" in activity
shim = _strip_js_comments(_read(SHIM))
gated = shim.split("...(BACKUP ? {", 1)[1].split("} : {}),", 1)[0]
- for name in ("photoSync:", "phoneSync:", "contactSync:"):
+ for name in ("photoSync:", "phoneSync:", "contactSync:", "calendarSync:", "driveSync:",
+ "whatsappSync:"):
assert name in gated, name
for path in [*(SRC / "phonesync").rglob("*.kt"), *(SRC / "photos").rglob("*.kt"),
+ *(SRC / "drive").rglob("*.kt"),
*(APP / "src" / "full").rglob("*.kt")]:
assert not re.search(r"\.ENTERPRISE_\w+", _read(path)), path.name
diff --git a/packages/meshbay-hub/tests/test_drive_sync.py b/packages/meshbay-hub/tests/test_drive_sync.py
new file mode 100644
index 0000000..3861585
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_drive_sync.py
@@ -0,0 +1,180 @@
+"""
+The phone's files backup, as the page runs it (docs/MESHBAY_DESIGN.md §9.13).
+
+Run under Node against the real `drive-sync.js`, which is the photo backup's
+own runner (`createBackup` in photo-sync.js) over the files bridge: the same
+promises hold, once a day, on an unmetered network, each file under a slot
+and read from the phone a range at a time, nothing removed from the node,
+and only into a group the account owns alone.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SYNC = STATIC / "drive-sync.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not SYNC.exists(),
+ reason="node or the SPA sources are not available")
+
+FAKE_POOL = r"""
+export class ConnectionPool {
+ async connect(groupId) {
+ globalThis.fake.log.push(['connect', groupId]);
+ return { transport: globalThis.fake.transport, ack: {} };
+ }
+ closeAll() { globalThis.fake.log.push(['close']); }
+}
+"""
+
+PRELUDE = r"""
+const fake = globalThis.fake = { log: [], uploadError: null, members: [{ user_id: 'u-bob' }] };
+const win = new EventTarget();
+const doc = new EventTarget();
+doc.visibilityState = 'visible';
+globalThis.window = win;
+globalThis.document = doc;
+globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
+globalThis.fetch = async (url) => {
+ const [path, query] = url.split('?');
+ const m = /range=(\d+)-(\d+)/.exec(query || '');
+ fake.log.push(['fetch', path, m ? `bytes=${m[1]}-${m[2]}` : null]);
+ return { ok: true, status: 200,
+ arrayBuffer: async () => new ArrayBuffer(m ? Number(m[2]) - Number(m[1]) + 1 : 0) };
+};
+fake.status = {
+ on: true, unmetered: true, sent: 0, now: Date.now(),
+ destination: { account: 'bob', groupId: 'g1', groupName: 'Mine', folder: 'Backups' },
+ dir: 'Backups/bob-drive', folders: [{ id: 'tree1', name: 'Documents' }],
+ lastCompleted: null, failure: null, failureAt: null,
+};
+fake.items = [
+ { token: 'd1', name: 'notes.txt', dir: 'Backups/bob-drive/Documents', size: 6, edited: false },
+ { token: 'd2', name: 'tax.pdf', dir: 'Backups/bob-drive/Documents/Tax/2026', size: 12, edited: false },
+];
+fake.index = { roots: [{ name: 'Backups', writable: true, available: true }], dirs: [], entries: [] };
+fake.transport = {
+ async fetchIndex() { return fake.index; },
+ async createDirectory(at, name) { fake.log.push(['mkdir', `${at}/${name}`]); return {}; },
+ openTransfer({ bytes }) {
+ return { tr: 'tr-' + bytes, acquire: async () => {}, release: () => fake.log.push(['release', bytes]) };
+ },
+ async uploadFile(file, opts) {
+ fake.log.push(['upload', opts.dir, file.name]);
+ if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; }
+ await file.slice(0, file.size).arrayBuffer();
+ return { stored_as: file.name, dir: opts.dir };
+ },
+};
+const hubFetch = async () => ({ admin_id: 'u-bob', members: fake.members, invited: [] });
+fake.wa = { on: false };
+win.meshbay = { whatsappSync: {
+ status: async () => ({ ...fake.status, on: fake.wa.on, dir: 'Backups/bob-whatsapp',
+ folders: [{ id: 'wa', name: 'WhatsApp' }] }),
+ plan: async () => ({ items: [{ token: 'w1', name: 'msgstore.db.crypt14',
+ dir: 'Backups/bob-whatsapp/Databases', size: 8, edited: false }],
+ manifest: false }),
+ sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; },
+ manifest: async () => ({ item: null }), manifestSent: async () => true,
+ completed: async () => { fake.log.push(['wa-completed']); return fake.status; },
+ failed: async (code) => { fake.log.push(['failed', code]); return true; },
+ keepAlive: async () => true,
+}, driveSync: {
+ status: async () => fake.status,
+ plan: async () => { fake.log.push(['plan']); return { items: fake.items, manifest: false }; },
+ manifest: async () => ({ item: null }),
+ manifestSent: async () => true,
+ sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; },
+ completed: async () => { fake.log.push(['completed']); fake.status.lastCompleted = Date.now(); return fake.status; },
+ failed: async (code, text) => { fake.log.push(['failed', code, text]); return true; },
+ keepAlive: async (on) => { fake.log.push(['keep', on]); return true; },
+} };
+"""
+
+
+def _run(body, tmp_path):
+ root = tmp_path / "static"
+ shutil.copytree(STATIC, root)
+ (root / "package.json").write_text('{"type":"module"}', encoding="utf-8")
+ (root / "connection-pool.js").write_text(FAKE_POOL, encoding="utf-8")
+ script = tmp_path / "case.mjs"
+ script.write_text(
+ PRELUDE
+ + f"const m = await import('{(root / 'drive-sync.js').as_uri()}');\n"
+ + "const ctx = { hub: 'https://hub.invalid', hubFetch, getUser: () => ({ username: 'bob', "
+ "userId: 'u-bob', token: 'tok' }), getBundleKey: async () => 'bk' };\n"
+ + body
+ + "\nm.stopDriveSync();\nconsole.log(JSON.stringify(fake.log));\n",
+ encoding="utf-8")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True,
+ encoding="utf-8", timeout=30)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout.strip().splitlines()[-1])
+
+
+def _start(extra=""):
+ return extra + "\nm.startDriveSync(ctx);\nawait m.runDriveSync();\n"
+
+
+def _of(log, kind):
+ return [e[1:] for e in log if e[0] == kind]
+
+
+def test_a_due_run_sends_each_file_into_its_folder_a_range_at_a_time(tmp_path):
+ log = _run(_start(), tmp_path)
+ assert _of(log, "mkdir") == [["Backups/bob-drive"], ["Backups/bob-drive/Documents"],
+ ["Backups/bob-drive/Documents/Tax"],
+ ["Backups/bob-drive/Documents/Tax/2026"]]
+ assert _of(log, "fetch") == [["/drivesync/d1", "bytes=0-5"], ["/drivesync/d2", "bytes=0-11"]]
+ assert _of(log, "sent") == [["d1", "Backups/bob-drive/Documents", "notes.txt"],
+ ["d2", "Backups/bob-drive/Documents/Tax/2026", "tax.pdf"]]
+ assert ["completed"] in log
+
+
+def test_on_mobile_data_a_scheduled_run_waits(tmp_path):
+ log = _run(_start("fake.status.unmetered = false;"), tmp_path)
+ assert ["plan"] not in log
+
+
+def test_no_folder_chosen_means_nothing_runs(tmp_path):
+ log = _run(_start("fake.status.folders = [];"), tmp_path)
+ assert ["plan"] not in log and _of(log, "connect") == []
+
+
+def test_a_group_with_somebody_else_in_it_stops_it_before_anything_is_read(tmp_path):
+ log = _run(_start("fake.members.push({ user_id: 'u-carol' });"), tmp_path)
+ assert [e[0] for e in _of(log, "failed")] == ["not_private"]
+ assert ["plan"] not in log
+
+
+def test_a_lasting_refusal_names_the_files_backup(tmp_path):
+ log = _run(_start("fake.uploadError = 'disk_full';"), tmp_path)
+ failed = _of(log, "failed")
+ assert [e[0] for e in failed] == ["disk_full"] and failed[0][1]
+ assert ["completed"] not in log
+
+
+def test_whatsapps_chats_go_into_their_own_folder_on_their_own_runner(tmp_path):
+ log = _run("""
+fake.wa.on = true;
+fake.items = [];
+m.startDriveSync(ctx);
+await m.runWhatsAppSync();
+""", tmp_path)
+ assert ["upload", "Backups/bob-whatsapp/Databases", "msgstore.db.crypt14"] in log
+ assert ["fetch", "/wasync/w1", "bytes=0-7"] in log
+ assert ["wa-completed"] in log
+
+
+def test_the_backup_has_no_way_to_remove_anything_on_the_node():
+ for path in (SYNC, STATIC / "photo-sync.js"):
+ src = path.read_text(encoding="utf-8")
+ for op in ("deleteFile", "deleteDirectory", "removeRoot", "file_delete", "dir_delete",
+ "root_remove", "rename"):
+ assert not re.search(rf"\b{op}\b", src), f"{path.name} reaches {op}"
diff --git a/packages/meshbay-hub/tests/test_first_load_is_lean.py b/packages/meshbay-hub/tests/test_first_load_is_lean.py
index 84f5179..2c191a5 100644
--- a/packages/meshbay-hub/tests/test_first_load_is_lean.py
+++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py
@@ -27,6 +27,7 @@ ON_DEMAND = {
# The Android application's backups: no browser or desktop has them.
"photo-sync.js", "photo-sync-settings.js", "android-sync-page.js",
"phone-sync.js", "phone-sync-settings.js", "sync-destination.js",
+ "drive-sync.js", "drive-sync-settings.js", "whatsapp-sync-settings.js",
}
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index 6dddcc3..33408bb 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -56,7 +56,7 @@ STATIC_FILES = [
"ask.js",
# The Android application's backups, on their own page loaded on demand.
"settings-page.js", "android-sync-page.js", "photo-sync-settings.js",
- "phone-sync-settings.js",
+ "phone-sync-settings.js", "drive-sync-settings.js", "whatsapp-sync-settings.js",
]
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
diff --git a/packages/meshbay-hub/tests/test_photo_sync.py b/packages/meshbay-hub/tests/test_photo_sync.py
index 98f49ba..9ade08b 100644
--- a/packages/meshbay-hub/tests/test_photo_sync.py
+++ b/packages/meshbay-hub/tests/test_photo_sync.py
@@ -58,8 +58,13 @@ globalThis.window = win;
globalThis.document = doc;
globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
globalThis.fetch = async (url) => {
- fake.log.push(['fetch', url]);
- return { ok: true, blob: async () => new Blob([new Uint8Array(4)]) };
+ // The slice is in the address (platform.js rangedFile), never a header.
+ const [path, query] = url.split('?');
+ const m = /range=(\d+)-(\d+)/.exec(query || '');
+ fake.log.push(['fetch', path, m ? `bytes=${m[1]}-${m[2]}` : null]);
+ const n = m ? Number(m[2]) - Number(m[1]) + 1 : 4;
+ return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(n),
+ blob: async () => new Blob([new Uint8Array(n)]) };
};
const NOW = Date.now();
@@ -95,6 +100,12 @@ fake.transport = {
},
async uploadFile(file, opts) {
fake.log.push(['upload', opts.dir, file.name, opts.tr]);
+ if (fake.readChunk) {
+ for (let at = 0; at < file.size; at += fake.readChunk) {
+ const buf = await file.slice(at, at + fake.readChunk).arrayBuffer();
+ fake.log.push(['read', at, buf.byteLength]);
+ }
+ }
if (fake.uploadError) { const e = new Error('refused'); e.code = fake.uploadError; throw e; }
if (fake.afterUpload) fake.afterUpload();
return { stored_as: file.name, dir: opts.dir };
@@ -102,7 +113,9 @@ fake.transport = {
};
win.meshbay = { photoSync: {
status: async () => fake.status,
- plan: async () => { fake.log.push(['plan']); return { items: fake.items }; },
+ plan: async () => { fake.log.push(['plan']); return { items: fake.items, manifest: !!fake.manifest }; },
+ manifest: async () => ({ item: fake.manifest || null }),
+ manifestSent: async (token) => { fake.log.push(['manifest-sent', token]); fake.manifest = null; return true; },
sent: async (token, dir, name) => { fake.log.push(['sent', token, dir, name]); return true; },
completed: async () => {
fake.log.push(['completed']);
@@ -164,6 +177,20 @@ def test_a_due_run_sends_newest_first_and_records_each(tmp_path):
"the screen-off keep-alive was not released after the run")
+def test_a_file_is_read_from_the_phone_a_chunk_at_a_time(tmp_path):
+ """A video of gigabytes is never whole in the page: each slice the upload
+ reads is its own ranged request to the phone."""
+ log = _run(_start("""
+fake.items = [{ token: 'v1', name: 'clip.mp4', dir: 'Media/Photos/Bob/2026/2026-10', size: 25,
+ edited: false, video: true }];
+fake.readChunk = 10;
+"""), tmp_path)
+ assert _of(log, "fetch") == [["/photosync/v1", "bytes=0-9"], ["/photosync/v1", "bytes=10-19"],
+ ["/photosync/v1", "bytes=20-24"]]
+ assert _of(log, "read") == [[0, 10], [10, 10], [20, 5]]
+ assert ["sent", "v1", "Media/Photos/Bob/2026/2026-10", "clip.mp4"] in log
+
+
def test_only_missing_folders_are_made_and_parents_first(tmp_path):
log = _run(_start(), tmp_path)
assert _of(log, "mkdir") == [["Media/Photos/Bob/2026/2026-10"]]
@@ -286,6 +313,41 @@ def test_another_account_on_the_phone_sends_nothing(tmp_path):
assert ["plan"] not in log
+# ── the manifest ────────────────────────────────────────────────────────────
+
+MANIFEST = """
+fake.manifest = { token: 'mf', name: 'manifest-2026-10-10-090000.jsonl',
+ dir: 'Media/Photos/Bob/bob-photos/meshbay-manifest', size: 7 };
+"""
+
+
+def test_a_run_ends_with_the_manifest_of_what_it_sent(tmp_path):
+ log = _run(_start(MANIFEST), tmp_path)
+ uploads = _of(log, "upload")
+ assert uploads[-1][:2] == ["Media/Photos/Bob/bob-photos/meshbay-manifest",
+ "manifest-2026-10-10-090000.jsonl"]
+ assert ["mkdir", "Media/Photos/Bob/bob-photos/meshbay-manifest"] in log
+ assert log.index(["manifest-sent", "mf"]) < log.index(["completed"])
+
+
+def test_a_manifest_left_by_an_interrupted_run_goes_on_its_own(tmp_path):
+ log = _run(_start(MANIFEST + "fake.items = [];"), tmp_path)
+ assert [u[1] for u in _of(log, "upload")] == ["manifest-2026-10-10-090000.jsonl"]
+ assert ["manifest-sent", "mf"] in log and ["completed"] in log
+
+
+def test_a_manifest_the_node_refused_is_kept_for_the_next_run(tmp_path):
+ log = _run(_start(MANIFEST + """
+const real = fake.transport.uploadFile;
+fake.transport.uploadFile = async (file, opts) => {
+ if (file.name.startsWith('manifest-')) { const e = new Error('full'); e.code = 'disk_full'; throw e; }
+ return real(file, opts);
+};"""), tmp_path)
+ assert _of(log, "manifest-sent") == []
+ assert _of(log, "failed") == [["disk_full"]]
+ assert ["completed"] not in log
+
+
# ── the account's own group, at every run ───────────────────────────────────
@pytest.mark.parametrize("setup", [
diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py
index b101310..da0ceeb 100644
--- a/packages/meshbay-hub/tests/test_photo_sync_ui.py
+++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py
@@ -57,7 +57,7 @@ def test_limited_access_is_said_rather_than_failing_quietly(probe):
def test_where_backups_go_lists_each_kinds_folder(probe):
text = probe["dest360"]["text"]
- assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "messages"))
+ assert all(f"Media/bob-{k}" in text for k in ("photos", "contacts", "calendar", "messages", "drive", "whatsapp"))
def test_photos_the_photos_tab_will_not_show_are_said(probe):
@@ -70,3 +70,24 @@ def test_the_contacts_backup_says_where_its_copies_go(probe):
def test_the_messages_backup_says_where_its_copies_go(probe):
assert "bob-messages/2026/sms-2026-10-10-0900.xml" in probe["configured360"]["text"]
+
+
+def test_the_calendar_backup_says_where_its_copies_go(probe):
+ assert "bob-calendar/calendar-2026-10-10-0900.ics" in probe["configured360"]["text"]
+
+
+def test_videos_are_offered_and_off_by_default(probe):
+ assert "Videos too" in probe["setup360"]["text"]
+ assert "Videos too" not in probe["setup360"]["checked"]
+
+
+def test_the_files_backup_lists_its_folders_and_what_it_leaves_out(probe):
+ text = probe["configured360"]["text"]
+ assert "Documents" in text and "Scans_from_the_office_printer" in text
+ assert "DCIM, Pictures and Movies cannot be chosen" in text
+
+
+def test_the_whatsapp_backup_says_how_to_restore(probe):
+ text = probe["configured360"]["text"]
+ assert "WhatsApp's folder is chosen" in text
+ assert "To restore on a new phone" in text
diff --git a/packages/meshbay-hub/tests/test_photo_videos.py b/packages/meshbay-hub/tests/test_photo_videos.py
new file mode 100644
index 0000000..2585f47
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_photo_videos.py
@@ -0,0 +1,51 @@
+"""
+A phone's clips among the photos of an album (harness/photo_video_probe.py).
+
+Backed up beside the photos (docs/MESHBAY_DESIGN.md §9.12), a clip belongs to
+its album: counted on the card, marked and timed on its tile, and played in
+the group's video player from the lightbox, never downloaded whole into it. A
+slideshow is of photos and passes a clip by.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "photo_video_probe.py"
+
+
+@pytest.fixture(scope="module")
+def cases():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("Chrome is not available")
+ proc = subprocess.run([sys.executable, str(HARNESS)],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, out["error"]
+ assert out["logs"] == []
+ return {c["case"]: c for c in out["cases"]}
+
+
+def test_the_album_counts_photos_and_videos_apart(cases):
+ assert "2 photos · 1 video" in cases["card"]["text"]
+
+
+def test_a_clip_is_in_the_album_marked_with_its_length(cases):
+ assert cases["tiles"]["count"] == 3
+ assert cases["tiles"]["badges"] == [None, "0:42", None]
+
+
+def test_the_lightbox_hands_a_clip_to_the_video_player(cases):
+ c = cases["lightbox"]
+ assert c["title"] == "b.mp4" and c["play"]
+ assert c["played"] == ["v1"]
+ assert "0:42" in c["info"]
+
+
+def test_a_slideshow_passes_a_clip_by(cases):
+ assert cases["slideshow"]["button"] and cases["slideshow"]["now"] == "c.jpg"
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index bb39966..0f0edb8 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -295,7 +295,9 @@ def test_no_setter_survives_the_state_it_belonged_to():
# `useState` behind it by design. Without this the rule reports every
# such helper, and a rule that cries wolf is one someone eventually
# silences.
- defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M))
+ # Nested too: a runner built by a factory (createBackup) keeps its
+ # own `setState` inside it.
+ defined = set(re.findall(r"^\s*(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M))
defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M))
# A setter named in a `function Component({ ..., setX, ... })` prop
# list is handed down from wherever it is really declared.