feat: unify research in profile-bound decks with TweetDeck-style UI
This commit is contained in:
+178
-259
@@ -1,26 +1,15 @@
|
||||
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,
|
||||
@@ -38,7 +27,6 @@ async function toolNames(page: Page) {
|
||||
.sort(),
|
||||
)
|
||||
}
|
||||
|
||||
async function executeTool(
|
||||
page: Page,
|
||||
name: string,
|
||||
@@ -55,290 +43,139 @@ async function executeTool(
|
||||
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 ?? '')
|
||||
}
|
||||
const column = (title: string, profileName = 'e2e') => ({
|
||||
title,
|
||||
profileName,
|
||||
source: { kind: 'search', query: 'WebMCP' },
|
||||
})
|
||||
|
||||
let pageErrors: string[]
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
test('creates a deck through native WebMCP, edits it, and restores its saved definition', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/deck')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect
|
||||
.poll(() => toolNames(page))
|
||||
.toEqual(['get_deck', 'search_posts', 'set_deck'])
|
||||
const result = await executeTool(page, 'set_deck', {
|
||||
.toEqual([
|
||||
'delete_deck',
|
||||
'get_column_posts',
|
||||
'get_deck',
|
||||
'list_decks',
|
||||
'load_more_column',
|
||||
'select_deck',
|
||||
'set_deck',
|
||||
])
|
||||
})
|
||||
test.afterEach(() => expect(pageErrors, 'uncaught browser errors').toEqual([]))
|
||||
|
||||
test('creates, replaces, switches and deletes persisted decks through native WebMCP', async ({
|
||||
page,
|
||||
}) => {
|
||||
const inventory = await executeTool(page, 'list_decks')
|
||||
expect(JSON.parse(inventory.content[0]?.text ?? '').profiles).toEqual([
|
||||
'e2e',
|
||||
'alternate',
|
||||
])
|
||||
const created = await executeTool(page, 'set_deck', {
|
||||
title: 'WebMCPの反応',
|
||||
columns: [
|
||||
{ id: 'new', title: '日本語', source: { query: 'WebMCP lang:ja' } },
|
||||
{ title: '話題', source: { query: 'WebMCP', product: 'Top' } },
|
||||
{ ...column('日本語'), id: 'new' },
|
||||
column('別の接続', 'alternate'),
|
||||
],
|
||||
})
|
||||
expect(result.isError).not.toBe(true)
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['日本語', '話題'])
|
||||
await expect(page.getByText('Latest · all page 1')).toBeVisible()
|
||||
await expect(page.getByText('Top · all page 1')).toBeVisible()
|
||||
const before = await executeTool(page, 'get_deck')
|
||||
const { deck } = JSON.parse(before.content[0]?.text ?? '')
|
||||
expect(deck.columns[0].id).toBe('new')
|
||||
// Tool-provided IDs must not collide with the UI's new-column editing state.
|
||||
expect(created.isError).not.toBe(true)
|
||||
const { deck } = JSON.parse(created.content[0]?.text ?? '')
|
||||
await expect(page.locator('.deck-column h2')).toHaveText([
|
||||
'日本語',
|
||||
'別の接続',
|
||||
])
|
||||
await expect(
|
||||
page.getByText('alternate · Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
await page
|
||||
.getByRole('region', { name: '日本語', exact: true })
|
||||
.locator('summary[aria-label="日本語の操作"]')
|
||||
.click()
|
||||
await page
|
||||
.getByRole('region', { name: '日本語', exact: true })
|
||||
.getByRole('button', { name: '編集', exact: true })
|
||||
.click()
|
||||
await expect(page.getByLabel('Twitterの検索条件')).toHaveValue(
|
||||
'WebMCP lang:ja',
|
||||
)
|
||||
const edited = await executeTool(page, 'set_deck', {
|
||||
await expect(page.getByLabel('Twitterの検索条件')).toHaveValue('WebMCP')
|
||||
const replaced = await executeTool(page, 'set_deck', {
|
||||
deckId: deck.id,
|
||||
title: '更新した調査',
|
||||
columns: [deck.columns[1]],
|
||||
})
|
||||
expect(edited.isError).not.toBe(true)
|
||||
expect(replaced.isError).not.toBe(true)
|
||||
await expect(page.getByRole('form', { name: 'カラムを編集' })).toHaveCount(0)
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['話題'])
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['別の接続'])
|
||||
const another = await executeTool(page, 'set_deck', {
|
||||
title: '別の調査',
|
||||
columns: [],
|
||||
})
|
||||
const anotherId = JSON.parse(another.content[0]?.text ?? '').deck.id
|
||||
expect(
|
||||
(await executeTool(page, 'select_deck', { deckId: deck.id })).isError,
|
||||
).not.toBe(true)
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
||||
'更新した調査',
|
||||
)
|
||||
await expect.poll(() => toolNames(page)).toContain('get_deck')
|
||||
const restored = await executeTool(page, 'get_deck')
|
||||
expect(JSON.parse(restored.content[0]?.text ?? '').deck.columns[0].id).toBe(
|
||||
deck.columns[1].id,
|
||||
const restored = JSON.parse(
|
||||
(await executeTool(page, 'get_deck')).content[0]?.text ?? '',
|
||||
).deck
|
||||
expect(restored).toEqual({
|
||||
...deck,
|
||||
title: '更新した調査',
|
||||
columns: [deck.columns[1]],
|
||||
})
|
||||
expect(
|
||||
(await executeTool(page, 'delete_deck', { deckId: anotherId })).isError,
|
||||
).not.toBe(true)
|
||||
const after = JSON.parse(
|
||||
(await executeTool(page, 'list_decks')).content[0]?.text ?? '',
|
||||
)
|
||||
await page.getByRole('link', { name: 'ユーザー', exact: true }).click()
|
||||
await expect.poll(() => toolNames(page)).toEqual(['search_posts'])
|
||||
expect(
|
||||
after.decks.some((item: { id: string }) => item.id === anotherId),
|
||||
).toBe(false)
|
||||
expect(after.activeDeckId).toBe(deck.id)
|
||||
})
|
||||
|
||||
test('rejects an invalid deck without changing the visible or saved columns', async ({
|
||||
test('rejects duplicate IDs, unavailable profiles and invalid sources without mutations', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/deck')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect.poll(() => toolNames(page)).toContain('set_deck')
|
||||
await executeTool(page, 'set_deck', {
|
||||
title: '残す調査',
|
||||
columns: [{ title: '残す観点', source: { query: 'AI' } }],
|
||||
columns: [column('残す観点')],
|
||||
})
|
||||
const saved = await page.evaluate(() =>
|
||||
localStorage.getItem('twitter-lite-research-deck'),
|
||||
)
|
||||
const column = { id: 'duplicate', title: '不正', source: { query: 'AI' } }
|
||||
const result = await executeTool(page, 'set_deck', {
|
||||
title: '変更しない',
|
||||
columns: [column, column],
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['残す観点'])
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('twitter-lite-research-deck'),
|
||||
),
|
||||
).toBe(saved)
|
||||
const duplicate = { ...column('不正'), id: 'duplicate' }
|
||||
for (const columns of [
|
||||
[duplicate, duplicate],
|
||||
[column('不明', 'missing')],
|
||||
[{ ...column('不正'), source: { kind: 'list', target: 'not-a-list' } }],
|
||||
]) {
|
||||
expect(
|
||||
(await executeTool(page, 'set_deck', { title: '変更しない', columns }))
|
||||
.isError,
|
||||
).toBe(true)
|
||||
await expect(page.locator('.deck-column h2')).toHaveText(['残す観点'])
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
localStorage.getItem('twitter-lite-research-deck'),
|
||||
),
|
||||
).toBe(saved)
|
||||
}
|
||||
})
|
||||
|
||||
test('reports a deck persistence failure instead of claiming it was saved', async ({
|
||||
test('reports persistence failure while keeping the applied deck visible', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/deck')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect.poll(() => toolNames(page)).toContain('set_deck')
|
||||
await page.evaluate(() => {
|
||||
Storage.prototype.setItem = () => {
|
||||
throw new Error('QuotaExceededError')
|
||||
const setItem = Storage.prototype.setItem
|
||||
Storage.prototype.setItem = function (key, value) {
|
||||
if (this === localStorage) throw new Error('QuotaExceededError')
|
||||
setItem.call(this, key, value)
|
||||
}
|
||||
})
|
||||
const result = await executeTool(page, 'set_deck', {
|
||||
@@ -354,3 +191,85 @@ test('reports a deck persistence failure instead of claiming it was saved', asyn
|
||||
)
|
||||
await expect(page.getByRole('alert')).toContainText('保存できません')
|
||||
})
|
||||
|
||||
test('keeps manual deck creation functional when WebMCP is disabled', async ({
|
||||
playwright,
|
||||
baseURL,
|
||||
}) => {
|
||||
const browser = await playwright.chromium.launch({
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
|
||||
args: ['--disable-blink-features=WebMCP,WebMCPTesting'],
|
||||
})
|
||||
try {
|
||||
const page = await browser.newPage({ baseURL })
|
||||
await page.goto('/')
|
||||
await page
|
||||
.getByRole('button', { name: 'カラムを追加', exact: true })
|
||||
.click()
|
||||
await page.getByLabel('カラム名', { exact: true }).fill('手動で調査')
|
||||
await page
|
||||
.getByRole('combobox', { name: '接続プロファイル', exact: true })
|
||||
.selectOption('e2e')
|
||||
await page.getByLabel('Twitterの検索条件').fill('ordinary-search')
|
||||
await page.getByRole('button', { name: '追加して検索' }).click()
|
||||
await expect(
|
||||
page.getByText('Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('reads and pages the bound column profile without mixing sibling feeds', async ({
|
||||
page,
|
||||
}) => {
|
||||
const result = await executeTool(page, 'set_deck', {
|
||||
title: '接続を比較',
|
||||
columns: [column('通常'), column('別の接続', 'alternate')],
|
||||
})
|
||||
const { deck } = JSON.parse(result.content[0]?.text ?? '')
|
||||
await expect(
|
||||
page.getByText('alternate · Latest · all page 1', { exact: true }),
|
||||
).toBeVisible()
|
||||
const boundId = deck.columns[1].id
|
||||
const loaded = JSON.parse(
|
||||
(await executeTool(page, 'get_column_posts', { columnId: boundId }))
|
||||
.content[0]?.text ?? '',
|
||||
)
|
||||
expect(loaded.column.profileName).toBe('alternate')
|
||||
expect(loaded.posts.map((post: { text: string }) => post.text)).toEqual([
|
||||
'alternate · Latest · all page 1',
|
||||
])
|
||||
const moreResult = await executeTool(page, 'load_more_column', {
|
||||
columnId: boundId,
|
||||
})
|
||||
expect(moreResult.isError).not.toBe(true)
|
||||
const more = JSON.parse(moreResult.content[0]?.text ?? '')
|
||||
expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false })
|
||||
expect(more.posts.map((post: { text: string }) => post.text)).toEqual([
|
||||
'alternate · Latest · all page 2',
|
||||
])
|
||||
await expect(
|
||||
page.getByRole('region', { name: '通常', exact: true }).locator('article'),
|
||||
).toHaveCount(1)
|
||||
await expect(
|
||||
page
|
||||
.getByRole('region', { name: '別の接続', exact: true })
|
||||
.locator('article'),
|
||||
).toHaveCount(2)
|
||||
const slice = JSON.parse(
|
||||
(
|
||||
await executeTool(page, 'get_column_posts', {
|
||||
columnId: boundId,
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
})
|
||||
).content[0]?.text ?? '',
|
||||
)
|
||||
expect(slice.posts).toEqual(more.posts)
|
||||
await executeTool(page, 'set_deck', { title: '別デッキ', columns: [] })
|
||||
expect(
|
||||
(await executeTool(page, 'get_column_posts', { columnId: boundId }))
|
||||
.isError,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user