diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs index 15120b1..badee08 100644 --- a/frontend/server.ssr.test.mjs +++ b/frontend/server.ssr.test.mjs @@ -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') }) diff --git a/frontend/src/components/ErrorScreen.tsx b/frontend/src/components/ErrorScreen.tsx index 4bfa6e9..80f2b4d 100644 --- a/frontend/src/components/ErrorScreen.tsx +++ b/frontend/src/components/ErrorScreen.tsx @@ -45,10 +45,16 @@ const ErrorScreen: FC = ({ status }) => { space-y-2 sm:space-y-4 md:space-y-6 text-center flex flex-col justify-center items-center" style={{ fontFamily: 'Nikumaru' }}>

{status}

-
-

{label (`${ status }.left`)}

- {translate -

{label (`${ status }.right`)}

+
+

+ {label (`${ status }.left`)} +

+ {translate +

+ {label (`${ status }.right`)} +

{label (`${ status }.message`)}