# Writing UI tests for install4j installers

The **install4j Test API** drives a real install4j installer's Swing UI from inside a test JVM. It is framework-agnostic — any test runner (JUnit, TestNG, Spock, plain `main`) works since it's just a session API. Use when the task is to assert post-install state (files written, variables set, config persisted), not when editing project XML.

The API is a separate Maven artifact `com.install4j:install4j-test` (also bundled at `$INSTALL4J_HOME/resource/install4j-test.jar`). The class reference is `reference/api/test/` (overview at `reference/api/test.md`, flat type list at `reference/api/test/types-index.md`). Do not confuse with `reference/api/` (Custom-code API).

## Add the dependency

```kotlin
// Gradle
testImplementation("com.install4j:install4j-test:13.0.2")
```

```xml
<!-- Maven -->
<dependency>
  <groupId>com.install4j</groupId>
  <artifactId>install4j-test</artifactId>
  <version>13.0.2</version>
  <scope>test</scope>
</dependency>
```

If you cannot pull from a Maven repo, point your test classpath at `$INSTALL4J_HOME/resource/install4j-test.jar` and `$INSTALL4J_HOME/resource/i4jruntime.jar`.

## Anatomy of a test

```java
import com.install4j.api.test.session.*;
import com.install4j.api.test.ui.ScreenHandle;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class HelloInstallerTest {
    @Test
    public void installs() throws Exception {
        File media = new File(System.getProperty("test.media"));
        File installDir = Files.createTempDirectory("hello-").toFile();

        try (InstallerSession session = InstallerSessionBuilder
                .forMediaFile(media)
                .installationDirectory(installDir)
                .start()) {

            session.waitForScreenId("3").nextScreen();          // Welcome (id="3")
            ScreenHandle dir = session.waitForScreenId("5");    // InstallationDirectory
            dir.directoryChooser().setDirectory(installDir);
            dir.nextScreen();

            session.navigateUntilScreen("40", /*maxHops*/ 10);  // FinishedScreen
            session.currentScreen().finish();
            assertEquals(0, session.awaitExit());
        }

        // Assert what the installer wrote
        assertTrue(new File(installDir, "expected-file.txt").isFile());
    }
}
```

Kotlin equivalent uses inline DSL extensions from `com.install4j.api.test.session.Sessions`: `useSession { onScreen("3") { nextScreen() }; ... }`.

`session` is `AutoCloseable`: closing it terminates the installer thread, clears the extraction cache, and deletes the install dir *only if the session created it* (user-supplied dirs are kept).

The installation-directory screen pops an "Existing directory" warning alert if the path you set already exists. `Files.createTempDirectory` creates the directory eagerly — pass a **non-existing subpath** (e.g. `new File(tempRoot, "install")`) or be prepared to dismiss the alert with `session.alert().clickOk()` before `clickNext`.

## Sessions

| Session | Builder | Drives |
|---|---|---|
| `InstallerSession` | `InstallerSessionBuilder.forMediaFile(media)` | the installer wizard from a media file |
| `UninstallerSession` | `UninstallerSessionBuilder.forInstallation(dir)` | the uninstaller of an installed copy |
| `InstallerApplicationSession` | `InstallerApplicationSessionBuilder.forInstallation(dir).applicationId(id)` | a custom installer application from an installed copy |

All three implement `Session` (waits, variables, log, exit). Builders are fluent: `timeout(Duration)`, `arg(String...)`, `systemProperty(k, v)`, `workingDirectory(File)`, `logFile(File)`.

## Driving screens

```java
ScreenHandle s = session.waitForScreenId("23");
s.textField("24").setText("db.example.com");
s.integerTextField("26").setValue(5432);
s.dropdownList("29").selectItem("Postgres");
s.checkBox("31").setSelected(true);
s.radioButtons("32").selectLabel("Standalone");
s.passwordField("200").setText("s3cr3t");
s.nextScreen();
```

Screen ids are the values you set in the install4j IDE: `customizedId` if set, else the integer `id` attribute. Form-component accessors come in two forms:

- **`textField("24")`** — by form-component id. Use this; it is stable across project edits.
- **`textField()`** — the unique text field on the screen. Throws `FormComponentNotFoundException` / `FormComponentNotUniqueException`. Avoid on screens that may grow.

Every handle has typed asserts that return the handle for chaining:

```java
s.checkBox("31").assertNotSelected().setSelected(true).assertSelected();
```

The full list of handle types is in `reference/api/test/formcomponents/`.

## Navigation

