test(core): add PageAgentCore lifecycle tests
This commit is contained in:
@@ -48,6 +48,7 @@
|
|||||||
"homepage": "https://alibaba.github.io/page-agent/",
|
"homepage": "https://alibaba.github.io/page-agent/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"test": "vitest run",
|
||||||
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
|
||||||
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
"prepublishOnly": "node ../../scripts/pre-publish.js",
|
||||||
"postpublish": "node ../../scripts/post-publish.js"
|
"postpublish": "node ../../scripts/post-publish.js"
|
||||||
|
|||||||
275
packages/core/src/PageAgentCore.test.ts
Normal file
275
packages/core/src/PageAgentCore.test.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import type { BrowserState, PageController } from '@page-agent/page-controller'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import * as z from 'zod/v4'
|
||||||
|
|
||||||
|
import { PageAgentCore, tool } from './PageAgentCore'
|
||||||
|
import type { ExecutionResult } from './types'
|
||||||
|
|
||||||
|
type TestFetch = (...args: Parameters<typeof globalThis.fetch>) => Promise<Response>
|
||||||
|
|
||||||
|
function agentResponse(args: unknown): Response {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
finish_reason: 'tool_calls',
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
function: {
|
||||||
|
name: 'AgentOutput',
|
||||||
|
arguments: JSON.stringify(args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPageController(): PageController {
|
||||||
|
const browserState: BrowserState = {
|
||||||
|
url: 'https://example.test/',
|
||||||
|
title: 'Test page',
|
||||||
|
header: '',
|
||||||
|
content: '',
|
||||||
|
footer: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showMask: vi.fn(async () => {}),
|
||||||
|
hideMask: vi.fn(),
|
||||||
|
cleanUpHighlights: vi.fn(),
|
||||||
|
getLastUpdateTime: vi.fn(() => Date.now()),
|
||||||
|
getBrowserState: vi.fn(async () => browserState),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
} as unknown as PageController
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgent(
|
||||||
|
customFetch: TestFetch,
|
||||||
|
options: Partial<ConstructorParameters<typeof PageAgentCore>[0]> = {}
|
||||||
|
): PageAgentCore {
|
||||||
|
return new PageAgentCore({
|
||||||
|
baseURL: 'https://llm.test',
|
||||||
|
model: 'test-model',
|
||||||
|
maxRetries: 0,
|
||||||
|
stepDelay: 0,
|
||||||
|
customFetch,
|
||||||
|
customSystemPrompt: 'test',
|
||||||
|
pageController: createPageController(),
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFetchMock() {
|
||||||
|
return vi.fn<TestFetch>()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onceActivity(
|
||||||
|
agent: PageAgentCore,
|
||||||
|
predicate: (detail: unknown) => boolean
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const onActivity = (event: Event) => {
|
||||||
|
if (!predicate((event as CustomEvent).detail)) return
|
||||||
|
agent.removeEventListener('activity', onActivity)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.addEventListener('activity', onActivity)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutingTool(detail: unknown, toolName: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof detail === 'object' &&
|
||||||
|
detail !== null &&
|
||||||
|
'type' in detail &&
|
||||||
|
'tool' in detail &&
|
||||||
|
detail.type === 'executing' &&
|
||||||
|
detail.tool === toolName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doneResponse(text: string, success = true): Response {
|
||||||
|
return agentResponse({ action: { done: { text, success } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitResponse(seconds = 10): Response {
|
||||||
|
return agentResponse({ action: { wait: { seconds } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a task that blocks on `wait`, returning once the tool is executing.
|
||||||
|
* The running promise is wrapped so awaiting this helper does not await the task.
|
||||||
|
*/
|
||||||
|
async function startBlockedTask(
|
||||||
|
agent: PageAgentCore,
|
||||||
|
task = 'first'
|
||||||
|
): Promise<{ result: Promise<ExecutionResult> }> {
|
||||||
|
const waitStarted = onceActivity(agent, (detail) => isExecutingTool(detail, 'wait'))
|
||||||
|
const result = agent.execute(task)
|
||||||
|
await waitStarted
|
||||||
|
return { result }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.concurrent('PageAgentCore lifecycle', () => {
|
||||||
|
describe('normal execution', () => {
|
||||||
|
it('runs a task to natural completion', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(doneResponse('all done'))
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
|
||||||
|
const result = await agent.execute('do something')
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ success: true, data: 'all done' })
|
||||||
|
expect(agent.status).toBe('completed')
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when a task is already running', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
const { result } = await startBlockedTask(agent)
|
||||||
|
|
||||||
|
await expect(agent.execute('second')).rejects.toThrow('A task is already running.')
|
||||||
|
|
||||||
|
agent.stop()
|
||||||
|
await result
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stop', () => {
|
||||||
|
it('aborts the running task and keeps the agent reusable', async () => {
|
||||||
|
const fetchMock = createFetchMock()
|
||||||
|
.mockResolvedValueOnce(waitResponse())
|
||||||
|
.mockResolvedValueOnce(doneResponse('second task'))
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
const { result: firstTask } = await startBlockedTask(agent)
|
||||||
|
|
||||||
|
agent.stop()
|
||||||
|
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
|
||||||
|
const secondTask = await agent.execute('second')
|
||||||
|
expect(secondTask).toMatchObject({ success: true, data: 'second task' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a no-op when no task is running', () => {
|
||||||
|
const agent = createAgent(createFetchMock())
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
agent.stop()
|
||||||
|
agent.stop()
|
||||||
|
}).not.toThrow()
|
||||||
|
expect(agent.status).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispose', () => {
|
||||||
|
it('aborts the running task and blocks further execution', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
const { result: task } = await startBlockedTask(agent)
|
||||||
|
|
||||||
|
agent.dispose()
|
||||||
|
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
|
||||||
|
expect(agent.disposed).toBe(true)
|
||||||
|
await expect(agent.execute('again')).rejects.toThrow('has been disposed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
const agent = createAgent(createFetchMock())
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
agent.dispose()
|
||||||
|
agent.dispose()
|
||||||
|
}).not.toThrow()
|
||||||
|
expect(agent.disposed).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('fails the task when the network request rejects', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockRejectedValue(new Error('network down'))
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
|
||||||
|
const result = await agent.execute('do something')
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(agent.status).toBe('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails the task when a tool throws', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValue(agentResponse({ action: { boom: {} } }))
|
||||||
|
const agent = createAgent(fetchMock, {
|
||||||
|
customTools: {
|
||||||
|
boom: tool({
|
||||||
|
description: 'Always throws.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
throw new Error('tool exploded')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await agent.execute('trigger tool error')
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(agent.status).toBe('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cancellation edge cases', () => {
|
||||||
|
it('rejects a new task while a stopped task is settling', async () => {
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValueOnce(waitResponse())
|
||||||
|
const agent = createAgent(fetchMock)
|
||||||
|
const { result: firstTask } = await startBlockedTask(agent)
|
||||||
|
|
||||||
|
agent.stop()
|
||||||
|
|
||||||
|
await expect(agent.execute('too early')).rejects.toThrow('A task is already running.')
|
||||||
|
await expect(firstTask).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('discards a custom tool result that resolves after stop', async () => {
|
||||||
|
let resolveTool!: () => void
|
||||||
|
let notifyToolStarted!: () => void
|
||||||
|
const toolFinished = new Promise<void>((resolve) => {
|
||||||
|
resolveTool = resolve
|
||||||
|
})
|
||||||
|
const toolStarted = new Promise<void>((resolve) => {
|
||||||
|
notifyToolStarted = resolve
|
||||||
|
})
|
||||||
|
const fetchMock = createFetchMock().mockResolvedValue(
|
||||||
|
agentResponse({ action: { slow_tool: {} } })
|
||||||
|
)
|
||||||
|
const agent = createAgent(fetchMock, {
|
||||||
|
customTools: {
|
||||||
|
slow_tool: tool({
|
||||||
|
description: 'A tool that deliberately ignores cancellation.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
notifyToolStarted()
|
||||||
|
await toolFinished
|
||||||
|
return 'ignored stop'
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const task = agent.execute('run slow tool')
|
||||||
|
await toolStarted
|
||||||
|
|
||||||
|
agent.stop()
|
||||||
|
resolveTool()
|
||||||
|
|
||||||
|
await expect(task).resolves.toMatchObject({ success: false, data: 'Task aborted' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
9
packages/core/vitest.config.js
Normal file
9
packages/core/vitest.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
name: 'core',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
silent: 'passed-only',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user