|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import { existsSync } from 'node:fs'; |
| 3 | +import { createServer } from 'node:http'; |
| 4 | +import { resolve } from 'node:path'; |
| 5 | +import { pathToFileURL } from 'node:url'; |
| 6 | + |
| 7 | +const serverBundlePath = resolve('dist/Patcher/server/server.mjs'); |
| 8 | +const browserIndexPath = resolve('dist/Patcher/browser/index.csr.html'); |
| 9 | + |
| 10 | +assert.ok(existsSync(serverBundlePath), 'SSR server bundle is missing; run `pnpm build` first'); |
| 11 | +assert.ok(existsSync(browserIndexPath), 'SSR browser index.csr.html is missing; production build is not emitting SSR browser HTML'); |
| 12 | + |
| 13 | +process.env['NG_ALLOWED_HOSTS'] = [ |
| 14 | + process.env['NG_ALLOWED_HOSTS'], |
| 15 | + '127.0.0.1', |
| 16 | + 'localhost', |
| 17 | + 'patcher.xyz', |
| 18 | +].filter(Boolean).join(','); |
| 19 | +process.env['SEO_CANONICAL_ORIGIN'] ||= 'https://patcher.xyz'; |
| 20 | +process.env['VERCEL'] ||= '1'; |
| 21 | + |
| 22 | +let failure; |
| 23 | +process.on('unhandledRejection', recordFailure); |
| 24 | +process.on('uncaughtException', recordFailure); |
| 25 | + |
| 26 | +const serverBundleUrl = pathToFileURL(serverBundlePath).href; |
| 27 | +const { app } = await import(serverBundleUrl); |
| 28 | +assert.equal(typeof app, 'function', 'SSR server bundle must export app() for local smoke tests'); |
| 29 | + |
| 30 | +const vercelShim = await import(pathToFileURL(resolve('api/ssr.mjs')).href); |
| 31 | +assert.equal(typeof vercelShim.default, 'function', 'Vercel SSR shim must export a request handler'); |
| 32 | + |
| 33 | +const expressApp = app(); |
| 34 | +const httpServer = createServer(expressApp); |
| 35 | + |
| 36 | +const ssrRouteCases = [ |
| 37 | + // Home |
| 38 | + { path: '/', expectedText: 'Browse modules' }, |
| 39 | + { path: '/home', expectedText: 'Browse modules' }, |
| 40 | + |
| 41 | + // Main public browser/detail surfaces |
| 42 | + { path: '/modules' }, |
| 43 | + { path: '/modules/browser', expectedText: 'Modules' }, |
| 44 | + { path: '/modules/details/1' }, |
| 45 | + { path: '/modules/add' }, |
| 46 | + { path: '/patches' }, |
| 47 | + { path: '/patches/browser', expectedText: 'Patches' }, |
| 48 | + { path: '/patches/details/1' }, |
| 49 | + { path: '/patches/example-public-id' }, |
| 50 | + { path: '/racks' }, |
| 51 | + { path: '/racks/browser', expectedText: 'Racks' }, |
| 52 | + { path: '/racks/details/1' }, |
| 53 | + { path: '/racks/example-public-id' }, |
| 54 | + { path: '/manufacturers' }, |
| 55 | + { path: '/manufacturers/browser', expectedText: 'Manufacturers' }, |
| 56 | + { path: '/manufacturers/details/1' }, |
| 57 | + |
| 58 | + // Production-disabled collection routes must not become crawlable soft-200s. |
| 59 | + { path: '/collections', expectedText: 'Page not found!', expectedStatus: 404 }, |
| 60 | + { path: '/collections/browser', expectedText: 'Page not found!', expectedStatus: 404 }, |
| 61 | + { path: '/collections/example-public-id', expectedText: 'Page not found!', expectedStatus: 404 }, |
| 62 | + { path: '/collections/manage/1', expectedText: 'Page not found!', expectedStatus: 404 }, |
| 63 | + { path: '/collection/1', expectedText: 'Page not found!', expectedStatus: 404 }, |
| 64 | + |
| 65 | + // Info pages |
| 66 | + { path: '/info', expectedStatus: 404 }, |
| 67 | + { path: '/info/changelog', expectedText: 'Changelog' }, |
| 68 | + { path: '/info/insights' }, |
| 69 | + |
| 70 | + // Auth and user account surfaces |
| 71 | + { path: '/auth', expectedStatus: 404 }, |
| 72 | + { path: '/auth/login', expectedText: 'Who are you again?' }, |
| 73 | + { path: '/auth/signup', expectedText: "We haven't been introduced yet" }, |
| 74 | + { path: '/auth/reset-password' }, |
| 75 | + { path: '/auth/callback' }, |
| 76 | + { path: '/auth/complete-profile' }, |
| 77 | + { path: '/u/example-user' }, |
| 78 | + { path: '/user', expectedStatus: 404 }, |
| 79 | + { path: '/user/account' }, |
| 80 | + { path: '/user/area' }, |
| 81 | + |
| 82 | + // Admin, retired links, and explicit not-found |
| 83 | + { path: '/admin' }, |
| 84 | + { path: '/links/retired' }, |
| 85 | + { path: '/404', expectedText: 'Page not found!', expectedStatus: 404 }, |
| 86 | +]; |
| 87 | + |
| 88 | +await new Promise((resolveListen, rejectListen) => { |
| 89 | + httpServer.once('error', rejectListen); |
| 90 | + httpServer.listen(0, '127.0.0.1', resolveListen); |
| 91 | +}); |
| 92 | + |
| 93 | +try { |
| 94 | + const address = httpServer.address(); |
| 95 | + assert.ok(address && typeof address === 'object', 'SSR smoke server did not expose a listening port'); |
| 96 | + const baseUrl = `http://127.0.0.1:${ address.port }`; |
| 97 | + |
| 98 | + for (const routeCase of ssrRouteCases) { |
| 99 | + await assertServerRenderedPage( |
| 100 | + `${ baseUrl }${ routeCase.path }`, |
| 101 | + routeCase.expectedText, |
| 102 | + routeCase.expectedStatus ?? 200, |
| 103 | + ); |
| 104 | + } |
| 105 | +} catch (error) { |
| 106 | + failure = error; |
| 107 | +} finally { |
| 108 | + await new Promise((resolveClose, rejectClose) => { |
| 109 | + httpServer.close(error => error ? rejectClose(error) : resolveClose()); |
| 110 | + }); |
| 111 | +} |
| 112 | + |
| 113 | +if (failure) { |
| 114 | + console.error(failure); |
| 115 | + process.exit(1); |
| 116 | +} |
| 117 | + |
| 118 | +function recordFailure(error) { |
| 119 | + failure ??= error; |
| 120 | +} |
| 121 | + |
| 122 | +async function assertServerRenderedPage(url, expectedText, expectedStatus = 200) { |
| 123 | + const response = await fetch(url, { |
| 124 | + headers: { |
| 125 | + host: 'patcher.xyz', |
| 126 | + 'x-forwarded-host': 'patcher.xyz', |
| 127 | + 'x-forwarded-proto': 'https', |
| 128 | + 'user-agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)', |
| 129 | + }, |
| 130 | + }); |
| 131 | + const html = await response.text(); |
| 132 | + |
| 133 | + assert.equal(response.status, expectedStatus, `${ url } should SSR with HTTP ${ expectedStatus }; body: ${ html.slice(0, 500) }`); |
| 134 | + assert.match(html, /<app-root[^>]*>/, `${ url } should include the Angular root`); |
| 135 | + if (expectedText) { |
| 136 | + assert.ok(html.includes(expectedText), `${ url } should contain server-rendered page text "${ expectedText }"`); |
| 137 | + } |
| 138 | + assert.doesNotMatch(html, /<app-root[^>]*><\/app-root>/, `${ url } should not return the empty SPA shell`); |
| 139 | + assert.doesNotMatch(html, /Internal Server Error/i, `${ url } should not return the SSR error response`); |
| 140 | +} |
0 commit comments