interlock.sh docs

Testing #

Raw markdown: /testing.md

For humans #

Read this if you write Java units. Skip the first two sections if you only write JS or JSX.

Do this #

  1. Add the compile gate to your repo as scripts/check-units-compile.sh. It compiles every Java unit against the packaged app before anything boots. Takes about two seconds.
  2. Run it in CI and as a pre-commit hook. It is the cheapest check on this page.
  3. Assert what happened, not that the request returned 200.
# not this
curl -s -o /dev/null -w '%{http_code}' "$HOST/app/counter/api"   # 200 proves nothing
# this
curl -s -X POST "$HOST/app/counter/api" -d '{"action":"add","by":1}'
curl -s "$HOST/app/counter/api" | grep -q '"count":1' && echo OK

What will bite you #

Then read #

For robots #

Four patterns, each one here because it caught something real. Every rule below states the failure it prevents, the literal error text that failure produces, a minimal example, and the command that proves the rule holds in your own repo.

Versions: measured 2026-08-08 on Quarkus 3.15.1, JDK 21.0.2, interlock-java-sdk 0.1.0-SNAPSHOT, against a packaged fast-jar. Every javap and javac command below was run, not reasoned about. Re-run them on your own version before trusting the table in Every measurement needs a control arm.

The two-second gate #

The rule. Compile EVERY Java unit against the PACKAGED application, with quarkus/transformed-bytecode.jar first on the classpath, before anything boots. Make it the first gate in your pre-flight, ahead of the test suite and ahead of the server.

