Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 2 additions & 15 deletions .github/workflows/ci_cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ jobs:

- name: Check UI dependencies
if: steps.ui-files-changed.outputs.any_modified == 'true' && steps.is_core_repo.outputs.value == 'true'
run: npx knip@5.88.0 -c ui/knip.jsonc
run: yarn knip

- name: Check deployment build.gradle
id: check_deployment_gradle
Expand Down Expand Up @@ -703,20 +703,7 @@ jobs:

- name: Check for outdated translations
if: steps.is_core_repo.outputs.value == 'true'
run: |
set -e

# Run the tests
./gradlew -p ui/app/shared processTranslations --no-parallel

out=$(git status --porcelain | grep "ui/app/shared/locales" || true)
if [[ -z "$out" ]]; then
echo "✅ No pipeline changes detected."
else
echo "❌ Pipeline files have changed. Please run './gradlew -p ui/app/shared processTranslations --no-parallel'"
echo "$out"
exit 1
fi
run: ./gradlew :ui:app:shared:checkTranslations --no-parallel
timeout-minutes: 20

- name: Run backend tests
Expand Down
16 changes: 1 addition & 15 deletions .github/workflows/ui-check-outdated-translations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,4 @@ jobs:
cache: "gradle"
- name: Check for outdated translations
timeout-minutes: 20
run: |
# Check Outdated Translations
./gradlew -p ui/app/shared processTranslations --no-parallel

