Download tests/plugins/platforms/photon/test_spectrum_patch.py from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 10.3 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/plugins/platforms/photon/test_spectrum_patch.py
- Command line
-
hf download hf://SaylorTwift/hermes-agent/tests/plugins/platforms/photon/test_spectrum_patch.py
-
curl -L -o test_spectrum_patch.py https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/plugins/platforms/photon/test_spectrum_patch.py
10.3 kB
| """Regression tests for Hermes' Spectrum mixed text+attachment workaround.""" | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import shutil | |
| import socket | |
| import subprocess | |
| import textwrap | |
| import time | |
| import urllib.request | |
| from pathlib import Path | |
| _PATCHER = Path("plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs") | |
| def _sidecar_env(port: int) -> dict[str, str]: | |
| return { | |
| **os.environ, | |
| "PHOTON_PROJECT_ID": "test-project", | |
| "PHOTON_PROJECT_SECRET": "test-secret", | |
| "PHOTON_SIDECAR_PORT": str(port), | |
| "PHOTON_SIDECAR_TOKEN": "test-token", | |
| } | |
| def _free_port() -> int: | |
| with socket.socket() as sock: | |
| sock.bind(("127.0.0.1", 0)) | |
| return sock.getsockname()[1] | |
| def _write_sidecar_fixture(tmp_path: Path, *, sdk_available: bool) -> Path: | |
| sidecar = tmp_path / "sidecar" | |
| sidecar.mkdir() | |
| shutil.copyfile("plugins/platforms/photon/sidecar/index.mjs", sidecar / "index.mjs") | |
| # index.mjs imports sibling helper modules — copy every non-patch .mjs so | |
| # the fixture keeps working as helpers are extracted from index.mjs. | |
| for helper in Path("plugins/platforms/photon/sidecar").glob("*.mjs"): | |
| if helper.name in ("index.mjs", "patch-spectrum-mixed-attachments.mjs"): | |
| continue | |
| shutil.copyfile(helper, sidecar / helper.name) | |
| (sidecar / "patch-spectrum-mixed-attachments.mjs").write_text( | |
| "export function patchSpectrumTs() { throw new Error('forced patch failure'); }\n", | |
| encoding="utf-8", | |
| ) | |
| if not sdk_available: | |
| return sidecar | |
| package = sidecar / "node_modules" / "spectrum-ts" | |
| (package / "providers").mkdir(parents=True) | |
| (package / "package.json").write_text( | |
| json.dumps( | |
| { | |
| "name": "spectrum-ts", | |
| "type": "module", | |
| "exports": { | |
| ".": "./index.js", | |
| "./providers/imessage": "./providers/imessage.js", | |
| }, | |
| } | |
| ), | |
| encoding="utf-8", | |
| ) | |
| (package / "index.js").write_text( | |
| textwrap.dedent( | |
| """ | |
| export async function Spectrum() { | |
| return { | |
| messages: { [Symbol.asyncIterator]() { return { next: () => new Promise(() => {}) }; } }, | |
| stop: async () => undefined, | |
| }; | |
| } | |
| export const attachment = value => value; | |
| export const voice = value => value; | |
| export const text = value => value; | |
| export const markdown = value => value; | |
| export const typing = value => value; | |
| """ | |
| ).lstrip(), | |
| encoding="utf-8", | |
| ) | |
| (package / "providers" / "imessage.js").write_text( | |
| "export function imessage() { return {}; }\nimessage.config = () => ({});\n", | |
| encoding="utf-8", | |
| ) | |
| return sidecar | |
| def test_sidecar_patch_failure_still_reaches_health_endpoint(tmp_path: Path) -> None: | |
| """The compatibility patch is optional when the SDK itself remains usable.""" | |
| sidecar = _write_sidecar_fixture(tmp_path, sdk_available=True) | |
| port = _free_port() | |
| proc = subprocess.Popen( | |
| ["node", "index.mjs"], | |
| cwd=sidecar, | |
| env=_sidecar_env(port), | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| ) | |
| request = urllib.request.Request( | |
| f"http://127.0.0.1:{port}/healthz", | |
| data=b"{}", | |
| headers={"X-Hermes-Sidecar-Token": "test-token"}, | |
| method="POST", | |
| ) | |
| try: | |
| deadline = time.monotonic() + 5 | |
| while True: | |
| try: | |
| with urllib.request.urlopen(request, timeout=0.5) as response: | |
| payload = json.load(response) | |
| break | |
| except OSError: | |
| if proc.poll() is not None or time.monotonic() >= deadline: | |
| raise | |
| time.sleep(0.05) | |
| assert payload["ok"] is True | |
| assert proc.poll() is None | |
| finally: | |
| proc.terminate() | |
| _, stderr = proc.communicate(timeout=5) | |
| assert "forced patch failure" in stderr | |
| def _tabify(src: str) -> str: | |
| """Convert the fixture's two-space indentation to the tab indentation that | |
| spectrum-ts ships in `@spectrum-ts/imessage/dist`, so the patch anchors | |
| (which match tabs) apply exactly as they do against a real install.""" | |
| out = [] | |
| for line in src.split("\n"): | |
| stripped = line.lstrip(" ") | |
| indent = len(line) - len(stripped) | |
| out.append("\t" * (indent // 2) + " " * (indent % 2) + stripped) | |
| return "\n".join(out) | |
| # A faithful, *executable* slice of spectrum-ts 8.x's iMessage inbound mapper: | |
| # the two functions the patch rewrites (`rebuildFromAppleMessage` for | |
| # `space.getMessage`, `toInboundMessages` for the live stream), plus stubs of | |
| # the helpers they close over. Mirrors the published shape — tab-indented (via | |
| # `_tabify`), `const ... = async` declarations, single-line builder calls — so | |
| # the anchors exercise the real code path, and exporting the two functions lets | |
| # the test assert runtime behavior rather than only string shape. | |
| _SPECTRUM_IMESSAGE_FIXTURE = """ | |
| const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`; | |
| const asText = (text) => ({ type: "text", text }); | |
| const asCustom = (message) => ({ type: "custom" }); | |
| const asProviderGroup = (items) => ({ type: "group", items }); | |
| const messageAttachments = (message) => message.content.attachments ?? []; | |
| const buildMessageBase = (message, chatGuidHint, timestamp, phone) => ({ direction: "inbound", sender: { id: "s" }, space: { id: "sp", type: "dm", phone }, timestamp }); | |
| const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => { | |
| const msg = { ...base, id, content: { type: "attachment", id: info.guid }, partIndex }; | |
| if (parentId !== void 0) msg.parentId = parentId; | |
| return msg; | |
| }; | |
| const cacheMessage = (cache, message) => { cache.set(message.id, message); }; | |
| const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => { | |
| const messageGuidStr = message.guid; | |
| const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone); | |
| const attachments = messageAttachments(message); | |
| if (attachments.length === 1) { | |
| const info = attachments[0]; | |
| if (!info) throw new Error("Unreachable: attachments.length === 1 but no element"); | |
| return buildAttachmentMessage(client, base, info, messageGuidStr, 0); | |
| } | |
| if (attachments.length > 1) { | |
| const items = []; | |
| for (let i = 0; i < attachments.length; i++) { | |
| const info = attachments[i]; | |
| if (!info) continue; | |
| items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr)); | |
| } | |
| return { | |
| ...base, | |
| id: messageGuidStr, | |
| content: asProviderGroup(items) | |
| }; | |
| } | |
| const text = message.content.text; | |
| return { | |
| ...base, | |
| id: messageGuidStr, | |
| content: text ? asText(text) : asCustom(message) | |
| }; | |
| }; | |
| const toInboundMessages = async (client, cache, event, phone) => { | |
| const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone); | |
| const messageGuidStr = event.message.guid; | |
| const attachments = messageAttachments(event.message); | |
| if (attachments.length === 1) { | |
| const info = attachments[0]; | |
| if (!info) throw new Error("Unreachable: attachments.length === 1 but no element"); | |
| const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0); | |
| cacheMessage(cache, msg); | |
| return [msg]; | |
| } | |
| if (attachments.length > 1) { | |
| const items = []; | |
| for (let i = 0; i < attachments.length; i++) { | |
| const info = attachments[i]; | |
| if (!info) continue; | |
| items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr)); | |
| } | |
| const parent = { | |
| ...base, | |
| id: messageGuidStr, | |
| content: asProviderGroup(items) | |
| }; | |
| cacheMessage(cache, parent); | |
| return [parent]; | |
| } | |
| const text = event.message.content.text; | |
| const msg = { | |
| ...base, | |
| id: messageGuidStr, | |
| content: text ? asText(text) : asCustom(event.message) | |
| }; | |
| cacheMessage(cache, msg); | |
| return [msg]; | |
| }; | |
| export { rebuildFromAppleMessage, toInboundMessages }; | |
| """ | |
| def _write_fixture(tmp_path: Path) -> Path: | |
| dist = tmp_path / "node_modules" / "@spectrum-ts" / "imessage" / "dist" | |
| dist.mkdir(parents=True) | |
| chunk = dist / "index.js" | |
| chunk.write_text(_tabify(_SPECTRUM_IMESSAGE_FIXTURE), encoding="utf-8") | |
| return chunk | |
| def test_spectrum_patch_rewrites_the_imessage_mapper(tmp_path: Path) -> None: | |
| """The dependency patch must apply to the 8.x `@spectrum-ts/imessage` chunk | |
| and rewrite both inbound mappers to thread text through attachment bubbles.""" | |
| chunk = _write_fixture(tmp_path) | |
| result = subprocess.run( | |
| ["node", str(_PATCHER), str(tmp_path)], | |
| cwd=Path.cwd(), | |
| text=True, | |
| capture_output=True, | |
| check=False, | |
| ) | |
| assert result.returncode == 0, result.stderr | |
| patched = chunk.read_text(encoding="utf-8") | |
| assert "Preserve mixed text + attachment iMessage payloads" in patched | |
| # Single-attachment bubbles wrap the text + attachment in a group... | |
| assert "content: asProviderGroup([textMsg, msg2])" in patched # rebuild | |
| assert "content: asProviderGroup([textMsg, msg])" in patched # inbound | |
| # ...multi-attachment bubbles keep the group and shift attachment indices. | |
| assert "content: asProviderGroup(items)" in patched | |
| assert "formatChildId(text2 ? i + 1 : i, messageGuidStr)" in patched | |
| # The text is captured in both mappers before the attachment branches run. | |
| assert "const text2 = message.content.text;" in patched | |
| assert "const text2 = event.message.content.text;" in patched | |
| # Re-running is a no-op (idempotent self-heal on every sidecar start). | |
| again = subprocess.run( | |
| ["node", str(_PATCHER), str(tmp_path)], | |
| cwd=Path.cwd(), | |
| text=True, | |
| capture_output=True, | |
| check=False, | |
| ) | |
| assert again.returncode == 0, again.stderr | |
| assert chunk.read_text(encoding="utf-8") == patched | |