test(e2e): generate relay fixture types from OpenAPI

This commit is contained in:
2026-08-12 00:24:55 +09:00
parent 8eb299f46d
commit 5406855e1c
8 changed files with 4090 additions and 44 deletions
+14 -2
View File
@@ -68,6 +68,7 @@ sent to the browser.
```bash ```bash
nix develop -c pnpm dev nix develop -c pnpm dev
nix develop -c pnpm dev:tailscale nix develop -c pnpm dev:tailscale
nix develop -c pnpm generate:e2e-openapi
nix develop -c pnpm knip nix develop -c pnpm knip
nix develop -c pnpm test nix develop -c pnpm test
nix develop -c pnpm test:e2e nix develop -c pnpm test:e2e
@@ -79,8 +80,19 @@ nix develop -c pnpm start
`test:e2e` is intentionally Nix-only and uses the system Chromium supplied by `test:e2e` is intentionally Nix-only and uses the system Chromium supplied by
the dev shell. Browser tests are organized under `e2e/`: page object models the dev shell. Browser tests are organized under `e2e/`: page object models
and fixtures are shared by integration flows and Axe checks for WCAG 2.0/2.1 and fixtures are shared by integration flows and Axe checks for WCAG 2.0/2.1
A and AA. `test:live` is an explicit, opt-in smoke test that performs exactly A and AA. The tests keep a standalone HTTP relay so TanStack Start server
one read-only Top search using the configured relay. functions and SSR requests exercise the same network boundary as production.
The API client and fixture types are generated with Orval from a pinned
[twitter-openapi specification](https://github.com/fa0311/twitter-openapi/commit/590dae5c9f8575abc91d3774946bfe6f23960aba);
run `generate:e2e-openapi` only when updating that pin or the selected
operations. Generated files are committed, so normal E2E runs do not require
network access. E2E fixtures use the generated response types but stay
deterministic. Runtime requests continue through Bird because its transport
differs from the upstream OpenAPI client. Relay-only endpoints, request
transport differences, cursors, and stateful failure scenarios remain in the
handwritten mock server.
`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 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 `0.0.0.0`, so it exposes the app on LAN interfaces as well as Tailscale. Use it
+35
View File
@@ -0,0 +1,35 @@
import type { TweetLegacy } from '../generated/twitter-openapi/client.ts'
interface TweetLegacyFixtureOptions {
conversationId?: string
inReplyTo?: string
replyCount?: number
}
export const tweetLegacyFixture = (
id: string,
text: string,
{
conversationId = id,
inReplyTo,
replyCount = 1,
}: TweetLegacyFixtureOptions = {},
): TweetLegacy => ({
bookmark_count: 0,
bookmarked: false,
conversation_id_str: conversationId,
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
display_text_range: [0, text.length],
favorite_count: 3,
favorited: false,
full_text: text,
id_str: id,
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
is_quote_status: false,
lang: 'ja',
quote_count: 0,
reply_count: replyCount,
retweet_count: 2,
retweeted: false,
user_id_str: '42',
})
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -1,4 +1,5 @@
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { tweetLegacyFixture } from './fixtures/twitter.ts'
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT
const port = Number(portValue) const port = Number(portValue)
@@ -30,15 +31,11 @@ const tweetResult = (
{ conversationId = id, inReplyTo, replyCount = 1 } = {}, { conversationId = id, inReplyTo, replyCount = 1 } = {},
) => ({ ) => ({
rest_id: id, rest_id: id,
legacy: { legacy: tweetLegacyFixture(id, text, {
full_text: text, conversationId,
created_at: 'Mon Jul 13 00:00:00 +0000 2026', inReplyTo,
reply_count: replyCount, replyCount,
retweet_count: 2, }),
favorite_count: 3,
conversation_id_str: conversationId,
...(inReplyTo ? { in_reply_to_status_id_str: inReplyTo } : {}),
},
core: { core: {
user_results: { user_results: {
result: { result: {
+75
View File
@@ -0,0 +1,75 @@
import { defineConfig, defineTransformer } from 'orval'
const selectedTag = 'twitter-lite-e2e'
const selectedOperationIds = new Set([
'getUserByScreenName',
'getUserTweets',
'getTweetDetail',
'getSearchTimeline',
'getListLatestTweetsTimeline',
])
const selectE2eOperations = defineTransformer((document) => {
const foundOperationIds = new Set<string>()
for (const pathItem of Object.values(document.paths ?? {})) {
if (!pathItem || typeof pathItem !== 'object') continue
for (const candidate of Object.values(pathItem)) {
if (
!candidate ||
typeof candidate !== 'object' ||
Array.isArray(candidate) ||
!('operationId' in candidate)
) {
continue
}
const operation = candidate as {
operationId?: string
tags?: string[]
}
if (
operation.operationId &&
selectedOperationIds.has(operation.operationId)
) {
foundOperationIds.add(operation.operationId)
operation.tags = [...(operation.tags ?? []), selectedTag]
}
}
}
const missingOperationIds = [...selectedOperationIds].filter(
(operationId) => !foundOperationIds.has(operationId),
)
if (missingOperationIds.length > 0) {
throw new Error(
`twitter-openapi is missing selected operations: ${missingOperationIds.join(', ')}`,
)
}
return document
})
export default defineConfig({
twitterOpenApi: {
input: {
target:
'https://raw.githubusercontent.com/fa0311/twitter-openapi/590dae5c9f8575abc91d3774946bfe6f23960aba/dist/docs/openapi-3.0.yaml',
filters: {
mode: 'include',
tags: [selectedTag],
},
override: {
transformer: selectE2eOperations,
},
},
output: {
clean: true,
client: 'fetch',
mode: 'single',
target: './e2e/generated/twitter-openapi/client.ts',
},
},
})
+8
View File
@@ -6,12 +6,18 @@
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
}, },
"knip": {
"ignore": [
"e2e/generated/**"
]
},
"imports": { "imports": {
"#/*": "./src/*" "#/*": "./src/*"
}, },
"scripts": { "scripts": {
"dev": "vite dev --host 127.0.0.1 --port 3000", "dev": "vite dev --host 127.0.0.1 --port 3000",
"dev:tailscale": "vite dev --host 0.0.0.0 --port 3000", "dev:tailscale": "vite dev --host 0.0.0.0 --port 3000",
"generate:e2e-openapi": "orval --config orval.config.ts --formatter biome --fail-on-warnings",
"generate-routes": "node scripts/generate-routes.mjs", "generate-routes": "node scripts/generate-routes.mjs",
"check:routes": "pnpm generate-routes && git diff --exit-code -- src/routeTree.gen.ts", "check:routes": "pnpm generate-routes && git diff --exit-code -- src/routeTree.gen.ts",
"build": "vite build", "build": "vite build",
@@ -49,6 +55,8 @@
"@vitejs/plugin-react": "6.0.3", "@vitejs/plugin-react": "6.0.3",
"jsdom": "29.1.1", "jsdom": "29.1.1",
"knip": "6.27.0", "knip": "6.27.0",
"orval": "8.24.0",
"tsx": "4.23.12",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.1.4", "vite": "8.1.4",
"vitest": "4.1.10" "vitest": "4.1.10"
+1 -1
View File
@@ -22,7 +22,7 @@ export default defineConfig({
}, },
webServer: [ webServer: [
{ {
command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} node e2e/mock-relay.mjs`, command: `TWITTER_LITE_MOCK_RELAY_PORT=${relayPort} pnpm exec tsx e2e/mock-relay.mjs`,
port: relayPort, port: relayPort,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120_000, timeout: 120_000,
+1102 -32
View File
File diff suppressed because it is too large Load Diff