Add Axe accessibility E2E tests (#2)
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import { HomePage } from './models/HomePage'
|
||||
import { ListsPage } from './models/ListsPage'
|
||||
import { SearchPage } from './models/SearchPage'
|
||||
import { ThreadPage } from './models/ThreadPage'
|
||||
import { UserPage } from './models/UserPage'
|
||||
|
||||
type Fixtures = {
|
||||
homePage: HomePage
|
||||
listsPage: ListsPage
|
||||
searchPage: SearchPage
|
||||
threadPage: ThreadPage
|
||||
userPage: UserPage
|
||||
}
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
homePage: ({ page }, use) => use(new HomePage(page)),
|
||||
listsPage: ({ page }, use) => use(new ListsPage(page)),
|
||||
searchPage: ({ page }, use) => use(new SearchPage(page)),
|
||||
threadPage: ({ page }, use) => use(new ThreadPage(page)),
|
||||
userPage: ({ page }, use) => use(new UserPage(page)),
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
@@ -1,444 +0,0 @@
|
||||
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 rootByTarget = new Map([
|
||||
['1001', '1000'],
|
||||
['2001', '2000'],
|
||||
])
|
||||
const targetByRoot = new Map([
|
||||
['1000', '1001'],
|
||||
['2000', '2001'],
|
||||
])
|
||||
const failedThreadRoots = new Set()
|
||||
|
||||
const isRecord = (value) =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const tweetResult = (
|
||||
id,
|
||||
text,
|
||||
username = 'yuta',
|
||||
{ conversationId = id, inReplyTo } = {},
|
||||
) => ({
|
||||
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: conversationId,
|
||||
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
|
||||
},
|
||||
core: {
|
||||
user_results: {
|
||||
result: {
|
||||
rest_id: '42',
|
||||
legacy: { screen_name: username, name: 'Yuta' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const tweet = (id, text, username = 'yuta', options) => ({
|
||||
entryId: `tweet-${id}`,
|
||||
content: {
|
||||
itemContent: {
|
||||
tweet_results: {
|
||||
result: tweetResult(id, text, username, options),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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',
|
||||
'TweetDetail',
|
||||
'SearchTimeline',
|
||||
'ListsManagementPageTimeline',
|
||||
'ListLatestTweetsTimeline',
|
||||
].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('1010', 'user page 2')]
|
||||
: [
|
||||
tweet('1001', 'user page 1', 'yuta', {
|
||||
conversationId: '1000',
|
||||
}),
|
||||
cursor('user-next'),
|
||||
]
|
||||
send(response, {
|
||||
data: {
|
||||
user: { result: { timeline: { timeline: timeline(entries) } } },
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (operation === 'ListsManagementPageTimeline') {
|
||||
if (variables.count !== 100) {
|
||||
fail(response, 400, 'invalid lists management variables')
|
||||
return
|
||||
}
|
||||
const features = url.searchParams.get('features')
|
||||
try {
|
||||
if (!features || !isRecord(JSON.parse(features))) throw new Error()
|
||||
} catch {
|
||||
fail(response, 400, 'invalid lists management feature lock')
|
||||
return
|
||||
}
|
||||
send(response, {
|
||||
data: {
|
||||
viewer: {
|
||||
list_management_timeline: {
|
||||
timeline: {
|
||||
instructions: [
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
content: {
|
||||
clientEventInfo: {
|
||||
component: 'suggest_owned_subscribed_list',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
item: {
|
||||
itemContent: {
|
||||
list: {
|
||||
id_str: '1234567890',
|
||||
name: 'My E2E List',
|
||||
description: 'Accounts selected for testing',
|
||||
member_count: 10,
|
||||
mode: 'Public',
|
||||
created_at: 1_704_067_200_000,
|
||||
user_results: {
|
||||
result: {
|
||||
rest_id: '42',
|
||||
core: {
|
||||
screen_name: 'yuta',
|
||||
name: 'Yuta',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errors: [{ message: 'optional banner field failed' }],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (operation === 'ListLatestTweetsTimeline') {
|
||||
const expectedCursor =
|
||||
variables.cursor === undefined || variables.cursor === 'list-next'
|
||||
if (
|
||||
typeof variables.listId !== 'string' ||
|
||||
!/^\d+$/.test(variables.listId) ||
|
||||
variables.count !== 20 ||
|
||||
!expectedCursor
|
||||
) {
|
||||
fail(response, 400, 'invalid list timeline variables')
|
||||
return
|
||||
}
|
||||
const secondPage = variables.cursor === 'list-next'
|
||||
const entries = secondPage
|
||||
? [tweet('3010', 'list page 2', 'listed')]
|
||||
: [tweet('3001', 'list page 1', 'listed'), cursor('list-next')]
|
||||
send(response, {
|
||||
data: {
|
||||
list: { tweets_timeline: { timeline: timeline(entries) } },
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (operation === 'TweetDetail') {
|
||||
const focalTweetId = variables.focalTweetId
|
||||
if (typeof focalTweetId !== 'string' || !/^\d+$/.test(focalTweetId)) {
|
||||
fail(response, 400, 'invalid focalTweetId')
|
||||
return
|
||||
}
|
||||
const features = url.searchParams.get('features')
|
||||
const fieldToggles = url.searchParams.get('fieldToggles')
|
||||
try {
|
||||
if (!features || !isRecord(JSON.parse(features))) throw new Error()
|
||||
if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) {
|
||||
throw new Error()
|
||||
}
|
||||
} catch {
|
||||
fail(response, 400, 'invalid TweetDetail feature locks')
|
||||
return
|
||||
}
|
||||
|
||||
const targetRoot = rootByTarget.get(focalTweetId)
|
||||
if (targetRoot && variables.cursor === undefined) {
|
||||
const text = focalTweetId === '1001' ? 'user page 1' : 'retry focal'
|
||||
const result = tweetResult(focalTweetId, text, 'focus', {
|
||||
conversationId: targetRoot,
|
||||
inReplyTo: targetRoot,
|
||||
})
|
||||
send(response, {
|
||||
data: {
|
||||
tweetResult: { result },
|
||||
threaded_conversation_with_injections_v2: {
|
||||
instructions: [
|
||||
{
|
||||
entries: [
|
||||
tweet(focalTweetId, text, 'focus', {
|
||||
conversationId: targetRoot,
|
||||
inReplyTo: targetRoot,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const targetId = targetByRoot.get(focalTweetId)
|
||||
if (!targetId) {
|
||||
fail(response, 400, 'unsupported TweetDetail fixture')
|
||||
return
|
||||
}
|
||||
const expectedCursor = `thread-next:${focalTweetId}`
|
||||
if (variables.cursor !== undefined && variables.cursor !== expectedCursor) {
|
||||
fail(response, 400, 'invalid thread cursor')
|
||||
return
|
||||
}
|
||||
if (
|
||||
variables.cursor &&
|
||||
focalTweetId === '2000' &&
|
||||
!failedThreadRoots.has(focalTweetId)
|
||||
) {
|
||||
failedThreadRoots.add(focalTweetId)
|
||||
fail(response, 503, 'transient thread fixture failure')
|
||||
return
|
||||
}
|
||||
|
||||
const entries = variables.cursor
|
||||
? [
|
||||
tweet(`${focalTweetId}3`, 'thread page 2', 'reply2', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
]
|
||||
: [
|
||||
tweet(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
tweet(
|
||||
targetId,
|
||||
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
||||
'focus',
|
||||
{
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: focalTweetId,
|
||||
},
|
||||
),
|
||||
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
||||
conversationId: focalTweetId,
|
||||
inReplyTo: targetId,
|
||||
}),
|
||||
cursor(expectedCursor),
|
||||
]
|
||||
send(response, {
|
||||
data: {
|
||||
tweetResult: {
|
||||
result: tweetResult(focalTweetId, 'thread root', 'root', {
|
||||
conversationId: focalTweetId,
|
||||
}),
|
||||
},
|
||||
threaded_conversation_with_injections_v2: {
|
||||
instructions: [{ entries }],
|
||||
},
|
||||
},
|
||||
})
|
||||
if (variables.cursor && focalTweetId === '2000') {
|
||||
failedThreadRoots.delete(focalTweetId)
|
||||
}
|
||||
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')
|
||||
@@ -1,49 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
export class Base {
|
||||
readonly ambientLinksLocator: Locator
|
||||
readonly articlesLocator: Locator
|
||||
readonly continuationErrorLocator: Locator
|
||||
readonly endOfFeedLocator: Locator
|
||||
readonly loadingRailLocator: Locator
|
||||
readonly mutationButtonsLocator: Locator
|
||||
readonly mutationLinksLocator: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
|
||||
const mutationControlNames =
|
||||
/投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
|
||||
|
||||
this.ambientLinksLocator = page.getByRole('link', {
|
||||
name: ambientLinkNames,
|
||||
})
|
||||
this.articlesLocator = page.locator('article')
|
||||
this.continuationErrorLocator = page
|
||||
.getByRole('alert')
|
||||
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||
this.endOfFeedLocator = page.getByText('これ以上の投稿はありません。')
|
||||
this.loadingRailLocator = page.locator('.loading-rail')
|
||||
this.mutationButtonsLocator = page.getByRole('button', {
|
||||
name: mutationControlNames,
|
||||
})
|
||||
this.mutationLinksLocator = page.getByRole('link', {
|
||||
name: mutationControlNames,
|
||||
})
|
||||
}
|
||||
|
||||
protected async navigateTo(path: string) {
|
||||
await this.page.goto(path)
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async tabTo(target: Locator) {
|
||||
await this.page.keyboard.press('Tab')
|
||||
await expect(target).toBeFocused()
|
||||
}
|
||||
|
||||
async retryContinuation() {
|
||||
await this.continuationErrorLocator
|
||||
.getByRole('button', { name: '再試行' })
|
||||
.click()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Base } from './Base'
|
||||
|
||||
export class HomePage extends Base {
|
||||
readonly handleInputLocator: Locator
|
||||
readonly submitButtonLocator: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.handleInputLocator = page.getByLabel('ハンドルまたはプロフィール URL')
|
||||
this.submitButtonLocator = page.getByRole('button', { name: '表示' })
|
||||
}
|
||||
|
||||
async goTo() {
|
||||
await this.navigateTo('/')
|
||||
}
|
||||
|
||||
async displayUser(handle: string) {
|
||||
await this.handleInputLocator.fill(handle)
|
||||
await this.submitButtonLocator.click()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Base } from './Base'
|
||||
|
||||
export class ListsPage extends Base {
|
||||
async goTo() {
|
||||
await this.navigateTo('/i/lists')
|
||||
}
|
||||
|
||||
async openList(name: RegExp | string) {
|
||||
await this.page.getByRole('link', { name }).click()
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Base } from './Base'
|
||||
|
||||
export class SearchPage extends Base {
|
||||
readonly followingOnlyLocator: Locator
|
||||
readonly latestRankingLocator: Locator
|
||||
readonly queryInputLocator: Locator
|
||||
readonly searchButtonLocator: Locator
|
||||
readonly topRankingLocator: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.followingOnlyLocator = page.getByLabel('フォロー中のみ')
|
||||
this.latestRankingLocator = page.getByLabel('最新順')
|
||||
this.queryInputLocator = page.getByLabel('検索語')
|
||||
this.searchButtonLocator = page.getByRole('button', { name: '検索' })
|
||||
this.topRankingLocator = page.getByLabel('人気順')
|
||||
}
|
||||
|
||||
async goTo() {
|
||||
await this.navigateTo('/search?q=&product=Latest&following=false')
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
await this.queryInputLocator.fill(query)
|
||||
await this.searchButtonLocator.click()
|
||||
}
|
||||
|
||||
async searchTopFromFollowing(query: string) {
|
||||
await this.queryInputLocator.fill(query)
|
||||
await this.topRankingLocator.check()
|
||||
await this.followingOnlyLocator.check()
|
||||
await this.searchButtonLocator.click()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Base } from './Base'
|
||||
|
||||
export class ThreadPage extends Base {
|
||||
readonly headingLocator: Locator
|
||||
readonly selectedPostLabelLocator: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.headingLocator = page.getByRole('heading', { name: '会話' })
|
||||
this.selectedPostLabelLocator = page.getByText('表示中の投稿')
|
||||
}
|
||||
|
||||
async goTo(handle: string, tweetId: string) {
|
||||
await this.navigateTo(`/${handle}/status/${tweetId}`)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Base } from './Base'
|
||||
|
||||
export class UserPage extends Base {
|
||||
async goTo(handle: string) {
|
||||
await this.navigateTo(`/${handle}`)
|
||||
}
|
||||
|
||||
async openPostDetails(postText: string) {
|
||||
await this.page
|
||||
.getByRole('article')
|
||||
.filter({ hasText: postText })
|
||||
.getByRole('link', { name: '詳細・スレッド' })
|
||||
.click()
|
||||
}
|
||||
}
|
||||
@@ -1,176 +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(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('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('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('link', { name: 'ユーザー' }),
|
||||
)
|
||||
await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' }))
|
||||
await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' }))
|
||||
await searchPage.tabTo(searchPage.queryInputLocator)
|
||||
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: 78 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB |
Reference in New Issue
Block a user