test: verify tweet conversation flows
This commit is contained in:
+159
-25
@@ -8,34 +8,51 @@ if (!portValue || !Number.isInteger(port) || port < 1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const failedRetryQueries = new Set()
|
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) =>
|
const isRecord = (value) =>
|
||||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
|
||||||
const tweet = (id, text, username = 'yuta') => ({
|
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}`,
|
entryId: `tweet-${id}`,
|
||||||
content: {
|
content: {
|
||||||
itemContent: {
|
itemContent: {
|
||||||
tweet_results: {
|
tweet_results: {
|
||||||
result: {
|
result: tweetResult(id, text, username, options),
|
||||||
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' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -80,9 +97,12 @@ const timeline = (entries) => ({ instructions: [{ entries }] })
|
|||||||
|
|
||||||
const server = createServer(async (request, response) => {
|
const server = createServer(async (request, response) => {
|
||||||
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
|
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`)
|
||||||
const operation = ['UserByScreenName', 'UserTweets', 'SearchTimeline'].find(
|
const operation = [
|
||||||
(name) => url.pathname.endsWith(`/${name}`),
|
'UserByScreenName',
|
||||||
)
|
'UserTweets',
|
||||||
|
'TweetDetail',
|
||||||
|
'SearchTimeline',
|
||||||
|
].find((name) => url.pathname.endsWith(`/${name}`))
|
||||||
|
|
||||||
if (!operation) {
|
if (!operation) {
|
||||||
fail(response, 501, 'unsupported read operation')
|
fail(response, 501, 'unsupported read operation')
|
||||||
@@ -143,8 +163,13 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
const secondPage = variables.cursor === 'user-next'
|
const secondPage = variables.cursor === 'user-next'
|
||||||
const entries = secondPage
|
const entries = secondPage
|
||||||
? [tweet('u2', 'user page 2')]
|
? [tweet('1010', 'user page 2')]
|
||||||
: [tweet('u1', 'user page 1'), cursor('user-next')]
|
: [
|
||||||
|
tweet('1001', 'user page 1', 'yuta', {
|
||||||
|
conversationId: '1000',
|
||||||
|
}),
|
||||||
|
cursor('user-next'),
|
||||||
|
]
|
||||||
send(response, {
|
send(response, {
|
||||||
data: {
|
data: {
|
||||||
user: { result: { timeline: { timeline: timeline(entries) } } },
|
user: { result: { timeline: { timeline: timeline(entries) } } },
|
||||||
@@ -153,6 +178,115 @@ const server = createServer(async (request, response) => {
|
|||||||
return
|
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'] ?? ''
|
const contentType = request.headers['content-type'] ?? ''
|
||||||
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
|
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
|
||||||
fail(response, 400, 'SearchTimeline requires JSON')
|
fail(response, 400, 'SearchTimeline requires JSON')
|
||||||
|
|||||||
@@ -62,6 +62,53 @@ test('requires intent and infinitely loads a user timeline', async ({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('opens a card detail and infinitely loads its conversation', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openReader(page, '/user?target=yuta')
|
||||||
|
const sourceCard = page
|
||||||
|
.getByRole('article')
|
||||||
|
.filter({ hasText: 'user page 1' })
|
||||||
|
await sourceCard.getByRole('link', { name: '詳細・スレッド' }).click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/status\/1001$/)
|
||||||
|
await expect(page.getByRole('heading', { name: '会話' })).toBeVisible()
|
||||||
|
await expect(page.getByText('表示中の投稿')).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(page.getByText('これ以上の投稿はありません。')).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,
|
||||||
|
}) => {
|
||||||
|
await openReader(page, '/status/2001')
|
||||||
|
|
||||||
|
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||||
|
await expect(page.getByText('thread root')).toBeVisible()
|
||||||
|
await expect(page.getByText('thread page 1')).toBeVisible()
|
||||||
|
const alert = page
|
||||||
|
.getByRole('alert')
|
||||||
|
.filter({ hasText: '続きの投稿を取得できませんでした。' })
|
||||||
|
await expect(alert).toHaveCount(1)
|
||||||
|
await expect(page.getByText('retry focal')).toHaveCount(1)
|
||||||
|
|
||||||
|
await alert.getByRole('button', { name: '再試行' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('thread page 2')).toBeVisible()
|
||||||
|
await expect(alert).toHaveCount(0)
|
||||||
|
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: mutationControlNames }),
|
||||||
|
).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
test('searches Top posts from followed accounts', async ({ page }) => {
|
test('searches Top posts from followed accounts', async ({ page }) => {
|
||||||
await openReader(page, '/search?q=&product=Latest&following=false')
|
await openReader(page, '/search?q=&product=Latest&following=false')
|
||||||
await expect(page.locator('article')).toHaveCount(0)
|
await expect(page.locator('article')).toHaveCount(0)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 50 KiB |
Reference in New Issue
Block a user