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
+2
View File
@@ -0,0 +1,2 @@
TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
BIRD_PROFILE_NAME=
+5
View File
@@ -1,4 +1,9 @@
.superpowers/
.worktrees/
.env*
!.env.example
.output/
dist/
node_modules/
playwright-report/
test-results/
+74
View File
@@ -0,0 +1,74 @@
# Twitter Lite
Twitter Lite is an intentional, read-only X reader. It stays empty until you
enter a user handle, profile URL, or search query.
## Scope
- User timelines and raw X search syntax
- Popular (`Top`) and chronological (`Latest`) search
- Optional `filter:follows` search
- Infinite cursor pagination with explicit retry
- Read-only cards for text, media, quotes, articles, and quiet engagement counts
It intentionally has no home feed, recommendations, trends, notifications,
history, account directory, or write actions.
## Requirements and setup
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. The package link also
requires a local Bird checkout at `../bird`, exactly at unpublished commit
`81921226f099e7f7a2107be15fc9783218a6f400`.
A fresh clone of the remote Bird repository alone does **not** contain that
local Top/Latest commit yet. Obtain the supplied checkout; do not assume the
commit or a compatible package has been published.
```bash
cd ../bird
git rev-parse HEAD
nix develop -c pnpm install --frozen-lockfile
nix develop -c pnpm run build:dist
cd ../twitter-lite
nix develop -c pnpm install --frozen-lockfile
cp .env.example .env.local
```
Set `TWITTER_RELAY_BASE_URL` in `.env.local` for Vite development, and set
`BIRD_PROFILE_NAME` when the relay has multiple profiles. Export the same
values in the process environment before `start` or `test:live`:
```bash
export TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
export BIRD_PROFILE_NAME=
```
Both values are read by server-only code at runtime; neither value nor Bird is
sent to the browser.
## Commands
```bash
nix develop -c pnpm dev
nix develop -c pnpm dev:tailscale
nix develop -c pnpm test
nix develop -c pnpm test:e2e
nix develop -c pnpm test:live
nix develop -c pnpm build
nix develop -c pnpm start
```
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by
the dev shell. `test:live` is an explicit, opt-in smoke test that performs
exactly one read-only Top search using the configured relay.
Development binds to `127.0.0.1` by default. `dev:tailscale` binds to
`0.0.0.0`, so it exposes the app on LAN interfaces as well as Tailscale. Use it
only on a trusted network and obtain the Tailscale address with
`tailscale ip -4`.
## Reliability
Bird uses X's internal GraphQL operations through the configured relay. Query
IDs and response shapes can change without notice.
+113 -122
View File
@@ -2861,28 +2861,36 @@ Create `playwright.config.ts`:
```ts
import { defineConfig, devices } from '@playwright/test'
const appPort = 4173
const relayPort = 6911
const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
if (!chromiumExecutable) {
throw new Error('Run E2E tests through `nix develop` to provide Chromium.')
}
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
workers: 1,
use: {
baseURL: 'http://127.0.0.1:3000',
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
},
baseURL: `http://127.0.0.1:${appPort}`,
launchOptions: { executablePath: chromiumExecutable },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: [
{
command: 'node tests/e2e/mock-relay.mjs',
port: 6901,
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`,
port: relayPort,
reuseExistingServer: false,
timeout: 120_000,
},
{
command:
'TWITTER_RELAY_BASE_URL=http://127.0.0.1:6901 BIRD_PROFILE_NAME=e2e pnpm dev',
url: 'http://127.0.0.1:3000/user?target=',
command: `TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
url: `http://127.0.0.1:${appPort}`,
reuseExistingServer: false,
timeout: 120_000,
},
],
projects: [
@@ -2894,11 +2902,11 @@ export default defineConfig({
- [ ] **Step 2: Create a deterministic read-only relay**
Create `tests/e2e/mock-relay.mjs`:
Create `tests/e2e/mock-relay.mjs`. The response-shape excerpt below is only
part of the mock: the executable implementation must also enforce the request
contract listed after it.
```js
import { createServer } from 'node:http'
const tweet = (id, text, username = 'yuta') => ({
entryId: `tweet-${id}`,
content: {
@@ -2934,89 +2942,22 @@ const cursor = (value) => ({
entryId: 'cursor-bottom',
content: { cursorType: 'Bottom', value },
})
const send = (response, payload, status = 200) => {
response.writeHead(status, { 'content-type': 'application/json' })
response.end(JSON.stringify(payload))
}
createServer((request, response) => {
if (request.headers['x-profile-name'] !== 'e2e') {
send(response, { errors: [{ message: 'missing profile' }] }, 403)
return
}
const url = new URL(request.url ?? '/', 'http://127.0.0.1:6901')
const variables = JSON.parse(url.searchParams.get('variables') ?? '{}')
if (url.pathname.endsWith('/UserByScreenName')) {
send(response, {
data: {
user: {
result: {
rest_id: '42',
legacy: { screen_name: variables.screen_name, name: 'Yuta' },
},
},
},
})
return
}
if (url.pathname.endsWith('/UserTweets')) {
const second = Boolean(variables.cursor)
send(response, {
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: second
? [tweet('u2', 'user page 2')]
: [tweet('u1', 'user page 1'), cursor('user-next')],
},
],
},
},
},
},
},
})
return
}
if (url.pathname.endsWith('/SearchTimeline')) {
const second = Boolean(variables.cursor)
const label = `${variables.product} · ${
String(variables.rawQuery).includes('filter:follows')
? 'follows'
: 'all'
}`
send(response, {
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: second
? [tweet('s2', `${label} page 2`)]
: [tweet('s1', `${label} page 1`), cursor('search-next')],
},
],
},
},
},
},
})
return
}
send(response, { errors: [{ message: 'unsupported read operation' }] }, 404)
}).listen(6901, '127.0.0.1')
```
Route by the operation-name suffix, never the query ID. Require
`x-profile-name: e2e`; require GET for `UserByScreenName` and `UserTweets`; and
require POST plus JSON `{ features, queryId }` for `SearchTimeline`, whose
`variables` remain in the URL. Validate only the minimal fixture variables,
not exact query IDs or complete feature bags. Return 405 for a wrong method,
403 for a wrong profile, 400 for malformed supported input, and 501 for an
unsupported operation. Never return 404 or make an outbound request.
Key transient state by raw query. The first cursor request for each
`retry-<project>` query returns 503 once, then succeeds on explicit retry. A
`slow-<project>` cursor request is delayed long enough to observe the loading
rail. Every page carrying a cursor also carries a parseable tweet, and page two
omits the cursor.
- [ ] **Step 3: Write failing end-to-end flows**
Create `tests/e2e/reader.spec.ts`:
@@ -3024,6 +2965,23 @@ Create `tests/e2e/reader.spec.ts`:
```ts
import { expect, 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).toEqual([])
expect(pageErrors).toEqual([])
})
test('requires intent and infinitely loads a user timeline', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/user\?target=/)
@@ -3061,6 +3019,14 @@ test('keeps discovery surfaces absent', async ({ page }) => {
})
```
Wait for the cold SSR page to hydrate before manipulating controlled fields.
In addition to the excerpted happy paths, cover a later-page 503 that retains
page one and succeeds only after Retry, the exact keyboard Tab order across
both navigation links and all form controls, absent discovery/mutation links
and controls, and a `slow-<project>` request under reduced motion whose loading
rail pseudo-element has `animation-name: none`. Run every flow in both projects
and let the slow request finish before teardown.
Run:
```bash
@@ -3085,23 +3051,32 @@ Expected: the first run creates reviewed desktop and mobile `mist-user.png` snap
Create `tests/live/relay.test.ts`:
```ts
// @vitest-environment node
import { TwitterClient } from '@yuta/bird'
import { describe, expect, it } from 'vitest'
describe.runIf(process.env.TWITTER_LITE_LIVE === '1')(
'configured relay',
() => {
it('reads one Top search result without a mutation', async () => {
const client = new TwitterClient({
relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL,
profileName: process.env.BIRD_PROFILE_NAME,
timeoutMs: 20_000,
})
const result = await client.search('OpenAI', 1, { product: 'Top' })
expect(result.success).toBe(true)
if (result.success) expect(result.tweets.length).toBeGreaterThan(0)
})
it(
'performs one read-only Top search',
async () => {
const relayBaseUrl = process.env.TWITTER_RELAY_BASE_URL
if (!relayBaseUrl) {
throw new Error('TWITTER_RELAY_BASE_URL is required for the live test')
}
const client = new TwitterClient({
relayBaseUrl,
profileName: process.env.BIRD_PROFILE_NAME,
timeoutMs: 20_000,
})
const query = process.env.TWITTER_LITE_LIVE_QUERY ?? 'OpenAI'
const result = await client.search(query, 1, { product: 'Top' })
expect(result.success).toBe(true)
},
30_000,
)
},
)
```
@@ -3112,7 +3087,9 @@ Run:
nix develop -c pnpm test:live
```
Expected: one read-only Top search test passes using the configured relay.
Expected: when relay/profile environment configuration is actually available,
one read-only Top search test passes. Do not run this opt-in command
unconditionally or log relay configuration, response bodies, or post content.
- [ ] **Step 6: Document setup and intentional omissions**
@@ -3127,8 +3104,9 @@ Extend `.gitignore` to exactly:
```gitignore
.superpowers/
.env
.env.local
.worktrees/
.env*
!.env.example
.output/
dist/
node_modules/
@@ -3141,8 +3119,8 @@ Create `README.md` with these sections and commands:
````md
# Twitter Lite
A localhost-only X reader that shows content only after a deliberate handle,
profile URL, or search query.
An intentional, read-only X reader that shows content only after a deliberate
handle, profile URL, or search query.
## Features
@@ -3155,19 +3133,23 @@ profile URL, or search query.
## Setup
Clone the modified Bird checkout beside this repository:
Use Nix, or Node.js `>=22.12.0` with pnpm `11.9.0`. Obtain the supplied local
Bird checkout beside this repository at unpublished commit
`81921226f099e7f7a2107be15fc9783218a6f400`. A fresh remote clone alone does
not contain that Top/Latest commit yet.
```bash
git clone https://git.yutakobayashi.com/yuta/bird ../bird
cd ../bird
git rev-parse HEAD
nix develop -c pnpm install --frozen-lockfile
nix develop -c pnpm run build:dist
cd ../twitter-lite
nix develop -c pnpm install --frozen-lockfile
```
Set `TWITTER_RELAY_BASE_URL` and, when the relay has multiple profiles,
`BIRD_PROFILE_NAME`. Bird and both values remain in the server bundle.
Set `TWITTER_RELAY_BASE_URL` at runtime and, when the relay has multiple
profiles, `BIRD_PROFILE_NAME`. Bird and both values are read only by
server-side code and never reach the browser.
## Commands
@@ -3181,8 +3163,11 @@ nix develop -c pnpm build
nix develop -c pnpm start
```
The default bind address is `127.0.0.1`. Use `dev:tailscale` only on a trusted
tailnet.
The default bind address is `127.0.0.1`. E2E runs through Nix with the system
Chromium; the live command performs exactly one explicitly configured read.
`dev:tailscale` binds `0.0.0.0`, including LAN interfaces as well as Tailscale,
so use it only on a trusted network. Find the Tailscale address with
`tailscale ip -4`.
## Reliability
@@ -3198,20 +3183,22 @@ From `../bird`:
nix develop -c pnpm run build:dist
nix develop -c pnpm run lint
nix develop -c pnpm test
git status --short
git status --short --branch
git rev-list --count origin/main..HEAD
git log --oneline origin/main..HEAD
```
From Twitter Lite:
```bash
nix develop -c pnpm install --frozen-lockfile
nix develop -c pnpm generate-routes
nix develop -c pnpm check:routes
nix develop -c pnpm lint
nix develop -c pnpm typecheck
nix develop -c pnpm test
nix develop -c pnpm test:e2e
nix develop -c pnpm test:live
nix develop -c pnpm build
nix develop -c pnpm test:e2e
nix develop -c pnpm test:live # only with configured relay environment
git diff --check
```
@@ -3220,7 +3207,9 @@ Expected:
- Bird build, lint, and all non-live tests pass.
- Twitter Lite lint, typecheck, unit tests, desktop/mobile browser tests, live read test, and production build pass.
- Bird has one local feature commit and no remote push.
- Browser bundles contain no relay environment value and expose no mutation endpoint.
- Fresh `.output/public` scans contain no Bird/client or relay environment names;
source contains only the two intended server functions and no mutation/generic
proxy calls.
- `README.md` and the design docs match the delivered commands and behavior.
- `AGENTS.md` and `CLAUDE.md` remain absent because no agent-specific instruction changed.
@@ -3229,17 +3218,19 @@ Expected:
```bash
git add \
.env.example .gitignore README.md playwright.config.ts \
tests/e2e tests/live docs
src/routes/__root.tsx tests/e2e tests/live docs
git commit -m "test: verify intentional reader flows"
```
Run one fresh post-commit audit:
```bash
git status --short
git status --short --branch
git log --oneline --decorate -8
nix develop -c pnpm test
nix develop -c pnpm test:e2e
nix develop -c pnpm build
git diff --check
```
Expected: clean status, the planned commits are present, all unit tests pass, and the production build exits zero.
+41
View File
@@ -0,0 +1,41 @@
import { defineConfig, devices } from '@playwright/test'
const appPort = 4173
const relayPort = 6911
const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
if (!chromiumExecutable) {
throw new Error(
'PLAYWRIGHT_CHROMIUM_EXECUTABLE is required; run E2E tests with `nix develop -c pnpm test:e2e`.',
)
}
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
workers: 1,
use: {
baseURL: `http://127.0.0.1:${appPort}`,
launchOptions: { executablePath: chromiumExecutable },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: [
{
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`,
port: relayPort,
reuseExistingServer: false,
timeout: 120_000,
},
{
command: `TWITTER_RELAY_BASE_URL=http://127.0.0.1:${relayPort} BIRD_PROFILE_NAME=e2e pnpm exec vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
url: `http://127.0.0.1:${appPort}`,
reuseExistingServer: false,
timeout: 120_000,
},
],
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
],
})
+7 -1
View File
@@ -18,7 +18,13 @@ export const Route = createRootRouteWithContext<RouterContext>()({
},
{ title: 'Twitter Lite' },
],
links: [{ rel: 'stylesheet', href: appCss }],
links: [
{ rel: 'stylesheet', href: appCss },
{
rel: 'icon',
href: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23255f6e'/%3E%3C/svg%3E",
},
],
}),
shellComponent: RootDocument,
})
+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

+28
View File
@@ -0,0 +1,28 @@
// @vitest-environment node
import { TwitterClient } from '@yuta/bird'
import { describe, expect, it } from 'vitest'
describe.runIf(process.env.TWITTER_LITE_LIVE === '1')(
'configured relay',
() => {
it('performs one read-only Top search', async () => {
const relayBaseUrl = process.env.TWITTER_RELAY_BASE_URL
if (!relayBaseUrl) {
throw new Error(
'TWITTER_RELAY_BASE_URL is required for the opt-in live smoke test',
)
}
const client = new TwitterClient({
relayBaseUrl,
profileName: process.env.BIRD_PROFILE_NAME,
timeoutMs: 20_000,
})
const query = process.env.TWITTER_LITE_LIVE_QUERY ?? 'OpenAI'
const result = await client.search(query, 1, { product: 'Top' })
expect(result.success).toBe(true)
}, 30_000)
},
)