diff --git a/.github/workflows/generate-stats.yml b/.github/workflows/generate-stats.yml index e77f71bb3..465f0a3cd 100644 --- a/.github/workflows/generate-stats.yml +++ b/.github/workflows/generate-stats.yml @@ -71,6 +71,7 @@ jobs: deps-matrix: ${{ steps.set-matrix.outputs.deps }} client-side-rendered-matrix: ${{ steps.set-matrix.outputs.client-side-rendered }} server-side-rendered-matrix: ${{ steps.set-matrix.outputs.server-side-rendered }} + initial-load-matrix: ${{ steps.set-matrix.outputs.initial-load }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -102,6 +103,7 @@ jobs: echo "deps=$(echo "$FRAMEWORKS" | jq -c '[.[] | select(.starter) | select(.starter.measurements | contains(["dependencies"])) | {name, displayName, package: .starter.package}]')" >> $GITHUB_OUTPUT echo "client-side-rendered=$(echo "$FRAMEWORKS" | jq -c '[.[] | select(.app) | select(.app.measurements | contains(["clientSideRendered"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT echo "server-side-rendered=$(echo "$FRAMEWORKS" | jq -c '[.[] | select(.app) | select(.app.measurements | contains(["serverSideRendered"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT + echo "initial-load=$(echo "$FRAMEWORKS" | jq -c '[.[] | select(.app) | select(.app.measurements | contains(["initialLoad"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT measure: name: Run @@ -117,6 +119,7 @@ jobs: deps-matrix: ${{ needs.setup.outputs.deps-matrix }} client-side-rendered-matrix: ${{ needs.setup.outputs.client-side-rendered-matrix }} server-side-rendered-matrix: ${{ needs.setup.outputs.server-side-rendered-matrix }} + initial-load-matrix: ${{ needs.setup.outputs.initial-load-matrix }} validate: name: Validate results diff --git a/.github/workflows/measure-framework.yml b/.github/workflows/measure-framework.yml index 3827ca43b..ba19b36ca 100644 --- a/.github/workflows/measure-framework.yml +++ b/.github/workflows/measure-framework.yml @@ -48,6 +48,11 @@ on: type: string required: false default: '[]' + initial-load-matrix: + description: 'JSON array of frameworks to measure initial page resource downloads' + type: string + required: false + default: '[]' jobs: measure-install: @@ -470,3 +475,49 @@ jobs: path: packages/${{ matrix.framework.package }}/ci-stats.json retention-days: 1 if-no-files-found: error + + measure-initial-load: + name: ${{ matrix.framework.displayName }} · Initial load + if: inputs.initial-load-matrix != '[]' + runs-on: depot-ubuntu-24.04 + strategy: + fail-fast: false + matrix: + framework: ${{ fromJson(inputs.initial-load-matrix) }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Install package dependencies + working-directory: ./packages/${{ matrix.framework.package }} + run: pnpm install --frozen-lockfile + + - name: Build app + working-directory: ./packages/${{ matrix.framework.package }} + run: pnpm build + + - name: Verify runner Chrome + run: google-chrome --version + + - name: Run initial load benchmark + run: pnpm --filter @framework-tracker/stats-generator run:initial-load ${{ matrix.framework.package }} + + - name: Upload initial load stats + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: initial-load-stats-${{ matrix.framework.name }} + path: packages/${{ matrix.framework.package }}/ci-stats.json + retention-days: 1 + if-no-files-found: error diff --git a/.github/workflows/preview-stats.yml b/.github/workflows/preview-stats.yml index 23022b437..cf7a527eb 100644 --- a/.github/workflows/preview-stats.yml +++ b/.github/workflows/preview-stats.yml @@ -16,6 +16,7 @@ jobs: ssr-request-throughput-matrix: ${{ steps.detect.outputs.ssr-request-throughput }} ssr-load-matrix: ${{ steps.detect.outputs.ssr-load }} ssr-router-link-load-matrix: ${{ steps.detect.outputs.ssr-router-link-load }} + initial-load-matrix: ${{ steps.detect.outputs.initial-load }} deps-matrix: ${{ steps.detect.outputs.deps }} framework-names: ${{ steps.detect.outputs.framework-names }} has-changes: ${{ steps.detect.outputs.has-changes }} @@ -66,6 +67,7 @@ jobs: echo "ssr-request-throughput=[]" >> $GITHUB_OUTPUT echo "ssr-load=[]" >> $GITHUB_OUTPUT echo "ssr-router-link-load=[]" >> $GITHUB_OUTPUT + echo "initial-load=[]" >> $GITHUB_OUTPUT echo "deps=[]" >> $GITHUB_OUTPUT exit 0 fi @@ -92,6 +94,7 @@ jobs: echo "ssr-request-throughput=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.app) | select(.app.measurements | contains(["ssrRequestThroughput"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT echo "ssr-load=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.app) | select(.app.measurements | contains(["ssrLoad"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT echo "ssr-router-link-load=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.app) | select(.app.measurements | contains(["ssrRouterLinkLoad"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT + echo "initial-load=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.app) | select(.app.measurements | contains(["initialLoad"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT echo "deps=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.starter) | select(.starter.measurements | contains(["dependencies"])) | {name, displayName, package: .starter.package}]')" >> $GITHUB_OUTPUT measure: @@ -106,6 +109,7 @@ jobs: ssr-request-throughput-matrix: ${{ needs.setup.outputs.ssr-request-throughput-matrix }} ssr-load-matrix: ${{ needs.setup.outputs.ssr-load-matrix }} ssr-router-link-load-matrix: ${{ needs.setup.outputs.ssr-router-link-load-matrix }} + initial-load-matrix: ${{ needs.setup.outputs.initial-load-matrix }} deps-matrix: ${{ needs.setup.outputs.deps-matrix }} preview-comment: diff --git a/packages/app-astro/src/pages/initial-load.astro b/packages/app-astro/src/pages/initial-load.astro new file mode 100644 index 000000000..b0d843190 --- /dev/null +++ b/packages/app-astro/src/pages/initial-load.astro @@ -0,0 +1,55 @@ +--- +import '../../../testdata/src/initial-load.css' + +export const prerender = false +--- + + + Ecosystem Performance
+
+

Ecosystem Performance

+ Small steps. Faster experiences. +

+ A healthier JavaScript ecosystem starts with the essentials. Explore + three ways to make your projects lighter, faster, and easier to + maintain. +

+
+
+ diff --git a/packages/app-next-js/app/initial-load/page.tsx b/packages/app-next-js/app/initial-load/page.tsx new file mode 100644 index 000000000..fb3c0cbf9 --- /dev/null +++ b/packages/app-next-js/app/initial-load/page.tsx @@ -0,0 +1,72 @@ +import { Suspense } from 'react' +import { connection } from 'next/server' +import '../../../testdata/src/initial-load.css' + +export default function InitialLoad() { + return ( + + + + ) +} + +async function RequestContent() { + await connection() + + return ( + <> +
+
+

Ecosystem Performance

+

Small steps. Faster experiences.

+

+ A healthier JavaScript ecosystem starts with the essentials. Explore + three ways to make your projects lighter, faster, and easier to + maintain. +

+ +
+
+ + ) +} diff --git a/packages/app-nuxt/app/pages/initial-load.vue b/packages/app-nuxt/app/pages/initial-load.vue new file mode 100644 index 000000000..7831f980b --- /dev/null +++ b/packages/app-nuxt/app/pages/initial-load.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/app-pracht/src/routes.ts b/packages/app-pracht/src/routes.ts index dea31ca6b..116218d92 100644 --- a/packages/app-pracht/src/routes.ts +++ b/packages/app-pracht/src/routes.ts @@ -2,6 +2,10 @@ import { defineApp, route } from '@pracht/core' export const app = defineApp({ routes: [ + route('/initial-load', './routes/initial-load.tsx', { + id: 'initial-load', + render: 'ssr', + }), route('/', './routes/home.tsx', { id: 'home', render: 'ssr' }), route('/server-side-rendered', './routes/server-side-rendered.tsx', { id: 'server-side-rendered', diff --git a/packages/app-pracht/src/routes/initial-load.tsx b/packages/app-pracht/src/routes/initial-load.tsx new file mode 100644 index 000000000..bd817aa45 --- /dev/null +++ b/packages/app-pracht/src/routes/initial-load.tsx @@ -0,0 +1,60 @@ +import '../../../testdata/src/initial-load.css' + +export function Component() { + return ( + <> +
+
+

Ecosystem Performance

+

Small steps. Faster experiences.

+

+ A healthier JavaScript ecosystem starts with the essentials. Explore + three ways to make your projects lighter, faster, and easier to + maintain. +

+ +
+
+ + ) +} diff --git a/packages/app-react-router/app/routes.ts b/packages/app-react-router/app/routes.ts index e1d1e5973..a52d2346a 100644 --- a/packages/app-react-router/app/routes.ts +++ b/packages/app-react-router/app/routes.ts @@ -2,6 +2,7 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes' export default [ index('routes/home.tsx'), + route('/initial-load', 'routes/initial-load.tsx'), route('/client-side-rendered', 'routes/client-side-rendered.tsx'), route('/client-side-rendered/:id', 'routes/client-side-rendered.detail.tsx'), route('/server-side-rendered', 'routes/server-side-rendered.tsx'), diff --git a/packages/app-react-router/app/routes/initial-load.tsx b/packages/app-react-router/app/routes/initial-load.tsx new file mode 100644 index 000000000..6a6c3bfbb --- /dev/null +++ b/packages/app-react-router/app/routes/initial-load.tsx @@ -0,0 +1,60 @@ +import '../../../testdata/src/initial-load.css' + +export default function InitialLoad() { + return ( + <> +
+
+

Ecosystem Performance

+

Small steps. Faster experiences.

+

+ A healthier JavaScript ecosystem starts with the essentials. Explore + three ways to make your projects lighter, faster, and easier to + maintain. +

+ +
+
+ + ) +} diff --git a/packages/app-solid-start/src/routes/initial-load.tsx b/packages/app-solid-start/src/routes/initial-load.tsx new file mode 100644 index 000000000..6a6c3bfbb --- /dev/null +++ b/packages/app-solid-start/src/routes/initial-load.tsx @@ -0,0 +1,60 @@ +import '../../../testdata/src/initial-load.css' + +export default function InitialLoad() { + return ( + <> +
+
+

Ecosystem Performance

+

Small steps. Faster experiences.

+

+ A healthier JavaScript ecosystem starts with the essentials. Explore + three ways to make your projects lighter, faster, and easier to + maintain. +

+ +
+
+ + ) +} diff --git a/packages/app-sveltekit/src/routes/initial-load/+page.svelte b/packages/app-sveltekit/src/routes/initial-load/+page.svelte new file mode 100644 index 000000000..31f5fb023 --- /dev/null +++ b/packages/app-sveltekit/src/routes/initial-load/+page.svelte @@ -0,0 +1,5 @@ + + +

Ecosystem Performance

Small steps. Faster experiences.

A healthier JavaScript ecosystem starts with the essentials. Explore three ways to make your projects lighter, faster, and easier to maintain.

diff --git a/packages/app-tanstack-start-react/src/routeTree.gen.ts b/packages/app-tanstack-start-react/src/routeTree.gen.ts index 0fe40cb99..3c6f49f77 100644 --- a/packages/app-tanstack-start-react/src/routeTree.gen.ts +++ b/packages/app-tanstack-start-react/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as ClientSideRenderedRouteImport } from './routes/client-side-rendered' +import { Route as InitialLoadRouteImport } from './routes/initial-load' import { Route as ServerSideRenderedRouteImport } from './routes/server-side-rendered' import { Route as ServerSideRenderedPlainLinksRouteImport } from './routes/server-side-rendered-plain-links' import { Route as SsrThroughputRouteImport } from './routes/ssr-throughput' @@ -27,6 +28,11 @@ const ClientSideRenderedRoute = ClientSideRenderedRouteImport.update({ path: '/client-side-rendered', getParentRoute: () => rootRouteImport, } as any) +const InitialLoadRoute = InitialLoadRouteImport.update({ + id: '/initial-load', + path: '/initial-load', + getParentRoute: () => rootRouteImport, +} as any) const ServerSideRenderedRoute = ServerSideRenderedRouteImport.update({ id: '/server-side-rendered', path: '/server-side-rendered', @@ -57,6 +63,7 @@ const ServerSideRenderedIdRoute = ServerSideRenderedIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/client-side-rendered': typeof ClientSideRenderedRoute + '/initial-load': typeof InitialLoadRoute '/server-side-rendered': typeof ServerSideRenderedRoute '/server-side-rendered-plain-links': typeof ServerSideRenderedPlainLinksRoute '/ssr-throughput': typeof SsrThroughputRoute @@ -66,6 +73,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/client-side-rendered': typeof ClientSideRenderedRoute + '/initial-load': typeof InitialLoadRoute '/server-side-rendered': typeof ServerSideRenderedRoute '/server-side-rendered-plain-links': typeof ServerSideRenderedPlainLinksRoute '/ssr-throughput': typeof SsrThroughputRoute @@ -76,6 +84,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/client-side-rendered': typeof ClientSideRenderedRoute + '/initial-load': typeof InitialLoadRoute '/server-side-rendered': typeof ServerSideRenderedRoute '/server-side-rendered-plain-links': typeof ServerSideRenderedPlainLinksRoute '/ssr-throughput': typeof SsrThroughputRoute @@ -87,6 +96,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/client-side-rendered' + | '/initial-load' | '/server-side-rendered' | '/server-side-rendered-plain-links' | '/ssr-throughput' @@ -96,6 +106,7 @@ export interface FileRouteTypes { to: | '/' | '/client-side-rendered' + | '/initial-load' | '/server-side-rendered' | '/server-side-rendered-plain-links' | '/ssr-throughput' @@ -105,6 +116,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/client-side-rendered' + | '/initial-load' | '/server-side-rendered' | '/server-side-rendered-plain-links' | '/ssr-throughput' @@ -115,6 +127,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ClientSideRenderedRoute: typeof ClientSideRenderedRoute + InitialLoadRoute: typeof InitialLoadRoute ServerSideRenderedRoute: typeof ServerSideRenderedRoute ServerSideRenderedPlainLinksRoute: typeof ServerSideRenderedPlainLinksRoute SsrThroughputRoute: typeof SsrThroughputRoute @@ -138,6 +151,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ClientSideRenderedRouteImport parentRoute: typeof rootRouteImport } + '/initial-load': { + id: '/initial-load' + path: '/initial-load' + fullPath: '/initial-load' + preLoaderRoute: typeof InitialLoadRouteImport + parentRoute: typeof rootRouteImport + } '/server-side-rendered': { id: '/server-side-rendered' path: '/server-side-rendered' @@ -179,6 +199,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ClientSideRenderedRoute: ClientSideRenderedRoute, + InitialLoadRoute: InitialLoadRoute, ServerSideRenderedRoute: ServerSideRenderedRoute, ServerSideRenderedPlainLinksRoute: ServerSideRenderedPlainLinksRoute, SsrThroughputRoute: SsrThroughputRoute, diff --git a/packages/app-tanstack-start-react/src/routes/initial-load.tsx b/packages/app-tanstack-start-react/src/routes/initial-load.tsx new file mode 100644 index 000000000..c7128a2c6 --- /dev/null +++ b/packages/app-tanstack-start-react/src/routes/initial-load.tsx @@ -0,0 +1,66 @@ +import '../../../testdata/src/initial-load.css' + +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/initial-load')({ + component: InitialLoad, +}) + +function InitialLoad() { + return ( + <> +
+
+

Ecosystem Performance

+

Small steps. Faster experiences.

+

+ A healthier JavaScript ecosystem starts with the essentials. Explore + three ways to make your projects lighter, faster, and easier to + maintain. +

+ +
+
+ + ) +} diff --git a/packages/results/src/frameworks.ts b/packages/results/src/frameworks.ts index a4f238a96..a565b6b9e 100644 --- a/packages/results/src/frameworks.ts +++ b/packages/results/src/frameworks.ts @@ -56,6 +56,7 @@ export const frameworks = [ 'ssrRequestThroughput', 'clientSideRendered', 'serverSideRendered', + 'initialLoad', 'ssrLoad', ], }, @@ -86,6 +87,7 @@ export const frameworks = [ 'ssrRequestThroughput', 'clientSideRendered', 'serverSideRendered', + 'initialLoad', 'ssrLoad', 'ssrRouterLinkLoad', ], @@ -117,6 +119,7 @@ export const frameworks = [ 'ssrRequestThroughput', 'clientSideRendered', 'serverSideRendered', + 'initialLoad', 'ssrLoad', 'ssrRouterLinkLoad', ], @@ -148,6 +151,7 @@ export const frameworks = [ 'ssrRequestThroughput', 'clientSideRendered', 'serverSideRendered', + 'initialLoad', 'ssrLoad', 'ssrRouterLinkLoad', ], @@ -179,6 +183,7 @@ export const frameworks = [ 'ssrRequestThroughput', 'clientSideRendered', 'serverSideRendered', + 'initialLoad', 'ssrLoad', 'ssrRouterLinkLoad', ], @@ -210,6 +215,7 @@ export const frameworks = [ 'ssrRequestThroughput', 'clientSideRendered', 'serverSideRendered', + 'initialLoad', 'ssrLoad', ], }, @@ -240,6 +246,7 @@ export const frameworks = [ 'ssrRequestThroughput', 'clientSideRendered', 'serverSideRendered', + 'initialLoad', 'ssrLoad', ], }, @@ -270,6 +277,7 @@ export const frameworks = [ 'ssrRequestThroughput', 'clientSideRendered', 'serverSideRendered', + 'initialLoad', 'ssrLoad', 'ssrRouterLinkLoad', ], diff --git a/packages/results/src/measurements.ts b/packages/results/src/measurements.ts index 4304d1b9b..e81389d36 100644 --- a/packages/results/src/measurements.ts +++ b/packages/results/src/measurements.ts @@ -11,6 +11,7 @@ export const measurements = { ssrRouterLinkLoad: { runs: 3 }, clientSideRendered: { runs: 5 }, serverSideRendered: { runs: 5 }, + initialLoad: {}, } satisfies Record export type MeasurementName = keyof typeof measurements diff --git a/packages/results/src/schemas.ts b/packages/results/src/schemas.ts index 435a343da..8c548f3a2 100644 --- a/packages/results/src/schemas.ts +++ b/packages/results/src/schemas.ts @@ -23,6 +23,7 @@ export const MetricUnitSchema = z.enum([ 'errors', 'requests/s', 'bytes/s', + 'bytes', ]) export const MetricBaseSchema = z.object({ @@ -455,6 +456,43 @@ export const ServerSideRenderedStatsSchema = z.object({ browserVersion: z.string().min(1), }) +/** @see https://developer.chrome.com/docs/lighthouse/performance/resource-summary */ +export const InitialLoadResourceTypeSchema = z.enum([ + 'total', + 'document', + 'script', + 'stylesheet', + 'image', + 'media', + 'font', + 'other', + 'third-party', +]) + +export const InitialLoadResourceSchema = z.object({ + requestCount: z.number().int().nonnegative(), + /** Transferred bytes, including response headers, as reported by Lighthouse. */ + transferSize: z.number().int().nonnegative(), +}) + +export const InitialLoadStatsSchema = TestBaseSchema.extend({ + name: z.literal('initialLoad'), + source: z.literal('lighthouse-resource-summary'), + path: z.literal('/initial-load'), + resources: z.record(InitialLoadResourceTypeSchema, InitialLoadResourceSchema), +}) + +export const InitialLoadResultSchema = z.object({ + initialLoadTests: InitialLoadStatsSchema, + frameworkVersion: z.string().optional(), + timingMeasuredAt: z.string().optional(), + runner: z.string().optional(), + browserVersion: z.string().min(1), +}) + +export type InitialLoadStats = z.infer +export type InitialLoadResult = z.infer + export const DependencyCountsSchema = z.object({ prodDependencies: z.number().int().nonnegative(), devDependencies: z.number().int().nonnegative(), @@ -497,6 +535,7 @@ export const FrameworkResultSchema = z.object({ browserVersion: z.string().optional(), frameworkVersion: z.string().optional(), releasedAt: z.string().optional(), + initialLoadTests: InitialLoadStatsSchema.optional(), install: InstallBenchmarkSchema.optional(), coldBuildTime: BuildStatsSchema.shape.coldBuildTime.optional(), warmBuildTime: BuildStatsSchema.shape.warmBuildTime.optional(), diff --git a/packages/stats-generator/package.json b/packages/stats-generator/package.json index 3bec65cd6..61761084c 100644 --- a/packages/stats-generator/package.json +++ b/packages/stats-generator/package.json @@ -13,6 +13,7 @@ "serve:ssr-load": "node src/run-ssr-load-server.ts", "run:client-side-rendered": "node src/run-client-side-rendered-benchmark.ts", "run:server-side-rendered": "node src/run-server-side-rendered-benchmark.ts", + "run:initial-load": "node src/run-initial-load-benchmark.ts", "run:install": "node src/run-install-benchmark.ts", "run:build": "node src/run-build-benchmark.ts", "run:dev-server": "node src/run-dev-server-benchmark.ts", diff --git a/packages/stats-generator/src/benchmark-server.test.ts b/packages/stats-generator/src/benchmark-server.test.ts new file mode 100644 index 000000000..7a9858da8 --- /dev/null +++ b/packages/stats-generator/src/benchmark-server.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { test } from 'node:test' +import { waitForBenchmarkServer } from './benchmark-server.ts' + +test('readiness deadline interrupts a server that never responds', async () => { + const server = createServer(() => {}) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + assert.ok(address && typeof address !== 'string') + try { + await assert.rejects( + waitForBenchmarkServer( + `http://127.0.0.1:${address.port}`, + AbortSignal.timeout(100), + ), + { name: 'TimeoutError' }, + ) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } +}) diff --git a/packages/stats-generator/src/benchmark-server.ts b/packages/stats-generator/src/benchmark-server.ts new file mode 100644 index 000000000..d29df3fee --- /dev/null +++ b/packages/stats-generator/src/benchmark-server.ts @@ -0,0 +1,83 @@ +import { spawn } from 'node:child_process' +import { join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' +import { packagesDir } from './constants.ts' +import type { ServingFrameworkConfig } from './serving-frameworks.ts' + +export async function waitForBenchmarkServer( + url: string, + signal: AbortSignal, +): Promise { + while (true) { + signal.throwIfAborted() + try { + const response = await fetch(url, { signal }) + await response.body?.cancel() + if (response.status === 200) return + } catch { + signal.throwIfAborted() + } + await sleep(500, undefined, { signal }) + } +} + +export async function startBenchmarkServer( + config: Pick, + options: { + host: string + port: number + readinessPath: string + timeoutMs?: number + }, +): Promise<() => void> { + const { host, port, readinessPath, timeoutMs = 30_000 } = options + const scriptPath = fileURLToPath( + new URL(`./serve/${config.serveScript}`, import.meta.url), + ) + const proc = spawn( + process.execPath, + [scriptPath, join(packagesDir, config.package)], + { + env: { + ...process.env, + HOST: host, + NODE_ENV: 'production', + PORT: String(port), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + proc.stdout.on('data', (chunk: Buffer) => + process.stdout.write(`[${config.package}] ${chunk}`), + ) + proc.stderr.on('data', (chunk: Buffer) => + process.stderr.write(`[${config.package}] ${chunk}`), + ) + + const lifecycle = new AbortController() + proc.once('error', (error) => lifecycle.abort(error)) + proc.once('exit', (code, signal) => { + lifecycle.abort( + new Error(`Server process exited with ${signal ?? `code ${code}`}`), + ) + }) + const stop = () => { + lifecycle.abort(new Error('Server stopped')) + if (proc.exitCode === null && proc.signalCode === null) proc.kill('SIGTERM') + } + const url = `http://${host}:${port}${readinessPath}` + try { + await waitForBenchmarkServer( + url, + AbortSignal.any([lifecycle.signal, AbortSignal.timeout(timeoutMs)]), + ) + } catch (error) { + stop() + throw new Error( + `Server at ${url} failed to become ready within ${timeoutMs}ms`, + { cause: error }, + ) + } + return stop +} diff --git a/packages/stats-generator/src/find-chromium.ts b/packages/stats-generator/src/find-chromium.ts new file mode 100644 index 000000000..5916dc02b --- /dev/null +++ b/packages/stats-generator/src/find-chromium.ts @@ -0,0 +1,29 @@ +import { existsSync, readdirSync } from 'node:fs' + +export function findChromium(): string { + if (process.env.CHROME_PATH) return process.env.CHROME_PATH + + const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH + if (browsersPath && existsSync(browsersPath)) { + const chromiumDir = readdirSync(browsersPath).find((e) => + e.startsWith('chromium-'), + ) + if (chromiumDir) { + const chromePath = `${browsersPath}/${chromiumDir}/chrome-linux/chrome` + if (existsSync(chromePath)) return chromePath + } + } + + const candidates = [ + '/usr/bin/google-chrome', + '/usr/bin/chromium-browser', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + + throw new Error( + 'Could not find Chromium/Chrome. Set the CHROME_PATH env var.', + ) +} diff --git a/packages/stats-generator/src/initial-load-persistence.test.ts b/packages/stats-generator/src/initial-load-persistence.test.ts new file mode 100644 index 000000000..b1add7d63 --- /dev/null +++ b/packages/stats-generator/src/initial-load-persistence.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { InitialLoadResourceTypeSchema } from '@framework-tracker/results' +import { + projectResult, + readProjectResult, +} from '@framework-tracker/results/storage' +import { frameworks } from './frameworks.ts' +import { processCIStats } from './save-ci-stats.ts' + +test('CI requires, validates, and preserves initial load artifacts', async () => { + const directory = mkdtempSync(join(tmpdir(), 'initial-load-ci-')) + const framework = frameworks.find((framework) => framework.name === 'astro')! + const configured = { + ...framework, + starter: undefined, + app: { ...framework.app!, measurements: ['initialLoad' as const] }, + } + const options = { + validateOnly: true, + requireAllArtifacts: true, + frameworks: [configured], + } + const path = join(directory, 'initial-load-stats-astro', 'ci-stats.json') + const resources = Object.fromEntries( + InitialLoadResourceTypeSchema.options.map((key) => [ + key, + { requestCount: 0, transferSize: 0 }, + ]), + ) + const artifact = { + frameworkVersion: readProjectResult('app-astro')?.frameworkVersion, + browserVersion: 'Chrome/156', + initialLoadTests: { + name: 'initialLoad', + source: 'lighthouse-resource-summary', + path: '/initial-load', + resources, + }, + } + try { + await assert.rejects( + processCIStats(directory, options), + /Missing or invalid measurement artifact/, + ) + mkdirSync(join(directory, 'initial-load-stats-astro')) + writeFileSync(path, JSON.stringify(artifact)) + const results = await processCIStats(directory, options) + assert.deepEqual( + projectResult(results.get('astro')!, 'app')?.initialLoadTests, + artifact.initialLoadTests, + ) + assert.equal( + projectResult(results.get('astro')!, 'app')?.frameworkVersion, + artifact.frameworkVersion, + ) + writeFileSync(path, JSON.stringify({ ...artifact, initialLoadTests: {} })) + await assert.rejects(processCIStats(directory, options)) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/stats-generator/src/initial-load.test.ts b/packages/stats-generator/src/initial-load.test.ts new file mode 100644 index 000000000..45c100349 --- /dev/null +++ b/packages/stats-generator/src/initial-load.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { InitialLoadResourceTypeSchema } from '@framework-tracker/results' +import { parseResourceSummary } from './initialLoad/run-benchmark.ts' + +const items = InitialLoadResourceTypeSchema.options.map((resourceType) => ({ + resourceType, + requestCount: 1, + transferSize: 100, +})) + +test('initial load retains Lighthouse totals without adding third-party again', () => { + const result = parseResourceSummary({ type: 'table', items }) + assert.deepEqual(result.resources.total, { + requestCount: 1, + transferSize: 100, + }) + assert.equal('iterations' in result, false) +}) + +test('initial load rejects missing, duplicate, or invalid resource rows', () => { + for (const details of [ + undefined, + { type: 'table', items: items.slice(1) }, + { type: 'table', items: [...items, items[0]] }, + { + type: 'table', + items: [{ ...items[0], transferSize: -1 }, ...items.slice(1)], + }, + ]) { + assert.throws(() => parseResourceSummary(details)) + } +}) diff --git a/packages/stats-generator/src/initialLoad/index.ts b/packages/stats-generator/src/initialLoad/index.ts new file mode 100644 index 000000000..d125f0078 --- /dev/null +++ b/packages/stats-generator/src/initialLoad/index.ts @@ -0,0 +1,36 @@ +import { servingFrameworks } from '../serving-frameworks.ts' +import { startBenchmarkServer } from '../benchmark-server.ts' +import { getHost } from '../serve/common.ts' +import { runBenchmark } from './run-benchmark.ts' +import type { InitialLoadResult } from '@framework-tracker/results' + +const INITIAL_LOAD_HOST = getHost() +const INITIAL_LOAD_PORT = 3003 + +export async function runInitialLoadBenchmark( + packageName: string, +): Promise { + const config = servingFrameworks.find((f) => f.package === packageName) + + if (!config) { + throw new Error( + `Unknown initial load package: ${packageName}. Available: ${servingFrameworks.map((f) => f.package).join(', ')}`, + ) + } + + console.info(`Starting server for ${config.displayName}...`) + const killServer = await startBenchmarkServer(config, { + host: INITIAL_LOAD_HOST, + port: INITIAL_LOAD_PORT, + readinessPath: '/initial-load', + }) + + try { + console.info(`Running initial load benchmark for ${config.displayName}...`) + return await runBenchmark( + `http://${INITIAL_LOAD_HOST}:${INITIAL_LOAD_PORT}`, + ) + } finally { + killServer() + } +} diff --git a/packages/stats-generator/src/initialLoad/run-benchmark.ts b/packages/stats-generator/src/initialLoad/run-benchmark.ts new file mode 100644 index 000000000..58e524f1e --- /dev/null +++ b/packages/stats-generator/src/initialLoad/run-benchmark.ts @@ -0,0 +1,78 @@ +import puppeteer from 'puppeteer-core' +import { startFlow } from 'lighthouse' +import { z } from 'zod' +import { + InitialLoadResourceTypeSchema, + InitialLoadResourceSchema, + InitialLoadStatsSchema, +} from '@framework-tracker/results' +import { findChromium } from '../find-chromium.ts' + +const ResourceRowsSchema = z.array( + InitialLoadResourceSchema.extend({ + resourceType: InitialLoadResourceTypeSchema, + }), +) + +export function parseResourceSummary(details: unknown) { + const table = z + .object({ type: z.literal('table'), items: ResourceRowsSchema }) + .parse(details) + const resources = Object.fromEntries( + table.items.map(({ resourceType, requestCount, transferSize }) => [ + resourceType, + { requestCount, transferSize }, + ]), + ) + if ( + new Set(table.items.map((row) => row.resourceType)).size !== + table.items.length + ) { + throw new Error('Duplicate Lighthouse resource-summary category') + } + // Do not include third-party when calculating totals. + // https://developer.chrome.com/docs/lighthouse/performance/resource-summary + return InitialLoadStatsSchema.parse({ + name: 'initialLoad', + source: 'lighthouse-resource-summary', + path: '/initial-load', + resources, + }) +} + +export async function runBenchmark(url: string) { + const browser = await puppeteer.launch({ + executablePath: findChromium(), + headless: true, + defaultViewport: { width: 800, height: 600, deviceScaleFactor: 1 }, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }) + try { + const page = await browser.newPage() + const flow = await startFlow(page, { + name: 'Initial load', + flags: { + throttlingMethod: 'provided', + formFactor: 'desktop', + screenEmulation: { disabled: true }, + onlyAudits: ['resource-summary'], + }, + }) + await flow.navigate(`${url}/initial-load`) + const result = await flow.createFlowResult() + const lhr = result.steps[0]?.lhr + if (!lhr || lhr.runtimeError) { + throw new Error( + `Initial load failed: ${lhr?.runtimeError?.message ?? 'Missing Lighthouse result'}`, + ) + } + return { + browserVersion: await browser.version(), + initialLoadTests: parseResourceSummary( + lhr.audits['resource-summary']?.details, + ), + } + } finally { + await browser.close() + } +} diff --git a/packages/stats-generator/src/run-initial-load-benchmark.ts b/packages/stats-generator/src/run-initial-load-benchmark.ts new file mode 100644 index 000000000..a43b77892 --- /dev/null +++ b/packages/stats-generator/src/run-initial-load-benchmark.ts @@ -0,0 +1,63 @@ +import { readProjectResult } from '@framework-tracker/results/storage' +import { InitialLoadResultSchema } from '@framework-tracker/results' +import { join } from 'node:path' +import { runInitialLoadBenchmark } from './initialLoad/index.ts' +import { packagesDir } from './constants.ts' +import { + getFrameworkByPackage, + getFrameworkVersion, + normalizeCIStats, + parseArgs, + readJsonFile, + writeJsonFile, +} from './utils.ts' +import type { CIStats } from './types.ts' + +async function main() { + const { packageName } = parseArgs( + 'Usage: run-initial-load-benchmark \nExample: run-initial-load-benchmark app-astro', + ) + const { framework } = await getFrameworkByPackage(packageName) + const frameworkVersion = await getFrameworkVersion( + packageName, + framework.frameworkPackage, + ) + + const result = await runInitialLoadBenchmark(packageName) + const timestamp = new Date().toISOString() + const runner = process.env.RUNNER_LABEL || 'local' + + const existingStats = normalizeCIStats( + readJsonFile(join(packagesDir, packageName, 'ci-stats.json')) ?? + (readProjectResult(packageName) as CIStats | null) ?? + {}, + ) + + const ciStats: CIStats = { + ...existingStats, + timingMeasuredAt: timestamp, + runner, + browserVersion: result.browserVersion, + frameworkVersion: frameworkVersion ?? existingStats?.frameworkVersion, + initialLoadTests: result.initialLoadTests, + } + + const outputPath = join(packagesDir, packageName, 'ci-stats.json') + InitialLoadResultSchema.parse(ciStats) + writeJsonFile(outputPath, ciStats) + + console.info( + `\n✓ Saved ${packageName} v${frameworkVersion ?? 'unknown'} (${packageName})`, + ) + console.info( + ` Requests: ${result.initialLoadTests.resources.total.requestCount}`, + ) + console.info( + ` Transfer: ${result.initialLoadTests.resources.total.transferSize} bytes`, + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/packages/stats-generator/src/save-ci-stats.ts b/packages/stats-generator/src/save-ci-stats.ts index d3cd42abe..b62f9ad60 100644 --- a/packages/stats-generator/src/save-ci-stats.ts +++ b/packages/stats-generator/src/save-ci-stats.ts @@ -13,6 +13,7 @@ import { FrameworkDependencyScanSchema, FirstPartyDependencyScanSchema, ServerSideRenderedStatsSchema, + InitialLoadResultSchema, ClientSideRenderedStatsSchema, BuildStatsSchema, DevServerStatsSchema, @@ -607,6 +608,33 @@ export async function processCIStats( ) } + const initialLoadArtifactPath = join( + artifactsDir, + `initial-load-stats-${name}`, + 'ci-stats.json', + ) + const initialLoadArtifact = readArtifact( + initialLoadArtifactPath, + framework.app, + 'initialLoad', + ) + if (initialLoadArtifact) { + const initialLoad = InitialLoadResultSchema.parse(initialLoadArtifact) + stats = { + ...stats, + browserVersion: initialLoad.browserVersion, + frameworkVersion: + initialLoad.frameworkVersion ?? stats.frameworkVersion, + initialLoadTests: initialLoad.initialLoadTests, + } + frameworkVersion = initialLoad.frameworkVersion ?? frameworkVersion + console.info(' ✓ Found initial load stats artifact') + } else { + console.warn( + `No initial load stats artifact found at ${initialLoadArtifactPath}`, + ) + } + // check for releasedAt, if not fetch from repository const releasedAt = existingStats?.frameworkVersion === frameworkVersion && diff --git a/packages/stats-generator/src/serverSideRendered/index.ts b/packages/stats-generator/src/serverSideRendered/index.ts index f7a53b92f..dca786e91 100644 --- a/packages/stats-generator/src/serverSideRendered/index.ts +++ b/packages/stats-generator/src/serverSideRendered/index.ts @@ -1,7 +1,5 @@ -import { spawn } from 'node:child_process' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { packagesDir } from '../constants.ts' +import { servingFrameworks } from '../serving-frameworks.ts' +import { startBenchmarkServer } from '../benchmark-server.ts' import { getHost } from '../serve/common.ts' import { runBenchmark } from './run-benchmark.ts' import type { ServerSideRenderedBenchmarkResult } from './types.ts' @@ -9,156 +7,24 @@ import type { ServerSideRenderedBenchmarkResult } from './types.ts' const SERVER_SIDE_RENDERED_HOST = getHost() const SERVER_SIDE_RENDERED_PORT = 3002 -interface ServerSideRenderedFrameworkConfig { - name: string - displayName: string - package: string - serveScript: string - fullDocumentNavigation: boolean -} - -const SERVER_SIDE_RENDERED_FRAMEWORKS: ServerSideRenderedFrameworkConfig[] = [ - { - name: 'astro-server-side-rendered', - displayName: 'Astro Server Side Rendered', - package: 'app-astro', - serveScript: 'astro.ts', - fullDocumentNavigation: true, - }, - { - name: 'next-server-side-rendered', - displayName: 'Next.js Server Side Rendered', - package: 'app-next-js', - serveScript: 'next.ts', - fullDocumentNavigation: false, - }, - { - name: 'nuxt-server-side-rendered', - displayName: 'Nuxt Server Side Rendered', - package: 'app-nuxt', - serveScript: 'nitro.ts', - fullDocumentNavigation: false, - }, - { - name: 'pracht-server-side-rendered', - displayName: 'Pracht Server Side Rendered', - package: 'app-pracht', - serveScript: 'pracht.ts', - fullDocumentNavigation: false, - }, - { - name: 'react-router-server-side-rendered', - displayName: 'React Router Server Side Rendered', - package: 'app-react-router', - serveScript: 'react-router.ts', - fullDocumentNavigation: false, - }, - { - name: 'solid-start-server-side-rendered', - displayName: 'SolidStart Server Side Rendered', - package: 'app-solid-start', - serveScript: 'nitro.ts', - fullDocumentNavigation: false, - }, - { - name: 'sveltekit-server-side-rendered', - displayName: 'SvelteKit Server Side Rendered', - package: 'app-sveltekit', - serveScript: 'sveltekit.ts', - fullDocumentNavigation: false, - }, - { - name: 'tanstack-start-server-side-rendered', - displayName: 'TanStack Start Server Side Rendered', - package: 'app-tanstack-start-react', - serveScript: 'tanstack-start.ts', - fullDocumentNavigation: false, - }, -] - -async function waitForServer(url: string, timeoutMs = 30_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - const res = await fetch(url) - if (res.status === 200) return - } catch { - // not ready yet - } - await new Promise((r) => setTimeout(r, 500)) - } - throw new Error(`Server at ${url} did not become ready within ${timeoutMs}ms`) -} - -async function spawnServer( - config: ServerSideRenderedFrameworkConfig, -): Promise<() => void> { - const appDir = join(packagesDir, config.package) - const scriptPath = fileURLToPath( - new URL(`../serve/${config.serveScript}`, import.meta.url), - ) - - const proc = spawn('node', [scriptPath, appDir], { - env: { - ...process.env, - HOST: SERVER_SIDE_RENDERED_HOST, - NODE_ENV: 'production', - PORT: String(SERVER_SIDE_RENDERED_PORT), - }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - - proc.stdout?.on('data', (chunk: Buffer) => - process.stdout.write(`[${config.package}] ${chunk}`), - ) - proc.stderr?.on('data', (chunk: Buffer) => - process.stderr.write(`[${config.package}] ${chunk}`), - ) - - let exited = false - proc.on('exit', (code) => { - exited = true - if (code != null && code !== 0) { - console.error(`[${config.package}] server exited with code ${code}`) - } - }) - - const exitPromise = new Promise((_, reject) => { - proc.on('exit', (code) => { - if (code != null && code !== 0) { - reject(new Error(`Server process exited with code ${code}`)) - } - }) - }) - - await Promise.race([ - waitForServer( - `http://${SERVER_SIDE_RENDERED_HOST}:${SERVER_SIDE_RENDERED_PORT}/server-side-rendered`, - ), - exitPromise, - ]) - - return () => { - if (!exited) proc.kill('SIGTERM') - } -} - export async function runServerSideRenderedBenchmark( packageName: string, runs = 5, ): Promise { - const config = SERVER_SIDE_RENDERED_FRAMEWORKS.find( - (f) => f.package === packageName, - ) + const config = servingFrameworks.find((f) => f.package === packageName) if (!config) { throw new Error( - `Unknown server side rendered package: ${packageName}. Available: ${SERVER_SIDE_RENDERED_FRAMEWORKS.map((f) => f.package).join(', ')}`, + `Unknown server side rendered package: ${packageName}. Available: ${servingFrameworks.map((f) => f.package).join(', ')}`, ) } console.info(`Starting server for ${config.displayName}...`) - const killServer = await spawnServer(config) + const killServer = await startBenchmarkServer(config, { + host: SERVER_SIDE_RENDERED_HOST, + port: SERVER_SIDE_RENDERED_PORT, + readinessPath: '/server-side-rendered', + }) try { console.info( @@ -166,7 +32,7 @@ export async function runServerSideRenderedBenchmark( ) return await runBenchmark( `http://${SERVER_SIDE_RENDERED_HOST}:${SERVER_SIDE_RENDERED_PORT}`, - config.name, + `${config.package === 'app-next-js' ? 'next' : config.package === 'app-tanstack-start-react' ? 'tanstack-start' : config.package.slice(4)}-server-side-rendered`, config.displayName, runs, config.fullDocumentNavigation, diff --git a/packages/stats-generator/src/serverSideRendered/run-benchmark.ts b/packages/stats-generator/src/serverSideRendered/run-benchmark.ts index 821f2c3e6..88c902c3d 100644 --- a/packages/stats-generator/src/serverSideRendered/run-benchmark.ts +++ b/packages/stats-generator/src/serverSideRendered/run-benchmark.ts @@ -1,7 +1,7 @@ -import { existsSync, readdirSync } from 'node:fs' import { execFileSync } from 'node:child_process' import puppeteer from 'puppeteer-core' import { startFlow } from 'lighthouse' +import { findChromium } from '../find-chromium.ts' import { warmUpServer } from '../server-warmup.ts' import { getInteractionTimingFromLighthouse, @@ -16,34 +16,6 @@ import { assembleServerRendering } from '../server-rendering.ts' const SERVER_SIDE_RENDERED_PATH = '/server-side-rendered' -function findChromium(): string { - if (process.env.CHROME_PATH) return process.env.CHROME_PATH - - const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH - if (browsersPath && existsSync(browsersPath)) { - const chromiumDir = readdirSync(browsersPath).find((e) => - e.startsWith('chromium-'), - ) - if (chromiumDir) { - const chromePath = `${browsersPath}/${chromiumDir}/chrome-linux/chrome` - if (existsSync(chromePath)) return chromePath - } - } - - const candidates = [ - '/usr/bin/google-chrome', - '/usr/bin/chromium-browser', - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - ] - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate - } - - throw new Error( - 'Could not find Chromium/Chrome. Set the CHROME_PATH env var.', - ) -} - function getBrowserVersion(chromiumPath: string): string | undefined { try { return execFileSync(chromiumPath, ['--version'], { diff --git a/packages/stats-generator/src/serving-frameworks.ts b/packages/stats-generator/src/serving-frameworks.ts new file mode 100644 index 000000000..639e1115f --- /dev/null +++ b/packages/stats-generator/src/serving-frameworks.ts @@ -0,0 +1,57 @@ +export interface ServingFrameworkConfig { + displayName: string + package: string + serveScript: string + fullDocumentNavigation: boolean +} + +export const servingFrameworks: ServingFrameworkConfig[] = [ + { + displayName: 'Astro Server Side Rendered', + package: 'app-astro', + serveScript: 'astro.ts', + fullDocumentNavigation: true, + }, + { + displayName: 'Next.js Server Side Rendered', + package: 'app-next-js', + serveScript: 'next.ts', + fullDocumentNavigation: false, + }, + { + displayName: 'Nuxt Server Side Rendered', + package: 'app-nuxt', + serveScript: 'nitro.ts', + fullDocumentNavigation: false, + }, + { + displayName: 'Pracht Server Side Rendered', + package: 'app-pracht', + serveScript: 'pracht.ts', + fullDocumentNavigation: false, + }, + { + displayName: 'React Router Server Side Rendered', + package: 'app-react-router', + serveScript: 'react-router.ts', + fullDocumentNavigation: false, + }, + { + displayName: 'SolidStart Server Side Rendered', + package: 'app-solid-start', + serveScript: 'nitro.ts', + fullDocumentNavigation: false, + }, + { + displayName: 'SvelteKit Server Side Rendered', + package: 'app-sveltekit', + serveScript: 'sveltekit.ts', + fullDocumentNavigation: false, + }, + { + displayName: 'TanStack Start Server Side Rendered', + package: 'app-tanstack-start-react', + serveScript: 'tanstack-start.ts', + fullDocumentNavigation: false, + }, +] diff --git a/packages/stats-generator/src/types.ts b/packages/stats-generator/src/types.ts index 76fbd4c39..402ec456c 100644 --- a/packages/stats-generator/src/types.ts +++ b/packages/stats-generator/src/types.ts @@ -14,6 +14,7 @@ import type { DevServerStatsSchema, InstallBenchmarkSchema, InstallResultSchema, + InitialLoadStatsSchema, } from '@framework-tracker/results' import type { SSRLoadTests } from './ssrLoad/types.ts' export type { @@ -45,6 +46,7 @@ interface RenderedTestStandardDeviation extends InteractionTiming { } export interface CIStats { + initialLoadTests?: z.infer framework?: string packagePath?: string timingMeasuredAt?: string diff --git a/packages/testdata/src/initial-load.css b/packages/testdata/src/initial-load.css new file mode 100644 index 000000000..3b49a5f48 --- /dev/null +++ b/packages/testdata/src/initial-load.css @@ -0,0 +1,110 @@ +.performance-page { + box-sizing: border-box; + min-height: 100vh; + padding: 80px 24px; + background: #f5f7f4; + color: #203329; + font-family: system-ui, sans-serif; +} +.performance-page * { + box-sizing: border-box; +} +.performance-content { + max-width: 1080px; + margin: 0 auto; +} +.performance-eyebrow { + margin: 0 0 16px; + color: #476650; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.performance-page h1 { + margin: 0; + font-size: clamp(36px, 5vw, 56px); + line-height: 1.1; + letter-spacing: -0.04em; +} +.performance-intro { + max-width: 580px; + margin: 20px 0 40px; + color: #56665b; + font-size: 18px; + line-height: 1.7; +} +.performance-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; + margin: 0; + padding: 0; + list-style: none; +} +.performance-card { + display: flex; + flex-direction: column; + padding: 28px; + background: #fff; + border: 1px solid #dce5db; + border-radius: 20px; + box-shadow: 0 6px 20px #20332906; +} +.performance-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-bottom: 32px; + border-radius: 12px; + background: #edf4e9; + color: #46653b; + font-size: 13px; + font-weight: 700; +} +.performance-card h2 { + margin: 0 0 12px; + font-size: 25px; + letter-spacing: -0.025em; +} +.performance-card p { + flex: 1; + margin: 0 0 28px; + color: #56665b; + font-size: 16px; + line-height: 1.7; +} +.performance-card a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 13px 16px; + border: 1px solid #dce5db; + border-radius: 10px; + color: #284b30; + font-size: 14px; + font-weight: 650; + text-decoration: none; +} +.performance-card a:hover { + background: #edf4e9; + border-color: #8da583; +} +.performance-card a:focus-visible { + outline: 3px solid #476650; + outline-offset: 4px; +} +@media (max-width: 760px) { + .performance-page { + padding: 48px 20px; + } + .performance-cards { + grid-template-columns: 1fr; + } + .performance-number { + margin-bottom: 24px; + } +}