test: verify intentional reader flows
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900
|
||||||
|
BIRD_PROFILE_NAME=
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
.output/
|
.output/
|
||||||
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -2861,28 +2861,36 @@ Create `playwright.config.ts`:
|
|||||||
```ts
|
```ts
|
||||||
import { defineConfig, devices } from '@playwright/test'
|
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({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:3000',
|
baseURL: `http://127.0.0.1:${appPort}`,
|
||||||
launchOptions: {
|
launchOptions: { executablePath: chromiumExecutable },
|
||||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,
|
|
||||||
},
|
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
trace: 'retain-on-failure',
|
trace: 'retain-on-failure',
|
||||||
},
|
},
|
||||||
webServer: [
|
webServer: [
|
||||||
{
|
{
|
||||||
command: 'node tests/e2e/mock-relay.mjs',
|
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node tests/e2e/mock-relay.mjs`,
|
||||||
port: 6901,
|
port: relayPort,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command:
|
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`,
|
||||||
'TWITTER_RELAY_BASE_URL=http://127.0.0.1:6901 BIRD_PROFILE_NAME=e2e pnpm dev',
|
url: `http://127.0.0.1:${appPort}`,
|
||||||
url: 'http://127.0.0.1:3000/user?target=',
|
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
projects: [
|
projects: [
|
||||||
@@ -2894,11 +2902,11 @@ export default defineConfig({
|
|||||||
|
|
||||||
- [ ] **Step 2: Create a deterministic read-only relay**
|
- [ ] **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
|
```js
|
||||||
import { createServer } from 'node:http'
|
|
||||||
|
|
||||||
const tweet = (id, text, username = 'yuta') => ({
|
const tweet = (id, text, username = 'yuta') => ({
|
||||||
entryId: `tweet-${id}`,
|
entryId: `tweet-${id}`,
|
||||||
content: {
|
content: {
|
||||||
@@ -2934,89 +2942,22 @@ const cursor = (value) => ({
|
|||||||
entryId: 'cursor-bottom',
|
entryId: 'cursor-bottom',
|
||||||
content: { cursorType: 'Bottom', value },
|
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**
|
- [ ] **Step 3: Write failing end-to-end flows**
|
||||||
|
|
||||||
Create `tests/e2e/reader.spec.ts`:
|
Create `tests/e2e/reader.spec.ts`:
|
||||||
@@ -3024,6 +2965,23 @@ Create `tests/e2e/reader.spec.ts`:
|
|||||||
```ts
|
```ts
|
||||||
import { expect, test } from '@playwright/test'
|
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 }) => {
|
test('requires intent and infinitely loads a user timeline', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await expect(page).toHaveURL(/\/user\?target=/)
|
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:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -3085,23 +3051,32 @@ Expected: the first run creates reviewed desktop and mobile `mist-user.png` snap
|
|||||||
Create `tests/live/relay.test.ts`:
|
Create `tests/live/relay.test.ts`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
import { TwitterClient } from '@yuta/bird'
|
import { TwitterClient } from '@yuta/bird'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
describe.runIf(process.env.TWITTER_LITE_LIVE === '1')(
|
describe.runIf(process.env.TWITTER_LITE_LIVE === '1')(
|
||||||
'configured relay',
|
'configured relay',
|
||||||
() => {
|
() => {
|
||||||
it('reads one Top search result without a mutation', async () => {
|
it(
|
||||||
const client = new TwitterClient({
|
'performs one read-only Top search',
|
||||||
relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL,
|
async () => {
|
||||||
profileName: process.env.BIRD_PROFILE_NAME,
|
const relayBaseUrl = process.env.TWITTER_RELAY_BASE_URL
|
||||||
timeoutMs: 20_000,
|
if (!relayBaseUrl) {
|
||||||
})
|
throw new Error('TWITTER_RELAY_BASE_URL is required for the live test')
|
||||||
|
}
|
||||||
const result = await client.search('OpenAI', 1, { product: 'Top' })
|
const client = new TwitterClient({
|
||||||
expect(result.success).toBe(true)
|
relayBaseUrl,
|
||||||
if (result.success) expect(result.tweets.length).toBeGreaterThan(0)
|
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
|
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**
|
- [ ] **Step 6: Document setup and intentional omissions**
|
||||||
|
|
||||||
@@ -3127,8 +3104,9 @@ Extend `.gitignore` to exactly:
|
|||||||
|
|
||||||
```gitignore
|
```gitignore
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.env
|
.worktrees/
|
||||||
.env.local
|
.env*
|
||||||
|
!.env.example
|
||||||
.output/
|
.output/
|
||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -3141,8 +3119,8 @@ Create `README.md` with these sections and commands:
|
|||||||
````md
|
````md
|
||||||
# Twitter Lite
|
# Twitter Lite
|
||||||
|
|
||||||
A localhost-only X reader that shows content only after a deliberate handle,
|
An intentional, read-only X reader that shows content only after a deliberate
|
||||||
profile URL, or search query.
|
handle, profile URL, or search query.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -3155,19 +3133,23 @@ profile URL, or search query.
|
|||||||
|
|
||||||
## Setup
|
## 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
|
```bash
|
||||||
git clone https://git.yutakobayashi.com/yuta/bird ../bird
|
|
||||||
cd ../bird
|
cd ../bird
|
||||||
|
git rev-parse HEAD
|
||||||
nix develop -c pnpm install --frozen-lockfile
|
nix develop -c pnpm install --frozen-lockfile
|
||||||
nix develop -c pnpm run build:dist
|
nix develop -c pnpm run build:dist
|
||||||
cd ../twitter-lite
|
cd ../twitter-lite
|
||||||
nix develop -c pnpm install --frozen-lockfile
|
nix develop -c pnpm install --frozen-lockfile
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `TWITTER_RELAY_BASE_URL` and, when the relay has multiple profiles,
|
Set `TWITTER_RELAY_BASE_URL` at runtime and, when the relay has multiple
|
||||||
`BIRD_PROFILE_NAME`. Bird and both values remain in the server bundle.
|
profiles, `BIRD_PROFILE_NAME`. Bird and both values are read only by
|
||||||
|
server-side code and never reach the browser.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -3181,8 +3163,11 @@ nix develop -c pnpm build
|
|||||||
nix develop -c pnpm start
|
nix develop -c pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
The default bind address is `127.0.0.1`. Use `dev:tailscale` only on a trusted
|
The default bind address is `127.0.0.1`. E2E runs through Nix with the system
|
||||||
tailnet.
|
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
|
## Reliability
|
||||||
|
|
||||||
@@ -3198,20 +3183,22 @@ From `../bird`:
|
|||||||
nix develop -c pnpm run build:dist
|
nix develop -c pnpm run build:dist
|
||||||
nix develop -c pnpm run lint
|
nix develop -c pnpm run lint
|
||||||
nix develop -c pnpm test
|
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:
|
From Twitter Lite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix develop -c pnpm install --frozen-lockfile
|
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 lint
|
||||||
nix develop -c pnpm typecheck
|
nix develop -c pnpm typecheck
|
||||||
nix develop -c pnpm test
|
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 build
|
||||||
|
nix develop -c pnpm test:e2e
|
||||||
|
nix develop -c pnpm test:live # only with configured relay environment
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -3220,7 +3207,9 @@ Expected:
|
|||||||
- Bird build, lint, and all non-live tests pass.
|
- 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.
|
- 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.
|
- 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.
|
- `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.
|
- `AGENTS.md` and `CLAUDE.md` remain absent because no agent-specific instruction changed.
|
||||||
|
|
||||||
@@ -3229,17 +3218,19 @@ Expected:
|
|||||||
```bash
|
```bash
|
||||||
git add \
|
git add \
|
||||||
.env.example .gitignore README.md playwright.config.ts \
|
.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"
|
git commit -m "test: verify intentional reader flows"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run one fresh post-commit audit:
|
Run one fresh post-commit audit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git status --short
|
git status --short --branch
|
||||||
git log --oneline --decorate -8
|
git log --oneline --decorate -8
|
||||||
nix develop -c pnpm test
|
nix develop -c pnpm test
|
||||||
|
nix develop -c pnpm test:e2e
|
||||||
nix develop -c pnpm build
|
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.
|
Expected: clean status, the planned commits are present, all unit tests pass, and the production build exits zero.
|
||||||
|
|||||||
@@ -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'] } },
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -18,7 +18,13 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
},
|
},
|
||||||
{ title: 'Twitter Lite' },
|
{ 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,
|
shellComponent: RootDocument,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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 |
@@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user