Files
zeekay 51100cafb0 @hanzo/cloud: typed client for the Hanzo Cloud /v1/ platform API
First-class services for agents, agent sessions, and machines, plus a typed
send() escape hatch backed by the OpenAPI-generated paths (the spec is the
single source of truth, regenerated via npm run codegen). Zero runtime deps,
Hanzo IAM-native Bearer auth. Orthogonal to @hanzo/base (datastore) and
@hanzo/ai (inference).

typecheck + build + 51 smoke assertions green.
2026-07-04 01:10:38 -07:00

227 lines
8.3 KiB
JavaScript

/**
* Smoke test for @hanzo/cloud.
*
* Stubs global fetch to capture requests and verifies transport behavior:
* URL/query building, Bearer auth, method/body per service call, envelope
* unwrapping, error handling, and SSE frame parsing. No network.
*/
import {
HanzoCloud,
HanzoCloudError,
MemoryAuthStore,
DEFAULT_URL,
parseSseFrame,
} from '../dist/core/index.js'
let passed = 0
let failed = 0
function assert(cond, msg) {
if (cond) passed++
else {
failed++
console.error(` FAIL: ${msg}`)
}
}
function assertEq(actual, expected, msg) {
if (actual === expected) passed++
else {
failed++
console.error(` FAIL: ${msg} -- expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
}
}
// ---- fetch stub -----------------------------------------------------------
let lastCall = null
let nextResponse = { status: 200, body: {} }
function jsonResponse(status, body) {
const text = body === undefined ? '' : JSON.stringify(body)
return {
ok: status >= 200 && status < 300,
status,
text: async () => text,
}
}
globalThis.fetch = async (url, init = {}) => {
lastCall = { url, method: init.method ?? 'GET', headers: init.headers ?? {}, body: init.body }
return jsonResponse(nextResponse.status, nextResponse.body)
}
function expectNext(status, body) {
nextResponse = { status, body }
}
// ---- construction ---------------------------------------------------------
console.log('=== construction ===')
assertEq(DEFAULT_URL, 'https://api.hanzo.ai', 'default url constant')
const c1 = new HanzoCloud()
assertEq(c1.url, 'https://api.hanzo.ai', 'no-arg defaults to api.hanzo.ai')
const c2 = new HanzoCloud('https://api.hanzo.ai/')
assertEq(c2.url, 'https://api.hanzo.ai', 'trailing slash stripped from url string')
const c3 = new HanzoCloud({ url: 'https://example.test', token: 'tok123' })
assertEq(c3.url, 'https://example.test', 'config url honored')
assertEq(c3.authStore.token, 'tok123', 'config token seeds auth store')
assert(c1.agents && c1.sessions && c1.machines, 'services attached')
// ---- auth store -----------------------------------------------------------
console.log('=== auth store ===')
const auth = new MemoryAuthStore()
assertEq(auth.isValid, false, 'empty token invalid')
auth.save('opaque-api-key')
assertEq(auth.isValid, true, 'opaque API key is valid when present')
const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
const futureJwt = `${enc({ alg: 'HS256' })}.${enc({ exp: Math.floor(Date.now() / 1000) + 3600 })}.sig`
const pastJwt = `${enc({ alg: 'HS256' })}.${enc({ exp: Math.floor(Date.now() / 1000) - 3600 })}.sig`
auth.save(futureJwt)
assertEq(auth.isValid, true, 'unexpired JWT valid')
auth.save(pastJwt)
assertEq(auth.isValid, false, 'expired JWT invalid')
let changed = 0
const off = auth.onChange(() => { changed++ })
auth.save('x')
assertEq(changed, 1, 'onChange fired')
off()
auth.save('y')
assertEq(changed, 1, 'unsubscribed listener silent')
// ---- Bearer header --------------------------------------------------------
console.log('=== bearer header ===')
const c = new HanzoCloud({ token: 'my-jwt' })
expectNext(200, { status: 'ok' })
await c.health()
assertEq(lastCall.url, 'https://api.hanzo.ai/health', 'health path')
assertEq(lastCall.headers['Authorization'], 'Bearer my-jwt', 'Bearer prefix applied')
c.signOut()
expectNext(200, {})
await c.health()
assert(!lastCall.headers['Authorization'], 'no auth header after signOut')
// ---- agents ---------------------------------------------------------------
console.log('=== agents ===')
c.setToken('t')
expectNext(200, { agents: [{ id: 'agent_1', name: 'a' }] })
const agents = await c.agents.list()
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/agents', 'agents.list path')
assertEq(agents.length, 1, 'agents.list unwraps { agents }')
assertEq(agents[0].id, 'agent_1', 'agent id')
expectNext(201, { id: 'agent_2', name: 'writer' })
const created = await c.agents.create({ name: 'writer', model: 'zen-coder' })
assertEq(lastCall.method, 'POST', 'agents.create is POST')
assertEq(JSON.parse(lastCall.body).model, 'zen-coder', 'agents.create body')
assertEq(lastCall.headers['Content-Type'], 'application/json', 'json content-type on body')
assertEq(created.id, 'agent_2', 'created agent returned directly')
expectNext(200, { id: 'agent_2', name: 'writer', instructions: 'hi' })
await c.agents.get('has space')
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/agents/has%20space', 'ref is url-encoded')
expectNext(200, { id: 'run_1', status: 'ok' })
const run = await c.agents.run('writer', { input: 'go' })
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/agents/writer/run', 'agents.run path')
assertEq(run.status, 'ok', 'run status')
expectNext(200, { runs: [{ id: 'run_1' }, { id: 'run_2' }] })
const runs = await c.agents.runs('writer', 10)
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/agents/writer/runs?limit=10', 'runs limit query')
assertEq(runs.length, 2, 'runs unwrapped')
expectNext(200, { range: '7D', series: [] })
await c.agents.metrics('7D')
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/agents/metrics?range=7D', 'metrics range query')
expectNext(204, undefined)
const del = await c.agents.delete('writer')
assertEq(lastCall.method, 'DELETE', 'agents.delete is DELETE')
assertEq(del, undefined, '204 returns undefined')
// ---- sessions -------------------------------------------------------------
console.log('=== sessions ===')
expectNext(201, { id: 'sess_1', status: 'running' })
await c.sessions.register({ agent: 'writer' })
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/agents/sessions', 'sessions.register path')
assertEq(lastCall.method, 'POST', 'register is POST')
expectNext(200, { sessions: [{ id: 'sess_1' }] })
const sess = await c.sessions.list({ status: 'running', root: 'sess_1', limit: 50 })
assert(lastCall.url.includes('status=running'), 'status filter in query')
assert(lastCall.url.includes('root=sess_1'), 'root filter in query')
assert(lastCall.url.includes('limit=50'), 'limit in query')
assertEq(sess.length, 1, 'sessions unwrapped')
expectNext(200, { command: 'pause', forwarded: false })
await c.sessions.pause('sess_1')
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/agents/sessions/sess_1/pause', 'pause path')
expectNext(201, { id: 'evt_1', seq: 1 })
await c.sessions.appendEvent('sess_1', { kind: 'log' })
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/agents/sessions/sess_1/events', 'appendEvent path')
// ---- machines -------------------------------------------------------------
console.log('=== machines ===')
expectNext(200, [{ id: 'org/m1', name: 'm1' }])
await c.machines.list({ pageSize: 20, page: 2 })
assert(lastCall.url.includes('pageSize=20'), 'machines pageSize query')
assert(lastCall.url.includes('p=2'), 'machines page (p) query')
expectNext(200, { id: 'org/m1', name: 'm1' })
await c.machines.get('org/m1')
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/cloud/get-machine?id=org%2Fm1', 'machines.get id encoded')
expectNext(200, { status: 'ok', msg: '' })
await c.machines.add({ name: 'm2', region: 'sfo3' })
assertEq(lastCall.url, 'https://api.hanzo.ai/v1/cloud/add-machine', 'machines.add path')
assertEq(lastCall.method, 'POST', 'machines.add POST')
// ---- errors ---------------------------------------------------------------
console.log('=== errors ===')
expectNext(402, { message: 'Insufficient org credit balance' })
let caught = null
try {
await c.agents.run('writer')
} catch (e) {
caught = e
}
assert(caught instanceof HanzoCloudError, 'non-2xx throws HanzoCloudError')
assertEq(caught.status, 402, 'error carries status')
assertEq(caught.detail.message, 'Insufficient org credit balance', 'error carries parsed detail')
// ---- SSE parser -----------------------------------------------------------
console.log('=== SSE parser ===')
const f1 = parseSseFrame('event: session\ndata: {"id":"sess_1","status":"running"}')
assertEq(f1.type, 'session', 'SSE event type parsed')
assertEq(f1.data.id, 'sess_1', 'SSE data parsed as JSON')
const f2 = parseSseFrame('data: plain text')
assertEq(f2.type, 'message', 'SSE default event type')
assertEq(f2.data, 'plain text', 'SSE non-JSON data kept raw')
assertEq(parseSseFrame(': comment only'), null, 'SSE comment-only frame ignored')
// ---- summary --------------------------------------------------------------
console.log('')
console.log(`=== Results: ${passed} passed, ${failed} failed ===`)
process.exit(failed > 0 ? 1 : 0)