@@ -38,6 +38,8 @@ const ROUND_NAME_RE = /round=([a-z0-9-]+)/g
3838const ROOT_DIR = path . resolve ( process . cwd ( ) , 'e2e/benchmark/e2e-watch-hmr' )
3939const SNAPSHOTS_DIR = path . join ( ROOT_DIR , 'snapshots' )
4040const FAILURES_DIR = path . join ( ROOT_DIR , 'failures' )
41+ const SPEED_REPORT_JSON = path . join ( ROOT_DIR , 'hmr-speed-report.json' )
42+ const SPEED_REPORT_MD = path . join ( ROOT_DIR , 'hmr-speed-report.md' )
4143
4244function ensureFailuresDir ( ) {
4345 fs . mkdirSync ( FAILURES_DIR , { recursive : true } )
@@ -229,6 +231,271 @@ function pickMetricFromReport(report, primary) {
229231 return undefined
230232}
231233
234+ function toFiniteNumber ( value ) {
235+ return Number . isFinite ( value ) ? value : undefined
236+ }
237+
238+ function pushSpeedSample ( samples , item ) {
239+ const hotUpdateMs = toFiniteNumber ( item . hotUpdateMs )
240+ const rollbackMs = toFiniteNumber ( item . rollbackMs )
241+ if ( hotUpdateMs == null ) {
242+ return
243+ }
244+ samples . push ( {
245+ caseName : item . caseName || 'unknown' ,
246+ project : item . project || 'unknown' ,
247+ surface : item . surface || 'unknown' ,
248+ sourceFile : item . sourceFile || '' ,
249+ hotUpdateMs,
250+ rollbackMs,
251+ initialReadyMs : toFiniteNumber ( item . initialReadyMs ) ,
252+ reportFile : item . reportFile || '' ,
253+ } )
254+ }
255+
256+ function percentile ( sortedValues , ratio ) {
257+ if ( sortedValues . length === 0 ) {
258+ return 0
259+ }
260+ const index = Math . ceil ( sortedValues . length * ratio ) - 1
261+ return sortedValues [ Math . min ( Math . max ( index , 0 ) , sortedValues . length - 1 ) ]
262+ }
263+
264+ function summarizeSpeedSamples ( samples ) {
265+ if ( samples . length === 0 ) {
266+ return {
267+ count : 0 ,
268+ avgMs : 0 ,
269+ minMs : 0 ,
270+ maxMs : 0 ,
271+ p50Ms : 0 ,
272+ p95Ms : 0 ,
273+ }
274+ }
275+ const values = samples
276+ . map ( item => item . hotUpdateMs )
277+ . filter ( Number . isFinite )
278+ . sort ( ( a , b ) => a - b )
279+ const sum = values . reduce ( ( total , value ) => total + value , 0 )
280+ return {
281+ count : values . length ,
282+ avgMs : Math . round ( sum / values . length ) ,
283+ minMs : values [ 0 ] ,
284+ maxMs : values . at ( - 1 ) ,
285+ p50Ms : percentile ( values , 0.5 ) ,
286+ p95Ms : percentile ( values , 0.95 ) ,
287+ }
288+ }
289+
290+ function groupSpeedSamples ( samples , key ) {
291+ const grouped = new Map ( )
292+ for ( const sample of samples ) {
293+ const groupKey = sample [ key ] || 'unknown'
294+ const list = grouped . get ( groupKey ) || [ ]
295+ list . push ( sample )
296+ grouped . set ( groupKey , list )
297+ }
298+ return Object . fromEntries (
299+ [ ...grouped . entries ( ) ]
300+ . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) )
301+ . map ( ( [ name , groupSamples ] ) => [ name , summarizeSpeedSamples ( groupSamples ) ] ) ,
302+ )
303+ }
304+
305+ function collectSpeedSamplesFromReport ( report , reportFile ) {
306+ const samples = [ ]
307+ for ( const oneCase of report ?. cases ?? [ ] ) {
308+ const caseName = oneCase . name || oneCase . label || oneCase . project || 'unknown'
309+ const project = oneCase . project || 'unknown'
310+ pushSpeedSample ( samples , {
311+ caseName,
312+ project,
313+ surface : 'case-template-preferred' ,
314+ sourceFile : '' ,
315+ hotUpdateMs : oneCase . hotUpdateEffectiveMs ,
316+ rollbackMs : oneCase . rollbackEffectiveMs ,
317+ initialReadyMs : oneCase . initialReadyMs ,
318+ reportFile,
319+ } )
320+
321+ for ( const mutation of oneCase . mutationMetrics ?? [ ] ) {
322+ const sourceFile = mutation . sourceFile || ''
323+ if ( Array . isArray ( mutation . rounds ) ) {
324+ for ( const round of mutation . rounds ) {
325+ pushSpeedSample ( samples , {
326+ caseName,
327+ project,
328+ surface : `${ mutation . mutationKind } :${ round . roundName } ` ,
329+ sourceFile,
330+ hotUpdateMs : round . hotUpdateEffectiveMs ,
331+ rollbackMs : round . rollbackEffectiveMs ,
332+ initialReadyMs : oneCase . initialReadyMs ,
333+ reportFile,
334+ } )
335+ }
336+ }
337+ else {
338+ pushSpeedSample ( samples , {
339+ caseName,
340+ project,
341+ surface : mutation . mutationKind ,
342+ sourceFile,
343+ hotUpdateMs : mutation . hotUpdateEffectiveMs ,
344+ rollbackMs : mutation . rollbackEffectiveMs ,
345+ initialReadyMs : oneCase . initialReadyMs ,
346+ reportFile,
347+ } )
348+ }
349+
350+ for ( const [ extraKey , extraValue ] of [
351+ [ 'added-class' , mutation . addedClassHmr ] ,
352+ [ 'same-class-literal' , mutation . sameClassLiteralHmr ] ,
353+ [ 'comment-carrier' , mutation . commentCarrierHmr ] ,
354+ ] ) {
355+ if ( ! extraValue ) {
356+ continue
357+ }
358+ pushSpeedSample ( samples , {
359+ caseName,
360+ project,
361+ surface : `${ mutation . mutationKind } :${ extraKey } ` ,
362+ sourceFile,
363+ hotUpdateMs : extraValue . hotUpdateEffectiveMs ,
364+ rollbackMs : extraValue . rollbackEffectiveMs ,
365+ initialReadyMs : oneCase . initialReadyMs ,
366+ reportFile,
367+ } )
368+ }
369+ }
370+
371+ for ( const subPackage of oneCase . subPackageMutationMetrics ?? [ ] ) {
372+ for ( const [ kind , mutation ] of [
373+ [ 'template' , subPackage . template ] ,
374+ [ 'style' , subPackage . style ] ,
375+ ] ) {
376+ if ( ! mutation ) {
377+ continue
378+ }
379+ pushSpeedSample ( samples , {
380+ caseName,
381+ project,
382+ surface : `subpackage:${ subPackage . root } :${ kind } ` ,
383+ sourceFile : mutation . sourceFile || '' ,
384+ hotUpdateMs : mutation . hotUpdateEffectiveMs ,
385+ rollbackMs : mutation . rollbackEffectiveMs ,
386+ initialReadyMs : oneCase . initialReadyMs ,
387+ reportFile,
388+ } )
389+ }
390+ }
391+ }
392+ return samples
393+ }
394+
395+ function readWatchReports ( ) {
396+ const reportFiles = listFilesSafe ( ROOT_DIR , name =>
397+ name . endsWith ( '.json' ) && name !== path . basename ( SPEED_REPORT_JSON ) )
398+ . sort ( ( a , b ) => fs . statSync ( a ) . mtimeMs - fs . statSync ( b ) . mtimeMs )
399+ const reports = [ ]
400+ for ( const file of reportFiles ) {
401+ try {
402+ reports . push ( {
403+ file,
404+ report : JSON . parse ( readUtf8 ( file ) ) ,
405+ } )
406+ }
407+ catch { }
408+ }
409+ return reports
410+ }
411+
412+ function buildSpeedReport ( ) {
413+ const reports = readWatchReports ( )
414+ const samples = reports . flatMap ( item =>
415+ collectSpeedSamplesFromReport ( item . report , path . basename ( item . file ) ) )
416+ const initialReadyValues = samples
417+ . map ( item => item . initialReadyMs )
418+ . filter ( Number . isFinite )
419+ . sort ( ( a , b ) => a - b )
420+ const slowest = [ ...samples ]
421+ . sort ( ( a , b ) => b . hotUpdateMs - a . hotUpdateMs )
422+ . slice ( 0 , 10 )
423+
424+ return {
425+ generatedAt : new Date ( ) . toISOString ( ) ,
426+ reportCount : reports . length ,
427+ sampleCount : samples . length ,
428+ summary : summarizeSpeedSamples ( samples ) ,
429+ initialReady : summarizeSpeedSamples (
430+ initialReadyValues . map ( value => ( { hotUpdateMs : value } ) ) ,
431+ ) ,
432+ byProject : groupSpeedSamples ( samples , 'project' ) ,
433+ bySurface : groupSpeedSamples ( samples , 'surface' ) ,
434+ slowest,
435+ sourceReports : reports . map ( item => path . basename ( item . file ) ) ,
436+ }
437+ }
438+
439+ function renderSummaryTable ( grouped ) {
440+ const lines = [
441+ '| name | count | avg | p50 | p95 | min | max |' ,
442+ '| --- | ---: | ---: | ---: | ---: | ---: | ---: |' ,
443+ ]
444+ for ( const [ name , summary ] of Object . entries ( grouped ) ) {
445+ lines . push (
446+ `| ${ name } | ${ summary . count } | ${ summary . avgMs } ms | ${ summary . p50Ms } ms | ${ summary . p95Ms } ms | ${ summary . minMs } ms | ${ summary . maxMs } ms |` ,
447+ )
448+ }
449+ return lines
450+ }
451+
452+ function renderSpeedReportMarkdown ( report ) {
453+ const lines = [ ]
454+ lines . push ( '# e2e-watch HMR 速度报告' )
455+ lines . push ( '' )
456+ lines . push ( `- generated_at: ${ report . generatedAt } ` )
457+ lines . push ( `- source reports: ${ report . reportCount } ` )
458+ lines . push ( `- samples: ${ report . sampleCount } ` )
459+ lines . push (
460+ `- hot update: avg=${ report . summary . avgMs } ms, p50=${ report . summary . p50Ms } ms, p95=${ report . summary . p95Ms } ms, max=${ report . summary . maxMs } ms` ,
461+ )
462+ lines . push (
463+ `- initial ready: avg=${ report . initialReady . avgMs } ms, p50=${ report . initialReady . p50Ms } ms, p95=${ report . initialReady . p95Ms } ms, max=${ report . initialReady . maxMs } ms` ,
464+ )
465+ lines . push ( '' )
466+ lines . push ( '## 按项目' )
467+ lines . push ( ...renderSummaryTable ( report . byProject ) )
468+ lines . push ( '' )
469+ lines . push ( '## 按 HMR 场景' )
470+ lines . push ( ...renderSummaryTable ( report . bySurface ) )
471+ lines . push ( '' )
472+ lines . push ( '## 最慢样本' )
473+ if ( report . slowest . length === 0 ) {
474+ lines . push ( '- no samples' )
475+ }
476+ else {
477+ for ( const sample of report . slowest ) {
478+ lines . push (
479+ `- ${ sample . hotUpdateMs } ms | ${ sample . project } | ${ sample . surface } | ${ sample . sourceFile || 'n/a' } | ${ sample . reportFile } ` ,
480+ )
481+ }
482+ }
483+ lines . push ( '' )
484+ lines . push ( '## 实现依据' )
485+ lines . push ( '- Tailwind v3/v4 官方 Vite/Webpack 插件在 watch 生命周期复用 compiler、scanner 与 candidates 集合。' )
486+ lines . push ( '- 本仓库 HMR 回归沿用同一思路:启动一次开发 watcher,在同一 session 内连续验证 template/script/style/content/subpackage 变更,并记录增量输出与实际生效耗时。' )
487+ return `${ lines . join ( '\n' ) } \n`
488+ }
489+
490+ function generateSpeedReport ( ) {
491+ fs . mkdirSync ( ROOT_DIR , { recursive : true } )
492+ const report = buildSpeedReport ( )
493+ fs . writeFileSync ( SPEED_REPORT_JSON , `${ JSON . stringify ( report , null , 2 ) } \n` , 'utf8' )
494+ fs . writeFileSync ( SPEED_REPORT_MD , renderSpeedReportMarkdown ( report ) , 'utf8' )
495+ process . stdout . write ( `[e2e-watch] hmr speed report written: ${ SPEED_REPORT_MD } \n` )
496+ return report
497+ }
498+
232499function pickSnippet ( source , probes ) {
233500 if ( ! source ) {
234501 return '(empty)'
@@ -632,6 +899,7 @@ function publishJobSummary() {
632899
633900 const title = process . env . SUMMARY_TITLE || 'e2e-watch'
634901 const rootCauseReportPath = path . join ( FAILURES_DIR , 'root-cause-report.md' )
902+ const speedReport = generateSpeedReport ( )
635903
636904 const reportFiles = listFilesSafe ( ROOT_DIR , name => name . endsWith ( '.json' ) )
637905 reportFiles . sort ( ( a , b ) => fs . statSync ( a ) . mtimeMs - fs . statSync ( b ) . mtimeMs )
@@ -674,6 +942,12 @@ function publishJobSummary() {
674942 lines . push (
675943 `- RCA report: ${ fs . existsSync ( rootCauseReportPath ) ? '[root-cause-report.md](./e2e/benchmark/e2e-watch-hmr/failures/root-cause-report.md)' : 'not-found' } ` ,
676944 )
945+ lines . push (
946+ `- HMR speed: avg=${ speedReport . summary . avgMs } ms, p50=${ speedReport . summary . p50Ms } ms, p95=${ speedReport . summary . p95Ms } ms, max=${ speedReport . summary . maxMs } ms` ,
947+ )
948+ lines . push (
949+ `- HMR speed report: ${ fs . existsSync ( SPEED_REPORT_MD ) ? '[hmr-speed-report.md](./e2e/benchmark/e2e-watch-hmr/hmr-speed-report.md)' : 'not-found' } ` ,
950+ )
677951
678952 fs . appendFileSync ( summaryPath , `${ lines . join ( '\n' ) } \n` , 'utf8' )
679953}
@@ -692,6 +966,10 @@ function main() {
692966 publishJobSummary ( )
693967 return
694968 }
969+ if ( command === 'speed-report' ) {
970+ generateSpeedReport ( )
971+ return
972+ }
695973 throw new Error ( `unknown command: ${ command || '(empty)' } ` )
696974}
697975
0 commit comments