feat: unify research in profile-bound decks with TweetDeck-style UI
This commit is contained in:
+174
-100
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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