Skip to content
17 changes: 14 additions & 3 deletions cli/src/claude/utils/sessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,26 @@ import { createSessionScanner } from './sessionScanner'
import { RawJSONLines } from '../types'
import { mkdir, writeFile, appendFile, rm, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir, homedir } from 'node:os'
import { tmpdir } from 'node:os'
import { existsSync } from 'node:fs'

describe('sessionScanner', () => {
let testDir: string
let projectDir: string
let claudeConfigDir: string
let collectedMessages: RawJSONLines[]
let scanner: Awaited<ReturnType<typeof createSessionScanner>> | null = null
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR

beforeEach(async () => {
testDir = join(tmpdir(), `scanner-test-${Date.now()}`)
await mkdir(testDir, { recursive: true })

claudeConfigDir = join(testDir, '.claude')
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir

const projectName = testDir.replace(/\//g, '-')
projectDir = join(homedir(), '.claude', 'projects', projectName)
projectDir = join(claudeConfigDir, 'projects', projectName)
await mkdir(projectDir, { recursive: true })

collectedMessages = []
Expand All @@ -36,6 +41,12 @@ describe('sessionScanner', () => {
if (existsSync(projectDir)) {
await rm(projectDir, { recursive: true, force: true })
}

if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
})

it('should process initial session and resumed session correctly', async () => {
Expand Down Expand Up @@ -144,4 +155,4 @@ describe('sessionScanner', () => {
expect(content).toContain('readme.md')
}
})
})
})
161 changes: 97 additions & 64 deletions cli/src/claude/utils/startHookServer.test.ts
Original file line number Diff line number Diff line change
@@ -1,117 +1,150 @@
import { describe, it, expect } from 'vitest'
import { request } from 'node:http'
import { startHookServer, type SessionHookData } from './startHookServer'

const sendHookRequest = async (port: number, body: string, token?: string): Promise<{ statusCode?: number; body: string }> => {
return await new Promise((resolve, reject) => {
const headers: Record<string, string | number> = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
import { Readable } from 'node:stream'
import { createHookRequestHandler, type SessionHookData } from './startHookServer'

class MockRequest extends Readable {
headers: Record<string, string | string[] | undefined>
method: string
url: string
private sent = false

constructor(opts: {
body: string
path?: string
token?: string
}) {
super()
this.method = 'POST'
this.url = opts.path ?? '/hook/session-start'
this.headers = {
'content-type': 'application/json',
'content-length': `${Buffer.byteLength(opts.body)}`
}
if (token) {
headers['x-hapi-hook-token'] = token
if (opts.token) {
this.headers['x-hapi-hook-token'] = opts.token
}
this.body = opts.body
}

const req = request({
host: '127.0.0.1',
port,
path: '/hook/session-start',
method: 'POST',
headers
}, (res) => {
const chunks: Buffer[] = []
res.on('data', (chunk) => chunks.push(chunk as Buffer))
res.on('error', reject)
res.on('end', () => {
resolve({
statusCode: res.statusCode,
body: Buffer.concat(chunks).toString('utf-8')
})
})
})
private readonly body: string

req.on('error', reject)
req.end(body)
})
_read(): void {
if (this.sent) {
this.push(null)
return
}
this.sent = true
this.push(Buffer.from(this.body, 'utf-8'))
this.push(null)
}
}

class MockResponse {
statusCode: number | undefined
headersSent = false
writableEnded = false
body = ''

writeHead(statusCode: number): this {
this.statusCode = statusCode
this.headersSent = true
return this
}

end(body?: string): this {
if (body) {
this.body += body
}
this.writableEnded = true
return this
}
}

const sendHookRequest = async (
handler: ReturnType<typeof createHookRequestHandler>,
body: string,
token?: string
): Promise<{ statusCode?: number; body: string }> => {
const req = new MockRequest({ body, token })
const res = new MockResponse()

await handler(req as never, res as never)

return {
statusCode: res.statusCode,
body: res.body
}
}

describe('startHookServer', () => {
it('forwards session hook payload to callback', async () => {
let received: { sessionId?: string; data?: SessionHookData } = {}
const server = await startHookServer({
const token = 'test-hook-token'
const handler = createHookRequestHandler({
token,
onSessionHook: (sessionId, data) => {
received = { sessionId, data }
}
})

try {
const body = JSON.stringify({ session_id: 'session-123', extra: 'ok' })
const response = await sendHookRequest(server.port, body, server.token)
expect(response.statusCode).toBe(200)
} finally {
server.stop()
}
const body = JSON.stringify({ session_id: 'session-123', extra: 'ok' })
const response = await sendHookRequest(handler, body, token)

expect(response.statusCode).toBe(200)
expect(received.sessionId).toBe('session-123')
expect(received.data?.session_id).toBe('session-123')
})

it('returns 400 for invalid JSON payloads', async () => {
let hookCalled = false
const server = await startHookServer({
const token = 'test-hook-token'
const handler = createHookRequestHandler({
token,
onSessionHook: () => {
hookCalled = true
}
})

try {
const response = await sendHookRequest(server.port, '{"session_id":', server.token)
expect(response.statusCode).toBe(400)
expect(response.body).toBe('invalid json')
} finally {
server.stop()
}
const response = await sendHookRequest(handler, '{"session_id":', token)

expect(response.statusCode).toBe(400)
expect(response.body).toBe('invalid json')
expect(hookCalled).toBe(false)
})

it('returns 422 when session_id is missing', async () => {
let hookCalled = false
const server = await startHookServer({
const token = 'test-hook-token'
const handler = createHookRequestHandler({
token,
onSessionHook: () => {
hookCalled = true
}
})

try {
const body = JSON.stringify({ extra: 'ok' })
const response = await sendHookRequest(server.port, body, server.token)
expect(response.statusCode).toBe(422)
expect(response.body).toBe('missing session_id')
} finally {
server.stop()
}
const body = JSON.stringify({ extra: 'ok' })
const response = await sendHookRequest(handler, body, token)

expect(response.statusCode).toBe(422)
expect(response.body).toBe('missing session_id')
expect(hookCalled).toBe(false)
})

it('returns 401 when hook token is missing', async () => {
let hookCalled = false
const server = await startHookServer({
const token = 'test-hook-token'
const handler = createHookRequestHandler({
token,
onSessionHook: () => {
hookCalled = true
}
})

try {
const body = JSON.stringify({ session_id: 'session-123' })
const response = await sendHookRequest(server.port, body)
expect(response.statusCode).toBe(401)
expect(response.body).toBe('unauthorized')
} finally {
server.stop()
}
const body = JSON.stringify({ session_id: 'session-123' })
const response = await sendHookRequest(handler, body)

expect(response.statusCode).toBe(401)
expect(response.body).toBe('unauthorized')
expect(hookCalled).toBe(false)
})
})
Loading
Loading