mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
* feat: add Twitter share referral UI and expose browserosId When credits are exhausted, users now see a "Share on Twitter" CTA with a pre-filled tweet URL and an input to paste their tweet link. Reusable ShareForCredits component used in both ChatError and UsagePage. Server's GET /credits now includes browserosId for the extension to pass to the referral service. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rebuild chat session on provider change * fix: address Greptile review comments - Move referral service URL to EXTERNAL_URLS - Guard submitReferral on !response.ok - Remove stale TODO comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
361 lines
9.6 KiB
TypeScript
361 lines
9.6 KiB
TypeScript
import { describe, expect, it, mock } from 'bun:test'
|
|
|
|
interface MockMessage {
|
|
id: string
|
|
role: 'user' | 'assistant'
|
|
parts: Array<{ type: 'text'; text: string }>
|
|
}
|
|
|
|
interface MockAgent {
|
|
toolLoopAgent: object
|
|
toolNames: Set<string>
|
|
messages: MockMessage[]
|
|
appendUserMessage(text: string): void
|
|
dispose(): Promise<void>
|
|
}
|
|
|
|
interface StoredSession {
|
|
agent: MockAgent
|
|
hiddenPageId?: number
|
|
}
|
|
|
|
interface StreamResponseOptions {
|
|
onFinish(args: { messages: MockMessage[] }): Promise<void>
|
|
}
|
|
|
|
let agentToReturn: MockAgent | undefined
|
|
let streamResponseHandler:
|
|
| ((options: StreamResponseOptions) => Promise<Response>)
|
|
| undefined
|
|
|
|
const createAgentSpy = mock(async (config: unknown) => {
|
|
if (!agentToReturn) {
|
|
throw new Error(`No mock agent configured for ${JSON.stringify(config)}`)
|
|
}
|
|
return agentToReturn
|
|
})
|
|
|
|
const createAgentUIStreamResponseSpy = mock(
|
|
async (options: StreamResponseOptions) => {
|
|
if (!streamResponseHandler) {
|
|
throw new Error('No stream response handler configured')
|
|
}
|
|
return await streamResponseHandler(options)
|
|
},
|
|
)
|
|
|
|
const resolveLLMConfigSpy = mock(
|
|
async (config: {
|
|
provider?: string
|
|
model?: string
|
|
apiKey?: string
|
|
baseUrl?: string
|
|
}) => ({
|
|
provider: config.provider ?? 'openai',
|
|
model: config.model ?? 'gpt-5',
|
|
apiKey: config.apiKey ?? 'test-key',
|
|
baseUrl: config.baseUrl,
|
|
}),
|
|
)
|
|
|
|
mock.module('ai', () => ({
|
|
createAgentUIStreamResponse: createAgentUIStreamResponseSpy,
|
|
}))
|
|
|
|
mock.module('../../../src/agent/ai-sdk-agent', () => ({
|
|
AiSdkAgent: {
|
|
create: createAgentSpy,
|
|
},
|
|
}))
|
|
|
|
mock.module('../../../src/lib/clients/llm/config', () => ({
|
|
resolveLLMConfig: resolveLLMConfigSpy,
|
|
}))
|
|
|
|
mock.module('../../../src/lib/logger', () => ({
|
|
logger: {
|
|
info: mock(() => {}),
|
|
warn: mock(() => {}),
|
|
debug: mock(() => {}),
|
|
},
|
|
}))
|
|
|
|
const { ChatService } = await import('../../../src/api/services/chat-service')
|
|
|
|
function createSessionStore() {
|
|
const sessions = new Map<string, StoredSession>()
|
|
return {
|
|
get(conversationId: string) {
|
|
return sessions.get(conversationId)
|
|
},
|
|
set(conversationId: string, session: StoredSession) {
|
|
sessions.set(conversationId, session)
|
|
},
|
|
remove(conversationId: string) {
|
|
return sessions.delete(conversationId)
|
|
},
|
|
async delete(conversationId: string) {
|
|
const session = sessions.get(conversationId)
|
|
if (!session) return false
|
|
await session.agent.dispose()
|
|
sessions.delete(conversationId)
|
|
return true
|
|
},
|
|
count() {
|
|
return sessions.size
|
|
},
|
|
}
|
|
}
|
|
|
|
function createFakeAgent() {
|
|
const messages: MockMessage[] = []
|
|
return {
|
|
toolLoopAgent: {},
|
|
toolNames: new Set<string>(),
|
|
messages,
|
|
appendUserMessage(text: string) {
|
|
messages.push({
|
|
id: 'user-1',
|
|
role: 'user',
|
|
parts: [{ type: 'text', text }],
|
|
})
|
|
},
|
|
dispose: mock(async () => {}),
|
|
}
|
|
}
|
|
|
|
describe('ChatService scheduled task hidden page lifecycle', () => {
|
|
it('creates and cleans up a hidden page without creating a hidden window', async () => {
|
|
const fakeAgent = createFakeAgent()
|
|
agentToReturn = fakeAgent
|
|
streamResponseHandler = async ({ onFinish }) => {
|
|
await onFinish({ messages: fakeAgent.messages })
|
|
return new Response('ok')
|
|
}
|
|
|
|
const browser = {
|
|
newPage: mock(async () => 77),
|
|
listPages: mock(async () => [
|
|
{
|
|
pageId: 77,
|
|
windowId: 11,
|
|
},
|
|
]),
|
|
closePage: mock(async () => {}),
|
|
createWindow: mock(async () => ({ windowId: 11 })),
|
|
closeWindow: mock(async () => {}),
|
|
resolveTabIds: mock(async () => new Map<number, number>()),
|
|
}
|
|
const sessionStore = createSessionStore()
|
|
const service = new ChatService({
|
|
sessionStore: sessionStore as never,
|
|
klavisClient: {} as never,
|
|
browser: browser as never,
|
|
registry: {} as never,
|
|
})
|
|
|
|
await service.processMessage(
|
|
{
|
|
conversationId: crypto.randomUUID(),
|
|
message: 'Run the scheduled task',
|
|
isScheduledTask: true,
|
|
mode: 'agent',
|
|
origin: 'sidepanel',
|
|
browserContext: {
|
|
windowId: 9,
|
|
activeTab: {
|
|
id: 3,
|
|
url: 'https://example.com',
|
|
title: 'Example',
|
|
},
|
|
selectedTabs: [{ id: 4 }],
|
|
enabledMcpServers: ['slack'],
|
|
},
|
|
} as never,
|
|
new AbortController().signal,
|
|
)
|
|
|
|
expect(browser.newPage).toHaveBeenCalledWith('about:blank', {
|
|
hidden: true,
|
|
background: true,
|
|
})
|
|
expect(browser.createWindow).not.toHaveBeenCalled()
|
|
expect(browser.closePage).toHaveBeenCalledWith(77)
|
|
expect(browser.closeWindow).not.toHaveBeenCalled()
|
|
|
|
const createArgs = createAgentSpy.mock.calls.at(-1)?.[0] as {
|
|
browserContext?: {
|
|
windowId?: number
|
|
selectedTabs?: unknown[]
|
|
activeTab?: {
|
|
id: number
|
|
pageId: number
|
|
url: string
|
|
title: string
|
|
}
|
|
enabledMcpServers?: string[]
|
|
}
|
|
}
|
|
expect(createArgs.browserContext?.windowId).toBe(11)
|
|
expect(createArgs.browserContext?.selectedTabs).toBeUndefined()
|
|
expect(createArgs.browserContext?.activeTab).toEqual({
|
|
id: 77,
|
|
pageId: 77,
|
|
url: 'about:blank',
|
|
title: 'Scheduled Task',
|
|
})
|
|
expect(createArgs.browserContext?.enabledMcpServers).toEqual(['slack'])
|
|
})
|
|
|
|
it('deleteSession closes the tracked hidden page', async () => {
|
|
const fakeAgent = createFakeAgent()
|
|
const sessionStore = createSessionStore()
|
|
const browser = {
|
|
closePage: mock(async () => {}),
|
|
}
|
|
const conversationId = crypto.randomUUID()
|
|
|
|
sessionStore.set(conversationId, {
|
|
agent: fakeAgent,
|
|
hiddenPageId: 33,
|
|
})
|
|
|
|
const service = new ChatService({
|
|
sessionStore: sessionStore as never,
|
|
klavisClient: {} as never,
|
|
browser: browser as never,
|
|
registry: {} as never,
|
|
})
|
|
|
|
const result = await service.deleteSession(conversationId)
|
|
|
|
expect(result).toEqual({ deleted: true, sessionCount: 0 })
|
|
expect(browser.closePage).toHaveBeenCalledWith(33)
|
|
expect(fakeAgent.dispose).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('keeps the scheduled hidden page context when metadata lookup fails', async () => {
|
|
const fakeAgent = createFakeAgent()
|
|
agentToReturn = fakeAgent
|
|
streamResponseHandler = async ({ onFinish }) => {
|
|
await onFinish({ messages: fakeAgent.messages })
|
|
return new Response('ok')
|
|
}
|
|
|
|
const browser = {
|
|
newPage: mock(async () => 88),
|
|
listPages: mock(async () => {
|
|
throw new Error('CDP lookup failed')
|
|
}),
|
|
closePage: mock(async () => {}),
|
|
resolveTabIds: mock(async () => new Map<number, number>()),
|
|
}
|
|
const sessionStore = createSessionStore()
|
|
const service = new ChatService({
|
|
sessionStore: sessionStore as never,
|
|
klavisClient: {} as never,
|
|
browser: browser as never,
|
|
registry: {} as never,
|
|
})
|
|
|
|
await service.processMessage(
|
|
{
|
|
conversationId: crypto.randomUUID(),
|
|
message: 'Run the scheduled task',
|
|
isScheduledTask: true,
|
|
mode: 'agent',
|
|
origin: 'sidepanel',
|
|
browserContext: {
|
|
activeTab: {
|
|
id: 3,
|
|
url: 'https://example.com',
|
|
title: 'Example',
|
|
},
|
|
},
|
|
} as never,
|
|
new AbortController().signal,
|
|
)
|
|
|
|
const createArgs = createAgentSpy.mock.calls.at(-1)?.[0] as {
|
|
browserContext?: {
|
|
windowId?: number
|
|
activeTab?: {
|
|
id: number
|
|
pageId: number
|
|
url: string
|
|
title: string
|
|
}
|
|
}
|
|
}
|
|
expect(createArgs.browserContext?.windowId).toBeUndefined()
|
|
expect(createArgs.browserContext?.activeTab).toEqual({
|
|
id: 88,
|
|
pageId: 88,
|
|
url: 'about:blank',
|
|
title: 'Scheduled Task',
|
|
})
|
|
expect(browser.closePage).toHaveBeenCalledWith(88)
|
|
})
|
|
|
|
it('rebuilds an existing session when the LLM provider changes', async () => {
|
|
const firstAgent = createFakeAgent()
|
|
agentToReturn = firstAgent
|
|
streamResponseHandler = async ({ onFinish }) => {
|
|
await onFinish({ messages: agentToReturn?.messages ?? [] })
|
|
return new Response('ok')
|
|
}
|
|
|
|
const browser = {
|
|
resolveTabIds: mock(async () => new Map<number, number>()),
|
|
}
|
|
const sessionStore = createSessionStore()
|
|
const service = new ChatService({
|
|
sessionStore: sessionStore as never,
|
|
klavisClient: {} as never,
|
|
browser: browser as never,
|
|
registry: {} as never,
|
|
})
|
|
const conversationId = crypto.randomUUID()
|
|
const createCallsBefore = createAgentSpy.mock.calls.length
|
|
|
|
await service.processMessage(
|
|
{
|
|
conversationId,
|
|
message: 'First message',
|
|
provider: 'browseros',
|
|
model: 'browseros-auto',
|
|
mode: 'agent',
|
|
origin: 'sidepanel',
|
|
} as never,
|
|
new AbortController().signal,
|
|
)
|
|
|
|
const secondAgent = createFakeAgent()
|
|
agentToReturn = secondAgent
|
|
|
|
await service.processMessage(
|
|
{
|
|
conversationId,
|
|
message: 'Second message',
|
|
provider: 'chatgpt-pro',
|
|
model: 'gpt-5.3-codex',
|
|
mode: 'agent',
|
|
origin: 'sidepanel',
|
|
} as never,
|
|
new AbortController().signal,
|
|
)
|
|
|
|
expect(createAgentSpy.mock.calls.length).toBe(createCallsBefore + 2)
|
|
expect(firstAgent.dispose).toHaveBeenCalledTimes(1)
|
|
expect(sessionStore.get(conversationId)?.agent).toBe(secondAgent)
|
|
|
|
const latestCreateArgs = createAgentSpy.mock.calls.at(-1)?.[0] as {
|
|
resolvedConfig: { provider: string; model: string }
|
|
}
|
|
expect(latestCreateArgs.resolvedConfig).toMatchObject({
|
|
provider: 'chatgpt-pro',
|
|
model: 'gpt-5.3-codex',
|
|
})
|
|
})
|
|
})
|