If we follow the import.meta.glob(...) advice in the README, then we're creating a dynamic entrypoint for every single test file in the app. In large apps, that can be hundreds/thousands of files. Introducing that many entrypoints can cause a massive hit to rollup/rolldown build performance.
Multiple entrypoints are necessary because Ember Exam needs to control the execution of the QUnit.* methods for filtering & load balancing. Our workaround has been to wrap all our test modules in a default-exported function, add eager: true to the import.meta.glob, and then 'trick' Ember Exam into running those functions when it asks for the module.
const rawModules = import.meta.glob("./**/*-test.{gjs,js}", {
eager: true, // << this is the important bit for improved build performance
});
examModules = {};
for (const [key, value] of Object.entries(rawModules)) {
examModules[key] = value.default; // `.default` returns our wrapper functions
}
startEmberExam({
availableModules: examModules,
// ...
});
This works because Ember Exam expects availableModules to be a map of moduleName => function. It doesn't care whether the function is async-or-not, and doesn't care about the return value.
For reference, this is the rolldown plugin we use to wrap the modules automatically. The same effect could be achieved by making this change in the actual source files:
Rolldown test module wrapping plugin
const TEST_FILE_RE = /tests\/(?!helpers\/).*-test\.(?:gjs|js)$/;
// Technically not 100% safe. Would be tripped up by top-level-await, imports in weird places, etc.
export default function wrapTestModulesPlugin() {
return {
name: "wrap-test-modules",
transform: {
filter: { id: TEST_FILE_RE },
handler(code, id, { magicString }) {
const ast = this.parse(code);
let lastImportEnd = 0;
for (const node of ast.body) {
if (node.type === "ImportDeclaration") {
lastImportEnd = node.end;
}
}
if (lastImportEnd >= code.length) {
return null;
}
magicString.appendLeft(
lastImportEnd,
"\n\nexport default function () {\n"
);
magicString.append("\n}\n");
return {
code: magicString,
};
},
},
};
}
In Discourse, this workaround cuts around 680 entrypoints from our build, and reduces overall Rolldown build time by almost 30% (from ~7s to ~5s in my A/B test just now).
Clearly... the workaround is not ideal. But I wanted to share our findings in case it helps anyone else, and so that it can be a starting point for a more 'official' solution to the problem.
If we follow the
import.meta.glob(...)advice in the README, then we're creating a dynamic entrypoint for every single test file in the app. In large apps, that can be hundreds/thousands of files. Introducing that many entrypoints can cause a massive hit to rollup/rolldown build performance.Multiple entrypoints are necessary because Ember Exam needs to control the execution of the
QUnit.*methods for filtering & load balancing. Our workaround has been to wrap all our test modules in a default-exported function, addeager: trueto theimport.meta.glob, and then 'trick' Ember Exam into running those functions when it asks for the module.This works because Ember Exam expects
availableModulesto be a map of moduleName => function. It doesn't care whether the function is async-or-not, and doesn't care about the return value.For reference, this is the rolldown plugin we use to wrap the modules automatically. The same effect could be achieved by making this change in the actual source files:
Rolldown test module wrapping plugin
In Discourse, this workaround cuts around 680 entrypoints from our build, and reduces overall Rolldown build time by almost 30% (from ~7s to ~5s in my A/B test just now).
Clearly... the workaround is not ideal. But I wanted to share our findings in case it helps anyone else, and so that it can be a starting point for a more 'official' solution to the problem.