This file guides AI assistants writing and changing plugin tests under test/plugin/scenarios/. For the full test-framework mechanics (scenario layout, configuration.yml, expectedData.yaml, containers, running locally) see the “Plugin Test Framework” section of apm-sniffer/apm-sdk-plugin/CLAUDE.md, and docs/.../Plugin-test.md.
For an agent plugin, the plugin test scenario is the primary and sufficient test. Do not over-engineer by adding many unit tests for a plugin.
A plugin's real risk is two things, and a scenario proves both at once by running the real framework:
So: when adding or changing a plugin, add or extend its scenario, not a pile of mock-based unit tests. Reserve unit tests for genuinely logic/namespace-critical shared code where a deterministic in-JVM assertion earns its keep (e.g. servlet-commons wrap() resolving javax vs jakarta with both APIs on the test classpath) — never for per-plugin interceptor mocking.
A scenario proves a plugin by asserting the exact spans the real framework makes the agent produce. Pick the shape by what the framework actually does — and don't settle for a single entry span when the plugin also does propagation, or you silently skip the inject/extract path.
RPC / web frameworks that have both a server and a client side (Struts, Spring MVC, gRPC, Dubbo, HTTP servers …): drive the full round-trip — curl → server → its own client call → another service's endpoint. The trace then contains 2 entry spans + 1 exit span:
curl hit (proves the server side: extract + entry),The ref itself must be asserted — for RPC the cross-process refs: block is the whole point, so expectedData.yaml must include it on that second entry span with refType: CrossProcess, parentEndpoint, parentSpanId (pointing at the exit span), parentTraceSegmentId: not null, parentService, and traceId: not null. A scenario that asserts spans but not the ref does not prove propagation.
The struts2.7 scenario is exactly this: case.action (entry) → HttpClient (exit) → case1.action (entry + ref); jetty-12 uses the same shape via a JDK-HttpURLConnection self-call. Keep that nested self-call — it is the only thing that proves inject / extract / propagation; a lone entry span does not.
Client-only plugins (JDBC/PostgreSQL, Redis, MongoDB, an HTTP client, a message producer): the peer is a database/broker, not another instrumented SkyWalking service, so there is no second entry span. Assert just the client exit span (component, peer, and the db.* / http.* tags). You do NOT need to stand up another service endpoint.
support-version.list must cover the framework's supported range, but keep one version per minor version — the latest patch, not every patch. E.g. Jetty 12.0.36 + 12.1.10; Struts 7.0.3, 7.1.1, 7.2.1; Spring 6.0.4, 6.1.1, 6.2.19. Verify each version resolves on Maven Central. This is the compatibility proof; CI runs the scenario against each listed version.
A scenario runs inside one of two prebuilt container images, selected by type: in configuration.yml:
type: jvm → the agent-test-jvm container. Your app is a fat-jar started by bin/startup.sh with ${agent_opts} (the -javaagent: line — you MUST include it in startup.sh). Built FROM ${base_image_java}.type: tomcat → the agent-test-tomcat container. Your app is a *.war dropped into /usr/local/tomcat/webapps/; the agent is wired in via the container's patched catalina.sh. Built FROM ${base_image_tomcat}.The version pin is in the CI lane, not configuration.yml. configuration.yml only says type: tomcat — it does not choose a Tomcat version. The concrete JDK and Tomcat versions come from the base_image_java / base_image_tomcat inputs passed to ./.github/actions/build in the lane's Build job, and every scenario in that lane shares that one image. So:
type: tomcat on the plugins-jdk8-* lane → tomcat:8.5-jdk8 (javax); on plugins-jdk11-* → tomcat:9.0-jdk11 (javax); on plugins-jdk17-* → tomcat:10.1-jdk17 (jakarta).To run locally you pass the same two knobs explicitly (the defaults are the javax/JDK-8 pair): bash test/plugin/run.sh --base_image_java eclipse-temurin:17-jdk --base_image_tomcat tomcat:10.1-jdk17-temurin <scenario>. Some newer bases lack curl (the container health check needs it) — the JDK-25 lane bakes it in via .github/workflows/Dockerfile-tomcat-jdk25-withCurl.
Scenarios are load-balanced across .github/workflows/plugins-<jdk>-test.<group>.yaml files. Each file is one lane: its Build job pins base_image_java + base_image_tomcat, and its test job‘s matrix.case: list names the scenarios that run on that image. Lanes exist per JDK (jdk8/jdk11/jdk17/jdk21/jdk25); the trailing .0/.1/… is just a bucket to spread load. Register a new scenario by adding its directory name to the matrix.case: list of the lane whose base image matches the framework’s needs — use python3 tools/select-group.py to pick the least-loaded bucket in that JDK lane.
The container‘s run.sh runs under set -e and hits the entry service with curl -s --max-time 3 ${SCENARIO_ENTRY_SERVICE} — no warm-up first. So the very first (cold) entry request must finish within 3 seconds or curl exits 28 and the whole scenario fails. That first request pays a lot of one-time cost at once: the agent’s ByteBuddy enhancement of every class it touches on that path, JIT, and — for WARs — a cold Jasper JSP compile, all under the container's tight -Xmx256m heap and whatever CPU the runner gives, with jacoco simultaneously instrumenting the org.apache.skywalking.* classes for coverage.
Keep the entry path's cold cost low:
httpheader, a plain-text/stream result). The view is irrelevant to what a plugin scenario verifies (entry/exit spans, tags, propagation), and a cold Jasper compile is expensive.docker run --cpus=0.5 …) before assuming a hang.type: to the deployment: jvm (fat-jar started by bin/startup.sh, ${agent_opts} required) or tomcat (WAR on a container). Jakarta/newer frameworks go in a JDK-17 CI group.componentId, spanType: Entry, spanLayer: Http, and the url / http.method / http.status_code (and http.params / http.headers where the plugin collects them) tags, plus cross-process refs..github/workflows/plugins-*.yaml matrix (use python3 tools/select-group.py to pick the least-loaded group for its JDK lane).