out=$(git status --porcelain | grep "ui/app/shared/locales" || true)
if [[ -z "$out" ]]; then
echo "✅ No pipeline changes detected."
else
echo "❌ Pipeline files have changed. Please run:
\`\`\`sh
./gradlew -p ui/app/shared processTranslations --no-parallel
\`\`\`
$out" >> $GITHUB_STEP_SUMMARY
exit 1
fi
run: ./gradlew :ui:app:shared:checkTranslations --no-parallel
14 changes: 13 additions & 1 deletion AGENTS.md
Comment thread
wborn marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@ When naming scripts, use \<date>_\<time> as version number, date being YYYYMMDD
## Dev environment

The backend is written in Java, we target JDK 21, use modern language features up to that version during implementation.
Gradle is used as the build system; run `./gradlew clean installDist` for a full clean build.
Gradle is used as the build system:

- `./gradlew assemble` builds normal backend and UI artifacts without verification, installing the distribution, or building Docker images.
- `./gradlew check` runs backend and UI tests, Spotless, UI dependency checks, and translation checks.
- `./gradlew build` builds and verifies the project. Use this as the normal pre-PR command.
- `./gradlew installDist` prepares the runnable distribution in `manager/build/install/manager/` and the deployment Docker build context.

Use `./gradlew clean assemble` for a clean artifact build, or `./gradlew clean build` to also run verification.
Lifecycle tasks compose individual tasks, which remain independently runnable for targeted development and CI checks.
In sandboxed environments, add `--offline` (requires dependencies to already be cached).

## Tests
Expand All @@ -32,8 +40,12 @@ ex.response.withCloseable { r ->

### Running tests

`./gradlew test` runs backend unit tests, backend integration tests, and then `npmTest` (UI component tests followed by UI app tests). Use `./gradlew test -x npmTest` for backend tests only, or `./gradlew npmTest` for UI tests only. Both `check` and `build` include the complete test sequence.

When running integration tests, part of the stack (PostgreSQL and Keycloak) must be running. Start it with `docker compose -f profile/dev-testing.yml -p openremote up -d --no-build`.

The same stack is required for `check` and `build`. Install the Playwright browser once with `./gradlew :ui:test:installBrowsers` before running UI tests. The verification lifecycle prepares the manager and UI distribution required by UI app tests; standalone test tasks can reuse an existing distribution.

## REST resources

### Endpoint roles
Expand Down
25 changes: 25 additions & 0 deletions README.md
Comment thread
Ekhorn marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,31 @@ For information and how to set up a development environment, see the [Developer

We work with Java, Groovy, TypeScript, Gradle, Docker, and a wide range of APIs and protocol implementations.

Use JDK 21 and the repository's Yarn version to build from source:

```sh
./gradlew assemble # Build backend and UI artifacts
./gradlew check # Run tests, formatting, dependency, and translation checks
./gradlew build # Build and verify: the normal pre-PR command
./gradlew installDist # Prepare the runnable manager and deployment files
```

For a clean build, use `./gradlew clean assemble`, or `./gradlew clean build` to include verification.
The runnable distribution is installed in `manager/build/install/manager/`; `assemble` does not install it or build Docker images.

Before running `check` or `build`, start PostgreSQL and Keycloak with `docker compose -f profile/dev-testing.yml -p openremote up -d --no-build` and install the Playwright browser with `./gradlew :ui:test:installBrowsers`.
These lifecycle tasks compose individual tasks that remain independently runnable, allowing CI to run checks separately, reuse artifacts, and prepare distributions only where needed.

For targeted test runs:

```sh
./gradlew test # Backend tests followed by npmTest (all UI tests)
./gradlew test -x npmTest # Backend tests only
./gradlew npmTest # UI component tests followed by UI app tests
```

Both `check` and `build` include the complete test sequence as well as the other verification checks.

We follow the [GitHub Flow](https://docs.github.com/en/get-started/quickstart/github-flow) workflow with tags and releases for published versions of our components; when working on the codebase create descriptive branch names (e.g. `feature/cool_feature_x`, `hotfix/flux_capacitor`, `issue/123`, etc.).

When your changes are complete then create a Pull Request ensuring that your branch is up-to-date with the source branch and that code changes are covered by tests and that the full test suite passes.
Expand Down
28 changes: 16 additions & 12 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,27 @@ subprojects {
}
}

// Root lifecycle: build -> assemble + check; check -> test + subproject verification;
// test -> backend unit tests + integration tests + npmTest; npmTest -> UI component + app tests.
tasks.register('npmTest') {
group = 'verification'
description = 'Runs all UI tests sequentially (component then app)'
description = 'Runs UI component tests followed by UI app tests'
}

tasks.register('test') {
group = 'verification'
description = 'Executes all backend and UI tests in specific order'
// When './gradlew test' is run, ensure the UI tests are also pulled into the task graph
dependsOn 'npmTest'
description = 'Runs backend unit and integration tests, then npmTest for all UI tests'
}

tasks.named('assemble') {
dependsOn subprojects.collect { it.tasks.matching { t -> t.name == 'assemble' } }
}

tasks.named('check') {
dependsOn tasks.named('test')
dependsOn subprojects.collect { it.tasks.matching { t -> t.name == 'check' } }
}

// Enforce order in which we run the backend- and frontend tests
// Order: 1. Unit tests
// -> 2. Integration tests
// -> 3. UI component tests
// -> 4. UI app tests
gradle.projectsEvaluated {
def unitTests = subprojects.findAll { it.path != ':test' }
.collect { it.tasks.matching { t -> t.name == 'test' } }
Expand All @@ -73,11 +77,11 @@ gradle.projectsEvaluated {
dependsOn(uiComponentTests, uiAppTests)
}

def rootTest = tasks.findByName('test') ?: tasks.register('test').get()
rootTest.configure {
dependsOn(unitTests, integrationTests, npmTest)
tasks.named('test').configure {
dependsOn(unitTests, integrationTests, tasks.named('npmTest'))
}

// Order test execution without making standalone UI test commands run backend tests.
integrationTests.forEach { group ->
group.configureEach { it.mustRunAfter(unitTests) }
}
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"packageManager": "yarn@4.18.0",
"private": true,
"scripts": {
"knip": "knip -c ui/knip.ts",
"test:knip": "yarn workspace @openremote/util test",
"lint": "cd ui && eslint . --cache --cache-location ../.eslintcache",
"lint:fix": "cd ui && eslint . --fix --cache --cache-location ../.eslintcache",
"format": "cd ui && prettier . --write",
Expand All @@ -16,6 +18,7 @@
"ui/demo/*"
],
"devDependencies": {
"@types/node": "^26.2.0",
"@typescript-eslint/eslint-plugin": "8.40.0",
"@typescript-eslint/parser": "8.40.0",
"eslint": "8.57.1",
Expand All @@ -26,6 +29,7 @@
"eslint-plugin-n": "16.6.2",
"eslint-plugin-promise": "6.6.0",
"eslint-plugin-wc": "3.1.0",
"knip": "6.43.0",
"prettier": "3.9.6",
"typescript": "~5.9.3"
}
Expand Down
4 changes: 3 additions & 1 deletion project.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,9 @@ tasks.register('npmServeProduction', Exec) {

// Add typescript tasks
tasks.register('tscWatch', Exec) {
commandLine npmCommand("npx"), "tsc", "-b", "--watch"
dependsOn getYarnInstallTask()
// TypeScript is a root workspace dependency; keep the build target in this project.
commandLine npmCommand("yarn"), "run", "--top-level", "tsc", "-b", projectDir.absolutePath, "--watch"
}

// Configure Java build
Expand Down
4 changes: 4 additions & 0 deletions setup/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ tasks.register('notificationJar', Jar) {
from sourceSets.notification.output
}

tasks.named('assemble') {
dependsOn tasks.withType(Jar)
}

tasks.named("compileJava") {
dependsOn("load1Jar", "load2Jar")
}
Expand Down
2 changes: 1 addition & 1 deletion ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Applies to everything under `ui/`. See the root `AGENTS.md` for build, test stac

Every package writes its output to its own `build/` dir, so a single Gradle `clean` removes it and a single `**/build/**` pattern covers it in `.gitignore`, `ui/.prettierignore` and `ui/.eslintrc.json`. Do not introduce a new top-level dir for compiler output, bundles, generated manifests, reports, caches or auth state. Point the tool at `build/` instead: `outDir` and `tsBuildInfoFile` in the package `tsconfig.json`, `outdir` in the cem config, `outputDir` and the reporter's `outputFolder` in the Playwright configs, and `output.path` in `ui/util/rspack.util.js`. A package's `exports`, `module`, `types` and `files` must then point into `build/` too.

Packages with no Gradle plugin need `tasks.register('clean', Delete) { delete layout.buildDirectory }`, since only the `base` plugin supplies `clean`. Where a plugin is already applied, add nothing: a second `clean` is redundant, and configuring it through `doLast` breaks the configuration cache.
The UI build applies the `base` plugin to all UI projects, supplying `clean`, `assemble`, `check`, and `build`. Do not register another `clean`. UI `npmBuild` and `npmAnalyze` tasks participate in `assemble`, and `npmTest` tasks participate in `check`. Use `installDist` only to copy application resources into the manager distribution.

Anything a package references relative to its own emitted files must survive the extra `build/lib` nesting. Reach assets through a package self-reference resolved by an `exports` subpath, not a path relative to the output dir, so the same specifier works from `src` and from the compiled output.

Expand Down
5 changes: 5 additions & 0 deletions ui/app/manager/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,15 @@ tasks.register('npmBuild', Exec) {

tasks.register('npmTest', Exec) {
dependsOn ":ui:test:installTestDeps", ":ui:test:clean"
mustRunAfter ":manager:installDist", ":ui:app:manager:installDist", ":ui:app:shared:installDist"
commandLine npmCommand("yarn"), "run", "test"
args((findProperty('args')?.toString()?.tokenize()) ?: [])
}

tasks.named('check') {
dependsOn ":manager:installDist", ":ui:app:manager:installDist", ":ui:app:shared:installDist"
}

tasks.register('cleanDist') {
dependsOn npmClean
}
Expand Down
3 changes: 3 additions & 0 deletions ui/app/manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@
"@openremote/rest": "workspace:*",
"@openremote/theme": "workspace:*",
"@reduxjs/toolkit": "^2.12.0",
"i18next": "^26.3.6",
"lit": "^3.3.1",
"maplibre-gl": "~6.8.0",
"moment": "^2.31.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^4.1.8"
},
"devDependencies": {
Expand Down
41 changes: 38 additions & 3 deletions ui/app/shared/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ tasks.register('extractOpenMapTilesFontsZip', Copy) {

tasks.register('generateSources', Copy) {
dependsOn extractOpenMapTilesFontsZip
dependsOn ":ui:component:or-icon:prepareFonts"
from fileTree(projectDir) {
exclude 'build.gradle'
exclude 'build/**'
Expand All @@ -177,7 +178,40 @@ tasks.register('installDist', Copy) {
into project(':manager').layout.buildDirectory.dir("install/manager/web/${project.name}")
}

tasks.register('processTranslations', JavaCompile) {
def checkedTranslationsDir = layout.buildDirectory.dir('checked-translations')
def localesDir = layout.projectDirectory.dir('locales')

tasks.register('prepareTranslationsCheck', Sync) {
from localesDir
into checkedTranslationsDir.map { it.dir('locales') }
}

tasks.register('processTranslations', JavaCompile)

tasks.register('checkTranslations', JavaCompile) {
group = 'verification'
description = 'Checks that backend translations are up to date without changing source files'
dependsOn tasks.named('prepareTranslationsCheck')

doLast {
def generatedLocales = checkedTranslationsDir.get().dir('locales').asFile
def outdated = localesDir.asFile.listFiles().findAll { locale ->
def original = new File(locale, 'or.json')
original.isFile() && original.text != new File(generatedLocales, "${locale.name}/or.json").text
}
if (!outdated.empty) {
throw new GradleException('Translations are outdated. Run ./gradlew :ui:app:shared:processTranslations')
}
}
}

tasks.named('check') {
dependsOn tasks.named('checkTranslations')
}

tasks.withType(JavaCompile).matching {
it.name in ['processTranslations', 'checkTranslations']
}.configureEach {
dependsOn(":agent:classes")
dependsOn(":model:classes")
outputs.upToDateWhen { false } // Always invalidate
Expand All @@ -187,13 +221,14 @@ tasks.register('processTranslations', JavaCompile) {

source = files(agentSourceSet.allJava, modelSourceSet.allJava)
classpath = files(agentSourceSet.compileClasspath, modelSourceSet.compileClasspath)
destinationDirectory = layout.buildDirectory.dir("generated/i18n") // unused
destinationDirectory = layout.buildDirectory.dir("generated/i18n/${name}") // unused
def sharedDir = name == 'checkTranslations' ? checkedTranslationsDir.get().asFile : projectDir
options.compilerArgs += [
"-proc:only",
// Only run the processor
"-processor",
"org.openremote.model.util.I18nAnnotationProcessor",
"-Ashared=${project.getProjectDir()}".toString()
"-Ashared=${sharedDir}".toString()
]
// Pass classpath to compiler
options.annotationProcessorPath = modelSourceSet.output + modelSourceSet.compileClasspath
Expand Down
57 changes: 41 additions & 16 deletions ui/build.gradle
Original file line number Diff line number Diff line change
@@ -1,31 +1,56 @@
plugins {
id 'base'
}

def generatedUiSources = [':ui:component:model:generateTypeScript', ':ui:component:rest:generateTypeScript']

afterEvaluate {
// Add dependencies on model and rest typescript generation
rootProject.getTasksByName('installDist', true).forEach {
it.dependsOn ":ui:component:model:generateTypeScript", ":ui:component:rest:generateTypeScript"
rootProject.allprojects {
tasks.matching {
it.name in ['installDist', 'prepareUi', 'publishUi', 'npmBuild']
}.configureEach {
dependsOn generatedUiSources
}
rootProject.getTasksByName('prepareUi', true).forEach {
it.dependsOn ":ui:component:model:generateTypeScript", ":ui:component:rest:generateTypeScript"
}

subprojects {
apply plugin: 'base'

tasks.named('assemble') {
dependsOn tasks.matching { it.name in ['npmBuild', 'npmAnalyze'] }
Comment thread
wborn marked this conversation as resolved.
Resolved
}
rootProject.getTasksByName('publishUi', true).forEach {
it.dependsOn ":ui:component:model:generateTypeScript", ":ui:component:rest:generateTypeScript"

def npmTestTasks = tasks.matching { it.name == 'npmTest' }
npmTestTasks.configureEach {
mustRunAfter generatedUiSources
}
rootProject.getTasksByName('npmBuild', true).forEach {
it.dependsOn ":ui:component:model:generateTypeScript", ":ui:component:rest:generateTypeScript"

tasks.named('check') {
dependsOn npmTestTasks
// Standalone test tasks can reuse CI artifacts; check also prepares generated sources.
dependsOn { npmTestTasks.empty ? [] : generatedUiSources }
}
}

tasks.register("modelWatch") {
dependsOn ":ui:component:model:build", ":ui:component:rest:build"
}

tasks.register("installKnipDeps", Exec) {
// Required to load the `rspack.config.js` and `rsbuild.config.ts` files, ref https://knip.dev/reference/known-issues#exceptions-from-config-files
commandLine npmCommand("yarn"), "workspaces", "focus", "@openremote/util", "@openremote/manager", "@openremote/storybook"
tasks.register("knip", Exec) {
group = 'verification'
description = 'Checks UI dependencies'
dependsOn getYarnInstallTask()
Comment thread
wborn marked this conversation as resolved.
Resolved
workingDir project.rootDir
commandLine npmCommand("yarn"), "run", "knip"
}

tasks.register("knip", Exec) {
dependsOn installKnipDeps
tasks.register('testKnip', Exec) {
group = 'verification'
description = 'Tests Knip configuration with source-only and built workspaces'
dependsOn getYarnInstallTask()
workingDir project.rootDir
commandLine npmCommand("npx"), "knip@5.88.0", "-c", "ui/knip.jsonc"
commandLine npmCommand("yarn"), "run", "test:knip"
}

tasks.named('check') {
dependsOn tasks.named('knip'), tasks.named('testKnip')
}
5 changes: 3 additions & 2 deletions ui/component/core/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
tasks.register('clean', Delete) {
delete layout.buildDirectory
tasks.register('npmBuild', Exec) {
dependsOn getYarnInstallTask()
commandLine npmCommand("yarn"), "run", "build"
}

tasks.register('prepareUi') {
Expand Down
Loading
Loading