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.py182
-rw-r--r--packages/meshbay-hub/tests/test_android_cast.py7
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py13
-rw-r--r--packages/meshbay-hub/tests/test_first_load_is_lean.py2
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync.py307
-rw-r--r--packages/meshbay-hub/tests/test_photo_sync_ui.py55
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py2
8 files changed, 563 insertions, 7 deletions
diff --git a/packages/meshbay-hub/tests/harness/photo_sync_probe.py b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
new file mode 100644
index 0000000..48d9270
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/photo_sync_probe.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+"""
+The phone's photo backup section (photo-sync-settings.js), rendered in Chrome
+at phone widths with names nobody would choose for a fixture.
+
+Only the Android application has this section, and its engine is Chromium, so
+one engine is the whole of it. The bridge is a stand-in that answers the way
+the phone does; the group connection (`connection-pool.js`) is replaced in the
+copy by one that hands out a node with long, space-free folder names, because
+those are what widen a page (CLAUDE.md). Three states: setting up, set up with
+a refusal showing, and setting up with only selected photos allowed.
+
+ photo_sync_probe.py prints one JSON object, keyed <state><width>
+"""
+import functools
+import http.server
+import json
+import shutil
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+
+LONG_FOLDER = "Media/Photos/Family_holidays_summer_2026_at_the_seaside_with_everyone_and_the_dog"
+LONG_GROUP = "A_group_name_with_no_spaces_at_all_whatsoever_long"
+LONG_OWNER = "someone_with_a_long_handle"
+
+FAKE_POOL = """
+export class ConnectionPool {
+ async connect() {
+ return {
+ transport: {
+ fetchIndex: async () => ({
+ roots: [{ name: 'Media', writable: true, available: true }],
+ dirs: ['Media/Photos',
+ 'Media/Photos/Some_Band_Complete_Discography_1992-2000_FLAC_and_more_pictures',
+ '__FOLDER__'],
+ }),
+ },
+ ack: { photo_directories: ['Media/Photos'] },
+ };
+ }
+ closeAll() {}
+}
+""".replace("__FOLDER__", LONG_FOLDER)
+
+PROBE = """<!doctype html><html><head><meta charset=utf-8>
+<meta name=viewport content="width=device-width">
+<link rel=stylesheet href="style.css"></head><body>
+<script>
+const note = (m) => {
+ const el = document.documentElement;
+ el.dataset.err = (el.dataset.err || '') + ' ' + m;
+};
+window.addEventListener('error', (e) => note(e.message));
+window.addEventListener('unhandledrejection',
+ (e) => note((e.reason && e.reason.message) || e.reason));
+const MODE = location.hash.slice(1);
+const cfg = {
+ account: 'bob', groupId: 'g1', groupName: '__GROUP__', owner: '__OWNER__',
+ folder: '__FOLDER__', albums: ['cam'], includeExisting: true, since: 0,
+};
+window.meshbay = {
+ hubBase: () => 'https://hub.invalid',
+ photoSync: {
+ status: async () => ({
+ permission: MODE === 'partial' ? 'partial' : 'granted', unmetered: true, sent: 1240,
+ config: MODE === 'configured' ? cfg : null, lastCompleted: Date.now(),
+ failure: MODE === 'configured' ? 'disk_full' : null,
+ }),
+ albums: async () => [
+ { id: 'cam', name: 'Camera', count: 1240, bytes: 4.1e9, camera: true },
+ { id: 'x', name: 'Screenshots_and_saved_images_from_a_messaging_application_long',
+ count: 12, bytes: 3e6, camera: false },
+ ],
+ permit: async () => ({}), configure: async () => ({}),
+ estimate: async () => ({ count: 1, bytes: 1 }), plan: async () => ({ items: [] }),
+ sent: async () => true, completed: async () => ({}), failed: async () => true,
+ keepAlive: async () => true,
+ },
+};
+</script>
+<main class="main"><div id=root></div></main>
+<script type=module>
+import { html, render } from './vendor/htm-preact.js';
+import { initLocale } from './i18n.js';
+import { startPhotoSync } from './photo-sync.js';
+import { PhotoSyncSection } from './photo-sync-settings.js';
+await initLocale();
+startPhotoSync({ hub: '', getUser: () => ({ username: 'bob', token: 't' }),
+ getBundleKey: async () => '' });
+const groups = [{ id: 'g1', name: '__GROUP__', owner_username: '__OWNER__' }];
+render(html`<${PhotoSyncSection} user=${{ username: 'bob', token: 't' }} groups=${groups} />`,
+ document.getElementById('root'));
+setTimeout(() => {
+ if (MODE === 'configured') return;
+ [...document.querySelectorAll('button')].find((b) => b.textContent.includes('Set up')).click();
+ setTimeout(() => {
+ const sel = document.querySelector('select');
+ sel.value = 'g1';
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
+ }, 300);
+}, 500);
+</script></body></html>
+""".replace("__GROUP__", LONG_GROUP).replace("__OWNER__", LONG_OWNER) \
+ .replace("__FOLDER__", LONG_FOLDER)
+
+OUTER = """<!doctype html><html><body style="margin:0">
+<script>
+for (const m of ['setup', 'configured', 'partial']) for (const w of [360, 420]) {
+ const f = document.createElement('iframe');
+ f.id = m + w;
+ f.style.cssText = `width:${w}px;height:900px;border:0;display:block`;
+ f.src = 'probe.html#' + m;
+ document.body.appendChild(f);
+}
+setTimeout(() => {
+ const out = {};
+ for (const f of document.querySelectorAll('iframe')) {
+ const d = f.contentDocument;
+ const vw = f.contentWindow.innerWidth;
+ const wide = [...d.querySelectorAll('#root *')]
+ .filter((e) => e.getBoundingClientRect().right > vw + 0.5)
+ .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, 600),
+ err: d.documentElement.dataset.err || '',
+ checked: [...d.querySelectorAll('#root input:checked')]
+ .map((i) => i.closest('label').innerText.split('\\n')[0]),
+ };
+ }
+ const pre = document.createElement('pre');
+ pre.id = 'out';
+ pre.textContent = JSON.stringify(out);
+ document.body.appendChild(pre);
+}, 4000);
+</script></body></html>
+"""
+
+
+class _Quiet(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *args) -> None:
+ pass
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp) / "static"
+ shutil.copytree(STATIC, root)
+ (root / "connection-pool.js").write_text(FAKE_POOL, encoding="utf-8")
+ (root / "probe.html").write_text(PROBE, encoding="utf-8")
+ (root / "outer.html").write_text(OUTER, encoding="utf-8")
+ handler = functools.partial(_Quiet, directory=str(root))
+ with socketserver.TCPServer(("127.0.0.1", 0), handler) as httpd:
+ port = httpd.server_address[1]
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
+ try:
+ dom = subprocess.run(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-first-run",
+ f"--user-data-dir={tmp}/profile", "--virtual-time-budget=8000",
+ "--dump-dom", f"http://127.0.0.1:{port}/outer.html"],
+ capture_output=True, text=True, timeout=120).stdout
+ finally:
+ httpd.shutdown()
+ start = dom.find('<pre id="out">')
+ if start < 0:
+ print(json.dumps({"error": "no result in the page"}))
+ return 1
+ body = dom[start + len('<pre id="out">'):dom.index("</pre>", start)]
+ for entity, char in (("&quot;", '"'), ("&lt;", "<"), ("&gt;", ">"), ("&amp;", "&")):
+ body = body.replace(entity, char)
+ print(json.dumps(json.loads(body)))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/packages/meshbay-hub/tests/test_android_cast.py b/packages/meshbay-hub/tests/test_android_cast.py
index ebcdec4..82b36d6 100644
--- a/packages/meshbay-hub/tests/test_android_cast.py
+++ b/packages/meshbay-hub/tests/test_android_cast.py
@@ -113,11 +113,12 @@ def test_relay_calls_keep_their_order():
" || frame.channel == BinaryFrame.CAST_FILE") in _read(SRC / "bridge" / "Channels.kt")
-def test_screen_off_survival_is_switched_on_only_while_casting_or_playing():
+def test_screen_off_survival_is_switched_on_only_while_casting_playing_or_backing_up():
activity = _read(SRC / "MainActivity.kt")
keep = activity.split("private fun keepAlive()", 1)[1].split("\n }\n", 1)[0]
assert "val on = casting || playing" in keep
- assert "web.keepVisible = on" in keep
+ # A photo backup holds the page too, through its own service (§9.12).
+ assert "web.keepVisible = on || syncing" in keep
assert "if (!on) { stopService(service); return }" in keep
assert "startForegroundService(service)" in keep
assert activity.count("keepVisible =") == 1, "the page is kept visible from one place only"
@@ -126,6 +127,8 @@ def test_screen_off_survival_is_switched_on_only_while_casting_or_playing():
manifest = _read(MAIN / "AndroidManifest.xml")
assert 'android:name=".cast.CastService"' in manifest
assert 'android:foregroundServiceType="mediaPlayback"' in manifest
+ assert 'android:name=".photos.BackupService"' in manifest
+ assert 'android:foregroundServiceType="dataSync"' in manifest
def test_the_receiver_is_given_what_the_desktop_gives_it():
diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py
index 129732c..5262aae 100644
--- a/packages/meshbay-hub/tests/test_android_shell.py
+++ b/packages/meshbay-hub/tests/test_android_shell.py
@@ -135,14 +135,17 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each():
preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js))
native = set()
for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt",
- SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt"):
+ SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt",
+ SRC / "photos" / "PhotoChannels.kt"):
native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M))
shim = _shim_channels()
assert shim, "no channel found in the shim"
- # A phone has a push distributor to talk to and a desktop does not; that
- # family is the one the desktop lacks rather than the one it shares.
- phone_only = {c for c in shim if c.startswith("push:")}
- assert phone_only, "the push channels are gone from the shim"
+ # A phone has a push distributor to talk to and a camera roll to back up,
+ # and a desktop has neither; 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:"))}
+ 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}"
assert shim == native, f"shim and native disagree: {shim ^ native}"
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 74cd880..25638ca 100644
--- a/packages/meshbay-hub/tests/test_first_load_is_lean.py
+++ b/packages/meshbay-hub/tests/test_first_load_is_lean.py
@@ -24,6 +24,8 @@ ON_DEMAND = {
"chat-app-settings.js", "video-app-settings.js", "music-app-settings.js",
"photos-app-settings.js", "helloworld-app-settings.js",
"video-player.js", "group-settings.js", "search-page.js",
+ # The Android application's photo backup: no browser or desktop has it.
+ "photo-sync.js", "photo-sync-settings.js",
}
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index 0172127..73bac38 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -54,6 +54,8 @@ STATIC_FILES = [
"menu.js", "playlist-menu.js",
# The page's own confirm/alert, mounted outside the app tree.
"ask.js",
+ # The Android application's photo backup, loaded by Settings on demand.
+ "settings-page.js", "photo-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
new file mode 100644
index 0000000..f7852f4
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_photo_sync.py
@@ -0,0 +1,307 @@
+"""
+The phone's photo backup, as the page runs it (docs/MESHBAY_DESIGN.md §9.12).
+
+Run under Node against the real `photo-sync.js`, with the two things it talks
+to replaced: the Android bridge (`window.meshbay.photoSync`, which lists the
+photos and keeps the ledger) and the group connection (`connection-pool.js`,
+swapped in the copy for one that hands out a scripted node). What is pinned is
+what the person was promised: once a day, on an unmetered network, newest
+first, nothing ever deleted on the node, and a refusal that will hold tomorrow
+said once rather than retried at every opening.
+"""
+
+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 / "photo-sync.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not SYNC.exists(),
+ reason="node or the SPA sources are not available")
+
+DAY = 24 * 3600 * 1000
+
+FAKE_POOL = r"""
+export class ConnectionPool {
+ constructor(hub) { globalThis.fake.pools.push(hub); }
+ async connect(groupId) {
+ if (globalThis.fake.refuseConnect) {
+ const e = new Error('refused'); e.reason = globalThis.fake.refuseConnect; throw e;
+ }
+ globalThis.fake.log.push(['connect', groupId]);
+ return { transport: globalThis.fake.transport, ack: globalThis.fake.ack };
+ }
+ closeAll() { globalThis.fake.log.push(['close']); }
+}
+"""
+
+PRELUDE = r"""
+const fake = globalThis.fake = { log: [], pools: [], refuseConnect: null, uploadError: null,
+ afterUpload: null };
+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) => {
+ fake.log.push(['fetch', url]);
+ return { ok: true, blob: async () => new Blob([new Uint8Array(4)]) };
+};
+
+const NOW = Date.now();
+fake.status = {
+ permission: 'granted', unmetered: true, sent: 0, now: NOW,
+ config: { account: 'bob', groupId: 'g1', groupName: 'Family', owner: 'alice',
+ folder: 'Media/Photos/Bob', albums: ['cam'], includeExisting: true, since: 0 },
+ lastCompleted: null, failure: null, failureAt: null,
+};
+fake.items = [
+ { token: 't1', name: 'IMG_3.jpg', dir: 'Media/Photos/Bob/2026/10', size: 30, edited: false,
+ alsoKnownAs: 'IMG_3-edited-x.jpg' },
+ { token: 't2', name: 'IMG_2.jpg', dir: 'Media/Photos/Bob/2026/09', size: 20, edited: false,
+ alsoKnownAs: 'IMG_2-edited-x.jpg' },
+ { token: 't3', name: 'IMG_1.jpg', dir: 'Media/Photos/Bob/2026/09', size: 10, edited: false,
+ alsoKnownAs: 'IMG_1-edited-x.jpg' },
+];
+fake.index = {
+ roots: [{ name: 'Media', writable: true, available: true }],
+ dirs: ['Media/Photos', 'Media/Photos/Bob', 'Media/Photos/Bob/2026', 'Media/Photos/Bob/2026/09'],
+ entries: [{ path: 'Media/Photos/Bob/2026/09', name: 'IMG_1.jpg', size: 10 }],
+};
+fake.ack = { photo_directories: ['Media/Photos'] };
+fake.transport = {
+ connected: true,
+ async fetchIndex() { return fake.index; },
+ async createDirectory(at, name) { fake.log.push(['mkdir', `${at}/${name}`]); return {}; },
+ openTransfer({ kind, bytes }) {
+ fake.log.push(['lease', kind, 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, opts.tr]);
+ 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 };
+ },
+};
+win.meshbay = { photoSync: {
+ status: async () => fake.status,
+ plan: async () => { fake.log.push(['plan']); return { items: fake.items }; },
+ 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) => { fake.log.push(['failed', code]); 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 / 'photo-sync.js').as_uri()}');\n"
+ + "const ctx = { hub: 'https://hub.invalid', getUser: () => ({ username: 'bob', "
+ "userId: 'u-bob', token: 'tok' }), getBundleKey: async () => 'bk' };\n"
+ + body
+ # The next run's timer would keep Node alive for a day.
+ + "\nm.stopPhotoSync();\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.startPhotoSync(ctx);\nawait m.runPhotoSync();\n"
+
+
+def _of(log, kind):
+ return [e[1:] for e in log if e[0] == kind]
+
+
+# ── a run ───────────────────────────────────────────────────────────────────
+
+def test_a_due_run_sends_newest_first_and_records_each(tmp_path):
+ log = _run(_start(), tmp_path)
+ # IMG_1 is already in the folder (a reinstall's empty ledger): recorded,
+ # not sent again.
+ assert _of(log, "upload") == [
+ ["Media/Photos/Bob/2026/10", "IMG_3.jpg", "tr-30"],
+ ["Media/Photos/Bob/2026/09", "IMG_2.jpg", "tr-20"],
+ ]
+ assert _of(log, "sent") == [
+ ["t1", "Media/Photos/Bob/2026/10", "IMG_3.jpg"],
+ ["t2", "Media/Photos/Bob/2026/09", "IMG_2.jpg"],
+ ["t3", "Media/Photos/Bob/2026/09", "IMG_1.jpg"],
+ ]
+ assert ["completed"] in log
+ assert _of(log, "keep")[0] == [True] and _of(log, "keep")[-1] == [False], (
+ "the screen-off keep-alive was not released after the run")
+
+
+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/10"]]
+
+
+def test_every_upload_holds_a_slot_and_gives_it_back(tmp_path):
+ """The backup queues like any member: one slot, returned every time."""
+ log = _run(_start(), tmp_path)
+ assert _of(log, "lease") == [["upload", 30], ["upload", 20]]
+ assert _of(log, "release") == [[30], [20]]
+
+
+def test_an_edit_under_its_own_name_counts_as_already_there(tmp_path):
+ log = _run(_start("""
+fake.index.entries.push({ path: 'Media/Photos/Bob/2026/10', name: 'IMG_3-edited-x.jpg', size: 30 });
+"""), tmp_path)
+ assert ["t1", "Media/Photos/Bob/2026/10", "IMG_3-edited-x.jpg"] in _of(log, "sent")
+ assert all(u[1] != "IMG_3.jpg" for u in _of(log, "upload"))
+
+
+# ── when ────────────────────────────────────────────────────────────────────
+
+def test_nothing_happens_before_a_day_has_passed(tmp_path):
+ log = _run(_start("fake.status.lastCompleted = Date.now() - 3600 * 1000;"), tmp_path)
+ assert ["plan"] not in log and _of(log, "connect") == []
+
+
+def test_a_day_later_it_runs(tmp_path):
+ log = _run(_start(f"fake.status.lastCompleted = Date.now() - {DAY} - 1000;"), tmp_path)
+ assert ["completed"] in log
+
+
+def test_back_up_now_runs_whenever(tmp_path):
+ log = _run("""
+fake.status.lastCompleted = Date.now() - 60000;
+m.startPhotoSync(ctx);
+await m.runPhotoSync();
+fake.log.length = 0;
+await m.runPhotoSync({ force: true });
+""", tmp_path)
+ assert ["completed"] in log
+
+
+def test_on_a_metered_network_a_scheduled_run_waits(tmp_path):
+ log = _run(_start("fake.status.unmetered = false;"), tmp_path)
+ assert ["plan"] not in log
+
+
+def test_a_run_the_person_allowed_on_mobile_data_goes(tmp_path):
+ log = _run("""
+fake.status.unmetered = false;
+m.startPhotoSync(ctx);
+await m.runPhotoSync();
+fake.log.length = 0;
+await m.runPhotoSync({ force: true, metered: true });
+""", tmp_path)
+ assert ["completed"] in log
+
+
+def test_leaving_wifi_stops_after_the_file_in_flight(tmp_path):
+ log = _run(_start("""
+fake.afterUpload = () => {
+ fake.afterUpload = null;
+ window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: false } }));
+};
+"""), tmp_path)
+ assert len(_of(log, "upload")) == 1, "it went on sending over mobile data"
+ assert ["completed"] not in log, "an unfinished run was recorded as the day's run"
+
+
+def test_wifi_coming_back_starts_a_waiting_run(tmp_path):
+ log = _run("""
+fake.status.unmetered = false;
+m.startPhotoSync(ctx);
+await m.runPhotoSync();
+fake.status.unmetered = true;
+window.dispatchEvent(new CustomEvent('meshbay-network', { detail: { unmetered: true } }));
+await m.runPhotoSync();
+""", tmp_path)
+ assert ["completed"] in log
+
+
+# ── refusals ────────────────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("code", ["disk_full", "root_read_only", "no_such_directory",
+ "root_unavailable"])
+def test_a_lasting_refusal_stops_and_is_said_once(tmp_path, code):
+ log = _run(_start(f"fake.uploadError = '{code}';"), tmp_path)
+ assert _of(log, "failed") == [[code]]
+ assert len(_of(log, "upload")) == 1, "it carried on into the same refusal"
+ assert ["completed"] not in log
+
+
+def test_after_a_lasting_refusal_it_waits_a_day(tmp_path):
+ log = _run(_start("""
+fake.status.failure = 'disk_full';
+fake.status.failureAt = Date.now() - 3600 * 1000;
+"""), tmp_path)
+ assert ["plan"] not in log
+
+
+def test_a_member_removed_from_the_group_is_told_once(tmp_path):
+ log = _run(_start("fake.refuseConnect = 'not_a_member';"), tmp_path)
+ assert _of(log, "failed") == [["not_a_member"]]
+
+
+def test_an_offline_node_is_not_a_lasting_refusal(tmp_path):
+ log = _run(_start("fake.refuseConnect = 'timeout';"), tmp_path)
+ assert _of(log, "failed") == [] and ["completed"] not in log
+
+
+def test_a_folder_no_longer_writable_is_found_before_sending(tmp_path):
+ log = _run(_start("fake.index.roots[0].writable = false;"), tmp_path)
+ assert _of(log, "failed") == [["root_read_only"]]
+ assert _of(log, "upload") == []
+
+
+def test_another_account_on_the_phone_sends_nothing(tmp_path):
+ log = _run(_start("fake.status.config.account = 'carol';"), tmp_path)
+ assert ["plan"] not in log
+
+
+# ── choosing a folder ───────────────────────────────────────────────────────
+
+def test_only_writable_folders_the_photos_tab_shows_are_offered(tmp_path):
+ log = _run("""
+fake.log.push(['folders', m.backupFolders(
+ { photo_directories: ['Media/Photos', 'Archive/Pictures'] },
+ { roots: [{ name: 'Media', writable: true, available: true },
+ { name: 'Archive', writable: false, available: true },
+ { name: 'Ejected', writable: true, available: false }],
+ dirs: ['Media/Films', 'Media/Photos', 'Media/Photos/Bob', 'Archive/Pictures',
+ 'Ejected/Photos'] })]);
+""", tmp_path)
+ assert _of(log, "folders") == [[["Media/Photos", "Media/Photos/Bob"]]]
+
+
+# ── nothing is ever removed ─────────────────────────────────────────────────
+
+def test_the_backup_has_no_way_to_remove_anything_on_the_node():
+ """
+ Additive by construction: a photo deleted on the phone stays in the group,
+ and nothing the sync does can take one away. Read from the source, because
+ the property is the absence of a call.
+ """
+ src = SYNC.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"photo-sync.js reaches {op}"
diff --git a/packages/meshbay-hub/tests/test_photo_sync_ui.py b/packages/meshbay-hub/tests/test_photo_sync_ui.py
new file mode 100644
index 0000000..bd512c1
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_photo_sync_ui.py
@@ -0,0 +1,55 @@
+"""
+The photo backup section in Settings, measured in Chrome at phone widths
+(harness/photo_sync_probe.py). Android's WebView is the only engine that ever
+shows it.
+
+A group, a folder or an album is named by somebody else and often has no space
+in it; one such name made a 360px page 634px wide before `.settings-name`, and
+a page wider than the screen takes every pinned header with it (CLAUDE.md).
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+PROBE = Path(__file__).parent / "harness" / "photo_sync_probe.py"
+
+
+@pytest.fixture(scope="module")
+def probe():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("chrome is not available")
+ proc = subprocess.run([sys.executable, str(PROBE)], capture_output=True, text=True,
+ timeout=240)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ return json.loads(proc.stdout)
+
+
+def test_it_renders_without_an_error(probe):
+ for state, r in probe.items():
+ assert r["err"] == "", f"{state}: {r['err']}"
+ assert r["text"].strip(), f"{state}: nothing was drawn"
+
+
+def test_no_name_widens_the_page_on_a_phone(probe):
+ for state, r in probe.items():
+ assert r["scrollW"] <= r["vw"], (
+ f"{state}: {r['scrollW']}px of page in a {r['vw']}px window — {r['wide']}")
+
+
+def test_the_camera_and_the_photos_already_there_are_the_defaults(probe):
+ for state in ("setup360", "partial360"):
+ assert probe[state]["checked"] == [
+ "Camera", "The photos already on this phone, then new ones"], probe[state]["checked"]
+
+
+def test_a_refusal_is_said_where_the_backup_is(probe):
+ assert "the node's disk is full" in probe["configured360"]["text"]
+
+
+def test_limited_access_is_said_rather_than_failing_quietly(probe):
+ assert "Access is limited to the photos you selected" in probe["partial360"]["text"]
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index c1ca36b..bb39966 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -46,6 +46,8 @@ SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js",
STATIC / "helloworld-app-settings.js",
STATIC / "menu.js", STATIC / "playlist-menu.js",
STATIC / "auth-page.js", STATIC / "explore-page.js",
+ STATIC / "settings-page.js", STATIC / "photo-sync-settings.js",
+ STATIC / "photo-sync.js",
CREATE_GROUP]
pytestmark = pytest.mark.skipif(