このコミットが含まれているのは:
2026-10-20 01:34:25 +09:00
コミット 8d2f18e8fc
2個のファイルの変更、112行の追加、24行の削除
+102 -20
ファイルの表示
@@ -92,7 +92,22 @@ const responseFor = url => {
return null return null
} }
// Give startup failures a bounded, visible error instead of waiting indefinitely.
const withTimeout = async (label, promise, milliseconds) => {
let timer
try {
return await Promise.race ([promise, new Promise ((_, reject) => {
timer = setTimeout (() => reject (new Error (
`[SSR TEST] ${label} timed out after ${milliseconds}ms`)), milliseconds)
})])
} finally {
clearTimeout (timer)
}
}
before (async () => { before (async () => {
console.error('[SSR TEST] setup started')
api = createServer (async (request, response) => { api = createServer (async (request, response) => {
requests.push ({ method: request.method, url: request.url, requests.push ({ method: request.method, url: request.url,
cookie: request.headers.cookie, csrf: request.headers['x-csrf-token'], cookie: request.headers.cookie, csrf: request.headers['x-csrf-token'],
@@ -169,29 +184,78 @@ before (async () => {
response.writeHead (data == null ? 404 : 200) response.writeHead (data == null ? 404 : 200)
response.end (JSON.stringify (data)) response.end (JSON.stringify (data))
}) })
await new Promise (resolve => api.listen (0, '127.0.0.1', resolve)) console.error ('[SSR TEST] API created')
await withTimeout ('mock API listen', new Promise ((resolve, reject) => {
api.once ('error', reject)
api.listen (0, '127.0.0.1', () => {
api.off ('error', reject)
resolve ()
})
}), 15_000)
apiURL = `http://127.0.0.1:${api.address ().port}` apiURL = `http://127.0.0.1:${api.address ().port}`
console.error ('[SSR TEST] API ready:', apiURL)
node = spawn (process.execPath, ['server.mjs'], { env: { node = spawn (process.execPath, ['server.mjs'], { env: {
...process.env, NODE_ENV: 'production', PORT: '0', HOST: '127.0.0.1', ...process.env, NODE_ENV: 'production', PORT: '0', HOST: '127.0.0.1',
PUBLIC_ORIGIN: origin, SSR_API_BASE_URL: apiURL }, PUBLIC_ORIGIN: origin, SSR_API_BASE_URL: apiURL },
stdio: ['ignore', 'pipe', 'pipe'] }) stdio: ['ignore', 'pipe', 'pipe'] })
httpOrigin = await new Promise ((resolve, reject) => { console.error ('[SSR TEST] SSR process spawned:', node.pid ?? '(no PID)')
node.stdout.on ('data', data => {
const match = data.toString ().match (/listening on (\d+)/) let childOutput = ''
if (match) resolve (`http://127.0.0.1:${match[1]}`) let childError = ''
}) node.stderr.on ('data', data => {
node.on ('error', reject) const text = data.toString ()
node.on ('exit', code => reject (new Error (`SSR server exited ${code}`))) childError = (childError + text).slice (-8_192)
process.stderr.write (`[SSR CHILD] ${text}`)
}) })
vite = await createViteServer ({ server: { middlewareMode: true }, appType: 'custom' }) httpOrigin = await withTimeout ('SSR server readiness', new Promise ((resolve, reject) => {
const { pageRoutes } = await vite.ssrLoadModule ('/src/App.tsx') const onData = data => {
childOutput = (childOutput + data.toString()).slice(-8192)
// ANSI カラーコードを取り除いてから解析する
const output = childOutput.replace(/\x1b\[[0-9;]*m/g, '')
const match = output.match(/\[SSR\]\s+listening on\s+(\d+)/)
if (!match) return
console.error('[SSR TEST] detected SSR port:', match[1])
resolve(`http://127.0.0.1:${match[1]}`)
}
node.stdout.on ('data', onData)
node.once ('error', reject)
node.once ('exit', (code, signal) => reject (new Error (
`SSR process exited before readiness (code=${code}, signal=${signal})` +
`\nstdout: ${childOutput}\nstderr: ${childError}`)))
}), 30_000).catch (error => {
throw new Error (`${error.message}\nSSR stdout: ${childOutput}\nSSR stderr: ${childError}`,
{ cause: error })
})
console.error ('[SSR TEST] SSR server ready:', httpOrigin)
console.error ('[SSR TEST] starting Vite')
vite = await withTimeout ('Vite initialization',
createViteServer ({ server: { middlewareMode: true }, appType: 'custom' }), 90_000)
console.error ('[SSR TEST] Vite ready')
const { pageRoutes } = await withTimeout ('App.tsx module loading',
vite.ssrLoadModule ('/src/App.tsx'), 90_000)
console.error ('[SSR TEST] App loaded')
const flatten = (entries, parent = '') => entries.flatMap (route => { const flatten = (entries, parent = '') => entries.flatMap (route => {
const path = [parent, route.path].filter (Boolean).join ('/') const path = [parent, route.path].filter (Boolean).join ('/')
return route.children ? flatten (route.children, path) : [{ ...route, path }] return route.children ? flatten (route.children, path) : [{ ...route, path }]
}) })
routes = flatten (pageRoutes (null, () => {})) routes = flatten (pageRoutes (null, () => {}))
console.error (`[SSR TEST] setup complete (${routes.length} routes)`)
}) })
// Report the route on which server-side rendering stalls or fails.
const renderPage = async (url, ...args) => {
console.error (`[SSR TEST] rendering: ${url}`)
const result = await withTimeout (`render ${url}`, render (url, ...args), 60_000)
console.error (`[SSR TEST] rendered: ${url}`)
return result
}
const authenticatedCookie = async (code = recoveryCode) => { const authenticatedCookie = async (code = recoveryCode) => {
const csrf = await fetch (`${httpOrigin}/api/users/csrf`) const csrf = await fetch (`${httpOrigin}/api/users/csrf`)
assert.equal (csrf.status, 200) assert.equal (csrf.status, 200)
@@ -209,10 +273,28 @@ const authenticatedCookie = async (code = recoveryCode) => {
} }
after (async () => { after (async () => {
await vite?.close () console.error ('[SSR TEST] teardown started')
node?.kill () const errors = []
api?.closeAllConnections () if (vite) {
await new Promise (resolve => api?.close (resolve)) try {
await withTimeout ('Vite shutdown', vite.close (), 30_000)
} catch (error) {
errors.push (error)
}
}
if (node && node.exitCode == null && node.signalCode == null) node.kill ()
if (api?.listening) {
api.closeAllConnections ()
try {
await withTimeout ('mock API shutdown', new Promise ((resolve, reject) => {
api.close (error => error ? reject (error) : resolve ())
}), 10_000)
} catch (error) {
errors.push (error)
}
}
console.error ('[SSR TEST] teardown finished')
if (errors.length) throw new AggregateError (errors, 'SSR teardown failed')
}) })
test ('every canonical screen route returns page HTML without JavaScript', async t => { test ('every canonical screen route returns page HTML without JavaScript', async t => {
@@ -223,7 +305,7 @@ test ('every canonical screen route returns page HTML without JavaScript', async
.replace ('*', 'unknown-screen') .replace ('*', 'unknown-screen')
const url = `/${locale}/${path}?from=2026-09-15&to=2026-09-15` const url = `/${locale}/${path}?from=2026-09-15&to=2026-09-15`
await t.test (`${locale} ${route.path}`, async () => { await t.test (`${locale} ${route.path}`, async () => {
const result = await render (url, origin, apiURL, { cookie }) const result = await renderPage (url, origin, apiURL, { cookie })
assert.ok (result != null) assert.ok (result != null)
if (route.handle?.redirectTo) { if (route.handle?.redirectTo) {
assert.equal (result.status, 308) assert.equal (result.status, 308)
@@ -254,7 +336,7 @@ test ('every canonical screen route returns page HTML without JavaScript', async
test ('parallel localized requests keep list data isolated', async () => { test ('parallel localized requests keep list data isolated', async () => {
const start = requests.length const start = requests.length
const pages = await Promise.all (['ja', 'ain-Kana', 'ja', 'ain-Kana'].map (locale => const pages = await Promise.all (['ja', 'ain-Kana', 'ja', 'ain-Kana'].map (locale =>
render (`/${locale}/posts?limit=50&page=2`, origin, apiURL))) renderPage (`/${locale}/posts?limit=50&page=2`, origin, apiURL)))
for (const [index, page] of pages.entries ()) { for (const [index, page] of pages.entries ()) {
const locale = index % 2 === 0 ? 'ja' : 'ain-Kana' const locale = index % 2 === 0 ? 'ja' : 'ain-Kana'
assert.match (page.html, new RegExp (`SSR post ${locale}`)) assert.match (page.html, new RegExp (`SSR post ${locale}`))
@@ -267,10 +349,10 @@ test ('parallel localized requests keep list data isolated', async () => {
}) })
test ('unknown resources and legacy URLs have correct status', async () => { test ('unknown resources and legacy URLs have correct status', async () => {
assert.equal ((await render ('/ja/unknown', origin, apiURL)).status, 404) assert.equal ((await renderPage ('/ja/unknown', origin, apiURL)).status, 404)
assert.equal ((await render ('/ja/help/unknown', origin, apiURL)).status, 404) assert.equal ((await renderPage ('/ja/help/unknown', origin, apiURL)).status, 404)
assert.equal ((await render ('/ja/posts/999', origin, apiURL)).status, 404) assert.equal ((await renderPage ('/ja/posts/999', origin, apiURL)).status, 404)
const redirect = await render ('/help/home?edition=2026-09-15', origin, apiURL) const redirect = await renderPage ('/help/home?edition=2026-09-15', origin, apiURL)
assert.equal (redirect.status, 308) assert.equal (redirect.status, 308)
assert.equal (redirect.location, '/ja/help/home?edition=2026-09-15') assert.equal (redirect.location, '/ja/help/home?edition=2026-09-15')
}) })
+10 -4
ファイルの表示
@@ -45,10 +45,16 @@ const ErrorScreen: FC<Props> = ({ status }) => {
space-y-2 sm:space-y-4 md:space-y-6 text-center flex flex-col justify-center items-center" space-y-2 sm:space-y-4 md:space-y-6 text-center flex flex-col justify-center items-center"
style={{ fontFamily: 'Nikumaru' }}> style={{ fontFamily: 'Nikumaru' }}>
<p>{status}</p> <p>{status}</p>
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4"> <div className="flex w-full min-w-0 items-center justify-center
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.left`)}</p> space-x-1 sm:space-x-2 md:space-x-4">
<img className="max-w-[70vw]" src={errorImg} alt={translate ('imageAlt').text}/> <p className="shrink-0" style={{ writingMode: 'vertical-rl' }}>
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.right`)}</p> {label (`${ status }.left`)}
</p>
<img className="aspect-video h-auto min-w-0 max-w-[70vw] object-contain"
width={640} height={360} src={errorImg} alt={translate ('imageAlt').text}/>
<p className="shrink-0" style={{ writingMode: 'vertical-rl' }}>
{label (`${ status }.right`)}
</p>
</div> </div>
<p className="mr-[-.5em]">{label (`${ status }.message`)}</p> <p className="mr-[-.5em]">{label (`${ status }.message`)}</p>
</div> </div>