- `waitForScreenId(id)` — block until that screen is active. Throws `ScreenNotFoundException` on timeout.
- `waitForScreenChange()` — block until *any* screen becomes active.
- `navigateUntilScreen(id, maxHops)` — click Next up to `maxHops` times until landing on `id`. **Use this for transient screens** like `InstallationScreen` that auto-advance once their actions finish — `waitForScreenId("8")` against an InstallationScreen will time out because by the time the test polls, the wizard has already advanced to the FinishedScreen.

Every wait method takes an optional `Duration` overload; the no-arg form uses the builder's default (3 minutes).

## Dialogs

`Session` waits for modals the wizard pops up:

| Method | Returns | Use when |
|---|---|---|
| `session.alert()` | `AlertHandle` (`clickOk`/`Cancel`/`Yes`/`No`) | A `JOptionPane` alert is open |
| `session.fileChooser()` | `FileChooserHandle` | A `JFileChooser` is open (e.g. Browse button) |
| `session.languageDialog()` | `LanguageSelectionDialogHandle` | Startup language picker |
| `session.passwordDialog()` | `PasswordDialogHandle` | Credentials prompt |
| `session.createUserDialog()` | `CreateUserDialogHandle` | Windows user-selector "Create user…" |
| `session.runningProcessesDialog()` | `RunningProcessesDialogHandle` | "Check for running processes" |
| `session.dialog(predicate)` | `DialogHandle` | Custom-code or extension dialogs |

Each method blocks until the matching modal is open, then returns the handle. Call after the action that opens the dialog.

## Asserting post-install state

```java
assertEquals(5432, session.getVariable("dbPort"));        // installer variables
String log = session.readLog();                            // full installer log
assertTrue(log.contains("..."));
File config = new File(installDir, "conf/db.properties"); // filesystem
Properties p = new Properties();
try (var in = new FileInputStream(config)) { p.load(in); }
assertEquals("5432", p.getProperty("port"));
```

Kotlin: `session["dbPort"]` is shorthand for `session.getVariable("dbPort")`.

## Diagnostics

- `session.dumpAsString()` — the active screen's Swing component tree.
- `ScreenHandle.saveScreenshot(File)` — PNG of the wizard window. Call before the action that fails.
- `session.getLogFile()` / `session.readLog()` — the installer log.
- Wrap the test body in a try/catch that prints `dumpAsString()` + `readLog()` on failure if you need debug output in CI.

## How the installer runs

By default the Test API launches the produced installer media (`.sh`/`.exe`/`.dmg`) as a real subprocess and drives it from the test JVM over IPC. This exercises the real bundled JRE, the native launcher, and the actual classloader hierarchy. The same applies to `UninstallerSession` and `InstallerApplicationSession` — the Test API locates the matching launcher inside the installed directory.

On Linux without a usable `DISPLAY`, the Test API wraps the spawned installer subprocess in `xvfb-run` + a reparenting window manager (`metacity`/`mutter`/`openbox`/`fluxbox`/`marco`) itself. Install one of those plus `xvfb-run` on CI and you're done — no wrapper scripts, no `Test.executable`, no failsafe `<jvm>`.

```bash
sudo apt install -y xvfb metacity   # Debian/Ubuntu
```

The test JVM itself does not need Xvfb.

## Required JVM arguments

On JDK 24+, pass `--enable-native-access=ALL-UNNAMED` to silence the native-access warning the installer's native libraries trigger. No `--add-opens` flags are needed.

## Wiring to a test runner

Any JVM test runner works. Recipes below cover the three most common setups; adapt them for other runners as needed.

### Standalone (no build system)