Why it exists. A unit is compiled at RUNTIME, by the host JVM, against the classes that JVM actually loaded. Quarkus builds every entity twice: app/<app>.jar holds the ORIGINAL bytecode and quarkus/transformed-bytecode.jar holds the rewritten copy, and the JVM loads the transformed one. A unit compiled against anything else (your repo's build/classes, a stale jar, a quarkusDev session that writes no transformed jar at all) is compiled against a host API that is not the one it will meet. The gate reproduces the server's classpath exactly, so the compile either succeeds for the same reason the server will succeed, or fails now.

The failure it prevents. A whole unit tree broken for an entire session, hidden behind an unrelated outage: nothing had ever compiled those units, so nothing had ever reported them. The two error texts to search for are quoted from a real host application (Team Lakes), so Presence below is that host's own entity, not a class you are expected to own:

error: status has protected access in Presence

at unit compile time (the entity has no hand-written accessor, so Quarkus privatized the field), and

java.lang.IllegalAccessError: tried to access protected field com.teamlakes.api.office.Presence.status

at runtime, on the first HTTP request after a deploy (the unit compiled against the ORIGINAL bytecode, where the field is still public, and the JVM loaded the transformed copy). Without the gate the second one is how you find out, in production, from a visitor.

Boot also warns once when the transformed jar is missing entirely, which is what quarkusDev looks like:

WARN  java units: no transformed-bytecode jar found — if this is quarkusDev, units that touch host entities will not compile against their accessors; run the packaged jar for entity-touching units

Minimal example. The whole gate is one javac invocation. Transformed jar first, then the original and the libraries, with -sourcepath pointing at the unit tree so sibling imports resolve the way the runtime resolves them (directory is package):

javac -nowarn -d /tmp/units -cp "$APP/quarkus/transformed-bytecode.jar:$APP/app/*:$APP/lib/main/*:$APP/lib/boot/*" -sourcepath code/ $(find code/ -name '*.java')

Verify it. Save the script in the next section as scripts/check-units-compile.sh. That path is the canonical one: every page in these docs invokes the gate by exactly that name, so a link, a CI job, and a pre-commit hook all name the same file. Then:

./scripts/check-units-compile.sh

Green output is ==> all units compile. Exit status is 1 on any compile error, so it fails a CI job or a pre-flight script without further plumbing.

The gate script #

Copy this verbatim. It packages first (Gradle and Maven both no-op when nothing changed), finds the quarkus-app directory, puts the transformed jar first, and compiles the whole unit tree. It runs in about two seconds on a warm build: 13 units in 2.0s wall clock on the reference repo.

#!/usr/bin/env bash
# scripts/check-units-compile.sh — compile every Java unit the way the RUNNING SERVER will.
#
# Not the way this repo's own test classpath would: Quarkus builds every entity twice, and the
# packaged application loads the transformed copy. This puts that copy FIRST, exactly as the SDK's
# JavaRunner does, so a unit that only compiles against this repo's classes fails HERE, in about
# two seconds, instead of on the first HTTP request after a deploy.
#
# Packaging first is not optional: checking units against a stale jar reports the last build's host
# API, which is the same lie this script exists to catch. Gradle and Maven no-op when nothing
# changed. Pass --no-build only when you have just packaged by hand.
#
# Usage:  ./scripts/check-units-compile.sh [--no-build]   (the cd below makes the CWD irrelevant)
# Override: APP=<quarkus-app dir> UNITS=<unit tree> BUILD_CMD='<packaging command>'
set -euo pipefail

cd "$(git rev-parse --show-toplevel)"

UNITS="${UNITS:-code}"
APP="${APP:-}"

find_app() {
    [[ -n "$APP" ]] && return 0
    local c
    for c in */build/quarkus-app build/quarkus-app */target/quarkus-app target/quarkus-app; do
        if [[ -f "$c/quarkus/transformed-bytecode.jar" ]]; then
            APP="$c"
            return 0
        fi
    done
    return 0
}

if [[ "${1:-}" != "--no-build" ]]; then
    echo "==> packaging (needed for the transformed bytecode)"
    if [[ -n "${BUILD_CMD:-}" ]]; then
        eval "$BUILD_CMD"
    elif [[ -x ./gradlew ]]; then
        ./gradlew quarkusBuild -q
    elif [[ -x ./mvnw ]]; then
        ./mvnw -q package -DskipTests
    else
        echo "!! no ./gradlew or ./mvnw — set BUILD_CMD='...' or pass --no-build" >&2
        exit 1
    fi
fi

find_app
if [[ -z "$APP" ]]; then
    echo "!! no transformed bytecode found — package a fast-jar first (quarkusDev writes none)" >&2
    exit 1
fi

# Transformed FIRST. The JVM loads that copy, so javac must see it ahead of the original in app/.
CP="$APP/quarkus/transformed-bytecode.jar"
for j in "$APP"/app/*.jar "$APP"/lib/main/*.jar "$APP"/lib/boot/*.jar; do
    [[ -f "$j" ]] && CP="$CP:$j"
done

OUT="$(mktemp -d)"
trap 'rm -rf "$OUT"' EXIT

FILES="$(find "$UNITS" -name '*.java' -not -name '*.conflict-server' | sort)"
if [[ -z "$FILES" ]]; then
    echo "==> no Java units under $UNITS — nothing to check"
    exit 0
fi
echo "==> compiling $(echo "$FILES" | wc -l | tr -d ' ') unit(s) against $APP"

# -sourcepath is the sibling-import rule: directory is package, so javac resolves notes.NoteApi
# out of code/notes/NoteApi.java the same way JavaRunner's file manager serves it.
if javac -nowarn -d "$OUT" -cp "$CP" -sourcepath "$UNITS" $FILES; then
    echo "==> all units compile"
else
    echo "!! units do not compile against the packaged application" >&2
    exit 1
fi

Three details are load-bearing, and each of them was a bug before it was a line of script:

  1. transformed-bytecode.jar comes first. Put the launcher jar or app/ first and javac silently resolves the untransformed classes instead, because javac honours a jar manifest's Class-Path and quarkus-run.jar lists app/.
  2. --no-build is opt-in, not the default. Checking units against a stale jar reports the last build's host API, which is exactly the lie the gate exists to catch.
  3. -not -name '*.conflict-server' keeps a sync conflict file from failing the build with a phantom error. Conflicts are covered in operating.

Both outcomes, verified on a real host application (Team Lakes), whose own paths and classes the output below names:

./scripts/check-units-compile.sh --no-build
==> compiling 13 unit(s) against teamlake-api/build/quarkus-app
==> all units compile

and with one broken unit in the tree:

==> compiling 1 unit(s) against /path/to/quarkus-app
code/probe/Broken.java:6: error: status has protected access in Presence
    public static String statusOf(Presence p) { return p.status; }
                                                        ^
1 error
!! units do not compile against the packaged application

The unit-harness test #

The rule. Run your real units through the real pipeline inside the host's ordinary test suite: DirectoryCodeSource pointed at the git checkout, Engine.unitClass(id, env) to compile a unit, and reflection to exercise it. No server, no key, no sync, no DB.

Why it exists. unitClass compiles through the same path a request takes: sibling resolution, package validation, the host compile anchors, the transformed classpath. So a harness test is two things at once, a behaviour test for the unit's logic and a compile gate for the unit tree, and it runs in a couple of seconds inside a suite people already run.

The failure it prevents. A broken unit that surfaces only as a boot WARN nobody reads in time, or as a 500 for the first visitor. When a real host moved logic into units, the harness caught a shipped bug on its first run: a unit called a package-private method on a sibling and could never have compiled on the host, because until that moment nothing had ever compiled it. The errors the harness turns into red tests are:

no code 'notes/NoteApi' in env 'dev'

(CodeNotFoundException: wrong id, wrong env, or the code root resolved to the wrong directory) and

unit 'counter/panel' is jsx, not java

(EngineException from unitClass, which only compiles Java units).

Minimal example.

class NoteUnitsTest {

    private static Engine engine;
    private static Class<?> noteApi;

    @BeforeAll
    static void compileTheUnits() {
        // Gradle runs tests with the module directory as CWD; the unit tree is the repo's.
        Path code = Files.isDirectory(Path.of("../code")) ? Path.of("../code") : Path.of("code");
        // The same registration the host does at boot: javac needs the host classes on a real
        // -classpath, and an anchor's protection-domain code source is how the runner finds them.
        Engine.addCompileAnchor(MyApp.class);
        // null key: the key is the credential for the DEFAULT code source, and this test replaces
        // that source, so nothing ever reads it. No server, no network, no key in CI.
        engine = InterlockSDK.init(null)
                .codeSource(new DirectoryCodeSource(code))
                .warmUp(false)
                .build();
        noteApi = engine.unitClass("notes/NoteApi", "dev");
    }

    @Test
    void theUnitCompilesWithItsSiblingsAndCarriesItsPackage() {
        assertEquals("notes.NoteApi", noteApi.getName(),
                "directory is package — the id notes/NoteApi implies exactly this binary name");
    }

    @Test
    void everyHandlerInTheDirectoryCompilesThroughTheRealPipeline() {
        for (String id : new String[]{"notes/NoteList", "notes/NoteStore", "notes/NoteRenderer"}) {
            assertEquals("notes." + id.substring("notes/".length()),
                    engine.unitClass(id, "dev").getName());
        }
    }
}

DirectoryCodeSource reads text units straight off code/<id>.<ext> and marks everything authored, so never point it at a directory where generated units land. Binary asset units are not served by it: their bytes live behind the content-addressed blob store, and a test that needs them needs a server.

This test does NOT replace the packaged gate, and cannot #

The harness runs in your ordinary test suite. The gate runs against a packaged fast-jar. Those are two different classpaths, and the difference is exactly the entity rule.

Here is the mechanism, from JavaRunner. The host classes reach javac through addCodeSource(anchor), which resolves the anchor's protection domain to a file and then calls addTransformedBytecode on it. That helper returns immediately unless the file is a .jar:

private static void addTransformedBytecode(Set<String> entries, File entry) {
    if (!entry.getName().endsWith(".jar")) {
        return;
    }

In a Gradle or Maven test JVM the anchor's code source is build/classes/java/main, a directory. Nothing inserts quarkus/transformed-bytecode.jar, because in a test run there is no such jar to insert (quarkusDev and @QuarkusTest transform in memory and write none). So the harness compiles every unit against the original bytecode, where an entity field Quarkus will later privatize is still public and javac has no complaint. The packaged host then loads the transformed copy and the same unit dies on the first request.

That is the green-test-red-production condition in full, and it is the precise thing the entity rule exists to catch.

The classpaths differ a second way, in the opposite direction. classpath() copies the JVM's java.class.path verbatim, and under ./gradlew test that string carries build/classes/java/test and every test-scope dependency. So the harness can also compile a unit too successfully, against a jar the packaged server does not ship. Both differences push the same way: a green harness is not a statement about the server. The full list is what is on a unit's compile classpath.

Which is why the split is:

checkclasspath it compiles againstcatchesmisses
the harness testthe host's test classpath: build/classes/java/main, untransformed, plus everything else the test JVM was launched withwrong id or env, a sibling that does not resolve, a reserved package, a package-private call across units, ordinary type errors, and the unit's actual behaviourevery entity-privatization failure, because the field is still public in the classes it sees; and it can pass a unit that compiles only against a test-scope jar
the two-second gatethe packaged app, transformed-bytecode.jar firstexactly that failure: status has protected access in Presenceanything about behaviour; it compiles and asserts nothing

Neither is a superset of the other. The harness is the only one that runs your unit's logic; the gate is the only one that sees the bytecode the server will load. Run both, gate first, which is what the order to run them does.

One diagnostic to know: if the harness is the thing reporting a protected-access error, your test JVM somehow does have a transformed jar on its classpath, and the boot line tells you which world you are in. no transformed-bytecode jar found in the log means you are on the untransformed classpath and the gate is doing work this test cannot.

Verify it.

./gradlew test --tests '*UnitsTest'
# and prove the two checks read different bytecode. The first path is a DIRECTORY, which is why the
# harness gets no transformed jar; the second is the jar only the packaged gate ever sees.
ls -d */build/classes/java/main
ls -l  */build/quarkus-app/quarkus/transformed-bytecode.jar

Reflection is the boundary, not a workaround #

The rule. Reach unit classes reflectively, and do not try to make them compile-visible to the host. If reflection feels like a smell, that feeling is the boundary reporting itself correctly.

Why it exists. The unit is deliberately not on the host's test classpath. It is authored code that an interlock sync can change without rebuilding the host, compiled at runtime into its own per-importer classloader. A host that could import notes.NoteApi would be a host that has to be rebuilt whenever a unit changes, which is the entire property the unit tree exists to provide.

The failure it prevents. Two, in opposite directions. Trying to import the unit type into a host test fails at compile:

error: package notes does not exist
import notes.NoteApi;

Casting the Class<?> result to a host type of the same name fails at runtime with a ClassCastException whose two class names print identically, because they are the same name in two different loaders. The same trap exists across il.call, and the SDK warns about it once per class rather than letting you meet it at the failure site:

WARN  il.call id:notes/NoteList returned unit-local class notes.NoteApi — unit classes are per-importer; types crossing units should be host classes

Minimal example. The unit under test is code/lib/Counter.java: it holds an int[] of slots, and it declares a static of(int[]) factory, an add(...) method and a raw() reader. Nothing else about it matters. Reflective doors wrap those three methods, named so the test body still reads like the behaviour it asserts:

// the only handle a host test can have on a unit class: no import, no cast
private static Class<?> counter;   // = engine.unitClass("lib/Counter", "dev")

private static Object of(int[] slots) throws Exception {
    return counter.getMethod("of", int[].class).invoke(null, (Object) slots);
}

private static int[] raw(Object c) throws Exception {
    return (int[]) counter.getMethod("raw").invoke(c);
}

// Amount is a record declared in the HOST, so both sides mean the same class by that name.
private static void add(Object c, int slot, int by) throws Exception {
    counter.getMethod("add", Amount.class).invoke(c, new Amount(slot, by));
}

@Test
void addsAccumulateInTheSlotTheyName() throws Exception {
    Object c = of(new int[4]);
    add(c, 0, 2);
    add(c, 0, 3);
    assertEquals(5, raw(c)[0], "two adds to the same slot sum");
    assertEquals(0, raw(c)[3], "and no other slot moves");
}

Types that genuinely need to cross the boundary (arguments and return values, like Amount above) are HOST classes, not unit classes. That is the same rule the il.call warning states, and it is why the doors take int[] and host records rather than unit types.

Verify it. The negative proof matters more than the positive one: add the import to a host test and confirm the build refuses it.

./gradlew compileTestJava

Assert effects, not status codes #

The rule. An end-to-end check asserts that the EFFECT happened, read back through the API. A 200, or a non-empty body, or a schema match, is not an assertion.

Why it exists. A unit can answer correctly-shaped 200s while running entirely the wrong branch. Status is produced by the transport; the effect is produced by the logic under test.

The failure it prevents. The one that justifies the whole rule: req.str(name, def) used to read query parameters only and ignore the JSON body, so EVERY unit API silently ran its default branch on every POST. Well-formed request, 200 response, wrong code path, nothing anywhere reporting it. No error text exists for this failure, which is precisely the point: it was found only because one e2e check asked "did the mute request arrive?" instead of "did the POST return 200?". req.str reads query params AND the JSON body today, query wins, but a check that asserts on status will hide the next bug of this shape just as well as it hid this one.

Related: a unit that can only answer 200 turns every refusal into an apparent success. Throw Refusal (default status 409) rather than returning a map with an ok:false in it, so a client that ignores the body still fails visibly.

Minimal example. Two requests: one that causes the effect, one that reads it back.

curl -fsS -X POST http://localhost:8099/app/counter/api \
  -H 'content-type: application/json' \
  -d '{"action":"add","by":1}'
curl -fsS http://localhost:8099/app/counter/api \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["count"])'

The assertion is that the second command prints exactly one more than it printed before the POST. Assert the negative too: read it again without posting and expect the SAME number, because a read that increments is a bug the first assertion cannot see. And assert refusals by reason, not by absence: an increment the unit is meant to reject must come back 409 with a reason token, not a 200 carrying an ok:false in the body.

Verify it. Run your full-circle script and read the assertions, not the exit code:

./run-full-local-macos-test.sh

If any assertion in it can pass while the handler runs its default branch, it is a status-code assertion wearing an effect's clothes.

Every measurement needs a control arm #

The rule. Any claim about how the platform behaves needs at least two arms: the case you suspect and a control that differs in exactly the property under test. Print the artifact; do not reason about it.

Why it exists. A one-armed measurement can only confirm the hypothesis you brought to it. The entity rule is the worked case: measuring only an entity with public fields and no hand-written accessors shows the fields turning protected and accessors appearing, which supports exactly the wrong conclusion ("Quarkus privatizes entity fields, so units must use the generated accessors"). Generated accessors do not exist on the classes host tests compile against, so that conclusion fails in the other direction, and days went into three wrong diagnoses because of it. The control entity, which had hand-written accessors, is what showed the actual mechanism: Quarkus privatizes a field only when it GENERATES that field's accessor.

The failure it prevents. Shipping the inverted rule, whose two error texts are exactly the ones the two-second gate catches: y has protected access in X at compile, and tried to access protected field X.y at runtime. Also the quieter version: an entity with public fields whose accessor someone later deletes. Quarkus regenerates it, privatizes the field, every unit reading that field breaks on the next deploy, and every host test stays green. Private fields make that same deletion fail the host build immediately, which is the only reason to prefer them.

Minimal example. The measurement, both arms, run against a packaged real host application (Team Lakes). The class names below are that host's own, not names you own. Test arm, an entity with public fields and no hand-written accessors:

unzip -o -q "$APP/quarkus/transformed-bytecode.jar" 'com/teamlakes/api/office/Presence.class' -d /tmp/t && javap -p /tmp/t/com/teamlakes/api/office/Presence.class | grep -E ' status;|getStatus| id;'
  public java.lang.String id;
  protected java.lang.String status;
  public java.lang.String getStatus();

Control arm, an entity with private fields and hand-written accessors, same jar, same command shape:

unzip -o -q "$APP/quarkus/transformed-bytecode.jar" 'com/teamlakes/api/world/OfficeWorld.class' -d /tmp/c && javap -p /tmp/c/com/teamlakes/api/world/OfficeWorld.class | grep -E 'officeId|getOfficeId'
  java.lang.String officeId;
  public java.lang.String getOfficeId();

The control arm is what settles it. Nothing was generated, and getOfficeId() is byte-identical to the one in app/<app>.jar, which is why ONE unit source can compile against the host's ordinary test classpath AND against the packaged application. Note also that @Id stays public in both arms, which is why entity.id works from a unit regardless and why this bug hides for so long.

entity as declaredtransformed bytecodeusable from a unit?
public fields, no hand-written accessorsfields become protected, accessors generatedno
public fields, with hand-written accessorsfields stay public, nothing generatedyes, either style
private fields, with hand-written accessorsaccessors untouched, field access relaxed to package-privateyes, accessors only

Verify it. Re-run both arms on your own Quarkus version and your own entities before relying on the table, and diff the two copies of the same class:

javap -p /tmp/t/com/teamlakes/api/office/Presence.class > /tmp/transformed.txt && unzip -o -q "$APP"/app/*.jar 'com/teamlakes/api/office/Presence.class' -d /tmp/o && javap -p /tmp/o/com/teamlakes/api/office/Presence.class > /tmp/original.txt && diff /tmp/original.txt /tmp/transformed.txt

The order to run them #

Cheapest and most specific first, so the slow gates only ever run on code that already passed the fast ones.

The first two are not interchangeable and neither one is optional. The gate is the only check that compiles against the bytecode the packaged server loads; the harness is the only check that runs what a unit actually does. See this test does not replace the packaged gate.

./scripts/check-units-compile.sh          # packaged classpath, transformed bytecode: the entity rule
./gradlew test --tests '*UnitsTest'       # test classpath, untransformed: ids, siblings, behaviour
./gradlew test
./run-full-local-macos-test.sh