Plugin imports (inside packs, or config-direct)
Plugins are not a separate system - they are the bundled-import mechanism of the Scenario Packs master surface. The same scenario contract, the same loader rules, the same campaign. Choosing between the surfaces is a packaging decision, not a learning problem:
| How the scenario reaches your run | What it is called |
|---|---|
files in packs/<visibility>/<name>/ + manifest entry |
pack (the default) |
an import specifier listed in the pack’s own pack.json |
plugin (bundled) - the pack ships its imports |
an import specifier listed in crashtest.config.js |
plugin (config-direct) - the escape hatch |
A plugin is a scenario module delivered as an importable specifier - an npm package, or a local ESM file. The same contract as libraries and packs; everything lands in one campaign.
Bundled (inside a pack) - the unified path
// packs/public/your-pack/pack.json
{
"schemaVersion": 1,
"name": "your-pack",
"description": "entry + bundled imports, self-contained",
"entry": "index.js",
"plugins": ["./bundled-canary.js", "some-npm-shape-pack"]
}
Bundled specifiers resolve against the pack directory (a pack ships its
own files), join the same campaign, and the module plan carries the pack
origin with them: pack:public/your-pack -> plugin:./bundled-canary.js.
An adopting repo picks up the whole pack - entry + imports - with zero
config wiring.
Config-direct (the escape hatch)
// crashtest.config.js
export default {
routes: buildRoutes(),
env: ENV_SPEC,
plugins: [
'crashtest-plugin-rails-shapes', // npm package (imported as-is)
'./shims/wallet-profile.js', // local file, relative to this config
],
};
Use this when the scenario belongs to no pack - a one-off shim, a registry package you don’t vendor. Relative paths resolve against the config file’s directory, not the cwd - a run root beside the target resolves plugin paths the same way the target sees them.
The rules (shared with packs, non-negotiable)
- Same contract. A plugin must default-export
{ name, description, attacks: [{ name, run(harness, ctx) => one verdict }] }- the same validatorsrc/scenarios/contract.jsapplies to libraries, packs, and plugins alike. - Fail loudly at cause. An unresolvable specifier, a TypeScript plugin (crashtest imports plain ESM JS only - it never transpiles), a syntax error inside the module, or a contract violation fails the run naming both the specifier and the reason. A broken import is a config error, never a silent gap.
- One name, one module. A module exporting a scenario name that is already loaded (library, pack, bundled import, or another plugin) throws with the origin of the earlier claim. Rename one of them.
- Knob shape matters. The manifest’s
pluginsand the config’splugins:must both be arrays of non-empty strings; anything else fails at validation time with the expected shape.
Determinism and dependencies
Plugins run under the same seeded ctx as every other module - a plugin is as reproducible as the built-ins. The zero-dependency rule covers what crashtest ships; a plugin that imports its own dependencies owns them, and a missing dependency surfaces as the honest resolver error, not a crash.
Where plugins show in the plan
crashtest run --module-plan --json labels every module with its delivery
surface. Config-direct rows read plugin:<specifier>; bundled rows carry
the pack they came in with. Libraries, packs, bundled imports, and plugins
sort together by module name - one campaign, one plan, honest labels.