diff --git a/.github/scripts/__tests__/bundle-size.test.mts b/.github/scripts/__tests__/bundle-size.test.mts new file mode 100644 index 0000000000..44f689ecd0 --- /dev/null +++ b/.github/scripts/__tests__/bundle-size.test.mts @@ -0,0 +1,719 @@ +import { describe, it, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFileSync, mkdirSync, rmSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + round1, + getGzipSize, + parseManifestEntries, + computeRootLayout, + computeRouteMetrics, + compareReport, + clearSizeCache, +} from '../bundle-size.mts'; + +// --------------------------------------------------------------------------- +// Shared temp directory with fixture chunk files. +// Initialized at module load time so testDir is set before any test runs. +// Files are large enough (varied content) to produce measurable gzip sizes. +// --------------------------------------------------------------------------- + +const testDir = join(tmpdir(), `bundle-size-test-${Date.now()}`); + +mkdirSync(testDir, { recursive: true }); + +// Each file gets unique, varied content so gzip produces a non-trivial size. +const makeJs = (prefix: string) => + Array.from( + { length: 30 }, + (_, i) => `export const ${prefix}_${i} = ${JSON.stringify(`${prefix}_v${i}_pad${i * 37 + 13}`)};`, + ).join('\n') + '\n'; + +const makeCss = (prefix: string) => + Array.from( + { length: 20 }, + (_, i) => `.${prefix}-class-${i} { color: hsl(${i * 17}, 50%, 50%); margin: ${i}px; }`, + ).join('\n') + '\n'; + +writeFileSync(join(testDir, 'route.js'), makeJs('route')); +writeFileSync(join(testDir, 'shared.js'), makeJs('shared')); +writeFileSync(join(testDir, 'root-layout.js'), makeJs('root_layout')); +writeFileSync(join(testDir, 'product-layout.js'), makeJs('product_layout')); +writeFileSync(join(testDir, 'route.css'), makeCss('route')); + +after(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// round1 +// --------------------------------------------------------------------------- + +describe('round1', () => { + it('rounds up at .05', () => { + assert.equal(round1(1.25), 1.3); + }); + + it('rounds down below .05', () => { + assert.equal(round1(1.24), 1.2); + }); + + it('returns 0 unchanged', () => { + assert.equal(round1(0), 0); + }); + + it('handles negative values', () => { + assert.equal(round1(-1.25), -1.2); + }); +}); + +// --------------------------------------------------------------------------- +// parseManifestEntries +// --------------------------------------------------------------------------- + +describe('parseManifestEntries', () => { + it('routes /layout entries to layouts', () => { + const { layouts, pages } = parseManifestEntries({ + '/app/layout': ['a.js'], + '/app/about/page': ['b.js'], + }); + + assert.deepEqual(Object.keys(layouts), ['/app/layout']); + assert.deepEqual(Object.keys(pages), ['/app/about/page']); + }); + + it('routes /page entries to pages', () => { + const { pages } = parseManifestEntries({ '/app/contact/page': ['c.js'] }); + + assert.deepEqual(Object.keys(pages), ['/app/contact/page']); + }); + + it('ignores entries ending in neither /layout nor /page', () => { + const { layouts, pages } = parseManifestEntries({ + '/app/route': ['d.js'], + '/api/handler': [], + '/app/loading': ['e.js'], + }); + + assert.deepEqual(Object.keys(layouts), []); + assert.deepEqual(Object.keys(pages), []); + }); + + it('returns empty objects for empty input', () => { + const { layouts, pages } = parseManifestEntries({}); + + assert.deepEqual(layouts, {}); + assert.deepEqual(pages, {}); + }); + + it('handles multiple layouts and pages together', () => { + const { layouts, pages } = parseManifestEntries({ + '/app/layout': ['a.js'], + '/app/products/layout': ['b.js'], + '/app/page': ['c.js'], + '/app/products/page': ['d.js'], + }); + + assert.deepEqual(Object.keys(layouts).sort(), ['/app/layout', '/app/products/layout']); + assert.deepEqual(Object.keys(pages).sort(), ['/app/page', '/app/products/page']); + }); +}); + +// --------------------------------------------------------------------------- +// computeRootLayout +// --------------------------------------------------------------------------- + +describe('computeRootLayout', () => { + beforeEach(() => clearSizeCache()); + + it('selects shortest path as root when multiple layouts exist', () => { + const layouts = { + '/[locale]/products/layout': [], + '/[locale]/layout': [], + '/[locale]/about/deep/layout': [], + }; + const { rootLayoutPath } = computeRootLayout( + Object.keys(layouts), + layouts, + new Set(), + testDir, + ); + + assert.equal(rootLayoutPath, '/[locale]/layout'); + }); + + it('returns null rootLayoutPath when layoutPaths is empty', () => { + const { rootLayoutPath, rootLayoutChunks, rootLayoutJs, rootLayoutCss } = computeRootLayout( + [], + {}, + new Set(), + testDir, + ); + + assert.equal(rootLayoutPath, null); + assert.equal(rootLayoutChunks.size, 0); + assert.equal(rootLayoutJs, 0); + assert.equal(rootLayoutCss, 0); + }); + + it('excludes sharedChunks from rootLayoutChunks', () => { + const layouts = { '/layout': ['shared.js', 'root-layout.js'] }; + const sharedChunks = new Set(['shared.js']); + const { rootLayoutChunks } = computeRootLayout( + ['/layout'], + layouts, + sharedChunks, + testDir, + ); + + assert.ok(!rootLayoutChunks.has('shared.js'), 'shared.js should be excluded'); + assert.ok(rootLayoutChunks.has('root-layout.js'), 'root-layout.js should be included'); + }); + + it('rootLayoutChunks contains all non-shared layout chunks', () => { + const layouts = { '/layout': ['root-layout.js', 'route.js'] }; + const { rootLayoutChunks } = computeRootLayout( + ['/layout'], + layouts, + new Set(), + testDir, + ); + + assert.ok(rootLayoutChunks.has('root-layout.js')); + assert.ok(rootLayoutChunks.has('route.js')); + assert.equal(rootLayoutChunks.size, 2); + }); + + it('computes non-zero sizes when real files exist', () => { + const layouts = { '/layout': ['root-layout.js'] }; + const { rootLayoutJs } = computeRootLayout( + ['/layout'], + layouts, + new Set(), + testDir, + ); + + assert.ok(rootLayoutJs > 0, `Expected rootLayoutJs > 0, got ${rootLayoutJs}`); + }); +}); + +// --------------------------------------------------------------------------- +// computeRouteMetrics +// --------------------------------------------------------------------------- + +describe('computeRouteMetrics', () => { + beforeEach(() => clearSizeCache()); + + it('firstLoadJs equals firstLoadJs arg when all chunks are non-existent', () => { + const pages = { '/app/page': [] }; + const routes = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 100, + testDir, + ); + + assert.equal(routes['/app/page'].firstLoadJs, 100); + }); + + it('firstLoadJs is greater than firstLoadJs arg when real chunk files exist', () => { + const pages = { '/app/page': ['route.js', 'route.css'] }; + const routes = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 0, + testDir, + ); + + const { js, css, firstLoadJs } = routes['/app/page']; + + assert.ok(js > 0, `js should be > 0 (real file exists), got ${js}`); + assert.ok(css > 0, `css should be > 0 (real file exists), got ${css}`); + assert.ok(firstLoadJs > 0, `firstLoadJs should be > 0, got ${firstLoadJs}`); + }); + + it('excludes sharedChunks from route chunk set', () => { + const pages = { '/app/page': ['shared.js', 'route.js'] }; + + // With both chunks in sharedChunks, routeChunks is empty -> js = 0 + const routesAllExcluded = computeRouteMetrics( + pages, + {}, + new Set(['shared.js', 'route.js']), + null, + new Set(), + 0, + testDir, + ); + + assert.equal(routesAllExcluded['/app/page'].js, 0, 'All shared chunks excluded -> js = 0'); + + clearSizeCache(); + + // With no exclusions, real files contribute -> js > 0 + const routesNoneExcluded = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 0, + testDir, + ); + + assert.ok(routesNoneExcluded['/app/page'].js > 0, 'No exclusions -> js > 0'); + }); + + it('excludes rootLayoutChunks from route chunk set', () => { + const pages = { '/app/page': ['root-layout.js', 'route.js'] }; + + // With both chunks in rootLayoutChunks, routeChunks is empty -> js = 0 + const routesAllExcluded = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(['root-layout.js', 'route.js']), + 0, + testDir, + ); + + assert.equal(routesAllExcluded['/app/page'].js, 0, 'All rootLayout chunks excluded -> js = 0'); + + clearSizeCache(); + + // With no rootLayoutChunks excluded, real files contribute -> js > 0 + const routesNoneExcluded = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 0, + testDir, + ); + + assert.ok(routesNoneExcluded['/app/page'].js > 0, 'No exclusions -> js > 0'); + }); + + it('includes non-root ancestor layout chunks in route size', () => { + // Page has no own chunks; non-root ancestor layout contributes product-layout.js + const pages = { '/[locale]/products/page': [] }; + const layouts = { + '/[locale]/layout': ['root-layout.js'], + '/[locale]/products/layout': ['product-layout.js'], + }; + const rootLayoutChunks = new Set(['root-layout.js']); + + const routes = computeRouteMetrics( + pages, + layouts, + new Set(), + '/[locale]/layout', + rootLayoutChunks, + 0, + testDir, + ); + + assert.ok( + routes['/[locale]/products/page'].js > 0, + 'Non-root ancestor layout chunk should contribute to route js', + ); + }); + + it('does not include root ancestor layout chunks in route size', () => { + // Page has no own chunks; root layout has root-layout.js (should be excluded) + const pages = { '/[locale]/page': [] }; + const layouts = { + '/[locale]/layout': ['root-layout.js'], + }; + const rootLayoutChunks = new Set(['root-layout.js']); + + const routes = computeRouteMetrics( + pages, + layouts, + new Set(), + '/[locale]/layout', + rootLayoutChunks, + 0, + testDir, + ); + + assert.equal( + routes['/[locale]/page'].js, + 0, + 'Root ancestor layout chunks should NOT contribute to route js', + ); + }); + + it('applies round1 to all output values', () => { + const pages = { '/app/page': [] }; + const routes = computeRouteMetrics( + pages, + {}, + new Set(), + null, + new Set(), + 1.25, + testDir, + ); + + // firstLoadJs = round1(1.25 + 0 + 0) = 1.3 + assert.equal(routes['/app/page'].firstLoadJs, 1.3); + assert.equal(routes['/app/page'].js, 0); + assert.equal(routes['/app/page'].css, 0); + }); +}); + +// --------------------------------------------------------------------------- +// compareReport +// The warning sign in the report output is U+26A0 U+FE0F (warning emoji). +// Warning table rows end with "| warning-emoji |" while the footer contains +// the same emoji in a sentence. Use "warning-emoji |" to match only table cells. +// --------------------------------------------------------------------------- + +const WARN_EMOJI = '\u26a0\ufe0f'; // ⚠️ +const WARN_IN_ROW = `${WARN_EMOJI} |`; // appears only in warning table cells + +describe('compareReport', () => { + function makeReport(overrides = {}) { + return { + commitSha: 'abc123', + updatedAt: '2024-01-01', + firstLoadJs: 100, + totalJs: 200, + totalCss: 10, + routes: {}, + ...overrides, + }; + } + + it('shows "No bundle size changes detected." when nothing changed', () => { + const baseline = makeReport(); + const current = makeReport(); + const report = compareReport(baseline, current); + + assert.ok(report.includes('No bundle size changes detected.')); + assert.ok(!report.includes('_No route changes detected._')); + assert.ok(!report.includes('### Per-Route First Load JS')); + }); + + it('shows "No route changes detected." when only global metrics changed', () => { + // Global metric differs (Case 2) but routes are identical → section shown, no threshold + const baseline = makeReport({ firstLoadJs: 100, routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ firstLoadJs: 110, routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('_No route changes detected._')); + assert.ok(!report.includes(`Threshold:`)); + }); + + it('does not show global metrics table when global metrics are unchanged', () => { + const baseline = makeReport(); + const current = makeReport(); + const report = compareReport(baseline, current); + + assert.ok(!report.includes('| Metric |')); + }); + + it('shows global metrics table only when metrics changed', () => { + const baseline = makeReport({ firstLoadJs: 100 }); + const current = makeReport({ firstLoadJs: 115 }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('| Metric |')); + assert.ok(report.includes('First Load JS')); + }); + + it('shows only the changed global metrics', () => { + const baseline = makeReport({ firstLoadJs: 100, totalJs: 200, totalCss: 10 }); + const current = makeReport({ firstLoadJs: 100, totalJs: 210, totalCss: 10 }); + const report = compareReport(baseline, current); + + // Use pipe-delimited patterns to match table rows only (not the section header) + assert.ok(report.includes('| Total JS |')); + assert.ok(!report.includes('| First Load JS |')); + assert.ok(!report.includes('| Total CSS |')); + }); + + it('shows NEW row for added route', () => { + const baseline = makeReport({ routes: {} }); + const current = makeReport({ + routes: { '/app/new/page': { firstLoadJs: 120, js: 60, css: 5 } }, + }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('NEW')); + assert.ok(report.includes('120 kB')); + }); + + it('shows REMOVED row for deleted route', () => { + const baseline = makeReport({ + routes: { '/app/old/page': { firstLoadJs: 120, js: 60, css: 5 } }, + }); + const current = makeReport({ routes: {} }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('REMOVED')); + assert.ok(report.includes('120 kB')); + }); + + it('does not show warning for increase under threshold', () => { + // delta=3kB, pct=3% < 5% threshold: no warning row + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 103, js: 53, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(!report.includes(WARN_IN_ROW), 'Should not have a warning table cell'); + }); + + it('shows warning for increase over threshold (over 1kB AND over threshold percent)', () => { + // delta=10kB, pct=10% > 5% threshold: warning row present + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes(WARN_IN_ROW), 'Should have a warning table cell'); + }); + + it('does not warn when delta is over threshold percent but 1kB or less', () => { + // delta=0.5kB = 50% but <=1kB: no warning + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 1, js: 1, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 1.5, js: 1.5, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(!report.includes(WARN_IN_ROW)); + }); + + it('does not warn when delta is over 1kB but at or under threshold percent', () => { + // delta=2kB = 1% < 5% threshold: no warning + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 200, js: 200, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 202, js: 202, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(!report.includes(WARN_IN_ROW)); + }); + + it('respects custom threshold: no warning when under', () => { + // delta=8kB = 8%, threshold=10: no warning + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 108, js: 58, css: 5 } } }); + const report = compareReport(baseline, current, { threshold: 10 }); + + assert.ok(!report.includes(WARN_IN_ROW)); + }); + + it('respects custom threshold: warning when over', () => { + // delta=8kB = 8%, threshold=3: warning + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 108, js: 58, css: 5 } } }); + const report = compareReport(baseline, current, { threshold: 3 }); + + assert.ok(report.includes(WARN_IN_ROW)); + }); + + it('uses default threshold of 5 percent when not specified', () => { + // delta=6kB = 6% > 5%: warning with default + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 100, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 106, js: 106, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes(WARN_IN_ROW)); + assert.ok(report.includes('Threshold: 5%')); + }); + + it('shows threshold in footer only when route changes are present', () => { + // Route changed: threshold callout shown + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 5 } } }); + const report = compareReport(baseline, current, { threshold: 7 }); + + assert.ok(report.includes('Threshold: 7%')); + }); + + it('omits threshold footer when there are no route changes', () => { + // Global metrics differ but routes are identical — no threshold callout + const baseline = makeReport({ firstLoadJs: 100 }); + const current = makeReport({ firstLoadJs: 115 }); + const report = compareReport(baseline, current); + + assert.ok(!report.includes('Threshold:')); + }); + + it('formats positive delta with + sign and percent', () => { + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('+10 kB')); + assert.ok(report.includes('+10%')); + }); + + it('formats negative delta with minus sign and percent', () => { + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 5 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 90, js: 40, css: 5 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('-10 kB')); + assert.ok(report.includes('-10%')); + }); + + it('sorts routes alphabetically', () => { + const makeRoute = (v: number) => ({ firstLoadJs: v, js: v, css: 0 }); + const baseline = makeReport({ + routes: { + '/z/page': makeRoute(100), + '/a/page': makeRoute(100), + '/m/page': makeRoute(100), + }, + }); + const current = makeReport({ + routes: { + '/z/page': makeRoute(110), + '/a/page': makeRoute(110), + '/m/page': makeRoute(110), + }, + }); + const report = compareReport(baseline, current); + + const aIdx = report.indexOf('/a/page'); + const mIdx = report.indexOf('/m/page'); + const zIdx = report.indexOf('/z/page'); + + assert.ok(aIdx < mIdx, '/a should appear before /m'); + assert.ok(mIdx < zIdx, '/m should appear before /z'); + }); + + it('strips the /[locale] prefix from display names', () => { + const baseline = makeReport({ routes: {} }); + const current = makeReport({ + routes: { '/[locale]/products/page': { firstLoadJs: 120, js: 60, css: 5 } }, + }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('/products/page'), 'Should show /products/page (locale stripped)'); + assert.ok( + !report.includes('/[locale]/products/page'), + 'Should not show /[locale] prefix', + ); + }); + + it('omits near-zero deltas that round to 0.0', () => { + // 0.04kB delta rounds to 0.0: treated as no change + const baseline = makeReport({ + routes: { '/app/page': { firstLoadJs: 100.04, js: 50, css: 5 } }, + }); + const current = makeReport({ + routes: { '/app/page': { firstLoadJs: 100.04, js: 50, css: 5 } }, + }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('No bundle size changes detected.')); + }); + + it('shows Per-Route First Load JS section when there are route changes', () => { + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('### Per-Route First Load JS')); + }); + + it('omits Per-Route First Load JS section when nothing changed', () => { + const baseline = makeReport(); + const current = makeReport(); + const report = compareReport(baseline, current); + + assert.ok(!report.includes('### Per-Route First Load JS')); + }); + + it('shows header with baseline commitSha and updatedAt', () => { + const baseline = makeReport({ commitSha: 'deadbeef', updatedAt: '2024-06-15' }); + const current = makeReport(); + const report = compareReport(baseline, current); + + assert.ok(report.includes('`deadbeef`')); + assert.ok(report.includes('2024-06-15')); + }); + + it('shows "No bundle size changes detected." for empty routes in both reports', () => { + const baseline = makeReport({ routes: {} }); + const current = makeReport({ routes: {} }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('No bundle size changes detected.')); + }); + + it('shows table header when routes have changes', () => { + const baseline = makeReport({ routes: { '/app/page': { firstLoadJs: 100, js: 50, css: 0 } } }); + const current = makeReport({ routes: { '/app/page': { firstLoadJs: 110, js: 60, css: 0 } } }); + const report = compareReport(baseline, current); + + assert.ok(report.includes('| Route |')); + assert.ok(report.includes('| Baseline |')); + assert.ok(report.includes('| Current |')); + }); +}); + +// --------------------------------------------------------------------------- +// getGzipSize +// --------------------------------------------------------------------------- + +describe('getGzipSize', () => { + beforeEach(() => clearSizeCache()); + + it('returns 0 when file does not exist', () => { + const result = getGzipSize(join(testDir, 'nonexistent-file-xyz.js')); + + assert.equal(result, 0); + }); + + it('returns a positive number for an existing file', () => { + const result = getGzipSize(join(testDir, 'route.js')); + + assert.ok(result > 0, `Expected positive size, got ${result}`); + }); + + it('caches results and returns same value on second call', () => { + const filePath = join(testDir, `cache-test-${Date.now()}.js`); + + writeFileSync(filePath, makeJs('cached')); + + const firstResult = getGzipSize(filePath); + + assert.ok(firstResult > 0); + + // Delete the file — the cached value should still be returned + unlinkSync(filePath); + + const secondResult = getGzipSize(filePath); + + assert.equal(secondResult, firstResult, 'Should return cached value after file deletion'); + }); + + it('clearSizeCache resets the cache', () => { + const filePath = join(testDir, `clear-test-${Date.now()}.js`); + + writeFileSync(filePath, makeJs('cleared')); + + const sizeBeforeDelete = getGzipSize(filePath); + + assert.ok(sizeBeforeDelete > 0); + + unlinkSync(filePath); + clearSizeCache(); + + // After clearing cache, file is gone so size should be 0 + const sizeAfterClear = getGzipSize(filePath); + + assert.equal(sizeAfterClear, 0, 'Should return 0 after cache cleared and file deleted'); + }); +}); diff --git a/.github/scripts/__tests__/post-bundle-comment.test.mts b/.github/scripts/__tests__/post-bundle-comment.test.mts new file mode 100644 index 0000000000..8a11c4eb9e --- /dev/null +++ b/.github/scripts/__tests__/post-bundle-comment.test.mts @@ -0,0 +1,189 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const postBundleComment = require('../post-bundle-comment.js') as (args: { + github: ReturnType['github']; + context: ReturnType; + reportPath?: string; +}) => Promise; + +const marker = ''; + +let tmpDir: string; +let reportPath: string; + +beforeEach(() => { + tmpDir = join(tmpdir(), `post-bundle-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + reportPath = join(tmpDir, 'report.md'); + writeFileSync(reportPath, '## Bundle Size Report\n\nSome content here.'); +}); + +interface Comment { + id: number; + body: string; +} + +interface GithubCalls { + create: object[]; + update: object[]; + list: object[]; +} + +// Helper to create a mock github object and record calls +function makeGithub(existingComments: Comment[] = []) { + const calls: GithubCalls = { create: [], update: [], list: [] }; + const github = { + rest: { + issues: { + listComments: async (args: object) => { + calls.list.push(args); + return { data: existingComments }; + }, + createComment: async (args: object) => { + calls.create.push(args); + }, + updateComment: async (args: object) => { + calls.update.push(args); + }, + }, + }, + }; + return { github, calls }; +} + +function makeContext({ owner = 'test-owner', repo = 'test-repo', number = 42 } = {}) { + return { + repo: { owner, repo }, + issue: { number }, + }; +} + +describe('post-bundle-comment', () => { + it('creates a new comment when no existing comment contains the marker', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + assert.equal(calls.create.length, 1, 'Should create exactly one comment'); + assert.equal(calls.update.length, 0, 'Should not update any comment'); + }); + + it('updates existing comment when marker found', async () => { + const existing = { id: 99, body: `${marker}\nOld content` }; + const { github, calls } = makeGithub([existing]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + assert.equal(calls.update.length, 1, 'Should update exactly one comment'); + assert.equal(calls.create.length, 0, 'Should not create a new comment'); + assert.equal((calls.update[0] as { comment_id: number }).comment_id, 99, 'Should update the correct comment by id'); + }); + + it('body always starts with marker and newline', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + const body = (calls.create[0] as { body: string }).body; + + assert.ok(body.startsWith(`${marker}\n`), `Body should start with marker, got: ${body.slice(0, 50)}`); + }); + + it('updated comment body also starts with marker and newline', async () => { + const existing = { id: 7, body: `${marker}\nStale content` }; + const { github, calls } = makeGithub([existing]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + const body = (calls.update[0] as { body: string }).body; + + assert.ok(body.startsWith(`${marker}\n`)); + }); + + it('includes report file content in the comment body', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ github, context: makeContext(), reportPath }); + + const body = (calls.create[0] as { body: string }).body; + + assert.ok(body.includes('## Bundle Size Report'), 'Should include report heading'); + assert.ok(body.includes('Some content here.'), 'Should include report body content'); + }); + + it('reads report from a custom reportPath', async () => { + const customPath = join(tmpDir, 'custom.md'); + + writeFileSync(customPath, 'Custom report content for testing!'); + + const { github, calls } = makeGithub([]); + await postBundleComment({ github, context: makeContext(), reportPath: customPath }); + + assert.ok((calls.create[0] as { body: string }).body.includes('Custom report content for testing!')); + }); + + it('passes correct owner, repo, issue_number from context to listComments', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ + github, + context: makeContext({ owner: 'my-org', repo: 'my-repo', number: 123 }), + reportPath, + }); + + assert.equal((calls.list[0] as { owner: string }).owner, 'my-org'); + assert.equal((calls.list[0] as { repo: string }).repo, 'my-repo'); + assert.equal((calls.list[0] as { issue_number: number }).issue_number, 123); + }); + + it('passes correct owner, repo, issue_number from context to createComment', async () => { + const { github, calls } = makeGithub([]); + await postBundleComment({ + github, + context: makeContext({ owner: 'my-org', repo: 'my-repo', number: 123 }), + reportPath, + }); + + assert.equal((calls.create[0] as { owner: string }).owner, 'my-org'); + assert.equal((calls.create[0] as { repo: string }).repo, 'my-repo'); + assert.equal((calls.create[0] as { issue_number: number }).issue_number, 123); + }); + + it('passes correct owner and repo to updateComment', async () => { + const existing = { id: 55, body: `${marker}\nOld` }; + const { github, calls } = makeGithub([existing]); + await postBundleComment({ + github, + context: makeContext({ owner: 'org2', repo: 'repo2', number: 7 }), + reportPath, + }); + + assert.equal((calls.update[0] as { owner: string }).owner, 'org2'); + assert.equal((calls.update[0] as { repo: string }).repo, 'repo2'); + }); + + it('uses the first comment that contains the marker (not just exact match)', async () => { + const comments = [ + { id: 1, body: 'Just a regular comment' }, + { id: 2, body: `${marker}\nFirst bundle report` }, + { id: 3, body: `${marker}\nSecond bundle report` }, + ]; + const { github, calls } = makeGithub(comments); + await postBundleComment({ github, context: makeContext(), reportPath }); + + assert.equal(calls.update.length, 1); + assert.equal((calls.update[0] as { comment_id: number }).comment_id, 2, 'Should update the first matching comment'); + }); + + it('creates comment when existing comments do not contain the marker', async () => { + const comments = [ + { id: 10, body: 'No marker here' }, + { id: 11, body: 'Also no marker' }, + ]; + const { github, calls } = makeGithub(comments); + await postBundleComment({ github, context: makeContext(), reportPath }); + + assert.equal(calls.create.length, 1); + assert.equal(calls.update.length, 0); + }); +}); diff --git a/.github/scripts/bundle-size.mts b/.github/scripts/bundle-size.mts new file mode 100644 index 0000000000..1d7286a3f2 --- /dev/null +++ b/.github/scripts/bundle-size.mts @@ -0,0 +1,490 @@ +#!/usr/bin/env node +/* eslint-disable no-console, no-restricted-syntax, no-plusplus, no-continue */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { gzipSync } from "node:zlib"; + +// eslint-disable-next-line no-underscore-dangle +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CORE_DIR = resolve(__dirname, "..", "..", "core"); + +interface ChunkSizes { + js: number; + css: number; +} + +interface RouteMetric { + js: number; + css: number; + firstLoadJs: number; +} + +interface BundleReport { + commitSha: string; + updatedAt: string; + firstLoadJs: number; + totalJs: number; + totalCss: number; + shared?: { js: number; css: number }; + routes?: Record; +} + +interface CompareOptions { + threshold?: number; +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +const sizeCache = new Map(); + +function clearSizeCache(): void { + sizeCache.clear(); +} + +function getGzipSize(filePath: string): number { + if (sizeCache.has(filePath)) return sizeCache.get(filePath)!; + + if (!existsSync(filePath)) { + sizeCache.set(filePath, 0); + + return 0; + } + + const data = readFileSync(filePath); + const gzipped = gzipSync(data, { level: 6 }); + const sizeKb = gzipped.length / 1024; + + sizeCache.set(filePath, sizeKb); + + return sizeKb; +} + +function sumChunkSizes(chunks: Iterable, dir: string): ChunkSizes { + let js = 0; + let css = 0; + + for (const chunk of chunks) { + const size = getGzipSize(join(dir, chunk)); + + if (chunk.endsWith(".css")) { + css += size; + } else { + js += size; + } + } + + return { js, css }; +} + +function parseManifestEntries(entries: Record): { + layouts: Record; + pages: Record; +} { + const layouts: Record = {}; + const pages: Record = {}; + + for (const [route, chunks] of Object.entries(entries)) { + if (route.endsWith("/layout")) { + layouts[route] = chunks; + } else if (route.endsWith("/page")) { + pages[route] = chunks; + } + } + + return { layouts, pages }; +} + +function computeRootLayout( + layoutPaths: string[], + layouts: Record, + sharedChunks: Set, + nextDir: string, +): { + rootLayoutPath: string | null; + rootLayoutChunks: Set; + rootLayoutJs: number; + rootLayoutCss: number; +} { + const sorted = [...layoutPaths].sort( + (a, b) => a.split("/").length - b.split("/").length, + ); + const rootLayoutPath = sorted[0] ?? null; + const rootLayoutChunks = new Set(); + let rootLayoutJs = 0; + let rootLayoutCss = 0; + + if (rootLayoutPath) { + const uniqueChunks = layouts[rootLayoutPath].filter( + (c) => !sharedChunks.has(c), + ); + const sizes = sumChunkSizes(uniqueChunks, nextDir); + + rootLayoutJs = sizes.js; + rootLayoutCss = sizes.css; + uniqueChunks.forEach((c) => rootLayoutChunks.add(c)); + } + + return { rootLayoutPath, rootLayoutChunks, rootLayoutJs, rootLayoutCss }; +} + +function computeRouteMetrics( + pages: Record, + layouts: Record, + sharedChunks: Set, + rootLayoutPath: string | null, + rootLayoutChunks: Set, + firstLoadJs: number, + nextDir: string, +): Record { + const routes: Record = {}; + + for (const [route, chunks] of Object.entries(pages)) { + const segments = route.split("/"); + + segments.pop(); // remove 'page' + + const ancestorLayouts: string[] = []; + + for (let i = segments.length; i >= 1; i--) { + const parentPath = `${segments.slice(0, i).join("/")}/layout`; + + if (layouts[parentPath]) { + ancestorLayouts.push(parentPath); + } + } + + const routeChunks = new Set(); + + for (const chunk of chunks.filter((c) => !sharedChunks.has(c))) { + if (!rootLayoutChunks.has(chunk)) { + routeChunks.add(chunk); + } + } + + for (const layoutPath of ancestorLayouts) { + if (layoutPath === rootLayoutPath) continue; + + for (const chunk of layouts[layoutPath].filter( + (c) => !sharedChunks.has(c), + )) { + if (!rootLayoutChunks.has(chunk)) { + routeChunks.add(chunk); + } + } + } + + const sizes = sumChunkSizes(routeChunks, nextDir); + + routes[route] = { + js: round1(sizes.js), + css: round1(sizes.css), + firstLoadJs: round1(firstLoadJs + sizes.js + sizes.css), + }; + } + + return routes; +} + +function compareReport( + baseline: BundleReport, + current: BundleReport, + { threshold = 5 }: CompareOptions = {}, +): string { + function hasChanged(base: number, curr: number): boolean { + if (round1(curr - base) === 0) return false; + const pct = base > 0 ? ((curr - base) / base) * 100 : null; + if (pct !== null && round1(pct) === 0) return false; + return true; + } + + function formatDelta(base: number, curr: number): string { + const delta = curr - base; + const rounded = round1(delta); + const sign = delta >= 0 ? "+" : ""; + const pct = base > 0 ? (delta / base) * 100 : 0; + const pctStr = base > 0 ? ` (${sign}${round1(pct)}%)` : ""; + return `${sign}${rounded} kB${pctStr}`; + } + + function isWarning(base: number, curr: number): boolean { + const delta = curr - base; + const pct = base > 0 ? (delta / base) * 100 : 0; + + return delta > 1 && pct > threshold; + } + + function displayRoute(route: string): string { + return route.replace(/^\/\[locale\]/, ""); + } + + const lines: string[] = []; + + lines.push("## Bundle Size Report"); + lines.push(""); + lines.push( + `Comparing against baseline from \`${baseline.commitSha}\` (${baseline.updatedAt}).`, + ); + lines.push(""); + + const changedMetrics = [ + { + name: "First Load JS", + base: baseline.firstLoadJs, + curr: current.firstLoadJs, + }, + { name: "Total JS", base: baseline.totalJs, curr: current.totalJs }, + { name: "Total CSS", base: baseline.totalCss, curr: current.totalCss }, + ].filter((m) => hasChanged(m.base, m.curr)); + + const allRoutes = new Set([ + ...Object.keys(baseline.routes ?? {}), + ...Object.keys(current.routes ?? {}), + ]); + + const sortedRoutes = [...allRoutes].sort(); + const routeLines: string[] = []; + + for (const route of sortedRoutes) { + const display = displayRoute(route); + const base = baseline.routes?.[route]; + const curr = current.routes?.[route]; + + if (!base && curr) { + routeLines.push( + `| ${display} | -- | ${round1(curr.firstLoadJs)} kB | ✨ NEW | |`, + ); + } else if (base && !curr) { + routeLines.push( + `| ${display} | ${round1(base.firstLoadJs)} kB | -- | REMOVED | |`, + ); + } else if (base && curr && hasChanged(base.firstLoadJs, curr.firstLoadJs)) { + const d = formatDelta(base.firstLoadJs, curr.firstLoadJs); + const warn = isWarning(base.firstLoadJs, curr.firstLoadJs) ? " ⚠️" : ""; + + routeLines.push( + `| ${display} | ${round1(base.firstLoadJs)} kB | ${round1(curr.firstLoadJs)} kB | ${d} |${warn} |`, + ); + } + } + + if (changedMetrics.length === 0 && routeLines.length === 0) { + lines.push("No bundle size changes detected."); + lines.push(""); + return lines.join("\n"); + } + + if (changedMetrics.length > 0) { + lines.push("| Metric | Baseline | Current | Delta | |"); + lines.push("|:-------|:---------|:--------|:------|:-|"); + + for (const m of changedMetrics) { + const d = formatDelta(m.base, m.curr); + const warn = isWarning(m.base, m.curr) ? " ⚠️" : ""; + + lines.push( + `| ${m.name} | ${round1(m.base)} kB | ${round1(m.curr)} kB | ${d} |${warn} |`, + ); + } + + lines.push(""); + } + + lines.push("### Per-Route First Load JS"); + lines.push(""); + + if (routeLines.length > 0) { + lines.push("| Route | Baseline | Current | Delta | |"); + lines.push("|:------|:---------|:--------|:------|:-|"); + lines.push(...routeLines); + lines.push(""); + lines.push( + `> Threshold: ${threshold}% increase. Routes with ⚠️ exceed the threshold.`, + ); + } else { + lines.push("_No route changes detected._"); + } + + lines.push(""); + + return lines.join("\n"); +} + +function generate( + nextDir: string, + values: Record, +): void { + const appManifestPath = join(nextDir, "app-build-manifest.json"); + const buildManifestPath = join(nextDir, "build-manifest.json"); + + if (!existsSync(appManifestPath)) { + console.error( + "Error: .next/app-build-manifest.json not found. Run `next build` first.", + ); + process.exit(1); + } + + const appManifest = JSON.parse(readFileSync(appManifestPath, "utf-8")) as { + pages?: Record; + }; + const buildManifest = JSON.parse( + readFileSync(buildManifestPath, "utf-8"), + ) as { + rootMainFiles?: string[]; + polyfillFiles?: string[]; + }; + + const rootMainFiles = new Set(buildManifest.rootMainFiles ?? []); + const polyfillFiles = new Set(buildManifest.polyfillFiles ?? []); + const sharedChunks = new Set([...rootMainFiles, ...polyfillFiles]); + + const entries = appManifest.pages ?? {}; + const { layouts, pages } = parseManifestEntries(entries); + + // Shared JS = sum of rootMainFiles gzipped sizes + const sharedSizes = sumChunkSizes(rootMainFiles, nextDir); + const sharedJs = round1(sharedSizes.js); + + // Root layout + const { rootLayoutPath, rootLayoutChunks, rootLayoutJs, rootLayoutCss } = + computeRootLayout(Object.keys(layouts), layouts, sharedChunks, nextDir); + + const sharedCss = round1(rootLayoutCss); + const firstLoadJs = round1(sharedJs + rootLayoutJs + rootLayoutCss); + + // Total JS and CSS across all unique chunks + const allChunksSet = new Set(); + + for (const chunks of Object.values(entries)) { + for (const chunk of chunks) { + allChunksSet.add(chunk); + } + } + + const totals = sumChunkSizes(allChunksSet, nextDir); + const totalJs = round1(totals.js); + const totalCss = round1(totals.css); + + // Per-route metrics + const routes = computeRouteMetrics( + pages, + layouts, + sharedChunks, + rootLayoutPath, + rootLayoutChunks, + firstLoadJs, + nextDir, + ); + + const result: BundleReport = { + commitSha: values.sha ?? "unknown", + updatedAt: new Date().toISOString().split("T")[0], + firstLoadJs, + shared: { js: sharedJs, css: sharedCss }, + routes, + totalJs, + totalCss, + }; + + const output = values.output ?? null; + const json = `${JSON.stringify(result, null, 2)}\n`; + + if (output) { + writeFileSync(resolve(output), json); + console.error(`Bundle size report written to ${output}`); + } else { + process.stdout.write(json); + } +} + +function compare( + nextDir: string, + values: Record, +): void { + const baselinePath = resolve( + values.baseline ?? join(CORE_DIR, "bundle-baseline.json"), + ); + const currentPath = resolve(values.current ?? ""); + const threshold = Number(values.threshold ?? "5"); + + if (!currentPath || !existsSync(currentPath)) { + console.error("Error: --current is required and must exist"); + process.exit(1); + } + + if (!existsSync(baselinePath)) { + console.error(`Error: baseline not found at ${baselinePath}`); + process.exit(1); + } + + const baseline = JSON.parse( + readFileSync(baselinePath, "utf-8"), + ) as BundleReport; + const current = JSON.parse( + readFileSync(currentPath, "utf-8"), + ) as BundleReport; + + process.stdout.write(compareReport(baseline, current, { threshold })); +} + +export { + round1, + getGzipSize, + sumChunkSizes, + parseManifestEntries, + computeRootLayout, + computeRouteMetrics, + compareReport, + clearSizeCache, +}; + +export type { BundleReport, RouteMetric, ChunkSizes, CompareOptions }; + +const isMain = process.argv[1] === fileURLToPath(import.meta.url); + +if (isMain) { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + output: { type: "string" }, + baseline: { type: "string" }, + current: { type: "string" }, + threshold: { type: "string" }, + sha: { type: "string" }, + dir: { type: "string" }, + }, + }); + + const NEXT_DIR = values.dir ? resolve(values.dir) : join(CORE_DIR, ".next"); + const command = positionals.at(0); + + if (command === "generate") { + generate(NEXT_DIR, values); + } else if (command === "compare") { + compare(NEXT_DIR, values); + } else { + console.error("Usage: bundle-size.mts [options]"); + console.error(""); + console.error("Commands:"); + console.error( + " generate Analyze .next/ build output and produce bundle size JSON", + ); + console.error(" --output Write JSON to file instead of stdout"); + console.error(""); + console.error(" compare Compare current bundle against a baseline"); + console.error( + " --baseline Path to baseline JSON (default: ./bundle-baseline.json)", + ); + console.error( + " --current Path to current bundle JSON (required)", + ); + console.error( + " --threshold Warning threshold percentage (default: 5)", + ); + process.exit(1); + } +} diff --git a/.github/scripts/post-bundle-comment.js b/.github/scripts/post-bundle-comment.js new file mode 100644 index 0000000000..43832a5a60 --- /dev/null +++ b/.github/scripts/post-bundle-comment.js @@ -0,0 +1,30 @@ +const fs = require('fs'); + +module.exports = async ({ github, context, reportPath = '/tmp/bundle-report.md' }) => { + const marker = ''; + const body = marker + '\n' + fs.readFileSync(reportPath, 'utf-8'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } +}; diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 0000000000..2e640f18ad --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,141 @@ +name: Bundle Size +# Reports the bundle size impact of a PR by comparing the current build against +# a live build of the base branch (canary or integrations/makeswift). +# +# build-pr and build-baseline run in parallel, each uploading a JSON artifact. +# compare downloads both artifacts, runs the comparison, and posts the PR comment. + +on: + pull_request: + types: [opened, synchronize] + +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + BIGCOMMERCE_STORE_HASH: ${{ vars.BIGCOMMERCE_STORE_HASH }} + BIGCOMMERCE_CHANNEL_ID: ${{ vars.BIGCOMMERCE_CHANNEL_ID }} + BIGCOMMERCE_CLIENT_ID: ${{ secrets.BIGCOMMERCE_CLIENT_ID }} + BIGCOMMERCE_CLIENT_SECRET: ${{ secrets.BIGCOMMERCE_CLIENT_SECRET }} + BIGCOMMERCE_STOREFRONT_TOKEN: ${{ secrets.BIGCOMMERCE_STOREFRONT_TOKEN }} + BIGCOMMERCE_ACCESS_TOKEN: ${{ secrets.BIGCOMMERCE_ACCESS_TOKEN }} + +jobs: + build-pr: + name: Build & Measure PR Bundle + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + path: pr + + - uses: pnpm/action-setup@v4 + with: + package_json_file: pr/package.json + + - uses: actions/setup-node@v4 + with: + node-version-file: pr/.nvmrc + cache: pnpm + cache-dependency-path: pr/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + working-directory: pr + + - run: pnpm build + working-directory: pr + + - run: node .github/scripts/bundle-size.mts generate --output /tmp/bundle-current.json --sha ${{ github.sha }} + working-directory: pr + + - uses: actions/upload-artifact@v4 + with: + name: bundle-current + path: /tmp/bundle-current.json + + build-baseline: + name: Build & Measure Baseline Bundle + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + path: pr + + - name: Detect baseline branch + id: baseline + run: | + PKG_NAME=$(node -p "require('./pr/core/package.json').name") + if [ "$PKG_NAME" = "@bigcommerce/catalyst-makeswift" ]; then + echo "branch=integrations/makeswift" >> $GITHUB_OUTPUT + else + echo "branch=canary" >> $GITHUB_OUTPUT + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.baseline.outputs.branch }} + path: baseline + + - uses: pnpm/action-setup@v4 + with: + package_json_file: pr/package.json + + - uses: actions/setup-node@v4 + with: + node-version-file: pr/.nvmrc + cache: pnpm + cache-dependency-path: baseline/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + working-directory: baseline + + - run: pnpm build + working-directory: baseline + + - name: Generate baseline bundle size + run: | + SHA=$(git -C $GITHUB_WORKSPACE/baseline rev-parse --short HEAD) + node .github/scripts/bundle-size.mts generate --dir $GITHUB_WORKSPACE/baseline/core/.next --output /tmp/bundle-baseline.json --sha $SHA + working-directory: pr + + - uses: actions/upload-artifact@v4 + with: + name: bundle-baseline + path: /tmp/bundle-baseline.json + + compare: + name: Compare Bundles & Post Report + needs: [build-pr, build-baseline] + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + path: pr + + - uses: actions/setup-node@v4 + with: + node-version-file: pr/.nvmrc + + - uses: actions/download-artifact@v4 + with: + pattern: bundle-* + path: /tmp + merge-multiple: true + + - run: node .github/scripts/bundle-size.mts compare --baseline /tmp/bundle-baseline.json --current /tmp/bundle-current.json > /tmp/bundle-report.md + working-directory: pr + + - run: cat /tmp/bundle-report.md >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/github-script@v7 + with: + script: | + const postComment = require('./pr/.github/scripts/post-bundle-comment.js') + await postComment({ github, context }) diff --git a/.github/workflows/regression-tests.yml b/.github/workflows/regression-tests.yml index 3b4e8d4c19..7442510925 100644 --- a/.github/workflows/regression-tests.yml +++ b/.github/workflows/regression-tests.yml @@ -2,7 +2,7 @@ name: Regression Tests on: deployment_status: - states: ['success'] + states: ["success"] env: VERCEL_PROTECTION_BYPASS: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} @@ -18,7 +18,7 @@ jobs: strategy: matrix: device: [desktop, mobile] - concurrency: + concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.deployment_status.target_url }}-${{ matrix.device }} cancel-in-progress: true @@ -39,5 +39,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: unlighthouse-vercel-${{ matrix.device }}-report - path: './.unlighthouse/' - include-hidden-files: 'true' + path: "./.unlighthouse/" + include-hidden-files: "true" diff --git a/package.json b/package.json index c234d41183..a1d0fc6f70 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "build": "dotenv -e .env.local -- turbo run build", "lint": "dotenv -e .env.local -- turbo lint", "test": "turbo run test", + "test:scripts": "node --test .github/scripts/__tests__/*.test.mjs", "typecheck": "turbo typecheck" }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.29.4", + "@types/node": "^22.15.30", "dotenv-cli": "^8.0.0", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c69467826..f7dd24ad67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@changesets/cli': specifier: ^2.29.4 version: 2.29.4 + '@types/node': + specifier: ^22.15.30 + version: 22.15.30 dotenv-cli: specifier: ^8.0.0 version: 8.0.0 @@ -6704,20 +6707,23 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -9983,6 +9989,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -11475,9 +11482,9 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-config-prettier: 9.1.0(eslint@8.57.1) - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) eslint-plugin-gettext: 1.2.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30)(ts-node@10.9.2(@swc/core@1.11.31)(@types/node@22.15.30)(typescript@5.8.3)))(typescript@5.8.3) eslint-plugin-jest-dom: 5.5.0(eslint@8.57.1) eslint-plugin-jest-formatting: 3.1.0(eslint@8.57.1) @@ -17369,7 +17376,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -17396,21 +17403,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 - eslint: 8.57.1 - get-tsconfig: 4.10.0 - is-bun-module: 1.3.0 - rspack-resolver: 1.2.2 - stable-hash: 0.0.5 - tinyglobby: 0.2.14 - optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -17422,7 +17414,7 @@ snapshots: stable-hash: 0.0.5 tinyglobby: 0.2.14 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -17433,7 +17425,7 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -17447,36 +17439,7 @@ snapshots: dependencies: gettext-parser: 4.2.0 - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.8 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8