feat: unify research in profile-bound decks with TweetDeck-style UI

This commit is contained in:
2026-09-24 15:59:54 +09:00
parent da8f52e605
commit d2cbf4dbd3
90 changed files with 2617 additions and 6934 deletions
+178 -259
View File
@@ -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)
})