diff --git a/app/test/playwright/specs/tinyplace-messaging.spec.ts b/app/test/playwright/specs/tinyplace-messaging.spec.ts new file mode 100644 index 000000000..7ff9e3b54 --- /dev/null +++ b/app/test/playwright/specs/tinyplace-messaging.spec.ts @@ -0,0 +1,148 @@ +// UI e2e: tiny.place direct messaging through the real openhuman Messaging +// screen, against a real tiny.place backend. +// +// The app-under-test (Alice) talks to the standalone core the web session +// booted (PW_CORE_RPC_URL). A second real openhuman-core (Bob) is launched here +// as the peer. Both point at the same tiny.place backend +// (TINYPLACE_API_BASE_URL). We establish an accepted contact out-of-band (the +// contact request/accept flow itself is covered exhaustively by the core suite +// at e2e/tinyplace-messaging/messaging.e2e.mjs), then drive the DM UI: +// +// • type a recipient + open the DM thread +// • send an end-to-end encrypted message from the UI → assert the real peer +// core receives and decrypts it +// • have the peer reply → assert the plaintext renders in the UI thread +// +// Requires the web session harness (app/scripts/e2e-web-session.sh) with +// TINYPLACE_API_BASE_URL exported so the core hits a real backend. The +// messaging e2e runner (e2e/tinyplace-messaging/run-ui.sh) wires this up. +import { test, expect } from '@playwright/test'; + +import { bootAuthenticatedPage } from '../helpers/core-rpc'; +// The core-launch helper is shared with the core-level suite (plain ESM). +import { launchAgent, receiveMessage } from '../../../../e2e/tinyplace-messaging/lib/core.mjs'; + +const CORE_RPC_URL = process.env.PW_CORE_RPC_URL || 'http://127.0.0.1:17788/rpc'; +const CORE_RPC_TOKEN = process.env.PW_CORE_RPC_TOKEN || 'openhuman-playwright-token'; +const BACKEND = process.env.TINYPLACE_API_BASE_URL || 'http://localhost:18080'; + +const TEST_MNEMONIC_WORDS = 12; +// A fresh, valid BIP-39 mnemonic for Alice (the app's core identity). Generated +// via the same dependency-free generator the core suite uses. +async function freshMnemonic(): Promise { + const { generateMnemonic } = await import('../../../../e2e/tinyplace-messaging/lib/mnemonic.mjs'); + const m = generateMnemonic(); + if (m.split(' ').length !== TEST_MNEMONIC_WORDS) throw new Error('unexpected mnemonic length'); + return m; +} + +const PLACEHOLDER_ACCOUNTS = [ + { chain: 'evm', address: '0x0000000000000000000000000000000000000001', derivationPath: "m/44'/60'/0'/0/0" }, + { chain: 'btc', address: 'bc1qplaceholderplaceholderplaceholderplac0000', derivationPath: "m/84'/0'/0'/0/0" }, + { chain: 'solana', address: '11111111111111111111111111111111', derivationPath: "m/44'/501'/0'/0'" }, + { chain: 'tron', address: 'T0000000000000000000000000000000001', derivationPath: "m/44'/195'/0'/0/0" }, +]; + +/** Call Alice's (the app's) core over JSON-RPC and unwrap the {logs,result}. */ +async function aliceRpc(method: string, params: Record = {}): Promise { + const res = await fetch(CORE_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${CORE_RPC_TOKEN}` }, + body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }), + }); + const body = await res.json(); + if (body.error) throw new Error(`${method} -> ${JSON.stringify(body.error)}`); + let result = body.result; + if (result && typeof result === 'object' && 'result' in result && 'logs' in result) { + result = result.result; + } + return result as T; +} + +let bob: Awaited>; +let aliceCryptoId: string; + +test.describe('tiny.place direct messaging (UI)', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + // 1) Give the app's core a fresh tiny.place identity + published Signal keys. + const mnemonic = await freshMnemonic(); + const encryptedMnemonic = await aliceRpc('openhuman.encrypt_secret', { plaintext: mnemonic }); + await aliceRpc('openhuman.wallet_setup', { + consentGranted: true, + source: 'imported', + mnemonicWordCount: TEST_MNEMONIC_WORDS, + encryptedMnemonic, + accounts: PLACEHOLDER_ACCOUNTS, + force: true, + }); + await aliceRpc('openhuman.tinyplace_signal_provision', { preKeyCount: 10 }); + await aliceRpc('openhuman.tinyplace_signal_register_encryption_key', {}); + const status = await aliceRpc<{ agentId: string }>('openhuman.tinyplace_signal_key_status', {}); + aliceCryptoId = status.agentId; + expect(aliceCryptoId, 'app core produced a cryptoId').toBeTruthy(); + + // 2) Launch the peer core (Bob) and make Alice + Bob accepted contacts so + // the relay will carry their DMs. + bob = await launchAgent('pw-bob', { port: 17851, backend: BACKEND }); + await aliceRpc('openhuman.tinyplace_contacts_request', { agentId: bob.cryptoId }); + await bob.rpc('openhuman.tinyplace_contacts_accept', { agentId: aliceCryptoId }); + }); + + test.afterAll(() => { + bob?.stop(); + }); + + test('sends an encrypted DM from the UI that the peer decrypts, and renders the peer reply', async ({ page }) => { + await bootAuthenticatedPage(page, 'pw-messaging-user', '/agent-world/messaging'); + + // The DM composer: enter the peer's cryptoId and open the thread. + const recipient = page.getByPlaceholder('Recipient @handle or wallet address'); + await expect(recipient).toBeVisible(); + await recipient.fill(bob.cryptoId); + await page.getByRole('button', { name: 'Open DM' }).click(); + + // We're in the encrypted thread with Bob (the header lock badge + empty state). + await expect(page.getByText('Encrypted', { exact: true })).toBeVisible(); + await expect(page.getByTestId('dm-empty-state')).toBeVisible(); + + // Send an end-to-end encrypted message from the UI. + const outgoing = `ui → peer @ ${Date.now()}`; + const compose = page.getByPlaceholder('Type a message...'); + await compose.fill(outgoing); + await page.getByRole('button', { name: 'Send' }).click(); + + // Optimistic echo appears in the thread. + await expect(page.getByText(outgoing)).toBeVisible(); + + // The real peer core receives + decrypts exactly what the UI sent. + const received = await receiveMessage(bob, { fromCryptoId: aliceCryptoId, timeoutMs: 12_000 }); + expect(received, 'peer decrypts the message sent from the UI').toBe(outgoing); + + // Now the peer replies; the plaintext must render in the UI thread. + const reply = `peer → ui @ ${Date.now()}`; + await bob.rpc('openhuman.tinyplace_signal_send_message', { recipient: aliceCryptoId, plaintext: reply }); + + // Wait until the reply envelope is actually in Alice's mailbox — inspected, + // not decrypted (decrypt advances the ratchet and can only run once, so we + // must not consume it before the UI does). + await expect + .poll( + async () => { + const list = await aliceRpc('openhuman.tinyplace_messages_list', { limit: 50 }); + const envelopes = Array.isArray(list) ? list : list?.messages ?? []; + return envelopes.some((e: any) => e.from === bob.cryptoId); + }, + { timeout: 15_000, intervals: [500, 1000] }, + ) + .toBe(true); + + // Re-open the thread once. The DM view has no interval poll; its mount fetch + // is what decrypts + renders the delivered reply. + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByPlaceholder('Recipient @handle or wallet address').fill(bob.cryptoId); + await page.getByRole('button', { name: 'Open DM' }).click(); + await expect(page.getByText(reply)).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/e2e/tinyplace-messaging/README.md b/e2e/tinyplace-messaging/README.md new file mode 100644 index 000000000..d9d169abc --- /dev/null +++ b/e2e/tinyplace-messaging/README.md @@ -0,0 +1,96 @@ +# openhuman ↔ tiny.place messaging e2e + +End-to-end coverage for the tiny.place direct-messaging flow — **DMs, contact +requests, accepting requests, and sending messages** — exercised through +openhuman's real core against a real tiny.place backend. Two layers: + +| Layer | File | What it drives | +| ----- | ---- | -------------- | +| **Core** | [`messaging.e2e.mjs`](messaging.e2e.mjs) | Two real `openhuman-core` processes talking to each other over the `openhuman.tinyplace_*` JSON-RPC surface (the exact API the desktop UI calls via `core_rpc_relay`). | +| **UI** | [`../../app/test/playwright/specs/tinyplace-messaging.spec.ts`](../../app/test/playwright/specs/tinyplace-messaging.spec.ts) | The web build of the app (Messaging screen) driving the same flow through the browser, with a second core as the peer. | + +Both run against the **real Go backend** (identity/contacts/relay/Signal +key services) — not a mock — because messaging is contact-gated and +Signal-encrypted server-side, and only the real backend enforces that. + +## What the core suite proves + +Each `openhuman-core` derives its tiny.place identity (a base58 Solana +`cryptoId`) from its wallet mnemonic, so two cores with two fresh mnemonics are +two distinct agents. The suite walks the full lifecycle: + +1. **Identities** — each core boots a distinct, message-ready identity + (published signed pre-key, one-time pre-keys, and directory encryption key). +2. **Contact gate** — a DM between non-contacts is refused (`not_a_contact`). +3. **Send request** — Alice sends a contact request; Bob sees it `pending`. +4. **Accept request** — Bob accepts; both sides see a mutual `accepted` contact. +5. **Send DM (X3DH)** — Alice's first message is stored by the relay as opaque + ciphertext and decrypts correctly on Bob's side. +6. **Reply (Double Ratchet)** — Bob's reply decrypts on Alice's side. +7. **In-session DM** — a follow-up message still decrypts. + +## Run it + +```bash +# Core layer (two openhuman-core processes over JSON-RPC). Brings up an isolated +# backend (mongo+redis+backend, static payment verifier) if one isn't already +# reachable, builds the core if needed, then runs the node:test suite. +./run.sh + +# UI layer (Playwright against the web build). Same backend handling; boots the +# app's core + web host and a peer core, then drives the Messaging screen. +./run-ui.sh +``` + +Or, if you already have a backend and a built core: + +```bash +TINYPLACE_API_BASE_URL=http://localhost:18080 node --test messaging.e2e.mjs +``` + +## What the UI suite proves + +Driven through the real **Messaging** screen (`/agent-world/messaging`) of the +web build, against the same real backend, with a second core as the peer: + +1. **Send** — typing a recipient + message and hitting Send emits an + end-to-end encrypted DM that the real peer core receives and decrypts. +2. **Receive** — a reply sent by the peer renders as plaintext in the UI thread. + +Contact establishment is done out-of-band here (it's exhaustively covered by the +core suite); the UI layer focuses on the encrypted send/receive round trip a +user actually performs on screen. + +### Requirements + +- Docker (only if you want `run.sh` to auto-start the backend). +- A built `openhuman-core` binary (`cargo build --bin openhuman-core`; `run.sh` + builds it if missing). On Apple Silicon prefix with `GGML_NATIVE=OFF`. + +### Env knobs + +| Var | Default | Meaning | +| --- | ------- | ------- | +| `TINYPLACE_API_BASE_URL` | `http://localhost:18080` | Backend base URL both cores point at. | +| `OPENHUMAN_CORE_BIN` | `target/debug/openhuman-core` | Path to the core binary. | +| `MANAGE_STACK` | `1` | `0` disables auto start/stop of the backend. | +| `BACKEND_PORT` | `18080` | Host port for the managed backend. | +| `VERBOSE` | – | `1` streams each core's stdout/stderr. | + +## Why two cores instead of one core + a mock peer + +The core's tiny.place identity and Signal session state are process-global +singletons — one process is exactly one identity. A real two-party round trip +therefore needs two processes. Using two real cores (rather than a hand-rolled +SDK peer) means **both** ends of every assertion are the actual openhuman code +path under test. + +## Notes + +- Every run generates fresh mnemonics (see [`lib/mnemonic.mjs`](lib/mnemonic.mjs)) + so identities never collide with pre-key state a previous run already + published to the backend (which would `409` on re-provision). No npm install + is needed — the BIP-39 generator is dependency-free. +- The backend must run with a payment verifier that doesn't require real funds + for identity provisioning; the umbrella `e2e/docker-compose.e2e.yml` overlay + (static verifier) is what `run.sh` uses. diff --git a/e2e/tinyplace-messaging/lib/bip39-english.txt b/e2e/tinyplace-messaging/lib/bip39-english.txt new file mode 100644 index 000000000..942040ed5 --- /dev/null +++ b/e2e/tinyplace-messaging/lib/bip39-english.txt @@ -0,0 +1,2048 @@ +abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo diff --git a/e2e/tinyplace-messaging/lib/core.mjs b/e2e/tinyplace-messaging/lib/core.mjs new file mode 100644 index 000000000..155edf326 --- /dev/null +++ b/e2e/tinyplace-messaging/lib/core.mjs @@ -0,0 +1,189 @@ +// Boot a real `openhuman-core` process and drive its tiny.place messaging RPCs. +// +// openhuman is a Rust core exposing JSON-RPC over `POST /rpc`. All tiny.place +// messaging lives in the core's `tinyplace` domain (methods +// `openhuman.tinyplace_*`), backed by the vendored Rust tiny.place SDK. The +// core derives its tiny.place identity (a base58 Solana cryptoId) from the +// wallet mnemonic at m/44'/501'/0'/0'. So: one core process = one identity, and +// two cores with two mnemonics = two agents that can message each other over a +// real tiny.place backend — exactly what these tests exercise. +import { spawn } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { generateMnemonic } from "./mnemonic.mjs"; + +const HERE = resolve(fileURLToPath(import.meta.url), ".."); +const REPO_ROOT = resolve(HERE, "..", "..", ".."); + +export const DEFAULT_CORE_BIN = + process.env.OPENHUMAN_CORE_BIN || join(REPO_ROOT, "target", "debug", "openhuman-core"); +export const DEFAULT_BACKEND = + process.env.TINYPLACE_API_BASE_URL || "http://localhost:18080"; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// The tiny.place identity is re-derived from the mnemonic, so these per-chain +// account addresses are cosmetic metadata; wallet_setup only validates that +// each supported chain appears once with a non-empty address + derivation path. +// The Solana derivation path must be the canonical one the signer uses. +const PLACEHOLDER_ACCOUNTS = [ + { chain: "evm", address: "0x0000000000000000000000000000000000000001", derivationPath: "m/44'/60'/0'/0/0" }, + { chain: "btc", address: "bc1qplaceholderplaceholderplaceholderplac0000", derivationPath: "m/84'/0'/0'/0/0" }, + { chain: "solana", address: "11111111111111111111111111111111", derivationPath: "m/44'/501'/0'/0'" }, + { chain: "tron", address: "T0000000000000000000000000000000001", derivationPath: "m/44'/195'/0'/0/0" }, +]; + +function makeRpc(port, token, label) { + return async function rpc(method, params = {}) { + let res; + try { + res = await fetch(`http://127.0.0.1:${port}/rpc`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ jsonrpc: "2.0", id: Date.now(), method, params }), + }); + } catch (e) { + throw new Error(`[${label}] ${method} transport error: ${e.message}`); + } + const body = await res.json(); + if (body.error) { + throw new Error(`[${label}] ${method} -> ${JSON.stringify(body.error)}`); + } + // The core wraps every handler payload as { logs, result }; unwrap it. + let result = body.result; + if (result && typeof result === "object" && "result" in result && "logs" in result) { + result = result.result; + } + return result; + }; +} + +/** + * Launch an openhuman core, import a fresh wallet identity, publish Signal + * pre-keys, and advertise the encryption key on the directory card — i.e. a + * fully message-ready tiny.place agent. + * + * Returns a handle: { name, cryptoId, rpc, port, workspace, stop() }. + */ +export async function launchAgent(name, opts = {}) { + const { + port, + backend = DEFAULT_BACKEND, + coreBin = DEFAULT_CORE_BIN, + mnemonic = generateMnemonic(), + preKeyCount = 10, + verbose = !!process.env.VERBOSE, + readyTimeoutMs = 45_000, + } = opts; + if (!port) throw new Error("launchAgent requires an explicit port"); + + const token = `oh-${name}-${process.pid}-token`; + const workspace = mkdtempSync(join(tmpdir(), `oh-${name}-`)); + const child = spawn( + coreBin, + ["run", "--host", "127.0.0.1", "--port", String(port), "--jsonrpc-only"], + { + env: { + ...process.env, + OPENHUMAN_WORKSPACE: workspace, + OPENHUMAN_KEYRING_BACKEND: "file", + OPENHUMAN_CORE_TOKEN: token, + TINYPLACE_API_BASE_URL: backend, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const logs = []; + const capture = (stream, tag) => + stream.on("data", (d) => { + const line = d.toString(); + logs.push(line); + if (verbose) process.stderr.write(`[${name}${tag}] ${line}`); + }); + capture(child.stdout, ""); + capture(child.stderr, "!"); + + let stopped = false; + const stop = () => { + if (!stopped) { + stopped = true; + child.kill("SIGKILL"); + } + }; + const fail = (msg) => { + stop(); + return new Error(`${msg}\n--- last core logs (${name}) ---\n${logs.slice(-15).join("")}`); + }; + + try { + // 1) Wait for the HTTP server to accept connections. + let up = false; + const deadline = Date.now() + readyTimeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw fail(`core '${name}' exited early (code ${child.exitCode})`); + try { + const r = await fetch(`http://127.0.0.1:${port}/health`); + if (r.ok) { up = true; break; } + } catch { /* not up yet */ } + await sleep(400); + } + if (!up) throw fail(`core '${name}' /health never came up on :${port}`); + + const rpc = makeRpc(port, token, name); + + // 2) Import a fresh wallet identity (encrypt the mnemonic, then persist it). + const encryptedMnemonic = await rpc("openhuman.encrypt_secret", { plaintext: mnemonic }); + await rpc("openhuman.wallet_setup", { + consentGranted: true, + source: "imported", + mnemonicWordCount: mnemonic.split(" ").length, + encryptedMnemonic, + accounts: PLACEHOLDER_ACCOUNTS, + force: true, + }); + + // 3) Publish Signal pre-keys so peers can start an encrypted session, and + // advertise the encryption key on the directory card so it's routable. + await rpc("openhuman.tinyplace_signal_provision", { preKeyCount }); + await rpc("openhuman.tinyplace_signal_register_encryption_key", {}); + + const status = await rpc("openhuman.tinyplace_signal_key_status", {}); + const cryptoId = status.agentId; + if (!cryptoId) throw fail(`core '${name}' produced an empty cryptoId`); + + return { name, cryptoId, rpc, port, workspace, stop, mnemonic }; + } catch (e) { + stop(); + throw e; + } +} + +/** + * Poll a core's mailbox, decrypting + acknowledging each envelope, until a + * message from `fromCryptoId` (or any, if omitted) is decrypted — or timeout. + * Returns the decrypted plaintext string, or null on timeout. + */ +export async function receiveMessage(core, { fromCryptoId, timeoutMs = 8_000 } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const list = await core.rpc("openhuman.tinyplace_messages_list", { limit: 50 }); + const envelopes = Array.isArray(list) ? list : list?.messages ?? []; + for (const envelope of envelopes) { + let decoded; + try { + decoded = await core.rpc("openhuman.tinyplace_signal_decrypt_message", { envelope }); + } finally { + // Acknowledge (delete) regardless: the ratchet already advanced. + await core.rpc("openhuman.tinyplace_messages_acknowledge", { messageId: envelope.id }); + } + if (decoded && (!fromCryptoId || decoded.from === fromCryptoId)) { + return decoded.plaintext; + } + } + await sleep(400); + } + return null; +} diff --git a/e2e/tinyplace-messaging/lib/mnemonic.mjs b/e2e/tinyplace-messaging/lib/mnemonic.mjs new file mode 100644 index 000000000..b7814d78f --- /dev/null +++ b/e2e/tinyplace-messaging/lib/mnemonic.mjs @@ -0,0 +1,40 @@ +// Minimal, dependency-free BIP-39 mnemonic generator (English, 128-bit → 12 +// words). Self-contained so the messaging e2e needs no npm install in this +// pnpm-enforced repo. Standard algorithm: 16 random entropy bytes, append the +// first ENT/32 = 4 bits of SHA-256(entropy) as checksum, slice into 11-bit +// groups, map each to the canonical English wordlist. +// +// We only need *fresh, valid* mnemonics per run so every test identity is a +// brand-new tiny.place cryptoId (avoids colliding with prekey state a previous +// run already published to the backend, which would 409 on re-provision). +import { createHash, randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const WORDLIST = readFileSync(join(HERE, "bip39-english.txt"), "utf8") + .split("\n") + .map((w) => w.trim()) + .filter(Boolean); + +if (WORDLIST.length !== 2048) { + throw new Error(`bip39 wordlist must have 2048 words, got ${WORDLIST.length}`); +} + +/** Generate a fresh, checksum-valid 12-word English BIP-39 mnemonic. */ +export function generateMnemonic() { + const entropy = randomBytes(16); // 128 bits + const checksum = createHash("sha256").update(entropy).digest(); + + // Build a big-endian bit string of entropy (128) + checksum (4) = 132 bits. + let bits = ""; + for (const byte of entropy) bits += byte.toString(2).padStart(8, "0"); + bits += checksum[0].toString(2).padStart(8, "0").slice(0, 4); + + const words = []; + for (let i = 0; i < bits.length; i += 11) { + words.push(WORDLIST[parseInt(bits.slice(i, i + 11), 2)]); + } + return words.join(" "); +} diff --git a/e2e/tinyplace-messaging/messaging.e2e.mjs b/e2e/tinyplace-messaging/messaging.e2e.mjs new file mode 100644 index 000000000..bca400ae9 --- /dev/null +++ b/e2e/tinyplace-messaging/messaging.e2e.mjs @@ -0,0 +1,178 @@ +// End-to-end: openhuman ↔ tiny.place messaging, driven through the real +// openhuman core JSON-RPC surface against a real tiny.place backend. +// +// Two independent `openhuman-core` processes (Alice, Bob), each with its own +// freshly-imported wallet identity, exercise the complete DM lifecycle exactly +// as the desktop app would drive it over `core_rpc_relay`: +// +// • send a contact request (openhuman.tinyplace_contacts_request) +// • see it arrive on the other side (openhuman.tinyplace_contacts_requests) +// • accept the request (openhuman.tinyplace_contacts_accept) +// • confirm the mutual contact (openhuman.tinyplace_contacts_list) +// • send Signal-encrypted DMs (openhuman.tinyplace_signal_send_message) +// • receive + decrypt + acknowledge (messages_list / signal_decrypt_message / messages_acknowledge) +// +// It also proves the two security invariants that make this a real messaging +// system: DMs are refused between non-contacts, and the relay only ever stores +// opaque ciphertext. +// +// Requires: a reachable tiny.place backend (TINYPLACE_API_BASE_URL, default +// http://localhost:18080) and a built `openhuman-core` binary. `run.sh` wires +// both up; see README.md. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import net from "node:net"; + +import { launchAgent, receiveMessage, DEFAULT_BACKEND, DEFAULT_CORE_BIN } from "./lib/core.mjs"; +import { existsSync } from "node:fs"; + +function freePort() { + return new Promise((res, rej) => { + const srv = net.createServer(); + srv.on("error", rej); + srv.listen(0, "127.0.0.1", () => { + const { port } = srv.address(); + srv.close(() => res(port)); + }); + }); +} + +async function backendReachable() { + try { + const r = await fetch(`${DEFAULT_BACKEND}/healthz`); + return r.ok; + } catch { + return false; + } +} + +let alice; +let bob; + +before(async () => { + assert.ok( + existsSync(DEFAULT_CORE_BIN), + `openhuman-core binary not found at ${DEFAULT_CORE_BIN}. Build it: cargo build --bin openhuman-core (or run run.sh).`, + ); + assert.ok( + await backendReachable(), + `tiny.place backend not reachable at ${DEFAULT_BACKEND}/healthz. Start it first (see run.sh / README.md).`, + ); + const [pa, pb] = await Promise.all([freePort(), freePort()]); + [alice, bob] = await Promise.all([ + launchAgent("alice", { port: pa }), + launchAgent("bob", { port: pb }), + ]); +}); + +after(() => { + alice?.stop(); + bob?.stop(); +}); + +test("each core boots a distinct, message-ready tiny.place identity", async () => { + assert.match(alice.cryptoId, /^[1-9A-HJ-NP-Za-km-z]{32,44}$/, "alice cryptoId is base58"); + assert.match(bob.cryptoId, /^[1-9A-HJ-NP-Za-km-z]{32,44}$/, "bob cryptoId is base58"); + assert.notEqual(alice.cryptoId, bob.cryptoId, "the two cores have different identities"); + + for (const core of [alice, bob]) { + const status = await core.rpc("openhuman.tinyplace_signal_key_status", {}); + assert.equal(status.agentId, core.cryptoId); + assert.equal(status.hasActiveSignedPreKey, true, `${core.name} published a signed pre-key`); + assert.ok(status.localPreKeyCount > 0, `${core.name} holds local one-time pre-keys`); + assert.equal(status.encryptionKeyPublished, true, `${core.name} advertised its encryption key`); + } +}); + +test("a DM is refused between agents who are not contacts", async () => { + // Fresh, unrelated identity → not a contact of Bob → the relay must refuse. + const stranger = await launchAgent("stranger", { port: await freePort() }); + try { + await assert.rejects( + () => + stranger.rpc("openhuman.tinyplace_signal_send_message", { + recipient: bob.cryptoId, + plaintext: "you don't know me", + }), + /not_a_contact|403|contact/i, + "sending a DM without an accepted contact relationship is rejected", + ); + } finally { + stranger.stop(); + } +}); + +test("Alice sends a contact request and Bob sees it pending", async () => { + await alice.rpc("openhuman.tinyplace_contacts_request", { agentId: bob.cryptoId }); + + const incoming = await bob.rpc("openhuman.tinyplace_contacts_requests", {}); + const ids = (incoming.incoming ?? []).map((r) => r.agentId ?? r.cryptoId); + assert.ok(ids.includes(alice.cryptoId), "Alice's request is in Bob's incoming requests"); + + const status = await bob.rpc("openhuman.tinyplace_contacts_status", { agentId: alice.cryptoId }); + assert.equal(status.status, "pending", "relationship is pending before acceptance"); +}); + +test("Bob accepts and both sides see a mutual accepted contact", async () => { + await bob.rpc("openhuman.tinyplace_contacts_accept", { agentId: alice.cryptoId }); + + const aliceContacts = await alice.rpc("openhuman.tinyplace_contacts_list", {}); + const aliceIds = (aliceContacts.contacts ?? []).map((c) => c.agentId ?? c.cryptoId); + assert.ok(aliceIds.includes(bob.cryptoId), "Bob is in Alice's contact list"); + + const bobStatus = await bob.rpc("openhuman.tinyplace_contacts_status", { agentId: alice.cryptoId }); + assert.equal(bobStatus.status, "accepted", "relationship is accepted"); +}); + +test("Alice → Bob: first DM (X3DH) is delivered as ciphertext and decrypts", async () => { + const plaintext = "hello bob — first encrypted DM from alice"; + const sent = await alice.rpc("openhuman.tinyplace_signal_send_message", { + recipient: bob.cryptoId, + plaintext, + }); + assert.equal(sent.encrypted, true, "send reports the message was encrypted"); + assert.ok(sent.messageId, "send returns a messageId"); + + // Inspect the raw relayed envelope before decrypting: the relay must only + // ever hold opaque ciphertext, never the plaintext. + let sawCiphertext = false; + for (let i = 0; i < 15 && !sawCiphertext; i++) { + const list = await bob.rpc("openhuman.tinyplace_messages_list", { limit: 50 }); + const envelopes = Array.isArray(list) ? list : list?.messages ?? []; + const env = envelopes.find((e) => e.id === sent.messageId); + if (env) { + assert.notEqual(env.body, plaintext, "relay envelope body is not the plaintext"); + assert.ok(!String(env.body ?? "").includes(plaintext), "ciphertext does not contain the plaintext"); + assert.match(env.type, /PREKEY_BUNDLE|CIPHERTEXT/, "envelope carries a Signal message type"); + sawCiphertext = true; + } else { + await new Promise((r) => setTimeout(r, 400)); + } + } + assert.ok(sawCiphertext, "Bob's relay mailbox received the ciphertext envelope"); + + const received = await receiveMessage(bob, { fromCryptoId: alice.cryptoId }); + assert.equal(received, plaintext, "Bob decrypts Alice's first message"); +}); + +test("Bob → Alice: ratchet reply decrypts, proving a bidirectional session", async () => { + const plaintext = "hi alice — ratchet reply from bob"; + const sent = await bob.rpc("openhuman.tinyplace_signal_send_message", { + recipient: alice.cryptoId, + plaintext, + }); + assert.equal(sent.encrypted, true); + + const received = await receiveMessage(alice, { fromCryptoId: bob.cryptoId }); + assert.equal(received, plaintext, "Alice decrypts Bob's reply"); +}); + +test("a follow-up DM in the established session still decrypts", async () => { + const plaintext = "and one more, to prove the session persists"; + await alice.rpc("openhuman.tinyplace_signal_send_message", { + recipient: bob.cryptoId, + plaintext, + }); + const received = await receiveMessage(bob, { fromCryptoId: alice.cryptoId }); + assert.equal(received, plaintext, "Bob decrypts a subsequent in-session message"); +}); diff --git a/e2e/tinyplace-messaging/run-ui.sh b/e2e/tinyplace-messaging/run-ui.sh new file mode 100755 index 000000000..ac925900b --- /dev/null +++ b/e2e/tinyplace-messaging/run-ui.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# Run the openhuman ↔ tiny.place messaging UI e2e (Playwright). +# +# Reuses the app's standard web-session harness (app/scripts/e2e-web-session.sh) +# — mock cloud backend + standalone core + static web host — but points the +# core's *tiny.place* backend at a real one via TINYPLACE_API_BASE_URL, so the +# Messaging screen exchanges real Signal DMs. The peer core is launched by the +# spec itself. +# +# Steps (each skipped if already satisfied): +# 1. Ensure a tiny.place backend is reachable (auto-start one unless MANAGE_STACK=0). +# 2. Ensure the web bundle + core binary are built. +# 3. Run the Playwright spec through the web session. +# +# Env knobs: same as run.sh, plus: +# HEADED=1 run Playwright headed. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OPENHUMAN_ROOT="$(cd "$HERE/../.." && pwd)" +UMBRELLA_ROOT="$(cd "$OPENHUMAN_ROOT/.." && pwd)" +APP_DIR="$OPENHUMAN_ROOT/app" + +BACKEND_PORT="${BACKEND_PORT:-18080}" +export TINYPLACE_API_BASE_URL="${TINYPLACE_API_BASE_URL:-http://localhost:${BACKEND_PORT}}" +# Reuse the already-built debug core instead of a separate e2e-web target. +export E2E_WEB_CORE_TARGET_DIR="${E2E_WEB_CORE_TARGET_DIR:-$OPENHUMAN_ROOT/target}" +MANAGE_STACK="${MANAGE_STACK:-1}" +COMPOSE_PROJECT="tinyplace-ohe2e" +SPEC="test/playwright/specs/tinyplace-messaging.spec.ts" + +log() { printf '\033[1;35m[messaging-ui-e2e]\033[0m %s\n' "$*"; } +backend_up() { curl -fsS "${TINYPLACE_API_BASE_URL%/}/healthz" >/dev/null 2>&1; } + +STARTED_STACK=0 +cleanup() { + if [ "$STARTED_STACK" = "1" ]; then + log "tearing down backend stack ($COMPOSE_PROJECT)" + ( cd "$UMBRELLA_ROOT" && MONGO_PORT="${MONGO_PORT:-37017}" REDIS_PORT="${REDIS_PORT:-16379}" BACKEND_PORT="$BACKEND_PORT" \ + docker compose -p "$COMPOSE_PROJECT" -f docker-compose.yml -f e2e/docker-compose.e2e.yml down -v >/dev/null 2>&1 || true ) + fi +} +trap cleanup EXIT + +# 1) Backend +if backend_up; then + log "backend reachable at $TINYPLACE_API_BASE_URL" +elif [ "$MANAGE_STACK" != "0" ]; then + log "starting isolated backend stack (mongo+redis+backend, static verifier) on :$BACKEND_PORT" + ( cd "$UMBRELLA_ROOT" && MONGO_PORT="${MONGO_PORT:-37017}" REDIS_PORT="${REDIS_PORT:-16379}" BACKEND_PORT="$BACKEND_PORT" \ + docker compose -p "$COMPOSE_PROJECT" -f docker-compose.yml -f e2e/docker-compose.e2e.yml up --build -d mongo redis backend ) + STARTED_STACK=1 + for i in $(seq 1 60); do backend_up && break; sleep 2; done + backend_up || { log "ERROR: backend never became healthy"; exit 1; } + log "backend healthy" +else + log "ERROR: backend not reachable and MANAGE_STACK=0."; exit 1 +fi + +# 2) Build web bundle + core if needed. +if [ ! -f "$APP_DIR/dist-web/index.html" ] || [ ! -x "$E2E_WEB_CORE_TARGET_DIR/debug/openhuman-core" ]; then + log "building web e2e bundle (+ core if missing)…" + ( cd "$OPENHUMAN_ROOT" && bash app/scripts/e2e-web-build.sh ) +fi +# Ensure the Playwright browser is present. +( cd "$APP_DIR" && pnpm exec playwright install chromium chromium-headless-shell >/dev/null 2>&1 || true ) + +# 3) Run the spec through the web session harness. +log "running Playwright spec: $SPEC (tiny.place backend: $TINYPLACE_API_BASE_URL)" +ARGS=("$SPEC") +[ "${HEADED:-}" = "1" ] && ARGS+=("--headed") +exec bash "$APP_DIR/scripts/e2e-web-session.sh" "${ARGS[@]}" diff --git a/e2e/tinyplace-messaging/run.sh b/e2e/tinyplace-messaging/run.sh new file mode 100755 index 000000000..8cffd2009 --- /dev/null +++ b/e2e/tinyplace-messaging/run.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Run the openhuman ↔ tiny.place messaging core e2e. +# +# Steps (each is skipped if already satisfied): +# 1. Ensure a tiny.place backend is reachable at $TINYPLACE_API_BASE_URL. +# If not, and MANAGE_STACK != 0, bring one up from the umbrella +# docker-compose (mongo + redis + backend, static payment verifier) on an +# isolated compose project + ports, and tear it down on exit. +# 2. Ensure the openhuman-core binary is built. +# 3. Run the node:test suite. +# +# Env knobs: +# TINYPLACE_API_BASE_URL backend base URL (default http://localhost:18080) +# OPENHUMAN_CORE_BIN path to openhuman-core (default target/debug/openhuman-core) +# MANAGE_STACK 1 = auto-manage backend (default 1) +# BACKEND_PORT host port for managed backend (default 18080) +# VERBOSE 1 = stream core logs +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OPENHUMAN_ROOT="$(cd "$HERE/../.." && pwd)" +UMBRELLA_ROOT="$(cd "$OPENHUMAN_ROOT/.." && pwd)" + +BACKEND_PORT="${BACKEND_PORT:-18080}" +export TINYPLACE_API_BASE_URL="${TINYPLACE_API_BASE_URL:-http://localhost:${BACKEND_PORT}}" +export OPENHUMAN_CORE_BIN="${OPENHUMAN_CORE_BIN:-$OPENHUMAN_ROOT/target/debug/openhuman-core}" +MANAGE_STACK="${MANAGE_STACK:-1}" +COMPOSE_PROJECT="tinyplace-ohe2e" + +log() { printf '\033[1;36m[messaging-e2e]\033[0m %s\n' "$*"; } + +backend_up() { curl -fsS "${TINYPLACE_API_BASE_URL%/}/healthz" >/dev/null 2>&1; } + +STARTED_STACK=0 +cleanup() { + if [ "$STARTED_STACK" = "1" ]; then + log "tearing down backend stack ($COMPOSE_PROJECT)" + ( cd "$UMBRELLA_ROOT" && MONGO_PORT="${MONGO_PORT:-37017}" REDIS_PORT="${REDIS_PORT:-16379}" BACKEND_PORT="$BACKEND_PORT" \ + docker compose -p "$COMPOSE_PROJECT" -f docker-compose.yml -f e2e/docker-compose.e2e.yml down -v >/dev/null 2>&1 || true ) + fi +} +trap cleanup EXIT + +# 1) Backend +if backend_up; then + log "backend reachable at $TINYPLACE_API_BASE_URL" +elif [ "$MANAGE_STACK" != "0" ]; then + if [ ! -f "$UMBRELLA_ROOT/docker-compose.yml" ] || [ ! -f "$UMBRELLA_ROOT/e2e/docker-compose.e2e.yml" ]; then + log "ERROR: cannot find umbrella docker-compose to auto-start the backend." + log "Start a tiny.place backend yourself and set TINYPLACE_API_BASE_URL." + exit 1 + fi + log "starting isolated backend stack (mongo+redis+backend, static verifier) on :$BACKEND_PORT" + ( cd "$UMBRELLA_ROOT" && MONGO_PORT="${MONGO_PORT:-37017}" REDIS_PORT="${REDIS_PORT:-16379}" BACKEND_PORT="$BACKEND_PORT" \ + docker compose -p "$COMPOSE_PROJECT" -f docker-compose.yml -f e2e/docker-compose.e2e.yml up --build -d mongo redis backend ) + STARTED_STACK=1 + log "waiting for backend health…" + for i in $(seq 1 60); do backend_up && break; sleep 2; done + backend_up || { log "ERROR: backend never became healthy"; exit 1; } + log "backend healthy" +else + log "ERROR: backend not reachable and MANAGE_STACK=0." + exit 1 +fi + +# 2) Core binary +if [ ! -x "$OPENHUMAN_CORE_BIN" ]; then + log "building openhuman-core (this can take a while the first time)…" + ( cd "$OPENHUMAN_ROOT" && GGML_NATIVE=OFF cargo build --bin openhuman-core --manifest-path Cargo.toml ) +fi +log "using core binary: $OPENHUMAN_CORE_BIN" + +# 3) Test +log "running node:test suite…" +cd "$HERE" +exec node --test messaging.e2e.mjs