diff options
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/photo_sync_probe.py | 34 | ||||
| -rwxr-xr-x | packages/meshbay-hub/tests/harness/photo_video_probe.py | 170 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_android_shell.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_drive_sync.py | 180 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_first_load_is_lean.py | 1 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hook_ordering.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_photo_sync.py | 68 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_photo_sync_ui.py | 23 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_photo_videos.py | 51 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_transport_contracts.py | 4 |
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. |