feat: add authenticated list reading

This commit is contained in:
2026-07-26 12:58:35 +09:00
parent 83992bd962
commit 604c7f16e2
28 changed files with 947 additions and 29 deletions
+90
View File
@@ -102,6 +102,8 @@ const server = createServer(async (request, response) => {
'UserTweets',
'TweetDetail',
'SearchTimeline',
'ListsManagementPageTimeline',
'ListLatestTweetsTimeline',
].find((name) => url.pathname.endsWith(`/${name}`))
if (!operation) {
@@ -178,6 +180,94 @@ const server = createServer(async (request, response) => {
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)) {
+15
View File
@@ -62,6 +62,20 @@ test('requires intent and infinitely loads a user timeline', async ({
})
})
test('selects an authenticated list and infinitely loads its timeline', async ({
page,
}) => {
await openReader(page, '/list?target=')
await expect(page.locator('article')).toHaveCount(0)
await page.getByRole('link', { name: /My E2E List.*10 メンバー/ }).click()
await expect(page).toHaveURL(/\/list\?target=(?:%22)?1234567890/)
await expect(page.getByText('list page 1')).toBeVisible()
await expect(page.getByText('list page 2')).toBeVisible()
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible()
})
test('opens a card detail and infinitely loads its conversation', async ({
page,
}) => {
@@ -151,6 +165,7 @@ test('keeps keyboard focus intentional and discovery or mutation controls absent
await tabTo(page, page.getByRole('link', { name: 'ユーザー' }))
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()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB