mirror of
https://github.com/hanzo-js/cloud.git
synced 2026-10-30 18:55:53 +00:00
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.
227 lines
8.3 KiB
JavaScript
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)
|