feat: prototype WebMCP reader tools
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect, test } from '../fixtures'
|
||||
import { SearchPage } from '../models/SearchPage'
|
||||
|
||||
type NativeTesting = {
|
||||
listTools(): { name: string }[]
|
||||
executeTool(name: string, input: string): Promise<string | null>
|
||||
}
|
||||
|
||||
type ToolResult = {
|
||||
content: { type: string; text: string }[]
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
type FeedResult = {
|
||||
request: { kind: string; query: string; product: string; following: boolean }
|
||||
posts: { id: string; text: string; url: string }[]
|
||||
loadedCount: number
|
||||
offset: number
|
||||
nextOffset: number | null
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
test.use({
|
||||
launchOptions: {
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
|
||||
args: ['--enable-blink-features=WebMCP,WebMCPTesting'],
|
||||
},
|
||||
})
|
||||
|
||||
async function toolNames(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
(
|
||||
navigator as Navigator & { modelContextTesting: NativeTesting }
|
||||
).modelContextTesting
|
||||
.listTools()
|
||||
.map((tool) => tool.name)
|
||||
.sort(),
|
||||
)
|
||||
}
|
||||
|
||||
async function executeTool(
|
||||
page: Page,
|
||||
name: string,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<ToolResult> {
|
||||
const result = await page.evaluate(
|
||||
({ name, input }) =>
|
||||
(
|
||||
navigator as Navigator & { modelContextTesting: NativeTesting }
|
||||
).modelContextTesting.executeTool(name, JSON.stringify(input)),
|
||||
{ name, input },
|
||||
)
|
||||
expect(result, `${name} returned a result`).not.toBeNull()
|
||||
if (result === null) throw new Error(`${name} returned no result`)
|
||||
return JSON.parse(result)
|
||||
}
|
||||
|
||||
async function executeFeedTool(
|
||||
page: Page,
|
||||
name: string,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<FeedResult> {
|
||||
const result = await executeTool(page, name, input)
|
||||
expect(result.isError, JSON.stringify(result.content)).not.toBe(true)
|
||||
expect(result.content[0]?.type).toBe('text')
|
||||
return JSON.parse(result.content[0]?.text ?? '')
|
||||
}
|
||||
|
||||
let pageErrors: string[]
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
pageErrors = []
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
if (testInfo.title === 'joins pagination already started by scrolling') return
|
||||
// Keep pagination under the tool's control without replacing WebMCP itself.
|
||||
await page.addInitScript(() => {
|
||||
window.IntersectionObserver = class {
|
||||
readonly root = null
|
||||
readonly rootMargin = '0px'
|
||||
readonly scrollMargin = '0px'
|
||||
readonly thresholds = [0]
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
expect(pageErrors, 'uncaught browser errors').toEqual([])
|
||||
})
|
||||
|
||||
test('searches through native WebMCP and keeps results synchronized with the UI', async ({
|
||||
homePage,
|
||||
page,
|
||||
searchPage,
|
||||
}) => {
|
||||
await homePage.goTo()
|
||||
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
|
||||
|
||||
const first = await executeFeedTool(page, 'search_posts', {
|
||||
q: 'AI',
|
||||
product: 'Top',
|
||||
following: true,
|
||||
})
|
||||
|
||||
expect(first.request).toMatchObject({
|
||||
kind: 'search',
|
||||
query: 'AI filter:follows',
|
||||
product: 'Top',
|
||||
following: true,
|
||||
})
|
||||
expect(first.posts.map((post) => post.text)).toEqual(['Top · follows page 1'])
|
||||
expect(first).toMatchObject({ loadedCount: 1, offset: 0, hasMore: true })
|
||||
await expect(page).toHaveURL(/\/search\?/)
|
||||
await expect(searchPage.queryInputLocator).toHaveValue('AI')
|
||||
await expect(searchPage.topRankingLocator).toBeChecked()
|
||||
await expect(searchPage.followingOnlyLocator).toBeChecked()
|
||||
await expect(
|
||||
page.getByText(first.posts[0]?.text ?? '', { exact: true }),
|
||||
).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => toolNames(page))
|
||||
.toEqual(['get_loaded_posts', 'load_more_posts', 'search_posts'])
|
||||
const more = await executeFeedTool(page, 'load_more_posts')
|
||||
expect(more.posts.map((post) => post.text)).toEqual(['Top · follows page 2'])
|
||||
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
await expect(searchPage.articlesLocator).toHaveCount(2)
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
|
||||
const slice = await executeFeedTool(page, 'get_loaded_posts', {
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
})
|
||||
expect(slice.posts).toEqual(more.posts)
|
||||
expect(slice).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
|
||||
const end = await executeFeedTool(page, 'load_more_posts')
|
||||
expect(end).toMatchObject({ posts: [], loadedCount: 2, hasMore: false })
|
||||
await expect(searchPage.articlesLocator).toHaveCount(2)
|
||||
|
||||
// SPA navigation must unregister the old feed's tools in the same document.
|
||||
await page.getByRole('link', { name: 'ユーザー', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
|
||||
})
|
||||
|
||||
test('rejects invalid search conditions without navigating or dropping the active feed', async ({
|
||||
homePage,
|
||||
page,
|
||||
searchPage,
|
||||
}) => {
|
||||
await homePage.goTo()
|
||||
await expect.poll(() => toolNames(page)).toContain('search_posts')
|
||||
const first = await executeFeedTool(page, 'search_posts', { q: 'existing' })
|
||||
const previousURL = page.url()
|
||||
const result = await executeTool(page, 'search_posts', {
|
||||
q: 'invalid dates',
|
||||
since: '2026-09-24',
|
||||
until: '2026-09-23',
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]?.type).toBe('text')
|
||||
expect(() => JSON.parse(result.content[0]?.text ?? '')).not.toThrow()
|
||||
await expect(page).toHaveURL(previousURL)
|
||||
await expect(searchPage.queryInputLocator).toHaveValue('existing')
|
||||
const current = await executeFeedTool(page, 'get_loaded_posts')
|
||||
expect(current.posts).toEqual(first.posts)
|
||||
})
|
||||
|
||||
test('retries a failed continuation without losing the first page', async ({
|
||||
homePage,
|
||||
page,
|
||||
searchPage,
|
||||
}, testInfo) => {
|
||||
await homePage.goTo()
|
||||
await expect.poll(() => toolNames(page)).toContain('search_posts')
|
||||
const first = await executeFeedTool(page, 'search_posts', {
|
||||
q: `retry-webmcp-${testInfo.project.name}`,
|
||||
})
|
||||
const failed = await executeTool(page, 'load_more_posts')
|
||||
expect(failed.isError, JSON.stringify(failed.content)).toBe(true)
|
||||
await expect(searchPage.continuationErrorLocator).toBeVisible()
|
||||
const retained = await executeFeedTool(page, 'get_loaded_posts')
|
||||
expect(retained.posts).toEqual(first.posts)
|
||||
|
||||
const recovered = await executeFeedTool(page, 'load_more_posts')
|
||||
expect(recovered.posts.map((post) => post.text)).toEqual([
|
||||
'Latest · all page 2',
|
||||
])
|
||||
expect(recovered).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
await expect(searchPage.continuationErrorLocator).toHaveCount(0)
|
||||
await expect(searchPage.articlesLocator).toHaveCount(2)
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('joins pagination already started by scrolling', async ({
|
||||
homePage,
|
||||
page,
|
||||
searchPage,
|
||||
}, testInfo) => {
|
||||
await homePage.goTo()
|
||||
await expect.poll(() => toolNames(page)).toContain('search_posts')
|
||||
await executeFeedTool(page, 'search_posts', {
|
||||
q: `slow-webmcp-${testInfo.project.name}`,
|
||||
})
|
||||
await expect(searchPage.loadingRailLocator).toBeVisible()
|
||||
|
||||
const more = await executeFeedTool(page, 'load_more_posts')
|
||||
expect(more.posts.map((post) => post.text)).toEqual(['Latest · all page 2'])
|
||||
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
await expect(searchPage.articlesLocator).toHaveCount(2)
|
||||
await expect(searchPage.endOfFeedLocator).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the normal search form functional without native WebMCP', async ({
|
||||
playwright,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
|
||||
args: ['--disable-blink-features=WebMCP,WebMCPTesting'],
|
||||
})
|
||||
const page = await browser.newPage({ baseURL })
|
||||
const searchPage = new SearchPage(page)
|
||||
try {
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
await searchPage.goTo()
|
||||
expect(await page.evaluate(() => Boolean(document.modelContext))).toBe(
|
||||
false,
|
||||
)
|
||||
await searchPage.search('ordinary search')
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
await expect(page.getByText('Latest · all page 2')).toBeVisible()
|
||||
} catch (error) {
|
||||
await testInfo.attach('unsupported-browser-state', {
|
||||
body: JSON.stringify({ url: page.url(), html: await page.content() }),
|
||||
contentType: 'application/json',
|
||||
})
|
||||
await testInfo.attach('unsupported-browser-screen', {
|
||||
body: await page.screenshot(),
|
||||
contentType: 'image/png',
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user