| … | … | @@ -0,0 +1,96 @@ |
| 1 | // The sink must work with no socket at all — because on a phone there cannot be one. |
| 2 | // |
| 3 | // ⛔ WHY THIS MATTERS. The bsky projection runs next to the SIGNING KEY. On a desktop that is a |
| 4 | // co-located hpp reached over a unix socket; on a handset hpp is in the same worklet and SELinux |
| 5 | // refuses an unprivileged app a `sock_file` at all. So the phone supplies a transport and no path. |
| 6 | // |
| 7 | // Two things stopped that working, and both are the same mistake in different clothes — asking about |
| 8 | // the SOCKET when the question is about the TRANSPORT: |
| 9 | // |
| 10 | // 1. `get configured ()` returned `!!this.sock`, so every method short-circuited to "no bsky sink |
| 11 | // configured" for a caller that had already supplied a working connect. |
| 12 | // 2. `import { Client } from 'hyper-svc'` sat at module scope. bare-pack bundles by IMPORT, not by |
| 13 | // use, so naming it there ships bare-net and a unix-socket client into a worklet that can never |
| 14 | // call them. |
| 15 | // |
| 16 | // The measured consequence of getting this wrong is not an error — it is a projection that silently |
| 17 | // does nothing, which is the state a handset was in with a Bluesky identity that resolved correctly |
| 18 | // and an atproto repo holding one bootstrap record. |
| 19 | |
| 20 | import test from 'brittle' |
| 21 | import fs from 'bare-fs' |
| 22 | import { BskySink, PROFILE_COLLECTION, PROFILE_RKEY } from '../lib/bsky.js' |
| 23 | |
| 24 | const ROOT = new URL('..', import.meta.url).pathname |
| 25 | |
| 26 | // A stand-in for the co-located device: the same `call(cmd, args)` shape hpp's UDS surface exposes, |
| 27 | // which is also the shape favela-host's inProcessClient already hands the surface. |
| 28 | function fakeDevice () { |
| 29 | const records = new Map() |
| 30 | const calls = [] |
| 31 | return { |
| 32 | records, |
| 33 | calls, |
| 34 | client: { |
| 35 | call: async (cmd, args = {}) => { |
| 36 | calls.push(cmd) |
| 37 | if (cmd === 'get-state') { |
| 38 | return { records: [...records.keys()].map((k) => { const i = k.indexOf('/'); return { collection: k.slice(0, i), rkey: k.slice(i + 1) } }) } |
| 39 | } |
| 40 | if (cmd === 'apply-write') { records.set(args.collection + '/' + args.rkey, args.record); return { ok: true } } |
| 41 | if (cmd === 'get-record') return records.get(args.collection + '/' + args.rkey) || null |
| 42 | if (cmd === 'upload-blob') return { cid: 'bafyfake', mimeType: args.mimeType, size: 3 } |
| 43 | throw new Error('unexpected command ' + cmd) |
| 44 | } |
| 45 | // ⛔ NO `close`. The sink closes its client after every call — correct for a socket it opened, |
| 46 | // fatal for a device that must outlive the projection. bsky.js guards with `if (client.close)`, |
| 47 | // so omitting it is the adapter saying "this one is not mine to close". |
| 48 | } |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | test('★★★ a sink with an injected transport and NO socket is configured', (t) => { |
| 53 | const d = fakeDevice() |
| 54 | const sink = new BskySink(null, { connect: async () => d.client }) |
| 55 | t.ok(sink.configured, 'a transport is what makes it usable, not a path') |
| 56 | t.absent(new BskySink(null).configured, 'and with neither it is still correctly unconfigured') |
| 57 | }) |
| 58 | |
| 59 | test('★★ the whole surface the projector uses works over an injected transport', async (t) => { |
| 60 | const d = fakeDevice() |
| 61 | const sink = new BskySink(null, { connect: async () => d.client }) |
| 62 | |
| 63 | const profile = { $type: PROFILE_COLLECTION, displayName: 'Fleeky' } |
| 64 | t.alike(await sink.putProfile(profile), { sent: true }, 'the profile is written') |
| 65 | t.alike(await sink.getProfile(), profile, 'and read back from the repo, not from a local note') |
| 66 | |
| 67 | const post = { $type: 'app.bsky.feed.post', text: 'hello', createdAt: '2026-09-11T00:00:00.000Z' } |
| 68 | const r = await sink.send('a-favela-record', post) |
| 69 | t.ok(r.sent, 'a post is sent') |
| 70 | t.ok(/^[0-9a-z]{13}$/.test(r.rkey), 'under a real TID rkey — the AppView drops posts without one') |
| 71 | |
| 72 | const have = await sink.have() |
| 73 | t.ok(have instanceof Set && have.has(r.rkey), 'and `have` reports it, so a re-run does not duplicate it') |
| 74 | |
| 75 | const blob = await sink.uploadBlob(ROOT + 'package.json', 'image/png') |
| 76 | t.is(blob.$type, 'blob') |
| 77 | t.is(blob.ref.$link, 'bafyfake', 'blobs travel the same transport') |
| 78 | }) |
| 79 | |
| 80 | test('★★ absent and unreadable stay distinguishable over the injected transport', async (t) => { |
| 81 | const sink = new BskySink(null, { connect: async () => ({ call: async () => { throw new Error('device is busy') } }) }) |
| 82 | |
| 83 | const got = await sink.getProfile() |
| 84 | t.ok(got && got.unknown, 'a failed read is `unknown`, never null') |
| 85 | t.absent(await new BskySink(null, { connect: async () => ({ call: async () => null }) }).getProfile(), |
| 86 | 'and a genuinely absent record is null — writing because a read failed republishes on every blip') |
| 87 | }) |
| 88 | |
| 89 | // ⛔ SOURCE-LEVEL, because the cost is paid by bare-pack at BUILD time and is invisible at runtime: |
| 90 | // the socket client works perfectly well on a desktop while bloating a bundle that can never use it. |
| 91 | test('★★ hyper-svc is not imported at module scope', (t) => { |
| 92 | const src = fs.readFileSync(ROOT + 'lib/bsky.js', 'utf8') |
| 93 | t.absent(/^import .*from 'hyper-svc'/m.test(src), |
| 94 | 'a top-level import ships bare-net and a unix socket into the worklet') |
| 95 | t.ok(/await import\('hyper-svc'\)/.test(src), 'it is loaded on the socket path, which is the only path that uses it') |
| 96 | }) |