このコミットが含まれているのは:
+102
-20
@@ -92,7 +92,22 @@ const responseFor = url => {
|
||||
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 () => {
|
||||
console.error('[SSR TEST] setup started')
|
||||
|
||||
api = createServer (async (request, response) => {
|
||||
requests.push ({ method: request.method, url: request.url,
|
||||
cookie: request.headers.cookie, csrf: request.headers['x-csrf-token'],
|
||||
@@ -169,29 +184,78 @@ before (async () => {
|
||||
response.writeHead (data == null ? 404 : 200)
|
||||
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}`
|
||||
console.error ('[SSR TEST] API ready:', apiURL)
|
||||
|
||||
node = spawn (process.execPath, ['server.mjs'], { env: {
|
||||
...process.env, NODE_ENV: 'production', PORT: '0', HOST: '127.0.0.1',
|
||||
PUBLIC_ORIGIN: origin, SSR_API_BASE_URL: apiURL },
|
||||
stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
httpOrigin = await new Promise ((resolve, reject) => {
|
||||
node.stdout.on ('data', data => {
|
||||
const match = data.toString ().match (/listening on (\d+)/)
|
||||
if (match) resolve (`http://127.0.0.1:${match[1]}`)
|
||||
})
|
||||
node.on ('error', reject)
|
||||
node.on ('exit', code => reject (new Error (`SSR server exited ${code}`)))
|
||||
console.error ('[SSR TEST] SSR process spawned:', node.pid ?? '(no PID)')
|
||||
|
||||
let childOutput = ''
|
||||
let childError = ''
|
||||
node.stderr.on ('data', data => {
|
||||
const text = data.toString ()
|
||||
childError = (childError + text).slice (-8_192)
|
||||
process.stderr.write (`[SSR CHILD] ${text}`)
|
||||
})
|
||||
vite = await createViteServer ({ server: { middlewareMode: true }, appType: 'custom' })
|
||||
const { pageRoutes } = await vite.ssrLoadModule ('/src/App.tsx')
|
||||
httpOrigin = await withTimeout ('SSR server readiness', new Promise ((resolve, reject) => {
|
||||
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 path = [parent, route.path].filter (Boolean).join ('/')
|
||||
return route.children ? flatten (route.children, path) : [{ ...route, path }]
|
||||
})
|
||||
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 csrf = await fetch (`${httpOrigin}/api/users/csrf`)
|
||||
assert.equal (csrf.status, 200)
|
||||
@@ -209,10 +273,28 @@ const authenticatedCookie = async (code = recoveryCode) => {
|
||||
}
|
||||
|
||||
after (async () => {
|
||||
await vite?.close ()
|
||||
node?.kill ()
|
||||
api?.closeAllConnections ()
|
||||
await new Promise (resolve => api?.close (resolve))
|
||||
console.error ('[SSR TEST] teardown started')
|
||||
const errors = []
|
||||
if (vite) {
|
||||
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 => {
|
||||
@@ -223,7 +305,7 @@ test ('every canonical screen route returns page HTML without JavaScript', async
|
||||
.replace ('*', 'unknown-screen')
|
||||
const url = `/${locale}/${path}?from=2026-09-15&to=2026-09-15`
|
||||
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)
|
||||
if (route.handle?.redirectTo) {
|
||||
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 () => {
|
||||
const start = requests.length
|
||||
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 ()) {
|
||||
const locale = index % 2 === 0 ? 'ja' : 'ain-Kana'
|
||||
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 () => {
|
||||
assert.equal ((await render ('/ja/unknown', origin, apiURL)).status, 404)
|
||||
assert.equal ((await render ('/ja/help/unknown', origin, apiURL)).status, 404)
|
||||
assert.equal ((await render ('/ja/posts/999', origin, apiURL)).status, 404)
|
||||
const redirect = await render ('/help/home?edition=2026-09-15', origin, apiURL)
|
||||
assert.equal ((await renderPage ('/ja/unknown', origin, apiURL)).status, 404)
|
||||
assert.equal ((await renderPage ('/ja/help/unknown', origin, apiURL)).status, 404)
|
||||
assert.equal ((await renderPage ('/ja/posts/999', origin, apiURL)).status, 404)
|
||||
const redirect = await renderPage ('/help/home?edition=2026-09-15', origin, apiURL)
|
||||
assert.equal (redirect.status, 308)
|
||||
assert.equal (redirect.location, '/ja/help/home?edition=2026-09-15')
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
style={{ fontFamily: 'Nikumaru' }}>
|
||||
<p>{status}</p>
|
||||
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4">
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.left`)}</p>
|
||||
<img className="max-w-[70vw]" src={errorImg} alt={translate ('imageAlt').text}/>
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.right`)}</p>
|
||||
<div className="flex w-full min-w-0 items-center justify-center
|
||||
space-x-1 sm:space-x-2 md:space-x-4">
|
||||
<p className="shrink-0" style={{ writingMode: 'vertical-rl' }}>
|
||||
{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>
|
||||
<p className="mr-[-.5em]">{label (`${ status }.message`)}</p>
|
||||
</div>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする