Automated UI and API tests for OpenL Studio and OpenL Rule Services, built with Playwright, TestNG and TestContainers. Every test runs against its own fresh application container; the tests run locally or in Docker-based execution mode with unified driver management, and the regression runs on GitHub Actions with a merged HTML report.
Test reports: openl-tablets.github.io/openl-tests, the merged HTML report of every GitHub Actions run.
Components → DriverPool (Unified Interface)
↓
[Automatic Mode Detection]
↓
LOCAL Mode → Direct Playwright → Container App
DOCKER Mode → Container Playwright → Container App
↓
[File Operations Support]
↓
Upload: Volume Mapping + TestDataUtil
Download: DownloadUtil (mode-aware)
- Dual Execution Modes:
PLAYWRIGHT_LOCAL(default) andPLAYWRIGHT_DOCKERwith automatic detection - Unified Driver Pool: Single interface for both execution modes
- TestContainers Integration: Application containers with network isolation
- Multi-Container Infrastructure:
DeployInfrastructureServicefor tests requiring DB + WebService containers - Parallel Test Execution: TestNG parallel execution with configurable thread counts
- Merged HTML report: per-test step logs, Playwright traces, application logs and screenshots for every GitHub Actions run
- Comprehensive Configuration: Property-based configuration with environment override support
- Cross-Browser Support: Chromium, Firefox, and WebKit browsers
- File Upload/Download Support: Mode-aware file operations with volume mapping
- Java 21 or higher
- Maven 3.8+
- Docker (for Docker execution mode and application containers)
-
Clone the repository
git clone <repository-url> cd openl-tests
-
Install Playwright browsers (for local execution)
mvn clean test -Dtest=TestAdminEmail -Dexecution.mode=PLAYWRIGHT_LOCAL -
Verify Docker setup (for Docker execution mode)
docker pull ghcr.io/openl-tablets/webstudio:latest
The framework supports two execution modes controlled by the execution.mode system property:
PLAYWRIGHT_LOCAL(default): Playwright runs on host machine, faster startupPLAYWRIGHT_DOCKER: Playwright runs in Docker containers, better isolation
Located in src/test/resources/testng_suites/. Only the three *_regression suites remain: studio_zip_projects_regression, studio_central_projects_regression, studio_preconfig_projects_regression. They validate client project sets, carry @LocalOnly classes that GitHub Actions never queues, and run only locally. default.xml is an empty placeholder suite: it is what -Dsuite resolves to when the property is omitted, so a bare mvn clean test compiles the project and runs no tests instead of failing on a missing suite file. Every other test is run by class name with -Dtest=; GitHub Actions discovers test classes by scanning src/test/java, so no suite file lists them.
# Run the zipped client projects suite in LOCAL mode (default)
mvn clean test -Dsuite=studio_zip_projects_regression
# Same suite with an explicit mode
mvn clean test -Dsuite=studio_zip_projects_regression -Dexecution.mode=PLAYWRIGHT_LOCAL
mvn clean test -Dsuite=studio_zip_projects_regression -Dexecution.mode=PLAYWRIGHT_DOCKER
# Run the other client project suites
mvn clean test -Dsuite=studio_central_projects_regression -Dexecution.mode=PLAYWRIGHT_LOCAL
mvn clean test -Dsuite=studio_preconfig_projects_regression -Dexecution.mode=PLAYWRIGHT_DOCKER# Local mode
mvn clean test -Dtest=TestAdminEmail -Dexecution.mode=PLAYWRIGHT_LOCAL
# Docker mode
mvn clean test -Dtest=TestAdminEmail -Dexecution.mode=PLAYWRIGHT_DOCKERmvn clean test -Dtest=TestAdminEmail#testPlaywrightAdminEmail -Dexecution.mode=PLAYWRIGHT_LOCALmvn clean test -Dtest=TestAdminEmail,TestAdminUserSettings -Dexecution.mode=PLAYWRIGHT_LOCALTests support parallel execution with thread-safe driver management:
- LOCAL Mode: Multiple Playwright instances on host machine
- DOCKER Mode: Multiple Docker containers with isolated Playwright instances
Look for log entries indicating parallel execution:
[TestNG-test-1] [INFO] Initializing test with Playwright: testPlaywrightAdminEmail
[TestNG-test-2] [INFO] Initializing test with Playwright: testPlaywrightUserSettings
All multi-container tests use Docker DNS via a shared Network object. No host.docker.internal dependency. This works on Linux CI without extra configuration.
The entire mechanism is built on one Network Java object passed through NetworkPool (a ThreadLocal storage):
Test.beforeMethod()
│
├── 1. DeployInfrastructureService.start()
│ ├── network = Network.newNetwork() // create Docker network
│ ├── NetworkPool.setNetwork(network) // store in ThreadLocal
│ ├── postgresContainer.withNetwork(network) // DB joins the network
│ └── wsContainer.withNetwork(network) // WS joins the network
│
└── 2. super.beforeMethod() → BaseTest
├── network = NetworkPool.getNetwork() // read SAME object from ThreadLocal
└── setupAppContainer(result, network)
└── AppContainerFactory.createContainer(name, network, ...)
└── container.withNetwork(network) // App joins the SAME network
All containers call .withNetwork(network) with the same Java object, so Docker places them in one Docker network. .withNetworkAliases("postgres") / .withNetworkAliases("wscontainer") sets DNS names by which containers discover each other.
NetworkPool is just a transfer point between Test.beforeMethod() (where network is created) and BaseTest.beforeMethod() (where it is read for the app container). Without it, BaseTest would not know which network to use.
BaseTest exposes two overridable methods a test can implement to feed extra configuration into
the app container (an explicit, compiler-checked contract — no reflection involved):
| Method | Returns | Purpose |
|---|---|---|
additionalContainerFiles() |
Map<String, String> |
Files to copy into container (host path -> container path) |
additionalContainerConfig() |
Map<String, String> |
Extra env vars to pass to the container |
The test populates its backing maps in beforeMethod() before calling super.beforeMethod(),
and overrides the methods to expose them:
// In test class:
private static final Map<String, String> additionalContainerConfig = new HashMap<>();
private static final Map<String, String> additionalContainerFiles = new HashMap<>();
@Override
protected Map<String, String> additionalContainerConfig() {
return additionalContainerConfig;
}
@Override
protected Map<String, String> additionalContainerFiles() {
return additionalContainerFiles;
}
@Override
@BeforeMethod
public void beforeMethod(ITestResult result) {
additionalContainerConfig.clear();
additionalContainerFiles.clear();
deployInfra = DeployInfrastructureService.builder()
.withPostgresAsSecurityDb()
.build();
deployInfra.start();
additionalContainerConfig.putAll(deployInfra.getContainerConfig());
additionalContainerFiles.putAll(deployInfra.getFilesToCopy());
super.beforeMethod(result); // BaseTest calls both methods while setting up the container
}helpers.service.DeployInfrastructureService encapsulates all Docker infrastructure setup for multi-container tests. Builder pattern, supports PostgreSQL, Oracle, and WebService containers.
Multi-container tests (deploy to production, JDBC repositories, security DB) require 20-90 lines of boilerplate: network creation, DB container start, schema creation, JDBC JAR paths, .properties file generation, WS container with healthcheck, env vars. This service eliminates duplication.
// 1. PostgreSQL as production repo + WebService container (TestNewDeployPopup):
deployInfra = DeployInfrastructureService.builder()
.withPostgres().withWsContainer().build();
// 2. Oracle as deployment repo (TestDeploymentConfigurationRepositoryConnection):
deployInfra = DeployInfrastructureService.builder()
.withOracle().build();
// 3. PostgreSQL as security DB (TestMultipleDesignRepositoriesWithPostgres):
deployInfra = DeployInfrastructureService.builder()
.withPostgresAsSecurityDb().build();| PRODUCTION_REPO | SECURITY_DB | |
|---|---|---|
| DB name | openl |
container default (test) |
| Credentials | openl/openl |
container defaults |
| Schema | CREATE SCHEMA repository |
none |
.properties file |
generated and copied | none |
getFilesToCopy() |
pgJar + .properties |
pgJar only |
getContainerConfig() |
empty map | db.url, db.user, db.password |
The production repository configuration uses OpenL's $$ref syntax to inherit settings from a predefined template:
production-repository-configs = production
repository.production.name = Deployment
repository.production.$$ref = repo-jdbc
repository.production.uri = jdbc:postgresql://postgres:5432/openl?currentSchema=repository
repository.production.login = openl
repository.production.password = openlThe $$ref key contains $$ characters that cannot be passed as Docker env vars (Docker -e does not support $$ in key names) and cannot be passed as JVM system properties via JAVA_OPTS. The only way to deliver this configuration to the container is a file mounted to /opt/openl/shared/.properties.
In contrast, the security DB mode uses simple flat keys (db.url, db.user, db.password) that work fine as env vars through additionalContainerConfig.
| Method | Returns | Description |
|---|---|---|
start() |
void | Creates network, starts all configured containers |
cleanup() |
void | Stops all running containers |
getFilesToCopy() |
Map<String, String> |
Files for additionalContainerFiles (JDBC JAR, .properties) |
getContainerConfig() |
Map<String, String> |
Env vars for additionalContainerConfig (security DB mode) |
getWsContainer() |
GenericContainer<?> |
Access WS container (for API calls) |
getPostgresContainer() |
PostgreSQLContainer<?> |
Access PG container (for DB verification) |
getOracleContainer() |
OracleContainer |
Access Oracle container (for DB verification) |
getOracleJdbcUrl() |
String |
In-network Oracle JDBC URL |
domain.api.GetWsServicesMethod calls the WebService container's /admin/services endpoint to verify deployed rules.
The Docker WS image deploys the application to webapps/ROOT (root context /), not /webservice as in the legacy WAR-based approach.
- Service list:
GET /admin/services(returns JSON array) - REST method call:
GET /{deploymentName}/{projectName}/{method}(no/REST/prefix when only RESTFUL publisher is active, which is the default) - Service names in the API follow the format
{deploymentName}_{projectName}
Three sibling regressions live in tests/api/webstudio/client and validate real customer/product
OpenL content against a fresh WebStudio container per case. All three are API-driven (no UI),
share the same compile-validation flow (open → POST /rest/projects/{id}/tests/run as the compile
trigger → GET /rest/projects/{id}?include=status, JSESSIONID kept across calls) and appear in the
report with one test entry per project/group via @Factory + ITest.
| Zip regression | Central-studio regression | Preconfig regression | |
|---|---|---|---|
| Test / suite | TestZippedProjects / studio_zip_projects_regression.xml |
TestStudioCentralGroup* / studio_central_projects_regression.xml |
TestPreconfigProjects / studio_preconfig_projects_regression.xml |
| Content | Static ZIP snapshots of customer projects (client_projects/customers_projects_test_automation_6.x) |
Live Genesis client projects (openl-rating/claim/policy/policy-life/financials) | Live EIS product preconfigurations (benefits/commercial/personal policy, claims) |
| Source | Local folder tree; *deployment-suffixed folders group interdependent zips into one container |
EIS GitLab git repos mounted by Studio itself as design repositories (STUDIO_CENTRAL_GROUP_*_PARAMS, creds in .env: GITLAB_USER, GITLAB_PASSWORD, GITLAB_BRANCH) |
Local Mercurial clones under Projects/eis/preconfigs (vno-hg.exigengroup.com; creds in ~/.hgrc), synced by hg pull -u before discovery |
| Unit of test | ZIP group (deployment folder) or single zip | Studio instance per repo group, projects interdependent (bulk-open first) | Single OpenL project (<module>/src/main/openl), all independent |
| Validation | Upload + compile + run Test tables | Compile + run Test tables (Studio clones repos itself — the test polls until the lazy clone finishes) | Upload + compile + deploy (POST /rest/deployments) + service served by ruleservice (GET ws:/admin/services) |
| Extra infra | — | — | DeployInfrastructureService per project: PostgreSQL production repo + ruleservice (WS) container |
The central-studio regression validates only the projects that are on at least one of the branches master, main, development (studio.central.branches, a comma-separated list, case-insensitive, read like any other property: -D, .env, config.properties; an empty value means the default). Studio indexes every branch of a design repository and lists the projects that live only on old branches too, and the old branches of the Genesis repositories hold projects that no longer compile. setUp waits until projectIndexHealth of GET /rest/projects reports no repository as indexing, asks GET /rest/projects/{id}/branches for each listed project, leaves out and logs the ones on none of those branches, keeps a project whose branches cannot be read, and fails when no project is left. The branch configured for the repositories (GITLAB_BRANCH) has to be in the list, otherwise its projects are left out.
studio_zip_projects_regression.xml, studio_central_projects_regression.xml and studio_preconfig_projects_regression.xml register ReportPortalTestNGListener; log lines of the tests reach the launch through the ReportPortalAppender of log4j2-test.xml. No other suite and no GitHub Actions shard reports to ReportPortal. src/test/resources/reportportal.properties holds the server, the project and the zip launch with rp.enable=false, so nothing is reported unless a run passes -Drp.enable=true (the server is reachable only through the EIS VPN); the API key is never committed: put it into rp.api.key of the local copy and hide that change from git with git update-index --skip-worktree src/test/resources/reportportal.properties, or export RP_API_KEY. -D overrides the file, and RP_* environment variables override it too, so the central and preconfig runs name their own launches:
mvn clean test -Dsuite=studio_zip_projects_regression -Dzip.projects.root=<client_projects>/customers_projects_test_automation_6.x -Ddocker_image_name=<image> -Drp.enable=true "-Drp.attributes=tests_branch:main;build:<image tag>;run:local"
mvn clean test -Dsuite=studio_central_projects_regression -Ddocker_image_name=<image> -Drp.enable=true -Drp.launch=central_projects_regression "-Drp.attributes=tests_branch:main;build:<image tag>;run:local"
mvn clean test -Dsuite=studio_preconfig_projects_regression -Dpreconfig.repos.root=<preconfigs> -Ddocker_image_name=<image> -Dws_docker_image_name=<ws image> -Drp.enable=true -Drp.launch=preconfig_projects_regression "-Drp.attributes=tests_branch:main;build:<image tag>;run:local"rp.attributes replaces the attributes of the file as a whole. RP_DESCRIPTION replaces the launch description; without it a central or preconfig run carries the zip description.
The suite is a self-contained harness: on every run it clones the 4 needed repos itself if they
are missing (first run downloads ~1.5 GB), otherwise pulls the latest changes (hg pull -u),
then builds what needs building and runs the validation. Only 4 of the ~190 repos on vno-hg are
used — the preconfig products that contain OpenL rules; *-central/*-contrib are byte-identical
mirrors, eis-preconfig-commercial-claim has no OpenL content, openl-mapper/pub/tests are
unrelated, and vnoeisgrok02.exigengroup.com/source is just OpenGrok (a code browser over the
same sources).
What a fresh machine needs before the first run:
# 1. Mercurial + auth for vno-hg (~/.hgrc)
brew install mercurial
cat >> ~/.hgrc <<'EOF'
[auth]
vnohg.prefix = vno-hg.exigengroup.com
vnohg.username = <your-login>
vnohg.password = <your-password>
vnohg.schemes = http https
EOF
# 2. Corporate Nexus creds in ~/.m2/settings.xml (GENESIS / GENESIS_STAGING servers,
# profile genesis-v20 active) — used by the Maven stage for jar-dependent projects.Manual clone (optional — the suite clones missing repos automatically on the first run; use this only if you want to pre-fetch them yourself or debug hg access):
mkdir -p ~/Projects/eis/preconfigs && cd ~/Projects/eis/preconfigs
for r in eis-preconfig-benefits-policy \
eis-preconfig-commercial-policy \
eis-preconfig-personal-claims \
eis-preconfig-personalpolicy; do
hg clone "http://vno-hg.exigengroup.com/hg/$r"
doneKnobs: -Dpreconfig.repos.root (clone location, default ~/Projects/eis/preconfigs),
-Dpreconfig.hg.base.url, -Dpreconfig.hg.sync=false (offline — use local copies as-is).
# Full regression
mvn -B test -Dsuite=studio_preconfig_projects_regression
# One project only (substring match on repo/module), no hg sync — handy for debugging
mvn test -Dtest=TestPreconfigProjects -Dpreconfig.module.filter=di-std-openl-rules -Dpreconfig.hg.sync=falseDiscovery finds 32 src/main/openl projects across the 4 repos:
- 9 dependency-free (3 in benefits-policy, 6 in personalpolicy) — zipped straight from the hg sources, no build needed.
- 23 jar-dependent (
<classpath>in rules.xml or<dependency>entries in the module pom) — their Java domain types (e.g.PackageInfo,ExtDimension) live in JARs, so the suite runs a Maven stage per module:mvn install -pl <module> -am(the openl-maven-plugin produces the deployable ZIP), thendependency:copy-dependencies -DincludeScope=provided -DexcludeTransitive=trueand repacks those direct provided JARs into the ZIP'slib/— the exact shape of the historical preconfig snapshots. (The transitive provided tree is ~400 JARs of the whole EIS platform — neither needed nor wanted; the direct ones are enough for OpenL to compile.)
Requirements for the Maven stage: corporate Nexus access in ~/.m2/settings.xml
(GENESIS/GENESIS_STAGING servers, profile genesis-v20 active) with a valid password — the same
account as for vno-hg. Disable the jar-dependent half with
-Dpreconfig.include.jar.dependent=false (e.g. no Nexus reachable); those projects are then
skipped with a log line each — that opt-out is the only case where a project leaves the run
silently. A Maven stage that fails never hides the project: it still reaches the @Factory and
becomes a failed test carrying the Maven output, so a broken build cannot masquerade as a green
launch.
EIS preconfigs are Maven multi-module Mercurial repositories; the OpenL project lives in
<module>/src/main/openl (rules.xml, rules/*.xlsx, rules-deploy.xml with a RESTFUL
publisher). Studio cannot mount hg as a design repository, so PreconfigSourcesService:
- runs
hg pull -uon every clone under-Dpreconfig.repos.root(default~/openl-preconfigs; a failed pull falls back to the local copy), - discovers every
src/main/openl/rules.xml, extracts the project name and therules-deploy.xmlservice name, - zips each project flat (rules.xml at zip root — the shape
PUT /rest/repos/{repo}/projects/{name}accepts).
Per discovered project, AbstractPreconfigProjectsApi starts a trio of containers on one
Docker network: WebStudio (DEPLOY_STUDIO_PARAMS + production-repo .properties copied into the
container), PostgreSQL as the production repository, and the ruleservice image watching that
repository. The test then uploads, compiles (fails on any ERROR message), deploys via
POST /rest/deployments, and polls GET /admin/services on the ruleservice until the service
declared in rules-deploy.xml is served.
Notes:
- Jar-dependent projects (23 of 32) get an automatic Maven stage (see "Scope" above): build via openl-maven-plugin + repack with direct provided JARs. Dependency-free ones (9 of 32) are zipped straight from sources.
- Repos with no OpenL content (eis-preconfig-commercial-claim) are excluded from
HG_REPOS. - The whole suite needs JDK 25: the EIS modules compile with
release 25, so on JDK 21 the Maven stage fails for most jar-dependent projects (invalid target release: 25) and they all turn red. The Studio and ruleservice images already run Temurin 25. - Discovery logs how the projects split —
Discovered N ...: X ready to validate, Y with a failed Maven build (reported as failed tests), Z skipped by -Dpreconfig.include.jar.dependent=false. Compare the test count against that line to confirm the run covered everything. - Run:
mvn -B test -Dsuite=studio_preconfig_projects_regression.
# Browser configuration
browser=chrome # chrome, firefox, webkit
browser_version=latest
playwright_default_timeout=10000 # Default timeout in ms
test_retry_count=1 # Test retry attempts# Application container
default_app_port=8080
docker_image_name=ghcr.io/openl-tablets/webstudio:latest
deployed_app_path= # App context path (empty for root)
# Volume mappings
host_resource_path=src/test/resources
container_resource_path=/test_resources
host_screenshot_path=target/screenshots
host_app_logs_path=target/logs# Playwright media capture
enable_video_recording=true # Enable video for failed tests (Docker mode)
enable_playwright_tracing=true # Record a Playwright trace (DOM snapshots, network, console) for every test
debug_artifacts_on_success=false # Keep the trace and the application log for passed tests too (failed tests always keep them)
enable_screenshot_on_failure=true # Screenshot on test failure
playwright_downloads_path=target/downloads
playwright_videos_path=target/videosuser_pool=admin_user::openl_1_user::openl_2_user::openl_ac_user
admin_user.login=admin
admin_user.password=admin
openl_1_user.login=openl_1
openl_1_user.password=h1plaKvaskaSystem properties override configuration file values:
mvn test -Dexecution.mode=PLAYWRIGHT_DOCKER -Dbrowser=firefox -Dplaywright_default_timeout=10000├── src/
│ ├── main/java/
│ │ ├── configuration/
│ │ │ ├── annotations/ # Custom annotations (@AppContainerConfig)
│ │ │ ├── appcontainer/ # TestContainers app management
│ │ │ ├── core/ui/ # Core UI components (WebElement, CoreComponent)
│ │ │ ├── driver/ # DriverPool facade + LocalDriverPool/DockerDriverPool implementations
│ │ │ ├── listeners/ # TestNG listeners and retry analyzers
│ │ │ ├── network/ # Docker network management (NetworkPool)
│ │ │ └── projectconfig/ # Configuration management
│ │ ├── domain/
│ │ │ ├── api/ # API test methods (GetWsServicesMethod, etc.)
│ │ │ ├── serviceclasses/ # Service classes and constants
│ │ │ └── ui/webstudio/ # Page objects and components
│ │ │ ├── components/ # UI components by functionality
│ │ │ └── pages/ # Page objects
│ │ └── helpers/
│ │ ├── service/ # Business logic services (DeployInfrastructureService, etc.)
│ │ └── utils/ # Utility classes (PrintUtil, WaitUtil, etc.)
│ └── test/
│ ├── java/tests/
│ │ ├── BaseTest.java # Base test class with setup/teardown
│ │ └── ui/webstudio/ # Test classes organized by functionality
│ └── resources/
│ ├── config.properties # Main configuration
│ ├── testng_suites/ # TestNG suite definitions
│ └── test_data/ # Test data files
@Test
@TestCaseId("TEST-001")
@Description("Test admin email configuration")
@AppContainerConfig(startParams = AppContainerStartParameters.DEFAULT_STUDIO_PARAMS)
public void testAdminEmail() {
EditorPage editorPage = new LoginService(DriverPool.getPage())
.login(UserService.getUser(User.ADMIN));
AdminPage adminPage = editorPage.navigateToAdmin();
EmailPageComponent emailComponent = adminPage.getEmailPageComponent();
emailComponent.setEmailUrl("smtp.example.com");
emailComponent.applySettings();
assertThat(emailComponent.isSettingsSaved()).isTrue();
}// Use exact field names — BaseTest reads them via reflection
private static final Map<String, String> additionalContainerConfig = new HashMap<>();
private static final Map<String, String> additionalContainerFiles = new HashMap<>();
// Use annotation for container setup
@AppContainerConfig(
startParams = AppContainerStartParameters.DEFAULT_STUDIO_PARAMS,
copyFileFromPath = "test-data/project.zip",
copyFileToContainerPath = "/opt/project.zip"
)
// Or use different configurations
@AppContainerConfig(startParams = AppContainerStartParameters.SAML_STUDIO_PARAMS)
@AppContainerConfig(startParams = AppContainerStartParameters.OAUTH_STUDIO_PARAMS)public class TestNewDeployPopup extends BaseTest {
private static final Map<String, String> additionalContainerFiles = new HashMap<>();
private DeployInfrastructureService deployInfra;
@Override
@BeforeMethod
public void beforeMethod(ITestResult result) {
additionalContainerFiles.clear();
deployInfra = DeployInfrastructureService.builder()
.withPostgres().withWsContainer().build();
deployInfra.start();
additionalContainerFiles.putAll(deployInfra.getFilesToCopy());
super.beforeMethod(result);
}
@Override
@AfterMethod
public void afterMethod(ITestResult result) {
super.afterMethod(result);
if (deployInfra != null) {
deployInfra.cleanup();
}
}
@Test
@AppContainerConfig(startParams = AppContainerStartParameters.DEPLOY_STUDIO_PARAMS)
public void testNewDeployPopup() {
// All containers (postgres, wscontainer, appcontainer) are in the same network
// and can reach each other via Docker DNS aliases
}
}// File upload (mode-aware)
String testDataPath = TestDataUtil.getTestDataPath("project-template.zip");
zipComponent.selectFile(testDataPath);
// File download (mode-aware)
File downloadedFile = DownloadUtil.downloadFile(downloadButton);
assertThat(downloadedFile).exists();The framework automatically generates unique test names for DataProvider iterations in the reports. This ensures each data set appears as a separate test instead of having identical names.
Before: All DataProvider iterations appeared with the same name in the report
The report shows:
- testLocalZippedProjects
- testLocalZippedProjects
- testLocalZippedProjects
After: Each iteration has a unique, descriptive name
The report shows:
- testLocalZippedProjects[project1, project2]
- testLocalZippedProjects[claims-home, auto-policy]
- testLocalZippedProjects[commercial-underwriting]
- BaseTest implements
ITestinterface for custom test names - Names are generated before test execution in
@BeforeMethod - Parameters are sanitized (filenames extracted, extensions removed, long values truncated)
- Thread-safe for parallel execution using
ThreadLocal
Simply extend BaseTest - no additional code needed:
public class TestWithDataProvider extends BaseTest {
@Test(dataProvider = "ProjectData")
public void testMultipleProjects(String path1, String path2, String path3) {
// The report will show: testMultipleProjects[project1, project2, project3]
}
@DataProvider(name = "ProjectData")
public Object[][] getData() {
return new Object[][] {
{"/path/to/project1.zip", "/path/to/project2.zip", null},
{"project-A.zip", "project-B.zip", "project-C.zip"}
};
}
}| Original Parameter | Report Display |
|---|---|
/Users/user/Projects/file.zip |
file |
very-long-parameter-name-exceeding-50-chars... |
very-long-parameter-name-exceeding-50-cha... |
null |
null |
- Works with all tests extending BaseTest
- Tests without DataProvider work unchanged (standard method names)
- Thread-safe for parallel execution
The DriverPool facade provides unified access to Playwright functionality with automatic mode detection
(LocalDriverPool and DockerDriverPool hold the per-mode implementations):
// These methods work in both LOCAL and DOCKER modes
Page page = DriverPool.getPage();
BrowserContext context = DriverPool.getBrowserContext();
// Navigation (mode-aware URL handling)
DriverPool.navigateToApp(); // Uses correct URL for each mode
// Utilities
byte[] screenshot = DriverPool.takeScreenshot();
Page newPage = DriverPool.createNewPage();| Feature | LOCAL Mode | DOCKER Mode |
|---|---|---|
| Playwright Location | Host machine | Docker container |
| Application URL | localhost:mappedPort |
container-network-url |
| File Access | Direct host filesystem | Volume-mapped paths |
| Performance | Faster startup | Better isolation |
| Debugging | Easier browser inspection | Containerized debugging |
| Video Recording | Not available | Available for failed tests |
Every failed test leaves its debug artifacts in the test export directory (target/test-export, see below) and in the merged report:
- Screenshots: Automatically attached on test failures
- Videos: Recorded for failed tests (Docker mode only)
- Page Content: HTML of the page at failure, attached only when no Playwright trace was recorded for the test
- Application Logs: Container logs attached to test results
- Execution Info: Debug information about driver state
Each test starts its own Studio or Rule Services container and stops it afterwards. Reusing an application container between tests, classes or shards is forbidden in the framework and in every CI pipeline; regression speed is gained only through parallelism and shard balancing. The rule is also recorded in CLAUDE.md.
The GitHub Actions workflow OpenL Tests accepts an optional list of test class names in the tests input, so a fix can be verified in minutes instead of a full regression. Names are separated by commas or spaces and may be simple (TestMethodTable) or fully qualified (tests.ui.webstudio.git.TestGitBranchSwitching). A whole class runs, single @Test methods cannot be selected. The selected classes form the queue described below; a name without a matching class under src/test/java fails the "Discover tests" job. The merged report carries the attribute run:selective. An empty value runs the full regression. Classes annotated @DedicatedWorkflow cannot be selected here: the "Discover tests" job fails and names the workflow they run in.
In PLAYWRIGHT_DOCKER mode every test starts a mcr.microsoft.com/playwright container whose entry command downloads the playwright npm package before the server listens; a cold download takes about 90 seconds and on a slow runner can exceed the default 120-second startup timeout. Two properties tune this: playwright_server_startup_timeout_seconds (default 120) and playwright_npm_cache_dir, a host directory mounted as the container's npm cache so only the warm-up, or the first cold start, downloads the package (a cold start with a shared cache is serialized across threads). GitHub Actions sets both and warms the cache once per shard before Maven starts; locally the same warm-up is docker run --rm -v <cache>:/root/.npm mcr.microsoft.com/playwright:v1.52.0-noble sh -c "npx -y playwright@1.52.0 --version". When the container still fails to start, its own output is written to the test log.
The workflow runs on demand and on every pull request to main: when it is opened, reopened, marked ready for review and on every push to it. Draft pull requests, pull requests from forks and Dependabot pull requests are not run, because their token cannot write the queue refs. A new push cancels the run of the previous push of the same pull request; a run started by hand is never cancelled. The pull request run is informational: a failed test turns the check red, but no check is required, so the pull request can still be merged.
The application_version input is the ghcr.io tag of the OpenL images (webstudio:<tag> and ws:<tag>-all); it defaults to latest, the newest published build, and a pull request run always uses latest. The "Discover tests" job resolves both tags once with scripts/github-actions/resolve-images.py (docker buildx imagetools inspect) and every shard runs the image by digest (webstudio@sha256:...), so all shards test the same build even when latest is republished during the run. A wrong tag fails the job within seconds, so a typo no longer costs a full run of tests that cannot start their container. The report and the Pages index show the tag the run asked for, the digest it was resolved to, the openl-tablets revision and the build time of the image, and mark latest as a moving tag; a pull request run is labelled with its number and title.
GitHub Actions does not read the TestNG suite files. scripts/github-actions/discover-tests.py scans src/test/java and queues every non-abstract class that has @Test methods, except classes annotated @LocalOnly(reason = "...") (configuration.annotations.LocalOnly), which are meant for local runs only: special infrastructure, manual data, exploratory checks. A new test class is therefore part of the GitHub regression the moment it is committed, and nothing has to be added to a suite for it. A concrete class whose @Test methods are all inherited from a base class is found through its extends clause. The four classes of the *_regression suites (TestZippedProjects, TestPreconfigProjects, TestStudioCentralGroupPolicyBundle, TestStudioCentralGroupRatingClaim) carry @LocalOnly because they need local project sets, JDK 25 or the Genesis central repositories; every other test class is queued, except classes annotated @DedicatedWorkflow(workflow = "...", reason = "...") (configuration.annotations.DedicatedWorkflow), which run only in the workflow they name. Because the queue is keyed by run id and run attempt, a re-run of the workflow starts with a fresh queue.
The shards input (default 20) only sets the number of parallel jobs. The classes form one queue: scripts/github-actions/run-test-queue.py makes every shard claim the next unclaimed class by creating the git ref refs/openl-queue/<run id>/<class name> through the GitHub API (creating a ref is atomic, the second shard gets HTTP 422 and moves on), run it with mvn surefire:test on a generated one-class TestNG suite and claim again until the queue is empty. Shards therefore finish within one class duration of each other with no duration bookkeeping; the queue refs are invisible in the GitHub UI and the "Remove the queue refs" job deletes them, also when the run is cancelled. The workflow needs contents: write on the default token for the refs. Every shard runs with both images available: Studio from docker_image_name and Rule Services from ws_docker_image_name; a class that needs Rule Services declares dockerImageProperty = PropertyNameSpace.WS_DOCKER_IMAGE_NAME in its @AppContainerConfig. Instead of a suite name, the test export and the merged report group tests by the test package: tests.ui.webstudio.git becomes git, tests.ui.webservice becomes webservice (TestGroupUtil in Java, testgroups.py in the scripts).
A shard job fails only through scripts/github-actions/gate-shard-results.py, which reads the shard's test export and its queue record (target/queue/<shard>.json): any failed test without a known issue or failing on something else than its known issue (see below), any skipped test (a skipped test means a configuration method failed, usually a container start), a Maven run that exited with an error and a claimed class that exported no result fail the job, everything else passes.
All Git LFS tests run only in the OpenL LFS Tests workflow (.github/workflows/openl-lfs.yml), started by hand with the Studio image tag (latest by default); they are @DedicatedWorkflow, so the regression neither queues them nor accepts them in its tests input. Four shards take the four classes from the same queue the regression uses and run them in parallel (a fast shard may take two classes and another none); the Merge test report job builds one report from their artifacts. The classes are:
TestGitLfsDesignRepository: LFS on the Gitea container itself, whose LFS server accepts any upload.TestGitLfsGitHubStorage,TestGitLfsGitLabStorage,TestGitLfsBitbucketStorage: the same scenario (GitLfsExternalStorageTest) against the LFS storage of a real provider. The design repository stays in Gitea; its.lfsconfigpoints LFS at the provider (helpers.service.LfsProvider,ExternalLfsService), so only LFS objects reach the provider, never a branch or a commit. GitHub hands out presigned S3 URLs for uploads, like Bitbucket, and rejects what S3 does not accept (EPBDS-16711: Studio built with JGit 7.8.0.202609011348-openl sent chunked uploads and got HTTP 501; fixed in openl-tablets 825e4093ae); GitLab takes uploads on gitlab.com.
Studio sends one login and password to Gitea and to the LFS server, so the Gitea owner gets the provider's login (x-access-token, openl-lfs-test, x-bitbucket-api-token-auth) and the provider token as password. Credentials and repositories come from the environment:
| Provider | Repository | Token |
|---|---|---|
| GitHub | this repository | GITHUB_TOKEN (the workflow token; locally gh auth token when it is not set) |
| GitLab | secret LFS_GITLAB_REPO |
secret LFS_GITLAB_TOKEN (personal access token, write_repository) |
| Bitbucket | secret LFS_BITBUCKET_REPO |
secret LFS_BITBUCKET_TOKEN (Atlassian API token, repository read and write) |
The repository URLs are internal paths, so they are secrets too: the artifacts, the report, the job summary and Pages name the variable (${LFS_GITLAB_REPO}), never the value. In the console of the job, where a file cannot be rewritten, the values rely on GitHub masking and show as ***: a step right after checkout fails when a secret is empty and masks the Basic credentials and every URL form that GitHub would not mask by itself. The tests write variable names instead of secret values in every message they produce (helpers.utils.SecretText). In every shard, scripts/github-actions/redact-secrets.py replaces any remaining value with its variable name in every file the shard uploads, inside zip and gzip archives such as Playwright traces too. Then scripts/github-actions/check-secret-leaks.py searches those files, and in the merge job the merged report and the job summary, for every token (raw, URL-encoded, inside any base64 such as Basic credentials, split across lines) and every form of the repository URLs, inside archives at any depth. When anything is found in a shard, that shard uploads nothing; when anything is found in the merged report, it is neither uploaded, written to the job summary nor published. The report is published only when it holds all four classes; otherwise the merge job fails and names the missing ones. Otherwise each shard uploads its artifact openl-tests-lfs-NN, and the merge job uploads the merged report test-report-merged and publishes the report to GitHub Pages next to the regression runs; the site keeps the last 30 runs of both workflows and of the pull request runs together, so each run pushes out the oldest report. org.testcontainers and com.github.dockerjava log at info, because at debug they print the environment of the Studio container, where the token is the repository password. LFS objects cannot be deleted from the providers, so each run leaves about 20 KB in the LFS storage of each repository.
A test that fails because of an open product bug is annotated with the ticket and with the part of the failure message that shows that bug, on the method or on the whole class:
@KnownIssue(value = "EPBDS-16742", failsWith = "The Trace form must keep the input entered in the Run form")
@Test
public void traceFormKeepsTheInputEnteredInRun() { ... }failsWith is best the .as(...) description of the assertion the bug trips, which stays the same whatever values it reports. A test that can fail on more than one open bug carries one @KnownIssue per ticket; the one whose failsWith the failure contains is recorded, and the first one when none does or when the test passes. The test itself is not changed and still asserts the correct behaviour, so it keeps failing in TestNG. The framework records the ticket in the test's result.json (knownIssue with the ticket, the tracker URL, the outcome, failsWith and causeMatched — whether the failure message, whitespace collapsed, contains failsWith). Only a failure that matches is known: the shard gate ignores it, the merged report shows the test with an orange KNOWN ISSUE badge and a link to the ticket instead of a red FAILED, and the job summary lists the known issues in their own table. A test whose failure does not contain failsWith — its ticket fixed and the test failing at another step on a defect of its own, a locator, a timeout — stays a blocking red FAILED, and the report says it failed on something else than its known issue. The check tells failures apart by their message only: a failure at the very assertion failsWith names, for whatever reason, still counts as the known issue. When a test with @KnownIssue passes, the report shows it in blue as FIXED?: verify the fix, then remove the annotation. The tracker URL prefix comes from the system property issue.tracker.browse.url (default https://jira.eisgroup.com/browse/).
After the shards finish, the Merge test report job publishes one artifact, test-report-merged, a self-contained report built by scripts/github-actions/generate-merged-report.py from every shard's test export directory (the framework's per-test export under target/test-export). The header of index.html states what exactly was tested: the run start and end (UTC, from the first and the last test), every application image with the OpenL version, build number and build date the tests observed (/web/public/info/openl.json on Studio, /admin/ui/info on Rule Services; stored as application.json in each test's export), the tests branch and commit with a link, the Playwright and Java versions and the workflow run. Below, all tests are listed with status, the package group, shard, duration, @TestCaseId and a first error line, and every row opens a tabbed panel: Overview (class with a link to the source at the tested commit, description, timestamps, build, tests revision, execution info), Failure (message and stack trace), Steps (the framework log of that test only, see below), Application log (WARN, ERROR and exception lines of the Studio or Rule Services container with a link to the full log), Trace (the Playwright trace archive) and Media (failure screenshot, downloads). The same run facts, numbers and the list of failed and skipped tests are appended to the workflow job summary; summary.json carries them for scripts.
The report is also published to GitHub Pages, so it opens in the browser without downloading the artifact: scripts/github-actions/publish-report-pages.py pushes every run to the gh-pages branch under runs/<run id>/ as a single orphan commit (--force-with-lease, so pruned reports do not stay in the git history and the branch never grows), keeps the last 30 runs, pull request runs included, and regenerates the site index at https://openl-tablets.github.io/openl-tests/ with the date, build, results and tests commit of each run; the job summary links to the published report.
The per-test artifacts are produced by the framework itself. TestExportUtil attaches a log4j appender that writes every log line of the current test thread (container start, page actions, waits, REST calls, assertions) to attachments/test-steps.log of that test in the test export. Both driver pools start Playwright tracing (enable_playwright_tracing, default true) on the browser context with DOM snapshots, network and console but without per-action screenshots (they would multiply the archive size by twenty while the snapshots already render every step); in PLAYWRIGHT_LOCAL mode the archive also carries the Java sources of the recorded actions when PLAYWRIGHT_JAVA_SRC names the source directories (the workflow sets it), while a trace recorded through a remote Playwright Server (PLAYWRIGHT_DOCKER) has no Java stack frames, so it holds no sources; BaseTest stops it in @AfterMethod and keeps the archive as the Playwright Trace attachment, next to the application container log, for every failed test, or for every test when debug_artifacts_on_success=true; the page HTML at failure is attached only when no trace archive was recorded (tracing off or its start or stop failed), because the last trace snapshot is that page. A passed test with the default settings leaves only its steps log. The merged report does not copy the failure videos (the trace snapshots show the same steps), they stay in the shard artifacts. Open a trace with npx playwright show-trace trace.zip or drop it onto https://trace.playwright.dev.
For debugging with an AI assistant the report also writes debug/<TestClass>.<method>.json for every failed or skipped test: test identity and source link, run identity (build, workflow URL, tests commit), the result with the full stack trace, the complete steps log as lines, the application log extract and the paths of the trace and every attachment; debug/index.json lists all tests and debug/README.md explains the bundle and suggests a prompt. Hand the bundle plus the referenced files to the assistant instead of collecting the context by hand.
// Get current execution mode and debug info
ExecutionMode mode = DriverPool.getCurrentExecutionMode();
String debugInfo = DriverPool.getDebugInfo();
logger.info("Current mode: {}, Debug: {}", mode, debugInfo);Application logs are automatically collected from containers:
# Logs are saved to configured path
target/logs/app-container-<timestamp>.log-
Docker connectivity issues
# Verify Docker is running docker ps # Check Docker image availability docker pull ghcr.io/openl-tablets/webstudio:latest
-
Port conflicts
- Application containers use random ports to avoid conflicts
- Check mapped ports in logs:
"App URL for LOGIN service (LOCAL): http://localhost:32769"
-
File upload/download issues
- Verify volume mappings in configuration
- Check host resource path permissions
- Ensure container resource path is writable
-
Parallel execution failures
- Each thread gets isolated Playwright instances
- Docker mode creates separate containers per thread
- Check thread-count in TestNG suite configuration
-
IPv6/IPv4 connection issues (ECONNREFUSED ::1:port)
- Symptom:
WebSocket error: connect ECONNREFUSED ::1:32848when running in Docker mode - Cause: System resolves
localhostto IPv6 (::1) but Docker port mappings are bound to IPv4 only - Solution: The project includes
.mvn/jvm.configwith-Djava.net.preferIPv4Stack=true - Manual fix: Add
-Djava.net.preferIPv4Stack=trueto Maven command:mvn test -Djava.net.preferIPv4Stack=true -Dexecution.mode=PLAYWRIGHT_DOCKER
- Symptom:
Run with full request and response logging of the REST calls and the waits:
mvn test -Dexecution.mode=PLAYWRIGHT_DOCKER -Dlog.api.level=debug- Follow existing code patterns and naming conventions
- Add tests for new functionality
- Update configuration documentation
- Ensure both execution modes work correctly
- Add appropriate logging and error handling
This project is licensed under the MIT License - see the LICENSE file for details.