docs/EXT.md
The extension (“ext”) contract: how a separate JAR contributes runtime behaviour (a JS-scope global, event observation) and report UI (assets, embed renderers, nav pages) to a Karate run without touching
karate-core. The first real consumer iskarate-image; a worked example lives there.Companion to DESIGN.md § Ext Architecture, which covers the
karate-boot.jsactivation surface and lifecycle. This doc is the authoring reference for the SPI types an ext implements / calls.
| Contribution | Mechanism | Lifecycle |
|---|---|---|
| Observe the run | implement Ext extends RunListener, onEvent(RunEvent) | every event SUITE_ENTER→SUITE_EXIT |
A JS-scope global (* image.compare(...)) | Suite.registerGlobal(name, …) in onBoot | per-Suite singleton or per-scenario instance |
| Report assets (JS/CSS/pages) | Suite.registerReportAssets(ReportAssets, ClassLoader) in onBoot | copied + spliced at report-write time |
| A custom embed payload + its UI | emit StepResult.Embed at runtime + KarateReport.registerEmbed in ext JS | embed on the wire; renderer in the browser |
An async channel (karate.channel('grpc')) | Suite.registerChannelFactory(type, factory) in onBoot | factory is suite-scoped; wins over the built-in fallback |
A CLI subcommand (karate serve) | implement CliCommandProvider, register via META-INF/services | discovered at launch; see CLI subcommands |
The run-time wiring above is imperative, from onBoot(Suite) — there is no manifest.json
and no annotation/ServiceLoader discovery for ext activation (that is the explicit
boot.ext('name') in karate-boot.js; see DESIGN.md). Resolution is by name convention:
boot.ext('image') → io.karatelabs.ext.image.ImageExt. CLI subcommands are the one
exception — they are a launch-time (pre-Suite) concern and are discovered via ServiceLoader
(below).
Ext interfaceio.karatelabs.core.Ext — extends RunListener; every method has a default, so an ext
implements only what it needs.
public interface Ext extends RunListener {
default void onBoot(Suite suite) {} // register globals + assets here
default void onShutdown() {} // after SUITE_EXIT
default Map<String, Object> getManifest() { // → SUITE_ENTER.data.exts[]
return Collections.emptyMap();
}
default boolean onEvent(RunEvent event) { return true; } // observe; false skips (from RunListener)
}
onBoot runs once per Suite, before SUITE_ENTER. Exceptions here fail the Suite
loudly — do eager validation (bad config path, missing resource) here. Exceptions
inside onEvent are logged WARN and dropped (the run continues, that signal is lost).getManifest is a free-form Map surfaced on SUITE_ENTER.data.exts[] so receivers
know which exts were active and with what config. Keep it small (name/version/key config).Source: Ext.java, and DESIGN.md § Ext Architecture for the boot.* namespace + lifecycle.
onBoot registers an object under a name; the runtime seeds it into JS scope before
karate-base.js / karate-config.js evaluate (same mechanism as karate/read/match,
applies to called features too). A name that collides with a built-in
(karate/read/match/driver/…) fails the Suite at boot.
Two registration forms on Suite:
// (a) one shared instance for the whole Suite — fine for STATELESS globals
public void registerGlobal(String name, Object instance);
// (b) a factory — a FRESH instance per scenario, handed that scenario's context.
// Use this whenever the global holds per-scenario config (parallel-safe).
public void registerGlobal(String name, ExtGlobalFactory factory);
public Object getGlobal(String name);
public Map<String, Object> getGlobals();
public interface ExtGlobalFactory {
Object create(KarateJsContext context); // context.getWorkingDir() resolves this:/classpath:/file:
}
Choose the factory form for any stateful global. A single shared instance is unsafe
once scenarios set config on it (* image.threshold = 0.02) under parallel execution.
karate-image registers a factory so each scenario gets its own ImageApi seeded with a
copy of the boot defaults plus the scenario’s KarateJsContext (which resolves
this:/classpath:/file: paths — no bespoke path code, no thread-locals).
SimpleObject, not a reflection POJOio.karatelabs.js.SimpleObject exposes members to the JS engine natively (no reflective
adapter on the hot path). The contract is three methods:
public class ImageApi implements SimpleObject {
public Object jsGet(String name) { ... } // member read OR a JavaInvokable for a method
public void putMember(String name, Object value) {} // member/config write → image.threshold = 0.02
public Collection<String> jsKeys() { ... } // enumeration
}
image.compare(...)) is jsGet("compare") returning a JavaInvokable
(Object call(Object... args)).image.baselineDir = '...') lands in putMember; reads come back
through jsGet.This property-setter + verb idiom is the canonical ext-global style (it mirrors
karate.channel('grpc')): set config as properties, then call the operation —
no method-name churn when config keys are added.
* image.baselineDir = 'baselines' # putMember
* image.threshold = 0.02 # putMember
* def r = image.compare('home', shot) # jsGet('compare') → JavaInvokable
Binary args. A JS
Uint8Arrayarrives as aJsValuewhosegetJavaValue()isbyte[]; a raw decodedbyte[]crosses JS scope as a number list. Unwrap canonically viaJsValue.getJavaValue()(seeImageApi.toBytes).
Source: Suite.registerGlobal / getGlobal / getGlobals, ExtGlobalFactory.java,
SimpleObject.java (karate-js), ScenarioRuntime.initEngine (seeding).
karate.channel('<type>')An ext can supply the ChannelFactory for an async channel type (grpc, kafka, …) by
calling Suite.registerChannelFactory(type, factory) from onBoot. When a scenario evaluates
karate.channel('grpc'), KarateJs.channel() looks up the suite-registered factory first,
and only falls back to the name convention io.karatelabs.ext.<type>.<Type>ChannelFactory
(e.g. io.karatelabs.ext.grpc.GrpcChannelFactory) — mirroring boot.ext resolution — if none is
registered. There is no hardcoded type→class map in core. This lets a gated ext own its
channel wiring end-to-end:
// io.karatelabs.ext.grpc.GrpcExt
public void onBoot(Suite suite) {
// 1. license gate (keycheck) — throws here fail the Suite
requireProduct("grpc");
// 2. register the factory; suite-scoped instance = natural home for suite/JVM-wide init
suite.registerChannelFactory("grpc", new GrpcChannelFactory());
}
// karate-boot.js
boot.ext('grpc'); // gates + registers the grpc channel factory
Because the factory instance is held on the Suite, the ext gets a single place to do
suite/JVM-wide init (shared ManagedChannels, connection pools, proto descriptor caches)
that earlier Karate lacked — lazily, on first channel() use, or eagerly in onBoot. The
name-convention fallback still resolves a factory from a bare classpath dep (no boot.ext), but
that path is ungated by the ext — the factory itself remains responsible for its own license
check. The boot.ext('grpc') path is canonical: it gates loudly at boot and owns init.
Channels self-configure via their rich JS object — karate.channel('kafka') (or the
boot.ext('kafka') ext object) exposes config setters/methods — not via a global
configure <type>. Core's configure keyword is strict (unknown keys throw), so there is no
hardcoded channel-type list and no map-valued config keys to typo-swallow. Connection-scoped
state (Kafka bootstrap.servers, pooled ManagedChannels) lives on the suite-scoped factory/ext
for suite/JVM-wide reuse; per-scenario objects (consumers/producers) are created per channel() call.
Source: Suite.registerChannelFactory / getChannelFactory, KarateJs.channel()
(suite registry → name-convention fallback), ChannelFactory.java.
karate serveAn ext JAR can add a top-level karate subcommand (e.g. karate serve) without any change to
core per command. This is a launch-time concern (it runs before — or instead of — a Suite), so
unlike ext activation it uses java.util.ServiceLoader, not boot.ext.
// 1. a picocli @Command (picocli is a core dependency, so it's on your compile classpath)
@Command(name = "serve", description = "Start the karate-max curl + MCP server")
public class ServeCommand implements Callable<Integer> {
@Option(names = {"-p", "--port"}) int port = 4444;
@Override public Integer call() { /* gate, start server, block */ return 0; }
}
// 2. a provider returning the command instance(s)
public class MaxCliCommandProvider implements CliCommandProvider {
@Override public List<Object> commands() { return List.of(new ServeCommand()); }
}
Register the provider the standard ServiceLoader way — a file on the ext JAR:
META-INF/services/io.karatelabs.cli.CliCommandProvider
→ io.karatelabs.ext.max.MaxCliCommandProvider
io.karatelabs.Main.buildCommandLine() loads every CliCommandProvider on the classpath and
registers its commands as first-class karate subcommands. They show up in --help, parse their
own flags, and are not swallowed by the legacy "bare path → run" default. The karate
launcher already composes the classpath as core jar → ext/*.jar → --cp, so simply dropping
the ext JAR in .karate/ext/ makes its subcommand available.
Source: io.karatelabs.cli.CliCommandProvider, Main.buildCommandLine() / Main.defaultToRun.
ReportAssetsAn ext ships JS/CSS/HTML inside its JAR under META-INF/karate-ext/ and declares them in
onBoot. io.karatelabs.core.ReportAssets is a fluent spec:
suite.registerReportAssets(
ReportAssets.named("image")
.js("static/image.js") // required
.css("static/image.css") // optional
.page("nav.pages", "Image diffs", "pages/image.html"), // zero or more
getClass().getClassLoader());
On-disk shape inside the JAR (META-INF/karate-ext/ is the implicit root):
META-INF/karate-ext/
├── static/ # ext.js/css + any assets — the static/ prefix is stripped for the web path
└── pages/ # optional standalone HTML pages (nav.pages slot) — pages/ prefix kept
Validation runs at onBoot and fails the Suite loudly: js(...) is required, and every
referenced resource must exist on the classloader. No core-version guard today (exts ship
in lockstep with core).
At report-write time (HtmlReportWriter via HtmlReportListener):
target/karate-reports/ext/<name>/ (static/ stripped,
pages/ kept) — so static/image.js → ext/image/image.js;<script src="ext/<name>/<js>" defer></script> (+ optional <link>) is spliced into
the <!-- KARATE_EXTS --> placeholder on every page, registration-ordered;page("nav.pages", title, href) becomes an <a> topbar tab spliced into
<!-- KARATE_NAV -->, linking ext/<name>/<href>.feature-html/, so their refs carry a ../ prefix
(root/summary/timeline use no prefix) — the writer handles this.Convention: name the asset file after the ext (
image.js, not a genericext.js). The per-ext dir already prevents collisions; ext-named files are self-identifying in browser DevTools / stack traces, and the source leaf becomes the URL leaf (ext/image/image.js).CSS: scope it, don’t Tailwind it. Core owns the Tailwind build and scans only core templates, so a Tailwind class an ext references that core never renders is purged. Ext CSS is hand-authored and scoped under a
.k-<name>-extclass (channel b). (A maintainer-managedsafelistinetc/tailwind/tailwind.config.jsexists for a future shared utility vocabulary, but is empty today.)
Source: ReportAssets.java, Suite.registerReportAssets / getReportAssets,
HtmlReportWriter (buildExtsHtml / buildNavHtml / copyExtAssets).
A step can attach a named, multi-part embed; the report renders it. This is also how exts coordinate (each writes its own named embed; receivers decode by name).
StepResult.Embedpublic static class Embed {
Embed(byte[] data, String mime, String name); // single-asset convenience → one "primary" part
Embed(String name, List<Part> parts, Map<String,Object> meta); // multi-asset
}
public static class Part {
Part(String role, String mime, byte[] data); // inline bytes (core writes to embeds/)
Part(String role, String mime, String url); // ext-written asset (report-relative URL)
}
Every embed serialises to the uniform shape
{name, parts:[{role, mime, data|url|file}], meta} (no legacy flat form). A screenshot is
one "primary" part; image-comparison carries baseline/latest/diff. Per-part
serialisation precedence: file (core wrote inline bytes to disk) → url (ext wrote the
asset) → data (inline base64). role is an open string; meta is free-form.
Emit from a global/step via the thread-local log context:
LogContext.get().embed(new StepResult.Embed("image-comparison", parts, meta));
…or from JS (so an ext recipe in user space can emit) via the multi-part form of
karate.embed:
karate.embed({
name: 'image-comparison',
parts: [
{ role: 'baseline', path: 'this:baselines/home.png' }, // core reads it to bytes
{ role: 'latest', data: latestBytes }, // bytes / Uint8Array
{ role: 'diff', data: diffBytes } // mime auto-detected when omitted
],
meta: { mismatchPercentage: 2.3, pass: false }
});
Dispatch: a first-arg Map with a parts list → multi-part; otherwise the legacy
karate.embed(data, mime?, name?) single-part form. Each part takes data (bytes), path
(a this:/classpath:/file: resource core reads), or url (an asset the ext wrote);
role is required, mime is auto-detected from the bytes when omitted.
Embeds land only at FEATURE_EXIT.data.scenarioResults[i].stepResults[j].embeds[]
(not duplicated onto SCENARIO_EXIT) — see DESIGN.md § Reports for why.
KarateReport.registerEmbedCore report JS (res/karate-report.js) renders each embed; an ext can take over rendering
for its embed name. The ext’s <script defer> calls:
window.KarateReport.registerEmbed('image-comparison', (embed, api) => {
// embed = {name, parts, meta}; return an HTML string.
// api helpers: api._embedPartSrc(part) → page-relative src; api._esc(str) → HTML-escape.
return '<div class="k-image-ext">…</div>';
});
_renderEmbed delegates to a registered renderer keyed by embed.name, else falls back
to the generic per-part render (so the embed still shows with no ext loaded — graceful
degradation).defer) renders the report before the ext <script defer> runs, so
registration is typically late. registerEmbed handles this by upgrading
already-rendered embeds in place (each embed host carries a stable data-embed-id). Ext
authors don’t manage ordering._renderEmbed emits an empty placeholder host and runs the
renderer only when the host scrolls into view (IntersectionObserver), so large reports
(hundreds of image diffs) don’t build/decode every embed at first paint. Renderers stay a
single function — defer is transparent to ext authors. Embeds inside collapsed steps
materialize when expanded into view; everything is force-rendered on beforeprint (so
print / PDF / Ctrl-F don’t miss off-screen embeds). An ext can defer its own heavy
sub-content further (e.g. build a <dialog>’s full-res images only on open).KarateReport is exposed on window precisely so a separate ext script can reach it (a
top-level const is a global lexical binding, not a window property).Source: StepResult.java (Embed / Part / toMap), res/karate-report.js
(registerEmbed / _renderEmbed / _embedPartSrc), LogContext.embed.
Named DOM containers / splice points the report exposes for ext UI. Reality vs. design:
| Slot | Where | Wired today? |
|---|---|---|
step.embed | per-step | ✅ via KarateReport.registerEmbed (above) — no DOM container needed |
nav.pages | topbar nav | ✅ server-side splice (<!-- KARATE_NAV -->) from ReportAssets.page(...) |
summary.panels | summary, below fold | ◑ container present; client-side render |
summary.cards / feature.panels | summary KPI row / feature sidebar | ✗ container not yet in templates |
step.embed and nav.pages are the two an ext relies on today.
io.karatelabs.ext.<name>.<Name>Ext implements Ext; in onBoot:
suite.registerGlobal("<name>", ctx -> new <Name>Api(defaults, ctx)) (factory if stateful);suite.registerReportAssets(ReportAssets.named("<name>").js("static/<name>.js")…, getClass().getClassLoader()).<Name>Api implements SimpleObject — jsGet (verbs as JavaInvokable + config reads),
putMember (config writes), jsKeys.StepResult.Embed via LogContext.get().embed(...) for any rich step output.META-INF/karate-ext/static/<name>.js that calls
window.KarateReport.registerEmbed('<embed-name>', fn); CSS scoped under .k-<name>-ext.karate-boot.js: const x = boot.ext('<name>'); x.someConfig = ….See karate-image for a complete, shipping example.
Pattern — primitives + a JS recipe (for stateful exts with orchestration). Prefer pure,
composable Java verbs (e.g. image.diff returns a result + an embed payload; it doesn't
emit, fail, or write files) and ship the multi-step orchestration (capture → establish →
compare → emit → fail) as a scenario-scope JS recipe the project copies and overrides —
not as a baked-in method, and not as a function attached to the global in karate-boot.js
(that closes over boot scope, so screenshot()/karate would bind wrong; the recipe
must live where it's called — a karate-config-loaded *.js or a called feature). This keeps
the engine testable and leaves policy (paths, thresholds, what counts as failure) in user
space. karate-image's screenGrab is the worked example.
Forward-looking design captured here so it isn't lost. These are not shipped. Items are grouped by area; each is a one-liner with enough of the rationale to restart the work. (This section absorbed the live forward-design of the now-deleted
IMAGE_SPIKE.md— Phases 1–3 of that spike are shipped and documented above / in DESIGN.md / thekarate-imageREADME; only the unbuilt parts live on here.)
ReportAssets allows a single .js() / .css() today. An
ext bundling several capabilities must concat into one bundle (no SPI change) — decide
"concat-first" vs. growing the builder to lists. Surfaced by the karate-xplorer über-ext
(multiple report surfaces). Land the decision before/with the second real ext.feature.panels / summary.cards slots. Declared in the slot model but the template
containers don't exist yet — add them only when a feature-page panel or extra KPI card is
actually wanted (see Slot model table above).karate/read/…) only. A registered ext global also shadows a user def-bound
var of the same name — extend the seed-time check to warn (or error) on that. (* image = 1
is already invalid syntax, so only shadowing, not assignment, is the concern.)META-INF/services/...Ext ServiceLoader discovery —
boot.ext('name') is the explicit, only activation path (surprise activation is a non-goal).* image.threshold = 0.02) and JS method
calls (* def r = image.compare(...)), both of which already work with no StepExecutor
change. Two richer forms are designed but deferred (no ETA), to a separate spike:
* image { compare: 'home.png', baseline: '...' }: handler routes
by keys; needs a StepHandler functional interface + a branch in StepExecutor.run's
keyword-switch default (the hot path — only matches when the first token is a registered
ext global implementing StepHandler, else falls through to JS eval).* image compare "x.png" against "base/x.png" within 0.02
matched against a JS-authored, runtime-registered pattern (no compile step, typed params).Resolved. Suite.registerChannelFactory(type, factory) lets io.karatelabs.ext.grpc.GrpcExt /
…kafka.KafkaExt register their factory in onBoot; KarateJs.channel() checks the suite
registry first, then falls back to the io.karatelabs.ext.<type>.<Type>ChannelFactory name
convention. The hardcoded KarateConfig.CHANNEL_FACTORIES map (and getChannelFactoryClass /
isChannelType) were removed — no back-compat. grpc/kafka repackaged to io.karatelabs.ext.*
in the karate-ext monorepo.
These need upstream karate-core wire/serializer changes before the report frontend can use them:
step.logs is a free-form text blob with 1 > / 1 < markers;
emit structured request/response on the step so the report can render a method/status/
headers/body block (and a copy-as-cURL button) without regex-parsing text.HtmlReportWriter.buildStepData only passes
step.getError().getMessage(); a structured (recursive) Result.Failure serializer is
needed for a side-by-side diff view.buildScenarioData omits the row's column values); needed to aggregate sibling outline
examples into one table.SuiteResult.tagStats
(Map<String,{passed,failed,skipped}>); enables tag pass-rate rings.@-prefix inconsistency (was O23). SCENARIO_ENTER emits tags via
RunUtils.tagTexts (no leading @) while ScenarioResult / SCENARIO_EXIT emit
tag.toString() (with @). Any consumer joining scenarios by tag must normalize — emit
uniformly across events to remove the foot-gun.@report=false × ext embeds (was O9). @report=false strips step detail from
artifacts; verify ext-emitted embeds (e.g. image diffs of redacted scenarios) also don't
leak. Confirm with a test — not obviously covered today.karate-pdf. Two paths kept open, both targeting the same print CSS so
ext content renders in both: (A) JSONL → printable HTML → browser print-to-PDF (default,
zero new deps, needs user interaction); (B) boot.ext('pdf-export') observes
SUITE_EXIT, renders the written karate-summary.html via the existing headless-Chrome CDP
infra (Page.printToPDF), with native bookmarks + target-counter() TOC.static/<name>.print.css (print pipeline injects it); decide
auto-detection (was O4). karate-image's print CSS would stack the 3 images vertically.karate-xplorer über-ext (was Phase 4)karate-ext repo). Prereq nav.pages render is
done; the remaining SPI-side blocker is the multi-file-JS decision above. Detailed über-ext
design lives in the veriquant unified-traceability-substrate memo, not here.karate-core done; the sibling karate-ext repo still needs
repackaging io.karatelabs.plugins.* → io.karatelabs.ext.*, and the Rust launcher's
~/.karate/ext/ recognition verified. A window where on-disk JARs say karate-plugins
against a core resolving karate-ext breaks user setups — coordinate with the first release
that requires it.karate-image artifact adds:
publish io.karatelabs:karate-image to Maven Central; attach the -Pfatjar jar to the
GitHub release; add a karate.sh manifest entry; CI runs mvn -pl karate-image test + a
fatjar-build job. karate-ext's monorepo version tracks karate-core exactly (O12).