77 mkdirSync ,
88 existsSync ,
99 rmSync ,
10+ readdirSync ,
1011} from "node:fs" ;
1112import { join , resolve , dirname } from "node:path" ;
1213import { fileURLToPath } from "node:url" ;
@@ -88,6 +89,12 @@ function parseOciRef(packageStr) {
8889 } ;
8990}
9091
92+ const FRONTEND_ROLES = new Set ( [ "frontend-plugin" , "frontend-plugin-module" ] ) ;
93+
94+ function isFrontendRole ( role ) {
95+ return FRONTEND_ROLES . has ( role ) ;
96+ }
97+
9198// ---------------------------------------------------------------------------
9299// OCI Download (mirrors install-dynamic-plugins.py §663-715)
93100// ---------------------------------------------------------------------------
@@ -148,6 +155,61 @@ async function downloadPlugins(plugins, dest) {
148155 rmSync ( tmpDir , { recursive : true , force : true } ) ;
149156}
150157
158+ // ---------------------------------------------------------------------------
159+ // Frontend bundle validation (Layer 1)
160+ // ---------------------------------------------------------------------------
161+
162+ function findJsFiles ( dir ) {
163+ const entries = readdirSync ( dir , { withFileTypes : true } ) ;
164+ for ( const entry of entries ) {
165+ const full = join ( dir , entry . name ) ;
166+ if ( entry . isDirectory ( ) ) {
167+ if ( findJsFiles ( full ) ) return true ;
168+ } else if ( / \. ( j s | m j s | c j s ) $ / . test ( entry . name ) ) {
169+ return true ;
170+ }
171+ }
172+ return false ;
173+ }
174+
175+ function validateFrontendBundles ( plugins , pluginsRoot ) {
176+ const results = [ ] ;
177+ for ( const plugin of plugins ) {
178+ const { pluginPath } = parseOciRef ( plugin . package ) ;
179+ if ( ! pluginPath ) continue ;
180+
181+ const { pkgName, role } = readPluginMeta ( pluginsRoot , pluginPath ) ;
182+ if ( ! isFrontendRole ( role ) ) continue ;
183+
184+ const scalprumDir = join ( pluginsRoot , pluginPath , "dist-scalprum" ) ;
185+
186+ if ( ! existsSync ( scalprumDir ) ) {
187+ results . push ( {
188+ pkgName,
189+ role,
190+ pluginPath,
191+ status : "fail-bundle" ,
192+ detail : "dist-scalprum/ directory missing" ,
193+ } ) ;
194+ continue ;
195+ }
196+
197+ if ( ! findJsFiles ( scalprumDir ) ) {
198+ results . push ( {
199+ pkgName,
200+ role,
201+ pluginPath,
202+ status : "fail-bundle" ,
203+ detail : "dist-scalprum/ contains no .js/.mjs/.cjs files" ,
204+ } ) ;
205+ continue ;
206+ }
207+
208+ results . push ( { pkgName, role, pluginPath, status : "pass" } ) ;
209+ }
210+ return results ;
211+ }
212+
151213// ---------------------------------------------------------------------------
152214// Config generation
153215// ---------------------------------------------------------------------------
@@ -187,9 +249,10 @@ async function bootBackend(configPaths) {
187249 dynamicPluginsFeatureLoader,
188250 CommonJSModuleLoader,
189251 dynamicPluginsFrontendServiceRef,
252+ dynamicPluginsServiceRef,
190253 } = await import ( "@backstage/backend-dynamic-feature-service" ) ;
191254 const { PackageRoles } = await import ( "@backstage/cli-node" ) ;
192- const { createServiceFactory } =
255+ const { createServiceFactory, createBackendPlugin , coreServices } =
193256 await import ( "@backstage/backend-plugin-api" ) ;
194257 const path = await import ( "node:path" ) ;
195258
@@ -218,6 +281,36 @@ async function bootBackend(configPaths) {
218281 } ) ,
219282 ) ;
220283
284+ backend . add (
285+ createBackendPlugin ( {
286+ pluginId : "smoke-test-probe" ,
287+ register ( env ) {
288+ env . registerInit ( {
289+ deps : {
290+ http : coreServices . httpRouter ,
291+ dynamicPlugins : dynamicPluginsServiceRef ,
292+ } ,
293+ async init ( { http, dynamicPlugins } ) {
294+ const { Router } = await import ( "express" ) ;
295+ const router = Router ( ) ;
296+ router . get ( "/loaded-plugins" , ( _ , res ) => {
297+ res . json ( dynamicPlugins . plugins ( { includeFailed : true } ) ) ;
298+ } ) ;
299+ http . use ( router ) ;
300+ try {
301+ http . addAuthPolicy ( {
302+ path : "/loaded-plugins" ,
303+ allow : "unauthenticated" ,
304+ } ) ;
305+ } catch {
306+ /* API may not exist on this version */
307+ }
308+ } ,
309+ } ) ;
310+ } ,
311+ } ) ,
312+ ) ;
313+
221314 backend . add ( import ( "@backstage/plugin-catalog-backend" ) ) ;
222315 backend . add (
223316 import ( "@backstage/plugin-catalog-backend-module-scaffolder-entity-model" ) ,
@@ -281,6 +374,8 @@ async function probePluginRoutes(plugins, port, pluginsRoot) {
281374
282375 const { pkgName, role, pluginId } = readPluginMeta ( pluginsRoot , pluginPath ) ;
283376
377+ if ( isFrontendRole ( role ) ) continue ;
378+
284379 if ( role !== "backend-plugin" ) {
285380 results . push ( { pkgName, role, pluginPath, status : "skip" } ) ;
286381 continue ;
@@ -322,6 +417,98 @@ async function probePluginRoutes(plugins, port, pluginsRoot) {
322417 return results ;
323418}
324419
420+ // ---------------------------------------------------------------------------
421+ // Frontend plugin probing (Layer 2)
422+ // ---------------------------------------------------------------------------
423+
424+ async function probeFrontendPlugins ( plugins , port , pluginsRoot ) {
425+ const frontendPlugins = [ ] ;
426+ for ( const plugin of plugins ) {
427+ const { pluginPath } = parseOciRef ( plugin . package ) ;
428+ if ( ! pluginPath ) continue ;
429+ const meta = readPluginMeta ( pluginsRoot , pluginPath ) ;
430+ if ( isFrontendRole ( meta . role ) ) {
431+ frontendPlugins . push ( { ...meta , pluginPath } ) ;
432+ }
433+ }
434+
435+ if ( frontendPlugins . length === 0 ) return [ ] ;
436+
437+ const failAll = ( detail ) =>
438+ frontendPlugins . map ( ( fp ) => ( {
439+ pkgName : fp . pkgName ,
440+ role : fp . role ,
441+ pluginPath : fp . pluginPath ,
442+ status : "fail-load" ,
443+ detail,
444+ } ) ) ;
445+
446+ let res ;
447+ try {
448+ res = await fetch (
449+ `http://localhost:${ port } /api/smoke-test-probe/loaded-plugins` ,
450+ ) ;
451+ } catch ( err ) {
452+ return failAll ( `probe endpoint unreachable: ${ err . message } ` ) ;
453+ }
454+
455+ if ( ! res . ok ) {
456+ return failAll ( `probe returned HTTP ${ res . status } ` ) ;
457+ }
458+
459+ let body ;
460+ try {
461+ body = await res . json ( ) ;
462+ } catch {
463+ return failAll ( "invalid probe response" ) ;
464+ }
465+
466+ if ( ! Array . isArray ( body ) ) {
467+ return failAll ( "invalid probe response" ) ;
468+ }
469+
470+ const results = [ ] ;
471+ for ( const fp of frontendPlugins ) {
472+ const loaded = body . find (
473+ ( lp ) => lp && typeof lp === "object" && lp . name === fp . pkgName ,
474+ ) ;
475+
476+ if ( ! loaded ) {
477+ results . push ( {
478+ pkgName : fp . pkgName ,
479+ role : fp . role ,
480+ pluginPath : fp . pluginPath ,
481+ status : "fail-load" ,
482+ detail : "not found in loaded plugins list" ,
483+ } ) ;
484+ } else if ( loaded . platform !== "web" ) {
485+ results . push ( {
486+ pkgName : fp . pkgName ,
487+ role : fp . role ,
488+ pluginPath : fp . pluginPath ,
489+ status : "fail-load" ,
490+ detail : `unexpected platform: ${ loaded . platform } ` ,
491+ } ) ;
492+ } else if ( loaded . failure ) {
493+ results . push ( {
494+ pkgName : fp . pkgName ,
495+ role : fp . role ,
496+ pluginPath : fp . pluginPath ,
497+ status : "fail-load" ,
498+ detail : `plugin loaded with failure: ${ loaded . failure } ` ,
499+ } ) ;
500+ } else {
501+ results . push ( {
502+ pkgName : fp . pkgName ,
503+ role : fp . role ,
504+ pluginPath : fp . pluginPath ,
505+ status : "pass" ,
506+ } ) ;
507+ }
508+ }
509+ return results ;
510+ }
511+
325512// ---------------------------------------------------------------------------
326513// Reporting
327514// ---------------------------------------------------------------------------
@@ -336,33 +523,86 @@ function reportAndWrite(results, resultsFile) {
336523 console . log ( ` SKIP ${ r . pkgName } (${ r . role } )` ) ;
337524 break ;
338525 case "pass" :
339- console . log ( ` PASS ${ r . pkgName } → /api/${ r . pluginId } (${ r . http } )` ) ;
526+ if ( r . pluginId ) {
527+ console . log (
528+ ` PASS ${ r . pkgName } → /api/${ r . pluginId } (${ r . http } )` ,
529+ ) ;
530+ } else if ( isFrontendRole ( r . role ) ) {
531+ console . log ( ` PASS ${ r . pkgName } (${ r . role } )` ) ;
532+ }
340533 break ;
341534 case "warn" :
342535 console . log (
343536 ` WARN ${ r . pkgName } → /api/${ r . pluginId } (404 — pluginId guess may be wrong)` ,
344537 ) ;
345538 break ;
539+ case "fail-bundle" :
540+ console . log ( ` FAIL ${ r . pkgName } [bundle] ${ r . detail } ` ) ;
541+ failedPlugins . push ( r . pkgName ) ;
542+ break ;
543+ case "fail-load" :
544+ console . log ( ` FAIL ${ r . pkgName } [load] ${ r . detail } ` ) ;
545+ failedPlugins . push ( r . pkgName ) ;
546+ break ;
346547 default :
347548 console . log ( ` FAIL ${ r . pkgName } ${ r . error } ` ) ;
348549 failedPlugins . push ( r . pkgName ) ;
349550 }
350551 }
351552
352- const counts = { pass : 0 , warn : 0 , skip : 0 , fail : 0 } ;
353- for ( const r of results ) counts [ r . status ] ++ ;
553+ const be = { pass : 0 , warn : 0 , skip : 0 , fail : 0 } ;
554+ const fe = { pass : 0 , fail : 0 } ;
555+ for ( const r of results ) {
556+ const isFe = isFrontendRole ( r . role ) ;
557+ if ( r . status === "fail-bundle" || r . status === "fail-load" ) {
558+ if ( isFe ) fe . fail ++ ;
559+ else be . fail ++ ;
560+ } else if ( isFe ) {
561+ fe [ r . status ] = ( fe [ r . status ] ?? 0 ) + 1 ;
562+ } else {
563+ be [ r . status ] = ( be [ r . status ] ?? 0 ) + 1 ;
564+ }
565+ }
566+ const total = results . length ;
567+ const totalFail = be . fail + fe . fail ;
354568 console . log (
355- `\n Total: ${ results . length } Pass : ${ counts . pass } Warn: ${ counts . warn } Skip: ${ counts . skip } Fail : ${ counts . fail } \n` ,
569+ `\n Total: ${ total } Backend : ${ be . pass } pass / ${ be . warn } warn / ${ be . fail } fail / ${ be . skip } skip Frontend : ${ fe . pass } pass / ${ fe . fail } fail \n` ,
356570 ) ;
357571
358- const success = counts . fail === 0 ;
572+ const success = totalFail === 0 ;
359573 writeFileSync (
360574 resultsFile ,
361575 JSON . stringify ( { success, failedPlugins, results } , null , 2 ) ,
362576 ) ;
363577 return success ;
364578}
365579
580+ // ---------------------------------------------------------------------------
581+ // Result merging
582+ // ---------------------------------------------------------------------------
583+
584+ function mergeFrontendResults ( bundleResults , loadResults ) {
585+ const loadMap = new Map ( ) ;
586+ for ( const r of loadResults ) {
587+ if ( ! loadMap . has ( r . pkgName ) ) loadMap . set ( r . pkgName , r ) ;
588+ }
589+
590+ return bundleResults . map ( ( br ) => {
591+ if ( br . status === "fail-bundle" ) return br ;
592+
593+ const lr = loadMap . get ( br . pkgName ) ;
594+ if ( ! lr ) {
595+ return {
596+ ...br ,
597+ status : "fail-load" ,
598+ detail : "missing load probe result" ,
599+ } ;
600+ }
601+ if ( lr . status === "fail-load" ) return lr ;
602+ return { ...br , status : "pass" } ;
603+ } ) ;
604+ }
605+
366606// ---------------------------------------------------------------------------
367607// Main
368608// ---------------------------------------------------------------------------
@@ -385,6 +625,15 @@ async function main() {
385625 console . log ( "\n2. Downloading OCI plugin images" ) ;
386626 await downloadPlugins ( plugins , args . pluginsRoot ) ;
387627
628+ console . log ( "\n2b. Validating frontend bundles" ) ;
629+ const bundleResults = validateFrontendBundles ( plugins , args . pluginsRoot ) ;
630+ const bundleFailCount = bundleResults . filter (
631+ ( r ) => r . status === "fail-bundle" ,
632+ ) . length ;
633+ console . log (
634+ ` ${ bundleResults . length } frontend plugin(s) checked, ${ bundleFailCount } failed` ,
635+ ) ;
636+
388637 console . log ( "\n3. Generating merged plugin config" ) ;
389638 const generatedCfg = generateMergedConfig ( plugins , args . pluginsRoot ) ;
390639
@@ -397,13 +646,27 @@ async function main() {
397646 console . log ( "\n5. Waiting for readiness" ) ;
398647 await waitForReady ( args . port , args . timeout ) ;
399648
400- console . log ( "\n6. Probing plugin routes" ) ;
401- const results = await probePluginRoutes (
649+ console . log ( "\n6a. Probing backend plugin routes" ) ;
650+ const backendResults = await probePluginRoutes (
651+ plugins ,
652+ args . port ,
653+ args . pluginsRoot ,
654+ ) ;
655+
656+ console . log ( "\n6b. Probing frontend loaded plugins" ) ;
657+ const frontendLoadResults = await probeFrontendPlugins (
402658 plugins ,
403659 args . port ,
404660 args . pluginsRoot ,
405661 ) ;
406- success = reportAndWrite ( results , args . resultsFile ) ;
662+
663+ const frontendResults = mergeFrontendResults (
664+ bundleResults ,
665+ frontendLoadResults ,
666+ ) ;
667+
668+ const allResults = [ ...backendResults , ...frontendResults ] ;
669+ success = reportAndWrite ( allResults , args . resultsFile ) ;
407670 } finally {
408671 console . log ( "Shutting down backend..." ) ;
409672 await backend . stop ( ) ;
0 commit comments