Download the JUnit Platform Console Launcher once. Look up the current `junit-platform-console-standalone` version on Maven Central (the latest tag at <https://central.sonatype.com/artifact/org.junit.platform/junit-platform-console-standalone>) and substitute it below:

```bash
JUNIT=<current-version>   # JUnit Platform version, currently 1.x — NOT the Jupiter 5.x version
curl -fLo junit-cli.jar "https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/${JUNIT}/junit-platform-console-standalone-${JUNIT}.jar"
```

Compile and run:

```bash
I4J=$INSTALL4J_HOME/resource
javac -d build/test-classes \
    -cp $I4J/install4j-test.jar:$I4J/i4jruntime.jar:junit-cli.jar \
    tests/MyInstallerTest.java

java \
    -Dtest.media=build/media/myapp-linux-x64.sh \
    -jar junit-cli.jar \
    --class-path build/test-classes:$I4J/install4j-test.jar:$I4J/i4jruntime.jar \
    --select-class MyInstallerTest \
    --reports-dir build/test-results
```

JUnit-XML report lands at `build/test-results/TEST-junit-jupiter.xml`.

### Gradle

Use the `com.install4j.gradle` plugin to build the media (full reference in `doc/cli/gradle.md`).

The plugin needs to know which install4j installation to call. Two options:

- **Auto-provision** (default): leave `installDir` unset and the plugin downloads an install4j distribution matching its own version into `<Gradle user home>/install4j/dist`. Requires network access. The right choice when CI machines don't have install4j pre-installed.
- **Point at a local install**: set `installDir` in the global `install4j { … }` block to skip the download. The right choice when install4j is already present.

For the local-install variant:

```kotlin
plugins {
    id("com.install4j.gradle") version "13.0.2"
}

install4j {
    installDir = file("@INSTALL4J_HOME@")
}

dependencies {
    testImplementation("com.install4j:install4j-test:13.0.2")
    // Use the current org.junit:junit-bom from Maven Central (look up the latest at
    // https://central.sonatype.com/artifact/org.junit/junit-bom).
    testImplementation(platform("org.junit:junit-bom:<current-version>"))
    testImplementation("org.junit.jupiter:junit-jupiter")
}

val buildInstaller by tasks.registering(com.install4j.gradle.Install4jTask::class) {
    projectFile = file("src/main/installer/myProject.install4j")
    mediaTypes = listOf("unixInstaller")
    destination = layout.buildDirectory.dir("media").get().asFile
}

tasks.named<Test>("test") {
    dependsOn(buildInstaller)
    useJUnitPlatform()
    systemProperty("test.media",
        layout.buildDirectory.file("media/myapp-linux-x64.sh").get().asFile.absolutePath)
}
```

`@INSTALL4J_HOME@` is substituted with the real install dir when the skill is installed into your agent's skills directory. For shared / CI builds, switch to `installDir = file(System.getenv("INSTALL4J_HOME"))` and export `INSTALL4J_HOME` in the build environment instead.

### Maven

Use **failsafe** (integration-test phase), not surefire (test phase) — installer media must be built first. Match `*IT.java`, not `*Test.java`.

The `install4j-maven` plugin needs to know which install4j installation to call. Two options:

- **Auto-provision** (default): leave `install4j.home` unset and the plugin downloads an install4j distribution matching its own version from ej-technologies. Requires network access. The right choice when CI machines don't have install4j pre-installed.
- **Point at a local install**: set `install4j.home` to skip the download. The right choice when install4j is already present.

For the local-install variant, set the path as a Maven property:

```xml
<properties>
  <install4j.home>@INSTALL4J_HOME@</install4j.home>
</properties>
```

`@INSTALL4J_HOME@` is substituted with the real install dir when the skill is installed into your agent's skills directory. For shared / CI builds, switch to `${env.INSTALL4J_HOME}` and export `INSTALL4J_HOME` in the build environment instead.

Either way, don't fall back to `exec-maven-plugin` invoking `install4jc` directly.

```xml
<properties>
  <!-- Look up the current version at https://central.sonatype.com/artifact/org.junit.jupiter/junit-jupiter -->
  <junit.jupiter.version>5.x.y</junit.jupiter.version>
</properties>

<dependencies>
  <dependency>
    <groupId>com.install4j</groupId>
    <artifactId>install4j-test</artifactId>
    <version>13.0.2</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>${junit.jupiter.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>com.install4j</groupId>
      <artifactId>install4j-maven</artifactId>
      <version>13.0.2</version>
      <executions>
        <execution>
          <phase>pre-integration-test</phase>
          <goals><goal>compile</goal></goals>
          <configuration>
            <projectFile>${project.basedir}/src/main/installer/myProject.install4j</projectFile>
            <mediaTypes>unixInstaller</mediaTypes>
            <destination>${project.build.directory}/media</destination>
          </configuration>
        </execution>
      </executions>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-failsafe-plugin</artifactId>
      <!-- Look up the current version at https://central.sonatype.com/artifact/org.apache.maven.plugins/maven-failsafe-plugin -->
      <version>3.x.y</version>
      <executions>
        <execution><goals><goal>integration-test</goal><goal>verify</goal></goals></execution>
      </executions>
      <configuration>
        <systemPropertyVariables>
          <test.media>${project.build.directory}/media/myProject-linux-x64.sh</test.media>
        </systemPropertyVariables>
        <includes><include>**/*IT.java</include></includes>
      </configuration>
    </plugin>
  </plugins>
</build>
```

Run with `mvn verify`. `mvn -DskipITs` builds media but skips the test.

## Selecting the right media for the platform

The Test API expects exactly one of `.sh`, `.exe`, or `.dmg` matching the current platform. In a real build, pick the path off `OperatingSystem.current()` (Gradle) or `${os.detected.classifier}` (Maven os-maven-plugin), or filter `<mediaTypes>` to one platform per CI job.

## When not to use the Test API

- Schema or bean-property validation → `install4jc --test` is enough.
- Custom-code compilation check → `javac` against `install4j-runtime`.
- Unattended-mode regression → run the installer with `-q`, no Test API.
