diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5f5d809 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +TWITTER_RELAY_BASE_URL=http://127.0.0.1:6900 +BIRD_PROFILE_NAME= diff --git a/.gitignore b/.gitignore index a8ccfc4..8a9f57c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ .superpowers/ .worktrees/ +.env* +!.env.example .output/ +dist/ node_modules/ +playwright-report/ +test-results/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..79f537b --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/superpowers/plans/2026-07-13-twitter-lite.md b/docs/superpowers/plans/2026-07-13-twitter-lite.md index a1bb96a..780abd1 100644 --- a/docs/superpowers/plans/2026-07-13-twitter-lite.md +++ b/docs/superpowers/plans/2026-07-13-twitter-lite.md @@ -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-` query returns 503 once, then succeeds on explicit retry. A +`slow-` 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-` 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. diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a10fdeb --- /dev/null +++ b/playwright.config.ts @@ -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'] } }, + ], +}) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 0983bd3..1593d6b 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -18,7 +18,13 @@ export const Route = createRootRouteWithContext()({ }, { 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, }) diff --git a/tests/e2e/mock-relay.mjs b/tests/e2e/mock-relay.mjs new file mode 100644 index 0000000..416032a --- /dev/null +++ b/tests/e2e/mock-relay.mjs @@ -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') diff --git a/tests/e2e/reader.spec.ts b/tests/e2e/reader.spec.ts new file mode 100644 index 0000000..9ce1f65 --- /dev/null +++ b/tests/e2e/reader.spec.ts @@ -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() +}) 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 new file mode 100644 index 0000000..69f7c53 Binary files /dev/null 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 new file mode 100644 index 0000000..2767866 Binary files /dev/null and b/tests/e2e/reader.spec.ts-snapshots/mist-user-mobile-linux.png differ diff --git a/tests/live/relay.test.ts b/tests/live/relay.test.ts new file mode 100644 index 0000000..c48d832 --- /dev/null +++ b/tests/live/relay.test.ts @@ -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) + }, +)