// home.get — every identity has ONE home room (hyper-tps docs/home-world-design.md §2,
// gate H2). The daemon owns the pointer: identities.json entry `homeRoomId`.
//
// 1. a fresh identity: two CONCURRENT home.get calls create ONE room
// 2. a later call returns the same room, created:false
// 3. the pointer is on disk (identities.json), so it survives a restart
// 4. another identity gets its own home; switching back returns the first
// 5. a pointer to a room that is gone -> HOME_ROOM_MISSING, and NO new room
//
// SAFETY: fresh tmp storage; ~/.config/keet-cli is never opened. RoomMeta's legacy
// migration renames files under os.homedir(), so this REFUSES to run unless HOME
// is a scratch dir too:
//
// cd hyper-fleeky/packages/keet-daemon && HOME=$(mktemp -d) bare test/home-get.test.js
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Pipe from 'bare-pipe'
import FramedStream from 'framed-stream'
import { startDaemon } from '../index.js'
let exitCode = 0
const failures = []
function ok (cond, msg) {
if (cond) console.log(' ok ' + msg)
else { console.log(' FAIL ' + msg); exitCode = 1; failures.push(msg) }
}
function rmrf (dir) {
try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
}
class TestClient {
constructor (socketPath) {
this._socketPath = socketPath
this._nextId = 1
this._pending = new Map()
}
connect () {
return new Promise((resolve, reject) => {
const pipe = new Pipe(this._socketPath)
pipe.once('error', reject)
pipe.once('connect', () => {
pipe.removeListener('error', reject)
this._pipe = pipe
const FS = FramedStream.default || FramedStream
this._framed = new FS(pipe)
this._framed.on('data', (buf) => {
let msg
try { msg = JSON.parse(buf.toString('utf-8')) } catch { return }
const p = this._pending.get(msg.id)
if (!p) return
this._pending.delete(msg.id)
if (msg.ok === false || msg.error) p.reject(new Error(msg.error || 'RPC failed'))
else p.resolve(msg.data !== undefined ? msg.data : msg)
})
resolve()
})
})
}
call (method, params = {}, timeoutMs = 60000) {
const id = this._nextId++
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this._pending.delete(id)
reject(new Error('RPC timeout: ' + method))
}, timeoutMs)
this._pending.set(id, {
resolve: (v) => { clearTimeout(timer); resolve(v) },
reject: (e) => { clearTimeout(timer); reject(e) }
})
this._framed.write(Buffer.from(JSON.stringify({ id, method, params }), 'utf-8'))
})
}
close () {
try { this._framed && this._framed.destroy() } catch {}
try { this._pipe && this._pipe.destroy() } catch {}
}
}
function readRegistry (baseDir) {
return JSON.parse(fs.readFileSync(path.join(baseDir, 'identities.json'), 'utf-8'))
}
async function main () {
const home = os.homedir()
if (!home.startsWith(os.tmpdir())) {
console.log('REFUSING: HOME=' + home + ' is not under ' + os.tmpdir() + '. Run with HOME=$(mktemp -d).')
Bare.exit(2)
}
const baseDir = path.join(os.tmpdir(), 'kd-home-' + Date.now().toString(36))
fs.mkdirSync(baseDir, { recursive: true })
const socketPath = path.join(baseDir, 'd.sock')
console.log('[test] baseDir = ' + baseDir)
const daemon = await startDaemon({ storage: baseDir, socketPath, pidFile: path.join(baseDir, 'd.pid') })
const client = new TestClient(socketPath)
await client.connect()
// 1. concurrent first calls on a fresh identity -> one room
const [a, b] = await Promise.all([client.call('home.get'), client.call('home.get')])
ok(typeof a.roomId === 'string' && a.roomId.length > 0, 'home.get returns a roomId: ' + a.roomId)
ok(a.roomId === b.roomId, 'two concurrent calls agree on one room')
ok([a.created, b.created].filter(Boolean).length === 1, 'exactly one of them created it; got ' + a.created + '/' + b.created)
const rooms1 = await client.call('rooms')
ok(rooms1.filter(r => r.roomId === a.roomId).length === 1 && rooms1.length === 1, 'one room exists; got ' + rooms1.length)
// 2. a later call: same room, not created
const c = await client.call('home.get')
ok(c.roomId === a.roomId && c.created === false, 'later call returns it with created:false')
// 3. on disk
const reg = readRegistry(baseDir)
const entry0 = reg.identities.find(e => e.id === reg.activeId)
ok(entry0 && entry0.homeRoomId === a.roomId, 'identities.json holds homeRoomId for ' + reg.activeId)
// 4. another identity has its own home; switching back returns the first
const created = await client.call('identity-manager.create', { name: 'home-test-2' })
await client.call('identity-manager.switch', { id: created.id }, 60000)
const d = await client.call('home.get')
ok(d.created === true && d.roomId !== a.roomId, created.id + ' gets its own home: ' + d.roomId)
await client.call('identity-manager.switch', { id: 'identity-0' }, 60000)
const e = await client.call('home.get')
ok(e.roomId === a.roomId && e.created === false, 'switching back returns the first home')
// 5. a pointer to a room that is gone: error, and no new room
const live = daemon.registry.getActive()
const realHome = live.homeRoomId
live.homeRoomId = 'y'.repeat(52) // well-formed z32 length, no such room
const before = (await client.call('rooms')).length
let err = null
try { await client.call('home.get') } catch (x) { err = x }
ok(err && /HOME_ROOM_MISSING/.test(err.message), 'missing room -> HOME_ROOM_MISSING; got ' + (err ? err.message : 'no error'))
const after = (await client.call('rooms')).length
ok(after === before, 'no new room created on a missing home (' + before + ' -> ' + after + ')')
ok(daemon.registry.getActive().homeRoomId === 'y'.repeat(52), 'the pointer is left as it was for someone to look at')
live.homeRoomId = realHome
client.close()
try { await daemon.router.core.close() } catch {}
try { await daemon.core.close() } catch {}
try { await daemon.uds.stop() } catch {}
rmrf(baseDir)
console.log('')
console.log(exitCode === 0 ? 'ALL PASSED' : failures.length + ' FAILED:\n - ' + failures.join('\n - '))
Bare.exit(exitCode)
}
main().catch(err => {
console.error('uncaught:', err && err.stack ? err.stack : err)
Bare.exit(1)
})