| 1 | import re |
| 2 | import sys |
| 3 | from pathlib import Path |
| 4 | |
| 5 | |
| 6 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 7 | if str(PROJECT_ROOT) not in sys.path: |
| 8 | sys.path.insert(0, str(PROJECT_ROOT)) |
| 9 | |
| 10 | |
| 11 | def _get_named_exports(source: str) -> set[str]: |
| 12 | exports: set[str] = set() |
| 13 | |
| 14 | exports.update(re.findall(r"^export\s+function\s+([A-Za-z0-9_]+)\s*\(", source, flags=re.M)) |
| 15 | exports.update(re.findall(r"^export\s+const\s+([A-Za-z0-9_]+)\s*=", source, flags=re.M)) |
| 16 | exports.update(re.findall(r"^export\s+class\s+([A-Za-z0-9_]+)\s*[\{:]", source, flags=re.M)) |
| 17 | |
| 18 | for m in re.findall(r"^export\s*\{([^}]+)\}\s*;?", source, flags=re.M): |
| 19 | for item in m.split(","): |
| 20 | item = item.strip() |
| 21 | if not item: |
| 22 | continue |
| 23 | # Handle: `foo as bar` |
| 24 | parts = item.split() |
| 25 | if len(parts) >= 3 and parts[-2] == "as": |
| 26 | exports.add(parts[-1]) |
| 27 | else: |
| 28 | exports.add(parts[0]) |
| 29 | |
| 30 | return exports |
| 31 | |
| 32 | |
| 33 | def test_websocket_js_exports_minimal_namespaced_api_surface() -> None: |
| 34 | source = (PROJECT_ROOT / "webui" / "js" / "websocket.js").read_text(encoding="utf-8") |
| 35 | exports = _get_named_exports(source) |
| 36 | |
| 37 | assert "createNamespacedClient" in exports |
| 38 | assert "getNamespacedClient" in exports |
| 39 | |
| 40 | assert "broadcast" not in exports |
| 41 | assert "requestAll" not in exports |
| 42 | |
| 43 | |
| 44 | def test_completed_state_push_cannot_overwrite_disconnected_mode() -> None: |
| 45 | source = ( |
| 46 | PROJECT_ROOT / "webui" / "components" / "sync" / "sync-store.js" |
| 47 | ).read_text(encoding="utf-8") |
| 48 | |
| 49 | apply_end = source.split("await applySnapshot(data.snapshot", 1)[1].split( |
| 50 | 'this._setMode(SYNC_MODES.HEALTHY, "push applied");', 1 |
| 51 | )[0] |
| 52 | assert "if (!stateSocket.isConnected()) return;" in apply_end |
| 53 | |
| 54 | |
| 55 | def test_state_push_handlers_are_serialized() -> None: |
| 56 | source = ( |
| 57 | PROJECT_ROOT / "webui" / "components" / "sync" / "sync-store.js" |
| 58 | ).read_text(encoding="utf-8") |
| 59 | |
| 60 | subscription = source.split('stateSocket.on("state_push"', 1)[1].split( |
| 61 | 'debug("[syncStore] subscribed to state_push")', 1 |
| 62 | )[0] |
| 63 | assert "this._pushQueue = this._pushQueue" in subscription |
| 64 | assert ".then(() => this._handlePush(envelope))" in subscription |
| 65 | |
| 66 | |
| 67 | def test_partial_snapshot_retains_sidebar_collections_and_extension_shape() -> None: |
| 68 | source = (PROJECT_ROOT / "webui" / "index.js").read_text(encoding="utf-8") |
| 69 | request_builder = source.split( |
| 70 | "export function buildStateRequestPayload", 1 |
| 71 | )[1].split("export async function applySnapshot", 1)[0] |
| 72 | |
| 73 | assert "collections_delta: true" in request_builder |
| 74 | assert "const hasCollections =" in source |
| 75 | assert "Array.isArray(snapshot.contexts) && Array.isArray(snapshot.tasks)" in source |
| 76 | assert "snapshot: extensionSnapshot" in source |
| 77 | assert "contexts: chatsStore.contexts" in source |
| 78 | assert "tasks: tasksStore.tasks" in source |
| 79 | assert "if (hasCollections)" in source |
| 80 | assert "snapshot.contexts || []" not in source |