feat: add research decks with WebMCP controls

This commit is contained in:
2026-09-24 15:38:06 +09:00
parent 2bc052c354
commit da8f52e605
32 changed files with 1876 additions and 51 deletions
+193
View File
@@ -0,0 +1,193 @@
import type { Page } from '@playwright/test'
import { expect, test } from '../fixtures'
async function addColumn(
page: Page,
title: string,
query: string,
top = false,
) {
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('button', { name: '追加して検索' }).click()
await expect(
page
.getByRole('region', { name: title, exact: true })
.locator('article')
.first(),
).toBeVisible()
}
test.beforeEach(async ({ page }) => {
await page.goto('/deck')
await page.waitForLoadState('networkidle')
})
test('creates independent columns, edits, reorders, and restores saved conditions', 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 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: '編集', exact: true }).click()
await page
.getByRole('form', { name: 'カラムを編集' })
.getByLabel('カラム名', { exact: true })
.fill('公式の投稿')
await page.getByLabel('Twitterの検索条件').fill('from:OpenAI')
await page.getByRole('button', { name: '変更を保存' }).click()
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'WebMCPの反応',
)
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([])
})
test('keeps other columns usable when pagination fails and retries only that column', async ({
page,
}, testInfo) => {
await addColumn(page, '失敗する検索', `retry-deck-${testInfo.project.name}`)
await addColumn(page, '別の検索', 'independent-deck', true)
const failed = page.getByRole('region', { name: '失敗する検索', exact: true })
const other = page.getByRole('region', { name: '別の検索', exact: true })
await failed.getByRole('button', { name: '続きを読み込む' }).click()
await expect(failed.getByRole('alert')).toBeVisible()
await expect(failed.locator('article')).toHaveCount(1)
await other.getByRole('button', { name: '続きを読み込む' }).click()
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 ({
page,
a11y,
}, testInfo) => {
await addColumn(page, '日本語の反応', 'WebMCP lang:ja')
await addColumn(page, '海外の反応', 'WebMCP lang:en', true)
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 900 })
expect(
await page.evaluate(
() => document.documentElement.scrollWidth > innerWidth,
),
).toBe(false)
}
await page.setViewportSize({
width: testInfo.project.name === 'desktop' ? 1440 : 393,
height: 900,
})
await expect(
page
.getByRole('region', { name: '日本語の反応', exact: true })
.locator('article'),
).toHaveCount(1)
expect((await a11y().analyze()).violations).toEqual([])
await page.screenshot({
path: testInfo.outputPath('deck.png'),
fullPage: true,
})
})
test('does not overwrite an unreadable saved deck on reload', async ({
page,
}) => {
await page.evaluate(() =>
localStorage.setItem('twitter-lite-research-deck', '{broken'),
)
await page.reload()
await expect(page.getByRole('alert')).toContainText('読み込めませんでした')
expect(
await page.evaluate(() =>
localStorage.getItem('twitter-lite-research-deck'),
),
).toBe('{broken')
})
test('keeps the six-column limit when undo and add overlap', 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.reload()
await page.waitForLoadState('networkidle')
await addColumn(page, 'HTTP接続からの調査', 'http-deck')
await page.reload()
await expect(
page
.getByRole('region', { name: 'HTTP接続からの調査', exact: true })
.locator('article'),
).toHaveCount(1)
})