|
| 1 | +import { readFile } from 'node:fs/promises'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Formats bytes into human-readable format |
| 5 | + * @param {number} bytes - Number of bytes |
| 6 | + * @returns {string} Formatted string (e.g., "1.5 KB") |
| 7 | + */ |
| 8 | +const formatBytes = bytes => { |
| 9 | + if (bytes === 0) {return '0 B';} |
| 10 | + const units = ['B', 'KB', 'MB', 'GB']; |
| 11 | + const index = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)); |
| 12 | + return (bytes / Math.pow(1024, index)).toFixed(2) + ' ' + units[index]; |
| 13 | +}; |
| 14 | + |
| 15 | +/** |
| 16 | + * Calculates percentage change |
| 17 | + * @param {number} oldValue - Original value |
| 18 | + * @param {number} newValue - New value |
| 19 | + * @returns {string} Formatted percentage |
| 20 | + */ |
| 21 | +const formatPercent = (oldValue, newValue) => { |
| 22 | + const percent = (((newValue - oldValue) / oldValue) * 100).toFixed(2); |
| 23 | + return `${percent > 0 ? '+' : ''}${percent}%`; |
| 24 | +}; |
| 25 | + |
| 26 | +/** |
| 27 | + * Categorizes asset changes |
| 28 | + */ |
| 29 | +const categorizeChanges = (oldAssets, newAssets) => { |
| 30 | + const oldMap = new Map(oldAssets.map(a => [a.name, a])); |
| 31 | + const newMap = new Map(newAssets.map(a => [a.name, a])); |
| 32 | + const changes = { added: [], removed: [], modified: [] }; |
| 33 | + |
| 34 | + for (const [name, oldAsset] of oldMap) { |
| 35 | + const newAsset = newMap.get(name); |
| 36 | + if (!newAsset) { |
| 37 | + changes.removed.push({ name, size: oldAsset.size }); |
| 38 | + } else if (oldAsset.size !== newAsset.size) { |
| 39 | + changes.modified.push({ |
| 40 | + name, |
| 41 | + oldSize: oldAsset.size, |
| 42 | + newSize: newAsset.size, |
| 43 | + delta: newAsset.size - oldAsset.size, |
| 44 | + }); |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + for (const [name, newAsset] of newMap) { |
| 49 | + if (!oldMap.has(name)) { |
| 50 | + changes.added.push({ name, size: newAsset.size }); |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | + return changes; |
| 55 | +}; |
| 56 | + |
| 57 | +/** |
| 58 | + * Builds a collapsible table section |
| 59 | + */ |
| 60 | +const tableSection = (title, items, columns, icon) => { |
| 61 | + if (!items.length) {return '';} |
| 62 | + const header = `| ${columns.map(c => c.label).join(' | ')} |\n`; |
| 63 | + const separator = `| ${columns.map(() => '---').join(' | ')} |\n`; |
| 64 | + const rows = items |
| 65 | + .map(item => `| ${columns.map(c => c.format(item)).join(' | ')} |`) |
| 66 | + .join('\n'); |
| 67 | + return `<details>\n<summary>${icon} ${title} <strong>(${items.length})</strong></summary>\n\n${header}${separator}${rows}\n\n</details>\n\n`; |
| 68 | +}; |
| 69 | + |
| 70 | +/** |
| 71 | + * Compares old and new assets and returns a markdown report |
| 72 | + */ |
| 73 | +function reportDiff({ assets: oldAssets }, { assets: newAssets }) { |
| 74 | + const changes = categorizeChanges(oldAssets, newAssets); |
| 75 | + |
| 76 | + const oldTotal = oldAssets.reduce((sum, a) => sum + a.size, 0); |
| 77 | + const newTotal = newAssets.reduce((sum, a) => sum + a.size, 0); |
| 78 | + const totalDelta = newTotal - oldTotal; |
| 79 | + |
| 80 | + // Summary table |
| 81 | + let report = `# 📦 Build Size Comparison\n\n## Summary\n\n| Metric | Value |\n|--------|-------|\n`; |
| 82 | + report += `| Old Total Size | ${formatBytes(oldTotal)} |\n`; |
| 83 | + report += `| New Total Size | ${formatBytes(newTotal)} |\n`; |
| 84 | + report += `| Delta | ${formatBytes(totalDelta)} (${formatPercent( |
| 85 | + oldTotal, |
| 86 | + newTotal |
| 87 | + )}) |\n\n`; |
| 88 | + |
| 89 | + // Changes |
| 90 | + if ( |
| 91 | + changes.added.length || |
| 92 | + changes.removed.length || |
| 93 | + changes.modified.length |
| 94 | + ) { |
| 95 | + report += `### Changes\n\n`; |
| 96 | + |
| 97 | + // Asset tables |
| 98 | + report += tableSection( |
| 99 | + 'Added Assets', |
| 100 | + changes.added, |
| 101 | + [ |
| 102 | + { label: 'Name', format: a => `\`${a.name}\`` }, |
| 103 | + { label: 'Size', format: a => formatBytes(a.size) }, |
| 104 | + ], |
| 105 | + '➕' |
| 106 | + ); |
| 107 | + |
| 108 | + report += tableSection( |
| 109 | + 'Removed Assets', |
| 110 | + changes.removed, |
| 111 | + [ |
| 112 | + { label: 'Name', format: a => `\`${a.name}\`` }, |
| 113 | + { label: 'Size', format: a => formatBytes(a.size) }, |
| 114 | + ], |
| 115 | + '➖' |
| 116 | + ); |
| 117 | + |
| 118 | + report += tableSection( |
| 119 | + 'Modified Assets', |
| 120 | + changes.modified, |
| 121 | + [ |
| 122 | + { label: 'Name', format: a => `\`${a.name}\`` }, |
| 123 | + { label: 'Old Size', format: a => formatBytes(a.oldSize) }, |
| 124 | + { label: 'New Size', format: a => formatBytes(a.newSize) }, |
| 125 | + { |
| 126 | + label: 'Delta', |
| 127 | + format: a => |
| 128 | + `${a.delta > 0 ? '📈' : '📉'} ${formatBytes( |
| 129 | + a.delta |
| 130 | + )} (${formatPercent(a.oldSize, a.newSize)})`, |
| 131 | + }, |
| 132 | + ], |
| 133 | + '🔄' |
| 134 | + ); |
| 135 | + } |
| 136 | + |
| 137 | + return report; |
| 138 | +} |
| 139 | + |
| 140 | +export async function compare({ core }) { |
| 141 | + const [oldAssets, newAssets] = await Promise.all([ |
| 142 | + readFile(process.env.BASE_STATS_PATH).then(f => JSON.parse(f)), |
| 143 | + readFile(process.env.HEAD_STATS_PATH).then(f => JSON.parse(f)), |
| 144 | + ]); |
| 145 | + |
| 146 | + const comment = reportDiff(oldAssets, newAssets); |
| 147 | + core.setOutput('comment', comment); |
| 148 | +} |
0 commit comments