test: verify intentional reader flows

This commit is contained in:
2026-07-13 23:02:53 +09:00
parent 62bd480399
commit 5704b9501b
11 changed files with 633 additions and 123 deletions
+220
View File
@@ -0,0 +1,220 @@
import { createServer } from 'node:http'
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT
const port = Number(portValue)
if (!portValue || !Number.isInteger(port) || port < 1) {
throw new Error('TWITTER_LITE_MOCK_RELAY_PORT must be a valid port')
}
const failedRetryQueries = new Set()
const isRecord = (value) =>
value !== null && typeof value === 'object' && !Array.isArray(value)
const tweet = (id, text, username = 'yuta') => ({
entryId: `tweet-${id}`,
content: {
itemContent: {
tweet_results: {
result: {
rest_id: id,
legacy: {
full_text: text,
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
reply_count: 1,
retweet_count: 2,
favorite_count: 3,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: '42',
legacy: { screen_name: username, name: 'Yuta' },
},
},
},
},
},
},
},
})
const cursor = (value) => ({
entryId: 'cursor-bottom',
content: { cursorType: 'Bottom', value },
})
const send = (response, payload, status = 200, headers = {}) => {
response.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
...headers,
})
response.end(JSON.stringify(payload))
}
const fail = (response, status, message, headers) =>
send(response, { errors: [{ message }] }, status, headers)
const variablesFrom = (url) => {
const encoded = url.searchParams.get('variables')
if (!encoded) throw new Error('variables are required')
const variables = JSON.parse(encoded)
if (!isRecord(variables)) throw new Error('variables must be an object')
return variables
}
const jsonBodyFrom = async (request) => {
let body = ''
for await (const chunk of request) {
body += chunk
if (body.length > 1_000_000) throw new Error('request body is too large')
}
const parsed = JSON.parse(body)
if (!isRecord(parsed)) throw new Error('JSON body must be an object')
return parsed
}
const timeline = (entries) => ({ instructions: [{ entries }] })
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
const operation = ['UserByScreenName', 'UserTweets', 'SearchTimeline'].find(
(name) => url.pathname.endsWith(`/${name}`),
)
if (!operation) {
fail(response, 501, 'unsupported read operation')
return
}
const expectedMethod = operation === 'SearchTimeline' ? 'POST' : 'GET'
if (request.method !== expectedMethod) {
fail(response, 405, `expected ${expectedMethod}`, {
allow: expectedMethod,
})
return
}
if (request.headers['x-profile-name'] !== 'e2e') {
fail(response, 403, 'invalid profile')
return
}
let variables
try {
variables = variablesFrom(url)
} catch {
fail(response, 400, 'malformed variables')
return
}
if (operation === 'UserByScreenName') {
if (
typeof variables.screen_name !== 'string' ||
variables.screen_name.length === 0
) {
fail(response, 400, 'screen_name is required')
return
}
send(response, {
data: {
user: {
result: {
rest_id: '42',
legacy: { screen_name: variables.screen_name, name: 'Yuta' },
},
},
},
})
return
}
if (operation === 'UserTweets') {
const expectedCursor =
variables.cursor === undefined || variables.cursor === 'user-next'
if (
variables.userId !== '42' ||
variables.count !== 20 ||
!expectedCursor
) {
fail(response, 400, 'invalid user timeline variables')
return
}
const secondPage = variables.cursor === 'user-next'
const entries = secondPage
? [tweet('u2', 'user page 2')]
: [tweet('u1', 'user page 1'), cursor('user-next')]
send(response, {
data: {
user: { result: { timeline: { timeline: timeline(entries) } } },
},
})
return
}
const contentType = request.headers['content-type'] ?? ''
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
fail(response, 400, 'SearchTimeline requires JSON')
return
}
let body
try {
body = await jsonBodyFrom(request)
} catch {
fail(response, 400, 'malformed JSON body')
return
}
const rawQuery = variables.rawQuery
const expectedCursor =
variables.cursor === undefined ||
variables.cursor === `search-next:${rawQuery}`
if (
typeof rawQuery !== 'string' ||
rawQuery.length === 0 ||
variables.count !== 20 ||
variables.querySource !== 'typed_query' ||
!['Top', 'Latest'].includes(variables.product) ||
!expectedCursor ||
!isRecord(body.features) ||
typeof body.queryId !== 'string' ||
body.queryId.length === 0 ||
Object.hasOwn(body, 'variables')
) {
fail(response, 400, 'invalid search request')
return
}
const secondPage = variables.cursor !== undefined
if (
secondPage &&
rawQuery.startsWith('retry-') &&
!failedRetryQueries.has(rawQuery)
) {
failedRetryQueries.add(rawQuery)
fail(response, 503, 'transient fixture failure')
return
}
if (secondPage && rawQuery.startsWith('slow-')) {
await new Promise((resolve) => setTimeout(resolve, 1_200))
}
const follows = rawQuery.includes('filter:follows') ? 'follows' : 'all'
const label = `${variables.product} · ${follows}`
const id = encodeURIComponent(rawQuery)
const entries = secondPage
? [tweet(`s-${id}-2`, `${label} page 2`)]
: [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)]
send(response, {
data: {
search_by_raw_query: {
search_timeline: { timeline: timeline(entries) },
},
},
})
})
server.listen(port, '127.0.0.1')
+143
View File
@@ -0,0 +1,143 @@
import { expect, type Locator, test } from '@playwright/test'
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([])
})
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
const mutationControlNames = /投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
async function submitSearch(
page: import('@playwright/test').Page,
query: string,
) {
await openReader(page, '/search?q=&product=Latest&following=false')
await expect(page.locator('article')).toHaveCount(0)
await page.getByLabel('検索語').fill(query)
await page.getByRole('button', { name: '検索' }).click()
}
async function openReader(page: import('@playwright/test').Page, path: string) {
await page.goto(path)
await page.waitForLoadState('networkidle')
}
async function tabTo(page: import('@playwright/test').Page, target: Locator) {
await page.keyboard.press('Tab')
await expect(target).toBeFocused()
}
test('requires intent and infinitely loads a user timeline', async ({
page,
}) => {
await openReader(page, '/')
await expect(page).toHaveURL(/\/user\?target=$/)
await expect(page.locator('article')).toHaveCount(0)
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
0,
)
await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta')
await page.getByRole('button', { name: '表示' }).click()
await expect(page.getByText('user page 1')).toBeVisible()
await expect(page.getByText('user page 2')).toBeVisible()
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
await expect(page).toHaveScreenshot('mist-user.png', {
animations: 'disabled',
fullPage: true,
})
})
test('searches Top posts from followed accounts', async ({ page }) => {
await openReader(page, '/search?q=&product=Latest&following=false')
await expect(page.locator('article')).toHaveCount(0)
await page.getByLabel('検索語').fill('AI lang:ja')
await page.getByLabel('人気順').check()
await page.getByLabel('フォロー中のみ').check()
await expect(page.getByLabel('フォロー中のみ')).toBeChecked()
await page.getByRole('button', { name: '検索' }).click()
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
})
test('retains the first page through a pagination error and retries explicitly', async ({
page,
}, testInfo) => {
await submitSearch(page, `retry-${testInfo.project.name}`)
await expect(page.getByText('Latest · all page 1')).toBeVisible()
const laterPageAlert = page
.getByRole('alert')
.filter({ hasText: '続きの投稿を取得できませんでした。' })
await expect(laterPageAlert).toHaveCount(1)
await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(page.getByText('Latest · all page 2')).toHaveCount(0)
await laterPageAlert.getByRole('button', { name: '再試行' }).click()
await expect(page.getByText('Latest · all page 2')).toBeVisible()
await expect(laterPageAlert).toHaveCount(0)
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
})
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
page,
}) => {
await openReader(page, '/search?q=&product=Latest&following=false')
await tabTo(page, page.getByRole('link', { name: 'ユーザー' }))
await tabTo(page, page.getByRole('link', { name: '検索' }))
await tabTo(page, page.getByLabel('検索語'))
const ranking = page.getByLabel('最新順')
await expect(ranking).toBeChecked()
await tabTo(page, ranking)
await tabTo(page, page.getByLabel('フォロー中のみ'))
await tabTo(page, page.getByRole('button', { name: '検索' }))
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
0,
)
await expect(
page.getByRole('button', { name: mutationControlNames }),
).toHaveCount(0)
await expect(
page.getByRole('link', { name: mutationControlNames }),
).toHaveCount(0)
})
test('removes loading rail animation for reduced motion', async ({
page,
}, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await submitSearch(page, `slow-${testInfo.project.name}`)
const rail = page.locator('.loading-rail')
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(page.getByText('これ以上の投稿はありません。')).toBeVisible()
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB