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
+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.