Skip to content

Commit fca50df

Browse files
committed
feat(smoke-tests): validate frontend plugins via bundle checks and loaded-plugin probe
Add two-layer frontend plugin validation to the smoke test harness: - Layer 1: static validation of dist-scalprum/ after OCI download - Layer 2: inline backend probe plugin querying dynamicPluginsServiceRef Made-with: Cursor
1 parent 6cb5fd0 commit fca50df

1 file changed

Lines changed: 272 additions & 9 deletions

File tree

smoke-tests/smoke-test.mjs

Lines changed: 272 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
mkdirSync,
88
existsSync,
99
rmSync,
10+
readdirSync,
1011
} from "node:fs";
1112
import { join, resolve, dirname } from "node:path";
1213
import { 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 (/\.(js|mjs|cjs)$/.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

Comments
 (0)