Merge pull request #543 from alibaba/test/page-agent-core-lifecycle

test(core): add PageAgentCore lifecycle tests
This commit is contained in:
Simon
2026-06-09 20:32:23 +08:00
committed by GitHub
5 changed files with 286 additions and 1 deletions

View File

@@ -48,6 +48,7 @@
"homepage": "https://alibaba.github.io/page-agent/",
"scripts": {
"build": "vite build",
"test": "vitest run",
"dev:iife": "concurrently \"vite build --config vite.iife.config.js --watch\" \"npx serve dist/iife -p 5174\"",
"prepublishOnly": "node ../../scripts/pre-publish.js",
"postpublish": "node ../../scripts/post-publish.js"

View 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' })
})
})
})

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
name: 'core',
include: ['src/**/*.test.ts'],
silent: 'passed-only',
},
})

View File

@@ -3,6 +3,6 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
},
"include": ["**/*.ts"],
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}