diff --git a/tests/e2e/mock-relay.mjs b/tests/e2e/mock-relay.mjs index 416032a..32f73c9 100644 --- a/tests/e2e/mock-relay.mjs +++ b/tests/e2e/mock-relay.mjs @@ -8,34 +8,51 @@ if (!portValue || !Number.isInteger(port) || port < 1) { } 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 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}`, 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' }, - }, - }, - }, - }, + result: tweetResult(id, text, username, options), }, }, }, @@ -80,9 +97,12 @@ 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}`), - ) + const operation = [ + 'UserByScreenName', + 'UserTweets', + 'TweetDetail', + 'SearchTimeline', + ].find((name) => url.pathname.endsWith(`/${name}`)) if (!operation) { fail(response, 501, 'unsupported read operation') @@ -143,8 +163,13 @@ const server = createServer(async (request, response) => { } const secondPage = variables.cursor === 'user-next' const entries = secondPage - ? [tweet('u2', 'user page 2')] - : [tweet('u1', 'user page 1'), cursor('user-next')] + ? [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) } } }, @@ -153,6 +178,115 @@ const server = createServer(async (request, response) => { 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') diff --git a/tests/e2e/reader.spec.ts b/tests/e2e/reader.spec.ts index 9ce1f65..0781ed4 100644 --- a/tests/e2e/reader.spec.ts +++ b/tests/e2e/reader.spec.ts @@ -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 }) => { await openReader(page, '/search?q=&product=Latest&following=false') await expect(page.locator('article')).toHaveCount(0) diff --git a/tests/e2e/reader.spec.ts-snapshots/mist-thread-desktop-linux.png b/tests/e2e/reader.spec.ts-snapshots/mist-thread-desktop-linux.png new file mode 100644 index 0000000..7809f20 Binary files /dev/null and b/tests/e2e/reader.spec.ts-snapshots/mist-thread-desktop-linux.png differ diff --git a/tests/e2e/reader.spec.ts-snapshots/mist-thread-mobile-linux.png b/tests/e2e/reader.spec.ts-snapshots/mist-thread-mobile-linux.png new file mode 100644 index 0000000..e95c2dc Binary files /dev/null and b/tests/e2e/reader.spec.ts-snapshots/mist-thread-mobile-linux.png differ diff --git a/tests/e2e/reader.spec.ts-snapshots/mist-user-desktop-linux.png b/tests/e2e/reader.spec.ts-snapshots/mist-user-desktop-linux.png index 69f7c53..12dbfd7 100644 Binary files a/tests/e2e/reader.spec.ts-snapshots/mist-user-desktop-linux.png and b/tests/e2e/reader.spec.ts-snapshots/mist-user-desktop-linux.png differ diff --git a/tests/e2e/reader.spec.ts-snapshots/mist-user-mobile-linux.png b/tests/e2e/reader.spec.ts-snapshots/mist-user-mobile-linux.png index 2767866..5e4f84d 100644 Binary files a/tests/e2e/reader.spec.ts-snapshots/mist-user-mobile-linux.png and b/tests/e2e/reader.spec.ts-snapshots/mist-user-mobile-linux.png differ