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
+174 -100
View File
@@ -5,13 +5,28 @@ async function addColumn(
page: Page,
title: string,
query: string,
top = false,
profile = 'e2e',
kind = 'search',
) {
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true })
await editor.getByLabel('カラム名', { exact: true }).fill(title)
await editor.getByLabel('Twitterの検索条件').fill(query)
if (top) await editor.getByLabel('表示順').selectOption('Top')
await editor
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption(profile)
await editor
.getByRole('combobox', { name: 'カラムの種類', exact: true })
.selectOption(kind)
await editor
.getByLabel(
kind === 'search'
? 'Twitterの検索条件'
: kind === 'user'
? 'ユーザー'
: 'リスト',
{ exact: true },
)
.fill(query)
await editor.getByRole('button', { name: '追加して検索' }).click()
await expect(
page
@@ -22,63 +37,114 @@ async function addColumn(
}
test.beforeEach(async ({ page }) => {
await page.goto('/deck')
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('creates independent columns, edits, reorders, and restores saved conditions', async ({
test('binds identical searches to independent profiles through paging, editing and reload', async ({
page,
}) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await page.getByRole('button', { name: '名前を変更' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('WebMCPの反応')
await page.getByRole('button', { name: '名前を保存' }).click()
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
await addColumn(page, '話題の投稿', 'WebMCP', true)
const first = page.getByRole('region', { name: '日本語の反応', exact: true })
const second = page.getByRole('region', { name: '話題の投稿', exact: true })
await addColumn(page, '通常の接続', 'same-query')
await addColumn(page, '別の接続', 'same-query', 'alternate')
const first = page.getByRole('region', { name: '通常の接続', exact: true })
const second = page.getByRole('region', { name: '別の接続', exact: true })
await expect(
first.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(
second.getByText('alternate · Latest · all page 1', { exact: true }),
).toBeVisible()
await first.getByRole('button', { name: '続きを読み込む' }).click()
await expect(first.locator('article')).toHaveCount(2)
await expect(second.locator('article')).toHaveCount(1)
await second.getByRole('button', { name: '話題の投稿を左へ' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'話題の投稿',
'日本語の反応',
])
await second.getByRole('button', { name: '続きを読み込む' }).click()
await expect(
second.getByText('alternate · Latest · all page 2'),
).toBeVisible()
await second.locator('summary[aria-label="別の接続の操作"]').click()
await second.getByRole('button', { name: '編集', exact: true }).click()
await page
.getByRole('form', { name: 'カラムを編集' })
.getByLabel('カラム名', { exact: true })
.fill('公式の投稿')
await page.getByLabel('Twitterの検索条件').fill('from:OpenAI')
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption('e2e')
await page.getByRole('button', { name: '変更を保存' }).click()
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'WebMCPの反応',
await expect(
second.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(second.getByText('alternate · Latest · all page 1')).toHaveCount(
0,
)
await expect(page.locator('.deck-column h2')).toHaveText([
'公式の投稿',
'日本語の反応',
])
const official = page.getByRole('region', { name: '公式の投稿', exact: true })
await expect(official.locator('.deck-query')).toHaveText('from:OpenAI')
await expect(official.locator('article')).toHaveCount(1)
await official.getByRole('button', { name: '公式の投稿を削除' }).click()
await expect(page.locator('.deck-column')).toHaveCount(1)
await page.getByRole('button', { name: '元に戻す' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'公式の投稿',
'日本語の反応',
])
expect(errors).toEqual([])
await page.reload()
await expect(
second.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
await expect(first.locator('article')).toHaveCount(1)
})
test('keeps other columns usable when pagination fails and retries only that column', async ({
test('creates, switches, renames and deletes persisted deck profiles', async ({
page,
}) => {
await page.getByRole('button', { name: '名前を変更' }).click()
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査')
await page.getByRole('button', { name: '名前を保存' }).click()
await addColumn(page, '最初の観点', 'first-deck')
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('別の調査')
await page.getByRole('button', { name: '作成', exact: true }).click()
await expect(page.locator('.deck-column')).toHaveCount(0)
await addColumn(page, '別の観点', 'second-deck', 'alternate')
await page
.getByLabel('デッキプロファイル', { exact: true })
.selectOption({ label: '最初の調査' })
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await page
.getByLabel('デッキプロファイル', { exact: true })
.selectOption({ label: '別の調査' })
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
await page.getByRole('button', { name: 'デッキを削除', exact: true }).click()
await page.getByRole('button', { name: '削除する', exact: true }).click()
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await expect(
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'),
).toHaveCount(1)
})
test('reads user and list timelines within columns and reorders, deletes and restores them', async ({
page,
}) => {
await addColumn(page, 'アカウント', 'yuta', 'alternate', 'user')
await addColumn(page, 'リストの投稿', '1234567890', 'e2e', 'list')
const user = page.getByRole('region', { name: 'アカウント', exact: true })
const list = page.getByRole('region', { name: 'リストの投稿', exact: true })
await expect(user.getByText('alternate profile page 1')).toBeVisible()
await expect(list.getByText('list page 1')).toBeVisible()
await list.getByRole('button', { name: '続きを読み込む' }).click()
await expect(list.getByText('list page 2')).toBeVisible()
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
await list.getByRole('button', { name: 'リストの投稿を左へ' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿',
'アカウント',
])
await list.locator('summary[aria-label="リストの投稿の操作"]').click()
await list.getByRole('button', { name: 'リストの投稿を削除' }).click()
await page.getByRole('button', { name: '元に戻す' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿',
'アカウント',
])
await page.reload()
await expect(user.getByText('alternate profile page 1')).toBeVisible()
await expect(list.getByText('list page 1')).toBeVisible()
})
test('keeps other columns usable when pagination fails and retries that column', async ({
page,
}, testInfo) => {
await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`)
await addColumn(page, '別の検索', 'independent-deck', true)
await addColumn(page, '別の検索', 'independent-deck')
const failed = page.getByRole('region', { name: '失敗する検索', exact: true })
const other = page.getByRole('region', { name: '別の検索', exact: true })
await failed.getByRole('button', { name: '続きを読み込む' }).click()
@@ -88,15 +154,15 @@ test('keeps other columns usable when pagination fails and retries only that col
await expect(other.locator('article')).toHaveCount(2)
await failed.getByRole('button', { name: '再試行' }).click()
await expect(failed.locator('article')).toHaveCount(2)
await expect(failed.getByRole('alert')).toHaveCount(0)
})
test('keeps columns within narrow viewports and accessible', async ({
test('keeps the deck and editor accessible within narrow viewports', async ({
page,
a11y,
}, testInfo) => {
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
await addColumn(page, '海外の反応', 'WebMCP lang:en', true)
await addColumn(page, '海外の反応', 'WebMCP lang:en', 'alternate')
await addColumn(page, '公式アカウント', 'OpenAI', 'e2e', 'user')
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 })
expect(
@@ -104,16 +170,36 @@ test('keeps columns within narrow viewports and accessible', async ({
() => document.documentElement.scrollWidth > innerWidth,
),
).toBe(false)
expect(
await page.evaluate(
() => document.documentElement.scrollHeight <= innerHeight,
),
).toBe(true)
const board = await page.locator('.deck-board').boundingBox()
expect(board).not.toBeNull()
expect((board?.y ?? 0) + (board?.height ?? 0)).toBeLessThanOrEqual(901)
}
await page.setViewportSize({
width: testInfo.project.name === 'desktop' ? 1440 : 393,
height: 900,
})
await expect(
page
.getByRole('region', { name: '日本語の反応', exact: true })
.locator('article'),
).toHaveCount(1)
if (testInfo.project.name === 'desktop') {
await page
.getByRole('navigation', { name: 'カラム一覧' })
.getByRole('button', { name: '公式アカウント' })
.click()
await expect(
page
.getByRole('region', { name: '公式アカウント', exact: true })
.locator('.deck-column-results'),
).toBeFocused()
}
expect((await a11y().analyze()).violations).toEqual([])
await page.screenshot({
path: testInfo.outputPath('deck-populated.png'),
fullPage: true,
})
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
expect((await a11y().analyze()).violations).toEqual([])
await page.screenshot({
path: testInfo.outputPath('deck.png'),
@@ -121,9 +207,22 @@ test('keeps columns within narrow viewports and accessible', async ({
})
})
test('does not overwrite an unreadable saved deck on reload', async ({
test('closes the column editor with Escape and restores focus without applying edits', async ({
page,
}) => {
const opener = page.getByRole('button', { name: 'カラムを追加', exact: true })
await opener.click()
await expect(page.getByRole('dialog')).toBeVisible()
await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点')
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
await expect(opener).toBeFocused()
await expect(page.locator('.deck-column')).toHaveCount(0)
await opener.click()
await expect(page.getByLabel('カラム名', { exact: true })).toHaveValue('')
})
test('does not overwrite unreadable storage on reload', async ({ page }) => {
await page.evaluate(() =>
localStorage.setItem('twitter-lite-research-deck', '{broken'),
)
@@ -136,58 +235,33 @@ test('does not overwrite an unreadable saved deck on reload', async ({
).toBe('{broken')
})
test('keeps the six-column limit when undo and add overlap', async ({
test('selects a list from the column profile catalog and refreshes it on profile change', async ({
page,
}) => {
await page.evaluate(() =>
localStorage.setItem(
'twitter-lite-research-deck',
JSON.stringify({
version: 1,
title: '上限の確認',
columns: Array.from({ length: 6 }, (_, index) => ({
id: `column-${index}`,
title: `観点${index}`,
source: {
platform: 'twitter',
query: `limit-${index}`,
product: 'Latest',
following: false,
},
})),
}),
),
)
await page.reload()
await page.waitForLoadState('networkidle')
await expect(
page.getByRole('button', { name: 'カラムを追加', exact: true }),
).toBeDisabled()
await page.getByRole('button', { name: '観点0を削除' }).click()
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled()
await page.getByLabel('カラム名', { exact: true }).fill('追加の観点')
await page.getByLabel('Twitterの検索条件').fill('new-limit')
await page.getByRole('button', { name: '追加して検索' }).click()
await expect(page.locator('.deck-column')).toHaveCount(6)
await expect(page.getByRole('button', { name: '元に戻す' })).toBeDisabled()
await page.reload()
await expect(page.locator('.deck-column')).toHaveCount(6)
})
test('creates columns without the secure-context-only randomUUID API', async ({
page,
}) => {
await page.addInitScript(() =>
Object.defineProperty(crypto, 'randomUUID', { value: undefined }),
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
await page
.getByRole('combobox', { name: 'カラムの種類', exact: true })
.selectOption('list')
await expect(
page.getByRole('button', { name: 'My E2E List', exact: true }),
).toBeVisible()
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption('alternate')
await expect(
page.getByRole('button', { name: 'My E2E List', exact: true }),
).toHaveCount(0)
await page
.getByRole('button', { name: 'Alternate List', exact: true })
.click()
await expect(page.getByLabel('リスト', { exact: true })).toHaveValue(
'1234567890',
)
await page.reload()
await page.waitForLoadState('networkidle')
await addColumn(page, 'HTTP接続からの調査', 'http-deck')
await page.reload()
await page.getByRole('button', { name: '追加して検索' }).click()
await expect(
page
.getByRole('region', { name: 'HTTP接続からの調査', exact: true })
.locator('article'),
).toHaveCount(1)
.getByRole('region', { name: '購読リスト', exact: true })
.getByText('list page 1', { exact: true }),
).toBeVisible()
})
-241
View File
@@ -1,241 +0,0 @@
import { expect, test } from '../fixtures'
let consoleErrors: string[]
let pageErrors: string[]
test.beforeEach(async ({ page }) => {
consoleErrors = []
pageErrors = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => pageErrors.push(error.message))
})
test.afterEach(() => {
expect(consoleErrors, 'browser console errors').toEqual([])
expect(pageErrors, 'uncaught browser errors').toEqual([])
})
test('requires intent and infinitely loads a user timeline', async ({
homePage,
page,
userPage,
}) => {
await homePage.goTo()
await expect(page).toHaveURL(/\/$/)
await expect(page.locator('meta[name="referrer"]')).toHaveAttribute(
'content',
'no-referrer',
)
await expect(homePage.articlesLocator).toHaveCount(0)
await expect(homePage.ambientLinksLocator).toHaveCount(0)
await homePage.displayUser('@yuta')
await expect(page).toHaveURL(/\/yuta$/)
await expect(page.getByText('user page 1')).toBeVisible()
await expect(page.getByText('user page 2')).toBeVisible()
await expect(userPage.endOfFeedLocator).toBeVisible()
await expect(page).toHaveScreenshot('mist-user.png', {
animations: 'disabled',
fullPage: true,
})
})
test('switches the relay profile and reloads visible content', async ({
homePage,
page,
}) => {
await homePage.goTo()
const profile = page.getByLabel('プロファイル')
await expect(profile).toHaveValue('e2e')
await homePage.displayUser('@yuta')
await expect(page.getByText('user page 1')).toBeVisible()
await profile.selectOption('alternate')
await expect(page.getByRole('status')).toHaveText('切り替えました。')
await expect(page.getByText('alternate profile page 1')).toBeVisible()
})
test('keeps the profile selector within narrow viewports', async ({
homePage,
page,
}) => {
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 })
await homePage.goTo()
await expect(page.getByLabel('プロファイル')).toBeVisible()
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
),
).toBe(false)
}
})
test('selects an authenticated list and infinitely loads its timeline', async ({
listsPage,
page,
}) => {
await listsPage.goTo()
await expect(listsPage.articlesLocator).toHaveCount(0)
await listsPage.openList(/My E2E List.*10 メンバー/)
await expect(page).toHaveURL(/\/i\/lists\/1234567890$/)
await expect(page.getByText('list page 1')).toBeVisible()
await expect(page.getByText('list page 2')).toBeVisible()
await expect(listsPage.endOfFeedLocator).toBeVisible()
})
test('opens a card detail and infinitely loads its conversation', async ({
page,
threadPage,
userPage,
}) => {
await userPage.goTo('yuta')
await userPage.openPostDetails('user page 1')
await expect(page).toHaveURL(/\/yuta\/status\/1001$/)
await expect(threadPage.headingLocator).toBeVisible()
await expect(threadPage.selectedPostLabelLocator).toBeVisible()
await expect(page.getByText('user page 1')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(page.getByText('thread page 1')).toBeVisible()
await expect(page.getByText('thread page 2')).toBeVisible()
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(page).toHaveScreenshot('mist-thread.png', {
animations: 'disabled',
fullPage: true,
})
})
test('resolves a reply root and retries only the failed continuation', async ({
page,
threadPage,
}) => {
await threadPage.goTo('yuta', '2001')
await expect(page.getByText('retry focal')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(page.getByText('thread page 1')).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(1)
await expect(page.getByText('retry focal')).toHaveCount(1)
await threadPage.retryContinuation()
await expect(page.getByText('thread page 2')).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(threadPage.mutationButtonsLocator).toHaveCount(0)
})
test('ends a thread without loading a continuation when there are no replies', async ({
page,
threadPage,
}) => {
await threadPage.goTo('yuta', '3001')
await expect(page.getByText('no replies')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible()
await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
})
test('searches Top posts from followed accounts', async ({
page,
searchPage,
}) => {
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.searchTopFromFollowing('AI lang:ja')
await expect(searchPage.followingOnlyLocator).toBeChecked()
await expect(page).toHaveURL(/following=true/)
await expect(page.getByText('Top · follows page 1')).toBeVisible()
await expect(page.getByText('Top · follows page 2')).toBeVisible()
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
test('retains the first page through a pagination error and retries explicitly', async ({
page,
searchPage,
}, testInfo) => {
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.search(`retry-${testInfo.project.name}`)
await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(searchPage.continuationErrorLocator).toHaveCount(1)
await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(page.getByText('Latest · all page 2')).toHaveCount(0)
await searchPage.retryContinuation()
await expect(page.getByText('Latest · all page 2')).toBeVisible()
await expect(searchPage.continuationErrorLocator).toHaveCount(0)
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
searchPage,
}) => {
await searchPage.goTo()
await searchPage.tabTo(
searchPage.page.getByRole('combobox', { name: 'プロファイル' }),
)
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'デッキ' }))
await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' }))
await searchPage.tabTo(
searchPage.page.getByRole('link', { name: 'ユーザー' }),
)
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' }))
await searchPage.tabTo(searchPage.queryInputLocator)
await searchPage.tabTo(
searchPage.page.getByRole('button', { name: '投稿者' }),
)
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '期間' }))
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '言語' }))
await searchPage.tabTo(
searchPage.page.getByRole('button', { name: '投稿の種類' }),
)
await searchPage.tabTo(searchPage.page.getByRole('button', { name: '除外' }))
await searchPage.tabTo(
searchPage.page.getByText('検索式を確認', { exact: true }),
)
await expect(searchPage.latestRankingLocator).toBeChecked()
await searchPage.tabTo(searchPage.latestRankingLocator)
await searchPage.tabTo(searchPage.followingOnlyLocator)
await searchPage.tabTo(searchPage.searchButtonLocator)
await expect(searchPage.ambientLinksLocator).toHaveCount(0)
await expect(searchPage.mutationButtonsLocator).toHaveCount(0)
await expect(searchPage.mutationLinksLocator).toHaveCount(0)
})
test('removes loading rail animation for reduced motion', async ({
page,
searchPage,
}, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.search(`slow-${testInfo.project.name}`)
const rail = searchPage.loadingRailLocator
await expect(rail).toBeVisible()
await expect
.poll(() =>
rail.evaluate((element) =>
getComputedStyle(element, '::after').getPropertyValue('animation-name'),
),
)
.toBe('none')
await expect(page.getByText('Latest · all page 2')).toBeVisible()
await expect(searchPage.endOfFeedLocator).toBeVisible()
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

+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)
})