|
| 1 | +import { XMLParser } from 'fast-xml-parser'; |
| 2 | +import fs from 'fs-extra'; |
| 3 | +import path from 'path'; |
| 4 | + |
| 5 | +export interface GradleProfileTask { |
| 6 | + path: string; |
| 7 | + durationMs: number; |
| 8 | + result: string; |
| 9 | +} |
| 10 | + |
| 11 | +export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> { |
| 12 | + const profileDir = path.join(androidDir, 'build', 'reports', 'profile'); |
| 13 | + if (!(await fs.pathExists(profileDir))) { |
| 14 | + throw new Error(`Gradle profile directory not found at ${profileDir}`); |
| 15 | + } |
| 16 | + |
| 17 | + const files = await fs.readdir(profileDir); |
| 18 | + const htmlFile = files |
| 19 | + .filter(f => f.startsWith('profile-') && f.endsWith('.html')) |
| 20 | + .sort() |
| 21 | + .pop(); |
| 22 | + |
| 23 | + if (!htmlFile) { |
| 24 | + throw new Error(`No Gradle profile HTML found in ${profileDir}`); |
| 25 | + } |
| 26 | + |
| 27 | + const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8'); |
| 28 | + |
| 29 | + // Locate the <h2>Task Execution</h2> heading and extract its <table> |
| 30 | + const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i); |
| 31 | + if (!headingMatch || headingMatch.index === undefined) { |
| 32 | + throw new Error('Could not find Task Execution section in Gradle profile'); |
| 33 | + } |
| 34 | + |
| 35 | + const sectionStart = headingMatch.index; |
| 36 | + const tableStart = html.indexOf('<table', sectionStart); |
| 37 | + const tableEnd = html.indexOf('</table>', tableStart); |
| 38 | + if (tableStart === -1 || tableEnd === -1) { |
| 39 | + throw new Error('Could not find task execution table in Gradle profile'); |
| 40 | + } |
| 41 | + |
| 42 | + const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length); |
| 43 | + const parser = new XMLParser({ |
| 44 | + ignoreAttributes: true, |
| 45 | + isArray: name => name === 'tr' || name === 'td', |
| 46 | + trimValues: true, |
| 47 | + }); |
| 48 | + |
| 49 | + const parsed = parser.parse(tableHtml); |
| 50 | + const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? []; |
| 51 | + |
| 52 | + const tasks: GradleProfileTask[] = []; |
| 53 | + for (const row of rows) { |
| 54 | + const cells: unknown[] = row?.td; |
| 55 | + if (!cells || cells.length < 2) { |
| 56 | + continue; |
| 57 | + } |
| 58 | + |
| 59 | + const taskPath = String(cells[0] ?? '').trim(); |
| 60 | + const durationStr = String(cells[1] ?? '').trim(); |
| 61 | + const result = String(cells[2] ?? '').trim() || 'executed'; |
| 62 | + |
| 63 | + if (!taskPath || !durationStr) { |
| 64 | + continue; |
| 65 | + } |
| 66 | + |
| 67 | + const durationMs = parseDurationToMs(durationStr); |
| 68 | + tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() }); |
| 69 | + } |
| 70 | + |
| 71 | + return tasks; |
| 72 | +} |
| 73 | + |
| 74 | +function parseDurationToMs(duration: string): number { |
| 75 | + // Gradle profile durations can be like "1.234s", "0.045s", "12.5s" |
| 76 | + const secondsMatch = duration.match(/^([\d.]+)s$/); |
| 77 | + if (secondsMatch) { |
| 78 | + return Math.round(parseFloat(secondsMatch[1]) * 1000); |
| 79 | + } |
| 80 | + return 0; |
| 81 | +} |
| 82 | + |
| 83 | +function formatSeconds(ms: number): string { |
| 84 | + const s = ms / 1000; |
| 85 | + if (s < 0.1) { |
| 86 | + return `${ms}ms`; |
| 87 | + } |
| 88 | + return `${s.toFixed(1)}s`; |
| 89 | +} |
| 90 | + |
| 91 | +export function formatGradleProfileReport(tasks: GradleProfileTask[]): string { |
| 92 | + // Filter out tasks under 1 second |
| 93 | + const significantTasks = tasks.filter(t => t.durationMs >= 1000); |
| 94 | + |
| 95 | + // Separate module totals from individual tasks |
| 96 | + const moduleTotals = significantTasks.filter(t => t.result === '(total)'); |
| 97 | + const individualTasks = significantTasks.filter(t => t.result !== '(total)'); |
| 98 | + |
| 99 | + // Group individual tasks by their module prefix |
| 100 | + const moduleChildren = new Map<string, GradleProfileTask[]>(); |
| 101 | + const orphanTasks: GradleProfileTask[] = []; |
| 102 | + |
| 103 | + for (const task of individualTasks) { |
| 104 | + const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':')); |
| 105 | + if (parent) { |
| 106 | + const children = moduleChildren.get(parent.path) ?? []; |
| 107 | + children.push(task); |
| 108 | + moduleChildren.set(parent.path, children); |
| 109 | + } else { |
| 110 | + orphanTasks.push(task); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + // Sort module totals by duration, sort children within each module |
| 115 | + const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs); |
| 116 | + for (const children of moduleChildren.values()) { |
| 117 | + children.sort((a, b) => b.durationMs - a.durationMs); |
| 118 | + } |
| 119 | + orphanTasks.sort((a, b) => b.durationMs - a.durationMs); |
| 120 | + |
| 121 | + // Build display rows: [displayName, task] |
| 122 | + const rows: { displayName: string; task: GradleProfileTask }[] = []; |
| 123 | + |
| 124 | + for (const mod of sortedModules) { |
| 125 | + rows.push({ displayName: mod.path, task: mod }); |
| 126 | + const children = moduleChildren.get(mod.path) ?? []; |
| 127 | + for (let i = 0; i < children.length; i++) { |
| 128 | + const isLast = i === children.length - 1; |
| 129 | + const prefix = isLast ? ' └─ ' : ' ├─ '; |
| 130 | + const shortName = children[i].path.slice(mod.path.length + 1); |
| 131 | + rows.push({ displayName: prefix + shortName, task: children[i] }); |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + for (const task of orphanTasks) { |
| 136 | + rows.push({ displayName: task.path, task }); |
| 137 | + } |
| 138 | + |
| 139 | + // Compute totals from individual tasks only (avoid double-counting) |
| 140 | + const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0); |
| 141 | + const maxMs = totalMs || 1; |
| 142 | + |
| 143 | + const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2; |
| 144 | + const barMaxWidth = 20; |
| 145 | + |
| 146 | + const header = |
| 147 | + '┌─' + |
| 148 | + '─'.repeat(nameWidth) + |
| 149 | + '─┬────────────┬──────────┬────────────┬─' + |
| 150 | + '─'.repeat(barMaxWidth) + |
| 151 | + '─┐'; |
| 152 | + const divider = |
| 153 | + '├─' + |
| 154 | + '─'.repeat(nameWidth) + |
| 155 | + '─┼────────────┼──────────┼────────────┼─' + |
| 156 | + '─'.repeat(barMaxWidth) + |
| 157 | + '─┤'; |
| 158 | + const footer = |
| 159 | + '└─' + |
| 160 | + '─'.repeat(nameWidth) + |
| 161 | + '─┴────────────┴──────────┴────────────┴─' + |
| 162 | + '─'.repeat(barMaxWidth) + |
| 163 | + '─┘'; |
| 164 | + |
| 165 | + const taskCount = individualTasks.length; |
| 166 | + const cachedCount = individualTasks.filter(t => t.result !== 'executed').length; |
| 167 | + const lines: string[] = []; |
| 168 | + |
| 169 | + lines.push('Gradle Build — Task Execution Profile'); |
| 170 | + const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : ''; |
| 171 | + lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`); |
| 172 | + lines.push('% Time = share of total task execution time'); |
| 173 | + lines.push(''); |
| 174 | + lines.push(header); |
| 175 | + lines.push( |
| 176 | + '│ ' + |
| 177 | + 'Task'.padEnd(nameWidth) + |
| 178 | + ' │ ' + |
| 179 | + 'Duration'.padStart(10) + |
| 180 | + ' │ ' + |
| 181 | + '% Time'.padStart(8) + |
| 182 | + ' │ ' + |
| 183 | + 'Result'.padEnd(10) + |
| 184 | + ' │ ' + |
| 185 | + ' '.repeat(barMaxWidth) + |
| 186 | + ' │' |
| 187 | + ); |
| 188 | + lines.push(divider); |
| 189 | + |
| 190 | + for (const row of rows) { |
| 191 | + const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100; |
| 192 | + const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth); |
| 193 | + const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength); |
| 194 | + const result = row.task.result === '(total)' ? 'total' : row.task.result; |
| 195 | + |
| 196 | + lines.push( |
| 197 | + '│ ' + |
| 198 | + row.displayName.padEnd(nameWidth) + |
| 199 | + ' │ ' + |
| 200 | + formatSeconds(row.task.durationMs).padStart(10) + |
| 201 | + ' │ ' + |
| 202 | + `${pct.toFixed(1)}%`.padStart(8) + |
| 203 | + ' │ ' + |
| 204 | + result.padEnd(10) + |
| 205 | + ' │ ' + |
| 206 | + bar + |
| 207 | + ' │' |
| 208 | + ); |
| 209 | + } |
| 210 | + |
| 211 | + lines.push(divider); |
| 212 | + lines.push( |
| 213 | + '│ ' + |
| 214 | + 'TOTAL'.padEnd(nameWidth) + |
| 215 | + ' │ ' + |
| 216 | + formatSeconds(totalMs).padStart(10) + |
| 217 | + ' │ ' + |
| 218 | + '100.0%'.padStart(8) + |
| 219 | + ' │ ' + |
| 220 | + ' '.repeat(10) + |
| 221 | + ' │ ' + |
| 222 | + ' '.repeat(barMaxWidth) + |
| 223 | + ' │' |
| 224 | + ); |
| 225 | + lines.push(footer); |
| 226 | + lines.push(''); |
| 227 | + |
| 228 | + return lines.join('\n'); |
| 229 | +} |
0 commit comments